mirror of
https://github.com/home-assistant/core.git
synced 2026-06-11 19:51:39 +02:00
Compare commits
121 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 907a1f7019 | |||
| e1f1a7f91c | |||
| 6791c64d59 | |||
| 21788fd815 | |||
| e5f2f8f932 | |||
| 33dab10779 | |||
| 98e63bc133 | |||
| 6b7d559d8d | |||
| 623c569807 | |||
| 1a72d6658c | |||
| 2bb6cac651 | |||
| b7e58d234b | |||
| 2085b5348d | |||
| e95fd93e21 | |||
| 07dcf64357 | |||
| 9da2dfa714 | |||
| bb32e859f1 | |||
| 8142996b08 | |||
| 5ffbe73ae2 | |||
| af129cb26a | |||
| 7533080597 | |||
| 87c7fb5b46 | |||
| ccadaf1236 | |||
| 3fced25724 | |||
| 0ae2eef60a | |||
| caa52e2823 | |||
| 5b35d4b20e | |||
| fa28e7630a | |||
| b7e3a36002 | |||
| 1cef20237f | |||
| 3c60b2b1a2 | |||
| 66f96e9438 | |||
| 11e97c62ea | |||
| ecc8384382 | |||
| 34d0a533c7 | |||
| bd90dcd7dc | |||
| 43310e8c21 | |||
| a9781cca14 | |||
| cf535a086f | |||
| d20ed216cb | |||
| afc45ae34b | |||
| 83a0c28229 | |||
| 0dd9252d73 | |||
| fd2c319e1b | |||
| 83cc4d4a07 | |||
| 6206489b5f | |||
| 5d5c1eca6e | |||
| 2ad24f9111 | |||
| 7c0308e60c | |||
| ec709db2f4 | |||
| 7b8b31afa5 | |||
| 9cd52e950e | |||
| 5bab9f867b | |||
| cd02466612 | |||
| 107cb8b38e | |||
| 4e982e34ca | |||
| 1224f16df1 | |||
| 1b1e954a4f | |||
| d4b7aef732 | |||
| c92348b931 | |||
| 42560c6cd0 | |||
| 1eaa79d261 | |||
| f03474c029 | |||
| 360e454330 | |||
| 43eb0ca426 | |||
| 7c77d915d9 | |||
| 0d64a7e484 | |||
| 8389f7ad96 | |||
| a0732f3e09 | |||
| f66e7e4034 | |||
| 9480436982 | |||
| c5c7e4adcb | |||
| 3833290b16 | |||
| fd05b17a25 | |||
| 969834845b | |||
| 8bf3abdc3c | |||
| 5141f96ebe | |||
| 3bf251eb83 | |||
| 4c85363668 | |||
| 19adbba726 | |||
| d0bbd34028 | |||
| e4e0fbef54 | |||
| 4d0c0e7626 | |||
| 317afd9739 | |||
| 7270a52be7 | |||
| 39dc4c912f | |||
| b28e6502a3 | |||
| e3aafaedb1 | |||
| 9f32319481 | |||
| ddd9c5ab61 | |||
| 4936885598 | |||
| 67fff835b2 | |||
| 7b19a3a71b | |||
| 7994744bea | |||
| e9e5bda3f6 | |||
| 3d807de32d | |||
| fa60ef5477 | |||
| 3046996869 | |||
| 9930d7dad4 | |||
| e18dd7e906 | |||
| d12fb7814a | |||
| 8e6be68fe3 | |||
| c1a71bed25 | |||
| ee82ca9677 | |||
| b51067d37d | |||
| 12f24ac6bf | |||
| 6b92011cae | |||
| c88253752f | |||
| 4f43b99540 | |||
| 8f1a294efe | |||
| f07d650de8 | |||
| f494fa2909 | |||
| b81a221c20 | |||
| f852c33cf8 | |||
| 7b60f912a7 | |||
| da978415a8 | |||
| 64750386cb | |||
| 0c45d006f7 | |||
| cd81c61509 | |||
| 81bca02aed | |||
| cc2428c2b5 |
@@ -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
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
"""Sandbox — run integrations in isolated subprocesses.
|
||||
|
||||
The integration owns three runtime objects, all hung off
|
||||
:class:`SandboxData`:
|
||||
|
||||
* :class:`SandboxManager` — supervises one subprocess per sandbox group
|
||||
("main", "built-in", "custom"), lazily spawning them on first need.
|
||||
* :class:`SandboxFlowRouter` — installed as
|
||||
``hass.config_entries.router``. Diverts new config flows to
|
||||
sandbox runtimes and routes ``async_setup_entry`` for tagged entries.
|
||||
* :class:`SandboxBridge` (one per running sandbox) — owns the entity-side
|
||||
protocol: receives ``register_entity`` + ``state_changed`` pushes from
|
||||
the sandbox, instantiates proxy entities, and forwards entity service
|
||||
calls back via the shared ``sandbox/call_service`` channel.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.const import EVENT_HOMEASSISTANT_STOP
|
||||
from homeassistant.core import Event, HomeAssistant
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
from homeassistant.helpers.translation import (
|
||||
async_register_sandbox_translation_provider,
|
||||
)
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
from ._proto import sandbox_pb2 as pb
|
||||
from .bridge import SandboxBridge, async_create_bridge
|
||||
from .channel import Channel
|
||||
from .const import DATA_SANDBOX, DOMAIN
|
||||
from .manager import SandboxManager
|
||||
from .router import SandboxFlowRouter
|
||||
from .translation import SandboxTranslationProvider
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN)
|
||||
|
||||
|
||||
@dataclass
|
||||
class SandboxData:
|
||||
"""Global Sandbox runtime data."""
|
||||
|
||||
manager: SandboxManager | None = None
|
||||
router: SandboxFlowRouter | None = None
|
||||
channels: dict[str, Channel] = field(default_factory=dict)
|
||||
bridges: dict[str, SandboxBridge] = field(default_factory=dict)
|
||||
|
||||
|
||||
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
"""Set up the Sandbox integration."""
|
||||
data = SandboxData()
|
||||
hass.data[DATA_SANDBOX] = data
|
||||
|
||||
def _on_channel_ready(group: str, channel: Channel) -> None:
|
||||
# Drop any prior bridge for this group (a sandbox restart hands us
|
||||
# a fresh channel — the previous bridge owned the dead one).
|
||||
data.channels[group] = channel
|
||||
data.bridges[group] = async_create_bridge(hass, group=group, channel=channel)
|
||||
|
||||
async def _on_shutdown_reply(group: str, reply: Any) -> None:
|
||||
"""Persist the sandbox's restore-state snapshot.
|
||||
|
||||
The runtime ships its ``RestoreEntity`` state in the shutdown
|
||||
reply (a ``ShutdownResult``) rather than via the sandbox store
|
||||
bridge (the reader task is busy dispatching the shutdown handler —
|
||||
a re-entrant store_save would deadlock). We route the payload
|
||||
through the bridge's store server so it lands at the same path the
|
||||
next run's warm-load reads from.
|
||||
"""
|
||||
if not reply.HasField("restore_state"):
|
||||
return
|
||||
bridge = data.bridges.get(group)
|
||||
if bridge is None:
|
||||
_LOGGER.debug(
|
||||
"sandbox[%s]: shutdown reply carried restore_state but"
|
||||
" no bridge is registered; dropping",
|
||||
group,
|
||||
)
|
||||
return
|
||||
try:
|
||||
await bridge._handle_store_save( # noqa: SLF001 — internal write path
|
||||
pb.StoreSave(key="core.restore_state", data=reply.restore_state)
|
||||
)
|
||||
except Exception:
|
||||
_LOGGER.exception(
|
||||
"Failed to persist restore_state snapshot for sandbox %s",
|
||||
group,
|
||||
)
|
||||
|
||||
manager = SandboxManager(
|
||||
hass,
|
||||
on_channel_ready=_on_channel_ready,
|
||||
on_shutdown_reply=_on_shutdown_reply,
|
||||
)
|
||||
router = SandboxFlowRouter(hass, manager, data=data)
|
||||
data.manager = manager
|
||||
data.router = router
|
||||
|
||||
hass.config_entries.router = router
|
||||
|
||||
# Feed sandboxed integrations' frontend translations into core's cache.
|
||||
# Built-in domains read main's own disk; only customs pull over RPC.
|
||||
translation_provider = SandboxTranslationProvider(hass, data)
|
||||
unregister_translation_provider = async_register_sandbox_translation_provider(
|
||||
hass, translation_provider.async_get_translations
|
||||
)
|
||||
|
||||
async def _on_stop(_event: Event) -> None:
|
||||
"""Stop every sandbox process on HA shutdown.
|
||||
|
||||
Ask each sandbox to unload its entries and flush
|
||||
``RestoreEntity`` state through the ``current_sandbox`` store
|
||||
bridge before pulling the plug. ``async_stop_all`` then handles SIGTERM
|
||||
/ SIGKILL for any sandbox that didn't ack the graceful request
|
||||
within the grace.
|
||||
"""
|
||||
hass.config_entries.router = None
|
||||
unregister_translation_provider()
|
||||
await manager.async_graceful_shutdown_all(timeout=manager.shutdown_grace)
|
||||
await manager.async_stop_all()
|
||||
data.channels.clear()
|
||||
data.bridges.clear()
|
||||
|
||||
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _on_stop)
|
||||
|
||||
return True
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,479 @@
|
||||
from google.protobuf import struct_pb2 as _struct_pb2
|
||||
from google.protobuf.internal import containers as _containers
|
||||
from google.protobuf import descriptor as _descriptor
|
||||
from google.protobuf import message as _message
|
||||
from collections.abc import Iterable as _Iterable, Mapping as _Mapping
|
||||
from typing import ClassVar as _ClassVar, Optional as _Optional, Union as _Union
|
||||
|
||||
DESCRIPTOR: _descriptor.FileDescriptor
|
||||
|
||||
class Frame(_message.Message):
|
||||
__slots__ = ("id", "type", "request", "response")
|
||||
ID_FIELD_NUMBER: _ClassVar[int]
|
||||
TYPE_FIELD_NUMBER: _ClassVar[int]
|
||||
REQUEST_FIELD_NUMBER: _ClassVar[int]
|
||||
RESPONSE_FIELD_NUMBER: _ClassVar[int]
|
||||
id: int
|
||||
type: str
|
||||
request: bytes
|
||||
response: Response
|
||||
def __init__(self, id: _Optional[int] = ..., type: _Optional[str] = ..., request: _Optional[bytes] = ..., response: _Optional[_Union[Response, _Mapping]] = ...) -> None: ...
|
||||
|
||||
class Response(_message.Message):
|
||||
__slots__ = ("ok", "result", "error")
|
||||
OK_FIELD_NUMBER: _ClassVar[int]
|
||||
RESULT_FIELD_NUMBER: _ClassVar[int]
|
||||
ERROR_FIELD_NUMBER: _ClassVar[int]
|
||||
ok: bool
|
||||
result: bytes
|
||||
error: Error
|
||||
def __init__(self, ok: bool = ..., result: _Optional[bytes] = ..., error: _Optional[_Union[Error, _Mapping]] = ...) -> None: ...
|
||||
|
||||
class Error(_message.Message):
|
||||
__slots__ = ("message", "type", "invalid", "multiple")
|
||||
MESSAGE_FIELD_NUMBER: _ClassVar[int]
|
||||
TYPE_FIELD_NUMBER: _ClassVar[int]
|
||||
INVALID_FIELD_NUMBER: _ClassVar[int]
|
||||
MULTIPLE_FIELD_NUMBER: _ClassVar[int]
|
||||
message: str
|
||||
type: str
|
||||
invalid: _containers.RepeatedCompositeFieldContainer[InvalidError]
|
||||
multiple: bool
|
||||
def __init__(self, message: _Optional[str] = ..., type: _Optional[str] = ..., invalid: _Optional[_Iterable[_Union[InvalidError, _Mapping]]] = ..., multiple: bool = ...) -> None: ...
|
||||
|
||||
class InvalidError(_message.Message):
|
||||
__slots__ = ("message", "path")
|
||||
MESSAGE_FIELD_NUMBER: _ClassVar[int]
|
||||
PATH_FIELD_NUMBER: _ClassVar[int]
|
||||
message: str
|
||||
path: _containers.RepeatedScalarFieldContainer[str]
|
||||
def __init__(self, message: _Optional[str] = ..., path: _Optional[_Iterable[str]] = ...) -> None: ...
|
||||
|
||||
class DevicePair(_message.Message):
|
||||
__slots__ = ("key", "value")
|
||||
KEY_FIELD_NUMBER: _ClassVar[int]
|
||||
VALUE_FIELD_NUMBER: _ClassVar[int]
|
||||
key: str
|
||||
value: str
|
||||
def __init__(self, key: _Optional[str] = ..., value: _Optional[str] = ...) -> None: ...
|
||||
|
||||
class DeviceInfo(_message.Message):
|
||||
__slots__ = ("identifiers", "connections", "via_device", "entry_type", "name", "manufacturer", "model", "model_id", "sw_version", "hw_version", "serial_number", "suggested_area", "configuration_url", "default_name", "default_manufacturer", "default_model", "translation_key")
|
||||
IDENTIFIERS_FIELD_NUMBER: _ClassVar[int]
|
||||
CONNECTIONS_FIELD_NUMBER: _ClassVar[int]
|
||||
VIA_DEVICE_FIELD_NUMBER: _ClassVar[int]
|
||||
ENTRY_TYPE_FIELD_NUMBER: _ClassVar[int]
|
||||
NAME_FIELD_NUMBER: _ClassVar[int]
|
||||
MANUFACTURER_FIELD_NUMBER: _ClassVar[int]
|
||||
MODEL_FIELD_NUMBER: _ClassVar[int]
|
||||
MODEL_ID_FIELD_NUMBER: _ClassVar[int]
|
||||
SW_VERSION_FIELD_NUMBER: _ClassVar[int]
|
||||
HW_VERSION_FIELD_NUMBER: _ClassVar[int]
|
||||
SERIAL_NUMBER_FIELD_NUMBER: _ClassVar[int]
|
||||
SUGGESTED_AREA_FIELD_NUMBER: _ClassVar[int]
|
||||
CONFIGURATION_URL_FIELD_NUMBER: _ClassVar[int]
|
||||
DEFAULT_NAME_FIELD_NUMBER: _ClassVar[int]
|
||||
DEFAULT_MANUFACTURER_FIELD_NUMBER: _ClassVar[int]
|
||||
DEFAULT_MODEL_FIELD_NUMBER: _ClassVar[int]
|
||||
TRANSLATION_KEY_FIELD_NUMBER: _ClassVar[int]
|
||||
identifiers: _containers.RepeatedCompositeFieldContainer[DevicePair]
|
||||
connections: _containers.RepeatedCompositeFieldContainer[DevicePair]
|
||||
via_device: DevicePair
|
||||
entry_type: str
|
||||
name: str
|
||||
manufacturer: str
|
||||
model: str
|
||||
model_id: str
|
||||
sw_version: str
|
||||
hw_version: str
|
||||
serial_number: str
|
||||
suggested_area: str
|
||||
configuration_url: str
|
||||
default_name: str
|
||||
default_manufacturer: str
|
||||
default_model: str
|
||||
translation_key: str
|
||||
def __init__(self, identifiers: _Optional[_Iterable[_Union[DevicePair, _Mapping]]] = ..., connections: _Optional[_Iterable[_Union[DevicePair, _Mapping]]] = ..., via_device: _Optional[_Union[DevicePair, _Mapping]] = ..., entry_type: _Optional[str] = ..., name: _Optional[str] = ..., manufacturer: _Optional[str] = ..., model: _Optional[str] = ..., model_id: _Optional[str] = ..., sw_version: _Optional[str] = ..., hw_version: _Optional[str] = ..., serial_number: _Optional[str] = ..., suggested_area: _Optional[str] = ..., configuration_url: _Optional[str] = ..., default_name: _Optional[str] = ..., default_manufacturer: _Optional[str] = ..., default_model: _Optional[str] = ..., translation_key: _Optional[str] = ...) -> None: ...
|
||||
|
||||
class IntegrationSource(_message.Message):
|
||||
__slots__ = ("kind", "url", "ref", "tag", "domain", "subdir")
|
||||
KIND_FIELD_NUMBER: _ClassVar[int]
|
||||
URL_FIELD_NUMBER: _ClassVar[int]
|
||||
REF_FIELD_NUMBER: _ClassVar[int]
|
||||
TAG_FIELD_NUMBER: _ClassVar[int]
|
||||
DOMAIN_FIELD_NUMBER: _ClassVar[int]
|
||||
SUBDIR_FIELD_NUMBER: _ClassVar[int]
|
||||
kind: str
|
||||
url: str
|
||||
ref: str
|
||||
tag: str
|
||||
domain: str
|
||||
subdir: str
|
||||
def __init__(self, kind: _Optional[str] = ..., url: _Optional[str] = ..., ref: _Optional[str] = ..., tag: _Optional[str] = ..., domain: _Optional[str] = ..., subdir: _Optional[str] = ...) -> None: ...
|
||||
|
||||
class EntrySetup(_message.Message):
|
||||
__slots__ = ("entry_id", "domain", "title", "data", "options", "source", "unique_id", "version", "minor_version", "integration_source")
|
||||
ENTRY_ID_FIELD_NUMBER: _ClassVar[int]
|
||||
DOMAIN_FIELD_NUMBER: _ClassVar[int]
|
||||
TITLE_FIELD_NUMBER: _ClassVar[int]
|
||||
DATA_FIELD_NUMBER: _ClassVar[int]
|
||||
OPTIONS_FIELD_NUMBER: _ClassVar[int]
|
||||
SOURCE_FIELD_NUMBER: _ClassVar[int]
|
||||
UNIQUE_ID_FIELD_NUMBER: _ClassVar[int]
|
||||
VERSION_FIELD_NUMBER: _ClassVar[int]
|
||||
MINOR_VERSION_FIELD_NUMBER: _ClassVar[int]
|
||||
INTEGRATION_SOURCE_FIELD_NUMBER: _ClassVar[int]
|
||||
entry_id: str
|
||||
domain: str
|
||||
title: str
|
||||
data: _struct_pb2.Struct
|
||||
options: _struct_pb2.Struct
|
||||
source: str
|
||||
unique_id: str
|
||||
version: int
|
||||
minor_version: int
|
||||
integration_source: IntegrationSource
|
||||
def __init__(self, entry_id: _Optional[str] = ..., domain: _Optional[str] = ..., title: _Optional[str] = ..., data: _Optional[_Union[_struct_pb2.Struct, _Mapping]] = ..., options: _Optional[_Union[_struct_pb2.Struct, _Mapping]] = ..., source: _Optional[str] = ..., unique_id: _Optional[str] = ..., version: _Optional[int] = ..., minor_version: _Optional[int] = ..., integration_source: _Optional[_Union[IntegrationSource, _Mapping]] = ...) -> None: ...
|
||||
|
||||
class EntrySetupResult(_message.Message):
|
||||
__slots__ = ("ok", "reason")
|
||||
OK_FIELD_NUMBER: _ClassVar[int]
|
||||
REASON_FIELD_NUMBER: _ClassVar[int]
|
||||
ok: bool
|
||||
reason: str
|
||||
def __init__(self, ok: bool = ..., reason: _Optional[str] = ...) -> None: ...
|
||||
|
||||
class EntryUnload(_message.Message):
|
||||
__slots__ = ("entry_id",)
|
||||
ENTRY_ID_FIELD_NUMBER: _ClassVar[int]
|
||||
entry_id: str
|
||||
def __init__(self, entry_id: _Optional[str] = ...) -> None: ...
|
||||
|
||||
class EntryUnloadResult(_message.Message):
|
||||
__slots__ = ("ok",)
|
||||
OK_FIELD_NUMBER: _ClassVar[int]
|
||||
ok: bool
|
||||
def __init__(self, ok: bool = ...) -> None: ...
|
||||
|
||||
class CallService(_message.Message):
|
||||
__slots__ = ("domain", "service", "target", "service_data", "context_id", "return_response")
|
||||
DOMAIN_FIELD_NUMBER: _ClassVar[int]
|
||||
SERVICE_FIELD_NUMBER: _ClassVar[int]
|
||||
TARGET_FIELD_NUMBER: _ClassVar[int]
|
||||
SERVICE_DATA_FIELD_NUMBER: _ClassVar[int]
|
||||
CONTEXT_ID_FIELD_NUMBER: _ClassVar[int]
|
||||
RETURN_RESPONSE_FIELD_NUMBER: _ClassVar[int]
|
||||
domain: str
|
||||
service: str
|
||||
target: _struct_pb2.Struct
|
||||
service_data: _struct_pb2.Struct
|
||||
context_id: str
|
||||
return_response: bool
|
||||
def __init__(self, domain: _Optional[str] = ..., service: _Optional[str] = ..., target: _Optional[_Union[_struct_pb2.Struct, _Mapping]] = ..., service_data: _Optional[_Union[_struct_pb2.Struct, _Mapping]] = ..., context_id: _Optional[str] = ..., return_response: bool = ...) -> None: ...
|
||||
|
||||
class ServiceResponse(_message.Message):
|
||||
__slots__ = ("data",)
|
||||
DATA_FIELD_NUMBER: _ClassVar[int]
|
||||
data: _struct_pb2.Struct
|
||||
def __init__(self, data: _Optional[_Union[_struct_pb2.Struct, _Mapping]] = ...) -> None: ...
|
||||
|
||||
class CallServiceResult(_message.Message):
|
||||
__slots__ = ("response",)
|
||||
RESPONSE_FIELD_NUMBER: _ClassVar[int]
|
||||
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]
|
||||
DOMAINS_FIELD_NUMBER: _ClassVar[int]
|
||||
language: str
|
||||
domains: _containers.RepeatedScalarFieldContainer[str]
|
||||
def __init__(self, language: _Optional[str] = ..., domains: _Optional[_Iterable[str]] = ...) -> None: ...
|
||||
|
||||
class GetTranslationsResult(_message.Message):
|
||||
__slots__ = ("language", "strings")
|
||||
LANGUAGE_FIELD_NUMBER: _ClassVar[int]
|
||||
STRINGS_FIELD_NUMBER: _ClassVar[int]
|
||||
language: str
|
||||
strings: _struct_pb2.Struct
|
||||
def __init__(self, language: _Optional[str] = ..., strings: _Optional[_Union[_struct_pb2.Struct, _Mapping]] = ...) -> None: ...
|
||||
|
||||
class Shutdown(_message.Message):
|
||||
__slots__ = ()
|
||||
def __init__(self) -> None: ...
|
||||
|
||||
class ShutdownResult(_message.Message):
|
||||
__slots__ = ("ok", "unloaded", "restore_state")
|
||||
OK_FIELD_NUMBER: _ClassVar[int]
|
||||
UNLOADED_FIELD_NUMBER: _ClassVar[int]
|
||||
RESTORE_STATE_FIELD_NUMBER: _ClassVar[int]
|
||||
ok: bool
|
||||
unloaded: int
|
||||
restore_state: _struct_pb2.Struct
|
||||
def __init__(self, ok: bool = ..., unloaded: _Optional[int] = ..., restore_state: _Optional[_Union[_struct_pb2.Struct, _Mapping]] = ...) -> None: ...
|
||||
|
||||
class Ping(_message.Message):
|
||||
__slots__ = ()
|
||||
def __init__(self) -> None: ...
|
||||
|
||||
class PingResult(_message.Message):
|
||||
__slots__ = ("pong",)
|
||||
PONG_FIELD_NUMBER: _ClassVar[int]
|
||||
pong: str
|
||||
def __init__(self, pong: _Optional[str] = ...) -> None: ...
|
||||
|
||||
class Ready(_message.Message):
|
||||
__slots__ = ()
|
||||
def __init__(self) -> None: ...
|
||||
|
||||
class FlowInit(_message.Message):
|
||||
__slots__ = ("handler", "context", "data")
|
||||
HANDLER_FIELD_NUMBER: _ClassVar[int]
|
||||
CONTEXT_FIELD_NUMBER: _ClassVar[int]
|
||||
DATA_FIELD_NUMBER: _ClassVar[int]
|
||||
handler: str
|
||||
context: _struct_pb2.Struct
|
||||
data: _struct_pb2.Struct
|
||||
def __init__(self, handler: _Optional[str] = ..., context: _Optional[_Union[_struct_pb2.Struct, _Mapping]] = ..., data: _Optional[_Union[_struct_pb2.Struct, _Mapping]] = ...) -> None: ...
|
||||
|
||||
class FlowStep(_message.Message):
|
||||
__slots__ = ("flow_id", "user_input")
|
||||
FLOW_ID_FIELD_NUMBER: _ClassVar[int]
|
||||
USER_INPUT_FIELD_NUMBER: _ClassVar[int]
|
||||
flow_id: str
|
||||
user_input: _struct_pb2.Struct
|
||||
def __init__(self, flow_id: _Optional[str] = ..., user_input: _Optional[_Union[_struct_pb2.Struct, _Mapping]] = ...) -> None: ...
|
||||
|
||||
class FlowAbort(_message.Message):
|
||||
__slots__ = ("flow_id",)
|
||||
FLOW_ID_FIELD_NUMBER: _ClassVar[int]
|
||||
flow_id: str
|
||||
def __init__(self, flow_id: _Optional[str] = ...) -> None: ...
|
||||
|
||||
class FlowAbortResult(_message.Message):
|
||||
__slots__ = ()
|
||||
def __init__(self) -> None: ...
|
||||
|
||||
class FlowResult(_message.Message):
|
||||
__slots__ = ("type", "flow_id", "handler", "step_id", "reason", "title", "description", "last_step", "preview", "version", "minor_version", "data", "options", "errors", "description_placeholders", "context", "data_schema", "has_data_schema")
|
||||
TYPE_FIELD_NUMBER: _ClassVar[int]
|
||||
FLOW_ID_FIELD_NUMBER: _ClassVar[int]
|
||||
HANDLER_FIELD_NUMBER: _ClassVar[int]
|
||||
STEP_ID_FIELD_NUMBER: _ClassVar[int]
|
||||
REASON_FIELD_NUMBER: _ClassVar[int]
|
||||
TITLE_FIELD_NUMBER: _ClassVar[int]
|
||||
DESCRIPTION_FIELD_NUMBER: _ClassVar[int]
|
||||
LAST_STEP_FIELD_NUMBER: _ClassVar[int]
|
||||
PREVIEW_FIELD_NUMBER: _ClassVar[int]
|
||||
VERSION_FIELD_NUMBER: _ClassVar[int]
|
||||
MINOR_VERSION_FIELD_NUMBER: _ClassVar[int]
|
||||
DATA_FIELD_NUMBER: _ClassVar[int]
|
||||
OPTIONS_FIELD_NUMBER: _ClassVar[int]
|
||||
ERRORS_FIELD_NUMBER: _ClassVar[int]
|
||||
DESCRIPTION_PLACEHOLDERS_FIELD_NUMBER: _ClassVar[int]
|
||||
CONTEXT_FIELD_NUMBER: _ClassVar[int]
|
||||
DATA_SCHEMA_FIELD_NUMBER: _ClassVar[int]
|
||||
HAS_DATA_SCHEMA_FIELD_NUMBER: _ClassVar[int]
|
||||
type: str
|
||||
flow_id: str
|
||||
handler: str
|
||||
step_id: str
|
||||
reason: str
|
||||
title: str
|
||||
description: str
|
||||
last_step: bool
|
||||
preview: str
|
||||
version: int
|
||||
minor_version: int
|
||||
data: _struct_pb2.Struct
|
||||
options: _struct_pb2.Struct
|
||||
errors: _struct_pb2.Struct
|
||||
description_placeholders: _struct_pb2.Struct
|
||||
context: _struct_pb2.Struct
|
||||
data_schema: _struct_pb2.ListValue
|
||||
has_data_schema: bool
|
||||
def __init__(self, type: _Optional[str] = ..., flow_id: _Optional[str] = ..., handler: _Optional[str] = ..., step_id: _Optional[str] = ..., reason: _Optional[str] = ..., title: _Optional[str] = ..., description: _Optional[str] = ..., last_step: bool = ..., preview: _Optional[str] = ..., version: _Optional[int] = ..., minor_version: _Optional[int] = ..., data: _Optional[_Union[_struct_pb2.Struct, _Mapping]] = ..., options: _Optional[_Union[_struct_pb2.Struct, _Mapping]] = ..., errors: _Optional[_Union[_struct_pb2.Struct, _Mapping]] = ..., description_placeholders: _Optional[_Union[_struct_pb2.Struct, _Mapping]] = ..., context: _Optional[_Union[_struct_pb2.Struct, _Mapping]] = ..., data_schema: _Optional[_Union[_struct_pb2.ListValue, _Mapping]] = ..., has_data_schema: bool = ...) -> None: ...
|
||||
|
||||
class EntityInfo(_message.Message):
|
||||
__slots__ = ("description", "device_info")
|
||||
class Description(_message.Message):
|
||||
__slots__ = ("name", "icon", "entity_category", "device_class", "supported_features", "translation_key")
|
||||
NAME_FIELD_NUMBER: _ClassVar[int]
|
||||
ICON_FIELD_NUMBER: _ClassVar[int]
|
||||
ENTITY_CATEGORY_FIELD_NUMBER: _ClassVar[int]
|
||||
DEVICE_CLASS_FIELD_NUMBER: _ClassVar[int]
|
||||
SUPPORTED_FEATURES_FIELD_NUMBER: _ClassVar[int]
|
||||
TRANSLATION_KEY_FIELD_NUMBER: _ClassVar[int]
|
||||
name: str
|
||||
icon: str
|
||||
entity_category: str
|
||||
device_class: str
|
||||
supported_features: int
|
||||
translation_key: str
|
||||
def __init__(self, name: _Optional[str] = ..., icon: _Optional[str] = ..., entity_category: _Optional[str] = ..., device_class: _Optional[str] = ..., supported_features: _Optional[int] = ..., translation_key: _Optional[str] = ...) -> None: ...
|
||||
DESCRIPTION_FIELD_NUMBER: _ClassVar[int]
|
||||
DEVICE_INFO_FIELD_NUMBER: _ClassVar[int]
|
||||
description: EntityInfo.Description
|
||||
device_info: DeviceInfo
|
||||
def __init__(self, description: _Optional[_Union[EntityInfo.Description, _Mapping]] = ..., device_info: _Optional[_Union[DeviceInfo, _Mapping]] = ...) -> None: ...
|
||||
|
||||
class InitialState(_message.Message):
|
||||
__slots__ = ("state", "capabilities", "attributes")
|
||||
STATE_FIELD_NUMBER: _ClassVar[int]
|
||||
CAPABILITIES_FIELD_NUMBER: _ClassVar[int]
|
||||
ATTRIBUTES_FIELD_NUMBER: _ClassVar[int]
|
||||
state: str
|
||||
capabilities: _struct_pb2.Struct
|
||||
attributes: _struct_pb2.Struct
|
||||
def __init__(self, state: _Optional[str] = ..., capabilities: _Optional[_Union[_struct_pb2.Struct, _Mapping]] = ..., attributes: _Optional[_Union[_struct_pb2.Struct, _Mapping]] = ...) -> None: ...
|
||||
|
||||
class EntityDescription(_message.Message):
|
||||
__slots__ = ("entry_id", "domain", "sandbox_entity_id", "unique_id", "has_entity_name", "info", "initial")
|
||||
ENTRY_ID_FIELD_NUMBER: _ClassVar[int]
|
||||
DOMAIN_FIELD_NUMBER: _ClassVar[int]
|
||||
SANDBOX_ENTITY_ID_FIELD_NUMBER: _ClassVar[int]
|
||||
UNIQUE_ID_FIELD_NUMBER: _ClassVar[int]
|
||||
HAS_ENTITY_NAME_FIELD_NUMBER: _ClassVar[int]
|
||||
INFO_FIELD_NUMBER: _ClassVar[int]
|
||||
INITIAL_FIELD_NUMBER: _ClassVar[int]
|
||||
entry_id: str
|
||||
domain: str
|
||||
sandbox_entity_id: str
|
||||
unique_id: str
|
||||
has_entity_name: bool
|
||||
info: EntityInfo
|
||||
initial: InitialState
|
||||
def __init__(self, entry_id: _Optional[str] = ..., domain: _Optional[str] = ..., sandbox_entity_id: _Optional[str] = ..., unique_id: _Optional[str] = ..., has_entity_name: bool = ..., info: _Optional[_Union[EntityInfo, _Mapping]] = ..., initial: _Optional[_Union[InitialState, _Mapping]] = ...) -> None: ...
|
||||
|
||||
class RegisterEntityResult(_message.Message):
|
||||
__slots__ = ("entity_id",)
|
||||
ENTITY_ID_FIELD_NUMBER: _ClassVar[int]
|
||||
entity_id: str
|
||||
def __init__(self, entity_id: _Optional[str] = ...) -> None: ...
|
||||
|
||||
class UnregisterEntity(_message.Message):
|
||||
__slots__ = ("sandbox_entity_id",)
|
||||
SANDBOX_ENTITY_ID_FIELD_NUMBER: _ClassVar[int]
|
||||
sandbox_entity_id: str
|
||||
def __init__(self, sandbox_entity_id: _Optional[str] = ...) -> None: ...
|
||||
|
||||
class UnregisterEntityResult(_message.Message):
|
||||
__slots__ = ("ok",)
|
||||
OK_FIELD_NUMBER: _ClassVar[int]
|
||||
ok: bool
|
||||
def __init__(self, ok: bool = ...) -> None: ...
|
||||
|
||||
class StateChanged(_message.Message):
|
||||
__slots__ = ("sandbox_entity_id", "state", "attributes", "context_id")
|
||||
SANDBOX_ENTITY_ID_FIELD_NUMBER: _ClassVar[int]
|
||||
STATE_FIELD_NUMBER: _ClassVar[int]
|
||||
ATTRIBUTES_FIELD_NUMBER: _ClassVar[int]
|
||||
CONTEXT_ID_FIELD_NUMBER: _ClassVar[int]
|
||||
sandbox_entity_id: str
|
||||
state: str
|
||||
attributes: _struct_pb2.Struct
|
||||
context_id: str
|
||||
def __init__(self, sandbox_entity_id: _Optional[str] = ..., state: _Optional[str] = ..., attributes: _Optional[_Union[_struct_pb2.Struct, _Mapping]] = ..., context_id: _Optional[str] = ...) -> None: ...
|
||||
|
||||
class RegisterService(_message.Message):
|
||||
__slots__ = ("domain", "service", "supports_response", "schema")
|
||||
DOMAIN_FIELD_NUMBER: _ClassVar[int]
|
||||
SERVICE_FIELD_NUMBER: _ClassVar[int]
|
||||
SUPPORTS_RESPONSE_FIELD_NUMBER: _ClassVar[int]
|
||||
SCHEMA_FIELD_NUMBER: _ClassVar[int]
|
||||
domain: str
|
||||
service: str
|
||||
supports_response: str
|
||||
schema: _struct_pb2.ListValue
|
||||
def __init__(self, domain: _Optional[str] = ..., service: _Optional[str] = ..., supports_response: _Optional[str] = ..., schema: _Optional[_Union[_struct_pb2.ListValue, _Mapping]] = ...) -> None: ...
|
||||
|
||||
class RegisterServiceResult(_message.Message):
|
||||
__slots__ = ("ok", "installed")
|
||||
OK_FIELD_NUMBER: _ClassVar[int]
|
||||
INSTALLED_FIELD_NUMBER: _ClassVar[int]
|
||||
ok: bool
|
||||
installed: bool
|
||||
def __init__(self, ok: bool = ..., installed: bool = ...) -> None: ...
|
||||
|
||||
class UnregisterService(_message.Message):
|
||||
__slots__ = ("domain", "service")
|
||||
DOMAIN_FIELD_NUMBER: _ClassVar[int]
|
||||
SERVICE_FIELD_NUMBER: _ClassVar[int]
|
||||
domain: str
|
||||
service: str
|
||||
def __init__(self, domain: _Optional[str] = ..., service: _Optional[str] = ...) -> None: ...
|
||||
|
||||
class UnregisterServiceResult(_message.Message):
|
||||
__slots__ = ("ok", "removed")
|
||||
OK_FIELD_NUMBER: _ClassVar[int]
|
||||
REMOVED_FIELD_NUMBER: _ClassVar[int]
|
||||
ok: bool
|
||||
removed: bool
|
||||
def __init__(self, ok: bool = ..., removed: bool = ...) -> None: ...
|
||||
|
||||
class FireEvent(_message.Message):
|
||||
__slots__ = ("event_type", "event_data", "context_id")
|
||||
EVENT_TYPE_FIELD_NUMBER: _ClassVar[int]
|
||||
EVENT_DATA_FIELD_NUMBER: _ClassVar[int]
|
||||
CONTEXT_ID_FIELD_NUMBER: _ClassVar[int]
|
||||
event_type: str
|
||||
event_data: _struct_pb2.Struct
|
||||
context_id: str
|
||||
def __init__(self, event_type: _Optional[str] = ..., event_data: _Optional[_Union[_struct_pb2.Struct, _Mapping]] = ..., context_id: _Optional[str] = ...) -> None: ...
|
||||
|
||||
class StoreLoad(_message.Message):
|
||||
__slots__ = ("key",)
|
||||
KEY_FIELD_NUMBER: _ClassVar[int]
|
||||
key: str
|
||||
def __init__(self, key: _Optional[str] = ...) -> None: ...
|
||||
|
||||
class StoreLoadResult(_message.Message):
|
||||
__slots__ = ("data",)
|
||||
DATA_FIELD_NUMBER: _ClassVar[int]
|
||||
data: _struct_pb2.Struct
|
||||
def __init__(self, data: _Optional[_Union[_struct_pb2.Struct, _Mapping]] = ...) -> None: ...
|
||||
|
||||
class StoreSave(_message.Message):
|
||||
__slots__ = ("key", "data")
|
||||
KEY_FIELD_NUMBER: _ClassVar[int]
|
||||
DATA_FIELD_NUMBER: _ClassVar[int]
|
||||
key: str
|
||||
data: _struct_pb2.Struct
|
||||
def __init__(self, key: _Optional[str] = ..., data: _Optional[_Union[_struct_pb2.Struct, _Mapping]] = ...) -> None: ...
|
||||
|
||||
class StoreSaveResult(_message.Message):
|
||||
__slots__ = ("ok",)
|
||||
OK_FIELD_NUMBER: _ClassVar[int]
|
||||
ok: bool
|
||||
def __init__(self, ok: bool = ...) -> None: ...
|
||||
|
||||
class StoreRemove(_message.Message):
|
||||
__slots__ = ("key",)
|
||||
KEY_FIELD_NUMBER: _ClassVar[int]
|
||||
key: str
|
||||
def __init__(self, key: _Optional[str] = ...) -> None: ...
|
||||
|
||||
class StoreRemoveResult(_message.Message):
|
||||
__slots__ = ("ok",)
|
||||
OK_FIELD_NUMBER: _ClassVar[int]
|
||||
ok: bool
|
||||
def __init__(self, ok: bool = ...) -> None: ...
|
||||
@@ -0,0 +1,866 @@
|
||||
"""Main-side bridge — owns the per-sandbox entity registry + outbound dispatch.
|
||||
|
||||
Responsibilities:
|
||||
|
||||
* Hold a :class:`SandboxBridge` per sandbox group. Each one knows its
|
||||
:class:`Channel` plus the set of proxy entities the sandbox has
|
||||
registered with it.
|
||||
* Handle inbound sandbox→main calls:
|
||||
|
||||
- ``sandbox/register_entity`` — instantiate a proxy entity, add it to
|
||||
the matching :class:`EntityComponent` via
|
||||
:meth:`async_register_remote_platform`, and reply with the assigned
|
||||
main-side ``entity_id``.
|
||||
- ``sandbox/unregister_entity`` — drop the proxy.
|
||||
- ``sandbox/state_changed`` — push state/attributes into the cached
|
||||
state of the matching proxy entity.
|
||||
|
||||
* Expose :meth:`SandboxBridge.async_call_service` for proxy entities to
|
||||
forward action calls back to the sandbox — one RPC per call. (Coalescing
|
||||
same-tick calls for the same service into a single multi-entity RPC is a
|
||||
possible future optimisation; the first iteration keeps it simple.)
|
||||
* Translate sandbox-side exceptions back into the exception types proxy
|
||||
callers would have raised locally (``vol.Invalid`` → ``TypeError``,
|
||||
unknown service / entity → ``HomeAssistantError``).
|
||||
|
||||
The Store routing handlers (``sandbox/store_load`` /
|
||||
``store_save`` / ``store_remove``) are backed by a per-group
|
||||
:class:`_SandboxStoreServer`, writing each key to
|
||||
``<config>/.storage/sandbox/<group>/<key>``.
|
||||
Scope isolation is by construction — each bridge owns one channel for
|
||||
one group, so a sandbox can't reach another sandbox's files.
|
||||
"""
|
||||
|
||||
from collections import OrderedDict
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timedelta
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any, NamedTuple
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import (
|
||||
Context,
|
||||
HomeAssistant,
|
||||
ServiceCall,
|
||||
SupportsResponse,
|
||||
callback,
|
||||
)
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import device_registry as dr, json as json_helper
|
||||
from homeassistant.helpers.entity_component import DATA_INSTANCES, EntityComponent
|
||||
from homeassistant.helpers.entity_platform import EntityPlatform
|
||||
from homeassistant.helpers.storage import STORAGE_DIR
|
||||
from homeassistant.setup import async_setup_component
|
||||
from homeassistant.util import dt as dt_util, json as json_util
|
||||
from homeassistant.util.file import write_utf8_file_atomic
|
||||
|
||||
from ._proto import sandbox_pb2 as pb
|
||||
from .channel import Channel, ChannelClosedError, ChannelRemoteError
|
||||
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,
|
||||
MSG_STATE_CHANGED,
|
||||
MSG_STORE_LOAD,
|
||||
MSG_STORE_REMOVE,
|
||||
MSG_STORE_SAVE,
|
||||
MSG_UNREGISTER_ENTITY,
|
||||
MSG_UNREGISTER_SERVICE,
|
||||
)
|
||||
from .schema_bridge import reconstruct_schema
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
_REMOTE_PLATFORM_NAME = "sandbox"
|
||||
|
||||
# Lifetime of a remembered context_id → Context mapping. Only contexts main
|
||||
# hands *down* to the sandbox (service calls) are cached, and the sandbox
|
||||
# echoes them back within the same operation (seconds), so a 15-minute TTL is
|
||||
# generous headroom while keeping the cache naturally tiny. A miss is always
|
||||
# safe — it degrades to a fresh ``user_id=None`` Context — so expiry only ever
|
||||
# loses attribution on a pathologically delayed echo, never correctness.
|
||||
_CONTEXT_TTL = timedelta(minutes=15)
|
||||
# Sanity backstop only; the TTL does the real bounding given the low volume.
|
||||
_CONTEXT_CACHE_MAX = 2048
|
||||
|
||||
|
||||
class _CachedContext(NamedTuple):
|
||||
"""A remembered Context plus the instant its TTL lapses."""
|
||||
|
||||
context: Context
|
||||
expires_at: datetime
|
||||
|
||||
|
||||
@dataclass
|
||||
class SandboxEntityDescription:
|
||||
"""Snapshot of a sandbox-side entity, sent at registration time."""
|
||||
|
||||
entry_id: str
|
||||
domain: str
|
||||
sandbox_entity_id: str
|
||||
unique_id: str | None = None
|
||||
name: str | None = None
|
||||
icon: str | None = None
|
||||
has_entity_name: bool = False
|
||||
entity_category: str | None = None
|
||||
device_class: str | None = None
|
||||
supported_features: int = 0
|
||||
capabilities: dict[str, Any] = field(default_factory=dict)
|
||||
initial_state: str | None = None
|
||||
initial_attributes: dict[str, Any] = field(default_factory=dict)
|
||||
device_info: dict[str, Any] | None = None
|
||||
device_id: str | None = None
|
||||
|
||||
@classmethod
|
||||
def from_proto(cls, msg: pb.EntityDescription) -> SandboxEntityDescription:
|
||||
"""Build a description from the typed ``EntityDescription`` message.
|
||||
|
||||
Flattens the nested ``EntityInfo`` / ``InitialState`` sub-messages back
|
||||
into the flat shape the proxy entities consume.
|
||||
"""
|
||||
description = msg.info.description
|
||||
initial = msg.initial
|
||||
device_info = (
|
||||
_deserialise_device_info(msg.info.device_info)
|
||||
if msg.info.HasField("device_info")
|
||||
else None
|
||||
)
|
||||
return cls(
|
||||
entry_id=msg.entry_id,
|
||||
domain=msg.domain,
|
||||
sandbox_entity_id=msg.sandbox_entity_id,
|
||||
unique_id=msg.unique_id if msg.HasField("unique_id") else None,
|
||||
name=description.name if description.HasField("name") else None,
|
||||
icon=description.icon if description.HasField("icon") else None,
|
||||
has_entity_name=msg.has_entity_name,
|
||||
entity_category=(
|
||||
description.entity_category
|
||||
if description.HasField("entity_category")
|
||||
else None
|
||||
),
|
||||
device_class=(
|
||||
description.device_class
|
||||
if description.HasField("device_class")
|
||||
else None
|
||||
),
|
||||
supported_features=description.supported_features,
|
||||
capabilities=struct_to_dict(initial.capabilities),
|
||||
initial_state=initial.state if initial.HasField("state") else None,
|
||||
initial_attributes=struct_to_dict(initial.attributes),
|
||||
device_info=device_info,
|
||||
)
|
||||
|
||||
|
||||
class SandboxBridge:
|
||||
"""Per-sandbox-group bridge owning entities + outbound RPC dispatch."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
*,
|
||||
group: str,
|
||||
channel: Channel,
|
||||
) -> None:
|
||||
"""Initialise the bridge for one sandbox group's live channel."""
|
||||
self.hass = hass
|
||||
self.group = group
|
||||
self.channel = channel
|
||||
# Map sandbox-side entity_id → live proxy. Used for state-push
|
||||
# routing and unregister calls.
|
||||
self._entities: dict[str, Any] = {}
|
||||
# Map config_entry_id → EntityPlatform we own for that (domain, entry).
|
||||
# Keyed by (entry_id, domain) so different domains for the same entry
|
||||
# land in their own EntityComponent slot.
|
||||
self._platforms: dict[tuple[str, str], EntityPlatform] = {}
|
||||
# (domain, service) pairs this bridge has mirrored onto main.
|
||||
# Used to clean up on shutdown / unregister.
|
||||
self._mirrored_services: set[tuple[str, str]] = set()
|
||||
|
||||
self._store_server = _SandboxStoreServer(hass, group)
|
||||
|
||||
# Context security + restoration: the sandbox only ever sends a
|
||||
# context_id (a string) — it can never set parent_id / user_id on the
|
||||
# wire. Main records every Context it hands *down* to the sandbox
|
||||
# (service forwards, entity service calls) keyed by id; when the
|
||||
# sandbox echoes that id back (state_changed / fire_event), main
|
||||
# restores the original Context verbatim, so a user-initiated action's
|
||||
# attribution survives the round-trip. An id main never issued (or one
|
||||
# whose entry has expired) resolves to a brand-new main-owned Context
|
||||
# with no fabricated parentage — main never adopts the sandbox's id
|
||||
# (it is an untrusted ULID; see ``_resolve_context``). The cache is
|
||||
# TTL-bounded (``_CONTEXT_TTL``) and ordered by insertion so expiry
|
||||
# pruning is a cheap front-to-back walk; a miss is always safe.
|
||||
self._contexts: OrderedDict[str, _CachedContext] = OrderedDict()
|
||||
|
||||
channel.register(MSG_REGISTER_ENTITY, self._handle_register_entity)
|
||||
channel.register(MSG_UNREGISTER_ENTITY, self._handle_unregister_entity)
|
||||
channel.register(MSG_STATE_CHANGED, self._handle_state_changed)
|
||||
channel.register(MSG_REGISTER_SERVICE, self._handle_register_service)
|
||||
channel.register(MSG_UNREGISTER_SERVICE, self._handle_unregister_service)
|
||||
channel.register(MSG_FIRE_EVENT, self._handle_fire_event)
|
||||
channel.register(MSG_STORE_LOAD, self._handle_store_load)
|
||||
channel.register(MSG_STORE_SAVE, self._handle_store_save)
|
||||
channel.register(MSG_STORE_REMOVE, self._handle_store_remove)
|
||||
|
||||
async def async_call_service(
|
||||
self,
|
||||
*,
|
||||
domain: str,
|
||||
service: str,
|
||||
sandbox_entity_id: str,
|
||||
service_data: dict[str, Any],
|
||||
context: Context | None = None,
|
||||
return_response: bool = False,
|
||||
) -> Any:
|
||||
"""Forward one entity service call to the sandbox as a single RPC.
|
||||
|
||||
``context`` is the main-side Context driving the entity call. It is
|
||||
remembered here (before the id is reduced to a bare wire value) so that
|
||||
when the sandbox echoes the same id back on a resulting state change
|
||||
or event, :meth:`_resolve_context` restores the original
|
||||
``parent_id`` / ``user_id`` instead of minting a fresh attribution.
|
||||
|
||||
One RPC per call keeps the first iteration simple. Coalescing same-tick
|
||||
calls for one service into a single multi-entity RPC (so a 200-entity
|
||||
area call pays one round-trip instead of 200) is a possible future
|
||||
optimisation — see ``docs/FOLLOWUPS.md``.
|
||||
"""
|
||||
self._remember_context(context)
|
||||
return await self._raw_call_service(
|
||||
domain=domain,
|
||||
service=service,
|
||||
target={"entity_id": [sandbox_entity_id]},
|
||||
service_data=service_data,
|
||||
context_id=context.id if context is not None else None,
|
||||
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,
|
||||
*,
|
||||
domain: str,
|
||||
service: str,
|
||||
target: dict[str, Any],
|
||||
service_data: dict[str, Any],
|
||||
context_id: str | None,
|
||||
return_response: bool,
|
||||
) -> Any:
|
||||
"""Send one ``sandbox/call_service`` RPC and translate errors."""
|
||||
request = pb.CallService(
|
||||
domain=domain,
|
||||
service=service,
|
||||
target=dict_to_struct(target),
|
||||
service_data=dict_to_struct(service_data),
|
||||
return_response=return_response,
|
||||
)
|
||||
if context_id is not None:
|
||||
request.context_id = context_id
|
||||
try:
|
||||
return await self.channel.call(MSG_CALL_SERVICE, 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-call"
|
||||
) from err
|
||||
|
||||
def _prune_contexts(self, now: datetime) -> None:
|
||||
"""Drop expired entries from the front of the context cache.
|
||||
|
||||
The cache is kept ordered by insertion (every write moves its key to
|
||||
the end), and the TTL is constant, so insertion order *is* expiry
|
||||
order — expired entries always cluster at the front and a single walk
|
||||
that stops at the first live entry prunes everything stale.
|
||||
"""
|
||||
contexts = self._contexts
|
||||
while contexts:
|
||||
key = next(iter(contexts))
|
||||
if contexts[key].expires_at > now:
|
||||
break
|
||||
del contexts[key]
|
||||
|
||||
@callback
|
||||
def _remember_context(self, context: Context | None) -> None:
|
||||
"""Record a Context main is handing down to the sandbox.
|
||||
|
||||
Keyed by its (trusted, main-issued) id so an echoed id resolves back
|
||||
to the original Context, restoring ``parent_id`` / ``user_id``. The
|
||||
entry lives for ``_CONTEXT_TTL``; re-recording refreshes it and moves
|
||||
it to the end so the cache stays ordered by expiry. Expiry only loses
|
||||
attribution on a later echo (it degrades to a fresh Context), never
|
||||
correctness.
|
||||
"""
|
||||
if context is None:
|
||||
return
|
||||
now = dt_util.utcnow()
|
||||
self._prune_contexts(now)
|
||||
contexts = self._contexts
|
||||
contexts[context.id] = _CachedContext(context, now + _CONTEXT_TTL)
|
||||
contexts.move_to_end(context.id)
|
||||
# TTL + low volume keep this tiny; the cap is only a sanity backstop.
|
||||
while len(contexts) > _CONTEXT_CACHE_MAX:
|
||||
contexts.popitem(last=False)
|
||||
|
||||
@callback
|
||||
def _resolve_context(self, context_id: str | None) -> Context:
|
||||
"""Resolve a sandbox-supplied context_id to an authoritative Context.
|
||||
|
||||
The sandbox can never set ``parent_id`` / ``user_id`` on the wire —
|
||||
main owns that. A context_id main handed down (and still remembers)
|
||||
resolves back to the original Context verbatim, so a user-initiated
|
||||
action's attribution survives the round-trip.
|
||||
|
||||
An id main never issued — or whose entry has expired — yields a
|
||||
**brand-new** main-owned ``Context(user_id=None)``: a fresh
|
||||
main-generated id, no fabricated parentage. Main never adopts the
|
||||
sandbox-supplied id: context ids are ULIDs carrying an embedded
|
||||
millisecond timestamp, and main cannot trust the sandbox's clock (a
|
||||
crafted id could back- or forward-date the event for recorder /
|
||||
logbook ordering). The sandbox string is used only as the cache
|
||||
**key**, never as the resulting Context's identity. Caching the fresh
|
||||
context under that key lets repeated echoes within one operation map
|
||||
to the same stable Context.
|
||||
"""
|
||||
now = dt_util.utcnow()
|
||||
self._prune_contexts(now)
|
||||
if context_id is None:
|
||||
return Context(user_id=None)
|
||||
cached = self._contexts.get(context_id)
|
||||
if cached is not None:
|
||||
return cached.context
|
||||
context = Context(user_id=None)
|
||||
self._contexts[context_id] = _CachedContext(context, now + _CONTEXT_TTL)
|
||||
self._contexts.move_to_end(context_id)
|
||||
return context
|
||||
|
||||
async def _handle_register_entity(
|
||||
self, msg: pb.EntityDescription
|
||||
) -> pb.RegisterEntityResult:
|
||||
description = SandboxEntityDescription.from_proto(msg)
|
||||
entry = self.hass.config_entries.async_get_entry(description.entry_id)
|
||||
if entry is None:
|
||||
raise HomeAssistantError(
|
||||
f"register_entity: unknown entry_id {description.entry_id!r}"
|
||||
)
|
||||
# Namespace the proxy unique_id with the source integration domain so
|
||||
# two integrations in one group reusing the same unique_id don't
|
||||
# collide on the shared sandbox platform_name. A None unique_id
|
||||
# stays None (the entity opts out of the registry).
|
||||
if description.unique_id is not None:
|
||||
description.unique_id = (
|
||||
f"{entry.domain}{UNIQUE_ID_SEPARATOR}{description.unique_id}"
|
||||
)
|
||||
# The proxy entity subclasses the domain's *EntityBase* (LightEntity,
|
||||
# SwitchEntity, …); for the framework to host it the domain
|
||||
# component itself has to be set up so its EntityComponent exists.
|
||||
await self._ensure_domain_loaded(description.domain)
|
||||
# Pre-create the device entry so its id is known before the proxy
|
||||
# registers; the framework's own async_get_or_create call inside
|
||||
# EntityPlatform.async_add_entities is idempotent on (identifiers,
|
||||
# connections) and will reuse the same DeviceEntry.
|
||||
if description.device_info is not None:
|
||||
try:
|
||||
device = dr.async_get(self.hass).async_get_or_create(
|
||||
config_entry_id=description.entry_id,
|
||||
**description.device_info,
|
||||
)
|
||||
except dr.DeviceInfoError as err:
|
||||
raise HomeAssistantError(
|
||||
f"register_entity: invalid device_info for "
|
||||
f"{description.sandbox_entity_id!r}: {err}"
|
||||
) from err
|
||||
description.device_id = device.id
|
||||
# MSG_REGISTER_ENTITY is an upsert: a re-send for an already-tracked
|
||||
# entity (the client re-describes on registry/device updates) refreshes
|
||||
# the existing proxy in place rather than adding a duplicate. The
|
||||
# device pre-creation above already refreshed the DeviceEntry via the
|
||||
# idempotent async_get_or_create.
|
||||
existing = self._entities.get(description.sandbox_entity_id)
|
||||
if existing is not None:
|
||||
existing.sandbox_update_description(description)
|
||||
return pb.RegisterEntityResult(entity_id=existing.entity_id or "")
|
||||
proxy = self._build_proxy(description)
|
||||
platform = self._ensure_platform(entry, description.domain)
|
||||
await platform.async_add_entities([proxy])
|
||||
self._entities[description.sandbox_entity_id] = proxy
|
||||
return pb.RegisterEntityResult(entity_id=proxy.entity_id or "")
|
||||
|
||||
async def _ensure_domain_loaded(self, domain: str) -> None:
|
||||
"""Make sure the domain's :class:`EntityComponent` is loaded on main."""
|
||||
components = self.hass.data.get(DATA_INSTANCES, {})
|
||||
if domain in components:
|
||||
return
|
||||
# Empty config — we never own the domain ourselves; we just want
|
||||
# the EntityComponent so we can attach a proxy platform to it.
|
||||
await async_setup_component(self.hass, domain, {})
|
||||
|
||||
async def _handle_unregister_entity(
|
||||
self, msg: pb.UnregisterEntity
|
||||
) -> pb.UnregisterEntityResult:
|
||||
sandbox_entity_id = msg.sandbox_entity_id
|
||||
proxy = self._entities.pop(sandbox_entity_id, None)
|
||||
if proxy is None:
|
||||
return pb.UnregisterEntityResult(ok=True)
|
||||
entity_id = getattr(proxy, "entity_id", None)
|
||||
if not entity_id:
|
||||
return pb.UnregisterEntityResult(ok=True)
|
||||
domain = entity_id.split(".", 1)[0]
|
||||
component: EntityComponent[Any] | None = self.hass.data.get(
|
||||
DATA_INSTANCES, {}
|
||||
).get(domain)
|
||||
if component is not None:
|
||||
await component.async_remove_entity(entity_id)
|
||||
return pb.UnregisterEntityResult(ok=True)
|
||||
|
||||
async def _handle_state_changed(self, msg: pb.StateChanged) -> None:
|
||||
proxy = self._entities.get(msg.sandbox_entity_id)
|
||||
if proxy is None:
|
||||
return
|
||||
state_str = msg.state if msg.HasField("state") else None
|
||||
attributes = struct_to_dict(msg.attributes)
|
||||
context = (
|
||||
self._resolve_context(msg.context_id)
|
||||
if msg.HasField("context_id")
|
||||
else None
|
||||
)
|
||||
proxy.sandbox_apply_state(state_str, attributes, context)
|
||||
|
||||
async def _handle_register_service(
|
||||
self, msg: pb.RegisterService
|
||||
) -> pb.RegisterServiceResult:
|
||||
"""Mirror a sandbox-registered service onto main's service registry.
|
||||
|
||||
The handler that gets installed forwards every call back over
|
||||
the shared ``sandbox/call_service`` channel, so the
|
||||
integration's real handler (and its real schema) runs on the
|
||||
sandbox side. Exception translation reuses
|
||||
:func:`_translate_remote_error`.
|
||||
|
||||
If a service with the same ``(domain, service)`` already exists
|
||||
on main (e.g. the host ``light`` EntityComponent registered
|
||||
``light.turn_on`` for our proxy entities, or another integration
|
||||
already owns the slot) we skip the install — the existing
|
||||
handler stays in charge.
|
||||
"""
|
||||
domain = msg.domain.lower()
|
||||
service = msg.service.lower()
|
||||
supports_response = _parse_supports_response(msg.supports_response)
|
||||
if self.hass.services.has_service(domain, service):
|
||||
_LOGGER.debug(
|
||||
"SandboxBridge[%s]: %s.%s already on main, not replacing",
|
||||
self.group,
|
||||
domain,
|
||||
service,
|
||||
)
|
||||
return pb.RegisterServiceResult(ok=True, installed=False)
|
||||
|
||||
forwarder = _build_service_forwarder(self, domain, service, supports_response)
|
||||
schema = reconstruct_schema(listvalue_to_list(msg.schema))
|
||||
self.hass.services.async_register(
|
||||
domain,
|
||||
service,
|
||||
forwarder,
|
||||
schema=schema,
|
||||
supports_response=supports_response,
|
||||
)
|
||||
self._mirrored_services.add((domain, service))
|
||||
return pb.RegisterServiceResult(ok=True, installed=True)
|
||||
|
||||
async def _handle_unregister_service(
|
||||
self, msg: pb.UnregisterService
|
||||
) -> pb.UnregisterServiceResult:
|
||||
domain = msg.domain.lower()
|
||||
service = msg.service.lower()
|
||||
key = (domain, service)
|
||||
if key not in self._mirrored_services:
|
||||
return pb.UnregisterServiceResult(ok=True, removed=False)
|
||||
self._mirrored_services.discard(key)
|
||||
if self.hass.services.has_service(domain, service):
|
||||
self.hass.services.async_remove(domain, service)
|
||||
return pb.UnregisterServiceResult(ok=True, removed=True)
|
||||
|
||||
async def _handle_store_load(self, msg: pb.StoreLoad) -> pb.StoreLoadResult:
|
||||
"""Serve a sandbox-side ``Store.async_load``."""
|
||||
data = await self._store_server.async_load(_validate_key(msg.key))
|
||||
result = pb.StoreLoadResult()
|
||||
if data is not None:
|
||||
result.data.update(data)
|
||||
return result
|
||||
|
||||
async def _handle_store_save(self, msg: pb.StoreSave) -> pb.StoreSaveResult:
|
||||
"""Persist a sandbox-side ``Store.async_save`` flush."""
|
||||
await self._store_server.async_save(
|
||||
_validate_key(msg.key), struct_to_dict(msg.data)
|
||||
)
|
||||
return pb.StoreSaveResult(ok=True)
|
||||
|
||||
async def _handle_store_remove(self, msg: pb.StoreRemove) -> pb.StoreRemoveResult:
|
||||
"""Drop the on-disk file for a sandbox-side ``Store.async_remove``."""
|
||||
await self._store_server.async_remove(_validate_key(msg.key))
|
||||
return pb.StoreRemoveResult(ok=True)
|
||||
|
||||
async def _handle_fire_event(self, msg: pb.FireEvent) -> None:
|
||||
"""Re-fire a sandbox-side event on main's bus.
|
||||
|
||||
The sandbox tags every push with ``event_type`` + ``event_data`` and,
|
||||
optionally, a ``context_id``. Main resolves that id to an authoritative
|
||||
Context — restoring the original attribution for an id it handed down,
|
||||
or a fresh ``user_id=None`` Context otherwise. The sandbox can never
|
||||
inject a ``parent_id`` / ``user_id``.
|
||||
"""
|
||||
event_data = struct_to_dict(msg.event_data)
|
||||
context = (
|
||||
self._resolve_context(msg.context_id)
|
||||
if msg.HasField("context_id")
|
||||
else None
|
||||
)
|
||||
self.hass.bus.async_fire(msg.event_type, event_data, context=context)
|
||||
|
||||
def _ensure_platform(self, entry: ConfigEntry, domain: str) -> EntityPlatform:
|
||||
key = (entry.entry_id, domain)
|
||||
existing = self._platforms.get(key)
|
||||
if existing is not None:
|
||||
return existing
|
||||
component: EntityComponent[Any] | None = self.hass.data.get(
|
||||
DATA_INSTANCES, {}
|
||||
).get(domain)
|
||||
if component is None:
|
||||
raise HomeAssistantError(
|
||||
f"register_entity: no EntityComponent for {domain!r}; the"
|
||||
" host integration is not loaded"
|
||||
)
|
||||
platform = EntityPlatform(
|
||||
hass=self.hass,
|
||||
logger=_LOGGER,
|
||||
domain=domain,
|
||||
platform_name=_REMOTE_PLATFORM_NAME,
|
||||
platform=None,
|
||||
scan_interval=timedelta(seconds=0),
|
||||
entity_namespace=None,
|
||||
)
|
||||
platform.config_entry = entry
|
||||
platform.async_prepare()
|
||||
component.async_register_remote_platform(entry, platform)
|
||||
self._platforms[key] = platform
|
||||
return platform
|
||||
|
||||
def _build_proxy(self, description: SandboxEntityDescription) -> Any:
|
||||
from .entity import build_proxy # noqa: PLC0415 — break import cycle
|
||||
|
||||
return build_proxy(self, description)
|
||||
|
||||
async def async_unload_entry(self, entry: ConfigEntry) -> None:
|
||||
"""Drop every platform and proxy this bridge added for ``entry``."""
|
||||
domains = [d for (eid, d) in list(self._platforms) if eid == entry.entry_id]
|
||||
for domain in domains:
|
||||
platform = self._platforms.pop((entry.entry_id, domain), None)
|
||||
if platform is None:
|
||||
continue
|
||||
await platform.async_destroy()
|
||||
component: EntityComponent[Any] | None = self.hass.data.get(
|
||||
DATA_INSTANCES, {}
|
||||
).get(domain)
|
||||
if component is not None:
|
||||
# Mirror the EntityComponent.async_unload_entry side-effect.
|
||||
component._platforms.pop(entry.entry_id, None) # noqa: SLF001
|
||||
# Forget proxies that were owned by this entry.
|
||||
survivors = {
|
||||
sid: proxy
|
||||
for sid, proxy in self._entities.items()
|
||||
if getattr(proxy.description, "entry_id", None) != entry.entry_id
|
||||
}
|
||||
self._entities = survivors
|
||||
|
||||
|
||||
_STORE_KEY_FORBIDDEN = ("/", "\\", "\x00")
|
||||
|
||||
|
||||
def _validate_key(key: str) -> str:
|
||||
"""Validate a store ``key`` from the wire.
|
||||
|
||||
Defends the host filesystem from a compromised sandbox: a key must
|
||||
be a non-empty string with no path separators, no null bytes, and
|
||||
no parent-directory hop. Anything else trips a
|
||||
:class:`HomeAssistantError`, which the channel framework turns into
|
||||
a remote-error frame for the sandbox.
|
||||
"""
|
||||
if not key:
|
||||
raise HomeAssistantError("store request: missing 'key'")
|
||||
if any(ch in key for ch in _STORE_KEY_FORBIDDEN):
|
||||
raise HomeAssistantError(f"store request: invalid key {key!r}")
|
||||
if key in {".", ".."} or key.startswith(".."):
|
||||
raise HomeAssistantError(f"store request: invalid key {key!r}")
|
||||
return key
|
||||
|
||||
|
||||
class _SandboxStoreServer:
|
||||
"""Per-group store backend on main.
|
||||
|
||||
Each :class:`SandboxBridge` owns one of these. The bridge's channel
|
||||
is dedicated to one sandbox group, so scope isolation is enforced by
|
||||
construction: sandbox "built-in" only ever talks to its own bridge,
|
||||
which only ever reads/writes ``<config>/.storage/sandbox/built-in/``.
|
||||
Cross-group access requires forging a channel, which the sandbox
|
||||
cannot do.
|
||||
"""
|
||||
|
||||
def __init__(self, hass: HomeAssistant, group: str) -> None:
|
||||
"""Pin the storage directory to ``<config>/.storage/sandbox/<group>``."""
|
||||
self.hass = hass
|
||||
self.group = group
|
||||
self._dir = Path(hass.config.path(STORAGE_DIR, "sandbox", group))
|
||||
|
||||
def _path_for(self, key: str) -> Path:
|
||||
# ``_require_key`` has already rejected slashes / ``..`` / NUL.
|
||||
return self._dir / key
|
||||
|
||||
async def async_load(self, key: str) -> dict[str, Any] | None:
|
||||
"""Return the wrapped Store payload or ``None`` if missing."""
|
||||
path = self._path_for(key)
|
||||
try:
|
||||
data = await self.hass.async_add_executor_job(
|
||||
json_util.load_json, str(path), None
|
||||
)
|
||||
except HomeAssistantError as err:
|
||||
_LOGGER.warning(
|
||||
"Sandbox %s store_load(%s) failed: %s", self.group, key, err
|
||||
)
|
||||
return None
|
||||
if data is None or data == {}:
|
||||
return None
|
||||
if not isinstance(data, dict):
|
||||
_LOGGER.warning(
|
||||
"Sandbox %s store_load(%s): non-dict on disk (%s)",
|
||||
self.group,
|
||||
key,
|
||||
type(data).__name__,
|
||||
)
|
||||
return None
|
||||
return data
|
||||
|
||||
async def async_save(self, key: str, data: dict[str, Any]) -> None:
|
||||
"""Write the wrapped Store payload atomically."""
|
||||
path = self._path_for(key)
|
||||
await self.hass.async_add_executor_job(self._write_sync, path, data)
|
||||
|
||||
def _write_sync(self, path: Path, data: dict[str, Any]) -> None:
|
||||
os.makedirs(path.parent, exist_ok=True)
|
||||
mode, json_data = json_helper.prepare_save_json(data, encoder=None)
|
||||
write_utf8_file_atomic(str(path), json_data, False, mode=mode)
|
||||
|
||||
async def async_remove(self, key: str) -> None:
|
||||
"""Unlink the file backing ``key`` if it exists."""
|
||||
path = self._path_for(key)
|
||||
await self.hass.async_add_executor_job(self._remove_sync, path)
|
||||
|
||||
def _remove_sync(self, path: Path) -> None:
|
||||
try:
|
||||
os.unlink(path)
|
||||
except FileNotFoundError:
|
||||
return
|
||||
|
||||
|
||||
_DEVICE_INFO_STR_FIELDS = (
|
||||
"name",
|
||||
"manufacturer",
|
||||
"model",
|
||||
"model_id",
|
||||
"sw_version",
|
||||
"hw_version",
|
||||
"serial_number",
|
||||
"suggested_area",
|
||||
"configuration_url",
|
||||
"default_name",
|
||||
"default_manufacturer",
|
||||
"default_model",
|
||||
"translation_key",
|
||||
)
|
||||
|
||||
|
||||
def _deserialise_device_info(info: pb.DeviceInfo) -> dict[str, Any] | None:
|
||||
"""Rebuild a ``DeviceInfo`` TypedDict from the typed proto.
|
||||
|
||||
``identifiers`` / ``connections`` come back as sets of tuples and
|
||||
``via_device`` as a tuple — the shapes
|
||||
:func:`device_registry.async_get_or_create` validates. ``entry_type`` is
|
||||
rebuilt as a :class:`DeviceEntryType` enum value.
|
||||
"""
|
||||
out: dict[str, Any] = {}
|
||||
if info.identifiers:
|
||||
out["identifiers"] = {(pair.key, pair.value) for pair in info.identifiers}
|
||||
if info.connections:
|
||||
out["connections"] = {(pair.key, pair.value) for pair in info.connections}
|
||||
if info.HasField("via_device"):
|
||||
out["via_device"] = (info.via_device.key, info.via_device.value)
|
||||
if info.entry_type:
|
||||
try:
|
||||
out["entry_type"] = dr.DeviceEntryType(info.entry_type)
|
||||
except ValueError:
|
||||
_LOGGER.debug(
|
||||
"register_entity: unknown entry_type %r — dropping", info.entry_type
|
||||
)
|
||||
for field_name in _DEVICE_INFO_STR_FIELDS:
|
||||
value = getattr(info, field_name)
|
||||
if value:
|
||||
out[field_name] = value
|
||||
return out or None
|
||||
|
||||
|
||||
def _parse_supports_response(value: Any) -> SupportsResponse:
|
||||
"""Coerce the wire ``supports_response`` field into the enum."""
|
||||
if isinstance(value, SupportsResponse):
|
||||
return value
|
||||
if value is None:
|
||||
return SupportsResponse.NONE
|
||||
try:
|
||||
return SupportsResponse(str(value).lower())
|
||||
except ValueError:
|
||||
return SupportsResponse.NONE
|
||||
|
||||
|
||||
def _build_service_forwarder(
|
||||
bridge: SandboxBridge,
|
||||
domain: str,
|
||||
service: str,
|
||||
supports_response: SupportsResponse,
|
||||
):
|
||||
"""Return a callable suitable for :meth:`ServiceRegistry.async_register`.
|
||||
|
||||
The forwarder rebuilds the original service-call payload and ships it
|
||||
back over the sandbox's shared ``sandbox/call_service`` channel.
|
||||
Schema validation already ran on the way in (main's registry runs
|
||||
``schema=None`` because the sandbox owns the schema); the sandbox
|
||||
runs the real handler against its own entities and registry.
|
||||
"""
|
||||
|
||||
async def _forward(call: ServiceCall) -> Any:
|
||||
# Remember the real (main-issued) Context so the sandbox echoing this
|
||||
# id back on a derived state/event restores it verbatim.
|
||||
bridge._remember_context(call.context) # noqa: SLF001
|
||||
response = await bridge._raw_call_service( # noqa: SLF001
|
||||
domain=domain,
|
||||
service=service,
|
||||
target=_target_from_call(call),
|
||||
service_data=dict(call.data),
|
||||
context_id=call.context.id if call.context is not None else None,
|
||||
return_response=call.return_response,
|
||||
)
|
||||
if supports_response is SupportsResponse.NONE:
|
||||
return None
|
||||
if response.HasField("response"):
|
||||
return struct_to_dict(response.response.data)
|
||||
return None
|
||||
|
||||
return _forward
|
||||
|
||||
|
||||
def _target_from_call(call: ServiceCall) -> dict[str, Any]:
|
||||
"""Extract a ``target`` dict from the (already-validated) service call."""
|
||||
target: dict[str, Any] = {}
|
||||
if not call.data:
|
||||
return target
|
||||
for key in ("entity_id", "area_id", "device_id", "floor_id", "label_id"):
|
||||
value = call.data.get(key)
|
||||
if value is None:
|
||||
continue
|
||||
target[key] = list(value) if isinstance(value, (list, tuple, set)) else value
|
||||
return target
|
||||
|
||||
|
||||
def _rebuild_invalid(data: Mapping[str, Any]) -> vol.Invalid:
|
||||
"""Rebuild a single :class:`vol.Invalid` from its serialized payload."""
|
||||
path = data.get("path") or None
|
||||
return vol.Invalid(data.get("msg", ""), path=path)
|
||||
|
||||
|
||||
def _translate_remote_error(err: ChannelRemoteError) -> Exception:
|
||||
"""Map a sandbox-side exception class name to a sensible main-side one.
|
||||
|
||||
Service-handler errors come back from the sandbox as whatever
|
||||
``services.async_call`` raised — most often :class:`vol.Invalid`. When
|
||||
the error frame carries structured ``error_data`` (set for voluptuous
|
||||
errors), the original :class:`vol.Invalid` / :class:`vol.MultipleInvalid`
|
||||
is rebuilt with its ``path`` intact — callers on main (service/flow
|
||||
framework) handle real voluptuous errors correctly. Older/edge frames
|
||||
without ``error_data`` fall back to the class-name mapping. Anything we
|
||||
don't have a mapping for surfaces as a plain :class:`HomeAssistantError`
|
||||
with the remote message preserved.
|
||||
"""
|
||||
if (error_data := err.error_data) is not None:
|
||||
kind = error_data.get("kind")
|
||||
if kind == "invalid":
|
||||
return _rebuild_invalid(error_data)
|
||||
if kind == "multiple":
|
||||
return vol.MultipleInvalid(
|
||||
[_rebuild_invalid(child) for child in error_data.get("errors", [])]
|
||||
)
|
||||
name = err.error_type or ""
|
||||
msg = err.error
|
||||
if name in {"Invalid", "MultipleInvalid"}:
|
||||
return TypeError(msg)
|
||||
if name in {"ServiceNotFound", "ServiceValidationError"}:
|
||||
return HomeAssistantError(msg)
|
||||
if name == "HomeAssistantError":
|
||||
return HomeAssistantError(msg)
|
||||
return HomeAssistantError(f"sandbox error ({name or 'unknown'}): {msg}")
|
||||
|
||||
|
||||
@callback
|
||||
def async_create_bridge(
|
||||
hass: HomeAssistant, *, group: str, channel: Channel
|
||||
) -> SandboxBridge:
|
||||
"""Public constructor used by ``__init__.async_setup``'s channel callback."""
|
||||
return SandboxBridge(hass, group=group, channel=channel)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"SandboxBridge",
|
||||
"SandboxEntityDescription",
|
||||
"async_create_bridge",
|
||||
]
|
||||
@@ -0,0 +1,42 @@
|
||||
"""Picker catalog hook for sandbox-only custom integrations.
|
||||
|
||||
A custom (HACS) integration that runs in a stateless sandbox has its code
|
||||
fetched at ``entry_setup`` and never lands under ``<config>/custom_components``
|
||||
on the main install. The add-integration picker is built from an on-disk scan
|
||||
(``loader.async_get_integration_descriptions``), so such an integration has no
|
||||
picker row and no display name — a discoverability gap, of which ``title`` is a
|
||||
subset.
|
||||
|
||||
This module is the sandbox-namespaced face of the catalog hook, parallel to the
|
||||
:mod:`~homeassistant.components.sandbox.sources` source resolver: HACS — or any
|
||||
distribution mechanism — registers a provider that *enumerates* the custom
|
||||
integrations it knows about, and core merges those descriptors into the picker
|
||||
and the ``title`` fallback. The hook itself lives in
|
||||
:mod:`homeassistant.loader` because core (not the sandbox component) consumes it;
|
||||
this re-export keeps HACS's registration surface in one place.
|
||||
|
||||
Contract (decision (a), display-only):
|
||||
|
||||
* Deliberately **separate** from the source resolver. The resolver is lazy,
|
||||
per-domain and security-critical (it pins ``ref`` to an exact commit sha); the
|
||||
catalog is eager, enumerable and purely cosmetic. Fusing them would drag
|
||||
display strings through the sha-validation path.
|
||||
* ``name`` is the load-bearing field — it feeds both the picker row and the
|
||||
``title`` fallback. ``title_translations`` is **optional**: HACS may not have
|
||||
the un-fetched tarball's ``translations/`` indexed, and absent it the picker
|
||||
degrades to ``name``.
|
||||
* A wrong or missing ``name`` is cosmetic, so — unlike ``ref`` — core does **no**
|
||||
validation of catalog descriptors.
|
||||
"""
|
||||
|
||||
from homeassistant.loader import (
|
||||
SandboxCatalogProvider,
|
||||
SandboxIntegrationDescriptor,
|
||||
async_register_sandbox_catalog_provider,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"SandboxCatalogProvider",
|
||||
"SandboxIntegrationDescriptor",
|
||||
"async_register_sandbox_catalog_provider",
|
||||
]
|
||||
@@ -0,0 +1,605 @@
|
||||
"""Request/response channel between manager and sandbox runtime.
|
||||
|
||||
The channel is split into three layers so the wire format and the byte
|
||||
transport can each be swapped without touching the concurrency-critical
|
||||
dispatch core:
|
||||
|
||||
* :class:`Channel` — the dispatch core: pending-id map, inflight
|
||||
semaphore, ``register`` / ``call`` / ``push`` / ``close``. It speaks in
|
||||
:class:`Frame` objects and never touches raw bytes.
|
||||
* :class:`Codec` — turns a :class:`Frame` into bytes and back.
|
||||
:class:`~.codec_protobuf.ProtobufCodec` is the production wire (a typed
|
||||
protobuf ``Frame`` envelope; the codec owns the ``type → message`` registry
|
||||
so this dispatch core stays codec-agnostic). :class:`JsonCodec` (one JSON
|
||||
object per frame) is retained only as the channel-core test/debug wire.
|
||||
* :class:`Transport` — moves whole frame blobs over some byte channel.
|
||||
:class:`StreamTransport` length-prefixes each frame (4-byte big-endian
|
||||
length + body) over an :class:`asyncio.StreamReader` /
|
||||
:class:`asyncio.StreamWriter` pair (stdio, unix socket). A future
|
||||
``WebSocketTransport`` drops in via :meth:`Channel.from_transport` using
|
||||
aiohttp's native binary framing.
|
||||
|
||||
The :class:`Frame` shape mirrors the three message kinds that cross the
|
||||
wire:
|
||||
|
||||
* **call**: ``id`` (>0), ``type``, ``payload`` — expects a reply
|
||||
* **push**: ``id`` 0, ``type``, ``payload`` — one-way, no reply
|
||||
* **response**: ``id`` (>0), ``ok``, and either ``result`` or
|
||||
``error`` / ``error_type`` / ``error_data``
|
||||
|
||||
The channel is symmetric: either side may call or be called on. The same
|
||||
class runs in the HA Core integration and inside the sandbox subprocess
|
||||
(the sandbox side lives at :mod:`hass_client.channel`; the two are kept in
|
||||
sync by the protocol shape rather than a shared import — the integration
|
||||
must not depend on ``hass_client``).
|
||||
|
||||
Inbound calls and pushes are dispatched in their own tasks so a handler
|
||||
that itself issues :meth:`Channel.call` does not block the reader — the
|
||||
reply for the nested call has to come back through the same reader. A
|
||||
bounded semaphore caps how many handlers can run concurrently; the N+1th
|
||||
inbound message queues at the semaphore (not at the reader) until a slot
|
||||
frees up.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Awaitable, Callable, Coroutine
|
||||
import contextlib
|
||||
from dataclasses import dataclass, field
|
||||
from enum import StrEnum
|
||||
import json
|
||||
import logging
|
||||
import struct
|
||||
from typing import Any, Protocol
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
Handler = Callable[[Any], Awaitable[Any]]
|
||||
|
||||
DEFAULT_MAX_INFLIGHT = 16
|
||||
|
||||
# Hard cap on a single frame's body. A length prefix larger than this aborts
|
||||
# the channel rather than letting a compromised sandbox allocate the host to
|
||||
# death (same hardening spirit as the auth key check).
|
||||
MAX_FRAME_SIZE = 16 * 1024 * 1024
|
||||
|
||||
_LENGTH_PREFIX = struct.Struct(">I")
|
||||
|
||||
|
||||
def _serialize_invalid(err: vol.Invalid) -> dict[str, Any]:
|
||||
"""Capture a ``vol.Invalid``'s message + path so the peer can rebuild it.
|
||||
|
||||
Path parts may be ``vol.Marker``s or other non-JSON objects, so each
|
||||
part is stringified.
|
||||
"""
|
||||
return {
|
||||
"kind": "invalid",
|
||||
"msg": err.error_message,
|
||||
"path": [str(part) for part in (err.path or [])],
|
||||
}
|
||||
|
||||
|
||||
def error_data_for(err: BaseException) -> dict[str, Any] | None:
|
||||
"""Structured payload that lets the peer reconstruct a voluptuous error.
|
||||
|
||||
``MultipleInvalid`` is a subclass of ``Invalid``, so it is checked first.
|
||||
Returns ``None`` for anything that is not a voluptuous error.
|
||||
"""
|
||||
if isinstance(err, vol.MultipleInvalid):
|
||||
return {
|
||||
"kind": "multiple",
|
||||
"errors": [_serialize_invalid(child) for child in err.errors],
|
||||
}
|
||||
if isinstance(err, vol.Invalid):
|
||||
return _serialize_invalid(err)
|
||||
return None
|
||||
|
||||
|
||||
class FrameKind(StrEnum):
|
||||
"""Which of the three wire shapes a :class:`Frame` carries."""
|
||||
|
||||
CALL = "call"
|
||||
PUSH = "push"
|
||||
RESPONSE = "response"
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class Frame:
|
||||
"""Transport/codec-neutral representation of one wire message."""
|
||||
|
||||
kind: FrameKind
|
||||
id: int = 0
|
||||
type: str = ""
|
||||
payload: Any = None
|
||||
ok: bool = False
|
||||
result: Any = None
|
||||
error: str | None = None
|
||||
error_type: str | None = None
|
||||
error_data: dict[str, Any] | None = field(default=None)
|
||||
|
||||
@classmethod
|
||||
def call(cls, call_id: int, msg_type: str, payload: Any) -> Frame:
|
||||
"""Build a request frame that expects a reply."""
|
||||
return cls(FrameKind.CALL, id=call_id, type=msg_type, payload=payload)
|
||||
|
||||
@classmethod
|
||||
def push(cls, msg_type: str, payload: Any) -> Frame:
|
||||
"""Build a one-way push frame."""
|
||||
return cls(FrameKind.PUSH, id=0, type=msg_type, payload=payload)
|
||||
|
||||
@classmethod
|
||||
def ok_response(cls, call_id: int, result: Any, msg_type: str = "") -> Frame:
|
||||
"""Build a success response frame.
|
||||
|
||||
``msg_type`` is carried so a stateless codec (the protobuf one) can
|
||||
look up the result message class on encode + decode.
|
||||
"""
|
||||
return cls(
|
||||
FrameKind.RESPONSE, id=call_id, type=msg_type, ok=True, result=result
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def error_response(
|
||||
cls,
|
||||
call_id: int,
|
||||
error: str,
|
||||
error_type: str | None,
|
||||
error_data: dict[str, Any] | None = None,
|
||||
msg_type: str = "",
|
||||
) -> Frame:
|
||||
"""Build a failure response frame."""
|
||||
return cls(
|
||||
FrameKind.RESPONSE,
|
||||
id=call_id,
|
||||
type=msg_type,
|
||||
ok=False,
|
||||
error=error,
|
||||
error_type=error_type,
|
||||
error_data=error_data,
|
||||
)
|
||||
|
||||
|
||||
class Codec(Protocol):
|
||||
"""Serialises a :class:`Frame` to bytes and back."""
|
||||
|
||||
def encode(self, frame: Frame) -> bytes:
|
||||
"""Return the wire bytes for ``frame``."""
|
||||
|
||||
def decode(self, data: bytes) -> Frame:
|
||||
"""Rebuild a :class:`Frame` from wire bytes."""
|
||||
|
||||
|
||||
class JsonCodec:
|
||||
"""One-JSON-object-per-frame codec.
|
||||
|
||||
The registry-free test/debug wire: it passes frame payloads through as
|
||||
plain JSON (no ``type``-to-proto lookup), so the concurrency-critical
|
||||
channel core can be exercised with synthetic message types and arbitrary
|
||||
dict/int payloads. Production rides :class:`ProtobufCodec`; this stays
|
||||
for the channel-core tests only.
|
||||
"""
|
||||
|
||||
def encode(self, frame: Frame) -> bytes:
|
||||
"""Encode a frame to a compact JSON object."""
|
||||
message: dict[str, Any]
|
||||
if frame.kind is FrameKind.CALL:
|
||||
message = {"id": frame.id, "type": frame.type, "payload": frame.payload}
|
||||
elif frame.kind is FrameKind.PUSH:
|
||||
message = {"type": frame.type, "payload": frame.payload}
|
||||
elif frame.ok:
|
||||
message = {"id": frame.id, "ok": True, "result": frame.result}
|
||||
else:
|
||||
message = {
|
||||
"id": frame.id,
|
||||
"ok": False,
|
||||
"error": frame.error,
|
||||
"error_type": frame.error_type,
|
||||
}
|
||||
if frame.error_data is not None:
|
||||
message["error_data"] = frame.error_data
|
||||
return json.dumps(message, separators=(",", ":")).encode("utf-8")
|
||||
|
||||
def decode(self, data: bytes) -> Frame:
|
||||
"""Decode a JSON object into a frame, inferring the kind from keys."""
|
||||
message = json.loads(data)
|
||||
has_id = "id" in message
|
||||
has_type = "type" in message
|
||||
if has_id and not has_type:
|
||||
# Response to a call we sent out.
|
||||
if message.get("ok"):
|
||||
return Frame.ok_response(message["id"], message.get("result"))
|
||||
return Frame.error_response(
|
||||
message["id"],
|
||||
message.get("error", "unknown error"),
|
||||
message.get("error_type"),
|
||||
message.get("error_data"),
|
||||
)
|
||||
if not has_id:
|
||||
return Frame.push(message.get("type", ""), message.get("payload"))
|
||||
return Frame.call(message["id"], message["type"], message.get("payload"))
|
||||
|
||||
|
||||
class Transport(Protocol):
|
||||
"""Moves whole frame blobs over some byte channel."""
|
||||
|
||||
async def read_frame(self) -> bytes | None:
|
||||
"""Return the next frame's bytes, or ``None`` at end-of-stream."""
|
||||
|
||||
async def write_frame(self, data: bytes) -> None:
|
||||
"""Write one frame's bytes."""
|
||||
|
||||
def close(self) -> None:
|
||||
"""Begin closing the underlying channel."""
|
||||
|
||||
async def wait_closed(self) -> None:
|
||||
"""Wait for the underlying channel to finish closing."""
|
||||
|
||||
|
||||
class FrameTooLargeError(Exception):
|
||||
"""A peer announced a frame larger than :data:`MAX_FRAME_SIZE`."""
|
||||
|
||||
|
||||
class StreamTransport:
|
||||
"""Length-prefixed framing over a reader/writer pair.
|
||||
|
||||
Each frame is a 4-byte big-endian length followed by exactly that many
|
||||
body bytes. Used for stdio and unix-socket connections — anywhere the
|
||||
byte channel is an :class:`asyncio.StreamReader` /
|
||||
:class:`asyncio.StreamWriter` pair.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
reader: asyncio.StreamReader,
|
||||
writer: asyncio.StreamWriter,
|
||||
) -> None:
|
||||
"""Wrap a reader/writer pair with length-prefixed framing."""
|
||||
self._reader = reader
|
||||
self._writer = writer
|
||||
|
||||
async def read_frame(self) -> bytes | None:
|
||||
"""Read one length-prefixed frame, or ``None`` at clean EOF."""
|
||||
try:
|
||||
header = await self._reader.readexactly(_LENGTH_PREFIX.size)
|
||||
except asyncio.IncompleteReadError:
|
||||
return None
|
||||
(length,) = _LENGTH_PREFIX.unpack(header)
|
||||
if length > MAX_FRAME_SIZE:
|
||||
raise FrameTooLargeError(
|
||||
f"frame length {length} exceeds cap {MAX_FRAME_SIZE}"
|
||||
)
|
||||
try:
|
||||
return await self._reader.readexactly(length)
|
||||
except asyncio.IncompleteReadError:
|
||||
return None
|
||||
|
||||
async def write_frame(self, data: bytes) -> None:
|
||||
"""Write one length-prefixed frame and flush it."""
|
||||
self._writer.write(_LENGTH_PREFIX.pack(len(data)) + data)
|
||||
await self._writer.drain()
|
||||
|
||||
def close(self) -> None:
|
||||
"""Close the writer side of the connection."""
|
||||
self._writer.close()
|
||||
|
||||
async def wait_closed(self) -> None:
|
||||
"""Wait for the writer to finish closing."""
|
||||
await self._writer.wait_closed()
|
||||
|
||||
|
||||
class ChannelClosedError(Exception):
|
||||
"""Raised when an operation is attempted on a closed channel."""
|
||||
|
||||
|
||||
class ChannelRemoteError(Exception):
|
||||
"""Raised when the remote side returns an error response."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
error: str,
|
||||
error_type: str | None = None,
|
||||
error_data: dict[str, Any] | None = None,
|
||||
) -> None:
|
||||
"""Initialise with the remote error message and exception class name.
|
||||
|
||||
``error_data`` carries a structured payload (set for voluptuous
|
||||
errors) so the receiver can rebuild the original exception shape.
|
||||
"""
|
||||
super().__init__(error)
|
||||
self.error = error
|
||||
self.error_type = error_type
|
||||
self.error_data = error_data
|
||||
|
||||
|
||||
class Channel:
|
||||
"""One bidirectional request/response channel over a transport + codec."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
reader: asyncio.StreamReader | None = None,
|
||||
writer: asyncio.StreamWriter | None = None,
|
||||
*,
|
||||
transport: Transport | None = None,
|
||||
codec: Codec | None = None,
|
||||
name: str = "channel",
|
||||
max_inflight: int = DEFAULT_MAX_INFLIGHT,
|
||||
) -> None:
|
||||
"""Wrap a reader/writer pair (or a transport) into a channel.
|
||||
|
||||
The common case passes a ``reader``/``writer`` pair, framed with
|
||||
:class:`StreamTransport` (length-prefixed). To run over a non-stream
|
||||
transport (e.g. websockets), pass ``transport=`` instead — see
|
||||
:meth:`from_transport`.
|
||||
|
||||
``codec`` defaults to :class:`JsonCodec`. ``max_inflight`` bounds how
|
||||
many handler tasks may run at once. Once the cap is reached, the read
|
||||
loop keeps draining the wire but newly-spawned handlers wait on the
|
||||
semaphore until a slot frees up — so a misbehaving integration can't
|
||||
starve the reader by fanning out unbounded inbound work.
|
||||
"""
|
||||
if transport is None:
|
||||
if reader is None or writer is None:
|
||||
raise TypeError("Channel needs a reader/writer pair or a transport")
|
||||
transport = StreamTransport(reader, writer)
|
||||
self._transport: Transport = transport
|
||||
self._codec: Codec = codec if codec is not None else JsonCodec()
|
||||
self._name = name
|
||||
self._next_id = 1
|
||||
self._pending: dict[int, asyncio.Future[Any]] = {}
|
||||
self._handlers: dict[str, Handler] = {}
|
||||
self._reader_task: asyncio.Task[None] | None = None
|
||||
self._closed: bool = False
|
||||
self._write_lock = asyncio.Lock()
|
||||
self._inflight: set[asyncio.Task[None]] = set()
|
||||
self._inflight_sem = asyncio.Semaphore(max_inflight)
|
||||
|
||||
@classmethod
|
||||
def from_transport(
|
||||
cls,
|
||||
transport: Transport,
|
||||
*,
|
||||
codec: Codec | None = None,
|
||||
name: str = "channel",
|
||||
max_inflight: int = DEFAULT_MAX_INFLIGHT,
|
||||
) -> Channel:
|
||||
"""Build a channel over an arbitrary :class:`Transport`.
|
||||
|
||||
This is the seam a future ``WebSocketTransport`` drops into — the
|
||||
dispatch core is identical regardless of how frames reach the wire.
|
||||
"""
|
||||
return cls(
|
||||
transport=transport, codec=codec, name=name, max_inflight=max_inflight
|
||||
)
|
||||
|
||||
@property
|
||||
def closed(self) -> bool:
|
||||
"""Return True once the channel has been closed."""
|
||||
return self._closed
|
||||
|
||||
def register(self, msg_type: str, handler: Handler) -> None:
|
||||
"""Register an async handler for inbound calls of this type."""
|
||||
self._handlers[msg_type] = handler
|
||||
|
||||
def start(self) -> None:
|
||||
"""Begin reading messages off the wire."""
|
||||
if self._reader_task is not None:
|
||||
return
|
||||
self._reader_task = asyncio.create_task(
|
||||
self._read_loop(), name=f"sandbox[{self._name}]:reader"
|
||||
)
|
||||
|
||||
async def call(
|
||||
self, msg_type: str, payload: Any = None, *, timeout: float | None = None
|
||||
) -> Any:
|
||||
"""Send a request and await its response.
|
||||
|
||||
Raises :class:`ChannelClosedError` if the channel closes while the
|
||||
call is in flight and :class:`ChannelRemoteError` if the remote
|
||||
returns an error response.
|
||||
"""
|
||||
if self._closed:
|
||||
raise ChannelClosedError(f"channel {self._name!r} is closed")
|
||||
call_id = self._next_id
|
||||
self._next_id += 1
|
||||
future: asyncio.Future[Any] = asyncio.get_running_loop().create_future()
|
||||
self._pending[call_id] = future
|
||||
try:
|
||||
await self._write(Frame.call(call_id, msg_type, payload))
|
||||
if timeout is None:
|
||||
return await future
|
||||
return await asyncio.wait_for(future, timeout=timeout)
|
||||
finally:
|
||||
self._pending.pop(call_id, None)
|
||||
|
||||
async def push(self, msg_type: str, payload: Any = None) -> None:
|
||||
"""Send a one-way push message; the remote does not reply."""
|
||||
if self._closed:
|
||||
raise ChannelClosedError(f"channel {self._name!r} is closed")
|
||||
await self._write(Frame.push(msg_type, payload))
|
||||
|
||||
async def close(self) -> None:
|
||||
"""Close the channel and cancel any in-flight calls."""
|
||||
if self._closed:
|
||||
return
|
||||
self._closed = True
|
||||
for future in self._pending.values():
|
||||
if not future.done():
|
||||
future.set_exception(
|
||||
ChannelClosedError(f"channel {self._name!r} is closed")
|
||||
)
|
||||
self._pending.clear()
|
||||
inflight = list(self._inflight)
|
||||
for task in inflight:
|
||||
task.cancel()
|
||||
with contextlib.suppress(Exception):
|
||||
self._transport.close()
|
||||
with contextlib.suppress(asyncio.CancelledError):
|
||||
await self._transport.wait_closed()
|
||||
if self._reader_task is not None:
|
||||
self._reader_task.cancel()
|
||||
with contextlib.suppress(asyncio.CancelledError, Exception):
|
||||
await self._reader_task
|
||||
self._reader_task = None
|
||||
if inflight:
|
||||
await asyncio.gather(*inflight, return_exceptions=True)
|
||||
|
||||
async def _write(self, frame: Frame) -> None:
|
||||
data = self._codec.encode(frame)
|
||||
async with self._write_lock:
|
||||
await self._transport.write_frame(data)
|
||||
|
||||
async def _read_loop(self) -> None:
|
||||
try:
|
||||
while True:
|
||||
try:
|
||||
data = await self._transport.read_frame()
|
||||
except FrameTooLargeError as err:
|
||||
_LOGGER.error("Channel %s: %s; aborting channel", self._name, err)
|
||||
return
|
||||
if data is None:
|
||||
return
|
||||
try:
|
||||
frame = self._codec.decode(data)
|
||||
except Exception: # noqa: BLE001
|
||||
_LOGGER.warning(
|
||||
"Channel %s: dropping undecodable frame (%d bytes)",
|
||||
self._name,
|
||||
len(data),
|
||||
)
|
||||
continue
|
||||
self._dispatch(frame)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception:
|
||||
_LOGGER.exception("Channel %s: read loop crashed", self._name)
|
||||
finally:
|
||||
# Mark closed so any pending calls don't hang forever.
|
||||
if not self._closed:
|
||||
self._closed = True
|
||||
for future in self._pending.values():
|
||||
if not future.done():
|
||||
future.set_exception(
|
||||
ChannelClosedError(f"channel {self._name!r} stream ended")
|
||||
)
|
||||
self._pending.clear()
|
||||
for task in list(self._inflight):
|
||||
task.cancel()
|
||||
|
||||
def _dispatch(self, frame: Frame) -> None:
|
||||
"""Route an inbound frame; non-blocking — handlers run in tasks."""
|
||||
if frame.kind is FrameKind.RESPONSE:
|
||||
# Response to a call we sent out — set the future inline; no I/O.
|
||||
future = self._pending.get(frame.id)
|
||||
if future is None or future.done():
|
||||
return
|
||||
if frame.ok:
|
||||
future.set_result(frame.result)
|
||||
else:
|
||||
future.set_exception(
|
||||
ChannelRemoteError(
|
||||
frame.error or "unknown error",
|
||||
frame.error_type,
|
||||
frame.error_data,
|
||||
)
|
||||
)
|
||||
return
|
||||
|
||||
handler = self._handlers.get(frame.type)
|
||||
|
||||
if frame.kind is FrameKind.PUSH:
|
||||
# One-way push. Dispatch in a task so a slow push handler
|
||||
# cannot block the reader from draining the next message.
|
||||
if handler is not None:
|
||||
self._spawn_handler(
|
||||
self._run_push_handler(frame.type, handler, frame.payload)
|
||||
)
|
||||
return
|
||||
|
||||
if handler is None:
|
||||
# No work to do — write the unknown-type error directly. Still
|
||||
# spawn it so a stalled writer cannot stall the reader.
|
||||
self._spawn_handler(
|
||||
self._write(
|
||||
Frame.error_response(
|
||||
frame.id,
|
||||
f"no handler for {frame.type!r}",
|
||||
"ChannelUnknownType",
|
||||
msg_type=frame.type,
|
||||
)
|
||||
)
|
||||
)
|
||||
return
|
||||
|
||||
self._spawn_handler(
|
||||
self._run_call_handler(frame.id, frame.type, handler, frame.payload)
|
||||
)
|
||||
|
||||
def _spawn_handler(self, coro: Coroutine[Any, Any, Any]) -> None:
|
||||
"""Start a handler task and track it for cancellation on close."""
|
||||
task = asyncio.create_task(coro, name=f"sandbox[{self._name}]:dispatch")
|
||||
self._inflight.add(task)
|
||||
task.add_done_callback(self._inflight.discard)
|
||||
|
||||
async def _run_push_handler(
|
||||
self, msg_type: str, handler: Handler, payload: Any
|
||||
) -> None:
|
||||
"""Run a push handler under the inflight cap; swallow exceptions."""
|
||||
async with self._inflight_sem:
|
||||
try:
|
||||
await handler(payload)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception:
|
||||
_LOGGER.exception(
|
||||
"Channel %s: push handler for %s raised",
|
||||
self._name,
|
||||
msg_type,
|
||||
)
|
||||
|
||||
async def _run_call_handler(
|
||||
self,
|
||||
call_id: int,
|
||||
msg_type: str,
|
||||
handler: Handler,
|
||||
payload: Any,
|
||||
) -> None:
|
||||
"""Run a call handler under the inflight cap and write its reply."""
|
||||
async with self._inflight_sem:
|
||||
try:
|
||||
result = await handler(payload)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as err: # noqa: BLE001
|
||||
if self._closed:
|
||||
return
|
||||
frame = Frame.error_response(
|
||||
call_id,
|
||||
str(err) or err.__class__.__name__,
|
||||
err.__class__.__name__,
|
||||
error_data_for(err),
|
||||
msg_type=msg_type,
|
||||
)
|
||||
with contextlib.suppress(Exception):
|
||||
await self._write(frame)
|
||||
return
|
||||
if self._closed:
|
||||
return
|
||||
with contextlib.suppress(Exception):
|
||||
await self._write(Frame.ok_response(call_id, result, msg_type))
|
||||
|
||||
|
||||
__all__ = [
|
||||
"Channel",
|
||||
"ChannelClosedError",
|
||||
"ChannelRemoteError",
|
||||
"Codec",
|
||||
"Frame",
|
||||
"FrameKind",
|
||||
"FrameTooLargeError",
|
||||
"Handler",
|
||||
"JsonCodec",
|
||||
"StreamTransport",
|
||||
"Transport",
|
||||
"error_data_for",
|
||||
]
|
||||
@@ -0,0 +1,76 @@
|
||||
"""Routing rules: which sandbox should host a given integration?
|
||||
|
||||
`classify(integration)` is a pure function from a loaded `Integration`
|
||||
(manifest + on-disk shape) to a `SandboxAssignment`. It is called by the
|
||||
config-flow router and by config-entry setup interception — every
|
||||
decision about "main vs sandbox" funnels through here.
|
||||
|
||||
Rule order (first match wins):
|
||||
|
||||
1. `integration_type == "system"` → Main. System integrations are part of
|
||||
the HA runtime; sandboxing them is meaningless.
|
||||
2. `domain in ALWAYS_MAIN` → Main. Hand-picked deny-list for integrations
|
||||
the bridge cannot host correctly today (see `const.py` for the why).
|
||||
3. Any platform file in `SANDBOX_INCOMPATIBLE_PLATFORMS` → Main. Platform-
|
||||
level deny-list for shapes the websocket bridge can't ferry yet.
|
||||
4. Custom (non-built-in) integration → `Sandbox("custom")`.
|
||||
5. Otherwise → `Sandbox("built-in")`.
|
||||
|
||||
The check uses `Integration.platforms_exists()` so we never have to import
|
||||
the integration to classify it.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Final
|
||||
|
||||
from homeassistant.const import BASE_PLATFORMS
|
||||
from homeassistant.loader import Integration
|
||||
|
||||
from .const import ALWAYS_MAIN, SANDBOX_INCOMPATIBLE_PLATFORMS
|
||||
|
||||
GROUP_BUILT_IN: Final = "built-in"
|
||||
GROUP_CUSTOM: Final = "custom"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class SandboxAssignment:
|
||||
"""Where an integration should run.
|
||||
|
||||
`group is None` means "stay on main"; otherwise it's the name of the
|
||||
sandbox process that should host the integration.
|
||||
"""
|
||||
|
||||
group: str | None
|
||||
|
||||
@property
|
||||
def is_main(self) -> bool:
|
||||
"""Return True if the integration runs on main."""
|
||||
return self.group is None
|
||||
|
||||
|
||||
MAIN: Final = SandboxAssignment(group=None)
|
||||
|
||||
|
||||
def _sandbox(group: str) -> SandboxAssignment:
|
||||
return SandboxAssignment(group=group)
|
||||
|
||||
|
||||
def classify(integration: Integration) -> SandboxAssignment:
|
||||
"""Return the sandbox assignment for an integration."""
|
||||
if integration.integration_type == "system":
|
||||
return MAIN
|
||||
|
||||
if integration.domain in ALWAYS_MAIN:
|
||||
return MAIN
|
||||
|
||||
incompatible = (
|
||||
set(integration.platforms_exists(BASE_PLATFORMS))
|
||||
& SANDBOX_INCOMPATIBLE_PLATFORMS
|
||||
)
|
||||
if incompatible:
|
||||
return MAIN
|
||||
|
||||
if not integration.is_built_in:
|
||||
return _sandbox(GROUP_CUSTOM)
|
||||
|
||||
return _sandbox(GROUP_BUILT_IN)
|
||||
@@ -0,0 +1,134 @@
|
||||
"""Protobuf :class:`~.channel.Codec` — the production wire.
|
||||
|
||||
Serialises a :class:`~.channel.Frame` to the protobuf ``Frame`` envelope and
|
||||
back. The envelope carries ``type`` on responses too, so this stateless codec
|
||||
can look up the result message class from ``frame.type`` on both encode and
|
||||
decode — the dispatch core never has to know about proto types (the registry
|
||||
lives here, not on :meth:`Channel.register`).
|
||||
|
||||
Mirrored verbatim across the no-cross-import boundary (the same file lives at
|
||||
``hass_client.codec_protobuf``); the relative imports resolve to each side's
|
||||
own :mod:`messages` + ``_proto`` gencode.
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from google.protobuf.message import Message
|
||||
|
||||
from ._proto import sandbox_pb2 as pb
|
||||
from .channel import Frame, FrameKind
|
||||
from .messages import REGISTRY
|
||||
|
||||
Registry = dict[str, tuple[type[Message], type[Message] | None]]
|
||||
|
||||
|
||||
class ProtobufCodec:
|
||||
"""Encode/decode :class:`Frame` objects as protobuf ``Frame`` envelopes."""
|
||||
|
||||
def __init__(self, registry: Registry | None = None) -> None:
|
||||
"""Build the codec over a ``type → (request_cls, result_cls)`` map."""
|
||||
self._registry = registry if registry is not None else REGISTRY
|
||||
|
||||
def _classes(
|
||||
self, msg_type: str
|
||||
) -> tuple[type[Message] | None, type[Message] | None]:
|
||||
return self._registry.get(msg_type, (None, None))
|
||||
|
||||
def encode(self, frame: Frame) -> bytes:
|
||||
"""Serialise a frame to the protobuf ``Frame`` envelope bytes."""
|
||||
envelope = pb.Frame(id=frame.id, type=frame.type)
|
||||
if frame.kind is FrameKind.RESPONSE:
|
||||
response = envelope.response
|
||||
response.ok = frame.ok
|
||||
if frame.ok:
|
||||
_, result_cls = self._classes(frame.type)
|
||||
response.result = _serialize_body(frame.result, result_cls)
|
||||
else:
|
||||
_fill_error(response.error, frame)
|
||||
else:
|
||||
request_cls, _ = self._classes(frame.type)
|
||||
envelope.request = _serialize_body(frame.payload, request_cls)
|
||||
return envelope.SerializeToString()
|
||||
|
||||
def decode(self, data: bytes) -> Frame:
|
||||
"""Rebuild a frame from protobuf ``Frame`` envelope bytes."""
|
||||
envelope = pb.Frame.FromString(data)
|
||||
msg_type = envelope.type
|
||||
body = envelope.WhichOneof("body")
|
||||
if body == "response":
|
||||
response = envelope.response
|
||||
if response.ok:
|
||||
_, result_cls = self._classes(msg_type)
|
||||
result = _parse_body(response.result, result_cls)
|
||||
return Frame.ok_response(envelope.id, result, msg_type)
|
||||
error, error_type, error_data = _read_error(response.error)
|
||||
return Frame.error_response(
|
||||
envelope.id, error, error_type, error_data, msg_type
|
||||
)
|
||||
request_cls, _ = self._classes(msg_type)
|
||||
payload = _parse_body(envelope.request, request_cls)
|
||||
if envelope.id == 0:
|
||||
return Frame.push(msg_type, payload)
|
||||
return Frame.call(envelope.id, msg_type, payload)
|
||||
|
||||
|
||||
def _serialize_body(body: Any, cls: type[Message] | None) -> bytes:
|
||||
"""Serialise a proto-message body; ``None`` becomes an empty message."""
|
||||
if body is None:
|
||||
return cls().SerializeToString() if cls is not None else b""
|
||||
if isinstance(body, Message):
|
||||
return body.SerializeToString()
|
||||
raise TypeError(
|
||||
f"ProtobufCodec expected a proto message body, got {type(body).__name__}"
|
||||
)
|
||||
|
||||
|
||||
def _parse_body(raw: bytes, cls: type[Message] | None) -> Any:
|
||||
"""Deserialise a body into ``cls``; an unregistered type decodes to None."""
|
||||
if cls is None:
|
||||
return None
|
||||
return cls.FromString(raw)
|
||||
|
||||
|
||||
def _fill_error(error: pb.Error, frame: Frame) -> None:
|
||||
"""Populate the proto ``Error`` from a failure frame.
|
||||
|
||||
Carries fidelity #7's structured voluptuous data: the ``multiple`` flag
|
||||
distinguishes a ``MultipleInvalid`` from a single ``Invalid`` so the peer
|
||||
rebuilds the right exception.
|
||||
"""
|
||||
error.message = frame.error or ""
|
||||
error.type = frame.error_type or ""
|
||||
data = frame.error_data
|
||||
if not data:
|
||||
return
|
||||
if data.get("kind") == "multiple":
|
||||
error.multiple = True
|
||||
for child in data.get("errors", []):
|
||||
error.invalid.add(message=child.get("msg", ""), path=child.get("path", []))
|
||||
elif data.get("kind") == "invalid":
|
||||
error.invalid.add(message=data.get("msg", ""), path=data.get("path", []))
|
||||
|
||||
|
||||
def _read_error(error: pb.Error) -> tuple[str, str | None, dict[str, Any] | None]:
|
||||
"""Rebuild ``(message, type, error_data)`` from the proto ``Error``."""
|
||||
error_data: dict[str, Any] | None = None
|
||||
if error.multiple:
|
||||
error_data = {
|
||||
"kind": "multiple",
|
||||
"errors": [
|
||||
{"kind": "invalid", "msg": item.message, "path": list(item.path)}
|
||||
for item in error.invalid
|
||||
],
|
||||
}
|
||||
elif len(error.invalid) == 1:
|
||||
item = error.invalid[0]
|
||||
error_data = {
|
||||
"kind": "invalid",
|
||||
"msg": item.message,
|
||||
"path": list(item.path),
|
||||
}
|
||||
return error.message, (error.type or None), error_data
|
||||
|
||||
|
||||
__all__ = ["ProtobufCodec"]
|
||||
@@ -0,0 +1,116 @@
|
||||
"""Constants for the Sandbox integration."""
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from homeassistant.util.hass_dict import HassKey
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from . import SandboxData
|
||||
|
||||
DOMAIN = "sandbox"
|
||||
|
||||
DATA_SANDBOX: HassKey[SandboxData] = HassKey(DOMAIN)
|
||||
|
||||
# Proxy entities all register under the shared ``sandbox`` platform_name,
|
||||
# so the entity-registry uniqueness key ``(domain, "sandbox", unique_id)``
|
||||
# would collide when two integrations in one group reuse a unique_id. The
|
||||
# proxy unique_id is therefore namespaced as
|
||||
# ``f"{source_domain}{UNIQUE_ID_SEPARATOR}{unique_id}"``. ``:`` is chosen
|
||||
# because HA's default slug logic never produces it, so it cannot clash with
|
||||
# a real unique_id segment.
|
||||
UNIQUE_ID_SEPARATOR = ":"
|
||||
|
||||
# Platforms that the sandbox cannot host today. Any integration that ships a
|
||||
# platform file in this set is forced onto `main`. Each entry needs a one-line
|
||||
# "why" so the deny-list is reviewable.
|
||||
#
|
||||
# TODO(sandbox): revisit each entry once the protocol can carry the missing
|
||||
# payload shape. Tracked in sandbox/plan.md "Risks → Deny-list rot".
|
||||
SANDBOX_INCOMPATIBLE_PLATFORMS: frozenset[str] = frozenset(
|
||||
{
|
||||
# stt: streams audio chunks via async generator; not serializable over WS.
|
||||
"stt",
|
||||
# tts: returns audio bytes + streaming variants the bridge has no path for.
|
||||
"tts",
|
||||
# conversation: agent API exchanges live chat objects and tool callbacks.
|
||||
"conversation",
|
||||
# assist_satellite: bidirectional audio pipeline + wake/voice runtime state.
|
||||
"assist_satellite",
|
||||
# wake_word: streaming detector entities yielding bytes/audio chunks.
|
||||
"wake_word",
|
||||
# camera: entity surface returns image/stream bytes; needs a byte channel.
|
||||
"camera",
|
||||
# To-do lists: the panel reads the sync `todo_items` property (which
|
||||
# also feeds `TodoListEntity.state`), so it can't be satisfied by a
|
||||
# request/response query — it needs the sandbox to push the item list
|
||||
# into a proxy cache. Until that subscription/push primitive lands a
|
||||
# sandboxed list would render empty in the UI while looking supported,
|
||||
# so route it to main. See sandbox/docs/query-shaped-rpcs.md.
|
||||
"todo",
|
||||
}
|
||||
)
|
||||
|
||||
# Integrations that must always run on main, regardless of platform shape.
|
||||
ALWAYS_MAIN: frozenset[str] = frozenset(
|
||||
{
|
||||
"script",
|
||||
"automation",
|
||||
"scene",
|
||||
"cloud",
|
||||
# ai_task's service handler resolves attachments into Attachment
|
||||
# objects with Path values + temp files before the entity method
|
||||
# runs. Neither bridge option intercepts at service-call level yet,
|
||||
# and resolution depends on camera/image bytes (deny-listed). Folded
|
||||
# into ALWAYS_MAIN — revisit when ai_task is made sandbox-aware or
|
||||
# we add service-handler-level interception.
|
||||
"ai_task",
|
||||
# image owns the same bytes-returning entity surface camera does;
|
||||
# the deny-list above catches integrations *providing* an image
|
||||
# platform, but the image integration itself needs to stay on main
|
||||
# so consumers (ai_task, etc.) can fetch bytes locally.
|
||||
"image",
|
||||
# Broad readers — read ALL entities / registries, not narrowly
|
||||
# scopable, so they break under sandbox lockdown. See
|
||||
# sandbox/plans/research/builtin-lockdown-breakage.md (point 1,
|
||||
# decision: blanket ALWAYS_MAIN).
|
||||
# template: Jinja states()/is_state() over any entity at render time.
|
||||
"template",
|
||||
# group: state/attrs derive entirely from foreign member entities.
|
||||
"group",
|
||||
# homekit: hass.states.async_all() + entity/device registries.
|
||||
"homekit",
|
||||
# Source-entity helpers — read a declared set of foreign entities
|
||||
# (and sometimes the registries). ALWAYS_MAIN until the share-states
|
||||
# consumer lands a scoped declared-source-entity allow-list.
|
||||
# min_max: min/max/mean over foreign sensors.
|
||||
"min_max",
|
||||
# statistics: stats buffer over a foreign entity.
|
||||
"statistics",
|
||||
# trend: gradient of a foreign sensor.
|
||||
"trend",
|
||||
# threshold: compares a foreign sensor to bounds.
|
||||
"threshold",
|
||||
# derivative: time-derivative of a foreign sensor.
|
||||
"derivative",
|
||||
# integration: Riemann integral of a foreign sensor.
|
||||
"integration",
|
||||
# utility_meter: tracks a foreign energy sensor.
|
||||
"utility_meter",
|
||||
# filter: filtered passthrough of a foreign sensor.
|
||||
"filter",
|
||||
# mold_indicator: computes from foreign temp + humidity sensors.
|
||||
"mold_indicator",
|
||||
# bayesian: probability from many foreign states.
|
||||
"bayesian",
|
||||
# generic_thermostat: reads a foreign sensor, drives a foreign switch.
|
||||
"generic_thermostat",
|
||||
# generic_hygrostat: same as generic_thermostat for humidity.
|
||||
"generic_hygrostat",
|
||||
# switch_as_x: mirrors a foreign switch; also reads the registry.
|
||||
"switch_as_x",
|
||||
# history_stats: needs foreign state + recorder history.
|
||||
"history_stats",
|
||||
# proximity: distance of foreign trackers to a foreign zone.
|
||||
"proximity",
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,313 @@
|
||||
"""Per-domain proxy entities for sandboxed integrations.
|
||||
|
||||
The :class:`SandboxProxyEntity` base holds the cached state and the
|
||||
``async_call_service`` plumbing every proxy shares. Domain-specific
|
||||
subclasses add typed properties that pull values out of the cache so
|
||||
service-handler kwarg filtering (``light.filter_turn_on_params``,
|
||||
``climate`` schema validation, …) and frontend rendering see the same
|
||||
shape they would for a local entity.
|
||||
|
||||
A small "rich" set of domains ships typed proxies; the remaining
|
||||
domains use the same mechanical pattern.
|
||||
"""
|
||||
|
||||
import contextlib
|
||||
from enum import IntFlag
|
||||
from typing import TYPE_CHECKING, Any, NoReturn, cast
|
||||
|
||||
from homeassistant.const import EntityCategory
|
||||
from homeassistant.core import Context
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.entity import Entity
|
||||
|
||||
from ..messages import struct_to_dict
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..bridge import SandboxBridge, SandboxEntityDescription
|
||||
|
||||
|
||||
def raise_not_proxied(operation: str) -> NoReturn:
|
||||
"""Raise for a query/subscribe entity API the bridge can't proxy yet.
|
||||
|
||||
The entity-method bridge only forwards fire-and-forget service calls. The
|
||||
server-side query, subscription, and WS-only mutation APIs (calendar
|
||||
listings/event edits, weather forecasts, media browsing/search, update
|
||||
release notes, vacuum segments, …) need a request/response RPC that does not
|
||||
exist yet. Until it lands the proxy fails loudly with a clear message
|
||||
instead of silently returning empty results. See
|
||||
``sandbox/docs/query-shaped-rpcs.md``.
|
||||
"""
|
||||
raise HomeAssistantError(f"{operation} is not yet supported for sandboxed entities")
|
||||
|
||||
|
||||
class SandboxProxyEntity(Entity):
|
||||
"""Base class for proxy entities backed by a sandboxed entity."""
|
||||
|
||||
_attr_should_poll = False
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
bridge: SandboxBridge,
|
||||
description: SandboxEntityDescription,
|
||||
) -> None:
|
||||
"""Initialise the proxy entity from its sandbox-side description."""
|
||||
self._bridge = bridge
|
||||
self.description = description
|
||||
self._state_cache: dict[str, Any] = dict(description.initial_attributes)
|
||||
if description.initial_state is not None:
|
||||
self._state_cache["state"] = description.initial_state
|
||||
self._sandbox_available: bool = True
|
||||
|
||||
self._attr_unique_id = description.unique_id
|
||||
self._attr_has_entity_name = description.has_entity_name
|
||||
if description.name:
|
||||
self._attr_name = description.name
|
||||
if description.icon:
|
||||
self._attr_icon = description.icon
|
||||
if description.entity_category:
|
||||
with contextlib.suppress(ValueError):
|
||||
self._attr_entity_category = EntityCategory(description.entity_category)
|
||||
if description.device_class:
|
||||
self._attr_device_class = description.device_class
|
||||
# Domains like ``light`` index supported_features with bitwise
|
||||
# ``in``; ``None`` blows up the check, so default to 0.
|
||||
self._attr_supported_features = int(description.supported_features or 0)
|
||||
# Surface the sandbox-side DeviceInfo so EntityPlatform's normal
|
||||
# async_add_entities path runs dr.async_get_or_create and links
|
||||
# the proxy to the matching DeviceEntry (idempotent with the
|
||||
# pre-creation the bridge does).
|
||||
if description.device_info is not None:
|
||||
self._attr_device_info = cast(DeviceInfo, description.device_info)
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Available iff the sandbox is reachable and the entity has state."""
|
||||
if not self._sandbox_available:
|
||||
return False
|
||||
state = self._state_cache.get("state")
|
||||
return state not in (None, "unavailable")
|
||||
|
||||
@property
|
||||
def extra_state_attributes(self) -> dict[str, Any] | None:
|
||||
"""Sandbox proxies expose attributes through typed properties.
|
||||
|
||||
Anything domain-specific (``brightness``, ``hvac_mode``, …) is
|
||||
surfaced by the domain proxy's own ``@property`` declarations
|
||||
reading from ``_state_cache``. Returning extras here would
|
||||
duplicate those values in the state-machine attributes dict.
|
||||
"""
|
||||
return None
|
||||
|
||||
def sandbox_update_description(self, description: SandboxEntityDescription) -> None:
|
||||
"""Refresh mirrored attributes from a re-sent registration (upsert).
|
||||
|
||||
The unique_id is deliberately left untouched — changing it would
|
||||
orphan the entity-registry row. State flows via the separate
|
||||
``state_changed`` push path, so only the registration-carried
|
||||
fields (name / icon / category / device_class / features /
|
||||
device_info) are refreshed here.
|
||||
"""
|
||||
self.description = description
|
||||
self._attr_has_entity_name = description.has_entity_name
|
||||
self._attr_name = description.name or None
|
||||
self._attr_icon = description.icon or None
|
||||
if description.entity_category:
|
||||
with contextlib.suppress(ValueError):
|
||||
self._attr_entity_category = EntityCategory(description.entity_category)
|
||||
else:
|
||||
self._attr_entity_category = None
|
||||
if description.device_class:
|
||||
self._attr_device_class = description.device_class
|
||||
# Domain subclasses store supported_features as their own IntFlag
|
||||
# (light's capability_attributes does ``X in supported_features``,
|
||||
# which only works on the flag). Preserve that type when refreshing.
|
||||
features = int(description.supported_features or 0)
|
||||
current = self._attr_supported_features
|
||||
if isinstance(current, IntFlag):
|
||||
self._attr_supported_features = type(current)(features)
|
||||
else:
|
||||
self._attr_supported_features = features
|
||||
if description.device_info is not None:
|
||||
self._attr_device_info = cast(DeviceInfo, description.device_info)
|
||||
if self.hass is not None:
|
||||
self.async_write_ha_state()
|
||||
|
||||
def sandbox_apply_state(
|
||||
self,
|
||||
state: str | None,
|
||||
attributes: dict[str, Any],
|
||||
context: Context | None = None,
|
||||
) -> None:
|
||||
"""Update the cache from a sandbox push, and notify HA.
|
||||
|
||||
``context`` is the main-side authoritative Context the bridge resolved
|
||||
from the sandbox's ``context_id`` — the original Context for an id main
|
||||
handed down, or a fresh ``user_id=None`` one otherwise, never carrying
|
||||
a sandbox-supplied parent_id / user_id. When absent the entity writes
|
||||
with its own context as before.
|
||||
"""
|
||||
self._state_cache = dict(attributes)
|
||||
if state is not None:
|
||||
self._state_cache["state"] = state
|
||||
if self.hass is not None:
|
||||
if context is not None:
|
||||
self.async_set_context(context)
|
||||
self.async_write_ha_state()
|
||||
|
||||
def sandbox_set_available(self, available: bool) -> None:
|
||||
"""Toggle availability — used when the sandbox channel drops."""
|
||||
if self._sandbox_available == available:
|
||||
return
|
||||
self._sandbox_available = available
|
||||
if self.hass is not None:
|
||||
self.async_write_ha_state()
|
||||
|
||||
async def _call_service(
|
||||
self, service: str, *, return_response: bool = False, **service_data: Any
|
||||
) -> Any:
|
||||
"""Forward a service call to the sandbox.
|
||||
|
||||
Domain proxies translate each entity method into one of these
|
||||
calls (the spike's Option B); the bridge sends one RPC per call.
|
||||
|
||||
``self._context`` is the main-side Context the service framework set
|
||||
for this call. Passing it lets the bridge remember it, so a state
|
||||
change the sandbox derives from this call resolves back to the
|
||||
original attribution instead of a fresh context.
|
||||
|
||||
When ``return_response`` is set, the call forwards a
|
||||
``SupportsResponse`` service (``calendar.get_events``,
|
||||
``weather.get_forecasts``, ``media_player.browse_media``) and the
|
||||
decoded service-response dict is returned (``{}`` when the sandbox
|
||||
sent no response). Otherwise the raw ``CallServiceResult`` is returned
|
||||
and ignored by command-style proxies.
|
||||
"""
|
||||
result = await self._bridge.async_call_service(
|
||||
domain=self.description.domain,
|
||||
service=service,
|
||||
sandbox_entity_id=self.description.sandbox_entity_id,
|
||||
service_data=service_data,
|
||||
context=self._context,
|
||||
return_response=return_response,
|
||||
)
|
||||
if not return_response:
|
||||
return result
|
||||
if result.HasField("response"):
|
||||
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
|
||||
# the domain platform; the domain platforms can import sandbox
|
||||
# indirectly via helpers).
|
||||
def build_proxy(
|
||||
bridge: SandboxBridge, description: SandboxEntityDescription
|
||||
) -> SandboxProxyEntity:
|
||||
"""Return the domain-specific proxy class for ``description.domain``."""
|
||||
cls = _DOMAIN_PROXIES.get(description.domain, SandboxProxyEntity)
|
||||
return cls(bridge, description)
|
||||
|
||||
|
||||
def _build_registry() -> dict[str, type[SandboxProxyEntity]]:
|
||||
"""Lazy-build the domain → proxy-class map.
|
||||
|
||||
Importing every domain proxy eagerly at module import time would force
|
||||
every domain platform module (``homeassistant.components.light``, …)
|
||||
to load on integration boot. Hand-rolled to avoid the import storm.
|
||||
"""
|
||||
from . import ( # noqa: PLC0415
|
||||
alarm_control_panel,
|
||||
binary_sensor,
|
||||
button,
|
||||
calendar,
|
||||
climate,
|
||||
cover,
|
||||
date,
|
||||
datetime,
|
||||
device_tracker,
|
||||
event,
|
||||
fan,
|
||||
humidifier,
|
||||
lawn_mower,
|
||||
light,
|
||||
lock,
|
||||
media_player,
|
||||
notify,
|
||||
number,
|
||||
remote,
|
||||
scene,
|
||||
select,
|
||||
sensor,
|
||||
siren,
|
||||
switch,
|
||||
text,
|
||||
time,
|
||||
update,
|
||||
vacuum,
|
||||
valve,
|
||||
water_heater,
|
||||
weather,
|
||||
)
|
||||
|
||||
return {
|
||||
"alarm_control_panel": alarm_control_panel.SandboxAlarmControlPanelEntity,
|
||||
"binary_sensor": binary_sensor.SandboxBinarySensorEntity,
|
||||
"button": button.SandboxButtonEntity,
|
||||
"calendar": calendar.SandboxCalendarEntity,
|
||||
"climate": climate.SandboxClimateEntity,
|
||||
"cover": cover.SandboxCoverEntity,
|
||||
"date": date.SandboxDateEntity,
|
||||
"datetime": datetime.SandboxDateTimeEntity,
|
||||
"device_tracker": device_tracker.SandboxDeviceTrackerEntity,
|
||||
"event": event.SandboxEventEntity,
|
||||
"fan": fan.SandboxFanEntity,
|
||||
"humidifier": humidifier.SandboxHumidifierEntity,
|
||||
"lawn_mower": lawn_mower.SandboxLawnMowerEntity,
|
||||
"light": light.SandboxLightEntity,
|
||||
"lock": lock.SandboxLockEntity,
|
||||
"media_player": media_player.SandboxMediaPlayerEntity,
|
||||
"notify": notify.SandboxNotifyEntity,
|
||||
"number": number.SandboxNumberEntity,
|
||||
"remote": remote.SandboxRemoteEntity,
|
||||
"scene": scene.SandboxSceneEntity,
|
||||
"select": select.SandboxSelectEntity,
|
||||
"sensor": sensor.SandboxSensorEntity,
|
||||
"siren": siren.SandboxSirenEntity,
|
||||
"switch": switch.SandboxSwitchEntity,
|
||||
"text": text.SandboxTextEntity,
|
||||
"time": time.SandboxTimeEntity,
|
||||
"update": update.SandboxUpdateEntity,
|
||||
"vacuum": vacuum.SandboxVacuumEntity,
|
||||
"valve": valve.SandboxValveEntity,
|
||||
"water_heater": water_heater.SandboxWaterHeaterEntity,
|
||||
"weather": weather.SandboxWeatherEntity,
|
||||
}
|
||||
|
||||
|
||||
_DOMAIN_PROXIES: dict[str, type[SandboxProxyEntity]] = _build_registry()
|
||||
|
||||
|
||||
__all__ = [
|
||||
"SandboxProxyEntity",
|
||||
"build_proxy",
|
||||
"raise_not_proxied",
|
||||
]
|
||||
@@ -0,0 +1,91 @@
|
||||
"""Sandbox proxy for ``alarm_control_panel`` entities."""
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from homeassistant.components.alarm_control_panel import (
|
||||
AlarmControlPanelEntity,
|
||||
AlarmControlPanelEntityFeature,
|
||||
AlarmControlPanelState,
|
||||
CodeFormat,
|
||||
)
|
||||
|
||||
from . import SandboxProxyEntity
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..bridge import SandboxBridge, SandboxEntityDescription
|
||||
|
||||
|
||||
# pylint: disable-next=home-assistant-enforce-class-module
|
||||
class SandboxAlarmControlPanelEntity(SandboxProxyEntity, AlarmControlPanelEntity):
|
||||
"""Proxy for an ``alarm_control_panel`` entity in a sandbox."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
bridge: SandboxBridge,
|
||||
description: SandboxEntityDescription,
|
||||
) -> None:
|
||||
"""Wrap ``supported_features`` as ``AlarmControlPanelEntityFeature``."""
|
||||
super().__init__(bridge, description)
|
||||
self._attr_supported_features = AlarmControlPanelEntityFeature(
|
||||
description.supported_features or 0
|
||||
)
|
||||
|
||||
@property
|
||||
def alarm_state(self) -> AlarmControlPanelState | None:
|
||||
"""Return the cached alarm state."""
|
||||
value = self._state_cache.get("state")
|
||||
if value is None:
|
||||
return None
|
||||
try:
|
||||
return AlarmControlPanelState(value)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
@property
|
||||
def code_format(self) -> CodeFormat | None:
|
||||
"""Return the configured code format."""
|
||||
value = self.description.capabilities.get("code_format")
|
||||
if value is None:
|
||||
return None
|
||||
try:
|
||||
return CodeFormat(value)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
@property
|
||||
def changed_by(self) -> str | None:
|
||||
"""Return the cached changed_by user."""
|
||||
return self._state_cache.get("changed_by")
|
||||
|
||||
@property
|
||||
def code_arm_required(self) -> bool:
|
||||
"""Mirror the sandbox-side requirement flag."""
|
||||
return bool(self.description.capabilities.get("code_arm_required", True))
|
||||
|
||||
async def async_alarm_disarm(self, code: str | None = None) -> None:
|
||||
"""Forward disarm as ``alarm_control_panel.alarm_disarm``."""
|
||||
await self._call_service("alarm_disarm", code=code)
|
||||
|
||||
async def async_alarm_arm_home(self, code: str | None = None) -> None:
|
||||
"""Forward arm_home as ``alarm_control_panel.alarm_arm_home``."""
|
||||
await self._call_service("alarm_arm_home", code=code)
|
||||
|
||||
async def async_alarm_arm_away(self, code: str | None = None) -> None:
|
||||
"""Forward arm_away as ``alarm_control_panel.alarm_arm_away``."""
|
||||
await self._call_service("alarm_arm_away", code=code)
|
||||
|
||||
async def async_alarm_arm_night(self, code: str | None = None) -> None:
|
||||
"""Forward arm_night as ``alarm_control_panel.alarm_arm_night``."""
|
||||
await self._call_service("alarm_arm_night", code=code)
|
||||
|
||||
async def async_alarm_arm_vacation(self, code: str | None = None) -> None:
|
||||
"""Forward arm_vacation as ``alarm_control_panel.alarm_arm_vacation``."""
|
||||
await self._call_service("alarm_arm_vacation", code=code)
|
||||
|
||||
async def async_alarm_trigger(self, code: str | None = None) -> None:
|
||||
"""Forward trigger as ``alarm_control_panel.alarm_trigger``."""
|
||||
await self._call_service("alarm_trigger", code=code)
|
||||
|
||||
async def async_alarm_arm_custom_bypass(self, code: str | None = None) -> None:
|
||||
"""Forward arm_custom_bypass."""
|
||||
await self._call_service("alarm_arm_custom_bypass", code=code)
|
||||
@@ -0,0 +1,19 @@
|
||||
"""Sandbox proxy for ``binary_sensor`` entities."""
|
||||
|
||||
from homeassistant.components.binary_sensor import BinarySensorEntity
|
||||
from homeassistant.const import STATE_ON
|
||||
|
||||
from . import SandboxProxyEntity
|
||||
|
||||
|
||||
# pylint: disable-next=home-assistant-enforce-class-module
|
||||
class SandboxBinarySensorEntity(SandboxProxyEntity, BinarySensorEntity):
|
||||
"""Proxy for a ``binary_sensor`` entity in a sandbox."""
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool | None:
|
||||
"""Return whether the cached state is ``on``."""
|
||||
state = self._state_cache.get("state")
|
||||
if state is None:
|
||||
return None
|
||||
return state == STATE_ON
|
||||
@@ -0,0 +1,35 @@
|
||||
"""Sandbox proxy for ``button`` entities."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.components.button import ButtonEntity
|
||||
from homeassistant.core import Context
|
||||
|
||||
from . import SandboxProxyEntity
|
||||
|
||||
|
||||
# pylint: disable-next=home-assistant-enforce-class-module
|
||||
class SandboxButtonEntity(SandboxProxyEntity, ButtonEntity):
|
||||
"""Proxy for a ``button`` entity in a sandbox."""
|
||||
|
||||
def sandbox_apply_state(
|
||||
self,
|
||||
state: str | None,
|
||||
attributes: dict[str, Any],
|
||||
context: Context | None = None,
|
||||
) -> None:
|
||||
"""Forward sandbox state into ButtonEntity's last-pressed field.
|
||||
|
||||
``ButtonEntity.state`` is ``@final`` and reads the name-mangled
|
||||
``__last_pressed_isoformat`` attribute. Setting the cache alone
|
||||
wouldn't surface as the state on main, so we update the private
|
||||
field directly before the framework recomputes state.
|
||||
"""
|
||||
if state is not None:
|
||||
# pylint: disable-next=attribute-defined-outside-init
|
||||
self._ButtonEntity__last_pressed_isoformat = state
|
||||
super().sandbox_apply_state(state, attributes, context)
|
||||
|
||||
async def async_press(self) -> None:
|
||||
"""Forward press as a ``button.press`` service call."""
|
||||
await self._call_service("press")
|
||||
@@ -0,0 +1,116 @@
|
||||
"""Sandbox proxy for ``calendar`` entities."""
|
||||
|
||||
import datetime
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.components.calendar import CalendarEntity, CalendarEvent
|
||||
|
||||
from . import SandboxProxyEntity
|
||||
|
||||
|
||||
def _parse_calendar_date(value: Any) -> datetime.date | datetime.datetime | Any:
|
||||
"""Parse an ISO date/datetime string back into a date or datetime.
|
||||
|
||||
The ``calendar.get_events`` service serialises every event date through
|
||||
``CalendarEvent.as_dict``'s factory, which emits ``isoformat()`` strings.
|
||||
All-day events carry a bare ``YYYY-MM-DD`` (a ``date``); timed events carry
|
||||
a full timestamp (a ``datetime``). ``CalendarEvent`` keys its all-day check
|
||||
off the start being a plain ``date``, so the two must rebuild distinctly.
|
||||
"""
|
||||
if isinstance(value, str):
|
||||
if "T" in value:
|
||||
return datetime.datetime.fromisoformat(value)
|
||||
return datetime.date.fromisoformat(value)
|
||||
return value
|
||||
|
||||
|
||||
def _calendar_event_from_dict(data: dict[str, Any]) -> CalendarEvent:
|
||||
"""Rebuild a :class:`CalendarEvent` from a ``get_events`` response entry.
|
||||
|
||||
``CalendarEvent`` is a dataclass whose ``as_dict`` shape uses the field
|
||||
names directly, so fields map across explicitly (no ``**data`` splat — the
|
||||
response also carries the derived ``all_day`` key the constructor rejects).
|
||||
``get_events`` only returns start/end/summary/description/location; the
|
||||
uid/recurrence_id/rrule keys are read defensively in case a richer payload
|
||||
arrives.
|
||||
"""
|
||||
return CalendarEvent(
|
||||
start=_parse_calendar_date(data["start"]),
|
||||
end=_parse_calendar_date(data["end"]),
|
||||
summary=data["summary"],
|
||||
description=data.get("description"),
|
||||
location=data.get("location"),
|
||||
uid=data.get("uid"),
|
||||
recurrence_id=data.get("recurrence_id"),
|
||||
rrule=data.get("rrule"),
|
||||
)
|
||||
|
||||
|
||||
# pylint: disable-next=home-assistant-enforce-class-module
|
||||
class SandboxCalendarEntity(SandboxProxyEntity, CalendarEntity):
|
||||
"""Proxy for a ``calendar`` entity in a sandbox.
|
||||
|
||||
``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``) 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``.
|
||||
"""
|
||||
|
||||
@property
|
||||
def event(self) -> CalendarEvent | None:
|
||||
"""Return ``None`` — the next-event listing is not proxied yet."""
|
||||
return None
|
||||
|
||||
async def async_get_events(
|
||||
self, hass: Any, start_date: Any, end_date: Any
|
||||
) -> list[CalendarEvent]:
|
||||
"""Forward the listing query as the ``calendar.get_events`` service."""
|
||||
response = await self._call_service(
|
||||
"get_events",
|
||||
return_response=True,
|
||||
start_date_time=start_date.isoformat(),
|
||||
end_date_time=end_date.isoformat(),
|
||||
)
|
||||
entity_response = response.get(self.description.sandbox_entity_id, {})
|
||||
return [
|
||||
_calendar_event_from_dict(event)
|
||||
for event in entity_response.get("events", [])
|
||||
]
|
||||
|
||||
async def async_create_event(self, **kwargs: Any) -> None:
|
||||
"""Forward create as ``calendar.create_event``."""
|
||||
await self._call_service("create_event", **kwargs)
|
||||
|
||||
async def async_update_event(
|
||||
self,
|
||||
uid: str,
|
||||
event: dict[str, Any],
|
||||
recurrence_id: str | None = None,
|
||||
recurrence_range: str | None = None,
|
||||
) -> None:
|
||||
"""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,
|
||||
uid: str,
|
||||
recurrence_id: str | None = None,
|
||||
recurrence_range: str | None = None,
|
||||
) -> None:
|
||||
"""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,
|
||||
)
|
||||
@@ -0,0 +1,239 @@
|
||||
"""Sandbox proxy for ``climate`` entities."""
|
||||
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from homeassistant.components.climate import (
|
||||
ATTR_CURRENT_HUMIDITY,
|
||||
ATTR_CURRENT_TEMPERATURE,
|
||||
ATTR_FAN_MODE,
|
||||
ATTR_FAN_MODES,
|
||||
ATTR_HUMIDITY,
|
||||
ATTR_HVAC_ACTION,
|
||||
ATTR_HVAC_MODES,
|
||||
ATTR_MAX_HUMIDITY,
|
||||
ATTR_MAX_TEMP,
|
||||
ATTR_MIN_HUMIDITY,
|
||||
ATTR_MIN_TEMP,
|
||||
ATTR_PRESET_MODE,
|
||||
ATTR_PRESET_MODES,
|
||||
ATTR_SWING_HORIZONTAL_MODE,
|
||||
ATTR_SWING_HORIZONTAL_MODES,
|
||||
ATTR_SWING_MODE,
|
||||
ATTR_SWING_MODES,
|
||||
ATTR_TARGET_TEMP_HIGH,
|
||||
ATTR_TARGET_TEMP_LOW,
|
||||
ATTR_TARGET_TEMP_STEP,
|
||||
ATTR_TEMPERATURE,
|
||||
ClimateEntity,
|
||||
ClimateEntityFeature,
|
||||
HVACAction,
|
||||
HVACMode,
|
||||
)
|
||||
|
||||
from . import SandboxProxyEntity
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..bridge import SandboxBridge, SandboxEntityDescription
|
||||
|
||||
|
||||
# pylint: disable-next=home-assistant-enforce-class-module
|
||||
class SandboxClimateEntity(SandboxProxyEntity, ClimateEntity):
|
||||
"""Proxy for a ``climate`` entity in a sandbox."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
bridge: SandboxBridge,
|
||||
description: SandboxEntityDescription,
|
||||
) -> None:
|
||||
"""Wrap ``supported_features`` as ``ClimateEntityFeature``."""
|
||||
super().__init__(bridge, description)
|
||||
self._attr_supported_features = ClimateEntityFeature(
|
||||
description.supported_features or 0
|
||||
)
|
||||
|
||||
@property
|
||||
def temperature_unit(self) -> str:
|
||||
"""Return the unit declared by the sandbox-side entity."""
|
||||
from homeassistant.const import UnitOfTemperature # noqa: PLC0415
|
||||
|
||||
return str(
|
||||
self.description.capabilities.get(
|
||||
"temperature_unit", UnitOfTemperature.CELSIUS
|
||||
)
|
||||
)
|
||||
|
||||
@property
|
||||
def hvac_mode(self) -> HVACMode | None:
|
||||
"""Return the cached HVAC mode."""
|
||||
value = self._state_cache.get("state")
|
||||
if value is None or value == "unavailable":
|
||||
return None
|
||||
try:
|
||||
return HVACMode(value)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
@property
|
||||
def hvac_modes(self) -> list[HVACMode]:
|
||||
"""Return advertised HVAC modes."""
|
||||
modes = self.description.capabilities.get(ATTR_HVAC_MODES) or []
|
||||
return [HVACMode(m) for m in modes if m in HVACMode._value2member_map_]
|
||||
|
||||
@property
|
||||
def hvac_action(self) -> HVACAction | None:
|
||||
"""Return the cached current HVAC action."""
|
||||
value = self._state_cache.get(ATTR_HVAC_ACTION)
|
||||
if value is None:
|
||||
return None
|
||||
try:
|
||||
return HVACAction(value)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
@property
|
||||
def current_temperature(self) -> float | None:
|
||||
"""Return the cached current temperature."""
|
||||
value = self._state_cache.get(ATTR_CURRENT_TEMPERATURE)
|
||||
return None if value is None else float(value)
|
||||
|
||||
@property
|
||||
def target_temperature(self) -> float | None:
|
||||
"""Return the cached target temperature."""
|
||||
value = self._state_cache.get(ATTR_TEMPERATURE)
|
||||
return None if value is None else float(value)
|
||||
|
||||
@property
|
||||
def target_temperature_high(self) -> float | None:
|
||||
"""Return the cached high target temperature."""
|
||||
value = self._state_cache.get(ATTR_TARGET_TEMP_HIGH)
|
||||
return None if value is None else float(value)
|
||||
|
||||
@property
|
||||
def target_temperature_low(self) -> float | None:
|
||||
"""Return the cached low target temperature."""
|
||||
value = self._state_cache.get(ATTR_TARGET_TEMP_LOW)
|
||||
return None if value is None else float(value)
|
||||
|
||||
@property
|
||||
def target_temperature_step(self) -> float | None:
|
||||
"""Return the cached target temperature step."""
|
||||
value = self._state_cache.get(ATTR_TARGET_TEMP_STEP)
|
||||
return None if value is None else float(value)
|
||||
|
||||
@property
|
||||
def current_humidity(self) -> float | None:
|
||||
"""Return the cached current humidity."""
|
||||
value = self._state_cache.get(ATTR_CURRENT_HUMIDITY)
|
||||
return None if value is None else float(value)
|
||||
|
||||
@property
|
||||
def target_humidity(self) -> float | None:
|
||||
"""Return the cached target humidity."""
|
||||
value = self._state_cache.get(ATTR_HUMIDITY)
|
||||
return None if value is None else float(value)
|
||||
|
||||
@property
|
||||
def fan_mode(self) -> str | None:
|
||||
"""Return the cached fan mode."""
|
||||
return self._state_cache.get(ATTR_FAN_MODE)
|
||||
|
||||
@property
|
||||
def fan_modes(self) -> list[str] | None:
|
||||
"""Return advertised fan modes."""
|
||||
return self.description.capabilities.get(ATTR_FAN_MODES)
|
||||
|
||||
@property
|
||||
def swing_mode(self) -> str | None:
|
||||
"""Return the cached swing mode."""
|
||||
return self._state_cache.get(ATTR_SWING_MODE)
|
||||
|
||||
@property
|
||||
def swing_modes(self) -> list[str] | None:
|
||||
"""Return advertised swing modes."""
|
||||
return self.description.capabilities.get(ATTR_SWING_MODES)
|
||||
|
||||
@property
|
||||
def swing_horizontal_mode(self) -> str | None:
|
||||
"""Return the cached horizontal swing mode."""
|
||||
return self._state_cache.get(ATTR_SWING_HORIZONTAL_MODE)
|
||||
|
||||
@property
|
||||
def swing_horizontal_modes(self) -> list[str] | None:
|
||||
"""Return advertised horizontal swing modes."""
|
||||
return self.description.capabilities.get(ATTR_SWING_HORIZONTAL_MODES)
|
||||
|
||||
@property
|
||||
def preset_mode(self) -> str | None:
|
||||
"""Return the cached preset mode."""
|
||||
return self._state_cache.get(ATTR_PRESET_MODE)
|
||||
|
||||
@property
|
||||
def preset_modes(self) -> list[str] | None:
|
||||
"""Return advertised preset modes."""
|
||||
return self.description.capabilities.get(ATTR_PRESET_MODES)
|
||||
|
||||
@property
|
||||
def min_temp(self) -> float:
|
||||
"""Return the cached minimum temperature."""
|
||||
value = self.description.capabilities.get(ATTR_MIN_TEMP)
|
||||
return float(value) if value is not None else super().min_temp
|
||||
|
||||
@property
|
||||
def max_temp(self) -> float:
|
||||
"""Return the cached maximum temperature."""
|
||||
value = self.description.capabilities.get(ATTR_MAX_TEMP)
|
||||
return float(value) if value is not None else super().max_temp
|
||||
|
||||
@property
|
||||
def min_humidity(self) -> float:
|
||||
"""Return the cached minimum humidity."""
|
||||
value = self.description.capabilities.get(ATTR_MIN_HUMIDITY)
|
||||
return float(value) if value is not None else super().min_humidity
|
||||
|
||||
@property
|
||||
def max_humidity(self) -> float:
|
||||
"""Return the cached maximum humidity."""
|
||||
value = self.description.capabilities.get(ATTR_MAX_HUMIDITY)
|
||||
return float(value) if value is not None else super().max_humidity
|
||||
|
||||
async def async_set_temperature(self, **kwargs: Any) -> None:
|
||||
"""Forward set_temperature."""
|
||||
await self._call_service("set_temperature", **kwargs)
|
||||
|
||||
async def async_set_humidity(self, humidity: int) -> None:
|
||||
"""Forward set_humidity."""
|
||||
await self._call_service("set_humidity", humidity=humidity)
|
||||
|
||||
async def async_set_fan_mode(self, fan_mode: str) -> None:
|
||||
"""Forward set_fan_mode."""
|
||||
await self._call_service("set_fan_mode", fan_mode=fan_mode)
|
||||
|
||||
async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
|
||||
"""Forward set_hvac_mode."""
|
||||
await self._call_service("set_hvac_mode", hvac_mode=hvac_mode)
|
||||
|
||||
async def async_set_swing_mode(self, swing_mode: str) -> None:
|
||||
"""Forward set_swing_mode."""
|
||||
await self._call_service("set_swing_mode", swing_mode=swing_mode)
|
||||
|
||||
async def async_set_swing_horizontal_mode(self, swing_horizontal_mode: str) -> None:
|
||||
"""Forward set_swing_horizontal_mode."""
|
||||
await self._call_service(
|
||||
"set_swing_horizontal_mode", swing_horizontal_mode=swing_horizontal_mode
|
||||
)
|
||||
|
||||
async def async_set_preset_mode(self, preset_mode: str) -> None:
|
||||
"""Forward set_preset_mode."""
|
||||
await self._call_service("set_preset_mode", preset_mode=preset_mode)
|
||||
|
||||
async def async_turn_on(self) -> None:
|
||||
"""Forward turn_on."""
|
||||
await self._call_service("turn_on")
|
||||
|
||||
async def async_turn_off(self) -> None:
|
||||
"""Forward turn_off."""
|
||||
await self._call_service("turn_off")
|
||||
|
||||
async def async_toggle(self) -> None:
|
||||
"""Forward toggle."""
|
||||
await self._call_service("toggle")
|
||||
@@ -0,0 +1,99 @@
|
||||
"""Sandbox proxy for ``cover`` entities."""
|
||||
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from homeassistant.components.cover import (
|
||||
ATTR_CURRENT_POSITION,
|
||||
ATTR_CURRENT_TILT_POSITION,
|
||||
ATTR_IS_CLOSED,
|
||||
CoverEntity,
|
||||
CoverEntityFeature,
|
||||
CoverState,
|
||||
)
|
||||
|
||||
from . import SandboxProxyEntity
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..bridge import SandboxBridge, SandboxEntityDescription
|
||||
|
||||
|
||||
# pylint: disable-next=home-assistant-enforce-class-module
|
||||
class SandboxCoverEntity(SandboxProxyEntity, CoverEntity):
|
||||
"""Proxy for a ``cover`` entity in a sandbox."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
bridge: SandboxBridge,
|
||||
description: SandboxEntityDescription,
|
||||
) -> None:
|
||||
"""Wrap ``supported_features`` as ``CoverEntityFeature``."""
|
||||
super().__init__(bridge, description)
|
||||
self._attr_supported_features = CoverEntityFeature(
|
||||
description.supported_features or 0
|
||||
)
|
||||
|
||||
@property
|
||||
def is_opening(self) -> bool | None:
|
||||
"""True iff the cached state is ``opening``."""
|
||||
return self._state_cache.get("state") == CoverState.OPENING
|
||||
|
||||
@property
|
||||
def is_closing(self) -> bool | None:
|
||||
"""True iff the cached state is ``closing``."""
|
||||
return self._state_cache.get("state") == CoverState.CLOSING
|
||||
|
||||
@property
|
||||
def is_closed(self) -> bool | None:
|
||||
"""Derive closed from cached state / ATTR_IS_CLOSED."""
|
||||
if (value := self._state_cache.get(ATTR_IS_CLOSED)) is not None:
|
||||
return bool(value)
|
||||
state = self._state_cache.get("state")
|
||||
if state == CoverState.CLOSED:
|
||||
return True
|
||||
if state in (CoverState.OPEN, CoverState.OPENING, CoverState.CLOSING):
|
||||
return False
|
||||
return None
|
||||
|
||||
@property
|
||||
def current_cover_position(self) -> int | None:
|
||||
"""Return the cached current position."""
|
||||
value = self._state_cache.get(ATTR_CURRENT_POSITION)
|
||||
return None if value is None else int(value)
|
||||
|
||||
@property
|
||||
def current_cover_tilt_position(self) -> int | None:
|
||||
"""Return the cached current tilt position."""
|
||||
value = self._state_cache.get(ATTR_CURRENT_TILT_POSITION)
|
||||
return None if value is None else int(value)
|
||||
|
||||
async def async_open_cover(self, **kwargs: Any) -> None:
|
||||
"""Forward open_cover."""
|
||||
await self._call_service("open_cover", **kwargs)
|
||||
|
||||
async def async_close_cover(self, **kwargs: Any) -> None:
|
||||
"""Forward close_cover."""
|
||||
await self._call_service("close_cover", **kwargs)
|
||||
|
||||
async def async_set_cover_position(self, **kwargs: Any) -> None:
|
||||
"""Forward set_cover_position."""
|
||||
await self._call_service("set_cover_position", **kwargs)
|
||||
|
||||
async def async_stop_cover(self, **kwargs: Any) -> None:
|
||||
"""Forward stop_cover."""
|
||||
await self._call_service("stop_cover", **kwargs)
|
||||
|
||||
async def async_open_cover_tilt(self, **kwargs: Any) -> None:
|
||||
"""Forward open_cover_tilt."""
|
||||
await self._call_service("open_cover_tilt", **kwargs)
|
||||
|
||||
async def async_close_cover_tilt(self, **kwargs: Any) -> None:
|
||||
"""Forward close_cover_tilt."""
|
||||
await self._call_service("close_cover_tilt", **kwargs)
|
||||
|
||||
async def async_set_cover_tilt_position(self, **kwargs: Any) -> None:
|
||||
"""Forward set_cover_tilt_position."""
|
||||
await self._call_service("set_cover_tilt_position", **kwargs)
|
||||
|
||||
async def async_stop_cover_tilt(self, **kwargs: Any) -> None:
|
||||
"""Forward stop_cover_tilt."""
|
||||
await self._call_service("stop_cover_tilt", **kwargs)
|
||||
@@ -0,0 +1,28 @@
|
||||
"""Sandbox proxy for ``date`` entities."""
|
||||
|
||||
from datetime import date
|
||||
|
||||
from homeassistant.components.date import DateEntity
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from . import SandboxProxyEntity
|
||||
|
||||
|
||||
# pylint: disable-next=home-assistant-enforce-class-module
|
||||
class SandboxDateEntity(SandboxProxyEntity, DateEntity):
|
||||
"""Proxy for a ``date`` entity in a sandbox."""
|
||||
|
||||
@property
|
||||
def native_value(self) -> date | None:
|
||||
"""Parse the cached ISO date string."""
|
||||
value = self._state_cache.get("state")
|
||||
if not isinstance(value, str) or value in ("unavailable", "unknown"):
|
||||
return None
|
||||
try:
|
||||
return dt_util.parse_date(value)
|
||||
except TypeError, ValueError:
|
||||
return None
|
||||
|
||||
async def async_set_value(self, value: date) -> None:
|
||||
"""Forward set_value as ``date.set_value``."""
|
||||
await self._call_service("set_value", date=value.isoformat())
|
||||
@@ -0,0 +1,28 @@
|
||||
"""Sandbox proxy for ``datetime`` entities."""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from homeassistant.components.datetime import DateTimeEntity
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from . import SandboxProxyEntity
|
||||
|
||||
|
||||
# pylint: disable-next=home-assistant-enforce-class-module
|
||||
class SandboxDateTimeEntity(SandboxProxyEntity, DateTimeEntity):
|
||||
"""Proxy for a ``datetime`` entity in a sandbox."""
|
||||
|
||||
@property
|
||||
def native_value(self) -> datetime | None:
|
||||
"""Parse the cached ISO datetime string."""
|
||||
value = self._state_cache.get("state")
|
||||
if not isinstance(value, str) or value in ("unavailable", "unknown"):
|
||||
return None
|
||||
try:
|
||||
return dt_util.parse_datetime(value)
|
||||
except TypeError, ValueError:
|
||||
return None
|
||||
|
||||
async def async_set_value(self, value: datetime) -> None:
|
||||
"""Forward set_value as ``datetime.set_value``."""
|
||||
await self._call_service("set_value", datetime=value.isoformat())
|
||||
@@ -0,0 +1,38 @@
|
||||
"""Sandbox proxy for ``device_tracker`` entities."""
|
||||
|
||||
from homeassistant.components.device_tracker import (
|
||||
ATTR_SOURCE_TYPE,
|
||||
BaseTrackerEntity,
|
||||
SourceType,
|
||||
)
|
||||
|
||||
from . import SandboxProxyEntity
|
||||
|
||||
|
||||
# pylint: disable-next=home-assistant-enforce-class-module
|
||||
class SandboxDeviceTrackerEntity(SandboxProxyEntity, BaseTrackerEntity):
|
||||
"""Proxy for a ``device_tracker`` entity in a sandbox.
|
||||
|
||||
Subclasses the abstract :class:`BaseTrackerEntity` so we can override
|
||||
both ``state`` and ``state_attributes`` (the GPS-specific
|
||||
:class:`TrackerEntity` marks ``state_attributes`` ``@final``).
|
||||
"""
|
||||
|
||||
@property
|
||||
def state(self) -> str | None:
|
||||
"""Mirror the sandbox-side state directly."""
|
||||
return self._state_cache.get("state")
|
||||
|
||||
@property
|
||||
def source_type(self) -> SourceType:
|
||||
"""Return the cached source_type (gps / router / bluetooth / …)."""
|
||||
value = self._state_cache.get(
|
||||
ATTR_SOURCE_TYPE,
|
||||
self.description.capabilities.get(ATTR_SOURCE_TYPE),
|
||||
)
|
||||
if value is None:
|
||||
return SourceType.ROUTER
|
||||
try:
|
||||
return SourceType(value)
|
||||
except ValueError:
|
||||
return SourceType.ROUTER
|
||||
@@ -0,0 +1,44 @@
|
||||
"""Sandbox proxy for ``event`` entities."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.components.event import ATTR_EVENT_TYPE, EventEntity
|
||||
from homeassistant.core import Context
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from . import SandboxProxyEntity
|
||||
|
||||
|
||||
# pylint: disable-next=home-assistant-enforce-class-module
|
||||
class SandboxEventEntity(SandboxProxyEntity, EventEntity):
|
||||
"""Proxy for an ``event`` entity in a sandbox.
|
||||
|
||||
``EventEntity`` marks ``state`` and ``state_attributes`` ``@final``,
|
||||
so we set the name-mangled fields directly in
|
||||
:meth:`sandbox_apply_state` and let the framework recompute the
|
||||
state through the existing getters.
|
||||
"""
|
||||
|
||||
@property
|
||||
def event_types(self) -> list[str]:
|
||||
"""Surface the cached list of event types."""
|
||||
return list(self.description.capabilities.get("event_types") or [])
|
||||
|
||||
def sandbox_apply_state(
|
||||
self,
|
||||
state: str | None,
|
||||
attributes: dict[str, Any],
|
||||
context: Context | None = None,
|
||||
) -> None:
|
||||
"""Replay the sandbox-side event into the EventEntity fields."""
|
||||
# pylint: disable=attribute-defined-outside-init
|
||||
if state is None or state in ("unavailable", "unknown"):
|
||||
self._EventEntity__last_event_triggered = None
|
||||
self._EventEntity__last_event_type = None
|
||||
self._EventEntity__last_event_attributes = None
|
||||
else:
|
||||
self._EventEntity__last_event_triggered = dt_util.parse_datetime(state)
|
||||
event_attrs = dict(attributes)
|
||||
self._EventEntity__last_event_type = event_attrs.pop(ATTR_EVENT_TYPE, None)
|
||||
self._EventEntity__last_event_attributes = event_attrs or None
|
||||
super().sandbox_apply_state(state, attributes, context)
|
||||
@@ -0,0 +1,105 @@
|
||||
"""Sandbox proxy for ``fan`` entities."""
|
||||
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from homeassistant.components.fan import (
|
||||
ATTR_DIRECTION,
|
||||
ATTR_OSCILLATING,
|
||||
ATTR_PERCENTAGE,
|
||||
ATTR_PRESET_MODE,
|
||||
ATTR_PRESET_MODES,
|
||||
FanEntity,
|
||||
FanEntityFeature,
|
||||
)
|
||||
from homeassistant.const import STATE_ON
|
||||
|
||||
from . import SandboxProxyEntity
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..bridge import SandboxBridge, SandboxEntityDescription
|
||||
|
||||
|
||||
# pylint: disable-next=home-assistant-enforce-class-module
|
||||
class SandboxFanEntity(SandboxProxyEntity, FanEntity):
|
||||
"""Proxy for a ``fan`` entity in a sandbox."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
bridge: SandboxBridge,
|
||||
description: SandboxEntityDescription,
|
||||
) -> None:
|
||||
"""Wrap ``supported_features`` as ``FanEntityFeature``."""
|
||||
super().__init__(bridge, description)
|
||||
self._attr_supported_features = FanEntityFeature(
|
||||
description.supported_features or 0
|
||||
)
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool | None:
|
||||
"""Return whether the cached state is ``on``."""
|
||||
state = self._state_cache.get("state")
|
||||
if state is None:
|
||||
return None
|
||||
return state == STATE_ON
|
||||
|
||||
@property
|
||||
def percentage(self) -> int | None:
|
||||
"""Return the cached fan percentage."""
|
||||
value = self._state_cache.get(ATTR_PERCENTAGE)
|
||||
return None if value is None else int(value)
|
||||
|
||||
@property
|
||||
def current_direction(self) -> str | None:
|
||||
"""Return the cached direction."""
|
||||
return self._state_cache.get(ATTR_DIRECTION)
|
||||
|
||||
@property
|
||||
def oscillating(self) -> bool | None:
|
||||
"""Return the cached oscillation state."""
|
||||
value = self._state_cache.get(ATTR_OSCILLATING)
|
||||
return None if value is None else bool(value)
|
||||
|
||||
@property
|
||||
def preset_mode(self) -> str | None:
|
||||
"""Return the cached preset mode."""
|
||||
return self._state_cache.get(ATTR_PRESET_MODE)
|
||||
|
||||
@property
|
||||
def preset_modes(self) -> list[str] | None:
|
||||
"""Return the configured preset modes."""
|
||||
modes = self.description.capabilities.get(ATTR_PRESET_MODES)
|
||||
return list(modes) if modes else None
|
||||
|
||||
async def async_turn_on(
|
||||
self,
|
||||
percentage: int | None = None,
|
||||
preset_mode: str | None = None,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
"""Forward turn_on."""
|
||||
payload: dict[str, Any] = dict(kwargs)
|
||||
if percentage is not None:
|
||||
payload[ATTR_PERCENTAGE] = percentage
|
||||
if preset_mode is not None:
|
||||
payload[ATTR_PRESET_MODE] = preset_mode
|
||||
await self._call_service("turn_on", **payload)
|
||||
|
||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||
"""Forward turn_off."""
|
||||
await self._call_service("turn_off", **kwargs)
|
||||
|
||||
async def async_set_percentage(self, percentage: int) -> None:
|
||||
"""Forward set_percentage."""
|
||||
await self._call_service("set_percentage", percentage=percentage)
|
||||
|
||||
async def async_set_preset_mode(self, preset_mode: str) -> None:
|
||||
"""Forward set_preset_mode."""
|
||||
await self._call_service("set_preset_mode", preset_mode=preset_mode)
|
||||
|
||||
async def async_set_direction(self, direction: str) -> None:
|
||||
"""Forward set_direction."""
|
||||
await self._call_service("set_direction", direction=direction)
|
||||
|
||||
async def async_oscillate(self, oscillating: bool) -> None:
|
||||
"""Forward oscillate."""
|
||||
await self._call_service("oscillate", oscillating=oscillating)
|
||||
@@ -0,0 +1,108 @@
|
||||
"""Sandbox proxy for ``humidifier`` entities."""
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from homeassistant.components.humidifier import (
|
||||
ATTR_ACTION,
|
||||
ATTR_AVAILABLE_MODES,
|
||||
ATTR_CURRENT_HUMIDITY,
|
||||
ATTR_HUMIDITY,
|
||||
ATTR_MAX_HUMIDITY,
|
||||
ATTR_MIN_HUMIDITY,
|
||||
ATTR_MODE,
|
||||
HumidifierAction,
|
||||
HumidifierEntity,
|
||||
HumidifierEntityFeature,
|
||||
)
|
||||
from homeassistant.const import STATE_ON
|
||||
|
||||
from . import SandboxProxyEntity
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..bridge import SandboxBridge, SandboxEntityDescription
|
||||
|
||||
|
||||
# pylint: disable-next=home-assistant-enforce-class-module
|
||||
class SandboxHumidifierEntity(SandboxProxyEntity, HumidifierEntity):
|
||||
"""Proxy for a ``humidifier`` entity in a sandbox."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
bridge: SandboxBridge,
|
||||
description: SandboxEntityDescription,
|
||||
) -> None:
|
||||
"""Wrap ``supported_features`` as ``HumidifierEntityFeature``."""
|
||||
super().__init__(bridge, description)
|
||||
self._attr_supported_features = HumidifierEntityFeature(
|
||||
description.supported_features or 0
|
||||
)
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool | None:
|
||||
"""Return whether the cached state is ``on``."""
|
||||
state = self._state_cache.get("state")
|
||||
if state is None:
|
||||
return None
|
||||
return state == STATE_ON
|
||||
|
||||
@property
|
||||
def action(self) -> HumidifierAction | None:
|
||||
"""Return the cached current action."""
|
||||
value = self._state_cache.get(ATTR_ACTION)
|
||||
if value is None:
|
||||
return None
|
||||
try:
|
||||
return HumidifierAction(value)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
@property
|
||||
def current_humidity(self) -> float | None:
|
||||
"""Return the cached current humidity."""
|
||||
value = self._state_cache.get(ATTR_CURRENT_HUMIDITY)
|
||||
return None if value is None else float(value)
|
||||
|
||||
@property
|
||||
def target_humidity(self) -> float | None:
|
||||
"""Return the cached target humidity."""
|
||||
value = self._state_cache.get(ATTR_HUMIDITY)
|
||||
return None if value is None else float(value)
|
||||
|
||||
@property
|
||||
def mode(self) -> str | None:
|
||||
"""Return the cached mode."""
|
||||
return self._state_cache.get(ATTR_MODE)
|
||||
|
||||
@property
|
||||
def available_modes(self) -> list[str] | None:
|
||||
"""Return the configured available modes."""
|
||||
modes = self.description.capabilities.get(ATTR_AVAILABLE_MODES)
|
||||
return list(modes) if modes else None
|
||||
|
||||
@property
|
||||
def min_humidity(self) -> float:
|
||||
"""Return the configured minimum humidity."""
|
||||
value = self.description.capabilities.get(ATTR_MIN_HUMIDITY)
|
||||
return float(value) if value is not None else super().min_humidity
|
||||
|
||||
@property
|
||||
def max_humidity(self) -> float:
|
||||
"""Return the configured maximum humidity."""
|
||||
value = self.description.capabilities.get(ATTR_MAX_HUMIDITY)
|
||||
return float(value) if value is not None else super().max_humidity
|
||||
|
||||
async def async_turn_on(self, **kwargs: object) -> None:
|
||||
"""Forward turn_on."""
|
||||
await self._call_service("turn_on")
|
||||
|
||||
async def async_turn_off(self, **kwargs: object) -> None:
|
||||
"""Forward turn_off."""
|
||||
await self._call_service("turn_off")
|
||||
|
||||
async def async_set_humidity(self, humidity: int) -> None:
|
||||
"""Forward set_humidity."""
|
||||
await self._call_service("set_humidity", humidity=humidity)
|
||||
|
||||
async def async_set_mode(self, mode: str) -> None:
|
||||
"""Forward set_mode."""
|
||||
await self._call_service("set_mode", mode=mode)
|
||||
@@ -0,0 +1,53 @@
|
||||
"""Sandbox proxy for ``lawn_mower`` entities."""
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from homeassistant.components.lawn_mower import (
|
||||
LawnMowerActivity,
|
||||
LawnMowerEntity,
|
||||
LawnMowerEntityFeature,
|
||||
)
|
||||
|
||||
from . import SandboxProxyEntity
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..bridge import SandboxBridge, SandboxEntityDescription
|
||||
|
||||
|
||||
# pylint: disable-next=home-assistant-enforce-class-module
|
||||
class SandboxLawnMowerEntity(SandboxProxyEntity, LawnMowerEntity):
|
||||
"""Proxy for a ``lawn_mower`` entity in a sandbox."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
bridge: SandboxBridge,
|
||||
description: SandboxEntityDescription,
|
||||
) -> None:
|
||||
"""Wrap ``supported_features`` as ``LawnMowerEntityFeature``."""
|
||||
super().__init__(bridge, description)
|
||||
self._attr_supported_features = LawnMowerEntityFeature(
|
||||
description.supported_features or 0
|
||||
)
|
||||
|
||||
@property
|
||||
def activity(self) -> LawnMowerActivity | None:
|
||||
"""Return the cached mowing activity."""
|
||||
value = self._state_cache.get("state")
|
||||
if value is None or value == "unavailable":
|
||||
return None
|
||||
try:
|
||||
return LawnMowerActivity(value)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
async def async_start_mowing(self) -> None:
|
||||
"""Forward start_mowing."""
|
||||
await self._call_service("start_mowing")
|
||||
|
||||
async def async_dock(self) -> None:
|
||||
"""Forward dock."""
|
||||
await self._call_service("dock")
|
||||
|
||||
async def async_pause(self) -> None:
|
||||
"""Forward pause."""
|
||||
await self._call_service("pause")
|
||||
@@ -0,0 +1,141 @@
|
||||
"""Sandbox proxy for ``light`` entities."""
|
||||
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from homeassistant.components.light import (
|
||||
ATTR_BRIGHTNESS,
|
||||
ATTR_COLOR_MODE,
|
||||
ATTR_COLOR_TEMP_KELVIN,
|
||||
ATTR_EFFECT,
|
||||
ATTR_EFFECT_LIST,
|
||||
ATTR_HS_COLOR,
|
||||
ATTR_MAX_COLOR_TEMP_KELVIN,
|
||||
ATTR_MIN_COLOR_TEMP_KELVIN,
|
||||
ATTR_RGB_COLOR,
|
||||
ATTR_RGBW_COLOR,
|
||||
ATTR_RGBWW_COLOR,
|
||||
ATTR_SUPPORTED_COLOR_MODES,
|
||||
ATTR_XY_COLOR,
|
||||
ColorMode,
|
||||
LightEntity,
|
||||
LightEntityFeature,
|
||||
)
|
||||
from homeassistant.const import STATE_ON
|
||||
|
||||
from . import SandboxProxyEntity
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..bridge import SandboxBridge, SandboxEntityDescription
|
||||
|
||||
|
||||
# pylint: disable-next=home-assistant-enforce-class-module
|
||||
class SandboxLightEntity(SandboxProxyEntity, LightEntity):
|
||||
"""Proxy for a ``light`` entity in a sandbox."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
bridge: SandboxBridge,
|
||||
description: SandboxEntityDescription,
|
||||
) -> None:
|
||||
"""Initialise the proxy with ``supported_features`` as a LightEntityFeature."""
|
||||
super().__init__(bridge, description)
|
||||
# ``light``'s capability_attributes does ``X in supported_features``,
|
||||
# which only works on the IntFlag. The base class stores the int.
|
||||
self._attr_supported_features = LightEntityFeature(
|
||||
description.supported_features or 0
|
||||
)
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool | None:
|
||||
"""Return whether the cached state is ``on``."""
|
||||
state = self._state_cache.get("state")
|
||||
if state is None:
|
||||
return None
|
||||
return state == STATE_ON
|
||||
|
||||
@property
|
||||
def brightness(self) -> int | None:
|
||||
"""Return the cached brightness."""
|
||||
value = self._state_cache.get(ATTR_BRIGHTNESS)
|
||||
return None if value is None else int(value)
|
||||
|
||||
@property
|
||||
def color_mode(self) -> ColorMode | None:
|
||||
"""Return the cached color mode."""
|
||||
value = self._state_cache.get(ATTR_COLOR_MODE)
|
||||
if value is None:
|
||||
return None
|
||||
return ColorMode(value)
|
||||
|
||||
@property
|
||||
def hs_color(self) -> tuple[float, float] | None:
|
||||
"""Return the cached hs color."""
|
||||
val = self._state_cache.get(ATTR_HS_COLOR)
|
||||
return tuple(val) if val else None
|
||||
|
||||
@property
|
||||
def rgb_color(self) -> tuple[int, int, int] | None:
|
||||
"""Return the cached rgb color."""
|
||||
val = self._state_cache.get(ATTR_RGB_COLOR)
|
||||
return tuple(val) if val else None
|
||||
|
||||
@property
|
||||
def rgbw_color(self) -> tuple[int, int, int, int] | None:
|
||||
"""Return the cached rgbw color."""
|
||||
val = self._state_cache.get(ATTR_RGBW_COLOR)
|
||||
return tuple(val) if val else None
|
||||
|
||||
@property
|
||||
def rgbww_color(self) -> tuple[int, int, int, int, int] | None:
|
||||
"""Return the cached rgbww color."""
|
||||
val = self._state_cache.get(ATTR_RGBWW_COLOR)
|
||||
return tuple(val) if val else None
|
||||
|
||||
@property
|
||||
def xy_color(self) -> tuple[float, float] | None:
|
||||
"""Return the cached xy color."""
|
||||
val = self._state_cache.get(ATTR_XY_COLOR)
|
||||
return tuple(val) if val else None
|
||||
|
||||
@property
|
||||
def color_temp_kelvin(self) -> int | None:
|
||||
"""Return the cached color temperature in kelvin."""
|
||||
value = self._state_cache.get(ATTR_COLOR_TEMP_KELVIN)
|
||||
return None if value is None else int(value)
|
||||
|
||||
@property
|
||||
def min_color_temp_kelvin(self) -> int:
|
||||
"""Return the cached or default min color temperature."""
|
||||
return int(self.description.capabilities.get(ATTR_MIN_COLOR_TEMP_KELVIN, 2000))
|
||||
|
||||
@property
|
||||
def max_color_temp_kelvin(self) -> int:
|
||||
"""Return the cached or default max color temperature."""
|
||||
return int(self.description.capabilities.get(ATTR_MAX_COLOR_TEMP_KELVIN, 6500))
|
||||
|
||||
@property
|
||||
def effect(self) -> str | None:
|
||||
"""Return the active effect."""
|
||||
return self._state_cache.get(ATTR_EFFECT)
|
||||
|
||||
@property
|
||||
def effect_list(self) -> list[str] | None:
|
||||
"""Return the list of supported effects."""
|
||||
effects = self.description.capabilities.get(ATTR_EFFECT_LIST)
|
||||
return list(effects) if effects else None
|
||||
|
||||
@property
|
||||
def supported_color_modes(self) -> set[ColorMode] | None:
|
||||
"""Return the cached supported color modes set."""
|
||||
modes = self.description.capabilities.get(ATTR_SUPPORTED_COLOR_MODES)
|
||||
if not modes:
|
||||
return None
|
||||
return {ColorMode(m) for m in modes}
|
||||
|
||||
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||
"""Forward turn_on as a ``light.turn_on`` service call."""
|
||||
await self._call_service("turn_on", **kwargs)
|
||||
|
||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||
"""Forward turn_off as a ``light.turn_off`` service call."""
|
||||
await self._call_service("turn_off", **kwargs)
|
||||
@@ -0,0 +1,82 @@
|
||||
"""Sandbox proxy for ``lock`` entities."""
|
||||
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from homeassistant.components.lock import LockEntity, LockEntityFeature, LockState
|
||||
|
||||
from . import SandboxProxyEntity
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..bridge import SandboxBridge, SandboxEntityDescription
|
||||
|
||||
|
||||
# pylint: disable-next=home-assistant-enforce-class-module
|
||||
class SandboxLockEntity(SandboxProxyEntity, LockEntity):
|
||||
"""Proxy for a ``lock`` entity in a sandbox."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
bridge: SandboxBridge,
|
||||
description: SandboxEntityDescription,
|
||||
) -> None:
|
||||
"""Wrap ``supported_features`` as ``LockEntityFeature``."""
|
||||
super().__init__(bridge, description)
|
||||
self._attr_supported_features = LockEntityFeature(
|
||||
description.supported_features or 0
|
||||
)
|
||||
|
||||
@property
|
||||
def is_locked(self) -> bool | None:
|
||||
"""Derive locked from cached state."""
|
||||
state = self._state_cache.get("state")
|
||||
if state is None:
|
||||
return None
|
||||
return state == LockState.LOCKED
|
||||
|
||||
@property
|
||||
def is_locking(self) -> bool | None:
|
||||
"""True iff cached state is ``locking``."""
|
||||
return self._state_cache.get("state") == LockState.LOCKING
|
||||
|
||||
@property
|
||||
def is_unlocking(self) -> bool | None:
|
||||
"""True iff cached state is ``unlocking``."""
|
||||
return self._state_cache.get("state") == LockState.UNLOCKING
|
||||
|
||||
@property
|
||||
def is_open(self) -> bool | None:
|
||||
"""True iff cached state is ``open``."""
|
||||
return self._state_cache.get("state") == LockState.OPEN
|
||||
|
||||
@property
|
||||
def is_opening(self) -> bool | None:
|
||||
"""True iff cached state is ``opening``."""
|
||||
return self._state_cache.get("state") == LockState.OPENING
|
||||
|
||||
@property
|
||||
def is_jammed(self) -> bool | None:
|
||||
"""True iff cached state is ``jammed``."""
|
||||
return self._state_cache.get("state") == LockState.JAMMED
|
||||
|
||||
@property
|
||||
def code_format(self) -> str | None:
|
||||
"""Return the configured code format."""
|
||||
value = self.description.capabilities.get("code_format")
|
||||
return str(value) if value is not None else None
|
||||
|
||||
@property
|
||||
def changed_by(self) -> str | None:
|
||||
"""Return the cached changed_by."""
|
||||
return self._state_cache.get("changed_by")
|
||||
|
||||
async def async_lock(self, **kwargs: Any) -> None:
|
||||
"""Forward lock."""
|
||||
await self._call_service("lock", **kwargs)
|
||||
|
||||
async def async_unlock(self, **kwargs: Any) -> None:
|
||||
"""Forward unlock."""
|
||||
await self._call_service("unlock", **kwargs)
|
||||
|
||||
async def async_open(self, **kwargs: Any) -> None:
|
||||
"""Forward open."""
|
||||
await self._call_service("open", **kwargs)
|
||||
@@ -0,0 +1,320 @@
|
||||
"""Sandbox proxy for ``media_player`` entities."""
|
||||
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from homeassistant.components.media_player import (
|
||||
ATTR_APP_ID,
|
||||
ATTR_APP_NAME,
|
||||
ATTR_INPUT_SOURCE,
|
||||
ATTR_INPUT_SOURCE_LIST,
|
||||
ATTR_MEDIA_ALBUM_ARTIST,
|
||||
ATTR_MEDIA_ALBUM_NAME,
|
||||
ATTR_MEDIA_ARTIST,
|
||||
ATTR_MEDIA_CONTENT_ID,
|
||||
ATTR_MEDIA_CONTENT_TYPE,
|
||||
ATTR_MEDIA_DURATION,
|
||||
ATTR_MEDIA_POSITION,
|
||||
ATTR_MEDIA_TITLE,
|
||||
ATTR_MEDIA_TRACK,
|
||||
ATTR_MEDIA_VOLUME_LEVEL,
|
||||
ATTR_MEDIA_VOLUME_MUTED,
|
||||
ATTR_SOUND_MODE,
|
||||
ATTR_SOUND_MODE_LIST,
|
||||
BrowseMedia,
|
||||
MediaPlayerEntity,
|
||||
MediaPlayerEntityFeature,
|
||||
MediaPlayerState,
|
||||
MediaType,
|
||||
RepeatMode,
|
||||
SearchMedia,
|
||||
SearchMediaQuery,
|
||||
)
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
|
||||
from . import SandboxProxyEntity
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..bridge import SandboxBridge, SandboxEntityDescription
|
||||
|
||||
|
||||
def _browse_media_from_dict(data: dict[str, Any]) -> BrowseMedia:
|
||||
"""Rebuild a :class:`BrowseMedia` tree from its ``as_dict`` shape.
|
||||
|
||||
``BrowseMedia.as_dict`` is frontend-shaped — it carries
|
||||
``children_media_class`` and emits ``not_shown`` / ``children`` only at the
|
||||
parent level — so fields map across explicitly rather than via a ``**data``
|
||||
splat. ``children`` recurses; numbers arriving as floats through the wire
|
||||
Struct are coerced back to the constructor's ``int`` / ``bool`` types.
|
||||
"""
|
||||
children = data.get("children")
|
||||
return BrowseMedia(
|
||||
media_class=data["media_class"],
|
||||
media_content_id=data["media_content_id"],
|
||||
media_content_type=data["media_content_type"],
|
||||
title=data["title"],
|
||||
can_play=bool(data["can_play"]),
|
||||
can_expand=bool(data["can_expand"]),
|
||||
children=(
|
||||
[_browse_media_from_dict(child) for child in children] if children else None
|
||||
),
|
||||
children_media_class=data.get("children_media_class"),
|
||||
thumbnail=data.get("thumbnail"),
|
||||
not_shown=int(data.get("not_shown") or 0),
|
||||
can_search=bool(data.get("can_search", False)),
|
||||
)
|
||||
|
||||
|
||||
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."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
bridge: SandboxBridge,
|
||||
description: SandboxEntityDescription,
|
||||
) -> None:
|
||||
"""Wrap ``supported_features`` as ``MediaPlayerEntityFeature``."""
|
||||
super().__init__(bridge, description)
|
||||
self._attr_supported_features = MediaPlayerEntityFeature(
|
||||
description.supported_features or 0
|
||||
)
|
||||
|
||||
@property
|
||||
def state(self) -> MediaPlayerState | None:
|
||||
"""Return the cached state."""
|
||||
value = self._state_cache.get("state")
|
||||
if value is None or value == "unavailable":
|
||||
return None
|
||||
try:
|
||||
return MediaPlayerState(value)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
@property
|
||||
def volume_level(self) -> float | None:
|
||||
"""Return the cached volume level."""
|
||||
value = self._state_cache.get(ATTR_MEDIA_VOLUME_LEVEL)
|
||||
return None if value is None else float(value)
|
||||
|
||||
@property
|
||||
def is_volume_muted(self) -> bool | None:
|
||||
"""Return the cached mute state."""
|
||||
value = self._state_cache.get(ATTR_MEDIA_VOLUME_MUTED)
|
||||
return None if value is None else bool(value)
|
||||
|
||||
@property
|
||||
def media_content_id(self) -> str | None:
|
||||
"""Return cached media_content_id."""
|
||||
return self._state_cache.get(ATTR_MEDIA_CONTENT_ID)
|
||||
|
||||
@property
|
||||
def media_content_type(self) -> str | None:
|
||||
"""Return cached media_content_type."""
|
||||
return self._state_cache.get(ATTR_MEDIA_CONTENT_TYPE)
|
||||
|
||||
@property
|
||||
def media_duration(self) -> int | None:
|
||||
"""Return cached media_duration."""
|
||||
value = self._state_cache.get(ATTR_MEDIA_DURATION)
|
||||
return None if value is None else int(value)
|
||||
|
||||
@property
|
||||
def media_position(self) -> int | None:
|
||||
"""Return cached media_position."""
|
||||
value = self._state_cache.get(ATTR_MEDIA_POSITION)
|
||||
return None if value is None else int(value)
|
||||
|
||||
@property
|
||||
def media_title(self) -> str | None:
|
||||
"""Return cached media_title."""
|
||||
return self._state_cache.get(ATTR_MEDIA_TITLE)
|
||||
|
||||
@property
|
||||
def media_artist(self) -> str | None:
|
||||
"""Return cached media_artist."""
|
||||
return self._state_cache.get(ATTR_MEDIA_ARTIST)
|
||||
|
||||
@property
|
||||
def media_album_name(self) -> str | None:
|
||||
"""Return cached media_album_name."""
|
||||
return self._state_cache.get(ATTR_MEDIA_ALBUM_NAME)
|
||||
|
||||
@property
|
||||
def media_album_artist(self) -> str | None:
|
||||
"""Return cached media_album_artist."""
|
||||
return self._state_cache.get(ATTR_MEDIA_ALBUM_ARTIST)
|
||||
|
||||
@property
|
||||
def media_track(self) -> int | None:
|
||||
"""Return cached media_track."""
|
||||
value = self._state_cache.get(ATTR_MEDIA_TRACK)
|
||||
return None if value is None else int(value)
|
||||
|
||||
@property
|
||||
def source(self) -> str | None:
|
||||
"""Return cached source."""
|
||||
return self._state_cache.get(ATTR_INPUT_SOURCE)
|
||||
|
||||
@property
|
||||
def source_list(self) -> list[str] | None:
|
||||
"""Return cached source list."""
|
||||
value = self._state_cache.get(
|
||||
ATTR_INPUT_SOURCE_LIST,
|
||||
self.description.capabilities.get(ATTR_INPUT_SOURCE_LIST),
|
||||
)
|
||||
return list(value) if value else None
|
||||
|
||||
@property
|
||||
def sound_mode(self) -> str | None:
|
||||
"""Return cached sound_mode."""
|
||||
return self._state_cache.get(ATTR_SOUND_MODE)
|
||||
|
||||
@property
|
||||
def sound_mode_list(self) -> list[str] | None:
|
||||
"""Return cached sound_mode_list."""
|
||||
value = self._state_cache.get(
|
||||
ATTR_SOUND_MODE_LIST,
|
||||
self.description.capabilities.get(ATTR_SOUND_MODE_LIST),
|
||||
)
|
||||
return list(value) if value else None
|
||||
|
||||
@property
|
||||
def app_id(self) -> str | None:
|
||||
"""Return cached app_id."""
|
||||
return self._state_cache.get(ATTR_APP_ID)
|
||||
|
||||
@property
|
||||
def app_name(self) -> str | None:
|
||||
"""Return cached app_name."""
|
||||
return self._state_cache.get(ATTR_APP_NAME)
|
||||
|
||||
async def async_turn_on(self) -> None:
|
||||
"""Forward turn_on."""
|
||||
await self._call_service("turn_on")
|
||||
|
||||
async def async_turn_off(self) -> None:
|
||||
"""Forward turn_off."""
|
||||
await self._call_service("turn_off")
|
||||
|
||||
async def async_mute_volume(self, mute: bool) -> None:
|
||||
"""Forward volume_mute."""
|
||||
await self._call_service("volume_mute", is_volume_muted=mute)
|
||||
|
||||
async def async_set_volume_level(self, volume: float) -> None:
|
||||
"""Forward volume_set."""
|
||||
await self._call_service("volume_set", volume_level=volume)
|
||||
|
||||
async def async_media_play(self) -> None:
|
||||
"""Forward media_play."""
|
||||
await self._call_service("media_play")
|
||||
|
||||
async def async_media_pause(self) -> None:
|
||||
"""Forward media_pause."""
|
||||
await self._call_service("media_pause")
|
||||
|
||||
async def async_media_stop(self) -> None:
|
||||
"""Forward media_stop."""
|
||||
await self._call_service("media_stop")
|
||||
|
||||
async def async_media_next_track(self) -> None:
|
||||
"""Forward media_next_track."""
|
||||
await self._call_service("media_next_track")
|
||||
|
||||
async def async_media_previous_track(self) -> None:
|
||||
"""Forward media_previous_track."""
|
||||
await self._call_service("media_previous_track")
|
||||
|
||||
async def async_media_seek(self, position: float) -> None:
|
||||
"""Forward media_seek."""
|
||||
await self._call_service("media_seek", seek_position=position)
|
||||
|
||||
async def async_play_media(
|
||||
self, media_type: str, media_id: str, **kwargs: Any
|
||||
) -> None:
|
||||
"""Forward play_media."""
|
||||
await self._call_service(
|
||||
"play_media",
|
||||
media_content_type=media_type,
|
||||
media_content_id=media_id,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
async def async_select_source(self, source: str) -> None:
|
||||
"""Forward select_source."""
|
||||
await self._call_service("select_source", source=source)
|
||||
|
||||
async def async_select_sound_mode(self, sound_mode: str) -> None:
|
||||
"""Forward select_sound_mode."""
|
||||
await self._call_service("select_sound_mode", sound_mode=sound_mode)
|
||||
|
||||
async def async_browse_media(
|
||||
self,
|
||||
media_content_type: MediaType | str | None = None,
|
||||
media_content_id: str | None = None,
|
||||
) -> BrowseMedia:
|
||||
"""Browse via the ``media_player.browse_media`` service.
|
||||
|
||||
Caveat: a sandboxed player's browse surfaces only its OWN sources. The
|
||||
``media_source`` tree a player normally merges in (via
|
||||
``media_source.async_browse_media(self.hass, …)``) is empty here —
|
||||
``media_source`` runs on main, outside the sandbox boundary, so the
|
||||
sandbox's private hass has nothing to resolve against. Not a bug;
|
||||
closing it needs a cross-boundary hook (pairs with the opt-in sharing
|
||||
work). See ``sandbox/docs/query-shaped-rpcs.md``.
|
||||
"""
|
||||
service_data: dict[str, Any] = {}
|
||||
if media_content_type is not None:
|
||||
service_data["media_content_type"] = media_content_type
|
||||
if media_content_id is not None:
|
||||
service_data["media_content_id"] = media_content_id
|
||||
response = await self._call_service(
|
||||
"browse_media", return_response=True, **service_data
|
||||
)
|
||||
entity_response = response.get(self.description.sandbox_entity_id)
|
||||
if not entity_response:
|
||||
raise HomeAssistantError("Sandbox returned no browse_media result")
|
||||
return _browse_media_from_dict(entity_response)
|
||||
|
||||
async def async_search_media(self, query: SearchMediaQuery) -> SearchMedia:
|
||||
"""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."""
|
||||
await self._call_service("clear_playlist")
|
||||
|
||||
async def async_set_shuffle(self, shuffle: bool) -> None:
|
||||
"""Forward shuffle_set."""
|
||||
await self._call_service("shuffle_set", shuffle=shuffle)
|
||||
|
||||
async def async_set_repeat(self, repeat: RepeatMode) -> None:
|
||||
"""Forward repeat_set."""
|
||||
await self._call_service("repeat_set", repeat=repeat)
|
||||
@@ -0,0 +1,43 @@
|
||||
"""Sandbox proxy for ``notify`` entities."""
|
||||
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from homeassistant.components.notify import NotifyEntity, NotifyEntityFeature
|
||||
from homeassistant.core import Context
|
||||
|
||||
from . import SandboxProxyEntity
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..bridge import SandboxBridge, SandboxEntityDescription
|
||||
|
||||
|
||||
# pylint: disable-next=home-assistant-enforce-class-module
|
||||
class SandboxNotifyEntity(SandboxProxyEntity, NotifyEntity):
|
||||
"""Proxy for a ``notify`` entity in a sandbox."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
bridge: SandboxBridge,
|
||||
description: SandboxEntityDescription,
|
||||
) -> None:
|
||||
"""Wrap ``supported_features`` as ``NotifyEntityFeature``."""
|
||||
super().__init__(bridge, description)
|
||||
self._attr_supported_features = NotifyEntityFeature(
|
||||
description.supported_features or 0
|
||||
)
|
||||
|
||||
def sandbox_apply_state(
|
||||
self,
|
||||
state: str | None,
|
||||
attributes: dict[str, Any],
|
||||
context: Context | None = None,
|
||||
) -> None:
|
||||
"""Mirror ``__last_notified_isoformat`` for state computation."""
|
||||
if state is not None:
|
||||
# pylint: disable-next=attribute-defined-outside-init
|
||||
self._NotifyEntity__last_notified_isoformat = state
|
||||
super().sandbox_apply_state(state, attributes, context)
|
||||
|
||||
async def async_send_message(self, message: str, title: str | None = None) -> None:
|
||||
"""Forward send_message."""
|
||||
await self._call_service("send_message", message=message, title=title)
|
||||
@@ -0,0 +1,60 @@
|
||||
"""Sandbox proxy for ``number`` entities."""
|
||||
|
||||
from homeassistant.components.number import (
|
||||
ATTR_MAX,
|
||||
ATTR_MIN,
|
||||
ATTR_STEP,
|
||||
NumberEntity,
|
||||
NumberMode,
|
||||
)
|
||||
|
||||
from . import SandboxProxyEntity
|
||||
|
||||
|
||||
# pylint: disable-next=home-assistant-enforce-class-module
|
||||
class SandboxNumberEntity(SandboxProxyEntity, NumberEntity):
|
||||
"""Proxy for a ``number`` entity in a sandbox."""
|
||||
|
||||
@property
|
||||
def native_value(self) -> float | None:
|
||||
"""Parse the cached number state."""
|
||||
value = self._state_cache.get("state")
|
||||
if value is None or value in ("unavailable", "unknown"):
|
||||
return None
|
||||
try:
|
||||
return float(value)
|
||||
except TypeError, ValueError:
|
||||
return None
|
||||
|
||||
@property
|
||||
def native_min_value(self) -> float:
|
||||
"""Return the configured minimum."""
|
||||
value = self.description.capabilities.get(ATTR_MIN)
|
||||
return float(value) if value is not None else super().native_min_value
|
||||
|
||||
@property
|
||||
def native_max_value(self) -> float:
|
||||
"""Return the configured maximum."""
|
||||
value = self.description.capabilities.get(ATTR_MAX)
|
||||
return float(value) if value is not None else super().native_max_value
|
||||
|
||||
@property
|
||||
def native_step(self) -> float | None:
|
||||
"""Return the configured step."""
|
||||
value = self.description.capabilities.get(ATTR_STEP)
|
||||
return float(value) if value is not None else None
|
||||
|
||||
@property
|
||||
def mode(self) -> NumberMode:
|
||||
"""Return the configured display mode."""
|
||||
value = self.description.capabilities.get("mode")
|
||||
if value is None:
|
||||
return NumberMode.AUTO
|
||||
try:
|
||||
return NumberMode(value)
|
||||
except ValueError:
|
||||
return NumberMode.AUTO
|
||||
|
||||
async def async_set_native_value(self, value: float) -> None:
|
||||
"""Forward set_value as ``number.set_value``."""
|
||||
await self._call_service("set_value", value=value)
|
||||
@@ -0,0 +1,76 @@
|
||||
"""Sandbox proxy for ``remote`` entities."""
|
||||
|
||||
from collections.abc import Iterable
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from homeassistant.components.remote import (
|
||||
ATTR_ACTIVITY_LIST,
|
||||
ATTR_CURRENT_ACTIVITY,
|
||||
RemoteEntity,
|
||||
RemoteEntityFeature,
|
||||
)
|
||||
from homeassistant.const import STATE_ON
|
||||
|
||||
from . import SandboxProxyEntity
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..bridge import SandboxBridge, SandboxEntityDescription
|
||||
|
||||
|
||||
# pylint: disable-next=home-assistant-enforce-class-module
|
||||
class SandboxRemoteEntity(SandboxProxyEntity, RemoteEntity):
|
||||
"""Proxy for a ``remote`` entity in a sandbox."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
bridge: SandboxBridge,
|
||||
description: SandboxEntityDescription,
|
||||
) -> None:
|
||||
"""Wrap ``supported_features`` as ``RemoteEntityFeature``."""
|
||||
super().__init__(bridge, description)
|
||||
self._attr_supported_features = RemoteEntityFeature(
|
||||
description.supported_features or 0
|
||||
)
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool | None:
|
||||
"""Return whether the cached state is ``on``."""
|
||||
state = self._state_cache.get("state")
|
||||
if state is None:
|
||||
return None
|
||||
return state == STATE_ON
|
||||
|
||||
@property
|
||||
def current_activity(self) -> str | None:
|
||||
"""Return the cached current activity."""
|
||||
return self._state_cache.get(ATTR_CURRENT_ACTIVITY)
|
||||
|
||||
@property
|
||||
def activity_list(self) -> list[str] | None:
|
||||
"""Return the configured activity list."""
|
||||
value = self.description.capabilities.get(ATTR_ACTIVITY_LIST)
|
||||
return list(value) if value else None
|
||||
|
||||
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||
"""Forward turn_on."""
|
||||
await self._call_service("turn_on", **kwargs)
|
||||
|
||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||
"""Forward turn_off."""
|
||||
await self._call_service("turn_off", **kwargs)
|
||||
|
||||
async def async_toggle(self, **kwargs: Any) -> None:
|
||||
"""Forward toggle."""
|
||||
await self._call_service("toggle", **kwargs)
|
||||
|
||||
async def async_send_command(self, command: Iterable[str], **kwargs: Any) -> None:
|
||||
"""Forward send_command."""
|
||||
await self._call_service("send_command", command=list(command), **kwargs)
|
||||
|
||||
async def async_learn_command(self, **kwargs: Any) -> None:
|
||||
"""Forward learn_command."""
|
||||
await self._call_service("learn_command", **kwargs)
|
||||
|
||||
async def async_delete_command(self, **kwargs: Any) -> None:
|
||||
"""Forward delete_command."""
|
||||
await self._call_service("delete_command", **kwargs)
|
||||
@@ -0,0 +1,34 @@
|
||||
"""Sandbox proxy for ``scene`` entities.
|
||||
|
||||
``scene`` is in ``ALWAYS_MAIN`` so the classifier never routes it to a
|
||||
sandbox in practice. The proxy ships anyway for symmetry — the full
|
||||
set is covered so a future classifier change doesn't surprise us.
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.components.scene import Scene
|
||||
from homeassistant.core import Context
|
||||
|
||||
from . import SandboxProxyEntity
|
||||
|
||||
|
||||
# pylint: disable-next=home-assistant-enforce-class-module
|
||||
class SandboxSceneEntity(SandboxProxyEntity, Scene):
|
||||
"""Proxy for a ``scene`` entity in a sandbox."""
|
||||
|
||||
def sandbox_apply_state(
|
||||
self,
|
||||
state: str | None,
|
||||
attributes: dict[str, Any],
|
||||
context: Context | None = None,
|
||||
) -> None:
|
||||
"""Mirror the sandbox-side last-activated timestamp."""
|
||||
if state is not None:
|
||||
# pylint: disable-next=attribute-defined-outside-init
|
||||
self._BaseScene__last_activated = state
|
||||
super().sandbox_apply_state(state, attributes, context)
|
||||
|
||||
async def async_activate(self, **kwargs: Any) -> None:
|
||||
"""Forward activate as ``scene.turn_on``."""
|
||||
await self._call_service("turn_on", **kwargs)
|
||||
@@ -0,0 +1,28 @@
|
||||
"""Sandbox proxy for ``select`` entities."""
|
||||
|
||||
from homeassistant.components.select import ATTR_OPTIONS, SelectEntity
|
||||
|
||||
from . import SandboxProxyEntity
|
||||
|
||||
|
||||
# pylint: disable-next=home-assistant-enforce-class-module
|
||||
class SandboxSelectEntity(SandboxProxyEntity, SelectEntity):
|
||||
"""Proxy for a ``select`` entity in a sandbox."""
|
||||
|
||||
@property
|
||||
def current_option(self) -> str | None:
|
||||
"""Return the cached current option."""
|
||||
value = self._state_cache.get("state")
|
||||
if value in (None, "unavailable", "unknown"):
|
||||
return None
|
||||
return value
|
||||
|
||||
@property
|
||||
def options(self) -> list[str]:
|
||||
"""Return the cached options list."""
|
||||
value = self.description.capabilities.get(ATTR_OPTIONS) or []
|
||||
return list(value)
|
||||
|
||||
async def async_select_option(self, option: str) -> None:
|
||||
"""Forward select_option."""
|
||||
await self._call_service("select_option", option=option)
|
||||
@@ -0,0 +1,24 @@
|
||||
"""Sandbox proxy for ``sensor`` entities."""
|
||||
|
||||
from homeassistant.components.sensor import SensorEntity
|
||||
from homeassistant.const import ATTR_UNIT_OF_MEASUREMENT
|
||||
|
||||
from . import SandboxProxyEntity
|
||||
|
||||
|
||||
# pylint: disable-next=home-assistant-enforce-class-module
|
||||
class SandboxSensorEntity(SandboxProxyEntity, SensorEntity):
|
||||
"""Proxy for a ``sensor`` entity in a sandbox."""
|
||||
|
||||
@property
|
||||
def native_value(self) -> str | int | float | None:
|
||||
"""Return the cached state as the sensor's native value."""
|
||||
return self._state_cache.get("state")
|
||||
|
||||
@property
|
||||
def native_unit_of_measurement(self) -> str | None:
|
||||
"""Return the cached unit of measurement."""
|
||||
return self._state_cache.get(
|
||||
ATTR_UNIT_OF_MEASUREMENT,
|
||||
self.description.capabilities.get(ATTR_UNIT_OF_MEASUREMENT),
|
||||
)
|
||||
@@ -0,0 +1,56 @@
|
||||
"""Sandbox proxy for ``siren`` entities."""
|
||||
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from homeassistant.components.siren import (
|
||||
ATTR_AVAILABLE_TONES,
|
||||
SirenEntity,
|
||||
SirenEntityFeature,
|
||||
)
|
||||
from homeassistant.const import STATE_ON
|
||||
|
||||
from . import SandboxProxyEntity
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..bridge import SandboxBridge, SandboxEntityDescription
|
||||
|
||||
|
||||
# pylint: disable-next=home-assistant-enforce-class-module
|
||||
class SandboxSirenEntity(SandboxProxyEntity, SirenEntity):
|
||||
"""Proxy for a ``siren`` entity in a sandbox."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
bridge: SandboxBridge,
|
||||
description: SandboxEntityDescription,
|
||||
) -> None:
|
||||
"""Wrap ``supported_features`` as ``SirenEntityFeature``."""
|
||||
super().__init__(bridge, description)
|
||||
self._attr_supported_features = SirenEntityFeature(
|
||||
description.supported_features or 0
|
||||
)
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool | None:
|
||||
"""Return whether the cached state is ``on``."""
|
||||
state = self._state_cache.get("state")
|
||||
if state is None:
|
||||
return None
|
||||
return state == STATE_ON
|
||||
|
||||
@property
|
||||
def available_tones(self) -> list[int | str] | dict[int, str] | None:
|
||||
"""Return the configured available tones."""
|
||||
return self.description.capabilities.get(ATTR_AVAILABLE_TONES)
|
||||
|
||||
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||
"""Forward turn_on."""
|
||||
await self._call_service("turn_on", **kwargs)
|
||||
|
||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||
"""Forward turn_off."""
|
||||
await self._call_service("turn_off", **kwargs)
|
||||
|
||||
async def async_toggle(self, **kwargs: Any) -> None:
|
||||
"""Forward toggle."""
|
||||
await self._call_service("toggle", **kwargs)
|
||||
@@ -0,0 +1,33 @@
|
||||
"""Sandbox proxy for ``switch`` entities."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.components.switch import SwitchEntity
|
||||
from homeassistant.const import STATE_ON
|
||||
|
||||
from . import SandboxProxyEntity
|
||||
|
||||
|
||||
# pylint: disable-next=home-assistant-enforce-class-module
|
||||
class SandboxSwitchEntity(SandboxProxyEntity, SwitchEntity):
|
||||
"""Proxy for a ``switch`` entity in a sandbox."""
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool | None:
|
||||
"""Return whether the cached state is ``on``."""
|
||||
state = self._state_cache.get("state")
|
||||
if state is None:
|
||||
return None
|
||||
return state == STATE_ON
|
||||
|
||||
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||
"""Forward turn_on as a ``switch.turn_on`` service call."""
|
||||
await self._call_service("turn_on", **kwargs)
|
||||
|
||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||
"""Forward turn_off as a ``switch.turn_off`` service call."""
|
||||
await self._call_service("turn_off", **kwargs)
|
||||
|
||||
async def async_toggle(self, **kwargs: Any) -> None:
|
||||
"""Forward toggle as a ``switch.toggle`` service call."""
|
||||
await self._call_service("toggle", **kwargs)
|
||||
@@ -0,0 +1,58 @@
|
||||
"""Sandbox proxy for ``text`` entities."""
|
||||
|
||||
from homeassistant.components.text import (
|
||||
ATTR_MAX,
|
||||
ATTR_MIN,
|
||||
ATTR_MODE,
|
||||
ATTR_PATTERN,
|
||||
TextEntity,
|
||||
TextMode,
|
||||
)
|
||||
|
||||
from . import SandboxProxyEntity
|
||||
|
||||
|
||||
# pylint: disable-next=home-assistant-enforce-class-module
|
||||
class SandboxTextEntity(SandboxProxyEntity, TextEntity):
|
||||
"""Proxy for a ``text`` entity in a sandbox."""
|
||||
|
||||
@property
|
||||
def native_value(self) -> str | None:
|
||||
"""Return the cached text value."""
|
||||
value = self._state_cache.get("state")
|
||||
if value in (None, "unavailable", "unknown"):
|
||||
return None
|
||||
return str(value)
|
||||
|
||||
@property
|
||||
def native_min(self) -> int:
|
||||
"""Return the configured minimum length."""
|
||||
value = self.description.capabilities.get(ATTR_MIN)
|
||||
return int(value) if value is not None else 0
|
||||
|
||||
@property
|
||||
def native_max(self) -> int:
|
||||
"""Return the configured maximum length."""
|
||||
value = self.description.capabilities.get(ATTR_MAX)
|
||||
return int(value) if value is not None else super().native_max
|
||||
|
||||
@property
|
||||
def pattern(self) -> str | None:
|
||||
"""Return the configured pattern."""
|
||||
value = self.description.capabilities.get(ATTR_PATTERN)
|
||||
return str(value) if value is not None else None
|
||||
|
||||
@property
|
||||
def mode(self) -> TextMode:
|
||||
"""Return the configured display mode."""
|
||||
value = self.description.capabilities.get(ATTR_MODE)
|
||||
if value is None:
|
||||
return TextMode.TEXT
|
||||
try:
|
||||
return TextMode(value)
|
||||
except ValueError:
|
||||
return TextMode.TEXT
|
||||
|
||||
async def async_set_value(self, value: str) -> None:
|
||||
"""Forward set_value as ``text.set_value``."""
|
||||
await self._call_service("set_value", value=value)
|
||||
@@ -0,0 +1,28 @@
|
||||
"""Sandbox proxy for ``time`` entities."""
|
||||
|
||||
from datetime import time
|
||||
|
||||
from homeassistant.components.time import TimeEntity
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from . import SandboxProxyEntity
|
||||
|
||||
|
||||
# pylint: disable-next=home-assistant-enforce-class-module
|
||||
class SandboxTimeEntity(SandboxProxyEntity, TimeEntity):
|
||||
"""Proxy for a ``time`` entity in a sandbox."""
|
||||
|
||||
@property
|
||||
def native_value(self) -> time | None:
|
||||
"""Parse the cached ISO time string."""
|
||||
value = self._state_cache.get("state")
|
||||
if not isinstance(value, str) or value in ("unavailable", "unknown"):
|
||||
return None
|
||||
try:
|
||||
return dt_util.parse_time(value)
|
||||
except TypeError, ValueError:
|
||||
return None
|
||||
|
||||
async def async_set_value(self, value: time) -> None:
|
||||
"""Forward set_value as ``time.set_value``."""
|
||||
await self._call_service("set_value", time=value.isoformat())
|
||||
@@ -0,0 +1,103 @@
|
||||
"""Sandbox proxy for ``update`` entities."""
|
||||
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from homeassistant.components.update import (
|
||||
ATTR_INSTALLED_VERSION,
|
||||
ATTR_LATEST_VERSION,
|
||||
UpdateEntity,
|
||||
UpdateEntityFeature,
|
||||
)
|
||||
|
||||
from . import SandboxProxyEntity
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..bridge import SandboxBridge, SandboxEntityDescription
|
||||
|
||||
# These attribute names are emitted by ``UpdateEntity.state_attributes``
|
||||
# (see ``components/update/__init__.py``). They're defined in
|
||||
# ``update.const`` but not exported from the package root, so we hold the
|
||||
# string keys locally rather than chase the pylint / mypy conflict on
|
||||
# importing from ``.const``.
|
||||
_ATTR_AUTO_UPDATE = "auto_update"
|
||||
_ATTR_IN_PROGRESS = "in_progress"
|
||||
_ATTR_RELEASE_SUMMARY = "release_summary"
|
||||
_ATTR_RELEASE_URL = "release_url"
|
||||
_ATTR_TITLE = "title"
|
||||
_ATTR_UPDATE_PERCENTAGE = "update_percentage"
|
||||
|
||||
|
||||
# pylint: disable-next=home-assistant-enforce-class-module
|
||||
class SandboxUpdateEntity(SandboxProxyEntity, UpdateEntity):
|
||||
"""Proxy for an ``update`` entity in a sandbox."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
bridge: SandboxBridge,
|
||||
description: SandboxEntityDescription,
|
||||
) -> None:
|
||||
"""Wrap ``supported_features`` as ``UpdateEntityFeature``."""
|
||||
super().__init__(bridge, description)
|
||||
self._attr_supported_features = UpdateEntityFeature(
|
||||
description.supported_features or 0
|
||||
)
|
||||
|
||||
@property
|
||||
def installed_version(self) -> str | None:
|
||||
"""Return the cached installed version."""
|
||||
return self._state_cache.get(ATTR_INSTALLED_VERSION)
|
||||
|
||||
@property
|
||||
def latest_version(self) -> str | None:
|
||||
"""Return the cached latest version."""
|
||||
return self._state_cache.get(ATTR_LATEST_VERSION)
|
||||
|
||||
@property
|
||||
def release_summary(self) -> str | None:
|
||||
"""Return the cached release summary."""
|
||||
return self._state_cache.get(_ATTR_RELEASE_SUMMARY)
|
||||
|
||||
@property
|
||||
def release_url(self) -> str | None:
|
||||
"""Return the cached release URL."""
|
||||
return self._state_cache.get(_ATTR_RELEASE_URL)
|
||||
|
||||
@property
|
||||
def title(self) -> str | None:
|
||||
"""Return the cached title."""
|
||||
return self._state_cache.get(_ATTR_TITLE)
|
||||
|
||||
@property
|
||||
def in_progress(self) -> bool | None:
|
||||
"""Return the cached progress flag."""
|
||||
value = self._state_cache.get(_ATTR_IN_PROGRESS)
|
||||
return None if value is None else bool(value)
|
||||
|
||||
@property
|
||||
def update_percentage(self) -> int | float | None:
|
||||
"""Return the cached progress percentage."""
|
||||
value = self._state_cache.get(_ATTR_UPDATE_PERCENTAGE)
|
||||
if value is None:
|
||||
return None
|
||||
try:
|
||||
return float(value)
|
||||
except TypeError, ValueError:
|
||||
return None
|
||||
|
||||
@property
|
||||
def auto_update(self) -> bool:
|
||||
"""Return the cached auto-update flag."""
|
||||
return bool(self._state_cache.get(_ATTR_AUTO_UPDATE, False))
|
||||
|
||||
async def async_install(
|
||||
self, version: str | None, backup: bool, **kwargs: Any
|
||||
) -> None:
|
||||
"""Forward install."""
|
||||
payload: dict[str, Any] = {"backup": backup, **kwargs}
|
||||
if version is not None:
|
||||
payload["version"] = version
|
||||
await self._call_service("install", **payload)
|
||||
|
||||
async def async_release_notes(self) -> str | None:
|
||||
"""Return the release notes via ``EntityQuery`` (a plain str/None)."""
|
||||
return await self._entity_query("async_release_notes")
|
||||
@@ -0,0 +1,104 @@
|
||||
"""Sandbox proxy for ``vacuum`` entities."""
|
||||
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from homeassistant.components.vacuum import (
|
||||
ATTR_FAN_SPEED,
|
||||
ATTR_FAN_SPEED_LIST,
|
||||
Segment,
|
||||
StateVacuumEntity,
|
||||
VacuumActivity,
|
||||
VacuumEntityFeature,
|
||||
)
|
||||
|
||||
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."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
bridge: SandboxBridge,
|
||||
description: SandboxEntityDescription,
|
||||
) -> None:
|
||||
"""Wrap ``supported_features`` as ``VacuumEntityFeature``."""
|
||||
super().__init__(bridge, description)
|
||||
self._attr_supported_features = VacuumEntityFeature(
|
||||
description.supported_features or 0
|
||||
)
|
||||
|
||||
@property
|
||||
def activity(self) -> VacuumActivity | None:
|
||||
"""Return the cached vacuum activity."""
|
||||
value = self._state_cache.get("state")
|
||||
if value is None or value == "unavailable":
|
||||
return None
|
||||
try:
|
||||
return VacuumActivity(value)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
@property
|
||||
def fan_speed(self) -> str | None:
|
||||
"""Return the cached fan speed."""
|
||||
return self._state_cache.get(ATTR_FAN_SPEED)
|
||||
|
||||
@property
|
||||
def fan_speed_list(self) -> list[str]:
|
||||
"""Return the configured fan speed list."""
|
||||
return list(self.description.capabilities.get(ATTR_FAN_SPEED_LIST) or [])
|
||||
|
||||
async def async_start(self) -> None:
|
||||
"""Forward start."""
|
||||
await self._call_service("start")
|
||||
|
||||
async def async_pause(self) -> None:
|
||||
"""Forward pause."""
|
||||
await self._call_service("pause")
|
||||
|
||||
async def async_stop(self, **kwargs: Any) -> None:
|
||||
"""Forward stop."""
|
||||
await self._call_service("stop", **kwargs)
|
||||
|
||||
async def async_return_to_base(self, **kwargs: Any) -> None:
|
||||
"""Forward return_to_base."""
|
||||
await self._call_service("return_to_base", **kwargs)
|
||||
|
||||
async def async_clean_spot(self, **kwargs: Any) -> None:
|
||||
"""Forward clean_spot."""
|
||||
await self._call_service("clean_spot", **kwargs)
|
||||
|
||||
async def async_locate(self, **kwargs: Any) -> None:
|
||||
"""Forward locate."""
|
||||
await self._call_service("locate", **kwargs)
|
||||
|
||||
async def async_set_fan_speed(self, fan_speed: str, **kwargs: Any) -> None:
|
||||
"""Forward set_fan_speed."""
|
||||
await self._call_service("set_fan_speed", fan_speed=fan_speed, **kwargs)
|
||||
|
||||
async def async_send_command(
|
||||
self,
|
||||
command: str,
|
||||
params: dict[str, Any] | list[Any] | None = None,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
"""Forward send_command."""
|
||||
payload: dict[str, Any] = {"command": command, **kwargs}
|
||||
if params is not None:
|
||||
payload["params"] = params
|
||||
await self._call_service("send_command", **payload)
|
||||
|
||||
async def async_get_segments(self) -> list[Segment]:
|
||||
"""Return the cleanable segments via ``EntityQuery``."""
|
||||
response = await self._entity_query("async_get_segments")
|
||||
return [_segment_from_dict(segment) for segment in response or []]
|
||||
@@ -0,0 +1,81 @@
|
||||
"""Sandbox proxy for ``valve`` entities."""
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from homeassistant.components.valve import (
|
||||
ATTR_CURRENT_POSITION,
|
||||
ATTR_IS_CLOSED,
|
||||
ValveEntity,
|
||||
ValveEntityFeature,
|
||||
ValveState,
|
||||
)
|
||||
|
||||
from . import SandboxProxyEntity
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..bridge import SandboxBridge, SandboxEntityDescription
|
||||
|
||||
|
||||
# pylint: disable-next=home-assistant-enforce-class-module
|
||||
class SandboxValveEntity(SandboxProxyEntity, ValveEntity):
|
||||
"""Proxy for a ``valve`` entity in a sandbox."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
bridge: SandboxBridge,
|
||||
description: SandboxEntityDescription,
|
||||
) -> None:
|
||||
"""Wrap ``supported_features`` as ``ValveEntityFeature``."""
|
||||
super().__init__(bridge, description)
|
||||
self._attr_supported_features = ValveEntityFeature(
|
||||
description.supported_features or 0
|
||||
)
|
||||
|
||||
@property
|
||||
def reports_position(self) -> bool:
|
||||
"""Mirror the sandbox-side flag."""
|
||||
return bool(self.description.capabilities.get("reports_position", False))
|
||||
|
||||
@property
|
||||
def is_opening(self) -> bool | None:
|
||||
"""True iff cached state is ``opening``."""
|
||||
return self._state_cache.get("state") == ValveState.OPENING
|
||||
|
||||
@property
|
||||
def is_closing(self) -> bool | None:
|
||||
"""True iff cached state is ``closing``."""
|
||||
return self._state_cache.get("state") == ValveState.CLOSING
|
||||
|
||||
@property
|
||||
def is_closed(self) -> bool | None:
|
||||
"""Derive closed from cached state / ATTR_IS_CLOSED."""
|
||||
if (value := self._state_cache.get(ATTR_IS_CLOSED)) is not None:
|
||||
return bool(value)
|
||||
state = self._state_cache.get("state")
|
||||
if state == ValveState.CLOSED:
|
||||
return True
|
||||
if state == ValveState.OPEN:
|
||||
return False
|
||||
return None
|
||||
|
||||
@property
|
||||
def current_valve_position(self) -> int | None:
|
||||
"""Return the cached current position."""
|
||||
value = self._state_cache.get(ATTR_CURRENT_POSITION)
|
||||
return None if value is None else int(value)
|
||||
|
||||
async def async_open_valve(self) -> None:
|
||||
"""Forward open_valve."""
|
||||
await self._call_service("open_valve")
|
||||
|
||||
async def async_close_valve(self) -> None:
|
||||
"""Forward close_valve."""
|
||||
await self._call_service("close_valve")
|
||||
|
||||
async def async_set_valve_position(self, position: int) -> None:
|
||||
"""Forward set_valve_position."""
|
||||
await self._call_service("set_valve_position", position=position)
|
||||
|
||||
async def async_stop_valve(self) -> None:
|
||||
"""Forward stop_valve."""
|
||||
await self._call_service("stop_valve")
|
||||
@@ -0,0 +1,135 @@
|
||||
"""Sandbox proxy for ``water_heater`` entities."""
|
||||
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from homeassistant.components.water_heater import (
|
||||
ATTR_CURRENT_TEMPERATURE,
|
||||
ATTR_MAX_TEMP,
|
||||
ATTR_MIN_TEMP,
|
||||
ATTR_OPERATION_LIST,
|
||||
ATTR_TARGET_TEMP_HIGH,
|
||||
ATTR_TARGET_TEMP_LOW,
|
||||
ATTR_TARGET_TEMP_STEP,
|
||||
ATTR_TEMPERATURE,
|
||||
WaterHeaterEntity,
|
||||
WaterHeaterEntityFeature,
|
||||
)
|
||||
from homeassistant.const import UnitOfTemperature
|
||||
|
||||
from . import SandboxProxyEntity
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..bridge import SandboxBridge, SandboxEntityDescription
|
||||
|
||||
|
||||
# pylint: disable-next=home-assistant-enforce-class-module
|
||||
class SandboxWaterHeaterEntity(SandboxProxyEntity, WaterHeaterEntity):
|
||||
"""Proxy for a ``water_heater`` entity in a sandbox."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
bridge: SandboxBridge,
|
||||
description: SandboxEntityDescription,
|
||||
) -> None:
|
||||
"""Wrap ``supported_features`` as ``WaterHeaterEntityFeature``."""
|
||||
super().__init__(bridge, description)
|
||||
self._attr_supported_features = WaterHeaterEntityFeature(
|
||||
description.supported_features or 0
|
||||
)
|
||||
|
||||
@property
|
||||
def temperature_unit(self) -> str:
|
||||
"""Return the unit declared by the sandbox-side entity."""
|
||||
return str(
|
||||
self.description.capabilities.get(
|
||||
"temperature_unit", UnitOfTemperature.CELSIUS
|
||||
)
|
||||
)
|
||||
|
||||
@property
|
||||
def current_operation(self) -> str | None:
|
||||
"""Return the cached current operation."""
|
||||
value = self._state_cache.get("state")
|
||||
if value in (None, "unavailable", "unknown"):
|
||||
return None
|
||||
return value
|
||||
|
||||
@property
|
||||
def operation_list(self) -> list[str] | None:
|
||||
"""Return the configured operation list."""
|
||||
value = self.description.capabilities.get(ATTR_OPERATION_LIST)
|
||||
return list(value) if value else None
|
||||
|
||||
@property
|
||||
def current_temperature(self) -> float | None:
|
||||
"""Return the cached current temperature."""
|
||||
value = self._state_cache.get(ATTR_CURRENT_TEMPERATURE)
|
||||
return None if value is None else float(value)
|
||||
|
||||
@property
|
||||
def target_temperature(self) -> float | None:
|
||||
"""Return the cached target temperature."""
|
||||
value = self._state_cache.get(ATTR_TEMPERATURE)
|
||||
return None if value is None else float(value)
|
||||
|
||||
@property
|
||||
def target_temperature_high(self) -> float | None:
|
||||
"""Return the cached high target temperature."""
|
||||
value = self._state_cache.get(ATTR_TARGET_TEMP_HIGH)
|
||||
return None if value is None else float(value)
|
||||
|
||||
@property
|
||||
def target_temperature_low(self) -> float | None:
|
||||
"""Return the cached low target temperature."""
|
||||
value = self._state_cache.get(ATTR_TARGET_TEMP_LOW)
|
||||
return None if value is None else float(value)
|
||||
|
||||
@property
|
||||
def target_temperature_step(self) -> float | None:
|
||||
"""Return the configured target temperature step."""
|
||||
value = self.description.capabilities.get(ATTR_TARGET_TEMP_STEP)
|
||||
return float(value) if value is not None else None
|
||||
|
||||
@property
|
||||
def min_temp(self) -> float:
|
||||
"""Return the configured minimum temperature."""
|
||||
value = self.description.capabilities.get(ATTR_MIN_TEMP)
|
||||
return float(value) if value is not None else super().min_temp
|
||||
|
||||
@property
|
||||
def max_temp(self) -> float:
|
||||
"""Return the configured maximum temperature."""
|
||||
value = self.description.capabilities.get(ATTR_MAX_TEMP)
|
||||
return float(value) if value is not None else super().max_temp
|
||||
|
||||
@property
|
||||
def is_away_mode_on(self) -> bool | None:
|
||||
"""Return the cached away-mode flag."""
|
||||
value = self._state_cache.get("away_mode")
|
||||
if value is None:
|
||||
return None
|
||||
return value == "on"
|
||||
|
||||
async def async_set_temperature(self, **kwargs: Any) -> None:
|
||||
"""Forward set_temperature."""
|
||||
await self._call_service("set_temperature", **kwargs)
|
||||
|
||||
async def async_set_operation_mode(self, operation_mode: str) -> None:
|
||||
"""Forward set_operation_mode."""
|
||||
await self._call_service("set_operation_mode", operation_mode=operation_mode)
|
||||
|
||||
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||
"""Forward turn_on."""
|
||||
await self._call_service("turn_on", **kwargs)
|
||||
|
||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||
"""Forward turn_off."""
|
||||
await self._call_service("turn_off", **kwargs)
|
||||
|
||||
async def async_turn_away_mode_on(self) -> None:
|
||||
"""Forward turn_away_mode_on."""
|
||||
await self._call_service("turn_away_mode_on")
|
||||
|
||||
async def async_turn_away_mode_off(self) -> None:
|
||||
"""Forward turn_away_mode_off."""
|
||||
await self._call_service("turn_away_mode_off")
|
||||
@@ -0,0 +1,110 @@
|
||||
"""Sandbox proxy for ``weather`` entities."""
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from homeassistant.components.weather import (
|
||||
ATTR_WEATHER_HUMIDITY,
|
||||
ATTR_WEATHER_TEMPERATURE,
|
||||
ATTR_WEATHER_TEMPERATURE_UNIT,
|
||||
ATTR_WEATHER_WIND_BEARING,
|
||||
ATTR_WEATHER_WIND_SPEED,
|
||||
ATTR_WEATHER_WIND_SPEED_UNIT,
|
||||
Forecast,
|
||||
WeatherEntity,
|
||||
WeatherEntityFeature,
|
||||
)
|
||||
|
||||
from . import SandboxProxyEntity
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..bridge import SandboxBridge, SandboxEntityDescription
|
||||
|
||||
|
||||
# pylint: disable-next=home-assistant-enforce-class-module
|
||||
class SandboxWeatherEntity(SandboxProxyEntity, WeatherEntity):
|
||||
"""Proxy for a ``weather`` entity in a sandbox.
|
||||
|
||||
The proxy mirrors the condition + instantaneous attributes. Forecasts ride
|
||||
the ``weather.get_forecasts`` ``SupportsResponse`` service: each
|
||||
``async_forecast_*`` method forwards a one-shot query and returns the real
|
||||
entity's forecast list. The streaming ``weather/subscribe_forecast`` WS
|
||||
command still has no push primitive, so it sees only that first fetch. See
|
||||
``sandbox/docs/query-shaped-rpcs.md``.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
bridge: SandboxBridge,
|
||||
description: SandboxEntityDescription,
|
||||
) -> None:
|
||||
"""Wrap ``supported_features`` as ``WeatherEntityFeature``."""
|
||||
super().__init__(bridge, description)
|
||||
self._attr_supported_features = WeatherEntityFeature(
|
||||
description.supported_features or 0
|
||||
)
|
||||
|
||||
@property
|
||||
def condition(self) -> str | None:
|
||||
"""Return the cached weather condition."""
|
||||
value = self._state_cache.get("state")
|
||||
if value in (None, "unavailable", "unknown"):
|
||||
return None
|
||||
return value
|
||||
|
||||
@property
|
||||
def native_temperature(self) -> float | None:
|
||||
"""Return the cached temperature."""
|
||||
value = self._state_cache.get(ATTR_WEATHER_TEMPERATURE)
|
||||
return None if value is None else float(value)
|
||||
|
||||
@property
|
||||
def native_temperature_unit(self) -> str | None:
|
||||
"""Return the cached temperature unit."""
|
||||
return self._state_cache.get(ATTR_WEATHER_TEMPERATURE_UNIT)
|
||||
|
||||
@property
|
||||
def humidity(self) -> float | None:
|
||||
"""Return the cached humidity."""
|
||||
value = self._state_cache.get(ATTR_WEATHER_HUMIDITY)
|
||||
return None if value is None else float(value)
|
||||
|
||||
@property
|
||||
def native_wind_speed(self) -> float | None:
|
||||
"""Return the cached wind speed."""
|
||||
value = self._state_cache.get(ATTR_WEATHER_WIND_SPEED)
|
||||
return None if value is None else float(value)
|
||||
|
||||
@property
|
||||
def native_wind_speed_unit(self) -> str | None:
|
||||
"""Return the cached wind speed unit."""
|
||||
return self._state_cache.get(ATTR_WEATHER_WIND_SPEED_UNIT)
|
||||
|
||||
@property
|
||||
def wind_bearing(self) -> float | str | None:
|
||||
"""Return the cached wind bearing."""
|
||||
return self._state_cache.get(ATTR_WEATHER_WIND_BEARING)
|
||||
|
||||
async def _async_forecast(self, forecast_type: str) -> list[Forecast]:
|
||||
"""Forward a forecast query as the ``weather.get_forecasts`` service.
|
||||
|
||||
The service response is keyed by the (sandbox-side) entity_id and wraps
|
||||
the list under ``forecast``. ``Forecast`` is a plain TypedDict, so the
|
||||
unwrapped list crosses verbatim with no rebuild.
|
||||
"""
|
||||
response = await self._call_service(
|
||||
"get_forecasts", return_response=True, type=forecast_type
|
||||
)
|
||||
entity_response = response.get(self.description.sandbox_entity_id, {})
|
||||
return entity_response.get("forecast", [])
|
||||
|
||||
async def async_forecast_daily(self) -> list[Forecast] | None:
|
||||
"""Return the daily forecast via ``weather.get_forecasts``."""
|
||||
return await self._async_forecast("daily")
|
||||
|
||||
async def async_forecast_hourly(self) -> list[Forecast] | None:
|
||||
"""Return the hourly forecast via ``weather.get_forecasts``."""
|
||||
return await self._async_forecast("hourly")
|
||||
|
||||
async def async_forecast_twice_daily(self) -> list[Forecast] | None:
|
||||
"""Return the twice-daily forecast via ``weather.get_forecasts``."""
|
||||
return await self._async_forecast("twice_daily")
|
||||
@@ -0,0 +1,686 @@
|
||||
"""Sandbox — subprocess lifecycle and supervision.
|
||||
|
||||
The manager owns one supervised subprocess per sandbox group
|
||||
(``main`` / ``built-in`` / ``custom``); callers invoke
|
||||
:meth:`SandboxManager.ensure_started` lazily as config entries are routed.
|
||||
|
||||
The contract between manager and runtime is:
|
||||
|
||||
* the manager launches ``python -m hass_client.sandbox`` and tells it
|
||||
which control-channel transport to use via ``--url``
|
||||
* the runtime opens the control channel and sends a :data:`MSG_READY`
|
||||
frame as its first message once it is up (no stdout text marker)
|
||||
* on ``SIGTERM`` the runtime exits cleanly
|
||||
|
||||
Two transports are supported (selected by :class:`SandboxManager`'s
|
||||
``transport`` option, defaulting to ``stdio``):
|
||||
|
||||
* **stdio** — frames ride the subprocess's stdin/stdout pipes
|
||||
(``--url stdio://``); the default, unchanged from earlier phases.
|
||||
* **unix** — the manager opens a unix-domain socket, passes its path as
|
||||
``--url unix://<path>``, and the runtime dials back; the manager is the
|
||||
server. Both transports share :class:`~.channel.StreamTransport`'s
|
||||
length-prefixed framing, so there is no dedicated unix transport class.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from collections import deque
|
||||
from collections.abc import Awaitable, Callable
|
||||
import contextlib
|
||||
from dataclasses import dataclass
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from .channel import Channel, ChannelClosedError, ChannelRemoteError
|
||||
from .codec_protobuf import ProtobufCodec
|
||||
from .protocol import MSG_READY, MSG_SHUTDOWN
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
DEFAULT_RESTART_LIMIT = 3
|
||||
DEFAULT_RESTART_WINDOW = 60.0
|
||||
DEFAULT_RESTART_BACKOFF = 1.0
|
||||
DEFAULT_READY_TIMEOUT = 30.0
|
||||
DEFAULT_SHUTDOWN_GRACE = 10.0
|
||||
|
||||
# A command factory receives ``(group, url)`` — the manager decides the
|
||||
# control-channel URL from its transport and hands it to the factory so the
|
||||
# spawned argv carries the right ``--url``.
|
||||
CommandFactory = Callable[[str, str], list[str]]
|
||||
|
||||
# Supported control-channel transports.
|
||||
TRANSPORT_STDIO = "stdio"
|
||||
TRANSPORT_UNIX = "unix"
|
||||
_TRANSPORTS = (TRANSPORT_STDIO, TRANSPORT_UNIX)
|
||||
# The reply is a protobuf ``ShutdownResult``; typed loosely to keep the
|
||||
# manager free of a proto import.
|
||||
ShutdownReplyCallback = Callable[[str, Any], Awaitable[None]]
|
||||
|
||||
|
||||
class SandboxError(Exception):
|
||||
"""Base class for sandbox lifecycle errors."""
|
||||
|
||||
|
||||
class SandboxStartError(SandboxError):
|
||||
"""Sandbox did not reach the ``running`` state."""
|
||||
|
||||
|
||||
class SandboxFailedError(SandboxError):
|
||||
"""Sandbox crashed more than the configured restart limit allows."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SandboxConfig:
|
||||
"""Tunables for one supervised sandbox process."""
|
||||
|
||||
restart_limit: int = DEFAULT_RESTART_LIMIT
|
||||
restart_window: float = DEFAULT_RESTART_WINDOW
|
||||
restart_backoff: float = DEFAULT_RESTART_BACKOFF
|
||||
ready_timeout: float = DEFAULT_READY_TIMEOUT
|
||||
shutdown_grace: float = DEFAULT_SHUTDOWN_GRACE
|
||||
|
||||
|
||||
class SandboxProcess:
|
||||
"""One supervised sandbox subprocess.
|
||||
|
||||
States cycle through ``stopped`` → ``starting`` → ``running`` →
|
||||
(``starting`` on crash) → ``failed`` once the restart budget is spent.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
group: str,
|
||||
command_factory: Callable[[str], list[str]],
|
||||
config: SandboxConfig,
|
||||
*,
|
||||
transport: str = TRANSPORT_STDIO,
|
||||
on_failed: Callable[[str], None] | None = None,
|
||||
on_channel_ready: Callable[[str, Channel], None] | None = None,
|
||||
on_shutdown_reply: ShutdownReplyCallback | None = None,
|
||||
) -> None:
|
||||
"""Initialise a supervised sandbox subprocess.
|
||||
|
||||
``command_factory`` is called with the control-channel URL the
|
||||
chosen ``transport`` requires (``stdio://`` or ``unix://<path>``)
|
||||
and returns the argv to spawn.
|
||||
|
||||
``on_channel_ready`` is invoked with the live :class:`Channel` as
|
||||
soon as it is opened — before the runtime's :data:`MSG_READY`
|
||||
frame arrives — so its handlers are in place before the runtime's
|
||||
own warm-load round-trip lands. It runs synchronously on the
|
||||
manager's loop.
|
||||
|
||||
``on_shutdown_reply`` is invoked with the runtime's reply to
|
||||
:data:`MSG_SHUTDOWN` so the caller can persist any
|
||||
``restore_state`` payload before the subprocess exits.
|
||||
"""
|
||||
self.group = group
|
||||
self._command_factory = command_factory
|
||||
self._config = config
|
||||
self._transport = transport
|
||||
self._on_failed = on_failed
|
||||
self._on_channel_ready = on_channel_ready
|
||||
self._on_shutdown_reply = on_shutdown_reply
|
||||
self._state: str = "stopped"
|
||||
self._process: asyncio.subprocess.Process | None = None
|
||||
self._supervisor: asyncio.Task[None] | None = None
|
||||
self._ready: asyncio.Event = asyncio.Event()
|
||||
self._stopped: asyncio.Event = asyncio.Event()
|
||||
self._stopped.set()
|
||||
self._stopping: bool = False
|
||||
self._attempts: deque[float] = deque()
|
||||
self._channel: Channel | None = None
|
||||
|
||||
@property
|
||||
def state(self) -> str:
|
||||
"""Current lifecycle state."""
|
||||
return self._state
|
||||
|
||||
@property
|
||||
def pid(self) -> int | None:
|
||||
"""PID of the live subprocess, or ``None`` if not running."""
|
||||
proc = self._process
|
||||
return proc.pid if proc is not None and proc.returncode is None else None
|
||||
|
||||
@property
|
||||
def channel(self) -> Channel | None:
|
||||
"""The active control channel, or None when not running."""
|
||||
return self._channel
|
||||
|
||||
async def start(self) -> None:
|
||||
"""Spawn the subprocess and block until it is ``running``.
|
||||
|
||||
Raises :class:`SandboxStartError` if the supervisor gives up or the
|
||||
ready handshake times out.
|
||||
"""
|
||||
if self._supervisor is not None:
|
||||
return
|
||||
self._stopping = False
|
||||
self._stopped.clear()
|
||||
self._ready.clear()
|
||||
self._state = "starting"
|
||||
self._attempts.clear()
|
||||
self._supervisor = asyncio.create_task(
|
||||
self._supervise(), name=f"sandbox[{self.group}]"
|
||||
)
|
||||
|
||||
ready_task = asyncio.create_task(self._ready.wait())
|
||||
stopped_task = asyncio.create_task(self._stopped.wait())
|
||||
try:
|
||||
await asyncio.wait(
|
||||
{ready_task, stopped_task},
|
||||
return_when=asyncio.FIRST_COMPLETED,
|
||||
timeout=self._config.ready_timeout,
|
||||
)
|
||||
finally:
|
||||
for task in (ready_task, stopped_task):
|
||||
if not task.done():
|
||||
task.cancel()
|
||||
with contextlib.suppress(asyncio.CancelledError):
|
||||
await task
|
||||
|
||||
if self._state == "running":
|
||||
return
|
||||
|
||||
await self.stop()
|
||||
raise SandboxStartError(
|
||||
f"Sandbox {self.group!r} failed to start (state={self._state})"
|
||||
)
|
||||
|
||||
async def stop(self) -> None:
|
||||
"""Terminate the subprocess and wait for the supervisor to exit."""
|
||||
self._stopping = True
|
||||
proc = self._process
|
||||
if proc is not None and proc.returncode is None:
|
||||
with contextlib.suppress(ProcessLookupError):
|
||||
proc.terminate()
|
||||
try:
|
||||
await asyncio.wait_for(proc.wait(), timeout=self._config.shutdown_grace)
|
||||
except TimeoutError:
|
||||
_LOGGER.warning(
|
||||
"Sandbox %s did not exit on SIGTERM within %.1fs; sending SIGKILL",
|
||||
self.group,
|
||||
self._config.shutdown_grace,
|
||||
)
|
||||
with contextlib.suppress(ProcessLookupError):
|
||||
proc.kill()
|
||||
with contextlib.suppress(BaseException):
|
||||
await proc.wait()
|
||||
|
||||
supervisor = self._supervisor
|
||||
if supervisor is not None:
|
||||
try:
|
||||
await supervisor
|
||||
finally:
|
||||
self._supervisor = None
|
||||
|
||||
if self._state != "failed":
|
||||
self._state = "stopped"
|
||||
|
||||
async def async_graceful_shutdown(self, *, timeout: float) -> bool:
|
||||
"""Ask the runtime to unload + flush, then wait for exit.
|
||||
|
||||
Sends ``sandbox/shutdown`` over the live channel and waits up
|
||||
to ``timeout`` for the runtime to reply and then exit on its
|
||||
own. Sets :attr:`_stopping` first so the supervisor does not
|
||||
treat the clean exit as a crash. Returns ``True`` if the process
|
||||
exited within the grace, ``False`` if anything went wrong
|
||||
(timeout, no channel, channel closed) — in which case the
|
||||
caller should fall through to :meth:`stop` for SIGTERM/SIGKILL.
|
||||
|
||||
``on_reply`` is invoked with the dict the runtime returns (the
|
||||
``restore_state`` payload + summary counters) so the caller can
|
||||
persist it before the channel goes away.
|
||||
"""
|
||||
self._stopping = True
|
||||
channel = self._channel
|
||||
proc = self._process
|
||||
if channel is None or channel.closed or proc is None:
|
||||
return False
|
||||
if proc.returncode is not None:
|
||||
return True
|
||||
|
||||
try:
|
||||
reply = await channel.call(MSG_SHUTDOWN, None, timeout=timeout)
|
||||
except TimeoutError:
|
||||
_LOGGER.warning(
|
||||
"Sandbox %s did not reply to shutdown within %.1fs",
|
||||
self.group,
|
||||
timeout,
|
||||
)
|
||||
return False
|
||||
except (ChannelClosedError, ChannelRemoteError) as err:
|
||||
_LOGGER.debug(
|
||||
"Sandbox %s shutdown call failed (%s); falling back to SIGTERM",
|
||||
self.group,
|
||||
err,
|
||||
)
|
||||
return False
|
||||
|
||||
callback = self._on_shutdown_reply
|
||||
if callback is not None:
|
||||
try:
|
||||
await callback(self.group, reply)
|
||||
except Exception:
|
||||
_LOGGER.exception(
|
||||
"Sandbox %s on_shutdown_reply callback raised", self.group
|
||||
)
|
||||
|
||||
try:
|
||||
await asyncio.wait_for(proc.wait(), timeout=timeout)
|
||||
except TimeoutError:
|
||||
_LOGGER.warning(
|
||||
"Sandbox %s acked shutdown but did not exit within %.1fs",
|
||||
self.group,
|
||||
timeout,
|
||||
)
|
||||
return False
|
||||
return True
|
||||
|
||||
async def _supervise(self) -> None:
|
||||
"""Loop spawning the subprocess, applying the restart budget."""
|
||||
try:
|
||||
while not self._stopping:
|
||||
now = time.monotonic()
|
||||
while (
|
||||
self._attempts
|
||||
and now - self._attempts[0] > self._config.restart_window
|
||||
):
|
||||
self._attempts.popleft()
|
||||
if len(self._attempts) >= self._config.restart_limit:
|
||||
_LOGGER.error(
|
||||
"Sandbox %s exceeded restart limit (%d attempts in %.0fs);"
|
||||
" marking failed",
|
||||
self.group,
|
||||
self._config.restart_limit,
|
||||
self._config.restart_window,
|
||||
)
|
||||
self._state = "failed"
|
||||
if self._on_failed is not None:
|
||||
try:
|
||||
self._on_failed(self.group)
|
||||
except Exception:
|
||||
_LOGGER.exception(
|
||||
"Sandbox %s on_failed callback raised", self.group
|
||||
)
|
||||
return
|
||||
|
||||
self._attempts.append(now)
|
||||
self._state = "starting"
|
||||
self._ready.clear()
|
||||
await self._run_one()
|
||||
|
||||
if self._stopping:
|
||||
return
|
||||
|
||||
_LOGGER.warning(
|
||||
"Sandbox %s exited unexpectedly; restarting in %.2fs",
|
||||
self.group,
|
||||
self._config.restart_backoff,
|
||||
)
|
||||
try:
|
||||
await asyncio.sleep(self._config.restart_backoff)
|
||||
except asyncio.CancelledError:
|
||||
return
|
||||
finally:
|
||||
if self._state != "failed":
|
||||
self._state = "stopped"
|
||||
self._stopped.set()
|
||||
|
||||
async def _run_one(self) -> None:
|
||||
"""Spawn one process attempt and wait for it to exit."""
|
||||
if self._transport == TRANSPORT_UNIX:
|
||||
await self._run_one_unix()
|
||||
else:
|
||||
await self._run_one_stdio()
|
||||
|
||||
async def _run_one_stdio(self) -> None:
|
||||
"""Spawn over stdio: the channel rides the subprocess's pipes."""
|
||||
proc = await self._spawn(self._command_factory("stdio://"))
|
||||
if proc is None:
|
||||
return
|
||||
self._process = proc
|
||||
try:
|
||||
# Open the channel up front — stdout carries nothing but frames
|
||||
# now. Handlers go on before the reader starts so the runtime's
|
||||
# warm-load round-trip (and any early push) is never dropped.
|
||||
assert proc.stdout is not None
|
||||
assert proc.stdin is not None
|
||||
self._channel = self._build_channel(proc.stdout, proc.stdin)
|
||||
await self._supervise_until_exit(proc, self._channel, drain_stdout=False)
|
||||
finally:
|
||||
self._process = None
|
||||
|
||||
async def _run_one_unix(self) -> None:
|
||||
"""Spawn over a unix socket: the manager listens, runtime dials back.
|
||||
|
||||
The socket lives in a short-lived per-attempt tempdir rather than
|
||||
under the (possibly long) config dir, sidestepping the ~108-char
|
||||
``sun_path`` limit on Linux. It is unlinked when the server closes
|
||||
and the tempdir is removed on the way out — no leaked socket file.
|
||||
"""
|
||||
socket_dir = tempfile.mkdtemp(prefix=f"sandbox_{self.group}_")
|
||||
socket_path = os.path.join(socket_dir, "control.sock")
|
||||
loop = asyncio.get_running_loop()
|
||||
connected: asyncio.Future[tuple[asyncio.StreamReader, asyncio.StreamWriter]] = (
|
||||
loop.create_future()
|
||||
)
|
||||
|
||||
def _on_connect(
|
||||
reader: asyncio.StreamReader, writer: asyncio.StreamWriter
|
||||
) -> None:
|
||||
if connected.done():
|
||||
# Only the first (runtime) connection is honoured.
|
||||
writer.close()
|
||||
return
|
||||
connected.set_result((reader, writer))
|
||||
|
||||
server = await asyncio.start_unix_server(_on_connect, path=socket_path)
|
||||
try:
|
||||
proc = await self._spawn(self._command_factory(f"unix://{socket_path}"))
|
||||
if proc is None:
|
||||
return
|
||||
self._process = proc
|
||||
try:
|
||||
# The runtime connects back as part of its startup; race the
|
||||
# accept against an early exit so a crash-before-connect does
|
||||
# not hang here forever.
|
||||
exit_task = asyncio.create_task(proc.wait())
|
||||
waiters: set[asyncio.Future[Any]] = {connected, exit_task}
|
||||
try:
|
||||
await asyncio.wait(waiters, return_when=asyncio.FIRST_COMPLETED)
|
||||
finally:
|
||||
if not exit_task.done():
|
||||
exit_task.cancel()
|
||||
with contextlib.suppress(asyncio.CancelledError):
|
||||
await exit_task
|
||||
if not connected.done():
|
||||
_LOGGER.warning(
|
||||
"Sandbox %s exited before connecting to its control socket",
|
||||
self.group,
|
||||
)
|
||||
return
|
||||
reader, writer = connected.result()
|
||||
self._channel = self._build_channel(reader, writer)
|
||||
await self._supervise_until_exit(proc, self._channel, drain_stdout=True)
|
||||
finally:
|
||||
self._process = None
|
||||
finally:
|
||||
server.close()
|
||||
# The accepted connection may linger in the server's client set:
|
||||
# when the runtime exits, the channel's read loop sees EOF and
|
||||
# marks the channel closed, so the later ``channel.close()`` is a
|
||||
# no-op that never closes the accepted transport. Force-close any
|
||||
# such leftover so ``wait_closed()`` cannot block forever.
|
||||
server.close_clients()
|
||||
with contextlib.suppress(Exception):
|
||||
await server.wait_closed()
|
||||
shutil.rmtree(socket_dir, ignore_errors=True)
|
||||
|
||||
async def _spawn(self, command: list[str]) -> asyncio.subprocess.Process | None:
|
||||
"""Spawn the subprocess, returning ``None`` if it cannot start."""
|
||||
try:
|
||||
return await asyncio.create_subprocess_exec(
|
||||
*command,
|
||||
stdin=asyncio.subprocess.PIPE,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
except OSError:
|
||||
_LOGGER.exception(
|
||||
"Sandbox %s could not be spawned (%s)", self.group, command
|
||||
)
|
||||
return None
|
||||
|
||||
async def _supervise_until_exit(
|
||||
self,
|
||||
proc: asyncio.subprocess.Process,
|
||||
channel: Channel,
|
||||
*,
|
||||
drain_stdout: bool,
|
||||
) -> None:
|
||||
"""Wire the ready handshake, run until the process exits, clean up.
|
||||
|
||||
Shared by both transports — they reach here with a live channel and
|
||||
a running process; only how the channel's byte pipe was obtained
|
||||
differs. ``drain_stdout`` is set for the unix transport, where the
|
||||
subprocess's stdout pipe is unused (frames ride the socket) and must
|
||||
still be drained so its buffer never fills.
|
||||
"""
|
||||
ready_frame = asyncio.Event()
|
||||
|
||||
async def _on_ready(_payload: object) -> None:
|
||||
ready_frame.set()
|
||||
|
||||
channel.register(MSG_READY, _on_ready)
|
||||
if self._on_channel_ready is not None:
|
||||
try:
|
||||
self._on_channel_ready(self.group, channel)
|
||||
except Exception:
|
||||
_LOGGER.exception(
|
||||
"Sandbox %s on_channel_ready callback raised", self.group
|
||||
)
|
||||
channel.start()
|
||||
|
||||
ready_task = asyncio.create_task(ready_frame.wait())
|
||||
exit_task = asyncio.create_task(proc.wait())
|
||||
drain_tasks = [asyncio.create_task(self._drain_stream(proc.stderr, "stderr"))]
|
||||
if drain_stdout:
|
||||
drain_tasks.append(
|
||||
asyncio.create_task(self._drain_stream(proc.stdout, "stdout"))
|
||||
)
|
||||
|
||||
try:
|
||||
await asyncio.wait(
|
||||
{ready_task, exit_task}, return_when=asyncio.FIRST_COMPLETED
|
||||
)
|
||||
if ready_task.done() and not ready_task.cancelled():
|
||||
self._state = "running"
|
||||
self._ready.set()
|
||||
# Hold here until the process exits.
|
||||
await exit_task
|
||||
finally:
|
||||
for task in (ready_task, exit_task, *drain_tasks):
|
||||
if not task.done():
|
||||
task.cancel()
|
||||
with contextlib.suppress(asyncio.CancelledError):
|
||||
await task
|
||||
if self._channel is not None:
|
||||
await self._channel.close()
|
||||
self._channel = None
|
||||
self._ready.clear()
|
||||
|
||||
def _build_channel(
|
||||
self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter
|
||||
) -> Channel:
|
||||
"""Wrap a reader/writer pair in a :class:`Channel`.
|
||||
|
||||
Length-prefixed channel frames cross end-to-end — there is no text
|
||||
preamble. The pair comes from the subprocess's stdout/stdin (stdio)
|
||||
or from the accepted unix-socket connection (unix); the channel core
|
||||
is identical either way.
|
||||
"""
|
||||
return Channel(reader, writer, name=self.group, codec=ProtobufCodec())
|
||||
|
||||
async def _drain_stream(
|
||||
self, stream: asyncio.StreamReader | None, name: str
|
||||
) -> None:
|
||||
"""Read a child stream so its buffer never fills."""
|
||||
if stream is None:
|
||||
return
|
||||
while True:
|
||||
line = await stream.readline()
|
||||
if not line:
|
||||
return
|
||||
text = line.decode("utf-8", errors="replace").rstrip()
|
||||
if text:
|
||||
_LOGGER.debug("sandbox %s %s: %s", self.group, name, text)
|
||||
|
||||
|
||||
class SandboxManager:
|
||||
"""Owns one :class:`SandboxProcess` per group, started lazily."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
*,
|
||||
command_factory: CommandFactory | None = None,
|
||||
config: SandboxConfig | None = None,
|
||||
on_failed: Callable[[str], None] | None = None,
|
||||
on_channel_ready: Callable[[str, Channel], None] | None = None,
|
||||
on_shutdown_reply: ShutdownReplyCallback | None = None,
|
||||
transport: str = TRANSPORT_STDIO,
|
||||
) -> None:
|
||||
"""Initialise the manager.
|
||||
|
||||
``command_factory`` lets tests substitute the spawned command; it is
|
||||
called with ``(group, url)`` and the default builds the
|
||||
``python -m hass_client.sandbox`` argv that
|
||||
:class:`hass_client.sandbox.SandboxRuntime` consumes.
|
||||
|
||||
``transport`` selects the control-channel transport for every
|
||||
spawned sandbox: ``"stdio"`` (default — unchanged behavior) or
|
||||
``"unix"`` (the manager opens a unix socket and the runtime dials
|
||||
back). Unix is opt-in so existing deployments keep using stdio.
|
||||
|
||||
``on_channel_ready`` is invoked once a sandbox's control channel is
|
||||
live; the router uses it to register inbound flow handlers
|
||||
(e.g., ``sandbox/notify_flow_changed``).
|
||||
"""
|
||||
self._hass = hass
|
||||
self._command_factory = command_factory or self._default_command
|
||||
self._config = config or SandboxConfig()
|
||||
self._on_failed = on_failed
|
||||
self._on_channel_ready = on_channel_ready
|
||||
self._on_shutdown_reply = on_shutdown_reply
|
||||
if transport not in _TRANSPORTS:
|
||||
raise ValueError(
|
||||
f"unknown sandbox transport {transport!r}; expected one of "
|
||||
f"{_TRANSPORTS}"
|
||||
)
|
||||
self._transport = transport
|
||||
self._sandboxes: dict[str, SandboxProcess] = {}
|
||||
self._locks: dict[str, asyncio.Lock] = {}
|
||||
|
||||
@property
|
||||
def shutdown_grace(self) -> float:
|
||||
"""Configured grace window for ``async_graceful_shutdown_all``."""
|
||||
return self._config.shutdown_grace
|
||||
|
||||
@property
|
||||
def sandboxes(self) -> dict[str, SandboxProcess]:
|
||||
"""Live read-only-ish view of the supervised processes."""
|
||||
return dict(self._sandboxes)
|
||||
|
||||
def get(self, group: str) -> SandboxProcess | None:
|
||||
"""Return the sandbox for ``group`` if one has ever been requested."""
|
||||
return self._sandboxes.get(group)
|
||||
|
||||
async def ensure_started(self, group: str) -> SandboxProcess:
|
||||
"""Return a running sandbox for ``group``, spawning it if needed.
|
||||
|
||||
Raises :class:`SandboxFailedError` if the sandbox has already
|
||||
exhausted its restart budget and :class:`SandboxStartError` if a
|
||||
fresh spawn cannot reach ``running``.
|
||||
"""
|
||||
lock = self._locks.setdefault(group, asyncio.Lock())
|
||||
async with lock:
|
||||
existing = self._sandboxes.get(group)
|
||||
if existing is not None:
|
||||
if existing.state in ("starting", "running"):
|
||||
return existing
|
||||
if existing.state == "failed":
|
||||
raise SandboxFailedError(f"Sandbox {group!r} is in a failed state")
|
||||
# Was stopped — drop the stale process and re-spawn.
|
||||
del self._sandboxes[group]
|
||||
|
||||
# Keeping the SandboxProcess in the map after a failed start lets
|
||||
# callers observe its state — ensure_started won't try to
|
||||
# restart a failed sandbox.
|
||||
def make_command(url: str) -> list[str]:
|
||||
return self._command_factory(group, url)
|
||||
|
||||
process = SandboxProcess(
|
||||
group,
|
||||
make_command,
|
||||
self._config,
|
||||
transport=self._transport,
|
||||
on_failed=self._on_failed,
|
||||
on_channel_ready=self._on_channel_ready,
|
||||
on_shutdown_reply=self._on_shutdown_reply,
|
||||
)
|
||||
self._sandboxes[group] = process
|
||||
await process.start()
|
||||
return process
|
||||
|
||||
async def async_stop(self, group: str) -> None:
|
||||
"""Stop one sandbox if it exists."""
|
||||
process = self._sandboxes.get(group)
|
||||
if process is None:
|
||||
return
|
||||
await process.stop()
|
||||
|
||||
async def async_stop_all(self) -> None:
|
||||
"""Stop every supervised sandbox in parallel."""
|
||||
if not self._sandboxes:
|
||||
return
|
||||
await asyncio.gather(
|
||||
*(process.stop() for process in self._sandboxes.values()),
|
||||
return_exceptions=True,
|
||||
)
|
||||
|
||||
async def async_graceful_shutdown_all(self, *, timeout: float) -> None:
|
||||
"""Ask every running sandbox to shut down gracefully.
|
||||
|
||||
Best-effort fan-out. Sandboxes that did not ack inside ``timeout``
|
||||
are left for :meth:`async_stop_all` to clean up with SIGTERM /
|
||||
SIGKILL — this method never raises.
|
||||
"""
|
||||
if not self._sandboxes:
|
||||
return
|
||||
await asyncio.gather(
|
||||
*(
|
||||
process.async_graceful_shutdown(timeout=timeout)
|
||||
for process in self._sandboxes.values()
|
||||
if process.state == "running"
|
||||
),
|
||||
return_exceptions=True,
|
||||
)
|
||||
|
||||
def _default_command(self, group: str, url: str) -> list[str]:
|
||||
"""Argv for ``python -m hass_client.sandbox``.
|
||||
|
||||
``url`` is the control-channel URL the manager's transport requires
|
||||
(``stdio://`` or ``unix://<path>``) — the runtime reads its scheme
|
||||
to pick the transport.
|
||||
"""
|
||||
return [
|
||||
sys.executable,
|
||||
"-m",
|
||||
"hass_client.sandbox",
|
||||
"--name",
|
||||
group,
|
||||
"--url",
|
||||
url,
|
||||
]
|
||||
|
||||
|
||||
__all__ = [
|
||||
"TRANSPORT_STDIO",
|
||||
"TRANSPORT_UNIX",
|
||||
"CommandFactory",
|
||||
"SandboxConfig",
|
||||
"SandboxError",
|
||||
"SandboxFailedError",
|
||||
"SandboxManager",
|
||||
"SandboxProcess",
|
||||
"SandboxStartError",
|
||||
"ShutdownReplyCallback",
|
||||
]
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"domain": "sandbox",
|
||||
"name": "Sandbox",
|
||||
"codeowners": [],
|
||||
"dependencies": ["websocket_api"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/sandbox",
|
||||
"integration_type": "system",
|
||||
"iot_class": "local_push",
|
||||
"quality_scale": "internal",
|
||||
"requirements": ["protobuf==6.32.0"]
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
"""Typed protobuf message registry + dynamic-field helpers.
|
||||
|
||||
This module is the codec's view of the wire: the ``type → (request_cls,
|
||||
result_cls)`` registry plus the small Struct/ListValue helpers that carry the
|
||||
genuinely dynamic payloads (service_data, target, state attributes,
|
||||
capabilities, the wrapped Store envelope, flow ``data``/``errors``/``context``)
|
||||
and the serialized voluptuous schema.
|
||||
|
||||
Mirrored verbatim across the no-cross-import boundary, exactly like
|
||||
:mod:`channel` / :mod:`protocol`: the same file lives at
|
||||
``hass_client.messages``. The relative ``._proto`` import resolves to each
|
||||
side's own checked-in gencode, so the two copies are byte-identical.
|
||||
|
||||
Numbers note: ``google.protobuf.Struct`` stores every number as a double, so
|
||||
an ``int`` that crosses inside a dynamic field comes back as a ``float``
|
||||
(``255`` → ``255.0``). Python's ``==`` treats the two as equal, so dict
|
||||
comparisons still hold; only an ``isinstance(x, int)`` check would notice.
|
||||
Everything with integer semantics that matters (``version``, ``minor_version``,
|
||||
``supported_features``) is an explicit ``int32`` field, not a Struct value.
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from google.protobuf.message import Message
|
||||
|
||||
# pylint: disable-next=no-name-in-module
|
||||
from google.protobuf.struct_pb2 import ListValue, Struct, Value
|
||||
|
||||
from ._proto import sandbox_pb2 as pb
|
||||
|
||||
# Wire type → (request message class, result message class). The result class
|
||||
# is ``None`` for one-way pushes (ready / state_changed / fire_event). The
|
||||
# codec resolves these from ``frame.type`` on both encode and decode.
|
||||
REGISTRY: dict[str, tuple[type[Message], type[Message] | None]] = {
|
||||
# handshake (push)
|
||||
"sandbox/ready": (pb.Ready, None),
|
||||
# main → sandbox
|
||||
"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),
|
||||
"sandbox/flow_init": (pb.FlowInit, pb.FlowResult),
|
||||
"sandbox/flow_step": (pb.FlowStep, pb.FlowResult),
|
||||
"sandbox/flow_abort": (pb.FlowAbort, pb.FlowAbortResult),
|
||||
# sandbox → main
|
||||
"sandbox/register_entity": (pb.EntityDescription, pb.RegisterEntityResult),
|
||||
"sandbox/unregister_entity": (pb.UnregisterEntity, pb.UnregisterEntityResult),
|
||||
"sandbox/state_changed": (pb.StateChanged, None),
|
||||
"sandbox/register_service": (pb.RegisterService, pb.RegisterServiceResult),
|
||||
"sandbox/unregister_service": (
|
||||
pb.UnregisterService,
|
||||
pb.UnregisterServiceResult,
|
||||
),
|
||||
"sandbox/fire_event": (pb.FireEvent, None),
|
||||
"sandbox/store_load": (pb.StoreLoad, pb.StoreLoadResult),
|
||||
"sandbox/store_save": (pb.StoreSave, pb.StoreSaveResult),
|
||||
"sandbox/store_remove": (pb.StoreRemove, pb.StoreRemoveResult),
|
||||
}
|
||||
|
||||
|
||||
# --- Struct / ListValue helpers -------------------------------------------
|
||||
|
||||
|
||||
def _value_to_py(value: Value) -> Any:
|
||||
"""Convert one ``google.protobuf.Value`` into a plain Python value."""
|
||||
kind = value.WhichOneof("kind")
|
||||
if kind == "null_value" or kind is None:
|
||||
return None
|
||||
if kind == "number_value":
|
||||
return value.number_value
|
||||
if kind == "string_value":
|
||||
return value.string_value
|
||||
if kind == "bool_value":
|
||||
return value.bool_value
|
||||
if kind == "struct_value":
|
||||
return struct_to_dict(value.struct_value)
|
||||
return [_value_to_py(item) for item in value.list_value.values]
|
||||
|
||||
|
||||
def struct_to_dict(struct: Struct) -> dict[str, Any]:
|
||||
"""Convert a ``Struct`` into a plain ``dict`` (empty Struct → ``{}``)."""
|
||||
return {key: _value_to_py(val) for key, val in struct.fields.items()}
|
||||
|
||||
|
||||
def dict_to_struct(data: dict[str, Any] | None) -> Struct:
|
||||
"""Convert a ``dict`` (or ``None``) into a ``Struct``."""
|
||||
struct = Struct()
|
||||
if data:
|
||||
struct.update(data)
|
||||
return struct
|
||||
|
||||
|
||||
def listvalue_to_list(list_value: ListValue) -> list[Any]:
|
||||
"""Convert a ``ListValue`` into a plain ``list``."""
|
||||
return [_value_to_py(item) for item in list_value.values]
|
||||
|
||||
|
||||
def list_to_listvalue(items: list[Any] | None) -> ListValue:
|
||||
"""Convert a ``list`` (or ``None``) into a ``ListValue``."""
|
||||
list_value = ListValue()
|
||||
if items:
|
||||
list_value.extend(items)
|
||||
return list_value
|
||||
|
||||
|
||||
# --- DeviceInfo bridging --------------------------------------------------
|
||||
|
||||
# Scalar string fields of the DeviceInfo proto, copied through verbatim when
|
||||
# present in the JSON-flattened device_info dict.
|
||||
_DEVICE_INFO_SCALARS = (
|
||||
"entry_type",
|
||||
"name",
|
||||
"manufacturer",
|
||||
"model",
|
||||
"model_id",
|
||||
"sw_version",
|
||||
"hw_version",
|
||||
"serial_number",
|
||||
"suggested_area",
|
||||
"configuration_url",
|
||||
"default_name",
|
||||
"default_manufacturer",
|
||||
"default_model",
|
||||
"translation_key",
|
||||
)
|
||||
|
||||
|
||||
def device_info_to_proto(flat: dict[str, Any] | None) -> pb.DeviceInfo | None:
|
||||
"""Build a ``DeviceInfo`` proto from the JSON-flattened device_info dict.
|
||||
|
||||
The sandbox-side serializer (``entity_bridge._serialise_device_info``)
|
||||
already flattens sets/tuples/enums: ``identifiers`` / ``connections`` are
|
||||
lists of two-element lists, ``via_device`` is a two-element list, and
|
||||
``entry_type`` is the enum's string value. This maps that shape onto the
|
||||
explicit proto fields.
|
||||
"""
|
||||
if not flat:
|
||||
return None
|
||||
info = pb.DeviceInfo()
|
||||
for key, raw in flat.items():
|
||||
if raw is None:
|
||||
continue
|
||||
if key in ("identifiers", "connections"):
|
||||
for pair in raw:
|
||||
if len(pair) == 2:
|
||||
getattr(info, key).add(key=str(pair[0]), value=str(pair[1]))
|
||||
elif key == "via_device":
|
||||
if len(raw) == 2:
|
||||
info.via_device.key = str(raw[0])
|
||||
info.via_device.value = str(raw[1])
|
||||
elif key in _DEVICE_INFO_SCALARS:
|
||||
setattr(info, key, str(raw))
|
||||
return info
|
||||
|
||||
|
||||
def make_entity_description(
|
||||
*,
|
||||
entry_id: str,
|
||||
domain: str,
|
||||
sandbox_entity_id: str,
|
||||
unique_id: str | None = None,
|
||||
name: str | None = None,
|
||||
icon: str | None = None,
|
||||
has_entity_name: bool = False,
|
||||
entity_category: str | None = None,
|
||||
device_class: str | None = None,
|
||||
supported_features: int = 0,
|
||||
translation_key: str | None = None,
|
||||
capabilities: dict[str, Any] | None = None,
|
||||
initial_state: str | None = None,
|
||||
initial_attributes: dict[str, Any] | None = None,
|
||||
device_info: dict[str, Any] | None = None,
|
||||
) -> pb.EntityDescription:
|
||||
"""Build a nested ``EntityDescription`` proto from flat fields.
|
||||
|
||||
Used by the sandbox entity bridge and by tests so neither has to hand-nest
|
||||
the ``EntityInfo`` / ``InitialState`` sub-messages. ``device_info`` is the
|
||||
JSON-flattened dict the entity bridge produces (see
|
||||
:func:`device_info_to_proto`).
|
||||
"""
|
||||
msg = pb.EntityDescription(
|
||||
entry_id=entry_id,
|
||||
domain=domain,
|
||||
sandbox_entity_id=sandbox_entity_id,
|
||||
has_entity_name=has_entity_name,
|
||||
)
|
||||
if unique_id is not None:
|
||||
msg.unique_id = unique_id
|
||||
description = msg.info.description
|
||||
if name is not None:
|
||||
description.name = name
|
||||
if icon is not None:
|
||||
description.icon = icon
|
||||
if entity_category is not None:
|
||||
description.entity_category = entity_category
|
||||
if device_class is not None:
|
||||
description.device_class = device_class
|
||||
description.supported_features = int(supported_features or 0)
|
||||
if translation_key is not None:
|
||||
description.translation_key = translation_key
|
||||
device = device_info_to_proto(device_info)
|
||||
if device is not None:
|
||||
msg.info.device_info.CopyFrom(device)
|
||||
if initial_state is not None:
|
||||
msg.initial.state = initial_state
|
||||
if capabilities:
|
||||
msg.initial.capabilities.update(capabilities)
|
||||
if initial_attributes:
|
||||
msg.initial.attributes.update(initial_attributes)
|
||||
return msg
|
||||
|
||||
|
||||
__all__ = [
|
||||
"REGISTRY",
|
||||
"device_info_to_proto",
|
||||
"dict_to_struct",
|
||||
"list_to_listvalue",
|
||||
"listvalue_to_list",
|
||||
"make_entity_description",
|
||||
"struct_to_dict",
|
||||
]
|
||||
@@ -0,0 +1,143 @@
|
||||
"""Wire-protocol message-type constants.
|
||||
|
||||
The integration and the sandbox runtime exchange typed protobuf messages
|
||||
over the :class:`Channel`. Each message type is namespaced ``sandbox/…``;
|
||||
this module holds the type-string constants. Both sides share the same
|
||||
names — kept here on the HA side and mirrored verbatim in
|
||||
:mod:`hass_client.protocol` so neither has to import the other.
|
||||
|
||||
The wire is protobuf (default codec :class:`~.codec_protobuf.ProtobufCodec`):
|
||||
each ``type`` maps to a request/result proto message pair in
|
||||
:mod:`.messages` (the `REGISTRY`), generated from
|
||||
``sandbox/proto/sandbox.proto``. The payload shapes described below
|
||||
are the *logical* contract for each call — they are carried as those typed
|
||||
proto messages, not free-form dicts (only genuinely dynamic fields, e.g.
|
||||
``service_data`` / state attributes / serialized voluptuous schemas, cross
|
||||
as ``Struct`` / ``ListValue``). The line-oriented :class:`~.channel.JsonCodec`
|
||||
is retained only as the channel-core test/debug wire.
|
||||
|
||||
Main → Sandbox calls:
|
||||
|
||||
* ``sandbox/entry_setup`` — push a serialised :class:`ConfigEntry` into
|
||||
the sandbox, asking it to load the owning integration and run
|
||||
``async_setup_entry``. Returns ``{"ok": bool, "reason": str | None}``.
|
||||
Carries an ``integration_source`` sub-message telling a stateless sandbox
|
||||
where to fetch the integration code: ``{kind: "builtin"}`` (the bundled
|
||||
``homeassistant`` package provides it — a no-op) or ``{kind: "git", url,
|
||||
ref, tag, domain, subdir}`` for custom (HACS) integrations. ``ref`` is an
|
||||
exact commit sha (main pins tag→sha; see ``sources.py``); the sandbox
|
||||
fetches the code before setup (see ``hass_client.sources``).
|
||||
* ``sandbox/entry_unload`` — ask the sandbox to unload an entry by id.
|
||||
* ``sandbox/call_service`` — generic service dispatch (shared with
|
||||
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
|
||||
``{language, strings: {domain: <raw strings.json dict>}}`` — the
|
||||
un-flattened nesting a ``translations/<lang>.json`` holds, with ``title``
|
||||
pre-filled from the integration name (main has no ``Integration`` for a
|
||||
custom domain, so it cannot run that fallback). Built-in domains never
|
||||
cross the wire — main reads its byte-identical disk copy.
|
||||
|
||||
Sandbox → Main calls:
|
||||
|
||||
* ``sandbox/register_entity`` — sandbox tells main "I just added an
|
||||
entity, here's its description". Main builds the proxy and replies
|
||||
``{"entity_id": <main-side id>}`` so the sandbox can route later
|
||||
``call_service`` requests back to the right local entity. Optional
|
||||
``device_info`` field: a JSON-flattened ``DeviceInfo`` dict
|
||||
— sets become lists of two-element lists (``identifiers`` /
|
||||
``connections``), tuples become lists (``via_device``), and
|
||||
``entry_type`` is the enum's string value. When present, main calls
|
||||
:func:`device_registry.async_get_or_create` so the sandbox's devices
|
||||
surface in main's device_registry tied to the sandboxed entry.
|
||||
* ``sandbox/unregister_entity`` — symmetric counterpart.
|
||||
* ``sandbox/state_changed`` — push (no response). Carries the
|
||||
marshalled state delta for one entity.
|
||||
* ``sandbox/register_service`` — sandbox tells main "I just
|
||||
registered a service, please mirror it". Main installs a thin handler
|
||||
that forwards calls back over the shared ``sandbox/call_service``
|
||||
channel.
|
||||
* ``sandbox/unregister_service`` — symmetric counterpart.
|
||||
* ``sandbox/fire_event`` — push (no response). The sandbox
|
||||
forwards each ``<owned_domain>_*`` event so main listeners (notably
|
||||
``automation``) can react as if the integration ran locally.
|
||||
* ``sandbox/store_load`` — sandbox-side ``Store.async_load``
|
||||
proxies to this RPC. Payload ``{"key": str}``; response is the wrapped
|
||||
``{"version", "minor_version", "key", "data"}`` dict the sandbox last
|
||||
saved, or ``None`` if no data exists yet. The group is implicit from
|
||||
the channel — each :class:`SandboxBridge` only ever serves one group.
|
||||
* ``sandbox/store_save`` — sandbox-side ``Store`` flush.
|
||||
Payload ``{"key": str, "data": dict}``; main writes the wrapped dict
|
||||
to ``<config>/.storage/sandbox/<group>/<key>`` atomically. Response
|
||||
is ``{"ok": True}``.
|
||||
* ``sandbox/store_remove`` — sandbox-side
|
||||
``Store.async_remove``. Payload ``{"key": str}``; main unlinks the
|
||||
file (if any). Response is ``{"ok": True}``.
|
||||
|
||||
Main → Sandbox shutdown:
|
||||
|
||||
* ``sandbox/shutdown`` — ask the runtime to unload its entries, dump
|
||||
``RestoreEntity`` state, fire ``EVENT_HOMEASSISTANT_FINAL_WRITE`` so any
|
||||
pending Stores flush to main via the ``current_sandbox`` store bridge,
|
||||
and exit cleanly. Response ``{"ok": True, "unloaded": int, "restored":
|
||||
int}``. The runtime sets its shutdown event right after writing the
|
||||
reply, so the subprocess exits 0 on its own — main only needs SIGTERM
|
||||
if the round-trip times out.
|
||||
"""
|
||||
|
||||
from typing import Final
|
||||
|
||||
# Handshake (Sandbox → Main): the runtime's first frame on the channel.
|
||||
# Replaces the old ``sandbox:ready`` stdout text marker — the manager
|
||||
# registers a handler for this push and treats its arrival as "running",
|
||||
# so stdout carries nothing but channel frames.
|
||||
MSG_READY: Final = "sandbox/ready"
|
||||
|
||||
# Main → Sandbox
|
||||
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"
|
||||
|
||||
# Sandbox → Main
|
||||
MSG_REGISTER_ENTITY: Final = "sandbox/register_entity"
|
||||
MSG_UNREGISTER_ENTITY: Final = "sandbox/unregister_entity"
|
||||
MSG_STATE_CHANGED: Final = "sandbox/state_changed"
|
||||
MSG_REGISTER_SERVICE: Final = "sandbox/register_service"
|
||||
MSG_UNREGISTER_SERVICE: Final = "sandbox/unregister_service"
|
||||
MSG_FIRE_EVENT: Final = "sandbox/fire_event"
|
||||
MSG_STORE_LOAD: Final = "sandbox/store_load"
|
||||
MSG_STORE_SAVE: Final = "sandbox/store_save"
|
||||
MSG_STORE_REMOVE: Final = "sandbox/store_remove"
|
||||
|
||||
|
||||
__all__ = [
|
||||
"MSG_CALL_SERVICE",
|
||||
"MSG_ENTITY_QUERY",
|
||||
"MSG_ENTRY_SETUP",
|
||||
"MSG_ENTRY_UNLOAD",
|
||||
"MSG_FIRE_EVENT",
|
||||
"MSG_GET_TRANSLATIONS",
|
||||
"MSG_READY",
|
||||
"MSG_REGISTER_ENTITY",
|
||||
"MSG_REGISTER_SERVICE",
|
||||
"MSG_SHUTDOWN",
|
||||
"MSG_STATE_CHANGED",
|
||||
"MSG_STORE_LOAD",
|
||||
"MSG_STORE_REMOVE",
|
||||
"MSG_STORE_SAVE",
|
||||
"MSG_UNREGISTER_ENTITY",
|
||||
"MSG_UNREGISTER_SERVICE",
|
||||
]
|
||||
@@ -0,0 +1,293 @@
|
||||
"""Proxy :class:`ConfigFlow` that forwards every step to a sandbox runtime.
|
||||
|
||||
Behaviour:
|
||||
|
||||
1. The framework dispatches a flow step by name (``async_step_user``,
|
||||
``async_step_reauth``, …) on the flow object. We catch *any* such
|
||||
call via ``__getattr__``.
|
||||
2. On the **first** call we issue ``sandbox/flow_init`` with the
|
||||
integration domain plus the initial context/user input; the sandbox
|
||||
returns its own ``flow_id`` and the initial step's result.
|
||||
3. **Subsequent** calls go out as ``sandbox/flow_step`` carrying the
|
||||
sandbox's ``flow_id`` and the user input from the framework.
|
||||
4. On ``async_remove`` (framework cleanup) we fire
|
||||
``sandbox/flow_abort`` so the sandbox tears its flow down too.
|
||||
5. On the CREATE_ENTRY step we attach ``sandbox=<group>`` to the
|
||||
``ConfigFlowResult`` so the framework's entry constructor sets
|
||||
:attr:`ConfigEntry.sandbox` before ``async_setup`` runs — that's
|
||||
where the router consults it.
|
||||
|
||||
The proxy never touches ``data_schema`` on the wire — schema-driven
|
||||
validation happens *inside* the sandbox where the real schema lives. The
|
||||
proxy treats the sandbox's reply as authoritative; a re-shown form (with
|
||||
``errors`` set) is just another ``FORM`` result that the framework will
|
||||
forward to the user as usual.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||
from homeassistant.data_entry_flow import FlowResultType
|
||||
|
||||
from ._proto import sandbox_pb2 as pb
|
||||
from .channel import ChannelClosedError, ChannelRemoteError
|
||||
from .messages import dict_to_struct, listvalue_to_list, struct_to_dict
|
||||
from .schema_bridge import reconstruct_schema
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .manager import SandboxManager
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
# Holds fire-and-forget abort tasks alive long enough to complete; the
|
||||
# framework's ``async_remove`` is synchronous so we can't await them inline.
|
||||
_BACKGROUND_ABORTS: set = set()
|
||||
|
||||
|
||||
class SandboxFlowProxy(ConfigFlow):
|
||||
"""A flow handler that forwards each step to a sandbox runtime."""
|
||||
|
||||
# Marker so other code (e.g. tests) can spot a proxy without isinstance
|
||||
# importing the sandbox package eagerly.
|
||||
_is_sandbox_proxy = True
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
sandbox_group: str,
|
||||
manager: SandboxManager,
|
||||
handler_key: str,
|
||||
) -> None:
|
||||
"""Initialise the proxy flow."""
|
||||
super().__init__()
|
||||
self._sandbox_group = sandbox_group
|
||||
self._manager = manager
|
||||
self._handler_key = handler_key
|
||||
self._sandbox_flow_id: str | None = None
|
||||
self._terminated: bool = False
|
||||
|
||||
@property
|
||||
def sandbox_group(self) -> str:
|
||||
"""The sandbox group this in-progress flow forwards to.
|
||||
|
||||
Read by the translation provider to resolve a brand-new custom
|
||||
integration's group before any ``ConfigEntry`` exists.
|
||||
"""
|
||||
return self._sandbox_group
|
||||
|
||||
def __getattribute__(self, name: str) -> Any:
|
||||
"""Catch every ``async_step_*`` access and forward to the sandbox.
|
||||
|
||||
ConfigFlow's base class already defines several step methods (e.g.
|
||||
``async_step_user``, ``async_step_ignore``, ``async_step_reauth*``),
|
||||
so we cannot rely on ``__getattr__`` — those names resolve in the
|
||||
normal MRO before ``__getattr__`` is consulted. ``__getattribute__``
|
||||
runs for every attribute access; we only re-wrap the
|
||||
``async_step_*`` family.
|
||||
"""
|
||||
if name.startswith("async_step_"):
|
||||
step_id = name[len("async_step_") :]
|
||||
forward = object.__getattribute__(self, "_forward_step")
|
||||
|
||||
async def _step(
|
||||
user_input: dict[str, Any] | None = None,
|
||||
) -> ConfigFlowResult:
|
||||
return await forward(step_id, user_input)
|
||||
|
||||
_step.__name__ = name
|
||||
return _step
|
||||
return object.__getattribute__(self, name)
|
||||
|
||||
async def _forward_step(
|
||||
self, step_id: str, user_input: dict[str, Any] | None
|
||||
) -> ConfigFlowResult:
|
||||
if self._terminated:
|
||||
return self.async_abort(reason="sandbox_flow_terminated")
|
||||
|
||||
sandbox = await self._manager.ensure_started(self._sandbox_group)
|
||||
channel = sandbox.channel
|
||||
if channel is None: # pragma: no cover - manager guarantees this
|
||||
return self.async_abort(reason="sandbox_unavailable")
|
||||
|
||||
try:
|
||||
if self._sandbox_flow_id is None:
|
||||
# First step — bootstrap the flow on the sandbox. The
|
||||
# framework's first call passes the initial data; for a
|
||||
# USER source this is None. Everything else (REAUTH,
|
||||
# DISCOVERY, …) gets its discovery payload here.
|
||||
request = pb.FlowInit(
|
||||
handler=self._handler_key,
|
||||
context=dict_to_struct(dict(self.context)),
|
||||
)
|
||||
if user_input is not None:
|
||||
request.data.CopyFrom(dict_to_struct(user_input))
|
||||
result = await channel.call("sandbox/flow_init", request)
|
||||
self._sandbox_flow_id = (
|
||||
result.flow_id if result.HasField("flow_id") else None
|
||||
)
|
||||
else:
|
||||
step = pb.FlowStep(flow_id=self._sandbox_flow_id)
|
||||
if user_input is not None:
|
||||
step.user_input.CopyFrom(dict_to_struct(user_input))
|
||||
result = await channel.call("sandbox/flow_step", step)
|
||||
except ChannelClosedError:
|
||||
self._terminated = True
|
||||
_LOGGER.warning(
|
||||
"Sandbox %r channel closed mid-flow; aborting %s flow",
|
||||
self._sandbox_group,
|
||||
self._handler_key,
|
||||
)
|
||||
return self.async_abort(reason="sandbox_unavailable")
|
||||
except ChannelRemoteError as err:
|
||||
_LOGGER.warning(
|
||||
"Sandbox %r raised %s on %s step %s: %s",
|
||||
self._sandbox_group,
|
||||
err.error_type or "error",
|
||||
self._handler_key,
|
||||
step_id,
|
||||
err,
|
||||
)
|
||||
return self.async_abort(reason="sandbox_flow_error")
|
||||
|
||||
await self._apply_remote_context(result)
|
||||
return self._adapt_result(result, step_id)
|
||||
|
||||
async def _apply_remote_context(self, result: pb.FlowResult) -> None:
|
||||
"""Mirror ``unique_id`` (and other context bits) onto our own flow.
|
||||
|
||||
The sandbox's :meth:`ConfigFlow.async_set_unique_id` mutates the
|
||||
sandbox flow's ``context["unique_id"]``; the flow-runner surfaces
|
||||
it in the marshalled result. We pass it through
|
||||
:meth:`async_set_unique_id` so main's duplicate detection fires
|
||||
(it raises :class:`AbortFlow` for an in-progress collision,
|
||||
which the flow framework turns into an ABORT result).
|
||||
"""
|
||||
if not result.HasField("context"):
|
||||
return
|
||||
remote = struct_to_dict(result.context)
|
||||
if "unique_id" not in remote:
|
||||
return
|
||||
unique_id = remote["unique_id"]
|
||||
if self.context.get("unique_id") == unique_id:
|
||||
return
|
||||
# ``async_set_unique_id`` raises ``AbortFlow("already_in_progress")``
|
||||
# if another flow for the same handler already has this unique
|
||||
# id; that's exactly the duplicate-rejection signal we want.
|
||||
await self.async_set_unique_id(unique_id)
|
||||
|
||||
def _adapt_result(self, result: pb.FlowResult, step_id: str) -> ConfigFlowResult:
|
||||
"""Translate a sandbox-side ``FlowResult`` message into a main-side one.
|
||||
|
||||
The sandbox's ``flow_id`` and ``handler`` are replaced with main's
|
||||
view (so HA's frontend / FlowManager keep tracking the proxy
|
||||
flow), and CREATE_ENTRY data is tagged with the sandbox group so
|
||||
the setup interceptor knows where to route the entry.
|
||||
"""
|
||||
result_type = FlowResultType(result.type)
|
||||
placeholders = (
|
||||
struct_to_dict(result.description_placeholders)
|
||||
if result.HasField("description_placeholders")
|
||||
else None
|
||||
)
|
||||
|
||||
if result_type is FlowResultType.CREATE_ENTRY:
|
||||
entry_data = struct_to_dict(result.data)
|
||||
self._terminated = True
|
||||
create_result = self.async_create_entry(
|
||||
title=(
|
||||
result.title
|
||||
if result.HasField("title") and result.title
|
||||
else self._handler_key
|
||||
),
|
||||
data=entry_data,
|
||||
description=(
|
||||
result.description if result.HasField("description") else None
|
||||
),
|
||||
description_placeholders=placeholders,
|
||||
)
|
||||
# Tag the FlowResult so the framework's entry constructor in
|
||||
# ``ConfigEntriesFlowManager.async_finish_flow`` reads it into
|
||||
# ``ConfigEntry.sandbox`` — this lands the tag *before*
|
||||
# ``async_setup`` runs, where the router needs it.
|
||||
create_result["sandbox"] = self._sandbox_group
|
||||
return create_result
|
||||
|
||||
if result_type is FlowResultType.ABORT:
|
||||
self._terminated = True
|
||||
return self.async_abort(
|
||||
reason=(
|
||||
result.reason if result.HasField("reason") else "sandbox_aborted"
|
||||
),
|
||||
description_placeholders=placeholders,
|
||||
)
|
||||
|
||||
if result_type is FlowResultType.FORM:
|
||||
data_schema = reconstruct_schema(listvalue_to_list(result.data_schema))
|
||||
if data_schema is None and result.has_data_schema:
|
||||
_LOGGER.debug(
|
||||
"Sandbox %r returned a FORM with an unserialisable"
|
||||
" data_schema; rendering schema-less",
|
||||
self._sandbox_group,
|
||||
)
|
||||
errors = (
|
||||
struct_to_dict(result.errors) if result.HasField("errors") else None
|
||||
)
|
||||
return self.async_show_form(
|
||||
step_id=result.step_id if result.HasField("step_id") else step_id,
|
||||
data_schema=data_schema,
|
||||
errors=errors or None,
|
||||
description_placeholders=placeholders,
|
||||
last_step=result.last_step if result.HasField("last_step") else None,
|
||||
preview=result.preview if result.HasField("preview") else None,
|
||||
)
|
||||
|
||||
# Any other type (MENU, EXTERNAL_STEP, SHOW_PROGRESS, …) is
|
||||
# not supported; surface a noisy abort so a follow-up doesn't
|
||||
# silently drop the flow on the floor.
|
||||
self._terminated = True
|
||||
_LOGGER.warning(
|
||||
"Sandbox %r returned unsupported flow result type %s for %s;"
|
||||
" aborting (only FORM/CREATE_ENTRY/ABORT are supported)",
|
||||
self._sandbox_group,
|
||||
result_type,
|
||||
self._handler_key,
|
||||
)
|
||||
return self.async_abort(reason="sandbox_unsupported_result_type")
|
||||
|
||||
def async_remove(self) -> None:
|
||||
"""Tell the sandbox to drop its flow when the framework discards us."""
|
||||
if self._sandbox_flow_id is None or self._terminated:
|
||||
return
|
||||
sandbox = self._manager.get(self._sandbox_group)
|
||||
channel = sandbox.channel if sandbox is not None else None
|
||||
if channel is None:
|
||||
return
|
||||
# async_remove is a sync framework callback, but we're inside a
|
||||
# running HA loop — schedule the abort and move on.
|
||||
import asyncio # noqa: PLC0415
|
||||
|
||||
flow_id = self._sandbox_flow_id
|
||||
self._terminated = True
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
except RuntimeError:
|
||||
# Called outside an event loop (teardown path); nothing useful
|
||||
# we can do — the sandbox's flow will GC when the process dies.
|
||||
return
|
||||
task = loop.create_task(
|
||||
_safe_abort(channel, flow_id, self._sandbox_group, self._handler_key)
|
||||
)
|
||||
_BACKGROUND_ABORTS.add(task)
|
||||
task.add_done_callback(_BACKGROUND_ABORTS.discard)
|
||||
|
||||
|
||||
async def _safe_abort(channel: Any, flow_id: str, group: str, handler: str) -> None:
|
||||
"""Fire ``flow_abort`` on the sandbox and swallow errors."""
|
||||
try:
|
||||
await channel.call("sandbox/flow_abort", pb.FlowAbort(flow_id=flow_id))
|
||||
except (ChannelClosedError, ChannelRemoteError) as err:
|
||||
_LOGGER.debug("Sandbox %r flow_abort for %s failed: %s", group, handler, err)
|
||||
|
||||
|
||||
__all__ = ["SandboxFlowProxy"]
|
||||
@@ -0,0 +1,237 @@
|
||||
"""Main-side :class:`ConfigEntryRouter` implementation.
|
||||
|
||||
Bridges :class:`homeassistant.config_entries.ConfigEntries` to the sandbox
|
||||
manager:
|
||||
|
||||
* New flows for sandboxed integrations are diverted to a
|
||||
:class:`SandboxFlowProxy` that forwards each step over the sandbox's
|
||||
control :class:`Channel`.
|
||||
* Existing config-entry setup is intercepted when ``entry.sandbox`` is
|
||||
set — the entry is handed to the sandbox manager and pushed into the
|
||||
sandbox runtime via ``sandbox/entry_setup``.
|
||||
|
||||
The router treats classifier output as the source of truth for which
|
||||
sandbox a new entry should go into. Once an entry exists, the
|
||||
``sandbox`` field stored on it wins (so a re-classification later
|
||||
doesn't yank a running entry into a different sandbox).
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from homeassistant.config_entries import (
|
||||
ConfigEntry,
|
||||
ConfigEntryState,
|
||||
ConfigFlow,
|
||||
ConfigFlowContext,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.translation import async_invalidate_translations
|
||||
from homeassistant.loader import async_get_integration
|
||||
|
||||
from ._proto import sandbox_pb2 as pb
|
||||
from .channel import ChannelClosedError, ChannelRemoteError
|
||||
from .classifier import SandboxAssignment, classify
|
||||
from .manager import SandboxManager
|
||||
from .messages import dict_to_struct
|
||||
from .protocol import MSG_ENTRY_SETUP, MSG_ENTRY_UNLOAD
|
||||
from .proxy_flow import SandboxFlowProxy
|
||||
from .sources import SandboxSourceError, async_resolve_integration_source
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from . import SandboxData
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SandboxFlowRouter:
|
||||
"""Route config flows and entry setup to sandbox processes.
|
||||
|
||||
Structurally implements the :class:`ConfigEntryRouter` Protocol from
|
||||
``homeassistant.config_entries``; declared as a plain class so the
|
||||
sandbox integration does not pull a runtime dependency on the
|
||||
protocol's import side-effects.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
manager: SandboxManager,
|
||||
*,
|
||||
data: SandboxData | None = None,
|
||||
) -> None:
|
||||
"""Initialise the router with the active sandbox manager."""
|
||||
self._hass = hass
|
||||
self._manager = manager
|
||||
self._data = data
|
||||
|
||||
async def async_create_flow(
|
||||
self,
|
||||
handler_key: str,
|
||||
*,
|
||||
context: ConfigFlowContext,
|
||||
data: Any,
|
||||
) -> ConfigFlow | None:
|
||||
"""Return a :class:`SandboxFlowProxy` if the integration is sandboxed."""
|
||||
assignment = await self._assignment_for_new_flow(handler_key)
|
||||
if assignment.is_main:
|
||||
return None
|
||||
assert assignment.group is not None
|
||||
return SandboxFlowProxy(
|
||||
sandbox_group=assignment.group,
|
||||
manager=self._manager,
|
||||
handler_key=handler_key,
|
||||
)
|
||||
|
||||
async def async_setup_entry(self, entry: ConfigEntry) -> bool | None:
|
||||
"""Hand a sandboxed entry to the manager and run its setup remotely."""
|
||||
group = entry.sandbox
|
||||
if group is None:
|
||||
return None
|
||||
try:
|
||||
sandbox = await self._manager.ensure_started(group)
|
||||
except Exception:
|
||||
_LOGGER.exception(
|
||||
"Sandbox group %r failed to start for entry %s (%s)",
|
||||
group,
|
||||
entry.title,
|
||||
entry.domain,
|
||||
)
|
||||
entry._async_set_state( # noqa: SLF001
|
||||
self._hass, ConfigEntryState.SETUP_ERROR, "Sandbox failed to start"
|
||||
)
|
||||
return False
|
||||
|
||||
channel = sandbox.channel
|
||||
if channel is None:
|
||||
_LOGGER.error(
|
||||
"Sandbox %r has no live channel for entry %s (%s)",
|
||||
group,
|
||||
entry.title,
|
||||
entry.domain,
|
||||
)
|
||||
entry._async_set_state( # noqa: SLF001
|
||||
self._hass, ConfigEntryState.SETUP_ERROR, "Sandbox channel down"
|
||||
)
|
||||
return False
|
||||
|
||||
try:
|
||||
payload = await _entry_setup_payload(self._hass, entry)
|
||||
except SandboxSourceError as err:
|
||||
_LOGGER.error(
|
||||
"Cannot resolve integration source for entry %s (%s): %s",
|
||||
entry.title,
|
||||
entry.domain,
|
||||
err,
|
||||
)
|
||||
entry._async_set_state( # noqa: SLF001
|
||||
self._hass, ConfigEntryState.SETUP_ERROR, str(err)
|
||||
)
|
||||
return False
|
||||
try:
|
||||
result = await channel.call(MSG_ENTRY_SETUP, payload)
|
||||
except ChannelClosedError:
|
||||
entry._async_set_state( # noqa: SLF001
|
||||
self._hass,
|
||||
ConfigEntryState.SETUP_RETRY,
|
||||
"Sandbox channel closed during setup",
|
||||
)
|
||||
return False
|
||||
except ChannelRemoteError as err:
|
||||
entry._async_set_state( # noqa: SLF001
|
||||
self._hass,
|
||||
ConfigEntryState.SETUP_ERROR,
|
||||
f"Sandbox raised {err.error_type or 'error'}: {err.error}",
|
||||
)
|
||||
return False
|
||||
|
||||
if not result.ok:
|
||||
reason = (
|
||||
result.reason if result.HasField("reason") else "sandbox refused setup"
|
||||
)
|
||||
entry._async_set_state( # noqa: SLF001
|
||||
self._hass, ConfigEntryState.SETUP_ERROR, reason
|
||||
)
|
||||
return False
|
||||
|
||||
entry._async_set_state(self._hass, ConfigEntryState.LOADED, None) # noqa: SLF001
|
||||
return True
|
||||
|
||||
async def async_unload_entry(self, entry: ConfigEntry) -> bool | None:
|
||||
"""Push the unload back to the sandbox if the entry is sandboxed.
|
||||
|
||||
Returns ``None`` for non-sandbox entries so the normal HA unload
|
||||
path runs.
|
||||
"""
|
||||
group = entry.sandbox
|
||||
if group is None:
|
||||
return None
|
||||
# A reload re-fetches the integration code (possibly at a new commit
|
||||
# ref) and re-runs setup, so its translation strings may have changed.
|
||||
# Drop the cached strings; the next frontend fetch re-pulls them.
|
||||
async_invalidate_translations(self._hass, {entry.domain})
|
||||
sandbox = self._manager.get(group)
|
||||
if sandbox is None or sandbox.channel is None:
|
||||
return True
|
||||
try:
|
||||
result = await sandbox.channel.call(
|
||||
MSG_ENTRY_UNLOAD, pb.EntryUnload(entry_id=entry.entry_id)
|
||||
)
|
||||
except ChannelClosedError, ChannelRemoteError:
|
||||
_LOGGER.exception(
|
||||
"Sandbox %r failed to unload entry %s (%s)",
|
||||
group,
|
||||
entry.title,
|
||||
entry.domain,
|
||||
)
|
||||
return False
|
||||
if self._data is not None:
|
||||
bridge = self._data.bridges.get(group)
|
||||
if bridge is not None:
|
||||
await bridge.async_unload_entry(entry)
|
||||
return result.ok
|
||||
|
||||
async def _assignment_for_new_flow(self, handler_key: str) -> SandboxAssignment:
|
||||
"""Decide where a new flow for ``handler_key`` should run.
|
||||
|
||||
First an existing entry's ``sandbox`` wins (so a flow for a
|
||||
domain that already has sandboxed entries goes to the same
|
||||
sandbox). Otherwise the classifier picks.
|
||||
"""
|
||||
for existing in self._hass.config_entries.async_entries(handler_key):
|
||||
if (group := existing.sandbox) is not None:
|
||||
return SandboxAssignment(group=group)
|
||||
integration = await async_get_integration(self._hass, handler_key)
|
||||
return classify(integration)
|
||||
|
||||
|
||||
async def _entry_setup_payload(
|
||||
hass: HomeAssistant, entry: ConfigEntry
|
||||
) -> pb.EntrySetup:
|
||||
"""Build the typed ``EntrySetup`` message for ``sandbox/entry_setup``.
|
||||
|
||||
Surfaces the small subset of entry fields the integration's
|
||||
``async_setup_entry`` reads, plus the ``integration_source`` descriptor
|
||||
telling a stateless sandbox where to fetch the code (built-in → no-op;
|
||||
custom → a git source pinned to an exact sha). May raise
|
||||
:class:`SandboxSourceError` if a custom integration has no source resolver.
|
||||
"""
|
||||
msg = pb.EntrySetup(
|
||||
entry_id=entry.entry_id,
|
||||
domain=entry.domain,
|
||||
title=entry.title,
|
||||
data=dict_to_struct(dict(entry.data)),
|
||||
options=dict_to_struct(dict(entry.options)),
|
||||
source=entry.source,
|
||||
version=entry.version,
|
||||
minor_version=entry.minor_version,
|
||||
)
|
||||
if entry.unique_id is not None:
|
||||
msg.unique_id = entry.unique_id
|
||||
msg.integration_source.CopyFrom(
|
||||
await async_resolve_integration_source(hass, entry.domain)
|
||||
)
|
||||
return msg
|
||||
|
||||
|
||||
__all__ = ["SandboxFlowRouter"]
|
||||
@@ -0,0 +1,122 @@
|
||||
"""Main-side reconstruction of voluptuous schemas serialised by the sandbox.
|
||||
|
||||
The sandbox sends a list-of-fields rendering (the same shape
|
||||
:func:`voluptuous_serialize.convert` would produce against
|
||||
:func:`cv.custom_serializer`). We rebuild a :class:`vol.Schema` from it
|
||||
so:
|
||||
|
||||
* :meth:`hass.services.async_register` gets a real schema (good input
|
||||
passes, blatantly bad input is rejected before we round-trip to the
|
||||
sandbox).
|
||||
* The flow-manager view's :func:`_prepare_result_json` can re-render the
|
||||
same list back through :func:`voluptuous_serialize.convert` for the
|
||||
frontend.
|
||||
|
||||
Selectors and expandable sections are rebuilt as the **real**
|
||||
:class:`selector.Selector` / :class:`data_entry_flow.section` objects, so
|
||||
when the flow manager re-serialises main's reconstructed schema for the
|
||||
frontend it reproduces the sandbox's original list verbatim (the form
|
||||
renders with the right widget instead of a bare text box). Only genuinely
|
||||
unknown field types fall through to a pass-through validator.
|
||||
"""
|
||||
|
||||
from collections.abc import Iterable
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant import data_entry_flow
|
||||
from homeassistant.helpers import selector
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
_SCHEMA_TYPES_BY_NAME: dict[str, type] = {
|
||||
"string": str,
|
||||
"integer": int,
|
||||
"float": float,
|
||||
"boolean": bool,
|
||||
}
|
||||
|
||||
|
||||
def reconstruct_schema(
|
||||
serialized: list[dict[str, Any]] | None,
|
||||
) -> vol.Schema | None:
|
||||
"""Build a :class:`vol.Schema` from the wire form.
|
||||
|
||||
Returns ``None`` for an empty list (no fields) or ``None`` input so
|
||||
callers can short-circuit straight to ``schema=None``.
|
||||
"""
|
||||
if not serialized:
|
||||
return None
|
||||
fields: dict[Any, Any] = {}
|
||||
for entry in serialized:
|
||||
name = entry.get("name")
|
||||
if name is None:
|
||||
continue
|
||||
marker_cls = vol.Required if entry.get("required") else vol.Optional
|
||||
kwargs: dict[str, Any] = {}
|
||||
if "default" in entry:
|
||||
kwargs["default"] = entry["default"]
|
||||
if "description" in entry:
|
||||
kwargs["description"] = entry["description"]
|
||||
marker = marker_cls(name, **kwargs)
|
||||
fields[marker] = _validator_from_entry(entry)
|
||||
return vol.Schema(fields)
|
||||
|
||||
|
||||
def _validator_from_entry(entry: dict[str, Any]) -> Any:
|
||||
"""Inverse of :func:`voluptuous_serialize.convert` per field.
|
||||
|
||||
Rebuilds the real object where re-serialising it has to reproduce the
|
||||
original (selectors, sections) and falls back to a pass-through for
|
||||
anything we can't faithfully reconstruct.
|
||||
"""
|
||||
# A selector field carries its config under ``selector`` (no ``type``);
|
||||
# rebuild the real Selector so it re-serialises to the same shape.
|
||||
if "selector" in entry:
|
||||
try:
|
||||
return selector.selector(entry["selector"])
|
||||
except vol.Invalid:
|
||||
_LOGGER.warning(
|
||||
"Could not rebuild selector from %r; using pass-through",
|
||||
entry["selector"],
|
||||
)
|
||||
return _passthrough
|
||||
type_name = entry.get("type")
|
||||
if type_name == "expandable":
|
||||
# An ``data_entry_flow.section`` — rebuild it with its nested schema
|
||||
# so the frontend still renders the collapsible section.
|
||||
nested = reconstruct_schema(entry.get("schema")) or vol.Schema({})
|
||||
collapsed = not entry.get("expanded", True)
|
||||
return data_entry_flow.section(nested, {"collapsed": collapsed})
|
||||
if type_name in _SCHEMA_TYPES_BY_NAME:
|
||||
return _SCHEMA_TYPES_BY_NAME[type_name]
|
||||
if type_name == "select":
|
||||
options = entry.get("options") or []
|
||||
values = _select_values(options)
|
||||
if values:
|
||||
return vol.In(values)
|
||||
# Constants, datetime/format, and other shapes we don't reconstruct —
|
||||
# the sandbox owns the strict validator; on main, accept any value so
|
||||
# the caller's payload reaches the sandbox-side handler.
|
||||
return _passthrough
|
||||
|
||||
|
||||
def _select_values(options: Iterable[Any]) -> list[Any]:
|
||||
"""Pull the value half out of a serialised select's ``options``."""
|
||||
out: list[Any] = []
|
||||
for opt in options:
|
||||
if isinstance(opt, (list, tuple)) and opt:
|
||||
out.append(opt[0])
|
||||
else:
|
||||
out.append(opt)
|
||||
return out
|
||||
|
||||
|
||||
def _passthrough(value: Any) -> Any:
|
||||
"""Identity validator — sandbox-side handler does the real validation."""
|
||||
return value
|
||||
|
||||
|
||||
__all__ = ["reconstruct_schema"]
|
||||
@@ -0,0 +1,12 @@
|
||||
# Sandbox does not declare any user-facing services.
|
||||
#
|
||||
# The integration calls hass.services.async_register dynamically (see
|
||||
# bridge.py::SandboxBridge._handle_register_service) to install forwarders
|
||||
# that route each sandboxed integration's service back to the sandbox
|
||||
# subprocess over the sandbox/call_service channel. Those services are
|
||||
# owned by the sandboxed integrations themselves, not by sandbox, and
|
||||
# their schemas + descriptions live with those integrations.
|
||||
#
|
||||
# This file exists to satisfy hassfest's "Registers services but has no
|
||||
# services.yaml" gate, which uses a regex grep that can't tell static and
|
||||
# dynamic registrations apart.
|
||||
@@ -0,0 +1,152 @@
|
||||
"""Main-side integration-source resolution for stateless sandboxes.
|
||||
|
||||
A sandbox holds no persistent state. The last stateful bit was the
|
||||
integration *code*: built-ins ride the bundled ``homeassistant`` package, but
|
||||
custom (HACS) integrations live under ``<config>/custom_components`` on the
|
||||
main install and are absent from a fresh sandbox. This module lets main tell
|
||||
the sandbox *where to fetch the code* on ``entry_setup``; the sandbox fetches
|
||||
it before setup (see ``hass_client.sources``).
|
||||
|
||||
Core stays HACS-agnostic via a registered-resolver hook (decision (c),
|
||||
2026-06-03): HACS — or any other distribution mechanism — registers a
|
||||
resolver mapping a custom domain to a git source. Core ships only the
|
||||
builtin-vs-git decision; with no resolver registered the default is
|
||||
builtin-only, and a custom domain raises rather than silently falling back.
|
||||
|
||||
Security / tag→sha contract: the ``ref`` that crosses the wire must be an
|
||||
exact commit sha, never a moving tag. Core performs **no network I/O** here,
|
||||
so the resolver is responsible for pinning the installed version to a sha and
|
||||
returning it in ``ref`` (HACS already knows the sha of what the user
|
||||
installed). ``tag`` is informational only (logs). If a resolver returns a git
|
||||
source without a ``ref``, that is an error — main refuses to ship a sandbox a
|
||||
moving reference.
|
||||
"""
|
||||
|
||||
from collections.abc import Callable
|
||||
import logging
|
||||
from typing import TypedDict
|
||||
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.loader import async_get_integration
|
||||
from homeassistant.util.hass_dict import HassKey
|
||||
|
||||
from ._proto import sandbox_pb2 as pb
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class IntegrationSourceDict(TypedDict, total=False):
|
||||
"""The dict shape a resolver returns for a custom (git) integration.
|
||||
|
||||
``kind`` is always ``"git"`` (built-ins never reach a resolver). ``url``
|
||||
and ``ref`` (an exact commit sha) are required; ``domain`` and ``subdir``
|
||||
default from the domain being resolved when omitted.
|
||||
"""
|
||||
|
||||
kind: str
|
||||
url: str
|
||||
ref: str
|
||||
tag: str
|
||||
domain: str
|
||||
subdir: str
|
||||
|
||||
|
||||
# A resolver maps a custom integration domain to its git source, or ``None``
|
||||
# if it does not know that domain. Called only for non-built-in integrations.
|
||||
SandboxSourceResolver = Callable[[str], IntegrationSourceDict | None]
|
||||
|
||||
DATA_SOURCE_RESOLVERS: HassKey[list[SandboxSourceResolver]] = HassKey(
|
||||
"sandbox_source_resolvers"
|
||||
)
|
||||
|
||||
|
||||
class SandboxSourceError(Exception):
|
||||
"""Raised when an integration's source cannot be resolved."""
|
||||
|
||||
|
||||
@callback
|
||||
def async_register_sandbox_source_resolver(
|
||||
hass: HomeAssistant, resolver: SandboxSourceResolver
|
||||
) -> Callable[[], None]:
|
||||
"""Register a resolver mapping a custom domain to its git source.
|
||||
|
||||
HACS (or any custom-integration distribution mechanism) calls this to
|
||||
teach the sandbox where to fetch code from. Resolvers are consulted in
|
||||
registration order; the first to return a non-``None`` source wins. The
|
||||
resolver MUST pin ``ref`` to an exact commit sha (see module docstring).
|
||||
|
||||
Returns a callback that unregisters the resolver.
|
||||
"""
|
||||
resolvers = hass.data.setdefault(DATA_SOURCE_RESOLVERS, [])
|
||||
resolvers.append(resolver)
|
||||
|
||||
@callback
|
||||
def _unregister() -> None:
|
||||
resolvers.remove(resolver)
|
||||
|
||||
return _unregister
|
||||
|
||||
|
||||
async def async_resolve_integration_source(
|
||||
hass: HomeAssistant, domain: str
|
||||
) -> pb.IntegrationSource:
|
||||
"""Resolve the source descriptor for ``domain``'s code.
|
||||
|
||||
Built-in integrations short-circuit to ``{kind: "builtin"}`` (the bundled
|
||||
``homeassistant`` package provides them). For a custom integration the
|
||||
registered resolvers are consulted in order; the first git source returned
|
||||
is used. If no resolver knows the domain, raises :class:`SandboxSourceError`
|
||||
— a custom integration with no source cannot run in a stateless sandbox, so
|
||||
the failure is surfaced rather than masked.
|
||||
"""
|
||||
integration = await async_get_integration(hass, domain)
|
||||
if integration.is_built_in:
|
||||
return pb.IntegrationSource(kind="builtin")
|
||||
|
||||
for resolver in hass.data.get(DATA_SOURCE_RESOLVERS, []):
|
||||
source = resolver(domain)
|
||||
if source is not None:
|
||||
return _git_source_from_dict(domain, source)
|
||||
|
||||
raise SandboxSourceError(
|
||||
f"no sandbox source resolver knows custom integration {domain!r}; "
|
||||
"a custom integration cannot run in a stateless sandbox without one"
|
||||
)
|
||||
|
||||
|
||||
def _git_source_from_dict(
|
||||
domain: str, source: IntegrationSourceDict
|
||||
) -> pb.IntegrationSource:
|
||||
"""Build a typed git ``IntegrationSource`` from a resolver's dict.
|
||||
|
||||
Validates the tag→sha pinning contract: ``url`` and an exact-sha ``ref``
|
||||
are required. ``domain`` and ``subdir`` default from ``domain``.
|
||||
"""
|
||||
url = source.get("url")
|
||||
if not url:
|
||||
raise SandboxSourceError(
|
||||
f"resolver returned a git source for {domain!r} without a url"
|
||||
)
|
||||
ref = source.get("ref")
|
||||
if not ref:
|
||||
raise SandboxSourceError(
|
||||
f"resolver returned a git source for {domain!r} without a ref; "
|
||||
"the resolver must pin the version to an exact commit sha"
|
||||
)
|
||||
return pb.IntegrationSource(
|
||||
kind="git",
|
||||
url=url,
|
||||
ref=ref,
|
||||
tag=source.get("tag", ""),
|
||||
domain=source.get("domain", domain),
|
||||
subdir=source.get("subdir", f"custom_components/{domain}"),
|
||||
)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"IntegrationSourceDict",
|
||||
"SandboxSourceError",
|
||||
"SandboxSourceResolver",
|
||||
"async_register_sandbox_source_resolver",
|
||||
"async_resolve_integration_source",
|
||||
]
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"title": "Sandbox"
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
"""Main-side translation provider for sandboxed integrations.
|
||||
|
||||
A custom integration runs in an isolated sandbox process; its code — and so
|
||||
its ``translations/<lang>.json`` — is never on main's disk. Main's translation
|
||||
cache therefore resolves the domain to ``IntegrationNotFound`` and serves no
|
||||
strings (entity names, config-flow labels, services, exceptions all vanish).
|
||||
|
||||
This module fills that gap. It registers a provider into core's
|
||||
sandbox-agnostic translation hook
|
||||
(:func:`homeassistant.helpers.translation.async_register_sandbox_translation_provider`).
|
||||
For each requested component the provider resolves the owning sandbox group,
|
||||
batches that group's custom domains into one ``sandbox/get_translations`` RPC
|
||||
per language, and hands the raw (``title``-pre-filled) strings back to the
|
||||
cache, which merges them as if they came off disk.
|
||||
|
||||
Two invariants keep it safe:
|
||||
|
||||
* **Built-in carve-out.** A sandboxed built-in integration's manifest +
|
||||
translations resolve on main from the bundled package, byte-identical to the
|
||||
sandbox's. Those never cross the wire — the provider returns nothing for
|
||||
``Integration.is_built_in`` domains and main reads its own disk.
|
||||
* **Degrade to empty.** The overlay runs under the translation cache lock, so
|
||||
it must never block the frontend. A group with no live channel — or an RPC
|
||||
that fails or times out — yields no strings for those domains (they fall
|
||||
through to main's empty disk result), never an exception.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.loader import IntegrationNotFound, async_get_integration
|
||||
|
||||
from ._proto import sandbox_pb2 as pb
|
||||
from .channel import Channel, ChannelClosedError, ChannelRemoteError
|
||||
from .messages import struct_to_dict
|
||||
from .protocol import MSG_GET_TRANSLATIONS
|
||||
from .proxy_flow import SandboxFlowProxy
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from . import SandboxData
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
# The overlay runs under the translation cache lock; cap the round-trip so a
|
||||
# wedged (but not closed) sandbox channel cannot hang the frontend translation
|
||||
# endpoint. A timeout degrades to empty, exactly like a dead channel.
|
||||
_RPC_TIMEOUT = 5.0
|
||||
|
||||
|
||||
class SandboxTranslationProvider:
|
||||
"""Resolve sandboxed integrations' translation strings over the channel."""
|
||||
|
||||
def __init__(self, hass: HomeAssistant, data: SandboxData) -> None:
|
||||
"""Bind the provider to the running sandbox data."""
|
||||
self._hass = hass
|
||||
self._data = data
|
||||
|
||||
async def async_get_translations(
|
||||
self, languages: list[str], components: set[str]
|
||||
) -> dict[str, dict[str, Any]]:
|
||||
"""Return ``{language: {domain: raw_strings}}`` for owned domains.
|
||||
|
||||
Only custom domains that are sandboxed into a group with a live
|
||||
channel produce strings; everything else is omitted so it keeps its
|
||||
on-disk (or empty) result. Never raises — see the module docstring.
|
||||
"""
|
||||
domains_by_group: dict[str, set[str]] = {}
|
||||
for domain in components:
|
||||
group = await self._resolve_sandbox_group(domain)
|
||||
if group is not None:
|
||||
domains_by_group.setdefault(group, set()).add(domain)
|
||||
|
||||
if not domains_by_group:
|
||||
return {}
|
||||
|
||||
result: dict[str, dict[str, Any]] = {}
|
||||
for group, domains in domains_by_group.items():
|
||||
bridge = self._data.bridges.get(group)
|
||||
channel = bridge.channel if bridge is not None else None
|
||||
if channel is None:
|
||||
# Sandbox not up / channel down — degrade to empty.
|
||||
continue
|
||||
for language in languages:
|
||||
strings = await self._fetch(channel, group, language, domains)
|
||||
for domain, domain_strings in strings.items():
|
||||
result.setdefault(language, {})[domain] = domain_strings
|
||||
return result
|
||||
|
||||
async def _resolve_sandbox_group(self, domain: str) -> str | None:
|
||||
"""Return the sandbox group owning ``domain``, or ``None``.
|
||||
|
||||
``None`` means "leave it to the disk path": the domain is not
|
||||
sandboxed, or it is a built-in whose files main already holds.
|
||||
Resolution order matches the flow router — a loaded entry's
|
||||
``sandbox`` field wins; otherwise an in-progress sandbox flow's group
|
||||
(for a brand-new custom with no entry yet).
|
||||
"""
|
||||
group: str | None = None
|
||||
for entry in self._hass.config_entries.async_entries(domain):
|
||||
if entry.sandbox is not None:
|
||||
group = entry.sandbox
|
||||
break
|
||||
if group is None:
|
||||
group = self._group_for_flow_in_progress(domain)
|
||||
if group is None:
|
||||
return None
|
||||
|
||||
# Built-in carve-out: main reads its byte-identical bundled files.
|
||||
try:
|
||||
integration = await async_get_integration(self._hass, domain)
|
||||
except IntegrationNotFound:
|
||||
# No code on main ⇒ a custom that genuinely needs the RPC.
|
||||
return group
|
||||
if integration.is_built_in:
|
||||
return None
|
||||
return group
|
||||
|
||||
@callback
|
||||
def _group_for_flow_in_progress(self, domain: str) -> str | None:
|
||||
"""Return the group of an in-progress sandbox flow for ``domain``.
|
||||
|
||||
A brand-new custom integration being added has no ``ConfigEntry`` yet,
|
||||
so its group lives only on the live :class:`SandboxFlowProxy` driving
|
||||
the add-integration dialog. The public flow API exposes only
|
||||
serialized results, so the live flow object is reached through the flow
|
||||
manager's per-handler progress index.
|
||||
"""
|
||||
index = self._hass.config_entries.flow._handler_progress_index # noqa: SLF001
|
||||
for flow in index.get(domain, ()):
|
||||
if isinstance(flow, SandboxFlowProxy):
|
||||
return flow.sandbox_group
|
||||
return None
|
||||
|
||||
async def _fetch(
|
||||
self, channel: Channel, group: str, language: str, domains: set[str]
|
||||
) -> dict[str, Any]:
|
||||
"""Issue one batched ``get_translations`` RPC; empty on any failure."""
|
||||
request = pb.GetTranslations(language=language, domains=sorted(domains))
|
||||
try:
|
||||
result = await channel.call(
|
||||
MSG_GET_TRANSLATIONS, request, timeout=_RPC_TIMEOUT
|
||||
)
|
||||
except (ChannelClosedError, ChannelRemoteError, TimeoutError) as err:
|
||||
_LOGGER.debug(
|
||||
"sandbox[%s]: get_translations(%s) failed (%s); serving empty",
|
||||
group,
|
||||
language,
|
||||
err,
|
||||
)
|
||||
return {}
|
||||
return struct_to_dict(result.strings)
|
||||
@@ -21,7 +21,7 @@ from functools import cache
|
||||
import logging
|
||||
from random import randint
|
||||
from types import MappingProxyType
|
||||
from typing import TYPE_CHECKING, Any, Self, TypedDict, cast
|
||||
from typing import TYPE_CHECKING, Any, Protocol, Self, TypedDict, cast
|
||||
|
||||
from async_interrupt import interrupt
|
||||
from propcache.api import cached_property
|
||||
@@ -287,6 +287,7 @@ UPDATE_ENTRY_CONFIG_ENTRY_ATTRS = {
|
||||
"pref_disable_polling",
|
||||
"minor_version",
|
||||
"version",
|
||||
"sandbox",
|
||||
}
|
||||
|
||||
|
||||
@@ -311,6 +312,7 @@ class ConfigFlowResult(FlowResult[ConfigFlowContext, str], total=False):
|
||||
minor_version: int
|
||||
options: Mapping[str, Any]
|
||||
result: ConfigEntry
|
||||
sandbox: str
|
||||
subentries: Iterable[ConfigSubentryData]
|
||||
version: int
|
||||
|
||||
@@ -427,6 +429,7 @@ class ConfigEntry[_DataT = Any]:
|
||||
created_at: datetime
|
||||
modified_at: datetime
|
||||
discovery_keys: MappingProxyType[str, tuple[DiscoveryKey, ...]]
|
||||
sandbox: str | None
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -442,6 +445,7 @@ class ConfigEntry[_DataT = Any]:
|
||||
options: Mapping[str, Any] | None,
|
||||
pref_disable_new_entities: bool | None = None,
|
||||
pref_disable_polling: bool | None = None,
|
||||
sandbox: str | None = None,
|
||||
source: str,
|
||||
state: ConfigEntryState = ConfigEntryState.NOT_LOADED,
|
||||
subentries_data: Iterable[ConfigSubentryData | ConfigSubentryDataWithId] | None,
|
||||
@@ -559,6 +563,11 @@ class ConfigEntry[_DataT = Any]:
|
||||
_setter(self, "modified_at", modified_at or utcnow())
|
||||
_setter(self, "discovery_keys", discovery_keys)
|
||||
|
||||
# Sandbox group this entry belongs to, or None for non-sandboxed
|
||||
# entries. Set by sandbox at flow completion (CREATE_ENTRY) and
|
||||
# consulted by ConfigEntries.router on every setup/unload.
|
||||
_setter(self, "sandbox", sandbox)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
"""Representation of ConfigEntry."""
|
||||
return (
|
||||
@@ -1191,7 +1200,7 @@ class ConfigEntry[_DataT = Any]:
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
"""Return dictionary version of this entry."""
|
||||
return {
|
||||
result: dict[str, Any] = {
|
||||
"created_at": self.created_at.isoformat(),
|
||||
"data": dict(self.data),
|
||||
"discovery_keys": dict(self.discovery_keys),
|
||||
@@ -1209,6 +1218,11 @@ class ConfigEntry[_DataT = Any]:
|
||||
"unique_id": self.unique_id,
|
||||
"version": self.version,
|
||||
}
|
||||
# Persist sandbox tag only when set, to keep on-disk shape lean
|
||||
# for the common (non-sandboxed) case.
|
||||
if self.sandbox is not None:
|
||||
result["sandbox"] = self.sandbox
|
||||
return result
|
||||
|
||||
@callback
|
||||
def async_on_unload(
|
||||
@@ -1796,6 +1810,7 @@ class ConfigEntriesFlowManager(
|
||||
domain=result["handler"],
|
||||
minor_version=result["minor_version"],
|
||||
options=result["options"],
|
||||
sandbox=result.get("sandbox"),
|
||||
source=flow.context["source"],
|
||||
subentries_data=result["subentries"],
|
||||
title=result["title"],
|
||||
@@ -1832,12 +1847,20 @@ class ConfigEntriesFlowManager(
|
||||
|
||||
Handler key is the domain of the component that we want to set up.
|
||||
"""
|
||||
handler = await _async_get_flow_handler(
|
||||
self.hass, handler_key, self._hass_config
|
||||
)
|
||||
if not context or "source" not in context:
|
||||
raise KeyError("Context not set or doesn't have a source set")
|
||||
|
||||
if (router := self.config_entries.router) is not None and (
|
||||
flow := await router.async_create_flow(
|
||||
handler_key, context=context, data=data
|
||||
)
|
||||
) is not None:
|
||||
flow.init_step = context["source"]
|
||||
return flow
|
||||
|
||||
handler = await _async_get_flow_handler(
|
||||
self.hass, handler_key, self._hass_config
|
||||
)
|
||||
flow = handler()
|
||||
flow.init_step = context["source"]
|
||||
return flow
|
||||
@@ -2095,6 +2118,30 @@ class ConfigEntryStore(storage.Store[dict[str, list[dict[str, Any]]]]):
|
||||
return data
|
||||
|
||||
|
||||
class ConfigEntryRouter(Protocol):
|
||||
"""Hook protocol for routing config flows and entry setup elsewhere.
|
||||
|
||||
Currently used by `sandbox` to divert flows and config-entry setup to
|
||||
a sandbox subprocess. Each method returns ``None`` to fall through to
|
||||
the default behaviour and a concrete value to take over.
|
||||
"""
|
||||
|
||||
async def async_create_flow(
|
||||
self,
|
||||
handler_key: str,
|
||||
*,
|
||||
context: ConfigFlowContext,
|
||||
data: Any,
|
||||
) -> ConfigFlow | None:
|
||||
"""Return a flow handler that will run the flow, or None to fall through."""
|
||||
|
||||
async def async_setup_entry(self, entry: ConfigEntry) -> bool | None:
|
||||
"""Set up the entry and return success, or None to fall through."""
|
||||
|
||||
async def async_unload_entry(self, entry: ConfigEntry) -> bool | None:
|
||||
"""Unload the entry and return success, or None to fall through."""
|
||||
|
||||
|
||||
class ConfigEntries:
|
||||
"""Manage the configuration entries.
|
||||
|
||||
@@ -2110,6 +2157,8 @@ class ConfigEntries:
|
||||
self._hass_config = hass_config
|
||||
self._entries = ConfigEntryItems(hass)
|
||||
self._store = ConfigEntryStore(hass)
|
||||
# Optional hook for diverting flows and entry setup (used by sandbox).
|
||||
self.router: ConfigEntryRouter | None = None
|
||||
EntityRegistryDisabledHandler(hass).async_setup()
|
||||
|
||||
@callback
|
||||
@@ -2302,6 +2351,9 @@ class ConfigEntries:
|
||||
options=entry["options"],
|
||||
pref_disable_new_entities=entry["pref_disable_new_entities"],
|
||||
pref_disable_polling=entry["pref_disable_polling"],
|
||||
# Only sandboxed entries persist this key (as_dict writes it
|
||||
# solely when set), so non-sandboxed entries lack it.
|
||||
sandbox=entry.get("sandbox"),
|
||||
source=entry["source"],
|
||||
subentries_data=entry["subentries"],
|
||||
title=entry["title"],
|
||||
@@ -2377,6 +2429,11 @@ class ConfigEntries:
|
||||
f" be in the {ConfigEntryState.NOT_LOADED} state"
|
||||
)
|
||||
|
||||
if self.router is not None:
|
||||
result = await self.router.async_setup_entry(entry)
|
||||
if result is not None:
|
||||
return result
|
||||
|
||||
# Setup Component if not set up yet
|
||||
if entry.domain in self.hass.config.components:
|
||||
if _lock:
|
||||
@@ -2408,6 +2465,14 @@ class ConfigEntries:
|
||||
f" recoverable state {entry.state}"
|
||||
)
|
||||
|
||||
if self.router is not None:
|
||||
result = await self.router.async_unload_entry(entry)
|
||||
if result is not None:
|
||||
entry._async_set_state( # noqa: SLF001
|
||||
self.hass, ConfigEntryState.NOT_LOADED, None
|
||||
)
|
||||
return result
|
||||
|
||||
if _lock:
|
||||
async with entry.setup_lock:
|
||||
return await entry.async_unload(self.hass)
|
||||
@@ -2508,6 +2573,7 @@ class ConfigEntries:
|
||||
options: Mapping[str, Any] | UndefinedType = UNDEFINED,
|
||||
pref_disable_new_entities: bool | UndefinedType = UNDEFINED,
|
||||
pref_disable_polling: bool | UndefinedType = UNDEFINED,
|
||||
sandbox: str | None | UndefinedType = UNDEFINED,
|
||||
title: str | UndefinedType = UNDEFINED,
|
||||
unique_id: str | None | UndefinedType = UNDEFINED,
|
||||
version: int | UndefinedType = UNDEFINED,
|
||||
@@ -2528,6 +2594,7 @@ class ConfigEntries:
|
||||
options=options,
|
||||
pref_disable_new_entities=pref_disable_new_entities,
|
||||
pref_disable_polling=pref_disable_polling,
|
||||
sandbox=sandbox,
|
||||
title=title,
|
||||
unique_id=unique_id,
|
||||
version=version,
|
||||
@@ -2546,6 +2613,7 @@ class ConfigEntries:
|
||||
options: Mapping[str, Any] | UndefinedType = UNDEFINED,
|
||||
pref_disable_new_entities: bool | UndefinedType = UNDEFINED,
|
||||
pref_disable_polling: bool | UndefinedType = UNDEFINED,
|
||||
sandbox: str | None | UndefinedType = UNDEFINED,
|
||||
subentries: dict[str, ConfigSubentry] | UndefinedType = UNDEFINED,
|
||||
title: str | UndefinedType = UNDEFINED,
|
||||
unique_id: str | None | UndefinedType = UNDEFINED,
|
||||
@@ -2596,6 +2664,7 @@ class ConfigEntries:
|
||||
("minor_version", minor_version),
|
||||
("pref_disable_new_entities", pref_disable_new_entities),
|
||||
("pref_disable_polling", pref_disable_polling),
|
||||
("sandbox", sandbox),
|
||||
("title", title),
|
||||
("version", version),
|
||||
):
|
||||
|
||||
@@ -203,6 +203,26 @@ class EntityComponent[_EntityT: entity.Entity = entity.Entity]:
|
||||
await platform.async_reset()
|
||||
return True
|
||||
|
||||
@callback
|
||||
def async_register_remote_platform(
|
||||
self, config_entry: ConfigEntry, platform: EntityPlatform
|
||||
) -> None:
|
||||
"""Register a pre-built EntityPlatform for a remote integration.
|
||||
|
||||
Used by ``sandbox`` to attach a proxy ``EntityPlatform`` whose
|
||||
entities live on this Home Assistant instance but whose owning
|
||||
integration runs in a child process. The platform is keyed by the
|
||||
config entry just like ``async_setup_entry`` keys its own; a later
|
||||
``async_unload_entry`` removes it the same way.
|
||||
"""
|
||||
key = config_entry.entry_id
|
||||
if key in self._platforms:
|
||||
raise ValueError(
|
||||
f"Config entry {config_entry.title} ({key}) for {self.domain}"
|
||||
" has already been setup!"
|
||||
)
|
||||
self._platforms[key] = platform
|
||||
|
||||
async def async_extract_from_service(
|
||||
self, service_call: ServiceCall, expand_group: bool = True
|
||||
) -> list[_EntityT]:
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
"""Context-local routing primitive for sandboxed integrations.
|
||||
|
||||
A sandbox runtime (``sandbox``) runs integrations in an isolated
|
||||
subprocess. Core HA primitives such as :class:`homeassistant.helpers.storage.Store`
|
||||
must, inside that subprocess, route their IO to main instead of touching
|
||||
the sandbox's local disk. Rather than monkey-patching the ``Store`` class
|
||||
at module scope (the v1 footgun), the runtime sets a :class:`~contextvars.ContextVar`
|
||||
that those primitives read at call time.
|
||||
|
||||
The shape mirrors the existing module-level ContextVars in this package —
|
||||
``helpers/http.py::current_request`` and
|
||||
``helpers/chat_session.py::current_session``: a module-level
|
||||
``ContextVar[T | None]`` with ``default=None``.
|
||||
|
||||
Hard rule (see the plan's Risk #3): **never set ``current_sandbox`` from
|
||||
main-side code.** It is set exactly once, early in the sandbox runtime's
|
||||
``run()``, and inherited by every coroutine the runtime spawns (asyncio
|
||||
copies the context at ``create_task`` time). Setting it on main's event
|
||||
loop would silently reroute main's own ``Store`` IO to a bridge.
|
||||
"""
|
||||
|
||||
from contextvars import ContextVar
|
||||
from typing import Any, Protocol
|
||||
|
||||
|
||||
class SandboxBridge(Protocol):
|
||||
"""Per-sandbox routing surface, populated by the sandbox runtime.
|
||||
|
||||
Today this carries only the three ``Store`` IO methods. The protocol
|
||||
is forward-compatible with cross-sandbox sub-namespaces (IR / RF /
|
||||
BLE): a future plan adds e.g. ``infrared: InfraredBridge`` without
|
||||
touching the existing methods or their callers.
|
||||
|
||||
``async_store_load`` returns the *wrapped* storage envelope
|
||||
(``{"version", "minor_version", "key", "data"}``) or ``None`` — the
|
||||
migration loop in ``Store`` runs against it unchanged, regardless of
|
||||
whether the dict came from disk or from a bridge.
|
||||
"""
|
||||
|
||||
async def async_store_load(self, key: str) -> Any:
|
||||
"""Return the wrapped storage envelope for ``key`` (or ``None``)."""
|
||||
|
||||
async def async_store_save(self, key: str, data: Any) -> None:
|
||||
"""Persist the wrapped storage envelope ``data`` under ``key``."""
|
||||
|
||||
async def async_store_remove(self, key: str) -> None:
|
||||
"""Remove the stored data for ``key``."""
|
||||
|
||||
|
||||
current_sandbox: ContextVar[SandboxBridge | None] = ContextVar(
|
||||
"current_sandbox", default=None
|
||||
)
|
||||
@@ -32,6 +32,7 @@ from homeassistant.util.file import WriteError, write_utf8_file, write_utf8_file
|
||||
from homeassistant.util.hass_dict import HassKey
|
||||
|
||||
from . import json as json_helper
|
||||
from .sandbox_context import current_sandbox
|
||||
|
||||
# mypy: allow-untyped-calls, allow-untyped-defs, no-warn-return-any
|
||||
# mypy: no-check-untyped-defs
|
||||
@@ -357,6 +358,14 @@ class Store[_T: Mapping[str, Any] | Sequence[Any]]:
|
||||
# We make a copy because code might assume it's safe to mutate loaded data
|
||||
# and we don't want that to mess with what we're trying to store.
|
||||
data = deepcopy(data)
|
||||
elif sandbox := current_sandbox.get():
|
||||
# A sandbox runtime routes Store IO to main instead of local
|
||||
# disk. Fetch the wrapped envelope from the bridge; the migration
|
||||
# block below runs unchanged regardless of whether the dict came
|
||||
# from disk or from the bridge (design choice B).
|
||||
data = await sandbox.async_store_load(self.key)
|
||||
if data is None:
|
||||
return None
|
||||
elif cache := self._manager.async_fetch(self.key):
|
||||
exists, data = cache
|
||||
if not exists:
|
||||
@@ -589,6 +598,17 @@ class Store[_T: Mapping[str, Any] | Sequence[Any]]:
|
||||
_LOGGER.error("Error writing config for %s: %s", self.key, err)
|
||||
|
||||
async def _async_write_data(self, data: dict) -> None:
|
||||
if sandbox := current_sandbox.get():
|
||||
# A sandbox runtime routes the wrapped envelope to main instead
|
||||
# of writing to local disk. Branching here (rather than in
|
||||
# async_save) is load-bearing: async_save, async_delay_save, and
|
||||
# the EVENT_HOMEASSISTANT_FINAL_WRITE flush all funnel their
|
||||
# writes through _async_handle_write_data -> _async_write_data,
|
||||
# so a single branch here covers every write path uniformly. The
|
||||
# bridge owns the envelope normalisation (resolving any pending
|
||||
# data_func), orjson preserialise, and transport.
|
||||
await sandbox.async_store_save(self.key, data)
|
||||
return
|
||||
if self._serialize_in_event_loop:
|
||||
if "data_func" in data:
|
||||
data["data"] = data.pop("data_func")()
|
||||
@@ -627,5 +647,10 @@ class Store[_T: Mapping[str, Any] | Sequence[Any]]:
|
||||
self._async_cleanup_delay_listener()
|
||||
self._async_cleanup_final_write_listener()
|
||||
|
||||
if sandbox := current_sandbox.get():
|
||||
# A sandbox runtime unlinks on main, not on local disk.
|
||||
await sandbox.async_store_remove(self.key)
|
||||
return
|
||||
|
||||
with suppress(FileNotFoundError):
|
||||
await self.hass.async_add_executor_job(os.unlink, self.path)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Translation string lookup helpers."""
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Iterable, Mapping
|
||||
from collections.abc import Awaitable, Callable, Iterable, Mapping
|
||||
from contextlib import suppress
|
||||
from dataclasses import dataclass
|
||||
import logging
|
||||
@@ -19,7 +19,9 @@ from homeassistant.loader import (
|
||||
Integration,
|
||||
async_get_config_flows,
|
||||
async_get_integrations,
|
||||
async_get_sandbox_catalog,
|
||||
)
|
||||
from homeassistant.util.hass_dict import HassKey
|
||||
from homeassistant.util.json import load_json
|
||||
|
||||
from . import singleton
|
||||
@@ -29,6 +31,23 @@ _LOGGER = logging.getLogger(__name__)
|
||||
TRANSLATION_FLATTEN_CACHE = "translation_flatten_cache"
|
||||
LOCALE_EN = "en"
|
||||
|
||||
# A sandbox translation provider supplies frontend strings for sandboxed
|
||||
# integrations whose code (and therefore translations/<lang>.json) is not on
|
||||
# main's disk — a custom integration running in an isolated sandbox process.
|
||||
# Called inside the cache load with the requested languages and the full
|
||||
# component set; returns ``{language: {domain: <raw strings.json dict>}}`` for
|
||||
# only the domains it owns. It must never raise (a dead sandbox degrades to
|
||||
# empty) so a sandbox can't wedge the frontend translation path. Registered by
|
||||
# the sandbox integration so core stays sandbox-agnostic — mirrors the
|
||||
# ``sandbox.sources`` source-resolver convention.
|
||||
type SandboxTranslationProvider = Callable[
|
||||
[list[str], set[str]], Awaitable[dict[str, dict[str, Any]]]
|
||||
]
|
||||
|
||||
DATA_SANDBOX_TRANSLATION_PROVIDERS: HassKey[list[SandboxTranslationProvider]] = HassKey(
|
||||
"sandbox_translation_providers"
|
||||
)
|
||||
|
||||
|
||||
def recursive_flatten(
|
||||
prefix: str, data: dict[str, dict[str, Any] | str]
|
||||
@@ -113,15 +132,24 @@ async def _async_get_component_strings(
|
||||
_load_translations_files_by_language, files_to_load_by_language
|
||||
)
|
||||
|
||||
# Sandbox-only customs have no on-disk Integration, so their title falls
|
||||
# back to the catalog descriptor instead (name, or a localized title).
|
||||
catalog = async_get_sandbox_catalog(hass)
|
||||
|
||||
for language in languages:
|
||||
loaded_translations = loaded_translations_by_language.setdefault(language, {})
|
||||
for domain in components:
|
||||
# Translations that miss "title" will get integration put in.
|
||||
component_translations = loaded_translations.setdefault(domain, {})
|
||||
if "title" not in component_translations and (
|
||||
integration := integrations.get(domain)
|
||||
):
|
||||
if "title" in component_translations:
|
||||
continue
|
||||
if integration := integrations.get(domain):
|
||||
component_translations["title"] = integration.name
|
||||
elif descriptor := catalog.get(domain):
|
||||
title_translations = descriptor.get("title_translations") or {}
|
||||
component_translations["title"] = title_translations.get(
|
||||
language, descriptor.get("name") or domain
|
||||
)
|
||||
|
||||
translations_by_language.setdefault(language, {}).update(loaded_translations)
|
||||
|
||||
@@ -157,6 +185,24 @@ class _TranslationCache:
|
||||
"""Return if the given components are loaded for the language."""
|
||||
return components.issubset(self.cache_data.loaded.get(language, set()))
|
||||
|
||||
@callback
|
||||
def async_invalidate(self, components: set[str]) -> None:
|
||||
"""Drop cached + loaded state for ``components`` across all languages.
|
||||
|
||||
Translations are otherwise never evicted (see :meth:`async_load`), so
|
||||
there is no other way to refresh strings that changed on the source.
|
||||
The sandbox calls this when a custom integration is re-fetched at a new
|
||||
commit ref (or its entry reloads): the integration's strings may have
|
||||
changed, and the next :meth:`async_load` re-runs the provider overlay
|
||||
for the dropped components.
|
||||
"""
|
||||
for loaded in self.cache_data.loaded.values():
|
||||
loaded -= components
|
||||
for by_category in self.cache_data.cache.values():
|
||||
for category_cache in by_category.values():
|
||||
for component in components & category_cache.keys():
|
||||
del category_cache[component]
|
||||
|
||||
async def async_load(
|
||||
self,
|
||||
language: str,
|
||||
@@ -229,6 +275,9 @@ class _TranslationCache:
|
||||
translation_by_language_strings = await _async_get_component_strings(
|
||||
self.hass, languages, components, integrations
|
||||
)
|
||||
await self._async_overlay_sandbox_strings(
|
||||
languages, components, translation_by_language_strings
|
||||
)
|
||||
|
||||
# English is always the fallback language so we load them first
|
||||
self._build_category_cache(
|
||||
@@ -252,6 +301,35 @@ class _TranslationCache:
|
||||
|
||||
loaded[language].update(components)
|
||||
|
||||
async def _async_overlay_sandbox_strings(
|
||||
self,
|
||||
languages: list[str],
|
||||
components: set[str],
|
||||
translation_by_language_strings: dict[str, dict[str, Any]],
|
||||
) -> None:
|
||||
"""Splice sandboxed integrations' strings onto the disk-loaded set.
|
||||
|
||||
A sandboxed custom integration has no code — and so no
|
||||
``translations/<lang>.json`` — on main:
|
||||
:func:`async_get_integrations` returned an ``IntegrationNotFound`` for
|
||||
it, and :func:`_async_get_component_strings` produced an empty entry.
|
||||
Registered providers fetch the real strings over the sandbox channel;
|
||||
we merge them in *before* :meth:`_build_category_cache` so they go
|
||||
through the same flatten / English-fallback / ``loaded`` machinery as
|
||||
disk strings. A provider claims only the domains it owns and never
|
||||
raises (a dead channel degrades to empty), so a sandbox cannot wedge
|
||||
the frontend translation path.
|
||||
"""
|
||||
providers = self.hass.data.get(DATA_SANDBOX_TRANSLATION_PROVIDERS)
|
||||
if not providers:
|
||||
return
|
||||
for provider in providers:
|
||||
overlay = await provider(languages, components)
|
||||
for language, by_domain in overlay.items():
|
||||
translation_by_language_strings.setdefault(language, {}).update(
|
||||
by_domain
|
||||
)
|
||||
|
||||
def _validate_placeholders(
|
||||
self,
|
||||
language: str,
|
||||
@@ -419,6 +497,42 @@ async def async_load_integrations(hass: HomeAssistant, integrations: set[str]) -
|
||||
)
|
||||
|
||||
|
||||
@callback
|
||||
def async_register_sandbox_translation_provider(
|
||||
hass: HomeAssistant, provider: SandboxTranslationProvider
|
||||
) -> Callable[[], None]:
|
||||
"""Register a provider of sandboxed integrations' translation strings.
|
||||
|
||||
The sandbox integration registers one of these so core stays
|
||||
sandbox-agnostic (mirrors ``async_register_sandbox_source_resolver``). The
|
||||
provider is awaited inside the translation cache load and returns
|
||||
``{language: {domain: raw_strings}}`` for only the domains it owns; every
|
||||
other domain keeps its on-disk result. See
|
||||
:data:`SandboxTranslationProvider`.
|
||||
|
||||
Returns a callback that unregisters the provider.
|
||||
"""
|
||||
providers = hass.data.setdefault(DATA_SANDBOX_TRANSLATION_PROVIDERS, [])
|
||||
providers.append(provider)
|
||||
|
||||
@callback
|
||||
def _unregister() -> None:
|
||||
providers.remove(provider)
|
||||
|
||||
return _unregister
|
||||
|
||||
|
||||
@callback
|
||||
def async_invalidate_translations(hass: HomeAssistant, components: set[str]) -> None:
|
||||
"""Evict cached translations for ``components`` across all languages.
|
||||
|
||||
Used by the sandbox when a sandboxed integration's strings may have
|
||||
changed (re-fetch at a new ref / entry reload); the next load re-runs the
|
||||
provider overlay for those components.
|
||||
"""
|
||||
_async_get_translations_cache(hass).async_invalidate(components)
|
||||
|
||||
|
||||
@callback
|
||||
def async_translations_loaded(hass: HomeAssistant, components: set[str]) -> bool:
|
||||
"""Return if the given components are loaded for the language."""
|
||||
|
||||
@@ -413,6 +413,82 @@ class ComponentProtocol(Protocol):
|
||||
"""Set up integration."""
|
||||
|
||||
|
||||
class SandboxIntegrationDescriptor(TypedDict, total=False):
|
||||
"""Display metadata for a sandbox-only custom integration.
|
||||
|
||||
A custom integration whose code lives only in a sandbox (fetched on
|
||||
``entry_setup``, never on main's disk) is invisible to the on-disk scan
|
||||
that feeds the add-integration picker. A registered catalog provider
|
||||
supplies this small descriptor so the picker can list and name it without
|
||||
spawning a sandbox.
|
||||
|
||||
``domain`` and ``name`` are the load-bearing fields — ``name`` feeds both
|
||||
the picker row and the ``title`` fallback. ``title_translations``
|
||||
(``{language: title}``) is optional: a distribution mechanism (HACS) may
|
||||
not have the un-fetched tarball's ``translations/`` indexed, in which case
|
||||
the picker degrades to ``name``. Unlike the sha-pinned integration-source
|
||||
resolver, this is display-only and never security-critical.
|
||||
"""
|
||||
|
||||
domain: str
|
||||
name: str
|
||||
config_flow: bool
|
||||
integration_type: str
|
||||
iot_class: str | None
|
||||
single_config_entry: bool
|
||||
title_translations: dict[str, str]
|
||||
|
||||
|
||||
# A catalog provider enumerates the sandbox-only custom integrations a
|
||||
# distribution mechanism (HACS) knows about, for picker discoverability. It is
|
||||
# display-only and enumerable — deliberately separate from the lazy, per-domain,
|
||||
# security-critical sandbox source resolver (homeassistant.components.sandbox.
|
||||
# sources), so display strings never travel through the sha-validation path.
|
||||
SandboxCatalogProvider = Callable[[], list[SandboxIntegrationDescriptor]]
|
||||
|
||||
DATA_SANDBOX_CATALOG_PROVIDERS: HassKey[list[SandboxCatalogProvider]] = HassKey(
|
||||
"sandbox_catalog_providers"
|
||||
)
|
||||
|
||||
|
||||
@callback
|
||||
def async_register_sandbox_catalog_provider(
|
||||
hass: HomeAssistant, provider: SandboxCatalogProvider
|
||||
) -> Callable[[], None]:
|
||||
"""Register a provider enumerating sandbox-only custom integrations.
|
||||
|
||||
HACS (or any custom-integration distribution mechanism) calls this so the
|
||||
add-integration picker can list and name a custom integration whose code
|
||||
lives only in a sandbox. Providers are consulted in registration order; the
|
||||
first to claim a domain wins. Returns a callback that unregisters.
|
||||
"""
|
||||
providers = hass.data.setdefault(DATA_SANDBOX_CATALOG_PROVIDERS, [])
|
||||
providers.append(provider)
|
||||
|
||||
@callback
|
||||
def _unregister() -> None:
|
||||
providers.remove(provider)
|
||||
|
||||
return _unregister
|
||||
|
||||
|
||||
@callback
|
||||
def async_get_sandbox_catalog(
|
||||
hass: HomeAssistant,
|
||||
) -> dict[str, SandboxIntegrationDescriptor]:
|
||||
"""Return registered sandbox catalog descriptors keyed by domain.
|
||||
|
||||
Earlier-registered providers win on a domain collision, matching the
|
||||
source-resolver convention. Returns an empty dict when no provider is
|
||||
registered (the common case — no sandbox).
|
||||
"""
|
||||
catalog: dict[str, SandboxIntegrationDescriptor] = {}
|
||||
for provider in hass.data.get(DATA_SANDBOX_CATALOG_PROVIDERS, []):
|
||||
for descriptor in provider():
|
||||
catalog.setdefault(descriptor["domain"], descriptor)
|
||||
return catalog
|
||||
|
||||
|
||||
async def async_get_integration_descriptions(
|
||||
hass: HomeAssistant,
|
||||
) -> dict[str, Any]:
|
||||
@@ -457,6 +533,23 @@ async def async_get_integration_descriptions(
|
||||
}
|
||||
custom_flows[integration_key][integration.domain] = metadata
|
||||
|
||||
# Merge sandbox-only customs (code never on disk) so the picker can list
|
||||
# them. On-disk customs carry richer metadata and a domain may appear in
|
||||
# both, so the disk scan above wins on collision.
|
||||
for domain, descriptor in async_get_sandbox_catalog(hass).items():
|
||||
if domain in custom_flows["integration"] or domain in custom_flows["helper"]:
|
||||
continue
|
||||
integration_type = descriptor.get("integration_type", "integration")
|
||||
integration_key = "helper" if integration_type == "helper" else "integration"
|
||||
custom_flows[integration_key][domain] = {
|
||||
"config_flow": descriptor.get("config_flow", False),
|
||||
"integration_type": integration_type,
|
||||
"iot_class": descriptor.get("iot_class"),
|
||||
"name": descriptor.get("name", domain),
|
||||
"single_config_entry": descriptor.get("single_config_entry", False),
|
||||
"overwrites_built_in": False,
|
||||
}
|
||||
|
||||
return {"core": core_flows, "custom": custom_flows}
|
||||
|
||||
|
||||
|
||||
@@ -102,6 +102,8 @@ include = ["homeassistant*"]
|
||||
|
||||
[tool.pylint.MAIN]
|
||||
py-version = "3.14"
|
||||
# Checked-in protobuf gencode (sandbox) is machine-generated — never lint it.
|
||||
ignore-paths = [".*_pb2\\.pyi?$"]
|
||||
# Use a conservative default here; 2 should speed up most setups and not hurt
|
||||
# any too bad. Override on command line as appropriate.
|
||||
jobs = 2
|
||||
@@ -649,6 +651,9 @@ exclude_lines = [
|
||||
|
||||
[tool.ruff]
|
||||
required-version = ">=0.15.14"
|
||||
# Checked-in protobuf gencode (sandbox) — machine-generated, regenerated by
|
||||
# sandbox/proto/generate.sh; never hand-edited, so never linted.
|
||||
extend-exclude = ["*_pb2.py", "*_pb2.pyi"]
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = [
|
||||
|
||||
Generated
+3
@@ -1873,6 +1873,9 @@ proliphix==0.4.1
|
||||
# homeassistant.components.prometheus
|
||||
prometheus-client==0.21.0
|
||||
|
||||
# homeassistant.components.sandbox
|
||||
protobuf==6.32.0
|
||||
|
||||
# homeassistant.components.prowl
|
||||
prowlpy==1.1.5
|
||||
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
# Generated compat-lane output (run_compat.py). The curated COMPAT.md
|
||||
# baseline is committed; the per-run machine output is not.
|
||||
/COMPAT.csv
|
||||
/COMPAT_LATEST.md
|
||||
@@ -0,0 +1,399 @@
|
||||
# Home Assistant Sandbox — Architecture
|
||||
|
||||
> This document describes the **final, current architecture** of the Home
|
||||
> Assistant sandbox: how an integration runs in an isolated subprocess while
|
||||
> the main instance keeps a single unified view of devices, entities,
|
||||
> services, and events. It is a state-of-the-system reference, not a history.
|
||||
> A condensed changelog of the work that produced this state is at the bottom.
|
||||
>
|
||||
> Deeper, source-linked detail lives in [`OVERVIEW.md`](OVERVIEW.md); the
|
||||
> design rationale for individual decisions is in [`docs/`](docs/).
|
||||
|
||||
## 1. Goal
|
||||
|
||||
Run a Home Assistant integration's setup, config flow, entities, services,
|
||||
events, and storage fully inside an **isolated subprocess** ("sandbox"), while
|
||||
the main HA instance presents a **single, unified view** that looks identical to
|
||||
running everything locally.
|
||||
|
||||
A user who adds a light integration through the frontend ends up with a device
|
||||
plus entities in main's registries, working area targeting (`light.turn_on`
|
||||
against an area resolves the sandboxed lights like any other light), and the
|
||||
integration's services, events, and translations available on main — with the
|
||||
integration code only ever executing inside the sandbox.
|
||||
|
||||
The sandbox is **stateless**: it holds no persistent state of its own. Its
|
||||
storage and restore-state route to main (§9), and even the integration's *code*
|
||||
is fetched at startup (§7) rather than living on the sandbox — so a sandbox is
|
||||
wipe-and-restart safe and could run anywhere, including a fresh container.
|
||||
|
||||
## 2. Components
|
||||
|
||||
### Main side — `homeassistant/components/sandbox/`
|
||||
|
||||
| Component | Responsibility |
|
||||
|---|---|
|
||||
| `SandboxFlowRouter` | Plugged into `hass.config_entries.router`; routes new flows and entry setup/unload to a sandbox or to main. |
|
||||
| `SandboxManager` | `dict[group → SandboxProcess]`; lazily spawns one subprocess per group, supervises it, restarts on crash. |
|
||||
| `SandboxBridge` (per group) | Owns the proxy-entity registry, forwards entity service calls, re-fires sandbox events, and runs the per-group store server. |
|
||||
| `classifier.py` | Pure function `Integration → SandboxAssignment` deciding which group (or main) an integration belongs to. |
|
||||
| `sources.py` | The integration-source resolver registry (how custom code is located). |
|
||||
| `translation.py` | `SandboxTranslationProvider` — pulls a sandboxed integration's translation strings from the live sandbox into main's translation cache (see §11). |
|
||||
| `catalog.py` | Re-exports the loader catalog hook so HACS can make a sandbox-only custom integration discoverable + named in the add-integration picker (see §11). |
|
||||
|
||||
### Sandbox side — `sandbox/hass_client/`
|
||||
|
||||
The subprocess runs a private `HomeAssistant` instance hosting:
|
||||
|
||||
| Component | Responsibility |
|
||||
|---|---|
|
||||
| `SandboxRuntime` | Owns the private hass, opens the control channel, sets the store-routing contextvar. |
|
||||
| `FlowRunner` | Drives the integration's real `ConfigFlow` on flow_init / step / abort. |
|
||||
| `EntryRunner` | Fetches integration code if needed, then runs `async_setup_entry` against the private hass. |
|
||||
| `EntityBridge` | Pushes `register_entity` + `state_changed` to main. |
|
||||
| `ServiceMirror` | Pushes `register_service` for approved domains. |
|
||||
| `EventMirror` | Re-fires `<approved_domain>_*` events to main. |
|
||||
| `ApprovedDomains` | Refcounted domain set that gates the service/event mirrors. |
|
||||
| `ChannelSandboxBridge` | Implements store load/save/remove over the channel (see §8). |
|
||||
|
||||
## 3. Routing
|
||||
|
||||
`classify(integration)` is a pure function run from the router at flow creation
|
||||
and at entry setup (for entries with no `ConfigEntry.sandbox` value yet). It
|
||||
uses `Integration.platforms_exists()` so it never imports the integration to
|
||||
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; 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`**.
|
||||
|
||||
**`ALWAYS_MAIN`** holds two classes of integration. *Behavioural punts*:
|
||||
`script`, `automation`, `scene`, `cloud`, `ai_task`, `image` (the last two do
|
||||
non-idempotent pre-dispatch work no bridge intercepts cleanly). *Lockdown
|
||||
helpers* — integrations that read entities/registries/areas they don't own, so
|
||||
they cannot function under the locked-down sandbox posture and run on main:
|
||||
`template`, `group`, `homekit`, `min_max`, `statistics`, `trend`, `threshold`,
|
||||
`derivative`, `integration`, `utility_meter`, `filter`, `mold_indicator`,
|
||||
`bayesian`, `generic_thermostat`, `generic_hygrostat`, `switch_as_x`,
|
||||
`history_stats`, `proximity`. A future scoped state-sharing opt-in
|
||||
([`docs/design-share-states.md`](docs/design-share-states.md)) could return the
|
||||
helper cluster to sandboxes.
|
||||
|
||||
Three groups ship by default: **`main`** (hosts no process — anything routed to
|
||||
main runs directly), **`built-in`** (every other built-in integration), and
|
||||
**`custom`** (every HACS / user integration). The routing tag is persisted on
|
||||
the first-class `ConfigEntry.sandbox` field, not in `entry.data`.
|
||||
|
||||
## 4. Control channel & transport
|
||||
|
||||
Main and sandbox talk over a **`Channel`** with a deliberate three-layer split,
|
||||
so each layer is independently testable and replaceable:
|
||||
|
||||
```
|
||||
Channel (dispatch core: id↔reply map, inflight concurrency, register/call/push)
|
||||
→ Codec (Frame ↔ bytes; ProtobufCodec in production, JsonCodec for channel-core tests)
|
||||
→ Transport (StreamTransport: 4-byte big-endian length-prefix framing over a byte pipe)
|
||||
```
|
||||
|
||||
- **Wire format is protobuf.** A `Frame` envelope carries `id`, `type`, and a
|
||||
`oneof body { request | response }`; each `type` maps to a typed request
|
||||
message and a typed result message. The codec — not the channel — owns the
|
||||
`type → (request_cls, result_cls)` registry, keeping the concurrency-critical
|
||||
dispatch core codec-agnostic. The `.proto` is the single source of truth;
|
||||
generated `_pb2` modules are checked into both mirrors, regenerated by
|
||||
`proto/generate.sh` (isolated venv, no project-venv pollution) and guarded by
|
||||
a drift check.
|
||||
- **Transports are pluggable.** `stdio://` (default — frames ride the
|
||||
subprocess stdin/stdout) and `unix://<path>` (opt-in,
|
||||
`SandboxManager(transport="unix")`; main is the unix server, the runtime
|
||||
dials back) both reuse `StreamTransport`'s length-prefix framing. `ws://` is
|
||||
reserved and rejected with `NotImplementedError`; the `Transport` seam
|
||||
accepts a future `WebSocketTransport` drop-in without touching the channel.
|
||||
- **Handshake.** The runtime sends a `Ready` frame (`sandbox/ready`) as its
|
||||
first message; the manager treats its arrival as "running". stdout carries
|
||||
nothing but channel frames (no text marker; logs go to stderr).
|
||||
|
||||
Concurrency is real: handlers run as independent tasks bounded by an inflight
|
||||
semaphore, so a slow handler can't head-of-line-block the channel.
|
||||
|
||||
## 5. Lifecycle
|
||||
|
||||
**Spawn** is lazy — `SandboxManager.ensure_started(group)` starts the
|
||||
subprocess only when the first flow or entry routes to it:
|
||||
|
||||
```
|
||||
python -m hass_client.sandbox --name <group> --url stdio://
|
||||
```
|
||||
|
||||
**Crash recovery** is bounded: `SandboxProcess` restarts on unexpected exit up
|
||||
to 3 times in a 60s sliding window with backoff; exceeding the budget marks the
|
||||
sandbox `failed` and the router surfaces `SETUP_RETRY` on affected entries.
|
||||
|
||||
**Graceful shutdown** on `EVENT_HOMEASSISTANT_STOP`: the manager fans out
|
||||
`sandbox/shutdown`; each sandbox unloads its entries, snapshots
|
||||
`RestoreEntity` state into the reply, and schedules its own exit; main persists
|
||||
the returned `restore_state` to `<config>/.storage/sandbox/<group>/`. SIGTERM →
|
||||
SIGKILL backstops any sandbox that didn't ack. On the next boot the runtime
|
||||
warm-loads `core.restore_state` before any handler registers, so the first
|
||||
`RestoreEntity.async_get_last_state()` sees the previous run's state.
|
||||
|
||||
## 6. Config-flow forwarding
|
||||
|
||||
HA Core's `ConfigEntries` grows a single `router` attribute consulted at three
|
||||
sites: `async_create_flow` (new flow), `async_setup` (existing entry), and
|
||||
`async_unload` (entry teardown).
|
||||
|
||||
For a sandboxed handler the router returns a `SandboxFlowProxy` `ConfigFlow`
|
||||
that issues `sandbox/flow_init` / `flow_step` / `flow_abort` RPCs and re-issues
|
||||
each marshalled `FlowResult` as native `async_show_form` /
|
||||
`async_create_entry` / `async_abort`. Inside the sandbox the integration's real
|
||||
`ConfigFlow` runs in a `_SandboxFlowManager` that short-circuits CREATE_ENTRY —
|
||||
**main is the canonical owner of the `ConfigEntry`**.
|
||||
|
||||
**Main alone decides the group, and the sandbox never controls how its data is
|
||||
stored or routed.** The group is computed by main's `classify()` and passed to
|
||||
the proxy's constructor; on the final `create_entry`, the main-side proxy sets
|
||||
`create_result["sandbox"]` to *its own* (main-determined) group, overwriting
|
||||
anything in the sandbox's reply — and the wire `FlowResult` has no group/sandbox
|
||||
field for the sandbox to populate in the first place. The framework reads that
|
||||
main-set value into `ConfigEntry.sandbox`, and the next `async_setup`
|
||||
round-trips an `entry_setup` RPC. A compromised sandbox can shape its own
|
||||
flow's forms and validation, but it cannot influence which group it lands in,
|
||||
where its entry is persisted, or any other main-side storage/routing decision.
|
||||
|
||||
`data_schema` round-trips losslessly: it serialises via `voluptuous_serialize`
|
||||
and the main side rebuilds the **real** `Selector` / `data_entry_flow.section`
|
||||
objects, so when the flow manager re-serialises for the frontend the original
|
||||
list is reproduced verbatim — selectors keep their widgets instead of degrading
|
||||
to plain text boxes. The sandbox flow's `unique_id` rides every result so main's
|
||||
duplicate-detection fires.
|
||||
|
||||
## 7. Statelessness — integration source fetched at startup
|
||||
|
||||
A sandbox holds no persistent state. Config arrives on `entry_setup`,
|
||||
storage/restore-state routes to main (§8), and the last stateful bit — the
|
||||
**integration code itself** — is fetched at startup. `EntrySetup` carries a
|
||||
typed `IntegrationSource`:
|
||||
|
||||
- `{kind: "builtin"}` — the bundled `homeassistant` package provides it; no-op.
|
||||
- `{kind: "git", url, ref, tag, domain, subdir}` — `ref` is an **exact commit
|
||||
sha** (never a moving tag), so the fetched tree can't be re-pointed between
|
||||
resolution and fetch.
|
||||
|
||||
**Main** stays HACS-agnostic via a registered resolver hook:
|
||||
`async_register_sandbox_source_resolver(hass, resolver)` lets HACS (or anything)
|
||||
map a custom domain → git source. Built-ins short-circuit via
|
||||
`Integration.is_built_in`; a custom integration with no resolver **raises**
|
||||
rather than silently falling back. The resolver pins the version to a sha
|
||||
(core performs no network I/O; `tag` is logs-only).
|
||||
|
||||
**Sandbox** runs `async_ensure_integration_source` before `async_setup`: a git
|
||||
source downloads GitHub's codeload tarball for the exact sha (no `git` binary
|
||||
dependency) and extracts the repo's `subdir` into
|
||||
`<config>/custom_components/<domain>`, verifying a `manifest.json` is present. A
|
||||
process-lifetime cache keyed by `(url, ref)` fetches each repo once; nothing
|
||||
survives a restart, keeping the sandbox wipe-and-restart safe. The download
|
||||
primitive is injected so tests never hit the network.
|
||||
|
||||
> **Known runtime gap:** custom integrations that ship Python dependencies need
|
||||
> `async_process_requirements` (pip) plus network egress (GitHub + PyPI) at
|
||||
> setup. The wire + fetch are shipped and tested; the pip/egress runtime is
|
||||
> provided by the Docker image (§13) but not yet exercised end-to-end.
|
||||
|
||||
## 8. Entity bridge, services & events
|
||||
|
||||
**Entity bridge (action-call forwarding).** Every proxy-entity method becomes a
|
||||
standard `services.async_call(domain, service, target={"entity_id": [...]})`
|
||||
round-trip over the shared `sandbox/call_service` channel. The sandbox's
|
||||
`EntityBridge` pushes `register_entity` on an entity's first appearance (typed
|
||||
`EntityDescription` grouping identity as `EntityInfo` and runtime state as
|
||||
`InitialState`), then `state_changed` for updates. `register_entity` is an
|
||||
**upsert** — post-setup name/icon/category/capability/device_info changes
|
||||
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 **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
|
||||
is a noted future optimisation. Exception translation rebuilds sandbox-side `vol.Invalid` /
|
||||
`vol.MultipleInvalid` (with their `.path`) from a structured `error_data` frame
|
||||
field, so callers on main see the local-entity error shape.
|
||||
|
||||
**Server-side queries (request/response).** The query-shaped entity APIs that
|
||||
*ask* the entity a question now cross too. Ops with a `SupportsResponse` service
|
||||
ride the existing `call_service` path with `return_response=True`
|
||||
(`calendar.get_events`, `weather.get_forecasts`, `media_player.browse_media`);
|
||||
the rest cross via a generic `sandbox/entity_query` RPC that names the entity
|
||||
method + kwargs and returns the serialised result wrapped as `{"value": …}`
|
||||
(`media_player.search_media`, `update.release_notes`, `vacuum.get_segments`, the
|
||||
WS-only `calendar` event update/delete). Both decode the response with the
|
||||
`as_dict`-aware JSON encoder on the sandbox side and rebuild the rich return
|
||||
type (`BrowseMedia`, `CalendarEvent`, `SearchMedia`, `Segment`) on main with
|
||||
explicit field mapping. Sandbox-raised errors propagate as channel error frames
|
||||
and translate exactly like a service call. Still deferred: the
|
||||
subscription/push primitive (`weather/subscribe_forecast`,
|
||||
`calendar/event/subscribe`, the `todo` item-list push). Caveat: a sandboxed
|
||||
`media_player`'s browse surfaces only its own sources — the `media_source` tree
|
||||
runs on main, outside the boundary. See [`docs/query-shaped-rpcs.md`](docs/query-shaped-rpcs.md).
|
||||
|
||||
**Service & event mirroring.** After `async_setup_entry` succeeds, the entry's
|
||||
domain joins a refcounted `ApprovedDomains` set that gates both mirrors.
|
||||
`ServiceMirror` forwards `register_service` and installs a forwarder that
|
||||
refuses to clobber an existing handler. The serialised service schema is a
|
||||
best-effort optimisation (it lets main reject bad input without a round-trip);
|
||||
any schema that doesn't survive serialisation degrades to no-schema on main and
|
||||
the sandbox validates the call itself — a service is never dropped just because
|
||||
its schema is exotic. `EventMirror` forwards only `<approved_domain>_*` events
|
||||
via `sandbox/fire_event`; main re-fires them so automations react as if the
|
||||
integration ran locally.
|
||||
|
||||
**Context: the sandbox echoes ids, it never authors `Context`.** Only a
|
||||
`context_id` (a string) crosses the wire — never `parent_id` or `user_id`. Main
|
||||
**remembers every `Context` it hands down** to a sandbox (keyed by id, in a
|
||||
15-minute-TTL cache on the bridge) at the call-down sites: the service
|
||||
forwarder and the proxy-entity service call. When a sandbox event/state arrives
|
||||
carrying an id main recognises, main restores the *original* `Context` (with
|
||||
its real `parent_id` / `user_id`) verbatim, so a user-initiated action's
|
||||
attribution survives the round-trip. An id main never issued (or one whose
|
||||
entry has expired) gets a **brand-new** main-owned `Context(user_id=None)` — a
|
||||
fresh id main generated with its own trusted clock, no fabricated parentage.
|
||||
Main never adopts the sandbox-supplied id: `context_id`s are ULIDs carrying an
|
||||
embedded millisecond timestamp, and a sandbox could craft one to back-/forward-
|
||||
date an event (recorder / logbook order by it), so the sandbox string is used
|
||||
only as the cache **key**, never as the resulting `Context`'s identity. A cache
|
||||
miss is always safe — it degrades to a fresh context, never an error. Either
|
||||
way the sandbox cannot invent a `parent_id` or impersonate a `user_id`.
|
||||
|
||||
## 9. Store routing
|
||||
|
||||
`homeassistant.helpers.storage.Store` reads a `current_sandbox` **ContextVar**
|
||||
(`homeassistant/helpers/sandbox_context.py`) at IO time. When set, the store's
|
||||
`_async_load_data`, `_async_write_data`, and `async_remove` delegate to the
|
||||
contextvar's `SandboxBridge` instead of local disk. Branching at
|
||||
`_async_write_data` (not `async_save`) is deliberate: `async_save`,
|
||||
`async_delay_save`, and the `FINAL_WRITE` flush all funnel through it, so one
|
||||
branch covers every write path; the migration loop in `_async_load_data` runs
|
||||
unchanged whether the envelope came from disk or the bridge.
|
||||
|
||||
The runtime sets `current_sandbox.set(ChannelSandboxBridge(channel))` right
|
||||
after the channel opens and **before** the warm-load and any handler registers,
|
||||
so every coroutine inherits it (asyncio copies the context at `create_task`).
|
||||
This is a declared core hook, not a monkey-patch — and because it's read at
|
||||
call time it reaches helpers like `restore_state` that captured the original
|
||||
`Store` reference at import. On main, each bridge owns a `_SandboxStoreServer`
|
||||
pinned to `<config>/.storage/sandbox/<group>/`, with strict key validation and
|
||||
isolation-by-construction (one channel per group).
|
||||
|
||||
## 10. Auth
|
||||
|
||||
The sandbox is **not an authenticated principal inside HA**: it never opens a
|
||||
connection back to main and never acts on main's behalf, so it holds **no
|
||||
credential and no user**. A sandbox-originated `Context` with no recognised id
|
||||
is `user_id=None` (§8) — the honest shape, since no user authored it — so there
|
||||
is nothing to fabricate. When a future websocket consumer needs the sandbox to
|
||||
authenticate to main, the credential is designed then, with scopes (prior
|
||||
thinking in the SUPERSEDED
|
||||
[`docs/auth-scoping-decision.md`](docs/auth-scoping-decision.md)).
|
||||
|
||||
A richer attribution than `user_id=None` — a `Context` carrying which sandbox
|
||||
**group** originated an action, for audit/logbook — is possible future work; it
|
||||
needs a core `Context` field change (see [`docs/FOLLOWUPS.md`](docs/FOLLOWUPS.md)).
|
||||
|
||||
Opt-in data sharing (state stream, entity/area registry) into the sandbox is a
|
||||
future feature; the locked-down default (everything off) stands, with the
|
||||
design in [`docs/design-share-states.md`](docs/design-share-states.md).
|
||||
|
||||
## 11. Translation forwarding
|
||||
|
||||
A sandboxed integration's frontend strings (entity names, entity-state
|
||||
translations, config / options-flow labels, selectors, services, exceptions,
|
||||
issues) live in its `translations/<lang>.json`, keyed by domain. Main serves
|
||||
them to the frontend, but the integration runs in the sandbox — so a custom
|
||||
integration's strings would otherwise silently resolve to `{}`
|
||||
(`async_get_integrations` returns `IntegrationNotFound` as a dict value, which
|
||||
the translation cache skips). Two seams close the gap:
|
||||
|
||||
- **Live pull (sandbox running).** A declared core hook
|
||||
(`async_register_sandbox_translation_provider` in `helpers/translation.py`)
|
||||
lets `_TranslationCache` overlay a provider's strings onto the per-language
|
||||
set *before* flattening, so they share the same English-fallback + cache
|
||||
machinery as disk strings. The component's `SandboxTranslationProvider`
|
||||
resolves a domain's group (a loaded entry's `ConfigEntry.sandbox`, or an
|
||||
in-progress flow's `SandboxFlowProxy.sandbox_group`), **carves out built-ins**
|
||||
(main reads its own identical disk copy — the RPC is custom-only), batches the
|
||||
rest into one `sandbox/get_translations` RPC per group/language, and
|
||||
**degrades to empty** on a dead/slow channel so the cache lock never wedges
|
||||
the frontend. The sandbox handler reuses core's string loader and pre-fills
|
||||
`title` from `integration.name` (main can't — it has no `Integration` for a
|
||||
custom). `async_invalidate_translations` evicts a domain's strings on entry
|
||||
reload, so a HACS update at a new `ref` re-pulls.
|
||||
- **Picker (no sandbox running).** A separate, display-only catalog hook
|
||||
(`async_register_sandbox_catalog_provider` in `loader.py`, re-exported via
|
||||
`catalog.py`) lets HACS contribute `{domain, name, …, title_translations?}`
|
||||
entries that `async_get_integration_descriptions` merges into the
|
||||
add-integration dialog — so a sandbox-only custom is discoverable and named
|
||||
without spawning its sandbox. Kept separate from the sha-pinned source
|
||||
resolver; `title` degrades to `name`.
|
||||
|
||||
## 12. Core HA touch surface
|
||||
|
||||
The sandbox is deliberately small against core HA — five surfaces, each a
|
||||
declared public hook rather than a reach into private internals:
|
||||
|
||||
- `config_entries.py` — the `router` attribute + `ConfigEntryRouter` Protocol (three call sites) and the first-class `ConfigEntry.sandbox` field.
|
||||
- `helpers/entity_component.py` — `EntityComponent.async_register_remote_platform`, so a sandbox-built `EntityPlatform` attaches without re-discovering the local integration.
|
||||
- `helpers/sandbox_context.py` (new) + `helpers/storage.py` — the `current_sandbox` ContextVar + `SandboxBridge` Protocol read by `Store`'s IO methods.
|
||||
- `helpers/translation.py` — `async_register_sandbox_translation_provider` + the `_TranslationCache` overlay and `async_invalidate_translations` (§11).
|
||||
- `loader.py` — `async_register_sandbox_catalog_provider` + the catalog merge in `async_get_integration_descriptions` (§11).
|
||||
|
||||
## 13. Testing & containerisation
|
||||
|
||||
Two pytest plugins under `hass_client/testing/` let HA Core's per-integration
|
||||
suites run with the sandbox wired in; both share the manager-side
|
||||
`SandboxBridge` path and differ only in how the channel pair is materialised
|
||||
(in-process vs real subprocess). A protobuf round-trip drift guard keeps the
|
||||
checked-in `_pb2` mirrors honest.
|
||||
|
||||
A multi-stage `python:3.14-slim` Docker image (`hass_client/Dockerfile`) runs
|
||||
the runtime non-root with no persistent volumes — integration requirements are
|
||||
pip-installed on demand, not baked. It talks to main over the unix-socket
|
||||
transport (a same-host compose harness is templated; full remote operation
|
||||
waits on the websocket transport). See
|
||||
[`hass_client/docs/docker.md`](hass_client/docs/docker.md).
|
||||
|
||||
## 14. Out of scope / future work
|
||||
|
||||
- **WebSocket transport** — the seam is ready; lands with the share-states connection work.
|
||||
- **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 subscriptions** — the request/response RPCs shipped (§8: service-path + `entity_query`), so `calendar`/`weather`/`media_player`/`update`/`vacuum` queries answer with real data. What remains is the **subscription/push** primitive for the streaming `*/subscribe` commands (`weather/subscribe_forecast`, `calendar/event/subscribe`) and the `todo` item-list push that would un-block the `todo` platform, plus the `media_player.browse_media` media-source caveat (a sandboxed player's browse omits the main-side `media_source` tree). Full catalogue in [`docs/query-shaped-rpcs.md`](docs/query-shaped-rpcs.md).
|
||||
- **pip/egress validation** for custom-integration dependencies in the container (§7).
|
||||
|
||||
---
|
||||
|
||||
## Changelog
|
||||
|
||||
The architecture above is the result of the original phased build (Phases 0–20,
|
||||
summarised in [`plan.md`](plan.md) and [`docs/FOLLOWUPS.md`](docs/FOLLOWUPS.md))
|
||||
followed by a closing batch that hardened the boundary and finished the
|
||||
statelessness story. The closing batch, in landing order:
|
||||
|
||||
| Change | What it did |
|
||||
|---|---|
|
||||
| **current_sandbox ContextVar** | Replaced the module-level `Store` monkey-patch with a declared `current_sandbox` core-HA ContextVar; store IO routes to main at call time, reaching import-captured `Store` references the rebinding never could. (`plan-sandbox-context`, A1 + A2) |
|
||||
| **Strip auth scopes** | Reverted the unused Phase-7 `RefreshToken.scopes` mechanism from core HA; the sandbox token is a plain system-user token. Re-introduced when a real websocket consumer lands. (`plan-strip-auth-scopes`) |
|
||||
| **Protocol-fidelity batch** | CLI flag `--group`→`--name`; `vol.Invalid` reconstructed across the bridge with its `.path`; proxy `unique_id` prefixed with source domain; `register_entity` made an idempotent upsert; lossless `data_schema` (real selectors/sections) through the flow. (`plan-fidelity-batch`, #2/#7/#5/#6/#4) |
|
||||
| **Lockdown → ALWAYS_MAIN** | Moved ~18 helper/aggregator integrations that read data they don't own onto main under the locked-down posture. (fidelity appendix / point 1) |
|
||||
| **Protobuf wire + pluggable transports** | Rewrote the wire from JSON-lines to a three-layer Channel/Codec/Transport split: protobuf `Frame`s with typed per-message handlers (codec owns the registry), length-prefixed framing, a `Ready` frame replacing the text marker, and stdio + unix-socket transports. Context crosses as `context_id` only (no `parent_id`/`user_id` on the wire). WebSocket explicitly out of scope. (`plan-transport`, T1→T2→T3→T5) |
|
||||
| **Stateless sandboxes** | `entry_setup` carries a typed `IntegrationSource`; custom (HACS) code is fetched at startup as a sha-pinned tarball via a HACS-agnostic resolver hook, with a process-lifetime cache. (`plan-ephemeral-sources`) |
|
||||
| **Docker test image** | Multi-stage `python:3.14-slim` runtime image (non-root, no volumes, on-demand pip) + a unix-socket compose harness template. (`plan-docker`) |
|
||||
| **Rename `sandbox_v2` → `sandbox`** | Dropped the now-meaningless `_v2` suffix across directories, the integration domain, wire strings, storage namespace, protobuf, and the CLI module, now that v1 is gone; removed the hassfest ignore that masked v1's errors. (`plan-rename-sandbox`) |
|
||||
| **Drop token + system user, restore context** | Removed the unused `--token` / `SANDBOX_TOKEN` / `SandboxRuntime.token` end-to-end and deleted `auth.py` (per-group system user gone). Main now remembers every `Context` it hands down (15-min-TTL bridge cache, seeded at the service forwarder + proxy-entity call) and restores it verbatim on an echoed id; unknown/expired ids get a fresh main-owned `Context(user_id=None)` with main's own trusted id (never the untrusted sandbox ULID). (`plan-auth-context`, A/B/C) |
|
||||
|
||||
v1 (the original `sandbox` implementation) was removed 2026-05-28 — recover it
|
||||
from git history if ever needed.
|
||||
@@ -0,0 +1,205 @@
|
||||
# Sandbox — Phase 17 categorised backlog
|
||||
|
||||
Phase 17 moved the autotag's effect off `entry.data` onto a new
|
||||
first-class `ConfigEntry.sandbox` field. The full sweep was re-run
|
||||
(`run_compat_full.py` — 807 integrations, in-process plugin, JUnit
|
||||
captured per-test) and bucketed with `categorize_failures.py`. The raw
|
||||
rollup is in `BACKLOG_FAILURES.json`; the per-integration table is in
|
||||
`COMPAT_FULL.md`. This file is the **categorised remediation plan**.
|
||||
|
||||
## Headline
|
||||
|
||||
- **807** integrations, **34 378** tests collected.
|
||||
- **711** integrations pass cleanly; **96** have at least one failure.
|
||||
- Test-level pass rate: **99.67 %** (34 266 passed / 34 378).
|
||||
- Categorisation hit rate: **95.5 %** (107 of 112 failures bucketed).
|
||||
|
||||
### Phase-16 → Phase-17 delta
|
||||
|
||||
| | Phase 17 | Phase 16 | Δ |
|
||||
| --- | ---: | ---: | ---: |
|
||||
| Integrations | 807 | 807 | 0 |
|
||||
| Fully passing | **711** | 561 | +150 |
|
||||
| With failures | 96 | 246 | -150 |
|
||||
| Tests passed | 34 266 | 33 714 | +552 |
|
||||
| Tests failed | **112** | 664 | -552 |
|
||||
| **Test-level pass rate** | **99.67 %** | 98.07 % | +1.60 pp |
|
||||
| Categorisation hit rate | 95.5 % | 98.6 % | -3.1 pp |
|
||||
|
||||
The headline Phase 16 follow-up (move the sandbox-group tag off
|
||||
`entry.data`) **closed 552 of the 664 known failures** in one fix.
|
||||
What's left is two-thirds tests with frozen-time / snapshot
|
||||
drift (`'created_at': '20XX-...'` in diagnostic dicts that no longer
|
||||
match the snapshot) and one-third the same residual environmental
|
||||
issues Phase 16 flagged (BLE library, timezones, token refresh).
|
||||
|
||||
## Bucket overview (ordered by integration count)
|
||||
|
||||
| Bucket | Failures | Integrations |
|
||||
| --- | ---: | ---: |
|
||||
| `test-only` | 107 | 91 |
|
||||
| `unknown` | 5 | 5 |
|
||||
|
||||
Every category-specific bridge bucket (`proxy-missing`,
|
||||
`dependencies-not-shared`, `protocol-gap`, ...) is **at zero** for
|
||||
Phase 17 — including the two atag findings Phase 16 surfaced. That's
|
||||
notable: the autotag patch was previously injecting `__sandbox_group`
|
||||
into `entry.data` of `atag`'s test fixtures in a way that perturbed
|
||||
fixture composition and surfaced a coordinator-shape bug downstream.
|
||||
Moving the tag onto a side-band field removes that perturbation, and
|
||||
atag's `proxy-missing` / `dependencies-not-shared` rows vanish along
|
||||
with the autotag noise. Re-investigate only if atag-style failures
|
||||
re-appear once integrations adopt diagnostic snapshots that include
|
||||
the new `sandbox` field.
|
||||
|
||||
---
|
||||
|
||||
## `test-only` — 107 failures across 91 integrations
|
||||
|
||||
Three distinct sub-shapes, all with the same fix story: the test
|
||||
asserts on or snapshots a representation of the entry that includes a
|
||||
field the compat lane's autotag mutates. v2 didn't write the snapshot
|
||||
and can't refresh it from inside this tree — the fix lives in the
|
||||
integration's tests/ directory.
|
||||
|
||||
### Sub-shape 1: ``+ 'sandbox': 'built-in'`` in diagnostic snapshots — ~30 failures
|
||||
|
||||
`tests/components/<int>/test_diagnostics.py` snapshots
|
||||
`entry.as_dict()` (often via the Diagnostics framework) and the
|
||||
snapshot pre-dates Phase 17's `sandbox` field. Affects integrations
|
||||
that ship a `diagnostics.py` and a diagnostics test snapshot.
|
||||
|
||||
```
|
||||
'config_entry': dict({
|
||||
...
|
||||
+ 'sandbox': 'built-in',
|
||||
'source': 'user',
|
||||
...
|
||||
})
|
||||
```
|
||||
|
||||
Fix: `pytest tests/components/<int>/test_diagnostics.py --snapshot-update`
|
||||
per integration. One-line snapshot diff per file; mechanical.
|
||||
|
||||
### Sub-shape 2: ``'created_at': '20XX-...'`` snapshot drift — ~70 failures
|
||||
|
||||
`test_diagnostics.py` / `test_config_flow.py` snapshots that include
|
||||
the entry's full dict but don't use `freezegun` or the `<ANY>` Syrupy
|
||||
matcher for the timestamp. The compat lane runs on the wall clock so
|
||||
each snapshot diff shows the run date. **Pre-existing test fragility**
|
||||
— the same failures would appear in the integration's own CI on a
|
||||
non-snapshot-build day. Phase 16 had these too; their proportion grew
|
||||
because the dominant autotag noise vanished.
|
||||
|
||||
```
|
||||
- 'created_at': '2025-01-01T00:00:00+00:00',
|
||||
+ 'created_at': '2026-05-24T04:55:51.181434+00:00',
|
||||
```
|
||||
|
||||
Fix: integration-side. Either pin the time with
|
||||
`@pytest.mark.freeze_time` (preferred) or replace the timestamp in
|
||||
the snapshot with Syrupy's `<ANY>`. Out of v2 scope.
|
||||
|
||||
### Sub-shape 3: legacy ``entry.data == {…}`` assertions — handful
|
||||
|
||||
Helper integrations (e.g. `template`, `group`, `min_max` in Phase 15)
|
||||
that asserted `entry.data == {}` — Phase 17 cleared the dominant
|
||||
chunk of these, but a few stragglers remain where the snapshot or
|
||||
assertion shape is slightly different (e.g. nested under
|
||||
``'entry_data'`` rather than `data`).
|
||||
|
||||
### Top 10 affected integrations
|
||||
|
||||
| Integration | Failures |
|
||||
| --- | ---: |
|
||||
| `enphase_envoy` | 5 |
|
||||
| `vacasa` | 3 |
|
||||
| `ampio` | 2 |
|
||||
| `bang_olufsen` | 2 |
|
||||
| `comelit` | 2 |
|
||||
| `data_grand_lyon` | 2 |
|
||||
| `ecovacs` | 2 |
|
||||
| `whirlpool` | 2 |
|
||||
| `xiaomi_aqara` | 2 |
|
||||
| _… 82 more, 1 failure each_ | |
|
||||
|
||||
_Full per-integration list in `BACKLOG_FAILURES.json`._
|
||||
|
||||
### Proposed fix
|
||||
|
||||
**Zero v2 changes required.** The bridge code paths the compat lane
|
||||
exercises pass cleanly on every integration in this sweep
|
||||
(`proxy-missing` and `dependencies-not-shared` are both at 0). The
|
||||
remaining work is integration-side snapshot updates and freezegun
|
||||
adoption, neither of which is the sandbox tree's responsibility.
|
||||
|
||||
If we want to lift the pass rate further, the cleanest path is to
|
||||
extend the compat plugin with a fixture autouse that pins the clock
|
||||
to a known epoch (e.g. `2025-01-01T00:00:00+00:00`) for diagnostic
|
||||
tests. That would mask the `created_at` drift without forcing every
|
||||
integration owner to adopt freezegun. ~30 LOC in
|
||||
`hass_client/testing/pytest_plugin.py`, optional Phase 17b.
|
||||
|
||||
### Estimated size
|
||||
|
||||
- v2 work to close to ~100 %: **0 LOC** (zero bridge issues). The
|
||||
remaining diffs live in integrations' `__snapshots__/` directories
|
||||
and are out of scope.
|
||||
- Phase 17b: ~30 LOC for a clock-pinning fixture on the compat
|
||||
plugin if we want to eat the snapshot drift on v2's side.
|
||||
|
||||
---
|
||||
|
||||
## `unknown` — 5 failures across 5 integrations
|
||||
|
||||
The same residual environmental rows Phase 16 surfaced. Not v2
|
||||
bridge bugs:
|
||||
|
||||
| Integration | Failures | Likely root cause |
|
||||
| --- | ---: | --- |
|
||||
| `bluetooth` | 1 | `BleakClientBlueZDBus.__init__() missing 1 required keyword-only argument: 'bluez'` — `habluetooth` 4.x vs `bleak` 1.x compat issue in the test env. |
|
||||
| `chess_com` | 1 | `test_diagnostics` Syrupy diff on `joined`/`last_online` timestamps — test fixture renders local TZ vs UTC. |
|
||||
| `google` | 1 | `test_invalid_token_expiry_in_config_entry[timestamp_naive]` — refresh-token roundtrip yields `'ACCESS_TOKEN'` instead of `'some-updated-token'`. |
|
||||
| `html5` | 1 | `test_html5_send_message[…-86400-None]` — `timestamp` delta `18000000` vs `0`; freezegun + tz fragility. |
|
||||
| `mastodon` | 1 | `test_get_account_success` snapshot diff on `tzlocal()` vs `tzutc()`. |
|
||||
|
||||
### Proposed fix
|
||||
|
||||
- 0 LOC for v2. File upstream as integration-test fragility (BLE
|
||||
version skew is a HA env issue; the others are test-fixture issues
|
||||
for the respective integration owners).
|
||||
|
||||
---
|
||||
|
||||
## `ALWAYS_MAIN` additions recommended
|
||||
|
||||
**None** based on this sweep. Same as Phase 16's recommendation —
|
||||
no integration in the swept set surfaced a real
|
||||
sandbox-incompatibility shape. The two integrations that flagged
|
||||
`dependencies-not-shared` in Phase 16 (`azure_event_hub`, `atag`)
|
||||
now pass cleanly — the autotag noise that perturbed their fixtures
|
||||
was the actual cause, and Phase 17 removed it.
|
||||
|
||||
## Classifier rule changes recommended
|
||||
|
||||
**None.** The discovery filter caught everything the classifier would
|
||||
route to `MAIN`, and no integration in the swept set surfaced an
|
||||
`integration-uses-deny-listed-platform` failure. The deny-list and
|
||||
`ALWAYS_MAIN` set are correctly sized for the 807-integration
|
||||
universe.
|
||||
|
||||
## Reproducing this report
|
||||
|
||||
```bash
|
||||
cd sandbox
|
||||
# Full sweep (~12 min on a 16-core box, concurrency=6)
|
||||
uv run python run_compat_full.py --concurrency=6
|
||||
|
||||
# Categorise failures into buckets
|
||||
uv run python categorize_failures.py
|
||||
|
||||
# Regenerate the auto-draft skeleton (not used directly — this file
|
||||
# is hand-curated). Source of truth is BACKLOG_FAILURES.json + this
|
||||
# document.
|
||||
uv run python generate_backlog.py --out BACKLOG.draft.md
|
||||
```
|
||||
@@ -0,0 +1,219 @@
|
||||
# Home Assistant Sandbox
|
||||
|
||||
This directory is the home for the sandbox rewrite (it dropped its earlier
|
||||
versioned suffix once v1 was gone). The sandbox runs Home Assistant integrations in
|
||||
isolated subprocesses while main keeps a single unified view of devices,
|
||||
entities, services, and events.
|
||||
|
||||
v1 has been **removed** (2026-05-28) — it previously occupied these same
|
||||
paths (`../sandbox/` and `../homeassistant/components/sandbox/`) that the
|
||||
rewrite now lives at; recover it from git history if ever needed. This
|
||||
happened before the rewrite shipped a stable release (the documented gate's
|
||||
second condition), as a deliberate call relying on git history for rollback.
|
||||
|
||||
## Read these first
|
||||
|
||||
- [`OVERVIEW.md`](OVERVIEW.md) — full architecture: routing,
|
||||
lifecycle, flow forwarding, entity bridge, service/event mirror,
|
||||
scoped auth, store routing, shutdown, test infra.
|
||||
- [`plan.md`](plan.md) — phase-by-phase task list. Phases 0–20 are
|
||||
all ✅ COMPLETE; the follow-up phases (12 concurrent dispatcher,
|
||||
13 remaining domain proxies, 14 schema/unique_id/unload-hook/perf,
|
||||
15 v1-baseline sweep, 16 cross-integration sweep + backlog,
|
||||
17 `ConfigEntry.sandbox` field, 19 device-registry bridging,
|
||||
20 drop unwired `share_*` + design doc) closed every Phase 5–10
|
||||
deferral; the state-sharing consumer is now an explicit design
|
||||
([`docs/design-share-states.md`](docs/design-share-states.md))
|
||||
rather than dead-flag carrying. See
|
||||
[`docs/FOLLOWUPS.md`](docs/FOLLOWUPS.md) for the narrative.
|
||||
- [`status/`](status/) — per-phase (`STATUS-phase-N.md`) and per-plan
|
||||
(`STATUS-plan-*.md`) landing notes, the authoritative record of what each
|
||||
phase/plan shipped. **Always check the relevant STATUS file before assuming
|
||||
something is wired up the way the plan describes** — phases
|
||||
deliberately defer or simplify items and note exactly what
|
||||
changed.
|
||||
- [`docs/entity-bridge-decision.md`](docs/entity-bridge-decision.md) —
|
||||
Option A vs Option B (Phase 1 spike). Option B (action-call
|
||||
forwarding via the shared `sandbox/call_service` channel) is
|
||||
the protocol every entity proxy uses.
|
||||
- [`docs/auth-scoping-decision.md`](docs/auth-scoping-decision.md) —
|
||||
the auth design: the sandbox holds **no** credential (it is not an
|
||||
authenticated principal in main) and cannot fabricate a `Context`; main
|
||||
restores attribution from a TTL cache of contexts it issued, falling back
|
||||
to `user_id=None`. An appendix preserves the earlier scoped-token design
|
||||
(reverted, never shipped) for whenever the sandbox→main websocket lands.
|
||||
- [`docs/design-share-states.md`](docs/design-share-states.md) —
|
||||
design for the post-launch state-sharing consumer that replaces the
|
||||
Phase 7 `share_*` flags Phase 20 deleted. Covers entity_id
|
||||
alignment, the `share/subscribe_*` protocol, main-side filtering,
|
||||
and the open questions.
|
||||
|
||||
## Repository layout
|
||||
|
||||
- `hass_client/` — Python client library (its own `uv` env). Hosts
|
||||
`SandboxRuntime`, `FlowRunner`, `EntryRunner`, `EntityBridge`,
|
||||
`ServiceMirror`, `EventMirror`, `ChannelSandboxBridge`, and the two
|
||||
pytest plugins under `hass_client/testing/`. Also carries the runtime's
|
||||
**Docker test image** (`hass_client/Dockerfile` + `docker-compose.test.yml`)
|
||||
— see [`hass_client/docs/docker.md`](hass_client/docs/docker.md).
|
||||
- `docs/` — per-phase decision write-ups.
|
||||
- `run_compat.py` + curated `COMPAT.md` / `BACKLOG.md` — compat-lane
|
||||
runner and reports. Per-run machine output (`COMPAT.csv` /
|
||||
`COMPAT_LATEST.md`) is git-ignored.
|
||||
|
||||
The HA Core side of the integration lives at
|
||||
`../homeassistant/components/sandbox/`.
|
||||
|
||||
## Stateless sandboxes — integration source
|
||||
|
||||
Sandboxes hold no persistent state: config is pushed on `entry_setup`,
|
||||
storage/restore-state routes to main via the `current_sandbox` store bridge,
|
||||
and the **last stateful bit — the integration code — is now fetched at
|
||||
startup**. `EntrySetup.integration_source` (a typed proto sub-message) tells
|
||||
the sandbox where to get the code:
|
||||
|
||||
- Built-in → `{kind: "builtin"}`, a no-op (the bundled `homeassistant`
|
||||
package provides it).
|
||||
- Custom (HACS) → `{kind: "git", url, ref, tag, domain, subdir}`; the sandbox
|
||||
downloads the codeload tarball for the exact `ref` (commit sha) into
|
||||
`<config>/custom_components/<domain>` before `async_setup`.
|
||||
|
||||
**Resolver-hook contract.** Core stays HACS-agnostic. `sources.py` (HA side)
|
||||
exposes `async_register_sandbox_source_resolver(hass, resolver)`; a resolver
|
||||
maps a custom `domain → IntegrationSource-dict | None`. Built-ins
|
||||
short-circuit (`Integration.is_built_in`) without consulting a resolver; a
|
||||
custom domain with no resolver **raises** rather than silently falling back.
|
||||
The resolver MUST pin `ref` to an exact commit sha — core performs **no
|
||||
network I/O**, so it trusts the resolver's pin (`tag` is logs-only). The fetch
|
||||
+ process-lifetime `(url, ref)` cache live in `hass_client/sources.py`; the
|
||||
download primitive is injectable so tests never hit the network. See
|
||||
OVERVIEW.md "Integration source — fetch before setup (stateless)".
|
||||
|
||||
Runtime gap (follow-up, pairs with `plan-docker.md`): the bare-HA sandbox must
|
||||
run `async_process_requirements` (pip) for custom integrations that ship
|
||||
Python deps, and needs network egress (GitHub + PyPI). The wire + fetch are
|
||||
shipped + tested; the pip/egress runtime is not validated here.
|
||||
|
||||
## Core HA files modified (high-review surface)
|
||||
|
||||
the sandbox touches three core HA surfaces. Each is intentional, small, and was
|
||||
introduced by a specific phase — see the matching STATUS file for
|
||||
the rationale.
|
||||
|
||||
- `homeassistant/config_entries.py` — three additions on the same
|
||||
`router` attribute, plus the `ConfigEntry.sandbox` field that
|
||||
carries the routing tag without polluting `entry.data`.
|
||||
- `ConfigEntries.router` attribute + `ConfigEntryRouter` `Protocol`,
|
||||
consulted from `ConfigEntriesFlowManager.async_create_flow` and
|
||||
`ConfigEntries.async_setup`. **Phase 4.**
|
||||
- `ConfigEntries.async_unload` consults `router.async_unload_entry`
|
||||
before falling through to `entry.async_unload(hass)`. **Phase 14.**
|
||||
- `ConfigEntry.sandbox: str | None` field (declaration + `__init__`
|
||||
kwarg + `as_dict` write + storage read + `ConfigFlowResult["sandbox"]`
|
||||
plumbed through `async_finish_flow`). **Phase 17.**
|
||||
- `homeassistant/helpers/entity_component.py` —
|
||||
`EntityComponent.async_register_remote_platform`. Sandbox-built
|
||||
`EntityPlatform` instances attach without re-discovering the
|
||||
local integration. **Phase 5.**
|
||||
- `homeassistant/helpers/sandbox_context.py` (NEW) +
|
||||
`homeassistant/helpers/storage.py` — the `current_sandbox`
|
||||
`ContextVar` + `SandboxBridge` `Protocol`, read by `Store`'s IO
|
||||
methods (`_async_load_data`, `_async_write_data`, `async_remove`) so
|
||||
sandbox `Store` IO routes to main at call time. This **replaced** the
|
||||
Phase 8 module-level `Store` rebinding — no more monkey-patch.
|
||||
**plan-sandbox-context (Phase A1 + A2).**
|
||||
|
||||
Iron Law: do **not** monkey-patch private internals. v1's direct
|
||||
write to `EntityComponent._platforms` is the cautionary tale —
|
||||
the sandbox took the slightly bigger PR to add the public hook instead. The
|
||||
Phase 8 `Store` rebinding was the same smell; plan-sandbox-context
|
||||
replaced it with the declared `current_sandbox` core HA hook.
|
||||
|
||||
## Open follow-ups (not yet shipped)
|
||||
|
||||
The Phase 5–10 list of deferred items is mostly closed. See
|
||||
[`docs/FOLLOWUPS.md`](docs/FOLLOWUPS.md) for the narrative chain that
|
||||
took the codebase from Phase 11 to Phase 17. What's still open:
|
||||
|
||||
- **State-sharing subscription consumer + main-side filtering.**
|
||||
Phase 20 deleted the unwired `SharingConfig` /
|
||||
`SandboxGroupConfig` surface and replaced it with a design
|
||||
([`docs/design-share-states.md`](docs/design-share-states.md))
|
||||
covering the entity_id alignment constraint, the
|
||||
`share/subscribe_*` protocol, the main-side filter, and the
|
||||
remaining open questions. The actual consumer + main-side
|
||||
handlers are owed in a future phase against that design.
|
||||
- **v1 removal. DONE (2026-05-28).** The numeric gate (Phase 11) was cleared
|
||||
by Phase 17 (99.67 % full sweep, 99.97 % v1 baseline). v1 (`../sandbox/` +
|
||||
`../homeassistant/components/sandbox/` + `tests/components/sandbox/`) was
|
||||
removed ahead of the "the sandbox shipped a stable release" condition, relying on git
|
||||
history for rollback.
|
||||
- **Diagnostic snapshot drift / clock-pinning.** Phase 17's
|
||||
`BACKLOG.md` documents two test-side residuals: ~30 diagnostic
|
||||
snapshots showing `+ 'sandbox': 'built-in'` (fix is `pytest
|
||||
--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).
|
||||
- **Query-shaped RPCs — request/response DONE; subscriptions open.**
|
||||
The server-side query and WS-only-mutation entity APIs
|
||||
(calendar/weather/media_player/update/vacuum) now answer with real
|
||||
data: ops with a `SupportsResponse` service ride the `call_service`
|
||||
`return_response` path, the service-less ones cross via a generic
|
||||
`sandbox/entity_query` RPC, and main rebuilds each rich return type.
|
||||
See [`status/STATUS-plan-query-rpc.md`](status/STATUS-plan-query-rpc.md).
|
||||
What's still open is the **subscription/push** primitive: the
|
||||
`*/subscribe` commands (`weather/subscribe_forecast`,
|
||||
`calendar/event/subscribe`) get only the one-shot fetch, and `todo`
|
||||
stays in `SANDBOX_INCOMPATIBLE_PLATFORMS` (routed to main, no proxy)
|
||||
because its To-do panel reads the sync `todo_items` property that feeds
|
||||
`state` — it needs that pushed item-list cache. Plus the
|
||||
`media_player.browse_media` media-source caveat (browse omits the
|
||||
main-side `media_source` tree). Full catalogue in
|
||||
[`docs/query-shaped-rpcs.md`](docs/query-shaped-rpcs.md); plan in
|
||||
[`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
|
||||
fix. See the Phase 1 spike doc.
|
||||
- **Cross-sandbox in-process dependencies (ESPHome serial / BLE
|
||||
proxy).** Some integration pairs are coupled in-process — e.g. an
|
||||
ESPHome device acting as a serial proxy that another integration
|
||||
(ZHA, zwave_js, deCONZ, …) connects to. Today this only works if
|
||||
both integrations land in the *same* sandbox group, because the
|
||||
setup-time coordination (proxy enumeration, port lookup) happens
|
||||
via Python calls/events that the bridge doesn't cross. The classifier
|
||||
routes by built-in / custom / system, so a built-in ESPHome + custom
|
||||
consumer would split across sandboxes and break. The fix shape is
|
||||
either (a) a "co-locate with X" hint that overrides classifier
|
||||
output for known coupled pairs, or (b) routing the coordination
|
||||
events through the service/event mirror Phase 6 built — currently
|
||||
the mirror only forwards events whose name starts with
|
||||
`<owned_domain>_`, which catches `esphome_*` but not the consuming
|
||||
side's discovery hooks. BLE proxy has the same shape. IR / RF (e.g.
|
||||
Broadlink) are simpler — they're one-way command flows, so a
|
||||
consumer just needs to *send* commands; no setup-time enumeration
|
||||
or bidirectional stream — but still need dedicated cross-sandbox
|
||||
support since the consumer's send-call has to reach the producer.
|
||||
Worth a small spec before any cross-sandbox split actually trips
|
||||
this.
|
||||
|
||||
## Tests
|
||||
|
||||
```bash
|
||||
# HA-core side
|
||||
uv run pytest tests/components/sandbox/ --no-cov -q
|
||||
|
||||
# Client side (separate uv env — does NOT accept --no-cov)
|
||||
uv run pytest /home/paulus/dev/hass/core/sandbox/hass_client/ -q
|
||||
|
||||
# Compat lane
|
||||
cd sandbox && python run_compat.py
|
||||
```
|
||||
|
||||
For running the client runtime in a container (unix-socket transport today, WS
|
||||
later — not remote-ready yet), see
|
||||
[`hass_client/docs/docker.md`](hass_client/docs/docker.md).
|
||||
|
||||
After modifying anything under `sandbox/` or
|
||||
`homeassistant/components/sandbox/`, run
|
||||
`uv run prek run --files <changed files>` before committing.
|
||||
@@ -0,0 +1,184 @@
|
||||
# Sandbox compat report
|
||||
|
||||
Phase 17 baseline. This file is the **curated** reviewer-facing report
|
||||
— `run_compat.py` writes its raw per-run summary to `COMPAT_LATEST.md`
|
||||
and `COMPAT.csv`, never to `COMPAT.md`.
|
||||
|
||||
## Status
|
||||
|
||||
**Phase 17 baseline (in-process plugin, 2026-05-24)** — 37-integration
|
||||
set lifted from v1's `hass_client/SANDBOX_COMPAT.md`. Phase 17 moved
|
||||
the sandbox-group tag off `entry.data` onto the new first-class
|
||||
`ConfigEntry.sandbox` field, eliminating the autotag's
|
||||
`entry.data == {}` and `+ '__sandbox_group'` snapshot noise.
|
||||
|
||||
| | Phase 17 | Phase 15 | v1 (baseline) |
|
||||
| --- | ---: | ---: | ---: |
|
||||
| Integrations | 37 | 37 | 37 |
|
||||
| Fully passing | 35 | 29 | 35 |
|
||||
| With failures | 2 | 8 | 2 |
|
||||
| Tests passed | 7,646 | 7,586 | 955 |
|
||||
| Tests failed | 2 | 62 | 2 |
|
||||
| Test errors | 0 | 0 | 0 |
|
||||
| Tests skipped | 17 | 17 | 0 |
|
||||
| **Test-level pass rate** | **99.97%** | **99.19%** | **99.79%** |
|
||||
|
||||
The Phase 17 run climbs from 99.19 % to **99.97 %**, clearing the
|
||||
99.5 % v1-removal threshold the plan asks for. The two remaining
|
||||
failures (proximity, utility_meter) are both diagnostic-snapshot
|
||||
diffs that report `+ 'sandbox': 'built-in'` at the top level of
|
||||
`entry.as_dict()` — the autotag is still tagging the entry, the new
|
||||
`sandbox` field is now visible in diagnostics output, and the
|
||||
pre-Phase-17 snapshots don't include it. The fix is one
|
||||
snapshot-update per integration (out of the sandbox's scope; it lives in the
|
||||
integration's tests/).
|
||||
|
||||
## Bucketed triage
|
||||
|
||||
| Bucket | Count | Why |
|
||||
| --- | ---: | --- |
|
||||
| `test-only` (autotag-induced) | 2 | Diagnostic snapshots that include the entry's full `as_dict()` — the new `sandbox` field surfaces and the pre-Phase-17 snapshot doesn't expect it. |
|
||||
| `proxy-missing` | 0 | All 32 domains have proxies after Phase 13. |
|
||||
| `protocol-gap` | 0 | Phase 14's voluptuous-serialize bridge + `unique_id` propagation cleared the known gaps. |
|
||||
| `integration-incompat` | 0 | No integration in the v1 set hit `ALWAYS_MAIN`/deny-list paths. |
|
||||
|
||||
### Why the remaining failures are `test-only`
|
||||
|
||||
Phase 17 moved the autotag's effect off `entry.data` onto the new
|
||||
first-class `ConfigEntry.sandbox` field. The two remaining failures
|
||||
both happen in `test_diagnostics.py` files that include
|
||||
`entry.as_dict()` in their snapshot, e.g. `proximity` and
|
||||
`utility_meter`. The diagnostic now reports `+ 'sandbox': 'built-in'`
|
||||
at the top level. The bridge half is unchanged from a successful pass;
|
||||
only the snapshot needs a refresh.
|
||||
|
||||
Per-failure pytest output for each `issues` row lives under
|
||||
`${SANDBOX_ERRORS_DIR:-/tmp/sandbox_errors}/<integration>.txt`.
|
||||
|
||||
## Recommendation
|
||||
|
||||
The 99.97 % test-pass rate **clears the 99.5 % v1-removal threshold**
|
||||
the plan calls out. Phase 17 closes the dominant
|
||||
test-noise bucket Phase 15 / Phase 16 surfaced; the residual diff is
|
||||
two diagnostic snapshots that would update with one
|
||||
`pytest --snapshot-update tests/components/{proximity,utility_meter}/`.
|
||||
That update is out of the sandbox's scope — the snapshots live in the
|
||||
respective integrations' test trees, not under `sandbox/`.
|
||||
|
||||
The bridge code paths the compat lane exercises — router setup,
|
||||
entity proxies (all 32 domains), service mirror, event mirror,
|
||||
restore_state warm-load, schema bridge — pass cleanly on every
|
||||
integration in this run.
|
||||
|
||||
### Where this leaves v1 removal
|
||||
|
||||
The numeric trigger Phase 15 set ("the sandbox matches v1's compat numbers and
|
||||
clears ≥ 99.5 %") is now satisfied. Phase 11's deferred
|
||||
v1-removal item can be re-evaluated; the remaining condition the plan
|
||||
attaches to it ("the sandbox has shipped at least one stable release") is a
|
||||
release-process step rather than a code change.
|
||||
|
||||
## How to read this
|
||||
|
||||
Each integration row reflects one `pytest tests/components/<integration>/`
|
||||
run with the sandbox plugin active. Statuses:
|
||||
|
||||
- **`pass`** — every collected test passed.
|
||||
- **`issues`** — at least one failure or error. The pytest output is
|
||||
written to `${SANDBOX_ERRORS_DIR:-/tmp/sandbox_errors}/<integration>.txt`
|
||||
so reviewers can dig in.
|
||||
- **`timeout`** — the integration hit the per-run timeout (default 5 min).
|
||||
Often signals an integration that needs deny-listing (e.g. it spawns
|
||||
threads the sandbox doesn't model) or a real bug in the bridge.
|
||||
- **`no_tests`** — `pytest` collected zero tests. Usually means the
|
||||
integration only ships a `test_config_flow.py` or similar and not a
|
||||
`test_init.py`; the runner still records the row so a later sweep can
|
||||
add coverage.
|
||||
|
||||
## Per-integration results (Phase 17 baseline)
|
||||
|
||||
Plugin: `hass_client.testing.pytest_plugin`
|
||||
|
||||
| integration | status | passed | failed | errors | skipped |
|
||||
| --- | --- | ---: | ---: | ---: | ---: |
|
||||
| input_boolean | pass | 18 | 0 | 0 | 0 |
|
||||
| input_button | pass | 15 | 0 | 0 | 0 |
|
||||
| input_datetime | pass | 28 | 0 | 0 | 0 |
|
||||
| input_number | pass | 24 | 0 | 0 | 0 |
|
||||
| input_select | pass | 26 | 0 | 0 | 0 |
|
||||
| input_text | pass | 23 | 0 | 0 | 0 |
|
||||
| counter | pass | 751 | 0 | 0 | 0 |
|
||||
| timer | pass | 877 | 0 | 0 | 0 |
|
||||
| schedule | pass | 387 | 0 | 0 | 0 |
|
||||
| zone | pass | 32 | 0 | 0 | 0 |
|
||||
| tag | pass | 12 | 0 | 0 | 0 |
|
||||
| group | pass | 392 | 0 | 0 | 0 |
|
||||
| person | pass | 34 | 0 | 0 | 0 |
|
||||
| scene | pass | 41 | 0 | 0 | 0 |
|
||||
| todo | pass | 281 | 0 | 0 | 0 |
|
||||
| automation | pass | 117 | 0 | 0 | 0 |
|
||||
| script | pass | 64 | 0 | 0 | 0 |
|
||||
| alert | pass | 18 | 0 | 0 | 0 |
|
||||
| template | pass | 2470 | 0 | 0 | 0 |
|
||||
| plant | pass | 11 | 0 | 0 | 0 |
|
||||
| proximity | issues | 27 | 1 | 0 | 0 |
|
||||
| min_max | pass | 20 | 0 | 0 | 0 |
|
||||
| statistics | pass | 56 | 0 | 0 | 0 |
|
||||
| utility_meter | issues | 94 | 1 | 0 | 0 |
|
||||
| derivative | pass | 76 | 0 | 0 | 0 |
|
||||
| integration | pass | 61 | 0 | 0 | 0 |
|
||||
| generic_thermostat | pass | 114 | 0 | 0 | 0 |
|
||||
| generic_hygrostat | pass | 76 | 0 | 0 | 0 |
|
||||
| history_stats | pass | 55 | 0 | 0 | 0 |
|
||||
| threshold | pass | 114 | 0 | 0 | 0 |
|
||||
| filter | pass | 32 | 0 | 0 | 0 |
|
||||
| mqtt_statestream | pass | 17 | 0 | 0 | 0 |
|
||||
| recorder | pass | 932 | 0 | 0 | 17 |
|
||||
| rest | pass | 128 | 0 | 0 | 0 |
|
||||
| logbook | pass | 106 | 0 | 0 | 0 |
|
||||
| command_line | pass | 78 | 0 | 0 | 0 |
|
||||
| trend | pass | 39 | 0 | 0 | 0 |
|
||||
|
||||
## Reproducing this report
|
||||
|
||||
```bash
|
||||
cd sandbox
|
||||
|
||||
# Phase 15 baseline (v1's 37-integration list, in-process plugin)
|
||||
uv run python run_compat.py \
|
||||
input_boolean input_button input_datetime input_number input_select input_text \
|
||||
counter timer schedule zone tag group person scene todo automation script \
|
||||
alert template plant proximity min_max statistics utility_meter derivative \
|
||||
integration generic_thermostat generic_hygrostat history_stats threshold \
|
||||
filter mqtt_statestream recorder rest logbook command_line trend
|
||||
|
||||
# Default: in-process plugin, every component with tests
|
||||
uv run python run_compat.py
|
||||
|
||||
# Restrict to specific integrations
|
||||
uv run python run_compat.py input_boolean light switch
|
||||
|
||||
# Use the real-subprocess plugin (slower; freezer tests auto-skipped)
|
||||
uv run python run_compat.py --plugin subprocess
|
||||
```
|
||||
|
||||
`run_compat.py` writes its per-run table to `COMPAT_LATEST.md` (not
|
||||
`COMPAT.md`), so this curated baseline survives ad-hoc runs.
|
||||
|
||||
## Plugins
|
||||
|
||||
Two pytest plugins are wired up — see
|
||||
`hass_client/hass_client/testing/`:
|
||||
|
||||
| Plugin | Wire | When to use |
|
||||
| --- | --- | --- |
|
||||
| `hass_client.testing.pytest_plugin` (in-process) | in-memory channel pair | fast feedback, freezer-safe |
|
||||
| `hass_client.testing.conftest_sandbox` (subprocess) | real stdio JSON-line | pins the subprocess boundary, freezer tests auto-skip |
|
||||
|
||||
Both plugins install the `MockConfigEntry.add_to_hass` autotag patch
|
||||
in `pytest_configure` so the router's classifier path fires for
|
||||
entries the integration test itself creates. Phase 17 moved the tag
|
||||
from a synthetic key in `entry.data` to the first-class
|
||||
`ConfigEntry.sandbox` field, so the patch is now invisible to tests
|
||||
that assert on `entry.data` shape. See
|
||||
`sandbox/hass_client/hass_client/testing/_autotag.py`.
|
||||
@@ -0,0 +1,691 @@
|
||||
# Sandbox — Architecture overview
|
||||
|
||||
> **Status:** Complete through Phase 20. The follow-up phases (12–20)
|
||||
> closed every Phase 5–10 deferral; what remains of the original
|
||||
> `share_states=True` deferral is now an explicit design
|
||||
> ([`docs/design-share-states.md`](docs/design-share-states.md))
|
||||
> rather than a wired-but-unused config flag. The chain: the concurrent
|
||||
> channel dispatcher (Phase 12), all 32 domain proxies (Phase 13),
|
||||
> `data_schema` / service-schema marshalling + `unique_id` propagation
|
||||
> + the `async_unload_entry` core hook
|
||||
> (Phase 14), the v1-baseline compat sweep (Phase 15), the
|
||||
> 807-integration cross-sweep + categorised backlog (Phase 16), the
|
||||
> `ConfigEntry.sandbox` field that lifted the test-level pass rate
|
||||
> above the 99.5 % v1-removal threshold (Phase 17), the docs
|
||||
> reconciliation pass (Phase 18), device-registry bridging (Phase 19),
|
||||
> and the unwired `share_*` deletion + state-sharing design doc
|
||||
> (Phase 20). v1 (`../sandbox/`) was removed 2026-05-28 — recover from
|
||||
> git history if needed. See [`plan.md`](plan.md) for
|
||||
> the phase-by-phase task list, [`docs/FOLLOWUPS.md`](docs/FOLLOWUPS.md)
|
||||
> for the narrative history of Phases 12+, and the
|
||||
> [`status/`](status/) landing notes (`STATUS-phase-N.md` +
|
||||
> `STATUS-plan-*.md`) for what each phase/plan shipped, what it
|
||||
> deferred, and what it flagged forward.
|
||||
|
||||
## Goal
|
||||
|
||||
Run a Home Assistant integration's setup, config flow, entities,
|
||||
services, and events fully inside an **isolated subprocess** ("sandbox"),
|
||||
while the main HA instance keeps a **single, unified view** of devices,
|
||||
entities, services, events, and translations that looks identical to
|
||||
running everything locally.
|
||||
|
||||
A user adding a light integration through the frontend should end up
|
||||
with a device + entities in the main instance's registries, area
|
||||
targeting working (`light.turn_on` against an area resolves the
|
||||
sandboxed lights like any other light), the integration's services +
|
||||
events available on main — with the integration code only ever running
|
||||
inside the sandbox.
|
||||
|
||||
## How the sandbox differs from the removed v1
|
||||
|
||||
| | v1 (removed) | current |
|
||||
|---|---|---|
|
||||
| Routing | `entry.options["sandbox"]` set by hand | Computed at runtime from manifest + platform inspection ([`classifier.py`](../homeassistant/components/sandbox/classifier.py)) |
|
||||
| Transport | Live websocket connection back to main | Protobuf `Channel` over a pluggable transport (stdio by default, unix socket opt-in; websocket later) |
|
||||
| Entity bridge | Bespoke `sandbox/update_state` + `sandbox/entity_command_result` (Option A) | Shared `sandbox/call_service` (Option B) — see [`docs/entity-bridge-decision.md`](docs/entity-bridge-decision.md) |
|
||||
| Config flow | Forwarded through host integration | Runs inside the sandbox; main owns the canonical `ConfigEntry` store |
|
||||
| Auth | System-user token, full HA scope | None — the sandbox is not an authenticated principal inside main; no token, no system user. A credential is redesigned (scopes included) when the sandbox→main connection lands |
|
||||
| Data sharing | Sandbox sees all of main's state | Default locked-down; opt-in state/registry/area sharing per group is a future feature ([`docs/design-share-states.md`](docs/design-share-states.md)) |
|
||||
| Store routing | None — sandbox writes to its own tempdir | The `current_sandbox` contextvar makes `Store` IO proxy to main; main writes to `<config>/.storage/sandbox/<group>/<key>` |
|
||||
| Shutdown | Best-effort | Graceful `sandbox/shutdown` round-trip; sandbox unloads entries + dumps `RestoreEntity` state; main persists it for next boot |
|
||||
| Custom integrations | Out of scope | First-class — they route to the `custom` group |
|
||||
| Translations | Not forwarded — a sandboxed integration's frontend strings never reached main | Pulled on demand over `sandbox/get_translations` and overlaid into main's translation cache; a display-only catalog hook covers the not-yet-running picker case |
|
||||
|
||||
The design choices and the failure modes of v1 they fix are recorded in
|
||||
[`docs/entity-bridge-decision.md`](docs/entity-bridge-decision.md) and
|
||||
[`docs/auth-scoping-decision.md`](docs/auth-scoping-decision.md).
|
||||
|
||||
## High-level shape
|
||||
|
||||
```
|
||||
┌──────────────────────────────── Home Assistant Core ─────────────────────────────────┐
|
||||
│ │
|
||||
│ homeassistant/components/sandbox/ │
|
||||
│ ┌────────────────────┐ ┌────────────────────┐ ┌────────────────────────────┐ │
|
||||
│ │ SandboxFlowRouter │ │ SandboxManager │ │ SandboxBridge (per group) │ │
|
||||
│ │ • plugged into │ │ • dict[group, │ │ • proxy-entity registry │ │
|
||||
│ │ hass.config_ │ │ SandboxProcess] │ │ • forwards entity service │ │
|
||||
│ │ entries.router │ │ • lazy spawn per │ │ calls via call_service │ │
|
||||
│ │ • routes flows + │ │ group; restart │ │ • re-fires sandbox events │ │
|
||||
│ │ entry setup │ │ on crash │ │ • per-group store server │ │
|
||||
│ └─────────┬──────────┘ └─────────┬──────────┘ └─────────────┬──────────────┘ │
|
||||
│ │ │ │ │
|
||||
│ └────── classify() ──────┘ │ │
|
||||
│ │ │ │
|
||||
│ ▼ │ │
|
||||
│ on first need: ensure_started(group) │ │
|
||||
└─────────────────────────┬─────────────────────────────────────────┼───────────────────┘
|
||||
│ │
|
||||
│ subprocess.Popen │ Channel
|
||||
│ python -m hass_client.sandbox │ (protobuf frames over
|
||||
│ --name … --url … │ stdio / unix socket)
|
||||
▼ │
|
||||
┌──────────────────────────── Sandbox subprocess ──────────────────────────────────────┐
|
||||
│ sandbox/hass_client/hass_client/sandbox/__init__.py │
|
||||
│ │
|
||||
│ SandboxRuntime │
|
||||
│ • private HomeAssistant instance │
|
||||
│ • current_sandbox.set(bridge) — routes Store IO to main via contextvar │
|
||||
│ • FlowRunner — drives integration ConfigFlow on entry_init / step / abort │
|
||||
│ • EntryRunner — runs async_setup_entry against the sandbox's hass │
|
||||
│ • EntityBridge — pushes register_entity + state_changed to main │
|
||||
│ • ServiceMirror — pushes register_service for approved domains │
|
||||
│ • EventMirror — re-fires <approved_domain>_* events to main │
|
||||
│ • ApprovedDomains — refcounted set; gates ServiceMirror + EventMirror │
|
||||
│ • shutdown handler — unload entries, snapshot RestoreEntity state into reply │
|
||||
└───────────────────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Routing rules
|
||||
|
||||
`classify(integration)` ([`classifier.py`](../homeassistant/components/sandbox/classifier.py))
|
||||
is a pure function from a loaded `Integration` to a `SandboxAssignment`.
|
||||
It runs from two places: `SandboxFlowRouter.async_create_flow` (new
|
||||
flows) and `SandboxFlowRouter.async_setup_entry` (existing entries with
|
||||
no `ConfigEntry.sandbox` value yet).
|
||||
|
||||
Rule order (first match wins):
|
||||
|
||||
1. `integration_type == "system"` → **main**. System integrations are
|
||||
part of the HA runtime; sandboxing them is meaningless.
|
||||
2. `domain in ALWAYS_MAIN` → **main**. A 24-entry deny-list, each with an
|
||||
inline "why" in [`const.py`](../homeassistant/components/sandbox/const.py),
|
||||
in three groups:
|
||||
- **Behavioural punts** — `script`, `automation`, `scene`, `cloud`, plus
|
||||
`ai_task` and `image`. The latter two do non-idempotent pre-dispatch work
|
||||
(attachment / byte resolution) that neither bridge option intercepts
|
||||
cleanly — see the Phase 1 spike doc.
|
||||
- **Broad readers** — `template`, `group`, `homekit` read *all* entities /
|
||||
registries (Jinja `states()`, `hass.states.async_all()`), so they can't be
|
||||
narrowly scoped and break under sandbox lockdown.
|
||||
- **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` each read a declared set of *foreign*
|
||||
entities (and sometimes the registries). They stay on main until the
|
||||
share-states consumer lands a scoped declared-source-entity allow-list
|
||||
([`docs/design-share-states.md`](docs/design-share-states.md)).
|
||||
3. Any platform in `SANDBOX_INCOMPATIBLE_PLATFORMS` → **main**: `stt`,
|
||||
`tts`, `conversation`, `assist_satellite`, `wake_word`, `camera`.
|
||||
These exchange audio/byte streams the JSON channel can't ferry.
|
||||
4. Custom (non-built-in) integration → `Sandbox("custom")`.
|
||||
5. Otherwise → `Sandbox("built-in")`.
|
||||
|
||||
Three sandbox groups ship out of the box:
|
||||
|
||||
| Group | Hosts |
|
||||
|---|---|
|
||||
| `main` | nothing — anything in `ALWAYS_MAIN` or matching a deny-listed platform runs directly on main, no sandbox process |
|
||||
| `built-in` | every other built-in integration |
|
||||
| `custom` | every custom (HACS / user) integration |
|
||||
|
||||
State / entity-registry / area-registry sharing into the sandbox is a
|
||||
future feature — Phase 7 added per-group `share_*` defaults but Phase
|
||||
20 deleted them because nothing consumed them. See
|
||||
[`docs/design-share-states.md`](docs/design-share-states.md) for the
|
||||
design that will replace them.
|
||||
|
||||
The check uses `Integration.platforms_exists()` so the classifier never
|
||||
imports the integration to make the call.
|
||||
|
||||
## Lifecycle
|
||||
|
||||
### Spawn
|
||||
|
||||
`SandboxManager.ensure_started(group)` is lazy: the subprocess starts
|
||||
only when the first flow or entry routes to it. The subprocess command
|
||||
is:
|
||||
|
||||
```
|
||||
python -m hass_client.sandbox \
|
||||
--name <name> \
|
||||
--url stdio://
|
||||
```
|
||||
|
||||
`--url` selects the control-channel transport: `stdio://` (the default —
|
||||
frames ride the subprocess's stdin/stdout) or `unix://<path>` (the
|
||||
manager opens a unix socket and the runtime dials back). `ws://` / `wss://`
|
||||
are reserved for the deferred websocket transport and rejected for now.
|
||||
The runtime opens the channel and sends a `Ready` frame
|
||||
(`sandbox/ready`) as its first message; the manager treats its arrival
|
||||
as "running" (there is no stdout text marker — stdout carries nothing but
|
||||
channel frames). Frames are protobuf (a `Frame` envelope carrying one
|
||||
typed message per `type`; `JsonCodec` is kept only for channel-core tests)
|
||||
and length-prefixed (4-byte big-endian length + body) on the stream
|
||||
transports. The three-layer split is `Channel` (dispatch core) → `Codec`
|
||||
(`Frame` ↔ bytes; `ProtobufCodec` in production) → `Transport`
|
||||
(`StreamTransport` length-prefixing over stdio / unix).
|
||||
|
||||
### Health & crash recovery
|
||||
|
||||
`SandboxProcess._supervise` watches the subprocess for unexpected exits.
|
||||
Restart-on-crash is bounded: 3 attempts within a 60s sliding window,
|
||||
with a small backoff sleep between attempts. Exceeding the budget
|
||||
transitions the sandbox to `failed` and `ensure_started` raises
|
||||
`SandboxFailedError` — the router catches it in `async_setup_entry` and
|
||||
marks the affected entries `SETUP_ERROR`
|
||||
([`router.py`](../homeassistant/components/sandbox/router.py)).
|
||||
(`SETUP_RETRY` is reserved for a narrower case — a `ChannelClosedError`
|
||||
*during* an `entry_setup` round-trip, where a retry can succeed.)
|
||||
|
||||
A `sandbox/ping` handler is registered on the sandbox side and exercised
|
||||
by the subprocess test (`test_phase4_subprocess`), but the manager runs
|
||||
**no periodic ping loop** — liveness relies on process-exit detection,
|
||||
which covers the hard-crash case. An active health-ping is a possible
|
||||
future addition.
|
||||
|
||||
### Graceful shutdown
|
||||
|
||||
On `EVENT_HOMEASSISTANT_STOP` the integration runs:
|
||||
|
||||
1. `manager.async_graceful_shutdown_all(timeout=manager.shutdown_grace)`
|
||||
fans out `sandbox/shutdown` to every running sandbox.
|
||||
2. Each sandbox unloads its entries via `config_entries.async_unload`,
|
||||
snapshots `RestoreStateData.async_get_stored_states()` into a
|
||||
JSON-safe wrapped dict (round-tripped through orjson's HA-aware
|
||||
encoder), returns it in the reply, then schedules its own shutdown
|
||||
event via `call_soon` *after* the reply is queued so the subprocess
|
||||
exits 0 on its own.
|
||||
3. The reply lands in `SandboxData`'s `on_shutdown_reply` callback,
|
||||
which writes `restore_state` to
|
||||
`<config>/.storage/sandbox/<group>/core.restore_state` via the
|
||||
bridge's store server.
|
||||
4. `manager.async_stop_all()` falls through to SIGTERM, then SIGKILL,
|
||||
for any sandbox that didn't ack the graceful round-trip.
|
||||
|
||||
On the next boot the runtime warm-loads `core.restore_state` before any
|
||||
handler registers, so the first `RestoreEntity.async_get_last_state()`
|
||||
sees the previous run's state. It works against a vanilla `Store`: the
|
||||
runtime sets `current_sandbox` before the warm-load, and `Store`'s IO
|
||||
methods read the contextvar at call time, so the load routes to main even
|
||||
though `restore_state.py` captured the original `Store` reference at
|
||||
import. (Phase 8 needed an explicit sandbox-backed `Store` instance here
|
||||
because its module-level rebinding couldn't reach that captured
|
||||
reference; the contextvar made that workaround unnecessary.)
|
||||
|
||||
## Config-flow forwarding
|
||||
|
||||
The HA Core `ConfigEntries` object grows a single `router` attribute
|
||||
([`config_entries.py`](../homeassistant/config_entries.py)) consulted
|
||||
from three call sites:
|
||||
|
||||
- `ConfigEntriesFlowManager.async_create_flow` — when a new flow starts.
|
||||
- `ConfigEntries.async_setup` — when an existing entry is being set up.
|
||||
- `ConfigEntries.async_unload` — when an entry is being unloaded
|
||||
(Phase 14 hook on the same `router` attribute, same shape as the
|
||||
other two).
|
||||
|
||||
`SandboxFlowRouter.async_create_flow` runs the routing logic in order:
|
||||
look up any existing entry for the handler key, fall back to
|
||||
`classify(integration)`, then either return `None` (let HA handle it
|
||||
locally) or hand back a `SandboxFlowProxy` `ConfigFlow`. The proxy
|
||||
issues `sandbox/flow_init`, `sandbox/flow_step`, and
|
||||
`sandbox/flow_abort` RPCs against the matching sandbox's runtime;
|
||||
each RPC returns a marshalled `FlowResult` that the proxy re-issues as
|
||||
`async_show_form` / `async_create_entry` / `async_abort` so the
|
||||
framework treats the result as native.
|
||||
|
||||
Inside the sandbox, the integration's real `ConfigFlow` runs inside a
|
||||
`_SandboxFlowManager` (a `ConfigEntriesFlowManager` subclass) that
|
||||
short-circuits the CREATE_ENTRY path — main is the canonical owner of
|
||||
the `ConfigEntry`, so the sandbox never tries to add an entry to its
|
||||
own private store. When the sandbox returns a final `create_entry`
|
||||
result, `SandboxFlowProxy._adapt_result` attaches `sandbox=<group>` to
|
||||
the `ConfigFlowResult`; the framework's `ConfigEntry` constructor in
|
||||
`ConfigEntriesFlowManager.async_finish_flow` reads
|
||||
`result.get("sandbox")` and stores it on the new entry's first-class
|
||||
`ConfigEntry.sandbox` field (Phase 17). On the next
|
||||
`ConfigEntries.async_setup(entry_id)`, the router sees `entry.sandbox`,
|
||||
ensures the sandbox is running, and round-trips an `entry_setup` RPC.
|
||||
|
||||
The flow proxy serialises `data_schema` via `voluptuous_serialize`
|
||||
([`schema_bridge.py`](../homeassistant/components/sandbox/schema_bridge.py))
|
||||
and rebuilds a `vol.Schema` on main so frontend forms render correctly
|
||||
(Phase 14). The reconstruction rebuilds the real `Selector` /
|
||||
`data_entry_flow.section` objects, 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 instead of degrading to plain text
|
||||
boxes. The sandbox flow's `flow.context["unique_id"]`
|
||||
rides in every marshalled `FlowResult` and the proxy applies it via
|
||||
`async_set_unique_id`, so main's duplicate-detection guard fires for
|
||||
collisions (Phase 14).
|
||||
|
||||
## Integration source — fetch before setup (stateless)
|
||||
|
||||
A sandbox holds no persistent state. Config is pushed on `entry_setup`,
|
||||
storage/restore-state routes to main via the `current_sandbox` store
|
||||
bridge — the last stateful bit was the **integration code itself**. Built-in
|
||||
integrations ride the bundled `homeassistant` package, but custom (HACS)
|
||||
integrations live under `<config>/custom_components` on the main install and
|
||||
are absent from a fresh sandbox.
|
||||
|
||||
`entry_setup` therefore carries a typed `IntegrationSource` sub-message
|
||||
(`EntrySetup.integration_source`):
|
||||
|
||||
- `{kind: "builtin"}` — the bundled package provides it; the sandbox does
|
||||
nothing.
|
||||
- `{kind: "git", url, ref, tag, domain, subdir}` — main pushes where to fetch
|
||||
the code. `ref` is an **exact commit sha** (never a moving tag), so what the
|
||||
sandbox fetches can't be re-pointed between resolution and fetch.
|
||||
|
||||
**Main side** (`sources.py`): core stays HACS-agnostic via a registered
|
||||
resolver hook. `async_register_sandbox_source_resolver(hass, resolver)` lets
|
||||
HACS (or anything) map a custom domain → git source;
|
||||
`async_resolve_integration_source` short-circuits built-ins to
|
||||
`{kind: builtin}` (via `Integration.is_built_in`) and otherwise consults the
|
||||
resolvers in order. With no resolver, a custom integration **raises** rather
|
||||
than silently failing. The resolver is responsible for pinning the installed
|
||||
version to a sha (core performs no network I/O); `tag` is logs-only.
|
||||
|
||||
**Sandbox side** (`hass_client/sources.py`):
|
||||
`async_ensure_integration_source` runs **before** `async_setup`. A git source
|
||||
downloads GitHub's codeload tarball for the exact sha (no `git` binary
|
||||
dependency, matching HACS) and extracts the repo's `subdir` into
|
||||
`<config>/custom_components/<domain>`, verifying the tree has a
|
||||
`manifest.json`. A **process-lifetime cache** keyed by `(url, ref)` means
|
||||
multiple entries from one repo download once; nothing survives a process
|
||||
restart, so the sandbox stays wipe-and-restart safe. The download primitive is
|
||||
injected so tests substitute a local fixture — no fetch ever hits the network.
|
||||
|
||||
## Entity bridge (Option B — action-call forwarding)
|
||||
|
||||
The Phase 1 spike compared two designs head-to-head and recorded
|
||||
numbers in [`docs/entity-bridge-decision.md`](docs/entity-bridge-decision.md).
|
||||
We picked **Option B**: every proxy entity method translates into a
|
||||
standard `services.async_call("<domain>", "<service>",
|
||||
target={"entity_id": [...]})` round-trip over the shared
|
||||
`sandbox/call_service` channel.
|
||||
|
||||
### Sandbox side
|
||||
|
||||
`EntryRunner` rebuilds a `ConfigEntry` from the `sandbox/entry_setup`
|
||||
payload, **fetches the integration's code** if needed (see below), drops the
|
||||
entry into the sandbox's `ConfigEntries`, and runs the integration's
|
||||
`async_setup_entry`. The integration adds entities the
|
||||
normal way — `EntityBridge` listens for `EVENT_STATE_CHANGED` on the
|
||||
sandbox's bus and, on each entity's first appearance, pushes
|
||||
`sandbox/register_entity` to main with:
|
||||
|
||||
- `entry_id`, `domain`, `sandbox_entity_id`
|
||||
- `unique_id` (prefixed on main with the source domain, `<domain>:<unique_id>`,
|
||||
so two integrations in one group can't collide), `name`, `icon`,
|
||||
`has_entity_name`
|
||||
- `entity_category`, `device_class`, `supported_features`
|
||||
- `capability_attributes` (`supported_color_modes`, color temp range, …)
|
||||
- the initial `state` + `attributes`
|
||||
|
||||
Subsequent **state** updates push `sandbox/state_changed` (state +
|
||||
attributes only). `register_entity` is an **upsert**: post-setup changes to
|
||||
name / icon / category / capabilities / device_info arrive as
|
||||
entity- and device-registry-updated events, which re-send
|
||||
`register_entity` so main refreshes the existing proxy in place (no
|
||||
duplicate entity).
|
||||
|
||||
### Main side
|
||||
|
||||
`SandboxBridge` receives `register_entity`, instantiates a
|
||||
domain-specific proxy from
|
||||
[`entity/`](../homeassistant/components/sandbox/entity/), and attaches
|
||||
it to the matching `EntityComponent` via the new
|
||||
`EntityComponent.async_register_remote_platform` core hook (Phase 5's
|
||||
sole core change). The proxy holds a cached state + attributes dict
|
||||
fed by `state_changed`; `state`, `available`, and per-domain typed
|
||||
properties (`is_on`, `brightness`, `hs_color`, …) read from the cache.
|
||||
|
||||
Proxy method calls (e.g., `async_turn_on`) translate into one
|
||||
`sandbox/call_service` RPC each. Coalescing same-tick calls for one service
|
||||
into a single multi-entity RPC (so a 200-light area call pays one round-trip,
|
||||
not 200) is a noted future optimisation — see
|
||||
[`docs/FOLLOWUPS.md`](docs/FOLLOWUPS.md); the first iteration keeps it simple.
|
||||
|
||||
Exception translation rebuilds sandbox-side `vol.Invalid` /
|
||||
`vol.MultipleInvalid` as the real exception (with its `.path`) from a
|
||||
structured `error_data` field on the error frame, and maps
|
||||
`ServiceNotFound` / `ServiceValidationError` → `HomeAssistantError`, so
|
||||
callers on main see the local-entity error shape rather than a raw remote
|
||||
error. (Frames without `error_data` fall back to the older class-name
|
||||
mapping, where `vol.Invalid` → `TypeError`.)
|
||||
|
||||
### Domains shipped
|
||||
|
||||
All 32 supported domains have a typed proxy under
|
||||
[`entity/`](../homeassistant/components/sandbox/entity/). Phase 5
|
||||
shipped four (`light`, `switch`, `sensor`, `binary_sensor`) to prove
|
||||
the path; Phase 13 added the remaining 28 mechanical follow-ups
|
||||
(`alarm_control_panel`, `button`, `calendar`, `climate`, `cover`,
|
||||
`date`, `datetime`, `device_tracker`, `event`, `fan`, `humidifier`,
|
||||
`lawn_mower`, `lock`, `media_player`, `notify`, `number`, `remote`,
|
||||
`scene`, `select`, `siren`, `text`, `time`, `todo`, `update`,
|
||||
`vacuum`, `valve`, `water_heater`, `weather`). Each is a 20–80 LOC
|
||||
`SandboxProxyEntity` subclass that wires the domain-typed properties
|
||||
to the cache. Domains that index `supported_features` with `in`
|
||||
re-wrap the wire int into the domain's `*EntityFeature` IntFlag in
|
||||
`__init__`; four entities whose `state` is `@final` and reads a
|
||||
name-mangled private field (`button`, `event`, `notify`, `scene`)
|
||||
override `sandbox_apply_state` to set the mangled attribute directly.
|
||||
Unknown-domain registrations still fall back to the generic
|
||||
`SandboxProxyEntity` (state + attributes work; domain-typed properties
|
||||
don't).
|
||||
|
||||
## Service & event mirroring
|
||||
|
||||
Once a sandboxed integration's `async_setup_entry` succeeds,
|
||||
`EntryRunner` adds the entry's domain to a refcounted `ApprovedDomains`
|
||||
set; `EntityBridge` also adds the domain of each registered entity (so
|
||||
a sandbox that hosts a `light` integration approves the `light`
|
||||
domain by virtue of registering light entities). `ServiceMirror` and
|
||||
`EventMirror` consult this set before forwarding anything.
|
||||
|
||||
- **`ServiceMirror`** listens on the sandbox bus for
|
||||
`EVENT_SERVICE_REGISTERED` / `EVENT_SERVICE_REMOVED` and pushes
|
||||
`sandbox/register_service` / `unregister_service` (with
|
||||
`supports_response` and the serialised voluptuous schema via the
|
||||
Phase 14 `schema_bridge`). Main reconstructs the schema and passes
|
||||
it to `hass.services.async_register`, so bad service-call input is
|
||||
rejected on main without round-tripping. The sandbox still owns
|
||||
the real schema and runs full validation when the call lands on
|
||||
its `services.async_call`. Main installs a thin forwarder that
|
||||
ships each call back over the shared `sandbox/call_service`
|
||||
channel, reusing the Phase 5 exception translator. The forwarder
|
||||
**refuses to clobber an existing handler**, so the `light.turn_on`
|
||||
registered by the host `light` EntityComponent for our proxy
|
||||
entities keeps its dispatch role for entity services.
|
||||
|
||||
- **`EventMirror`** uses a `MATCH_ALL` listener with an internal-
|
||||
events deny-list and forwards only `<approved_domain>_*` events
|
||||
(e.g. `zha_event`, `mqtt_message_received`) via
|
||||
`sandbox/fire_event`. Main re-fires each on its own bus so
|
||||
`automation` listeners react as if the integration ran locally.
|
||||
The sandbox sends only a `context_id` string; main resolves it
|
||||
against the `Context` cache it seeds on every call-down (see
|
||||
*Context restoration* below), restoring the original
|
||||
`parent_id` / `user_id` for an id it issued or minting a fresh
|
||||
`user_id=None` `Context` (with main's own id) otherwise.
|
||||
|
||||
## Translation forwarding
|
||||
|
||||
A sandboxed integration's frontend strings — entity names, entity-state
|
||||
translations, config / options-flow labels, selectors, services, exceptions,
|
||||
issues — live in its `translations/<lang>.json`, keyed by integration domain.
|
||||
Main serves them to the frontend, but the integration runs in the sandbox, so
|
||||
without help a custom integration's strings silently resolve to `{}`
|
||||
(`async_get_integrations` returns `IntegrationNotFound` *as a dict value*; the
|
||||
translation cache skips it). Two seams close the gap:
|
||||
|
||||
- **Live pull (sandbox running).** `homeassistant/helpers/translation.py` grows
|
||||
a declared hook, `async_register_sandbox_translation_provider`;
|
||||
`_TranslationCache` overlays the provider's result onto the per-language
|
||||
strings *before* flattening, so sandboxed strings flow through the same
|
||||
English-fallback + cache machinery as disk strings. The sandbox component's
|
||||
[`translation.py`](../homeassistant/components/sandbox/translation.py)
|
||||
`SandboxTranslationProvider` resolves each domain's owning group (a loaded
|
||||
entry's `ConfigEntry.sandbox`, or an in-progress flow's
|
||||
`SandboxFlowProxy.sandbox_group`), **carves out built-ins** (main reads its
|
||||
own byte-identical disk copy — the RPC is only for customs), batches the rest
|
||||
into one `sandbox/get_translations` RPC per group/language, and **degrades to
|
||||
empty** on a dead/slow channel (5s timeout) so the cache lock never wedges the
|
||||
frontend. The sandbox handler (`hass_client/sandbox/__init__.py`,
|
||||
`_handle_get_translations`) reuses core's string loader and **pre-fills
|
||||
`title`** from `integration.name` — main can't, holding no `Integration` for a
|
||||
custom. `async_invalidate_translations` (the first translation-cache eviction
|
||||
API) drops a domain's cached strings on entry reload, so a HACS update at a
|
||||
new `ref` re-pulls fresh strings.
|
||||
|
||||
- **Picker (no sandbox running).** The add-integration dialog needs only the
|
||||
`title` string and must work cold, but a sandbox-only custom integration isn't
|
||||
on main's disk at all — it isn't even *discoverable*. A separate, display-only
|
||||
catalog hook — `async_register_sandbox_catalog_provider` in
|
||||
[`loader.py`](../homeassistant/loader.py), re-exported via the sandbox
|
||||
component's [`catalog.py`](../homeassistant/components/sandbox/catalog.py) —
|
||||
lets HACS contribute `{domain, name, …, title_translations?}` entries that
|
||||
`async_get_integration_descriptions` merges into the picker. It is kept
|
||||
deliberately separate from the security-critical, sha-pinned integration-source
|
||||
resolver; `title` degrades to the catalog `name` when no translations are
|
||||
indexed.
|
||||
|
||||
## Sandbox auth & opt-in data sharing
|
||||
|
||||
The sandbox is **not an authenticated principal inside main.** It never
|
||||
opens a connection back to main and never acts on main's behalf, so it
|
||||
needs no credential — and the `--token` the manager once minted was
|
||||
**never read** by the runtime. `plan-auth-context.md` dropped it
|
||||
end-to-end (no `--token` argv, no `SandboxRuntime.token`, no
|
||||
`SANDBOX_TOKEN` env) and **removed the per-group system user**
|
||||
(`auth.py` is gone). When the sandbox→main websocket actually lands
|
||||
([`plans/plan-transport.md`](plans/plan-transport.md) T4), the
|
||||
credential is a green-field redesign with a real consumer in hand —
|
||||
scopes included; the prior thinking is preserved in
|
||||
[`docs/auth-scoping-decision.md`](docs/auth-scoping-decision.md)
|
||||
(marked SUPERSEDED).
|
||||
|
||||
### Context restoration
|
||||
|
||||
Only a `context_id` string ever crosses the wire — the protobuf
|
||||
messages carry no `parent_id` / `user_id` field, so the sandbox can
|
||||
never author a `Context`. Main **remembers every `Context` it hands
|
||||
down** to a sandbox, keyed by id, at the two call-down sites: the
|
||||
service forwarder (`_forward`) and the proxy entity's service call
|
||||
(`async_call_service`). The store is a 15-minute-TTL cache on the
|
||||
bridge — volume is tiny (a forwarded context is echoed back within the
|
||||
same operation), so the TTL keeps it small and a miss is always safe.
|
||||
|
||||
On an inbound `state_changed` / `fire_event`, `_resolve_context`:
|
||||
|
||||
- **known id** (cached, not expired) → returns the original main-owned
|
||||
`Context` verbatim, so a user-initiated action's `parent_id` /
|
||||
`user_id` survive the main → sandbox → main round-trip;
|
||||
- **unknown / expired id** → mints a **brand-new** `Context(user_id=None)`
|
||||
with main's **own** id, cached under the sandbox-supplied string.
|
||||
Main never adopts that string as the `Context`'s identity:
|
||||
`context_id`s are ULIDs with an embedded timestamp, and a sandbox
|
||||
could craft one to back-/forward-date an event (recorder / logbook
|
||||
order by it) — so the untrusted string is a cache **key** only.
|
||||
|
||||
A richer future answer (a `Context` group attribute naming the
|
||||
originating sandbox) is noted in
|
||||
[`docs/FOLLOWUPS.md`](docs/FOLLOWUPS.md) but not built.
|
||||
|
||||
Opt-in data sharing (state stream, entity registry, area registry)
|
||||
into the sandbox is a future feature. Phase 7 added unwired
|
||||
`SharingConfig` / `SandboxGroupConfig` defaults; Phase 20 deleted them
|
||||
because no consumer existed and replaced the surface with a design doc
|
||||
([`docs/design-share-states.md`](docs/design-share-states.md)). The
|
||||
locked-down posture stays — defaults are everything-off; the opt-in
|
||||
subscription consumer lands behind whatever config surface the design
|
||||
doc settles on.
|
||||
|
||||
## Store routing
|
||||
|
||||
`homeassistant.helpers.storage.Store` reads a `current_sandbox`
|
||||
`ContextVar` (declared in
|
||||
[`homeassistant/helpers/sandbox_context.py`](../homeassistant/helpers/sandbox_context.py))
|
||||
at IO time. When it is set, `Store._async_load_data`,
|
||||
`Store._async_write_data`, and `Store.async_remove` delegate to the
|
||||
contextvar's `SandboxBridge` instead of touching local disk. Branching at
|
||||
`_async_write_data` (not `async_save`) is deliberate: `async_save`,
|
||||
`async_delay_save`, and the `EVENT_HOMEASSISTANT_FINAL_WRITE` flush all
|
||||
funnel through `_async_handle_write_data` → `_async_write_data`, so one
|
||||
branch there covers every write path. The migration loop in
|
||||
`_async_load_data` runs unchanged regardless of whether the wrapped
|
||||
envelope came from disk or the bridge.
|
||||
|
||||
The sandbox runtime supplies the bridge:
|
||||
`ChannelSandboxBridge` ([`hass_client/sandbox_bridge.py`](hass_client/hass_client/sandbox_bridge.py))
|
||||
implements the three `SandboxBridge` store methods over
|
||||
`sandbox/store_load`, `sandbox/store_save`,
|
||||
`sandbox/store_remove`. `SandboxRuntime.run` does
|
||||
`current_sandbox.set(ChannelSandboxBridge(channel))` right after the
|
||||
channel opens and **before** the warm-load and any per-runner handler
|
||||
registers, so every coroutine the runtime spawns inherits it (asyncio
|
||||
copies the context at `create_task` time). One sandbox process hosts one
|
||||
sandbox group, so a single bridge per runtime is correct. This replaced
|
||||
the Phase 8 module-level `Store` rebinding — no monkey-patch, and it
|
||||
reaches helpers like `restore_state` that captured the original `Store`
|
||||
reference at import.
|
||||
|
||||
On main, each `SandboxBridge` owns a `_SandboxStoreServer` pinned to
|
||||
`<config>/.storage/sandbox/<group>/`. Writes use
|
||||
`util.file.write_utf8_file_atomic` (the same primitive `Store` itself
|
||||
uses). Scope isolation is by construction: each bridge owns one
|
||||
channel for one group; forging a cross-group call would require
|
||||
forging the channel. Key validation (`_require_key`) rejects `/`,
|
||||
`\`, NUL, `.`, `..`, and any `..`-prefixed key before any path is
|
||||
constructed.
|
||||
|
||||
Registries (entity/device/area/auth) that load during the sandbox's
|
||||
startup *before* the channel is up keep their local tempdir backing.
|
||||
Routing the HA-internals Stores too is a larger decision deferred to
|
||||
post-launch.
|
||||
|
||||
## Test infrastructure
|
||||
|
||||
Two pytest plugins under
|
||||
[`hass_client/hass_client/testing/`](hass_client/hass_client/testing/)
|
||||
let HA Core's per-integration test suites run with sandbox wired
|
||||
in. Both share the same manager-side `SandboxBridge` code path; the
|
||||
only thing that differs is how the channel pair is materialised.
|
||||
|
||||
| Plugin | Wire | When to use |
|
||||
|---|---|---|
|
||||
| `hass_client.testing.pytest_plugin` | in-memory channel pair, `SandboxRuntime` as an asyncio task | fast feedback, freezer-safe |
|
||||
| `hass_client.testing.conftest_sandbox` | real stdio protobuf channel (`python -m hass_client.sandbox`) | pins the subprocess boundary, freezer tests auto-skip |
|
||||
|
||||
The compat lane runner
|
||||
[`run_compat.py`](run_compat.py) drives either plugin against a list of
|
||||
integration test directories, parses pytest's summary line, and writes a
|
||||
machine CSV plus a `COMPAT_LATEST.md` per-run report (both git-ignored). The
|
||||
curated baseline lives in [`COMPAT.md`](COMPAT.md) and the curated residual
|
||||
backlog in [`BACKLOG.md`](BACKLOG.md). Per-failure output lands in
|
||||
`${SANDBOX_ERRORS_DIR:-/tmp/sandbox_errors}`.
|
||||
|
||||
The one-shot full cross-sweep tooling that produced the original backlog
|
||||
(`run_compat_full.py` + `categorize_failures.py` + `generate_backlog.py`) was
|
||||
removed once the measurement was done; recover it from git history if a fresh
|
||||
tree-wide sweep is ever needed.
|
||||
|
||||
**Baseline numbers (Phase 17):** 35/37 integrations pass on the
|
||||
v1-baseline 37-integration set (99.97 % test-level); 711/807
|
||||
integrations pass on the broader sweep (99.67 % test-level — above the
|
||||
99.5 % v1-removal threshold the plan asked for).
|
||||
|
||||
## Where the design is still open
|
||||
|
||||
These are the items the per-phase STATUS files flagged forward as
|
||||
explicit non-goals for the sandbox's first pass. They're tracked separately so
|
||||
the sandbox itself stays reviewable. The closed-since-Phase-11 items are listed
|
||||
in [`docs/FOLLOWUPS.md`](docs/FOLLOWUPS.md) with the causal chain to
|
||||
the phase that resolved each one.
|
||||
|
||||
- **State-sharing subscription consumer + main-side filtering.**
|
||||
Phase 20 deleted the unwired `SharingConfig` / `SandboxGroupConfig`
|
||||
surface and replaced it with a design
|
||||
([`docs/design-share-states.md`](docs/design-share-states.md))
|
||||
covering the entity_id alignment constraint, the
|
||||
`share/subscribe_*` protocol, the main-side filter, and the
|
||||
remaining open questions. The actual consumer + main-side handlers
|
||||
are owed in a future phase against that design.
|
||||
- **Non-idempotent service handlers** (`ai_task` and friends).
|
||||
Punted to `ALWAYS_MAIN` for the sandbox; a future spec on service-handler-level
|
||||
interception or sandbox-aware integration hooks is the long-term
|
||||
fix. The Phase 1 spike doc has the full write-up.
|
||||
- **v1 removal. DONE (2026-05-28).** The numeric gate Phase 11 set was
|
||||
satisfied by Phases 15–17 (99.67 % full-sweep; 99.97 % v1-baseline).
|
||||
v1 (`sandbox/` + `homeassistant/components/sandbox/` +
|
||||
`tests/components/sandbox/`) was removed ahead of the "shipped a stable
|
||||
release" condition, relying on git history for rollback.
|
||||
- **Query-shaped subscriptions** (`calendar` / `weather`). The
|
||||
request/response query RPCs are now **implemented**: the server-side
|
||||
query and WS-only mutation APIs (`async_get_events`,
|
||||
`async_forecast_*`, `async_browse_media` / `async_search_media`,
|
||||
`async_release_notes`, `async_get_segments`, calendar event
|
||||
update/delete) answer with real data — ops with a `SupportsResponse`
|
||||
service ride the `call_service` `return_response` path, the rest cross
|
||||
via a generic `sandbox/entity_query` RPC, and main rebuilds each rich
|
||||
return type. See [`docs/query-shaped-rpcs.md`](docs/query-shaped-rpcs.md) /
|
||||
[`plans/plan-query-rpc.md`](plans/plan-query-rpc.md). What's still open
|
||||
is the **subscription/push** primitive: `weather/subscribe_forecast`
|
||||
and `calendar/event/subscribe` get only the one-shot fetch, never
|
||||
streamed updates. `todo` stays in `SANDBOX_INCOMPATIBLE_PLATFORMS`
|
||||
(routed to main) because its To-do panel reads the sync `todo_items`
|
||||
property that feeds `state` — it needs that same pushed item-list
|
||||
cache, not a query.
|
||||
**Caveat (`media_player.browse_media`):** a sandboxed player's browse
|
||||
surfaces 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'
|
||||
trees (`pytest --snapshot-update` per integration). Optional Phase
|
||||
17b: a clock-pinning fixture autouse on the compat plugin (~30
|
||||
LOC, sketched in `BACKLOG.md`) would also mask the `created_at`
|
||||
drift driving ~70 of the 112 residual failures.
|
||||
- **Cross-sandbox in-process dependencies (ESPHome serial / BLE
|
||||
proxy).** Some integration pairs are coupled in-process: an ESPHome
|
||||
device exposing a serial-over-TCP proxy that a downstream
|
||||
integration (ZHA, zwave_js, deCONZ, …) connects to, or ESPHome BLE
|
||||
proxy advertisements being forwarded to the `bluetooth`
|
||||
integration. Today these only work if both integrations end up in
|
||||
the same sandbox group — the setup-time coordination (proxy
|
||||
enumeration, port handoff, BLE advert forwarding) happens via
|
||||
Python calls/events the bridge doesn't cross. The current
|
||||
classifier puts all built-in integrations into one `built-in`
|
||||
sandbox, so the pure-built-in case is fine; the trip wire is a
|
||||
built-in integration paired with a custom variant of the consumer,
|
||||
which would split across the `built-in` / `custom` groups. Fix
|
||||
shape: either a "co-locate with X" classifier hint for known
|
||||
coupled pairs, or extend the Phase 6 event mirror beyond
|
||||
`<owned_domain>_*` to cover the coordination hooks. IR / RF
|
||||
(Broadlink-style command remotes) are simpler — one-way command
|
||||
flows with no setup-time enumeration or bidirectional stream — but
|
||||
still need dedicated cross-sandbox support to route the consumer's
|
||||
send-call to the producer. Worth a small spec before any real split
|
||||
trips it.
|
||||
|
||||
## Where to look in the code
|
||||
|
||||
The landing notes under [`status/`](status/) (`STATUS-phase-N.md` +
|
||||
`STATUS-plan-*.md`) are the authoritative record of what each phase/plan
|
||||
actually built, what it deferred, and what it flagged forward. For a quick map:
|
||||
|
||||
| Concern | HA Core side | Sandbox side |
|
||||
|---|---|---|
|
||||
| Classifier | [`classifier.py`](../homeassistant/components/sandbox/classifier.py) | — |
|
||||
| Lifecycle | [`manager.py`](../homeassistant/components/sandbox/manager.py) | [`sandbox.py`](hass_client/hass_client/sandbox/__init__.py), [`sandbox/__main__.py`](hass_client/hass_client/sandbox/__main__.py) |
|
||||
| Channel | [`channel.py`](../homeassistant/components/sandbox/channel.py) | [`channel.py`](hass_client/hass_client/channel.py) |
|
||||
| Config flow | [`router.py`](../homeassistant/components/sandbox/router.py), [`proxy_flow.py`](../homeassistant/components/sandbox/proxy_flow.py) | [`flow_runner.py`](hass_client/hass_client/flow_runner.py) |
|
||||
| Entity bridge | [`bridge.py`](../homeassistant/components/sandbox/bridge.py), [`entity/`](../homeassistant/components/sandbox/entity/) | [`entry_runner.py`](hass_client/hass_client/entry_runner.py), [`entity_bridge.py`](hass_client/hass_client/entity_bridge.py) |
|
||||
| Service/event mirror | [`bridge.py`](../homeassistant/components/sandbox/bridge.py) | [`service_mirror.py`](hass_client/hass_client/service_mirror.py), [`event_mirror.py`](hass_client/hass_client/event_mirror.py), [`approved_domains.py`](hass_client/hass_client/approved_domains.py) |
|
||||
| Context restoration | [`bridge.py`](../homeassistant/components/sandbox/bridge.py) (`_remember_context` / `_resolve_context`, TTL cache) | — |
|
||||
| Store routing | [`bridge.py`](../homeassistant/components/sandbox/bridge.py) (`_SandboxStoreServer`), `homeassistant/helpers/sandbox_context.py`, `homeassistant/helpers/storage.py` | [`sandbox_bridge.py`](hass_client/hass_client/sandbox_bridge.py) |
|
||||
| Translations | [`translation.py`](../homeassistant/components/sandbox/translation.py), [`catalog.py`](../homeassistant/components/sandbox/catalog.py), `homeassistant/helpers/translation.py`, `homeassistant/loader.py` | [`sandbox.py`](hass_client/hass_client/sandbox/__init__.py) (`_handle_get_translations`) |
|
||||
| Shutdown | [`__init__.py`](../homeassistant/components/sandbox/__init__.py) (`_on_stop`), `manager.py` | [`sandbox.py`](hass_client/hass_client/sandbox/__init__.py) (`_run_graceful_shutdown`) |
|
||||
| Test infra | — | [`testing/`](hass_client/hass_client/testing/), [`run_compat.py`](run_compat.py) |
|
||||
|
||||
The wire protocol constants live in two files that mirror each other
|
||||
verbatim:
|
||||
[`homeassistant/components/sandbox/protocol.py`](../homeassistant/components/sandbox/protocol.py)
|
||||
and [`sandbox/hass_client/hass_client/protocol.py`](hass_client/hass_client/protocol.py).
|
||||
@@ -0,0 +1,130 @@
|
||||
# Home Assistant Sandbox
|
||||
|
||||
A fresh rewrite of the sandbox system that runs Home Assistant
|
||||
integrations in isolated subprocesses while the main instance keeps a
|
||||
single, unified view of devices, entities, services, and events.
|
||||
|
||||
v1 (`../sandbox/` plus `../homeassistant/components/sandbox/`) is kept
|
||||
around for reference and comparison until the sandbox has matched v1's compat
|
||||
numbers and shipped at least one stable release. See
|
||||
[`OVERVIEW.md`](OVERVIEW.md) for the full architecture and
|
||||
[`plan.md`](plan.md) for the phase-by-phase task list.
|
||||
|
||||
## Layout
|
||||
|
||||
- `hass_client/` — Python client library (its own `uv` env). Hosts the
|
||||
`SandboxRuntime`, the entity / service / event bridges, the
|
||||
`RemoteStore`, and the two pytest plugins.
|
||||
- `docs/` — design decisions captured per phase:
|
||||
- [`entity-bridge-decision.md`](docs/entity-bridge-decision.md) —
|
||||
Option A vs Option B (the Phase 1 spike). Option B shipped.
|
||||
- [`auth-scoping-decision.md`](docs/auth-scoping-decision.md) — why
|
||||
`scopes` lives on `RefreshToken` itself and how the dispatcher
|
||||
enforces it (Phase 7).
|
||||
- `plan.md` — the implementation plan that drives this work.
|
||||
- `OVERVIEW.md` — architecture document.
|
||||
- `status/` — per-phase (`STATUS-phase-N.md`) and per-plan
|
||||
(`STATUS-plan-*.md`) landing notes: what each phase/plan built, what it
|
||||
deferred, what it flagged forward.
|
||||
- `run_compat.py` + `COMPAT.md` — compat-lane runner and report.
|
||||
|
||||
The HA Core side of the integration lives at
|
||||
[`../homeassistant/components/sandbox/`](../homeassistant/components/sandbox/).
|
||||
|
||||
## Quick start
|
||||
|
||||
```bash
|
||||
cd sandbox/hass_client
|
||||
uv sync
|
||||
uv run pytest
|
||||
|
||||
# Run the runtime by hand against a local HA (debugging only — the
|
||||
# manager normally spawns the subprocess for you).
|
||||
uv run python -m hass_client.sandbox \
|
||||
--name built-in \
|
||||
--url ws://localhost:8123/api/websocket \
|
||||
--token <scoped sandbox token>
|
||||
```
|
||||
|
||||
In production, the integration creates the system user, issues the
|
||||
scoped token, and spawns the subprocess automatically once the first
|
||||
flow or entry routes to a given group. The `<scoped sandbox token>`
|
||||
above is the credential `sandbox/auth.py` mints; running the
|
||||
runtime by hand requires creating one yourself.
|
||||
|
||||
## Running HA Core's tests through the sandbox
|
||||
|
||||
```bash
|
||||
# In-process plugin (fast, freezer-safe)
|
||||
cd sandbox/hass_client
|
||||
uv run python -m pytest -p hass_client.testing.pytest_plugin \
|
||||
../../tests/components/input_boolean/test_init.py -v
|
||||
|
||||
# Real-subprocess plugin (pins the subprocess boundary)
|
||||
uv run python -m pytest -p hass_client.testing.conftest_sandbox \
|
||||
../../tests/components/input_boolean/test_init.py -v
|
||||
|
||||
# Or drive the compat lane runner
|
||||
cd sandbox
|
||||
python run_compat.py input_boolean light switch
|
||||
```
|
||||
|
||||
[`COMPAT.md`](COMPAT.md) is the compat-lane report; per-failure
|
||||
output lands in `${SANDBOX_ERRORS_DIR:-/tmp/sandbox_errors}`.
|
||||
|
||||
## Status
|
||||
|
||||
Phases 0–17 landed:
|
||||
|
||||
- **Phase 0** — skeletons in place. Empty HA integration loads.
|
||||
- **Phase 1** — entity-bridge spike. Recommendation:
|
||||
[Option B (action-call forwarding)](docs/entity-bridge-decision.md).
|
||||
- **Phase 2** — runtime classifier (`classify(integration)`).
|
||||
Computes routing from manifest + platform inspection, no user
|
||||
config.
|
||||
- **Phase 3** — sandbox lifecycle. `SandboxManager` spawns one
|
||||
subprocess per group lazily; restart-on-crash with budget.
|
||||
- **Phase 4** — config-flow forwarding. New flows run inside the
|
||||
sandbox; main owns the canonical `ConfigEntry` store.
|
||||
- **Phase 5** — entity bridge end-to-end. Four initial proxies
|
||||
(`light`, `switch`, `sensor`, `binary_sensor`); exception
|
||||
translation. The remaining 28 domain proxies landed in **Phase 13**.
|
||||
- **Phase 6** — service & event mirroring. Sandbox-side
|
||||
`ServiceMirror` + `EventMirror` push registrations and events to
|
||||
main, gated by a refcounted `ApprovedDomains` set.
|
||||
- **Phase 7** — scoped auth (`RefreshToken.scopes`) + opt-in data
|
||||
sharing (`SandboxGroupConfig`). Sandbox tokens reject every
|
||||
non-`sandbox/*` command at the dispatcher.
|
||||
- **Phase 8** — `Store` routing. `RemoteStore` proxies every
|
||||
`Store(...)` in the sandbox to
|
||||
`<config>/.storage/sandbox/<group>/<key>` on main.
|
||||
- **Phase 9** — graceful shutdown + restore-state hand-off. Sandboxes
|
||||
unload entries and dump `RestoreEntity` state into the shutdown
|
||||
reply; main persists it for the next boot's warm-load.
|
||||
- **Phase 10** — test infrastructure. Two pytest plugins (in-process
|
||||
+ real-subprocess) plus the [`run_compat.py`](run_compat.py)
|
||||
runner.
|
||||
- **Phase 11** — docs & cleanup. [`OVERVIEW.md`](OVERVIEW.md),
|
||||
[`docs/auth-scoping-decision.md`](docs/auth-scoping-decision.md),
|
||||
and the directory-local [`CLAUDE.md`](CLAUDE.md).
|
||||
- **Phase 12** — concurrent channel dispatcher; closes Phase 9's
|
||||
reentrancy deadlock and fires `EVENT_HOMEASSISTANT_FINAL_WRITE`
|
||||
on sandbox shutdown.
|
||||
- **Phase 13** — remaining 28 domain proxies; all 32 supported HA
|
||||
entity domains now have a typed proxy.
|
||||
- **Phase 14** — `data_schema` + service-schema marshalling,
|
||||
`unique_id` propagation, `async_unload_entry` core hook.
|
||||
- **Phase 15** — v1-baseline compat sweep against the 37-integration
|
||||
list (99.19 % at the time; lifted to 99.97 % by Phase 17).
|
||||
- **Phase 16** — cross-integration sweep across 807 integrations
|
||||
(98.07 %), categorised backlog ([`BACKLOG.md`](BACKLOG.md)).
|
||||
- **Phase 17** — `ConfigEntry.sandbox` first-class field; closed
|
||||
552 of 664 known failures and lifted the full-sweep test-level
|
||||
pass rate from 98.07 % to **99.67 %** (above the 99.5 %
|
||||
v1-removal threshold).
|
||||
|
||||
The `status/` landing notes (`STATUS-phase-N.md` + `STATUS-plan-*.md`) are
|
||||
the authoritative record of what each phase/plan actually built, what it
|
||||
deferred, and what it flagged forward; [`docs/FOLLOWUPS.md`](docs/FOLLOWUPS.md) tells the
|
||||
narrative story of Phases 12–17 (what each one's predecessor
|
||||
flagged, what landed, the outcome).
|
||||
@@ -0,0 +1,442 @@
|
||||
# Sandbox — Follow-up phases (12–17)
|
||||
|
||||
The Phase 5–10 implementation landings each flagged work forward that
|
||||
would have made the corresponding PR too large to review. Phase 11
|
||||
shipped the architecture doc + decision log; Phases 12–17 are the
|
||||
follow-ups that closed those forward-flags in turn. This file is the
|
||||
**narrative** — the causal chain from one phase's deferral to the next
|
||||
phase's landing.
|
||||
|
||||
Per-failure remediation entries live in [`BACKLOG.md`](../BACKLOG.md);
|
||||
deep landing notes live in the [`status/`](../status/) files
|
||||
(`STATUS-phase-N.md` + `STATUS-plan-*.md`). FOLLOWUPS.md is the connective
|
||||
tissue between them.
|
||||
|
||||
---
|
||||
|
||||
## Phase 12 — Concurrent channel dispatcher
|
||||
|
||||
**Why.** Phase 9 found that the single-threaded `Channel` reader
|
||||
deadlocked when a handler re-entered with `channel.call(...)` — the
|
||||
reply landed on the same reader that was busy dispatching the handler.
|
||||
Phase 9 shipped restore_state in the shutdown reply as the specific
|
||||
workaround, but `EVENT_HOMEASSISTANT_FINAL_WRITE` couldn't fire on
|
||||
sandbox shutdown (it would re-enter `MSG_STORE_SAVE` on the same
|
||||
channel), so any integration that relied on `delay_save` Stores
|
||||
flushing on shutdown silently lost data.
|
||||
|
||||
**What landed.** Both `Channel` classes (HA-Core
|
||||
`homeassistant/components/sandbox/channel.py` and sandbox
|
||||
`sandbox/hass_client/hass_client/channel.py`) now dispatch each
|
||||
inbound call or push in its own `asyncio.create_task`. A bounded
|
||||
`asyncio.Semaphore` (default 16 in-flight, `max_inflight` keyword to
|
||||
dial down) gates concurrent handlers but is acquired inside the
|
||||
dispatched task, so the reader keeps draining the wire even when the
|
||||
cap is hit. `SandboxRuntime._run_graceful_shutdown` now fires
|
||||
`EVENT_HOMEASSISTANT_FINAL_WRITE` (after setting `CoreState.final_write`
|
||||
and `await hass.async_block_till_done()`) so `delay_save` Stores flush
|
||||
their pending writes to main before the reply goes out.
|
||||
|
||||
**Outcome.** 93 HA-core sandbox tests + 45 hass_client tests green
|
||||
(2 new channel tests covering reentrancy + the concurrency cap; 2 new
|
||||
shutdown tests covering FINAL_WRITE + `delay_save` flush). Phase 9's
|
||||
"concurrent channel dispatcher" flag is closed.
|
||||
|
||||
**Files.** `channel.py` (both sides) + `sandbox.py` + `_helpers.py` +
|
||||
`test_channel.py` + `test_shutdown.py`. No core HA files touched.
|
||||
|
||||
---
|
||||
|
||||
## Phase 13 — 28 remaining domain proxies
|
||||
|
||||
**Why.** Phase 5 shipped four entity proxies (`light`, `switch`,
|
||||
`sensor`, `binary_sensor`) to prove the action-call forwarding path
|
||||
end-to-end and keep the entity-bridge PR reviewable. The remaining 28
|
||||
supported HA entity domains were called out as mechanical wrappers
|
||||
around `SandboxProxyEntity` using the same `_call_service(...)`
|
||||
pattern — small but plenty enough to drown an in-flight PR.
|
||||
|
||||
**What landed.** 28 new proxy classes under
|
||||
`homeassistant/components/sandbox/entity/` plus a `scene` symmetry
|
||||
proxy (`scene` lives in `ALWAYS_MAIN` so it never routes through, but
|
||||
the proxy exists so a future classifier change can't surprise us).
|
||||
Each proxy subclasses `SandboxProxyEntity` + the domain's `*Entity`,
|
||||
exposes domain-typed properties out of `_state_cache`, and translates
|
||||
methods into `sandbox/call_service` RPCs via the Phase 5 batcher.
|
||||
Domains that index `supported_features` with `in` re-wrap the wire int
|
||||
into the domain's `*EntityFeature` IntFlag in `__init__`; four whose
|
||||
`state` is `@final` and reads a name-mangled private field (`button`,
|
||||
`event`, `notify`, `scene`) override `sandbox_apply_state` to write
|
||||
the mangled attribute directly so the parent's `@final` getter computes
|
||||
the right state.
|
||||
|
||||
**Outcome.** `_DOMAIN_PROXIES` now dispatches every supported HA
|
||||
entity domain. 121 HA-core sandbox tests green (28 new parametrised
|
||||
smoke tests + 93 prior). `calendar`/`todo` listing and
|
||||
`weather.async_forecast_*` flagged forward as query-shaped RPCs the
|
||||
action-call channel can't express — these stay open and live in
|
||||
[`BACKLOG.md`](../BACKLOG.md).
|
||||
|
||||
**Files.** 28 new `entity/<domain>.py` files + `entity/__init__.py`
|
||||
dispatch table + `test_phase13_proxies.py`. No core HA files touched.
|
||||
|
||||
---
|
||||
|
||||
## Phase 14 — Schema marshalling, unique_id, unload hook, perf benchmark
|
||||
|
||||
**Why.** Phase 5 stripped `data_schema` on the wire (tagged
|
||||
`_has_data_schema: True` for the future bridge) and didn't propagate
|
||||
`unique_id` from the sandbox flow's `flow.context` back to the proxy,
|
||||
so main's duplicate-detection guard couldn't fire. Phase 5 also left
|
||||
the entry-unload path without a router hook (Phase 4 only intercepted
|
||||
setup) and deferred the 200-light area-call benchmark because the
|
||||
in-process tests couldn't measure the real transport.
|
||||
|
||||
**What landed.** `voluptuous_serialize.convert(..., custom_serializer=cv.custom_serializer)`
|
||||
on the sandbox side ships the same list-of-fields shape the HA
|
||||
frontend already renders; a `schema_bridge.reconstruct_schema` helper
|
||||
on main rebuilds a permissive `vol.Schema` (primitives + `select` map
|
||||
back precisely; everything else is a pass-through since the sandbox
|
||||
runs the real validator on every call). The same bridge applies to
|
||||
service schemas: `ServiceMirror` now pushes the serialised schema with
|
||||
every `register_service` so main rejects bad service-call input
|
||||
without round-tripping. `unique_id` rides in the marshalled
|
||||
`FlowResult.context` (looked up via `flow_manager.async_get(flow_id)`
|
||||
because FORM / SHOW_PROGRESS results don't carry context themselves)
|
||||
and the proxy applies it via `await self.async_set_unique_id(...)`.
|
||||
`ConfigEntries.async_unload` consults `router.async_unload_entry`
|
||||
before falling through — same shape as Phase 4's setup intercept. The
|
||||
perf benchmark spins up the in-process plugin (real channel-pair +
|
||||
JSON encode/decode + batcher), registers 200 proxy lights,
|
||||
area-targets `light.turn_on`, and asserts the batcher coalesces 200
|
||||
entity invocations into ≤2 RPCs in under 500 ms.
|
||||
|
||||
**Outcome.** 133 HA-core sandbox tests + 46 hass_client tests + 383
|
||||
core `test_config_entries.py` + 30 core `test_entity_component.py`
|
||||
green. Phase 5's four deferrals (`data_schema`, `unique_id`,
|
||||
`async_unload_entry`, perf) all closed.
|
||||
|
||||
**Files.** `schema_bridge.py` (both sides) + `bridge.py` +
|
||||
`proxy_flow.py` + `flow_runner.py` + `service_mirror.py` +
|
||||
`test_phase14.py` + `test_perf.py`. **Core HA:**
|
||||
`config_entries.py` — `ConfigEntryRouter` Protocol gains
|
||||
`async_unload_entry`; `ConfigEntries.async_unload` consults it before
|
||||
the existing path. Same minimal-hook shape as the Phase 4 setup
|
||||
intercept; the Phase 4 `router` attribute is reused.
|
||||
|
||||
---
|
||||
|
||||
## Phase 15 — v1-baseline compat sweep (10b)
|
||||
|
||||
**Why.** Phase 10 shipped the test infrastructure (two pytest plugins
|
||||
+ `run_compat.py`) but deferred the actual v1-baseline run. The
|
||||
runner needed (a) the remaining 28 proxies (Phase 13), (b) two
|
||||
plumbing fixes — `cwd` was wrong for HA-core test conftest imports and
|
||||
the pytest-cov hook needed `--no-cov` — and (c) a `MockConfigEntry`
|
||||
autotag patch so the classifier path fires for entries the tests
|
||||
themselves create (otherwise the bridge code paths never run during
|
||||
the integration's own test suite).
|
||||
|
||||
**What landed.** A sync classifier mirror in
|
||||
`sandbox/hass_client/hass_client/testing/_autotag.py` (mirrors the
|
||||
Phase 2 classifier's five-rule order; the async real classifier
|
||||
can't run from inside an already-on-the-loop test). Both pytest
|
||||
plugins install the patch in `pytest_configure` and tear down in
|
||||
`pytest_unconfigure`. `run_compat.py` switched `cwd` to `CORE_ROOT`
|
||||
and passes `--no-cov`; its default markdown output moved to
|
||||
`COMPAT_LATEST.md` so ad-hoc runs don't overwrite the curated
|
||||
`COMPAT.md` baseline report.
|
||||
|
||||
**Outcome.** 29 of 37 integrations fully pass; **7,586/7,648 tests
|
||||
pass = 99.19 %** at the test level. Every one of the 62 failures
|
||||
buckets into a single `test-only` root cause: the autotag patch
|
||||
mutated `entry.data` to add `__sandbox_group: built-in`, which a
|
||||
handful of helper integrations (`group`, `template`, `min_max`,
|
||||
`derivative`, `threshold`, `utility_meter`, `integration`, `proximity`)
|
||||
inspect directly (assertions like `entry.data == {}`, or Syrupy
|
||||
snapshots). Confirmed by re-running the same files without the
|
||||
sandbox plugin: 107/107 pass. Below the 99.5 % v1-removal threshold —
|
||||
the recommended fix Phase 15 flagged is what became Phase 17.
|
||||
|
||||
**Files.** `_autotag.py` + `pytest_plugin.py` + `conftest_sandbox.py`
|
||||
+ `run_compat.py` + `COMPAT.md` + `COMPAT.csv` + tests. No core HA
|
||||
files touched.
|
||||
|
||||
---
|
||||
|
||||
## Phase 16 — Cross-integration sweep + categorised backlog
|
||||
|
||||
**Why.** Phase 15 covered v1's 37-integration list. The plan called
|
||||
for the full classifier-routable set so we'd see whether the autotag
|
||||
noise scaled, whether other buckets emerged at scale, and whether any
|
||||
classifier or `ALWAYS_MAIN` changes were warranted across the broader
|
||||
universe of HA integrations.
|
||||
|
||||
**What landed.** `run_compat_full.py` — asyncio + JUnit XML + outer
|
||||
concurrency, forked rather than extended from `run_compat.py` because
|
||||
the runner shape is different (asyncio vs sync-subprocess loop; JUnit
|
||||
XML vs text parsing; outer concurrency vs serial) and the Phase 15
|
||||
runner has to stay stable for the curated 37-integration report.
|
||||
`categorize_failures.py` walks the captured JUnit failures with an
|
||||
ordered regex rule set (first-hit-wins, most-specific → most-generic)
|
||||
into named buckets — `test-only`, `dependencies-not-shared`,
|
||||
`proxy-missing`, `protocol-gap`, `unknown`, etc. `generate_backlog.py`
|
||||
produces a draft skeleton; the committed `BACKLOG.md` is hand-curated
|
||||
on top.
|
||||
|
||||
**Outcome.** **807** integrations exercised in **705s wall** at
|
||||
concurrency=6 (well inside the 30–90 min budget the plan called out).
|
||||
561/807 pass cleanly; 33 714/34 378 tests pass = **98.07 %**
|
||||
test-level. Categoriser hit rate 98.6 % (clearing the ≥95 % gate).
|
||||
**640 of 664 failures (96.4 %) are the same `__sandbox_group` autotag
|
||||
noise Phase 15 already flagged**, just amplified — the single highest-
|
||||
leverage fix in the entire sandbox codebase. Two real bridge findings,
|
||||
both scoped to two integrations: `dependencies-not-shared` (10
|
||||
failures on `azure_event_hub` + `atag`) and `proxy-missing` (5
|
||||
failures on `atag`). Both turned out to be autotag perturbation in
|
||||
Phase 17, not real bridge bugs.
|
||||
|
||||
**Files.** `run_compat_full.py` + `categorize_failures.py` +
|
||||
`generate_backlog.py` + `COMPAT_FULL.md` + `COMPAT_FULL.csv` +
|
||||
`BACKLOG.md` + `BACKLOG_FAILURES.json`. No core HA files touched.
|
||||
|
||||
---
|
||||
|
||||
## Phase 17 — `ConfigEntry.sandbox` first-class field
|
||||
|
||||
**Why.** Phase 15 and Phase 16 both identified the same single highest-
|
||||
leverage fix: move the sandbox-group routing tag off `entry.data` onto
|
||||
a dedicated first-class field. The autotag patch mutating `entry.data`
|
||||
to add `__sandbox_group: built-in` was being observed by 96.4 % of
|
||||
every failure across 807 integrations (552 of 664) — every Syrupy
|
||||
snapshot that included `entry.data` and every test assertion like
|
||||
`entry.data == {}`.
|
||||
|
||||
**What landed.** Optional `ConfigEntry.sandbox: str | None` field on
|
||||
`homeassistant/config_entries.py` — additive, no storage version bump,
|
||||
optional read on load so pre-existing stored entries reconstruct with
|
||||
`sandbox=None`. Plumbed via `as_dict()` (writes only when non-None) +
|
||||
`async_update_entry(entry, sandbox=)` + the existing
|
||||
`UPDATE_ENTRY_CONFIG_ENTRY_ATTRS` set. The plan's "call
|
||||
`async_update_entry(entry, sandbox=group)` right after the framework
|
||||
creates the entry" approach hit an order-of-ops gap (`async_add` runs
|
||||
`async_setup` inside its own body, which consults the router; the
|
||||
after-hook fires too late). The fix that works is to attach
|
||||
`sandbox=<group>` to the `ConfigFlowResult` on the CREATE_ENTRY path so
|
||||
`ConfigEntriesFlowManager.async_finish_flow`'s entry constructor reads
|
||||
it via `result.get("sandbox")` — same plumbing shape `minor_version` /
|
||||
`options` / `subentries` already use. Read sites in `router.py` and
|
||||
`proxy_flow.py` consult `entry.sandbox`; the autotag patch sets
|
||||
`entry.sandbox` via `object.__setattr__` instead of mutating
|
||||
`entry.data`. `SANDBOX_GROUP_KEY` is fully gone.
|
||||
|
||||
**Outcome.** Curated 37-integration baseline **99.19 % → 99.97 %**
|
||||
(35/37 integrations pass; 2 residual diagnostic snapshots). Full
|
||||
807-integration sweep **98.07 % → 99.67 %** — clears the 99.5 %
|
||||
v1-removal threshold the plan asked for. **552 of the 664 known
|
||||
failures closed in one fix.** Every named bridge bucket
|
||||
(`proxy-missing`, `dependencies-not-shared`, `protocol-gap`, ...) is
|
||||
**at zero**. The atag `proxy-missing` and `dependencies-not-shared`
|
||||
rows Phase 16 flagged as "the microcosm of every remaining real-bug
|
||||
bucket" vanished without touching `bridge.py` — they were autotag-
|
||||
fixture perturbation, not real bridge bugs. 112 residual failures are
|
||||
**100 % test-side**: ~30 diagnostic snapshots showing
|
||||
`+ 'sandbox': 'built-in'`, ~70 `'created_at'` snapshot drift on tests
|
||||
that didn't pin the wall clock, 5 environmental rows from Phase 16.
|
||||
|
||||
**Files.** Core HA: `config_entries.py` (additive field + flow-result
|
||||
plumbing). Sandbox: `router.py` + `proxy_flow.py` + `_autotag.py` +
|
||||
`categorize_failures.py`. Tests: `tests/common.py` (`MockConfigEntry`
|
||||
gets `sandbox=` kwarg) + 6 new `tests/test_config_entries.py` cases +
|
||||
sandbox test updates. Reports: `COMPAT.md` + `COMPAT_FULL.md` + `BACKLOG.md`
|
||||
+ `BACKLOG_FAILURES.json` + companion `.csv` files all regenerated.
|
||||
|
||||
---
|
||||
|
||||
## plan-sandbox-context — `current_sandbox` contextvar replaces the store rebinding
|
||||
|
||||
**Why.** Phase 8 routed sandbox `Store` IO to main by rebinding
|
||||
`homeassistant.helpers.storage.Store` at module scope — the `remote_store`
|
||||
installer swapped in a `Store` subclass for the lifetime of the process. That
|
||||
is the exact "do not monkey-patch private internals" smell the project's Iron
|
||||
Law calls out — the same shape v1 was the cautionary tale for. It also had a
|
||||
footgun: helpers that did `from .storage import Store` at import time
|
||||
(`restore_state`, the registries) captured the *original* class, so the
|
||||
rebinding couldn't reach them — `restore_state` needed an explicit per-instance
|
||||
`Store` swap as a workaround.
|
||||
|
||||
**What landed.** A declared core HA hook: `current_sandbox`, a module-level
|
||||
`ContextVar[SandboxBridge | None]` in
|
||||
`homeassistant/helpers/sandbox_context.py`, read by `Store._async_load_data`,
|
||||
`Store._async_write_data`, and `Store.async_remove` at IO time. A contextvar
|
||||
read inside the instance methods is a single source of truth no matter how
|
||||
`Store` was imported, so the `restore_state` workaround is gone. The sandbox
|
||||
runtime sets the contextvar to a `ChannelSandboxBridge` before the warm-load;
|
||||
asyncio's context copy on `create_task` propagates it to every handler. Shipped
|
||||
as **A1** (additive — contextvar branch alongside the rebinding) then **A2**
|
||||
(deleted `remote_store.py`, the installer, and the `restore_state` swap). A2's
|
||||
load-bearing detail: the save branch lives at `_async_write_data`, not
|
||||
`async_save`, so `async_delay_save` and the FINAL_WRITE flush — which bypass
|
||||
`async_save` — route to main too.
|
||||
|
||||
**Outcome.** Zero patched globals in the sandbox; `Store` routing is a declared
|
||||
hook. The "monkey-patch the storage module" tension is closed.
|
||||
|
||||
---
|
||||
|
||||
## plan-strip-auth-scopes — revert the Phase-7 `RefreshToken.scopes` mechanism
|
||||
|
||||
**Why.** Phase 7 added `RefreshToken.scopes` + a websocket-dispatcher
|
||||
`_scope_allows` check across four core HA files
|
||||
(`auth/models.py`, `auth/__init__.py`, `auth/auth_store.py`,
|
||||
`websocket_api/connection.py`) plus a persisted `scopes` key in the
|
||||
on-disk auth store. It was built for a sandbox→main websocket that was
|
||||
never wired up, so no code path ever exercised the scope check
|
||||
end-to-end — the feature was asserted only by an isolated dispatcher
|
||||
test. Phase 20 had already deleted the `share_*` opt-in that paired with
|
||||
scope-as-deny, leaving scopes guarding a non-existent attack surface.
|
||||
That's permanent core surface for zero current value.
|
||||
|
||||
**What landed.** The whole `scopes` mechanism reverted from core HA. The
|
||||
sandbox still gets a dedicated system user per group and an access token
|
||||
freshly minted on each spawn — only the scoping disappears.
|
||||
`_get_or_create_sandbox_refresh_token` now identifies the token by the
|
||||
one-token-per-system-user invariant instead of matching a scope set.
|
||||
Back-compat: the auth-store load path pops a legacy `scopes` key if
|
||||
present (option A — silent drop, no storage-version bump), covered by a
|
||||
regression test; the sandbox is unreleased so the only on-disk scoped tokens are
|
||||
dev machines on this branch.
|
||||
|
||||
**Outcome.** Core HA's auth surface is back to its pre-Phase-7 shape; the
|
||||
sandbox core-HA touch list shrinks from four surfaces to three.
|
||||
[`auth-scoping-decision.md`](auth-scoping-decision.md) is kept as a
|
||||
SUPERSEDED design record for the eventual re-introduction.
|
||||
|
||||
---
|
||||
|
||||
## plan-auth-context — drop the unused token + system user, restore context
|
||||
|
||||
**Why.** Two design-review simplifications. (1) 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** — the
|
||||
sandbox is not an authenticated principal inside main and never connects
|
||||
back, so the credential was dead weight (same reasoning as
|
||||
`plan-strip-auth-scopes`). (2) Main's handling of an inbound `context_id`
|
||||
was incomplete: it minted a fresh `Context` per echo (adopting the
|
||||
sandbox's id and attributing it to the per-group system user), dropping
|
||||
the original attribution of a user-initiated action that flowed
|
||||
main → sandbox → back.
|
||||
|
||||
**What landed (Parts A/B/C).**
|
||||
- **A — token gone end-to-end.** No `--token` argv (`manager._default_command`),
|
||||
no `SandboxRuntime.token` field/param, no `SANDBOX_TOKEN` in the Docker
|
||||
entrypoint / compose / docs, no `async_issue_sandbox_access_token`.
|
||||
- **C — system user gone.** `auth.py` deleted entirely;
|
||||
`bridge._async_system_user_id` / `_system_user_id` removed. Genuinely
|
||||
sandbox-originated contexts are now `user_id=None` — the honest shape,
|
||||
since no user authored them.
|
||||
- **B — context restoration.** The bridge seeds a `context_id → Context`
|
||||
cache at every main→sandbox **call-down** site (the service forwarder
|
||||
`_forward`, and the proxy entity's `async_call_service`, which now
|
||||
threads the entity's live `Context`). A 15-minute TTL bounds it (volume
|
||||
is tiny — a forwarded context is echoed back within the same operation).
|
||||
`_resolve_context` returns a cached Context verbatim for a known id
|
||||
(restoring `parent_id` / `user_id`), and for an unknown/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 can't
|
||||
trust (recorder/logbook order by it). A miss is always safe.
|
||||
|
||||
**Outcome.** The sandbox provably cannot fabricate attribution: the wire
|
||||
carries only a `context_id` string, and main owns every `Context` it
|
||||
produces. The sandbox core-HA touch list is unchanged (this is all inside the
|
||||
integration + runtime). A richer audit answer — a `Context` group
|
||||
attribute — is left as a follow-up below.
|
||||
|
||||
---
|
||||
|
||||
## Still open
|
||||
|
||||
These are the items that survived Phase 17 — see
|
||||
[`../CLAUDE.md`](../CLAUDE.md)'s "Open follow-ups" section for the
|
||||
same list with deeper context, and [`../BACKLOG.md`](../BACKLOG.md)
|
||||
for the per-failure-category remediation table.
|
||||
|
||||
- **State-sharing subscription consumer + main-side filtering.**
|
||||
Phase 20 deleted the unwired `SharingConfig` / `SandboxGroupConfig`
|
||||
surface and replaced it with a design doc
|
||||
([`design-share-states.md`](design-share-states.md)) covering the
|
||||
entity_id alignment constraint, the `share/subscribe_*` protocol,
|
||||
the main-side filter, and the open questions. The actual consumer
|
||||
is owed in a future phase against that design.
|
||||
- **Re-introduce a sandbox credential (with scopes) when the WS lands.**
|
||||
`plan-strip-auth-scopes` reverted the Phase-7 `RefreshToken.scopes`
|
||||
mechanism, and `plan-auth-context` then dropped the unused token and
|
||||
system user entirely — the sandbox currently holds **no** credential.
|
||||
When the WS transport
|
||||
([`../plans/plan-transport.md`](../plans/plan-transport.md) T4) ships
|
||||
the share-states subscription, the sandbox will authenticate to main
|
||||
for the first time and the credential is designed **fresh** then —
|
||||
scopes included; reuse [`auth-scoping-decision.md`](auth-scoping-decision.md)'s
|
||||
design (prefix-grant + exact-match grammar) as the starting point,
|
||||
this time with a real consumer forcing the shape.
|
||||
- **`Context` group attribute for sandbox-originated actions.**
|
||||
`plan-auth-context` makes a genuinely sandbox-originated `Context`
|
||||
`user_id=None` (no user authored it). A richer audit answer would be a
|
||||
new optional `Context` field naming **which sandbox group** originated
|
||||
the action ("this came from the `custom` sandbox") — better for
|
||||
logbook/audit than a null user, without pretending a sandbox is a user.
|
||||
It needs a core `Context` change and is its own design; capture it when
|
||||
audit attribution actually needs it. **Do not** adopt the sandbox's
|
||||
`context_id` to carry this — that id is untrusted (see `_resolve_context`).
|
||||
- **v1 removal. DONE (2026-05-28).** The numeric gate (Phase 11) was
|
||||
cleared by Phase 17; v1 was removed ahead of the "shipped a stable
|
||||
release" condition, relying on git history for rollback.
|
||||
- **Diagnostic snapshot drift / clock-pinning.** ~30 integrations
|
||||
show `+ 'sandbox': 'built-in'` in their diagnostic snapshots (fix
|
||||
is `pytest --snapshot-update` per integration); ~70 show
|
||||
`created_at` drift on tests that didn't pin the wall clock
|
||||
(integration-side freezegun, or an optional Phase 17b clock-pinning
|
||||
fixture on the compat plugin — ~30 LOC, sketched in BACKLOG.md).
|
||||
- **`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 depends on these
|
||||
surfaces.
|
||||
- **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. See the Phase 1
|
||||
spike doc.
|
||||
- **Cross-sandbox in-process dependencies (ESPHome serial / BLE
|
||||
proxy).** Some integration pairs are coupled in-process — e.g. an
|
||||
ESPHome device exposing a serial proxy that another integration
|
||||
(ZHA, zwave_js, deCONZ, …) connects to. Today this only works if
|
||||
both integrations end up in the same sandbox group, because the
|
||||
setup-time coordination happens via Python calls/events the bridge
|
||||
doesn't forward. The classifier routes by built-in / custom / system,
|
||||
so a built-in ESPHome paired with a custom consumer would split
|
||||
across sandboxes and break. Fix shapes: (a) a "co-locate with X"
|
||||
classifier hint for known coupled pairs, or (b) extend the Phase 6
|
||||
event mirror beyond `<owned_domain>_*` to cover the coordination
|
||||
hooks. BLE proxy has the same shape. IR / RF (Broadlink-style) are
|
||||
simpler — one-way command flows with no setup-time enumeration —
|
||||
but still need dedicated cross-sandbox support to route the
|
||||
consumer's send-call to the producer. Worth a small spec before any
|
||||
cross-sandbox split actually trips this.
|
||||
|
||||
- **Coalesce same-tick entity service calls (perf optimisation).** Each proxy
|
||||
method call currently forwards as its own `sandbox/call_service` RPC. An
|
||||
earlier iteration batched calls made in the same event-loop tick for one
|
||||
`(domain, service, service_data)` into a single multi-entity RPC, so a
|
||||
200-light area call paid one round-trip instead of 200. It was dropped to
|
||||
keep the first iteration simple. Reintroduce it as a pure dispatch-layer
|
||||
optimisation behind `SandboxBridge.async_call_service`: gather the tick's
|
||||
calls, fire one RPC per coalesced bucket, and resolve every caller's future
|
||||
when it completes — each caller must still learn when its call finished and
|
||||
see any error (a service call is never fire-and-forget). Only response-less
|
||||
calls can coalesce: a `return_response=True` call needs its own response, so
|
||||
it stays a single-entity RPC.
|
||||
|
||||
For per-failure remediation (residual `test-only` failures, the rare
|
||||
`unknown` bucket entries, environmental rows) see
|
||||
[`../BACKLOG.md`](../BACKLOG.md).
|
||||
@@ -0,0 +1,148 @@
|
||||
# Auth decision — sandbox credential & context attribution
|
||||
|
||||
> **Current design (2026-06-03).** The sandbox is **not an authenticated
|
||||
> principal inside main**: it holds **no credential at all**, and it **cannot
|
||||
> author a `Context`** (it cannot fabricate `parent_id` / `user_id`). Main
|
||||
> restores attribution for sandbox-originated events from a cache of contexts
|
||||
> it issued; anything it does not recognise becomes an unauthenticated action
|
||||
> (`user_id=None`).
|
||||
>
|
||||
> An earlier design gave the sandbox a scoped websocket token; it was never
|
||||
> wired up (there is no sandbox→main websocket yet) and was removed. It is kept
|
||||
> as a [superseded appendix](#appendix--superseded-scoped-token-design) so the
|
||||
> next attempt has prior thinking to reuse when the websocket transport lands.
|
||||
|
||||
## The two properties we want
|
||||
|
||||
1. **No standing credential.** Nothing in main needs the sandbox to
|
||||
authenticate today — all sandbox↔main traffic rides the private control
|
||||
channel (stdio/unix `Channel`), not main's websocket/REST API. So the
|
||||
sandbox is handed no token. Carrying an unused credential is pure attack
|
||||
surface; the credential is redesigned (scopes included) only when a
|
||||
sandbox→main websocket consumer actually exists.
|
||||
|
||||
2. **No fabricated attribution.** Only a `context_id` string crosses the wire
|
||||
from the sandbox — never a `parent_id` or `user_id`. Main never trusts the
|
||||
sandbox to *say* who authored an action; it derives attribution itself.
|
||||
|
||||
## Context-id restoration
|
||||
|
||||
Sandboxed automations and scripts fire events and change states that carry a
|
||||
`context_id`. We want the original attribution — e.g. the user who pressed the
|
||||
button that triggered a sandboxed automation — to survive the round-trip,
|
||||
without letting the sandbox forge it.
|
||||
|
||||
Main keeps a bounded **`context_id → Context` cache** of contexts it has issued
|
||||
to the sandbox. The cache is **seeded where main hands a context down** — when
|
||||
main forwards a service call into the sandbox, the real (main-issued,
|
||||
trusted-timestamp) `Context` is recorded under its id. On any inbound sandbox
|
||||
message carrying a `context_id` (`state_changed`, `fire_event`, the result of a
|
||||
sandbox-originated `call_service`):
|
||||
|
||||
- **Known id** → return the cached main-owned `Context` verbatim, so the
|
||||
original `parent_id` / `user_id` survive.
|
||||
- **Unknown / expired id** → mint a **brand-new** main-owned `Context`
|
||||
(`Context(user_id=None)`, which generates its own fresh id) and cache it under
|
||||
the sandbox-supplied id so repeated echoes within one operation map to a
|
||||
single stable context.
|
||||
|
||||
### Why an unknown id is never adopted
|
||||
|
||||
`Context` ids are **ULIDs with an embedded millisecond timestamp**, and
|
||||
downstream consumers (recorder/logbook ordering) read time out of the id. Main
|
||||
**cannot trust the sandbox's clock** — a sandbox could craft a ULID to back- or
|
||||
forward-date an event. So for any id main did not itself issue, main generates
|
||||
its own ULID with its own clock. The sandbox-supplied string is used **only as a
|
||||
cache key**, never as the resulting context's identity.
|
||||
|
||||
### Bounding — TTL, not size
|
||||
|
||||
Entries expire on a **15-minute TTL**. Volume is naturally tiny: only contexts
|
||||
from main→sandbox **service calls** are cached, and the sandbox echoes them back
|
||||
within the same operation (seconds), so 15 minutes is generous headroom. A miss
|
||||
is always safe — it falls to a fresh main context — so expiry only loses
|
||||
parentage on pathologically delayed echoes, never correctness. Lazy pruning on
|
||||
each resolve is enough; a count cap is an optional backstop.
|
||||
|
||||
## Why `user_id=None` rather than a sandbox user
|
||||
|
||||
A genuinely sandbox-originated action was authored by nobody main can name, so
|
||||
`user_id=None` (a system/unauthenticated action) is the honest shape — the same
|
||||
shape automations and scripts without a user context already produce. An earlier
|
||||
design created a per-group system user (`"Sandbox: built-in"`, …) purely to have
|
||||
*something* to stamp as `user_id`; that user existed for no other reason and was
|
||||
removed. There is no reason for the sandbox to *be* a user when nothing needs it
|
||||
to authenticate.
|
||||
|
||||
## What this removed from core HA
|
||||
|
||||
The sandbox no longer touches the auth layer at all:
|
||||
|
||||
- **No `RefreshToken.scopes` field or websocket dispatcher enforcement** — the
|
||||
scoped-token mechanism (see appendix) was reverted from
|
||||
`auth/models.py`, `auth/__init__.py`, `auth/auth_store.py`, and
|
||||
`websocket_api/connection.py`.
|
||||
- **No sandbox token issuance and no per-group system user** — the
|
||||
`components/sandbox/auth.py` helper was deleted entirely; the manager no
|
||||
longer mints or passes a `--token`, and the runtime no longer carries one.
|
||||
|
||||
The only auth-adjacent code left is the context-id cache, which lives in the
|
||||
sandbox bridge — not in core HA's auth code.
|
||||
|
||||
## Future work (not built)
|
||||
|
||||
- **Sandbox→main websocket credential.** When a websocket consumer lands (the
|
||||
first candidate is remote/containerised sandboxes), the sandbox will need to
|
||||
authenticate to main. Design the credential then — the appendix's scoped-token
|
||||
shape is a reasonable starting point, deliberately *not* carried until needed.
|
||||
- **Group attribution on `Context`.** A richer answer than `user_id=None` would
|
||||
be a `Context` that records *which sandbox group* originated an action (useful
|
||||
for audit/logbook: "this came from the `custom` sandbox") without pretending a
|
||||
sandbox is a user. That needs a new optional core `Context` field and is its
|
||||
own design; capture it when audit attribution actually needs it.
|
||||
|
||||
---
|
||||
|
||||
## Appendix — superseded scoped-token design
|
||||
|
||||
> Kept as a historical record. **None of this is in the codebase.** It described
|
||||
> the credential the sandbox *would* present over a sandbox→main websocket that
|
||||
> was never wired up. Revisit when that transport lands.
|
||||
|
||||
The idea was to give the sandbox a restricted `RefreshToken` rather than a
|
||||
fully-privileged one. v1 handed the subprocess a normal system-user token and
|
||||
gated `sandbox/*` websocket commands with a per-process allow-list — which left
|
||||
two holes: the token could call any non-`sandbox/*` API the system user was
|
||||
authorised for (escalation), and it could read any state/area/device/entity in
|
||||
main (data exfiltration). Both were per-command gating bolted onto a
|
||||
fully-privileged token; the platform itself needed to treat the token as
|
||||
restricted.
|
||||
|
||||
**Mechanism (reverted):** an optional `scopes: frozenset[str] | None` on
|
||||
`RefreshToken` (`None` = fully privileged, unchanged behaviour), enforced
|
||||
centrally in the websocket dispatcher via a small helper:
|
||||
|
||||
```python
|
||||
def _scope_allows(scopes: frozenset[str], type_: str) -> bool:
|
||||
for scope in scopes:
|
||||
if scope.endswith("/"):
|
||||
if type_.startswith(scope):
|
||||
return True
|
||||
elif type_ == scope:
|
||||
return True
|
||||
return False
|
||||
```
|
||||
|
||||
Two grammar forms, chosen to keep the dispatcher allocation-free: a **prefix
|
||||
grant** (`"sandbox/"` matches any `sandbox/*` command) and an **exact match**
|
||||
(`"auth/current_user"`). The intended sandbox grant was
|
||||
`{"sandbox/", "auth/current_user"}`. Putting `scopes` on `RefreshToken` itself
|
||||
(rather than a `SandboxAccessToken` subclass) kept the surface to one optional
|
||||
attribute with no token-type fan-out, and made it reusable by any future scoped
|
||||
consumer (e.g. an OAuth client scoped to `calendar/*`).
|
||||
|
||||
**Data sharing** was to ride alongside as opt-in flags
|
||||
(`share_states` / `share_entity_registry` / `share_areas`), defaulting on for
|
||||
`built-in` / `main` and off for `custom` (the most likely attacker vector). That
|
||||
surface was also removed; the replacement is designed in
|
||||
[`design-share-states.md`](design-share-states.md).
|
||||
@@ -0,0 +1,99 @@
|
||||
# Catalog provider — picker discoverability for sandbox-only customs
|
||||
|
||||
> **Current design (2026-06-05, plan-translation-forwarding Phase A).** Core
|
||||
> exposes a **display-only** catalog hook so a custom integration whose code
|
||||
> lives only in a sandbox — never on main's `<config>/custom_components` disk —
|
||||
> can be listed and named in the add-integration picker **without spawning a
|
||||
> sandbox**. HACS (or any distribution mechanism) fills it. The hook is
|
||||
> deliberately separate from the security-critical integration-source resolver.
|
||||
|
||||
## The gap
|
||||
|
||||
The add-integration picker is built from `integration/descriptions`
|
||||
(`async_get_integration_descriptions`, `homeassistant/loader.py`), whose custom
|
||||
half is a scan of `<config>/custom_components` on **main's** disk. Under the
|
||||
stateless-sandbox model a custom integration's code is fetched at `entry_setup`
|
||||
into the sandbox and is **never on main's disk**, so:
|
||||
|
||||
- it has **no picker row** (the disk scan never sees it), and
|
||||
- even if a row existed, the `title` translation category has nothing to load —
|
||||
the `integration.name` fallback in
|
||||
`_async_get_component_strings` (`homeassistant/helpers/translation.py`) needs a
|
||||
loaded `Integration`, which main cannot build for code it doesn't have.
|
||||
|
||||
This is a **discoverability** gap, of which `title` is a subset. Closing it
|
||||
needs only a tiny static descriptor per domain — not a sandbox spawn (the picker
|
||||
never loads `config`/`selector`, only `title`; those load per-flow once the user
|
||||
starts adding the integration, where the Phase B live RPC handles them).
|
||||
|
||||
## The hook
|
||||
|
||||
```python
|
||||
from homeassistant.components.sandbox.catalog import (
|
||||
SandboxIntegrationDescriptor,
|
||||
async_register_sandbox_catalog_provider,
|
||||
)
|
||||
|
||||
def _catalog() -> list[SandboxIntegrationDescriptor]:
|
||||
return [
|
||||
{
|
||||
"domain": "my_custom",
|
||||
"name": "My Custom Integration", # load-bearing
|
||||
"config_flow": True,
|
||||
"integration_type": "integration", # or "helper"
|
||||
"iot_class": "cloud_polling",
|
||||
"single_config_entry": False,
|
||||
# optional; absent -> picker degrades to `name`
|
||||
"title_translations": {"en": "My Custom Integration"},
|
||||
}
|
||||
]
|
||||
|
||||
unregister = async_register_sandbox_catalog_provider(hass, _catalog)
|
||||
```
|
||||
|
||||
`async_register_sandbox_catalog_provider` is re-exported from the sandbox
|
||||
component (parallel to `async_register_sandbox_source_resolver` in
|
||||
`sandbox/sources.py`) for a single HACS-facing namespace; the registry itself
|
||||
lives in `homeassistant.loader` because core — not the sandbox component —
|
||||
consumes it (`async_get_integration_descriptions` and the translation `title`
|
||||
fallback). Providers are consulted in registration order; the first to claim a
|
||||
domain wins. The returned callback unregisters.
|
||||
|
||||
## Contract
|
||||
|
||||
- **Separate from the source resolver.** The source resolver
|
||||
(`IntegrationSourceDict`, `sandbox/sources.py`) is lazy, per-domain, and
|
||||
**security-critical**: it pins `ref` to an exact commit sha and core does no
|
||||
network I/O, so it trusts that pin. The catalog is **eager, enumerable, and
|
||||
cosmetic**. Fusing them would drag display strings through the sha-validation
|
||||
path and force the security-critical resolver to also be a full listing API.
|
||||
|
||||
- **`name` is load-bearing.** It feeds both the picker row
|
||||
(`integration.name || domainToName(...)` in the frontend) and the `title`
|
||||
fallback. A descriptor without a usable `name` falls back to a prettified
|
||||
domain — acceptable, but worse UX.
|
||||
|
||||
- **`title_translations` is optional.** HACS reliably knows the manifest `name`
|
||||
(it parses `manifest.json` to validate installs) but may **not** have the
|
||||
integration's `translations/<lang>.json` indexed — those live in the repo
|
||||
tarball, fetched only at `entry_setup`. When `title_translations[lang]` is
|
||||
absent the picker degrades to `name` (the same fallback chain main already
|
||||
uses). A localized title is a nice-to-have, not a requirement.
|
||||
|
||||
- **No validation.** Unlike `ref` (sha-pinned, security-critical), a wrong or
|
||||
missing `name` is cosmetic, so core does **no** strict validation of catalog
|
||||
descriptors. A domain that an on-disk scan also finds keeps the on-disk
|
||||
metadata — the disk scan wins a collision.
|
||||
|
||||
- **Display-only scope.** The catalog carries picker metadata, nothing more. It
|
||||
is intentionally **not** the broader "stateless-custom discovery" feature
|
||||
(config-flow allow-listing, schema, etc.); those remain out of scope.
|
||||
|
||||
## Relationship to the live path (Phase B)
|
||||
|
||||
Phase B already forwards a *running* sandboxed integration's strings over the
|
||||
`sandbox/get_translations` RPC, routed by `entry.sandbox` / the in-progress
|
||||
`SandboxFlowProxy`. The catalog covers the **cold** picker case where there is
|
||||
no entry and no running flow — so no group to route to — and the live RPC would
|
||||
return nothing. The two are complementary: catalog for the cold list + name,
|
||||
RPC for everything once a flow starts or an entry is loaded.
|
||||
@@ -0,0 +1,169 @@
|
||||
# Sync-states design (post-launch)
|
||||
|
||||
> **Status:** design only. Phase 7 wired the scoped sandbox token and a
|
||||
> per-group `share_*` config; Phase 20 deleted that config because
|
||||
> nothing consumed it. This doc captures the shape we want before
|
||||
> someone picks the consumer up.
|
||||
|
||||
## Goal
|
||||
|
||||
Sandboxed integrations should be able to react to entity-state changes
|
||||
that originated in **main** (or, eventually, in other sandboxes), so
|
||||
automation-, script-, and template-style logic written *inside* a
|
||||
sandbox behaves the same as if it ran in main. Equivalently: a
|
||||
sandboxed integration that calls `hass.states.async_all()` should
|
||||
optionally see the same view of the world a non-sandboxed integration
|
||||
sees.
|
||||
|
||||
v1 sandbox gave the sandbox the system user's full access token and
|
||||
therefore unconditional read access to all of main's data. Phase 7
|
||||
locked the sandbox down by default — the sandbox sees only its own
|
||||
entities/services/events. The locked-down posture is the right
|
||||
default; we just owe a controlled opt-in.
|
||||
|
||||
## Key constraint: entity_id alignment
|
||||
|
||||
Without explicit alignment, the sandbox's own `EntityRegistry`
|
||||
generates entity_ids independently. A sandbox-side automation written
|
||||
against `light.kitchen` would silently target a *different*
|
||||
`light.kitchen` from the one main hosts under the same slug, because
|
||||
the two registries pick suggested_object_ids independently.
|
||||
|
||||
The fix: shared entities **must use main's entity_id** when projected
|
||||
into the sandbox's state machine, regardless of what the sandbox's
|
||||
local registry would have chosen.
|
||||
|
||||
Mechanism:
|
||||
|
||||
- Main's entity_registry is mirrored into the sandbox as a read-only
|
||||
view (initial snapshot + delta stream).
|
||||
- `entity_id` is the canonical name on both sides. The mirror writes
|
||||
registry rows verbatim — the sandbox does not run its own
|
||||
collision/suggest logic against mirrored rows.
|
||||
- Sandbox-side state writes for **sandbox-owned** entities still flow
|
||||
through the existing entity bridge (Phase 5). The bridge already
|
||||
maps sandbox-local `entity_id` → main's `entity_id` via
|
||||
`SandboxBridge._entities` so there is no conflict between the
|
||||
sandbox-owned and main-owned naming.
|
||||
|
||||
## Mechanism sketch
|
||||
|
||||
1. The sandbox opens a websocket back to main. The auth token is the
|
||||
scoped `RefreshToken` from Phase 7 — same scope set
|
||||
(`{"sandbox/", "auth/current_user"}`) plus a single new exact
|
||||
entry `share/subscribe` (added to
|
||||
`homeassistant/components/sandbox/auth.py::SANDBOX_TOKEN_SCOPES`).
|
||||
2. The sandbox calls three subscribe commands, one per data class:
|
||||
- `share/subscribe_states` — initial snapshot of every state main
|
||||
wants this sandbox to see + `state_changed` deltas.
|
||||
- `share/subscribe_entity_registry` — initial snapshot of every
|
||||
registry row this sandbox is allowed to see + create/update/remove
|
||||
deltas.
|
||||
- `share/subscribe_areas` — initial snapshot of every area + delta
|
||||
stream. Area registry is small; full snapshot is fine.
|
||||
3. Each subscribe response carries a subscription id; subsequent push
|
||||
frames carry that id so the sandbox can route to the right
|
||||
consumer.
|
||||
4. On the sandbox side, each consumer applies the delta locally:
|
||||
- States → `hass.states.async_set(entity_id, state, attributes, …)`
|
||||
(with the existing source-context plumbing to mark these as
|
||||
remote).
|
||||
- Entity registry → `er.async_update_entity` / `async_get_or_create`
|
||||
/ `async_remove` on the sandbox's `EntityRegistry`. The sandbox
|
||||
marks mirrored rows with a `source` field so its own
|
||||
`async_remove` calls against them return an error rather than
|
||||
mutating main's data.
|
||||
- Areas → same pattern against `AreaRegistry`.
|
||||
|
||||
The control channel is the existing `Channel` for everything inbound
|
||||
from main → sandbox; subscription frames ride that channel rather than
|
||||
opening a second connection.
|
||||
|
||||
## Filtering on main's send-side
|
||||
|
||||
Per-sandbox allow-list, configured at sandbox-startup time. Coarse
|
||||
grain is fine for a future version — entity-domain-level allow-listing covers the
|
||||
main use cases (`["light.*", "sensor.*"]`, etc.). Filtering happens
|
||||
**before** the push hits the wire so a state-change-heavy main does
|
||||
not fan out N copies of every event to every sandbox.
|
||||
|
||||
Defaults match the Phase 7 plan that Phase 20 deleted:
|
||||
|
||||
| Group | states / entity_registry / areas |
|
||||
|---|---|
|
||||
| `built-in` | all on |
|
||||
| `main` | all on |
|
||||
| `custom` | all off |
|
||||
|
||||
The defaults are a starting point; the per-sandbox allow-list (set by
|
||||
the integration's config, not by the framework) can narrow them
|
||||
further. Default-on for `built-in` matches v1's behaviour so existing
|
||||
integrations behave the same; default-off for `custom` keeps the
|
||||
trust boundary tight for untrusted integrations.
|
||||
|
||||
## Open questions
|
||||
|
||||
- **Direction.** Is the share one-way (sandbox sees main only) or
|
||||
bidirectional (sandboxes also see each other's states)? Latter
|
||||
routes through main — main's entity_registry/state machine already
|
||||
carries the sandbox-owned entities via the existing bridge, so a
|
||||
second sandbox subscribing to `share/subscribe_states` would see
|
||||
them transparently. The cost is one extra hop per state change. Lean
|
||||
one-way for a future version and add bidirectional only if a real integration
|
||||
needs it.
|
||||
- **Mirrored registries: write-through behaviour.** What happens if a
|
||||
sandbox calls `er.async_remove(entity_id)` for a main-owned entity?
|
||||
Cleanest answer: read-only mirror — the call returns an error and
|
||||
the row stays. Alternative: silently no-op. The error path is
|
||||
louder and makes the boundary explicit, so prefer it.
|
||||
- **Device + area registries.** Same pattern as state +
|
||||
entity_registry. Phase 19's `device_registry` bridging (sandbox →
|
||||
main) is the precursor; the reverse direction (main → sandbox) is
|
||||
this work.
|
||||
- **Performance.** A state-change-heavy main fans out to every
|
||||
sandbox subscribed to the matching domain. Per-event filtering on
|
||||
main's send-side is the cheap fix (already a non-goal to fan out
|
||||
unfiltered); a domain-indexed subscription map on main avoids the
|
||||
per-event filter walk for sandboxes with narrow allow-lists.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- **Full read-write registry mirroring.** Sandboxes cannot write to
|
||||
main's entity_registry / area_registry / device_registry through the
|
||||
share channel. The existing entity bridge handles
|
||||
sandbox-owned-entity creation; the share channel is read-only into
|
||||
the sandbox.
|
||||
- **Bidirectional device targeting via the share channel.** A
|
||||
sandbox-side automation calling a main-side service (e.g.
|
||||
`light.turn_on` against a main-owned light) already works via the
|
||||
existing service mirror — the share channel does not need to grow
|
||||
that surface.
|
||||
- **Frontend surfacing of the per-sandbox allow-list.** The knob is
|
||||
a backend/integration config; no UI in v3.
|
||||
|
||||
## Why now
|
||||
|
||||
Phase 7 added `SandboxGroupConfig` + `SharingConfig` + `--share-*`
|
||||
CLI flags + `DEFAULT_GROUP_CONFIGS`. Phase 20 deleted all of it
|
||||
because nothing consumed it; carrying unwired flags risks readers
|
||||
assuming functionality that isn't there. This doc replaces the dead
|
||||
surface as the single point of truth for the eventual consumer.
|
||||
|
||||
The locked-down posture from Phase 7 stays — defaults remain
|
||||
everything-off. The opt-in subscription consumer lands behind the
|
||||
new config surface (whatever shape it takes when implemented) so the
|
||||
default behaviour does not regress.
|
||||
|
||||
## Files this design will touch
|
||||
|
||||
```
|
||||
homeassistant/components/sandbox/auth.py (extend SANDBOX_TOKEN_SCOPES)
|
||||
homeassistant/components/sandbox/share.py (new — main-side share/subscribe_* handlers, send-side filter)
|
||||
homeassistant/components/sandbox/manager.py (re-introduce a per-sandbox allow-list)
|
||||
sandbox/hass_client/hass_client/share.py (new — sandbox-side subscription consumer)
|
||||
sandbox/hass_client/hass_client/sandbox.py (open the websocket back to main; wire the consumer)
|
||||
```
|
||||
|
||||
Core HA: no further changes expected — Phase 7's `RefreshToken.scopes`
|
||||
and `_scope_allows` cover the auth side; the websocket subscription
|
||||
protocol is already public.
|
||||
@@ -0,0 +1,230 @@
|
||||
# Entity-bridge decision (Phase 1)
|
||||
|
||||
> **Decision:** adopt **Option B — action-call forwarding** for the sandbox entity
|
||||
> bridge. The proxy entity translates each entity method into a standard
|
||||
> `services.async_call("<domain>", "<service>", target={"entity_id": [...]})`
|
||||
> round-trip over the shared `sandbox/call_service` transport.
|
||||
|
||||
This document records the spike (`sandbox/hass_client/hass_client/spike/`,
|
||||
tests at `tests/components/sandbox/test_spike.py`), the numbers it
|
||||
produced, and the trade-offs that drove the call.
|
||||
|
||||
## What the spike measured
|
||||
|
||||
The spike runs **two `HomeAssistant` instances in the same process**, joined
|
||||
by an in-process JSON transport
|
||||
(`hass_client.spike.transport.InProcessTransport`). The transport
|
||||
`json.dumps`/`json.loads`-es every message and pushes it through an
|
||||
`asyncio.Queue` so every round-trip pays the cost of one loop yield plus
|
||||
serialization (no network — that's identical between options and would only
|
||||
add noise).
|
||||
|
||||
Both options share that transport. The only differences between them are:
|
||||
|
||||
- **Option A — method-forward RPC.** A bespoke
|
||||
`sandbox/entity_method_call` carries `(entity_id, method, kwargs)`. The
|
||||
sandbox-side handler does `getattr(entity, method)(**kwargs)`.
|
||||
- **Option B — action-call forwarding.** A generic
|
||||
`sandbox/call_service` carries `(domain, service, target, service_data)`.
|
||||
The sandbox-side handler just calls `hass.services.async_call(...)`. The
|
||||
sandbox's normal service dispatcher resolves the target and invokes
|
||||
`async_turn_on` on the real entity.
|
||||
|
||||
The spike installs 100 `SyntheticLight` entities on the sandbox side and 100
|
||||
proxy entities on the main side, assigns the proxies to an area, then
|
||||
repeatedly calls `light.turn_on` with `target={"area_id": ...}`. Each
|
||||
iteration toggles all 100 lights on and resets via `turn_off`.
|
||||
|
||||
## Numbers
|
||||
|
||||
Five runs of `test_report_comparison`, 100-entity area call, 5 iterations
|
||||
each:
|
||||
|
||||
| Option | Median (ms) | Min (ms) | Max (ms) | RPCs / call | Bytes / iteration |
|
||||
|:------:|------------:|---------:|---------:|------------:|------------------:|
|
||||
| A | ~46 | ~44 | ~50 | 100 | ~17.8 KB |
|
||||
| B | ~64 | ~60 | ~70 | 100 | ~20.7 KB |
|
||||
|
||||
Per-entity round-trip cost:
|
||||
|
||||
- **A:** ~0.46 ms / entity (just the RPC dispatch + a direct `await
|
||||
entity.async_turn_on(**kwargs)`).
|
||||
- **B:** ~0.64 ms / entity (~0.18 ms more — the extra cost is HA's full
|
||||
service handler on the sandbox side: target resolution, schema validation,
|
||||
per-entity dispatch).
|
||||
|
||||
Both options send exactly one RPC per proxied entity per call. The byte
|
||||
delta comes from Option B's richer payload (`target` + nested `entity_id`
|
||||
list + `service_data`).
|
||||
|
||||
## Lines of glue per new domain
|
||||
|
||||
Counted from the spike's `light` proxies (whole class, including docstrings
|
||||
and properties):
|
||||
|
||||
| Option | Proxy class LOC | Shared bridge LOC (one-time, not per-domain) |
|
||||
|:------:|----------------:|---------------------------------------------:|
|
||||
| A | 42 | 37 |
|
||||
| B | 48 | 45 |
|
||||
|
||||
Per-domain cost is essentially the same — both options ultimately need the
|
||||
proxy class plus its cached state/capability properties (the same
|
||||
`brightness`/`color_mode`/`supported_color_modes`/… fan-out v1 has). The 6-
|
||||
line delta is the slightly bigger `target=` dict construction inside each
|
||||
method body and is noise compared to the capability-property surface a real
|
||||
proxy needs.
|
||||
|
||||
## Why Option B
|
||||
|
||||
1. **Smaller protocol surface — and the channel is on the critical path
|
||||
regardless.** Phase 6 has to build a generic `sandbox/call_service`
|
||||
channel anyway, both to mirror sandbox-registered services back to main
|
||||
*and* so main can invoke services provided by sandboxed integrations.
|
||||
Option B reuses that channel for entity calls; Option A adds a second
|
||||
channel that does the same job for the entity-only subset. We get no
|
||||
protocol savings by deferring B — we just postpone consolidating onto
|
||||
the channel we have to build either way.
|
||||
2. **Behaviour parity for free.** Anything HA's own service handler does —
|
||||
target resolution, schema validation, entity filtering, color-mode kwarg
|
||||
filtering (`filter_turn_on_params`), response-data routing for services
|
||||
that return values — works for the proxy without re-implementing it.
|
||||
Option A has to keep its dispatcher in step with whatever HA's service
|
||||
layer adds.
|
||||
3. **Per-domain glue is identical.** 42 vs 48 lines means the maintenance
|
||||
burden of adding a new domain is the same either way. The proxy class is
|
||||
the bulk of the work, and that doesn't change.
|
||||
4. **Latency cost is small and we already plan to batch.** ~0.18 ms/entity
|
||||
extra. The plan's existing Risk note already says: *"if either bridge
|
||||
option exceeds ~50 ms for 100 entities, plan a batching layer in Phase
|
||||
5."* Option B is over that line (~64 ms) in-process, so batching is on the
|
||||
table regardless. A real websocket will add more latency on top of both
|
||||
options — the *relative* cost stays the same.
|
||||
5. **One fewer dispatcher to maintain.** Option A's sandbox-side
|
||||
`_handle_entity_method` is small but real, and it would need extending
|
||||
each time we add a new entity method shape (e.g., custom entity services
|
||||
registered with non-trivial schemas). Option B inherits HA's full surface
|
||||
and stays in lockstep with it.
|
||||
|
||||
## Trade-offs worth recording
|
||||
|
||||
- **Error paths differ slightly.** Option B's call goes through HA's
|
||||
service-call schema. A bad kwarg comes back as a `vol.Invalid` from the
|
||||
schema layer rather than as an `AttributeError`/`TypeError` from the
|
||||
entity method. The bridge needs to translate these so the proxy raises
|
||||
the same exception types it would have raised locally.
|
||||
- **Non-entity services from sandboxed integrations are unaffected.** Option
|
||||
B already routes everything through `services.async_call`; whether the
|
||||
registered handler is an entity service or a free service is transparent
|
||||
to the bridge. Option A would have needed *both* the entity_method RPC
|
||||
*and* a separate generic service-call path; B collapses these into one.
|
||||
- **Spike vs reality.** The spike's transport is in-process. A real
|
||||
websocket adds aiohttp framing + TCP RTT, identical for both options. The
|
||||
~0.18 ms/entity delta should hold; the absolute numbers will be larger
|
||||
and dominated by transport latency once a real connection is in the loop.
|
||||
- **The wire is JSON for both options.** `kwargs` must survive
|
||||
`json.dumps` (with HA's encoder, so `datetime` rides as ISO strings,
|
||||
enums as their values, etc.). Anything that doesn't — `bytes`,
|
||||
generators, file handles, in-memory `BrowseMedia` trees with cyclic
|
||||
references — fails on the wire under *either* option. That's an entity-
|
||||
method-signature constraint, not a bridge-protocol one.
|
||||
|
||||
## Where neither bridge option is enough
|
||||
|
||||
Some integrations have **non-idempotent service handlers**: the handler
|
||||
does meaningful work (resolution, I/O, object construction) *before*
|
||||
calling the entity method, and the entity method receives kwargs whose
|
||||
type signature doesn't match the registered service schema. For these,
|
||||
the proxy entity intercepts too late — by the time the proxy's method
|
||||
runs on main, the handler has already done the work, and Option B can't
|
||||
re-issue `services.async_call` with the post-handler kwargs because they
|
||||
no longer satisfy the service schema. Option A *can* sometimes limp by
|
||||
shipping the post-handler objects over the wire (e.g. file paths work
|
||||
because parent and child share a filesystem), but only with bespoke per-
|
||||
integration glue.
|
||||
|
||||
Canonical example, from `homeassistant/components/ai_task/task.py:43-95`:
|
||||
|
||||
- Service schema accepts `attachments: [{media_content_id: str,
|
||||
media_content_type: str}, ...]`.
|
||||
- `_resolve_attachments` inside the service handler walks each attachment,
|
||||
either fetches bytes from a camera/image entity (deny-listed!) or calls
|
||||
`media_source.async_resolve_media`, writes the bytes to a temp file, and
|
||||
builds `Attachment(media_content_id=..., mime_type=..., path=Path(...))`.
|
||||
- The entity method `_async_generate_data` receives the resolved
|
||||
`Attachment` list — `Path` objects, not the original `media_content_id`
|
||||
strings.
|
||||
|
||||
If `ai_task` were sandboxed:
|
||||
|
||||
- **Option B**: proxy gets the resolved list, tries to re-issue
|
||||
`services.async_call("ai_task", "generate_data", service_data={
|
||||
"attachments": [Attachment(...)]})`, schema rejects it.
|
||||
- **Option A**: proxy ships the `Attachment` list as a dict (with `Path`
|
||||
coerced to `str`), sandbox reconstructs. The path works *because* the
|
||||
parent and child share a filesystem, but the upstream resolution call
|
||||
into camera/image still needed to succeed on main, and camera/image
|
||||
entities are deny-listed and only available on main. So the bytes had
|
||||
to be fetched there anyway — Option A's "advantage" here is mostly that
|
||||
it lets us paper over a bigger architectural gap, not that it solves it.
|
||||
|
||||
**Resolution path.** Two complementary directions, neither in scope for
|
||||
this phase:
|
||||
|
||||
1. **Service-handler-level interception** for integrations where the
|
||||
service handler is non-idempotent. The bridge would intercept the
|
||||
service call *before* the handler runs and forward the raw service
|
||||
data to the sandbox; the sandbox-side handler runs against sandbox-
|
||||
local entities. This is a small extension of the Option B channel —
|
||||
essentially the same as Phase 6's main→sandbox service mirroring,
|
||||
pointed in the other direction.
|
||||
2. **Make individual integrations sandbox-aware** so they cooperate with
|
||||
the bridge rather than fight it. `ai_task` is the canonical first
|
||||
candidate: the service handler could detect that the target entity
|
||||
lives in a sandbox and route the raw attachment dicts there before
|
||||
resolution, so resolution happens once on the side that's going to
|
||||
consume the result. Same shape for any future integration whose
|
||||
service handler does expensive pre-dispatch work.
|
||||
|
||||
**Immediate consequence in this phase**: `ai_task` and `image` are added
|
||||
to `ALWAYS_MAIN` (alongside the existing `script`/`automation`/`scene`/
|
||||
`cloud`). `image` joins because its entities expose bytes-returning
|
||||
methods that downstream integrations (like `ai_task`) need to call
|
||||
locally; if `image` itself ran in a sandbox, those calls would fall over
|
||||
the same byte-channel gap that already deny-lists `camera`. `assist_satellite`,
|
||||
`camera`, `stt`, `tts`, `conversation`, `wake_word` remain in
|
||||
`SANDBOX_INCOMPATIBLE_PLATFORMS` (the platform-shape deny list) because
|
||||
the issue is what *their* entity methods return, not what calls them.
|
||||
|
||||
## Action items folded into the remaining plan
|
||||
|
||||
- **Phase 5 (entity bridge):** build the proxy classes against the shared
|
||||
`sandbox/call_service` channel. Mark Option A as discarded in
|
||||
`plan.md`'s "Open architectural choice".
|
||||
- **Phase 5 (entity bridge):** introduce the fan-out batching helper
|
||||
flagged in the plan's Risks section — proxy entities collected during one
|
||||
service call should be coalesced into a single `sandbox/call_service`
|
||||
carrying a multi-entity target, so a 200-light area call pays one RPC,
|
||||
not 200.
|
||||
- **Phase 6 (service & event mirroring):** the same `sandbox/call_service`
|
||||
channel built here is the one used for arbitrary main→sandbox service
|
||||
forwarding; no new RPC type required.
|
||||
- **Phase 5 / Phase 6:** add a small exception-translation layer on the
|
||||
sandbox side so service-handler errors come back as the exception types
|
||||
the proxy entity methods originally raised.
|
||||
- **Phase 2 (classifier):** `ai_task` and `image` are added to
|
||||
`ALWAYS_MAIN` immediately (see `homeassistant/components/sandbox/
|
||||
const.py`). The classifier test in Phase 2 must cover both — and
|
||||
ideally a parameterised case that asserts every domain in `ALWAYS_MAIN`
|
||||
routes to main without needing manifest inspection.
|
||||
- **Future (post-Phase 11):** spec out service-handler-level interception
|
||||
for non-idempotent handlers, and/or a "sandbox-aware integration" hook
|
||||
so `ai_task` (and the next integration that fits the pattern) can
|
||||
delegate attachment-style resolution to the sandbox side.
|
||||
|
||||
## Reproducing the numbers
|
||||
|
||||
The spike harness (`sandbox/hass_client/hass_client/spike/` and
|
||||
`tests/components/sandbox/test_spike.py`) was **removed once Option B was
|
||||
chosen and shipped** — it was a one-off bake-off, not part of the product.
|
||||
The numbers above are preserved here as the decision record; recover the
|
||||
harness from git history if you ever need to re-run it.
|
||||
@@ -0,0 +1,117 @@
|
||||
# Query-shaped RPCs — the unproxied entity-component APIs
|
||||
|
||||
> Status: **request/response shipped; subscriptions still open.** The two
|
||||
> request/response mechanisms (the `call_service` `return_response` path for ops
|
||||
> with a `SupportsResponse` service, and the generic `sandbox/entity_query` RPC
|
||||
> for the service-less ones) are wired and tested — every server-side query and
|
||||
> WS-only mutation below now answers with real data. What remains is the
|
||||
> subscription/push primitive (the `*/subscribe` rows + the `todo` item-list
|
||||
> push) and the `media_player.browse_media` media-source caveat. See
|
||||
> [`../plans/plan-query-rpc.md`](../plans/plan-query-rpc.md) and
|
||||
> [`../status/STATUS-plan-query-rpc.md`](../status/STATUS-plan-query-rpc.md).
|
||||
|
||||
## 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, on its own, **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. The
|
||||
request/response ones are now wired (a second `return_response` flavour of
|
||||
`call_service`, plus the generic `entity_query` RPC — see §8). The
|
||||
subscription-shaped commands (`weather/subscribe_forecast`,
|
||||
`calendar/event/subscribe`) ride the same query methods but get only the
|
||||
**one-shot fetch** — no streamed updates until the push primitive lands. The
|
||||
`entity.raise_not_proxied(...)` helper is now callerless, kept for that
|
||||
deferred subscription/`todo`-push work.
|
||||
|
||||
Two distinct primitives — the first is shipped, the second is not:
|
||||
|
||||
1. **Request/response RPC — SHIPPED.** Two flavours: ops that already have a
|
||||
`SupportsResponse` service ride the existing `call_service` path with
|
||||
`return_response=True` (the sandbox re-runs the real service against the real
|
||||
entity); the genuinely service-less ops cross via a generic
|
||||
`sandbox/entity_query` RPC where main sends `{sandbox_entity_id, method,
|
||||
args}`, the sandbox invokes the real entity method, and the serialised result
|
||||
(wrapped `{"value": …}`) comes back. Main rebuilds each rich return type
|
||||
(`BrowseMedia` / `CalendarEvent` / `SearchMedia` / `Segment`) with explicit
|
||||
field mapping. Covers everything except the subscriptions.
|
||||
2. **Subscription / push RPC — still missing.** 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. Until it lands the
|
||||
`*/subscribe` commands get only the one-shot fetch the request/response path
|
||||
provides, and `todo` is routed to main — 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 | Status |
|
||||
|---|---|---|---|---|
|
||||
| `calendar` | `calendar.get_events` (svc, response) | `async_get_events` | request/response | **wired** (`call_service` `return_response`) |
|
||||
| `calendar` | `calendar/event/subscribe` (WS) | `async_get_events` + recurrence timer | subscription | **one-shot only** (no streamed updates) |
|
||||
| `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) | **wired** (`entity_query`) |
|
||||
| `calendar` | `calendar/event/delete` (WS) | `async_delete_event` | command (WS-only, no svc) | **wired** (`entity_query`) |
|
||||
| `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 | **wired** (`call_service` `return_response`) |
|
||||
| `weather` | `weather/subscribe_forecast` (WS) | `async_forecast_*` + listeners | subscription | **one-shot only** (no streamed updates) |
|
||||
| `media_player` | `media_player.browse_media` (svc, response) / `media_player/browse_media` (WS) | `async_browse_media` | request/response | **wired** (`call_service` `return_response`; media-source caveat) |
|
||||
| `media_player` | `media_player/search_media` (WS) | `async_search_media` | request/response | **wired** (`entity_query`) |
|
||||
| `update` | `update/release_notes` (WS) | `async_release_notes` | request/response | **wired** (`entity_query`) |
|
||||
| `vacuum` | `vacuum/get_segments` (WS) | `async_get_segments` | request/response | **wired** (`entity_query`) |
|
||||
|
||||
### 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,46 @@
|
||||
# Build-context excludes for the Sandbox runtime image.
|
||||
#
|
||||
# IMPORTANT — which .dockerignore actually applies:
|
||||
# The documented build uses the REPO ROOT as context
|
||||
# (`docker build -f sandbox/hass_client/Dockerfile -t sandbox_test .`),
|
||||
# because the image installs the local `homeassistant` checkout. With the repo
|
||||
# root as context, Docker reads the repo-root `.dockerignore` (which already
|
||||
# excludes .git, tests, .venv, docs, config, __pycache__) — NOT this file.
|
||||
# THIS file applies only when the build context is `sandbox/hass_client/`
|
||||
# itself. It is kept self-sufficient for that case and to document intent.
|
||||
|
||||
# Version control / CI
|
||||
.git
|
||||
.github
|
||||
|
||||
# Python caches / build artifacts
|
||||
**/__pycache__
|
||||
**/*.pyc
|
||||
*.egg-info
|
||||
.mypy_cache
|
||||
.pytest_cache
|
||||
.ruff_cache
|
||||
build
|
||||
dist
|
||||
|
||||
# Virtualenvs
|
||||
.venv
|
||||
venv
|
||||
|
||||
# Tests + dev-only files (not needed at runtime)
|
||||
tests
|
||||
.vscode
|
||||
.devcontainer
|
||||
|
||||
# Docker assets themselves (not needed inside the image)
|
||||
Dockerfile
|
||||
.dockerignore
|
||||
docker-compose*.yml
|
||||
|
||||
# Sandbox sub-trees not needed in the runtime image
|
||||
docs
|
||||
plans
|
||||
*.md
|
||||
proto
|
||||
run_compat.py
|
||||
COMPAT.csv
|
||||
@@ -0,0 +1,98 @@
|
||||
# Sandbox runtime image — runs the `hass_client` sandbox runtime.
|
||||
#
|
||||
# NOT a remote-ready artifact today. The runtime talks to main over the
|
||||
# control channel; the only container-friendly transport that exists right now
|
||||
# is a unix socket over a shared volume (transport T3). The websocket transport
|
||||
# (T4) that a genuinely remote sandbox needs is DEFERRED, so this image is
|
||||
# partly forward-looking: build it now to pin the image's deps and to exercise
|
||||
# the runtime over a non-stdio transport on the same host. See docs/docker.md
|
||||
# and docker-compose.test.yml for the transport caveat and the (currently
|
||||
# blocking) manager gap for a two-container harness.
|
||||
#
|
||||
# Two-stage build keeps the final image small: the builder resolves and
|
||||
# installs `homeassistant` + `hass_client` into a venv; the final stage copies
|
||||
# only that venv.
|
||||
#
|
||||
# Standalone-image alternative (NOT what this file builds): instead of COPYing
|
||||
# the repo and installing the local checkout, install a pinned
|
||||
# `homeassistant==<ver>` from PyPI plus a pre-built `hass_client` wheel. The
|
||||
# test image installs the local checkout so it always matches the surrounding
|
||||
# core tree.
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Stage 1 — builder: install homeassistant + hass_client into a venv.
|
||||
# ---------------------------------------------------------------------------
|
||||
FROM python:3.14-slim AS builder
|
||||
|
||||
ENV PIP_NO_CACHE_DIR=1 \
|
||||
PIP_DISABLE_PIP_VERSION_CHECK=1
|
||||
|
||||
# Build toolchain — OPTIONAL. A handful of integration requirements have no
|
||||
# pre-built wheels for this platform and need a compiler to build at runtime.
|
||||
# Baking it bloats the image, so it is left off by default; uncomment if the
|
||||
# integrations under test pull such requirements. (`git` is deliberately NOT
|
||||
# installed: custom-integration code is fetched as a codeload *tarball* via
|
||||
# aiohttp — see hass_client/sources.py — not via a `git` clone.)
|
||||
# RUN apt-get update \
|
||||
# && apt-get install -y --no-install-recommends build-essential \
|
||||
# && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN python -m venv /opt/venv
|
||||
ENV PATH="/opt/venv/bin:$PATH"
|
||||
|
||||
# COPY the repo context (trimmed by .dockerignore) and install the local
|
||||
# checkout: the repo root provides `homeassistant`, and ./sandbox/hass_client
|
||||
# provides `hass-client-v2` (whose `homeassistant` dependency is already
|
||||
# satisfied by the local install, plus the `protobuf` + `aiohttp` runtime deps).
|
||||
#
|
||||
# Integration requirements are deliberately NOT pre-baked here. The runtime
|
||||
# pip-installs each integration's manifest requirements on demand at setup time
|
||||
# (`async_process_requirements`) — which is exactly why the final image keeps
|
||||
# pip and needs network egress at runtime.
|
||||
COPY . /src
|
||||
RUN pip install /src /src/sandbox/hass_client
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Stage 2 — runtime: copy the venv, drop privileges, run the runtime.
|
||||
# ---------------------------------------------------------------------------
|
||||
FROM python:3.14-slim AS runtime
|
||||
|
||||
# tini as PID 1: a bare Python process running as PID 1 ignores signals whose
|
||||
# default action would terminate it (e.g. SIGTERM from `docker stop`), so it
|
||||
# would never shut down cleanly. tini reaps zombies and forwards signals to the
|
||||
# runtime. (Alternative if you would rather not bake it: run with
|
||||
# `docker run --init` / compose `init: true` and drop this apt layer.)
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends tini \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# The runtime pip-installs integration requirements at setup time, so the venv
|
||||
# (its site-packages) must be writable by the non-root runtime user.
|
||||
RUN useradd --create-home --uid 10001 sandbox
|
||||
COPY --from=builder --chown=sandbox:sandbox /opt/venv /opt/venv
|
||||
|
||||
ENV PATH="/opt/venv/bin:$PATH" \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
SANDBOX_URL="stdio://" \
|
||||
SANDBOX_LOG_LEVEL="INFO"
|
||||
|
||||
COPY --chown=sandbox:sandbox sandbox/hass_client/docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
|
||||
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
|
||||
|
||||
# No VOLUME and no persistent state: the runtime keeps nothing on disk between
|
||||
# runs. It writes only an ephemeral config dir under the system temp dir
|
||||
# (TemporaryDirectory in hass_client/sandbox/__init__.py); storage/restore-state routes
|
||||
# to main over the channel, and custom-integration code is fetched at startup.
|
||||
#
|
||||
# No HEALTHCHECK on purpose: readiness is the `Ready` frame the runtime sends
|
||||
# on the control channel, which main already supervises — there is no HTTP/port
|
||||
# probe to hit. Do NOT add one.
|
||||
|
||||
USER sandbox
|
||||
WORKDIR /home/sandbox
|
||||
|
||||
# Exec-form entrypoint via tini → the entrypoint script `exec`s python, so
|
||||
# signals reach the runtime and it shuts down cleanly. The script expands the
|
||||
# SANDBOX_* env vars into the CLI flags (see docker-entrypoint.sh). The module
|
||||
# stays `hass_client.sandbox` (the rename to `sandbox` is a separate plan).
|
||||
ENTRYPOINT ["/usr/bin/tini", "--", "/usr/local/bin/docker-entrypoint.sh"]
|
||||
@@ -0,0 +1,21 @@
|
||||
# hass-client (v2)
|
||||
|
||||
Sandbox client library. Independent `uv`-managed environment that depends
|
||||
on `homeassistant` from the surrounding core checkout via
|
||||
`[tool.uv.sources]`.
|
||||
|
||||
```bash
|
||||
cd sandbox/hass_client
|
||||
uv sync
|
||||
uv run pytest
|
||||
```
|
||||
|
||||
## Docker
|
||||
|
||||
A container image runs the sandbox runtime (`python -m hass_client.sandbox`)
|
||||
for testing the client against main — see [`docs/docker.md`](docs/docker.md) for
|
||||
how to build it, the env vars, and the transport caveat (unix socket today,
|
||||
websocket later). It is partly forward-looking: not a remote-ready artifact
|
||||
today. The accompanying `docker-compose.test.yml` captures the intended
|
||||
same-host unix-socket harness but does not run against today's manager (gaps
|
||||
documented in `docs/docker.md`).
|
||||
@@ -0,0 +1,77 @@
|
||||
# Sandbox — same-host unix-socket test harness (FORWARD-LOOKING).
|
||||
#
|
||||
# ┌───────────────────────────────────────────────────────────────────────┐
|
||||
# │ THIS HARNESS DOES NOT RUN AGAINST TODAY'S MANAGER. It captures the │
|
||||
# │ INTENDED two-container shape so it is ready when the manager grows the │
|
||||
# │ capabilities below. It is valid YAML (`docker compose config` parses │
|
||||
# │ it), but `docker compose up` will not produce a working sandbox today. │
|
||||
# │ See docs/docker.md "Compose harness gap" + status/STATUS-plan-docker.md │
|
||||
# └───────────────────────────────────────────────────────────────────────┘
|
||||
#
|
||||
# Why it can't run yet (two manager gaps, neither hacked here):
|
||||
#
|
||||
# (a) Configurable socket path. The manager puts its unix socket in a private
|
||||
# per-attempt tempdir (`tempfile.mkdtemp(...control.sock)` in
|
||||
# homeassistant/components/sandbox/manager.py), not on a shared path.
|
||||
# For a cross-container harness the socket must live on the shared volume
|
||||
# (`/shared/sandbox.sock`). There is no option to point it there today.
|
||||
#
|
||||
# (b) Listen-only / attach mode. More fundamentally, the manager *spawns* the
|
||||
# runtime as its own child process (`create_subprocess_exec`) and then
|
||||
# listens for that child to dial back. It never waits for a separately
|
||||
# started runtime to connect. So the "sandbox" service below would never
|
||||
# be used — `main` spawns its own in-container child instead. A
|
||||
# two-container split needs a manager mode that listens on a known socket
|
||||
# and attaches to an externally launched runtime (or the websocket
|
||||
# transport — see below).
|
||||
#
|
||||
# The genuinely remote variant arrives with the WEBSOCKET transport (T4), which
|
||||
# is DEFERRED. With WS, `main` would listen and the sandbox container would dial
|
||||
# in over the network — no shared volume, no spawn. Until then, the only
|
||||
# transports that work are stdio and unix, both between `main` and a child it
|
||||
# spawned *inside its own container* (single-container model).
|
||||
|
||||
services:
|
||||
# Home Assistant core (main). Would run the sandbox integration with the
|
||||
# manager configured for the unix transport, pointing its control socket at
|
||||
# the shared volume — see gap (a). Image/build left to the deployment; this is
|
||||
# a placeholder showing the wiring, not a runnable service today.
|
||||
main:
|
||||
image: homeassistant/home-assistant:dev
|
||||
# init: true # PID-1 signal handling if main's image needs it.
|
||||
volumes:
|
||||
- sandbox_sock:/shared
|
||||
environment:
|
||||
# Forward-looking: the manager option that would place the control socket
|
||||
# on the shared volume. No such option exists yet — gap (a).
|
||||
SANDBOX_TRANSPORT: unix
|
||||
SANDBOX_SOCKET_PATH: /shared/sandbox.sock
|
||||
# The sandbox container can only attach once main listens on (not spawns
|
||||
# into) the shared socket — gap (b).
|
||||
depends_on:
|
||||
- sandbox
|
||||
|
||||
# The Sandbox runtime image built from ./Dockerfile. Dials the shared
|
||||
# unix socket that main would expose. Stateless: no volumes of its own beyond
|
||||
# the shared socket dir.
|
||||
sandbox:
|
||||
build:
|
||||
# Build context is the repo root (two levels up) so the image can install
|
||||
# the local `homeassistant` checkout; the repo-root .dockerignore trims
|
||||
# the context.
|
||||
context: ../..
|
||||
dockerfile: sandbox/hass_client/Dockerfile
|
||||
init: true # tini is baked in, but `init: true` is a harmless belt-and-braces.
|
||||
volumes:
|
||||
- sandbox_sock:/shared
|
||||
environment:
|
||||
SANDBOX_NAME: built-in
|
||||
# Must match the path main writes its socket to on the shared volume.
|
||||
SANDBOX_URL: unix:///shared/sandbox.sock
|
||||
SANDBOX_LOG_LEVEL: INFO
|
||||
|
||||
# Shared volume carrying the unix socket between the two services. (No volume
|
||||
# carries sandbox *state* — the runtime is stateless; this volume exists solely
|
||||
# so both containers can see the same unix socket file.)
|
||||
volumes:
|
||||
sandbox_sock:
|
||||
@@ -0,0 +1,15 @@
|
||||
#!/bin/sh
|
||||
# Entrypoint for the Sandbox runtime image.
|
||||
#
|
||||
# Expands the SANDBOX_* env vars into the runtime CLI flags and `exec`s the
|
||||
# module so the Python process replaces this shell (tini, as PID 1, then
|
||||
# forwards signals to it for a clean shutdown). The module name stays
|
||||
# `hass_client.sandbox` — do not rename it here.
|
||||
set -eu
|
||||
|
||||
: "${SANDBOX_NAME:?SANDBOX_NAME is required (the sandbox group, e.g. built-in / custom)}"
|
||||
|
||||
exec python -m hass_client.sandbox \
|
||||
--name "${SANDBOX_NAME}" \
|
||||
--url "${SANDBOX_URL:-stdio://}" \
|
||||
--log-level "${SANDBOX_LOG_LEVEL:-INFO}"
|
||||
@@ -0,0 +1,117 @@
|
||||
# Sandbox runtime — Docker image
|
||||
|
||||
A container image that runs the `hass_client` sandbox runtime
|
||||
(`python -m hass_client.sandbox`). Files:
|
||||
|
||||
- [`../Dockerfile`](../Dockerfile) — the image.
|
||||
- [`../.dockerignore`](../.dockerignore) — local build-context excludes (see
|
||||
the context note below).
|
||||
- [`../docker-entrypoint.sh`](../docker-entrypoint.sh) — expands the `SANDBOX_*`
|
||||
env vars into the runtime's CLI flags.
|
||||
- [`../docker-compose.test.yml`](../docker-compose.test.yml) — the intended
|
||||
same-host unix-socket harness (forward-looking — see "Compose harness gap").
|
||||
|
||||
## Not a remote-ready artifact today
|
||||
|
||||
The runtime talks to main over the control channel. A genuinely remote sandbox
|
||||
needs the **websocket transport (T4), which is DEFERRED**. The transports that
|
||||
exist today — stdio and unix socket (T3) — run between main and a child process
|
||||
it spawned *inside its own container*. So this image is **partly
|
||||
forward-looking**: build it now to
|
||||
|
||||
- pin the image's dependencies, and
|
||||
- package the runtime so it is ready when WS lands,
|
||||
|
||||
but do not mistake it for something that lets a separate sandbox container join
|
||||
a remote main today. The transport caveat is repeated in the Dockerfile and the
|
||||
compose file so it is hard to miss.
|
||||
|
||||
## What the image contains (and deliberately omits)
|
||||
|
||||
- **Base:** `python:3.14-slim` (HA's minimum is 3.14).
|
||||
- **Two stages:** a builder installs `homeassistant` (from the local checkout)
|
||||
plus `hass_client` (and its `protobuf` + `aiohttp` deps) into a venv; the
|
||||
final stage copies only that venv. Keeps the image lean.
|
||||
- **No pre-baked integration requirements.** The runtime pip-installs each
|
||||
integration's manifest requirements **on demand** at setup time
|
||||
(`async_process_requirements`). This is why the final image keeps `pip` and
|
||||
**needs network egress at runtime** (PyPI for deps, GitHub codeload for
|
||||
custom-integration code). This is what closes the pip/egress runtime gap that
|
||||
`plan-ephemeral-sources` flagged: the container is where pip + egress live.
|
||||
- **No `git`.** Custom-integration code is fetched as a codeload **tarball**
|
||||
over aiohttp (`hass_client/sources.py`), not via a `git` clone — so no `git`
|
||||
binary is needed.
|
||||
- **`build-essential` is optional** (commented out in the Dockerfile).
|
||||
Uncomment it only if the integrations under test pull requirements that have
|
||||
no pre-built wheel and must compile at runtime; baking it otherwise just
|
||||
bloats the image.
|
||||
- **Non-root** (`sandbox`, uid 10001). The venv is `chown`ed to that user so
|
||||
the runtime's on-demand `pip install` can write into site-packages.
|
||||
- **No persistent volumes / no state.** The runtime writes only an ephemeral
|
||||
config dir under the system temp dir; storage and restore-state route to main
|
||||
over the channel, and custom code is fetched at startup.
|
||||
- **No `HEALTHCHECK`.** Readiness is the `Ready` frame on the control channel,
|
||||
which main supervises — there is no port/HTTP probe. Do not add one.
|
||||
- **`tini` as PID 1** so `docker stop`'s SIGTERM reaches the runtime (a bare
|
||||
Python PID 1 would ignore it). Equivalent alternative: drop `tini` and run
|
||||
with `docker run --init` / compose `init: true`.
|
||||
|
||||
## Environment variables (entrypoint)
|
||||
|
||||
| Var | Required | Default | Maps to |
|
||||
| ------------------- | -------- | ----------- | ------------- |
|
||||
| `SANDBOX_NAME` | yes | — | `--name` |
|
||||
| `SANDBOX_URL` | no | `stdio://` | `--url` |
|
||||
| `SANDBOX_LOG_LEVEL` | no | `INFO` | `--log-level` |
|
||||
|
||||
`SANDBOX_URL` selects the transport by scheme: `stdio://` (default),
|
||||
`unix://<path>`, or `ws://…` (rejected — reserved for the deferred websocket
|
||||
work).
|
||||
|
||||
## Build
|
||||
|
||||
The build context is the **repo root** (two levels up) because the image
|
||||
installs the local `homeassistant` checkout:
|
||||
|
||||
```bash
|
||||
# from the repo root
|
||||
docker build -f sandbox/hass_client/Dockerfile -t sandbox_test .
|
||||
```
|
||||
|
||||
### Build-context / `.dockerignore` note
|
||||
|
||||
Because the context is the repo root, Docker reads the **repo-root**
|
||||
`.dockerignore` (which already excludes `.git`, `tests`, `.venv`, `docs`,
|
||||
`config`, `__pycache__`). The `.dockerignore` next to the Dockerfile applies
|
||||
only when the build context is `sandbox/hass_client/` itself; it is kept for
|
||||
that case and to document intent.
|
||||
|
||||
## Compose harness gap
|
||||
|
||||
`docker-compose.test.yml` models the intended same-host **unix-socket**
|
||||
harness: a `main` service and a `sandbox` service sharing a volume for the
|
||||
socket. **It does not run against today's manager.** Two manager capabilities
|
||||
are missing (neither is hacked in):
|
||||
|
||||
1. **Configurable socket path.** The manager puts its unix socket in a private
|
||||
per-attempt tempdir (`tempfile.mkdtemp`), not on a shared path. The harness
|
||||
needs the socket on the shared volume (`/shared/sandbox.sock`); there is no
|
||||
option to point it there.
|
||||
2. **Listen-only / attach mode.** The manager *spawns* the runtime as its own
|
||||
child (`create_subprocess_exec`) and listens for that child to dial back. It
|
||||
never waits for a separately started runtime to connect — so the `sandbox`
|
||||
service would never be used; `main` would spawn its own in-container child
|
||||
instead. A two-container split needs a manager mode that listens on a known
|
||||
socket and attaches to an externally launched runtime.
|
||||
|
||||
The genuinely remote variant arrives with the **websocket transport (T4)**,
|
||||
which is deferred: with WS, `main` listens and the sandbox container dials in
|
||||
over the network — no shared volume, no spawn. Until either (1)+(2) or WS
|
||||
lands, the working model is single-container (main spawns its sandbox children
|
||||
over stdio/unix inside one container).
|
||||
|
||||
Validate the compose file parses without running it:
|
||||
|
||||
```bash
|
||||
docker compose -f sandbox/hass_client/docker-compose.test.yml config
|
||||
```
|
||||
@@ -0,0 +1,3 @@
|
||||
"""Sandbox client library."""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,479 @@
|
||||
from google.protobuf import struct_pb2 as _struct_pb2
|
||||
from google.protobuf.internal import containers as _containers
|
||||
from google.protobuf import descriptor as _descriptor
|
||||
from google.protobuf import message as _message
|
||||
from collections.abc import Iterable as _Iterable, Mapping as _Mapping
|
||||
from typing import ClassVar as _ClassVar, Optional as _Optional, Union as _Union
|
||||
|
||||
DESCRIPTOR: _descriptor.FileDescriptor
|
||||
|
||||
class Frame(_message.Message):
|
||||
__slots__ = ("id", "type", "request", "response")
|
||||
ID_FIELD_NUMBER: _ClassVar[int]
|
||||
TYPE_FIELD_NUMBER: _ClassVar[int]
|
||||
REQUEST_FIELD_NUMBER: _ClassVar[int]
|
||||
RESPONSE_FIELD_NUMBER: _ClassVar[int]
|
||||
id: int
|
||||
type: str
|
||||
request: bytes
|
||||
response: Response
|
||||
def __init__(self, id: _Optional[int] = ..., type: _Optional[str] = ..., request: _Optional[bytes] = ..., response: _Optional[_Union[Response, _Mapping]] = ...) -> None: ...
|
||||
|
||||
class Response(_message.Message):
|
||||
__slots__ = ("ok", "result", "error")
|
||||
OK_FIELD_NUMBER: _ClassVar[int]
|
||||
RESULT_FIELD_NUMBER: _ClassVar[int]
|
||||
ERROR_FIELD_NUMBER: _ClassVar[int]
|
||||
ok: bool
|
||||
result: bytes
|
||||
error: Error
|
||||
def __init__(self, ok: bool = ..., result: _Optional[bytes] = ..., error: _Optional[_Union[Error, _Mapping]] = ...) -> None: ...
|
||||
|
||||
class Error(_message.Message):
|
||||
__slots__ = ("message", "type", "invalid", "multiple")
|
||||
MESSAGE_FIELD_NUMBER: _ClassVar[int]
|
||||
TYPE_FIELD_NUMBER: _ClassVar[int]
|
||||
INVALID_FIELD_NUMBER: _ClassVar[int]
|
||||
MULTIPLE_FIELD_NUMBER: _ClassVar[int]
|
||||
message: str
|
||||
type: str
|
||||
invalid: _containers.RepeatedCompositeFieldContainer[InvalidError]
|
||||
multiple: bool
|
||||
def __init__(self, message: _Optional[str] = ..., type: _Optional[str] = ..., invalid: _Optional[_Iterable[_Union[InvalidError, _Mapping]]] = ..., multiple: bool = ...) -> None: ...
|
||||
|
||||
class InvalidError(_message.Message):
|
||||
__slots__ = ("message", "path")
|
||||
MESSAGE_FIELD_NUMBER: _ClassVar[int]
|
||||
PATH_FIELD_NUMBER: _ClassVar[int]
|
||||
message: str
|
||||
path: _containers.RepeatedScalarFieldContainer[str]
|
||||
def __init__(self, message: _Optional[str] = ..., path: _Optional[_Iterable[str]] = ...) -> None: ...
|
||||
|
||||
class DevicePair(_message.Message):
|
||||
__slots__ = ("key", "value")
|
||||
KEY_FIELD_NUMBER: _ClassVar[int]
|
||||
VALUE_FIELD_NUMBER: _ClassVar[int]
|
||||
key: str
|
||||
value: str
|
||||
def __init__(self, key: _Optional[str] = ..., value: _Optional[str] = ...) -> None: ...
|
||||
|
||||
class DeviceInfo(_message.Message):
|
||||
__slots__ = ("identifiers", "connections", "via_device", "entry_type", "name", "manufacturer", "model", "model_id", "sw_version", "hw_version", "serial_number", "suggested_area", "configuration_url", "default_name", "default_manufacturer", "default_model", "translation_key")
|
||||
IDENTIFIERS_FIELD_NUMBER: _ClassVar[int]
|
||||
CONNECTIONS_FIELD_NUMBER: _ClassVar[int]
|
||||
VIA_DEVICE_FIELD_NUMBER: _ClassVar[int]
|
||||
ENTRY_TYPE_FIELD_NUMBER: _ClassVar[int]
|
||||
NAME_FIELD_NUMBER: _ClassVar[int]
|
||||
MANUFACTURER_FIELD_NUMBER: _ClassVar[int]
|
||||
MODEL_FIELD_NUMBER: _ClassVar[int]
|
||||
MODEL_ID_FIELD_NUMBER: _ClassVar[int]
|
||||
SW_VERSION_FIELD_NUMBER: _ClassVar[int]
|
||||
HW_VERSION_FIELD_NUMBER: _ClassVar[int]
|
||||
SERIAL_NUMBER_FIELD_NUMBER: _ClassVar[int]
|
||||
SUGGESTED_AREA_FIELD_NUMBER: _ClassVar[int]
|
||||
CONFIGURATION_URL_FIELD_NUMBER: _ClassVar[int]
|
||||
DEFAULT_NAME_FIELD_NUMBER: _ClassVar[int]
|
||||
DEFAULT_MANUFACTURER_FIELD_NUMBER: _ClassVar[int]
|
||||
DEFAULT_MODEL_FIELD_NUMBER: _ClassVar[int]
|
||||
TRANSLATION_KEY_FIELD_NUMBER: _ClassVar[int]
|
||||
identifiers: _containers.RepeatedCompositeFieldContainer[DevicePair]
|
||||
connections: _containers.RepeatedCompositeFieldContainer[DevicePair]
|
||||
via_device: DevicePair
|
||||
entry_type: str
|
||||
name: str
|
||||
manufacturer: str
|
||||
model: str
|
||||
model_id: str
|
||||
sw_version: str
|
||||
hw_version: str
|
||||
serial_number: str
|
||||
suggested_area: str
|
||||
configuration_url: str
|
||||
default_name: str
|
||||
default_manufacturer: str
|
||||
default_model: str
|
||||
translation_key: str
|
||||
def __init__(self, identifiers: _Optional[_Iterable[_Union[DevicePair, _Mapping]]] = ..., connections: _Optional[_Iterable[_Union[DevicePair, _Mapping]]] = ..., via_device: _Optional[_Union[DevicePair, _Mapping]] = ..., entry_type: _Optional[str] = ..., name: _Optional[str] = ..., manufacturer: _Optional[str] = ..., model: _Optional[str] = ..., model_id: _Optional[str] = ..., sw_version: _Optional[str] = ..., hw_version: _Optional[str] = ..., serial_number: _Optional[str] = ..., suggested_area: _Optional[str] = ..., configuration_url: _Optional[str] = ..., default_name: _Optional[str] = ..., default_manufacturer: _Optional[str] = ..., default_model: _Optional[str] = ..., translation_key: _Optional[str] = ...) -> None: ...
|
||||
|
||||
class IntegrationSource(_message.Message):
|
||||
__slots__ = ("kind", "url", "ref", "tag", "domain", "subdir")
|
||||
KIND_FIELD_NUMBER: _ClassVar[int]
|
||||
URL_FIELD_NUMBER: _ClassVar[int]
|
||||
REF_FIELD_NUMBER: _ClassVar[int]
|
||||
TAG_FIELD_NUMBER: _ClassVar[int]
|
||||
DOMAIN_FIELD_NUMBER: _ClassVar[int]
|
||||
SUBDIR_FIELD_NUMBER: _ClassVar[int]
|
||||
kind: str
|
||||
url: str
|
||||
ref: str
|
||||
tag: str
|
||||
domain: str
|
||||
subdir: str
|
||||
def __init__(self, kind: _Optional[str] = ..., url: _Optional[str] = ..., ref: _Optional[str] = ..., tag: _Optional[str] = ..., domain: _Optional[str] = ..., subdir: _Optional[str] = ...) -> None: ...
|
||||
|
||||
class EntrySetup(_message.Message):
|
||||
__slots__ = ("entry_id", "domain", "title", "data", "options", "source", "unique_id", "version", "minor_version", "integration_source")
|
||||
ENTRY_ID_FIELD_NUMBER: _ClassVar[int]
|
||||
DOMAIN_FIELD_NUMBER: _ClassVar[int]
|
||||
TITLE_FIELD_NUMBER: _ClassVar[int]
|
||||
DATA_FIELD_NUMBER: _ClassVar[int]
|
||||
OPTIONS_FIELD_NUMBER: _ClassVar[int]
|
||||
SOURCE_FIELD_NUMBER: _ClassVar[int]
|
||||
UNIQUE_ID_FIELD_NUMBER: _ClassVar[int]
|
||||
VERSION_FIELD_NUMBER: _ClassVar[int]
|
||||
MINOR_VERSION_FIELD_NUMBER: _ClassVar[int]
|
||||
INTEGRATION_SOURCE_FIELD_NUMBER: _ClassVar[int]
|
||||
entry_id: str
|
||||
domain: str
|
||||
title: str
|
||||
data: _struct_pb2.Struct
|
||||
options: _struct_pb2.Struct
|
||||
source: str
|
||||
unique_id: str
|
||||
version: int
|
||||
minor_version: int
|
||||
integration_source: IntegrationSource
|
||||
def __init__(self, entry_id: _Optional[str] = ..., domain: _Optional[str] = ..., title: _Optional[str] = ..., data: _Optional[_Union[_struct_pb2.Struct, _Mapping]] = ..., options: _Optional[_Union[_struct_pb2.Struct, _Mapping]] = ..., source: _Optional[str] = ..., unique_id: _Optional[str] = ..., version: _Optional[int] = ..., minor_version: _Optional[int] = ..., integration_source: _Optional[_Union[IntegrationSource, _Mapping]] = ...) -> None: ...
|
||||
|
||||
class EntrySetupResult(_message.Message):
|
||||
__slots__ = ("ok", "reason")
|
||||
OK_FIELD_NUMBER: _ClassVar[int]
|
||||
REASON_FIELD_NUMBER: _ClassVar[int]
|
||||
ok: bool
|
||||
reason: str
|
||||
def __init__(self, ok: bool = ..., reason: _Optional[str] = ...) -> None: ...
|
||||
|
||||
class EntryUnload(_message.Message):
|
||||
__slots__ = ("entry_id",)
|
||||
ENTRY_ID_FIELD_NUMBER: _ClassVar[int]
|
||||
entry_id: str
|
||||
def __init__(self, entry_id: _Optional[str] = ...) -> None: ...
|
||||
|
||||
class EntryUnloadResult(_message.Message):
|
||||
__slots__ = ("ok",)
|
||||
OK_FIELD_NUMBER: _ClassVar[int]
|
||||
ok: bool
|
||||
def __init__(self, ok: bool = ...) -> None: ...
|
||||
|
||||
class CallService(_message.Message):
|
||||
__slots__ = ("domain", "service", "target", "service_data", "context_id", "return_response")
|
||||
DOMAIN_FIELD_NUMBER: _ClassVar[int]
|
||||
SERVICE_FIELD_NUMBER: _ClassVar[int]
|
||||
TARGET_FIELD_NUMBER: _ClassVar[int]
|
||||
SERVICE_DATA_FIELD_NUMBER: _ClassVar[int]
|
||||
CONTEXT_ID_FIELD_NUMBER: _ClassVar[int]
|
||||
RETURN_RESPONSE_FIELD_NUMBER: _ClassVar[int]
|
||||
domain: str
|
||||
service: str
|
||||
target: _struct_pb2.Struct
|
||||
service_data: _struct_pb2.Struct
|
||||
context_id: str
|
||||
return_response: bool
|
||||
def __init__(self, domain: _Optional[str] = ..., service: _Optional[str] = ..., target: _Optional[_Union[_struct_pb2.Struct, _Mapping]] = ..., service_data: _Optional[_Union[_struct_pb2.Struct, _Mapping]] = ..., context_id: _Optional[str] = ..., return_response: bool = ...) -> None: ...
|
||||
|
||||
class ServiceResponse(_message.Message):
|
||||
__slots__ = ("data",)
|
||||
DATA_FIELD_NUMBER: _ClassVar[int]
|
||||
data: _struct_pb2.Struct
|
||||
def __init__(self, data: _Optional[_Union[_struct_pb2.Struct, _Mapping]] = ...) -> None: ...
|
||||
|
||||
class CallServiceResult(_message.Message):
|
||||
__slots__ = ("response",)
|
||||
RESPONSE_FIELD_NUMBER: _ClassVar[int]
|
||||
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]
|
||||
DOMAINS_FIELD_NUMBER: _ClassVar[int]
|
||||
language: str
|
||||
domains: _containers.RepeatedScalarFieldContainer[str]
|
||||
def __init__(self, language: _Optional[str] = ..., domains: _Optional[_Iterable[str]] = ...) -> None: ...
|
||||
|
||||
class GetTranslationsResult(_message.Message):
|
||||
__slots__ = ("language", "strings")
|
||||
LANGUAGE_FIELD_NUMBER: _ClassVar[int]
|
||||
STRINGS_FIELD_NUMBER: _ClassVar[int]
|
||||
language: str
|
||||
strings: _struct_pb2.Struct
|
||||
def __init__(self, language: _Optional[str] = ..., strings: _Optional[_Union[_struct_pb2.Struct, _Mapping]] = ...) -> None: ...
|
||||
|
||||
class Shutdown(_message.Message):
|
||||
__slots__ = ()
|
||||
def __init__(self) -> None: ...
|
||||
|
||||
class ShutdownResult(_message.Message):
|
||||
__slots__ = ("ok", "unloaded", "restore_state")
|
||||
OK_FIELD_NUMBER: _ClassVar[int]
|
||||
UNLOADED_FIELD_NUMBER: _ClassVar[int]
|
||||
RESTORE_STATE_FIELD_NUMBER: _ClassVar[int]
|
||||
ok: bool
|
||||
unloaded: int
|
||||
restore_state: _struct_pb2.Struct
|
||||
def __init__(self, ok: bool = ..., unloaded: _Optional[int] = ..., restore_state: _Optional[_Union[_struct_pb2.Struct, _Mapping]] = ...) -> None: ...
|
||||
|
||||
class Ping(_message.Message):
|
||||
__slots__ = ()
|
||||
def __init__(self) -> None: ...
|
||||
|
||||
class PingResult(_message.Message):
|
||||
__slots__ = ("pong",)
|
||||
PONG_FIELD_NUMBER: _ClassVar[int]
|
||||
pong: str
|
||||
def __init__(self, pong: _Optional[str] = ...) -> None: ...
|
||||
|
||||
class Ready(_message.Message):
|
||||
__slots__ = ()
|
||||
def __init__(self) -> None: ...
|
||||
|
||||
class FlowInit(_message.Message):
|
||||
__slots__ = ("handler", "context", "data")
|
||||
HANDLER_FIELD_NUMBER: _ClassVar[int]
|
||||
CONTEXT_FIELD_NUMBER: _ClassVar[int]
|
||||
DATA_FIELD_NUMBER: _ClassVar[int]
|
||||
handler: str
|
||||
context: _struct_pb2.Struct
|
||||
data: _struct_pb2.Struct
|
||||
def __init__(self, handler: _Optional[str] = ..., context: _Optional[_Union[_struct_pb2.Struct, _Mapping]] = ..., data: _Optional[_Union[_struct_pb2.Struct, _Mapping]] = ...) -> None: ...
|
||||
|
||||
class FlowStep(_message.Message):
|
||||
__slots__ = ("flow_id", "user_input")
|
||||
FLOW_ID_FIELD_NUMBER: _ClassVar[int]
|
||||
USER_INPUT_FIELD_NUMBER: _ClassVar[int]
|
||||
flow_id: str
|
||||
user_input: _struct_pb2.Struct
|
||||
def __init__(self, flow_id: _Optional[str] = ..., user_input: _Optional[_Union[_struct_pb2.Struct, _Mapping]] = ...) -> None: ...
|
||||
|
||||
class FlowAbort(_message.Message):
|
||||
__slots__ = ("flow_id",)
|
||||
FLOW_ID_FIELD_NUMBER: _ClassVar[int]
|
||||
flow_id: str
|
||||
def __init__(self, flow_id: _Optional[str] = ...) -> None: ...
|
||||
|
||||
class FlowAbortResult(_message.Message):
|
||||
__slots__ = ()
|
||||
def __init__(self) -> None: ...
|
||||
|
||||
class FlowResult(_message.Message):
|
||||
__slots__ = ("type", "flow_id", "handler", "step_id", "reason", "title", "description", "last_step", "preview", "version", "minor_version", "data", "options", "errors", "description_placeholders", "context", "data_schema", "has_data_schema")
|
||||
TYPE_FIELD_NUMBER: _ClassVar[int]
|
||||
FLOW_ID_FIELD_NUMBER: _ClassVar[int]
|
||||
HANDLER_FIELD_NUMBER: _ClassVar[int]
|
||||
STEP_ID_FIELD_NUMBER: _ClassVar[int]
|
||||
REASON_FIELD_NUMBER: _ClassVar[int]
|
||||
TITLE_FIELD_NUMBER: _ClassVar[int]
|
||||
DESCRIPTION_FIELD_NUMBER: _ClassVar[int]
|
||||
LAST_STEP_FIELD_NUMBER: _ClassVar[int]
|
||||
PREVIEW_FIELD_NUMBER: _ClassVar[int]
|
||||
VERSION_FIELD_NUMBER: _ClassVar[int]
|
||||
MINOR_VERSION_FIELD_NUMBER: _ClassVar[int]
|
||||
DATA_FIELD_NUMBER: _ClassVar[int]
|
||||
OPTIONS_FIELD_NUMBER: _ClassVar[int]
|
||||
ERRORS_FIELD_NUMBER: _ClassVar[int]
|
||||
DESCRIPTION_PLACEHOLDERS_FIELD_NUMBER: _ClassVar[int]
|
||||
CONTEXT_FIELD_NUMBER: _ClassVar[int]
|
||||
DATA_SCHEMA_FIELD_NUMBER: _ClassVar[int]
|
||||
HAS_DATA_SCHEMA_FIELD_NUMBER: _ClassVar[int]
|
||||
type: str
|
||||
flow_id: str
|
||||
handler: str
|
||||
step_id: str
|
||||
reason: str
|
||||
title: str
|
||||
description: str
|
||||
last_step: bool
|
||||
preview: str
|
||||
version: int
|
||||
minor_version: int
|
||||
data: _struct_pb2.Struct
|
||||
options: _struct_pb2.Struct
|
||||
errors: _struct_pb2.Struct
|
||||
description_placeholders: _struct_pb2.Struct
|
||||
context: _struct_pb2.Struct
|
||||
data_schema: _struct_pb2.ListValue
|
||||
has_data_schema: bool
|
||||
def __init__(self, type: _Optional[str] = ..., flow_id: _Optional[str] = ..., handler: _Optional[str] = ..., step_id: _Optional[str] = ..., reason: _Optional[str] = ..., title: _Optional[str] = ..., description: _Optional[str] = ..., last_step: bool = ..., preview: _Optional[str] = ..., version: _Optional[int] = ..., minor_version: _Optional[int] = ..., data: _Optional[_Union[_struct_pb2.Struct, _Mapping]] = ..., options: _Optional[_Union[_struct_pb2.Struct, _Mapping]] = ..., errors: _Optional[_Union[_struct_pb2.Struct, _Mapping]] = ..., description_placeholders: _Optional[_Union[_struct_pb2.Struct, _Mapping]] = ..., context: _Optional[_Union[_struct_pb2.Struct, _Mapping]] = ..., data_schema: _Optional[_Union[_struct_pb2.ListValue, _Mapping]] = ..., has_data_schema: bool = ...) -> None: ...
|
||||
|
||||
class EntityInfo(_message.Message):
|
||||
__slots__ = ("description", "device_info")
|
||||
class Description(_message.Message):
|
||||
__slots__ = ("name", "icon", "entity_category", "device_class", "supported_features", "translation_key")
|
||||
NAME_FIELD_NUMBER: _ClassVar[int]
|
||||
ICON_FIELD_NUMBER: _ClassVar[int]
|
||||
ENTITY_CATEGORY_FIELD_NUMBER: _ClassVar[int]
|
||||
DEVICE_CLASS_FIELD_NUMBER: _ClassVar[int]
|
||||
SUPPORTED_FEATURES_FIELD_NUMBER: _ClassVar[int]
|
||||
TRANSLATION_KEY_FIELD_NUMBER: _ClassVar[int]
|
||||
name: str
|
||||
icon: str
|
||||
entity_category: str
|
||||
device_class: str
|
||||
supported_features: int
|
||||
translation_key: str
|
||||
def __init__(self, name: _Optional[str] = ..., icon: _Optional[str] = ..., entity_category: _Optional[str] = ..., device_class: _Optional[str] = ..., supported_features: _Optional[int] = ..., translation_key: _Optional[str] = ...) -> None: ...
|
||||
DESCRIPTION_FIELD_NUMBER: _ClassVar[int]
|
||||
DEVICE_INFO_FIELD_NUMBER: _ClassVar[int]
|
||||
description: EntityInfo.Description
|
||||
device_info: DeviceInfo
|
||||
def __init__(self, description: _Optional[_Union[EntityInfo.Description, _Mapping]] = ..., device_info: _Optional[_Union[DeviceInfo, _Mapping]] = ...) -> None: ...
|
||||
|
||||
class InitialState(_message.Message):
|
||||
__slots__ = ("state", "capabilities", "attributes")
|
||||
STATE_FIELD_NUMBER: _ClassVar[int]
|
||||
CAPABILITIES_FIELD_NUMBER: _ClassVar[int]
|
||||
ATTRIBUTES_FIELD_NUMBER: _ClassVar[int]
|
||||
state: str
|
||||
capabilities: _struct_pb2.Struct
|
||||
attributes: _struct_pb2.Struct
|
||||
def __init__(self, state: _Optional[str] = ..., capabilities: _Optional[_Union[_struct_pb2.Struct, _Mapping]] = ..., attributes: _Optional[_Union[_struct_pb2.Struct, _Mapping]] = ...) -> None: ...
|
||||
|
||||
class EntityDescription(_message.Message):
|
||||
__slots__ = ("entry_id", "domain", "sandbox_entity_id", "unique_id", "has_entity_name", "info", "initial")
|
||||
ENTRY_ID_FIELD_NUMBER: _ClassVar[int]
|
||||
DOMAIN_FIELD_NUMBER: _ClassVar[int]
|
||||
SANDBOX_ENTITY_ID_FIELD_NUMBER: _ClassVar[int]
|
||||
UNIQUE_ID_FIELD_NUMBER: _ClassVar[int]
|
||||
HAS_ENTITY_NAME_FIELD_NUMBER: _ClassVar[int]
|
||||
INFO_FIELD_NUMBER: _ClassVar[int]
|
||||
INITIAL_FIELD_NUMBER: _ClassVar[int]
|
||||
entry_id: str
|
||||
domain: str
|
||||
sandbox_entity_id: str
|
||||
unique_id: str
|
||||
has_entity_name: bool
|
||||
info: EntityInfo
|
||||
initial: InitialState
|
||||
def __init__(self, entry_id: _Optional[str] = ..., domain: _Optional[str] = ..., sandbox_entity_id: _Optional[str] = ..., unique_id: _Optional[str] = ..., has_entity_name: bool = ..., info: _Optional[_Union[EntityInfo, _Mapping]] = ..., initial: _Optional[_Union[InitialState, _Mapping]] = ...) -> None: ...
|
||||
|
||||
class RegisterEntityResult(_message.Message):
|
||||
__slots__ = ("entity_id",)
|
||||
ENTITY_ID_FIELD_NUMBER: _ClassVar[int]
|
||||
entity_id: str
|
||||
def __init__(self, entity_id: _Optional[str] = ...) -> None: ...
|
||||
|
||||
class UnregisterEntity(_message.Message):
|
||||
__slots__ = ("sandbox_entity_id",)
|
||||
SANDBOX_ENTITY_ID_FIELD_NUMBER: _ClassVar[int]
|
||||
sandbox_entity_id: str
|
||||
def __init__(self, sandbox_entity_id: _Optional[str] = ...) -> None: ...
|
||||
|
||||
class UnregisterEntityResult(_message.Message):
|
||||
__slots__ = ("ok",)
|
||||
OK_FIELD_NUMBER: _ClassVar[int]
|
||||
ok: bool
|
||||
def __init__(self, ok: bool = ...) -> None: ...
|
||||
|
||||
class StateChanged(_message.Message):
|
||||
__slots__ = ("sandbox_entity_id", "state", "attributes", "context_id")
|
||||
SANDBOX_ENTITY_ID_FIELD_NUMBER: _ClassVar[int]
|
||||
STATE_FIELD_NUMBER: _ClassVar[int]
|
||||
ATTRIBUTES_FIELD_NUMBER: _ClassVar[int]
|
||||
CONTEXT_ID_FIELD_NUMBER: _ClassVar[int]
|
||||
sandbox_entity_id: str
|
||||
state: str
|
||||
attributes: _struct_pb2.Struct
|
||||
context_id: str
|
||||
def __init__(self, sandbox_entity_id: _Optional[str] = ..., state: _Optional[str] = ..., attributes: _Optional[_Union[_struct_pb2.Struct, _Mapping]] = ..., context_id: _Optional[str] = ...) -> None: ...
|
||||
|
||||
class RegisterService(_message.Message):
|
||||
__slots__ = ("domain", "service", "supports_response", "schema")
|
||||
DOMAIN_FIELD_NUMBER: _ClassVar[int]
|
||||
SERVICE_FIELD_NUMBER: _ClassVar[int]
|
||||
SUPPORTS_RESPONSE_FIELD_NUMBER: _ClassVar[int]
|
||||
SCHEMA_FIELD_NUMBER: _ClassVar[int]
|
||||
domain: str
|
||||
service: str
|
||||
supports_response: str
|
||||
schema: _struct_pb2.ListValue
|
||||
def __init__(self, domain: _Optional[str] = ..., service: _Optional[str] = ..., supports_response: _Optional[str] = ..., schema: _Optional[_Union[_struct_pb2.ListValue, _Mapping]] = ...) -> None: ...
|
||||
|
||||
class RegisterServiceResult(_message.Message):
|
||||
__slots__ = ("ok", "installed")
|
||||
OK_FIELD_NUMBER: _ClassVar[int]
|
||||
INSTALLED_FIELD_NUMBER: _ClassVar[int]
|
||||
ok: bool
|
||||
installed: bool
|
||||
def __init__(self, ok: bool = ..., installed: bool = ...) -> None: ...
|
||||
|
||||
class UnregisterService(_message.Message):
|
||||
__slots__ = ("domain", "service")
|
||||
DOMAIN_FIELD_NUMBER: _ClassVar[int]
|
||||
SERVICE_FIELD_NUMBER: _ClassVar[int]
|
||||
domain: str
|
||||
service: str
|
||||
def __init__(self, domain: _Optional[str] = ..., service: _Optional[str] = ...) -> None: ...
|
||||
|
||||
class UnregisterServiceResult(_message.Message):
|
||||
__slots__ = ("ok", "removed")
|
||||
OK_FIELD_NUMBER: _ClassVar[int]
|
||||
REMOVED_FIELD_NUMBER: _ClassVar[int]
|
||||
ok: bool
|
||||
removed: bool
|
||||
def __init__(self, ok: bool = ..., removed: bool = ...) -> None: ...
|
||||
|
||||
class FireEvent(_message.Message):
|
||||
__slots__ = ("event_type", "event_data", "context_id")
|
||||
EVENT_TYPE_FIELD_NUMBER: _ClassVar[int]
|
||||
EVENT_DATA_FIELD_NUMBER: _ClassVar[int]
|
||||
CONTEXT_ID_FIELD_NUMBER: _ClassVar[int]
|
||||
event_type: str
|
||||
event_data: _struct_pb2.Struct
|
||||
context_id: str
|
||||
def __init__(self, event_type: _Optional[str] = ..., event_data: _Optional[_Union[_struct_pb2.Struct, _Mapping]] = ..., context_id: _Optional[str] = ...) -> None: ...
|
||||
|
||||
class StoreLoad(_message.Message):
|
||||
__slots__ = ("key",)
|
||||
KEY_FIELD_NUMBER: _ClassVar[int]
|
||||
key: str
|
||||
def __init__(self, key: _Optional[str] = ...) -> None: ...
|
||||
|
||||
class StoreLoadResult(_message.Message):
|
||||
__slots__ = ("data",)
|
||||
DATA_FIELD_NUMBER: _ClassVar[int]
|
||||
data: _struct_pb2.Struct
|
||||
def __init__(self, data: _Optional[_Union[_struct_pb2.Struct, _Mapping]] = ...) -> None: ...
|
||||
|
||||
class StoreSave(_message.Message):
|
||||
__slots__ = ("key", "data")
|
||||
KEY_FIELD_NUMBER: _ClassVar[int]
|
||||
DATA_FIELD_NUMBER: _ClassVar[int]
|
||||
key: str
|
||||
data: _struct_pb2.Struct
|
||||
def __init__(self, key: _Optional[str] = ..., data: _Optional[_Union[_struct_pb2.Struct, _Mapping]] = ...) -> None: ...
|
||||
|
||||
class StoreSaveResult(_message.Message):
|
||||
__slots__ = ("ok",)
|
||||
OK_FIELD_NUMBER: _ClassVar[int]
|
||||
ok: bool
|
||||
def __init__(self, ok: bool = ...) -> None: ...
|
||||
|
||||
class StoreRemove(_message.Message):
|
||||
__slots__ = ("key",)
|
||||
KEY_FIELD_NUMBER: _ClassVar[int]
|
||||
key: str
|
||||
def __init__(self, key: _Optional[str] = ...) -> None: ...
|
||||
|
||||
class StoreRemoveResult(_message.Message):
|
||||
__slots__ = ("ok",)
|
||||
OK_FIELD_NUMBER: _ClassVar[int]
|
||||
ok: bool
|
||||
def __init__(self, ok: bool = ...) -> None: ...
|
||||
@@ -0,0 +1,89 @@
|
||||
"""Sandbox-side approved-domains gate.
|
||||
|
||||
A single shared :class:`ApprovedDomains` instance tracks which domains
|
||||
the sandbox is allowed to own. It is the firewall the user asked for:
|
||||
the service mirror and event mirror consult it before pushing anything
|
||||
up to main, so a sandboxed integration can't silently impersonate (say)
|
||||
``notify`` or fire ``persistent_notification_event`` on main's bus.
|
||||
|
||||
Population:
|
||||
|
||||
* The :class:`hass_client.entry_runner.EntryRunner` adds an entry's
|
||||
domain when ``sandbox/entry_setup`` succeeds, and removes it on
|
||||
``entry_unload`` once the last entry for that domain unloads.
|
||||
* The :class:`hass_client.entity_bridge.EntityBridge` adds the entity's
|
||||
domain on each successful ``register_entity``. This covers the
|
||||
``light`` is approved because a sandboxed integration registers light
|
||||
entities clause from the plan.
|
||||
|
||||
Lookups:
|
||||
|
||||
* :meth:`approves` — exact domain match. Used by the service mirror.
|
||||
* :meth:`approves_event` — ``<domain>_*`` pattern match against any
|
||||
approved domain. Used by the event mirror.
|
||||
|
||||
Domain comparison is case-insensitive; everything is normalised to
|
||||
lowercase at insertion time so the lookups stay cheap.
|
||||
"""
|
||||
|
||||
from collections.abc import Iterable
|
||||
import logging
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ApprovedDomains:
|
||||
"""Mutable set of domains the sandbox runtime is allowed to own."""
|
||||
|
||||
def __init__(self, initial: Iterable[str] | None = None) -> None:
|
||||
"""Initialise the gate, optionally seeded with a starter set."""
|
||||
self._counts: dict[str, int] = {}
|
||||
if initial is not None:
|
||||
for domain in initial:
|
||||
self.add(domain)
|
||||
|
||||
def add(self, domain: str) -> None:
|
||||
"""Approve ``domain``; multiple ``add`` calls bump a refcount."""
|
||||
key = domain.lower()
|
||||
self._counts[key] = self._counts.get(key, 0) + 1
|
||||
|
||||
def remove(self, domain: str) -> None:
|
||||
"""Drop one ``add`` for ``domain``; harmless when over-removed."""
|
||||
key = domain.lower()
|
||||
count = self._counts.get(key, 0)
|
||||
if count <= 1:
|
||||
self._counts.pop(key, None)
|
||||
return
|
||||
self._counts[key] = count - 1
|
||||
|
||||
def approves(self, domain: str) -> bool:
|
||||
"""Return whether ``domain`` is in the approved set."""
|
||||
return domain.lower() in self._counts
|
||||
|
||||
def approves_event(self, event_type: str) -> bool:
|
||||
"""Return whether ``event_type`` matches ``<approved_domain>_*``.
|
||||
|
||||
Event names like ``zha_event`` and ``mqtt_message_received`` are
|
||||
matched by the longest approved-domain prefix followed by ``_``;
|
||||
this means a sandbox owning ``device_tracker`` correctly
|
||||
approves ``device_tracker_see`` (which a shorter prefix would
|
||||
miss).
|
||||
"""
|
||||
if "_" not in event_type:
|
||||
return False
|
||||
lower = event_type.lower()
|
||||
return any(lower.startswith(f"{domain}_") for domain in self._counts)
|
||||
|
||||
@property
|
||||
def domains(self) -> frozenset[str]:
|
||||
"""Snapshot of the current approved-domain set."""
|
||||
return frozenset(self._counts)
|
||||
|
||||
def __contains__(self, domain: object) -> bool:
|
||||
"""Allow ``"light" in approved`` style membership tests."""
|
||||
if not isinstance(domain, str):
|
||||
return False
|
||||
return self.approves(domain)
|
||||
|
||||
|
||||
__all__ = ["ApprovedDomains"]
|
||||
@@ -0,0 +1,566 @@
|
||||
"""Sandbox-side mirror of ``homeassistant.components.sandbox.channel``.
|
||||
|
||||
Kept as a stand-alone module to honour the project boundary: the HA Core
|
||||
integration must not import from ``hass_client`` at integration-load time,
|
||||
and ``hass_client`` does not pull from ``homeassistant.components.*``. The
|
||||
two files speak the same wire format — see the docstring on the HA side
|
||||
for the layering (Channel / Codec / Transport) and the :class:`Frame`
|
||||
shape.
|
||||
|
||||
Inbound calls and pushes are dispatched in their own tasks so a handler
|
||||
that itself issues :meth:`Channel.call` does not block the reader — the
|
||||
reply for the nested call has to come back through the same reader. A
|
||||
bounded semaphore caps how many handlers can run concurrently; the N+1th
|
||||
inbound message queues at the semaphore (not at the reader) until a slot
|
||||
frees up.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Awaitable, Callable, Coroutine
|
||||
import contextlib
|
||||
from dataclasses import dataclass, field
|
||||
from enum import StrEnum
|
||||
import json
|
||||
import logging
|
||||
import struct
|
||||
from typing import Any, Protocol
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
Handler = Callable[[Any], Awaitable[Any]]
|
||||
|
||||
DEFAULT_MAX_INFLIGHT = 16
|
||||
|
||||
# Hard cap on a single frame's body. A length prefix larger than this aborts
|
||||
# the channel rather than letting a compromised peer allocate the process to
|
||||
# death.
|
||||
MAX_FRAME_SIZE = 16 * 1024 * 1024
|
||||
|
||||
_LENGTH_PREFIX = struct.Struct(">I")
|
||||
|
||||
|
||||
def _serialize_invalid(err: vol.Invalid) -> dict[str, Any]:
|
||||
"""Capture a ``vol.Invalid``'s message + path so main can rebuild it.
|
||||
|
||||
Path parts may be ``vol.Marker``s or other non-JSON objects, so each
|
||||
part is stringified.
|
||||
"""
|
||||
return {
|
||||
"kind": "invalid",
|
||||
"msg": err.error_message,
|
||||
"path": [str(part) for part in (err.path or [])],
|
||||
}
|
||||
|
||||
|
||||
def error_data_for(err: BaseException) -> dict[str, Any] | None:
|
||||
"""Structured payload that lets main reconstruct a voluptuous error.
|
||||
|
||||
``MultipleInvalid`` is a subclass of ``Invalid``, so it is checked first.
|
||||
Returns ``None`` for anything that is not a voluptuous error.
|
||||
"""
|
||||
if isinstance(err, vol.MultipleInvalid):
|
||||
return {
|
||||
"kind": "multiple",
|
||||
"errors": [_serialize_invalid(child) for child in err.errors],
|
||||
}
|
||||
if isinstance(err, vol.Invalid):
|
||||
return _serialize_invalid(err)
|
||||
return None
|
||||
|
||||
|
||||
class FrameKind(StrEnum):
|
||||
"""Which of the three wire shapes a :class:`Frame` carries."""
|
||||
|
||||
CALL = "call"
|
||||
PUSH = "push"
|
||||
RESPONSE = "response"
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class Frame:
|
||||
"""Transport/codec-neutral representation of one wire message."""
|
||||
|
||||
kind: FrameKind
|
||||
id: int = 0
|
||||
type: str = ""
|
||||
payload: Any = None
|
||||
ok: bool = False
|
||||
result: Any = None
|
||||
error: str | None = None
|
||||
error_type: str | None = None
|
||||
error_data: dict[str, Any] | None = field(default=None)
|
||||
|
||||
@classmethod
|
||||
def call(cls, call_id: int, msg_type: str, payload: Any) -> Frame:
|
||||
"""Build a request frame that expects a reply."""
|
||||
return cls(FrameKind.CALL, id=call_id, type=msg_type, payload=payload)
|
||||
|
||||
@classmethod
|
||||
def push(cls, msg_type: str, payload: Any) -> Frame:
|
||||
"""Build a one-way push frame."""
|
||||
return cls(FrameKind.PUSH, id=0, type=msg_type, payload=payload)
|
||||
|
||||
@classmethod
|
||||
def ok_response(cls, call_id: int, result: Any, msg_type: str = "") -> Frame:
|
||||
"""Build a success response frame.
|
||||
|
||||
``msg_type`` is carried so a stateless codec (the protobuf one) can
|
||||
look up the result message class on encode + decode.
|
||||
"""
|
||||
return cls(
|
||||
FrameKind.RESPONSE, id=call_id, type=msg_type, ok=True, result=result
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def error_response(
|
||||
cls,
|
||||
call_id: int,
|
||||
error: str,
|
||||
error_type: str | None,
|
||||
error_data: dict[str, Any] | None = None,
|
||||
msg_type: str = "",
|
||||
) -> Frame:
|
||||
"""Build a failure response frame."""
|
||||
return cls(
|
||||
FrameKind.RESPONSE,
|
||||
id=call_id,
|
||||
type=msg_type,
|
||||
ok=False,
|
||||
error=error,
|
||||
error_type=error_type,
|
||||
error_data=error_data,
|
||||
)
|
||||
|
||||
|
||||
class Codec(Protocol):
|
||||
"""Serialises a :class:`Frame` to bytes and back."""
|
||||
|
||||
def encode(self, frame: Frame) -> bytes:
|
||||
"""Return the wire bytes for ``frame``."""
|
||||
|
||||
def decode(self, data: bytes) -> Frame:
|
||||
"""Rebuild a :class:`Frame` from wire bytes."""
|
||||
|
||||
|
||||
class JsonCodec:
|
||||
"""One-JSON-object-per-frame codec.
|
||||
|
||||
The registry-free test/debug wire: it passes frame payloads through as
|
||||
plain JSON (no ``type``-to-proto lookup), so the concurrency-critical
|
||||
channel core can be exercised with synthetic message types and arbitrary
|
||||
dict/int payloads. Production rides :class:`ProtobufCodec`; this stays
|
||||
for the channel-core tests only.
|
||||
"""
|
||||
|
||||
def encode(self, frame: Frame) -> bytes:
|
||||
"""Encode a frame to a compact JSON object."""
|
||||
message: dict[str, Any]
|
||||
if frame.kind is FrameKind.CALL:
|
||||
message = {"id": frame.id, "type": frame.type, "payload": frame.payload}
|
||||
elif frame.kind is FrameKind.PUSH:
|
||||
message = {"type": frame.type, "payload": frame.payload}
|
||||
elif frame.ok:
|
||||
message = {"id": frame.id, "ok": True, "result": frame.result}
|
||||
else:
|
||||
message = {
|
||||
"id": frame.id,
|
||||
"ok": False,
|
||||
"error": frame.error,
|
||||
"error_type": frame.error_type,
|
||||
}
|
||||
if frame.error_data is not None:
|
||||
message["error_data"] = frame.error_data
|
||||
return json.dumps(message, separators=(",", ":")).encode("utf-8")
|
||||
|
||||
def decode(self, data: bytes) -> Frame:
|
||||
"""Decode a JSON object into a frame, inferring the kind from keys."""
|
||||
message = json.loads(data)
|
||||
has_id = "id" in message
|
||||
has_type = "type" in message
|
||||
if has_id and not has_type:
|
||||
# Response to a call we sent out.
|
||||
if message.get("ok"):
|
||||
return Frame.ok_response(message["id"], message.get("result"))
|
||||
return Frame.error_response(
|
||||
message["id"],
|
||||
message.get("error", "unknown error"),
|
||||
message.get("error_type"),
|
||||
message.get("error_data"),
|
||||
)
|
||||
if not has_id:
|
||||
return Frame.push(message.get("type", ""), message.get("payload"))
|
||||
return Frame.call(message["id"], message["type"], message.get("payload"))
|
||||
|
||||
|
||||
class Transport(Protocol):
|
||||
"""Moves whole frame blobs over some byte channel."""
|
||||
|
||||
async def read_frame(self) -> bytes | None:
|
||||
"""Return the next frame's bytes, or ``None`` at end-of-stream."""
|
||||
|
||||
async def write_frame(self, data: bytes) -> None:
|
||||
"""Write one frame's bytes."""
|
||||
|
||||
def close(self) -> None:
|
||||
"""Begin closing the underlying channel."""
|
||||
|
||||
async def wait_closed(self) -> None:
|
||||
"""Wait for the underlying channel to finish closing."""
|
||||
|
||||
|
||||
class FrameTooLargeError(Exception):
|
||||
"""A peer announced a frame larger than :data:`MAX_FRAME_SIZE`."""
|
||||
|
||||
|
||||
class StreamTransport:
|
||||
"""Length-prefixed framing over a reader/writer pair.
|
||||
|
||||
Each frame is a 4-byte big-endian length followed by exactly that many
|
||||
body bytes. Used for stdio and unix-socket connections.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
reader: asyncio.StreamReader,
|
||||
writer: asyncio.StreamWriter,
|
||||
) -> None:
|
||||
"""Wrap a reader/writer pair with length-prefixed framing."""
|
||||
self._reader = reader
|
||||
self._writer = writer
|
||||
|
||||
async def read_frame(self) -> bytes | None:
|
||||
"""Read one length-prefixed frame, or ``None`` at clean EOF."""
|
||||
try:
|
||||
header = await self._reader.readexactly(_LENGTH_PREFIX.size)
|
||||
except asyncio.IncompleteReadError:
|
||||
return None
|
||||
(length,) = _LENGTH_PREFIX.unpack(header)
|
||||
if length > MAX_FRAME_SIZE:
|
||||
raise FrameTooLargeError(
|
||||
f"frame length {length} exceeds cap {MAX_FRAME_SIZE}"
|
||||
)
|
||||
try:
|
||||
return await self._reader.readexactly(length)
|
||||
except asyncio.IncompleteReadError:
|
||||
return None
|
||||
|
||||
async def write_frame(self, data: bytes) -> None:
|
||||
"""Write one length-prefixed frame and flush it."""
|
||||
self._writer.write(_LENGTH_PREFIX.pack(len(data)) + data)
|
||||
await self._writer.drain()
|
||||
|
||||
def close(self) -> None:
|
||||
"""Close the writer side of the connection."""
|
||||
self._writer.close()
|
||||
|
||||
async def wait_closed(self) -> None:
|
||||
"""Wait for the writer to finish closing."""
|
||||
await self._writer.wait_closed()
|
||||
|
||||
|
||||
class ChannelClosedError(Exception):
|
||||
"""Raised when an operation is attempted on a closed channel."""
|
||||
|
||||
|
||||
class ChannelRemoteError(Exception):
|
||||
"""Raised when the remote side returns an error response."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
error: str,
|
||||
error_type: str | None = None,
|
||||
error_data: dict[str, Any] | None = None,
|
||||
) -> None:
|
||||
"""Initialise with the remote error message and exception class name.
|
||||
|
||||
``error_data`` carries a structured payload (set for voluptuous
|
||||
errors) so the receiver can rebuild the original exception shape.
|
||||
"""
|
||||
super().__init__(error)
|
||||
self.error = error
|
||||
self.error_type = error_type
|
||||
self.error_data = error_data
|
||||
|
||||
|
||||
class Channel:
|
||||
"""One bidirectional request/response channel over a transport + codec."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
reader: asyncio.StreamReader | None = None,
|
||||
writer: asyncio.StreamWriter | None = None,
|
||||
*,
|
||||
transport: Transport | None = None,
|
||||
codec: Codec | None = None,
|
||||
name: str = "channel",
|
||||
max_inflight: int = DEFAULT_MAX_INFLIGHT,
|
||||
) -> None:
|
||||
"""Wrap a reader/writer pair (or a transport) into a channel.
|
||||
|
||||
The common case passes a ``reader``/``writer`` pair, framed with
|
||||
:class:`StreamTransport` (length-prefixed). To run over a non-stream
|
||||
transport (e.g. websockets), pass ``transport=`` instead — see
|
||||
:meth:`from_transport`.
|
||||
|
||||
``codec`` defaults to :class:`JsonCodec`. ``max_inflight`` bounds how
|
||||
many handler tasks may run at once. Once the cap is reached, the read
|
||||
loop keeps draining the wire but newly-spawned handlers wait on the
|
||||
semaphore until a slot frees up — so a misbehaving integration can't
|
||||
starve the reader by fanning out unbounded inbound work.
|
||||
"""
|
||||
if transport is None:
|
||||
if reader is None or writer is None:
|
||||
raise TypeError("Channel needs a reader/writer pair or a transport")
|
||||
transport = StreamTransport(reader, writer)
|
||||
self._transport: Transport = transport
|
||||
self._codec: Codec = codec if codec is not None else JsonCodec()
|
||||
self._name = name
|
||||
self._next_id = 1
|
||||
self._pending: dict[int, asyncio.Future[Any]] = {}
|
||||
self._handlers: dict[str, Handler] = {}
|
||||
self._reader_task: asyncio.Task[None] | None = None
|
||||
self._closed: bool = False
|
||||
self._write_lock = asyncio.Lock()
|
||||
self._inflight: set[asyncio.Task[None]] = set()
|
||||
self._inflight_sem = asyncio.Semaphore(max_inflight)
|
||||
|
||||
@classmethod
|
||||
def from_transport(
|
||||
cls,
|
||||
transport: Transport,
|
||||
*,
|
||||
codec: Codec | None = None,
|
||||
name: str = "channel",
|
||||
max_inflight: int = DEFAULT_MAX_INFLIGHT,
|
||||
) -> Channel:
|
||||
"""Build a channel over an arbitrary :class:`Transport`.
|
||||
|
||||
This is the seam a future ``WebSocketTransport`` drops into — the
|
||||
dispatch core is identical regardless of how frames reach the wire.
|
||||
"""
|
||||
return cls(
|
||||
transport=transport, codec=codec, name=name, max_inflight=max_inflight
|
||||
)
|
||||
|
||||
@property
|
||||
def closed(self) -> bool:
|
||||
"""Return True once the channel has been closed."""
|
||||
return self._closed
|
||||
|
||||
def register(self, msg_type: str, handler: Handler) -> None:
|
||||
"""Register an async handler for inbound calls of this type."""
|
||||
self._handlers[msg_type] = handler
|
||||
|
||||
def start(self) -> None:
|
||||
"""Begin reading messages off the wire."""
|
||||
if self._reader_task is not None:
|
||||
return
|
||||
self._reader_task = asyncio.create_task(
|
||||
self._read_loop(), name=f"sandbox[{self._name}]:reader"
|
||||
)
|
||||
|
||||
async def call(
|
||||
self, msg_type: str, payload: Any = None, *, timeout: float | None = None
|
||||
) -> Any:
|
||||
"""Send a request and await its response."""
|
||||
if self._closed:
|
||||
raise ChannelClosedError(f"channel {self._name!r} is closed")
|
||||
call_id = self._next_id
|
||||
self._next_id += 1
|
||||
future: asyncio.Future[Any] = asyncio.get_running_loop().create_future()
|
||||
self._pending[call_id] = future
|
||||
try:
|
||||
await self._write(Frame.call(call_id, msg_type, payload))
|
||||
if timeout is None:
|
||||
return await future
|
||||
return await asyncio.wait_for(future, timeout=timeout)
|
||||
finally:
|
||||
self._pending.pop(call_id, None)
|
||||
|
||||
async def push(self, msg_type: str, payload: Any = None) -> None:
|
||||
"""Send a one-way push message; the remote does not reply."""
|
||||
if self._closed:
|
||||
raise ChannelClosedError(f"channel {self._name!r} is closed")
|
||||
await self._write(Frame.push(msg_type, payload))
|
||||
|
||||
async def close(self) -> None:
|
||||
"""Close the channel and cancel any in-flight calls."""
|
||||
if self._closed:
|
||||
return
|
||||
self._closed = True
|
||||
for future in self._pending.values():
|
||||
if not future.done():
|
||||
future.set_exception(
|
||||
ChannelClosedError(f"channel {self._name!r} is closed")
|
||||
)
|
||||
self._pending.clear()
|
||||
inflight = list(self._inflight)
|
||||
for task in inflight:
|
||||
task.cancel()
|
||||
with contextlib.suppress(Exception):
|
||||
self._transport.close()
|
||||
with contextlib.suppress(asyncio.CancelledError):
|
||||
await self._transport.wait_closed()
|
||||
if self._reader_task is not None:
|
||||
self._reader_task.cancel()
|
||||
with contextlib.suppress(asyncio.CancelledError, Exception):
|
||||
await self._reader_task
|
||||
self._reader_task = None
|
||||
if inflight:
|
||||
await asyncio.gather(*inflight, return_exceptions=True)
|
||||
|
||||
async def _write(self, frame: Frame) -> None:
|
||||
data = self._codec.encode(frame)
|
||||
async with self._write_lock:
|
||||
await self._transport.write_frame(data)
|
||||
|
||||
async def _read_loop(self) -> None:
|
||||
try:
|
||||
while True:
|
||||
try:
|
||||
data = await self._transport.read_frame()
|
||||
except FrameTooLargeError as err:
|
||||
_LOGGER.error("channel %s: %s; aborting channel", self._name, err)
|
||||
return
|
||||
if data is None:
|
||||
return
|
||||
try:
|
||||
frame = self._codec.decode(data)
|
||||
except Exception: # noqa: BLE001
|
||||
_LOGGER.warning(
|
||||
"channel %s: dropping undecodable frame (%d bytes)",
|
||||
self._name,
|
||||
len(data),
|
||||
)
|
||||
continue
|
||||
self._dispatch(frame)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception:
|
||||
_LOGGER.exception("channel %s: read loop crashed", self._name)
|
||||
finally:
|
||||
if not self._closed:
|
||||
self._closed = True
|
||||
for future in self._pending.values():
|
||||
if not future.done():
|
||||
future.set_exception(
|
||||
ChannelClosedError(f"channel {self._name!r} stream ended")
|
||||
)
|
||||
self._pending.clear()
|
||||
for task in list(self._inflight):
|
||||
task.cancel()
|
||||
|
||||
def _dispatch(self, frame: Frame) -> None:
|
||||
"""Route an inbound frame; non-blocking — handlers run in tasks."""
|
||||
if frame.kind is FrameKind.RESPONSE:
|
||||
future = self._pending.get(frame.id)
|
||||
if future is None or future.done():
|
||||
return
|
||||
if frame.ok:
|
||||
future.set_result(frame.result)
|
||||
else:
|
||||
future.set_exception(
|
||||
ChannelRemoteError(
|
||||
frame.error or "unknown error",
|
||||
frame.error_type,
|
||||
frame.error_data,
|
||||
)
|
||||
)
|
||||
return
|
||||
|
||||
handler = self._handlers.get(frame.type)
|
||||
|
||||
if frame.kind is FrameKind.PUSH:
|
||||
if handler is not None:
|
||||
self._spawn_handler(
|
||||
self._run_push_handler(frame.type, handler, frame.payload)
|
||||
)
|
||||
return
|
||||
|
||||
if handler is None:
|
||||
self._spawn_handler(
|
||||
self._write(
|
||||
Frame.error_response(
|
||||
frame.id,
|
||||
f"no handler for {frame.type!r}",
|
||||
"ChannelUnknownType",
|
||||
msg_type=frame.type,
|
||||
)
|
||||
)
|
||||
)
|
||||
return
|
||||
|
||||
self._spawn_handler(
|
||||
self._run_call_handler(frame.id, frame.type, handler, frame.payload)
|
||||
)
|
||||
|
||||
def _spawn_handler(self, coro: Coroutine[Any, Any, Any]) -> None:
|
||||
"""Start a handler task and track it for cancellation on close."""
|
||||
task = asyncio.create_task(coro, name=f"sandbox[{self._name}]:dispatch")
|
||||
self._inflight.add(task)
|
||||
task.add_done_callback(self._inflight.discard)
|
||||
|
||||
async def _run_push_handler(
|
||||
self, msg_type: str, handler: Handler, payload: Any
|
||||
) -> None:
|
||||
"""Run a push handler under the inflight cap; swallow exceptions."""
|
||||
async with self._inflight_sem:
|
||||
try:
|
||||
await handler(payload)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception:
|
||||
_LOGGER.exception(
|
||||
"channel %s: push handler for %s raised",
|
||||
self._name,
|
||||
msg_type,
|
||||
)
|
||||
|
||||
async def _run_call_handler(
|
||||
self,
|
||||
call_id: int,
|
||||
msg_type: str,
|
||||
handler: Handler,
|
||||
payload: Any,
|
||||
) -> None:
|
||||
"""Run a call handler under the inflight cap and write its reply."""
|
||||
async with self._inflight_sem:
|
||||
try:
|
||||
result = await handler(payload)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as err: # noqa: BLE001
|
||||
if self._closed:
|
||||
return
|
||||
frame = Frame.error_response(
|
||||
call_id,
|
||||
str(err) or err.__class__.__name__,
|
||||
err.__class__.__name__,
|
||||
error_data_for(err),
|
||||
msg_type=msg_type,
|
||||
)
|
||||
with contextlib.suppress(Exception):
|
||||
await self._write(frame)
|
||||
return
|
||||
if self._closed:
|
||||
return
|
||||
with contextlib.suppress(Exception):
|
||||
await self._write(Frame.ok_response(call_id, result, msg_type))
|
||||
|
||||
|
||||
__all__ = [
|
||||
"Channel",
|
||||
"ChannelClosedError",
|
||||
"ChannelRemoteError",
|
||||
"Codec",
|
||||
"Frame",
|
||||
"FrameKind",
|
||||
"FrameTooLargeError",
|
||||
"Handler",
|
||||
"JsonCodec",
|
||||
"StreamTransport",
|
||||
"Transport",
|
||||
"error_data_for",
|
||||
]
|
||||
@@ -0,0 +1,134 @@
|
||||
"""Protobuf :class:`~.channel.Codec` — the production wire.
|
||||
|
||||
Serialises a :class:`~.channel.Frame` to the protobuf ``Frame`` envelope and
|
||||
back. The envelope carries ``type`` on responses too, so this stateless codec
|
||||
can look up the result message class from ``frame.type`` on both encode and
|
||||
decode — the dispatch core never has to know about proto types (the registry
|
||||
lives here, not on :meth:`Channel.register`).
|
||||
|
||||
Mirrored verbatim across the no-cross-import boundary (the same file lives at
|
||||
``hass_client.codec_protobuf``); the relative imports resolve to each side's
|
||||
own :mod:`messages` + ``_proto`` gencode.
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from google.protobuf.message import Message
|
||||
|
||||
from ._proto import sandbox_pb2 as pb
|
||||
from .channel import Frame, FrameKind
|
||||
from .messages import REGISTRY
|
||||
|
||||
Registry = dict[str, tuple[type[Message], type[Message] | None]]
|
||||
|
||||
|
||||
class ProtobufCodec:
|
||||
"""Encode/decode :class:`Frame` objects as protobuf ``Frame`` envelopes."""
|
||||
|
||||
def __init__(self, registry: Registry | None = None) -> None:
|
||||
"""Build the codec over a ``type → (request_cls, result_cls)`` map."""
|
||||
self._registry = registry if registry is not None else REGISTRY
|
||||
|
||||
def _classes(
|
||||
self, msg_type: str
|
||||
) -> tuple[type[Message] | None, type[Message] | None]:
|
||||
return self._registry.get(msg_type, (None, None))
|
||||
|
||||
def encode(self, frame: Frame) -> bytes:
|
||||
"""Serialise a frame to the protobuf ``Frame`` envelope bytes."""
|
||||
envelope = pb.Frame(id=frame.id, type=frame.type)
|
||||
if frame.kind is FrameKind.RESPONSE:
|
||||
response = envelope.response
|
||||
response.ok = frame.ok
|
||||
if frame.ok:
|
||||
_, result_cls = self._classes(frame.type)
|
||||
response.result = _serialize_body(frame.result, result_cls)
|
||||
else:
|
||||
_fill_error(response.error, frame)
|
||||
else:
|
||||
request_cls, _ = self._classes(frame.type)
|
||||
envelope.request = _serialize_body(frame.payload, request_cls)
|
||||
return envelope.SerializeToString()
|
||||
|
||||
def decode(self, data: bytes) -> Frame:
|
||||
"""Rebuild a frame from protobuf ``Frame`` envelope bytes."""
|
||||
envelope = pb.Frame.FromString(data)
|
||||
msg_type = envelope.type
|
||||
body = envelope.WhichOneof("body")
|
||||
if body == "response":
|
||||
response = envelope.response
|
||||
if response.ok:
|
||||
_, result_cls = self._classes(msg_type)
|
||||
result = _parse_body(response.result, result_cls)
|
||||
return Frame.ok_response(envelope.id, result, msg_type)
|
||||
error, error_type, error_data = _read_error(response.error)
|
||||
return Frame.error_response(
|
||||
envelope.id, error, error_type, error_data, msg_type
|
||||
)
|
||||
request_cls, _ = self._classes(msg_type)
|
||||
payload = _parse_body(envelope.request, request_cls)
|
||||
if envelope.id == 0:
|
||||
return Frame.push(msg_type, payload)
|
||||
return Frame.call(envelope.id, msg_type, payload)
|
||||
|
||||
|
||||
def _serialize_body(body: Any, cls: type[Message] | None) -> bytes:
|
||||
"""Serialise a proto-message body; ``None`` becomes an empty message."""
|
||||
if body is None:
|
||||
return cls().SerializeToString() if cls is not None else b""
|
||||
if isinstance(body, Message):
|
||||
return body.SerializeToString()
|
||||
raise TypeError(
|
||||
f"ProtobufCodec expected a proto message body, got {type(body).__name__}"
|
||||
)
|
||||
|
||||
|
||||
def _parse_body(raw: bytes, cls: type[Message] | None) -> Any:
|
||||
"""Deserialise a body into ``cls``; an unregistered type decodes to None."""
|
||||
if cls is None:
|
||||
return None
|
||||
return cls.FromString(raw)
|
||||
|
||||
|
||||
def _fill_error(error: pb.Error, frame: Frame) -> None:
|
||||
"""Populate the proto ``Error`` from a failure frame.
|
||||
|
||||
Carries fidelity #7's structured voluptuous data: the ``multiple`` flag
|
||||
distinguishes a ``MultipleInvalid`` from a single ``Invalid`` so the peer
|
||||
rebuilds the right exception.
|
||||
"""
|
||||
error.message = frame.error or ""
|
||||
error.type = frame.error_type or ""
|
||||
data = frame.error_data
|
||||
if not data:
|
||||
return
|
||||
if data.get("kind") == "multiple":
|
||||
error.multiple = True
|
||||
for child in data.get("errors", []):
|
||||
error.invalid.add(message=child.get("msg", ""), path=child.get("path", []))
|
||||
elif data.get("kind") == "invalid":
|
||||
error.invalid.add(message=data.get("msg", ""), path=data.get("path", []))
|
||||
|
||||
|
||||
def _read_error(error: pb.Error) -> tuple[str, str | None, dict[str, Any] | None]:
|
||||
"""Rebuild ``(message, type, error_data)`` from the proto ``Error``."""
|
||||
error_data: dict[str, Any] | None = None
|
||||
if error.multiple:
|
||||
error_data = {
|
||||
"kind": "multiple",
|
||||
"errors": [
|
||||
{"kind": "invalid", "msg": item.message, "path": list(item.path)}
|
||||
for item in error.invalid
|
||||
],
|
||||
}
|
||||
elif len(error.invalid) == 1:
|
||||
item = error.invalid[0]
|
||||
error_data = {
|
||||
"kind": "invalid",
|
||||
"msg": item.message,
|
||||
"path": list(item.path),
|
||||
}
|
||||
return error.message, (error.type or None), error_data
|
||||
|
||||
|
||||
__all__ = ["ProtobufCodec"]
|
||||
@@ -0,0 +1,426 @@
|
||||
"""Sandbox-side entity bridge — pushes registrations + state changes to main.
|
||||
|
||||
The bridge listens for ``EVENT_STATE_CHANGED`` on the sandbox-private
|
||||
:class:`HomeAssistant`. First-time appearances (``old_state is None``)
|
||||
trigger a ``sandbox/register_entity`` call up to main; subsequent
|
||||
changes become ``sandbox/state_changed`` pushes.
|
||||
|
||||
We deliberately tag every event with the sandbox-side ``entry_id`` of
|
||||
the owning :class:`EntityPlatform` so main can route each proxy entity
|
||||
to the right :class:`ConfigEntry`. Entities that aren't owned by a
|
||||
sandbox-managed entry (rare — typically helper-domain entities the
|
||||
integration creates outside its own entry) are skipped with a debug log.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Iterable
|
||||
import json
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.const import EVENT_STATE_CHANGED
|
||||
from homeassistant.core import Event, EventStateChangedData, HomeAssistant, callback
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
from homeassistant.helpers.device_registry import EVENT_DEVICE_REGISTRY_UPDATED
|
||||
from homeassistant.helpers.entity import Entity
|
||||
from homeassistant.helpers.entity_component import DATA_INSTANCES
|
||||
from homeassistant.helpers.entity_registry import EVENT_ENTITY_REGISTRY_UPDATED
|
||||
|
||||
from ._proto import sandbox_pb2 as pb
|
||||
from .approved_domains import ApprovedDomains
|
||||
from .channel import Channel
|
||||
from .messages import make_entity_description
|
||||
from .protocol import MSG_REGISTER_ENTITY, MSG_STATE_CHANGED, MSG_UNREGISTER_ENTITY
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class EntityBridge:
|
||||
"""Forwards sandbox-side entity lifecycle events up to main.
|
||||
|
||||
One instance per sandbox process (channel). It does not own the
|
||||
integration code — it just observes ``EVENT_STATE_CHANGED`` and
|
||||
inspects the matching ``EntityComponent`` to extract the rich shape
|
||||
that a proxy entity on main needs (capability dict, supported
|
||||
features, entity category, …).
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self, hass: HomeAssistant, approved: ApprovedDomains | None = None
|
||||
) -> None:
|
||||
"""Initialise with the sandbox-private HA instance.
|
||||
|
||||
``approved`` is shared with the service + event mirrors so the
|
||||
entity's domain becomes approved as soon as the first entity of
|
||||
that domain registers (the plan's *light is approved if a
|
||||
sandboxed integration registers light entities* clause).
|
||||
"""
|
||||
self.hass = hass
|
||||
self.approved = approved if approved is not None else ApprovedDomains()
|
||||
self._channel: Channel | None = None
|
||||
self._registered: set[str] = set()
|
||||
self._pending: set[str] = set()
|
||||
# Hash of the last description (registry-shaped fields only, no
|
||||
# state) sent per entity, so a registry-update resend that mirrors
|
||||
# nothing we actually carry is a no-op instead of an event storm.
|
||||
self._last_hash: dict[str, str] = {}
|
||||
self._unsub_state: Any = None
|
||||
self._unsub_entity_registry: Any = None
|
||||
self._unsub_device_registry: Any = None
|
||||
|
||||
def register(self, channel: Channel) -> None:
|
||||
"""Subscribe to state + registry events and capture the channel."""
|
||||
self._channel = channel
|
||||
self._unsub_state = self.hass.bus.async_listen(
|
||||
EVENT_STATE_CHANGED, self._on_state_changed
|
||||
)
|
||||
# Post-registration changes to name / icon / category / device link
|
||||
# arrive as registry-updated events; re-send the registration as an
|
||||
# upsert so main's proxy keeps current.
|
||||
self._unsub_entity_registry = self.hass.bus.async_listen(
|
||||
EVENT_ENTITY_REGISTRY_UPDATED, self._on_entity_registry_updated
|
||||
)
|
||||
self._unsub_device_registry = self.hass.bus.async_listen(
|
||||
EVENT_DEVICE_REGISTRY_UPDATED, self._on_device_registry_updated
|
||||
)
|
||||
|
||||
async def async_stop(self) -> None:
|
||||
"""Detach the state + registry listeners."""
|
||||
for attr in (
|
||||
"_unsub_state",
|
||||
"_unsub_entity_registry",
|
||||
"_unsub_device_registry",
|
||||
):
|
||||
unsub = getattr(self, attr)
|
||||
if unsub is not None:
|
||||
unsub()
|
||||
setattr(self, attr, None)
|
||||
|
||||
@callback
|
||||
def _on_state_changed(self, event: Event[EventStateChangedData]) -> None:
|
||||
if self._channel is None or self._channel.closed:
|
||||
return
|
||||
entity_id: str = event.data["entity_id"]
|
||||
new_state = event.data.get("new_state")
|
||||
old_state = event.data.get("old_state")
|
||||
|
||||
if new_state is None:
|
||||
if entity_id in self._registered:
|
||||
self._registered.discard(entity_id)
|
||||
asyncio.create_task( # noqa: RUF006
|
||||
self._push_unregister(entity_id),
|
||||
name=f"sandbox:unregister:{entity_id}",
|
||||
)
|
||||
return
|
||||
|
||||
if entity_id in self._registered:
|
||||
asyncio.create_task( # noqa: RUF006
|
||||
self._push_state(entity_id, new_state),
|
||||
name=f"sandbox:state:{entity_id}",
|
||||
)
|
||||
return
|
||||
|
||||
if old_state is not None and entity_id not in self._pending:
|
||||
# Existed before we started watching; register it now anyway.
|
||||
pass
|
||||
|
||||
if entity_id in self._pending:
|
||||
return
|
||||
self._pending.add(entity_id)
|
||||
asyncio.create_task( # noqa: RUF006
|
||||
self._register_and_push(entity_id, new_state),
|
||||
name=f"sandbox:register:{entity_id}",
|
||||
)
|
||||
|
||||
@callback
|
||||
def _on_entity_registry_updated(self, event: Event[Any]) -> None:
|
||||
if self._channel is None or self._channel.closed:
|
||||
return
|
||||
if event.data.get("action") != "update":
|
||||
return
|
||||
entity_id: str = event.data["entity_id"]
|
||||
if entity_id not in self._registered:
|
||||
return
|
||||
asyncio.create_task( # noqa: RUF006
|
||||
self._resend(entity_id),
|
||||
name=f"sandbox:resend:{entity_id}",
|
||||
)
|
||||
|
||||
@callback
|
||||
def _on_device_registry_updated(self, event: Event[Any]) -> None:
|
||||
if self._channel is None or self._channel.closed:
|
||||
return
|
||||
if event.data.get("action") != "update":
|
||||
return
|
||||
device_id: str = event.data["device_id"]
|
||||
ent_reg = er.async_get(self.hass)
|
||||
# Re-send every tracked entity linked to the changed device so the
|
||||
# refreshed device_info reaches main.
|
||||
for entity_id in list(self._registered):
|
||||
registry_entry = ent_reg.async_get(entity_id)
|
||||
if registry_entry is None or registry_entry.device_id != device_id:
|
||||
continue
|
||||
asyncio.create_task( # noqa: RUF006
|
||||
self._resend(entity_id),
|
||||
name=f"sandbox:resend:{entity_id}",
|
||||
)
|
||||
|
||||
async def _register_and_push(self, entity_id: str, new_state: Any) -> None:
|
||||
try:
|
||||
await self._register(entity_id, new_state)
|
||||
finally:
|
||||
self._pending.discard(entity_id)
|
||||
|
||||
def _describe(self, entity_id: str) -> dict[str, Any] | None:
|
||||
"""Build the registry-shaped description for a live entity, or None."""
|
||||
domain = entity_id.split(".", 1)[0]
|
||||
components = self.hass.data.get(DATA_INSTANCES, {})
|
||||
component = components.get(domain)
|
||||
entity = component.get_entity(entity_id) if component is not None else None
|
||||
if entity is None:
|
||||
_LOGGER.debug(
|
||||
"EntityBridge: %s appeared in state machine but has no live"
|
||||
" entity object; skipping",
|
||||
entity_id,
|
||||
)
|
||||
return None
|
||||
entry_id = _entry_id_for(entity)
|
||||
if entry_id is None:
|
||||
_LOGGER.debug(
|
||||
"EntityBridge: %s has no owning config entry; not bridging",
|
||||
entity_id,
|
||||
)
|
||||
return None
|
||||
return _describe_entity(entity, entry_id)
|
||||
|
||||
async def _register(self, entity_id: str, new_state: Any) -> None:
|
||||
if self._channel is None:
|
||||
return
|
||||
payload = self._describe(entity_id)
|
||||
if payload is None:
|
||||
return
|
||||
new_hash = _payload_hash(payload)
|
||||
initial_state = None
|
||||
initial_attributes = None
|
||||
if hasattr(new_state, "state"):
|
||||
initial_state = new_state.state
|
||||
initial_attributes = dict(new_state.attributes)
|
||||
try:
|
||||
await self._channel.call(
|
||||
MSG_REGISTER_ENTITY,
|
||||
_to_entity_description(payload, initial_state, initial_attributes),
|
||||
)
|
||||
except Exception:
|
||||
_LOGGER.exception("EntityBridge: register failed for %s", entity_id)
|
||||
return
|
||||
self._registered.add(entity_id)
|
||||
self._last_hash[entity_id] = new_hash
|
||||
# Approve the entity's domain so the service + event mirrors
|
||||
# let through registrations / events that originate from it.
|
||||
self.approved.add(payload["domain"])
|
||||
|
||||
async def _resend(self, entity_id: str) -> None:
|
||||
"""Re-send a registration as an upsert after a registry change.
|
||||
|
||||
Skips when the entity isn't tracked yet (the initial register will
|
||||
carry current values) or when nothing we mirror actually changed.
|
||||
"""
|
||||
if self._channel is None or self._channel.closed:
|
||||
return
|
||||
if entity_id not in self._registered:
|
||||
return
|
||||
payload = self._describe(entity_id)
|
||||
if payload is None:
|
||||
return
|
||||
new_hash = _payload_hash(payload)
|
||||
if self._last_hash.get(entity_id) == new_hash:
|
||||
return
|
||||
initial_state = None
|
||||
initial_attributes = None
|
||||
state = self.hass.states.get(entity_id)
|
||||
if state is not None:
|
||||
initial_state = state.state
|
||||
initial_attributes = dict(state.attributes)
|
||||
try:
|
||||
await self._channel.call(
|
||||
MSG_REGISTER_ENTITY,
|
||||
_to_entity_description(payload, initial_state, initial_attributes),
|
||||
)
|
||||
except Exception:
|
||||
_LOGGER.exception("EntityBridge: resend failed for %s", entity_id)
|
||||
return
|
||||
self._last_hash[entity_id] = new_hash
|
||||
|
||||
async def _push_state(self, entity_id: str, new_state: Any) -> None:
|
||||
if self._channel is None:
|
||||
return
|
||||
msg = pb.StateChanged(sandbox_entity_id=entity_id)
|
||||
if new_state.state is not None:
|
||||
msg.state = new_state.state
|
||||
msg.attributes.update(dict(new_state.attributes))
|
||||
# Forward only the context id — never parent_id / user_id. Main
|
||||
# resolves it to a Context attributed to the sandbox system user.
|
||||
context = getattr(new_state, "context", None)
|
||||
if context is not None and context.id:
|
||||
msg.context_id = context.id
|
||||
try:
|
||||
await self._channel.push(MSG_STATE_CHANGED, msg)
|
||||
except Exception:
|
||||
_LOGGER.exception("EntityBridge: state push failed for %s", entity_id)
|
||||
|
||||
async def _push_unregister(self, entity_id: str) -> None:
|
||||
if self._channel is None:
|
||||
return
|
||||
try:
|
||||
await self._channel.call(
|
||||
MSG_UNREGISTER_ENTITY, pb.UnregisterEntity(sandbox_entity_id=entity_id)
|
||||
)
|
||||
except Exception:
|
||||
_LOGGER.exception("EntityBridge: unregister failed for %s", entity_id)
|
||||
|
||||
|
||||
def _to_entity_description(
|
||||
payload: dict[str, Any],
|
||||
initial_state: str | None,
|
||||
initial_attributes: dict[str, Any] | None,
|
||||
) -> pb.EntityDescription:
|
||||
"""Build the typed ``EntityDescription`` message from a describe dict."""
|
||||
return make_entity_description(
|
||||
entry_id=payload["entry_id"],
|
||||
domain=payload["domain"],
|
||||
sandbox_entity_id=payload["sandbox_entity_id"],
|
||||
unique_id=payload.get("unique_id"),
|
||||
name=payload.get("name"),
|
||||
icon=payload.get("icon"),
|
||||
has_entity_name=bool(payload.get("has_entity_name", False)),
|
||||
entity_category=payload.get("entity_category"),
|
||||
device_class=payload.get("device_class"),
|
||||
supported_features=int(payload.get("supported_features") or 0),
|
||||
capabilities=payload.get("capabilities"),
|
||||
initial_state=initial_state,
|
||||
initial_attributes=initial_attributes,
|
||||
device_info=payload.get("device_info"),
|
||||
)
|
||||
|
||||
|
||||
def _payload_hash(payload: dict[str, Any]) -> str:
|
||||
"""Stable hash of a description payload's mirrored fields.
|
||||
|
||||
State-shaped keys (``initial_state`` / ``initial_attributes``) flow via
|
||||
the ``state_changed`` push path and are excluded so the resend guard
|
||||
only fires on changes to fields a registration actually carries.
|
||||
"""
|
||||
mirrored = {
|
||||
key: value
|
||||
for key, value in payload.items()
|
||||
if key not in ("initial_state", "initial_attributes")
|
||||
}
|
||||
return json.dumps(mirrored, sort_keys=True, default=str)
|
||||
|
||||
|
||||
def _entry_id_for(entity: Entity) -> str | None:
|
||||
"""Return the entity's owning config-entry id, or None."""
|
||||
registry_entry = entity.registry_entry
|
||||
if registry_entry is not None and registry_entry.config_entry_id is not None:
|
||||
return registry_entry.config_entry_id
|
||||
platform = entity.platform
|
||||
if platform is not None and platform.config_entry is not None:
|
||||
return platform.config_entry.entry_id
|
||||
return None
|
||||
|
||||
|
||||
def _describe_entity(entity: Entity, entry_id: str) -> dict[str, Any]:
|
||||
"""Build a wire payload describing ``entity`` for ``register_entity``."""
|
||||
platform = entity.platform
|
||||
domain = platform.domain if platform is not None else entity.entity_id.split(".")[0]
|
||||
capabilities = _serialise(entity.capability_attributes or {})
|
||||
entity_category = entity.entity_category
|
||||
payload: dict[str, Any] = {
|
||||
"entry_id": entry_id,
|
||||
"domain": domain,
|
||||
"sandbox_entity_id": entity.entity_id,
|
||||
"unique_id": entity.unique_id,
|
||||
"name": _stringify(entity.name),
|
||||
"icon": _stringify(entity.icon),
|
||||
"has_entity_name": bool(entity.has_entity_name),
|
||||
"entity_category": (
|
||||
entity_category.value if entity_category is not None else None
|
||||
),
|
||||
"device_class": entity.device_class,
|
||||
"supported_features": int(entity.supported_features or 0),
|
||||
"capabilities": capabilities,
|
||||
}
|
||||
device_info = _serialise_device_info(entity.device_info)
|
||||
if device_info is not None:
|
||||
payload["device_info"] = device_info
|
||||
return payload
|
||||
|
||||
|
||||
def _serialise_device_info(device_info: Any) -> dict[str, Any] | None:
|
||||
"""Return a JSON-safe rendering of an entity's ``device_info``.
|
||||
|
||||
``DeviceInfo`` is a ``TypedDict`` with set/tuple-shaped fields
|
||||
(``identifiers``, ``connections``, ``via_device``) and a ``StrEnum``
|
||||
(``entry_type``). Sets become lists of two-element lists (preserving
|
||||
the pair shape main needs to rebuild tuples); enums become their
|
||||
string value; ``URL`` instances become strings. Anything else passes
|
||||
through ``_serialise`` for generic JSON-safety.
|
||||
"""
|
||||
if not device_info:
|
||||
return None
|
||||
if not isinstance(device_info, dict):
|
||||
return None
|
||||
out: dict[str, Any] = {}
|
||||
for key, value in device_info.items():
|
||||
if value is None:
|
||||
out[key] = None
|
||||
continue
|
||||
if key in ("identifiers", "connections"):
|
||||
# set[tuple[str, str]] → list[list[str, str]]
|
||||
out[key] = [list(item) for item in value]
|
||||
elif key == "via_device":
|
||||
# tuple[str, str] → list[str]
|
||||
out[key] = list(value)
|
||||
elif key == "entry_type":
|
||||
out[key] = getattr(value, "value", str(value))
|
||||
elif key == "configuration_url":
|
||||
out[key] = str(value) if value is not None else None
|
||||
else:
|
||||
out[key] = _serialise(value)
|
||||
return out
|
||||
|
||||
|
||||
def _stringify(value: Any) -> str | None:
|
||||
"""Coerce a name/icon-style value into a plain string."""
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, str):
|
||||
return value
|
||||
return str(value)
|
||||
|
||||
|
||||
def _serialise(value: Any) -> Any:
|
||||
"""JSON-safe recursive coercion for capability dicts."""
|
||||
if isinstance(value, dict):
|
||||
return {str(k): _serialise(v) for k, v in value.items()}
|
||||
if isinstance(value, (list, tuple, set, frozenset)):
|
||||
return [_serialise(v) for v in _iter(value)]
|
||||
if isinstance(value, (str, int, float, bool)) or value is None:
|
||||
return value
|
||||
enum_value = getattr(value, "value", None)
|
||||
if isinstance(enum_value, (str, int, float, bool)):
|
||||
return enum_value
|
||||
return str(value)
|
||||
|
||||
|
||||
def _iter(value: Any) -> Iterable[Any]:
|
||||
"""Stable iteration order for sets/frozensets."""
|
||||
if isinstance(value, (set, frozenset)):
|
||||
try:
|
||||
return sorted(value)
|
||||
except TypeError:
|
||||
return list(value)
|
||||
return value
|
||||
|
||||
|
||||
__all__ = ["EntityBridge"]
|
||||
@@ -0,0 +1,238 @@
|
||||
"""Sandbox-side entry runner — loads integrations + drives ``async_setup_entry``.
|
||||
|
||||
The manager pushes a serialised :class:`ConfigEntry` via
|
||||
``sandbox/entry_setup`` (see :mod:`hass_client.protocol`). The runner
|
||||
rebuilds the entry on the sandbox's private :class:`HomeAssistant`,
|
||||
calls ``hass.config_entries.async_setup`` to load the owning integration,
|
||||
and reports back. Main holds the canonical entry; the sandbox copy is
|
||||
ephemeral state used by the integration's lifecycle hooks.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from types import MappingProxyType
|
||||
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
|
||||
|
||||
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_ENTITY_QUERY,
|
||||
MSG_ENTRY_SETUP,
|
||||
MSG_ENTRY_UNLOAD,
|
||||
)
|
||||
from .sources import FetchPrimitive, SandboxSourceError, async_ensure_integration_source
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class EntryRunner:
|
||||
"""Load integrations on demand and run config entries inside the sandbox."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
approved: ApprovedDomains | None = None,
|
||||
*,
|
||||
fetch: FetchPrimitive | None = None,
|
||||
) -> None:
|
||||
"""Initialise with the sandbox-private HA instance.
|
||||
|
||||
``approved`` is shared with the service + event mirrors so an
|
||||
entry's domain becomes approved as soon as setup completes.
|
||||
``fetch`` overrides the integration-source download primitive (tests
|
||||
inject a local stub); ``None`` uses the real codeload tarball fetch.
|
||||
"""
|
||||
self.hass = hass
|
||||
self.approved = approved if approved is not None else ApprovedDomains()
|
||||
self._fetch = fetch
|
||||
|
||||
def register(self, channel: Channel) -> None:
|
||||
"""Wire the ``sandbox/entry_*`` + ``call_service`` handlers."""
|
||||
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."""
|
||||
try:
|
||||
entry = _entry_from_proto(msg)
|
||||
except (KeyError, TypeError) as err:
|
||||
return pb.EntrySetupResult(ok=False, reason=f"bad payload: {err}")
|
||||
|
||||
# Fetch the integration code before setup so a stateless sandbox can
|
||||
# load custom (HACS) integrations whose code isn't bundled. Built-in
|
||||
# sources are a no-op.
|
||||
try:
|
||||
await async_ensure_integration_source(
|
||||
self.hass.config.config_dir,
|
||||
msg.integration_source,
|
||||
fetch=self._fetch,
|
||||
)
|
||||
except SandboxSourceError as err:
|
||||
_LOGGER.error(
|
||||
"sandbox entry_setup: source fetch failed for %s (%s): %s",
|
||||
entry.title,
|
||||
entry.domain,
|
||||
err,
|
||||
)
|
||||
return pb.EntrySetupResult(ok=False, reason=f"source fetch failed: {err}")
|
||||
|
||||
config_entries = self.hass.config_entries
|
||||
if config_entries.async_get_entry(entry.entry_id) is not None:
|
||||
return pb.EntrySetupResult(ok=False, reason="entry already loaded")
|
||||
|
||||
# ConfigEntries doesn't expose a "add without persist" hook; the
|
||||
# sandbox's instance has no Store backing, so we drop the entry
|
||||
# straight into the internal map. `async_setup` then finds it via
|
||||
# `async_get_known_entry`.
|
||||
config_entries._entries[entry.entry_id] = entry # noqa: SLF001
|
||||
try:
|
||||
ok = await config_entries.async_setup(entry.entry_id)
|
||||
except Exception as err:
|
||||
_LOGGER.exception(
|
||||
"sandbox entry_setup raised for %s (%s)", entry.title, entry.domain
|
||||
)
|
||||
return pb.EntrySetupResult(
|
||||
ok=False, reason=str(err) or err.__class__.__name__
|
||||
)
|
||||
if not ok:
|
||||
return pb.EntrySetupResult(
|
||||
ok=False, reason=entry.reason or f"async_setup returned {ok!r}"
|
||||
)
|
||||
self.approved.add(entry.domain)
|
||||
return pb.EntrySetupResult(ok=True)
|
||||
|
||||
async def _handle_entry_unload(self, msg: pb.EntryUnload) -> pb.EntryUnloadResult:
|
||||
"""Unload an entry by id and drop it from the sandbox's store."""
|
||||
entry_id = msg.entry_id
|
||||
config_entries = self.hass.config_entries
|
||||
entry = config_entries.async_get_entry(entry_id)
|
||||
if entry is None:
|
||||
return pb.EntryUnloadResult(ok=True)
|
||||
try:
|
||||
unloaded = await config_entries.async_unload(entry_id)
|
||||
except Exception:
|
||||
_LOGGER.exception("sandbox entry_unload raised for %s", entry_id)
|
||||
return pb.EntryUnloadResult(ok=False)
|
||||
config_entries._entries.pop(entry_id, None) # noqa: SLF001
|
||||
# Drop one approval refcount; another loaded entry of the same
|
||||
# domain keeps it approved.
|
||||
self.approved.remove(entry.domain)
|
||||
return pb.EntryUnloadResult(ok=bool(unloaded))
|
||||
|
||||
async def _handle_call_service(self, msg: pb.CallService) -> pb.CallServiceResult:
|
||||
"""Dispatch a main→sandbox service call through HA's normal path.
|
||||
|
||||
Service-handler errors propagate as raised exceptions so the
|
||||
:class:`Channel`'s error frame carries the type name (e.g.
|
||||
``Invalid``). Main maps those back to ``TypeError`` /
|
||||
``HomeAssistantError`` in :mod:`bridge`'s exception translator.
|
||||
"""
|
||||
target = struct_to_dict(msg.target)
|
||||
service_data = struct_to_dict(msg.service_data)
|
||||
if msg.return_response:
|
||||
result = await self.hass.services.async_call(
|
||||
msg.domain,
|
||||
msg.service,
|
||||
service_data,
|
||||
blocking=True,
|
||||
target=target,
|
||||
return_response=True,
|
||||
)
|
||||
response = pb.CallServiceResult()
|
||||
response.response.data.CopyFrom(dict_to_struct(_json_safe(result)))
|
||||
return response
|
||||
await self.hass.services.async_call(
|
||||
msg.domain,
|
||||
msg.service,
|
||||
service_data,
|
||||
blocking=True,
|
||||
target=target,
|
||||
)
|
||||
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.
|
||||
|
||||
Entity service responses are keyed by entity_id and the value may be a
|
||||
rich object rather than a plain dict — ``media_player.browse_media``
|
||||
returns ``{entity_id: BrowseMedia}``, for instance. ``dict_to_struct``
|
||||
only accepts JSON scalars/dicts/lists, so the response is run through the
|
||||
same ``as_dict``-aware JSON encoder the websocket API uses for service
|
||||
responses, yielding the exact wire shape main rebuilds from.
|
||||
"""
|
||||
if not result:
|
||||
return {}
|
||||
return json_loads(json_bytes(result))
|
||||
|
||||
|
||||
def _entry_from_proto(msg: pb.EntrySetup) -> ConfigEntry:
|
||||
"""Rebuild a :class:`ConfigEntry` from the typed ``EntrySetup`` message.
|
||||
|
||||
Only fields the integration's setup hooks need are surfaced — the
|
||||
sandbox does not persist entries or track update listeners.
|
||||
"""
|
||||
return ConfigEntry(
|
||||
version=msg.version,
|
||||
minor_version=msg.minor_version,
|
||||
domain=msg.domain,
|
||||
title=msg.title,
|
||||
data=MappingProxyType(struct_to_dict(msg.data)),
|
||||
options=MappingProxyType(struct_to_dict(msg.options)),
|
||||
source=msg.source,
|
||||
unique_id=msg.unique_id if msg.HasField("unique_id") else None,
|
||||
entry_id=msg.entry_id,
|
||||
discovery_keys=MappingProxyType({}),
|
||||
subentries_data=None,
|
||||
state=ConfigEntryState.NOT_LOADED,
|
||||
)
|
||||
|
||||
|
||||
__all__ = ["EntryRunner"]
|
||||
@@ -0,0 +1,143 @@
|
||||
"""Sandbox-side event mirror.
|
||||
|
||||
Forwards every event whose ``event_type`` matches ``<approved_domain>_*``
|
||||
up to main via ``sandbox/fire_event``. Canonical examples: ``zha_event``,
|
||||
``mqtt_message_received``, ``hue_event``, ``device_tracker_see``.
|
||||
|
||||
The bus listener is installed via ``MATCH_ALL`` so we don't need to know
|
||||
the integration's event names ahead of time, with a callback-decorated
|
||||
event filter so the bus can short-circuit on a fast path before queuing
|
||||
the listener. Untrusted (non-approved) event types are silently dropped
|
||||
— they would never have been forwarded anyway and don't deserve a log
|
||||
line per event.
|
||||
|
||||
System events that already cross the bridge through dedicated channels
|
||||
(``EVENT_STATE_CHANGED``, ``EVENT_SERVICE_REGISTERED``, …) are
|
||||
suppressed unconditionally; ``state_changed`` for example is owned by
|
||||
:class:`hass_client.entity_bridge.EntityBridge` and re-emitting it as a
|
||||
plain event would double-count.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.const import (
|
||||
EVENT_CALL_SERVICE,
|
||||
EVENT_COMPONENT_LOADED,
|
||||
EVENT_CORE_CONFIG_UPDATE,
|
||||
EVENT_HOMEASSISTANT_CLOSE,
|
||||
EVENT_HOMEASSISTANT_FINAL_WRITE,
|
||||
EVENT_HOMEASSISTANT_START,
|
||||
EVENT_HOMEASSISTANT_STARTED,
|
||||
EVENT_HOMEASSISTANT_STOP,
|
||||
EVENT_LOGGING_CHANGED,
|
||||
EVENT_SERVICE_REGISTERED,
|
||||
EVENT_SERVICE_REMOVED,
|
||||
EVENT_STATE_CHANGED,
|
||||
EVENT_STATE_REPORTED,
|
||||
MATCH_ALL,
|
||||
)
|
||||
from homeassistant.core import Event, HomeAssistant, callback
|
||||
|
||||
from ._proto import sandbox_pb2 as pb
|
||||
from .approved_domains import ApprovedDomains
|
||||
from .channel import Channel
|
||||
from .protocol import MSG_FIRE_EVENT
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
# Events that are part of the bridge's own protocol or core lifecycle.
|
||||
# Forwarding them either double-counts (state_changed is the entity
|
||||
# bridge's job) or is meaningless on main (the sandbox's lifecycle is
|
||||
# not main's).
|
||||
_INTERNAL_EVENTS: frozenset[str] = frozenset(
|
||||
{
|
||||
EVENT_STATE_CHANGED,
|
||||
EVENT_STATE_REPORTED,
|
||||
EVENT_SERVICE_REGISTERED,
|
||||
EVENT_SERVICE_REMOVED,
|
||||
EVENT_CALL_SERVICE,
|
||||
EVENT_COMPONENT_LOADED,
|
||||
EVENT_CORE_CONFIG_UPDATE,
|
||||
EVENT_HOMEASSISTANT_START,
|
||||
EVENT_HOMEASSISTANT_STARTED,
|
||||
EVENT_HOMEASSISTANT_STOP,
|
||||
EVENT_HOMEASSISTANT_CLOSE,
|
||||
EVENT_HOMEASSISTANT_FINAL_WRITE,
|
||||
EVENT_LOGGING_CHANGED,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class EventMirror:
|
||||
"""Forward ``<approved_domain>_*`` events from the sandbox bus to main."""
|
||||
|
||||
def __init__(self, hass: HomeAssistant, approved: ApprovedDomains) -> None:
|
||||
"""Initialise with the sandbox HA and the shared approved-domains gate."""
|
||||
self.hass = hass
|
||||
self.approved = approved
|
||||
self._channel: Channel | None = None
|
||||
self._unsub: Any = None
|
||||
|
||||
def register(self, channel: Channel) -> None:
|
||||
"""Capture ``channel`` and start watching every event on the bus."""
|
||||
self._channel = channel
|
||||
# MATCH_ALL avoids re-subscribing every time the approved-domain
|
||||
# set grows. The handler does the cheap prefix check itself.
|
||||
self._unsub = self.hass.bus.async_listen(MATCH_ALL, self._on_event)
|
||||
|
||||
async def async_stop(self) -> None:
|
||||
"""Detach the bus listener."""
|
||||
if self._unsub is not None:
|
||||
self._unsub()
|
||||
self._unsub = None
|
||||
|
||||
@callback
|
||||
def _on_event(self, event: Event) -> None:
|
||||
if self._channel is None or self._channel.closed:
|
||||
return
|
||||
event_type = event.event_type
|
||||
if event_type in _INTERNAL_EVENTS:
|
||||
return
|
||||
if not self.approved.approves_event(event_type):
|
||||
return
|
||||
msg = pb.FireEvent(event_type=event_type)
|
||||
msg.event_data.update(_to_json_safe(dict(event.data)))
|
||||
# Forward only the context id — never parent_id / user_id.
|
||||
if event.context is not None and event.context.id:
|
||||
msg.context_id = event.context.id
|
||||
asyncio.create_task( # noqa: RUF006
|
||||
self._push(msg),
|
||||
name=f"sandbox:fire_event:{event_type}",
|
||||
)
|
||||
|
||||
async def _push(self, msg: pb.FireEvent) -> None:
|
||||
assert self._channel is not None
|
||||
try:
|
||||
await self._channel.push(MSG_FIRE_EVENT, msg)
|
||||
except Exception:
|
||||
_LOGGER.exception("EventMirror: forward failed for %s", msg.event_type)
|
||||
|
||||
|
||||
def _to_json_safe(value: Any) -> Any:
|
||||
"""JSON-coerce arbitrary event-data objects.
|
||||
|
||||
Event data on the sandbox bus is best-effort: integrations can stash
|
||||
domain objects in there. We don't want a single non-serialisable
|
||||
field to drop the whole event, so we coerce recursively and fall
|
||||
back to ``str(value)`` for unknown shapes.
|
||||
"""
|
||||
if isinstance(value, dict):
|
||||
return {str(k): _to_json_safe(v) for k, v in value.items()}
|
||||
if isinstance(value, (list, tuple, set, frozenset)):
|
||||
return [_to_json_safe(v) for v in value]
|
||||
if isinstance(value, (str, int, float, bool)) or value is None:
|
||||
return value
|
||||
enum_value = getattr(value, "value", None)
|
||||
if isinstance(enum_value, (str, int, float, bool)):
|
||||
return enum_value
|
||||
return str(value)
|
||||
|
||||
|
||||
__all__ = ["EventMirror"]
|
||||
@@ -0,0 +1,240 @@
|
||||
"""Sandbox-side config flow runner.
|
||||
|
||||
Runs an integration's :class:`ConfigFlow` inside a dedicated
|
||||
:class:`HomeAssistant` instance owned by the sandbox runtime. The
|
||||
manager-side proxy :class:`ConfigFlow` calls these handlers across the
|
||||
:class:`Channel`:
|
||||
|
||||
* ``sandbox/flow_init`` → ``(handler, source, context, data)`` → flow result
|
||||
* ``sandbox/flow_step`` → ``(flow_id, user_input)`` → flow result
|
||||
* ``sandbox/flow_abort`` → ``(flow_id)`` → ``{}``
|
||||
|
||||
Flow results cross the wire as plain dicts. ``data_schema`` and the
|
||||
``progress_task`` field are intentionally stripped — the schema lives on
|
||||
the sandbox where validation happens, and the task is a runtime object
|
||||
that can't be serialised. The docstring in ``_marshal_result`` is the
|
||||
load-bearing note for how the schema is later marshalled.
|
||||
"""
|
||||
|
||||
from collections.abc import Mapping
|
||||
import contextlib
|
||||
import logging
|
||||
from typing import Any, cast
|
||||
|
||||
from homeassistant import config_entries as ha_config_entries, loader
|
||||
from homeassistant.config_entries import (
|
||||
ConfigEntriesFlowManager,
|
||||
ConfigFlow,
|
||||
ConfigFlowResult,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.data_entry_flow import FlowResultType, UnknownFlow
|
||||
|
||||
from ._proto import sandbox_pb2 as pb
|
||||
from .channel import Channel
|
||||
from .messages import struct_to_dict
|
||||
from .schema_bridge import serialize_schema
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# Scalar optional-string fields copied verbatim from the integration's
|
||||
# FlowResult onto the proto. Dynamic dicts (data / options / errors /
|
||||
# description_placeholders / context) and data_schema get bespoke handling in
|
||||
# ``_marshal_result``. Result types beyond FORM / CREATE_ENTRY / ABORT carry no
|
||||
# extra fields (e.g. menu_options) — the main-side proxy only supports those
|
||||
# three and aborts noisily on anything else.
|
||||
_SCALAR_STRING_FIELDS = (
|
||||
"flow_id",
|
||||
"handler",
|
||||
"step_id",
|
||||
"reason",
|
||||
"title",
|
||||
"description",
|
||||
)
|
||||
|
||||
# Dynamic dict fields → Struct fields of the same name on the proto.
|
||||
_STRUCT_FIELDS = (
|
||||
"data",
|
||||
"options",
|
||||
"errors",
|
||||
"description_placeholders",
|
||||
)
|
||||
|
||||
|
||||
class _SandboxFlowManager(ConfigEntriesFlowManager):
|
||||
"""ConfigEntriesFlowManager that doesn't add CREATE_ENTRY results.
|
||||
|
||||
Main owns the canonical entry store; the sandbox just runs the flow
|
||||
and returns the result. The default ``async_finish_flow`` would
|
||||
create an entry inside the sandbox-private store and try to set the
|
||||
integration up locally — that's later work, not this layer's.
|
||||
"""
|
||||
|
||||
async def async_finish_flow(
|
||||
self, flow: Any, result: ConfigFlowResult
|
||||
) -> ConfigFlowResult:
|
||||
if result["type"] is FlowResultType.CREATE_ENTRY:
|
||||
# Return the bare result so the channel marshaller sees the
|
||||
# full data/title/version payload; main builds the actual
|
||||
# ConfigEntry.
|
||||
self._set_pending_import_done(cast(ConfigFlow, flow))
|
||||
self._async_validate_next_flow(result)
|
||||
return result
|
||||
return await super().async_finish_flow(flow, result)
|
||||
|
||||
|
||||
class FlowRunner:
|
||||
"""Run config flows inside the sandbox process."""
|
||||
|
||||
def __init__(self, hass: HomeAssistant) -> None:
|
||||
"""Initialise with a configured HomeAssistant instance."""
|
||||
self.hass = hass
|
||||
|
||||
@classmethod
|
||||
async def create(cls, *, config_dir: str) -> FlowRunner:
|
||||
"""Create a sandbox-private :class:`HomeAssistant` and wire it up."""
|
||||
hass = HomeAssistant(config_dir)
|
||||
hass.config.skip_pip = True
|
||||
hass.config.skip_pip_packages = []
|
||||
hass.config_entries = ha_config_entries.ConfigEntries(hass, {})
|
||||
# Swap in the sandbox-aware flow manager *after* ConfigEntries
|
||||
# has built its default one, so we inherit all the wiring.
|
||||
hass.config_entries.flow = _SandboxFlowManager(hass, hass.config_entries, {})
|
||||
loader.async_setup(hass)
|
||||
return cls(hass)
|
||||
|
||||
def register(self, channel: Channel) -> None:
|
||||
"""Register the ``sandbox/flow_*`` handlers on ``channel``."""
|
||||
channel.register("sandbox/flow_init", self._handle_flow_init)
|
||||
channel.register("sandbox/flow_step", self._handle_flow_step)
|
||||
channel.register("sandbox/flow_abort", self._handle_flow_abort)
|
||||
|
||||
async def async_stop(self) -> None:
|
||||
"""Tear down in-progress flows."""
|
||||
flow_manager = self.hass.config_entries.flow
|
||||
for progress in list(flow_manager.async_progress(include_uninitialized=True)):
|
||||
with contextlib.suppress(UnknownFlow):
|
||||
flow_manager.async_abort(progress["flow_id"])
|
||||
await self.hass.async_block_till_done()
|
||||
|
||||
async def _handle_flow_init(self, msg: pb.FlowInit) -> pb.FlowResult:
|
||||
context = struct_to_dict(msg.context)
|
||||
data = struct_to_dict(msg.data) if msg.HasField("data") else None
|
||||
result = await self.hass.config_entries.flow.async_init(
|
||||
msg.handler, context=context, data=data
|
||||
)
|
||||
return _marshal_result(result, self.hass.config_entries.flow)
|
||||
|
||||
async def _handle_flow_step(self, msg: pb.FlowStep) -> pb.FlowResult:
|
||||
user_input = (
|
||||
struct_to_dict(msg.user_input) if msg.HasField("user_input") else None
|
||||
)
|
||||
result = await self.hass.config_entries.flow.async_configure(
|
||||
msg.flow_id, user_input
|
||||
)
|
||||
return _marshal_result(result, self.hass.config_entries.flow)
|
||||
|
||||
async def _handle_flow_abort(self, msg: pb.FlowAbort) -> pb.FlowAbortResult:
|
||||
with contextlib.suppress(UnknownFlow):
|
||||
# Idempotent — main may have already given up on the flow.
|
||||
self.hass.config_entries.flow.async_abort(msg.flow_id)
|
||||
return pb.FlowAbortResult()
|
||||
|
||||
|
||||
def _marshal_result(
|
||||
result: Mapping[str, Any],
|
||||
flow_manager: ConfigEntriesFlowManager | None = None,
|
||||
) -> pb.FlowResult:
|
||||
"""Marshal a FlowResult into the typed ``FlowResult`` message.
|
||||
|
||||
``data_schema`` is rendered via :func:`serialize_schema` —
|
||||
the wire payload carries the same list-of-fields shape
|
||||
:func:`voluptuous_serialize.convert` produces, so the proxy on main
|
||||
can rebuild a usable :class:`vol.Schema`. ``flow.context`` (which
|
||||
carries ``unique_id`` once the integration calls
|
||||
:meth:`ConfigFlow.async_set_unique_id`) is pulled out of the live
|
||||
flow when the result type doesn't already include it.
|
||||
|
||||
Only FORM / CREATE_ENTRY / ABORT fields are carried — the main-side proxy
|
||||
supports only those three and aborts noisily on anything else, so
|
||||
``menu_options`` / ``subentries`` / ``url`` / … are intentionally dropped.
|
||||
"""
|
||||
out = pb.FlowResult(type=_flow_type_value(result["type"]))
|
||||
for key in _SCALAR_STRING_FIELDS:
|
||||
value = result.get(key)
|
||||
if value is not None:
|
||||
setattr(out, key, str(value))
|
||||
if result.get("version") is not None:
|
||||
out.version = int(result["version"])
|
||||
if result.get("minor_version") is not None:
|
||||
out.minor_version = int(result["minor_version"])
|
||||
if result.get("last_step") is not None:
|
||||
out.last_step = bool(result["last_step"])
|
||||
if result.get("preview") is not None:
|
||||
out.preview = str(result["preview"])
|
||||
for key in _STRUCT_FIELDS:
|
||||
value = result.get(key)
|
||||
if isinstance(value, Mapping):
|
||||
getattr(out, key).update(_to_json_safe(dict(value)))
|
||||
if result.get("data_schema") is not None:
|
||||
serialized = serialize_schema(result["data_schema"])
|
||||
if serialized is not None:
|
||||
out.data_schema.extend(serialized)
|
||||
else:
|
||||
# voluptuous_serialize couldn't render it; flag the gap so the
|
||||
# proxy still surfaces a (schema-less) form rather than abort.
|
||||
# Log the schema's repr at warning so the lossy fallback is
|
||||
# visible rather than silently swallowing a real form.
|
||||
_LOGGER.warning(
|
||||
"Could not serialize data_schema %r; main will render a"
|
||||
" schema-less form",
|
||||
result["data_schema"],
|
||||
)
|
||||
out.has_data_schema = True
|
||||
context_value = result.get("context")
|
||||
if isinstance(context_value, Mapping):
|
||||
out.context.update(_to_json_safe(dict(context_value)))
|
||||
elif flow_manager is not None:
|
||||
# FORM / SHOW_PROGRESS / EXTERNAL_STEP results don't include the
|
||||
# flow's context (only CREATE_ENTRY does). Look it up so the proxy
|
||||
# can mirror ``unique_id`` into its own ``self.context`` and let
|
||||
# main's duplicate detection fire.
|
||||
flow_id = result.get("flow_id")
|
||||
if isinstance(flow_id, str):
|
||||
try:
|
||||
partial = flow_manager.async_get(flow_id)
|
||||
except UnknownFlow:
|
||||
partial = None
|
||||
if partial is not None:
|
||||
ctx = partial.get("context")
|
||||
if isinstance(ctx, Mapping):
|
||||
out.context.update(_to_json_safe(dict(ctx)))
|
||||
return out
|
||||
|
||||
|
||||
def _flow_type_value(value: Any) -> str:
|
||||
"""Return the string value of a FlowResult ``type`` (enum or string)."""
|
||||
if isinstance(value, FlowResultType):
|
||||
return value.value
|
||||
return str(value)
|
||||
|
||||
|
||||
def _to_json_safe(value: Any) -> Any:
|
||||
"""Recursively coerce a value into JSON-safe primitives."""
|
||||
if isinstance(value, Mapping):
|
||||
return {str(k): _to_json_safe(v) for k, v in value.items()}
|
||||
if isinstance(value, (list, tuple, set, frozenset)):
|
||||
return [_to_json_safe(v) for v in value]
|
||||
if isinstance(value, FlowResultType):
|
||||
return value.value
|
||||
if isinstance(value, (str, int, float, bool)) or value is None:
|
||||
return value
|
||||
# Generic enum-ish: fall through to .value if available, otherwise str().
|
||||
enum_value = getattr(value, "value", None)
|
||||
if enum_value is not None and isinstance(enum_value, (str, int, float, bool)):
|
||||
return enum_value
|
||||
return str(value)
|
||||
|
||||
|
||||
__all__ = ["FlowRunner"]
|
||||
@@ -0,0 +1,224 @@
|
||||
"""Typed protobuf message registry + dynamic-field helpers.
|
||||
|
||||
This module is the codec's view of the wire: the ``type → (request_cls,
|
||||
result_cls)`` registry plus the small Struct/ListValue helpers that carry the
|
||||
genuinely dynamic payloads (service_data, target, state attributes,
|
||||
capabilities, the wrapped Store envelope, flow ``data``/``errors``/``context``)
|
||||
and the serialized voluptuous schema.
|
||||
|
||||
Mirrored verbatim across the no-cross-import boundary, exactly like
|
||||
:mod:`channel` / :mod:`protocol`: the same file lives at
|
||||
``hass_client.messages``. The relative ``._proto`` import resolves to each
|
||||
side's own checked-in gencode, so the two copies are byte-identical.
|
||||
|
||||
Numbers note: ``google.protobuf.Struct`` stores every number as a double, so
|
||||
an ``int`` that crosses inside a dynamic field comes back as a ``float``
|
||||
(``255`` → ``255.0``). Python's ``==`` treats the two as equal, so dict
|
||||
comparisons still hold; only an ``isinstance(x, int)`` check would notice.
|
||||
Everything with integer semantics that matters (``version``, ``minor_version``,
|
||||
``supported_features``) is an explicit ``int32`` field, not a Struct value.
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from google.protobuf.message import Message
|
||||
|
||||
# pylint: disable-next=no-name-in-module
|
||||
from google.protobuf.struct_pb2 import ListValue, Struct, Value
|
||||
|
||||
from ._proto import sandbox_pb2 as pb
|
||||
|
||||
# Wire type → (request message class, result message class). The result class
|
||||
# is ``None`` for one-way pushes (ready / state_changed / fire_event). The
|
||||
# codec resolves these from ``frame.type`` on both encode and decode.
|
||||
REGISTRY: dict[str, tuple[type[Message], type[Message] | None]] = {
|
||||
# handshake (push)
|
||||
"sandbox/ready": (pb.Ready, None),
|
||||
# main → sandbox
|
||||
"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),
|
||||
"sandbox/flow_init": (pb.FlowInit, pb.FlowResult),
|
||||
"sandbox/flow_step": (pb.FlowStep, pb.FlowResult),
|
||||
"sandbox/flow_abort": (pb.FlowAbort, pb.FlowAbortResult),
|
||||
# sandbox → main
|
||||
"sandbox/register_entity": (pb.EntityDescription, pb.RegisterEntityResult),
|
||||
"sandbox/unregister_entity": (pb.UnregisterEntity, pb.UnregisterEntityResult),
|
||||
"sandbox/state_changed": (pb.StateChanged, None),
|
||||
"sandbox/register_service": (pb.RegisterService, pb.RegisterServiceResult),
|
||||
"sandbox/unregister_service": (
|
||||
pb.UnregisterService,
|
||||
pb.UnregisterServiceResult,
|
||||
),
|
||||
"sandbox/fire_event": (pb.FireEvent, None),
|
||||
"sandbox/store_load": (pb.StoreLoad, pb.StoreLoadResult),
|
||||
"sandbox/store_save": (pb.StoreSave, pb.StoreSaveResult),
|
||||
"sandbox/store_remove": (pb.StoreRemove, pb.StoreRemoveResult),
|
||||
}
|
||||
|
||||
|
||||
# --- Struct / ListValue helpers -------------------------------------------
|
||||
|
||||
|
||||
def _value_to_py(value: Value) -> Any:
|
||||
"""Convert one ``google.protobuf.Value`` into a plain Python value."""
|
||||
kind = value.WhichOneof("kind")
|
||||
if kind == "null_value" or kind is None:
|
||||
return None
|
||||
if kind == "number_value":
|
||||
return value.number_value
|
||||
if kind == "string_value":
|
||||
return value.string_value
|
||||
if kind == "bool_value":
|
||||
return value.bool_value
|
||||
if kind == "struct_value":
|
||||
return struct_to_dict(value.struct_value)
|
||||
return [_value_to_py(item) for item in value.list_value.values]
|
||||
|
||||
|
||||
def struct_to_dict(struct: Struct) -> dict[str, Any]:
|
||||
"""Convert a ``Struct`` into a plain ``dict`` (empty Struct → ``{}``)."""
|
||||
return {key: _value_to_py(val) for key, val in struct.fields.items()}
|
||||
|
||||
|
||||
def dict_to_struct(data: dict[str, Any] | None) -> Struct:
|
||||
"""Convert a ``dict`` (or ``None``) into a ``Struct``."""
|
||||
struct = Struct()
|
||||
if data:
|
||||
struct.update(data)
|
||||
return struct
|
||||
|
||||
|
||||
def listvalue_to_list(list_value: ListValue) -> list[Any]:
|
||||
"""Convert a ``ListValue`` into a plain ``list``."""
|
||||
return [_value_to_py(item) for item in list_value.values]
|
||||
|
||||
|
||||
def list_to_listvalue(items: list[Any] | None) -> ListValue:
|
||||
"""Convert a ``list`` (or ``None``) into a ``ListValue``."""
|
||||
list_value = ListValue()
|
||||
if items:
|
||||
list_value.extend(items)
|
||||
return list_value
|
||||
|
||||
|
||||
# --- DeviceInfo bridging --------------------------------------------------
|
||||
|
||||
# Scalar string fields of the DeviceInfo proto, copied through verbatim when
|
||||
# present in the JSON-flattened device_info dict.
|
||||
_DEVICE_INFO_SCALARS = (
|
||||
"entry_type",
|
||||
"name",
|
||||
"manufacturer",
|
||||
"model",
|
||||
"model_id",
|
||||
"sw_version",
|
||||
"hw_version",
|
||||
"serial_number",
|
||||
"suggested_area",
|
||||
"configuration_url",
|
||||
"default_name",
|
||||
"default_manufacturer",
|
||||
"default_model",
|
||||
"translation_key",
|
||||
)
|
||||
|
||||
|
||||
def device_info_to_proto(flat: dict[str, Any] | None) -> pb.DeviceInfo | None:
|
||||
"""Build a ``DeviceInfo`` proto from the JSON-flattened device_info dict.
|
||||
|
||||
The sandbox-side serializer (``entity_bridge._serialise_device_info``)
|
||||
already flattens sets/tuples/enums: ``identifiers`` / ``connections`` are
|
||||
lists of two-element lists, ``via_device`` is a two-element list, and
|
||||
``entry_type`` is the enum's string value. This maps that shape onto the
|
||||
explicit proto fields.
|
||||
"""
|
||||
if not flat:
|
||||
return None
|
||||
info = pb.DeviceInfo()
|
||||
for key, raw in flat.items():
|
||||
if raw is None:
|
||||
continue
|
||||
if key in ("identifiers", "connections"):
|
||||
for pair in raw:
|
||||
if len(pair) == 2:
|
||||
getattr(info, key).add(key=str(pair[0]), value=str(pair[1]))
|
||||
elif key == "via_device":
|
||||
if len(raw) == 2:
|
||||
info.via_device.key = str(raw[0])
|
||||
info.via_device.value = str(raw[1])
|
||||
elif key in _DEVICE_INFO_SCALARS:
|
||||
setattr(info, key, str(raw))
|
||||
return info
|
||||
|
||||
|
||||
def make_entity_description(
|
||||
*,
|
||||
entry_id: str,
|
||||
domain: str,
|
||||
sandbox_entity_id: str,
|
||||
unique_id: str | None = None,
|
||||
name: str | None = None,
|
||||
icon: str | None = None,
|
||||
has_entity_name: bool = False,
|
||||
entity_category: str | None = None,
|
||||
device_class: str | None = None,
|
||||
supported_features: int = 0,
|
||||
translation_key: str | None = None,
|
||||
capabilities: dict[str, Any] | None = None,
|
||||
initial_state: str | None = None,
|
||||
initial_attributes: dict[str, Any] | None = None,
|
||||
device_info: dict[str, Any] | None = None,
|
||||
) -> pb.EntityDescription:
|
||||
"""Build a nested ``EntityDescription`` proto from flat fields.
|
||||
|
||||
Used by the sandbox entity bridge and by tests so neither has to hand-nest
|
||||
the ``EntityInfo`` / ``InitialState`` sub-messages. ``device_info`` is the
|
||||
JSON-flattened dict the entity bridge produces (see
|
||||
:func:`device_info_to_proto`).
|
||||
"""
|
||||
msg = pb.EntityDescription(
|
||||
entry_id=entry_id,
|
||||
domain=domain,
|
||||
sandbox_entity_id=sandbox_entity_id,
|
||||
has_entity_name=has_entity_name,
|
||||
)
|
||||
if unique_id is not None:
|
||||
msg.unique_id = unique_id
|
||||
description = msg.info.description
|
||||
if name is not None:
|
||||
description.name = name
|
||||
if icon is not None:
|
||||
description.icon = icon
|
||||
if entity_category is not None:
|
||||
description.entity_category = entity_category
|
||||
if device_class is not None:
|
||||
description.device_class = device_class
|
||||
description.supported_features = int(supported_features or 0)
|
||||
if translation_key is not None:
|
||||
description.translation_key = translation_key
|
||||
device = device_info_to_proto(device_info)
|
||||
if device is not None:
|
||||
msg.info.device_info.CopyFrom(device)
|
||||
if initial_state is not None:
|
||||
msg.initial.state = initial_state
|
||||
if capabilities:
|
||||
msg.initial.capabilities.update(capabilities)
|
||||
if initial_attributes:
|
||||
msg.initial.attributes.update(initial_attributes)
|
||||
return msg
|
||||
|
||||
|
||||
__all__ = [
|
||||
"REGISTRY",
|
||||
"device_info_to_proto",
|
||||
"dict_to_struct",
|
||||
"list_to_listvalue",
|
||||
"listvalue_to_list",
|
||||
"make_entity_description",
|
||||
"struct_to_dict",
|
||||
]
|
||||
@@ -0,0 +1,54 @@
|
||||
"""Sandbox-side mirror of ``homeassistant.components.sandbox.protocol``.
|
||||
|
||||
Kept as a stand-alone module to honour the project boundary: the HA Core
|
||||
integration must not import from ``hass_client`` at integration-load time,
|
||||
and ``hass_client`` does not pull from ``homeassistant.components.*``. The
|
||||
two files speak the same wire protocol — see the docstring on the HA side
|
||||
for the message catalogue.
|
||||
"""
|
||||
|
||||
from typing import Final
|
||||
|
||||
# Handshake: the runtime's first frame on the channel. Replaces the old
|
||||
# stdout text marker — the manager waits for this push instead of scanning
|
||||
# stdout, so stdout carries nothing but channel frames.
|
||||
MSG_READY: Final = "sandbox/ready"
|
||||
|
||||
# Main → Sandbox
|
||||
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"
|
||||
|
||||
# Sandbox → Main
|
||||
MSG_REGISTER_ENTITY: Final = "sandbox/register_entity"
|
||||
MSG_UNREGISTER_ENTITY: Final = "sandbox/unregister_entity"
|
||||
MSG_STATE_CHANGED: Final = "sandbox/state_changed"
|
||||
MSG_REGISTER_SERVICE: Final = "sandbox/register_service"
|
||||
MSG_UNREGISTER_SERVICE: Final = "sandbox/unregister_service"
|
||||
MSG_FIRE_EVENT: Final = "sandbox/fire_event"
|
||||
MSG_STORE_LOAD: Final = "sandbox/store_load"
|
||||
MSG_STORE_SAVE: Final = "sandbox/store_save"
|
||||
MSG_STORE_REMOVE: Final = "sandbox/store_remove"
|
||||
|
||||
|
||||
__all__ = [
|
||||
"MSG_CALL_SERVICE",
|
||||
"MSG_ENTITY_QUERY",
|
||||
"MSG_ENTRY_SETUP",
|
||||
"MSG_ENTRY_UNLOAD",
|
||||
"MSG_FIRE_EVENT",
|
||||
"MSG_GET_TRANSLATIONS",
|
||||
"MSG_READY",
|
||||
"MSG_REGISTER_ENTITY",
|
||||
"MSG_REGISTER_SERVICE",
|
||||
"MSG_SHUTDOWN",
|
||||
"MSG_STATE_CHANGED",
|
||||
"MSG_STORE_LOAD",
|
||||
"MSG_STORE_REMOVE",
|
||||
"MSG_STORE_SAVE",
|
||||
"MSG_UNREGISTER_ENTITY",
|
||||
"MSG_UNREGISTER_SERVICE",
|
||||
]
|
||||
@@ -0,0 +1,463 @@
|
||||
"""Sandbox runtime — the long-running process inside one sandbox group.
|
||||
|
||||
Composes the sandbox's per-process services:
|
||||
|
||||
* :class:`FlowRunner` — drives integration ``ConfigFlow`` instances
|
||||
out-of-process.
|
||||
* :class:`EntryRunner` — accepts ``sandbox/entry_setup`` pushes and
|
||||
runs ``async_setup_entry`` against the sandbox-private HA.
|
||||
* :class:`EntityBridge` — pushes entity registrations + state changes
|
||||
back to main.
|
||||
* :class:`ServiceMirror` / :class:`EventMirror` — mirror service
|
||||
registrations and ``<owned_domain>_*`` events up to main, gated by
|
||||
:class:`ApprovedDomains`.
|
||||
|
||||
The handshake: open the control channel (transport selected by the
|
||||
``--url`` scheme — ``stdio://`` by default, ``unix://<path>`` to dial back
|
||||
to the manager's unix socket), send a :data:`MSG_READY` frame as the first
|
||||
message, warm-load restore state, register handlers, then idle until
|
||||
SIGTERM (or until main asks for a graceful shutdown over the channel — see
|
||||
:meth:`SandboxRuntime._handle_shutdown`).
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Awaitable, Callable
|
||||
import contextlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import signal
|
||||
import sys
|
||||
import tempfile
|
||||
from typing import Any
|
||||
|
||||
from hass_client._proto import sandbox_pb2 as pb
|
||||
from hass_client.approved_domains import ApprovedDomains
|
||||
from hass_client.channel import Channel
|
||||
from hass_client.codec_protobuf import ProtobufCodec
|
||||
from hass_client.entity_bridge import EntityBridge
|
||||
from hass_client.entry_runner import EntryRunner
|
||||
from hass_client.event_mirror import EventMirror
|
||||
from hass_client.flow_runner import FlowRunner
|
||||
from hass_client.protocol import MSG_GET_TRANSLATIONS, MSG_READY, MSG_SHUTDOWN
|
||||
from hass_client.sandbox_bridge import ChannelSandboxBridge
|
||||
from hass_client.service_mirror import ServiceMirror
|
||||
from homeassistant.const import EVENT_HOMEASSISTANT_FINAL_WRITE
|
||||
from homeassistant.core import CoreState, HomeAssistant
|
||||
from homeassistant.helpers import json as json_helper, restore_state
|
||||
from homeassistant.helpers.sandbox_context import current_sandbox
|
||||
from homeassistant.helpers.translation import _async_get_component_strings
|
||||
from homeassistant.loader import async_get_integrations
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
ChannelFactory = Callable[[], Awaitable[Channel | None]]
|
||||
|
||||
|
||||
class SandboxRuntime:
|
||||
"""Runtime: Ready-frame handshake + length-prefixed control channel.
|
||||
|
||||
The control-channel transport is chosen from the ``--url`` scheme:
|
||||
``stdio://`` (default — frames over the process's stdin/stdout) or
|
||||
``unix://<path>`` (dial back to the manager's unix socket). ``ws://`` /
|
||||
``wss://`` are reserved for the deferred websocket transport and
|
||||
rejected for now. The handshake is a :data:`MSG_READY` frame sent as the
|
||||
channel's first message — there is no stdout text marker.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
url: str,
|
||||
group: str,
|
||||
config_dir: str | None = None,
|
||||
channel_factory: ChannelFactory | None = None,
|
||||
) -> None:
|
||||
"""Initialise the runtime with its main-HA connection parameters.
|
||||
|
||||
``channel_factory`` returns the live control channel — defaults to
|
||||
opening one over the process's stdin/stdout. Tests pass a factory
|
||||
that returns ``None`` (no channel) or an in-memory pair.
|
||||
"""
|
||||
self.url = url
|
||||
self.group = group
|
||||
self._config_dir = config_dir
|
||||
self._channel_factory = channel_factory or self._default_channel_factory
|
||||
self._shutdown: asyncio.Event | None = None
|
||||
self._ready: asyncio.Event | None = None
|
||||
self._channel: Channel | None = None
|
||||
self._flow_runner: FlowRunner | None = None
|
||||
self._entry_runner: EntryRunner | None = None
|
||||
self._entity_bridge: EntityBridge | None = None
|
||||
self._service_mirror: ServiceMirror | None = None
|
||||
self._event_mirror: EventMirror | None = None
|
||||
self._approved = ApprovedDomains()
|
||||
|
||||
@property
|
||||
def started(self) -> bool:
|
||||
"""Whether ``run()`` has initialised its shutdown event."""
|
||||
return self._shutdown is not None
|
||||
|
||||
async def wait_until_ready(self, *, timeout: float = 5.0) -> None:
|
||||
"""Block until all channel handlers have been registered.
|
||||
|
||||
``started`` flips to True very early in :meth:`run` (right after
|
||||
the SIGTERM hook); tests that want to issue a channel call need
|
||||
to wait until the runtime has finished registering every
|
||||
handler. This event is set right before ``run`` awaits the
|
||||
shutdown signal.
|
||||
"""
|
||||
if self._ready is None:
|
||||
raise RuntimeError("SandboxRuntime.run() has not been entered yet")
|
||||
await asyncio.wait_for(self._ready.wait(), timeout=timeout)
|
||||
|
||||
@property
|
||||
def channel(self) -> Channel | None:
|
||||
"""The runtime's control channel, once ``run()`` has started it."""
|
||||
return self._channel
|
||||
|
||||
def request_shutdown(self) -> None:
|
||||
"""Request a graceful shutdown of the runtime."""
|
||||
if self._shutdown is None:
|
||||
raise RuntimeError("SandboxRuntime.run() has not been entered yet")
|
||||
self._shutdown.set()
|
||||
|
||||
async def run(self) -> int:
|
||||
"""Run until SIGTERM/SIGINT/shutdown-call arrives. Returns exit code."""
|
||||
loop = asyncio.get_running_loop()
|
||||
self._shutdown = asyncio.Event()
|
||||
self._ready = asyncio.Event()
|
||||
for sig in (signal.SIGTERM, signal.SIGINT):
|
||||
with contextlib.suppress(NotImplementedError):
|
||||
loop.add_signal_handler(sig, self._shutdown.set)
|
||||
|
||||
_LOGGER.info("sandbox runtime ready (group=%s url=%s)", self.group, self.url)
|
||||
|
||||
# Set up the HA instance + flow runner before the marker so the
|
||||
# first manager call after the handshake cannot race.
|
||||
cleanup_tempdir: tempfile.TemporaryDirectory[str] | None = None
|
||||
config_dir = self._config_dir
|
||||
if config_dir is None:
|
||||
cleanup_tempdir = tempfile.TemporaryDirectory(
|
||||
prefix=f"sandbox_{self.group}_"
|
||||
)
|
||||
config_dir = cleanup_tempdir.name
|
||||
|
||||
self._flow_runner = await FlowRunner.create(config_dir=config_dir)
|
||||
hass = self._flow_runner.hass
|
||||
self._entry_runner = EntryRunner(hass, self._approved)
|
||||
self._entity_bridge = EntityBridge(hass, self._approved)
|
||||
self._service_mirror = ServiceMirror(hass, self._approved)
|
||||
self._event_mirror = EventMirror(hass, self._approved)
|
||||
|
||||
self._channel = await self._channel_factory()
|
||||
sandbox_token: Any = None
|
||||
if self._channel is not None:
|
||||
# Route every `Store` IO to main via `current_sandbox`. The
|
||||
# contextvar is read at call time by `Store.async_load/save/
|
||||
# remove`, so it reaches Stores no matter how they imported the
|
||||
# class — including the helpers that captured the original
|
||||
# `Store` at module load (restore_state, the registries). It is
|
||||
# set BEFORE the warm-load and before any handler registers, so
|
||||
# every coroutine the runtime spawns inherits it (asyncio copies
|
||||
# the context at `create_task` time).
|
||||
#
|
||||
# Ordering caveat (see the plan's touch-points audit): registries
|
||||
# whose `Store` is constructed AND first loaded inside
|
||||
# `FlowRunner.create` already ran their `async_load` against the
|
||||
# sandbox tempdir before this point, so they keep their local
|
||||
# file backing. `restore_state`'s `async_load` runs *after* this
|
||||
# set, so it routes to main — which is what we want. If a future
|
||||
# refactor moves a registry's first `async_load` to straddle this
|
||||
# line, that registry would silently start routing to main.
|
||||
assert current_sandbox.get() is None, (
|
||||
"current_sandbox already set — two sandbox runtimes sharing "
|
||||
"one event loop? (see plan Risk #3)"
|
||||
)
|
||||
sandbox_token = current_sandbox.set(ChannelSandboxBridge(self._channel))
|
||||
# Start the channel reader first so the warm-load
|
||||
# round-trip can resolve, then pre-load this sandbox group's
|
||||
# restore-state cache. The contextvar (set above) routes the
|
||||
# load to main. The data lives on main under
|
||||
# ``.storage/sandbox/<group>/core.restore_state`` and was
|
||||
# written by the previous run's shutdown handler. Bare HA —
|
||||
# no bootstrap — so we call it ourselves; any RestoreEntity
|
||||
# that registers during entry_setup will see its prior state
|
||||
# cached. Handlers register *after* the warm-load so no
|
||||
# entry_setup can arrive before the cache is populated.
|
||||
self._channel.start()
|
||||
# Signal readiness as the channel's first outbound frame — the
|
||||
# manager flips to "running" on its arrival. Sent before the
|
||||
# warm-load so the handshake timing matches the old stdout
|
||||
# marker (which was written before warm-load too).
|
||||
await self._channel.push(MSG_READY)
|
||||
await _load_restore_state(hass)
|
||||
self._channel.register("sandbox/ping", _handle_ping)
|
||||
self._channel.register(MSG_SHUTDOWN, self._handle_shutdown)
|
||||
self._channel.register(
|
||||
MSG_GET_TRANSLATIONS, self._handle_get_translations
|
||||
)
|
||||
self._flow_runner.register(self._channel)
|
||||
self._entry_runner.register(self._channel)
|
||||
self._entity_bridge.register(self._channel)
|
||||
self._service_mirror.register(self._channel)
|
||||
self._event_mirror.register(self._channel)
|
||||
|
||||
self._ready.set()
|
||||
try:
|
||||
await self._shutdown.wait()
|
||||
finally:
|
||||
_LOGGER.info("sandbox runtime shutting down (group=%s)", self.group)
|
||||
if self._event_mirror is not None:
|
||||
await self._event_mirror.async_stop()
|
||||
if self._service_mirror is not None:
|
||||
await self._service_mirror.async_stop()
|
||||
if self._entity_bridge is not None:
|
||||
await self._entity_bridge.async_stop()
|
||||
if self._channel is not None:
|
||||
await self._channel.close()
|
||||
if sandbox_token is not None:
|
||||
# Tidy test isolation; in prod the process exits anyway.
|
||||
current_sandbox.reset(sandbox_token)
|
||||
await self._flow_runner.async_stop()
|
||||
if cleanup_tempdir is not None:
|
||||
cleanup_tempdir.cleanup()
|
||||
return 0
|
||||
|
||||
async def _default_channel_factory(self) -> Channel:
|
||||
"""Open the control channel selected by the runtime's ``--url`` scheme.
|
||||
|
||||
* ``stdio://`` (or empty) — frames ride the process's stdin/stdout.
|
||||
* ``unix://<path>`` — dial back to the manager's unix socket.
|
||||
* ``ws://`` / ``wss://`` — reserved for the deferred websocket
|
||||
transport; rejected here with a clear error (this build ships
|
||||
stdio + unix only).
|
||||
"""
|
||||
kind = _transport_scheme(self.url)
|
||||
if kind == "unix":
|
||||
return await _open_unix_channel(
|
||||
self.url.removeprefix("unix://"), name=self.group
|
||||
)
|
||||
if kind == "ws":
|
||||
raise NotImplementedError(
|
||||
"websocket transport is not implemented in this build; it is "
|
||||
"reserved for the share-states work — use stdio:// or unix://"
|
||||
)
|
||||
return await _open_stdio_channel(name=self.group)
|
||||
|
||||
async def _handle_shutdown(self, _payload: object) -> pb.ShutdownResult:
|
||||
"""Unload entries, flush restore state, then exit cleanly.
|
||||
|
||||
Runs inside the channel dispatcher so the reply is written before
|
||||
the runtime starts its teardown. The actual shutdown event is set
|
||||
via ``call_soon`` so the reply lands on the wire first; ``run()``
|
||||
then exits on the next loop turn through the existing finally
|
||||
block (which closes the channel, stops mirrors, etc.).
|
||||
"""
|
||||
summary = await self._run_graceful_shutdown()
|
||||
if self._shutdown is not None:
|
||||
asyncio.get_running_loop().call_soon(self._shutdown.set)
|
||||
return summary
|
||||
|
||||
async def _handle_get_translations(
|
||||
self, msg: pb.GetTranslations
|
||||
) -> pb.GetTranslationsResult:
|
||||
"""Serve a main-side ``sandbox/get_translations`` pull.
|
||||
|
||||
Main holds no ``Integration`` for a custom sandboxed domain, so it
|
||||
cannot load the integration's ``translations/<lang>.json`` or run the
|
||||
``title``→``integration.name`` fallback. This sandbox does — it
|
||||
fetched and imported the code — so it loads the raw strings here and
|
||||
replies with the un-flattened nesting main's translation cache merges
|
||||
as-is.
|
||||
"""
|
||||
result = pb.GetTranslationsResult(language=msg.language)
|
||||
flow_runner = self._flow_runner
|
||||
if flow_runner is None:
|
||||
return result
|
||||
strings = await _collect_component_strings(
|
||||
flow_runner.hass, msg.language, list(msg.domains)
|
||||
)
|
||||
if strings:
|
||||
result.strings.update(strings)
|
||||
return result
|
||||
|
||||
async def _run_graceful_shutdown(self) -> pb.ShutdownResult:
|
||||
"""Unload every loaded entry and snapshot RestoreEntity state.
|
||||
|
||||
Fires ``EVENT_HOMEASSISTANT_FINAL_WRITE`` and waits for
|
||||
the bus to drain so ``Store``s with pending ``async_delay_save``
|
||||
writes flush to main via the ``current_sandbox`` bridge — the
|
||||
now-concurrent channel dispatcher means the re-entrant
|
||||
``MSG_STORE_SAVE`` call each flush issues no longer deadlocks
|
||||
against this handler.
|
||||
|
||||
Restore state is still **collected** (not flushed via the
|
||||
bridge) and returned in this reply: ``core.restore_state``
|
||||
is owned by the runtime's explicit warm-load / shutdown-dump path,
|
||||
not by an integration's ``Store``, so it doesn't ride the
|
||||
FINAL_WRITE flush. Shipping it back in the reply keeps the data
|
||||
path symmetric with the warm-load — main writes it via
|
||||
:meth:`SandboxBridge._handle_store_save`-style atomic write.
|
||||
"""
|
||||
flow_runner = self._flow_runner
|
||||
if flow_runner is None:
|
||||
return pb.ShutdownResult(ok=True, unloaded=0)
|
||||
|
||||
hass = flow_runner.hass
|
||||
unloaded = 0
|
||||
for entry in list(hass.config_entries.async_entries()):
|
||||
try:
|
||||
ok = await hass.config_entries.async_unload(entry.entry_id)
|
||||
except Exception:
|
||||
_LOGGER.exception(
|
||||
"sandbox %s: async_unload(%s) raised",
|
||||
self.group,
|
||||
entry.entry_id,
|
||||
)
|
||||
continue
|
||||
if ok:
|
||||
unloaded += 1
|
||||
|
||||
# Fire FINAL_WRITE so ``async_delay_save``-using
|
||||
# ``Store``s flush their pending data. Concurrent channel
|
||||
# dispatcher means each bridge write can re-enter the channel
|
||||
# without deadlocking against this handler.
|
||||
try:
|
||||
hass.set_state(CoreState.final_write)
|
||||
hass.bus.async_fire_internal(EVENT_HOMEASSISTANT_FINAL_WRITE)
|
||||
await hass.async_block_till_done()
|
||||
except Exception:
|
||||
_LOGGER.exception("sandbox %s: FINAL_WRITE flush failed", self.group)
|
||||
|
||||
result = pb.ShutdownResult(ok=True, unloaded=unloaded)
|
||||
try:
|
||||
restore_data = restore_state.async_get(hass)
|
||||
stored = restore_data.async_get_stored_states()
|
||||
if stored:
|
||||
# Coerce HA-specific types (Fragment / State / datetime)
|
||||
# to plain primitives by round-tripping through orjson.
|
||||
# ``prepare_save_json`` is the same serialiser ``Store``
|
||||
# uses on its way to disk; we just intercept the bytes.
|
||||
wrapped = {
|
||||
"version": restore_state.STORAGE_VERSION,
|
||||
"minor_version": 1,
|
||||
"key": restore_state.STORAGE_KEY,
|
||||
"data": [item.as_dict() for item in stored],
|
||||
}
|
||||
_mode, json_bytes = json_helper.prepare_save_json(wrapped, encoder=None)
|
||||
result.restore_state.update(json.loads(json_bytes))
|
||||
except Exception:
|
||||
_LOGGER.exception("sandbox %s: restore-state collect failed", self.group)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
async def _collect_component_strings(
|
||||
hass: HomeAssistant, language: str, domains: list[str]
|
||||
) -> dict[str, Any]:
|
||||
"""Load raw translation strings for ``domains`` from this sandbox's disk.
|
||||
|
||||
Resolves each domain's ``Integration`` against the sandbox-private
|
||||
``hass`` (built-in from the bundled package, custom from the fetched
|
||||
``<config>/custom_components/<domain>``) and reuses core's
|
||||
:func:`_async_get_component_strings`, which reads
|
||||
``translations/<language>.json`` and pre-fills ``title`` from
|
||||
``integration.name``. The return is ``{domain: <raw strings.json dict>}``
|
||||
for the requested language — the exact shape main's translation cache
|
||||
overlays. Domains the sandbox cannot resolve come back as ``{}`` (no
|
||||
Integration ⇒ no file, no title), which is harmless on main.
|
||||
"""
|
||||
if not domains:
|
||||
return {}
|
||||
components = set(domains)
|
||||
ints_or_excs = await async_get_integrations(hass, components)
|
||||
integrations = {
|
||||
domain: result
|
||||
for domain, result in ints_or_excs.items()
|
||||
if not isinstance(result, Exception)
|
||||
}
|
||||
by_language = await _async_get_component_strings(
|
||||
hass, [language], components, integrations
|
||||
)
|
||||
return by_language.get(language, {})
|
||||
|
||||
|
||||
async def _load_restore_state(hass: Any) -> None:
|
||||
"""Warm-load this sandbox's ``core.restore_state`` cache.
|
||||
|
||||
Calls :meth:`RestoreStateData.async_load` directly instead of
|
||||
:func:`restore_state.async_load`: the helper also wires up the
|
||||
periodic ``async_setup_dump`` listener via ``start.async_at_start``,
|
||||
which only fires on a fully-started HA. The sandbox's HA never goes
|
||||
through ``async_start``, so we skip that listener and rely on
|
||||
the shutdown handler to force the final dump.
|
||||
|
||||
No store swap is needed: ``RestoreStateData`` builds a vanilla
|
||||
``Store``, and ``Store.async_load`` reads ``current_sandbox`` at call
|
||||
time. Because the runtime set the contextvar before calling us, the
|
||||
load — and the later shutdown dump — round-trip through main no matter
|
||||
that ``restore_state.py`` captured the original ``Store`` reference at
|
||||
import time.
|
||||
"""
|
||||
data = restore_state.async_get(hass)
|
||||
try:
|
||||
await data.async_load()
|
||||
except Exception:
|
||||
_LOGGER.exception("sandbox: failed to pre-load core.restore_state")
|
||||
|
||||
|
||||
def _transport_scheme(url: str) -> str:
|
||||
"""Map a ``--url`` to its transport kind.
|
||||
|
||||
Returns ``"stdio"`` (empty / ``stdio://``), ``"unix"``
|
||||
(``unix://<path>``) or ``"ws"`` (``ws://`` / ``wss://``, reserved for
|
||||
the deferred websocket transport). Raises :class:`ValueError` for any
|
||||
other scheme.
|
||||
"""
|
||||
if not url:
|
||||
return "stdio"
|
||||
scheme = url.split("://", 1)[0] if "://" in url else url
|
||||
if scheme in ("", "stdio"):
|
||||
return "stdio"
|
||||
if scheme == "unix":
|
||||
return "unix"
|
||||
if scheme in ("ws", "wss"):
|
||||
return "ws"
|
||||
raise ValueError(f"unsupported sandbox transport url: {url!r}")
|
||||
|
||||
|
||||
async def _open_unix_channel(path: str, *, name: str) -> Channel:
|
||||
"""Connect to the manager's unix socket and wrap it in a :class:`Channel`.
|
||||
|
||||
The manager is the unix server; the runtime dials back here. Framing is
|
||||
the same length-prefixed :class:`~.channel.StreamTransport` the stdio
|
||||
path uses — a unix socket is just a different byte pipe under it, so no
|
||||
dedicated transport class is needed.
|
||||
"""
|
||||
reader, writer = await asyncio.open_unix_connection(path)
|
||||
return Channel(reader, writer, name=name, codec=ProtobufCodec())
|
||||
|
||||
|
||||
async def _open_stdio_channel(*, name: str) -> Channel:
|
||||
"""Wrap the runtime's stdin/stdout into a :class:`Channel`."""
|
||||
loop = asyncio.get_running_loop()
|
||||
reader = asyncio.StreamReader(loop=loop)
|
||||
await loop.connect_read_pipe(
|
||||
lambda: asyncio.StreamReaderProtocol(reader, loop=loop),
|
||||
os.fdopen(sys.stdin.fileno(), "rb"),
|
||||
)
|
||||
transport, protocol = await loop.connect_write_pipe(
|
||||
asyncio.streams.FlowControlMixin, # type: ignore[arg-type]
|
||||
os.fdopen(sys.stdout.fileno(), "wb"),
|
||||
)
|
||||
writer = asyncio.StreamWriter(transport, protocol, reader=None, loop=loop)
|
||||
return Channel(reader, writer, name=name, codec=ProtobufCodec())
|
||||
|
||||
|
||||
async def _handle_ping(_payload: object) -> pb.PingResult:
|
||||
"""Health-check handler — manager-side polling uses this round-trip."""
|
||||
return pb.PingResult(pong="sandbox")
|
||||
|
||||
|
||||
__all__ = ["SandboxRuntime"]
|
||||
@@ -0,0 +1,62 @@
|
||||
"""Entry point for ``python -m hass_client.sandbox``.
|
||||
|
||||
The Sandbox manager spawns this module as a subprocess. CLI arguments
|
||||
mirror what the websocket client needs so the manager-side command line
|
||||
is stable.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import logging
|
||||
import sys
|
||||
|
||||
from hass_client.sandbox import SandboxRuntime
|
||||
|
||||
|
||||
def _build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="python -m hass_client.sandbox",
|
||||
description="Sandbox runtime process.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--name",
|
||||
required=True,
|
||||
help="Sandbox name, e.g. built-in / custom / main",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--url",
|
||||
default="stdio://",
|
||||
help=(
|
||||
"Control-channel URL selecting the transport: stdio:// (default), "
|
||||
"unix://<path>, or ws://… (reserved — not implemented in this "
|
||||
"build)."
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--log-level",
|
||||
default="INFO",
|
||||
help="Python logging level for the runtime (default: INFO).",
|
||||
)
|
||||
return parser
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
"""Parse args, run the sandbox runtime, return the exit code."""
|
||||
args = _build_parser().parse_args(argv)
|
||||
logging.basicConfig(
|
||||
level=args.log_level,
|
||||
stream=sys.stderr,
|
||||
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
|
||||
)
|
||||
runtime = SandboxRuntime(
|
||||
url=args.url,
|
||||
group=args.name,
|
||||
)
|
||||
try:
|
||||
return asyncio.run(runtime.run())
|
||||
except KeyboardInterrupt:
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,100 @@
|
||||
"""Channel-backed :class:`SandboxBridge` for the sandbox runtime.
|
||||
|
||||
Implements ``homeassistant.helpers.sandbox_context.SandboxBridge`` over the
|
||||
control channel: the three ``Store`` IO methods delegate to main via the
|
||||
``MSG_STORE_LOAD`` / ``MSG_STORE_SAVE`` / ``MSG_STORE_REMOVE`` RPCs. Main
|
||||
namespaces every key as ``<config>/.storage/sandbox/<group>/<key>`` so
|
||||
two sandbox processes — or main itself — can't read each other's data.
|
||||
|
||||
The bodies are lifted from the pre-contextvar store subclass that
|
||||
this primitive replaced: same load semantics, same orjson preserialise on
|
||||
save, same channel error handling. The difference is *how* it's wired —
|
||||
``Store`` reads ``current_sandbox`` at call time instead of being rebound
|
||||
at module scope.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.helpers import json as json_helper
|
||||
from homeassistant.util.json import SerializationError
|
||||
|
||||
from ._proto import sandbox_pb2 as pb
|
||||
from .channel import Channel, ChannelClosedError, ChannelRemoteError
|
||||
from .messages import dict_to_struct, struct_to_dict
|
||||
from .protocol import MSG_STORE_LOAD, MSG_STORE_REMOVE, MSG_STORE_SAVE
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ChannelSandboxBridge:
|
||||
"""Route ``Store`` IO to main over a :class:`Channel`.
|
||||
|
||||
One bridge per sandbox runtime; the runtime sets it on
|
||||
``current_sandbox`` once ``run()`` opens the channel, and every
|
||||
``Store`` instance the sandbox builds resolves it at IO time.
|
||||
"""
|
||||
|
||||
def __init__(self, channel: Channel) -> None:
|
||||
"""Bind the bridge to the runtime's control channel."""
|
||||
self._channel = channel
|
||||
|
||||
async def async_store_load(self, key: str) -> Any:
|
||||
"""Fetch the wrapped envelope for ``key`` from main.
|
||||
|
||||
Returns the wrapped dict (``{"version", "minor_version", "key",
|
||||
"data"}``) so ``Store``'s migration loop runs against it unchanged,
|
||||
or ``None`` when main has no data / the channel is unavailable.
|
||||
"""
|
||||
try:
|
||||
result = await self._channel.call(MSG_STORE_LOAD, pb.StoreLoad(key=key))
|
||||
except ChannelClosedError:
|
||||
_LOGGER.warning("sandbox store[%s]: channel closed mid-load", key)
|
||||
return None
|
||||
except ChannelRemoteError as err:
|
||||
_LOGGER.warning("sandbox store[%s] load failed: %s", key, err)
|
||||
return None
|
||||
if not result.HasField("data"):
|
||||
return None
|
||||
return struct_to_dict(result.data)
|
||||
|
||||
async def async_store_save(self, key: str, data: Any) -> None:
|
||||
"""Push the wrapped payload to main instead of writing to disk.
|
||||
|
||||
``Store`` callers may hand us HA-specific types (``Fragment`` from
|
||||
``State.json_fragment``, ``set``/``tuple``, ``datetime``, ``Path``,
|
||||
``as_dict``-shaped objects). The channel transports plain JSON, so
|
||||
we run the payload through orjson's HA-aware encoder first and parse
|
||||
the resulting bytes back to primitives before handing it off — the
|
||||
same trip ``Store.async_save`` would take on its way to disk, just
|
||||
intercepted before the bytes hit a file.
|
||||
"""
|
||||
if "data_func" in data:
|
||||
data["data"] = data.pop("data_func")()
|
||||
try:
|
||||
_mode, json_bytes = json_helper.prepare_save_json(data, encoder=None)
|
||||
payload = json.loads(json_bytes)
|
||||
except SerializationError:
|
||||
_LOGGER.exception("sandbox store[%s]: payload not serialisable", key)
|
||||
return
|
||||
try:
|
||||
await self._channel.call(
|
||||
MSG_STORE_SAVE, pb.StoreSave(key=key, data=dict_to_struct(payload))
|
||||
)
|
||||
except ChannelClosedError:
|
||||
_LOGGER.warning("sandbox store[%s]: channel closed mid-save", key)
|
||||
except ChannelRemoteError as err:
|
||||
_LOGGER.error("sandbox store[%s] save failed: %s", key, err)
|
||||
|
||||
async def async_store_remove(self, key: str) -> None:
|
||||
"""Unlink ``key`` on main, not on local disk."""
|
||||
try:
|
||||
await self._channel.call(MSG_STORE_REMOVE, pb.StoreRemove(key=key))
|
||||
except ChannelClosedError:
|
||||
_LOGGER.warning("sandbox store[%s]: channel closed mid-remove", key)
|
||||
except ChannelRemoteError as err:
|
||||
_LOGGER.warning("sandbox store[%s] remove failed: %s", key, err)
|
||||
|
||||
|
||||
__all__ = ["ChannelSandboxBridge"]
|
||||
@@ -0,0 +1,65 @@
|
||||
"""Sandbox-side voluptuous schema serialisation.
|
||||
|
||||
The sandbox owns the real :class:`voluptuous.Schema` for every flow form
|
||||
and registered service. Main is the renderer / call site and needs a
|
||||
JSON-safe representation it can hand to the frontend (for forms) and to
|
||||
:meth:`hass.services.async_register` (for service-call validation). We
|
||||
reuse :func:`voluptuous_serialize.convert` with HA's
|
||||
:func:`cv.custom_serializer` so selectors and HA-specific types come out
|
||||
in the exact shape the frontend already understands.
|
||||
|
||||
The reverse path (build a usable :class:`vol.Schema` on main from a
|
||||
serialised list) lives in
|
||||
``homeassistant/components/sandbox/schema_bridge.py``.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
import voluptuous_serialize
|
||||
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def serialize_schema(schema: Any) -> list[dict[str, Any]] | None:
|
||||
"""Return a JSON-safe rendering of ``schema``.
|
||||
|
||||
Returns ``None`` for ``None``, an unhandled scalar (non-mapping)
|
||||
schema, or any schema serialisation fails on — that gives the caller
|
||||
a clear "no schema came across" signal rather than partial nonsense
|
||||
or a crash. Mapping schemas come out as the list-of-fields shape the
|
||||
HA frontend already renders.
|
||||
|
||||
The fallback is deliberately broad. A registered service or flow form
|
||||
may carry a schema with an exotic custom validator that
|
||||
``voluptuous_serialize`` chokes on in ways beyond ``ValueError`` /
|
||||
``TypeError`` (a validator raising ``vol.Invalid``, ``AttributeError``,
|
||||
a library-specific exception, …). Letting any of those propagate would
|
||||
drop the whole ``register_service`` / flow push, so main would never
|
||||
learn the service/form exists. Degrading to ``schema=None`` instead
|
||||
keeps the registration: main installs the service with no schema and
|
||||
the sandbox's own handler still runs full validation when the call
|
||||
lands. We log the failure (with the schema repr) so a genuinely
|
||||
unserialisable schema is visible rather than silently lossy.
|
||||
"""
|
||||
if schema is None:
|
||||
return None
|
||||
try:
|
||||
rendered = voluptuous_serialize.convert(
|
||||
schema, custom_serializer=cv.custom_serializer
|
||||
)
|
||||
except Exception: # noqa: BLE001 — any serialise failure must degrade, not drop
|
||||
_LOGGER.warning(
|
||||
"Schema did not survive serialisation; main falls back to no schema "
|
||||
"(sandbox still validates). Schema: %r",
|
||||
schema,
|
||||
)
|
||||
return None
|
||||
if not isinstance(rendered, list):
|
||||
return None
|
||||
return rendered
|
||||
|
||||
|
||||
__all__ = ["serialize_schema"]
|
||||
@@ -0,0 +1,189 @@
|
||||
"""Sandbox-side service-registration mirror.
|
||||
|
||||
Watches ``EVENT_SERVICE_REGISTERED`` / ``EVENT_SERVICE_REMOVED`` on the
|
||||
sandbox bus. For each registration whose domain is in
|
||||
:class:`ApprovedDomains`, it pushes ``sandbox/register_service`` to
|
||||
main with the metadata main needs to install a forwarding handler. Same
|
||||
shape for removals via ``sandbox/unregister_service``.
|
||||
|
||||
Schemas are intentionally not serialised — the sandbox is the
|
||||
authoritative validator (the call comes back over
|
||||
``sandbox/call_service`` and is run through ``services.async_call``
|
||||
on the sandbox side, where the real schema lives). Main only needs
|
||||
``supports_response`` so it can register the proxy with the right return
|
||||
shape; the proxy handler forwards everything else verbatim.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.const import (
|
||||
ATTR_DOMAIN,
|
||||
ATTR_SERVICE,
|
||||
EVENT_SERVICE_REGISTERED,
|
||||
EVENT_SERVICE_REMOVED,
|
||||
)
|
||||
from homeassistant.core import Event, HomeAssistant, callback
|
||||
|
||||
from ._proto import sandbox_pb2 as pb
|
||||
from .approved_domains import ApprovedDomains
|
||||
from .channel import Channel
|
||||
from .protocol import MSG_REGISTER_SERVICE, MSG_UNREGISTER_SERVICE
|
||||
from .schema_bridge import serialize_schema
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ServiceMirror:
|
||||
"""Forward sandbox-side service registrations up to main.
|
||||
|
||||
One instance per sandbox process. Lifetime is bound to the
|
||||
:class:`Channel` it was registered against: :meth:`register` attaches
|
||||
the bus listeners, :meth:`async_stop` detaches them.
|
||||
"""
|
||||
|
||||
def __init__(self, hass: HomeAssistant, approved: ApprovedDomains) -> None:
|
||||
"""Initialise with the sandbox HA and the shared approved-domains gate."""
|
||||
self.hass = hass
|
||||
self.approved = approved
|
||||
self._channel: Channel | None = None
|
||||
self._unsub_registered: Any = None
|
||||
self._unsub_removed: Any = None
|
||||
# Track what we've pushed so we don't double-register on the
|
||||
# main side if EVENT_SERVICE_REGISTERED fires twice for the same
|
||||
# (domain, service).
|
||||
self._mirrored: set[tuple[str, str]] = set()
|
||||
|
||||
def register(self, channel: Channel) -> None:
|
||||
"""Capture ``channel`` and start watching the service registry."""
|
||||
self._channel = channel
|
||||
self._unsub_registered = self.hass.bus.async_listen(
|
||||
EVENT_SERVICE_REGISTERED, self._on_service_registered
|
||||
)
|
||||
self._unsub_removed = self.hass.bus.async_listen(
|
||||
EVENT_SERVICE_REMOVED, self._on_service_removed
|
||||
)
|
||||
|
||||
async def async_stop(self) -> None:
|
||||
"""Detach the bus listeners."""
|
||||
if self._unsub_registered is not None:
|
||||
self._unsub_registered()
|
||||
self._unsub_registered = None
|
||||
if self._unsub_removed is not None:
|
||||
self._unsub_removed()
|
||||
self._unsub_removed = None
|
||||
|
||||
@callback
|
||||
def _on_service_registered(self, event: Event) -> None:
|
||||
if self._channel is None or self._channel.closed:
|
||||
return
|
||||
domain = str(event.data[ATTR_DOMAIN])
|
||||
service = str(event.data[ATTR_SERVICE])
|
||||
if not self.approved.approves(domain):
|
||||
_LOGGER.warning(
|
||||
"ServiceMirror: refusing to mirror %s.%s — domain not approved"
|
||||
" for this sandbox (approved=%s)",
|
||||
domain,
|
||||
service,
|
||||
sorted(self.approved.domains),
|
||||
)
|
||||
return
|
||||
key = (domain.lower(), service.lower())
|
||||
if key in self._mirrored:
|
||||
return
|
||||
supports_response = _supports_response(self.hass, domain, service)
|
||||
msg = pb.RegisterService(
|
||||
domain=domain,
|
||||
service=service,
|
||||
supports_response=supports_response,
|
||||
)
|
||||
schema = _service_schema(self.hass, domain, service)
|
||||
if schema:
|
||||
msg.schema.extend(schema)
|
||||
self._mirrored.add(key)
|
||||
asyncio.create_task( # noqa: RUF006
|
||||
self._push_register(msg, key),
|
||||
name=f"sandbox:register_service:{domain}.{service}",
|
||||
)
|
||||
|
||||
@callback
|
||||
def _on_service_removed(self, event: Event) -> None:
|
||||
if self._channel is None or self._channel.closed:
|
||||
return
|
||||
domain = str(event.data[ATTR_DOMAIN])
|
||||
service = str(event.data[ATTR_SERVICE])
|
||||
key = (domain.lower(), service.lower())
|
||||
if key not in self._mirrored:
|
||||
return
|
||||
self._mirrored.discard(key)
|
||||
msg = pb.UnregisterService(domain=domain, service=service)
|
||||
asyncio.create_task( # noqa: RUF006
|
||||
self._push_unregister(msg),
|
||||
name=f"sandbox:unregister_service:{domain}.{service}",
|
||||
)
|
||||
|
||||
async def _push_register(
|
||||
self, msg: pb.RegisterService, key: tuple[str, str]
|
||||
) -> None:
|
||||
assert self._channel is not None
|
||||
try:
|
||||
await self._channel.call(MSG_REGISTER_SERVICE, msg)
|
||||
except Exception:
|
||||
_LOGGER.exception(
|
||||
"ServiceMirror: register failed for %s.%s",
|
||||
msg.domain,
|
||||
msg.service,
|
||||
)
|
||||
# Roll back the mirrored bookkeeping so a retry can succeed.
|
||||
self._mirrored.discard(key)
|
||||
|
||||
async def _push_unregister(self, msg: pb.UnregisterService) -> None:
|
||||
assert self._channel is not None
|
||||
try:
|
||||
await self._channel.call(MSG_UNREGISTER_SERVICE, msg)
|
||||
except Exception:
|
||||
_LOGGER.exception(
|
||||
"ServiceMirror: unregister failed for %s.%s",
|
||||
msg.domain,
|
||||
msg.service,
|
||||
)
|
||||
|
||||
|
||||
def _service_schema(
|
||||
hass: HomeAssistant, domain: str, service: str
|
||||
) -> list[dict[str, Any]] | None:
|
||||
"""Serialise the registered service's voluptuous schema for the wire.
|
||||
|
||||
Returns ``None`` when the service registers with no schema (very
|
||||
common), when the schema doesn't survive voluptuous_serialize, or
|
||||
when the lookup races and the service isn't visible yet — in every
|
||||
case main falls back to ``schema=None`` and the sandbox's own
|
||||
handler still validates.
|
||||
"""
|
||||
services = hass.services.async_services_for_domain(domain)
|
||||
service_obj = services.get(service.lower())
|
||||
if service_obj is None:
|
||||
return None
|
||||
return serialize_schema(service_obj.schema)
|
||||
|
||||
|
||||
def _supports_response(hass: HomeAssistant, domain: str, service: str) -> str:
|
||||
"""Best-effort lookup of the service's ``supports_response`` value.
|
||||
|
||||
Returns the lowercase string value (``"none"`` / ``"only"`` /
|
||||
``"optional"``) since that's what main needs to pass back to
|
||||
:meth:`hass.services.async_register`. Falls back to ``"none"`` if
|
||||
the service isn't actually registered yet (a race with the
|
||||
``EVENT_SERVICE_REGISTERED`` listener) — the lookup is best-effort
|
||||
and main treats the metadata as authoritative.
|
||||
"""
|
||||
services = hass.services.async_services_for_domain(domain)
|
||||
service_obj = services.get(service.lower())
|
||||
if service_obj is None:
|
||||
return "none"
|
||||
value = getattr(service_obj.supports_response, "value", None)
|
||||
return str(value).lower() if value is not None else "none"
|
||||
|
||||
|
||||
__all__ = ["ServiceMirror"]
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user