mirror of
https://github.com/home-assistant/core.git
synced 2026-06-03 18:03:43 +02:00
Compare commits
78 Commits
2026.6.0b4
...
sandbox
| Author | SHA1 | Date | |
|---|---|---|---|
| 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_v2 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-v2-proto-drift` or in a CI lane.
|
||||
- id: sandbox-v2-proto-drift
|
||||
name: sandbox_v2 protobuf gencode drift guard
|
||||
entry: sandbox_v2/proto/check_drift.sh
|
||||
language: script
|
||||
pass_filenames: false
|
||||
stages: [manual]
|
||||
files: ^sandbox_v2/proto/sandbox_v2\.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_v2): 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
|
||||
|
||||
@@ -475,6 +475,10 @@ class AuthStore:
|
||||
else:
|
||||
last_used_at = None
|
||||
|
||||
# Silently drop the legacy ``scopes`` key written by the
|
||||
# reverted Phase-7 sandbox auth-scoping mechanism. Pre-existing
|
||||
# on-disk tokens may still carry it; it is no longer read.
|
||||
rt_dict.pop("scopes", None)
|
||||
token = models.RefreshToken(
|
||||
id=rt_dict["id"],
|
||||
user=users[rt_dict["user_id"]],
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
"""Sandbox — run integrations in isolated subprocesses.
|
||||
|
||||
The integration owns three runtime objects, all hung off
|
||||
:class:`SandboxV2Data`:
|
||||
|
||||
* :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`` (Phase 4). 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.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_V2, DOMAIN
|
||||
from .manager import SandboxManager
|
||||
from .router import SandboxFlowRouter
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN)
|
||||
|
||||
|
||||
@dataclass
|
||||
class SandboxV2Data:
|
||||
"""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 = SandboxV2Data()
|
||||
hass.data[DATA_SANDBOX_V2] = 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 (Phase 9).
|
||||
|
||||
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
|
||||
|
||||
async def _on_stop(_event: Event) -> None:
|
||||
"""Stop every sandbox process on HA shutdown.
|
||||
|
||||
Phase 9: 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
|
||||
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,445 @@
|
||||
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 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,939 @@
|
||||
"""Main-side bridge — owns the per-sandbox entity registry + outbound dispatch.
|
||||
|
||||
Responsibilities (Phase 5):
|
||||
|
||||
* 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. The forwarder coalesces calls
|
||||
made within the same event-loop tick using
|
||||
:class:`_CallServiceBatcher` so a 200-entity area call pays one RPC
|
||||
instead of 200.
|
||||
* Translate sandbox-side exceptions back into the exception types proxy
|
||||
callers would have raised locally (``vol.Invalid`` → ``TypeError``,
|
||||
unknown service / entity → ``HomeAssistantError``).
|
||||
|
||||
Phase 8 adds the Store routing handlers (``sandbox/store_load`` /
|
||||
``store_save`` / ``store_remove``). A per-group :class:`_SandboxStoreServer`
|
||||
backs them, 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.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
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_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 _CallServiceBatcher:
|
||||
"""Per-loop-tick coalescer keyed by (domain, service, frozen kwargs).
|
||||
|
||||
Proxy entities call :meth:`enqueue` for every method invocation. The
|
||||
batcher gathers everything that arrived this tick, fires one
|
||||
``sandbox/call_service`` per (domain, service, kwargs-shape) bucket
|
||||
with a multi-entity ``target.entity_id`` list, and resolves all the
|
||||
waiting futures with the same response.
|
||||
|
||||
Kwargs are not hashable (they include nested dicts/lists), so the key
|
||||
is the JSON-canonical form of the kwargs dict. Only entities that
|
||||
happen to use *identical* kwargs collapse into one RPC, which matches
|
||||
how an area call resolves: HA applies the same kwargs to every
|
||||
targeted entity.
|
||||
"""
|
||||
|
||||
def __init__(self, bridge: SandboxBridge) -> None:
|
||||
"""Initialise the batcher with its owning bridge."""
|
||||
self._bridge = bridge
|
||||
self._buckets: dict[tuple[str, str, str], _BatchBucket] = {}
|
||||
self._flush_handle: asyncio.Handle | None = None
|
||||
|
||||
async def enqueue(
|
||||
self,
|
||||
*,
|
||||
domain: str,
|
||||
service: str,
|
||||
sandbox_entity_id: str,
|
||||
service_data: dict[str, Any],
|
||||
context_id: str | None = None,
|
||||
return_response: bool = False,
|
||||
) -> Any:
|
||||
"""Queue one entity into the next batched ``call_service`` RPC."""
|
||||
import json # noqa: PLC0415 — local import keeps json off integration boot path
|
||||
|
||||
kwargs_key = json.dumps(
|
||||
service_data, sort_keys=True, separators=(",", ":"), default=str
|
||||
)
|
||||
bucket_key = (domain, service, kwargs_key)
|
||||
bucket = self._buckets.get(bucket_key)
|
||||
if bucket is None:
|
||||
future: asyncio.Future[Any] = asyncio.get_running_loop().create_future()
|
||||
bucket = _BatchBucket(
|
||||
domain=domain,
|
||||
service=service,
|
||||
service_data=service_data,
|
||||
context_id=context_id,
|
||||
return_response=return_response,
|
||||
future=future,
|
||||
)
|
||||
self._buckets[bucket_key] = bucket
|
||||
bucket.sandbox_entity_ids.append(sandbox_entity_id)
|
||||
self._schedule_flush()
|
||||
return await bucket.future
|
||||
|
||||
def _schedule_flush(self) -> None:
|
||||
if self._flush_handle is not None:
|
||||
return
|
||||
loop = asyncio.get_running_loop()
|
||||
self._flush_handle = loop.call_soon(self._flush)
|
||||
|
||||
def _flush(self) -> None:
|
||||
self._flush_handle = None
|
||||
buckets = self._buckets
|
||||
self._buckets = {}
|
||||
for bucket in buckets.values():
|
||||
asyncio.create_task( # noqa: RUF006 — fire-and-forget; bucket.future is the join point
|
||||
self._dispatch(bucket), name="sandbox:call_service:flush"
|
||||
)
|
||||
|
||||
async def _dispatch(self, bucket: _BatchBucket) -> None:
|
||||
try:
|
||||
result = await self._bridge._raw_call_service( # noqa: SLF001
|
||||
domain=bucket.domain,
|
||||
service=bucket.service,
|
||||
target={"entity_id": bucket.sandbox_entity_ids},
|
||||
service_data=bucket.service_data,
|
||||
context_id=bucket.context_id,
|
||||
return_response=bucket.return_response,
|
||||
)
|
||||
except BaseException as err: # noqa: BLE001
|
||||
if not bucket.future.done():
|
||||
bucket.future.set_exception(err)
|
||||
return
|
||||
if not bucket.future.done():
|
||||
bucket.future.set_result(result)
|
||||
|
||||
|
||||
@dataclass
|
||||
class _BatchBucket:
|
||||
"""One coalesced ``sandbox/call_service`` invocation in flight."""
|
||||
|
||||
domain: str
|
||||
service: str
|
||||
service_data: dict[str, Any]
|
||||
context_id: str | None
|
||||
return_response: bool
|
||||
future: asyncio.Future[Any]
|
||||
sandbox_entity_ids: list[str] = field(default_factory=list)
|
||||
|
||||
|
||||
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._batcher = _CallServiceBatcher(self)
|
||||
|
||||
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.
|
||||
|
||||
Calls made in the same tick with matching ``(domain, service,
|
||||
service_data)`` coalesce into a single RPC with a multi-entity
|
||||
target.
|
||||
|
||||
``context`` is the main-side Context driving the entity call. It is
|
||||
remembered here (before the batcher reduces it to a bare id) 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.
|
||||
"""
|
||||
self._remember_context(context)
|
||||
return await self._batcher.enqueue(
|
||||
domain=domain,
|
||||
service=service,
|
||||
sandbox_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 _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`` (Phase 8)."""
|
||||
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 (Phase 8)."""
|
||||
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:
|
||||
request = pb.CallService(
|
||||
domain=domain,
|
||||
service=service,
|
||||
service_data=dict_to_struct(dict(call.data)),
|
||||
target=dict_to_struct(_target_from_call(call)),
|
||||
return_response=call.return_response,
|
||||
)
|
||||
if call.context is not None:
|
||||
# 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
|
||||
request.context_id = call.context.id
|
||||
try:
|
||||
response = await bridge.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 {bridge.group!r} channel closed during {domain}.{service}"
|
||||
) from err
|
||||
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,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 (Phase 4) and by config-entry setup interception
|
||||
(Phase 4) — 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,109 @@
|
||||
"""Constants for the Sandbox integration."""
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from homeassistant.util.hass_dict import HassKey
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from . import SandboxV2Data
|
||||
|
||||
DOMAIN = "sandbox"
|
||||
|
||||
DATA_SANDBOX_V2: HassKey[SandboxV2Data] = 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",
|
||||
}
|
||||
)
|
||||
|
||||
# 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
|
||||
# in the Phase 1 decision doc — 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,267 @@
|
||||
"""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.
|
||||
|
||||
Phase 5 ships proxies for the small "rich" set the spike and tests
|
||||
exercise. The remaining domains from the v1 list use the same mechanical
|
||||
pattern — see ``plan.md`` Phase 5's deferral note.
|
||||
"""
|
||||
|
||||
import contextlib
|
||||
from enum import IntFlag
|
||||
from typing import TYPE_CHECKING, Any, cast
|
||||
|
||||
from homeassistant.const import EntityCategory
|
||||
from homeassistant.core import Context
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.entity import Entity
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..bridge import SandboxBridge, SandboxEntityDescription
|
||||
|
||||
|
||||
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, **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 coalesces calls made in
|
||||
the same tick into a single multi-entity RPC.
|
||||
|
||||
``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.
|
||||
"""
|
||||
return 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,
|
||||
)
|
||||
|
||||
|
||||
# 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,
|
||||
todo,
|
||||
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,
|
||||
"todo": todo.SandboxTodoListEntity,
|
||||
"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",
|
||||
]
|
||||
@@ -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,32 @@
|
||||
"""Sandbox proxy for ``calendar`` entities."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.components.calendar import CalendarEntity, CalendarEvent
|
||||
|
||||
from . import SandboxProxyEntity
|
||||
|
||||
|
||||
# pylint: disable-next=home-assistant-enforce-class-module
|
||||
class SandboxCalendarEntity(SandboxProxyEntity, CalendarEntity):
|
||||
"""Proxy for a ``calendar`` entity in a sandbox.
|
||||
|
||||
Calendar service calls go through the standard ``calendar.*`` service
|
||||
handlers; the listing/iteration APIs are server-side queries we don't
|
||||
proxy in Phase 13 (no test infra exercises them yet).
|
||||
"""
|
||||
|
||||
@property
|
||||
def event(self) -> CalendarEvent | None:
|
||||
"""Return ``None`` — listings are only fetched through service calls."""
|
||||
return None
|
||||
|
||||
async def async_get_events(
|
||||
self, hass: Any, start_date: Any, end_date: Any
|
||||
) -> list[CalendarEvent]:
|
||||
"""No-op — listing happens via the sandbox-side service handler."""
|
||||
return []
|
||||
|
||||
async def async_create_event(self, **kwargs: Any) -> None:
|
||||
"""Forward create as ``calendar.create_event``."""
|
||||
await self._call_service("create_event", **kwargs)
|
||||
@@ -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,228 @@
|
||||
"""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,
|
||||
MediaPlayerEntity,
|
||||
MediaPlayerEntityFeature,
|
||||
MediaPlayerState,
|
||||
RepeatMode,
|
||||
)
|
||||
|
||||
from . import SandboxProxyEntity
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..bridge import SandboxBridge, SandboxEntityDescription
|
||||
|
||||
|
||||
# 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_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 — Phase 13
|
||||
covers the full set 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,53 @@
|
||||
"""Sandbox proxy for ``todo`` entities."""
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from homeassistant.components.todo import (
|
||||
TodoItem,
|
||||
TodoListEntity,
|
||||
TodoListEntityFeature,
|
||||
)
|
||||
|
||||
from . import SandboxProxyEntity
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..bridge import SandboxBridge, SandboxEntityDescription
|
||||
|
||||
|
||||
# pylint: disable-next=home-assistant-enforce-class-module
|
||||
class SandboxTodoListEntity(SandboxProxyEntity, TodoListEntity):
|
||||
"""Proxy for a ``todo`` (To-do list) entity in a sandbox."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
bridge: SandboxBridge,
|
||||
description: SandboxEntityDescription,
|
||||
) -> None:
|
||||
"""Wrap ``supported_features`` as ``TodoListEntityFeature``."""
|
||||
super().__init__(bridge, description)
|
||||
self._attr_supported_features = TodoListEntityFeature(
|
||||
description.supported_features or 0
|
||||
)
|
||||
|
||||
@property
|
||||
def todo_items(self) -> list[TodoItem] | None:
|
||||
"""Item iteration happens on the sandbox side; do not proxy items."""
|
||||
# The Phase-13 proxy only mirrors state + service calls. Listing
|
||||
# items is a server-side query that needs the same bridge plumbing
|
||||
# ``calendar`` does and is deferred until those operations get a
|
||||
# cross-process protocol (out of scope for this phase).
|
||||
return None
|
||||
|
||||
async def async_create_todo_item(self, item: TodoItem) -> None:
|
||||
"""Forward create as ``todo.add_item``."""
|
||||
await self._call_service("add_item", item=item.summary)
|
||||
|
||||
async def async_update_todo_item(self, item: TodoItem) -> None:
|
||||
"""Forward update as ``todo.update_item``."""
|
||||
await self._call_service(
|
||||
"update_item", item=item.uid or item.summary, rename=item.summary
|
||||
)
|
||||
|
||||
async def async_delete_todo_items(self, uids: list[str]) -> None:
|
||||
"""Forward delete as ``todo.remove_item``."""
|
||||
await self._call_service("remove_item", item=uids)
|
||||
@@ -0,0 +1,99 @@
|
||||
"""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)
|
||||
@@ -0,0 +1,93 @@
|
||||
"""Sandbox proxy for ``vacuum`` entities."""
|
||||
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from homeassistant.components.vacuum import (
|
||||
ATTR_FAN_SPEED,
|
||||
ATTR_FAN_SPEED_LIST,
|
||||
StateVacuumEntity,
|
||||
VacuumActivity,
|
||||
VacuumEntityFeature,
|
||||
)
|
||||
|
||||
from . import SandboxProxyEntity
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..bridge import SandboxBridge, SandboxEntityDescription
|
||||
|
||||
|
||||
# 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)
|
||||
@@ -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,82 @@
|
||||
"""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,
|
||||
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.
|
||||
|
||||
Forecasts are computed by the sandbox-side ``WeatherEntity`` and
|
||||
pushed through the ``weather.get_forecasts`` service path, not over
|
||||
the entity-method bridge — Phase 13 only proxies the condition +
|
||||
instantaneous attributes.
|
||||
"""
|
||||
|
||||
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)
|
||||
@@ -0,0 +1,686 @@
|
||||
"""Sandbox — subprocess lifecycle and supervision.
|
||||
|
||||
Phase 3 building block. The manager owns one supervised subprocess per
|
||||
sandbox group (``main`` / ``built-in`` / ``custom``); higher phases call
|
||||
: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 SandboxV2Error(Exception):
|
||||
"""Base class for sandbox lifecycle errors."""
|
||||
|
||||
|
||||
class SandboxStartError(SandboxV2Error):
|
||||
"""Sandbox did not reach the ``running`` state."""
|
||||
|
||||
|
||||
class SandboxFailedError(SandboxV2Error):
|
||||
"""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` (Phase 9) 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:
|
||||
"""Phase 9: 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; Phase 4's 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:
|
||||
"""Phase 9: 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",
|
||||
"SandboxFailedError",
|
||||
"SandboxManager",
|
||||
"SandboxProcess",
|
||||
"SandboxStartError",
|
||||
"SandboxV2Error",
|
||||
"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,222 @@
|
||||
"""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/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,124 @@
|
||||
"""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
|
||||
Phase 6's 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 → 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 (Phase 19): 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`` (Phase 6) — 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`` (Phase 6) — symmetric counterpart.
|
||||
* ``sandbox/fire_event`` (Phase 6) — 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`` (Phase 8) — 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`` (Phase 8) — 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`` (Phase 8) — sandbox-side
|
||||
``Store.async_remove``. Payload ``{"key": str}``; main unlinks the
|
||||
file (if any). Response is ``{"ok": True}``.
|
||||
|
||||
Main → Sandbox shutdown (Phase 9):
|
||||
|
||||
* ``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_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_ENTRY_SETUP",
|
||||
"MSG_ENTRY_UNLOAD",
|
||||
"MSG_FIRE_EVENT",
|
||||
"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,284 @@
|
||||
"""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
|
||||
|
||||
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
|
||||
# explicitly out of Phase 4 scope; 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 (Phase 4 supports FORM/CREATE_ENTRY/ABORT only)",
|
||||
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,232 @@
|
||||
"""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.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 SandboxV2Data
|
||||
|
||||
_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: SandboxV2Data | 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
|
||||
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"
|
||||
}
|
||||
@@ -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
|
||||
@@ -285,6 +285,7 @@ UPDATE_ENTRY_CONFIG_ENTRY_ATTRS = {
|
||||
"pref_disable_polling",
|
||||
"minor_version",
|
||||
"version",
|
||||
"sandbox",
|
||||
}
|
||||
|
||||
|
||||
@@ -309,6 +310,7 @@ class ConfigFlowResult(FlowResult[ConfigFlowContext, str], total=False):
|
||||
minor_version: int
|
||||
options: Mapping[str, Any]
|
||||
result: ConfigEntry
|
||||
sandbox: str
|
||||
subentries: Iterable[ConfigSubentryData]
|
||||
version: int
|
||||
|
||||
@@ -425,6 +427,7 @@ class ConfigEntry[_DataT = Any]:
|
||||
created_at: datetime
|
||||
modified_at: datetime
|
||||
discovery_keys: MappingProxyType[str, tuple[DiscoveryKey, ...]]
|
||||
sandbox: str | None
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -440,6 +443,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,
|
||||
@@ -557,6 +561,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 (
|
||||
@@ -1189,7 +1198,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),
|
||||
@@ -1207,6 +1216,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(
|
||||
@@ -1781,6 +1795,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"],
|
||||
@@ -1817,12 +1832,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
|
||||
@@ -2080,6 +2103,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.
|
||||
|
||||
@@ -2095,6 +2142,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
|
||||
@@ -2287,6 +2336,8 @@ class ConfigEntries:
|
||||
options=entry["options"],
|
||||
pref_disable_new_entities=entry["pref_disable_new_entities"],
|
||||
pref_disable_polling=entry["pref_disable_polling"],
|
||||
# Optional — pre-Phase-17 entries don't carry this key.
|
||||
sandbox=entry.get("sandbox"),
|
||||
source=entry["source"],
|
||||
subentries_data=entry["subentries"],
|
||||
title=entry["title"],
|
||||
@@ -2362,6 +2413,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:
|
||||
@@ -2393,6 +2449,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)
|
||||
@@ -2493,6 +2557,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,
|
||||
@@ -2513,6 +2578,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,
|
||||
@@ -2531,6 +2597,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,
|
||||
@@ -2581,6 +2648,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)
|
||||
|
||||
@@ -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.13"
|
||||
# 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
@@ -1860,6 +1860,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,351 @@
|
||||
# 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, and
|
||||
events 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 and events available on main — with the integration
|
||||
code only ever executing inside the sandbox.
|
||||
|
||||
## 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). |
|
||||
|
||||
### 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).
|
||||
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 (§11) 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 **32** domains
|
||||
have one under `entity/`) and attaches it via the
|
||||
`EntityComponent.async_register_remote_platform` core hook. Outbound proxy
|
||||
calls coalesce through a per-loop-tick batcher: a 200-light area call pays one
|
||||
RPC, not 200. 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.
|
||||
|
||||
**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 needs no
|
||||
credential to call into HA. The `--token` the manager once passed was **never
|
||||
used** (the runtime stored it and nothing read it), so it has been **dropped
|
||||
end-to-end** (`plans/plan-auth-context.md`): no `--token` argv, no
|
||||
`SandboxRuntime.token`, no `SANDBOX_TOKEN` env. When a real websocket consumer
|
||||
lands, the credential is redesigned then — fresh, scopes included — per the
|
||||
SUPERSEDED [`docs/auth-scoping-decision.md`](docs/auth-scoping-decision.md).
|
||||
|
||||
The per-group **system user is gone too.** Its only live use was the `user_id`
|
||||
main stamped on a freshly-minted sandbox `Context`; under the §8 model a
|
||||
sandbox-originated context with no recognised id is `user_id=None` — the honest
|
||||
shape, since no user authored it — so there is no reason to fabricate a user.
|
||||
`auth.py` is removed entirely.
|
||||
|
||||
**Future work (not built):** a richer answer than `user_id=None` would be a
|
||||
`Context` carrying a **group attribute** identifying 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 core `Context`
|
||||
field change and waits until audit attribution needs it; 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. Core HA touch surface
|
||||
|
||||
The sandbox is deliberately small against core HA — three surfaces, each a
|
||||
declared public hook rather than an internal reach (the Iron Law: never
|
||||
monkey-patch 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.
|
||||
|
||||
## 12. 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).
|
||||
|
||||
## 13. 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 RPCs** for `calendar` / `todo` / `weather` server-side queries.
|
||||
- **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,646 @@
|
||||
{
|
||||
"test-only": {
|
||||
"aemet": [
|
||||
{
|
||||
"excerpt": " ...\n 'pref_disable_polling': False,\n + 'sandbox': 'built-in',\n 'source': 'user',\n ...\n }),",
|
||||
"node_id": "tests.components.aemet.test_diagnostics::test_config_entry_diagnostics"
|
||||
}
|
||||
],
|
||||
"airly": [
|
||||
{
|
||||
"excerpt": " ...\n 'pref_disable_polling': False,\n + 'sandbox': 'built-in',\n 'source': 'user',\n ...\n })",
|
||||
"node_id": "tests.components.airly.test_diagnostics::test_entry_diagnostics"
|
||||
}
|
||||
],
|
||||
"airnow": [
|
||||
{
|
||||
"excerpt": " ...\n 'pref_disable_polling': False,\n + 'sandbox': 'built-in',\n 'source': 'user',\n ...\n })",
|
||||
"node_id": "tests.components.airnow.test_diagnostics::test_entry_diagnostics"
|
||||
}
|
||||
],
|
||||
"airq": [
|
||||
{
|
||||
"excerpt": " 'config_entry': dict({\n - 'created_at': '2025-01-01T00:00:00+00:00',\n + 'created_at': '2026-05-24T04:51:45.904605+00:00',\n 'data': dict({\n ...\n 'minor_version': 1,...",
|
||||
"node_id": "tests.components.airq.test_diagnostics::test_entry_diagnostics"
|
||||
}
|
||||
],
|
||||
"airvisual": [
|
||||
{
|
||||
"excerpt": " ...\n 'pref_disable_polling': False,\n + 'sandbox': 'built-in',\n 'source': 'user',\n ...\n })",
|
||||
"node_id": "tests.components.airvisual.test_diagnostics::test_entry_diagnostics"
|
||||
}
|
||||
],
|
||||
"airvisual_pro": [
|
||||
{
|
||||
"excerpt": " ...\n 'pref_disable_polling': False,\n + 'sandbox': 'built-in',\n 'source': 'user',\n ...\n })",
|
||||
"node_id": "tests.components.airvisual_pro.test_diagnostics::test_entry_diagnostics"
|
||||
}
|
||||
],
|
||||
"airzone": [
|
||||
{
|
||||
"excerpt": " ...\n 'pref_disable_polling': False,\n + 'sandbox': 'built-in',\n 'source': 'user',\n ...\n 'wifi-channel': 6,",
|
||||
"node_id": "tests.components.airzone.test_diagnostics::test_config_entry_diagnostics"
|
||||
}
|
||||
],
|
||||
"airzone_cloud": [
|
||||
{
|
||||
"excerpt": " ...\n 'pref_disable_polling': False,\n + 'sandbox': 'built-in',\n 'source': 'user',\n ...\n 'temperature-setpoint-max-auto-air': 30.0,",
|
||||
"node_id": "tests.components.airzone_cloud.test_diagnostics::test_config_entry_diagnostics"
|
||||
}
|
||||
],
|
||||
"aladdin_connect": [
|
||||
{
|
||||
"excerpt": " ...\n 'pref_disable_polling': False,\n + 'sandbox': 'built-in',\n 'source': 'user',\n ...\n })",
|
||||
"node_id": "tests.components.aladdin_connect.test_diagnostics::test_diagnostics"
|
||||
}
|
||||
],
|
||||
"alexa_devices": [
|
||||
{
|
||||
"excerpt": " ...\n 'pref_disable_polling': False,\n + 'sandbox': 'built-in',\n 'source': 'user',\n ...\n })",
|
||||
"node_id": "tests.components.alexa_devices.test_diagnostics::test_entry_diagnostics"
|
||||
}
|
||||
],
|
||||
"ambient_station": [
|
||||
{
|
||||
"excerpt": " ...\n 'pref_disable_polling': False,\n + 'sandbox': 'built-in',\n 'source': 'user',\n ...\n })",
|
||||
"node_id": "tests.components.ambient_station.test_diagnostics::test_entry_diagnostics"
|
||||
}
|
||||
],
|
||||
"bang_olufsen": [
|
||||
{
|
||||
"excerpt": " ...\n 'pref_disable_polling': False,\n + 'sandbox': 'built-in',\n 'source': 'user',\n ...\n })",
|
||||
"node_id": "tests.components.bang_olufsen.test_diagnostics::test_async_get_config_entry_diagnostics"
|
||||
},
|
||||
{
|
||||
"excerpt": " ...\n 'pref_disable_polling': False,\n + 'sandbox': 'built-in',\n 'source': 'user',\n ...\n })",
|
||||
"node_id": "tests.components.bang_olufsen.test_diagnostics::test_async_get_config_entry_diagnostics_with_battery"
|
||||
}
|
||||
],
|
||||
"braviatv": [
|
||||
{
|
||||
"excerpt": " ...\n 'pref_disable_polling': False,\n + 'sandbox': 'built-in',\n 'source': 'user',\n ...\n })",
|
||||
"node_id": "tests.components.braviatv.test_diagnostics::test_entry_diagnostics"
|
||||
}
|
||||
],
|
||||
"co2signal": [
|
||||
{
|
||||
"excerpt": " ...\n 'pref_disable_polling': False,\n + 'sandbox': 'built-in',\n 'source': 'user',\n ...\n })",
|
||||
"node_id": "tests.components.co2signal.test_diagnostics::test_entry_diagnostics"
|
||||
}
|
||||
],
|
||||
"coinbase": [
|
||||
{
|
||||
"excerpt": " ...\n 'pref_disable_polling': False,\n + 'sandbox': 'built-in',\n 'source': 'user',\n ...\n })",
|
||||
"node_id": "tests.components.coinbase.test_diagnostics::test_entry_diagnostics"
|
||||
}
|
||||
],
|
||||
"comelit": [
|
||||
{
|
||||
"excerpt": " ...\n 'pref_disable_polling': False,\n + 'sandbox': 'built-in',\n 'source': 'user',\n ...\n })",
|
||||
"node_id": "tests.components.comelit.test_diagnostics::test_entry_diagnostics_bridge"
|
||||
},
|
||||
{
|
||||
"excerpt": " ...\n 'pref_disable_polling': False,\n + 'sandbox': 'built-in',\n 'source': 'user',\n ...\n })",
|
||||
"node_id": "tests.components.comelit.test_diagnostics::test_entry_diagnostics_vedo"
|
||||
}
|
||||
],
|
||||
"data_grand_lyon": [
|
||||
{
|
||||
"excerpt": " ...\n 'pref_disable_polling': False,\n + 'sandbox': 'built-in',\n 'source': 'user',\n ...\n })",
|
||||
"node_id": "tests.components.data_grand_lyon.test_diagnostics::test_config_entry_diagnostics"
|
||||
},
|
||||
{
|
||||
"excerpt": " ...\n 'pref_disable_polling': False,\n + 'sandbox': 'built-in',\n 'source': 'user',\n ...\n })",
|
||||
"node_id": "tests.components.data_grand_lyon.test_diagnostics::test_config_entry_diagnostics_with_velov"
|
||||
}
|
||||
],
|
||||
"deconz": [
|
||||
{
|
||||
"excerpt": " ...\n 'pref_disable_polling': False,\n + 'sandbox': 'built-in',\n 'source': 'user',\n ...\n })",
|
||||
"node_id": "tests.components.deconz.test_diagnostics::test_entry_diagnostics"
|
||||
}
|
||||
],
|
||||
"devolo_home_control": [
|
||||
{
|
||||
"excerpt": " ...\n 'pref_disable_polling': False,\n + 'sandbox': 'built-in',\n 'source': 'user',\n ...\n })",
|
||||
"node_id": "tests.components.devolo_home_control.test_diagnostics::test_entry_diagnostics"
|
||||
}
|
||||
],
|
||||
"devolo_home_network": [
|
||||
{
|
||||
"excerpt": " ...\n 'pref_disable_polling': False,\n + 'sandbox': 'built-in',\n 'source': 'user',\n ...\n })",
|
||||
"node_id": "tests.components.devolo_home_network.test_diagnostics::test_entry_diagnostics"
|
||||
}
|
||||
],
|
||||
"dsmr_reader": [
|
||||
{
|
||||
"excerpt": " ...\n 'pref_disable_polling': False,\n + 'sandbox': 'built-in',\n 'source': 'user',\n ...\n })",
|
||||
"node_id": "tests.components.dsmr_reader.test_diagnostics::test_get_config_entry_diagnostics"
|
||||
}
|
||||
],
|
||||
"ecovacs": [
|
||||
{
|
||||
"excerpt": " ...\n 'pref_disable_polling': False,\n + 'sandbox': 'built-in',\n 'source': 'user',\n ...\n })",
|
||||
"node_id": "tests.components.ecovacs.test_diagnostics::test_diagnostics[username@cloud]"
|
||||
},
|
||||
{
|
||||
"excerpt": " ...\n 'pref_disable_polling': False,\n + 'sandbox': 'built-in',\n 'source': 'user',\n ...\n })",
|
||||
"node_id": "tests.components.ecovacs.test_diagnostics::test_diagnostics[username@self-hosted]"
|
||||
}
|
||||
],
|
||||
"eheimdigital": [
|
||||
{
|
||||
"excerpt": " ...\n 'pref_disable_polling': False,\n + 'sandbox': 'built-in',\n 'source': 'user',\n ...\n })",
|
||||
"node_id": "tests.components.eheimdigital.test_diagnostics::test_entry_diagnostics"
|
||||
}
|
||||
],
|
||||
"enphase_envoy": [
|
||||
{
|
||||
"excerpt": " ...\n 'pref_disable_polling': False,\n + 'sandbox': 'built-in',\n 'source': 'user',\n ...\n dict({",
|
||||
"node_id": "tests.components.enphase_envoy.test_diagnostics::test_entry_diagnostics"
|
||||
},
|
||||
{
|
||||
"excerpt": " ...\n 'pref_disable_polling': False,\n + 'sandbox': 'built-in',\n 'source': 'user',\n ...\n dict({",
|
||||
"node_id": "tests.components.enphase_envoy.test_diagnostics::test_entry_diagnostics_with_fixtures"
|
||||
},
|
||||
{
|
||||
"excerpt": " ...\n 'pref_disable_polling': False,\n + 'sandbox': 'built-in',\n 'source': 'user',\n ...\n dict({",
|
||||
"node_id": "tests.components.enphase_envoy.test_diagnostics::test_entry_diagnostics_with_fixtures_with_error"
|
||||
},
|
||||
{
|
||||
"excerpt": " ...\n 'pref_disable_polling': False,\n + 'sandbox': 'built-in',\n 'source': 'user',\n ...\n 0,",
|
||||
"node_id": "tests.components.enphase_envoy.test_diagnostics::test_entry_diagnostics_with_interface_information[envoy]"
|
||||
},
|
||||
{
|
||||
"excerpt": " ...\n 'pref_disable_polling': False,\n + 'sandbox': 'built-in',\n 'source': 'user',\n ...\n 0,",
|
||||
"node_id": "tests.components.enphase_envoy.test_diagnostics::test_entry_diagnostics_with_interface_information[envoy_metered_batt_relay]"
|
||||
}
|
||||
],
|
||||
"firefly_iii": [
|
||||
{
|
||||
"excerpt": " ...\n 'pref_disable_polling': False,\n + 'sandbox': 'built-in',\n 'source': 'user',\n ...\n })",
|
||||
"node_id": "tests.components.firefly_iii.test_diagnostics::test_get_config_entry_diagnostics"
|
||||
}
|
||||
],
|
||||
"freshr": [
|
||||
{
|
||||
"excerpt": " ...\n 'entry': dict({\n - 'created_at': '2026-01-01T00:00:00+00:00',\n + 'created_at': '2026-05-24T04:53:55.668547+00:00',\n 'data': dict({\n ......",
|
||||
"node_id": "tests.components.freshr.test_diagnostics::test_diagnostics"
|
||||
}
|
||||
],
|
||||
"fritz": [
|
||||
{
|
||||
"excerpt": " ...\n 'pref_disable_polling': False,\n + 'sandbox': 'built-in',\n 'source': 'user',\n ...\n })",
|
||||
"node_id": "tests.components.fritz.test_diagnostics::test_entry_diagnostics"
|
||||
}
|
||||
],
|
||||
"fronius": [
|
||||
{
|
||||
"excerpt": " ...\n 'pref_disable_polling': False,\n + 'sandbox': 'built-in',\n 'source': 'user',\n ...\n }),",
|
||||
"node_id": "tests.components.fronius.test_diagnostics::test_diagnostics"
|
||||
}
|
||||
],
|
||||
"fyta": [
|
||||
{
|
||||
"excerpt": " ...\n 'pref_disable_polling': False,\n + 'sandbox': 'built-in',\n 'source': 'user',\n ...\n 'scientific_name': 'Theobroma cacao',",
|
||||
"node_id": "tests.components.fyta.test_diagnostics::test_entry_diagnostics"
|
||||
}
|
||||
],
|
||||
"gios": [
|
||||
{
|
||||
"excerpt": " ...\n 'pref_disable_polling': False,\n + 'sandbox': 'built-in',\n 'source': 'user',\n ...\n })",
|
||||
"node_id": "tests.components.gios.test_diagnostics::test_entry_diagnostics"
|
||||
}
|
||||
],
|
||||
"goodwe": [
|
||||
{
|
||||
"excerpt": " ...\n 'pref_disable_polling': False,\n + 'sandbox': 'built-in',\n 'source': 'user',\n ...\n })",
|
||||
"node_id": "tests.components.goodwe.test_diagnostics::test_entry_diagnostics"
|
||||
}
|
||||
],
|
||||
"google_weather": [
|
||||
{
|
||||
"excerpt": " 'entry': dict({\n - 'created_at': '2026-03-20T21:22:23+00:00',\n + 'created_at': '2026-05-24T04:54:19.938808+00:00',\n 'data': dict({\n ...\n 'minor_version': 1,...",
|
||||
"node_id": "tests.components.google_weather.test_diagnostics::test_diagnostics"
|
||||
}
|
||||
],
|
||||
"growatt_server": [
|
||||
{
|
||||
"excerpt": " ...\n 'pref_disable_polling': False,\n + 'sandbox': 'built-in',\n 'source': 'user',\n ...\n })",
|
||||
"node_id": "tests.components.growatt_server.test_diagnostics::test_diagnostics"
|
||||
},
|
||||
{
|
||||
"excerpt": " ...\n 'pref_disable_polling': False,\n + 'sandbox': 'built-in',\n 'source': 'user',\n ...\n })",
|
||||
"node_id": "tests.components.growatt_server.test_diagnostics::test_diagnostics_classic_api"
|
||||
}
|
||||
],
|
||||
"guardian": [
|
||||
{
|
||||
"excerpt": " Omitting 1 identical items, use -vv to show\n Differing items:\n {'entry': {'created_at': '2026-05-24T04:54:25.511997+00:00', 'data': {'ip_address': '192.168.1.100', 'port': 7777, 'uid': '**REDACTED**'}, 'disabled_by': None, 'discovery_keys': {}, ...}} != {'entry': {'created_at': <ANY>, 'data': {'ip_address': '192.168.1.100', 'port': 7777, 'uid': '**REDACTED**'}, 'disabled_by': None, 'discovery_keys': {}, ...}}\n Use -v to get more diff\n\ntests/components/guardian/test_diagnostics.py:24: in test_entry_diagnostics",
|
||||
"node_id": "tests.components.guardian.test_diagnostics::test_entry_diagnostics"
|
||||
}
|
||||
],
|
||||
"heos": [
|
||||
{
|
||||
"excerpt": " ...\n 'pref_disable_polling': False,\n + 'sandbox': 'built-in',\n 'source': 'user',\n ...\n dict({",
|
||||
"node_id": "tests.components.heos.test_diagnostics::test_config_entry_diagnostics"
|
||||
},
|
||||
{
|
||||
"excerpt": " ...\n 'pref_disable_polling': False,\n + 'sandbox': 'built-in',\n 'source': 'user',\n ...\n })",
|
||||
"node_id": "tests.components.heos.test_diagnostics::test_config_entry_diagnostics_error_getting_system"
|
||||
}
|
||||
],
|
||||
"homeassistant_connect_zbt2": [
|
||||
{
|
||||
"excerpt": " ...\n 'pref_disable_polling': False,\n + 'sandbox': 'built-in',\n 'source': 'user',\n ...\n })",
|
||||
"node_id": "tests.components.homeassistant_connect_zbt2.test_diagnostics::test_diagnostics_for_config_entry"
|
||||
}
|
||||
],
|
||||
"husqvarna_automower": [
|
||||
{
|
||||
"excerpt": " ...\n 'pref_disable_polling': False,\n + 'sandbox': 'built-in',\n 'source': 'user',\n ...\n })",
|
||||
"node_id": "tests.components.husqvarna_automower.test_diagnostics::test_entry_diagnostics"
|
||||
}
|
||||
],
|
||||
"imgw_pib": [
|
||||
{
|
||||
"excerpt": " ...\n 'pref_disable_polling': False,\n + 'sandbox': 'built-in',\n 'source': 'user',\n ...\n })",
|
||||
"node_id": "tests.components.imgw_pib.test_diagnostics::test_entry_diagnostics"
|
||||
}
|
||||
],
|
||||
"immich": [
|
||||
{
|
||||
"excerpt": " ...\n 'pref_disable_polling': False,\n + 'sandbox': 'built-in',\n 'source': 'user',\n ...\n })",
|
||||
"node_id": "tests.components.immich.test_diagnostics::test_entry_diagnostics"
|
||||
}
|
||||
],
|
||||
"iqvia": [
|
||||
{
|
||||
"excerpt": " ...\n 'pref_disable_polling': False,\n + 'sandbox': 'built-in',\n 'source': 'user',\n ...\n })",
|
||||
"node_id": "tests.components.iqvia.test_diagnostics::test_entry_diagnostics"
|
||||
}
|
||||
],
|
||||
"kostal_plenticore": [
|
||||
{
|
||||
"excerpt": " Omitting 3 identical items, use -vv to show\n Differing items:\n {'config_entry': {'created_at': '2026-05-24T04:55:51.181434+00:00', 'data': {'host': '192.168.1.2', 'password': '**REDACTED**'}, 'disabled_by': None, 'discovery_keys': {}, ...}} != {'config_entry': {'created_at': <ANY>, 'data': {'host': '192.168.1.2', 'password': '**REDACTED**'}, 'disabled_by': None, 'discovery_keys': {}, ...}}\n Use -v to get more diff\n\ntests/components/kostal_plenticore/test_diagnostics.py:26: in test_entry_diagnostics",
|
||||
"node_id": "tests.components.kostal_plenticore.test_diagnostics::test_entry_diagnostics"
|
||||
}
|
||||
],
|
||||
"lacrosse_view": [
|
||||
{
|
||||
"excerpt": " ...\n 'pref_disable_polling': False,\n + 'sandbox': 'built-in',\n 'source': 'user',\n ...\n })",
|
||||
"node_id": "tests.components.lacrosse_view.test_diagnostics::test_entry_diagnostics"
|
||||
}
|
||||
],
|
||||
"madvr": [
|
||||
{
|
||||
"excerpt": " ...\n 'pref_disable_polling': False,\n + 'sandbox': 'built-in',\n 'source': 'user',\n ...\n })",
|
||||
"node_id": "tests.components.madvr.test_diagnostics::test_entry_diagnostics[positive_payload0]"
|
||||
}
|
||||
],
|
||||
"melcloud": [
|
||||
{
|
||||
"excerpt": " ...\n 'pref_disable_polling': False,\n + 'sandbox': 'built-in',\n 'source': 'user',\n ...\n })",
|
||||
"node_id": "tests.components.melcloud.test_diagnostics::test_get_config_entry_diagnostics"
|
||||
}
|
||||
],
|
||||
"modern_forms": [
|
||||
{
|
||||
"excerpt": " ...\n 'pref_disable_polling': False,\n + 'sandbox': 'built-in',\n 'source': 'user',\n ...\n })",
|
||||
"node_id": "tests.components.modern_forms.test_diagnostics::test_entry_diagnostics"
|
||||
}
|
||||
],
|
||||
"motionblinds_ble": [
|
||||
{
|
||||
"excerpt": " ...\n 'pref_disable_polling': False,\n + 'sandbox': 'built-in',\n 'source': 'user',\n ...\n })",
|
||||
"node_id": "tests.components.motionblinds_ble.test_diagnostics::test_diagnostics"
|
||||
}
|
||||
],
|
||||
"nextdns": [
|
||||
{
|
||||
"excerpt": " ...\n 'pref_disable_polling': False,\n + 'sandbox': 'built-in',\n 'source': 'user',\n ...\n 'cache_boost': True,",
|
||||
"node_id": "tests.components.nextdns.test_diagnostics::test_entry_diagnostics"
|
||||
}
|
||||
],
|
||||
"nice_go": [
|
||||
{
|
||||
"excerpt": " ...\n 'pref_disable_polling': False,\n + 'sandbox': 'built-in',\n 'source': 'user',\n ...\n })",
|
||||
"node_id": "tests.components.nice_go.test_diagnostics::test_entry_diagnostics"
|
||||
}
|
||||
],
|
||||
"notion": [
|
||||
{
|
||||
"excerpt": " Omitting 1 identical items, use -vv to show\n Differing items:\n {'entry': {'created_at': '2026-05-24T04:57:19.794228+00:00', 'data': {'refresh_token': '**REDACTED**', 'user_uuid': '**REDACTED**', 'username': '**REDACTED**'}, 'disabled_by': None, 'discovery_keys': {}, ...}} != {'entry': {'created_at': <ANY>, 'data': {'refresh_token': '**REDACTED**', 'user_uuid': '**REDACTED**', 'username': '**REDACTED**'}, 'disabled_by': None, 'discovery_keys': {}, ...}}\n Use -v to get more diff\n\ntests/components/notion/test_diagnostics.py:19: in test_entry_diagnostics",
|
||||
"node_id": "tests.components.notion.test_diagnostics::test_entry_diagnostics"
|
||||
}
|
||||
],
|
||||
"novy_cooker_hood": [
|
||||
{
|
||||
"excerpt": " ...\n 'pref_disable_polling': False,\n + 'sandbox': 'built-in',\n 'source': 'user',\n ...\n })",
|
||||
"node_id": "tests.components.novy_cooker_hood.test_diagnostics::test_diagnostics"
|
||||
}
|
||||
],
|
||||
"openuv": [
|
||||
{
|
||||
"excerpt": " Omitting 1 identical items, use -vv to show\n Differing items:\n {'entry': {'created_at': '2026-05-24T04:57:39.013753+00:00', 'data': {'api_key': '**REDACTED**', 'elevation': 0, 'latitude': '**REDACTED**', 'longitude': '**REDACTED**'}, 'disabled_by': None, 'discovery_keys': {}, ...}} != {'entry': {'created_at': <ANY>, 'data': {'api_key': '**REDACTED**', 'elevation': 0, 'latitude': '**REDACTED**', 'longitude': '**REDACTED**'}, 'disabled_by': None, 'discovery_keys': {}, ...}}\n Use -v to get more diff\n\ntests/components/openuv/test_diagnostics.py:20: in test_entry_diagnostics",
|
||||
"node_id": "tests.components.openuv.test_diagnostics::test_entry_diagnostics"
|
||||
}
|
||||
],
|
||||
"opower": [
|
||||
{
|
||||
"excerpt": " ...\n 'entry': dict({\n - 'created_at': '2026-03-07T23:00:00+00:00',\n + 'created_at': '2026-05-24T04:57:42.183600+00:00',\n 'data': dict({\n ......",
|
||||
"node_id": "tests.components.opower.test_diagnostics::test_diagnostics"
|
||||
}
|
||||
],
|
||||
"p1_monitor": [
|
||||
{
|
||||
"excerpt": " ...\n 'pref_disable_polling': False,\n + 'sandbox': 'built-in',\n 'source': 'user',\n ...\n })",
|
||||
"node_id": "tests.components.p1_monitor.test_init::test_migration"
|
||||
},
|
||||
{
|
||||
"excerpt": " ...\n 'pref_disable_polling': False,\n + 'sandbox': 'built-in',\n 'source': 'user',\n ...\n })",
|
||||
"node_id": "tests.components.p1_monitor.test_init::test_port_migration"
|
||||
}
|
||||
],
|
||||
"pegel_online": [
|
||||
{
|
||||
"excerpt": " ...\n 'pref_disable_polling': False,\n + 'sandbox': 'built-in',\n 'source': 'user',\n ...\n })",
|
||||
"node_id": "tests.components.pegel_online.test_diagnostics::test_entry_diagnostics"
|
||||
}
|
||||
],
|
||||
"philips_js": [
|
||||
{
|
||||
"excerpt": " ...\n 'pref_disable_polling': False,\n + 'sandbox': 'built-in',\n 'source': 'user',\n ...\n })",
|
||||
"node_id": "tests.components.philips_js.test_diagnostics::test_entry_diagnostics"
|
||||
}
|
||||
],
|
||||
"pi_hole": [
|
||||
{
|
||||
"excerpt": " ...\n 'pref_disable_polling': False,\n + 'sandbox': 'built-in',\n 'source': 'user',\n ...\n })",
|
||||
"node_id": "tests.components.pi_hole.test_diagnostics::test_diagnostics"
|
||||
}
|
||||
],
|
||||
"portainer": [
|
||||
{
|
||||
"excerpt": " ...\n 'pref_disable_polling': False,\n + 'sandbox': 'built-in',\n 'source': 'user',\n ...\n })",
|
||||
"node_id": "tests.components.portainer.test_diagnostics::test_get_config_entry_diagnostics"
|
||||
}
|
||||
],
|
||||
"proximity": [
|
||||
{
|
||||
"excerpt": " ...\n 'pref_disable_polling': False,\n + 'sandbox': 'built-in',\n 'source': 'user',\n ...\n })",
|
||||
"node_id": "tests.components.proximity.test_diagnostics::test_entry_diagnostics"
|
||||
}
|
||||
],
|
||||
"proxmoxve": [
|
||||
{
|
||||
"excerpt": " ...\n 'pref_disable_polling': False,\n + 'sandbox': 'built-in',\n 'source': 'user',\n ...\n 'disk': 1234567890,",
|
||||
"node_id": "tests.components.proxmoxve.test_diagnostics::test_get_config_entry_diagnostics"
|
||||
}
|
||||
],
|
||||
"purpleair": [
|
||||
{
|
||||
"excerpt": " Omitting 1 identical items, use -vv to show\n Differing items:\n {'entry': {'created_at': '2026-05-24T04:58:14.127275+00:00', 'data': {'api_key': '**REDACTED**'}, 'disabled_by': None, 'discovery_keys': {}, ...}} != {'entry': {'created_at': <ANY>, 'data': {'api_key': '**REDACTED**'}, 'disabled_by': None, 'discovery_keys': {}, ...}}\n Use -v to get more diff\n\ntests/components/purpleair/test_diagnostics.py:18: in test_entry_diagnostics",
|
||||
"node_id": "tests.components.purpleair.test_diagnostics::test_entry_diagnostics"
|
||||
}
|
||||
],
|
||||
"rainforest_raven": [
|
||||
{
|
||||
"excerpt": " ...\n 'pref_disable_polling': False,\n + 'sandbox': 'built-in',\n 'source': 'user',\n ...\n })",
|
||||
"node_id": "tests.components.rainforest_raven.test_diagnostics::test_entry_diagnostics"
|
||||
},
|
||||
{
|
||||
"excerpt": " ...\n 'pref_disable_polling': False,\n + 'sandbox': 'built-in',\n 'source': 'user',\n ...\n })",
|
||||
"node_id": "tests.components.rainforest_raven.test_diagnostics::test_entry_diagnostics_no_meters"
|
||||
}
|
||||
],
|
||||
"rainmachine": [
|
||||
{
|
||||
"excerpt": " ...\n 'pref_disable_polling': False,\n + 'sandbox': 'built-in',\n 'source': 'user',\n ...\n })",
|
||||
"node_id": "tests.components.rainmachine.test_diagnostics::test_entry_diagnostics"
|
||||
},
|
||||
{
|
||||
"excerpt": " ...\n 'pref_disable_polling': False,\n + 'sandbox': 'built-in',\n 'source': 'user',\n ...\n })",
|
||||
"node_id": "tests.components.rainmachine.test_diagnostics::test_entry_diagnostics_failed_controller_diagnostics"
|
||||
}
|
||||
],
|
||||
"recollect_waste": [
|
||||
{
|
||||
"excerpt": " Omitting 1 identical items, use -vv to show\n Differing items:\n {'entry': {'created_at': '2026-05-24T04:58:25.488020+00:00', 'data': {'place_id': '**REDACTED**', 'service_id': '67890'}, 'disabled_by': None, 'discovery_keys': {}, ...}} != {'entry': {'created_at': <ANY>, 'data': {'place_id': '**REDACTED**', 'service_id': '67890'}, 'disabled_by': None, 'discovery_keys': {}, ...}}\n Use -v to get more diff\n\ntests/components/recollect_waste/test_diagnostics.py:20: in test_entry_diagnostics",
|
||||
"node_id": "tests.components.recollect_waste.test_diagnostics::test_entry_diagnostics"
|
||||
}
|
||||
],
|
||||
"ridwell": [
|
||||
{
|
||||
"excerpt": " ...\n 'pref_disable_polling': False,\n + 'sandbox': 'built-in',\n 'source': 'user',\n ...\n })",
|
||||
"node_id": "tests.components.ridwell.test_diagnostics::test_entry_diagnostics"
|
||||
}
|
||||
],
|
||||
"samsungtv": [
|
||||
{
|
||||
"excerpt": " ...\n 'pref_disable_polling': False,\n + 'sandbox': 'built-in',\n 'source': 'user',\n ...\n })",
|
||||
"node_id": "tests.components.samsungtv.test_diagnostics::test_entry_diagnostics"
|
||||
},
|
||||
{
|
||||
"excerpt": " ...\n 'pref_disable_polling': False,\n + 'sandbox': 'built-in',\n 'source': 'user',\n ...\n })",
|
||||
"node_id": "tests.components.samsungtv.test_diagnostics::test_entry_diagnostics_encrypte_offline"
|
||||
},
|
||||
{
|
||||
"excerpt": " ...\n 'pref_disable_polling': False,\n + 'sandbox': 'built-in',\n 'source': 'user',\n ...\n })",
|
||||
"node_id": "tests.components.samsungtv.test_diagnostics::test_entry_diagnostics_encrypted"
|
||||
}
|
||||
],
|
||||
"scrape": [
|
||||
{
|
||||
"excerpt": " ...\n 'pref_disable_polling': False,\n + 'sandbox': 'built-in',\n 'source': 'user',\n ...\n })",
|
||||
"node_id": "tests.components.scrape.test_init::test_migrate_from_version_1_to_2"
|
||||
}
|
||||
],
|
||||
"screenlogic": [
|
||||
{
|
||||
"excerpt": " ...\n 'pref_disable_polling': False,\n + 'sandbox': 'built-in',\n 'source': 'user',\n ...\n ]),",
|
||||
"node_id": "tests.components.screenlogic.test_diagnostics::test_diagnostics"
|
||||
}
|
||||
],
|
||||
"simplisafe": [
|
||||
{
|
||||
"excerpt": " Omitting 2 identical items, use -vv to show\n Differing items:\n {'entry': {'created_at': '2026-05-24T04:59:00.139992+00:00', 'data': {'token': '**REDACTED**', 'username': '**REDACTED**'}, 'disabled_by': None, 'discovery_keys': {}, ...}} != {'entry': {'created_at': <ANY>, 'data': {'token': '**REDACTED**', 'username': '**REDACTED**'}, 'disabled_by': None, 'discovery_keys': {}, ...}}\n Use -v to get more diff\n\ntests/components/simplisafe/test_diagnostics.py:18: in test_entry_diagnostics",
|
||||
"node_id": "tests.components.simplisafe.test_diagnostics::test_entry_diagnostics"
|
||||
}
|
||||
],
|
||||
"slide_local": [
|
||||
{
|
||||
"excerpt": " ...\n 'pref_disable_polling': False,\n + 'sandbox': 'built-in',\n 'source': 'user',\n ...\n })",
|
||||
"node_id": "tests.components.slide_local.test_diagnostics::test_entry_diagnostics"
|
||||
}
|
||||
],
|
||||
"sma": [
|
||||
{
|
||||
"excerpt": " ...\n 'pref_disable_polling': False,\n + 'sandbox': 'built-in',\n 'source': 'user',\n ...\n })",
|
||||
"node_id": "tests.components.sma.test_diagnostics::test_get_config_entry_diagnostics"
|
||||
}
|
||||
],
|
||||
"solarlog": [
|
||||
{
|
||||
"excerpt": " ...\n 'pref_disable_polling': False,\n + 'sandbox': 'built-in',\n 'source': 'user',\n ...\n })",
|
||||
"node_id": "tests.components.solarlog.test_diagnostics::test_entry_diagnostics"
|
||||
}
|
||||
],
|
||||
"switchbot": [
|
||||
{
|
||||
"excerpt": " ...\n 'pref_disable_polling': False,\n + 'sandbox': 'built-in',\n 'source': 'user',\n ...\n })",
|
||||
"node_id": "tests.components.switchbot.test_diagnostics::test_diagnostics"
|
||||
}
|
||||
],
|
||||
"switcher_kis": [
|
||||
{
|
||||
"excerpt": " ...\n 'pref_disable_polling': False,\n + 'sandbox': 'built-in',\n 'source': 'user',\n ...\n })",
|
||||
"node_id": "tests.components.switcher_kis.test_diagnostics::test_diagnostics"
|
||||
}
|
||||
],
|
||||
"systemmonitor": [
|
||||
{
|
||||
"excerpt": " ...\n 'pref_disable_polling': False,\n + 'sandbox': 'built-in',\n 'source': 'user',\n ...\n })",
|
||||
"node_id": "tests.components.systemmonitor.test_diagnostics::test_diagnostics"
|
||||
},
|
||||
{
|
||||
"excerpt": " ...\n 'pref_disable_polling': False,\n + 'sandbox': 'built-in',\n 'source': 'user',\n ...\n })",
|
||||
"node_id": "tests.components.systemmonitor.test_diagnostics::test_diagnostics_missing_items"
|
||||
}
|
||||
],
|
||||
"tankerkoenig": [
|
||||
{
|
||||
"excerpt": " ...\n 'pref_disable_polling': False,\n + 'sandbox': 'built-in',\n 'source': 'user',\n ...\n })",
|
||||
"node_id": "tests.components.tankerkoenig.test_diagnostics::test_entry_diagnostics"
|
||||
}
|
||||
],
|
||||
"tplink_omada": [
|
||||
{
|
||||
"excerpt": " ...\n 'pref_disable_polling': False,\n + 'sandbox': 'built-in',\n 'source': 'user',\n ...\n 'memUtil': 20,",
|
||||
"node_id": "tests.components.tplink_omada.test_diagnostics::test_entry_diagnostics"
|
||||
}
|
||||
],
|
||||
"tractive": [
|
||||
{
|
||||
"excerpt": " ...\n 'pref_disable_polling': False,\n + 'sandbox': 'built-in',\n 'source': 'user',\n ...\n })",
|
||||
"node_id": "tests.components.tractive.test_diagnostics::test_entry_diagnostics"
|
||||
}
|
||||
],
|
||||
"twinkly": [
|
||||
{
|
||||
"excerpt": " ...\n 'pref_disable_polling': False,\n + 'sandbox': 'built-in',\n 'source': 'user',\n ...\n })",
|
||||
"node_id": "tests.components.twinkly.test_diagnostics::test_diagnostics"
|
||||
}
|
||||
],
|
||||
"unifi": [
|
||||
{
|
||||
"excerpt": " ...\n 'pref_disable_polling': False,\n + 'sandbox': 'built-in',\n 'source': 'user',\n ...\n 'dtim_ng': 1,",
|
||||
"node_id": "tests.components.unifi.test_diagnostics::test_entry_diagnostics[wlan_payload0-dpi_group_payload0-dpi_app_payload0-device_payload0-client_payload0-config_entry_options0]"
|
||||
}
|
||||
],
|
||||
"utility_meter": [
|
||||
{
|
||||
"excerpt": " ...\n 'pref_disable_polling': False,\n + 'sandbox': 'built-in',\n 'source': 'user',\n ...\n })",
|
||||
"node_id": "tests.components.utility_meter.test_diagnostics::test_diagnostics"
|
||||
}
|
||||
],
|
||||
"v2c": [
|
||||
{
|
||||
"excerpt": " ...\n 'pref_disable_polling': False,\n + 'sandbox': 'built-in',\n 'source': 'user',\n ...\n })",
|
||||
"node_id": "tests.components.v2c.test_diagnostics::test_entry_diagnostics"
|
||||
}
|
||||
],
|
||||
"velbus": [
|
||||
{
|
||||
"excerpt": " ...\n 'pref_disable_polling': False,\n + 'sandbox': 'built-in',\n 'source': 'user',\n ...\n })",
|
||||
"node_id": "tests.components.velbus.test_diagnostics::test_entry_diagnostics"
|
||||
}
|
||||
],
|
||||
"vicare": [
|
||||
{
|
||||
"excerpt": " ...\n 'pref_disable_polling': False,\n + 'sandbox': 'built-in',\n 'source': 'user',\n ...\n })",
|
||||
"node_id": "tests.components.vicare.test_diagnostics::test_diagnostics"
|
||||
}
|
||||
],
|
||||
"vodafone_station": [
|
||||
{
|
||||
"excerpt": " ...\n 'pref_disable_polling': False,\n + 'sandbox': 'built-in',\n 'source': 'user',\n ...\n })",
|
||||
"node_id": "tests.components.vodafone_station.test_diagnostics::test_entry_diagnostics"
|
||||
}
|
||||
],
|
||||
"watts": [
|
||||
{
|
||||
"excerpt": " ...\n 'entry': dict({\n - 'created_at': '2026-01-01T12:00:00+00:00',\n + 'created_at': '2026-05-24T05:01:22.558366+00:00',\n 'data': dict({\n ......",
|
||||
"node_id": "tests.components.watts.test_diagnostics::test_diagnostics"
|
||||
}
|
||||
],
|
||||
"watttime": [
|
||||
{
|
||||
"excerpt": " ...\n 'pref_disable_polling': False,\n + 'sandbox': 'built-in',\n 'source': 'user',\n ...\n })",
|
||||
"node_id": "tests.components.watttime.test_diagnostics::test_entry_diagnostics"
|
||||
}
|
||||
],
|
||||
"webmin": [
|
||||
{
|
||||
"excerpt": " ...\n 'pref_disable_polling': False,\n + 'sandbox': 'built-in',\n 'source': 'user',\n ...\n })",
|
||||
"node_id": "tests.components.webmin.test_diagnostics::test_diagnostics"
|
||||
}
|
||||
],
|
||||
"webostv": [
|
||||
{
|
||||
"excerpt": " ...\n 'pref_disable_polling': False,\n + 'sandbox': 'built-in',\n 'source': 'user',\n ...\n })",
|
||||
"node_id": "tests.components.webostv.test_diagnostics::test_diagnostics"
|
||||
}
|
||||
],
|
||||
"whirlpool": [
|
||||
{
|
||||
"excerpt": " ...\n 'pref_disable_polling': False,\n + 'sandbox': 'built-in',\n 'source': 'user',\n ...\n })",
|
||||
"node_id": "tests.components.whirlpool.test_diagnostics::test_entry_diagnostics"
|
||||
}
|
||||
],
|
||||
"workday": [
|
||||
{
|
||||
"excerpt": " ...\n 'pref_disable_polling': False,\n + 'sandbox': 'built-in',\n 'source': 'user',\n ...\n })",
|
||||
"node_id": "tests.components.workday.test_diagnostics::test_diagnostics"
|
||||
}
|
||||
],
|
||||
"zha": [
|
||||
{
|
||||
"excerpt": " ...\n 'pref_disable_polling': False,\n + 'sandbox': 'built-in',\n 'source': 'user',\n ...\n })",
|
||||
"node_id": "tests.components.zha.test_diagnostics::test_diagnostics_for_config_entry"
|
||||
}
|
||||
]
|
||||
},
|
||||
"unknown": {
|
||||
"bluetooth": [
|
||||
{
|
||||
"excerpt": "tests/components/bluetooth/test_models.py:126: in test_wrapped_bleak_client_local_adapter_only\n await client.connect()\n.venv/lib/python3.14/site-packages/habluetooth/wrappers.py:437: in connect\n self._backend = wrapped_backend.client(\nE TypeError: BleakClientBlueZDBus.__init__() missing 1 required keyword-only argument: 'bluez'",
|
||||
"node_id": "tests.components.bluetooth.test_models::test_wrapped_bleak_client_local_adapter_only"
|
||||
}
|
||||
],
|
||||
"chess_com": [
|
||||
{
|
||||
"excerpt": " ...\n 'is_streamer': False,\n - 'joined': '2026-02-20T10:48:14',\n + 'joined': '2026-02-20T05:48:14',\n - 'last_online': '2026-03-06T12:32:59',\n + 'last_online': '2026-03-06T07:32:59',...",
|
||||
"node_id": "tests.components.chess_com.test_diagnostics::test_diagnostics"
|
||||
}
|
||||
],
|
||||
"google": [
|
||||
{
|
||||
"excerpt": " - some-updated-token\n + ACCESS_TOKEN\n\ntests/components/google/test_init.py:729: in test_invalid_token_expiry_in_config_entry\n assert entries[0].data[\"token\"][\"access_token\"] == \"some-updated-token\"\nE AssertionError: assert 'ACCESS_TOKEN' == 'some-updated-token'",
|
||||
"node_id": "tests.components.google.test_init::test_invalid_token_expiry_in_config_entry[timestamp_naive]"
|
||||
}
|
||||
],
|
||||
"html5": [
|
||||
{
|
||||
"excerpt": " Omitting 3 identical items, use -vv to show\n Differing items:\n {'timestamp': 18000000} != {'timestamp': 0}\n Use -v to get more diff\n\ntests/components/html5/test_notify.py:1165: in test_html5_send_message",
|
||||
"node_id": "tests.components.html5.test_notify::test_html5_send_message[service_data11-expected_payload11-86400-None]"
|
||||
}
|
||||
],
|
||||
"mastodon": [
|
||||
{
|
||||
"excerpt": " ...\n 'locked': False,\n - 'created_at': datetime.datetime(2016, 11, 24, 0, 0, tzinfo=tzlocal()),\n + 'created_at': datetime.datetime(2016, 11, 24, 0, 0, tzinfo=tzutc()),\n 'following_count': 328,\n ...",
|
||||
"node_id": "tests.components.mastodon.test_services::test_get_account_success"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
# Home Assistant Sandbox
|
||||
|
||||
This directory is the home for the sandbox rewrite (renamed from its earlier
|
||||
`_v2` 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-phase-N.md`](.) — the authoritative landing notes for each
|
||||
phase. **Always check the latest 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) —
|
||||
**SUPERSEDED.** The Phase-7 `RefreshToken.scopes` mechanism it
|
||||
describes was reverted from core HA (`plans/plan-strip-auth-scopes.md`),
|
||||
and `plans/plan-auth-context.md` then dropped the sandbox token + the
|
||||
per-group system user entirely — the sandbox now holds **no** credential.
|
||||
Kept as the design record for whenever the sandbox→main websocket
|
||||
actually lands (the credential is redesigned fresh then).
|
||||
- [`docs/design-share-states.md`](docs/design-share-states.md) —
|
||||
design for the post-v2 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` + `COMPAT.md` + `COMPAT.csv` — compat-lane runner
|
||||
and report (Phase 10).
|
||||
|
||||
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)
|
||||
|
||||
v2 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 —
|
||||
v2 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 "v2 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).
|
||||
- **`calendar` / `todo` / `weather` query-shaped RPCs.** The Phase
|
||||
13 proxies return empty lists for `async_get_events`, `todo_items`,
|
||||
and `weather.async_forecast_*` because the action-call channel
|
||||
can't express server-side queries. Add a query-shaped RPC if the
|
||||
compat sweep ever surfaces an integration that needs them.
|
||||
- **Non-idempotent service handlers** (`ai_task`, `image`).
|
||||
`ALWAYS_MAIN` punt for v2; v3 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,38 @@
|
||||
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
|
||||
|
@@ -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.
|
||||
|
||||
| | v2 (Phase 17) | v2 (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 v2'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_V2_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 v2'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 ("v2 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 ("v2 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_V2_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,808 @@
|
||||
integration,status,passed,failed,errors,skipped,duration_s,dominant_bucket
|
||||
acaia,pass,24,0,0,0,3.52,
|
||||
accuweather,pass,38,0,0,0,5.08,
|
||||
acmeda,pass,8,0,0,0,2.59,
|
||||
actron_air,pass,49,0,0,0,5.25,
|
||||
adax,pass,20,0,0,0,2.86,
|
||||
adguard,pass,36,0,0,0,3.42,
|
||||
advantage_air,pass,20,0,0,0,4.91,
|
||||
aemet,issues,16,1,0,0,3.26,
|
||||
aftership,pass,2,0,0,0,2.29,
|
||||
aidot,pass,16,0,0,0,2.90,
|
||||
airgradient,pass,47,0,0,0,4.70,
|
||||
airly,issues,21,1,0,0,2.99,
|
||||
airnow,issues,9,1,0,0,2.55,
|
||||
airobot,pass,60,0,0,0,4.85,
|
||||
airos,pass,62,0,0,0,4.89,
|
||||
airpatrol,pass,25,0,0,0,3.19,
|
||||
airq,issues,22,1,0,0,3.30,
|
||||
airthings,pass,12,0,0,0,2.56,
|
||||
airthings_ble,pass,38,0,0,0,4.13,
|
||||
airtouch4,pass,5,0,0,0,2.39,
|
||||
airtouch5,pass,5,0,0,0,2.45,
|
||||
airvisual,issues,15,1,0,0,2.95,
|
||||
airvisual_pro,issues,10,1,0,0,2.68,
|
||||
airzone,issues,37,1,0,0,5.58,
|
||||
airzone_cloud,issues,26,1,0,0,4.95,
|
||||
aladdin_connect,issues,32,1,0,0,3.78,
|
||||
alarmdecoder,pass,8,0,0,0,2.40,
|
||||
alexa_devices,issues,72,1,0,0,7.34,
|
||||
altruist,pass,11,0,0,0,2.61,
|
||||
amberelectric,pass,39,0,0,0,3.62,
|
||||
ambient_network,pass,7,0,0,0,2.76,
|
||||
ambient_station,issues,3,1,0,0,2.59,
|
||||
analytics_insights,pass,20,0,0,0,3.11,
|
||||
androidtv,pass,79,0,0,0,5.23,
|
||||
androidtv_remote,pass,40,0,0,0,4.60,
|
||||
anglian_water,pass,14,0,0,0,3.52,
|
||||
anova,pass,11,0,0,0,2.65,
|
||||
anthemav,pass,11,0,0,0,2.59,
|
||||
aosmith,pass,33,0,0,0,3.64,
|
||||
apcupsd,pass,44,0,0,0,4.29,
|
||||
apple_tv,pass,51,0,0,0,4.60,
|
||||
aprilaire,pass,3,0,0,0,2.51,
|
||||
apsystems,pass,13,0,0,0,3.03,
|
||||
aquacell,pass,13,0,0,0,2.89,
|
||||
aranet,pass,19,0,0,0,3.42,
|
||||
arcam_fmj,pass,85,0,0,0,5.84,
|
||||
arve,pass,5,0,0,0,2.70,
|
||||
aseko_pool_live,pass,13,0,0,0,2.80,
|
||||
asuswrt,pass,60,0,0,0,4.68,
|
||||
atag,pass,14,0,0,0,2.94,
|
||||
aurora,pass,4,0,0,0,2.48,
|
||||
aurora_abb_powerone,pass,7,0,0,0,6.85,
|
||||
aussie_broadband,pass,14,0,0,0,2.68,
|
||||
autarco,pass,13,0,0,0,2.73,
|
||||
autoskope,pass,22,0,0,0,2.91,
|
||||
avea,pass,20,0,0,0,3.12,
|
||||
awair,pass,24,0,0,0,2.73,
|
||||
aws_s3,pass,51,0,0,0,4.96,
|
||||
azure_data_explorer,pass,18,0,0,0,2.75,
|
||||
azure_devops,pass,20,0,0,0,2.82,
|
||||
azure_event_hub,pass,21,0,0,0,2.65,
|
||||
azure_storage,pass,25,0,0,0,3.70,
|
||||
backblaze_b2,pass,77,0,0,0,5.64,
|
||||
baf,pass,8,0,0,0,2.46,
|
||||
balboa,pass,42,0,0,0,5.47,
|
||||
bang_olufsen,issues,100,2,0,0,35.95,
|
||||
bayesian,pass,48,0,0,0,3.16,
|
||||
blebox,pass,117,0,0,0,8.95,
|
||||
blue_current,pass,28,0,0,0,3.03,
|
||||
bluemaestro,pass,11,0,0,0,2.77,
|
||||
bluesound,pass,39,0,0,0,4.73,
|
||||
bluetooth,issues,234,1,0,1,9.76,
|
||||
bond,pass,141,0,0,0,6.29,
|
||||
bosch_alarm,pass,121,0,0,0,11.87,
|
||||
bosch_shc,pass,16,0,0,0,2.66,
|
||||
braviatv,issues,19,1,0,0,2.74,
|
||||
bring,pass,72,0,0,0,6.03,
|
||||
broadlink,pass,104,0,0,0,4.81,
|
||||
brother,pass,33,0,0,0,7.24,
|
||||
brottsplatskartan,pass,4,0,0,0,2.30,
|
||||
brunt,pass,8,0,0,0,2.39,
|
||||
bryant_evolution,pass,24,0,0,0,3.09,
|
||||
bsblan,pass,127,0,0,0,11.91,
|
||||
bthome,pass,107,0,0,0,6.98,
|
||||
caldav,pass,109,0,0,0,6.70,
|
||||
cambridge_audio,pass,52,0,0,0,7.84,
|
||||
casper_glow,pass,67,0,0,0,6.44,
|
||||
cast,pass,86,0,0,0,6.63,
|
||||
ccm15,pass,8,0,0,0,2.52,
|
||||
centriconnect,pass,10,0,0,0,2.49,
|
||||
cert_expiry,pass,18,0,0,0,2.82,
|
||||
chacon_dio,pass,16,0,0,0,2.83,
|
||||
chess_com,issues,8,1,0,0,2.82,
|
||||
cielo_home,pass,7,0,0,0,18.27,
|
||||
cloudflare,pass,14,0,0,0,2.53,
|
||||
cloudflare_r2,pass,59,0,0,0,4.67,
|
||||
co2signal,issues,15,1,0,0,2.87,
|
||||
coinbase,issues,15,1,0,0,2.59,
|
||||
color_extractor,pass,9,0,0,0,4.47,
|
||||
comelit,issues,93,2,0,0,8.99,
|
||||
compit,pass,65,0,0,0,6.46,
|
||||
control4,pass,54,0,0,0,4.60,
|
||||
cookidoo,pass,52,0,0,0,5.41,
|
||||
coolmaster,pass,37,0,0,0,3.46,
|
||||
cpuspeed,pass,8,0,0,0,2.38,
|
||||
crownstone,pass,11,0,0,0,2.68,
|
||||
cync,pass,19,0,0,0,2.65,
|
||||
daikin,pass,31,0,0,0,3.09,
|
||||
data_grand_lyon,issues,40,2,0,0,3.29,
|
||||
datadog,pass,12,0,0,0,2.41,
|
||||
deako,pass,13,0,0,0,2.86,
|
||||
deconz,issues,172,1,0,1,12.86,
|
||||
decora_wifi,pass,13,0,0,0,3.01,
|
||||
deluge,pass,7,0,0,0,2.30,
|
||||
denon_rs232,pass,23,0,0,0,3.07,
|
||||
denonavr,pass,16,0,0,0,2.87,
|
||||
derivative,pass,76,0,0,0,4.62,
|
||||
devialet,pass,13,0,0,0,2.63,
|
||||
devolo_home_control,issues,41,1,0,0,4.24,
|
||||
devolo_home_network,issues,51,1,0,0,6.02,
|
||||
dexcom,pass,10,0,0,0,2.45,
|
||||
dialogflow,pass,17,0,0,0,3.17,
|
||||
directv,pass,24,0,0,0,3.07,
|
||||
discord,pass,11,0,0,0,2.51,
|
||||
discovergy,pass,25,0,0,0,3.19,
|
||||
dlink,pass,15,0,0,0,2.57,
|
||||
dlna_dmr,pass,72,0,0,0,13.95,
|
||||
dlna_dms,pass,55,0,0,0,7.98,
|
||||
dnsip,pass,21,0,0,0,2.72,
|
||||
dormakaba_dkey,pass,14,0,0,0,2.86,
|
||||
downloader,pass,21,0,0,0,2.63,
|
||||
drop_connect,pass,30,0,0,0,3.78,
|
||||
dropbox,pass,29,0,0,0,3.63,
|
||||
droplet,pass,16,0,0,0,3.18,
|
||||
dsmr,pass,75,0,0,0,4.57,
|
||||
dsmr_reader,issues,8,1,0,0,2.77,
|
||||
duckdns,pass,18,0,0,0,2.68,
|
||||
duco,pass,61,0,0,0,7.51,
|
||||
dunehd,pass,6,0,0,0,2.38,
|
||||
duotecno,pass,5,0,0,0,2.35,
|
||||
dwd_weather_warnings,pass,11,0,0,0,2.47,
|
||||
dynalite,pass,33,0,0,0,3.44,
|
||||
eafm,pass,18,0,0,0,2.64,
|
||||
earn_e_p1,pass,25,0,0,0,2.87,
|
||||
easyenergy,pass,70,0,0,0,5.17,
|
||||
ecobee,pass,60,0,0,0,3.53,
|
||||
ecoforest,pass,4,0,0,0,2.35,
|
||||
econet,pass,4,0,0,0,2.33,
|
||||
ecovacs,issues,74,2,0,0,6.74,
|
||||
ecowitt,pass,1,0,0,0,2.32,
|
||||
edl21,pass,2,0,0,0,2.29,
|
||||
efergy,pass,12,0,0,0,2.54,
|
||||
egauge,pass,16,0,0,0,2.81,
|
||||
eheimdigital,issues,79,1,0,0,8.30,
|
||||
ekeybionyx,pass,13,0,0,0,7.73,
|
||||
electrasmart,pass,6,0,0,0,2.33,
|
||||
electric_kiwi,pass,18,0,0,0,3.04,
|
||||
elgato,pass,36,0,0,0,3.88,
|
||||
elkm1,pass,45,0,0,0,3.43,
|
||||
elmax,pass,29,0,0,0,2.85,
|
||||
elvia,pass,9,0,0,0,2.72,
|
||||
emoncms,pass,16,0,0,0,2.71,
|
||||
emonitor,pass,7,0,0,0,2.38,
|
||||
emulated_roku,pass,7,0,0,0,2.39,
|
||||
energenie_power_sockets,pass,13,0,0,0,2.61,
|
||||
energyid,pass,81,0,0,0,3.92,
|
||||
energyzero,pass,41,0,0,0,3.82,
|
||||
enigma2,pass,26,0,0,0,3.30,
|
||||
enocean,pass,14,0,0,0,2.77,
|
||||
enphase_envoy,issues,231,5,0,0,24.65,
|
||||
epic_games_store,pass,20,0,0,0,2.68,
|
||||
epion,pass,4,0,0,0,2.33,
|
||||
epson,pass,5,0,0,0,2.46,
|
||||
eq3btsmart,pass,4,0,0,0,2.56,
|
||||
escea,pass,3,0,0,0,2.32,
|
||||
essent,pass,14,0,0,0,2.69,
|
||||
eufylife_ble,pass,10,0,0,0,2.79,
|
||||
eurotronic_cometblue,pass,39,0,0,0,4.04,
|
||||
evil_genius_labs,pass,10,0,0,0,2.53,
|
||||
faa_delays,pass,4,0,0,0,2.33,
|
||||
fastdotcom,pass,8,0,0,0,2.50,
|
||||
feedreader,pass,30,0,0,0,3.02,
|
||||
fibaro,pass,45,0,0,0,3.93,
|
||||
file,pass,26,0,0,0,2.84,
|
||||
filesize,pass,13,0,0,0,2.58,
|
||||
filter,pass,32,0,0,0,4.13,
|
||||
fing,pass,17,0,0,0,2.88,
|
||||
firefly_iii,issues,25,1,0,0,3.30,
|
||||
fireservicerota,pass,5,0,0,0,2.35,
|
||||
fitbit,pass,60,0,0,0,6.46,
|
||||
fivem,pass,5,0,0,0,2.36,
|
||||
fjaraskupan,pass,11,0,0,0,2.87,
|
||||
flexit_bacnet,pass,19,0,0,0,2.75,
|
||||
flipr,pass,18,0,0,0,2.79,
|
||||
flo,pass,10,0,0,0,2.73,
|
||||
flume,pass,11,0,0,0,2.50,
|
||||
fluss,pass,16,0,0,0,2.71,
|
||||
flux_led,pass,82,0,0,0,6.59,
|
||||
folder_watcher,pass,10,0,0,0,2.63,
|
||||
forecast_solar,pass,28,0,0,0,2.97,
|
||||
forked_daapd,pass,36,0,0,0,4.60,
|
||||
freedompro,pass,38,0,0,0,4.48,
|
||||
freshr,issues,28,1,0,0,3.42,
|
||||
fressnapf_tracker,pass,45,0,0,0,4.49,
|
||||
fritz,issues,138,1,0,0,10.15,
|
||||
fritzbox,pass,132,0,0,0,8.57,
|
||||
fritzbox_callmonitor,pass,12,0,0,0,2.45,
|
||||
fronius,issues,35,1,0,0,3.89,
|
||||
frontier_silicon,pass,22,0,0,0,2.64,
|
||||
fujitsu_fglair,pass,25,0,0,0,3.56,
|
||||
fumis,pass,72,0,0,0,5.55,
|
||||
fyta,issues,33,1,0,0,3.71,
|
||||
garages_amsterdam,pass,6,0,0,0,2.45,
|
||||
gardena_bluetooth,pass,40,0,0,0,5.17,
|
||||
gdacs,pass,8,0,0,0,2.70,
|
||||
generic_hygrostat,pass,76,0,0,0,4.01,
|
||||
generic_thermostat,pass,114,0,0,0,5.61,
|
||||
geniushub,pass,23,0,0,0,2.89,
|
||||
gentex_homelink,pass,13,0,0,0,2.75,
|
||||
geo_json_events,pass,5,0,0,0,2.55,
|
||||
geocaching,pass,5,0,0,0,2.81,
|
||||
geofency,pass,5,0,0,0,3.00,
|
||||
geonetnz_quakes,pass,9,0,0,0,2.78,
|
||||
geonetnz_volcano,pass,7,0,0,0,2.64,
|
||||
ghost,pass,27,0,0,0,3.44,
|
||||
gios,issues,17,1,0,0,3.07,
|
||||
github,pass,17,0,0,0,2.90,
|
||||
glances,pass,16,0,0,0,2.77,
|
||||
goalzero,pass,15,0,0,0,2.75,
|
||||
gogogate2,pass,15,0,0,0,2.67,
|
||||
goodwe,issues,5,1,0,0,2.59,
|
||||
google,issues,136,1,0,0,8.22,
|
||||
google_air_quality,pass,19,0,0,0,2.77,
|
||||
google_assistant_sdk,pass,41,0,0,0,3.71,
|
||||
google_drive,pass,40,0,0,0,4.21,
|
||||
google_mail,pass,28,0,0,0,3.57,
|
||||
google_photos,pass,35,0,0,0,3.52,
|
||||
google_sheets,pass,24,0,0,0,3.06,
|
||||
google_tasks,pass,42,0,0,0,4.20,
|
||||
google_travel_time,pass,44,0,0,0,3.65,
|
||||
google_weather,issues,44,1,0,0,3.85,
|
||||
govee_ble,pass,20,0,0,0,3.21,
|
||||
govee_light_local,pass,23,0,0,0,3.02,
|
||||
gpsd,pass,2,0,0,0,2.37,
|
||||
gpslogger,pass,3,0,0,1,2.91,
|
||||
gree,pass,123,0,0,0,7.31,
|
||||
green_planet_energy,pass,11,0,0,0,2.75,
|
||||
group,pass,392,0,0,0,24.47,
|
||||
growatt_server,issues,140,2,0,0,14.23,
|
||||
guardian,issues,11,1,0,0,2.54,
|
||||
guntamatic,pass,15,0,0,0,2.61,
|
||||
habitica,pass,382,0,0,0,38.88,
|
||||
hanna,pass,5,0,0,0,2.41,
|
||||
harmony,pass,22,0,0,0,2.79,
|
||||
hdfury,pass,50,0,0,0,6.49,
|
||||
hegel,pass,12,0,0,0,2.51,
|
||||
heos,issues,147,2,0,0,8.10,
|
||||
here_travel_time,pass,39,0,0,0,3.12,
|
||||
hisense_aehw4a1,pass,4,0,0,0,2.32,
|
||||
history_stats,pass,55,0,0,0,7.60,
|
||||
hive,pass,19,0,0,0,2.85,
|
||||
hko,pass,4,0,0,0,2.38,
|
||||
hlk_sw16,pass,4,0,0,0,2.29,
|
||||
holiday,pass,34,0,0,0,3.70,
|
||||
home_connect,pass,311,0,0,0,15.93,
|
||||
homeassistant_connect_zbt2,issues,24,1,0,0,4.13,
|
||||
homeassistant_sky_connect,pass,36,0,0,0,4.29,
|
||||
homee,pass,199,0,0,0,16.24,
|
||||
homekit,pass,372,0,0,0,13.61,
|
||||
homematicip_cloud,pass,182,0,0,0,23.98,
|
||||
homevolt,pass,33,0,0,0,3.29,
|
||||
homewizard,pass,149,0,0,0,10.98,
|
||||
homeworks,pass,38,0,0,0,4.28,
|
||||
honeywell,pass,44,0,0,0,4.33,
|
||||
honeywell_string_lights,pass,7,0,0,0,2.39,
|
||||
hr_energy_qube,pass,28,0,0,0,4.07,
|
||||
html5,issues,64,1,0,0,5.72,
|
||||
huawei_lte,pass,36,0,0,0,3.65,
|
||||
hue,pass,113,0,0,0,20.05,
|
||||
hue_ble,pass,19,0,0,0,3.05,
|
||||
huisbaasje,pass,13,0,0,0,2.47,
|
||||
hunterdouglas_powerview,pass,39,0,0,0,3.29,
|
||||
husqvarna_automower,issues,93,1,0,0,14.73,
|
||||
husqvarna_automower_ble,pass,42,0,0,0,5.09,
|
||||
huum,pass,34,0,0,0,3.35,
|
||||
hvv_departures,pass,9,0,0,0,2.32,
|
||||
hydrawise,pass,35,0,0,0,4.25,
|
||||
hypontech,pass,17,0,0,0,2.66,
|
||||
ialarm,pass,7,0,0,0,2.30,
|
||||
iaqualink,pass,28,0,0,0,2.86,
|
||||
ibeacon,pass,24,0,0,0,3.53,
|
||||
icloud,pass,17,0,0,0,2.47,
|
||||
idasen_desk,pass,30,0,0,0,3.75,
|
||||
idrive_e2,pass,65,0,0,0,4.66,
|
||||
ifttt,pass,1,0,0,0,2.47,
|
||||
igloohome,pass,8,0,0,0,2.37,
|
||||
imap,pass,159,0,0,0,12.42,
|
||||
imeon_inverter,pass,17,0,0,0,2.94,
|
||||
imgw_pib,issues,15,1,0,0,2.79,
|
||||
immich,issues,54,1,0,0,3.82,
|
||||
improv_ble,pass,38,0,0,0,3.69,
|
||||
incomfort,pass,50,0,0,0,4.05,
|
||||
indevolt,pass,83,0,0,0,6.18,
|
||||
inels,pass,28,0,0,0,3.04,
|
||||
influxdb,pass,145,0,0,0,6.03,
|
||||
inkbird,pass,16,0,0,0,2.99,
|
||||
insteon,pass,79,0,0,0,18.55,
|
||||
integration,pass,61,0,0,0,4.02,
|
||||
intelliclima,pass,28,0,0,0,3.21,
|
||||
intellifire,pass,29,0,0,0,4.24,
|
||||
iometer,pass,17,0,0,0,2.77,
|
||||
ios,pass,3,0,0,0,2.32,
|
||||
iotawatt,pass,9,0,0,0,2.45,
|
||||
iotty,pass,24,0,0,0,3.17,
|
||||
ipma,pass,15,0,0,0,2.73,
|
||||
ipp,pass,29,0,0,0,3.05,
|
||||
iqvia,issues,4,1,0,0,2.40,
|
||||
irm_kmi,pass,13,0,0,0,2.69,
|
||||
iron_os,pass,95,0,0,0,8.95,
|
||||
iskra,pass,13,0,0,0,2.45,
|
||||
islamic_prayer_times,pass,45,0,0,0,3.82,
|
||||
israel_rail,pass,9,0,0,0,2.56,
|
||||
iss,pass,17,0,0,0,2.56,
|
||||
ista_ecotrend,pass,49,0,0,0,4.07,
|
||||
isy994,pass,31,0,0,0,3.06,
|
||||
ituran,pass,19,0,0,0,2.96,
|
||||
izone,pass,10,0,0,0,2.55,
|
||||
jellyfin,pass,44,0,0,0,8.58,
|
||||
jewish_calendar,pass,123,0,0,0,11.89,
|
||||
justnimbus,pass,7,0,0,0,2.37,
|
||||
jvc_projector,pass,31,0,0,0,5.73,
|
||||
kaleidescape,pass,19,0,0,0,4.00,
|
||||
keenetic_ndms2,pass,14,0,0,0,2.38,
|
||||
kegtron,pass,12,0,0,0,2.73,
|
||||
keymitt_ble,pass,7,0,0,0,2.58,
|
||||
kiosker,pass,43,0,0,0,3.31,
|
||||
kmtronic,pass,11,0,0,0,2.48,
|
||||
knocki,pass,17,0,0,0,2.77,
|
||||
knx,pass,387,0,0,0,28.04,
|
||||
kodi,pass,21,0,0,0,2.77,
|
||||
kostal_plenticore,issues,38,1,0,0,4.10,
|
||||
kraken,pass,11,0,0,0,2.85,
|
||||
kulersky,pass,17,0,0,0,2.94,
|
||||
lacrosse_view,issues,28,1,0,0,3.09,
|
||||
lamarzocco,pass,94,0,0,0,14.30,
|
||||
lametric,pass,55,0,0,0,5.57,
|
||||
landisgyr_heat_meter,pass,10,0,0,0,2.77,
|
||||
lastfm,pass,15,0,0,0,2.52,
|
||||
launch_library,pass,2,0,0,0,2.28,
|
||||
laundrify,pass,18,0,0,0,2.79,
|
||||
lcn,pass,149,0,0,0,13.39,
|
||||
ld2410_ble,pass,6,0,0,0,2.56,
|
||||
leaone,pass,6,0,0,0,2.56,
|
||||
led_ble,pass,9,0,0,0,2.65,
|
||||
lektrico,pass,13,0,0,0,2.78,
|
||||
letpot,pass,40,0,0,0,4.13,
|
||||
lg_infrared,pass,61,0,0,0,4.33,
|
||||
lg_netcast,pass,22,0,0,0,3.46,
|
||||
lg_soundbar,pass,11,0,0,0,2.73,
|
||||
lg_thinq,pass,29,0,0,0,4.08,
|
||||
libre_hardware_monitor,pass,26,0,0,0,3.19,
|
||||
lichess,pass,8,0,0,0,2.48,
|
||||
lidarr,pass,12,0,0,0,2.60,
|
||||
liebherr,pass,76,0,0,0,5.72,
|
||||
lifx,pass,72,0,0,0,5.42,
|
||||
linkplay,pass,9,0,0,0,2.49,
|
||||
litejet,pass,32,0,0,0,3.77,
|
||||
litterrobot,pass,63,0,0,0,5.89,
|
||||
livisi,pass,4,0,0,0,2.36,
|
||||
local_calendar,pass,50,0,0,0,5.05,
|
||||
local_ip,pass,3,0,0,0,2.34,
|
||||
local_todo,pass,55,0,0,0,5.56,
|
||||
locative,pass,5,0,0,1,2.99,
|
||||
lojack,pass,16,0,0,0,2.92,
|
||||
london_underground,pass,12,0,0,0,2.55,
|
||||
lookin,pass,7,0,0,0,2.37,
|
||||
loqed,pass,17,0,0,0,2.98,
|
||||
luftdaten,pass,11,0,0,0,2.59,
|
||||
lunatone,pass,40,0,0,0,3.78,
|
||||
lupusec,pass,5,0,0,0,2.38,
|
||||
lutron,pass,42,0,0,0,3.76,
|
||||
lutron_caseta,pass,52,0,0,0,4.93,
|
||||
lyric,pass,4,0,0,0,2.40,
|
||||
madvr,issues,16,1,0,0,3.19,
|
||||
mailgun,pass,5,0,0,0,2.69,
|
||||
marantz_infrared,pass,36,0,0,0,3.44,
|
||||
mastodon,issues,82,1,0,0,26.20,
|
||||
matter,pass,372,0,0,1,68.76,
|
||||
mcp,pass,45,0,0,0,4.15,
|
||||
mcp_server,pass,47,0,0,0,5.93,
|
||||
mealie,pass,94,0,0,0,7.84,
|
||||
meater,pass,10,0,0,0,2.54,
|
||||
medcom_ble,pass,9,0,0,0,2.62,
|
||||
media_extractor,pass,20,0,0,0,7.61,
|
||||
melcloud,issues,23,1,0,0,2.79,
|
||||
melnor,pass,17,0,0,0,3.53,
|
||||
met,pass,18,0,0,0,2.57,
|
||||
met_eireann,pass,10,0,0,0,2.53,
|
||||
meteo_france,pass,6,0,0,0,2.45,
|
||||
meteo_lt,pass,7,0,0,0,2.47,
|
||||
meteoclimatic,pass,3,0,0,0,2.36,
|
||||
metoffice,pass,18,0,0,0,3.12,
|
||||
microbees,pass,9,0,0,0,2.54,
|
||||
miele,pass,115,0,0,0,10.16,
|
||||
mikrotik,pass,21,0,0,0,2.75,
|
||||
mill,pass,31,0,0,0,4.49,
|
||||
min_max,pass,20,0,0,0,2.58,
|
||||
minecraft_server,pass,40,0,0,0,3.49,
|
||||
mitsubishi_comfort,pass,66,0,0,0,5.31,
|
||||
moat,pass,11,0,0,0,2.79,
|
||||
mobile_app,pass,135,0,0,0,13.24,
|
||||
modem_callerid,pass,9,0,0,0,2.56,
|
||||
modern_forms,issues,29,1,0,0,3.44,
|
||||
moehlenhoff_alpha2,pass,8,0,0,0,2.41,
|
||||
mold_indicator,pass,37,0,0,0,3.05,
|
||||
monarch_money,pass,5,0,0,0,2.60,
|
||||
monoprice,pass,23,0,0,0,3.24,
|
||||
monzo,pass,11,0,0,0,2.72,
|
||||
moon,pass,11,0,0,0,2.46,
|
||||
mopeka,pass,14,0,0,0,2.90,
|
||||
motion_blinds,pass,12,0,0,0,2.53,
|
||||
motionblinds_ble,issues,42,1,0,0,6.28,
|
||||
motionmount,pass,38,0,0,0,3.53,
|
||||
mpd,pass,7,0,0,0,2.90,
|
||||
mta,pass,27,0,0,0,2.96,
|
||||
mullvad,pass,4,0,0,0,2.30,
|
||||
music_assistant,pass,127,0,0,0,9.42,
|
||||
mutesync,pass,5,0,0,0,2.32,
|
||||
myneomitis,pass,43,0,0,0,4.11,
|
||||
mysensors,pass,61,0,0,0,5.08,
|
||||
mystrom,pass,23,0,0,0,2.76,
|
||||
myuplink,pass,38,0,0,0,4.83,
|
||||
nam,pass,39,0,0,0,3.38,
|
||||
namecheapdns,pass,20,0,0,0,2.66,
|
||||
nanoleaf,pass,30,0,0,0,2.86,
|
||||
nasweb,pass,8,0,0,0,2.44,
|
||||
nederlandse_spoorwegen,pass,29,0,0,0,3.38,
|
||||
ness_alarm,pass,39,0,0,0,3.04,
|
||||
netgear,pass,10,0,0,0,2.41,
|
||||
netgear_lte,pass,11,0,0,0,2.63,
|
||||
nexia,pass,21,0,0,0,3.55,
|
||||
nextbus,pass,28,0,0,0,2.73,
|
||||
nextcloud,pass,17,0,0,0,2.93,
|
||||
nextdns,issues,52,1,0,0,5.83,
|
||||
nfandroidtv,pass,8,0,0,0,2.39,
|
||||
nibe_heatpump,pass,64,0,0,0,4.34,
|
||||
nice_go,issues,43,1,0,0,4.51,
|
||||
nightscout,pass,11,0,0,0,2.52,
|
||||
niko_home_control,pass,32,0,0,0,3.78,
|
||||
nina,pass,21,0,0,0,3.17,
|
||||
nintendo_parental_controls,pass,30,0,0,0,3.33,
|
||||
nmap_tracker,pass,13,0,0,0,2.57,
|
||||
nmbs,pass,5,0,0,0,2.50,
|
||||
nobo_hub,pass,59,0,0,0,4.63,
|
||||
nordpool,pass,37,0,0,0,4.93,
|
||||
notion,issues,7,1,0,0,2.55,
|
||||
novy_cooker_hood,issues,28,1,0,0,3.15,
|
||||
nrgkick,pass,87,0,0,0,5.68,
|
||||
ntfy,pass,85,0,0,0,6.82,
|
||||
nuheat,pass,9,0,0,0,2.46,
|
||||
nuki,pass,14,0,0,0,2.61,
|
||||
nut,pass,78,0,0,0,4.64,
|
||||
nws,pass,30,0,0,0,3.25,
|
||||
nyt_games,pass,10,0,0,0,2.60,
|
||||
nzbget,pass,10,0,0,0,2.46,
|
||||
obihai,pass,5,0,0,0,2.35,
|
||||
ohme,pass,34,0,0,0,4.42,
|
||||
omie,pass,19,0,0,0,2.86,
|
||||
omnilogic,pass,8,0,0,0,2.39,
|
||||
ondilo_ico,pass,17,0,0,0,2.98,
|
||||
onedrive,pass,79,0,0,0,6.52,
|
||||
onedrive_for_business,pass,38,0,0,0,4.62,
|
||||
onewire,pass,33,0,0,0,3.51,
|
||||
onkyo,pass,48,0,0,0,4.18,
|
||||
open_meteo,pass,5,0,0,0,2.70,
|
||||
opendisplay,pass,67,0,0,0,6.63,
|
||||
openevse,pass,40,0,0,0,4.50,
|
||||
openexchangerates,pass,9,0,0,0,2.38,
|
||||
opengarage,pass,6,0,0,0,2.37,
|
||||
openhome,pass,11,0,0,0,2.58,
|
||||
openrgb,pass,69,0,0,0,4.65,
|
||||
opensky,pass,12,0,0,0,2.64,
|
||||
opentherm_gw,pass,29,0,0,0,5.67,
|
||||
openuv,issues,10,1,0,0,2.69,
|
||||
openweathermap,pass,15,0,0,0,2.65,
|
||||
opower,issues,35,1,0,0,5.84,
|
||||
oralb,pass,14,0,0,0,2.89,
|
||||
orvibo,pass,15,0,0,0,2.60,
|
||||
osoenergy,pass,17,0,0,0,2.95,
|
||||
otbr,pass,103,0,0,0,7.85,
|
||||
otp,pass,7,0,0,0,2.44,
|
||||
ouman_eh_800,pass,20,0,0,0,3.07,
|
||||
ourgroceries,pass,21,0,0,0,2.81,
|
||||
overkiz,pass,181,0,0,0,9.91,
|
||||
overseerr,pass,45,0,0,0,4.83,
|
||||
ovo_energy,pass,7,0,0,0,2.39,
|
||||
owntracks,pass,71,0,0,0,5.75,
|
||||
p1_monitor,issues,11,2,0,0,2.73,
|
||||
paj_gps,pass,19,0,0,0,3.06,
|
||||
palazzetti,pass,17,0,0,0,2.99,
|
||||
panasonic_viera,pass,32,0,0,0,2.99,
|
||||
paperless_ngx,pass,41,0,0,0,3.57,
|
||||
peblar,pass,62,0,0,0,5.20,
|
||||
peco,pass,33,0,0,0,2.98,
|
||||
pegel_online,issues,9,1,0,0,2.60,
|
||||
permobil,pass,9,0,0,0,2.48,
|
||||
pglab,pass,21,0,0,0,3.50,
|
||||
philips_js,issues,15,1,0,0,3.73,
|
||||
pi_hole,issues,25,1,0,0,3.38,
|
||||
picnic,pass,40,0,0,0,3.35,
|
||||
ping,pass,21,0,0,0,2.92,
|
||||
pjlink,pass,29,0,0,0,3.31,
|
||||
plaato,pass,13,0,0,0,2.95,
|
||||
playstation_network,pass,68,0,0,0,6.66,
|
||||
plex,pass,53,0,0,0,6.67,
|
||||
plugwise,pass,86,0,0,0,8.26,
|
||||
point,pass,5,0,0,0,2.47,
|
||||
pooldose,pass,75,0,0,0,5.31,
|
||||
poolsense,pass,5,0,0,0,2.36,
|
||||
portainer,issues,86,1,0,0,12.95,
|
||||
powerfox,pass,22,0,0,0,3.04,
|
||||
powerfox_local,pass,21,0,0,0,2.87,
|
||||
powerwall,pass,37,0,0,0,3.57,
|
||||
prana,pass,37,0,0,0,3.77,
|
||||
private_ble_device,pass,21,0,0,0,3.29,
|
||||
probe_plus,pass,6,0,0,0,2.58,
|
||||
profiler,pass,13,0,0,0,2.57,
|
||||
progettihwsw,pass,4,0,0,0,2.35,
|
||||
prowl,pass,24,0,0,0,2.80,
|
||||
proximity,issues,27,1,0,0,3.03,
|
||||
proxmoxve,issues,103,1,0,0,9.21,
|
||||
ps4,pass,41,0,0,0,3.33,
|
||||
ptdevices,pass,7,0,0,0,2.41,
|
||||
pterodactyl,pass,14,0,0,0,2.66,
|
||||
pure_energie,pass,8,0,0,0,2.46,
|
||||
purpleair,issues,19,1,0,0,3.03,
|
||||
pushbullet,pass,17,0,0,0,2.62,
|
||||
pushover,pass,16,0,0,0,2.47,
|
||||
pvoutput,pass,17,0,0,0,2.75,
|
||||
pvpc_hourly_pricing,pass,2,0,0,0,2.38,
|
||||
pyload,pass,67,0,0,0,4.55,
|
||||
qbittorrent,pass,8,0,0,0,2.54,
|
||||
qbus,pass,25,0,0,0,4.01,
|
||||
qingping,pass,15,0,0,0,2.99,
|
||||
qnap,pass,6,0,0,0,2.39,
|
||||
qnap_qsw,pass,18,0,0,0,3.07,
|
||||
rabbitair,pass,7,0,0,0,2.40,
|
||||
rachio,pass,6,0,0,0,2.52,
|
||||
radarr,pass,32,0,0,0,3.90,
|
||||
radio_browser,pass,10,0,0,0,2.58,
|
||||
radiotherm,pass,7,0,0,0,2.36,
|
||||
rainbird,pass,70,0,0,0,4.30,
|
||||
rainforest_eagle,pass,12,0,0,0,2.46,
|
||||
rainforest_raven,issues,19,2,0,0,3.17,
|
||||
rainmachine,issues,19,2,0,0,2.88,
|
||||
random,pass,9,0,0,0,2.41,
|
||||
rapt_ble,pass,11,0,0,0,2.75,
|
||||
rdw,pass,10,0,0,0,2.58,
|
||||
recollect_waste,issues,3,1,0,0,2.50,
|
||||
redgtech,pass,18,0,0,0,2.77,
|
||||
refoss,pass,2,0,0,0,2.29,
|
||||
rehlko,pass,16,0,0,0,2.90,
|
||||
remote_calendar,pass,44,0,0,0,3.94,
|
||||
renault,pass,104,0,0,0,5.68,
|
||||
renson,pass,3,0,0,0,2.29,
|
||||
rfxtrx,pass,89,0,0,0,7.52,
|
||||
rhasspy,pass,3,0,0,0,2.28,
|
||||
ridwell,issues,13,1,0,0,2.69,
|
||||
risco,pass,69,0,0,0,4.26,
|
||||
rituals_perfume_genie,pass,25,0,0,0,3.10,
|
||||
roborock,pass,183,0,0,0,18.48,
|
||||
roku,pass,67,0,0,0,6.53,
|
||||
romy,pass,6,0,0,0,2.38,
|
||||
roomba,pass,26,0,0,0,2.69,
|
||||
roon,pass,5,0,0,0,2.34,
|
||||
route_b_smart_meter,pass,7,0,0,0,2.61,
|
||||
rova,pass,11,0,0,0,2.44,
|
||||
rpi_power,pass,6,0,0,0,2.33,
|
||||
ruckus_unleashed,pass,29,0,0,0,2.80,
|
||||
russound_rio,pass,50,0,0,0,6.05,
|
||||
ruuvi_gateway,pass,5,0,0,0,2.46,
|
||||
ruuvitag_ble,pass,13,0,0,0,2.75,
|
||||
rympro,pass,7,0,0,0,2.28,
|
||||
sabnzbd,pass,19,0,0,0,2.93,
|
||||
samsung_infrared,pass,18,0,0,0,2.74,
|
||||
samsungtv,issues,166,3,0,0,13.46,
|
||||
sanix,pass,6,0,0,0,2.35,
|
||||
satel_integra,pass,89,0,0,0,7.63,
|
||||
saunum,pass,68,0,0,0,4.99,
|
||||
schlage,pass,49,0,0,0,4.78,
|
||||
scrape,issues,37,1,0,0,3.12,
|
||||
screenlogic,issues,33,1,0,0,3.84,
|
||||
season,pass,21,0,0,0,2.91,
|
||||
sense,pass,19,0,0,0,3.20,
|
||||
sensibo,pass,69,0,0,0,8.44,
|
||||
sensirion_ble,pass,11,0,0,0,2.74,
|
||||
sensorpro,pass,11,0,0,0,2.74,
|
||||
sensorpush,pass,11,0,0,0,2.75,
|
||||
sensorpush_cloud,pass,5,0,0,0,2.45,
|
||||
sensoterra,pass,5,0,0,0,2.38,
|
||||
sentry,pass,24,0,0,0,2.52,
|
||||
senz,pass,21,0,0,0,3.05,
|
||||
seventeentrack,pass,16,0,0,0,2.61,
|
||||
sfr_box,pass,26,0,0,0,2.85,
|
||||
sftp_storage,pass,34,0,0,0,8.68,
|
||||
sharkiq,pass,42,0,0,0,3.25,
|
||||
shelly,pass,636,0,0,0,45.69,
|
||||
shopping_list,pass,60,0,0,0,4.74,
|
||||
sia,pass,21,0,0,0,2.65,
|
||||
simplefin,pass,12,0,0,0,2.68,
|
||||
simplepush,pass,5,0,0,0,2.32,
|
||||
simplisafe,issues,15,1,0,0,2.98,
|
||||
sky_remote,pass,9,0,0,0,2.40,
|
||||
slack,pass,13,0,0,0,2.44,
|
||||
sleep_as_android,pass,44,0,0,0,4.54,
|
||||
sleepiq,pass,33,0,0,0,4.94,
|
||||
slide_local,issues,48,1,0,0,4.02,
|
||||
slimproto,pass,2,0,0,0,2.34,
|
||||
sma,issues,33,1,0,0,3.82,
|
||||
smappee,pass,20,0,0,0,2.72,
|
||||
smarla,pass,37,0,0,0,4.23,
|
||||
smart_meter_texas,pass,16,0,0,0,2.58,
|
||||
smartthings,pass,340,0,0,0,70.72,
|
||||
smarttub,pass,40,0,0,0,4.88,
|
||||
smarty,pass,12,0,0,0,2.78,
|
||||
smhi,pass,20,0,0,0,3.39,
|
||||
smlight,pass,98,0,0,0,7.10,
|
||||
snapcast,pass,18,0,0,0,3.34,
|
||||
snoo,pass,21,0,0,0,3.43,
|
||||
snooz,pass,35,0,0,0,4.87,
|
||||
solaredge,pass,62,0,0,0,11.00,
|
||||
solarlog,issues,25,1,0,0,3.29,
|
||||
solarman,pass,17,0,0,0,2.79,
|
||||
solax,pass,3,0,0,0,2.33,
|
||||
soma,pass,7,0,0,0,2.30,
|
||||
somfy_mylink,pass,11,0,0,0,2.41,
|
||||
sonarr,pass,56,0,0,0,9.69,
|
||||
songpal,pass,26,0,0,0,3.03,
|
||||
sonos,pass,174,0,0,0,18.92,
|
||||
soundtouch,pass,31,0,0,0,3.29,
|
||||
speedtestdotnet,pass,8,0,0,0,2.37,
|
||||
splunk,pass,36,0,0,0,2.94,
|
||||
spotify,pass,70,0,0,0,8.97,
|
||||
sql,pass,80,0,0,0,6.49,
|
||||
squeezebox,pass,129,0,0,0,12.83,
|
||||
srp_energy,pass,15,0,0,0,2.70,
|
||||
starline,pass,4,0,0,0,2.27,
|
||||
starlink,pass,8,0,0,0,2.69,
|
||||
statistics,pass,56,0,0,0,4.17,
|
||||
steam_online,pass,14,0,0,0,2.48,
|
||||
steamist,pass,23,0,0,0,2.93,
|
||||
stiebel_eltron,pass,9,0,0,0,2.75,
|
||||
stookwijzer,pass,12,0,0,0,2.61,
|
||||
streamlabswater,pass,6,0,0,0,2.38,
|
||||
subaru,pass,59,0,0,0,4.58,
|
||||
suez_water,pass,19,0,0,0,4.18,
|
||||
sun,pass,27,0,0,1,4.86,
|
||||
sunricher_dali,pass,50,0,0,0,5.83,
|
||||
sunweg,pass,1,0,0,0,2.23,
|
||||
surepetcare,pass,15,0,0,0,2.74,
|
||||
swiss_public_transport,pass,36,0,0,0,3.10,
|
||||
switch_as_x,pass,176,0,0,0,10.02,
|
||||
switchbee,pass,6,0,0,0,2.34,
|
||||
switchbot,issues,352,1,0,0,19.66,
|
||||
switchbot_cloud,pass,134,0,0,0,10.81,
|
||||
switcher_kis,issues,76,1,0,0,7.69,
|
||||
syncthing,pass,5,0,0,0,2.30,
|
||||
syncthru,pass,9,0,0,0,2.59,
|
||||
system_bridge,pass,39,0,0,0,4.46,
|
||||
systemmonitor,issues,34,2,0,0,4.87,
|
||||
systemnexa2,pass,34,0,0,0,3.57,
|
||||
tado,pass,40,0,0,0,4.67,
|
||||
tailscale,pass,14,0,0,0,2.75,
|
||||
tailwind,pass,36,0,0,0,3.30,
|
||||
tami4,pass,14,0,0,0,2.56,
|
||||
tankerkoenig,issues,20,1,0,0,2.84,
|
||||
tautulli,pass,8,0,0,0,2.35,
|
||||
technove,pass,40,0,0,0,3.71,
|
||||
tedee,pass,41,0,0,0,4.48,
|
||||
telegram_bot,pass,123,0,0,0,13.16,
|
||||
teleinfo,pass,24,0,0,0,3.22,
|
||||
tellduslive,pass,15,0,0,0,2.41,
|
||||
teltonika,pass,50,0,0,0,3.79,
|
||||
template,pass,2470,0,0,0,60.07,
|
||||
tesla_fleet,pass,153,0,0,0,12.29,
|
||||
tesla_wall_connector,pass,12,0,0,0,2.47,
|
||||
teslemetry,pass,158,0,0,0,16.84,
|
||||
tessie,pass,67,0,0,0,4.46,
|
||||
thermobeacon,pass,11,0,0,0,2.72,
|
||||
thermopro,pass,18,0,0,0,3.01,
|
||||
thethingsnetwork,pass,8,0,0,0,2.40,
|
||||
thread,pass,65,0,0,0,4.17,
|
||||
threshold,pass,114,0,0,0,4.14,
|
||||
tibber,pass,92,0,0,0,13.32,
|
||||
tile,pass,11,0,0,0,2.50,
|
||||
tilt_ble,pass,11,0,0,0,2.79,
|
||||
tilt_pi,pass,6,0,0,0,2.43,
|
||||
time_date,pass,18,0,0,0,2.78,
|
||||
tod,pass,32,0,0,0,3.25,
|
||||
todoist,pass,50,0,0,0,4.23,
|
||||
togrill,pass,62,0,0,0,5.73,
|
||||
tolo,pass,7,0,0,0,2.35,
|
||||
tomorrowio,pass,21,0,0,0,3.08,
|
||||
toon,pass,10,0,0,0,2.71,
|
||||
totalconnect,pass,66,0,0,0,11.65,
|
||||
touchline,pass,8,0,0,0,2.52,
|
||||
touchline_sl,pass,15,0,0,0,2.71,
|
||||
tplink_omada,issues,52,1,0,0,4.67,
|
||||
traccar,pass,5,0,0,1,2.90,
|
||||
traccar_server,pass,12,0,0,0,2.88,
|
||||
tractive,issues,48,1,0,0,4.03,
|
||||
tradfri,pass,91,0,0,0,6.36,
|
||||
trafikverket_ferry,pass,13,0,0,0,2.52,
|
||||
trafikverket_train,pass,41,0,0,0,3.14,
|
||||
trafikverket_weatherstation,pass,16,0,0,0,2.53,
|
||||
trane,pass,39,0,0,0,3.98,
|
||||
transmission,pass,75,0,0,0,4.60,
|
||||
trend,pass,39,0,0,0,3.25,
|
||||
triggercmd,pass,5,0,0,0,2.37,
|
||||
trmnl,pass,36,0,0,0,3.72,
|
||||
twentemilieu,pass,17,0,0,0,2.87,
|
||||
twilio,pass,1,0,0,0,2.57,
|
||||
twinkly,issues,25,1,0,0,4.12,
|
||||
twitch,pass,16,0,0,0,4.22,
|
||||
uhoo,pass,24,0,0,0,2.99,
|
||||
ukraine_alarm,pass,11,0,0,0,2.43,
|
||||
unifi,issues,158,1,0,0,12.39,
|
||||
unifi_access,pass,155,0,0,0,10.85,
|
||||
upb,pass,6,0,0,0,2.42,
|
||||
upcloud,pass,6,0,0,0,2.35,
|
||||
upnp,pass,23,0,0,0,3.83,
|
||||
uptime,pass,4,0,0,0,2.36,
|
||||
uptime_kuma,pass,38,0,0,0,3.58,
|
||||
uptimerobot,pass,35,0,0,0,3.18,
|
||||
utility_meter,issues,94,1,0,0,6.00,
|
||||
v2c,issues,12,1,0,0,2.86,
|
||||
vallox,pass,76,0,0,0,5.65,
|
||||
vegehub,pass,18,0,0,0,2.92,
|
||||
velbus,issues,57,1,0,0,8.99,
|
||||
velux,pass,80,0,0,0,4.86,
|
||||
venstar,pass,12,0,0,0,2.56,
|
||||
vera,pass,29,0,0,0,3.08,
|
||||
version,pass,10,0,0,0,2.55,
|
||||
vesync,pass,175,0,0,0,12.54,
|
||||
vicare,issues,54,1,0,0,4.48,
|
||||
victron_ble,pass,34,0,0,0,3.61,
|
||||
victron_gx,pass,62,0,0,0,14.13,
|
||||
victron_remote_monitoring,pass,22,0,0,0,3.03,
|
||||
vilfo,pass,9,0,0,0,2.46,
|
||||
vizio,pass,73,0,0,0,4.50,
|
||||
vlc_telnet,pass,39,0,0,0,3.40,
|
||||
vodafone_station,issues,53,1,0,0,4.60,
|
||||
volumio,pass,9,0,0,0,2.29,
|
||||
volvo,pass,110,0,0,0,8.64,
|
||||
volvooncall,pass,4,0,0,0,2.20,
|
||||
wake_on_lan,pass,15,0,0,0,2.50,
|
||||
wallbox,pass,38,0,0,0,3.67,
|
||||
waqi,pass,20,0,0,0,2.63,
|
||||
waterfurnace,pass,77,0,0,0,11.93,
|
||||
watergate,pass,16,0,0,0,3.04,
|
||||
watts,issues,51,1,0,0,5.17,
|
||||
watttime,issues,11,1,0,0,2.56,
|
||||
waze_travel_time,pass,23,0,0,0,14.84,
|
||||
weatherflow,pass,5,0,0,0,2.95,
|
||||
weatherflow_cloud,pass,17,0,0,0,3.12,
|
||||
weatherkit,pass,35,0,0,0,2.92,
|
||||
webdav,pass,25,0,0,0,3.90,
|
||||
webmin,issues,11,1,0,0,2.52,
|
||||
webostv,issues,82,1,0,0,12.04,
|
||||
weheat,pass,16,0,0,0,2.85,
|
||||
wemo,pass,93,0,0,0,6.71,
|
||||
whirlpool,issues,239,1,0,0,12.16,
|
||||
whois,pass,41,0,0,0,3.40,
|
||||
wiffi,pass,4,0,0,0,2.19,
|
||||
wiim,pass,29,0,0,0,3.02,
|
||||
wilight,pass,22,0,0,0,2.82,
|
||||
withings,pass,62,0,0,0,8.01,
|
||||
wiz,pass,56,0,0,0,4.34,
|
||||
wled,pass,100,0,0,0,7.60,
|
||||
wmspro,pass,39,0,0,0,4.04,
|
||||
wolflink,pass,10,0,0,0,2.49,
|
||||
workday,issues,70,1,0,0,6.14,
|
||||
worldclock,pass,6,0,0,0,2.42,
|
||||
ws66i,pass,24,0,0,0,3.12,
|
||||
wsdot,pass,16,0,0,0,2.66,
|
||||
xbox,pass,203,0,0,0,14.92,
|
||||
xiaomi_aqara,pass,12,0,0,0,2.46,
|
||||
xiaomi_ble,pass,92,0,0,0,6.22,
|
||||
xiaomi_miio,pass,51,0,0,0,4.11,
|
||||
xthings_cloud,pass,30,0,0,0,3.72,
|
||||
yale_smart_alarm,pass,37,0,0,0,3.71,
|
||||
yalexs_ble,pass,26,0,0,0,3.32,
|
||||
yamaha_musiccast,pass,9,0,0,0,2.52,
|
||||
yardian,pass,16,0,0,0,2.79,
|
||||
yeelight,pass,62,0,0,0,4.73,
|
||||
yolink,pass,10,0,0,0,3.10,
|
||||
youless,pass,4,0,0,0,2.41,
|
||||
youtube,pass,24,0,0,0,3.38,
|
||||
zamg,pass,9,0,0,0,2.52,
|
||||
zerproc,pass,10,0,0,0,2.49,
|
||||
zeversolar,pass,10,0,0,0,2.46,
|
||||
zha,issues,339,1,0,4,70.49,
|
||||
zimi,pass,16,0,0,0,2.65,
|
||||
zinvolt,pass,12,0,0,0,2.77,
|
||||
zodiac,pass,5,0,0,0,2.31,
|
||||
zwave_js,pass,611,0,0,0,60.17,
|
||||
zwave_me,pass,8,0,0,0,2.24,
|
||||
|
@@ -0,0 +1,857 @@
|
||||
# Sandbox — full compat sweep (Phase 16)
|
||||
|
||||
**This file is auto-generated by `run_compat_full.py`** — re-run the
|
||||
script to refresh it. Companion machine-readable CSV is `COMPAT_FULL.csv`,
|
||||
categorised remediation backlog is `BACKLOG.md`.
|
||||
|
||||
## Sweep parameters
|
||||
|
||||
- Started: `2026-05-24T00:51:34`
|
||||
- Finished: `2026-05-24T01:03:01`
|
||||
- Wall time: **687s**
|
||||
- Outer concurrency: **6**
|
||||
- Per-integration pytest-xdist: **off**
|
||||
- Plugin: `hass_client.testing.pytest_plugin` (in-process)
|
||||
|
||||
## Discovery
|
||||
|
||||
Walked `homeassistant/components/`, applied the Phase 16 filters:
|
||||
|
||||
| Filter | Skipped |
|
||||
| --- | ---: |
|
||||
| No / invalid manifest | 1 |
|
||||
| `integration_type` in (`virtual`, `system`) | 206 |
|
||||
| Domain in `ALWAYS_MAIN` | 3 |
|
||||
| Ships a platform in `SANDBOX_INCOMPATIBLE_PLATFORMS` | 73 |
|
||||
| No `config_flow` in manifest | 380 |
|
||||
| No `test_*.py` files | 0 |
|
||||
| **Eligible (this sweep)** | **807** |
|
||||
|
||||
## Summary
|
||||
|
||||
- Integrations exercised: **807**
|
||||
- Fully passing: **711** (88.10%)
|
||||
- With failures: **96**
|
||||
- Timeouts: **0**
|
||||
- Spawn errors: **0**
|
||||
- No tests collected: **0**
|
||||
|
||||
- Tests passed: **34266**
|
||||
- Tests failed: **112**
|
||||
- Test errors: **0**
|
||||
- Tests skipped: **11**
|
||||
- **Test-level pass rate: 99.67%**
|
||||
|
||||
## Per-integration results
|
||||
|
||||
| integration | status | passed | failed | errors | skipped | dur (s) | bucket |
|
||||
| --- | --- | ---: | ---: | ---: | ---: | ---: | --- |
|
||||
| acaia | pass | 24 | 0 | 0 | 0 | 3.5 | |
|
||||
| accuweather | pass | 38 | 0 | 0 | 0 | 5.1 | |
|
||||
| acmeda | pass | 8 | 0 | 0 | 0 | 2.6 | |
|
||||
| actron_air | pass | 49 | 0 | 0 | 0 | 5.2 | |
|
||||
| adax | pass | 20 | 0 | 0 | 0 | 2.9 | |
|
||||
| adguard | pass | 36 | 0 | 0 | 0 | 3.4 | |
|
||||
| advantage_air | pass | 20 | 0 | 0 | 0 | 4.9 | |
|
||||
| aemet | issues | 16 | 1 | 0 | 0 | 3.3 | |
|
||||
| aftership | pass | 2 | 0 | 0 | 0 | 2.3 | |
|
||||
| aidot | pass | 16 | 0 | 0 | 0 | 2.9 | |
|
||||
| airgradient | pass | 47 | 0 | 0 | 0 | 4.7 | |
|
||||
| airly | issues | 21 | 1 | 0 | 0 | 3.0 | |
|
||||
| airnow | issues | 9 | 1 | 0 | 0 | 2.5 | |
|
||||
| airobot | pass | 60 | 0 | 0 | 0 | 4.8 | |
|
||||
| airos | pass | 62 | 0 | 0 | 0 | 4.9 | |
|
||||
| airpatrol | pass | 25 | 0 | 0 | 0 | 3.2 | |
|
||||
| airq | issues | 22 | 1 | 0 | 0 | 3.3 | |
|
||||
| airthings | pass | 12 | 0 | 0 | 0 | 2.6 | |
|
||||
| airthings_ble | pass | 38 | 0 | 0 | 0 | 4.1 | |
|
||||
| airtouch4 | pass | 5 | 0 | 0 | 0 | 2.4 | |
|
||||
| airtouch5 | pass | 5 | 0 | 0 | 0 | 2.4 | |
|
||||
| airvisual | issues | 15 | 1 | 0 | 0 | 2.9 | |
|
||||
| airvisual_pro | issues | 10 | 1 | 0 | 0 | 2.7 | |
|
||||
| airzone | issues | 37 | 1 | 0 | 0 | 5.6 | |
|
||||
| airzone_cloud | issues | 26 | 1 | 0 | 0 | 4.9 | |
|
||||
| aladdin_connect | issues | 32 | 1 | 0 | 0 | 3.8 | |
|
||||
| alarmdecoder | pass | 8 | 0 | 0 | 0 | 2.4 | |
|
||||
| alexa_devices | issues | 72 | 1 | 0 | 0 | 7.3 | |
|
||||
| altruist | pass | 11 | 0 | 0 | 0 | 2.6 | |
|
||||
| amberelectric | pass | 39 | 0 | 0 | 0 | 3.6 | |
|
||||
| ambient_network | pass | 7 | 0 | 0 | 0 | 2.8 | |
|
||||
| ambient_station | issues | 3 | 1 | 0 | 0 | 2.6 | |
|
||||
| analytics_insights | pass | 20 | 0 | 0 | 0 | 3.1 | |
|
||||
| androidtv | pass | 79 | 0 | 0 | 0 | 5.2 | |
|
||||
| androidtv_remote | pass | 40 | 0 | 0 | 0 | 4.6 | |
|
||||
| anglian_water | pass | 14 | 0 | 0 | 0 | 3.5 | |
|
||||
| anova | pass | 11 | 0 | 0 | 0 | 2.7 | |
|
||||
| anthemav | pass | 11 | 0 | 0 | 0 | 2.6 | |
|
||||
| aosmith | pass | 33 | 0 | 0 | 0 | 3.6 | |
|
||||
| apcupsd | pass | 44 | 0 | 0 | 0 | 4.3 | |
|
||||
| apple_tv | pass | 51 | 0 | 0 | 0 | 4.6 | |
|
||||
| aprilaire | pass | 3 | 0 | 0 | 0 | 2.5 | |
|
||||
| apsystems | pass | 13 | 0 | 0 | 0 | 3.0 | |
|
||||
| aquacell | pass | 13 | 0 | 0 | 0 | 2.9 | |
|
||||
| aranet | pass | 19 | 0 | 0 | 0 | 3.4 | |
|
||||
| arcam_fmj | pass | 85 | 0 | 0 | 0 | 5.8 | |
|
||||
| arve | pass | 5 | 0 | 0 | 0 | 2.7 | |
|
||||
| aseko_pool_live | pass | 13 | 0 | 0 | 0 | 2.8 | |
|
||||
| asuswrt | pass | 60 | 0 | 0 | 0 | 4.7 | |
|
||||
| atag | pass | 14 | 0 | 0 | 0 | 2.9 | |
|
||||
| aurora | pass | 4 | 0 | 0 | 0 | 2.5 | |
|
||||
| aurora_abb_powerone | pass | 7 | 0 | 0 | 0 | 6.8 | |
|
||||
| aussie_broadband | pass | 14 | 0 | 0 | 0 | 2.7 | |
|
||||
| autarco | pass | 13 | 0 | 0 | 0 | 2.7 | |
|
||||
| autoskope | pass | 22 | 0 | 0 | 0 | 2.9 | |
|
||||
| avea | pass | 20 | 0 | 0 | 0 | 3.1 | |
|
||||
| awair | pass | 24 | 0 | 0 | 0 | 2.7 | |
|
||||
| aws_s3 | pass | 51 | 0 | 0 | 0 | 5.0 | |
|
||||
| azure_data_explorer | pass | 18 | 0 | 0 | 0 | 2.8 | |
|
||||
| azure_devops | pass | 20 | 0 | 0 | 0 | 2.8 | |
|
||||
| azure_event_hub | pass | 21 | 0 | 0 | 0 | 2.6 | |
|
||||
| azure_storage | pass | 25 | 0 | 0 | 0 | 3.7 | |
|
||||
| backblaze_b2 | pass | 77 | 0 | 0 | 0 | 5.6 | |
|
||||
| baf | pass | 8 | 0 | 0 | 0 | 2.5 | |
|
||||
| balboa | pass | 42 | 0 | 0 | 0 | 5.5 | |
|
||||
| bang_olufsen | issues | 100 | 2 | 0 | 0 | 35.9 | |
|
||||
| bayesian | pass | 48 | 0 | 0 | 0 | 3.2 | |
|
||||
| blebox | pass | 117 | 0 | 0 | 0 | 9.0 | |
|
||||
| blue_current | pass | 28 | 0 | 0 | 0 | 3.0 | |
|
||||
| bluemaestro | pass | 11 | 0 | 0 | 0 | 2.8 | |
|
||||
| bluesound | pass | 39 | 0 | 0 | 0 | 4.7 | |
|
||||
| bluetooth | issues | 234 | 1 | 0 | 1 | 9.8 | |
|
||||
| bond | pass | 141 | 0 | 0 | 0 | 6.3 | |
|
||||
| bosch_alarm | pass | 121 | 0 | 0 | 0 | 11.9 | |
|
||||
| bosch_shc | pass | 16 | 0 | 0 | 0 | 2.7 | |
|
||||
| braviatv | issues | 19 | 1 | 0 | 0 | 2.7 | |
|
||||
| bring | pass | 72 | 0 | 0 | 0 | 6.0 | |
|
||||
| broadlink | pass | 104 | 0 | 0 | 0 | 4.8 | |
|
||||
| brother | pass | 33 | 0 | 0 | 0 | 7.2 | |
|
||||
| brottsplatskartan | pass | 4 | 0 | 0 | 0 | 2.3 | |
|
||||
| brunt | pass | 8 | 0 | 0 | 0 | 2.4 | |
|
||||
| bryant_evolution | pass | 24 | 0 | 0 | 0 | 3.1 | |
|
||||
| bsblan | pass | 127 | 0 | 0 | 0 | 11.9 | |
|
||||
| bthome | pass | 107 | 0 | 0 | 0 | 7.0 | |
|
||||
| caldav | pass | 109 | 0 | 0 | 0 | 6.7 | |
|
||||
| cambridge_audio | pass | 52 | 0 | 0 | 0 | 7.8 | |
|
||||
| casper_glow | pass | 67 | 0 | 0 | 0 | 6.4 | |
|
||||
| cast | pass | 86 | 0 | 0 | 0 | 6.6 | |
|
||||
| ccm15 | pass | 8 | 0 | 0 | 0 | 2.5 | |
|
||||
| centriconnect | pass | 10 | 0 | 0 | 0 | 2.5 | |
|
||||
| cert_expiry | pass | 18 | 0 | 0 | 0 | 2.8 | |
|
||||
| chacon_dio | pass | 16 | 0 | 0 | 0 | 2.8 | |
|
||||
| chess_com | issues | 8 | 1 | 0 | 0 | 2.8 | |
|
||||
| cielo_home | pass | 7 | 0 | 0 | 0 | 18.3 | |
|
||||
| cloudflare | pass | 14 | 0 | 0 | 0 | 2.5 | |
|
||||
| cloudflare_r2 | pass | 59 | 0 | 0 | 0 | 4.7 | |
|
||||
| co2signal | issues | 15 | 1 | 0 | 0 | 2.9 | |
|
||||
| coinbase | issues | 15 | 1 | 0 | 0 | 2.6 | |
|
||||
| color_extractor | pass | 9 | 0 | 0 | 0 | 4.5 | |
|
||||
| comelit | issues | 93 | 2 | 0 | 0 | 9.0 | |
|
||||
| compit | pass | 65 | 0 | 0 | 0 | 6.5 | |
|
||||
| control4 | pass | 54 | 0 | 0 | 0 | 4.6 | |
|
||||
| cookidoo | pass | 52 | 0 | 0 | 0 | 5.4 | |
|
||||
| coolmaster | pass | 37 | 0 | 0 | 0 | 3.5 | |
|
||||
| cpuspeed | pass | 8 | 0 | 0 | 0 | 2.4 | |
|
||||
| crownstone | pass | 11 | 0 | 0 | 0 | 2.7 | |
|
||||
| cync | pass | 19 | 0 | 0 | 0 | 2.6 | |
|
||||
| daikin | pass | 31 | 0 | 0 | 0 | 3.1 | |
|
||||
| data_grand_lyon | issues | 40 | 2 | 0 | 0 | 3.3 | |
|
||||
| datadog | pass | 12 | 0 | 0 | 0 | 2.4 | |
|
||||
| deako | pass | 13 | 0 | 0 | 0 | 2.9 | |
|
||||
| deconz | issues | 172 | 1 | 0 | 1 | 12.9 | |
|
||||
| decora_wifi | pass | 13 | 0 | 0 | 0 | 3.0 | |
|
||||
| deluge | pass | 7 | 0 | 0 | 0 | 2.3 | |
|
||||
| denon_rs232 | pass | 23 | 0 | 0 | 0 | 3.1 | |
|
||||
| denonavr | pass | 16 | 0 | 0 | 0 | 2.9 | |
|
||||
| derivative | pass | 76 | 0 | 0 | 0 | 4.6 | |
|
||||
| devialet | pass | 13 | 0 | 0 | 0 | 2.6 | |
|
||||
| devolo_home_control | issues | 41 | 1 | 0 | 0 | 4.2 | |
|
||||
| devolo_home_network | issues | 51 | 1 | 0 | 0 | 6.0 | |
|
||||
| dexcom | pass | 10 | 0 | 0 | 0 | 2.5 | |
|
||||
| dialogflow | pass | 17 | 0 | 0 | 0 | 3.2 | |
|
||||
| directv | pass | 24 | 0 | 0 | 0 | 3.1 | |
|
||||
| discord | pass | 11 | 0 | 0 | 0 | 2.5 | |
|
||||
| discovergy | pass | 25 | 0 | 0 | 0 | 3.2 | |
|
||||
| dlink | pass | 15 | 0 | 0 | 0 | 2.6 | |
|
||||
| dlna_dmr | pass | 72 | 0 | 0 | 0 | 13.9 | |
|
||||
| dlna_dms | pass | 55 | 0 | 0 | 0 | 8.0 | |
|
||||
| dnsip | pass | 21 | 0 | 0 | 0 | 2.7 | |
|
||||
| dormakaba_dkey | pass | 14 | 0 | 0 | 0 | 2.9 | |
|
||||
| downloader | pass | 21 | 0 | 0 | 0 | 2.6 | |
|
||||
| drop_connect | pass | 30 | 0 | 0 | 0 | 3.8 | |
|
||||
| dropbox | pass | 29 | 0 | 0 | 0 | 3.6 | |
|
||||
| droplet | pass | 16 | 0 | 0 | 0 | 3.2 | |
|
||||
| dsmr | pass | 75 | 0 | 0 | 0 | 4.6 | |
|
||||
| dsmr_reader | issues | 8 | 1 | 0 | 0 | 2.8 | |
|
||||
| duckdns | pass | 18 | 0 | 0 | 0 | 2.7 | |
|
||||
| duco | pass | 61 | 0 | 0 | 0 | 7.5 | |
|
||||
| dunehd | pass | 6 | 0 | 0 | 0 | 2.4 | |
|
||||
| duotecno | pass | 5 | 0 | 0 | 0 | 2.4 | |
|
||||
| dwd_weather_warnings | pass | 11 | 0 | 0 | 0 | 2.5 | |
|
||||
| dynalite | pass | 33 | 0 | 0 | 0 | 3.4 | |
|
||||
| eafm | pass | 18 | 0 | 0 | 0 | 2.6 | |
|
||||
| earn_e_p1 | pass | 25 | 0 | 0 | 0 | 2.9 | |
|
||||
| easyenergy | pass | 70 | 0 | 0 | 0 | 5.2 | |
|
||||
| ecobee | pass | 60 | 0 | 0 | 0 | 3.5 | |
|
||||
| ecoforest | pass | 4 | 0 | 0 | 0 | 2.4 | |
|
||||
| econet | pass | 4 | 0 | 0 | 0 | 2.3 | |
|
||||
| ecovacs | issues | 74 | 2 | 0 | 0 | 6.7 | |
|
||||
| ecowitt | pass | 1 | 0 | 0 | 0 | 2.3 | |
|
||||
| edl21 | pass | 2 | 0 | 0 | 0 | 2.3 | |
|
||||
| efergy | pass | 12 | 0 | 0 | 0 | 2.5 | |
|
||||
| egauge | pass | 16 | 0 | 0 | 0 | 2.8 | |
|
||||
| eheimdigital | issues | 79 | 1 | 0 | 0 | 8.3 | |
|
||||
| ekeybionyx | pass | 13 | 0 | 0 | 0 | 7.7 | |
|
||||
| electrasmart | pass | 6 | 0 | 0 | 0 | 2.3 | |
|
||||
| electric_kiwi | pass | 18 | 0 | 0 | 0 | 3.0 | |
|
||||
| elgato | pass | 36 | 0 | 0 | 0 | 3.9 | |
|
||||
| elkm1 | pass | 45 | 0 | 0 | 0 | 3.4 | |
|
||||
| elmax | pass | 29 | 0 | 0 | 0 | 2.8 | |
|
||||
| elvia | pass | 9 | 0 | 0 | 0 | 2.7 | |
|
||||
| emoncms | pass | 16 | 0 | 0 | 0 | 2.7 | |
|
||||
| emonitor | pass | 7 | 0 | 0 | 0 | 2.4 | |
|
||||
| emulated_roku | pass | 7 | 0 | 0 | 0 | 2.4 | |
|
||||
| energenie_power_sockets | pass | 13 | 0 | 0 | 0 | 2.6 | |
|
||||
| energyid | pass | 81 | 0 | 0 | 0 | 3.9 | |
|
||||
| energyzero | pass | 41 | 0 | 0 | 0 | 3.8 | |
|
||||
| enigma2 | pass | 26 | 0 | 0 | 0 | 3.3 | |
|
||||
| enocean | pass | 14 | 0 | 0 | 0 | 2.8 | |
|
||||
| enphase_envoy | issues | 231 | 5 | 0 | 0 | 24.6 | |
|
||||
| epic_games_store | pass | 20 | 0 | 0 | 0 | 2.7 | |
|
||||
| epion | pass | 4 | 0 | 0 | 0 | 2.3 | |
|
||||
| epson | pass | 5 | 0 | 0 | 0 | 2.5 | |
|
||||
| eq3btsmart | pass | 4 | 0 | 0 | 0 | 2.6 | |
|
||||
| escea | pass | 3 | 0 | 0 | 0 | 2.3 | |
|
||||
| essent | pass | 14 | 0 | 0 | 0 | 2.7 | |
|
||||
| eufylife_ble | pass | 10 | 0 | 0 | 0 | 2.8 | |
|
||||
| eurotronic_cometblue | pass | 39 | 0 | 0 | 0 | 4.0 | |
|
||||
| evil_genius_labs | pass | 10 | 0 | 0 | 0 | 2.5 | |
|
||||
| faa_delays | pass | 4 | 0 | 0 | 0 | 2.3 | |
|
||||
| fastdotcom | pass | 8 | 0 | 0 | 0 | 2.5 | |
|
||||
| feedreader | pass | 30 | 0 | 0 | 0 | 3.0 | |
|
||||
| fibaro | pass | 45 | 0 | 0 | 0 | 3.9 | |
|
||||
| file | pass | 26 | 0 | 0 | 0 | 2.8 | |
|
||||
| filesize | pass | 13 | 0 | 0 | 0 | 2.6 | |
|
||||
| filter | pass | 32 | 0 | 0 | 0 | 4.1 | |
|
||||
| fing | pass | 17 | 0 | 0 | 0 | 2.9 | |
|
||||
| firefly_iii | issues | 25 | 1 | 0 | 0 | 3.3 | |
|
||||
| fireservicerota | pass | 5 | 0 | 0 | 0 | 2.4 | |
|
||||
| fitbit | pass | 60 | 0 | 0 | 0 | 6.5 | |
|
||||
| fivem | pass | 5 | 0 | 0 | 0 | 2.4 | |
|
||||
| fjaraskupan | pass | 11 | 0 | 0 | 0 | 2.9 | |
|
||||
| flexit_bacnet | pass | 19 | 0 | 0 | 0 | 2.8 | |
|
||||
| flipr | pass | 18 | 0 | 0 | 0 | 2.8 | |
|
||||
| flo | pass | 10 | 0 | 0 | 0 | 2.7 | |
|
||||
| flume | pass | 11 | 0 | 0 | 0 | 2.5 | |
|
||||
| fluss | pass | 16 | 0 | 0 | 0 | 2.7 | |
|
||||
| flux_led | pass | 82 | 0 | 0 | 0 | 6.6 | |
|
||||
| folder_watcher | pass | 10 | 0 | 0 | 0 | 2.6 | |
|
||||
| forecast_solar | pass | 28 | 0 | 0 | 0 | 3.0 | |
|
||||
| forked_daapd | pass | 36 | 0 | 0 | 0 | 4.6 | |
|
||||
| freedompro | pass | 38 | 0 | 0 | 0 | 4.5 | |
|
||||
| freshr | issues | 28 | 1 | 0 | 0 | 3.4 | |
|
||||
| fressnapf_tracker | pass | 45 | 0 | 0 | 0 | 4.5 | |
|
||||
| fritz | issues | 138 | 1 | 0 | 0 | 10.2 | |
|
||||
| fritzbox | pass | 132 | 0 | 0 | 0 | 8.6 | |
|
||||
| fritzbox_callmonitor | pass | 12 | 0 | 0 | 0 | 2.5 | |
|
||||
| fronius | issues | 35 | 1 | 0 | 0 | 3.9 | |
|
||||
| frontier_silicon | pass | 22 | 0 | 0 | 0 | 2.6 | |
|
||||
| fujitsu_fglair | pass | 25 | 0 | 0 | 0 | 3.6 | |
|
||||
| fumis | pass | 72 | 0 | 0 | 0 | 5.6 | |
|
||||
| fyta | issues | 33 | 1 | 0 | 0 | 3.7 | |
|
||||
| garages_amsterdam | pass | 6 | 0 | 0 | 0 | 2.5 | |
|
||||
| gardena_bluetooth | pass | 40 | 0 | 0 | 0 | 5.2 | |
|
||||
| gdacs | pass | 8 | 0 | 0 | 0 | 2.7 | |
|
||||
| generic_hygrostat | pass | 76 | 0 | 0 | 0 | 4.0 | |
|
||||
| generic_thermostat | pass | 114 | 0 | 0 | 0 | 5.6 | |
|
||||
| geniushub | pass | 23 | 0 | 0 | 0 | 2.9 | |
|
||||
| gentex_homelink | pass | 13 | 0 | 0 | 0 | 2.8 | |
|
||||
| geo_json_events | pass | 5 | 0 | 0 | 0 | 2.5 | |
|
||||
| geocaching | pass | 5 | 0 | 0 | 0 | 2.8 | |
|
||||
| geofency | pass | 5 | 0 | 0 | 0 | 3.0 | |
|
||||
| geonetnz_quakes | pass | 9 | 0 | 0 | 0 | 2.8 | |
|
||||
| geonetnz_volcano | pass | 7 | 0 | 0 | 0 | 2.6 | |
|
||||
| ghost | pass | 27 | 0 | 0 | 0 | 3.4 | |
|
||||
| gios | issues | 17 | 1 | 0 | 0 | 3.1 | |
|
||||
| github | pass | 17 | 0 | 0 | 0 | 2.9 | |
|
||||
| glances | pass | 16 | 0 | 0 | 0 | 2.8 | |
|
||||
| goalzero | pass | 15 | 0 | 0 | 0 | 2.7 | |
|
||||
| gogogate2 | pass | 15 | 0 | 0 | 0 | 2.7 | |
|
||||
| goodwe | issues | 5 | 1 | 0 | 0 | 2.6 | |
|
||||
| google | issues | 136 | 1 | 0 | 0 | 8.2 | |
|
||||
| google_air_quality | pass | 19 | 0 | 0 | 0 | 2.8 | |
|
||||
| google_assistant_sdk | pass | 41 | 0 | 0 | 0 | 3.7 | |
|
||||
| google_drive | pass | 40 | 0 | 0 | 0 | 4.2 | |
|
||||
| google_mail | pass | 28 | 0 | 0 | 0 | 3.6 | |
|
||||
| google_photos | pass | 35 | 0 | 0 | 0 | 3.5 | |
|
||||
| google_sheets | pass | 24 | 0 | 0 | 0 | 3.1 | |
|
||||
| google_tasks | pass | 42 | 0 | 0 | 0 | 4.2 | |
|
||||
| google_travel_time | pass | 44 | 0 | 0 | 0 | 3.7 | |
|
||||
| google_weather | issues | 44 | 1 | 0 | 0 | 3.9 | |
|
||||
| govee_ble | pass | 20 | 0 | 0 | 0 | 3.2 | |
|
||||
| govee_light_local | pass | 23 | 0 | 0 | 0 | 3.0 | |
|
||||
| gpsd | pass | 2 | 0 | 0 | 0 | 2.4 | |
|
||||
| gpslogger | pass | 3 | 0 | 0 | 1 | 2.9 | |
|
||||
| gree | pass | 123 | 0 | 0 | 0 | 7.3 | |
|
||||
| green_planet_energy | pass | 11 | 0 | 0 | 0 | 2.7 | |
|
||||
| group | pass | 392 | 0 | 0 | 0 | 24.5 | |
|
||||
| growatt_server | issues | 140 | 2 | 0 | 0 | 14.2 | |
|
||||
| guardian | issues | 11 | 1 | 0 | 0 | 2.5 | |
|
||||
| guntamatic | pass | 15 | 0 | 0 | 0 | 2.6 | |
|
||||
| habitica | pass | 382 | 0 | 0 | 0 | 38.9 | |
|
||||
| hanna | pass | 5 | 0 | 0 | 0 | 2.4 | |
|
||||
| harmony | pass | 22 | 0 | 0 | 0 | 2.8 | |
|
||||
| hdfury | pass | 50 | 0 | 0 | 0 | 6.5 | |
|
||||
| hegel | pass | 12 | 0 | 0 | 0 | 2.5 | |
|
||||
| heos | issues | 147 | 2 | 0 | 0 | 8.1 | |
|
||||
| here_travel_time | pass | 39 | 0 | 0 | 0 | 3.1 | |
|
||||
| hisense_aehw4a1 | pass | 4 | 0 | 0 | 0 | 2.3 | |
|
||||
| history_stats | pass | 55 | 0 | 0 | 0 | 7.6 | |
|
||||
| hive | pass | 19 | 0 | 0 | 0 | 2.8 | |
|
||||
| hko | pass | 4 | 0 | 0 | 0 | 2.4 | |
|
||||
| hlk_sw16 | pass | 4 | 0 | 0 | 0 | 2.3 | |
|
||||
| holiday | pass | 34 | 0 | 0 | 0 | 3.7 | |
|
||||
| home_connect | pass | 311 | 0 | 0 | 0 | 15.9 | |
|
||||
| homeassistant_connect_zbt2 | issues | 24 | 1 | 0 | 0 | 4.1 | |
|
||||
| homeassistant_sky_connect | pass | 36 | 0 | 0 | 0 | 4.3 | |
|
||||
| homee | pass | 199 | 0 | 0 | 0 | 16.2 | |
|
||||
| homekit | pass | 372 | 0 | 0 | 0 | 13.6 | |
|
||||
| homematicip_cloud | pass | 182 | 0 | 0 | 0 | 24.0 | |
|
||||
| homevolt | pass | 33 | 0 | 0 | 0 | 3.3 | |
|
||||
| homewizard | pass | 149 | 0 | 0 | 0 | 11.0 | |
|
||||
| homeworks | pass | 38 | 0 | 0 | 0 | 4.3 | |
|
||||
| honeywell | pass | 44 | 0 | 0 | 0 | 4.3 | |
|
||||
| honeywell_string_lights | pass | 7 | 0 | 0 | 0 | 2.4 | |
|
||||
| hr_energy_qube | pass | 28 | 0 | 0 | 0 | 4.1 | |
|
||||
| html5 | issues | 64 | 1 | 0 | 0 | 5.7 | |
|
||||
| huawei_lte | pass | 36 | 0 | 0 | 0 | 3.7 | |
|
||||
| hue | pass | 113 | 0 | 0 | 0 | 20.1 | |
|
||||
| hue_ble | pass | 19 | 0 | 0 | 0 | 3.0 | |
|
||||
| huisbaasje | pass | 13 | 0 | 0 | 0 | 2.5 | |
|
||||
| hunterdouglas_powerview | pass | 39 | 0 | 0 | 0 | 3.3 | |
|
||||
| husqvarna_automower | issues | 93 | 1 | 0 | 0 | 14.7 | |
|
||||
| husqvarna_automower_ble | pass | 42 | 0 | 0 | 0 | 5.1 | |
|
||||
| huum | pass | 34 | 0 | 0 | 0 | 3.3 | |
|
||||
| hvv_departures | pass | 9 | 0 | 0 | 0 | 2.3 | |
|
||||
| hydrawise | pass | 35 | 0 | 0 | 0 | 4.3 | |
|
||||
| hypontech | pass | 17 | 0 | 0 | 0 | 2.7 | |
|
||||
| ialarm | pass | 7 | 0 | 0 | 0 | 2.3 | |
|
||||
| iaqualink | pass | 28 | 0 | 0 | 0 | 2.9 | |
|
||||
| ibeacon | pass | 24 | 0 | 0 | 0 | 3.5 | |
|
||||
| icloud | pass | 17 | 0 | 0 | 0 | 2.5 | |
|
||||
| idasen_desk | pass | 30 | 0 | 0 | 0 | 3.8 | |
|
||||
| idrive_e2 | pass | 65 | 0 | 0 | 0 | 4.7 | |
|
||||
| ifttt | pass | 1 | 0 | 0 | 0 | 2.5 | |
|
||||
| igloohome | pass | 8 | 0 | 0 | 0 | 2.4 | |
|
||||
| imap | pass | 159 | 0 | 0 | 0 | 12.4 | |
|
||||
| imeon_inverter | pass | 17 | 0 | 0 | 0 | 2.9 | |
|
||||
| imgw_pib | issues | 15 | 1 | 0 | 0 | 2.8 | |
|
||||
| immich | issues | 54 | 1 | 0 | 0 | 3.8 | |
|
||||
| improv_ble | pass | 38 | 0 | 0 | 0 | 3.7 | |
|
||||
| incomfort | pass | 50 | 0 | 0 | 0 | 4.0 | |
|
||||
| indevolt | pass | 83 | 0 | 0 | 0 | 6.2 | |
|
||||
| inels | pass | 28 | 0 | 0 | 0 | 3.0 | |
|
||||
| influxdb | pass | 145 | 0 | 0 | 0 | 6.0 | |
|
||||
| inkbird | pass | 16 | 0 | 0 | 0 | 3.0 | |
|
||||
| insteon | pass | 79 | 0 | 0 | 0 | 18.5 | |
|
||||
| integration | pass | 61 | 0 | 0 | 0 | 4.0 | |
|
||||
| intelliclima | pass | 28 | 0 | 0 | 0 | 3.2 | |
|
||||
| intellifire | pass | 29 | 0 | 0 | 0 | 4.2 | |
|
||||
| iometer | pass | 17 | 0 | 0 | 0 | 2.8 | |
|
||||
| ios | pass | 3 | 0 | 0 | 0 | 2.3 | |
|
||||
| iotawatt | pass | 9 | 0 | 0 | 0 | 2.5 | |
|
||||
| iotty | pass | 24 | 0 | 0 | 0 | 3.2 | |
|
||||
| ipma | pass | 15 | 0 | 0 | 0 | 2.7 | |
|
||||
| ipp | pass | 29 | 0 | 0 | 0 | 3.0 | |
|
||||
| iqvia | issues | 4 | 1 | 0 | 0 | 2.4 | |
|
||||
| irm_kmi | pass | 13 | 0 | 0 | 0 | 2.7 | |
|
||||
| iron_os | pass | 95 | 0 | 0 | 0 | 8.9 | |
|
||||
| iskra | pass | 13 | 0 | 0 | 0 | 2.4 | |
|
||||
| islamic_prayer_times | pass | 45 | 0 | 0 | 0 | 3.8 | |
|
||||
| israel_rail | pass | 9 | 0 | 0 | 0 | 2.6 | |
|
||||
| iss | pass | 17 | 0 | 0 | 0 | 2.6 | |
|
||||
| ista_ecotrend | pass | 49 | 0 | 0 | 0 | 4.1 | |
|
||||
| isy994 | pass | 31 | 0 | 0 | 0 | 3.1 | |
|
||||
| ituran | pass | 19 | 0 | 0 | 0 | 3.0 | |
|
||||
| izone | pass | 10 | 0 | 0 | 0 | 2.5 | |
|
||||
| jellyfin | pass | 44 | 0 | 0 | 0 | 8.6 | |
|
||||
| jewish_calendar | pass | 123 | 0 | 0 | 0 | 11.9 | |
|
||||
| justnimbus | pass | 7 | 0 | 0 | 0 | 2.4 | |
|
||||
| jvc_projector | pass | 31 | 0 | 0 | 0 | 5.7 | |
|
||||
| kaleidescape | pass | 19 | 0 | 0 | 0 | 4.0 | |
|
||||
| keenetic_ndms2 | pass | 14 | 0 | 0 | 0 | 2.4 | |
|
||||
| kegtron | pass | 12 | 0 | 0 | 0 | 2.7 | |
|
||||
| keymitt_ble | pass | 7 | 0 | 0 | 0 | 2.6 | |
|
||||
| kiosker | pass | 43 | 0 | 0 | 0 | 3.3 | |
|
||||
| kmtronic | pass | 11 | 0 | 0 | 0 | 2.5 | |
|
||||
| knocki | pass | 17 | 0 | 0 | 0 | 2.8 | |
|
||||
| knx | pass | 387 | 0 | 0 | 0 | 28.0 | |
|
||||
| kodi | pass | 21 | 0 | 0 | 0 | 2.8 | |
|
||||
| kostal_plenticore | issues | 38 | 1 | 0 | 0 | 4.1 | |
|
||||
| kraken | pass | 11 | 0 | 0 | 0 | 2.9 | |
|
||||
| kulersky | pass | 17 | 0 | 0 | 0 | 2.9 | |
|
||||
| lacrosse_view | issues | 28 | 1 | 0 | 0 | 3.1 | |
|
||||
| lamarzocco | pass | 94 | 0 | 0 | 0 | 14.3 | |
|
||||
| lametric | pass | 55 | 0 | 0 | 0 | 5.6 | |
|
||||
| landisgyr_heat_meter | pass | 10 | 0 | 0 | 0 | 2.8 | |
|
||||
| lastfm | pass | 15 | 0 | 0 | 0 | 2.5 | |
|
||||
| launch_library | pass | 2 | 0 | 0 | 0 | 2.3 | |
|
||||
| laundrify | pass | 18 | 0 | 0 | 0 | 2.8 | |
|
||||
| lcn | pass | 149 | 0 | 0 | 0 | 13.4 | |
|
||||
| ld2410_ble | pass | 6 | 0 | 0 | 0 | 2.6 | |
|
||||
| leaone | pass | 6 | 0 | 0 | 0 | 2.6 | |
|
||||
| led_ble | pass | 9 | 0 | 0 | 0 | 2.7 | |
|
||||
| lektrico | pass | 13 | 0 | 0 | 0 | 2.8 | |
|
||||
| letpot | pass | 40 | 0 | 0 | 0 | 4.1 | |
|
||||
| lg_infrared | pass | 61 | 0 | 0 | 0 | 4.3 | |
|
||||
| lg_netcast | pass | 22 | 0 | 0 | 0 | 3.5 | |
|
||||
| lg_soundbar | pass | 11 | 0 | 0 | 0 | 2.7 | |
|
||||
| lg_thinq | pass | 29 | 0 | 0 | 0 | 4.1 | |
|
||||
| libre_hardware_monitor | pass | 26 | 0 | 0 | 0 | 3.2 | |
|
||||
| lichess | pass | 8 | 0 | 0 | 0 | 2.5 | |
|
||||
| lidarr | pass | 12 | 0 | 0 | 0 | 2.6 | |
|
||||
| liebherr | pass | 76 | 0 | 0 | 0 | 5.7 | |
|
||||
| lifx | pass | 72 | 0 | 0 | 0 | 5.4 | |
|
||||
| linkplay | pass | 9 | 0 | 0 | 0 | 2.5 | |
|
||||
| litejet | pass | 32 | 0 | 0 | 0 | 3.8 | |
|
||||
| litterrobot | pass | 63 | 0 | 0 | 0 | 5.9 | |
|
||||
| livisi | pass | 4 | 0 | 0 | 0 | 2.4 | |
|
||||
| local_calendar | pass | 50 | 0 | 0 | 0 | 5.1 | |
|
||||
| local_ip | pass | 3 | 0 | 0 | 0 | 2.3 | |
|
||||
| local_todo | pass | 55 | 0 | 0 | 0 | 5.6 | |
|
||||
| locative | pass | 5 | 0 | 0 | 1 | 3.0 | |
|
||||
| lojack | pass | 16 | 0 | 0 | 0 | 2.9 | |
|
||||
| london_underground | pass | 12 | 0 | 0 | 0 | 2.5 | |
|
||||
| lookin | pass | 7 | 0 | 0 | 0 | 2.4 | |
|
||||
| loqed | pass | 17 | 0 | 0 | 0 | 3.0 | |
|
||||
| luftdaten | pass | 11 | 0 | 0 | 0 | 2.6 | |
|
||||
| lunatone | pass | 40 | 0 | 0 | 0 | 3.8 | |
|
||||
| lupusec | pass | 5 | 0 | 0 | 0 | 2.4 | |
|
||||
| lutron | pass | 42 | 0 | 0 | 0 | 3.8 | |
|
||||
| lutron_caseta | pass | 52 | 0 | 0 | 0 | 4.9 | |
|
||||
| lyric | pass | 4 | 0 | 0 | 0 | 2.4 | |
|
||||
| madvr | issues | 16 | 1 | 0 | 0 | 3.2 | |
|
||||
| mailgun | pass | 5 | 0 | 0 | 0 | 2.7 | |
|
||||
| marantz_infrared | pass | 36 | 0 | 0 | 0 | 3.4 | |
|
||||
| mastodon | issues | 82 | 1 | 0 | 0 | 26.2 | |
|
||||
| matter | pass | 372 | 0 | 0 | 1 | 68.8 | |
|
||||
| mcp | pass | 45 | 0 | 0 | 0 | 4.1 | |
|
||||
| mcp_server | pass | 47 | 0 | 0 | 0 | 5.9 | |
|
||||
| mealie | pass | 94 | 0 | 0 | 0 | 7.8 | |
|
||||
| meater | pass | 10 | 0 | 0 | 0 | 2.5 | |
|
||||
| medcom_ble | pass | 9 | 0 | 0 | 0 | 2.6 | |
|
||||
| media_extractor | pass | 20 | 0 | 0 | 0 | 7.6 | |
|
||||
| melcloud | issues | 23 | 1 | 0 | 0 | 2.8 | |
|
||||
| melnor | pass | 17 | 0 | 0 | 0 | 3.5 | |
|
||||
| met | pass | 18 | 0 | 0 | 0 | 2.6 | |
|
||||
| met_eireann | pass | 10 | 0 | 0 | 0 | 2.5 | |
|
||||
| meteo_france | pass | 6 | 0 | 0 | 0 | 2.5 | |
|
||||
| meteo_lt | pass | 7 | 0 | 0 | 0 | 2.5 | |
|
||||
| meteoclimatic | pass | 3 | 0 | 0 | 0 | 2.4 | |
|
||||
| metoffice | pass | 18 | 0 | 0 | 0 | 3.1 | |
|
||||
| microbees | pass | 9 | 0 | 0 | 0 | 2.5 | |
|
||||
| miele | pass | 115 | 0 | 0 | 0 | 10.2 | |
|
||||
| mikrotik | pass | 21 | 0 | 0 | 0 | 2.8 | |
|
||||
| mill | pass | 31 | 0 | 0 | 0 | 4.5 | |
|
||||
| min_max | pass | 20 | 0 | 0 | 0 | 2.6 | |
|
||||
| minecraft_server | pass | 40 | 0 | 0 | 0 | 3.5 | |
|
||||
| mitsubishi_comfort | pass | 66 | 0 | 0 | 0 | 5.3 | |
|
||||
| moat | pass | 11 | 0 | 0 | 0 | 2.8 | |
|
||||
| mobile_app | pass | 135 | 0 | 0 | 0 | 13.2 | |
|
||||
| modem_callerid | pass | 9 | 0 | 0 | 0 | 2.6 | |
|
||||
| modern_forms | issues | 29 | 1 | 0 | 0 | 3.4 | |
|
||||
| moehlenhoff_alpha2 | pass | 8 | 0 | 0 | 0 | 2.4 | |
|
||||
| mold_indicator | pass | 37 | 0 | 0 | 0 | 3.0 | |
|
||||
| monarch_money | pass | 5 | 0 | 0 | 0 | 2.6 | |
|
||||
| monoprice | pass | 23 | 0 | 0 | 0 | 3.2 | |
|
||||
| monzo | pass | 11 | 0 | 0 | 0 | 2.7 | |
|
||||
| moon | pass | 11 | 0 | 0 | 0 | 2.5 | |
|
||||
| mopeka | pass | 14 | 0 | 0 | 0 | 2.9 | |
|
||||
| motion_blinds | pass | 12 | 0 | 0 | 0 | 2.5 | |
|
||||
| motionblinds_ble | issues | 42 | 1 | 0 | 0 | 6.3 | |
|
||||
| motionmount | pass | 38 | 0 | 0 | 0 | 3.5 | |
|
||||
| mpd | pass | 7 | 0 | 0 | 0 | 2.9 | |
|
||||
| mta | pass | 27 | 0 | 0 | 0 | 3.0 | |
|
||||
| mullvad | pass | 4 | 0 | 0 | 0 | 2.3 | |
|
||||
| music_assistant | pass | 127 | 0 | 0 | 0 | 9.4 | |
|
||||
| mutesync | pass | 5 | 0 | 0 | 0 | 2.3 | |
|
||||
| myneomitis | pass | 43 | 0 | 0 | 0 | 4.1 | |
|
||||
| mysensors | pass | 61 | 0 | 0 | 0 | 5.1 | |
|
||||
| mystrom | pass | 23 | 0 | 0 | 0 | 2.8 | |
|
||||
| myuplink | pass | 38 | 0 | 0 | 0 | 4.8 | |
|
||||
| nam | pass | 39 | 0 | 0 | 0 | 3.4 | |
|
||||
| namecheapdns | pass | 20 | 0 | 0 | 0 | 2.7 | |
|
||||
| nanoleaf | pass | 30 | 0 | 0 | 0 | 2.9 | |
|
||||
| nasweb | pass | 8 | 0 | 0 | 0 | 2.4 | |
|
||||
| nederlandse_spoorwegen | pass | 29 | 0 | 0 | 0 | 3.4 | |
|
||||
| ness_alarm | pass | 39 | 0 | 0 | 0 | 3.0 | |
|
||||
| netgear | pass | 10 | 0 | 0 | 0 | 2.4 | |
|
||||
| netgear_lte | pass | 11 | 0 | 0 | 0 | 2.6 | |
|
||||
| nexia | pass | 21 | 0 | 0 | 0 | 3.6 | |
|
||||
| nextbus | pass | 28 | 0 | 0 | 0 | 2.7 | |
|
||||
| nextcloud | pass | 17 | 0 | 0 | 0 | 2.9 | |
|
||||
| nextdns | issues | 52 | 1 | 0 | 0 | 5.8 | |
|
||||
| nfandroidtv | pass | 8 | 0 | 0 | 0 | 2.4 | |
|
||||
| nibe_heatpump | pass | 64 | 0 | 0 | 0 | 4.3 | |
|
||||
| nice_go | issues | 43 | 1 | 0 | 0 | 4.5 | |
|
||||
| nightscout | pass | 11 | 0 | 0 | 0 | 2.5 | |
|
||||
| niko_home_control | pass | 32 | 0 | 0 | 0 | 3.8 | |
|
||||
| nina | pass | 21 | 0 | 0 | 0 | 3.2 | |
|
||||
| nintendo_parental_controls | pass | 30 | 0 | 0 | 0 | 3.3 | |
|
||||
| nmap_tracker | pass | 13 | 0 | 0 | 0 | 2.6 | |
|
||||
| nmbs | pass | 5 | 0 | 0 | 0 | 2.5 | |
|
||||
| nobo_hub | pass | 59 | 0 | 0 | 0 | 4.6 | |
|
||||
| nordpool | pass | 37 | 0 | 0 | 0 | 4.9 | |
|
||||
| notion | issues | 7 | 1 | 0 | 0 | 2.5 | |
|
||||
| novy_cooker_hood | issues | 28 | 1 | 0 | 0 | 3.1 | |
|
||||
| nrgkick | pass | 87 | 0 | 0 | 0 | 5.7 | |
|
||||
| ntfy | pass | 85 | 0 | 0 | 0 | 6.8 | |
|
||||
| nuheat | pass | 9 | 0 | 0 | 0 | 2.5 | |
|
||||
| nuki | pass | 14 | 0 | 0 | 0 | 2.6 | |
|
||||
| nut | pass | 78 | 0 | 0 | 0 | 4.6 | |
|
||||
| nws | pass | 30 | 0 | 0 | 0 | 3.3 | |
|
||||
| nyt_games | pass | 10 | 0 | 0 | 0 | 2.6 | |
|
||||
| nzbget | pass | 10 | 0 | 0 | 0 | 2.5 | |
|
||||
| obihai | pass | 5 | 0 | 0 | 0 | 2.4 | |
|
||||
| ohme | pass | 34 | 0 | 0 | 0 | 4.4 | |
|
||||
| omie | pass | 19 | 0 | 0 | 0 | 2.9 | |
|
||||
| omnilogic | pass | 8 | 0 | 0 | 0 | 2.4 | |
|
||||
| ondilo_ico | pass | 17 | 0 | 0 | 0 | 3.0 | |
|
||||
| onedrive | pass | 79 | 0 | 0 | 0 | 6.5 | |
|
||||
| onedrive_for_business | pass | 38 | 0 | 0 | 0 | 4.6 | |
|
||||
| onewire | pass | 33 | 0 | 0 | 0 | 3.5 | |
|
||||
| onkyo | pass | 48 | 0 | 0 | 0 | 4.2 | |
|
||||
| open_meteo | pass | 5 | 0 | 0 | 0 | 2.7 | |
|
||||
| opendisplay | pass | 67 | 0 | 0 | 0 | 6.6 | |
|
||||
| openevse | pass | 40 | 0 | 0 | 0 | 4.5 | |
|
||||
| openexchangerates | pass | 9 | 0 | 0 | 0 | 2.4 | |
|
||||
| opengarage | pass | 6 | 0 | 0 | 0 | 2.4 | |
|
||||
| openhome | pass | 11 | 0 | 0 | 0 | 2.6 | |
|
||||
| openrgb | pass | 69 | 0 | 0 | 0 | 4.6 | |
|
||||
| opensky | pass | 12 | 0 | 0 | 0 | 2.6 | |
|
||||
| opentherm_gw | pass | 29 | 0 | 0 | 0 | 5.7 | |
|
||||
| openuv | issues | 10 | 1 | 0 | 0 | 2.7 | |
|
||||
| openweathermap | pass | 15 | 0 | 0 | 0 | 2.6 | |
|
||||
| opower | issues | 35 | 1 | 0 | 0 | 5.8 | |
|
||||
| oralb | pass | 14 | 0 | 0 | 0 | 2.9 | |
|
||||
| orvibo | pass | 15 | 0 | 0 | 0 | 2.6 | |
|
||||
| osoenergy | pass | 17 | 0 | 0 | 0 | 3.0 | |
|
||||
| otbr | pass | 103 | 0 | 0 | 0 | 7.9 | |
|
||||
| otp | pass | 7 | 0 | 0 | 0 | 2.4 | |
|
||||
| ouman_eh_800 | pass | 20 | 0 | 0 | 0 | 3.1 | |
|
||||
| ourgroceries | pass | 21 | 0 | 0 | 0 | 2.8 | |
|
||||
| overkiz | pass | 181 | 0 | 0 | 0 | 9.9 | |
|
||||
| overseerr | pass | 45 | 0 | 0 | 0 | 4.8 | |
|
||||
| ovo_energy | pass | 7 | 0 | 0 | 0 | 2.4 | |
|
||||
| owntracks | pass | 71 | 0 | 0 | 0 | 5.8 | |
|
||||
| p1_monitor | issues | 11 | 2 | 0 | 0 | 2.7 | |
|
||||
| paj_gps | pass | 19 | 0 | 0 | 0 | 3.1 | |
|
||||
| palazzetti | pass | 17 | 0 | 0 | 0 | 3.0 | |
|
||||
| panasonic_viera | pass | 32 | 0 | 0 | 0 | 3.0 | |
|
||||
| paperless_ngx | pass | 41 | 0 | 0 | 0 | 3.6 | |
|
||||
| peblar | pass | 62 | 0 | 0 | 0 | 5.2 | |
|
||||
| peco | pass | 33 | 0 | 0 | 0 | 3.0 | |
|
||||
| pegel_online | issues | 9 | 1 | 0 | 0 | 2.6 | |
|
||||
| permobil | pass | 9 | 0 | 0 | 0 | 2.5 | |
|
||||
| pglab | pass | 21 | 0 | 0 | 0 | 3.5 | |
|
||||
| philips_js | issues | 15 | 1 | 0 | 0 | 3.7 | |
|
||||
| pi_hole | issues | 25 | 1 | 0 | 0 | 3.4 | |
|
||||
| picnic | pass | 40 | 0 | 0 | 0 | 3.4 | |
|
||||
| ping | pass | 21 | 0 | 0 | 0 | 2.9 | |
|
||||
| pjlink | pass | 29 | 0 | 0 | 0 | 3.3 | |
|
||||
| plaato | pass | 13 | 0 | 0 | 0 | 2.9 | |
|
||||
| playstation_network | pass | 68 | 0 | 0 | 0 | 6.7 | |
|
||||
| plex | pass | 53 | 0 | 0 | 0 | 6.7 | |
|
||||
| plugwise | pass | 86 | 0 | 0 | 0 | 8.3 | |
|
||||
| point | pass | 5 | 0 | 0 | 0 | 2.5 | |
|
||||
| pooldose | pass | 75 | 0 | 0 | 0 | 5.3 | |
|
||||
| poolsense | pass | 5 | 0 | 0 | 0 | 2.4 | |
|
||||
| portainer | issues | 86 | 1 | 0 | 0 | 12.9 | |
|
||||
| powerfox | pass | 22 | 0 | 0 | 0 | 3.0 | |
|
||||
| powerfox_local | pass | 21 | 0 | 0 | 0 | 2.9 | |
|
||||
| powerwall | pass | 37 | 0 | 0 | 0 | 3.6 | |
|
||||
| prana | pass | 37 | 0 | 0 | 0 | 3.8 | |
|
||||
| private_ble_device | pass | 21 | 0 | 0 | 0 | 3.3 | |
|
||||
| probe_plus | pass | 6 | 0 | 0 | 0 | 2.6 | |
|
||||
| profiler | pass | 13 | 0 | 0 | 0 | 2.6 | |
|
||||
| progettihwsw | pass | 4 | 0 | 0 | 0 | 2.3 | |
|
||||
| prowl | pass | 24 | 0 | 0 | 0 | 2.8 | |
|
||||
| proximity | issues | 27 | 1 | 0 | 0 | 3.0 | |
|
||||
| proxmoxve | issues | 103 | 1 | 0 | 0 | 9.2 | |
|
||||
| ps4 | pass | 41 | 0 | 0 | 0 | 3.3 | |
|
||||
| ptdevices | pass | 7 | 0 | 0 | 0 | 2.4 | |
|
||||
| pterodactyl | pass | 14 | 0 | 0 | 0 | 2.7 | |
|
||||
| pure_energie | pass | 8 | 0 | 0 | 0 | 2.5 | |
|
||||
| purpleair | issues | 19 | 1 | 0 | 0 | 3.0 | |
|
||||
| pushbullet | pass | 17 | 0 | 0 | 0 | 2.6 | |
|
||||
| pushover | pass | 16 | 0 | 0 | 0 | 2.5 | |
|
||||
| pvoutput | pass | 17 | 0 | 0 | 0 | 2.7 | |
|
||||
| pvpc_hourly_pricing | pass | 2 | 0 | 0 | 0 | 2.4 | |
|
||||
| pyload | pass | 67 | 0 | 0 | 0 | 4.6 | |
|
||||
| qbittorrent | pass | 8 | 0 | 0 | 0 | 2.5 | |
|
||||
| qbus | pass | 25 | 0 | 0 | 0 | 4.0 | |
|
||||
| qingping | pass | 15 | 0 | 0 | 0 | 3.0 | |
|
||||
| qnap | pass | 6 | 0 | 0 | 0 | 2.4 | |
|
||||
| qnap_qsw | pass | 18 | 0 | 0 | 0 | 3.1 | |
|
||||
| rabbitair | pass | 7 | 0 | 0 | 0 | 2.4 | |
|
||||
| rachio | pass | 6 | 0 | 0 | 0 | 2.5 | |
|
||||
| radarr | pass | 32 | 0 | 0 | 0 | 3.9 | |
|
||||
| radio_browser | pass | 10 | 0 | 0 | 0 | 2.6 | |
|
||||
| radiotherm | pass | 7 | 0 | 0 | 0 | 2.4 | |
|
||||
| rainbird | pass | 70 | 0 | 0 | 0 | 4.3 | |
|
||||
| rainforest_eagle | pass | 12 | 0 | 0 | 0 | 2.5 | |
|
||||
| rainforest_raven | issues | 19 | 2 | 0 | 0 | 3.2 | |
|
||||
| rainmachine | issues | 19 | 2 | 0 | 0 | 2.9 | |
|
||||
| random | pass | 9 | 0 | 0 | 0 | 2.4 | |
|
||||
| rapt_ble | pass | 11 | 0 | 0 | 0 | 2.8 | |
|
||||
| rdw | pass | 10 | 0 | 0 | 0 | 2.6 | |
|
||||
| recollect_waste | issues | 3 | 1 | 0 | 0 | 2.5 | |
|
||||
| redgtech | pass | 18 | 0 | 0 | 0 | 2.8 | |
|
||||
| refoss | pass | 2 | 0 | 0 | 0 | 2.3 | |
|
||||
| rehlko | pass | 16 | 0 | 0 | 0 | 2.9 | |
|
||||
| remote_calendar | pass | 44 | 0 | 0 | 0 | 3.9 | |
|
||||
| renault | pass | 104 | 0 | 0 | 0 | 5.7 | |
|
||||
| renson | pass | 3 | 0 | 0 | 0 | 2.3 | |
|
||||
| rfxtrx | pass | 89 | 0 | 0 | 0 | 7.5 | |
|
||||
| rhasspy | pass | 3 | 0 | 0 | 0 | 2.3 | |
|
||||
| ridwell | issues | 13 | 1 | 0 | 0 | 2.7 | |
|
||||
| risco | pass | 69 | 0 | 0 | 0 | 4.3 | |
|
||||
| rituals_perfume_genie | pass | 25 | 0 | 0 | 0 | 3.1 | |
|
||||
| roborock | pass | 183 | 0 | 0 | 0 | 18.5 | |
|
||||
| roku | pass | 67 | 0 | 0 | 0 | 6.5 | |
|
||||
| romy | pass | 6 | 0 | 0 | 0 | 2.4 | |
|
||||
| roomba | pass | 26 | 0 | 0 | 0 | 2.7 | |
|
||||
| roon | pass | 5 | 0 | 0 | 0 | 2.3 | |
|
||||
| route_b_smart_meter | pass | 7 | 0 | 0 | 0 | 2.6 | |
|
||||
| rova | pass | 11 | 0 | 0 | 0 | 2.4 | |
|
||||
| rpi_power | pass | 6 | 0 | 0 | 0 | 2.3 | |
|
||||
| ruckus_unleashed | pass | 29 | 0 | 0 | 0 | 2.8 | |
|
||||
| russound_rio | pass | 50 | 0 | 0 | 0 | 6.0 | |
|
||||
| ruuvi_gateway | pass | 5 | 0 | 0 | 0 | 2.5 | |
|
||||
| ruuvitag_ble | pass | 13 | 0 | 0 | 0 | 2.8 | |
|
||||
| rympro | pass | 7 | 0 | 0 | 0 | 2.3 | |
|
||||
| sabnzbd | pass | 19 | 0 | 0 | 0 | 2.9 | |
|
||||
| samsung_infrared | pass | 18 | 0 | 0 | 0 | 2.7 | |
|
||||
| samsungtv | issues | 166 | 3 | 0 | 0 | 13.5 | |
|
||||
| sanix | pass | 6 | 0 | 0 | 0 | 2.4 | |
|
||||
| satel_integra | pass | 89 | 0 | 0 | 0 | 7.6 | |
|
||||
| saunum | pass | 68 | 0 | 0 | 0 | 5.0 | |
|
||||
| schlage | pass | 49 | 0 | 0 | 0 | 4.8 | |
|
||||
| scrape | issues | 37 | 1 | 0 | 0 | 3.1 | |
|
||||
| screenlogic | issues | 33 | 1 | 0 | 0 | 3.8 | |
|
||||
| season | pass | 21 | 0 | 0 | 0 | 2.9 | |
|
||||
| sense | pass | 19 | 0 | 0 | 0 | 3.2 | |
|
||||
| sensibo | pass | 69 | 0 | 0 | 0 | 8.4 | |
|
||||
| sensirion_ble | pass | 11 | 0 | 0 | 0 | 2.7 | |
|
||||
| sensorpro | pass | 11 | 0 | 0 | 0 | 2.7 | |
|
||||
| sensorpush | pass | 11 | 0 | 0 | 0 | 2.7 | |
|
||||
| sensorpush_cloud | pass | 5 | 0 | 0 | 0 | 2.5 | |
|
||||
| sensoterra | pass | 5 | 0 | 0 | 0 | 2.4 | |
|
||||
| sentry | pass | 24 | 0 | 0 | 0 | 2.5 | |
|
||||
| senz | pass | 21 | 0 | 0 | 0 | 3.0 | |
|
||||
| seventeentrack | pass | 16 | 0 | 0 | 0 | 2.6 | |
|
||||
| sfr_box | pass | 26 | 0 | 0 | 0 | 2.8 | |
|
||||
| sftp_storage | pass | 34 | 0 | 0 | 0 | 8.7 | |
|
||||
| sharkiq | pass | 42 | 0 | 0 | 0 | 3.3 | |
|
||||
| shelly | pass | 636 | 0 | 0 | 0 | 45.7 | |
|
||||
| shopping_list | pass | 60 | 0 | 0 | 0 | 4.7 | |
|
||||
| sia | pass | 21 | 0 | 0 | 0 | 2.6 | |
|
||||
| simplefin | pass | 12 | 0 | 0 | 0 | 2.7 | |
|
||||
| simplepush | pass | 5 | 0 | 0 | 0 | 2.3 | |
|
||||
| simplisafe | issues | 15 | 1 | 0 | 0 | 3.0 | |
|
||||
| sky_remote | pass | 9 | 0 | 0 | 0 | 2.4 | |
|
||||
| slack | pass | 13 | 0 | 0 | 0 | 2.4 | |
|
||||
| sleep_as_android | pass | 44 | 0 | 0 | 0 | 4.5 | |
|
||||
| sleepiq | pass | 33 | 0 | 0 | 0 | 4.9 | |
|
||||
| slide_local | issues | 48 | 1 | 0 | 0 | 4.0 | |
|
||||
| slimproto | pass | 2 | 0 | 0 | 0 | 2.3 | |
|
||||
| sma | issues | 33 | 1 | 0 | 0 | 3.8 | |
|
||||
| smappee | pass | 20 | 0 | 0 | 0 | 2.7 | |
|
||||
| smarla | pass | 37 | 0 | 0 | 0 | 4.2 | |
|
||||
| smart_meter_texas | pass | 16 | 0 | 0 | 0 | 2.6 | |
|
||||
| smartthings | pass | 340 | 0 | 0 | 0 | 70.7 | |
|
||||
| smarttub | pass | 40 | 0 | 0 | 0 | 4.9 | |
|
||||
| smarty | pass | 12 | 0 | 0 | 0 | 2.8 | |
|
||||
| smhi | pass | 20 | 0 | 0 | 0 | 3.4 | |
|
||||
| smlight | pass | 98 | 0 | 0 | 0 | 7.1 | |
|
||||
| snapcast | pass | 18 | 0 | 0 | 0 | 3.3 | |
|
||||
| snoo | pass | 21 | 0 | 0 | 0 | 3.4 | |
|
||||
| snooz | pass | 35 | 0 | 0 | 0 | 4.9 | |
|
||||
| solaredge | pass | 62 | 0 | 0 | 0 | 11.0 | |
|
||||
| solarlog | issues | 25 | 1 | 0 | 0 | 3.3 | |
|
||||
| solarman | pass | 17 | 0 | 0 | 0 | 2.8 | |
|
||||
| solax | pass | 3 | 0 | 0 | 0 | 2.3 | |
|
||||
| soma | pass | 7 | 0 | 0 | 0 | 2.3 | |
|
||||
| somfy_mylink | pass | 11 | 0 | 0 | 0 | 2.4 | |
|
||||
| sonarr | pass | 56 | 0 | 0 | 0 | 9.7 | |
|
||||
| songpal | pass | 26 | 0 | 0 | 0 | 3.0 | |
|
||||
| sonos | pass | 174 | 0 | 0 | 0 | 18.9 | |
|
||||
| soundtouch | pass | 31 | 0 | 0 | 0 | 3.3 | |
|
||||
| speedtestdotnet | pass | 8 | 0 | 0 | 0 | 2.4 | |
|
||||
| splunk | pass | 36 | 0 | 0 | 0 | 2.9 | |
|
||||
| spotify | pass | 70 | 0 | 0 | 0 | 9.0 | |
|
||||
| sql | pass | 80 | 0 | 0 | 0 | 6.5 | |
|
||||
| squeezebox | pass | 129 | 0 | 0 | 0 | 12.8 | |
|
||||
| srp_energy | pass | 15 | 0 | 0 | 0 | 2.7 | |
|
||||
| starline | pass | 4 | 0 | 0 | 0 | 2.3 | |
|
||||
| starlink | pass | 8 | 0 | 0 | 0 | 2.7 | |
|
||||
| statistics | pass | 56 | 0 | 0 | 0 | 4.2 | |
|
||||
| steam_online | pass | 14 | 0 | 0 | 0 | 2.5 | |
|
||||
| steamist | pass | 23 | 0 | 0 | 0 | 2.9 | |
|
||||
| stiebel_eltron | pass | 9 | 0 | 0 | 0 | 2.8 | |
|
||||
| stookwijzer | pass | 12 | 0 | 0 | 0 | 2.6 | |
|
||||
| streamlabswater | pass | 6 | 0 | 0 | 0 | 2.4 | |
|
||||
| subaru | pass | 59 | 0 | 0 | 0 | 4.6 | |
|
||||
| suez_water | pass | 19 | 0 | 0 | 0 | 4.2 | |
|
||||
| sun | pass | 27 | 0 | 0 | 1 | 4.9 | |
|
||||
| sunricher_dali | pass | 50 | 0 | 0 | 0 | 5.8 | |
|
||||
| sunweg | pass | 1 | 0 | 0 | 0 | 2.2 | |
|
||||
| surepetcare | pass | 15 | 0 | 0 | 0 | 2.7 | |
|
||||
| swiss_public_transport | pass | 36 | 0 | 0 | 0 | 3.1 | |
|
||||
| switch_as_x | pass | 176 | 0 | 0 | 0 | 10.0 | |
|
||||
| switchbee | pass | 6 | 0 | 0 | 0 | 2.3 | |
|
||||
| switchbot | issues | 352 | 1 | 0 | 0 | 19.7 | |
|
||||
| switchbot_cloud | pass | 134 | 0 | 0 | 0 | 10.8 | |
|
||||
| switcher_kis | issues | 76 | 1 | 0 | 0 | 7.7 | |
|
||||
| syncthing | pass | 5 | 0 | 0 | 0 | 2.3 | |
|
||||
| syncthru | pass | 9 | 0 | 0 | 0 | 2.6 | |
|
||||
| system_bridge | pass | 39 | 0 | 0 | 0 | 4.5 | |
|
||||
| systemmonitor | issues | 34 | 2 | 0 | 0 | 4.9 | |
|
||||
| systemnexa2 | pass | 34 | 0 | 0 | 0 | 3.6 | |
|
||||
| tado | pass | 40 | 0 | 0 | 0 | 4.7 | |
|
||||
| tailscale | pass | 14 | 0 | 0 | 0 | 2.8 | |
|
||||
| tailwind | pass | 36 | 0 | 0 | 0 | 3.3 | |
|
||||
| tami4 | pass | 14 | 0 | 0 | 0 | 2.6 | |
|
||||
| tankerkoenig | issues | 20 | 1 | 0 | 0 | 2.8 | |
|
||||
| tautulli | pass | 8 | 0 | 0 | 0 | 2.3 | |
|
||||
| technove | pass | 40 | 0 | 0 | 0 | 3.7 | |
|
||||
| tedee | pass | 41 | 0 | 0 | 0 | 4.5 | |
|
||||
| telegram_bot | pass | 123 | 0 | 0 | 0 | 13.2 | |
|
||||
| teleinfo | pass | 24 | 0 | 0 | 0 | 3.2 | |
|
||||
| tellduslive | pass | 15 | 0 | 0 | 0 | 2.4 | |
|
||||
| teltonika | pass | 50 | 0 | 0 | 0 | 3.8 | |
|
||||
| template | pass | 2470 | 0 | 0 | 0 | 60.1 | |
|
||||
| tesla_fleet | pass | 153 | 0 | 0 | 0 | 12.3 | |
|
||||
| tesla_wall_connector | pass | 12 | 0 | 0 | 0 | 2.5 | |
|
||||
| teslemetry | pass | 158 | 0 | 0 | 0 | 16.8 | |
|
||||
| tessie | pass | 67 | 0 | 0 | 0 | 4.5 | |
|
||||
| thermobeacon | pass | 11 | 0 | 0 | 0 | 2.7 | |
|
||||
| thermopro | pass | 18 | 0 | 0 | 0 | 3.0 | |
|
||||
| thethingsnetwork | pass | 8 | 0 | 0 | 0 | 2.4 | |
|
||||
| thread | pass | 65 | 0 | 0 | 0 | 4.2 | |
|
||||
| threshold | pass | 114 | 0 | 0 | 0 | 4.1 | |
|
||||
| tibber | pass | 92 | 0 | 0 | 0 | 13.3 | |
|
||||
| tile | pass | 11 | 0 | 0 | 0 | 2.5 | |
|
||||
| tilt_ble | pass | 11 | 0 | 0 | 0 | 2.8 | |
|
||||
| tilt_pi | pass | 6 | 0 | 0 | 0 | 2.4 | |
|
||||
| time_date | pass | 18 | 0 | 0 | 0 | 2.8 | |
|
||||
| tod | pass | 32 | 0 | 0 | 0 | 3.3 | |
|
||||
| todoist | pass | 50 | 0 | 0 | 0 | 4.2 | |
|
||||
| togrill | pass | 62 | 0 | 0 | 0 | 5.7 | |
|
||||
| tolo | pass | 7 | 0 | 0 | 0 | 2.4 | |
|
||||
| tomorrowio | pass | 21 | 0 | 0 | 0 | 3.1 | |
|
||||
| toon | pass | 10 | 0 | 0 | 0 | 2.7 | |
|
||||
| totalconnect | pass | 66 | 0 | 0 | 0 | 11.7 | |
|
||||
| touchline | pass | 8 | 0 | 0 | 0 | 2.5 | |
|
||||
| touchline_sl | pass | 15 | 0 | 0 | 0 | 2.7 | |
|
||||
| tplink_omada | issues | 52 | 1 | 0 | 0 | 4.7 | |
|
||||
| traccar | pass | 5 | 0 | 0 | 1 | 2.9 | |
|
||||
| traccar_server | pass | 12 | 0 | 0 | 0 | 2.9 | |
|
||||
| tractive | issues | 48 | 1 | 0 | 0 | 4.0 | |
|
||||
| tradfri | pass | 91 | 0 | 0 | 0 | 6.4 | |
|
||||
| trafikverket_ferry | pass | 13 | 0 | 0 | 0 | 2.5 | |
|
||||
| trafikverket_train | pass | 41 | 0 | 0 | 0 | 3.1 | |
|
||||
| trafikverket_weatherstation | pass | 16 | 0 | 0 | 0 | 2.5 | |
|
||||
| trane | pass | 39 | 0 | 0 | 0 | 4.0 | |
|
||||
| transmission | pass | 75 | 0 | 0 | 0 | 4.6 | |
|
||||
| trend | pass | 39 | 0 | 0 | 0 | 3.2 | |
|
||||
| triggercmd | pass | 5 | 0 | 0 | 0 | 2.4 | |
|
||||
| trmnl | pass | 36 | 0 | 0 | 0 | 3.7 | |
|
||||
| twentemilieu | pass | 17 | 0 | 0 | 0 | 2.9 | |
|
||||
| twilio | pass | 1 | 0 | 0 | 0 | 2.6 | |
|
||||
| twinkly | issues | 25 | 1 | 0 | 0 | 4.1 | |
|
||||
| twitch | pass | 16 | 0 | 0 | 0 | 4.2 | |
|
||||
| uhoo | pass | 24 | 0 | 0 | 0 | 3.0 | |
|
||||
| ukraine_alarm | pass | 11 | 0 | 0 | 0 | 2.4 | |
|
||||
| unifi | issues | 158 | 1 | 0 | 0 | 12.4 | |
|
||||
| unifi_access | pass | 155 | 0 | 0 | 0 | 10.9 | |
|
||||
| upb | pass | 6 | 0 | 0 | 0 | 2.4 | |
|
||||
| upcloud | pass | 6 | 0 | 0 | 0 | 2.4 | |
|
||||
| upnp | pass | 23 | 0 | 0 | 0 | 3.8 | |
|
||||
| uptime | pass | 4 | 0 | 0 | 0 | 2.4 | |
|
||||
| uptime_kuma | pass | 38 | 0 | 0 | 0 | 3.6 | |
|
||||
| uptimerobot | pass | 35 | 0 | 0 | 0 | 3.2 | |
|
||||
| utility_meter | issues | 94 | 1 | 0 | 0 | 6.0 | |
|
||||
| v2c | issues | 12 | 1 | 0 | 0 | 2.9 | |
|
||||
| vallox | pass | 76 | 0 | 0 | 0 | 5.6 | |
|
||||
| vegehub | pass | 18 | 0 | 0 | 0 | 2.9 | |
|
||||
| velbus | issues | 57 | 1 | 0 | 0 | 9.0 | |
|
||||
| velux | pass | 80 | 0 | 0 | 0 | 4.9 | |
|
||||
| venstar | pass | 12 | 0 | 0 | 0 | 2.6 | |
|
||||
| vera | pass | 29 | 0 | 0 | 0 | 3.1 | |
|
||||
| version | pass | 10 | 0 | 0 | 0 | 2.5 | |
|
||||
| vesync | pass | 175 | 0 | 0 | 0 | 12.5 | |
|
||||
| vicare | issues | 54 | 1 | 0 | 0 | 4.5 | |
|
||||
| victron_ble | pass | 34 | 0 | 0 | 0 | 3.6 | |
|
||||
| victron_gx | pass | 62 | 0 | 0 | 0 | 14.1 | |
|
||||
| victron_remote_monitoring | pass | 22 | 0 | 0 | 0 | 3.0 | |
|
||||
| vilfo | pass | 9 | 0 | 0 | 0 | 2.5 | |
|
||||
| vizio | pass | 73 | 0 | 0 | 0 | 4.5 | |
|
||||
| vlc_telnet | pass | 39 | 0 | 0 | 0 | 3.4 | |
|
||||
| vodafone_station | issues | 53 | 1 | 0 | 0 | 4.6 | |
|
||||
| volumio | pass | 9 | 0 | 0 | 0 | 2.3 | |
|
||||
| volvo | pass | 110 | 0 | 0 | 0 | 8.6 | |
|
||||
| volvooncall | pass | 4 | 0 | 0 | 0 | 2.2 | |
|
||||
| wake_on_lan | pass | 15 | 0 | 0 | 0 | 2.5 | |
|
||||
| wallbox | pass | 38 | 0 | 0 | 0 | 3.7 | |
|
||||
| waqi | pass | 20 | 0 | 0 | 0 | 2.6 | |
|
||||
| waterfurnace | pass | 77 | 0 | 0 | 0 | 11.9 | |
|
||||
| watergate | pass | 16 | 0 | 0 | 0 | 3.0 | |
|
||||
| watts | issues | 51 | 1 | 0 | 0 | 5.2 | |
|
||||
| watttime | issues | 11 | 1 | 0 | 0 | 2.6 | |
|
||||
| waze_travel_time | pass | 23 | 0 | 0 | 0 | 14.8 | |
|
||||
| weatherflow | pass | 5 | 0 | 0 | 0 | 3.0 | |
|
||||
| weatherflow_cloud | pass | 17 | 0 | 0 | 0 | 3.1 | |
|
||||
| weatherkit | pass | 35 | 0 | 0 | 0 | 2.9 | |
|
||||
| webdav | pass | 25 | 0 | 0 | 0 | 3.9 | |
|
||||
| webmin | issues | 11 | 1 | 0 | 0 | 2.5 | |
|
||||
| webostv | issues | 82 | 1 | 0 | 0 | 12.0 | |
|
||||
| weheat | pass | 16 | 0 | 0 | 0 | 2.8 | |
|
||||
| wemo | pass | 93 | 0 | 0 | 0 | 6.7 | |
|
||||
| whirlpool | issues | 239 | 1 | 0 | 0 | 12.2 | |
|
||||
| whois | pass | 41 | 0 | 0 | 0 | 3.4 | |
|
||||
| wiffi | pass | 4 | 0 | 0 | 0 | 2.2 | |
|
||||
| wiim | pass | 29 | 0 | 0 | 0 | 3.0 | |
|
||||
| wilight | pass | 22 | 0 | 0 | 0 | 2.8 | |
|
||||
| withings | pass | 62 | 0 | 0 | 0 | 8.0 | |
|
||||
| wiz | pass | 56 | 0 | 0 | 0 | 4.3 | |
|
||||
| wled | pass | 100 | 0 | 0 | 0 | 7.6 | |
|
||||
| wmspro | pass | 39 | 0 | 0 | 0 | 4.0 | |
|
||||
| wolflink | pass | 10 | 0 | 0 | 0 | 2.5 | |
|
||||
| workday | issues | 70 | 1 | 0 | 0 | 6.1 | |
|
||||
| worldclock | pass | 6 | 0 | 0 | 0 | 2.4 | |
|
||||
| ws66i | pass | 24 | 0 | 0 | 0 | 3.1 | |
|
||||
| wsdot | pass | 16 | 0 | 0 | 0 | 2.7 | |
|
||||
| xbox | pass | 203 | 0 | 0 | 0 | 14.9 | |
|
||||
| xiaomi_aqara | pass | 12 | 0 | 0 | 0 | 2.5 | |
|
||||
| xiaomi_ble | pass | 92 | 0 | 0 | 0 | 6.2 | |
|
||||
| xiaomi_miio | pass | 51 | 0 | 0 | 0 | 4.1 | |
|
||||
| xthings_cloud | pass | 30 | 0 | 0 | 0 | 3.7 | |
|
||||
| yale_smart_alarm | pass | 37 | 0 | 0 | 0 | 3.7 | |
|
||||
| yalexs_ble | pass | 26 | 0 | 0 | 0 | 3.3 | |
|
||||
| yamaha_musiccast | pass | 9 | 0 | 0 | 0 | 2.5 | |
|
||||
| yardian | pass | 16 | 0 | 0 | 0 | 2.8 | |
|
||||
| yeelight | pass | 62 | 0 | 0 | 0 | 4.7 | |
|
||||
| yolink | pass | 10 | 0 | 0 | 0 | 3.1 | |
|
||||
| youless | pass | 4 | 0 | 0 | 0 | 2.4 | |
|
||||
| youtube | pass | 24 | 0 | 0 | 0 | 3.4 | |
|
||||
| zamg | pass | 9 | 0 | 0 | 0 | 2.5 | |
|
||||
| zerproc | pass | 10 | 0 | 0 | 0 | 2.5 | |
|
||||
| zeversolar | pass | 10 | 0 | 0 | 0 | 2.5 | |
|
||||
| zha | issues | 339 | 1 | 0 | 4 | 70.5 | |
|
||||
| zimi | pass | 16 | 0 | 0 | 0 | 2.7 | |
|
||||
| zinvolt | pass | 12 | 0 | 0 | 0 | 2.8 | |
|
||||
| zodiac | pass | 5 | 0 | 0 | 0 | 2.3 | |
|
||||
| zwave_js | pass | 611 | 0 | 0 | 0 | 60.2 | |
|
||||
| zwave_me | pass | 8 | 0 | 0 | 0 | 2.2 | |
|
||||
|
||||
Per-failure tracebacks are dumped under `${SANDBOX_V2_ERRORS_DIR:-/tmp/sandbox_errors}/<integration>/`.
|
||||
@@ -0,0 +1,57 @@
|
||||
# Sandbox compat report
|
||||
|
||||
Plugin: `hass_client.testing.pytest_plugin`
|
||||
|
||||
## Summary
|
||||
|
||||
- Integrations passing: **35**
|
||||
- Integrations with issues: **2**
|
||||
- Timeouts: **0**
|
||||
- No tests collected: **0**
|
||||
|
||||
- Tests passed: **7646**
|
||||
- Tests failed: **2**
|
||||
- Test errors: **0**
|
||||
- Tests skipped: **17**
|
||||
|
||||
## Per-integration results
|
||||
|
||||
| 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 |
|
||||
@@ -0,0 +1,626 @@
|
||||
# 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 200-light perf benchmark + 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 per-phase
|
||||
> `STATUS-phase-N.md` files for what each phase 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, and events 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 v2 differs from v1
|
||||
|
||||
| | v1 (`sandbox/`) | v2 (`sandbox/`) |
|
||||
|---|---|---|
|
||||
| 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 |
|
||||
|
||||
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**. Hand-picked deny-list:
|
||||
`script`, `automation`, `scene`, `cloud`, `ai_task`, `image`. Each
|
||||
entry has an inline "why" in [`const.py`](../homeassistant/components/sandbox/const.py).
|
||||
`ai_task` and `image` were added by the Phase 1 spike because their
|
||||
service handlers do non-idempotent pre-dispatch work that neither
|
||||
bridge option intercepts cleanly — see the spike doc.
|
||||
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 surfaces this as
|
||||
`SETUP_RETRY` on the affected entries.
|
||||
|
||||
A `sandbox/ping` handler is registered and exercised by the
|
||||
subprocess test (`test_phase4_subprocess`); the periodic 30s ping loop
|
||||
is wired through but currently disabled (process-exit detection covers
|
||||
the hard-crash case).
|
||||
|
||||
### 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 `SandboxV2Data`'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
|
||||
`sandbox/call_service` RPCs via a per-loop-tick batcher
|
||||
(`_CallServiceBatcher`) that coalesces matching
|
||||
`(domain, service, service_data)` calls into one multi-entity RPC — so
|
||||
a 200-light area call pays one RPC, not 200.
|
||||
|
||||
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).
|
||||
|
||||
The Phase 14 perf benchmark
|
||||
([`test_perf.py`](../tests/components/sandbox/test_perf.py))
|
||||
registers 200 proxy lights, area-targets `light.turn_on`, and asserts
|
||||
the batcher coalesces the 200 entity invocations into ≤2 RPCs in under
|
||||
500 ms. A regression that broke same-tick coalescing would fail
|
||||
loudly.
|
||||
|
||||
## 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.
|
||||
|
||||
## 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-v2.
|
||||
|
||||
## 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
|
||||
[`COMPAT.csv`](COMPAT.csv) + [`COMPAT_LATEST.md`](COMPAT_LATEST.md)
|
||||
(the curated baseline lives in [`COMPAT.md`](COMPAT.md)). Per-failure
|
||||
output lands in `${SANDBOX_V2_ERRORS_DIR:-/tmp/sandbox_errors}`.
|
||||
|
||||
[`run_compat_full.py`](run_compat_full.py) is the wider cross-sweep
|
||||
runner Phase 16 landed: asyncio + JUnit XML + outer concurrency,
|
||||
exercises every classifier-routable integration in the tree and writes
|
||||
[`COMPAT_FULL.md`](COMPAT_FULL.md) + [`COMPAT_FULL.csv`](COMPAT_FULL.csv).
|
||||
[`categorize_failures.py`](categorize_failures.py) buckets the
|
||||
JUnit failures into [`BACKLOG.md`](BACKLOG.md) +
|
||||
[`BACKLOG_FAILURES.json`](BACKLOG_FAILURES.json).
|
||||
|
||||
**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 v2's first pass. They're tracked separately so
|
||||
v2 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 v2; a v3 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.
|
||||
- **`calendar` / `todo` / `weather` query-shaped RPCs.** `async_get_events`
|
||||
(calendar), `todo_items` (todo), and `weather.async_forecast_*`
|
||||
return server-side query results the action-call channel can't
|
||||
express. The Phase 13 proxies return empty lists for these; a
|
||||
separate query-shaped RPC is owed if the compat sweep ever surfaces
|
||||
an integration that depends on these surfaces (it hasn't yet — see
|
||||
[`BACKLOG.md`](BACKLOG.md)).
|
||||
- **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 per-phase `STATUS-phase-N.md` files in this directory are the
|
||||
authoritative record of what each phase actually built, what it
|
||||
deferred, and what it flagged for the next phase. 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) |
|
||||
| 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,131 @@
|
||||
# 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 v2 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-phase-N.md` — per-phase landing notes: what each phase
|
||||
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_V2_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`); per-loop-tick
|
||||
fan-out batching; 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,
|
||||
200-light area-call perf benchmark.
|
||||
- **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 per-phase `STATUS-phase-N.md` files are the authoritative record
|
||||
of what each phase 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,149 @@
|
||||
Status: DONE
|
||||
|
||||
Phase 10 ships the testing infrastructure: two pytest plugins under
|
||||
`sandbox_v2/hass_client/hass_client/testing/` (in-process +
|
||||
real-subprocess) plus the `sandbox_v2/run_compat.py` lane runner that
|
||||
drives them against `tests/components/<integration>/` directories.
|
||||
|
||||
The v1 wording in the plan ("drop-in `HomeAssistant` →
|
||||
`RemoteHomeAssistant`") was reinterpreted for v2's subprocess
|
||||
architecture. v2 has no `RemoteHomeAssistant` class — integration code
|
||||
runs in a `SandboxRuntime` subprocess against its own private
|
||||
`HomeAssistant`. The "fast compat" lane therefore can't swap a class;
|
||||
instead it skips the subprocess by running `SandboxRuntime` as an
|
||||
asyncio task on the test event loop and joining it to the manager-side
|
||||
`Channel` via the in-memory loopback transport from `_inproc.py`. The
|
||||
"real-websocket" lane was likewise reinterpreted — v2's transport is
|
||||
stdio, not a websocket, so the equivalent is just letting the default
|
||||
`SandboxManager` spawn the real `python -m hass_client.sandbox_v2`
|
||||
subprocess. Both lanes share the same manager-side `SandboxBridge`
|
||||
code path; the only thing that differs is how the channel pair is
|
||||
materialised.
|
||||
|
||||
The in-process plugin's `async_setup_inprocess_sandbox()` is the
|
||||
load-bearing helper. It calls `async_setup_component(hass,
|
||||
"sandbox_v2", {})` to install the integration normally, then builds an
|
||||
in-memory channel pair, constructs a `SandboxRuntime` with a one-shot
|
||||
`channel_factory` that returns the runtime side, and pre-populates
|
||||
`manager._sandboxes[group]` with an `_InProcessSandboxProcess`
|
||||
stand-in that exposes the manager-side channel. The integration's
|
||||
router and bridge code paths run unchanged — they think they're
|
||||
talking to a real subprocess. One private-attribute access
|
||||
(`manager._sandboxes`) is the only deviation from public API; flagged
|
||||
inline with `# noqa: SLF001` and a comment.
|
||||
|
||||
The runtime task is created with `asyncio.create_task`, but
|
||||
`create_task` schedules without entering the coroutine, so an
|
||||
immediate `wait_until_ready` fails with `_ready is None`. The helper
|
||||
yields with a `while not runtime.started: await asyncio.sleep(0)` poll
|
||||
before calling `wait_until_ready(timeout=10)`, mirroring the polling
|
||||
pattern in `tests/components/sandbox_v2/test_sandbox_runtime.py`.
|
||||
|
||||
The subprocess plugin's contribution is mostly the freezer detection:
|
||||
`pytest_collection_modifyitems` adds a `pytest.mark.skip` to any test
|
||||
whose `fixturenames` includes `freezer` or that's tagged
|
||||
`@pytest.mark.no_sandbox_freezer`, and `pytest_configure` registers
|
||||
the marker so `--strict-markers` accepts it. v1 silently fell back to
|
||||
the in-process plugin when it detected `freezer`; v2 skips loudly so
|
||||
the compat report shows the gap. No module-level socket monkey-patch
|
||||
is needed — v2's transport is stdin/stdout pipes, not network sockets,
|
||||
so v1's `pytest-socket` workaround simply has no v2 analogue.
|
||||
|
||||
`run_compat.py` is a stand-alone CLI that calls `uv run python -m
|
||||
pytest -p <plugin> <test dir> --tb=no -q --no-header` for each
|
||||
integration, parses pytest's summary line for passed/failed/errors/
|
||||
skipped counts, and writes `COMPAT.csv` + `COMPAT.md`. Per-failure
|
||||
output lands in `$SANDBOX_V2_ERRORS_DIR` (default
|
||||
`/tmp/sandbox_v2_errors`). The runner is intentionally close in shape
|
||||
to v1's `sandbox/run_all_sandbox_tests.py` so existing muscle memory
|
||||
applies; the differences are (a) results live in `sandbox_v2/` not
|
||||
`/tmp`, and (b) the markdown report is a first-class deliverable.
|
||||
|
||||
The plan's verification bullet — "compat suite passes ≥ v1's
|
||||
baseline (878/880 = 99.8%)" — is **deferred to a Phase 10b sweep**.
|
||||
Phase 10 ships the infrastructure; producing the actual baseline
|
||||
needs the remaining 28 entity proxies Phase 5 deferred to Phase 5b
|
||||
and a focused triage pass on per-integration failures. Mixing both in
|
||||
this PR would have made review impossible.
|
||||
|
||||
Files added:
|
||||
- `sandbox_v2/hass_client/hass_client/testing/__init__.py`
|
||||
- `sandbox_v2/hass_client/hass_client/testing/_inproc.py`
|
||||
- `sandbox_v2/hass_client/hass_client/testing/pytest_plugin.py`
|
||||
- `sandbox_v2/hass_client/hass_client/testing/conftest_sandbox.py`
|
||||
- `sandbox_v2/hass_client/tests/test_testing_inproc.py`
|
||||
- `sandbox_v2/run_compat.py`
|
||||
- `sandbox_v2/COMPAT.md`
|
||||
- `tests/components/sandbox_v2/test_testing_plugins.py`
|
||||
|
||||
Files changed:
|
||||
- `sandbox_v2/plan.md` — Phase 10 marked complete; per-bullet status +
|
||||
inline notes for the v1→v2 reinterpretations and the deferred
|
||||
baseline verification.
|
||||
|
||||
Core HA files modified (review surface):
|
||||
- None. (Phase 10 is plugin-side and runner-side only; the
|
||||
manager-side `_sandboxes` access in the in-process plugin is a
|
||||
controlled internal hop covered by `# noqa: SLF001`.)
|
||||
|
||||
Test results:
|
||||
- `uv run pytest tests/components/sandbox_v2/ --no-cov -q` →
|
||||
**91 passed** (84 from Phase 0–9 + 7 new in
|
||||
`test_testing_plugins.py`).
|
||||
- `uv run pytest /home/paulus/dev/hass/core/sandbox_v2/hass_client/ -q`
|
||||
→ **43 passed** (39 from Phase 0–9 + 4 new in
|
||||
`test_testing_inproc.py`).
|
||||
- `uv run prek run --files <8 changed files>` → all hooks pass
|
||||
(ruff-check, ruff-format, codespell, pylint, prettier).
|
||||
|
||||
Things to flag for the next phase:
|
||||
|
||||
- **The baseline compat pass is owed.** `run_compat.py` is wired and
|
||||
smoke-tested end-to-end (the `--help` invocation works, and the
|
||||
parser/writer paths are exercised by the runner-internal tests), but
|
||||
no integration has been run through it yet. A Phase 10b sweep should
|
||||
(a) run the v1 33-integration list, (b) record results in COMPAT.md,
|
||||
(c) triage every non-pass row into a category bucket (mirroring v1's
|
||||
TEST_RESULTS.csv shape), and (d) raise issues for each category
|
||||
ahead of the v1→v2 migration cut-over.
|
||||
- **The in-process plugin auto-loads only the `built-in` sandbox
|
||||
group.** The `sandbox_v2_inprocess` fixture takes no parameters
|
||||
beyond `hass` and `tmp_path_factory`; tests that need a `main` or
|
||||
`custom` group must call `async_setup_inprocess_sandbox(group=...)`
|
||||
directly. Could be parametrised if a real compat run shows it
|
||||
matters.
|
||||
- **Route-on-classify is not yet automatic.** The plugins set up the
|
||||
sandbox infrastructure, but a vanilla HA Core integration test's
|
||||
`MockConfigEntry` does not auto-tag itself with `__sandbox_group`,
|
||||
so the router's classifier path doesn't fire for entries the test
|
||||
itself creates. The compat lane therefore tests the bridge in
|
||||
isolation today; for end-to-end "integration X routes to built-in"
|
||||
coverage the runner would need a small monkey-patch that tags
|
||||
`MockConfigEntry.add_to_hass` to set `__sandbox_group` based on the
|
||||
classifier. Flagged because it's the obvious next-tightening once
|
||||
Phase 10b numbers exist.
|
||||
- **`_InProcessSandboxProcess` does not implement the full
|
||||
`SandboxProcess` surface.** It exposes the two attributes
|
||||
(`state`, `channel`) the router actually reads plus a no-op
|
||||
`start`/`stop` and a best-effort `async_graceful_shutdown`. If a
|
||||
future phase grows the SandboxProcess interface (e.g., adds a
|
||||
`last_seen` for health protocol), the stand-in needs to keep up.
|
||||
- **The freezer skip is fixture-name-based.** It triggers on any test
|
||||
that takes a parameter literally named `freezer` — pytest-freezer's
|
||||
default. A test that wraps `freezer` in another fixture won't be
|
||||
caught; flagged for tightening if false negatives show up. The
|
||||
marker (`@pytest.mark.no_sandbox_freezer`) is the documented escape
|
||||
hatch.
|
||||
- **The CLI's `run_compat.py` lives at `sandbox_v2/` (script form),
|
||||
not as a package module.** Running `uv run python sandbox_v2/run_compat.py`
|
||||
works; the `# ruff: noqa: INP001` on the file is the documentation
|
||||
that this is intentional. If a future cleanup wants to make it
|
||||
`python -m sandbox_v2.run_compat`, the file would need to move
|
||||
under a package directory.
|
||||
- **Per-integration error captures land in `/tmp` by default.** The
|
||||
`SANDBOX_V2_ERRORS_DIR` env var overrides the location; the runner
|
||||
creates the dir on first failure. Documented in COMPAT.md.
|
||||
- **The runner takes a hard 5-minute per-integration timeout.** Same
|
||||
as v1; tunable via `--timeout`. If a real compat pass surfaces
|
||||
legitimately-slow integration suites, raise per-integration
|
||||
overrides via a config file rather than bumping the global default.
|
||||
@@ -0,0 +1,95 @@
|
||||
Status: DONE
|
||||
|
||||
Phase 11 is the documentation + migration-path sweep. `OVERVIEW.md`
|
||||
goes from a Phase-0 stub to the full architecture document — it now
|
||||
covers routing, lifecycle, graceful shutdown, config-flow forwarding,
|
||||
the Option B entity bridge, the service/event mirror, scoped auth,
|
||||
opt-in data sharing, Store routing, the test infrastructure, and the
|
||||
explicit list of v2-deferred follow-ups (Phase 5b / 10b /
|
||||
data_schema serialisation / unique_id propagation / share_states
|
||||
filtering / concurrent channel dispatcher / non-idempotent service
|
||||
handlers / v1 removal). The decision log is closed out:
|
||||
`docs/entity-bridge-decision.md` was already in place from Phase 1,
|
||||
and `docs/auth-scoping-decision.md` is new — it captures why
|
||||
`scopes` lives on `RefreshToken` itself (vs a subclass), the
|
||||
`_scope_allows` grammar (prefix grants for `sandbox_v2/`,
|
||||
exact matches for `auth/current_user`), the per-group sharing
|
||||
defaults (`built-in` / `main` all on, `custom` all off), and what
|
||||
the subscription consumer still needs to do once the sandbox→main
|
||||
websocket lands. `README.md` matches the shape of `sandbox/README.md`
|
||||
with a Phase-1-through-10 status block and a clear "v1 still lives
|
||||
in `sandbox/`" pointer. A directory-local `sandbox_v2/CLAUDE.md`
|
||||
points future Claude sessions at the right files (mirrors
|
||||
`sandbox/CLAUDE.md` for v1 — auto-loads when working inside
|
||||
`sandbox_v2/`); the repo-root `CLAUDE.md` / `AGENTS.md` stay focused
|
||||
on core-wide guidance, since the directory-local file is the right
|
||||
discovery hop. The v1 removal item stays deferred per plan —
|
||||
re-evaluate after Phase 10b's compat sweep lands a real baseline.
|
||||
|
||||
Files added:
|
||||
- `sandbox_v2/CLAUDE.md`
|
||||
- `sandbox_v2/docs/auth-scoping-decision.md`
|
||||
|
||||
Files changed:
|
||||
- `sandbox_v2/OVERVIEW.md` — replaced the Phase-0 stub with the full
|
||||
v2 architecture doc.
|
||||
- `sandbox_v2/README.md` — refreshed status block (Phases 0-10
|
||||
shipped, 5b / 10b deferred) and aligned shape with
|
||||
`sandbox/README.md`.
|
||||
- `sandbox_v2/plan.md` — Phase 11 section marked complete with
|
||||
per-checkbox status and an inline note on the deferred v1 removal.
|
||||
|
||||
Core HA files modified (review surface):
|
||||
- None. (Phase 11 is documentation only.)
|
||||
|
||||
Test results:
|
||||
- `uv run pytest tests/components/sandbox_v2/ --no-cov -q` →
|
||||
**91 passed** (unchanged from Phase 10).
|
||||
- `uv run pytest /home/paulus/dev/hass/core/sandbox_v2/hass_client/ -q`
|
||||
→ **43 passed** (unchanged from Phase 10).
|
||||
- `uv run prek run --files sandbox_v2/OVERVIEW.md sandbox_v2/README.md
|
||||
sandbox_v2/CLAUDE.md sandbox_v2/plan.md
|
||||
sandbox_v2/docs/auth-scoping-decision.md` → all hooks pass
|
||||
(codespell, prettier; ruff / mypy / pylint correctly skip
|
||||
Markdown-only changes).
|
||||
|
||||
Things to flag for the next phase:
|
||||
|
||||
- **There is no Phase 12.** The plan ends here; the remaining work
|
||||
is the explicitly-tracked Phase 5b (28 domain proxies) and Phase
|
||||
10b (compat baseline), plus the open follow-ups enumerated in
|
||||
`OVERVIEW.md`'s "Where the design is still open" section. Each
|
||||
follow-up is independent and can land as its own PR.
|
||||
- **v1 removal is the one item still in this plan.** Stays deferred
|
||||
until v2 has matched v1's compat numbers (Phase 10b) and shipped
|
||||
at least one stable release. When that day comes, the touch list
|
||||
is small: `sandbox/`, `homeassistant/components/sandbox/`, the
|
||||
`tests/components/sandbox/` tree, any CODEOWNERS line for v1, and
|
||||
the `sandbox/CLAUDE.md` discovery hop. The v1 surface has been
|
||||
stable since this work started so the cleanup is straightforward
|
||||
whenever the trigger fires.
|
||||
- **A migration script for v1 → v2 entries is not in scope.** Open
|
||||
question 4 from the plan ("What's the migration story for users
|
||||
on v1 sandbox today?") still wants an answer eventually:
|
||||
v1-tagged entries use `entry.options["sandbox"] = "<id>"`, v2
|
||||
uses `entry.data["__sandbox_group"]`. A script that walks the
|
||||
config-entry store and flips the tag is the obvious shape; it
|
||||
blocks the v1 removal item above but not the rest of v2.
|
||||
- **The top-level `CLAUDE.md` and `AGENTS.md` were left
|
||||
un-modified.** They already point at core-wide concerns
|
||||
(PR template, Python 3.14 syntax, test conventions, etc.) and
|
||||
aren't the right place to call out v2 specifically — the
|
||||
directory-local `sandbox_v2/CLAUDE.md` auto-loads whenever Claude
|
||||
reads or edits a file under `sandbox_v2/`, which is the hop a
|
||||
future session actually needs. Mentioning v2 at the repo root
|
||||
would also need the same line for v1 (it isn't there today). If
|
||||
a future maintainer disagrees, the change is a one-line addition
|
||||
to both files.
|
||||
- **Decision docs are now two — they could grow.** The
|
||||
per-phase STATUS files capture phase-local rationale, but
|
||||
longer-running decisions (like "we ship JSON over stdio rather
|
||||
than a websocket between manager and runtime", or the
|
||||
"concurrent channel dispatcher" follow-up's eventual shape)
|
||||
could plausibly become their own files under `docs/`. No need
|
||||
yet; flagging the pattern so the directory doesn't sprawl
|
||||
unintentionally.
|
||||
@@ -0,0 +1,126 @@
|
||||
Status: DONE
|
||||
|
||||
Phase 12 lifts the single-threaded-reader limitation Phase 9 flagged.
|
||||
Both `Channel` classes (the HA-Core integration's at
|
||||
`homeassistant/components/sandbox_v2/channel.py` and the sandbox
|
||||
runtime's at `sandbox_v2/hass_client/hass_client/channel.py`) now
|
||||
dispatch each inbound call or push in its own
|
||||
`asyncio.create_task`, freeing the reader to keep draining the wire.
|
||||
The synchronous-response path (a reply to one of our own calls) is
|
||||
unchanged — those still set the pending future inline, since there is
|
||||
no I/O to do.
|
||||
|
||||
A bounded `asyncio.Semaphore` caps concurrent handler tasks; the
|
||||
default is `DEFAULT_MAX_INFLIGHT = 16` and the new `max_inflight`
|
||||
keyword on `Channel.__init__` lets tests dial it down. The semaphore
|
||||
is acquired *inside* the dispatched task (not in the read loop), so
|
||||
the reader keeps making forward progress even when the cap is hit —
|
||||
the (cap+1)th call simply queues at the semaphore until a slot frees,
|
||||
matching the plan's "queues until earlier completes" requirement.
|
||||
|
||||
`Channel.close()` now cancels every tracked in-flight handler task
|
||||
and awaits them via `asyncio.gather(..., return_exceptions=True)`
|
||||
after the writer and reader teardown. The read loop's `finally` also
|
||||
cancels in-flight tasks on EOF so a remotely-closed channel doesn't
|
||||
leave handlers running against a dead writer. The
|
||||
`test_close_cancels_inflight_calls` semantics from Phase 0 still hold:
|
||||
the *caller* sees `ChannelClosedError` while the remote handler task
|
||||
is cancelled.
|
||||
|
||||
With concurrent dispatch in place,
|
||||
`SandboxRuntime._run_graceful_shutdown` now sets
|
||||
`hass.state = CoreState.final_write`, fires
|
||||
`EVENT_HOMEASSISTANT_FINAL_WRITE`, and `await hass.async_block_till_done()`
|
||||
right after unloading entries. Each pending `async_delay_save` Store
|
||||
runs its FINAL_WRITE listener, which calls `_async_handle_write_data`,
|
||||
which (with `install_remote_store` already in effect) round-trips
|
||||
through `MSG_STORE_SAVE` — the reader picks the reply up immediately
|
||||
because it's no longer blocked on the shutdown handler. The
|
||||
restore-state-via-reply path from Phase 9 stays in place because
|
||||
`core.restore_state` is owned by the runtime's explicit warm-load /
|
||||
shutdown-dump path, not by an integration's `Store`.
|
||||
|
||||
Files added:
|
||||
- `sandbox_v2/STATUS-phase-12.md`
|
||||
|
||||
Files changed:
|
||||
- `homeassistant/components/sandbox_v2/channel.py` — concurrent
|
||||
dispatch + bounded semaphore + in-flight tracking; `close()` cancels
|
||||
and awaits handler tasks. Updated module docstring.
|
||||
- `sandbox_v2/hass_client/hass_client/channel.py` — same changes
|
||||
mirrored on the sandbox side.
|
||||
- `sandbox_v2/hass_client/hass_client/sandbox.py` — fire
|
||||
`EVENT_HOMEASSISTANT_FINAL_WRITE` from `_run_graceful_shutdown`;
|
||||
set `CoreState.final_write` first and `await hass.async_block_till_done()`
|
||||
so re-entrant `RemoteStore` flushes complete. Updated docstring.
|
||||
- `tests/components/sandbox_v2/_helpers.py` — `make_channel_pair`
|
||||
grew `max_inflight_a` / `max_inflight_b` keywords so tests can
|
||||
exercise the cap path.
|
||||
- `tests/components/sandbox_v2/test_channel.py` — added
|
||||
`test_handler_can_call_back_without_deadlock` and
|
||||
`test_concurrency_cap_queues_excess_handlers`.
|
||||
- `sandbox_v2/hass_client/tests/test_shutdown.py` — added
|
||||
`test_shutdown_fires_final_write_event` and
|
||||
`test_shutdown_flushes_pending_delay_save`; switched the storage
|
||||
import to look up `Store` dynamically via `_storage.Store` so the
|
||||
`install_remote_store` patch is honoured.
|
||||
- `sandbox_v2/plan.md` — Phase 12 section marked ✅ COMPLETE with a
|
||||
summary paragraph and per-checkbox status.
|
||||
|
||||
Core HA files modified (review surface):
|
||||
- None. Phase 12 lives entirely under `sandbox_v2/` and
|
||||
`homeassistant/components/sandbox_v2/`. The Phase 4 / 5 / 7 core
|
||||
hooks are reused unchanged.
|
||||
|
||||
Test results:
|
||||
- `uv run pytest tests/components/sandbox_v2/ --no-cov -q` →
|
||||
**93 passed** (91 from prior phases + 2 new channel tests).
|
||||
- `cd sandbox_v2/hass_client && uv run pytest -q` →
|
||||
**45 passed** (43 from prior phases + 2 new shutdown tests).
|
||||
- `uv run prek run --files <6 changed files>` → all hooks pass
|
||||
(ruff-check, ruff-format, codespell, prettier, mypy, pylint).
|
||||
|
||||
Things to flag for the next phase:
|
||||
|
||||
- **`EVENT_HOMEASSISTANT_FINAL_WRITE` happens before `core.restore_state`
|
||||
collection.** Order was deliberate — flush integration Stores first
|
||||
(a misbehaving listener can no longer hang us thanks to Phase 12),
|
||||
then snapshot RestoreEntities. If a future integration produces
|
||||
restore-state updates from inside its FINAL_WRITE listener, the
|
||||
snapshot will see them. If anyone wants the opposite order, the
|
||||
block in `_run_graceful_shutdown` is one move.
|
||||
- **`hass.state` is mutated to `CoreState.final_write` inside the
|
||||
shutdown handler.** The sandbox-private `HomeAssistant` doesn't go
|
||||
through `async_start` / `async_stop`, so this is the first time its
|
||||
state changes from `not_running`. The bus and task system don't
|
||||
care, but if a future integration reads `hass.state` and adapts its
|
||||
behaviour, expect it to see `final_write` during shutdown — same
|
||||
signal a real HA instance would emit.
|
||||
- **Cap is process-wide, not per-message-type.** The default 16 was
|
||||
picked because it matches the order of magnitude of concurrent
|
||||
channel work the runtime would realistically see (one per loaded
|
||||
entry plus a few service / state pushes). If a single noisy push
|
||||
type ever needs throttling independent of calls, a per-type
|
||||
semaphore would slot in alongside `_inflight_sem` without churning
|
||||
the dispatch shape.
|
||||
- **Re-entrancy now works for any handler.** Phase 5/8's theoretical
|
||||
worry — an integration's `async_setup_entry` doing `Store.async_save`
|
||||
during `MSG_ENTRY_SETUP` — is now safe. No existing test directly
|
||||
exercises that path, but Phase 13's per-domain proxy tests are the
|
||||
natural place to add one if it becomes load-bearing.
|
||||
- **`_helpers.py::make_channel_pair` now takes
|
||||
`max_inflight_a` / `max_inflight_b`.** New surface area for tests
|
||||
to exercise the cap; only the new `test_concurrency_cap_queues_excess_handlers`
|
||||
uses it today. The `tests/components/sandbox_v2/` tree is the only
|
||||
consumer.
|
||||
- **`test_shutdown.py`'s `Store` resolution.** The new
|
||||
`test_shutdown_flushes_pending_delay_save` switched to
|
||||
`from homeassistant.helpers import storage as _storage` plus
|
||||
`_storage.Store(...)` so it picks up the `install_remote_store`
|
||||
patch. Integration authors who `from homeassistant.helpers.storage
|
||||
import Store` at module-import time before the patch installs will
|
||||
still capture the original `Store` — Phase 8 STATUS already flagged
|
||||
this as a known sharp edge.
|
||||
- **Phase 9's "concurrent channel dispatcher" follow-up is now
|
||||
closed.** Update Phase 9's STATUS callout if any future doc sweep
|
||||
passes through that file.
|
||||
@@ -0,0 +1,110 @@
|
||||
Status: DONE
|
||||
|
||||
Phase 13 fills in the 28 remaining domain proxy classes (plus a `scene`
|
||||
symmetry proxy) under `homeassistant/components/sandbox_v2/entity/`, so
|
||||
`_DOMAIN_PROXIES` now dispatches every supported HA entity domain to a
|
||||
typed proxy. Each proxy subclasses `SandboxProxyEntity` + the domain's
|
||||
`*Entity` and exposes the domain's typed properties out of
|
||||
`_state_cache`, then translates each entity-method call into a
|
||||
`sandbox_v2/call_service` RPC via the Phase-5 batcher + exception
|
||||
translator. Domains that index `supported_features` with `in`
|
||||
(`alarm_control_panel`, `climate`, `cover`, `fan`, `humidifier`,
|
||||
`lawn_mower`, `lock`, `media_player`, `notify`, `remote`, `siren`,
|
||||
`todo`, `update`, `vacuum`, `valve`, `water_heater`, `weather`) re-wrap
|
||||
the wire int into the domain's `*EntityFeature` IntFlag in `__init__`,
|
||||
matching the Phase-5 `light` pattern. Four entities whose `state`
|
||||
property is marked `@final` and reads a name-mangled private field
|
||||
(`button`, `event`, `notify`, `scene`) override `sandbox_apply_state`
|
||||
to set the mangled attribute directly so the parent's `@final` getter
|
||||
computes the right state from the sandbox-side push. A parametrised
|
||||
smoke test covers every new domain — register a synthetic entity, push
|
||||
state, invoke one method, assert the resulting RPC.
|
||||
|
||||
Files added:
|
||||
- homeassistant/components/sandbox_v2/entity/alarm_control_panel.py
|
||||
- homeassistant/components/sandbox_v2/entity/button.py
|
||||
- homeassistant/components/sandbox_v2/entity/calendar.py
|
||||
- homeassistant/components/sandbox_v2/entity/climate.py
|
||||
- homeassistant/components/sandbox_v2/entity/cover.py
|
||||
- homeassistant/components/sandbox_v2/entity/date.py
|
||||
- homeassistant/components/sandbox_v2/entity/datetime.py
|
||||
- homeassistant/components/sandbox_v2/entity/device_tracker.py
|
||||
- homeassistant/components/sandbox_v2/entity/event.py
|
||||
- homeassistant/components/sandbox_v2/entity/fan.py
|
||||
- homeassistant/components/sandbox_v2/entity/humidifier.py
|
||||
- homeassistant/components/sandbox_v2/entity/lawn_mower.py
|
||||
- homeassistant/components/sandbox_v2/entity/lock.py
|
||||
- homeassistant/components/sandbox_v2/entity/media_player.py
|
||||
- homeassistant/components/sandbox_v2/entity/notify.py
|
||||
- homeassistant/components/sandbox_v2/entity/number.py
|
||||
- homeassistant/components/sandbox_v2/entity/remote.py
|
||||
- homeassistant/components/sandbox_v2/entity/scene.py
|
||||
- homeassistant/components/sandbox_v2/entity/select.py
|
||||
- homeassistant/components/sandbox_v2/entity/siren.py
|
||||
- homeassistant/components/sandbox_v2/entity/text.py
|
||||
- homeassistant/components/sandbox_v2/entity/time.py
|
||||
- homeassistant/components/sandbox_v2/entity/todo.py
|
||||
- homeassistant/components/sandbox_v2/entity/update.py
|
||||
- homeassistant/components/sandbox_v2/entity/vacuum.py
|
||||
- homeassistant/components/sandbox_v2/entity/valve.py
|
||||
- homeassistant/components/sandbox_v2/entity/water_heater.py
|
||||
- homeassistant/components/sandbox_v2/entity/weather.py
|
||||
- tests/components/sandbox_v2/test_phase13_proxies.py
|
||||
|
||||
Files changed:
|
||||
- homeassistant/components/sandbox_v2/entity/__init__.py — extend
|
||||
`_build_registry()` so `_DOMAIN_PROXIES` dispatches all 32 supported
|
||||
domains.
|
||||
- sandbox_v2/plan.md — tick Phase 13 checkboxes and add the
|
||||
one-paragraph summary block.
|
||||
|
||||
Core HA files modified (review surface):
|
||||
None.
|
||||
|
||||
Test results:
|
||||
- `uv run pytest tests/components/sandbox_v2/ --no-cov -q` → 121 passed
|
||||
(28 new parametrised proxy smoke tests + 93 prior tests)
|
||||
- `uv run pytest /home/paulus/dev/hass/core/sandbox_v2/hass_client/ -q`
|
||||
→ 45 passed (no sandbox-side code touched)
|
||||
- `uv run prek run --files <30 changed files>` → all passing
|
||||
(ruff check, ruff format, codespell, mypy, pylint)
|
||||
|
||||
Things to flag for the next phase:
|
||||
- **`calendar` / `todo` listing is not proxied.** Both ship a proxy
|
||||
that translates the create/update/delete service methods, but
|
||||
`async_get_events` (calendar) and `todo_items` (todo) are
|
||||
server-side queries with shapes the `sandbox_v2/call_service`
|
||||
channel can't express. They return empty lists / None today. A
|
||||
separate query-shaped RPC is needed and was outside Phase 13's
|
||||
scope — flag for a Phase-14 or Phase-15 follow-up if the compat
|
||||
baseline reveals integrations that rely on these.
|
||||
- **`weather.async_forecast_*` not proxied.** Same shape problem as
|
||||
above — forecasts are async methods returning lists of dicts. The
|
||||
proxy exposes `condition` + instantaneous attributes; forecast
|
||||
retrieval would need its own RPC pattern.
|
||||
- **`update.async_skip` / `async_clear_skipped` not forwarded.** Both
|
||||
are `@final` on the base class and mutate a name-mangled
|
||||
`__skipped_version` field — not a service call. Phase 13 doesn't
|
||||
surface a way to drive these from main; if the compat sweep flags
|
||||
it, the fix is the same name-mangled-write pattern Phase 13 uses
|
||||
for `button` / `event` / `notify` / `scene`.
|
||||
- **`device_tracker` ignores GPS fields.** The proxy subclasses
|
||||
`BaseTrackerEntity` to avoid `TrackerEntity.state_attributes`'s
|
||||
`@final` decoration, which means lat/lon/gps_accuracy currently
|
||||
ride only as raw state attributes. If a real GPS integration shows
|
||||
up in the Phase-15 sweep, the fix is to inherit from
|
||||
`TrackerEntity` and feed `_attr_latitude` / `_attr_longitude` /
|
||||
`_attr_location_accuracy` from the cache in `sandbox_apply_state`.
|
||||
- **`climate.temperature_unit` defaults to °C.** The proxy reads it
|
||||
out of `description.capabilities["temperature_unit"]`, but the
|
||||
sandbox-side `EntityBridge._describe_entity` does not push that
|
||||
key today — Phase 6's capability bridge only forwards
|
||||
`entity.capability_attributes`, and `ClimateEntity` doesn't surface
|
||||
`temperature_unit` there. Integrations relying on °F will show
|
||||
wrong units on main. Fix is one extra key in the sandbox-side
|
||||
payload builder; flag for the same follow-up as the
|
||||
`data_schema` / service-schema serialisation work.
|
||||
- **Phase 5's deferred 200-light area-call benchmark and full
|
||||
area-targeted test remain deferred** — Phase 13 only proves
|
||||
per-domain shape, not the multi-entity scale Phase 5 promised.
|
||||
Phase 14 (`5b-other`) already owns the benchmark.
|
||||
@@ -0,0 +1,132 @@
|
||||
Status: DONE
|
||||
|
||||
Phase 14 fills in the four smaller follow-ups Phase 5 / 6 left open. The
|
||||
`data_schema` / service-schema bridge serialises voluptuous schemas on
|
||||
the sandbox side via `voluptuous_serialize.convert(..., custom_serializer=cv.custom_serializer)`
|
||||
— the wire shape is the same list-of-fields the HA frontend already
|
||||
renders — and rebuilds a permissive `vol.Schema` on main via a small
|
||||
`schema_bridge.reconstruct_schema` helper (primitive types map back to
|
||||
`str`/`int`/`float`/`bool`, `select` maps to `vol.In`, everything else
|
||||
falls through to a pass-through validator since the sandbox runs the
|
||||
real validator on every call). `ServiceMirror` now pushes the serialised
|
||||
schema alongside each `register_service` push and `SandboxBridge`
|
||||
reconstructs it before calling `hass.services.async_register`, so bad
|
||||
service-call input is rejected on main without round-tripping. `unique_id`
|
||||
rides in the marshalled `FlowResult.context` (the flow-runner looks it
|
||||
up via `flow_manager.async_get(flow_id)` because FORM /
|
||||
SHOW_PROGRESS / EXTERNAL_STEP results don't carry context themselves),
|
||||
and the proxy passes it through `await self.async_set_unique_id(...)`
|
||||
so main's duplicate-detection guard fires. The `async_unload_entry`
|
||||
hook on `ConfigEntries.async_unload` is the third call site against the
|
||||
existing `router` attribute, shaped like Phase 4's setup intercept —
|
||||
returns `None` → existing `entry.async_unload(hass)` path runs
|
||||
unchanged; returns `True`/`False` → entry transitions to `NOT_LOADED`
|
||||
and the result propagates. The perf benchmark spins up the in-process
|
||||
plugin's sandbox (real channel-pair + JSON encode/decode + batcher,
|
||||
no subprocess startup), registers 200 proxy lights, area-targets
|
||||
`light.turn_on`, and asserts the batcher coalesces the 200 entity
|
||||
invocations into ≤2 RPCs in under 500 ms (actual measured time is in
|
||||
the failure message so a regression has a recorded baseline).
|
||||
|
||||
Files added:
|
||||
- sandbox_v2/hass_client/hass_client/schema_bridge.py
|
||||
- homeassistant/components/sandbox_v2/schema_bridge.py
|
||||
- tests/components/sandbox_v2/test_phase14.py
|
||||
- tests/components/sandbox_v2/test_perf.py
|
||||
|
||||
Files changed:
|
||||
- sandbox_v2/hass_client/hass_client/flow_runner.py — serialise
|
||||
`data_schema` via `serialize_schema`; pull `flow.context` (with
|
||||
`unique_id`) off the live flow when the result type doesn't carry
|
||||
it; thread the flow manager into `_marshal_result`.
|
||||
- sandbox_v2/hass_client/hass_client/service_mirror.py — push the
|
||||
serialised service schema alongside `(domain, service,
|
||||
supports_response)` so main can register a real schema instead of
|
||||
`schema=None`.
|
||||
- sandbox_v2/hass_client/tests/test_flow_runner.py — update the
|
||||
FORM-init assertion to expect the serialised list shape; add a
|
||||
`test_flow_init_marshals_unique_id` that exercises context
|
||||
marshalling.
|
||||
- homeassistant/components/sandbox_v2/bridge.py — reconstruct the
|
||||
serialised schema in `_handle_register_service` and pass it to
|
||||
`hass.services.async_register`.
|
||||
- homeassistant/components/sandbox_v2/proxy_flow.py — apply remote
|
||||
`unique_id` to the proxy via `await self.async_set_unique_id(...)`
|
||||
(raises `AbortFlow("already_in_progress")` on collision, which the
|
||||
framework converts to an ABORT result); rebuild a usable
|
||||
`vol.Schema` from the serialised list for `async_show_form`.
|
||||
- sandbox_v2/plan.md — Phase 14 section marked complete with summary
|
||||
and per-checkbox notes.
|
||||
|
||||
Core HA files modified (review surface):
|
||||
- homeassistant/config_entries.py:2110-2113 — `ConfigEntryRouter`
|
||||
Protocol gains `async_unload_entry`. Same shape as the existing
|
||||
`async_create_flow` / `async_setup_entry` hooks: returns `None` to
|
||||
fall through, a concrete `bool` to take over.
|
||||
- homeassistant/config_entries.py:2434-2448 — `ConfigEntries.async_unload`
|
||||
consults `router.async_unload_entry` before the existing
|
||||
`entry.async_unload(hass)` path. When the router returns not-None
|
||||
the entry transitions to `NOT_LOADED`; when it returns `None` the
|
||||
existing setup-lock-guarded `entry.async_unload(hass)` path runs
|
||||
unchanged. 4 new lines + 1 reuse of `_async_set_state` — same
|
||||
minimal-hook shape as Phase 4's setup intercept; the Phase 4
|
||||
`router` attribute is reused, no new attribute.
|
||||
|
||||
Test results:
|
||||
- `uv run pytest tests/components/sandbox_v2/ --no-cov -q` → 133 passed
|
||||
(121 from Phase 0–13 + 11 new test_phase14 cases + 1 new test_perf
|
||||
case).
|
||||
- `cd sandbox_v2/hass_client && uv run pytest -q` → 46 passed (45 from
|
||||
Phase 0–13 + 1 new `test_flow_init_marshals_unique_id`; the
|
||||
existing `test_flow_init_returns_form` assertion is updated for
|
||||
the new serialised wire shape but the test count is unchanged).
|
||||
- `uv run pytest tests/test_config_entries.py --no-cov -q` → 383
|
||||
passed, 4 snapshots passed. The new
|
||||
`ConfigEntries.async_unload` router consult is benign when no
|
||||
router is installed.
|
||||
- `uv run pytest tests/helpers/test_entity_component.py --no-cov -q`
|
||||
→ 30 passed. Phase 5's `async_register_remote_platform` core hook
|
||||
is unaffected.
|
||||
- `uv run prek run --files <10 changed files>` → all hooks pass
|
||||
(ruff-check, ruff-format, codespell, mypy, pylint).
|
||||
|
||||
Things to flag for the next phase:
|
||||
|
||||
- **Schema reconstruction is permissive on purpose.** The main-side
|
||||
rebuild handles primitive types + `select` precisely; everything
|
||||
else collapses to a pass-through validator. That's fine for v2's
|
||||
posture — the sandbox runs the real validator on every call — but
|
||||
it means main-side validation rejects only obvious type/required
|
||||
errors. Phase 15 / 16's compat sweep will surface whether any
|
||||
integration's UI flows rely on richer client-side hints (selectors
|
||||
with constraints, expandable sections, etc.) that the
|
||||
pass-through silently strips. If so, the fix is to extend
|
||||
`_validator_from_entry` — the bridge plumbing doesn't change.
|
||||
- **Service-schema mirror runs lazily.** The serialised schema is
|
||||
pushed once per service registration; later integration code that
|
||||
mutates the schema on a registered service (rare but legal) won't
|
||||
re-push. If Phase 15 surfaces an integration that does this, the
|
||||
fix is a `services.async_register`-listener-driven delta push,
|
||||
same shape as the entity-bridge `update_entity` deferral.
|
||||
- **Perf benchmark uses the in-process plugin.** The plan called
|
||||
for a real-subprocess benchmark. The in-process variant exercises
|
||||
the same batcher code path and the same JSON encode/decode, but
|
||||
skips subprocess startup cost (~1 s of fixed overhead). The
|
||||
batcher's coalescing — which is the perf claim Phase 5 made — is
|
||||
what the test pins. A real-subprocess perf benchmark is a
|
||||
strict-superset measurement and can be added as a follow-up
|
||||
without changing the bridge.
|
||||
- **`async_unload_entry`'s state transition is unconditional.**
|
||||
When the router returns `True` *or* `False`, the entry transitions
|
||||
to `NOT_LOADED`. The plan didn't explicitly call out the failed-
|
||||
unload path; Phase 14 chose the simpler "always transition" since
|
||||
the entry no longer has anything attached to it after the bridge
|
||||
drops its proxies (success) or after a closed channel (failure).
|
||||
A future revision could surface `FAILED_UNLOAD` for the false
|
||||
return value if any integration relies on the distinction.
|
||||
- **`_apply_remote_context` only mirrors `unique_id`.** Other
|
||||
context bits the sandbox flow might mutate (`title_placeholders`,
|
||||
`source`, `unique_id`-adjacent flags) don't propagate today. The
|
||||
duplicate-detection use-case is fully covered; if Phase 15
|
||||
surfaces integrations that mutate other context fields mid-flow,
|
||||
the fix is one more key in the same helper.
|
||||
@@ -0,0 +1,145 @@
|
||||
Status: DONE
|
||||
|
||||
Phase 15 closes the deferred Phase 10b sweep: it lands the
|
||||
`MockConfigEntry.add_to_hass` autotag patch, fixes two `run_compat.py`
|
||||
plumbing gaps that prevented the runner from ever finding a real
|
||||
test, and produces the first real `COMPAT.md` / `COMPAT.csv` numbers
|
||||
against v1's 37-integration baseline. **No core HA files touched** —
|
||||
Phase 15 is entirely test infrastructure + runner plumbing +
|
||||
documentation.
|
||||
|
||||
Headline: 29 of 37 integrations fully pass; 7,586 of 7,648 tests
|
||||
pass (99.19%). Every one of the 62 failures buckets into a single
|
||||
`test-only` root cause — the autotag patch mutates `entry.data` to
|
||||
add `__sandbox_group: built-in`, and a handful of helper integration
|
||||
tests (`group`, `template`, `min_max`, `derivative`, `threshold`,
|
||||
`utility_meter`, `integration`, `proximity`) inspect that data dict
|
||||
directly (assertions like `entry.data == {}`, or Syrupy snapshots).
|
||||
Confirmed by re-running the same files **without** the sandbox
|
||||
plugin: 107/107 pass, so every failure traces back to the patch
|
||||
making the routing tag observable. The bridge code paths exercised
|
||||
by the suite (router setup, all 32 entity proxies, service mirror,
|
||||
event mirror, restore_state warm-load, schema bridge) pass cleanly
|
||||
on every integration.
|
||||
|
||||
The autotag patch lives in
|
||||
`sandbox_v2/hass_client/hass_client/testing/_autotag.py`. It
|
||||
re-implements the Phase 2 classifier synchronously (manifest +
|
||||
`os.listdir` walk; same five-rule order — `ALWAYS_MAIN` check,
|
||||
manifest `integration_type == "system"` check, deny-listed platform
|
||||
check, custom vs built-in fallback) because the real classifier
|
||||
takes an async-loaded `Integration` and would require driving a
|
||||
coroutine from inside the running event loop the test is already on.
|
||||
Both compat plugins install it in `pytest_configure` and tear it
|
||||
down in `pytest_unconfigure`. The patch wraps
|
||||
`MockConfigEntry.add_to_hass`: when the entry's domain classifies to
|
||||
a sandbox group and `entry.data` doesn't already carry
|
||||
`__sandbox_group`, it builds a new `MappingProxyType` with the tag
|
||||
injected and uses `object.__setattr__` to overwrite `entry.data`
|
||||
(mirroring the trick `ConfigEntry.__init__` uses to freeze the
|
||||
field), then delegates to the original `add_to_hass`. Idempotent and
|
||||
reversible.
|
||||
|
||||
Two `run_compat.py` fixes were needed for the runner to find tests
|
||||
at all:
|
||||
|
||||
1. `cwd` was `sandbox_v2/hass_client/`, but `tests/conftest.py`
|
||||
imports freezegun / pytest-aiohttp / other HA test deps that are
|
||||
only installed in the core uv env. Changed to `CORE_ROOT`
|
||||
(`sandbox_v2/..`). The hass_client env's own tests still run from
|
||||
that env via `cd sandbox_v2/hass_client && uv run pytest`.
|
||||
2. The pytest invocation now passes `--no-cov` so per-integration
|
||||
runs don't fail the pytest-cov plugin hook (it requires every
|
||||
test path resolve against the configured `cov` source).
|
||||
|
||||
`run_compat.py` also got a third change: its markdown default-output
|
||||
path moved from `COMPAT.md` to `COMPAT_LATEST.md` so the curated
|
||||
Phase 15 baseline report at `COMPAT.md` isn't silently overwritten by
|
||||
ad-hoc runs. `COMPAT.csv` is still the canonical machine-readable
|
||||
artifact.
|
||||
|
||||
Files added:
|
||||
- sandbox_v2/hass_client/hass_client/testing/_autotag.py
|
||||
- sandbox_v2/hass_client/tests/test_autotag.py
|
||||
- sandbox_v2/STATUS-phase-15.md (this file)
|
||||
|
||||
Files changed:
|
||||
- sandbox_v2/hass_client/hass_client/testing/pytest_plugin.py —
|
||||
install autotag in `pytest_configure` and tear down in
|
||||
`pytest_unconfigure`.
|
||||
- sandbox_v2/hass_client/hass_client/testing/conftest_sandbox.py —
|
||||
same autotag install/teardown alongside the existing freezer
|
||||
marker registration.
|
||||
- sandbox_v2/run_compat.py — `cwd=CORE_ROOT` so core test conftest
|
||||
imports resolve; pass `--no-cov`; default markdown output moved to
|
||||
`COMPAT_LATEST.md` to preserve curated `COMPAT.md`.
|
||||
- sandbox_v2/COMPAT.md — curated Phase 15 baseline report with
|
||||
bucketed triage table, v2-vs-v1 comparison, and the single
|
||||
follow-up that closes the v1-removal gap.
|
||||
- sandbox_v2/COMPAT.csv — fresh 37-integration baseline numbers.
|
||||
- tests/components/sandbox_v2/test_testing_plugins.py — add
|
||||
end-to-end autotag test (`test_autotag_mutates_mock_config_entry_data`);
|
||||
wrap `test_conftest_sandbox_registers_marker_in_configure` in
|
||||
try/finally so the autotag side-effect of `pytest_configure` is
|
||||
torn down via `pytest_unconfigure` instead of leaking into the
|
||||
rest of the suite.
|
||||
- sandbox_v2/plan.md — Phase 15 marked complete with the
|
||||
per-checkbox summary.
|
||||
|
||||
Core HA files modified (review surface):
|
||||
- None. (Phase 15 is plugin-side, runner-side, and documentation only.)
|
||||
|
||||
Test results:
|
||||
- `uv run pytest tests/components/sandbox_v2/ --no-cov -q` →
|
||||
**134 passed** (133 from Phase 0–14 + 1 new
|
||||
`test_autotag_mutates_mock_config_entry_data`).
|
||||
- `cd sandbox_v2/hass_client && uv run pytest -q` →
|
||||
**51 passed** (46 from Phase 0–14 + 5 new `test_autotag.py` cases).
|
||||
- `.venv/bin/prek run --files <7 changed files>` → all hooks pass
|
||||
(ruff-check, ruff-format, codespell, prettier, pylint).
|
||||
- `cd sandbox_v2 && uv run python run_compat.py <37 v1 integrations>`
|
||||
→ 29 pass, 8 issues, 7,586 tests passed, 62 failed, 17 skipped
|
||||
(99.19% test-level pass rate).
|
||||
|
||||
Things to flag for the next phase:
|
||||
|
||||
- **The 99.19% rate is below the 99.5% v1-removal threshold.** The
|
||||
single follow-up that closes the gap is "move the sandbox-group
|
||||
tag off `entry.data`". Two viable shapes: (a) carry the group on a
|
||||
side-channel mapping
|
||||
(`hass.data[DATA_SANDBOX_V2].group_for_entry[entry.entry_id]`)
|
||||
instead of mutating `entry.data`; or (b) keep `entry.data` clean
|
||||
and re-derive the group on every router lookup via the classifier
|
||||
when no explicit tag is present. Either approach removes the
|
||||
observable footprint and the 62 test-only failures vanish. v1
|
||||
removal stays deferred (per Phase 11) until that follow-up lands.
|
||||
- **The sync classifier duplicates ~30 LOC of the real classifier.**
|
||||
Justified — the real one needs an async-loaded `Integration` and
|
||||
the compat tests are already inside the event loop — but it can
|
||||
drift. The Phase-2 classifier tests catch behavioural drift on the
|
||||
real side; the new `tests/test_autotag.py` pins the sync side. If
|
||||
the deny-list grows, both lists need updating.
|
||||
- **`run_compat.py` runs strictly serially.** The 37-integration
|
||||
Phase 15 sweep took ~3 min wall time; the full
|
||||
`homeassistant/components/` tree (Phase 16's scope) is hundreds of
|
||||
integrations and will need pytest-xdist (`-n auto`) to finish in
|
||||
hours instead of half a day. Flagged in the Phase 16 spec already.
|
||||
- **`run_compat.py` still depends on `uv run python -m pytest`
|
||||
resolving in the core env.** Documented in COMPAT.md's
|
||||
"Reproducing this report" section, but the runner doesn't sanity-
|
||||
check that the core venv is present before spawning subprocesses.
|
||||
If someone runs from a fresh checkout without `script/setup`,
|
||||
every integration row will be `no_tests` with a confusing error in
|
||||
the captured output.
|
||||
- **`COMPAT_LATEST.md` is the auto-output file and is **not**
|
||||
gitignored.** A reviewer who re-runs `run_compat.py` should
|
||||
expect a working-tree change to `COMPAT_LATEST.md` (and `COMPAT.csv`)
|
||||
— flagged so future cleanup can decide whether to add it to
|
||||
`.gitignore` or include it as part of the committed deliverable.
|
||||
- **`group_for_entry` side-channel (the recommended follow-up) is
|
||||
not a pure docs change.** It touches `router.py` (every
|
||||
`entry.data.get(SANDBOX_GROUP_KEY)` site becomes
|
||||
`data.group_for_entry.get(entry.entry_id)`) and `proxy_flow.py`
|
||||
(the CREATE_ENTRY path writes to the side-channel instead of
|
||||
`entry.data`). Small but real — not a one-line change. Flagged so
|
||||
whoever takes it on plans for that surface.
|
||||
@@ -0,0 +1,141 @@
|
||||
Status: DONE
|
||||
|
||||
Phase 16 ships the cross-integration compat sweep + categorised backlog
|
||||
the plan called for. The sweep ran every classifier-routable,
|
||||
config-entry-based integration (807 in total) through the in-process
|
||||
compat plugin in **705s wall** at concurrency=6 — well inside the
|
||||
30-90 min budget the plan called out. **561/807** integrations pass
|
||||
cleanly; **33 714/34 378** tests pass — a **98.07 %** test-level rate
|
||||
across the broader set (Phase 15's 37-integration baseline was
|
||||
99.19 %, so the broader sweep is a little noisier as expected). The
|
||||
categoriser (`categorize_failures.py`) buckets **98.6 %** of the 664
|
||||
failures, clearing the plan's ≥95 % gate; `BACKLOG.md` is hand-curated
|
||||
on top of the auto-draft `generate_backlog.py` produces, with proposed
|
||||
fixes + rough sizes per bucket. The headline takeaway is the same as
|
||||
Phase 15's, just at scale: **640 of 664 failures (96.4 %) are the
|
||||
`__sandbox_group` autotag noise** Phase 15 already flagged; landing the
|
||||
"move sandbox-group tag off `entry.data`" follow-up clears all of them
|
||||
and lifts the rate above the 99.5 % v1-removal threshold. The two real
|
||||
bridge findings are scoped to two integrations: `dependencies-not-shared`
|
||||
(`azure_event_hub`+1; test mocks installed in main never reach the
|
||||
sandbox subprocess) and `proxy-missing` (`atag`; climate +
|
||||
water_heater entities register in the sandbox but main's registry /
|
||||
state machine never sees them). **No core HA files touched** — Phase
|
||||
16 is sweep tooling + documentation only.
|
||||
|
||||
The runner forks rather than extends `run_compat.py` (per the plan's
|
||||
"or fork into `run_compat_full.py`" carve-out). Two reasons: the
|
||||
Phase-15 runner stays the way Phase 15's curated 37-integration report
|
||||
expects it, and the new runner has a different shape — asyncio +
|
||||
JUnit XML + outer concurrency vs the Phase-15 sync-subprocess loop +
|
||||
text-output parsing. The per-integration parallelism the plan
|
||||
suggested (`pytest-xdist -n auto`) is wired behind `--xdist` but stays
|
||||
off by default: xdist worker spin-up cost dominates for the typical
|
||||
sub-30-test integration, and the outer asyncio concurrency is what
|
||||
actually drops the sweep from ~70 min serial to ~12 min. xdist is
|
||||
there for individual long integrations (e.g. zwave_js at 608 tests /
|
||||
65s) when someone wants to iterate on the backlog locally.
|
||||
|
||||
The categoriser's rule set is intentionally regex-on-traceback-excerpt
|
||||
because the alternative (parsing pytest's tree, importing the test
|
||||
module, or running a custom collector) buys precision the bucket
|
||||
labels don't need. Rules are ordered most-specific → most-generic so a
|
||||
real-bug rule fires before a catch-all picks up. The `mappingproxy(...)`
|
||||
patterns are the broadest — the autotag is the only thing in HA Core
|
||||
that turns a regular `entry.data == {…}` assertion into a
|
||||
`mappingproxy(…) == {…}` failure — but the rule is gated on `'built-in'`
|
||||
/ `'custom'` (the autotag's only possible group values) so a future
|
||||
non-autotag mappingproxy mismatch still lands in `unknown`. The
|
||||
re-purposed `proxy-missing` rule that catches both
|
||||
`async_is_registered(...) == False` and `hass.states.get(...) is None`
|
||||
is the one place the rule set is interpretive rather than mechanical —
|
||||
both shapes point at "entity registered in sandbox but main never
|
||||
saw it", which is the same fix story even if the proxy class itself
|
||||
exists.
|
||||
|
||||
Files added:
|
||||
- sandbox_v2/run_compat_full.py — the sweep runner (asyncio + JUnit
|
||||
+ outer concurrency).
|
||||
- sandbox_v2/categorize_failures.py — the categoriser (regex rules +
|
||||
JSON rollup).
|
||||
- sandbox_v2/generate_backlog.py — the auto-draft BACKLOG.md skeleton
|
||||
generator (the committed BACKLOG.md is hand-curated on top of it).
|
||||
- sandbox_v2/COMPAT_FULL.md — auto-generated per-integration results
|
||||
table (807 rows).
|
||||
- sandbox_v2/COMPAT_FULL.csv — machine-readable companion to
|
||||
COMPAT_FULL.md.
|
||||
- sandbox_v2/BACKLOG.md — hand-curated categorised remediation plan
|
||||
with proposed fixes + rough sizes.
|
||||
- sandbox_v2/BACKLOG_FAILURES.json — machine-readable rollup
|
||||
(`{bucket → {integration → [{node_id, excerpt}]}}`).
|
||||
- sandbox_v2/STATUS-phase-16.md (this file).
|
||||
|
||||
Files changed:
|
||||
- sandbox_v2/plan.md — Phase 16 marked complete with the per-checkbox
|
||||
summary block.
|
||||
|
||||
Core HA files modified (review surface):
|
||||
- None. (Phase 16 is sweep tooling + documentation only.)
|
||||
|
||||
Test results:
|
||||
- `uv run pytest tests/components/sandbox_v2/ --no-cov -q` →
|
||||
**134 passed** (no regression from the Phase 15 baseline of 134).
|
||||
- `cd sandbox_v2/hass_client && uv run pytest -q` →
|
||||
**51 passed** (no regression from Phase 15's 51).
|
||||
- `uv run prek run --files <8 changed files>` → all hooks pass
|
||||
(ruff-check, codespell, prettier, check-json).
|
||||
- Full sweep:
|
||||
- `cd sandbox_v2 && uv run python run_compat_full.py --concurrency=6 --timeout=180`
|
||||
→ 807 integrations exercised in 705s wall; 561 pass, 246 with
|
||||
failures; 33 714 tests pass / 34 378 collected (98.07 %).
|
||||
- `uv run python categorize_failures.py` → 655/664 failures
|
||||
bucketed (98.6 %).
|
||||
|
||||
Things to flag for the next phase:
|
||||
|
||||
- **The "move `__sandbox_group` off `entry.data`" follow-up is now the
|
||||
single highest-leverage fix in the entire v2 codebase.** 96.4 % of
|
||||
every failure across 807 integrations clears with one ~80–120 LOC
|
||||
patch. Phase 15 flagged this; Phase 16 quantifies it. The two viable
|
||||
shapes (side-channel mapping vs re-derive-on-lookup) are spelt out
|
||||
in `BACKLOG.md::test-only`. Either lands the test-level pass rate
|
||||
above the 99.5 % v1-removal threshold the plan asks for.
|
||||
- **`atag` is a microcosm of every remaining real-bug bucket.** It's
|
||||
the only integration in `proxy-missing` (5 failures), one of the two
|
||||
in `dependencies-not-shared` (1 failure), and three of the seven in
|
||||
`unknown` (3 failures). Fixing atag's specific coordinator-shape
|
||||
bug — climate + water_heater registering in the sandbox but main
|
||||
never surfacing them — likely closes 9 of the 24 remaining
|
||||
bridge-real failures in one go.
|
||||
- **The compat plugin's mock-propagation gap is the next real
|
||||
protocol decision.** `azure_event_hub` (9 failures) and atag (1)
|
||||
both fail because `unittest.mock.patch` installed in the main test
|
||||
process doesn't reach the sandbox subprocess. The in-process plugin
|
||||
could plausibly close this with a fixture re-entry hook (option (b)
|
||||
in `BACKLOG.md::dependencies-not-shared`); the subprocess plugin
|
||||
needs a sandbox-aware mock channel that v2 won't ship. Worth
|
||||
deciding before option (b)'s 40 LOC lands so the subprocess plugin
|
||||
isn't left without a story.
|
||||
- **The `unknown` bucket has 9 environmental rows that won't go away
|
||||
without integration-level test fixes** (bluetooth: `habluetooth`
|
||||
version skew; chess_com, mastodon: `tzlocal()` vs `tzutc()`
|
||||
fragility; html5: freezegun + tz; google: token-refresh; insteon:
|
||||
websocket error envelope). Six are not v2 bridge bugs. Worth
|
||||
filing upstream as integration-test issues rather than carrying
|
||||
them as v2 follow-ups.
|
||||
- **`run_compat_full.py` shells out to `uv run python -m pytest` per
|
||||
integration** — same dependency on the core venv being present that
|
||||
`run_compat.py` already has. With concurrency=6 on the test box the
|
||||
sweep finished in 12 min; on a smaller box (4 cores) it'll be
|
||||
closer to 30 min. The plan's 30-90 min budget covers both.
|
||||
- **The categoriser's regex rules are easy to extend** — every new
|
||||
bucket signature is one `Rule(...)` tuple. Watch for `unknown`
|
||||
bucket creep on the next sweep; if it gains rows that aren't
|
||||
environmental, add rules and re-run rather than letting the bucket
|
||||
drift wide.
|
||||
- **`generate_backlog.py` produces a draft skeleton that BACKLOG.md
|
||||
was written on top of, not the committed artefact directly.** The
|
||||
committed `BACKLOG.md` is hand-curated; running `generate_backlog.py
|
||||
--out BACKLOG.md` would overwrite the curated content with the
|
||||
TODO-marker skeleton. Document the workflow in CLAUDE.md if anyone
|
||||
else needs to regenerate.
|
||||
@@ -0,0 +1,216 @@
|
||||
Status: DONE
|
||||
|
||||
Phase 17 added an optional `ConfigEntry.sandbox: str | None` field on
|
||||
`homeassistant/config_entries.py` and moved the v2 routing tag off
|
||||
`entry.data["__sandbox_group"]` onto the new first-class field. This
|
||||
is the highest-leverage backlog fix Phase 15 / Phase 16 surfaced:
|
||||
**552 of 664 known failures cleared in one patch**, lifting the
|
||||
full-sweep test-level pass rate from 98.07 % to **99.67 %** (the
|
||||
99.5 % v1-removal threshold the plan asked for) and the curated
|
||||
37-integration baseline from 99.19 % to **99.97 %**. The fix has three
|
||||
parts: (a) core HA — additive optional field with storage-shape
|
||||
backwards compatibility (no version bump), an
|
||||
`async_update_entry(entry, sandbox=)` accessor, and a one-line
|
||||
read of `ConfigFlowResult["sandbox"]` at entry construction; (b) v2
|
||||
read sites — `router.py` and `proxy_flow.py` consult `entry.sandbox`
|
||||
and `SANDBOX_GROUP_KEY` is gone from the codebase; (c) the autotag
|
||||
patch sets `entry.sandbox` via `object.__setattr__` instead of
|
||||
mutating `entry.data`, removing the autotag's observable footprint
|
||||
from every integration test that asserted on `entry.data` shape.
|
||||
|
||||
The plan's "right after the framework creates the entry, call
|
||||
`async_update_entry(entry, sandbox=group)`" approach turned out to
|
||||
have an order-of-operations gap: `async_add(entry)` runs `async_setup`
|
||||
*inside* its own body, which consults the router; by the time
|
||||
`async_on_create_entry` fires the entry has already been
|
||||
(incorrectly) set up locally. The fix that works is to attach `sandbox=<group>` to the
|
||||
`ConfigFlowResult` on the CREATE_ENTRY path so the framework's
|
||||
`ConfigEntry` constructor reads it via `result.get("sandbox")`. That's
|
||||
one extra optional key on `ConfigFlowResult` and one extra constructor
|
||||
kwarg consult — strictly inside the "minimal and reviewable" bar the
|
||||
plan asked for, and the same plumbing shape `minor_version` /
|
||||
`options` / `subentries` already use.
|
||||
|
||||
The 112 residual failures across the 807-integration sweep are
|
||||
**100 % test-side**: every named bridge bucket (`proxy-missing`,
|
||||
`dependencies-not-shared`, `protocol-gap`, `restore-state-not-applied`,
|
||||
...) is at zero. ~30 are diagnostic snapshots that include
|
||||
`entry.as_dict()` and now show `+ 'sandbox': 'built-in'` (the new field
|
||||
is correctly surfaced in production diagnostics; the snapshot just
|
||||
pre-dates it). ~70 are `'created_at': '20XX-...'` drift in tests that
|
||||
didn't pin the wall clock with freezegun — pre-existing fragility
|
||||
Phase 16 also flagged but at smaller proportion (the autotag noise
|
||||
was dominating). 5 are environmental rows Phase 16 also surfaced
|
||||
(BLE library version skew, timezone fragility, token refresh fixture
|
||||
interaction); none are v2 bridge defects. The categoriser hit rate is
|
||||
95.5 % (above the 95 % gate) — a `'sandbox': '<group>'` rule and a
|
||||
broadened `'created_at'`/`modified_at'` rule were added to
|
||||
`categorize_failures.py` so the new shapes don't drift into the
|
||||
`unknown` bucket. The `atag` `proxy-missing` and
|
||||
`dependencies-not-shared` rows Phase 16 surfaced **also vanished** —
|
||||
strong indication the original failures were autotag-fixture
|
||||
perturbation, not real bridge bugs.
|
||||
|
||||
Files added:
|
||||
- sandbox_v2/STATUS-phase-17.md (this file).
|
||||
|
||||
Files changed:
|
||||
- homeassistant/config_entries.py — added `ConfigEntry.sandbox: str | None`
|
||||
field (declaration, `__init__` kwarg, `_setter` call), included it in
|
||||
`UPDATE_ENTRY_CONFIG_ENTRY_ATTRS`, plumbed through `async_update_entry`
|
||||
/ `_async_update_entry` (matching the existing `discovery_keys` /
|
||||
`pref_disable_*` plumbing), wrote it to `as_dict()` only when non-None,
|
||||
read it from storage via `dict.get("sandbox")`, added the `sandbox`
|
||||
key to the `ConfigFlowResult` TypedDict, and consulted
|
||||
`result.get("sandbox")` at the entry-creation site in
|
||||
`ConfigEntriesFlowManager.async_finish_flow`.
|
||||
- homeassistant/components/sandbox_v2/router.py — replaced every
|
||||
`entry.data.get(SANDBOX_GROUP_KEY)` with `entry.sandbox`; payload
|
||||
builder no longer strips the tag from `data`.
|
||||
- homeassistant/components/sandbox_v2/proxy_flow.py — `_adapt_result`
|
||||
attaches `sandbox=<group>` to the `CREATE_ENTRY` `ConfigFlowResult`
|
||||
instead of mutating `entry_data`; module no longer exports
|
||||
`SANDBOX_GROUP_KEY` (deleted).
|
||||
- sandbox_v2/hass_client/hass_client/testing/_autotag.py — sets
|
||||
`entry.sandbox` via `object.__setattr__` instead of building a new
|
||||
`MappingProxyType` for `entry.data`; import of `SANDBOX_GROUP_KEY`
|
||||
removed.
|
||||
- sandbox_v2/categorize_failures.py — added two `test-only` rules:
|
||||
`+\s+'sandbox'\s*:\s*'(?:built-in|custom|main)'` for the new
|
||||
diagnostic-snapshot shape, and a broadened `'(?:created_at|modified_at)'`
|
||||
rule that catches both Syrupy diff form and pytest dict-diff form.
|
||||
- sandbox_v2/COMPAT.md — Phase 17 baseline numbers; rewrites the
|
||||
Status / Bucketed-triage / Recommendation sections; per-integration
|
||||
table refreshed (35/37 pass).
|
||||
- sandbox_v2/BACKLOG.md — Phase 17 categorised backlog; documents the
|
||||
Phase-16 → Phase-17 delta (552 failures closed), the two residual
|
||||
test-only sub-shapes, and the optional Phase 17b clock-pinning
|
||||
fixture that would mask the `'created_at'` drift if we choose to
|
||||
eat it on v2's side.
|
||||
- sandbox_v2/BACKLOG_FAILURES.json — regenerated by
|
||||
`categorize_failures.py` (107 `test-only`, 5 `unknown`).
|
||||
- sandbox_v2/COMPAT_FULL.md — regenerated by `run_compat_full.py`
|
||||
(711/807 pass, 99.67 % test pass rate).
|
||||
- sandbox_v2/COMPAT_FULL.csv — regenerated companion to
|
||||
`COMPAT_FULL.md`.
|
||||
- sandbox_v2/COMPAT.csv — regenerated by `run_compat.py` (Phase 15
|
||||
37-integration baseline).
|
||||
- sandbox_v2/COMPAT_LATEST.md — regenerated by `run_compat.py`.
|
||||
- sandbox_v2/plan.md — Phase 17 ticked complete with the per-checkbox
|
||||
summary block.
|
||||
- tests/common.py — `MockConfigEntry.__init__` picked up a `sandbox=`
|
||||
kwarg threaded through to `ConfigEntry.__init__` so tests can
|
||||
construct entries that route through the sandbox without going
|
||||
through `add_to_hass` + autotag.
|
||||
- tests/test_config_entries.py — 6 new Phase-17 tests
|
||||
(`test_sandbox_default_is_none_and_omitted_from_storage`,
|
||||
`test_sandbox_is_persisted_when_set`,
|
||||
`test_sandbox_round_trip_through_storage`,
|
||||
`test_sandbox_absent_from_storage_loads_as_none`,
|
||||
`test_async_update_entry_sets_sandbox`,
|
||||
`test_sandbox_cannot_be_set_directly`).
|
||||
- tests/components/sandbox_v2/test_router.py — uses `sandbox="built-in"`
|
||||
on `MockConfigEntry` and asserts `entry.data` is untouched on the
|
||||
wire payload.
|
||||
- tests/components/sandbox_v2/test_proxy_flow.py — asserts
|
||||
`entries[0].sandbox == "built-in"` and `entries[0].data ==
|
||||
{"host": "1.2.3.4"}` (no extra key).
|
||||
- tests/components/sandbox_v2/test_perf.py — uses `sandbox=DEFAULT_GROUP`
|
||||
on the perf-bench `MockConfigEntry`.
|
||||
- tests/components/sandbox_v2/test_phase14.py — uses `sandbox="built-in"`
|
||||
for the `async_unload` round-trip test.
|
||||
- tests/components/sandbox_v2/test_testing_plugins.py — renamed the
|
||||
autotag test to `test_autotag_sets_mock_config_entry_sandbox`,
|
||||
asserts `entry.sandbox == "built-in"` and `dict(entry.data) ==
|
||||
{"foo": "bar"}` (data untouched).
|
||||
|
||||
Core HA files modified (review surface):
|
||||
- homeassistant/config_entries.py — three places:
|
||||
- `ConfigEntry.sandbox` field (declaration `:395-ish`, `__init__`
|
||||
kwarg + `_setter` call, included in
|
||||
`UPDATE_ENTRY_CONFIG_ENTRY_ATTRS`).
|
||||
- `as_dict()` writes `sandbox` only when non-None; storage
|
||||
constructor reads via `dict.get("sandbox")`.
|
||||
- `ConfigFlowResult["sandbox"]` typed-dict key + one-line
|
||||
`result.get("sandbox")` read at the entry constructor in
|
||||
`ConfigEntriesFlowManager.async_finish_flow`.
|
||||
- `ConfigEntries.async_update_entry(entry, sandbox=)` accessor
|
||||
(matches existing `discovery_keys` / `pref_disable_*` shape).
|
||||
Each piece is intentional, small, and additive. Pre-existing
|
||||
storage payloads load unchanged (`sandbox` defaults to `None`); the
|
||||
on-disk shape grows by exactly one optional key when set. **No
|
||||
storage version bump.**
|
||||
|
||||
Test results:
|
||||
- `uv run pytest tests/components/sandbox_v2/ --no-cov -q` →
|
||||
**134 passed** (no regression from Phase 16's 134).
|
||||
- `cd sandbox_v2/hass_client && uv run pytest -q` →
|
||||
**51 passed** (no regression from Phase 16's 51).
|
||||
- `uv run pytest tests/test_config_entries.py --no-cov -q` →
|
||||
**389 passed, 4 snapshots passed** (6 new Phase-17 tests added;
|
||||
no regression to the existing 383).
|
||||
- Phase 15 baseline (`run_compat.py` over 37 integrations):
|
||||
**35/37 pass; 7 646/7 648 tests = 99.97 %** (up from 99.19 %).
|
||||
Two residual failures are diagnostic snapshots showing
|
||||
`+ 'sandbox': 'built-in'` in `entry.as_dict()` (snapshot pre-dates
|
||||
Phase 17).
|
||||
- Phase 16 full sweep (`run_compat_full.py` over 807 integrations at
|
||||
concurrency=6, ~12 min wall): **711/807 pass; 34 266/34 378 tests
|
||||
= 99.67 %** (up from 98.07 %). Categoriser hit rate 95.5 %
|
||||
(107 `test-only` / 5 `unknown`).
|
||||
- `uv run prek run --files <changed files>` → all hooks pass.
|
||||
|
||||
Things to flag for the next phase:
|
||||
|
||||
- **The v1-removal trigger Phase 15 set is now numerically
|
||||
satisfied.** Phase 15 STATUS said "v1 removal stays deferred until
|
||||
the autotag follow-up lands and a re-run clears ≥ 99.5 %." Both
|
||||
conditions hold (99.97 % curated, 99.67 % full sweep). The
|
||||
remaining gate Phase 11 attaches ("v2 has shipped at least one
|
||||
stable release") is a release-process step rather than a code
|
||||
change. v1 removal can be queued for the release after v2 first
|
||||
ships.
|
||||
- **The 30-ish residual `+ 'sandbox': 'built-in'` diagnostic snapshot
|
||||
diffs are integration-side**. They live in the integrations'
|
||||
`__snapshots__/` directories, not under `sandbox_v2/`. The right
|
||||
fix is `pytest --snapshot-update` per integration when the
|
||||
integration owner refreshes their diagnostic snapshots — or v2
|
||||
can land a clock-pinning fixture autouse on the compat plugin
|
||||
(~30 LOC, sketched in `BACKLOG.md` as optional Phase 17b) to mask
|
||||
the `'created_at'` drift that drives ~70 of the 112 failures
|
||||
without forcing every integration to adopt freezegun. Either is
|
||||
fine; neither blocks v1-removal.
|
||||
- **The `atag` `proxy-missing` and `dependencies-not-shared` rows
|
||||
vanished**. Phase 16 STATUS flagged atag as the microcosm of every
|
||||
remaining real-bug bucket; Phase 17 closed all of atag's flagged
|
||||
failures without touching `bridge.py` or the bridge-side
|
||||
coordinator path. That strongly suggests atag's previous failures
|
||||
were autotag-fixture perturbation rather than a real
|
||||
coordinator-shape bug. The same may be true of `azure_event_hub`'s
|
||||
`dependencies-not-shared` rows (also at 0 in Phase 17). Worth
|
||||
noting in BACKLOG.md if these come back.
|
||||
- **The `ConfigFlowResult["sandbox"]` extension is the smallest
|
||||
surface that works.** The plan called for
|
||||
`async_update_entry(entry, sandbox=)` "right after the framework
|
||||
creates the entry"; that path doesn't work because `async_add`
|
||||
invokes `async_setup` inside its own body before any after-hook
|
||||
fires. Adding the key to the flow-result TypedDict and reading it
|
||||
at the entry constructor is the natural shape — same plumbing as
|
||||
`minor_version`, `options`, `subentries`. Reviewers of the
|
||||
`config_entries.py` diff should expect to see four small additions
|
||||
(field declaration, `as_dict` write, storage read, flow-result key
|
||||
+ constructor read) plus `UPDATE_ENTRY_CONFIG_ENTRY_ATTRS` and the
|
||||
`async_update_entry` signature extension. No new method, no new
|
||||
abstraction.
|
||||
- **The `as_dict()` containing `sandbox`** is what produces the new
|
||||
`+ 'sandbox': 'built-in'` snapshot diffs. That's deliberate: in
|
||||
production a user inspecting diagnostics for an entry *should* see
|
||||
whether it's sandboxed. Suppressing the field from `as_dict()` (by
|
||||
serialising only in `as_storage_fragment`) would make compat
|
||||
snapshots pass cleanly but lose useful runtime info. The current
|
||||
trade-off matches the plan's "Persist via `as_storage_fragment()` /
|
||||
`as_dict()`" wording.
|
||||
- **`SANDBOX_GROUP_KEY` is fully gone**. Anything that still does
|
||||
`entry.data.get("__sandbox_group")` is wrong post-Phase-17 — grep
|
||||
the codebase before merging any v2-related change to make sure
|
||||
none has re-appeared.
|
||||
@@ -0,0 +1,112 @@
|
||||
Status: DONE
|
||||
|
||||
Phase 18 reconciles three stale docs with the post-Phase-17 reality and
|
||||
adds a single canonical "why we did each follow-up phase" history doc
|
||||
at `sandbox_v2/docs/FOLLOWUPS.md`. OVERVIEW.md, the directory-local
|
||||
CLAUDE.md, and README.md all carried Phase-5b / Phase-10b /
|
||||
`data_schema`-stripping / `unique_id`-non-propagation /
|
||||
concurrent-dispatcher-deadlock callouts that have since been closed by
|
||||
Phases 12–17. After the sweep, the genuinely-still-open list is:
|
||||
`share_states=True` subscription consumer (Phase 7's lone surviving
|
||||
deferral), v1 removal (numerically satisfied — release-process gate
|
||||
remains), diagnostic snapshot drift / clock pinning (test-side
|
||||
residuals — fix lives in integrations' trees or as optional Phase 17b),
|
||||
`calendar` / `todo` / `weather` query-shaped RPCs (no compat-sweep
|
||||
demand surfaced yet), and non-idempotent service handlers (v3 spec).
|
||||
BACKLOG.md needed no edit — Phase 17 already rewrote it as the
|
||||
post-`ConfigEntry.sandbox` categorised backlog with every named
|
||||
bridge bucket at zero.
|
||||
|
||||
**No Python code changes, no test changes, no core HA surface
|
||||
touched.** In-tree test counts unchanged from Phase 17 (134 HA-core
|
||||
sandbox_v2 + 51 hass_client).
|
||||
|
||||
Files added:
|
||||
- sandbox_v2/docs/FOLLOWUPS.md
|
||||
- sandbox_v2/STATUS-phase-18.md (this file)
|
||||
|
||||
Files changed:
|
||||
- sandbox_v2/OVERVIEW.md — top status block rewritten to reflect
|
||||
Phase 17; routing-rules section now references `entry.sandbox`
|
||||
instead of `__sandbox_group`; config-flow forwarding section
|
||||
documents the 3rd router call site (`async_unload_entry`) +
|
||||
`ConfigFlowResult["sandbox"]` write path; "What's deferred"
|
||||
subsection removed (both items closed by Phase 14) and replaced
|
||||
with a positive description of how marshalling works today;
|
||||
"Domains shipped" updated (all 32 now ship, Phase 14 perf
|
||||
benchmark callout); "Service & event mirroring" updated (schema
|
||||
bridge in the wire payload); "Test infrastructure" updated
|
||||
(baseline numbers + `run_compat_full.py` / `BACKLOG.md` lineage);
|
||||
"Where the design is still open" pruned to the genuinely-open
|
||||
items only, with a FOLLOWUPS.md pointer.
|
||||
- sandbox_v2/CLAUDE.md — "Read these first" updated to reflect
|
||||
Phases 0–17 and link FOLLOWUPS.md; "Core HA files modified"
|
||||
section folds in Phase 14's `async_unload_entry` hook and Phase
|
||||
17's `ConfigEntry.sandbox` field; "Open follow-ups (not yet
|
||||
shipped)" pruned to surviving items with FOLLOWUPS.md pointer.
|
||||
- sandbox_v2/README.md — "Status" block enumerates Phases 0–17
|
||||
(was Phase 0–11) and adds the FOLLOWUPS.md pointer. The plan only
|
||||
named OVERVIEW.md and CLAUDE.md explicitly but the README's
|
||||
status block had drifted identically and would have pointed new
|
||||
readers at "Phase 5b deferred" / "Phase 10b deferred" for items
|
||||
that have since landed. Scope extension is small and stays
|
||||
squarely inside the spirit of Phase 18 (docs reconciliation only).
|
||||
- sandbox_v2/plan.md — Phase 18 marked complete with the per-checkbox
|
||||
summary block.
|
||||
|
||||
Core HA files modified (review surface):
|
||||
None.
|
||||
|
||||
Test results:
|
||||
- `uv run pytest tests/components/sandbox_v2/ --no-cov -q` →
|
||||
**134 passed** (no change from Phase 17's 134 — docs-only phase).
|
||||
- `uv run pytest /home/paulus/dev/hass/core/sandbox_v2/hass_client/ -q`
|
||||
→ **51 passed** (no change from Phase 17's 51).
|
||||
- `prek run --files sandbox_v2/OVERVIEW.md sandbox_v2/CLAUDE.md sandbox_v2/README.md sandbox_v2/docs/FOLLOWUPS.md`
|
||||
→ codespell + prettier passed; all Python-specific hooks correctly
|
||||
skipped (no Python files in the change set).
|
||||
- `grep -rn '__sandbox_group\|SANDBOX_GROUP_KEY' sandbox_v2/ homeassistant/components/sandbox_v2/ tests/components/sandbox_v2/`
|
||||
→ no code-path matches. All matches are in STATUS / BACKLOG /
|
||||
plan.md / COMPAT.md / FOLLOWUPS.md / `generate_backlog.py`'s
|
||||
historical-shape string literal — all narrative or auto-draft text,
|
||||
not live code.
|
||||
|
||||
Things to flag for the next phase:
|
||||
|
||||
- **The "v2 has shipped at least one stable release" gate is now the
|
||||
only thing standing between today's tree and v1 removal.** That's
|
||||
not a code change — it's a release-process step. The numeric gate
|
||||
(Phase 11 attached it to "match v1's compat numbers") cleared on
|
||||
Phase 17 (99.67 % full sweep, 99.97 % v1 baseline; thresholds were
|
||||
99.5 %). When v2 ships in a stable release, the next-cycle PR can
|
||||
delete `sandbox/` and `homeassistant/components/sandbox/` along
|
||||
with the v1-only references in CLAUDE.md, the v1-vs-v2 comparison
|
||||
table in OVERVIEW.md, and the dual-tracker behaviour noted in
|
||||
CLAUDE.md's preamble.
|
||||
- **FOLLOWUPS.md's "Still open" list and CLAUDE.md's "Open follow-
|
||||
ups" section say the same thing in the same order.** Intentional —
|
||||
they're the same source of truth, surfaced in two places (Claude
|
||||
loads CLAUDE.md, humans read FOLLOWUPS.md). If a new item closes
|
||||
or a new deferral opens, update both. A future docs-tightening pass
|
||||
could swap one for an inclusion of the other, but for now mirrored
|
||||
text is clearer than a `>>> include` directive that an editor might
|
||||
miss.
|
||||
- **README.md was updated despite not being in the plan's explicit
|
||||
checklist.** Called out in the plan changes (and in this STATUS) so
|
||||
a reviewer expecting "OVERVIEW.md + CLAUDE.md + FOLLOWUPS.md only"
|
||||
sees the README diff and the reason. The plan said "If any other
|
||||
section references closed items, update it" for OVERVIEW.md; the
|
||||
README's "Status" block was the same shape, so it falls under the
|
||||
same rule even though the plan said "OVERVIEW.md" specifically.
|
||||
- **BACKLOG.md needed no edit.** Phase 17 already rewrote it with the
|
||||
Phase-17 categorised numbers, the "test-only / __sandbox_group on
|
||||
entry.data" bucket replaced by the residual sub-shapes
|
||||
(`+ 'sandbox': 'built-in'` diagnostic snapshots + `'created_at'`
|
||||
drift), and every named bridge bucket at zero. The plan's
|
||||
"verify Phase 17 closed it" step confirmed the STATUS-phase-17
|
||||
claim is accurate.
|
||||
- **No new historical-narrative `__sandbox_group` matches were
|
||||
introduced.** FOLLOWUPS.md mentions the string in three places (all
|
||||
in the Phase 15 / 16 / 17 narrative sections describing what the
|
||||
autotag *did*), all in markdown prose with backticks — narrative
|
||||
references, not code references. The grep verification passes.
|
||||
@@ -0,0 +1,136 @@
|
||||
Status: DONE
|
||||
|
||||
Phase 19 wires device-registry bridging onto the existing
|
||||
`sandbox_v2/register_entity` round-trip. Sandboxed entities that carry
|
||||
`device_info` now produce a matching `DeviceEntry` in main's
|
||||
`device_registry`, the entity_registry row links to it via `device_id`,
|
||||
and area assignment works identically to a locally-running integration
|
||||
(the proxy reads its area through HA's standard device → entity
|
||||
inheritance path). Sandbox-side, `hass_client.entity_bridge` adds
|
||||
`_serialise_device_info`, which flattens the `DeviceInfo` TypedDict's
|
||||
set/tuple/enum shapes into JSON: `identifiers`/`connections` become
|
||||
lists of two-element lists, `via_device` becomes a list, `entry_type`
|
||||
becomes its `StrEnum` `.value`, and `configuration_url` becomes a
|
||||
string. Main-side, `SandboxEntityDescription.from_payload` runs
|
||||
`_deserialise_device_info` to rebuild the typed shapes, then
|
||||
`_handle_register_entity` calls
|
||||
`dr.async_get_or_create(config_entry_id=description.entry_id,
|
||||
**device_info)` once up front so the proxy carries a known `device_id`.
|
||||
The proxy then sets `_attr_device_info` so
|
||||
`EntityPlatform.async_add_entities`' standard path reuses the same
|
||||
`DeviceEntry` (idempotent on `(identifiers, connections)`) and pins
|
||||
`entity.device_entry` on the proxy.
|
||||
|
||||
**No new core HA changes** — Phase 5's `async_register_remote_platform`
|
||||
hook plus `device_registry`'s public API cover the whole bridge. The
|
||||
unregister path needs no change either: HA already leaves `DeviceEntry`s
|
||||
in place until the owning entry unloads, and the existing
|
||||
`_handle_unregister_entity` only touches the entity_registry / state
|
||||
machine.
|
||||
|
||||
Files added:
|
||||
- sandbox_v2/STATUS-phase-19.md (this file)
|
||||
- tests/components/sandbox_v2/test_phase19_devices.py — six tests
|
||||
covering DeviceEntry creation + entry-id linkage, proxy `device_id`
|
||||
propagation, backwards-compat with payloads that omit `device_info`,
|
||||
area assignment surfacing through the standard HA path, invalid
|
||||
`device_info` rejection as a `ChannelRemoteError`, and the
|
||||
payload-shape round-trip through `SandboxEntityDescription.from_payload`.
|
||||
|
||||
Files changed:
|
||||
- sandbox_v2/hass_client/hass_client/entity_bridge.py — new
|
||||
`_serialise_device_info` helper; `_describe_entity` now appends a
|
||||
`device_info` key to the wire payload when the entity exposes one.
|
||||
- homeassistant/components/sandbox_v2/bridge.py — imports
|
||||
`device_registry as dr`; `SandboxEntityDescription` gains
|
||||
`device_info`/`device_id` fields; `from_payload` runs the new
|
||||
`_deserialise_device_info` helper; `_handle_register_entity`
|
||||
pre-creates the `DeviceEntry` via
|
||||
`dr.async_get_or_create(config_entry_id=..., **device_info)` and
|
||||
pins the returned `device.id` on the description; `DeviceInfoError`
|
||||
is mapped to `HomeAssistantError` so it surfaces as a remote-error
|
||||
frame back to the sandbox.
|
||||
- homeassistant/components/sandbox_v2/entity/__init__.py — proxy base
|
||||
sets `_attr_device_info` from the description so
|
||||
`EntityPlatform.async_add_entities`' framework path re-runs
|
||||
`async_get_or_create` (idempotent) and wires `entity.device_entry`.
|
||||
- homeassistant/components/sandbox_v2/protocol.py — module docstring
|
||||
updated to document the new `device_info` key in `MSG_REGISTER_ENTITY`.
|
||||
The sandbox-side `protocol.py` is a constants-only mirror and points
|
||||
at the HA-side file for the catalogue; no edit needed there.
|
||||
- sandbox_v2/hass_client/tests/test_entity_bridge.py — three new tests:
|
||||
`_serialise_device_info` flattens sets/tuples/enums; the same helper
|
||||
short-circuits empty/None input; and an end-to-end EntityBridge run
|
||||
with a `_DeviceEntity` confirms the `device_info` key lands in the
|
||||
outbound `register_entity` payload.
|
||||
- sandbox_v2/plan.md — Phase 19 marked complete with per-checkbox
|
||||
status (deferral note on the compat-sweep regression).
|
||||
|
||||
Core HA files modified (review surface):
|
||||
None.
|
||||
|
||||
Test results:
|
||||
- `uv run pytest tests/components/sandbox_v2/ --no-cov -q` →
|
||||
**140 passed** (134 baseline + 6 new Phase 19 tests).
|
||||
- `uv run pytest /home/paulus/dev/hass/core/sandbox_v2/hass_client/ -q`
|
||||
→ **54 passed** (51 baseline + 3 new entity_bridge tests).
|
||||
- `uv run pytest tests/helpers/test_device_registry.py --no-cov -q` →
|
||||
**151 passed** (the bridge only consumes `dr.async_get_or_create` /
|
||||
`dr.async_get`'s public API; no core regression).
|
||||
- `uv run prek run --files homeassistant/components/sandbox_v2/bridge.py
|
||||
homeassistant/components/sandbox_v2/entity/__init__.py
|
||||
homeassistant/components/sandbox_v2/protocol.py
|
||||
sandbox_v2/hass_client/hass_client/entity_bridge.py
|
||||
sandbox_v2/hass_client/tests/test_entity_bridge.py
|
||||
tests/components/sandbox_v2/test_phase19_devices.py` — ruff-check,
|
||||
ruff-format, codespell, mypy, pylint, prettier all green.
|
||||
|
||||
Things to flag for the next phase:
|
||||
|
||||
- **The compat-sweep regression run is deferred to a future
|
||||
`run_compat_full.py` pass.** The in-process plugin tests exercise
|
||||
the same end-to-end chain (`register_entity` → `dr.async_get_or_create`
|
||||
→ entity_registry `device_id`) against a real `HomeAssistant`, so
|
||||
the unit coverage matches what a one-integration slice would
|
||||
validate. Re-running the full sweep is worth bundling with Phase 20
|
||||
(share_states cleanup) since both want a refreshed `COMPAT_FULL.md`.
|
||||
The expected delta from Phase 19 is "previously-empty device_registry
|
||||
for sandboxed integrations now mirrors the sandbox-side devices" —
|
||||
no failure shape change, so the categorised buckets should hold.
|
||||
|
||||
- **`OVERVIEW.md` / `CLAUDE.md` / `docs/FOLLOWUPS.md` reference the
|
||||
Phase 19 spec by name in their "Open follow-ups" sections.** Update
|
||||
those entries when Phase 20 lands its docs reconciliation so the
|
||||
surviving-list shrinks to just "share_states subscription consumer"
|
||||
+ "v1 removal release-process step" + the residuals (snapshot drift,
|
||||
`calendar`/`todo`/`weather` queries, non-idempotent service
|
||||
handlers). Phase 19 itself didn't sweep the docs — keeping it
|
||||
focused on the code change keeps the diff easy to review; Phase 20
|
||||
is the natural next docs touch.
|
||||
|
||||
- **Per-domain proxy classes do not need an update.** The proxy base
|
||||
class (`SandboxProxyEntity`) is where `_attr_device_info` is now
|
||||
set, so all 32 domain proxies inherit the behaviour without a
|
||||
per-domain edit. The Phase 13 smoke tests (which exercise every
|
||||
proxy through register → state push → method invocation) still
|
||||
pass — confirming none of the per-domain subclasses override
|
||||
`__init__` in a way that would shadow the base's device_info wiring.
|
||||
|
||||
- **`device_info` mutations after the initial register are not yet
|
||||
bridged.** The Phase 5 STATUS already flagged that the
|
||||
`sandbox_v2/update_entity` capability-delta channel is deferred,
|
||||
and Phase 19 inherits that limitation: if an integration mutates
|
||||
`device_info` after the entity's first `async_write_ha_state`, the
|
||||
change won't propagate to main. The fix shape — re-register the
|
||||
entity to push the updated description — already works (the bridge
|
||||
treats a re-register as an upsert via `dr.async_get_or_create`),
|
||||
but most integrations build `device_info` once at construction
|
||||
time, so this hasn't bitten yet.
|
||||
|
||||
- **The `device_info` payload is a small wire-size addition.** A
|
||||
typical entry with `identifiers`, `name`, `manufacturer`, `model`
|
||||
adds ~100-200 bytes to the `register_entity` call. Bulk
|
||||
registrations from a hub-style integration with 50+ devices will
|
||||
feel this; not a regression vs the framework path, but worth
|
||||
watching if the in-process plugin's throughput tests ever surface
|
||||
a slowdown.
|
||||
@@ -0,0 +1,120 @@
|
||||
Status: DONE
|
||||
|
||||
Phase 20 deletes the unwired Phase 7 sharing surface and replaces it
|
||||
with a design doc. The Phase 7 plan called for `SharingConfig` on the
|
||||
runtime + `SandboxGroupConfig` on the manager + `--share-states` /
|
||||
`--share-entity-registry` / `--share-areas` CLI flags +
|
||||
`DEFAULT_GROUP_CONFIGS` defaults so a future subscription consumer
|
||||
could hang off them. The consumer never landed, so the config was
|
||||
~40 LOC of dead surface across 5 files plus an entire test module
|
||||
(`test_sharing_config.py`, 7 tests). Carrying unwired flags risks
|
||||
readers assuming functionality that isn't there — Phase 16's
|
||||
classification work already had to call this out specifically. Phase
|
||||
20 removes the surface and replaces it with
|
||||
`sandbox_v2/docs/design-share-states.md`, a focused design that
|
||||
captures the entity_id-alignment constraint (the genuinely tricky
|
||||
piece), the `share/subscribe_*` protocol shape, per-sandbox
|
||||
allow-list filtering on main's send-side, and the still-open
|
||||
questions (one-way vs bidirectional, read-only mirror semantics,
|
||||
device + area registries as a follow-on to Phase 19, fan-out
|
||||
performance). `OVERVIEW.md`, `CLAUDE.md`, `docs/FOLLOWUPS.md`, and
|
||||
`generate_backlog.py`'s `dependencies-not-shared` bucket description
|
||||
all repoint at the new design doc instead of just naming the
|
||||
deferral. The locked-down posture — sandbox sees only its own
|
||||
entities/services/events — was never really "behind" the flags; it
|
||||
was the default-off behaviour of code that never existed. That stays
|
||||
unchanged.
|
||||
|
||||
**No core HA files touched.**
|
||||
|
||||
Files added:
|
||||
- sandbox_v2/STATUS-phase-20.md (this file)
|
||||
- sandbox_v2/docs/design-share-states.md — design for the post-v2
|
||||
state-sharing consumer: goal, entity_id alignment constraint,
|
||||
`share/subscribe_*` protocol mechanism, main-side filtering,
|
||||
open questions (direction / write-through / device-area / fan-out),
|
||||
non-goals, why-now link to v1 limitation, files-it-will-touch
|
||||
preview.
|
||||
|
||||
Files changed:
|
||||
- sandbox_v2/hass_client/hass_client/sandbox.py — drop the
|
||||
`SharingConfig` dataclass + `dataclass` import + `__all__` entry;
|
||||
drop the `sharing=` constructor param and the `self.sharing`
|
||||
assignment from `SandboxRuntime`.
|
||||
- sandbox_v2/hass_client/hass_client/sandbox_v2/__main__.py — drop
|
||||
the three `--share-*` argparser entries and the `SharingConfig(...)`
|
||||
call in `SandboxRuntime(...)` construction.
|
||||
- homeassistant/components/sandbox_v2/manager.py — drop the
|
||||
`SandboxGroupConfig` dataclass, `DEFAULT_GROUP_CONFIGS` map,
|
||||
`group_configs=` constructor param, `_group_configs` dict, the
|
||||
`group_config(group)` accessor, and the three `--share-*` argv
|
||||
branches in `_default_command`. Drop the matching `__all__` entries.
|
||||
- sandbox_v2/hass_client/hass_client/testing/pytest_plugin.py — drop
|
||||
the `SharingConfig` import and the `sharing=` parameter from
|
||||
`async_setup_inprocess_sandbox`.
|
||||
- tests/components/sandbox_v2/test_manager.py — drop the imports of
|
||||
`DEFAULT_GROUP_CONFIGS` / `SandboxGroupConfig`, the two
|
||||
`group_config` tests, and the `--share-*` argv assertions. Keep
|
||||
the token-factory test, narrowed to just assert the token + group
|
||||
end up in argv.
|
||||
- sandbox_v2/hass_client/tests/test_sandbox_runtime.py — drop the
|
||||
`runtime.sharing.share_*` assertions from
|
||||
`test_runtime_starts_in_locked_down_sharing_posture`; the test
|
||||
docstring now describes the locked-down posture as a property of
|
||||
the runtime itself and links to the design doc.
|
||||
- homeassistant/components/sandbox_v2/auth.py — module docstring
|
||||
bullet about `share_states=True` repointed at the new design doc.
|
||||
- sandbox_v2/generate_backlog.py — `dependencies-not-shared` bucket
|
||||
description repointed at the design doc instead of CLAUDE.md's
|
||||
"Open follow-ups" line.
|
||||
- sandbox_v2/OVERVIEW.md — status callout, "How v2 differs from v1"
|
||||
table row, "Three sandbox groups ship out of the box" table, the
|
||||
argv example, the `Scoped auth & opt-in data sharing` section, and
|
||||
the "Future work" bullet all updated to reference the design doc
|
||||
instead of the deleted flags.
|
||||
- sandbox_v2/CLAUDE.md — "Read these first" entry for plan.md updated
|
||||
for Phase 20, new entry for `docs/design-share-states.md`, "Open
|
||||
follow-ups" share_states entry rewritten.
|
||||
- sandbox_v2/docs/FOLLOWUPS.md — "Still open" share_states entry
|
||||
rewritten to point at the design doc.
|
||||
- sandbox_v2/plan.md — Phase 20 ticked complete with inline summary.
|
||||
|
||||
Files removed:
|
||||
- sandbox_v2/hass_client/tests/test_sharing_config.py — whole file
|
||||
(7 tests covering `SharingConfig` parsing, defaults, and runtime
|
||||
assignment).
|
||||
|
||||
Core HA files modified (review surface):
|
||||
None.
|
||||
|
||||
Test results:
|
||||
- `uv run pytest tests/components/sandbox_v2/ --no-cov -q` → 138 passed
|
||||
(down from 140; the two dropped tests are
|
||||
`test_default_group_config_posture` and
|
||||
`test_group_config_override`; `test_default_command_includes_token_and_share_flags`
|
||||
was narrowed to `test_default_command_includes_token` covering the
|
||||
surviving token-factory behaviour).
|
||||
- `uv run pytest /home/paulus/dev/hass/core/sandbox_v2/hass_client/ -q`
|
||||
→ 47 passed (down from 54; the seven dropped tests are the whole
|
||||
of `test_sharing_config.py`).
|
||||
- `grep -rn 'SharingConfig\|SandboxGroupConfig\|share_states\|share_entity_registry\|share_areas\|--share-' sandbox_v2/hass_client/hass_client/ homeassistant/components/sandbox_v2/ tests/components/sandbox_v2/`
|
||||
→ no matches.
|
||||
- `uv run prek run --files <changed>` → ruff + ruff-format + mypy +
|
||||
pylint + prettier all pass. Codespell flags one pre-existing
|
||||
`reuses` on `plan.md:1278` (Phase 19 prose, not touched by this
|
||||
PR); leaving it alone since it's outside Phase 20's scope.
|
||||
|
||||
Things to flag for the next phase:
|
||||
- The design doc is the contract for the future state-sharing
|
||||
consumer. The implementation will need: a `share` namespace
|
||||
websocket handler on main (3 subscribe commands), a sandbox-side
|
||||
consumer module, the `share/subscribe` exact-match scope added to
|
||||
`SANDBOX_TOKEN_SCOPES`, and a per-sandbox allow-list (the
|
||||
reintroduced equivalent of `SandboxGroupConfig`, but this time
|
||||
wired). Whichever phase picks this up should drive its config
|
||||
shape from real consumer needs rather than re-introducing the
|
||||
Phase 7 defaults verbatim.
|
||||
- v1 removal is unaffected — Phase 17 already cleared the numeric
|
||||
gate, and Phase 20's surface deletion is independent of that. The
|
||||
remaining condition is still "v2 has shipped at least one stable
|
||||
release."
|
||||
@@ -0,0 +1,70 @@
|
||||
Status: DONE
|
||||
|
||||
Phase 3 delivers the sandbox lifecycle layer: `SandboxManager` on the HA
|
||||
Core side owns a `dict[str, SandboxProcess]` keyed by group name and
|
||||
spawns each group lazily through `ensure_started`. Each `SandboxProcess`
|
||||
runs an asyncio supervisor task that launches
|
||||
`python -m hass_client.sandbox_v2 --group … --url … --token …`, reads
|
||||
stdout for the `sandbox_v2:ready` marker, and watches the process for
|
||||
unexpected exits. Restart-on-crash is bounded to 3 attempts in a 60s
|
||||
sliding window with a small backoff sleep between attempts; exceeding
|
||||
the budget transitions the sandbox to `failed` and `ensure_started`
|
||||
raises `SandboxFailedError` so Phase 4 callers can push affected
|
||||
entries to `setup_retry`. The client-side `SandboxRuntime` is the
|
||||
Phase 3 stub described in the prompt — it parses CLI args, prints the
|
||||
ready marker on stdout, and waits for SIGTERM/SIGINT (or an in-process
|
||||
`request_shutdown()` call) before returning 0. The runtime is launched
|
||||
as a real subprocess; the Phase 4 websocket transport is the next
|
||||
piece to plug in.
|
||||
|
||||
Files added:
|
||||
- `homeassistant/components/sandbox_v2/manager.py`
|
||||
- `sandbox_v2/hass_client/hass_client/sandbox.py`
|
||||
- `sandbox_v2/hass_client/hass_client/sandbox_v2/__init__.py`
|
||||
- `sandbox_v2/hass_client/hass_client/sandbox_v2/__main__.py`
|
||||
- `tests/components/sandbox_v2/test_manager.py`
|
||||
- `sandbox_v2/hass_client/tests/__init__.py`
|
||||
- `sandbox_v2/hass_client/tests/test_sandbox_runtime.py`
|
||||
|
||||
Files changed:
|
||||
- `sandbox_v2/plan.md` — Phase 3 section marked complete with summary;
|
||||
health-protocol items left unchecked with a deferral note.
|
||||
|
||||
Test results:
|
||||
- `uv run pytest tests/components/sandbox_v2/ --no-cov -q` → **28 passed**
|
||||
(3 new manager tests + the 25 existing Phase 0/1/2 tests).
|
||||
- `cd sandbox_v2/hass_client && uv run pytest -q` → **3 passed**
|
||||
(ready-marker constant, CLI parser, runtime shutdown).
|
||||
- `uv run prek run --files <changed files>` → all hooks pass
|
||||
(ruff-check, ruff-format, mypy, pylint, codespell, prettier).
|
||||
|
||||
Things to flag for the next phase:
|
||||
|
||||
- The `sandbox_v2/ping` health protocol checkbox is intentionally left
|
||||
unchecked. Phase 3's prompt scoped the websocket transport out, and
|
||||
the ping round-trip belongs with that transport. Process-exit
|
||||
detection in `SandboxProcess._supervise` covers the "hard crash"
|
||||
flavour of unhealthiness in the meantime — Phase 4 needs to add the
|
||||
ping handler on top.
|
||||
- `SandboxManager._default_command` ships with placeholder `--url` and
|
||||
`--token` values (`ws://localhost:8123/api/websocket`,
|
||||
`sandbox_v2_placeholder`). The runtime accepts but does not yet use
|
||||
them — Phase 4 wires the real auth flow (the scoped sandbox token is
|
||||
Phase 7 work, but Phase 4 needs at least a working long-lived token
|
||||
to bootstrap).
|
||||
- `SandboxManager` is not yet hooked into `async_setup` /
|
||||
`EVENT_HOMEASSISTANT_STOP`. Tests clean up explicitly with
|
||||
`async_stop_all`; Phase 4 will mount the manager on
|
||||
`SandboxV2Data.manager` and register the stop listener so production
|
||||
HA shuts down sandboxes cleanly.
|
||||
- `READY_MARKER` is duplicated between
|
||||
`homeassistant/components/sandbox_v2/manager.py` and
|
||||
`hass_client/sandbox.py` (with cross-referencing comments) rather
|
||||
than imported across the package boundary. This avoids HA Core
|
||||
importing from `hass_client` at integration-load time. If Phase 4
|
||||
ends up sharing more protocol constants, consolidating them into a
|
||||
small shared module is worth considering.
|
||||
- The `from __future__ import annotations` lines that ruff rewrote out
|
||||
of every new file are noted only because the surrounding sandbox_v2
|
||||
files do still carry them — the existing Phase 0/2 files predate the
|
||||
TID251 rule and may want a follow-up sweep. Not blocking.
|
||||
@@ -0,0 +1,123 @@
|
||||
Status: DONE
|
||||
|
||||
Phase 4 delivers the "perfect flow" end-to-end. The HA Core
|
||||
`ConfigEntries` gains a single `router` attribute consulted from
|
||||
`ConfigEntriesFlowManager.async_create_flow` and `ConfigEntries.async_setup`;
|
||||
sandbox_v2 plugs in a `SandboxFlowRouter` that hands sandbox-bound flows
|
||||
to a `SandboxFlowProxy` `ConfigFlow` and intercepts setup of entries
|
||||
tagged `__sandbox_group`. Manager and runtime now share a JSON-line
|
||||
`Channel` over the subprocess's stdin/stdout (post-marker); the sandbox
|
||||
runtime hosts a private `HomeAssistant` and a `FlowRunner` that drives
|
||||
the real integration's `ConfigFlow` inside a `_SandboxFlowManager` (a
|
||||
`ConfigEntriesFlowManager` subclass that short-circuits CREATE_ENTRY so
|
||||
the sandbox never tries to add an entry to its private store — main is
|
||||
the canonical owner). FlowResults are marshalled by stripping the live
|
||||
`data_schema` (Phase 5 work) and copying a known safe-fields list; the
|
||||
proxy re-issues `async_show_form` / `async_create_entry` /
|
||||
`async_abort` so the framework treats the result as native.
|
||||
`__getattribute__` (not `__getattr__`) intercepts every `async_step_*`
|
||||
because ConfigFlow declares several step methods at the class level.
|
||||
|
||||
Files added:
|
||||
- `homeassistant/components/sandbox_v2/channel.py`
|
||||
- `homeassistant/components/sandbox_v2/proxy_flow.py`
|
||||
- `homeassistant/components/sandbox_v2/router.py`
|
||||
- `sandbox_v2/hass_client/hass_client/channel.py`
|
||||
- `sandbox_v2/hass_client/hass_client/flow_runner.py`
|
||||
- `sandbox_v2/hass_client/tests/test_flow_runner.py`
|
||||
- `tests/components/sandbox_v2/_helpers.py`
|
||||
- `tests/components/sandbox_v2/test_channel.py`
|
||||
- `tests/components/sandbox_v2/test_phase4_subprocess.py`
|
||||
- `tests/components/sandbox_v2/test_proxy_flow.py`
|
||||
- `tests/components/sandbox_v2/test_router.py`
|
||||
|
||||
Files changed:
|
||||
- `homeassistant/components/sandbox_v2/__init__.py` — wire the manager
|
||||
and router into `async_setup`; register `EVENT_HOMEASSISTANT_STOP`
|
||||
cleanup; expose `SandboxV2Data { manager, router, channels }`.
|
||||
- `homeassistant/components/sandbox_v2/manager.py` — `SandboxProcess`
|
||||
now opens a `Channel` over the subprocess pipes after the ready
|
||||
marker, exposes `process.channel`, and invokes an
|
||||
`on_channel_ready(group, channel)` callback so the router can wire
|
||||
per-sandbox handlers. `SandboxManager.__init__` accepts the callback;
|
||||
subprocess spawn now requests `stdin=PIPE`.
|
||||
- `sandbox_v2/hass_client/hass_client/sandbox.py` — Phase 3 stub
|
||||
upgraded to Phase 4: builds a `FlowRunner` against a private
|
||||
`HomeAssistant` (with a temp config_dir if none provided), prints the
|
||||
ready marker, then opens a stdio `Channel`, registers
|
||||
`sandbox_v2/ping` + the flow handlers, and runs until shutdown. New
|
||||
`channel_factory` constructor parameter lets tests skip the stdio
|
||||
channel (pytest captures stdin).
|
||||
- `sandbox_v2/hass_client/tests/test_sandbox_runtime.py` — Phase 3
|
||||
shutdown test now passes a noop channel factory; the real stdio path
|
||||
is covered by the new HA-core subprocess test.
|
||||
- `tests/components/sandbox_v2/test_init.py` — assertions updated for
|
||||
the new `SandboxV2Data` shape and the router registration.
|
||||
- `sandbox_v2/plan.md` — Phase 4 section marked complete with summary
|
||||
and inline notes on deferrals.
|
||||
|
||||
Core HA files modified (review surface):
|
||||
- `homeassistant/config_entries.py` — 1 new attribute
|
||||
`ConfigEntries.router: ConfigEntryRouter | None`, plus the
|
||||
`ConfigEntryRouter` `Protocol` defining its two methods. Call sites:
|
||||
`ConfigEntriesFlowManager.async_create_flow` (consults
|
||||
`async_create_flow`) and `ConfigEntries.async_setup` (consults
|
||||
`async_setup_entry`). The plan called for both intercept points; both
|
||||
consult the same attribute so the surface stays minimal. Iron Law:
|
||||
no monkey-patching of private internals.
|
||||
|
||||
Test results:
|
||||
- `uv run pytest tests/components/sandbox_v2/ --no-cov -q` → **43 passed**
|
||||
(28 from Phase 0–3 + 15 new: 5 channel, 6 router, 3 proxy flow, 1
|
||||
subprocess e2e).
|
||||
- `cd sandbox_v2/hass_client && uv run pytest -q` → **7 passed** (3
|
||||
from Phase 3 + 4 new: flow runner init / step / errors / abort).
|
||||
- `uv run pytest tests/test_config_entries.py --no-cov -q` → **383
|
||||
passed, 4 snapshots passed** — the core hook is benign when no router
|
||||
is installed.
|
||||
- `uv run prek run --files <17 changed files>` → all hooks pass
|
||||
(ruff-check, ruff-format, codespell, mypy, pylint, prettier).
|
||||
|
||||
Things to flag for the next phase:
|
||||
|
||||
- **`async_setup_entry` is a Phase-4 stub.** The router currently marks
|
||||
a sandboxed entry LOADED as soon as the sandbox process starts. Phase
|
||||
5 needs to replace this with a real round-trip — push the entry's
|
||||
domain/data/options/version to the sandbox, have the sandbox load the
|
||||
integration and call `async_setup_entry` against the proxied entry,
|
||||
and return success/failure to the router. The hook point is
|
||||
`SandboxFlowRouter.async_setup_entry` in `router.py`.
|
||||
- **`data_schema` is stripped on the wire.** The FlowRunner sets
|
||||
`_has_data_schema: True` when it stripped a schema, and the proxy
|
||||
logs a debug message when it sees that flag. Phase 5 must add a
|
||||
serialised-schema bridge (voluptuous_serialize.convert on the
|
||||
sandbox side, a tiny wrapper that voluptuous_serialize can re-emit on
|
||||
main) so the frontend actually renders forms for sandboxed flows.
|
||||
- **`unique_id` is not propagated from sandbox to main.** When a
|
||||
sandboxed flow calls `self.async_set_unique_id(...)`, the unique_id
|
||||
lives in the sandbox's `flow.context` but is never reflected onto the
|
||||
proxy's `flow.context`. The framework's duplicate detection on main
|
||||
will miss this. Phase 4 only exercises flows without unique_id;
|
||||
Phase 5 should include `flow.context["unique_id"]` in every
|
||||
marshalled result and apply it to the proxy.
|
||||
- **Periodic ping loop is still not running.** The `sandbox_v2/ping`
|
||||
handler exists and is exercised by the subprocess test, but nothing
|
||||
drives it on a timer. A 30-second loop in `SandboxManager` (or a
|
||||
per-process watchdog task) is the next ergonomic improvement once
|
||||
there are real production-leaning paths.
|
||||
- **`SandboxFlowProxy.async_remove`'s fire-and-forget abort task.** It
|
||||
stashes the task in a module-level `_BACKGROUND_ABORTS` set to keep
|
||||
the GC away from it; the alternative (a per-manager set) was a layer
|
||||
of indirection that pylint didn't love and Phase 5 doesn't yet need.
|
||||
If Phase 5 grows additional background tasks, hoisting both onto the
|
||||
manager makes sense.
|
||||
- **`ConfigEntryRouter` `Protocol` lives in `config_entries.py`.** It
|
||||
is not exported via `homeassistant.config_entries`'s `__all__` (the
|
||||
file has no `__all__`). Phase 5+ may want to make the contract more
|
||||
prominent — for now `SandboxFlowRouter` documents the structural
|
||||
conformance in its docstring rather than inheriting from the
|
||||
Protocol class (to avoid coupling at import time).
|
||||
- **The `ignore_translations_for_mock_domains` fixture in
|
||||
`test_proxy_flow.py`** is a workaround for the conftest's
|
||||
translation-validation step against `mock_integration` domains.
|
||||
Phase 5's tests that use real integrations won't need it.
|
||||
@@ -0,0 +1,147 @@
|
||||
Status: DONE
|
||||
|
||||
Phase 5 wires the entity bridge end-to-end. The sandbox runtime now
|
||||
hosts an `EntryRunner` that rebuilds a `ConfigEntry` from the
|
||||
`sandbox_v2/entry_setup` payload, drops it into the sandbox's
|
||||
`ConfigEntries`, and runs the integration's `async_setup_entry` against
|
||||
the sandbox-private `HomeAssistant`. The sandbox's `EntityBridge`
|
||||
listens for `EVENT_STATE_CHANGED` and pushes `sandbox_v2/register_entity`
|
||||
(first appearance) and `sandbox_v2/state_changed` (subsequent updates)
|
||||
to main. On main, `SandboxBridge` instantiates a domain-specific proxy
|
||||
entity from `homeassistant/components/sandbox_v2/entity/` and attaches
|
||||
it to the matching `EntityComponent` via the new
|
||||
`EntityComponent.async_register_remote_platform` core hook. Proxy
|
||||
service methods (e.g., `light.async_turn_on`) translate into
|
||||
`sandbox_v2/call_service` RPCs via a per-loop-tick batcher that
|
||||
coalesces matching `(domain, service, service_data)` calls into one
|
||||
multi-entity RPC. An exception translator maps `vol.Invalid` from the
|
||||
sandbox's schema layer back to `TypeError` so callers see the
|
||||
local-entity error shape. Phase 4's LOADED stub is replaced — the
|
||||
router now actually awaits the round-trip and surfaces `SETUP_ERROR` or
|
||||
`SETUP_RETRY` on failure.
|
||||
|
||||
Files added:
|
||||
- `homeassistant/components/sandbox_v2/bridge.py`
|
||||
- `homeassistant/components/sandbox_v2/entity/__init__.py`
|
||||
- `homeassistant/components/sandbox_v2/entity/binary_sensor.py`
|
||||
- `homeassistant/components/sandbox_v2/entity/light.py`
|
||||
- `homeassistant/components/sandbox_v2/entity/sensor.py`
|
||||
- `homeassistant/components/sandbox_v2/entity/switch.py`
|
||||
- `homeassistant/components/sandbox_v2/protocol.py`
|
||||
- `sandbox_v2/hass_client/hass_client/entity_bridge.py`
|
||||
- `sandbox_v2/hass_client/hass_client/entry_runner.py`
|
||||
- `sandbox_v2/hass_client/hass_client/protocol.py`
|
||||
- `sandbox_v2/hass_client/tests/test_entity_bridge.py`
|
||||
- `sandbox_v2/hass_client/tests/test_entry_runner.py`
|
||||
- `tests/components/sandbox_v2/test_bridge.py`
|
||||
|
||||
Files changed:
|
||||
- `homeassistant/components/sandbox_v2/__init__.py` — wire one
|
||||
`SandboxBridge` per group via the manager's `on_channel_ready`
|
||||
callback; expose `SandboxV2Data.bridges`.
|
||||
- `homeassistant/components/sandbox_v2/router.py` — replace the
|
||||
Phase-4 LOADED stub with a real `sandbox_v2/entry_setup` round-trip;
|
||||
add an `async_unload_entry` helper for future use; surface
|
||||
`SETUP_ERROR` / `SETUP_RETRY` on refusal.
|
||||
- `sandbox_v2/hass_client/hass_client/sandbox.py` — construct and
|
||||
register the `EntryRunner` and `EntityBridge` alongside the
|
||||
Phase-4 `FlowRunner`; tear the bridge down on shutdown.
|
||||
- `tests/components/sandbox_v2/test_init.py` — assert the new
|
||||
`bridges` dict on `SandboxV2Data`.
|
||||
- `tests/components/sandbox_v2/test_router.py` — drive the new
|
||||
channel round-trip in `async_setup_entry` via a stub responder; add
|
||||
a `SETUP_ERROR`-on-refusal test.
|
||||
- `tests/components/sandbox_v2/test_proxy_flow.py` — extend the
|
||||
flow stub with `entry_setup` / `entry_unload` handlers so the full
|
||||
flow's setup interception completes.
|
||||
- `sandbox_v2/plan.md` — Phase 5 section marked complete with
|
||||
per-checkbox status (inline `*(Deferred …)*` notes for the 28
|
||||
remaining proxies, capability deltas, and the websocket-perf check
|
||||
that lives with Phase 10's compat lane).
|
||||
|
||||
Core HA files modified (review surface):
|
||||
- `homeassistant/helpers/entity_component.py:207-225` — new
|
||||
`EntityComponent.async_register_remote_platform(config_entry,
|
||||
platform)`. Mirrors `async_setup_entry`'s `_platforms[entry_id] =
|
||||
platform` assignment but lets sandbox_v2 hand in a pre-built remote
|
||||
`EntityPlatform` (rather than discovering one from the local
|
||||
integration). 1 new method, 0 changes to existing paths. Phase 5
|
||||
notes: this is the only Phase-5 core change; the Phase-4
|
||||
`router` hook is reused unchanged.
|
||||
|
||||
Test results:
|
||||
- `uv run pytest tests/components/sandbox_v2/ --no-cov -q` →
|
||||
**52 passed** (44 from Phase 0–4 + 8 new bridge tests).
|
||||
- `cd sandbox_v2/hass_client && uv run pytest -q` → **11 passed**
|
||||
(7 from Phase 0–4 + 1 new entity_bridge test + 3 new entry_runner
|
||||
tests).
|
||||
- `uv run pytest tests/test_config_entries.py tests/helpers/test_entity_component.py --no-cov -q`
|
||||
→ **413 passed, 4 snapshots passed** — the new EntityComponent
|
||||
hook is benign when not used.
|
||||
- `uv run prek run --files <21 changed files>` → all hooks pass
|
||||
(ruff-check, ruff-format, codespell, mypy, pylint, prettier).
|
||||
|
||||
Things to flag for the next phase:
|
||||
|
||||
- **28 of 32 domain proxies are still placeholders.** Phase 5 ships
|
||||
proxies for `light`, `switch`, `sensor`, `binary_sensor` to prove
|
||||
the path. Unknown-domain registrations fall back to the generic
|
||||
`SandboxProxyEntity` which has no domain-typed properties, so a
|
||||
sandboxed `climate` entity (for example) currently registers but
|
||||
reports no `hvac_mode`, `target_temperature`, etc. The base class
|
||||
+ `_DOMAIN_PROXIES` map are designed so each new proxy is a
|
||||
drop-in 20–80 LOC file (compare with v1's
|
||||
`homeassistant/components/sandbox/entity/`). Phase 5b.
|
||||
- **Capability delta protocol stub.** The plan called for a
|
||||
`sandbox_v2/update_entity` message for capability mutations after
|
||||
registration; Phase 5 surfaces capabilities only at register time
|
||||
and relies on re-registration for changes. Most integrations don't
|
||||
mutate capabilities post-setup, so this hasn't bitten yet — but
|
||||
`climate` and `cover` are known offenders.
|
||||
- **`async_unload_entry` core call site.** Router has
|
||||
`async_unload_entry` ready to wire (pushes `entry_unload` over the
|
||||
channel and calls `bridge.async_unload_entry`), but
|
||||
`homeassistant.config_entries.async_unload` does not consult the
|
||||
router. Adding the third call site means amending
|
||||
`ConfigEntryRouter` Protocol and `ConfigEntries.async_unload` —
|
||||
worth a Phase 5b PR since the integration code on main never
|
||||
loaded, so calling `entry.async_unload(hass)` blows up trying to
|
||||
invoke `async_unload_entry` on a module that has no integration
|
||||
state.
|
||||
- **`data_schema` is still stripped on the flow wire.** Phase 4's
|
||||
STATUS flagged this; Phase 5 didn't tackle it. Frontend forms for
|
||||
sandboxed integrations still won't render correctly. The
|
||||
`voluptuous_serialize`-based bridge is its own piece of work.
|
||||
- **`unique_id` propagation through the proxy flow.** Phase 4's
|
||||
STATUS flagged this; Phase 5 didn't tackle it. A sandboxed flow
|
||||
that calls `self.async_set_unique_id(...)` doesn't reflect that
|
||||
back to main's flow.context. Same shape as the data_schema
|
||||
follow-up — a small marshalling extension to `_marshal_result`.
|
||||
- **Performance benchmark deferred.** The 200-light area call under
|
||||
~50 ms target is an end-to-end-over-websocket measurement; the
|
||||
in-process channel pair the bridge tests use measures something
|
||||
different. Hook up with Phase 10's compat lane.
|
||||
- **`config_entries.async_unload`'s component-not-loaded path is
|
||||
fragile for sandboxed entries.** Even without an
|
||||
`async_unload_entry` Protocol method, the entry's state is
|
||||
`LOADED` after Phase 5 sets up successfully, so HA will try to
|
||||
unload via the local `component.async_unload_entry` on
|
||||
`entry.async_unload(hass)`. The integration module loads on main
|
||||
(manifest discovery) but `async_setup_entry` was never called on
|
||||
main, so its `hass.data` slot is missing and most integrations'
|
||||
unload functions raise `KeyError`. Phase 6 should land the
|
||||
unload-route hook before any UI-driven removal path is exercised.
|
||||
- **Auto-loading of host domains on first register.** The bridge
|
||||
calls `async_setup_component(domain)` on the first `register_entity`
|
||||
for an unfamiliar domain. This loads the platform module on main
|
||||
(`light`, `switch`, …) which is correct, but it does so lazily,
|
||||
meaning a brief delay on the very first entity of each domain. If
|
||||
this matters for perception, the manager could pre-load the
|
||||
domains declared by an integration's `manifest.json` at
|
||||
`entry_setup` time.
|
||||
- **`SandboxLightEntity.__init__` re-wraps `supported_features` in
|
||||
`LightEntityFeature`.** The base proxy stores an `int`, but the
|
||||
light's `capability_attributes` does `X in supported_features`,
|
||||
which only works on the IntFlag. Other domains that index
|
||||
`supported_features` with `in` (`fan`, `cover`, …) will need the
|
||||
same per-class wrapping when their proxies land.
|
||||
@@ -0,0 +1,131 @@
|
||||
Status: DONE
|
||||
|
||||
Phase 6 lands the sandbox→main service-registration mirror, event
|
||||
mirror, and the approved-domains firewall they share. A single
|
||||
refcounted `ApprovedDomains` instance is grown by `EntryRunner` when an
|
||||
entry's `async_setup_entry` succeeds and by `EntityBridge` when an
|
||||
entity registers, so the gate naturally tracks every domain the
|
||||
sandbox actually owns. `ServiceMirror` listens on the sandbox bus for
|
||||
`EVENT_SERVICE_REGISTERED` / `EVENT_SERVICE_REMOVED`, drops anything
|
||||
the gate doesn't approve (with a warning log), and otherwise pushes
|
||||
`sandbox_v2/register_service` (with `supports_response`) to main.
|
||||
`SandboxBridge` on main installs a forwarder that ships each call back
|
||||
over the existing `sandbox_v2/call_service` channel — never clobbering
|
||||
an existing handler, so the `light.turn_on` registered by the host
|
||||
`light` EntityComponent for Phase 5's proxy entities keeps its
|
||||
dispatch role for entity services. `EventMirror` uses a `MATCH_ALL`
|
||||
listener with an internal-events deny-list to forward only
|
||||
`<approved_domain>_*` events via `sandbox_v2/fire_event`; main
|
||||
re-fires each on its own bus so `automation` listeners react as if
|
||||
the integration ran locally. **No core HA files were touched** — the
|
||||
Phase 4 `router` hook and the Phase 5 `async_register_remote_platform`
|
||||
hook are reused unchanged.
|
||||
|
||||
Files added:
|
||||
- `sandbox_v2/hass_client/hass_client/approved_domains.py`
|
||||
- `sandbox_v2/hass_client/hass_client/service_mirror.py`
|
||||
- `sandbox_v2/hass_client/hass_client/event_mirror.py`
|
||||
- `sandbox_v2/hass_client/tests/test_approved_domains.py`
|
||||
- `sandbox_v2/hass_client/tests/test_service_mirror.py`
|
||||
- `sandbox_v2/hass_client/tests/test_event_mirror.py`
|
||||
|
||||
Files changed:
|
||||
- `sandbox_v2/hass_client/hass_client/protocol.py` — added
|
||||
`MSG_REGISTER_SERVICE`, `MSG_UNREGISTER_SERVICE`, `MSG_FIRE_EVENT`.
|
||||
- `sandbox_v2/hass_client/hass_client/sandbox.py` — construct and
|
||||
register `ServiceMirror` + `EventMirror` alongside the existing
|
||||
`FlowRunner` / `EntryRunner` / `EntityBridge`; share a single
|
||||
`ApprovedDomains` instance; tear them down on shutdown.
|
||||
- `sandbox_v2/hass_client/hass_client/entry_runner.py` — accept the
|
||||
shared `ApprovedDomains`; refcount-add the entry domain after
|
||||
`async_setup_entry` succeeds; refcount-remove on `entry_unload`.
|
||||
- `sandbox_v2/hass_client/hass_client/entity_bridge.py` — accept the
|
||||
shared `ApprovedDomains`; refcount-add the entity's domain on each
|
||||
successful `register_entity` push (covers the *"light is approved
|
||||
if a sandboxed integration registers light entities"* clause).
|
||||
- `homeassistant/components/sandbox_v2/protocol.py` — mirror the new
|
||||
message names.
|
||||
- `homeassistant/components/sandbox_v2/bridge.py` — handle inbound
|
||||
`sandbox_v2/register_service` / `..._unregister_service` /
|
||||
`sandbox_v2/fire_event` on `SandboxBridge`; install a forwarder
|
||||
callable that translates each main-side service call into a
|
||||
`sandbox_v2/call_service` RPC (reusing Phase 5's exception
|
||||
translator); refuse to clobber an existing service handler.
|
||||
- `tests/components/sandbox_v2/test_bridge.py` — add the four Phase 6
|
||||
bridge tests + a Phase-6 mock-domain ignore fixture.
|
||||
- `sandbox_v2/plan.md` — Phase 6 section marked complete with summary
|
||||
and per-checkbox status (inline `*(...)*` notes for the few items
|
||||
the implementation simplified — e.g., schemas not serialised,
|
||||
manifest-dependencies clause supplanted by the entity-registration
|
||||
path).
|
||||
|
||||
Core HA files modified (review surface):
|
||||
- None. (Phase 6 is purely sandbox-side glue plus integration-local
|
||||
handlers on main.)
|
||||
|
||||
Test results:
|
||||
- `uv run pytest tests/components/sandbox_v2/ --no-cov -q` →
|
||||
**56 passed** (52 from Phase 0–5 + 4 new bridge tests covering
|
||||
register / skip-existing / unregister / fire_event).
|
||||
- `cd sandbox_v2/hass_client && uv run pytest -q` → **22 passed**
|
||||
(11 from Phase 0–5 + 5 `approved_domains` + 3 `service_mirror` +
|
||||
3 `event_mirror`).
|
||||
- `uv run prek run --files <13 changed files>` → all hooks pass
|
||||
(ruff-check, ruff-format, codespell, mypy, pylint, prettier).
|
||||
|
||||
Things to flag for the next phase:
|
||||
|
||||
- **Service schemas are not serialised across the wire.** The Phase 6
|
||||
mirror registers `schema=None` on main and relies on the sandbox's
|
||||
copy of the schema to validate every call when it lands on the
|
||||
sandbox's `services.async_call`. This is fine for service handlers
|
||||
that re-validate or that are content with the raw `service_data`
|
||||
dict — but main's `voluptuous_serialize`-backed service-call UI
|
||||
cannot render argument hints for sandboxed services. The
|
||||
data_schema bridge already on the Phase 4/5 follow-up list should
|
||||
fold in service schemas at the same time.
|
||||
- **`Context` is not faithfully forwarded.** `sandbox_v2/fire_event`
|
||||
carries the sandbox's `context_id` but main's `bus.async_fire`
|
||||
receives no `Context` object, so the event lands with a fresh
|
||||
local context. Listeners that key off `context.user_id` or
|
||||
`context.parent_id` won't see the sandbox-side values. Phase 7's
|
||||
auth scoping is the right place to revisit this — a sandbox token
|
||||
doesn't have user identity to forward anyway, and the design will
|
||||
need to settle what "origin sandbox" should look like in a
|
||||
`Context`.
|
||||
- **Service-removal cleanup on unload depends on the sandbox's bus.**
|
||||
The bridge's `_mirrored_services` set tracks what *the bridge
|
||||
installed*, so an entry unload that runs `services.async_remove`
|
||||
inside the sandbox triggers `EVENT_SERVICE_REMOVED` →
|
||||
`sandbox_v2/unregister_service` → main drop. Integrations that
|
||||
bypass `services.async_remove` on unload (rare but legal) will
|
||||
leave a dangling forwarder on main. Phase 9's graceful-shutdown
|
||||
pass should iterate `_mirrored_services` and drop the lot on
|
||||
sandbox process exit as a backstop.
|
||||
- **`MATCH_ALL` event listener cost.** `EventMirror` subscribes to
|
||||
every event on the sandbox bus and does a per-event prefix scan
|
||||
against the approved-domain set. This is cheap (one O(domains)
|
||||
scan per event, short-circuit on the deny-list) but worth a
|
||||
second look once Phase 10's compat lane lets us measure event
|
||||
throughput under load. If it shows up, swapping to a
|
||||
domain-indexed subscription map (one listener per
|
||||
`<domain>_*` prefix, re-bound when the set grows) avoids the
|
||||
per-event scan.
|
||||
- **No cross-context entry-unload service cleanup yet.** When the
|
||||
router calls `bridge.async_unload_entry(entry)`, the bridge drops
|
||||
the entity platforms for that entry but does not yet
|
||||
cross-reference which mirrored services belonged to the entry
|
||||
(the sandbox doesn't tell us). If the integration's
|
||||
`async_unload_entry` doesn't unregister its services on the
|
||||
sandbox side, those forwarders stick around until the sandbox
|
||||
process exits. Pair this fix with the dangling-forwarder backstop
|
||||
above when Phase 9 lands.
|
||||
- **Approved-domains check is a one-way ratchet today.** `EntryRunner`
|
||||
removes one refcount on `entry_unload` and `EntityBridge` doesn't
|
||||
decrement on entity unregister at all — so once a domain has been
|
||||
approved by an entity, it stays approved for the lifetime of the
|
||||
sandbox process. That's fine while we're additive but means a
|
||||
sandbox that briefly hosted a `light` keeps light approved even
|
||||
after every light is gone. Tightening this needs the
|
||||
`EntityBridge` to refcount on `_push_unregister` too; not urgent
|
||||
for v2 but worth noting for the hardening pass.
|
||||
@@ -0,0 +1,136 @@
|
||||
Status: DONE
|
||||
|
||||
Phase 7 adds the scoped-auth primitive to `RefreshToken`, enforces it
|
||||
per-command in the websocket dispatcher, and wires sandbox-scoped
|
||||
access tokens into the manager so each subprocess receives a real
|
||||
credential instead of the placeholder string. The scope set granted to
|
||||
sandbox tokens is `{"sandbox_v2/", "auth/current_user"}` — a prefix
|
||||
grant for the entire `sandbox_v2/` namespace plus a single exact-match
|
||||
entry that lets the runtime confirm which user it authenticated as.
|
||||
Opt-in core-data sharing lands as `SandboxGroupConfig` with three
|
||||
flags (`share_states`, `share_entity_registry`, `share_areas`); the
|
||||
default posture is everything-off (locked down), and
|
||||
`DEFAULT_GROUP_CONFIGS` flips all three on for the `built-in` and
|
||||
`main` groups, matching the behaviour today's built-in integrations
|
||||
expect. `custom` stays locked down. The runtime accepts the matching
|
||||
`--share-*` CLI flags into a frozen `SharingConfig` dataclass so a
|
||||
future phase can hang the actual subscription code off it without
|
||||
churning the manager↔runtime contract.
|
||||
|
||||
The sandbox does not yet open a websocket back to main — the stdio
|
||||
control channel built in Phases 3-4 is still the only path between
|
||||
manager and runtime. The scope-enforcement test therefore exercises
|
||||
the dispatcher directly by handing a scoped access token to the
|
||||
ordinary `hass_ws_client` fixture. The "share_states=False isolation"
|
||||
contract is enforced trivially today (no subscription code exists) and
|
||||
asserted by a hass_client test that confirms a freshly-spawned runtime
|
||||
sees an empty `hass.states.async_all()`.
|
||||
|
||||
Files added:
|
||||
- `homeassistant/components/sandbox_v2/auth.py`
|
||||
- `tests/components/sandbox_v2/test_auth.py`
|
||||
- `tests/components/websocket_api/test_scopes.py`
|
||||
- `sandbox_v2/hass_client/tests/test_sharing_config.py`
|
||||
|
||||
Files changed:
|
||||
- `homeassistant/components/sandbox_v2/__init__.py` — pass an
|
||||
`async_issue_sandbox_access_token`-backed `token_factory` to the
|
||||
manager so subprocesses receive a real scoped token.
|
||||
- `homeassistant/components/sandbox_v2/manager.py` — add
|
||||
`SandboxGroupConfig`, `DEFAULT_GROUP_CONFIGS`, `TokenFactory`, the
|
||||
`group_config()` accessor, the token-factory plumbing through
|
||||
`ensure_started`, and the `--share-*` flag expansion in
|
||||
`_default_command`.
|
||||
- `sandbox_v2/hass_client/hass_client/sandbox.py` — add
|
||||
`SharingConfig` dataclass + a `sharing` constructor argument on
|
||||
`SandboxRuntime`.
|
||||
- `sandbox_v2/hass_client/hass_client/sandbox_v2/__main__.py` — add
|
||||
`--share-states` / `--share-entity-registry` / `--share-areas`
|
||||
flags and feed them into a `SharingConfig` at runtime construction.
|
||||
- `sandbox_v2/hass_client/tests/test_sandbox_runtime.py` — add the
|
||||
locked-down-sharing posture test.
|
||||
- `tests/components/sandbox_v2/test_manager.py` — add four new tests
|
||||
(default + override `group_config`, default-command argv, token
|
||||
factory invocation).
|
||||
- `tests/auth/test_init.py` — add scoped refresh-token defaults +
|
||||
round-trip-through-store tests.
|
||||
- `sandbox_v2/plan.md` — Phase 7 section marked complete with summary
|
||||
and inline notes for the deferred "when True, sandbox subscribes"
|
||||
half (needs the websocket connection).
|
||||
|
||||
Core HA files modified (review surface):
|
||||
- `homeassistant/auth/models.py:126-134` — `RefreshToken` grows an
|
||||
optional `scopes: frozenset[str] | None = None` attr. Default
|
||||
`None` preserves today's behaviour for every existing token.
|
||||
- `homeassistant/auth/__init__.py:453-518` — `AuthManager
|
||||
.async_create_refresh_token` accepts and forwards `scopes` to the
|
||||
store. Other call sites unchanged.
|
||||
- `homeassistant/auth/auth_store.py:204-235, 478-500, 561-587` —
|
||||
`AuthStore.async_create_refresh_token` accepts `scopes`; the
|
||||
persisted dict carries `scopes` as a sorted list; reload uses
|
||||
`dict.get("scopes")` so pre-existing stored tokens load with
|
||||
`scopes=None`. No storage-version bump needed because the new key
|
||||
is optional on read.
|
||||
- `homeassistant/components/websocket_api/connection.py:43-62,
|
||||
79-90, 232-245` — `ActiveConnection` stores `scopes` from the
|
||||
refresh token; `async_handle` checks each incoming type via the
|
||||
module-level `_scope_allows` helper and rejects with
|
||||
`ERR_UNAUTHORIZED`. Unscoped tokens (`scopes is None`) are
|
||||
unaffected.
|
||||
|
||||
Test results:
|
||||
- `uv run pytest tests/components/sandbox_v2/ --no-cov -q` →
|
||||
**67 passed** (56 from Phases 0-6 + 5 new test_auth + 4 new
|
||||
test_manager + 2 unchanged).
|
||||
- `cd sandbox_v2/hass_client && uv run pytest -q` → **30 passed**
|
||||
(22 from Phases 0-6 + 7 new test_sharing_config + 1 new
|
||||
test_sandbox_runtime).
|
||||
- `uv run pytest tests/auth/ tests/components/websocket_api/
|
||||
--no-cov -q` → **336 passed, 2 snapshots passed** — the new
|
||||
scopes attribute is backwards-compatible (None default) and the
|
||||
new dispatcher check is a no-op for scopes=None tokens.
|
||||
- `uv run prek run --files <15 changed files>` → all hooks pass
|
||||
(ruff-check, ruff-format, codespell, mypy, pylint, prettier).
|
||||
|
||||
Things to flag for the next phase:
|
||||
|
||||
- **The sandbox→main websocket is not yet wired.** The manager
|
||||
hands the runtime a real scoped access token plus the
|
||||
`share_*` flags, and the runtime stores both on
|
||||
`SandboxRuntime.sharing`, but nothing actually opens a websocket
|
||||
back to main today. Phase 8's `RemoteStore` is the first piece
|
||||
that needs it — when that lands, opening the connection and
|
||||
subscribing (gated on `sharing.share_states`) is a straight
|
||||
extension of the runtime's `run()` setup.
|
||||
- **`share_states=True` filtering on main is deferred.** The plan
|
||||
called for "main's `subscribe_events` and state reads filter to
|
||||
data the sandbox is allowed to see" when sharing is on. The
|
||||
config knob is in place but the filtering side hasn't shipped —
|
||||
it should land in the same PR that turns on the subscription.
|
||||
The natural place to gate this is `_scope_allows` plus a
|
||||
per-event scope check on the subscription's emit path.
|
||||
- **Token rotation on sandbox restart.** `_get_or_create_sandbox_refresh_token`
|
||||
reuses the same scoped refresh token across calls, so an HA restart
|
||||
hands the subprocess the same token it had before. That's fine for
|
||||
the locked-down posture but the plan's "rotate the refresh token on
|
||||
each call" note in the docstring is currently aspirational — once
|
||||
the websocket subscription lands, decide whether to keep the stable
|
||||
token (simpler) or rotate (tighter security if a subprocess is
|
||||
compromised).
|
||||
- **No supervisor for hash collisions.** Two HA processes (e.g., dev
|
||||
and prod) sharing the same auth store would each create their own
|
||||
`Sandbox v2: built-in` user with the same name. That's the same
|
||||
shape as the existing supervisor user collision pattern — not new
|
||||
in v2 — but worth noting if multi-instance auth stores ever land.
|
||||
- **`SandboxGroupConfig` is not user-facing.** Per the plan this is
|
||||
intentional for v2; surfacing the knob in the frontend is Phase 11+
|
||||
follow-up. If a user wants to lock down `built-in` they need to
|
||||
override `group_configs=` in code today.
|
||||
- **Scope-set serialisation is JSON-sorted.** The auth store writes
|
||||
`sorted(refresh_token.scopes)` so the on-disk shape is stable
|
||||
across reloads; load reconstructs a `frozenset`. The dispatcher
|
||||
comparison is set-based, so order does not leak into behaviour.
|
||||
- **The `_client_id_for_group` helper in `sandbox_v2/auth.py` is
|
||||
not used today** — kept for the case where a sandbox refresh token
|
||||
needs a stable `client_id` (e.g., to dedupe across HA versions).
|
||||
Wire it in if/when the rotation-on-each-call story changes.
|
||||
@@ -0,0 +1,124 @@
|
||||
Status: DONE
|
||||
|
||||
Phase 8 routes sandbox-side `Store` operations to main over the
|
||||
existing control channel. A new `RemoteStore` (in
|
||||
`sandbox_v2/hass_client/hass_client/remote_store.py`) subclasses
|
||||
`homeassistant.helpers.storage.Store` and overrides the three IO
|
||||
primitives — `_async_load_data`, `_async_write_data`, and
|
||||
`async_remove` — to talk to main via `sandbox_v2/store_load`,
|
||||
`sandbox_v2/store_save`, and `sandbox_v2/store_remove`. The sandbox
|
||||
runtime calls `install_remote_store(channel)` right after the channel
|
||||
opens and right before the per-runner handlers register, so every
|
||||
`Store(...)` instantiated during `async_setup_entry` is a RemoteStore.
|
||||
The patch is process-wide (a class-level `RemoteStore._channel` plus a
|
||||
rebinding of `homeassistant.helpers.storage.Store`), since one sandbox
|
||||
process hosts one sandbox group. On shutdown the uninstall callable
|
||||
restores the original `Store` class and clears the channel reference.
|
||||
|
||||
Migration, `delay_save`, the EVENT_HOMEASSISTANT_FINAL_WRITE hook, and
|
||||
the corruption-handling paths from `Store` are all reused unchanged —
|
||||
`RemoteStore` only swaps the disk-IO primitives for channel calls. The
|
||||
migration block in `_async_load_data` is copied from `Store` because
|
||||
the source method doesn't expose a hook to plug the load source; this
|
||||
is the load-bearing duplication (with an inline note pointing to the
|
||||
parent method) for the phase.
|
||||
|
||||
On main each `SandboxBridge` owns a `_SandboxStoreServer` pinned to
|
||||
`<config>/.storage/sandbox_v2/<group>/`. Reads use
|
||||
`json_util.load_json` with a `None` default; writes use
|
||||
`util.file.write_utf8_file_atomic` (same primitive `Store` uses); removes
|
||||
unlink the file. Key validation (`_require_key`) rejects `/`, `\`, NUL,
|
||||
`.`, `..`, and any `..`-prefixed key before any path is constructed.
|
||||
Scope isolation is by construction: each bridge owns one channel for
|
||||
one group, so a sandbox cannot reach another sandbox's files —
|
||||
forging a cross-group call would require forging the channel itself.
|
||||
|
||||
Files added:
|
||||
- `sandbox_v2/hass_client/hass_client/remote_store.py`
|
||||
- `sandbox_v2/hass_client/tests/test_remote_store.py`
|
||||
- `tests/components/sandbox_v2/test_store.py`
|
||||
|
||||
Files changed:
|
||||
- `homeassistant/components/sandbox_v2/protocol.py` — add
|
||||
`MSG_STORE_LOAD` / `MSG_STORE_SAVE` / `MSG_STORE_REMOVE` constants
|
||||
+ docstring entries.
|
||||
- `homeassistant/components/sandbox_v2/bridge.py` — add the three
|
||||
store handlers on `SandboxBridge`, the `_SandboxStoreServer`
|
||||
per-group backend, and the `_require_key` validator. Phase 8 note
|
||||
added to the module docstring.
|
||||
- `sandbox_v2/hass_client/hass_client/protocol.py` — mirror the new
|
||||
message constants.
|
||||
- `sandbox_v2/hass_client/hass_client/sandbox.py` — call
|
||||
`install_remote_store(channel)` after the channel is built, and
|
||||
uninstall on shutdown.
|
||||
- `sandbox_v2/plan.md` — Phase 8 section marked complete with
|
||||
per-checkbox status + inline notes for the deferrals.
|
||||
|
||||
Core HA files modified (review surface):
|
||||
- None. (Phase 8 is sandbox-side plus integration-local handlers on
|
||||
main. The bridge uses the existing public surface of
|
||||
`homeassistant.helpers.storage` and `homeassistant.util.file`.)
|
||||
|
||||
Test results:
|
||||
- `uv run pytest tests/components/sandbox_v2/ --no-cov -q` →
|
||||
**80 passed** (67 from Phase 0–7 + 13 new test_store).
|
||||
- `cd sandbox_v2/hass_client && uv run pytest -q` →
|
||||
**36 passed** (30 from Phase 0–7 + 6 new test_remote_store).
|
||||
- `uv run pytest tests/helpers/test_storage.py --no-cov -q` →
|
||||
**39 passed** — Phase 8 didn't disturb the public `Store` API.
|
||||
- `uv run prek run --files <7 changed files>` → all hooks pass
|
||||
(ruff-check, ruff-format, codespell, mypy, pylint, prettier).
|
||||
|
||||
Things to flag for the next phase:
|
||||
|
||||
- **`install_remote_store` is a process-wide rebinding.** It mutates
|
||||
`homeassistant.helpers.storage.Store` so every `Store(...)`
|
||||
instantiation in the sandbox process after the patch returns a
|
||||
`RemoteStore`. Two tests in `test_remote_store.py` exercise the
|
||||
install/uninstall cycle and confirm the patch is reverted, but any
|
||||
code path that captures `Store` at module-import time *before* the
|
||||
patch (or after the uninstall) will keep the original class. In
|
||||
practice this is harmless: registries that loaded before the patch
|
||||
keep their tempdir backing, and integrations import `Store` lazily
|
||||
during their own `async_setup_entry`.
|
||||
- **Migration logic is duplicated from `Store._async_load_data`.**
|
||||
The base class doesn't expose a hook to override only the disk-read
|
||||
step, so `RemoteStore._async_load_data` copies the migration block
|
||||
verbatim. If the upstream block grows (new fields, new migration
|
||||
shape), the copy needs to follow. A future hardening pass could
|
||||
refactor `Store` to extract `_read_wrapped_payload()` as a
|
||||
one-liner override point.
|
||||
- **`Store.path` still points at a local path on the sandbox tempdir.**
|
||||
RemoteStore inherits the `@cached_property` — the path it returns
|
||||
doesn't exist on disk. No RemoteStore code path uses it; integrations
|
||||
that read `store.path` directly (rare, mostly for logging) will see
|
||||
a meaningless string. If this trips a real integration, override
|
||||
`path` to emit a remote-flavoured sentinel.
|
||||
- **Phase 9's shutdown protocol needs to force-flush every RemoteStore.**
|
||||
`Store` writes pending data on `EVENT_HOMEASSISTANT_FINAL_WRITE`,
|
||||
but Phase 8 doesn't wire that event up on the sandbox side — the
|
||||
sandbox's HA instance isn't currently fired through the
|
||||
`homeassistant_final_write` step. Phase 9 should add a
|
||||
`flush_pending_writes()` pass over the per-process Store registry as
|
||||
part of the `sandbox_v2/shutdown` round-trip.
|
||||
- **HA registries on the sandbox still write to the tempdir.**
|
||||
Device/entity/area/auth registries that load during the sandbox's
|
||||
startup (before the channel is up) keep their local file backing,
|
||||
so cross-restart persistence for those is lost when the tempdir
|
||||
is recreated. Phase 8 intentionally leaves this alone — integration
|
||||
state is what the plan calls out, and routing the HA-internals
|
||||
Stores too is a larger decision that depends on Phase 9/10 needs.
|
||||
- **No back-pressure on `store_save` round-trips.** A flush waits for
|
||||
main's ack before resolving the future inside `_async_handle_write_data`'s
|
||||
write lock. If main is slow (or hung), the sandbox's `Store.async_save`
|
||||
call blocks accordingly — same shape as the local-disk slow-IO
|
||||
case. The 30s timeout knob in `Channel.call(...)` is available if a
|
||||
future phase wants to bound this.
|
||||
- **Path-traversal validator is conservative.** `_require_key` rejects
|
||||
`.` / `..` / slashes / NUL outright. Real-world Store keys use only
|
||||
`[A-Za-z0-9_.]`-shaped strings (`auth`, `core.entity_registry`,
|
||||
`light.hue.entry_id`), all of which pass. If a future integration
|
||||
uses something exotic, the validator will need adjusting.
|
||||
- **The locked-down sharing posture from Phase 7 still holds.** Stores
|
||||
are scoped per group, so `share_states=False` continues to apply at
|
||||
the bus level; Phase 8 doesn't change which data flows back to main.
|
||||
@@ -0,0 +1,165 @@
|
||||
Status: DONE
|
||||
|
||||
Phase 9 ships the graceful-shutdown round-trip plus restore-state
|
||||
hand-off between main and each sandbox. The sandbox runtime registers
|
||||
`sandbox_v2/shutdown` once its channel is up. On receipt the handler
|
||||
iterates `config_entries.async_entries()` and runs
|
||||
`config_entries.async_unload(entry_id)` for each, then snapshots
|
||||
`RestoreStateData.async_get_stored_states()` into a JSON-safe wrapped
|
||||
dict (round-tripped through orjson's HA-aware encoder so `Fragment`,
|
||||
`State`, `datetime`, and friends survive the plain-JSON channel),
|
||||
returns `{"ok": True, "unloaded": N, "restore_state": <payload | None>}`,
|
||||
and schedules its own `_shutdown` event via `call_soon` *after* the
|
||||
reply has been queued so the subprocess exits 0 on its own.
|
||||
|
||||
On main, `SandboxManager.async_graceful_shutdown_all(timeout=...)` fans
|
||||
out `MSG_SHUTDOWN` to every running sandbox, hands each reply to a
|
||||
configurable `on_shutdown_reply` callback, and waits for the process to
|
||||
exit. `async_stop_all` is unchanged — it remains the SIGTERM/SIGKILL
|
||||
escalation path for sandboxes that timed out the graceful round-trip.
|
||||
The integration's `_on_stop` listener now calls
|
||||
`async_graceful_shutdown_all(timeout=manager.shutdown_grace)` first,
|
||||
then `async_stop_all`. The Phase 9 `on_shutdown_reply` persists the
|
||||
`restore_state` payload via `SandboxBridge._handle_store_save` so it
|
||||
lands at the same `<config>/.storage/sandbox_v2/<group>/core.restore_state`
|
||||
path the next sandbox boot reads from.
|
||||
|
||||
On the next sandbox start, the runtime warm-loads that file before any
|
||||
handler can dispatch an `entry_setup`. Because `restore_state.py`
|
||||
captures `Store` at import time (`from .storage import Store`), Phase
|
||||
8's module-attribute rebinding (`install_remote_store` mutates
|
||||
`storage.Store`) can't reach it — Phase 9 swaps
|
||||
`RestoreStateData.store` with an explicit `RemoteStore(hass, ...,
|
||||
STORAGE_KEY, encoder=JSONEncoder)` and calls `async_load()` directly,
|
||||
bypassing the helper's `start.async_at_start` listener (which never
|
||||
fires on a bare HA). A new `wait_until_ready()` accessor on
|
||||
`SandboxRuntime` lets tests gate on "handlers fully registered" rather
|
||||
than the looser `started` flag.
|
||||
|
||||
The restore_state-via-`RemoteStore` route used inside the shutdown
|
||||
handler would deadlock — the sandbox channel's reader task is single-
|
||||
threaded and busy dispatching the handler when it tries to issue a
|
||||
`MSG_STORE_SAVE`, so the reply for store_save can never be processed.
|
||||
The reply-payload workaround is the lower-disruption fix: shipping the
|
||||
data in the existing shutdown reply costs one round-trip (vs the
|
||||
deadlock) and keeps the channel architecture unchanged. A concurrent
|
||||
channel dispatcher (spawn one task per inbound call) would lift the
|
||||
restriction for handlers in general; flagged for a future hardening
|
||||
pass.
|
||||
|
||||
The plan's "fire `EVENT_HOMEASSISTANT_FINAL_WRITE` so pending Stores
|
||||
flush" step is intentionally not implemented — it would have the same
|
||||
re-entrant-deadlock shape for every `delay_save`-using Store. The
|
||||
practical impact is bounded: integrations that rely on
|
||||
`delay_save`-pending writes being flushed by FINAL_WRITE will lose
|
||||
unwritten data on sandbox shutdown. Most integrations either save
|
||||
synchronously through `async_save` (which already round-trips through
|
||||
the channel during normal operation) or only buffer non-critical data.
|
||||
|
||||
`RemoteStore._async_write_data` grew an orjson pre-serialisation step
|
||||
so the channel's plain `json.dumps` never has to grapple with
|
||||
`Fragment` etc. — same trip `Store._async_write_data` would take on its
|
||||
way to disk, just intercepted before the bytes hit a file. This is
|
||||
what made the Phase 8 RemoteStore path work for `core.restore_state`
|
||||
even though we don't use it inside the shutdown handler — the warm-load
|
||||
on startup goes through RemoteStore.
|
||||
|
||||
Files added:
|
||||
- `sandbox_v2/hass_client/tests/test_shutdown.py`
|
||||
- `tests/components/sandbox_v2/test_phase9_shutdown.py`
|
||||
|
||||
Files changed:
|
||||
- `homeassistant/components/sandbox_v2/__init__.py` — wire the
|
||||
`on_shutdown_reply` callback that persists the sandbox's restore_state
|
||||
snapshot via the bridge's store server; call
|
||||
`async_graceful_shutdown_all` before `async_stop_all` in `_on_stop`.
|
||||
- `homeassistant/components/sandbox_v2/manager.py` — add
|
||||
`ShutdownReplyCallback`, the `on_shutdown_reply` plumbing on
|
||||
`SandboxProcess` and `SandboxManager`,
|
||||
`SandboxProcess.async_graceful_shutdown(timeout=...)`,
|
||||
`SandboxManager.async_graceful_shutdown_all(timeout=...)`, and the
|
||||
`shutdown_grace` property.
|
||||
- `homeassistant/components/sandbox_v2/protocol.py` — add
|
||||
`MSG_SHUTDOWN` and a Phase 9 docstring section.
|
||||
- `sandbox_v2/hass_client/hass_client/protocol.py` — mirror
|
||||
`MSG_SHUTDOWN`.
|
||||
- `sandbox_v2/hass_client/hass_client/remote_store.py` — pre-serialise
|
||||
the payload through orjson (`json_helper.prepare_save_json`) before
|
||||
handing it to the channel so HA-specific types (`Fragment`, `State`,
|
||||
`datetime`) survive plain JSON.
|
||||
- `sandbox_v2/hass_client/hass_client/sandbox.py` — register the
|
||||
`MSG_SHUTDOWN` handler; implement `_run_graceful_shutdown` (unload +
|
||||
collect restore_state); add `_ready` event + `wait_until_ready`
|
||||
helper; warm-load `core.restore_state` via a hand-installed
|
||||
`RemoteStore` before handlers register; reorder the run() body so
|
||||
the channel reader starts before the warm-load (the RPC needs it).
|
||||
- `sandbox_v2/plan.md` — Phase 9 section marked complete with per-
|
||||
checkbox status and inline deferral notes.
|
||||
|
||||
Core HA files modified (review surface):
|
||||
- None. (Phase 9 plumbing lives entirely in sandbox_v2/ and
|
||||
homeassistant/components/sandbox_v2/. The Phase 4 `router` hook,
|
||||
Phase 5 `async_register_remote_platform` hook, and Phase 7 `scopes`
|
||||
hook are reused unchanged.)
|
||||
|
||||
Test results:
|
||||
- `uv run pytest tests/components/sandbox_v2/ --no-cov -q` →
|
||||
**84 passed** (80 from Phase 0–8 + 4 new test_phase9_shutdown).
|
||||
- `cd sandbox_v2/hass_client && uv run pytest -q` →
|
||||
**39 passed** (36 from Phase 0–8 + 3 new test_shutdown).
|
||||
- `uv run pytest tests/helpers/test_storage.py
|
||||
tests/helpers/test_restore_state.py --no-cov -q` → **52 passed** —
|
||||
Phase 9 didn't disturb the public `Store` / `RestoreEntity` API.
|
||||
- `uv run prek run --files <8 changed files>` → all hooks pass
|
||||
(ruff-check, ruff-format, codespell, mypy, pylint, prettier).
|
||||
|
||||
Things to flag for the next phase:
|
||||
|
||||
- **Re-entrant `channel.call` from inside a handler deadlocks.** The
|
||||
channel's reader task is single-threaded and processes responses
|
||||
serially. A handler that issues `channel.call(...)` blocks waiting
|
||||
for a response that the same reader task can't pick up. Phase 9
|
||||
worked around the specific case (restore_state in the shutdown
|
||||
reply) but the more general fix — spawn a task per inbound call so
|
||||
the reader can keep draining the wire — is owed. This also
|
||||
matters for Phase 5/8 in theory: an integration's `async_setup_entry`
|
||||
that calls `Store.async_save` during the `MSG_ENTRY_SETUP` handler
|
||||
would hit the same deadlock. None of the existing tests exercise
|
||||
this path, but a real integration will. Recommended Phase 9b: add
|
||||
`Channel._dispatch_call_in_task` and a small concurrency cap.
|
||||
- **`EVENT_HOMEASSISTANT_FINAL_WRITE` is not fired on sandbox shutdown.**
|
||||
Same deadlock shape — any `delay_save`-using Store's flush would
|
||||
re-enter the channel. Concrete loss: integrations that batch writes
|
||||
via `Store.async_delay_save` lose the pending data on sandbox
|
||||
shutdown. Phase 9b's concurrent dispatcher fixes this for free, at
|
||||
which point we can fire FINAL_WRITE inside `_run_graceful_shutdown`.
|
||||
- **`restore_state` is the only framework Store routed to main.**
|
||||
Device/entity/area registries and the auth store still write to the
|
||||
sandbox's tempdir (Phase 8 STATUS already flagged this; Phase 9
|
||||
didn't change it). Adding them needs the same pattern Phase 9 used
|
||||
for `restore_state`: explicit `RemoteStore` wiring at startup before
|
||||
any consumer captures the original `Store` class. A registry helper
|
||||
that exposes the singleton Store would let us swap it in cleanly.
|
||||
- **The shutdown payload is unbounded.** A sandbox with thousands of
|
||||
RestoreEntities serialises every state into one channel reply. For
|
||||
today's targets that's well under a megabyte; if Phase 10's compat
|
||||
lane lights up an integration with >10k RestoreEntities, consider
|
||||
paging or compressing the payload.
|
||||
- **`on_shutdown_reply` is best-effort.** If the bridge isn't
|
||||
registered (e.g., a sandbox crashed before its `on_channel_ready`
|
||||
fired) the restore_state payload is dropped with a debug log.
|
||||
Phase 9 prefers data loss over a hang; the integration could
|
||||
instead write the payload directly through `_SandboxStoreServer`
|
||||
without the bridge, but that adds yet another file-write code path.
|
||||
Revisit if it bites.
|
||||
- **`SandboxRuntime.wait_until_ready` is a new public surface for
|
||||
tests.** It pins the readiness contract (`_ready` event set after
|
||||
every handler registers) so tests don't have to poll. Same shape as
|
||||
the existing `started` property but stricter.
|
||||
- **Test cross-fertilisation.** `tests/components/sandbox_v2/_helpers.py`
|
||||
already exports `make_channel_pair`, but the hass_client tree can't
|
||||
import from `tests/...` (TID251). The result is a duplicated
|
||||
`_make_channel_pair`/`_LoopbackWriter` snippet in
|
||||
`sandbox_v2/hass_client/tests/test_shutdown.py` (and the existing
|
||||
`test_remote_store.py`). Could be lifted to a `conftest.py` fixture
|
||||
in `sandbox_v2/hass_client/tests/` if it keeps growing.
|
||||
@@ -0,0 +1,164 @@
|
||||
# STATUS — plan-auth-context (drop token + system user + context restore)
|
||||
|
||||
**Done.** Parts A/B/C all landed; both suites green, hassfest clean, prek
|
||||
clean. The sandbox carries no credential and provably cannot fabricate
|
||||
`Context` attribution.
|
||||
|
||||
## Commits
|
||||
|
||||
| SHA | What |
|
||||
|---|---|
|
||||
| `6206489b5fd` | Parts A/B/C — code + tests (token gone, system user gone, context restoration) |
|
||||
| `83cc4d4a07c` | Docs (ARCHITECTURE §8/§10 + changelog, OVERVIEW, FOLLOWUPS, auth-scoping-decision, CLAUDE) |
|
||||
|
||||
Two commits (code, then docs) — each leaves the tree green. Not pushed
|
||||
(parent pushes). No `--no-verify`; pre-commit passed on both.
|
||||
|
||||
## Part A — token dropped end-to-end
|
||||
|
||||
- `manager.py`: `_default_command` no longer emits `--token`; dropped the
|
||||
`TokenFactory` type, the `token_factory` ctor param, `self._tokens`, and
|
||||
the token-fetch block in `ensure_started`.
|
||||
- `__init__.py`: removed the `async_issue_sandbox_access_token` import, the
|
||||
`_issue_token` callback, and `token_factory=` from the `SandboxManager`
|
||||
construction.
|
||||
- Runtime `hass_client/sandbox/__init__.py`: dropped `SandboxRuntime.token`
|
||||
(field + ctor param) and the docstring mention. **Note:** the
|
||||
`sandbox_token` local in `run()` is the `current_sandbox` contextvar
|
||||
*reset* token — unrelated, left intact.
|
||||
- `hass_client/sandbox/__main__.py`: removed the `--token` argument + its
|
||||
plumbing into `SandboxRuntime`.
|
||||
- Docker: `SANDBOX_TOKEN` removed from `docker-entrypoint.sh`,
|
||||
`docker-compose.test.yml`, and the env-var table in `docs/docker.md`.
|
||||
|
||||
## Part C — per-group system user dropped
|
||||
|
||||
- `auth.py` **deleted entirely** — both `async_issue_sandbox_access_token`
|
||||
(Part A) and `async_get_or_create_sandbox_user` (Part C) are gone, so
|
||||
nothing remained. Imports removed from `bridge.py` and `__init__.py`.
|
||||
- `bridge.py`: removed `_async_system_user_id` and `self._system_user_id`.
|
||||
- A genuinely sandbox-originated context is now `Context(user_id=None)`.
|
||||
- **Future-work note left, not built:** a `Context` group attribute (which
|
||||
sandbox group originated an action) — captured in FOLLOWUPS.md "Still
|
||||
open" + ARCHITECTURE §10/§13. Needs a core `Context` field change.
|
||||
|
||||
## Part B — context-id restoration
|
||||
|
||||
**Where the cache is seeded (the real gap the design wanted closed).** The
|
||||
T2 cache was only seeded by sandbox-*inbound* resolution; it was never
|
||||
seeded where main hands a real `Context` *down*. Seeded at both call-down
|
||||
sites:
|
||||
|
||||
1. **Service forwarder** (`_build_service_forwarder._forward`, ~line 790):
|
||||
`bridge._remember_context(call.context)` right before sending
|
||||
`request.context_id = call.context.id`.
|
||||
2. **Entity-call path.** `SandboxProxyEntity._call_service` now passes
|
||||
`context=self._context` (the Context the service framework sets on the
|
||||
entity for the in-flight call — `service.py` calls
|
||||
`entity.async_set_context(call.context)` before invoking the method).
|
||||
`SandboxBridge.async_call_service` took a new `context: Context | None`
|
||||
param: it calls `self._remember_context(context)` and then reduces to
|
||||
`context_id = context.id` for the batcher. **The full Context is threaded
|
||||
only as far as `async_call_service` (the single caller is the proxy);
|
||||
the `_CallServiceBatcher` still carries just `context_id`** — so no
|
||||
invasive batcher refactor, and every Context main sends down is
|
||||
remembered regardless of how calls coalesce.
|
||||
|
||||
**Event-down path:** there is none. Events only flow sandbox→main
|
||||
(`_handle_fire_event`); main never forwards an event into a sandbox, so
|
||||
there was nothing to seed there. Confirmed by grep.
|
||||
|
||||
**Refinement honored (the mid-task correction):**
|
||||
- **Bounded by a 15-minute TTL, not a size cap.** `_CONTEXT_TTL =
|
||||
timedelta(minutes=15)`. The cache is an `OrderedDict` kept in
|
||||
insertion/expiry order (every write `move_to_end`s its key); since the
|
||||
TTL is constant, insertion order *is* expiry order, so `_prune_contexts`
|
||||
is a cheap front-to-back walk that stops at the first live entry, run
|
||||
lazily on every `_remember_context` / `_resolve_context`. A
|
||||
`_CONTEXT_CACHE_MAX = 2048` count cap remains only as a sanity backstop.
|
||||
- **Unknown id → main's OWN id, never the sandbox ULID.** `_resolve_context`
|
||||
for an unknown/expired id mints `Context(user_id=None)` (fresh
|
||||
main-generated id) and caches it **under the sandbox-supplied string as a
|
||||
key only** — the sandbox's id is never adopted as the Context's identity,
|
||||
because `context_id`s are ULIDs with an embedded ms timestamp main can't
|
||||
trust (a crafted id could back-/forward-date an event; recorder/logbook
|
||||
order by it).
|
||||
|
||||
`_resolve_context` / `_remember_context` are now sync (`@callback`) — the
|
||||
system-user lookup was the only `await`, and it's gone; the two
|
||||
`_handle_*` call sites dropped their `await`.
|
||||
|
||||
## Tests
|
||||
|
||||
- **HA-side:** `uv run pytest tests/components/sandbox/ --no-cov -q` →
|
||||
**197 passed**. (The protobuf `Struct` map-ordering test
|
||||
`test_protobuf_codec_round_trip_is_byte_identical` is pre-existing flaky —
|
||||
seed/order-dependent map serialization, not in this diff; it passed on
|
||||
every run except one randomly-ordered full-suite pass, and passes
|
||||
deterministically with `-p no:randomly`.)
|
||||
- **Client-side:** `uv run pytest sandbox/hass_client/ -q` → **77 passed**.
|
||||
- **hassfest:** `python -m script.hassfest --action validate` → 0 invalid
|
||||
integrations (the `turbojpeg` RuntimeError line is an unrelated import
|
||||
warning from another integration, not a validation failure).
|
||||
- **prek:** clean on all touched files (one ruff RUF059 auto-fixed: an
|
||||
unused `bridge` → `_bridge` in the new test; one import re-sort).
|
||||
|
||||
New / changed tests:
|
||||
- `test_bridge.py::test_forwarded_context_restores_on_echoed_state` —
|
||||
end-to-end known-id restore: a `ServiceCall` with
|
||||
`Context(user_id="user-1", parent_id="parent-1")` is forwarded into the
|
||||
sandbox; the sandbox echoes that `context_id` on a `state_changed`; the
|
||||
applied proxy state carries the **original** Context (verbatim).
|
||||
- `test_proto_transport.py`:
|
||||
- `test_resolve_context_restores_known_and_mints_fresh_unknown` — known
|
||||
restores verbatim; unknown gets `user_id=None` with `id != sandbox_id`
|
||||
(stable on repeat); `None` → `user_id=None`.
|
||||
- `test_resolve_context_entry_expires_after_ttl` — `freezer.tick(TTL+1s)`;
|
||||
the evicted id degrades to a fresh `user_id=None` context, no error.
|
||||
- `test_wire_messages_carry_only_context_id_no_attribution` — no-forgery:
|
||||
`StateChanged` / `FireEvent` / `CallService` descriptors have
|
||||
`context_id` but no `parent_id` / `user_id` field.
|
||||
- `test_state_changed_unknown_context_gets_fresh_no_user` — rewrite of the
|
||||
old system-user test: an unknown `context_id` lands with `user_id=None`,
|
||||
`parent_id=None`, and `id != "sandbox-ctx-1"`.
|
||||
- `test_auth.py` **deleted** (both helpers it tested are gone).
|
||||
- `test_manager.py::test_default_command_carries_name_and_url_only` —
|
||||
asserts `--token` not in argv (replaced the two token-factory tests).
|
||||
- Spawn-factory tests that drove the real runtime
|
||||
(`test_phase4_subprocess`, `test_phase9_shutdown`, `test_transport_unix`)
|
||||
had their `--token …` argv pairs removed — otherwise the now-stricter
|
||||
argparser would `SystemExit` and the subprocess would fail to start.
|
||||
- Client tests (`test_sandbox_runtime`, `test_transport_scheme`,
|
||||
`test_shutdown`, `pytest_plugin`) dropped `token=` / `--token`;
|
||||
`test_cli_parser_accepts_name_and_url` now also asserts `--token` is
|
||||
rejected. (The `current_sandbox.set/reset` tokens in
|
||||
`test_sandbox_bridge.py` are the contextvar reset token — left intact.)
|
||||
|
||||
No assertions were silently loosened — the rewrites flip the *expected
|
||||
value* to match the new model (user_id None / id-not-adopted) and add
|
||||
stronger checks (id ≠ sandbox id, no-forgery field check).
|
||||
|
||||
## Greps (hold)
|
||||
|
||||
- `grep -rn --include=*.py "async_get_or_create_sandbox_user|_system_user_id|async_issue_sandbox_access_token" homeassistant/ sandbox/` → **empty** (only matches are in historical STATUS-*/plan-* docs and the FOLLOWUPS narrative describing the removal).
|
||||
- `grep -rn "\-\-token|SANDBOX_TOKEN|\.token\b" homeassistant/components/sandbox/ sandbox/hass_client/hass_client/sandbox/` → **empty** (no live token plumbing).
|
||||
|
||||
## Docs updated
|
||||
|
||||
ARCHITECTURE.md (§2 table, §5 spawn cmd, §8 context model, §10 auth
|
||||
rewrite, §13 future-work, changelog row), OVERVIEW.md (auth row, spawn
|
||||
blocks, EventMirror context paragraph, auth section + new Context-restoration
|
||||
subsection, file-pointer table), docs/FOLLOWUPS.md (narrative entry + two
|
||||
refreshed "Still open" items), docs/auth-scoping-decision.md (one-line
|
||||
further-superseded note), CLAUDE.md (auth-scoping-decision pointer).
|
||||
|
||||
## Anything weird
|
||||
|
||||
- The plan file's Part B touch-point still mentions `event_mirror?` and
|
||||
"folds the T2 `_resolve_context`" — there is no separate event_mirror
|
||||
module on the main side (re-fire lives in `bridge._handle_fire_event`),
|
||||
and `_resolve_context` already existed (T2); Part B seeded it at the
|
||||
call-down sites and changed the unknown-id branch. No blocker.
|
||||
- Did **not** modify the plan file, the historical STATUS-* files, the
|
||||
WebSocket transport, or reintroduce any scope mechanism. Did **not**
|
||||
build the future `Context` group attribute (note left only).
|
||||
@@ -0,0 +1,171 @@
|
||||
# STATUS — plan-docker (test Dockerfile + unix-socket compose harness)
|
||||
|
||||
**One-line:** Shipped the multi-stage `python:3.14-slim` runtime image for the
|
||||
`hass_client` sandbox + docs + a forward-looking unix-socket compose harness.
|
||||
The image is correct and lean; the two-container compose harness does **not**
|
||||
run against today's manager (it spawns its own child runtime rather than
|
||||
attaching to an external one) — documented precisely as a small follow-up, not
|
||||
hacked. Could not build/parse with Docker (no daemon/CLI on this box); validated
|
||||
by review + `sh -n` + YAML parse. prek clean.
|
||||
|
||||
## Commits (not pushed — parent pushes)
|
||||
|
||||
| SHA | Subject |
|
||||
|-----|---------|
|
||||
| `1224f16df1e` | `sandbox_v2: test Dockerfile + unix-socket compose harness` |
|
||||
| `<this commit>` | `sandbox_v2: docker tracker tick + STATUS` |
|
||||
|
||||
The plan file was **not** modified.
|
||||
|
||||
## Files added/changed
|
||||
|
||||
- `sandbox_v2/hass_client/Dockerfile` (new) — the image.
|
||||
- `sandbox_v2/hass_client/.dockerignore` (new) — local build-context excludes
|
||||
(see context caveat below).
|
||||
- `sandbox_v2/hass_client/docker-entrypoint.sh` (new) — expands `SANDBOX_*`
|
||||
env into the runtime CLI flags and `exec`s the module.
|
||||
- `sandbox_v2/hass_client/docker-compose.test.yml` (new) — intended same-host
|
||||
unix-socket harness (forward-looking; see gap below).
|
||||
- `sandbox_v2/hass_client/docs/docker.md` (new) — full docs.
|
||||
- `sandbox_v2/hass_client/README.md` — Docker pointer section (replaced the
|
||||
stale "Phase 0 ships an empty package" line).
|
||||
- `sandbox_v2/CLAUDE.md` — repo-layout + tests pointers to the image.
|
||||
- `sandbox_v2/plans/whats-changed.md` — Test-Dockerfile box `[ ]`→`[x]`
|
||||
+ SHA `1224f16df1e`.
|
||||
|
||||
## Image design
|
||||
|
||||
- **Base:** `python:3.14-slim` (HA min is 3.14; pyproject `requires-python
|
||||
>=3.14.2`).
|
||||
- **Two stages:**
|
||||
- *builder* — `python -m venv /opt/venv`, then
|
||||
`pip install /src /src/sandbox_v2/hass_client`. `/src` (the repo root,
|
||||
added via `COPY`) installs the **local** `homeassistant` checkout; the second
|
||||
path installs `hass-client-v2` (its `homeassistant` dep already satisfied,
|
||||
plus `protobuf==6.32.0` + `aiohttp`).
|
||||
- *runtime* — copies only `/opt/venv` (chowned to the runtime user), adds
|
||||
`tini`, drops to a non-root user.
|
||||
- **Installed:** `homeassistant` core + `hass_client` + their base deps. **NOT
|
||||
installed:** integration manifest requirements (the runtime pip-installs them
|
||||
on demand at setup via `async_process_requirements`) and `git`.
|
||||
- **Entrypoint:** `tini -- docker-entrypoint.sh`, which `exec`s
|
||||
`python -m hass_client.sandbox_v2 --name $SANDBOX_NAME --url $SANDBOX_URL
|
||||
--token $SANDBOX_TOKEN --log-level $SANDBOX_LOG_LEVEL`. Module name unchanged
|
||||
(no `sandbox` rename — out of scope).
|
||||
- **Non-root:** user `sandbox` (uid 10001); the venv is chowned so the
|
||||
runtime's on-demand `pip install` can write into site-packages.
|
||||
- **No VOLUME / no state:** the runtime writes only an ephemeral
|
||||
`TemporaryDirectory` under the system temp dir (`hass_client/sandbox.py`);
|
||||
storage/restore-state routes to main; custom code is fetched at startup.
|
||||
- **No HEALTHCHECK** (commented why): readiness is the `Ready` frame on the
|
||||
channel, supervised by main — no port/HTTP probe.
|
||||
|
||||
### Deliberately NOT baked
|
||||
|
||||
- Integration requirements (runtime pip, on demand).
|
||||
- `git` (see below).
|
||||
- `build-essential` — left commented; a toggle for integrations whose wheels
|
||||
must compile at runtime, otherwise it just bloats the image.
|
||||
|
||||
## Was `git` needed?
|
||||
|
||||
**No.** Custom (HACS) integration code is fetched as a **codeload tarball**
|
||||
over `aiohttp` (`hass_client/sources.py` → `_default_fetch` /
|
||||
`https://codeload.github.com/<owner>/<repo>/tar.gz/<ref>`), not via a `git`
|
||||
clone. No `git` binary is required, so it is omitted.
|
||||
|
||||
## Could it be built?
|
||||
|
||||
**No — there is no Docker daemon or CLI on this machine** (`docker` /
|
||||
`docker compose` / `hadolint` all absent). So:
|
||||
|
||||
- `docker build …` — **not run.**
|
||||
- `docker compose … config` — **not run.** Instead validated the compose file
|
||||
is well-formed YAML with `python -c "yaml.safe_load(...)"` → valid.
|
||||
- `hadolint` — **not available**, so no Dockerfile lint. Reviewed by hand
|
||||
against the plan/brief constraints.
|
||||
- Entrypoint script `sh -n` → OK (shellcheck not installed).
|
||||
|
||||
Recommend a manual `docker build -f sandbox_v2/hass_client/Dockerfile -t
|
||||
sandbox_v2_test .` (context = repo root) on a box with a daemon before relying
|
||||
on the image. The one build risk worth watching: `pip install /src` building
|
||||
the local `homeassistant` wheel and pulling its base deps (expected; that is
|
||||
the image's bulk).
|
||||
|
||||
## Compose harness shape + the socket-path / spawn gap
|
||||
|
||||
`docker-compose.test.yml` models the intended **same-host unix-socket** harness:
|
||||
a `main` service + a `sandbox` service sharing a named volume (`/shared`) for the
|
||||
socket, with `SANDBOX_URL=unix:///shared/sandbox.sock`. **It is forward-looking
|
||||
and does not run against today's manager.** Two manager gaps, neither hacked:
|
||||
|
||||
1. **Socket path is not configurable.** `SandboxProcess._run_one_unix`
|
||||
(`homeassistant/components/sandbox_v2/manager.py:370`) puts the socket in a
|
||||
private per-attempt `tempfile.mkdtemp(...)/control.sock`, not on a shared
|
||||
path. The harness needs it on the shared volume; there is no option for that.
|
||||
2. **Spawn, not attach (the deeper gap).** The manager **spawns the runtime as
|
||||
its own child** (`create_subprocess_exec`, manager.py:388) and then listens
|
||||
for *that child* to dial back. It never waits for a separately started
|
||||
runtime to connect — so the compose `sandbox` service would never be used;
|
||||
`main` would spawn its own in-container child instead. A real two-container
|
||||
split needs a manager mode that listens on a known socket and **attaches** to
|
||||
an externally launched runtime.
|
||||
|
||||
So a cross-container harness needs (1)+(2), or the **websocket transport (T4)**
|
||||
(deferred), where `main` listens and the sandbox dials in over the network (no
|
||||
shared volume, no spawn). Today's working model is single-container: main spawns
|
||||
its sandbox children over stdio/unix inside one container. All of this is
|
||||
documented in `docs/docker.md` ("Compose harness gap") and in prominent comments
|
||||
in the compose file itself, so the file is not mistaken for a working harness.
|
||||
|
||||
**Follow-up (small):** add a manager "listen-only + configurable shared socket
|
||||
path" mode (or land WS/T4) to make the two-container harness real. The compose
|
||||
file is the ready-made template for when that lands.
|
||||
|
||||
## How this closes ephemeral-sources' pip/egress follow-up
|
||||
|
||||
`STATUS-plan-ephemeral-sources.md` follow-up #2 flagged that the bare-HA sandbox
|
||||
must run `async_process_requirements` (pip) for custom integrations that ship
|
||||
Python deps and needs network egress (GitHub + PyPI), which was unvalidated
|
||||
there. This image is the answer: the final stage keeps `pip` (in the venv,
|
||||
writable by the non-root user) and is documented to require **network egress at
|
||||
runtime** — the container is where pip + egress live. (Still not *exercised*
|
||||
end-to-end here, since there is no daemon to run it; the image is built to
|
||||
provide the capability the follow-up named.)
|
||||
|
||||
## Build-context / `.dockerignore` caveat
|
||||
|
||||
The documented build uses the **repo root** as context (the image installs the
|
||||
local `homeassistant`), so Docker reads the **repo-root** `.dockerignore` (which
|
||||
already excludes `.git`, `tests`, `.venv`, `docs`, `config`, `__pycache__`) — I
|
||||
did **not** modify that core file. The `.dockerignore` next to the Dockerfile
|
||||
applies only when the build context is `sandbox_v2/hass_client/` itself; it is
|
||||
kept per the brief and to document intent, and is self-sufficient for that case.
|
||||
|
||||
## Signal handling / tini
|
||||
|
||||
A bare Python process as PID 1 ignores default-action signals (e.g. SIGTERM
|
||||
from `docker stop`), so it would never shut down cleanly. The image bakes
|
||||
**`tini`** as PID 1 (forwards signals; the entrypoint `exec`s python so the
|
||||
runtime is tini's direct child). Documented alternative: drop tini and run with
|
||||
`docker run --init` / compose `init: true` (the compose file also sets
|
||||
`init: true` on the sandbox service as belt-and-braces). One small apt layer
|
||||
(`tini`) is the only system package added.
|
||||
|
||||
## prek result
|
||||
|
||||
`uv run prek run --files <7 touched files>` → codespell, yamllint, prettier all
|
||||
**Passed**; ruff/mypy/pylint/hassfest skipped (no matching files). No prettier
|
||||
reformat needed (files already conformant). "don't commit to branch" passed
|
||||
(on `sandbox`, not `dev`).
|
||||
|
||||
## Anything weird / gaps
|
||||
|
||||
- **The compose harness can't run today** — see the spawn-not-attach gap above.
|
||||
This is the main caveat. The Dockerfile + docs that ARE correct shipped; the
|
||||
harness ships as a documented template + follow-up, per the brief's fallback.
|
||||
- **No daemon to build/verify** — image correctness rests on review, not a real
|
||||
build. Flagged above with the one build risk to watch.
|
||||
- **tree-vs-ref verification** of fetched custom code remains as
|
||||
ephemeral-sources noted (out of scope here).
|
||||
- whats-changed Test-Dockerfile box ticked (`1224f16df1e`).
|
||||
@@ -0,0 +1,152 @@
|
||||
# STATUS — plan-ephemeral-sources (stateless sandboxes)
|
||||
|
||||
**One-line:** Shipped green — main now attaches a typed `IntegrationSource`
|
||||
to `entry_setup` (builtin no-op, or a sha-pinned git source), and the sandbox
|
||||
fetches custom (HACS) code into `<config>/custom_components/<domain>` before
|
||||
`async_setup`. Custom code is the last stateful bit; sandboxes are now
|
||||
wipe-and-restart safe. Both suites green, prek + drift clean.
|
||||
|
||||
## Commits (not pushed — parent pushes)
|
||||
|
||||
- `d4b7aef732f` — `sandbox_v2: stateless sandboxes — push integration source on entry_setup`
|
||||
(proto + resolver + wire + fetch + tests).
|
||||
- `<this commit>` — docs + tracker + STATUS.
|
||||
|
||||
## Proto change
|
||||
|
||||
Added to `sandbox_v2/proto/sandbox_v2.proto`:
|
||||
|
||||
```proto
|
||||
message IntegrationSource {
|
||||
string kind = 1; // "builtin" | "git"
|
||||
string url = 2; // git-only
|
||||
string ref = 3; // exact commit sha
|
||||
string tag = 4; // human-readable, logs only
|
||||
string domain = 5;
|
||||
string subdir = 6; // path within the repo
|
||||
}
|
||||
```
|
||||
|
||||
and `IntegrationSource integration_source = 10;` on `EntrySetup` (next free
|
||||
field number; 1–9 were taken). Regenerated both `_pb2.py` + `_pb2.pyi`
|
||||
mirrors via the isolated-venv `generate.sh`; the two mirrors are byte-identical
|
||||
to each other. **Registry unchanged**: `IntegrationSource` is a nested field on
|
||||
an existing frame message (`EntrySetup`), not a new top-level wire type, so
|
||||
`messages.py`'s `REGISTRY` needed no entry (confirmed).
|
||||
|
||||
## Resolver contract (core side — `homeassistant/components/sandbox_v2/sources.py`)
|
||||
|
||||
- `async_register_sandbox_source_resolver(hass, resolver) -> unregister` —
|
||||
`@callback`. Stores resolvers in
|
||||
`hass.data[HassKey("sandbox_v2_source_resolvers")]` (a list, consulted in
|
||||
registration order; returns an unregister callback). A resolver is
|
||||
`Callable[[str], IntegrationSourceDict | None]`.
|
||||
- `async_resolve_integration_source(hass, domain) -> pb.IntegrationSource` —
|
||||
built-ins short-circuit to `{kind: "builtin"}` via `Integration.is_built_in`
|
||||
(no resolver consulted). For a custom integration, the registered resolvers
|
||||
are consulted in order; first non-`None` wins. **No resolver / all return
|
||||
`None` → raises `SandboxSourceError`** (a custom integration cannot run in a
|
||||
stateless sandbox without a source — surfaced, not silently fallen back).
|
||||
- Default (no resolver registered) is therefore builtin-only, as specified.
|
||||
|
||||
## Tag→sha pinning — where it happens
|
||||
|
||||
Core performs **no network I/O**, so it cannot itself turn a tag into a sha.
|
||||
The contract delegates the pin to the resolver: the resolver MUST return `ref`
|
||||
as an exact commit sha (HACS already knows the sha of the installed version);
|
||||
`tag` is logs-only. `_git_source_from_dict` **enforces** this — a git source
|
||||
without `ref` raises `SandboxSourceError("…must pin the version to an exact
|
||||
commit sha")`, so main never ships a sandbox a moving reference. This is the
|
||||
one deviation from the brief's literal "main resolves tag→sha": main *requires*
|
||||
the sha rather than resolving it, because resolving would mean a network call
|
||||
in core. Documented in the resolver docstring + `sources.py` module docstring.
|
||||
|
||||
## Fetch + cache (sandbox side — `sandbox_v2/hass_client/hass_client/sources.py`)
|
||||
|
||||
- `async_ensure_integration_source(config_dir, source, *, fetch=None)`:
|
||||
- `kind in ("", "builtin")` → no-op.
|
||||
- `kind == "git"` → if `<config_dir>/custom_components/<domain>/manifest.json`
|
||||
is absent, download the tarball for the exact `ref` and extract the repo's
|
||||
`subdir` into the dest.
|
||||
- **Fetch mechanism:** GitHub codeload tarball
|
||||
(`https://codeload.github.com/<owner>/<repo>/tar.gz/<ref>`), no `git` binary
|
||||
dependency (matches HACS). The download primitive `(url, ref) -> bytes` is
|
||||
**injected** — default does a one-shot `aiohttp` GET (imported lazily);
|
||||
tests pass a local stub.
|
||||
- **Cache:** module-level `_TARBALL_CACHE: dict[(url, ref) -> bytes]` guarded
|
||||
by an `asyncio.Lock`, **process-lifetime only** (nothing survives a process
|
||||
restart → honours "stateless"). Multiple entries from the same repo download
|
||||
once.
|
||||
- Wired into `EntryRunner._handle_entry_setup` **before**
|
||||
`config_entries.async_setup`, using `self.hass.config.config_dir`.
|
||||
`EntryRunner.__init__` gained an optional `fetch=` for test injection.
|
||||
|
||||
## Verification of the fetched tree
|
||||
|
||||
The codeload tarball for a sha is content-addressed by GitHub (the sha *is* the
|
||||
identity), so the transport already binds bytes→ref. On top of that the
|
||||
extractor: rejects path-traversal members (anything resolving outside dest),
|
||||
skips non-file members (symlinks/devices), requires ≥1 file under
|
||||
`<top>/<subdir>/`, and **requires `manifest.json`** in the dest afterwards
|
||||
(raises otherwise). It does **not** recompute the git tree hash to assert it
|
||||
equals `ref` — that would mean reimplementing git's tree-hashing, which is more
|
||||
than the "at minimum non-empty + manifest.json" bar the brief set. Noted as the
|
||||
weaker-than-ideal spot.
|
||||
|
||||
## Test results (exact)
|
||||
|
||||
- HA core: `uv run pytest tests/components/sandbox_v2/ --no-cov -q` →
|
||||
**201 passed, 2 warnings**. New this plan: `test_sources.py` (7 resolver
|
||||
tests) + 2 `_entry_setup_payload` tests in `test_router.py`. (The rest of
|
||||
the delta from T2's 189 is the T3 unix-transport suite that landed between.)
|
||||
- Client: `uv run pytest sandbox_v2/hass_client/ -q` → **70 passed, 1 warning**.
|
||||
New this plan: `test_sources.py` (8 fetch tests).
|
||||
- `uv run prek run --files <13 touched files>` → ruff/ruff-format/codespell/
|
||||
prettier/mypy/pylint all pass.
|
||||
- Drift guard: `bash sandbox_v2/proto/check_drift.sh` → "gencode matches
|
||||
sandbox_v2.proto."
|
||||
- `grep -rn "integration_source" sandbox_v2/proto/sandbox_v2.proto` → present
|
||||
(`IntegrationSource integration_source = 10;`).
|
||||
|
||||
**No test hits the network.** Every fetch in `test_sources.py` (both sides)
|
||||
uses an in-memory local tarball fixture / stub `fetch` primitive; the default
|
||||
`aiohttp` path is never exercised by a test. Resolver tests use mocked
|
||||
integrations (`mock_integration`), no real loading.
|
||||
|
||||
## Doc updates
|
||||
|
||||
- `protocol.py` (HA side) — documented the `integration_source` field on the
|
||||
`entry_setup` entry. Client `protocol.py` defers to the HA catalogue, unchanged.
|
||||
- `OVERVIEW.md` — new "Integration source — fetch before setup (stateless)"
|
||||
section; entry-lifecycle note that `EntryRunner` fetches before setup.
|
||||
- `CLAUDE.md` — new "Stateless sandboxes — integration source" section: the
|
||||
resolver-hook contract, the sha-pin rule, and the statelessness payoff, plus
|
||||
the pip/egress runtime gap as a follow-up.
|
||||
- `architecture.html` — light-touch: entry-lifecycle line + a stateless
|
||||
fetch-before-setup subsection.
|
||||
- `whats-changed.md` — "Custom (HACS) integrations are fetched at startup"
|
||||
`[ ]`→`[x]` + SHA `d4b7aef732f`.
|
||||
|
||||
## Anything weird / follow-ups
|
||||
|
||||
1. **Tree-vs-ref verification is weaker than a full git-tree-hash check** (see
|
||||
above): we trust GitHub's content-addressed codeload URL + assert a
|
||||
non-empty tree with `manifest.json`. Sufficient for the threat model the
|
||||
plan describes (the sha is pinned on the wire; the sandbox is the isolation
|
||||
boundary for untrusted custom code) but not a cryptographic tree match.
|
||||
2. **`async_process_requirements` (pip for custom deps) is NOT confirmed to run
|
||||
in the bare-HA sandbox.** The sandbox client disables nothing — the normal
|
||||
`config_entries.async_setup` → `async_process_deps_reqs` →
|
||||
`async_process_requirements` path is intact, so it *would* attempt a pip
|
||||
install — but whether `pip` + network egress (GitHub + PyPI) are actually
|
||||
available in the sandbox process is unverified here and untestable without a
|
||||
real environment. A custom integration that ships Python deps would fetch
|
||||
its code fine but fail to import its deps until this is resolved. **This is
|
||||
the plan's §"Runtime requirements" gap — left as a follow-up that pairs with
|
||||
`plan-docker.md` (the ephemeral container that provides pip + egress).** Per
|
||||
the brief, the testable wire + fetch are shipped; this runtime gap is flagged,
|
||||
not faked.
|
||||
3. The fetch holds `_CACHE_LOCK` across the network download, serializing
|
||||
concurrent fetches from different repos. Deliberate (prevents duplicate
|
||||
concurrent downloads of the same `(url, ref)`); startup fetches are few and
|
||||
one-shot, so the serialization cost is negligible.
|
||||
@@ -0,0 +1,107 @@
|
||||
# STATUS — plan-fidelity-batch
|
||||
|
||||
All six fidelity items (#2, #7, #5, #6, #4) plus the appendix lockdown
|
||||
(point 1) landed as independent commits on branch `sandbox`, each passing
|
||||
the relevant test suites; a final commit ticks the tracker, sweeps the
|
||||
docs, and adds this STATUS file.
|
||||
|
||||
## Commits (in landing order)
|
||||
|
||||
| # | SHA | Subject |
|
||||
|---|-----|---------|
|
||||
| 1 (#2) | `969834845b4` | sandbox_v2: rename CLI flag --group to --name (fidelity #2) |
|
||||
| 2 (#7) | `fd05b17a25b` | sandbox_v2: reconstruct vol.Invalid across the bridge (fidelity #7) |
|
||||
| 3 (#5) | `3833290b165` | sandbox_v2: prefix proxy entity unique_id with source domain (fidelity #5) |
|
||||
| 4 (#6) | `c5c7e4adcb5` | sandbox_v2: make register_entity an idempotent upsert (fidelity #6) |
|
||||
| 5 (#4) | `94804369825` | sandbox_v2: lossless data_schema reconstruction (fidelity #4) |
|
||||
| 6 (appendix) | `f66e7e40344` | sandbox_v2: blanket ALWAYS_MAIN for ~18 helpers (fidelity appendix / point 1) |
|
||||
| 7 (tracker/docs/STATUS) | _this commit_ | sandbox_v2: tick whats-changed + docs sweep + STATUS (fidelity batch close) |
|
||||
|
||||
## Per-item summary + test results
|
||||
|
||||
### #2 — CLI flag `--group` → `--name`
|
||||
- Client `__main__.py`: arg renamed, help text updated, `SandboxRuntime(group=args.name)`.
|
||||
- `manager._default_command` emits `"--name"`.
|
||||
- Stub argv factories in `test_manager`/`test_phase9_shutdown`/`test_phase4_subprocess`
|
||||
(which launch the real module) + the parser test all updated.
|
||||
- Tests: `tests/components/sandbox_v2/{test_manager,test_phase9_shutdown,test_phase4_subprocess}.py`
|
||||
+ `hass_client/tests/test_sandbox_runtime.py` — **pass**.
|
||||
|
||||
### #7 — Reconstruct `vol.Invalid` instead of `TypeError`
|
||||
- Both `channel.py` files: new `error_data_for()` serialises `vol.Invalid`
|
||||
(`{kind:invalid, msg, path}`) and `vol.MultipleInvalid`
|
||||
(`{kind:multiple, errors:[…]}`); path parts stringified. Error frame carries
|
||||
`error_data`; `_dispatch` threads it into `ChannelRemoteError(error_data=…)`.
|
||||
- `bridge._translate_remote_error` rebuilds the real `vol.Invalid` /
|
||||
`vol.MultipleInvalid` (with `.path`) from `error_data`, falling back to the
|
||||
class-name mapping when absent.
|
||||
- Tests: wire round-trip (`test_channel.py`, both invalid + multiple),
|
||||
`_translate_remote_error` rebuild + fallback (`test_bridge.py`), client wire
|
||||
round-trip (`test_sandbox_bridge.py`) — **pass**.
|
||||
|
||||
### #5 — Prefix proxy `unique_id` with source domain
|
||||
- `UNIQUE_ID_SEPARATOR = ":"` documented in `const.py`.
|
||||
- `_handle_register_entity` prefixes `unique_id` with `entry.domain`; `None`
|
||||
stays `None`.
|
||||
- Test: two integrations reusing `"1"` land without collision with distinct
|
||||
`demo_a:1` / `demo_b:1` registry rows (`test_bridge.py`) — **pass**.
|
||||
|
||||
### #6 — Idempotent / updatable `register_entity`
|
||||
- Client `EntityBridge` listens on `EVENT_ENTITY_REGISTRY_UPDATED` and
|
||||
`EVENT_DEVICE_REGISTRY_UPDATED`, re-describes + re-sends `MSG_REGISTER_ENTITY`
|
||||
for tracked entities, guarded by a hash of the description's mirrored fields
|
||||
(state-shaped keys excluded) to suppress event storms.
|
||||
- Main `_handle_register_entity` upserts: existing proxy → `sandbox_update_description`
|
||||
(refreshes `_attr_*`, preserves the subclass's `IntFlag` supported_features
|
||||
type, re-runs the idempotent device `async_get_or_create`, writes state);
|
||||
else the create path.
|
||||
- Tests: client entity-registry resend + hash-guard suppression, client
|
||||
device-registry resend (`test_entity_bridge.py`); main name-upsert (no
|
||||
duplicate) + device-firmware upsert (`test_bridge.py`) — **pass**.
|
||||
|
||||
### #4 — Lossless `data_schema` survival
|
||||
- `reconstruct_schema` rebuilds real `selector.selector(entry["selector"])`
|
||||
and `data_entry_flow.section(reconstruct(...), {"collapsed": …})`; keeps
|
||||
string/int/float/bool/select; pass-through only for genuinely unknown shapes.
|
||||
- Serialize-side `_has_data_schema` fallback now logs the dropped schema's repr
|
||||
at warning (`flow_runner.py`).
|
||||
- Test: a schema with `SelectSelector` + `NumberSelector` inside a `section`
|
||||
round-trips serialize → reconstruct → re-serialize **equal** to the original
|
||||
(`test_phase14.py`) — **pass**.
|
||||
|
||||
### Appendix — blanket `ALWAYS_MAIN`
|
||||
- Added broad readers (`template`, `group`, `homekit`) + 15 source-entity
|
||||
helpers to `ALWAYS_MAIN`, each with a one-line why.
|
||||
- Verified `prometheus` + `alert` are `config_flow: false` (YAML-only) → already
|
||||
route to main, so **not** added (matches the plan).
|
||||
- Test: dedicated parametrised `test_lockdown_helpers_pin_to_main` + the
|
||||
existing live-set parametrised test (`test_classifier.py`) — **pass**.
|
||||
|
||||
## Final test run
|
||||
- `uv run pytest tests/components/sandbox_v2/ --no-cov -q` → **182 passed**.
|
||||
- `uv run pytest sandbox_v2/hass_client/ -q` → **53 passed**.
|
||||
- `uv run prek run --files <all touched files>` → clean (ruff/mypy/pylint/prettier/codespell).
|
||||
|
||||
## Doc updates (commit 7)
|
||||
- `whats-changed.md`: ticked all 6 batch boxes with their commit SHAs.
|
||||
- `OVERVIEW.md`: refreshed the entity-bridge section (upsert + registry-event
|
||||
resend, unique_id prefix), exception-translation section (vol.Invalid rebuild),
|
||||
and schema-reconstruction section (real selectors/sections); `--name` in the
|
||||
run-by-hand snippet + ASCII diagram.
|
||||
- `README.md`, `architecture.html`: `--group` → `--name` in run snippets.
|
||||
- Left historical records untouched: `STATUS-phase-*`, `plans/interview.md`,
|
||||
`plans/plan-v1-removal.md` (the Phase D instruction), `docs/FOLLOWUPS.md`.
|
||||
|
||||
## Anything weird
|
||||
- The client device-registry resend test needs the entity registry loaded and a
|
||||
real device, which the minimal sandbox-private `FlowRunner` hass doesn't
|
||||
bootstrap. The test sets up `dr`/`er` explicitly, registers a config entry via
|
||||
the internal `_entries` collection, and creates a device — heavier than the
|
||||
other client tests but the only faithful way to exercise the device→entity
|
||||
lookup.
|
||||
- The hash-guard test had a real ordering subtlety: the client's resend sets the
|
||||
description hash only *after* its `await channel.call(...)` returns, which is a
|
||||
tick after main records the call. The test settles a few event-loop ticks
|
||||
before firing the duplicate event so the guard is actually exercised.
|
||||
- Did **not** touch `IGNORE_INTEGRATIONS_WITH_ERRORS`, did **not** start the
|
||||
`sandbox` rename, did **not** push — per the brief. Parent pushes.
|
||||
@@ -0,0 +1,178 @@
|
||||
# STATUS — plan-rename-sandbox
|
||||
|
||||
**Summary:** `sandbox_v2` → `sandbox` renamed across the whole tree (directories,
|
||||
integration domain, channel wire strings, storage-key namespace, CLI module,
|
||||
protobuf, client_id / system-user-name prefixes, docs). Both test suites green
|
||||
at unchanged counts (HA-side 201, client 70); proto drift guard clean; hassfest
|
||||
clean without the v1 ignore set. This closes the `v2` chapter.
|
||||
|
||||
## Commits (oldest → newest, not pushed — parent pushes)
|
||||
|
||||
| SHA | Phase | Subject |
|
||||
|-----|-------|---------|
|
||||
| `107cb8b38e8` | A | rename directories sandbox_v2 → sandbox (git mv) |
|
||||
| `cd024666128` | B | sweep identifiers sandbox_v2 → sandbox + regen protobuf |
|
||||
| `5bab9f867bf` | C | drop hassfest IGNORE_INTEGRATIONS_WITH_ERRORS |
|
||||
| `9cd52e950e4` | E | docs reconciliation for the rename |
|
||||
|
||||
Each commit leaves the tree in a known state. Phase A alone does **not** import
|
||||
or pass tests (expected — Phase B fixes every identifier). Phase B onward is
|
||||
green.
|
||||
|
||||
## Phase A — the directory renames (git mv)
|
||||
|
||||
5 planned; **4 applied as `git mv`, 1 skipped**:
|
||||
|
||||
1. ✅ `homeassistant/components/sandbox_v2` → `homeassistant/components/sandbox`
|
||||
2. ✅ `tests/components/sandbox_v2` → `tests/components/sandbox`
|
||||
3. ✅ `sandbox_v2` → `sandbox`
|
||||
4. ✅ `sandbox/hass_client/hass_client/sandbox_v2` → `…/sandbox`
|
||||
*(the launcher subpackage — see the collision note below)*
|
||||
5. ⏭️ `tests/testing_config/.storage/sandbox_v2` — **skipped, not tracked**
|
||||
(a runtime test artifact; `git ls-files` returns nothing for it). Left on
|
||||
disk; orphaned (the renamed code writes to `.storage/sandbox/`).
|
||||
|
||||
Plus a 6th file rename folded into Phase A: `sandbox/proto/sandbox_v2.proto`
|
||||
→ `sandbox/proto/sandbox.proto` (`git mv`).
|
||||
|
||||
`git log --diff-filter=R --name-status 107cb8b38e8` shows **189 `R` rename
|
||||
entries** — blame preserved. (Empty `__init__.py` files and `strings.json`
|
||||
show as add+delete pairs rather than `R` because git can't content-match
|
||||
0-byte / small files across a rename; their `sandbox_v2/…` counterparts are
|
||||
deleted in the same commit — they are real renames, not new files.)
|
||||
|
||||
## Phase B — identifier sweep
|
||||
|
||||
- Bare-token `sandbox_v2` → `sandbox` over 103 files (code + current-state
|
||||
docs), excluding historical `STATUS-*.md`, `plans/*.md`,
|
||||
`docs/auth-scoping-decision.md`, and the generated `_pb2` gencode.
|
||||
- Prose `Sandbox v2` → `Sandbox` (single pass also fixes `Sandbox v2: ` →
|
||||
`Sandbox: ` system-user-name prefix). `auth.py` now:
|
||||
`_USER_NAME_PREFIX = "Sandbox: "`, `_CLIENT_ID_PREFIX = "sandbox/"`.
|
||||
- Non-obvious targets all swept: channel message strings (`sandbox/call_service`,
|
||||
`sandbox/entry_setup`, `sandbox/ready`, `sandbox/state_changed`, …) on both
|
||||
sides + the proto; storage-key namespace
|
||||
(`<config>/.storage/sandbox/<group>/<key>` — `bridge.py`); manifest domain
|
||||
(`"sandbox"`); `requirements_all.txt` section comment.
|
||||
|
||||
### Full proto rename — DONE (not the fallback)
|
||||
|
||||
- Renamed `sandbox_v2.proto` → `sandbox.proto`; internal `package sandbox_v2;`
|
||||
→ `package sandbox;` + comment paths swept.
|
||||
- **Regenerated** the gencode via `sandbox/proto/generate.sh` (isolated venv,
|
||||
protobuf 6.32.0 + grpcio-tools): module `sandbox_v2_pb2` → `sandbox_pb2` in
|
||||
**both** mirrors; old `sandbox_v2_pb2.py(+.pyi)` `git rm`'d. New descriptor:
|
||||
`name=sandbox.proto`, `package=sandbox`, 42 messages.
|
||||
- All import sites updated by the bare sweep (`sandbox_v2_pb2` → `sandbox_pb2`).
|
||||
- `generate.sh` + `check_drift.sh` + `.pre-commit-config.yaml` paths/filename
|
||||
swept.
|
||||
- **Drift guard passes:** `sandbox proto drift guard: gencode matches
|
||||
sandbox.proto.`
|
||||
- `rg sandbox_v2_pb2` → only 3 historical files (`plans/plan-transport.md`,
|
||||
`STATUS-plan-transport.md`, `STATUS-plan-transport-T2.md`), deliberately
|
||||
preserved.
|
||||
|
||||
### Name-collision fix (forced by the rename — judgment call, documented)
|
||||
|
||||
The client had **two** things that both want the name `sandbox` after the
|
||||
rename:
|
||||
- `hass_client/sandbox.py` — the impl module (exports `SandboxRuntime`,
|
||||
`_open_unix_channel`, `_transport_scheme`).
|
||||
- `hass_client/sandbox_v2/` — a `-m` launcher subpackage (`__init__.py` +
|
||||
`__main__.py`) that does `from hass_client.sandbox import SandboxRuntime`.
|
||||
|
||||
Renaming the launcher subpackage to `sandbox` (Phase A mv #4) collides with the
|
||||
impl module. The plan/brief assumed `python -m hass_client.sandbox` would just
|
||||
work; it can't while a sibling `sandbox.py` exists. **Resolution: merged them.**
|
||||
`sandbox.py` is now `hass_client/sandbox/__init__.py`; the launcher's
|
||||
`__main__.py` stays. So:
|
||||
- `python -m hass_client.sandbox` runs `sandbox/__main__.py` ✅
|
||||
- `from hass_client.sandbox import SandboxRuntime` resolves to the package
|
||||
`__init__.py` ✅
|
||||
|
||||
The merged `__init__.py`'s parent-relative imports (`from ._proto …` → would be
|
||||
`hass_client.sandbox._proto`) were rewritten to **absolute** `from hass_client.…`
|
||||
(ruff `TID252` bans parent-relative imports in this repo).
|
||||
|
||||
### Docker / pyproject
|
||||
|
||||
- `docker-entrypoint.sh`, `docker-compose.test.yml`, `docs/docker.md`,
|
||||
`Dockerfile` → `python -m hass_client.sandbox` (the bare sweep handled the
|
||||
module path; the entrypoint comment "do not rename it here" is now
|
||||
consistent).
|
||||
- Client **distribution** name `hass-client-v2` → `hass-client` in
|
||||
`pyproject.toml` (the import package `hass_client` is unchanged; this matches
|
||||
the already-installed `hass_client.egg-info` whose `PKG-INFO` Name is
|
||||
`hass-client`). Description reworded to drop the dangling "v2".
|
||||
|
||||
## Phase C — hassfest
|
||||
|
||||
- ✅ `IGNORE_INTEGRATIONS_WITH_ERRORS` set **deleted** from
|
||||
`script/hassfest/__main__.py` (plus the two list-comprehension conditionals
|
||||
that consulted it). It existed to mask v1's broken state; v1 is gone.
|
||||
- ✅ Renamed integration passes hassfest **naturally**:
|
||||
`python -m script.hassfest --action validate` → **0 invalid integrations**;
|
||||
`--action generate` → **0 invalid, no generated-file changes**.
|
||||
- `homeassistant/generated/config_flows.py`: **no change needed** — `sandbox`
|
||||
has no `config_flow` in its manifest, so it was never listed there (it was not
|
||||
in the `sandbox_v2` grep set either).
|
||||
- `NO_QUALITY_SCALE` entry (`script/hassfest/quality_scale.py`): `sandbox_v2` →
|
||||
`sandbox`, correctly sorted (renamed by the Phase B sweep).
|
||||
|
||||
## Phase D — verification
|
||||
|
||||
```
|
||||
rg sandbox_v2 -g '!sandbox/STATUS-*.md' -g '!sandbox/plans/*.md' \
|
||||
-g '!sandbox/docs/auth-scoping-decision.md' → EMPTY ✓
|
||||
rg '"Sandbox v2"' -g '!sandbox/STATUS-*.md' -g '!sandbox/plans/*.md' → EMPTY ✓
|
||||
rg sandbox_v2_pb2 → only 3 historical files ✓
|
||||
```
|
||||
|
||||
Tests (same counts as before the rename):
|
||||
- HA-side: `pytest tests/components/sandbox/ --no-cov -q` → **201 passed**
|
||||
- Client: `pytest sandbox/hass_client/ -q` → **70 passed**
|
||||
- `tests/auth/test_auth_store.py` (swept the `Sandbox v2: built-in` literal) →
|
||||
**11 passed**
|
||||
- Drift guard → clean.
|
||||
|
||||
`prek`:
|
||||
- **`prek run --files <all 196 changed files>` → fully clean** (ruff, ruff
|
||||
format, codespell, prettier, mypy, pylint, hassfest, gen_requirements all
|
||||
Pass). This covers 100% of the files the rename touched.
|
||||
- **`prek run --all-files` fails on ONE pre-existing, rename-unrelated mypy
|
||||
artifact:** `homeassistant/util/hass_dict.py` + `.pyi` →
|
||||
`error: Duplicate module named "homeassistant.util.hass_dict"`. Both files
|
||||
existed at the batch base (`4e982e34cad`) and are **unchanged** by the rename;
|
||||
this duplicate-module error only surfaces when mypy is fed every `.py`+`.pyi`
|
||||
pair at once (i.e. `--all-files` mode), and aborts mypy before the later
|
||||
hooks run. Not introduced here. The scoped run above is the authoritative
|
||||
clean result.
|
||||
|
||||
## Things worth flagging
|
||||
|
||||
- **Storage-key orphaning (expected, pre-release).** Old dev instances persist
|
||||
data under `<config>/.storage/sandbox_v2/<group>/<key>` and auth users named
|
||||
`Sandbox v2: <group>` with client_id `sandbox_v2/<group>`. The renamed code
|
||||
reads/writes the `sandbox` namespace; old data orphans harmlessly. No
|
||||
migration (per [[plan-ephemeral-sources]] wipe-and-restart preference). Wipe
|
||||
`.storage/sandbox_v2/` after upgrading.
|
||||
- **Untracked `.storage/sandbox_v2/` dir** left on disk (test artifact); the
|
||||
matching `git mv` was skipped because it isn't tracked.
|
||||
- **Env fixups (not committed, local venvs only):** the client venv was
|
||||
pre-staged at the rename-target path `sandbox/hass_client/.venv` (editable
|
||||
install already pointing at `sandbox/hass_client/hass_client`); I (a) restored
|
||||
it into the renamed dir, (b) re-pointed the **main** `.venv`'s `hass_client`
|
||||
editable finder from the old `sandbox_v2/…` path to `sandbox/…`, and (c)
|
||||
installed the declared `protobuf==6.32.0` into the client venv (it was
|
||||
missing). `uv.lock` for the client was untracked and is gone; client tests
|
||||
were run via the venv python directly (`.venv/bin/python -m pytest`) rather
|
||||
than `uv run` to avoid a network re-sync.
|
||||
- **`plan.md` (top-level) was swept** to `sandbox`. It is NOT in the protected
|
||||
set (only `plans/plan-*.md` are), and the Phase D grep requires it clean.
|
||||
Historical `STATUS-*.md`, `plans/*.md` (incl. `interview.md`, the plan files,
|
||||
`whats-changed.md`) and `docs/auth-scoping-decision.md` keep their
|
||||
`sandbox_v2` mentions intact.
|
||||
- **`OVERVIEW.md` / `Dockerfile`** references to `hass_client/sandbox.py` were
|
||||
updated to `hass_client/sandbox/__init__.py` (the file moved in the
|
||||
collision-merge); one ASCII box-diagram line in OVERVIEW.md was re-padded to
|
||||
keep its border aligned.
|
||||
@@ -0,0 +1,110 @@
|
||||
# STATUS — plan-sandbox-context Phase A1
|
||||
|
||||
**One-line:** `current_sandbox` ContextVar landed in core HA; `Store`
|
||||
load/save/remove route to a `SandboxBridge` when set; the sandbox runtime
|
||||
sets it before warm-load. Additive only — `install_remote_store` stays, both
|
||||
paths active. All suites green, prek clean, single commit.
|
||||
|
||||
**Commit:** `d0bbd340289b566c2f4ca26b879a1bf2f71f413f`
|
||||
(`sandbox_v2: route Store IO via current_sandbox contextvar (Phase A1)`)
|
||||
on branch `sandbox` (not pushed — parent pushes).
|
||||
|
||||
## What landed
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `homeassistant/helpers/sandbox_context.py` | **NEW.** `current_sandbox: ContextVar[SandboxBridge \| None]` (`default=None`) + `SandboxBridge` Protocol (3 store methods only; IR/RF deferred per Q1). Module + Protocol docstrings carry the Risk #3 "never set from main-side code" rule. Matches `helpers/http.py` / `helpers/chat_session.py` shape. |
|
||||
| `homeassistant/helpers/storage.py` | Import `current_sandbox`. `_async_load_data`: new `elif sandbox := current_sandbox.get():` branch fetches the wrapped envelope via `async_store_load` (returns `None` → `None`); the existing migration block runs unchanged against it (design **B**). `async_save`: contextvar branch is the first action after building the wrapped dict — pushes via `async_store_save`, clears `_data`, returns (bypasses write-lock/manager/final-write machinery). `async_remove`: keeps the in-memory invalidate + listener cleanup (matching `RemoteStore.async_remove`), then branches to `async_store_remove`. |
|
||||
| `sandbox_v2/hass_client/hass_client/sandbox_bridge.py` | **NEW.** `ChannelSandboxBridge` — three store methods over `MSG_STORE_LOAD/SAVE/REMOVE`, bodies lifted from `RemoteStore` incl. the orjson preserialise (`prepare_save_json` → `json.loads`) on save and the same `ChannelClosedError`/`ChannelRemoteError` handling. |
|
||||
| `sandbox_v2/hass_client/hass_client/sandbox.py` | Import `current_sandbox` + `ChannelSandboxBridge`. In `run()`, inside `if self._channel is not None:` and **before** `install_remote_store` / `start` / `_load_restore_state` / handler registration: `assert current_sandbox.get() is None` (Risk #3), then `sandbox_token = current_sandbox.set(ChannelSandboxBridge(self._channel))`. `install_remote_store` **kept** (both paths active). Teardown `finally` does `current_sandbox.reset(sandbox_token)`. Added a comment documenting the registry-ordering caveat from the plan's touch-points audit. |
|
||||
| `sandbox_v2/hass_client/tests/test_sandbox_bridge.py` | **NEW.** The five required tests + one extra (see below). |
|
||||
|
||||
## Tests
|
||||
|
||||
All commands run from the repo (core env unless noted):
|
||||
|
||||
- `uv run pytest sandbox_v2/hass_client/ -q` → **55 passed** (client env;
|
||||
includes the new file plus the still-present `test_remote_store.py` and
|
||||
`test_shutdown.py`, both green with dual paths active).
|
||||
- `uv run pytest tests/components/sandbox_v2/ --no-cov -q` → **138 passed**.
|
||||
- `uv run pytest tests/helpers/test_storage.py tests/helpers/test_restore_state.py --no-cov -q` → **52 passed** (regression guard for the core `Store` change).
|
||||
- `uv run prek run --files <the 5 touched files>` → **clean** (ruff, ruff-format, codespell, mypy, pylint, prettier all Passed). Commit's own pre-commit run also passed — no `--no-verify`.
|
||||
|
||||
### The six tests in the new file
|
||||
|
||||
1. `test_load_routes_to_bridge_and_unwraps` — load via contextvar reaches the `_FakeBridge` by key, returns unwrapped data. (plan #1)
|
||||
2. `test_load_returns_none_when_bridge_has_no_data` — missing key → `None`.
|
||||
3. `test_migration_runs_through_bridge` — **parametrized 2-arg / 3-arg** `_async_migrate_func`; migration fires through the contextvar path and the post-migration `async_save` recurses back through the bridge. (plan #2)
|
||||
4. `test_no_sandbox_round_trip_uses_local_disk` — contextvar unset → real disk save/load round-trip; no-sandbox regression guard. (plan #3)
|
||||
5. `test_restore_state_warm_load_without_workaround` — vanilla `RestoreStateData` (captured original `Store` at import) routes `async_load` to the bridge purely because the contextvar is set, no store swap. The smoking gun for A2's workaround deletion. (plan #4)
|
||||
6. `test_contextvar_inherits_across_create_task` — contextvar set in body, task spawned after, load reaches the bridge. (plan #5)
|
||||
|
||||
Plus `test_channel_bridge_maps_store_rpcs` — **one extra beyond the required
|
||||
five** (see Deviations).
|
||||
|
||||
## Deviations from the plan
|
||||
|
||||
1. **Added a 6th test** (`test_channel_bridge_maps_store_rpcs`) driving the
|
||||
real `ChannelSandboxBridge` over an in-memory channel pair. The five
|
||||
required tests all use `_FakeBridge` (per plan #1 "no channel"), so none
|
||||
of them touch the new `sandbox_bridge.py` file's wire mapping directly —
|
||||
it's only covered transitively by the still-present `test_remote_store.py`.
|
||||
I judged direct coverage of the new file worth one small test. When A2
|
||||
deletes `test_remote_store.py`, this test should stay.
|
||||
|
||||
2. **Left `test_shutdown.py` and `test_remote_store.py` unmodified.** Risk #2
|
||||
anticipated A1 having to touch `test_shutdown.py`. It didn't need to: with
|
||||
both paths active, the `Store` those tests build is still `RemoteStore`
|
||||
(install stays), the contextvar branch routes to the bridge over the same
|
||||
channel, and both files pass unchanged. Their cleanup is an A2 concern.
|
||||
|
||||
## ⚠️ Open issue for the parent to look at BEFORE A2
|
||||
|
||||
**`async_delay_save` does NOT route through the contextvar in A1, and the
|
||||
plan's §2 claim that it does is inaccurate.** Plan §2 says: *"delay_save /
|
||||
final_write: unchanged … They eventually call `async_save`, which hits the
|
||||
contextvar branch."* That is **false** for the current `storage.py`:
|
||||
`async_delay_save` sets `self._data` directly and schedules
|
||||
`_async_handle_write_data` → `_async_write_data` — it never calls
|
||||
`async_save`. So my A1 contextvar branch in `async_save` is bypassed by the
|
||||
delayed-save and FINAL_WRITE-flush paths.
|
||||
|
||||
- **A1 impact: none.** `RemoteStore` is still installed and overrides
|
||||
`_async_write_data`, so delayed/final-write saves still reach main.
|
||||
`test_shutdown.test_shutdown_flushes_pending_delay_save` confirms this
|
||||
(green).
|
||||
- **A2 impact: real.** When A2 deletes `RemoteStore`, delayed saves and the
|
||||
FINAL_WRITE flush will fall through to local-disk `_write_prepared_data`
|
||||
inside the sandbox tempdir — silent data loss for any `Store` using
|
||||
`async_delay_save` (e.g. the restore-state dump path and many integration
|
||||
stores). **A2 must add a contextvar branch in `_async_write_data` (or
|
||||
`_async_handle_write_data`) — not just `async_save` — before removing
|
||||
`RemoteStore`.** Branching at `_async_write_data` mirrors what `RemoteStore`
|
||||
did (it overrode exactly that method) and would cover async_save,
|
||||
async_delay_save, and final-write uniformly. Recommend A2 either move the
|
||||
save branch down to `_async_write_data`, or add a second branch there.
|
||||
|
||||
I did **not** make that change in A1 because the brief/plan explicitly
|
||||
scoped A1 to "`async_save` and `async_remove` early-return through the
|
||||
bridge," and changing `_async_write_data` is a deviation I'm surfacing
|
||||
rather than silently making. The A1 tests don't exercise delayed-save
|
||||
through the contextvar, so A1 is internally consistent; the gap is purely
|
||||
a forward-looking A2 correctness requirement.
|
||||
|
||||
## Notes / smaller things
|
||||
|
||||
- **Q3 assertion never fires negatively in the suite.** No test sets
|
||||
`current_sandbox` and then re-enters `run()`. The runtime tests
|
||||
(`test_shutdown.py`) pass because the teardown `reset(token)` clears it
|
||||
between runs. The assertion is purely the two-runtimes-one-loop guard.
|
||||
- **Risk #5's suggested executor-not-entered test** is not in the required
|
||||
five and I didn't add it. It's implicitly satisfied — the load/save
|
||||
contextvar branches return before any `async_add_executor_job` call, and
|
||||
`_FakeBridge` has no executor interaction — but a dedicated assertion could
|
||||
be added in A2 if desired.
|
||||
- **Registry-ordering caveat** from the plan's touch-points audit is captured
|
||||
as a comment next to `current_sandbox.set` in `sandbox.py`.
|
||||
- Did **not** touch `IGNORE_INTEGRATIONS_WITH_ERRORS` in hassfest (hard
|
||||
rule #5), the plan file, or any of the unrelated pre-existing
|
||||
modified/untracked files in the worktree (plan docs, `architecture.html`,
|
||||
`tests/testing_config/.storage/*`, `.claude/scheduled_tasks.lock`).
|
||||
@@ -0,0 +1,145 @@
|
||||
# STATUS — plan-sandbox-context Phase A2
|
||||
|
||||
**One-line:** Deleted `RemoteStore` + `install_remote_store` + the
|
||||
`restore_state` store-swap workaround; the `current_sandbox` contextvar is
|
||||
now the sole route for sandbox `Store` IO, with the save branch moved down
|
||||
to `Store._async_write_data` so delayed/FINAL_WRITE saves reach main. All
|
||||
suites green, prek clean, single commit.
|
||||
|
||||
**Commit:** `4c85363668b` (`sandbox_v2: delete RemoteStore; route writes
|
||||
via contextvar (Phase A2)`) on branch `sandbox` — **not pushed** (parent
|
||||
pushes).
|
||||
|
||||
## The load-bearing fix: contextvar branch moved to `_async_write_data`
|
||||
|
||||
A1 put the save branch in `Store.async_save`. But `async_delay_save` and
|
||||
the `EVENT_HOMEASSISTANT_FINAL_WRITE` flush **never call `async_save`** —
|
||||
they funnel `self._data` through `_async_handle_write_data` →
|
||||
`_async_write_data`. While `RemoteStore` existed it overrode
|
||||
`_async_write_data`, so those paths reached main anyway. Deleting
|
||||
`RemoteStore` without this fix would have silently written delayed/final
|
||||
saves to the sandbox tempdir (data loss).
|
||||
|
||||
`homeassistant/helpers/storage.py`:
|
||||
- **Added** the contextvar branch as the first lines of
|
||||
`_async_write_data`: `if sandbox := current_sandbox.get(): await
|
||||
sandbox.async_store_save(self.key, data); return`. This covers
|
||||
`async_save`, `async_delay_save`, and FINAL_WRITE uniformly (they all
|
||||
reach `_async_write_data`).
|
||||
- **Removed** the `async_save` contextvar branch — single source of truth
|
||||
at `_async_write_data`. `async_save` now falls through to
|
||||
`_async_handle_write_data` (same as the disk path), which also means the
|
||||
sandbox write path now respects `_read_only` and the write-lock —
|
||||
matching the old `RemoteStore` (it inherited `_async_handle_write_data`).
|
||||
- `_async_load_data` and `async_remove` contextvar branches are unchanged
|
||||
from A1.
|
||||
- The bridge owns envelope normalisation (resolving a deferred `data_func`)
|
||||
+ orjson preserialise + transport, so `_async_write_data` just delegates
|
||||
the raw envelope. `_FakeBridge` in the tests was taught the same
|
||||
`data_func` resolution to stay a faithful double.
|
||||
|
||||
## What got deleted
|
||||
|
||||
- `sandbox_v2/hass_client/hass_client/remote_store.py` (the subclass +
|
||||
`install_remote_store`/uninstall)
|
||||
- `sandbox_v2/hass_client/tests/test_remote_store.py`
|
||||
- In `sandbox_v2/hass_client/hass_client/sandbox.py`:
|
||||
- the `from .remote_store import …` import
|
||||
- the `install_remote_store(self._channel)` call + the
|
||||
`uninstall_remote_store` variable and its teardown
|
||||
- the `data.store = RemoteStore(…)` swap in `_load_restore_state` (now a
|
||||
vanilla `Store`; the contextvar reaches the import-captured reference)
|
||||
- the now-unused `JSONEncoder` import
|
||||
- stale `RemoteStore` mentions in `_run_graceful_shutdown` docstrings/
|
||||
comments
|
||||
|
||||
## New regression test
|
||||
|
||||
`sandbox_v2/hass_client/tests/test_sandbox_bridge.py::test_delayed_save_flushes_through_bridge`
|
||||
— sets `current_sandbox` to the `_FakeBridge`, builds a `Store`, calls
|
||||
`async_delay_save(lambda: {"foo": "bar"}, delay=0)`, fires
|
||||
`EVENT_HOMEASSISTANT_FINAL_WRITE` + `async_block_till_done()`, and asserts
|
||||
`bridge.saved["delayed"]["key"] == "delayed"` and
|
||||
`bridge.saved["delayed"]["data"] == {"foo": "bar"}`. This is the guard that
|
||||
fails if the save branch ever regresses back to `async_save`-only.
|
||||
|
||||
`test_shutdown.py::test_shutdown_flushes_pending_delay_save` (the existing
|
||||
Phase 12 delayed-save test) still passes **unchanged in behaviour** — the
|
||||
FINAL_WRITE flush runs inside the shutdown handler's task, which inherits
|
||||
the contextvar set in `run()`, so the write reaches the bridge. Only its
|
||||
stale `RemoteStore`/`install_remote_store` comments were updated.
|
||||
|
||||
## Doc updates
|
||||
|
||||
- `sandbox_v2/CLAUDE.md` — "Core HA files modified" now says **four**
|
||||
files; added the `helpers/sandbox_context.py` + `helpers/storage.py`
|
||||
row; updated the Iron-Law note and the repo-layout `RemoteStore` →
|
||||
`ChannelSandboxBridge`.
|
||||
- `sandbox_v2/OVERVIEW.md` — comparison table, ASCII runtime diagram,
|
||||
restore-state warm-load paragraph, the whole "Store routing" section, and
|
||||
the file map all rewritten around the contextvar.
|
||||
- `sandbox_v2/docs/FOLLOWUPS.md` — added a "plan-sandbox-context" section
|
||||
closing the monkey-patch-the-storage-module tension; de-named the one
|
||||
`RemoteStore` mention in the Phase 12 narrative.
|
||||
- `sandbox_v2/architecture.html` — TOC, runtime diagram box, restore-state
|
||||
callout, the §10 "Store routing" section, the timeline card, and the file
|
||||
map all stripped of `RemoteStore`/`install_remote_store`.
|
||||
- `homeassistant/components/sandbox_v2/{protocol.py,__init__.py}` — three
|
||||
comment/docstring `RemoteStore` references reworded (these are in
|
||||
`homeassistant/`, so they had to go to keep that grep clean).
|
||||
|
||||
## Test results
|
||||
|
||||
- `uv run pytest sandbox_v2/hass_client/ -q` → **50 passed** (was 55;
|
||||
removed `test_remote_store.py`'s 6 tests, added 1).
|
||||
- `uv run pytest tests/components/sandbox_v2/ tests/helpers/test_storage.py tests/helpers/test_restore_state.py --no-cov -q`
|
||||
→ **190 passed**.
|
||||
- `uv run prek --files <11 changed files>` → clean (ruff, ruff-format,
|
||||
codespell, prettier, mypy, pylint all Passed). Commit's own pre-commit
|
||||
run also passed — no `--no-verify`.
|
||||
|
||||
## Verification greps
|
||||
|
||||
- `grep -rn "RemoteStore\|install_remote_store" homeassistant/` → **empty.** ✅
|
||||
- All **live code** under `sandbox_v2/` + the four enumerated docs
|
||||
(CLAUDE.md, OVERVIEW.md, FOLLOWUPS.md, architecture.html) → **empty.** ✅
|
||||
|
||||
⚠️ **The brief's `grep -rn … sandbox_v2/` "must be empty" is NOT fully
|
||||
empty** — and cannot be, given the brief's other hard rules. The residual
|
||||
references are exactly the files the brief told me not to touch:
|
||||
- **Historical STATUS files** — `STATUS-phase-7/8/9/12.md` and
|
||||
`STATUS-plan-sandbox-context-A1.md` (brief: "Don't rewrite historical
|
||||
STATUS-phase-*.md files. Leave them alone." The A1 STATUS is likewise a
|
||||
historical landing record.)
|
||||
- **Plan files** — `plans/plan-sandbox-context.md` (brief hard rule #1:
|
||||
do not modify the plan file), plus `plan.md`, `plans/plan-ephemeral-sources.md`,
|
||||
`plans/whats-changed.md` (other plan docs; left untouched as conservative
|
||||
scope — note `whats-changed.md:39` has an unchecked
|
||||
"[ ] install_remote_store monkey-patch removed" box that is now true and
|
||||
the parent may want to tick).
|
||||
- **Reference docs** — `README.md`, `generate_backlog.py` (a string about
|
||||
main-side key validation), `docs/auth-scoping-decision.md` — all
|
||||
describing Phase 8 history.
|
||||
|
||||
If the parent wants a literally-empty grep, those are the files to sweep,
|
||||
but every one is either explicitly protected by the brief or a historical
|
||||
record where the past-tense `RemoteStore` mention is accurate.
|
||||
|
||||
## Things to look at
|
||||
|
||||
1. **`architecture.html` is now committed (2744 lines).** It was an
|
||||
**untracked** file before this session (never in git history — created
|
||||
by an earlier session and left uncommitted; A1's STATUS explicitly
|
||||
avoided touching it). The brief's Phase E lists it as a doc to update
|
||||
with specific line numbers, so I updated **and committed** it — otherwise
|
||||
my edits would live nowhere. If you'd rather it not enter history via
|
||||
the A2 commit, that's a `git rm --cached` + separate decision. Flagging
|
||||
because it's a large new blob riding in on this commit.
|
||||
2. **`test_shutdown.py` needed no behavioural change**, only comment
|
||||
updates — the contextvar genuinely propagates into the shutdown
|
||||
handler's task (set in `run()` before `_channel.start()`, inherited via
|
||||
`create_task`). Confirmed green. This also retired Risk #2's worry that
|
||||
A1/A2 would have to rewire that test against the rebinding.
|
||||
3. **No change to `IGNORE_INTEGRATIONS_WITH_ERRORS`** (hard rule #5), the
|
||||
plan file, hassfest, or the pre-existing unrelated untracked files
|
||||
(`tests/testing_config/.storage/*`, `.claude/scheduled_tasks.lock`).
|
||||
@@ -0,0 +1,120 @@
|
||||
# STATUS — plan-strip-auth-scopes
|
||||
|
||||
**Summary:** Reverted Phase 7's `RefreshToken.scopes` + websocket-dispatcher
|
||||
enforcement from core HA; the sandbox now runs against a plain system-user
|
||||
token. No on-disk migration — the auth-store load path silently drops a legacy
|
||||
`scopes` key. All target test suites green; prek clean.
|
||||
|
||||
## Commits (branch `sandbox`, not pushed — parent pushes)
|
||||
|
||||
- **`5141f96ebe1`** — `sandbox_v2: strip RefreshToken.scopes from core; sandbox token goes plain`
|
||||
(Phase A core revert + Phase B sandbox helper + Phase C docs + tests). 14 files.
|
||||
- **(this commit)** — `sandbox_v2: tick whats-changed for strip-auth-scopes + STATUS marker`
|
||||
(ticks the breaking-changes checkbox with the code-commit SHA, adds this file).
|
||||
|
||||
## File-by-file (Phase A — core revert)
|
||||
|
||||
- `homeassistant/auth/models.py` — deleted the `scopes: frozenset[str] | None`
|
||||
field from `RefreshToken`.
|
||||
- `homeassistant/auth/__init__.py` — deleted the `scopes=` parameter from
|
||||
`AuthManager.async_create_refresh_token` and its forwarding to the store.
|
||||
- `homeassistant/auth/auth_store.py` —
|
||||
- deleted `scopes=` from `AuthStore.async_create_refresh_token` (param +
|
||||
kwargs dict entry);
|
||||
- deleted the load-side `scopes = rt_dict.get("scopes")` read and the
|
||||
`scopes=frozenset(...)` kwarg, replacing them with a one-line silent-drop
|
||||
shim `rt_dict.pop("scopes", None)` (option A — no migration, no
|
||||
storage-version bump);
|
||||
- deleted the write-side `"scopes": sorted(...)` serialization.
|
||||
- `homeassistant/components/websocket_api/connection.py` — deleted the
|
||||
module-level `_scope_allows` helper, the `"scopes"` `__slots__` entry, the
|
||||
`self.scopes = ...` assignment, and the `async_handle` enforcement branch.
|
||||
`async_handle` is back to its pre-Phase-7 shape. (`RefreshToken` import is
|
||||
still used by the `__init__` signature — kept.)
|
||||
|
||||
## File-by-file (Phase B — sandbox helper)
|
||||
|
||||
- `homeassistant/components/sandbox_v2/auth.py` —
|
||||
- deleted the `SANDBOX_TOKEN_SCOPES` constant (and its `__all__` entry);
|
||||
- `_get_or_create_sandbox_refresh_token` now takes `(hass, user)` and
|
||||
identifies the token by the **one-token-per-system-user invariant**
|
||||
(locked decision: option 2 — reuse `tokens[0]` if present, else create
|
||||
with no `scopes=`/`client_id=`). Token type stays `TOKEN_TYPE_SYSTEM`.
|
||||
- rewrote the module + helper docstrings to drop the Phase-7 scoping
|
||||
language.
|
||||
- `_user_name_for_group` / `_client_id_for_group` kept (the latter is unused
|
||||
but harmless, per the plan).
|
||||
|
||||
## Tests deleted vs added
|
||||
|
||||
- **Deleted:** `tests/components/websocket_api/test_scopes.py` (whole file, 140
|
||||
lines).
|
||||
- **Deleted:** `tests/auth/test_init.py` — `test_refresh_token_scopes_default_to_none`
|
||||
and `test_refresh_token_scopes_round_trip_through_store`.
|
||||
- **Added:** `tests/auth/test_auth_store.py::test_loading_drops_legacy_scopes_key`
|
||||
— hand-crafted on-disk auth store with a refresh token carrying
|
||||
`scopes: ["sandbox_v2/", "auth/current_user"]`; asserts it loads without
|
||||
error and the resulting `RefreshToken` has no `scopes` attribute.
|
||||
- **Updated:** `tests/components/sandbox_v2/test_auth.py` — removed the
|
||||
`SANDBOX_TOKEN_SCOPES` import + `test_sandbox_token_scopes_allowlist`; dropped
|
||||
the `refresh.scopes == ...` assertion; added a
|
||||
`len(refresh_a.user.refresh_tokens) == 1` assertion documenting the invariant
|
||||
the helper now relies on.
|
||||
|
||||
## Doc updates (Phase C)
|
||||
|
||||
- `sandbox_v2/docs/auth-scoping-decision.md` — prepended the SUPERSEDED banner.
|
||||
- `sandbox_v2/CLAUDE.md` — deleted the `auth/*` row from "Core HA files
|
||||
modified" (intro count "four core HA files" → "three core HA surfaces");
|
||||
rewrote the "Read these first" bullet to mark the design doc SUPERSEDED.
|
||||
- `sandbox_v2/OVERVIEW.md` — rewrote the v1/v2 auth comparison row, the
|
||||
"Scoped auth" → "Sandbox auth" section, the `--token` CLI placeholder, and
|
||||
the summary-table "Auth scopes" row.
|
||||
- `sandbox_v2/docs/FOLLOWUPS.md` — added a `plan-strip-auth-scopes` narrative
|
||||
section and a "Still open" bullet to re-introduce scope enforcement when the
|
||||
WS transport lands.
|
||||
- `sandbox_v2/architecture.html` — Iron-Law callout, TOC, section-9 heading +
|
||||
body (scopes-on-RefreshToken / `_scope_allows` / why-not-a-subclass rewritten
|
||||
to "plain token, enforcement deferred"), the auth SVG boxes, the Phase-7
|
||||
timeline entry (annotated as reverted), the core-HA-modified list, the
|
||||
summary-table row, and the CLI `--token` placeholder. (prettier reflowed the
|
||||
file.)
|
||||
- `sandbox_v2/plans/whats-changed.md` — ticked the `RefreshToken.scopes
|
||||
removed` breaking-change checkbox `[ ]` → `[x]` and appended the code-commit
|
||||
SHA `5141f96ebe1`.
|
||||
|
||||
## Test results
|
||||
|
||||
- `uv run pytest tests/auth/ tests/components/websocket_api/ tests/components/sandbox_v2/ --no-cov -q`
|
||||
→ **467 passed** (2 snapshots), 7 warnings.
|
||||
- `uv run pytest sandbox_v2/hass_client/ -q` → **50 passed**, 1 warning.
|
||||
- `uv run prek run --files <14 changed files>` → all hooks Passed (prettier
|
||||
auto-formatted `architecture.html` once, then clean).
|
||||
|
||||
## Verification grep results (Step 7)
|
||||
|
||||
- `grep -rn "RefreshToken\.scopes\|token\.scopes\|self\.scopes\|scopes="
|
||||
homeassistant/auth/ .../websocket_api/connection.py .../sandbox_v2/`
|
||||
→ **empty.**
|
||||
- `grep -rn "SANDBOX_TOKEN_SCOPES\|_scope_allows" homeassistant/ tests/ sandbox_v2/hass_client/`
|
||||
(Python) → **empty.**
|
||||
- `grep -rln "SANDBOX_TOKEN_SCOPES\|_scope_allows" .` → only markdown/HTML:
|
||||
the SUPERSEDED design doc, this batch's plan + FOLLOWUPS (past-tense /
|
||||
re-introduction references), historical `STATUS-phase-7/11/20.md`, and the
|
||||
forward-looking `design-share-states.md` / `plan-transport.md` (which point at
|
||||
the future re-introduction). The live `OVERVIEW.md` / `architecture.html` /
|
||||
`FOLLOWUPS.md` matches are all past-tense "was reverted" prose.
|
||||
|
||||
## Anything weird
|
||||
|
||||
- The first `git commit` only captured the already-staged `test_scopes.py`
|
||||
deletion because the `git add` line aborted on that removed pathspec; folded
|
||||
the remaining 13 files in via `git commit --amend` (commit not pushed, so the
|
||||
amend is safe). Final commit `5141f96ebe1` has all 14 files.
|
||||
- `design-share-states.md` and `plan-transport.md` still reference
|
||||
`SANDBOX_TOKEN_SCOPES` as the place to extend when scoping returns. Left as-is
|
||||
— they are forward-looking design/plan docs outside this plan's Phase-C list,
|
||||
and FOLLOWUPS now points re-introduction at `auth-scoping-decision.md`.
|
||||
- `architecture.html`'s "Core HA files modified" list does not include the
|
||||
`current_sandbox` contextvar row (pre-existing drift from plan-sandbox-context,
|
||||
not introduced here); only the auth row was removed per this plan's scope.
|
||||
@@ -0,0 +1,176 @@
|
||||
# STATUS — plan-transport T2 (protobuf wire + typed handlers)
|
||||
|
||||
**One-line:** T2 shipped green — the control channel now speaks typed
|
||||
protobuf messages end-to-end (default codec = `ProtobufCodec`), ~20 handlers
|
||||
and ~69 test call/push sites converted in one atomic commit, both suites green,
|
||||
prek clean.
|
||||
|
||||
**Commit:** `360e4543300` — `sandbox_v2: protobuf wire + typed handlers (transport T2)`
|
||||
(64 files, +3762/-1046). Not pushed — parent pushes.
|
||||
|
||||
## Test results (exact)
|
||||
|
||||
- HA core: `uv run pytest tests/components/sandbox_v2/ --no-cov -q` →
|
||||
**189 passed, 18 warnings** (T1 was 183; +6 from the new
|
||||
`test_proto_transport.py`).
|
||||
- Client: `uv run pytest sandbox_v2/hass_client/ -q` → **53 passed, 1 warning**.
|
||||
- `uv run prek run --files <60 touched non-gencode files>` → all hooks pass
|
||||
(ruff, ruff-format, codespell, json, yamllint, prettier, mypy, pylint,
|
||||
gen_requirements_all, hassfest, hassfest-metadata).
|
||||
- Drift guard: `prek run --hook-stage manual sandbox-v2-proto-drift` → Passed
|
||||
(regenerates both mirrors in an isolated venv, `git diff --exit-code` clean).
|
||||
|
||||
## Greps that came back empty (as required)
|
||||
|
||||
- `grep -rn from_payload homeassistant/components/sandbox_v2/ sandbox_v2/hass_client/hass_client/` → **empty** (all dict constructors replaced by `from_proto` / `make_entity_description`).
|
||||
- `grep -rn "class WebSocketTransport|WebSocketTransport("` → **empty** (no WS code introduced; only docstring references to the future drop-in seam, unchanged from T1).
|
||||
- `grep -rn "parent_id|user_id" sandbox_v2/hass_client/hass_client/` → only the two **comments** ("Forward only the context id — never parent_id / user_id") in `event_mirror.py` + `entity_bridge.py`. No wire-shaped occurrences — the sandbox never serializes `parent_id`/`user_id`.
|
||||
|
||||
## What changed, file by file
|
||||
|
||||
### New
|
||||
- `sandbox_v2/proto/sandbox_v2.proto` — single source of truth.
|
||||
- `sandbox_v2/proto/generate.sh` — regen into both mirrors via an isolated
|
||||
`/tmp` venv pinned to `protobuf==6.32.0` (verified `grpcio-tools==1.80.0`).
|
||||
- `sandbox_v2/proto/check_drift.sh` — drift guard wrapper (degrades gracefully
|
||||
when `uv` is absent).
|
||||
- `homeassistant/components/sandbox_v2/_proto/sandbox_v2_pb2.py(+.pyi)` and
|
||||
`sandbox_v2/hass_client/hass_client/_proto/sandbox_v2_pb2.py(+.pyi)` —
|
||||
checked-in gencode (two no-cross-import mirrors).
|
||||
- `…/messages.py` (both sides, byte-identical) — the `type → (request_cls,
|
||||
result_cls)` REGISTRY + `struct_to_dict`/`dict_to_struct`/`listvalue_to_list`/
|
||||
`list_to_listvalue` helpers + `device_info_to_proto` + `make_entity_description`.
|
||||
- `…/codec_protobuf.py` (both sides, byte-identical) — `ProtobufCodec`.
|
||||
- `tests/components/sandbox_v2/test_proto_transport.py` — 6 new tests (round-trip
|
||||
byte-identity, Error/MultipleInvalid round-trip, `_resolve_context`,
|
||||
state_changed Context security).
|
||||
|
||||
### Production handlers converted (~20)
|
||||
- HA `bridge.py`: `_handle_register_entity` (EntityDescription→RegisterEntityResult),
|
||||
`_handle_unregister_entity`, `_handle_state_changed`, `_handle_register_service`,
|
||||
`_handle_unregister_service`, `_handle_fire_event`, `_handle_store_load/save/remove`,
|
||||
`_raw_call_service` + the service forwarder; `SandboxEntityDescription.from_proto`,
|
||||
`_deserialise_device_info(pb.DeviceInfo)`, `_validate_key`; **new
|
||||
`_resolve_context` + `_async_system_user_id`**.
|
||||
- HA `router.py`: `_entry_setup_payload`→`pb.EntrySetup`, entry_setup/unload result reads.
|
||||
- HA `proxy_flow.py`: flow_init/step/abort send protos; FlowResult field reads.
|
||||
- HA `manager.py` / `__init__.py`: channel built with `ProtobufCodec`; shutdown
|
||||
reply consumed as `pb.ShutdownResult`.
|
||||
- HA `entity/__init__.py` + `entity/{button,event,notify,scene}.py`:
|
||||
`sandbox_apply_state` gained an optional `context` param threaded to
|
||||
`async_set_context`.
|
||||
- Client `entry_runner.py`, `flow_runner.py` (incl. `_marshal_result`→FlowResult),
|
||||
`entity_bridge.py`, `service_mirror.py`, `event_mirror.py`, `sandbox_bridge.py`,
|
||||
`sandbox.py` (ping/shutdown return protos; channel built with `ProtobufCodec`).
|
||||
|
||||
### Tests converted (~69 sites)
|
||||
- HA: `test_bridge` (18), `test_store` (13), `test_phase13_proxies` (28),
|
||||
`test_phase14`, `test_phase19_devices`, `test_perf`, `test_phase4_subprocess`,
|
||||
`test_phase9_shutdown`, `test_proxy_flow`, **`test_router`**,
|
||||
`test_testing_plugins`. `test_channel` stays JSON via `make_channel_pair(use_json=True)`.
|
||||
- Client: `test_entry_runner`, `test_flow_runner`, `test_entity_bridge`,
|
||||
`test_service_mirror`, `test_event_mirror`, `test_sandbox_bridge`,
|
||||
`test_shutdown`, `test_testing_inproc`.
|
||||
- `tests/.../_helpers.py` + `hass_client/testing/_inproc.py`: channel pairs now
|
||||
default to `ProtobufCodec`; `_helpers.make_channel_pair(use_json=True)` selects
|
||||
the registry-free `JsonCodec` for channel-core tests.
|
||||
|
||||
## Final proto schema shape (and deviations from the locked plan)
|
||||
|
||||
Implemented as the plan's T2 refinements specify: `EntityDescription` wraps
|
||||
`EntityInfo{Description, DeviceInfo}` + `InitialState{state, capabilities,
|
||||
attributes}`; `ServiceResponse{Struct data}` inside
|
||||
`CallServiceResult{optional ServiceResponse response}` (proto3 `optional`, no
|
||||
`has_response`); `StateChanged` flattened with `optional context_id`;
|
||||
`FireEvent` with `optional context_id`. Deviations / additions to flag:
|
||||
|
||||
1. **`Error` gained `bool multiple = 4`** (beyond the plan's
|
||||
`{message, type, repeated invalid}`) so a single `vol.Invalid` and a
|
||||
`vol.MultipleInvalid` are faithfully distinguished on decode (fidelity #7).
|
||||
2. **`FlowResult` carries only the FORM / CREATE_ENTRY / ABORT fields** the
|
||||
main-side proxy actually consumes (type, flow_id, handler, step_id, reason,
|
||||
title, description, last_step, preview, version, minor_version, + Struct
|
||||
data/options/errors/description_placeholders/context, + ListValue
|
||||
data_schema, + has_data_schema). `menu_options`/`subentries`/`url`/
|
||||
`progress_action`/`translation_domain` are intentionally dropped — the proxy
|
||||
already aborts noisily on any non-FORM/CREATE/ABORT result (the existing
|
||||
Phase-4 limitation), so they were never read.
|
||||
3. **`EntryUnloadResult` has no `reason` field.** The old dict returned
|
||||
`{"ok": False, "reason": …}` on failure but the router only ever read `ok`;
|
||||
the failure is still logged via `_LOGGER.exception`. No behavior lost.
|
||||
4. **`DeviceInfo`** models the keys the entity bridge forwards
|
||||
(`identifiers`/`connections` as `repeated DevicePair`, `via_device` as one
|
||||
pair, `entry_type` as string, the scalar string keys); unset scalars (default
|
||||
`""`) are treated as absent on the main side.
|
||||
|
||||
## Deviations from the brief worth the parent's eye
|
||||
|
||||
1. **`Channel.__init__`'s default codec stays `JsonCodec`** (not
|
||||
`ProtobufCodec`). The dispatch core in `channel.py` is kept free of any proto
|
||||
import; every *production* channel-construction site (`manager._open_channel`,
|
||||
the runtime's stdio factory) and the in-memory real-handler test helpers build
|
||||
`ProtobufCodec(REGISTRY)` explicitly. This is the same spirit as T1's
|
||||
deviation #1 (keep the core constructor stable) and satisfies "default codec
|
||||
is protobuf [in production]; JsonCodec retained for test wire."
|
||||
2. **`JsonCodec` was NOT converted to "proto-as-JSON".** It stays the
|
||||
registry-free, dict-passthrough T1 codec. Reason: `test_channel.py` exercises
|
||||
the concurrency-critical channel core with *synthetic* message types
|
||||
(`test/echo`, …) and arbitrary dict/int payloads — a registry-aware JsonCodec
|
||||
would break exactly the tests that prove the core. The brief's hard
|
||||
requirement ("JsonCodec retained for the test wire only") is met; the
|
||||
plan/brief's softer "proto-as-JSON via MessageToDict" suggestion was traded
|
||||
for keeping the channel-core test wire intact. A separate proto-as-JSON debug
|
||||
codec can be added later if wanted (it does not gate T3/T5).
|
||||
3. **`grpcio-tools` / `mypy-protobuf` are NOT added to any project requirements.**
|
||||
Per T1's verified warning, installing grpcio-tools into the project venv bumps
|
||||
`protobuf` past the pinned `6.32.0`. `generate.sh` + `check_drift.sh` bootstrap
|
||||
a throwaway venv instead. The drift guard is therefore a **manual-stage prek
|
||||
hook** (`sandbox-v2-proto-drift`) / dedicated CI lane, not an every-commit
|
||||
hook, and skips gracefully when `uv` is absent.
|
||||
4. **Sandbox-side `context_id → Context` cache: minimal / not added as a
|
||||
separate dict.** The substantive piece — the main-side `_resolve_context`
|
||||
resolver + cache — is implemented and tested. On the sandbox side, outbound
|
||||
`state_changed` / `fire_event` simply forward `state.context.id` /
|
||||
`event.context.id`; no current path needs to map an *incoming* context_id back
|
||||
to a Context on the sandbox (main→sandbox `call_service` carries a context_id
|
||||
but the sandbox's `services.async_call` does not consume it today), so the
|
||||
planned in-runtime dict would be dead state. Easy to add when a consumer
|
||||
appears.
|
||||
|
||||
## Anything weird (edge cases + the one real gotcha)
|
||||
|
||||
- **Struct numbers are doubles.** Dynamic fields crossing as
|
||||
`google.protobuf.Struct` (service_data, target, attributes, capabilities, the
|
||||
wrapped Store envelope, flow data/errors/context) come back with `int` →
|
||||
`float` (`255` → `255.0`). Python `==` treats them equal, so every ported dict
|
||||
assertion still holds with its exact expected value; nothing was loosened.
|
||||
Documented in `messages.py`. Anything with real integer semantics
|
||||
(`version`, `minor_version`, `supported_features`) is an explicit `int32`
|
||||
field, not a Struct value, so it is unaffected.
|
||||
- **Assertion-semantics shifts** (each carries an inline comment, no value
|
||||
loosened): `result is None` → `not result.HasField("response")`
|
||||
(CallServiceResult); `result["restore_state"] is None` →
|
||||
`not result.HasField("restore_state")` (ShutdownResult); `loaded is None` →
|
||||
`not loaded.HasField("data")` (StoreLoadResult); `result == {}` → empty
|
||||
`FlowAbortResult`.
|
||||
- **Synthetic test handlers must return proto results.** A handler registered in
|
||||
a test (e.g. a fake `register_entity` receiver) now has to return the typed
|
||||
result message (`pb.RegisterEntityResult(...)`) even where the production
|
||||
caller ignores the result — the codec raises `TypeError` on a non-proto
|
||||
handler return body. Called out by the conversion agents.
|
||||
- **The one real gotcha (caught + fixed):** `test_router.py` had an
|
||||
`entry_setup` stub returning a plain `dict`. Under `ProtobufCodec` the handler
|
||||
return can't be encoded, the response frame is silently dropped, and the
|
||||
router's `channel.call(MSG_ENTRY_SETUP)` (no timeout) **hangs forever**. This
|
||||
surfaced only in the full-suite run (the file wasn't in the original
|
||||
conversion list). Lesson for T3/T5: any test stub that registers a
|
||||
`sandbox_v2/*` handler must return a typed proto, or the call hangs rather than
|
||||
failing fast.
|
||||
|
||||
## T3 + T5 status
|
||||
|
||||
**Both unblocked.** T3 (`UnixSocketTransport`) reuses `StreamTransport`'s
|
||||
length-prefixed framing and is entirely codec-agnostic — the protobuf switch
|
||||
doesn't touch it. T5 (docs/cleanup) can now describe `ProtobufCodec` as the
|
||||
production default and `JsonCodec` as the test wire; the `whats-changed.md`
|
||||
transport boxes (protobuf wire / typed handlers) can be ticked with this SHA.
|
||||
@@ -0,0 +1,192 @@
|
||||
# STATUS — plan-transport T3 + T5 (unix socket + cleanup/docs)
|
||||
|
||||
**One-line:** T3 + T5 shipped green — the control channel now has an opt-in
|
||||
unix-socket transport alongside the default stdio (websocket rejected, not
|
||||
implemented), and the wire-protocol docs describe the current
|
||||
protobuf + Transport/Codec reality. Both suites green, prek + drift guard
|
||||
clean. The full transport effort (T1 → T2 → T3 → T5) is **complete**; T4
|
||||
(websocket) remains out of scope.
|
||||
|
||||
## Commits (not pushed — parent pushes)
|
||||
|
||||
| Phase | SHA | Subject |
|
||||
|-------|-----|---------|
|
||||
| T3 | `1eaa79d261e` | `sandbox_v2: unix socket transport (transport T3)` |
|
||||
| T5 | `42560c6cd00` | `sandbox_v2: transport cleanup + docs (transport T5)` |
|
||||
|
||||
Two commits, as the brief specified. The plan file was **not** modified.
|
||||
|
||||
## How the manager selects stdio vs unix
|
||||
|
||||
A **manager-level option, defaulting to stdio** — unix is opt-in, so every
|
||||
existing deployment/test keeps the unchanged stdio behavior:
|
||||
|
||||
```python
|
||||
SandboxManager(hass, transport="stdio") # default
|
||||
SandboxManager(hass, transport="unix") # opt-in
|
||||
```
|
||||
|
||||
`transport` is validated at construction (`"stdio"` / `"unix"`;
|
||||
`TRANSPORT_STDIO` / `TRANSPORT_UNIX` constants, unknown → `ValueError`) and
|
||||
propagated to each `SandboxProcess`. `SandboxProcess._run_one` branches into
|
||||
`_run_one_stdio` / `_run_one_unix`, which share a `_supervise_until_exit`
|
||||
helper (ready-handshake + run-until-exit + cleanup) so the two paths differ
|
||||
only in how the channel's reader/writer pair is obtained.
|
||||
|
||||
**The manager owns the transport, so it owns the `--url`.** `CommandFactory`
|
||||
changed from `(group) -> argv` to **`(group, url) -> argv`**: the manager
|
||||
computes the control-channel URL for its transport and hands it to the
|
||||
factory, which puts it in `--url`. (`stdio://` for stdio; `unix://<path>`
|
||||
for unix.) The five existing test command-factories were updated to the new
|
||||
signature; production (`_default_command`) is unchanged apart from taking
|
||||
the url.
|
||||
|
||||
Runtime side: the `--url` **scheme** selects the transport
|
||||
(`_transport_scheme` in `hass_client/sandbox.py`):
|
||||
|
||||
- absent / `stdio://` → stdio (`--url` now defaults to `stdio://`, no longer
|
||||
required).
|
||||
- `unix://<path>` → `asyncio.open_unix_connection(path)` → `Channel`.
|
||||
- `ws://` / `wss://` → rejected (see below).
|
||||
|
||||
## UnixSocketTransport: not a class — StreamTransport over unix streams
|
||||
|
||||
There is **no `UnixSocketTransport` class**. A unix socket is just a
|
||||
different byte pipe under the same length-prefixed framing, so both sides
|
||||
reuse the existing `StreamTransport` (built internally by `Channel(reader,
|
||||
writer)`):
|
||||
|
||||
- **Runtime (client):** `asyncio.open_unix_connection(path)` → the
|
||||
reader/writer pair → `Channel(..., codec=ProtobufCodec())`
|
||||
(`_open_unix_channel`).
|
||||
- **Manager (server):** `asyncio.start_unix_server(accept_cb, path)`; the
|
||||
accepted `(reader, writer)` → `Channel(...)`. Main is the server.
|
||||
|
||||
This is exactly the "thinner approach" the brief/plan preferred ("reuses
|
||||
StreamTransport framing" → the class is unnecessary). A dedicated class
|
||||
would add nothing over `StreamTransport`-over-unix-streams.
|
||||
|
||||
## ws:// rejection behavior
|
||||
|
||||
`_transport_scheme` classifies `ws://` / `wss://` as `"ws"`; the runtime's
|
||||
`_default_channel_factory` then raises:
|
||||
|
||||
```
|
||||
NotImplementedError(
|
||||
"websocket transport is not implemented in this build; it is reserved
|
||||
for the share-states work — use stdio:// or unix://"
|
||||
)
|
||||
```
|
||||
|
||||
No WS code, deps, or auth surface was added — the `Transport` seam still
|
||||
accepts a future `WebSocketTransport` drop-in via `Channel.from_transport`
|
||||
(docstring reference only). The token still travels the CLI for forward-compat.
|
||||
|
||||
## Test results (exact)
|
||||
|
||||
- HA core: `uv run pytest tests/components/sandbox_v2/ --no-cov -q` →
|
||||
**191 passed, 2 warnings** (T2 was 189; +2 from the new
|
||||
`test_transport_unix.py`).
|
||||
- Client: `uv run pytest sandbox_v2/hass_client/ -q` → **62 passed, 1
|
||||
warning** (T2 was 53; +9 from the new `test_transport_scheme.py`).
|
||||
- `uv run prek run --files <all touched files>` → all hooks pass (ruff,
|
||||
ruff-format, codespell, prettier, mypy, pylint). prettier reformatted
|
||||
`architecture.html` once (auto-fix, now idempotent).
|
||||
- Drift guard: `prek run --all-files --hook-stage manual
|
||||
sandbox-v2-proto-drift` → **Passed** (proto untouched — not regenerated).
|
||||
|
||||
### New tests
|
||||
|
||||
- `tests/components/sandbox_v2/test_transport_unix.py` (core, 2 tests):
|
||||
real subprocess unix round-trip (manager opens the socket, the real
|
||||
runtime dials back over `unix://`, `ping` round-trips, socket + tempdir
|
||||
cleaned up on shutdown — no leak); unknown-transport `ValueError`.
|
||||
- `sandbox_v2/hass_client/tests/test_transport_scheme.py` (client, 9
|
||||
tests): `_transport_scheme` selection (`stdio`/`unix`/`ws` + unknown
|
||||
raises), `--url` defaults to `stdio://`, ws:// rejection raises
|
||||
`NotImplementedError`, and a hermetic `_open_unix_channel` round-trip
|
||||
against an in-process server using a typed proto `ping` handler.
|
||||
|
||||
### Existing tests updated
|
||||
|
||||
`test_manager.py`, `test_phase4_subprocess.py`, `test_phase9_shutdown.py` —
|
||||
command-factory signatures moved to `(group, url)` and use the passed url
|
||||
instead of the old hard-coded `ws://localhost…` literal (which would now be
|
||||
rejected). `test_default_command_includes_token` passes a `stdio://` url.
|
||||
|
||||
## Doc files updated (T5)
|
||||
|
||||
- `sandbox_v2/OVERVIEW.md` — transport row, the high-level diagram label,
|
||||
and the spawn prose: protobuf `Frame` + length-prefix, Ready-frame
|
||||
handshake (no text marker), stdio + unix transports, the
|
||||
Channel/Codec/Transport layering. `--url stdio://` example.
|
||||
- `sandbox_v2/architecture.html` — TOC + §5 heading, the intro paragraph,
|
||||
the SVG channel labels ("stdio protobuf", "protobuf framing"), the spawn
|
||||
command + marker prose, §5 rewritten to the three-layer split, the test
|
||||
table row, and the Phase-3 timeline `Ready`-frame note.
|
||||
- `homeassistant/components/sandbox_v2/channel.py` +
|
||||
`hass_client/.../channel.py` — Codec-layer docstring (ProtobufCodec =
|
||||
production; JsonCodec = registry-free channel-core test wire).
|
||||
- `homeassistant/components/sandbox_v2/protocol.py` — intro rewritten:
|
||||
typed protobuf messages, REGISTRY + `sandbox_v2.proto`, payload shapes
|
||||
are the logical contract.
|
||||
- `hass_client/.../sandbox.py` — module + `SandboxRuntime` docstrings note
|
||||
the `--url`-selected transport.
|
||||
- `sandbox_v2/CLAUDE.md` — **no change needed** (it does not describe the
|
||||
wire format).
|
||||
|
||||
## whats-changed.md boxes ticked
|
||||
|
||||
- `[x]` "Protobuf wire format" → T2 `360e4543300`.
|
||||
- `[x]` "Pluggable transports" → T3 `1eaa79d261e`.
|
||||
- `[x]` "Handlers consume typed protobuf messages" → T2 `360e4543300`.
|
||||
|
||||
## JsonCodec positioning
|
||||
|
||||
Kept (not deleted). Its docstring (both mirrors) now states it is the
|
||||
**registry-free channel-core test/debug wire** — plain-JSON passthrough so
|
||||
`test_channel.py` can drive the concurrency core with synthetic message
|
||||
types. Production rides `ProtobufCodec`.
|
||||
|
||||
## Anything weird
|
||||
|
||||
- **The one real bug caught + fixed (unix teardown hang).** First cut hung
|
||||
forever at `server.wait_closed()` during shutdown. Root cause: when the
|
||||
runtime exits, the manager's channel read loop sees EOF and sets
|
||||
`Channel._closed = True`; the later `channel.close()` then early-returns
|
||||
**without** closing the accepted transport, so it lingers in
|
||||
`server._clients` and `wait_closed()` blocks. Fix: the unix path calls
|
||||
`server.close_clients()` (force-close lingering accepted connections)
|
||||
before `wait_closed()`. (stdio never exposed this — it has no
|
||||
`wait_closed()`.) The client round-trip test does the same in its
|
||||
teardown. Worth knowing if a `WebSocketTransport` is added later: the same
|
||||
read-EOF-then-close-is-a-noop interaction applies to any server-accepted
|
||||
transport.
|
||||
- **Unix socket path length.** Linux caps `sun_path` at ~108 chars, and
|
||||
pytest/config-dir paths can be long. **Choice:** the socket lives in a
|
||||
short per-attempt `tempfile.mkdtemp(prefix="sandbox_v2_<group>_")` (e.g.
|
||||
`/tmp/sandbox_v2_built-in_xxxx/control.sock`), **not** under the config
|
||||
dir — this sidesteps the limit entirely. Did not hit the limit in
|
||||
practice. The server's `cleanup_socket` (3.13+) unlinks the socket on
|
||||
close and the whole tempdir is `rmtree`'d on the way out — confirmed no
|
||||
leaked socket file by the test.
|
||||
- **Early-exit race.** `_run_one_unix` races the accept against
|
||||
`proc.wait()` so a crash-before-connect returns cleanly instead of
|
||||
hanging on the accept; the start-timeout path then surfaces
|
||||
`SandboxStartError` as usual.
|
||||
- **stdout in unix mode.** The subprocess's stdout pipe is unused (frames
|
||||
ride the socket); `_supervise_until_exit(..., drain_stdout=True)` drains
|
||||
it so its buffer never fills.
|
||||
- **Stale doc left intentionally (out of transport scope):** the
|
||||
architecture.html §5 "Known limitation — concurrent dispatcher" callout
|
||||
describes the one-task-per-call fix as future, but it shipped (Phase 12 /
|
||||
the inflight-semaphore dispatcher in `channel.py`). It does not mention
|
||||
the wire/marker, so it was left untouched to keep this batch scoped to the
|
||||
transport effort — flagging here for a future docs pass.
|
||||
|
||||
## Effort status
|
||||
|
||||
**T1 → T2 → T3 → T5 complete.** stdio (default) + unix-socket transports,
|
||||
protobuf wire, typed handlers, current docs. **T4 (websocket) remains
|
||||
out of scope** — the `Transport` seam is ready for it whenever the
|
||||
share-states work lands.
|
||||
@@ -0,0 +1,267 @@
|
||||
# STATUS — plan-transport (T1 → T2 → T3 → T5)
|
||||
|
||||
**One-line:** T1 (Transport/Codec seam, JSON-on-length-prefix, `Ready`
|
||||
frame) shipped green; T2/T3/T5 are **not started** — T2 is an atomic
|
||||
big-bang (protobuf wire + typed handlers converted in lockstep with ~69
|
||||
wire-call test sites) that cannot land in safe green increments the way
|
||||
T1 was designed to, so it is surfaced here for the parent to schedule as
|
||||
its own focused effort rather than rammed through this session at the
|
||||
risk of a broken tree or silently-weakened test assertions.
|
||||
|
||||
## Per-phase status
|
||||
|
||||
| Phase | State | Commit |
|
||||
|-------|-------|--------|
|
||||
| T1 — Transport/Codec seam | ✅ shipped, green | `8389f7ad96b` `sandbox_v2: Transport/Codec seam (transport T1)` |
|
||||
| T2 — protobuf wire + typed handlers | ❌ not started | — (design + recipe below) |
|
||||
| T3 — unix socket transport | ❌ not started (blocked on T2) | — |
|
||||
| T5 — cleanup + docs | ❌ not started (blocked on T2) | — |
|
||||
|
||||
No `git push` was done (per brief — parent pushes). The plan file was
|
||||
not modified. `whats-changed.md` boxes were **not** ticked: the three
|
||||
transport boxes (Protobuf wire / Pluggable transports / typed handlers)
|
||||
only flip when T2/T3 actually ship, and they did not.
|
||||
|
||||
## T1 — what shipped
|
||||
|
||||
Three-layer split of the control channel, **net behavior identical**
|
||||
(still JSON, still stdio), only framing + handshake changed:
|
||||
|
||||
- `Channel` — unchanged dispatch core (pending-id map, inflight
|
||||
semaphore, register/call/push/close). Now speaks `Frame` objects, never
|
||||
raw bytes.
|
||||
- `Codec` Protocol + `JsonCodec` — `Frame` ↔ bytes. `JsonCodec` is
|
||||
line-compatible with the old wire shape (same dict shapes, minus the
|
||||
trailing `\n` the length prefix replaces).
|
||||
- `Transport` Protocol + `StreamTransport` — whole frame blobs over a
|
||||
reader/writer pair with a **4-byte big-endian length prefix**. Caps
|
||||
frame size at `MAX_FRAME_SIZE` (16 MiB) and aborts the channel on a
|
||||
larger announced length (`FrameTooLargeError`) — host-hardening against
|
||||
a compromised sandbox.
|
||||
- `Frame` dataclass — unifies the three wire kinds (`call` / `push` /
|
||||
`response`) with a `FrameKind` discriminator.
|
||||
- The stdout text marker `sandbox_v2:ready` is **gone**. Handshake is now
|
||||
a `MSG_READY` (`sandbox_v2/ready`) **push frame**: the runtime sends it
|
||||
as the channel's first outbound message; the manager registers a
|
||||
handler for it and flips to `running` on arrival. stdout now carries
|
||||
nothing but channel frames (logs already go to stderr).
|
||||
|
||||
Both mirrors (`homeassistant/components/sandbox_v2/channel.py` +
|
||||
`sandbox_v2/hass_client/hass_client/channel.py`) and both `protocol.py`
|
||||
were updated in lockstep. `manager._run_one` now opens the channel up
|
||||
front, registers the `MSG_READY` + `on_channel_ready` handlers before
|
||||
starting the reader (so the runtime's warm-load round-trip is never
|
||||
dropped), then waits for the `Ready` frame.
|
||||
|
||||
### T1 deviations from the plan text (minor, intentional)
|
||||
|
||||
1. **`Channel.__init__` keeps the `(reader, writer)` signature** (plus a
|
||||
new optional `transport=` kwarg) and builds the `StreamTransport`
|
||||
internally; `Channel.from_transport(transport, …)` is the explicit
|
||||
non-stream entry point. The plan said "`Channel.__init__` takes a
|
||||
`Transport` + `Codec` instead of a raw reader/writer." Keeping the
|
||||
stream constructor let **every existing test + `manager`/`sandbox`
|
||||
call site stay byte-for-byte unchanged** (the brief's "existing test
|
||||
suites must pass unchanged except handshake/marker assertions"). The
|
||||
`Transport` seam is fully real — `Channel` delegates all I/O to
|
||||
`self._transport`; `from_transport` is the WebSocketTransport drop-in
|
||||
path, and it is exercised by a new `test_from_transport_round_trips`
|
||||
(in-memory queue transport) so it is not dead code.
|
||||
2. **`READY_MARKER` was removed in T1, not deferred to T5.** The plan
|
||||
lists marker removal under T5, but leaving a dead constant + dead
|
||||
stdout-scan code through T2/T3 was worse than removing it now. T5's
|
||||
marker work is therefore docs-only (OVERVIEW/architecture.html still
|
||||
describe the old marker — see "T5 remaining").
|
||||
|
||||
### T1 verification
|
||||
|
||||
- `uv run pytest tests/components/sandbox_v2/ --no-cov -q` → **183 passed**
|
||||
- `uv run pytest sandbox_v2/hass_client/ -q` → **53 passed**
|
||||
- `uv run prek run --files <9 touched files>` → all hooks pass (ruff,
|
||||
ruff-format, codespell, mypy, pylint).
|
||||
- `grep -rn READY_MARKER` over `sandbox_v2/` + `homeassistant/` → only
|
||||
**docs** remain (`OVERVIEW.md`, `architecture.html`, historical
|
||||
`STATUS-phase-3.md`, `plan.md`) — code is clean. T5 cleans the docs.
|
||||
- `grep -rn WebSocketTransport …` → empty (T4 correctly not introduced).
|
||||
|
||||
## Toolchain gate for T2 — CLEARED (recipe verified)
|
||||
|
||||
Core has **no `protoc` and no `grpcio-tools`** (and the client venv had
|
||||
no `protobuf` at all). `protobuf` is pinned to **6.32.0** in
|
||||
`homeassistant/package_constraints.txt`. Installing `grpcio-tools`
|
||||
directly into the **main** venv bumps `protobuf` → 6.33.x and `grpcio`
|
||||
→ 1.81 (breaks the pin) — **do not do that** (the main venv was repaired
|
||||
back to `protobuf==6.32.0` / `grpcio==1.80.0`).
|
||||
|
||||
Verified working recipe — generate in an **isolated** venv pinned to the
|
||||
runtime, so the main/client venvs are never polluted:
|
||||
|
||||
```bash
|
||||
uv venv /tmp/protogen --python 3.14
|
||||
uv pip install --python /tmp/protogen "protobuf==6.32.0" grpcio-tools mypy-protobuf
|
||||
# resolver picks grpcio-tools==1.80.0 (compatible with protobuf 6.32.0)
|
||||
/tmp/protogen/bin/python -m grpc_tools.protoc \
|
||||
-I sandbox_v2/proto \
|
||||
--python_out=<dest> --pyi_out=<dest> \
|
||||
sandbox_v2/proto/sandbox_v2.proto
|
||||
```
|
||||
|
||||
`grpcio-tools==1.80.0` emits gencode whose runtime gate is
|
||||
`ValidateProtobufRuntimeVersion(PUBLIC, 6, 31, 1, …)` — i.e. it requires
|
||||
protobuf **≥ 6.31.1**, which the pinned **6.32.0 satisfies**. Confirmed:
|
||||
the generated `_pb2.py` imports + serializes cleanly under the main
|
||||
venv's protobuf 6.32.0. **So T2's checked-in gencode is runtime-safe.**
|
||||
|
||||
The regen script (`sandbox_v2/proto/generate.sh` per plan) should
|
||||
bootstrap this isolated venv (not the project venv) and write both
|
||||
mirrors. The prek/CI drift guard must run the same isolated-venv
|
||||
generation then `git diff --exit-code` the two `_pb2`/`_pb2.pyi` paths;
|
||||
because grpcio-tools isn't a project dep, the hook needs to create the
|
||||
throwaway venv itself (or be a manual/optional CI lane) — note this when
|
||||
wiring it so it degrades gracefully where grpcio-tools is absent.
|
||||
|
||||
## T2 — resolved design (the real handoff)
|
||||
|
||||
The plan's "Codec & handler boundary — DECIDED: typed handlers" leaves
|
||||
one thing implicit that has to be nailed before coding: **a stateless
|
||||
codec can't type a `response`'s `result` bytes**, because responses don't
|
||||
obviously carry the message `type`. Resolution:
|
||||
|
||||
1. **Carry `type` on the response frame too.** The proto `Frame`
|
||||
envelope already has `type` as field 2 (always set) with a
|
||||
`oneof body { request | response }`. Populate `type` on response
|
||||
frames (the channel knows it at dispatch time). Then the codec can
|
||||
look up the result class from `frame.type` on **both** encode and
|
||||
decode — no per-call state needed. The `Frame` dataclass already has
|
||||
a `type` field; `_run_call_handler` just needs to pass the request's
|
||||
`type` into the response `Frame` (today it builds `ok_response`
|
||||
without `type`). `JsonCodec` may keep omitting `type` on the wire for
|
||||
responses (it infers kind from key presence); `ProtobufCodec` writes
|
||||
it.
|
||||
2. **The request/result class pair lives in the *codec's* registry, not
|
||||
`Channel.register`.** This is a deliberate refinement of the plan's
|
||||
"`Channel.register` gains the proto class pair": keeping the pairing
|
||||
in the codec keeps the concurrency-critical `Channel` core fully
|
||||
codec-agnostic — exactly the plan's stated safety property ("the
|
||||
concurrency-critical core doesn't move"). Each side builds a
|
||||
`type → (request_cls, result_cls)` registry from its `_proto` module
|
||||
and constructs `ProtobufCodec(registry)` / `JsonCodec(registry)`. **This
|
||||
is a deviation worth the parent's explicit ✅ before T2 coding.**
|
||||
3. **Both codecs become message-aware and hand handlers proto messages.**
|
||||
`ProtobufCodec` (de)serializes via protobuf; `JsonCodec` becomes a
|
||||
"proto-as-JSON" codec (`json_format.MessageToDict` /`ParseDict`) so it
|
||||
stays a human-readable debugging/test wire for the *same* typed
|
||||
messages. Handlers always receive a concrete proto message — no dict
|
||||
path (locked decision honored).
|
||||
4. **Dynamic fields** (`service_data`, `target`, state `attributes`,
|
||||
`capabilities`, flow `errors`/`context`, and the serialized voluptuous
|
||||
schema → `ListValue`) cross via small `struct_to_dict` /
|
||||
`dict_to_struct` / `listvalue_to_list` helpers. Everything else is an
|
||||
explicit proto field.
|
||||
5. **`Error` / `InvalidError` messages** carry fidelity #7's structured
|
||||
`vol.Invalid` data natively (already shipped on JSON via
|
||||
`error_data_for` / `_rebuild_invalid`; T2 keeps identical semantics on
|
||||
the typed `Error` message — `message`, `type`, `repeated InvalidError`).
|
||||
|
||||
### Proposed `.proto` (refined from the plan §"Protobuf schema")
|
||||
|
||||
Source of truth: `sandbox_v2/proto/sandbox_v2.proto`. Envelope + `Error`
|
||||
exactly as the plan. Typed bodies needed (one per wire `type`):
|
||||
|
||||
- `EntrySetup` (entry_setup) → `EntrySetupResult{ok, reason}`
|
||||
- `EntryUnload{entry_id}` (entry_unload) → `EntryUnloadResult{ok}`
|
||||
- `CallService` (call_service) → `CallServiceResult{has_response, response:Struct}`
|
||||
- `Shutdown{}` (shutdown) → `ShutdownResult{ok, unloaded, restore_state:Struct/bytes}`
|
||||
- `Ping{}` (ping) → `PingResult{pong}`
|
||||
- `Ready{}` (ready, push)
|
||||
- `FlowInit`/`FlowStep`/`FlowAbort` → `FlowResult` (plan has the field list)
|
||||
- `EntityDescription` (register_entity) → `RegisterEntityResult{entity_id}`
|
||||
- `UnregisterEntity{sandbox_entity_id}` → `UnregisterEntityResult{ok}`
|
||||
- `StateChanged` (state_changed, push)
|
||||
- `RegisterService`/`UnregisterService` → results
|
||||
- `FireEvent` (fire_event, push)
|
||||
- `StoreLoad{key}` → `StoreLoadResult{has_data, data:Struct}`,
|
||||
`StoreSave{key, data:Struct}` → `{ok}`, `StoreRemove{key}` → `{ok}`
|
||||
- `DeviceInfo` (nested in `EntityDescription`) — all known `DeviceInfo`
|
||||
TypedDict keys as explicit fields; `identifiers`/`connections` as
|
||||
`repeated` pairs, `via_device` as a pair, `entry_type` as string.
|
||||
|
||||
### Why T2 is a big-bang (not landable in green increments here)
|
||||
|
||||
Flipping the default codec to protobuf + switching every handler to typed
|
||||
messages must happen **atomically** — the moment handlers expect proto
|
||||
messages, every caller (production *and* tests) must pass/return proto
|
||||
messages. Surface to convert in one commit:
|
||||
|
||||
- **~20 handlers** across both sides: HA `bridge.py` (register/unregister
|
||||
entity, state_changed, register/unregister service, fire_event,
|
||||
store_load/save/remove), client `entry_runner.py` (entry_setup/unload,
|
||||
call_service), `flow_runner.py` (`_marshal_result` + 3 handlers),
|
||||
`entity_bridge.py`, `service_mirror.py`, `event_mirror.py`,
|
||||
`sandbox_bridge.py`, plus `schema_bridge.py` (both sides).
|
||||
- **~69 `.call(`/`.push(` test sites** across `test_bridge.py` (18),
|
||||
`test_store.py` (13), `test_channel.py` (11 — these stay JSON), the
|
||||
client `test_flow_runner`/`test_entry_runner`/`test_entity_bridge`/
|
||||
`test_service_mirror`/`test_event_mirror`/`test_sandbox_bridge`/
|
||||
`test_shutdown`, and the HA `test_phase13/14/19`. Each that drives a
|
||||
typed message must build/assert a proto message instead of a dict — a
|
||||
faithful but high-volume mechanical translation where a careless edit
|
||||
silently weakens an assertion.
|
||||
- **New modules** per side: `_proto/sandbox_v2_pb2.py(+.pyi)`,
|
||||
`messages.py` (typed adapters — kept in sync across the boundary like
|
||||
`channel.py`/`protocol.py`), `codec_protobuf.py` (or fold into
|
||||
`channel.py`), the registry, struct helpers.
|
||||
- **Deps:** `protobuf` → client `pyproject.toml` `dependencies` + HA
|
||||
`manifest.json` `requirements` (the latter triggers hassfest
|
||||
`requirements_all.txt` regeneration — a core-side change to validate
|
||||
carefully; **do not** touch `IGNORE_INTEGRATIONS_WITH_ERRORS`).
|
||||
`grpcio-tools`/`mypy-protobuf` → a **dev** requirements file only.
|
||||
|
||||
This is exactly the work; it's just too large + atomic to land safely in
|
||||
the remainder of this session without risking the green tree T1
|
||||
established.
|
||||
|
||||
## T3 — shape (blocked on T2)
|
||||
|
||||
`UnixSocketTransport` is thin: it reuses `StreamTransport`'s
|
||||
length-prefixed framing over the `(reader, writer)` from
|
||||
`asyncio.open_unix_connection` (subprocess) / `start_unix_server` accept
|
||||
(manager). Manager creates a socket under the config dir, passes the path
|
||||
to the subprocess, infers transport from the `--url` scheme
|
||||
(`unix://…` vs absent = stdio; `ws[s]://…` reserved for deferred WS).
|
||||
`Channel(reader, writer)` already covers it — the `Transport` Protocol is
|
||||
in place. Mostly manager wiring + new tests.
|
||||
|
||||
## T5 — remaining (blocked on T2; some already done by T1)
|
||||
|
||||
- ✅ Already removed in T1: the `READY_MARKER` constant + stdout scan code.
|
||||
- ⬜ Docs still describing the old wire/marker: `OVERVIEW.md` "Wire
|
||||
protocol"/"Channel" sections, `architecture.html` wire-format + runtime
|
||||
diagram, and the `channel.py`/`protocol.py` docstrings (T1 already
|
||||
rewrote the channel docstrings to describe the layering; revisit after
|
||||
T2 to mention protobuf as the default codec).
|
||||
- ⬜ Keep `JsonCodec` as test-only (lean: keep) and say so in its docstring
|
||||
(T1 docstring already calls it the test/debug default — update once
|
||||
protobuf is the production default).
|
||||
- ⬜ Tick the 3 `whats-changed.md` transport boxes with SHAs.
|
||||
|
||||
## Anything weird
|
||||
|
||||
- **Two mirrored generated `_pb2` copies** will exist (one per side,
|
||||
matching the existing `channel.py`/`protocol.py` no-cross-import
|
||||
boundary). The regen script must write both; the drift guard must check
|
||||
both. No drift today (none generated yet).
|
||||
- The **`messages.py` adapter modules** become a third pair that must be
|
||||
kept in sync by hand across the boundary, like `channel.py`/`protocol.py`.
|
||||
- **No deviation from the typed-handler decision** was made (T2 not
|
||||
started). The one design refinement to confirm: **codec owns the
|
||||
request/result class registry** rather than `Channel.register` (keeps
|
||||
the dispatch core codec-agnostic — see T2 design point 2).
|
||||
|
||||
## Recommendation
|
||||
|
||||
T1 is a clean, reviewable, independently-green commit — safe to push as
|
||||
its own PR or to sit at the head of the branch while T2 is scheduled.
|
||||
T2 should be its own focused session/PR using the verified codegen recipe
|
||||
and the resolved design above; T3 + T5 are small fast-follows once T2's
|
||||
seam is in protobuf.
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user