mirror of
https://github.com/home-assistant/core.git
synced 2026-05-27 19:25:18 +02:00
Compare commits
35 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 |
@@ -459,6 +459,7 @@ class AuthManager:
|
|||||||
token_type: str | None = None,
|
token_type: str | None = None,
|
||||||
access_token_expiration: timedelta = ACCESS_TOKEN_EXPIRATION,
|
access_token_expiration: timedelta = ACCESS_TOKEN_EXPIRATION,
|
||||||
credential: models.Credentials | None = None,
|
credential: models.Credentials | None = None,
|
||||||
|
scopes: frozenset[str] | None = None,
|
||||||
) -> models.RefreshToken:
|
) -> models.RefreshToken:
|
||||||
"""Create a new refresh token for a user."""
|
"""Create a new refresh token for a user."""
|
||||||
if not user.is_active:
|
if not user.is_active:
|
||||||
@@ -514,6 +515,7 @@ class AuthManager:
|
|||||||
access_token_expiration,
|
access_token_expiration,
|
||||||
expire_at,
|
expire_at,
|
||||||
credential,
|
credential,
|
||||||
|
scopes,
|
||||||
)
|
)
|
||||||
|
|
||||||
@callback
|
@callback
|
||||||
|
|||||||
@@ -211,6 +211,7 @@ class AuthStore:
|
|||||||
access_token_expiration: timedelta = ACCESS_TOKEN_EXPIRATION,
|
access_token_expiration: timedelta = ACCESS_TOKEN_EXPIRATION,
|
||||||
expire_at: float | None = None,
|
expire_at: float | None = None,
|
||||||
credential: models.Credentials | None = None,
|
credential: models.Credentials | None = None,
|
||||||
|
scopes: frozenset[str] | None = None,
|
||||||
) -> models.RefreshToken:
|
) -> models.RefreshToken:
|
||||||
"""Create a new token for a user."""
|
"""Create a new token for a user."""
|
||||||
kwargs: dict[str, Any] = {
|
kwargs: dict[str, Any] = {
|
||||||
@@ -220,6 +221,7 @@ class AuthStore:
|
|||||||
"access_token_expiration": access_token_expiration,
|
"access_token_expiration": access_token_expiration,
|
||||||
"expire_at": expire_at,
|
"expire_at": expire_at,
|
||||||
"credential": credential,
|
"credential": credential,
|
||||||
|
"scopes": scopes,
|
||||||
}
|
}
|
||||||
if client_name:
|
if client_name:
|
||||||
kwargs["client_name"] = client_name
|
kwargs["client_name"] = client_name
|
||||||
@@ -475,6 +477,7 @@ class AuthStore:
|
|||||||
else:
|
else:
|
||||||
last_used_at = None
|
last_used_at = None
|
||||||
|
|
||||||
|
scopes = rt_dict.get("scopes")
|
||||||
token = models.RefreshToken(
|
token = models.RefreshToken(
|
||||||
id=rt_dict["id"],
|
id=rt_dict["id"],
|
||||||
user=users[rt_dict["user_id"]],
|
user=users[rt_dict["user_id"]],
|
||||||
@@ -493,6 +496,7 @@ class AuthStore:
|
|||||||
last_used_ip=rt_dict.get("last_used_ip"),
|
last_used_ip=rt_dict.get("last_used_ip"),
|
||||||
expire_at=rt_dict.get("expire_at"),
|
expire_at=rt_dict.get("expire_at"),
|
||||||
version=rt_dict.get("version"),
|
version=rt_dict.get("version"),
|
||||||
|
scopes=frozenset(scopes) if scopes else None,
|
||||||
)
|
)
|
||||||
if "credential_id" in rt_dict:
|
if "credential_id" in rt_dict:
|
||||||
token.credential = credentials.get(rt_dict["credential_id"])
|
token.credential = credentials.get(rt_dict["credential_id"])
|
||||||
@@ -581,6 +585,9 @@ class AuthStore:
|
|||||||
if refresh_token.credential
|
if refresh_token.credential
|
||||||
else None,
|
else None,
|
||||||
"version": refresh_token.version,
|
"version": refresh_token.version,
|
||||||
|
"scopes": sorted(refresh_token.scopes)
|
||||||
|
if refresh_token.scopes is not None
|
||||||
|
else None,
|
||||||
}
|
}
|
||||||
for user in self._users.values()
|
for user in self._users.values()
|
||||||
for refresh_token in user.refresh_tokens.values()
|
for refresh_token in user.refresh_tokens.values()
|
||||||
|
|||||||
@@ -129,6 +129,13 @@ class RefreshToken:
|
|||||||
|
|
||||||
version: str | None = attr.ib(default=__version__)
|
version: str | None = attr.ib(default=__version__)
|
||||||
|
|
||||||
|
# Optional set of websocket-API command scopes. ``None`` means the token
|
||||||
|
# has no scope restriction (the default for normal user/system tokens).
|
||||||
|
# When set, the token may only call commands matching an entry in the
|
||||||
|
# set: a scope ending in ``/`` matches any command whose type starts
|
||||||
|
# with the prefix; otherwise the scope is an exact ``type`` match.
|
||||||
|
scopes: frozenset[str] | None = attr.ib(default=None)
|
||||||
|
|
||||||
|
|
||||||
@attr.s(slots=True)
|
@attr.s(slots=True)
|
||||||
class Credentials:
|
class Credentials:
|
||||||
|
|||||||
@@ -0,0 +1,243 @@
|
|||||||
|
"""The Sandbox integration.
|
||||||
|
|
||||||
|
Manages config entries that should run in isolated sandbox processes.
|
||||||
|
Config entries with options["sandbox"] set to a string value are grouped
|
||||||
|
by that value — entries sharing the same string run in the same sandbox
|
||||||
|
process. The sandbox integration spawns one process per group and provides
|
||||||
|
a websocket API for sandbox clients to register entities and push state.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from collections.abc import Callable
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
import logging
|
||||||
|
import sys
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from homeassistant.auth.models import RefreshToken, User
|
||||||
|
from homeassistant.config_entries import ConfigEntry
|
||||||
|
from homeassistant.core import HomeAssistant, callback
|
||||||
|
from homeassistant.helpers.typing import ConfigType
|
||||||
|
|
||||||
|
from .const import DATA_SANDBOX, DOMAIN
|
||||||
|
from .entity import SandboxEntityManager
|
||||||
|
from . import websocket_api as sandbox_ws
|
||||||
|
|
||||||
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
type SandboxConfigEntry = ConfigEntry[SandboxEntryData]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class SandboxInstance:
|
||||||
|
"""A sandbox instance that runs one or more config entries."""
|
||||||
|
|
||||||
|
sandbox_id: str
|
||||||
|
entries: list[dict[str, Any]]
|
||||||
|
user: User | None = None
|
||||||
|
refresh_token: RefreshToken | None = None
|
||||||
|
access_token: str | None = None
|
||||||
|
process: asyncio.subprocess.Process | None = None
|
||||||
|
managed_entity_ids: set[str] = field(default_factory=set)
|
||||||
|
send_command: Callable[[dict[str, Any]], None] | None = None
|
||||||
|
pending_service_calls: dict[str, asyncio.Future[Any]] = field(
|
||||||
|
default_factory=dict
|
||||||
|
)
|
||||||
|
pending_contexts: dict[str, dict[str, str | None]] = field(
|
||||||
|
default_factory=dict
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class SandboxEntryData:
|
||||||
|
"""Runtime data for a sandbox config entry."""
|
||||||
|
|
||||||
|
instance: SandboxInstance | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class SandboxData:
|
||||||
|
"""Global sandbox data stored in hass.data."""
|
||||||
|
|
||||||
|
sandboxes: dict[str, SandboxInstance] = field(default_factory=dict)
|
||||||
|
token_to_sandbox: dict[str, str] = field(default_factory=dict)
|
||||||
|
host_entry_ids: dict[str, str] = field(default_factory=dict)
|
||||||
|
entity_managers: dict[str, SandboxEntityManager] = field(default_factory=dict)
|
||||||
|
|
||||||
|
def get_host_entry_id(self, sandbox_id: str) -> str | None:
|
||||||
|
"""Return the HA Core config entry ID that hosts this sandbox."""
|
||||||
|
return self.host_entry_ids.get(sandbox_id)
|
||||||
|
|
||||||
|
|
||||||
|
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||||
|
"""Set up the Sandbox integration."""
|
||||||
|
hass.data[DATA_SANDBOX] = SandboxData()
|
||||||
|
sandbox_ws.async_setup(hass)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
async def async_setup_entry(hass: HomeAssistant, entry: SandboxConfigEntry) -> bool:
|
||||||
|
"""Set up a sandbox from a config entry.
|
||||||
|
|
||||||
|
Supports two modes:
|
||||||
|
1. Explicit entries: entry.data["entries"] contains a list of entry configs
|
||||||
|
(used by test infrastructure).
|
||||||
|
2. Discovery: entry.data["group"] names a sandbox group. All config entries
|
||||||
|
with options["sandbox"] == group are collected automatically.
|
||||||
|
"""
|
||||||
|
sandbox_data = hass.data[DATA_SANDBOX]
|
||||||
|
|
||||||
|
group = entry.data.get("group")
|
||||||
|
if group:
|
||||||
|
sandbox_entries = _discover_group_entries(hass, group)
|
||||||
|
else:
|
||||||
|
sandbox_entries = entry.data.get("entries", [])
|
||||||
|
|
||||||
|
if not sandbox_entries:
|
||||||
|
_LOGGER.warning("Sandbox %s has no entries to run", entry.entry_id)
|
||||||
|
return True
|
||||||
|
|
||||||
|
sandbox_id = entry.entry_id
|
||||||
|
|
||||||
|
instance = SandboxInstance(
|
||||||
|
sandbox_id=sandbox_id,
|
||||||
|
entries=sandbox_entries,
|
||||||
|
)
|
||||||
|
|
||||||
|
user = await hass.auth.async_create_system_user(
|
||||||
|
f"Sandbox {sandbox_id[:8]}",
|
||||||
|
group_ids=["system-admin"],
|
||||||
|
)
|
||||||
|
refresh_token = await hass.auth.async_create_refresh_token(user)
|
||||||
|
access_token = hass.auth.async_create_access_token(refresh_token)
|
||||||
|
|
||||||
|
instance.user = user
|
||||||
|
instance.refresh_token = refresh_token
|
||||||
|
instance.access_token = access_token
|
||||||
|
|
||||||
|
sandbox_data.sandboxes[sandbox_id] = instance
|
||||||
|
sandbox_data.token_to_sandbox[refresh_token.id] = sandbox_id
|
||||||
|
sandbox_data.host_entry_ids[sandbox_id] = entry.entry_id
|
||||||
|
|
||||||
|
manager = SandboxEntityManager(hass, sandbox_id)
|
||||||
|
sandbox_data.entity_managers[sandbox_id] = manager
|
||||||
|
|
||||||
|
entry.runtime_data = SandboxEntryData(instance=instance)
|
||||||
|
|
||||||
|
ws_url = _get_websocket_url(hass)
|
||||||
|
if ws_url:
|
||||||
|
instance.process = await _spawn_sandbox(
|
||||||
|
hass, ws_url, access_token, sandbox_id
|
||||||
|
)
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
async def async_unload_entry(hass: HomeAssistant, entry: SandboxConfigEntry) -> bool:
|
||||||
|
"""Unload a sandbox config entry."""
|
||||||
|
sandbox_data = hass.data[DATA_SANDBOX]
|
||||||
|
sandbox_id = entry.entry_id
|
||||||
|
instance = sandbox_data.sandboxes.pop(sandbox_id, None)
|
||||||
|
|
||||||
|
if instance is None:
|
||||||
|
return True
|
||||||
|
|
||||||
|
if instance.process is not None:
|
||||||
|
try:
|
||||||
|
instance.process.terminate()
|
||||||
|
await asyncio.wait_for(instance.process.wait(), timeout=10)
|
||||||
|
except (ProcessLookupError, asyncio.TimeoutError):
|
||||||
|
if instance.process.returncode is None:
|
||||||
|
instance.process.kill()
|
||||||
|
|
||||||
|
if instance.refresh_token is not None:
|
||||||
|
sandbox_data.token_to_sandbox.pop(instance.refresh_token.id, None)
|
||||||
|
hass.auth.async_remove_refresh_token(instance.refresh_token)
|
||||||
|
|
||||||
|
if instance.user is not None:
|
||||||
|
await hass.auth.async_remove_user(instance.user)
|
||||||
|
|
||||||
|
sandbox_data.host_entry_ids.pop(sandbox_id, None)
|
||||||
|
sandbox_data.entity_managers.pop(sandbox_id, None)
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def _discover_group_entries(
|
||||||
|
hass: HomeAssistant, group: str
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""Find all config entries whose options.sandbox matches the group string."""
|
||||||
|
entries = []
|
||||||
|
for entry in hass.config_entries.async_entries():
|
||||||
|
if entry.domain == DOMAIN:
|
||||||
|
continue
|
||||||
|
sandbox_opt = entry.options.get("sandbox")
|
||||||
|
if sandbox_opt == group:
|
||||||
|
entries.append(
|
||||||
|
{
|
||||||
|
"entry_id": entry.entry_id,
|
||||||
|
"domain": entry.domain,
|
||||||
|
"title": entry.title,
|
||||||
|
"data": dict(entry.data),
|
||||||
|
"options": {
|
||||||
|
k: v
|
||||||
|
for k, v in entry.options.items()
|
||||||
|
if k != "sandbox"
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return entries
|
||||||
|
|
||||||
|
|
||||||
|
@callback
|
||||||
|
def _get_websocket_url(hass: HomeAssistant) -> str | None:
|
||||||
|
"""Build the local websocket URL."""
|
||||||
|
if not hasattr(hass, "http") or hass.http is None:
|
||||||
|
return None
|
||||||
|
port = hass.http.server_port or 8123
|
||||||
|
return f"ws://127.0.0.1:{port}/api/websocket"
|
||||||
|
|
||||||
|
|
||||||
|
async def _spawn_sandbox(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
ws_url: str,
|
||||||
|
access_token: str,
|
||||||
|
sandbox_id: str,
|
||||||
|
) -> asyncio.subprocess.Process:
|
||||||
|
"""Spawn a sandbox subprocess."""
|
||||||
|
_LOGGER.info("Spawning sandbox process for %s", sandbox_id)
|
||||||
|
process = await asyncio.create_subprocess_exec(
|
||||||
|
sys.executable,
|
||||||
|
"-m",
|
||||||
|
"hass_client.sandbox",
|
||||||
|
"--url",
|
||||||
|
ws_url,
|
||||||
|
"--token",
|
||||||
|
access_token,
|
||||||
|
stdout=asyncio.subprocess.PIPE,
|
||||||
|
stderr=asyncio.subprocess.PIPE,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _log_stream(
|
||||||
|
stream: asyncio.StreamReader, level: int, prefix: str
|
||||||
|
) -> None:
|
||||||
|
while True:
|
||||||
|
line = await stream.readline()
|
||||||
|
if not line:
|
||||||
|
break
|
||||||
|
_LOGGER.log(level, "[sandbox %s] %s", prefix, line.decode().rstrip())
|
||||||
|
|
||||||
|
if process.stdout:
|
||||||
|
hass.async_create_background_task(
|
||||||
|
_log_stream(process.stdout, logging.INFO, sandbox_id[:8]),
|
||||||
|
f"sandbox_stdout_{sandbox_id}",
|
||||||
|
)
|
||||||
|
if process.stderr:
|
||||||
|
hass.async_create_background_task(
|
||||||
|
_log_stream(process.stderr, logging.WARNING, sandbox_id[:8]),
|
||||||
|
f"sandbox_stderr_{sandbox_id}",
|
||||||
|
)
|
||||||
|
|
||||||
|
return process
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
"""Config flow for the Sandbox integration."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||||
|
|
||||||
|
from .const import DOMAIN
|
||||||
|
|
||||||
|
|
||||||
|
class SandboxConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||||
|
"""Config flow for Sandbox."""
|
||||||
|
|
||||||
|
VERSION = 1
|
||||||
|
|
||||||
|
async def async_step_user(
|
||||||
|
self, user_input: dict[str, Any] | None = None
|
||||||
|
) -> ConfigFlowResult:
|
||||||
|
"""Handle the initial step."""
|
||||||
|
if user_input is not None:
|
||||||
|
return self.async_create_entry(
|
||||||
|
title="Sandbox",
|
||||||
|
data=user_input,
|
||||||
|
)
|
||||||
|
|
||||||
|
return self.async_show_form(step_id="user")
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
"""Constants for the Sandbox integration."""
|
||||||
|
|
||||||
|
from homeassistant.util.hass_dict import HassKey
|
||||||
|
|
||||||
|
DOMAIN = "sandbox"
|
||||||
|
|
||||||
|
DATA_SANDBOX: HassKey["SandboxData"] = HassKey(DOMAIN)
|
||||||
@@ -0,0 +1,280 @@
|
|||||||
|
"""Remote entity proxies for sandboxed integrations."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
import logging
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from homeassistant.core import HomeAssistant, callback
|
||||||
|
from homeassistant.helpers import device_registry as dr
|
||||||
|
from homeassistant.helpers.device_registry import DeviceInfo
|
||||||
|
from homeassistant.helpers.entity import Entity
|
||||||
|
|
||||||
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class SandboxEntityDescription:
|
||||||
|
"""Description of a remote entity from a sandbox."""
|
||||||
|
|
||||||
|
domain: str
|
||||||
|
platform: str
|
||||||
|
unique_id: str
|
||||||
|
sandbox_id: str
|
||||||
|
sandbox_entry_id: str
|
||||||
|
device_id: str | None = None
|
||||||
|
original_name: str | None = None
|
||||||
|
original_icon: str | None = None
|
||||||
|
entity_category: str | None = None
|
||||||
|
device_class: str | None = None
|
||||||
|
state_class: str | None = None
|
||||||
|
supported_features: int = 0
|
||||||
|
capabilities: dict[str, Any] = field(default_factory=dict)
|
||||||
|
has_entity_name: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class SandboxEntityManager:
|
||||||
|
"""Manages proxy entities for a sandbox connection."""
|
||||||
|
|
||||||
|
def __init__(self, hass: HomeAssistant, sandbox_id: str) -> None:
|
||||||
|
"""Initialize the entity manager."""
|
||||||
|
self.hass = hass
|
||||||
|
self.sandbox_id = sandbox_id
|
||||||
|
self._entities: dict[str, SandboxProxyEntity] = {}
|
||||||
|
self._pending_calls: dict[str, asyncio.Future[Any]] = {}
|
||||||
|
self._call_id_counter = 0
|
||||||
|
|
||||||
|
@callback
|
||||||
|
def add_entity(self, description: SandboxEntityDescription) -> SandboxProxyEntity:
|
||||||
|
"""Create a proxy entity (not yet tracked by entity_id)."""
|
||||||
|
return _create_proxy_entity(description, self)
|
||||||
|
|
||||||
|
@callback
|
||||||
|
def track_entity(self, entity_id: str, entity: SandboxProxyEntity) -> None:
|
||||||
|
"""Track a proxy entity by its assigned entity_id."""
|
||||||
|
self._entities[entity_id] = entity
|
||||||
|
|
||||||
|
@callback
|
||||||
|
def get_entity(self, entity_id: str) -> SandboxProxyEntity | None:
|
||||||
|
"""Get a proxy entity by entity_id."""
|
||||||
|
return self._entities.get(entity_id)
|
||||||
|
|
||||||
|
@callback
|
||||||
|
def remove_entity(self, entity_id: str) -> None:
|
||||||
|
"""Remove a proxy entity."""
|
||||||
|
self._entities.pop(entity_id, None)
|
||||||
|
|
||||||
|
@callback
|
||||||
|
def update_state(
|
||||||
|
self, entity_id: str, state: str, attributes: dict[str, Any] | None
|
||||||
|
) -> None:
|
||||||
|
"""Update a proxy entity's state from sandbox push."""
|
||||||
|
entity = self._entities.get(entity_id)
|
||||||
|
if entity is None:
|
||||||
|
return
|
||||||
|
entity.sandbox_update_state(state, attributes or {})
|
||||||
|
|
||||||
|
@callback
|
||||||
|
def mark_all_unavailable(self) -> None:
|
||||||
|
"""Mark all entities as unavailable (sandbox disconnected)."""
|
||||||
|
for entity in self._entities.values():
|
||||||
|
entity.sandbox_set_available(False)
|
||||||
|
|
||||||
|
@callback
|
||||||
|
def mark_all_available(self) -> None:
|
||||||
|
"""Mark all entities as available (sandbox reconnected)."""
|
||||||
|
for entity in self._entities.values():
|
||||||
|
entity.sandbox_set_available(True)
|
||||||
|
|
||||||
|
def next_call_id(self) -> str:
|
||||||
|
"""Generate a unique call ID."""
|
||||||
|
self._call_id_counter += 1
|
||||||
|
return f"{self.sandbox_id}_{self._call_id_counter}"
|
||||||
|
|
||||||
|
@callback
|
||||||
|
def resolve_call(self, call_id: str, result: Any, error: str | None) -> None:
|
||||||
|
"""Resolve a pending method call from the sandbox."""
|
||||||
|
future = self._pending_calls.pop(call_id, None)
|
||||||
|
if future is None or future.done():
|
||||||
|
return
|
||||||
|
if error:
|
||||||
|
future.set_exception(Exception(error))
|
||||||
|
else:
|
||||||
|
future.set_result(result)
|
||||||
|
|
||||||
|
def create_call_future(self, call_id: str) -> asyncio.Future[Any]:
|
||||||
|
"""Create a future for a pending call."""
|
||||||
|
future: asyncio.Future[Any] = self.hass.loop.create_future()
|
||||||
|
self._pending_calls[call_id] = future
|
||||||
|
return future
|
||||||
|
|
||||||
|
|
||||||
|
class SandboxProxyEntity(Entity):
|
||||||
|
"""Base class for proxy entities that live on the host."""
|
||||||
|
|
||||||
|
_attr_should_poll = False
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
description: SandboxEntityDescription,
|
||||||
|
manager: SandboxEntityManager,
|
||||||
|
) -> None:
|
||||||
|
"""Initialize the proxy entity."""
|
||||||
|
self._description = description
|
||||||
|
self._manager = manager
|
||||||
|
self._sandbox_available = True
|
||||||
|
self._state_cache: dict[str, Any] = {}
|
||||||
|
self._attr_unique_id = description.unique_id
|
||||||
|
self._attr_has_entity_name = description.has_entity_name
|
||||||
|
if description.original_name:
|
||||||
|
self._attr_name = description.original_name
|
||||||
|
if description.original_icon:
|
||||||
|
self._attr_icon = description.original_icon
|
||||||
|
if description.device_class:
|
||||||
|
self._attr_device_class = description.device_class
|
||||||
|
self._attr_supported_features = description.supported_features
|
||||||
|
|
||||||
|
@property
|
||||||
|
def device_info(self) -> DeviceInfo | None:
|
||||||
|
"""Return device info to associate with the correct device."""
|
||||||
|
if self._description.device_id is None:
|
||||||
|
return None
|
||||||
|
device_reg = dr.async_get(self.hass)
|
||||||
|
device = device_reg.async_get(self._description.device_id)
|
||||||
|
if device is None:
|
||||||
|
return None
|
||||||
|
return DeviceInfo(identifiers=device.identifiers)
|
||||||
|
|
||||||
|
async def async_added_to_hass(self) -> None:
|
||||||
|
"""Register with entity manager once we have our entity_id."""
|
||||||
|
self._manager.track_entity(self.entity_id, self)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def available(self) -> bool:
|
||||||
|
"""Return if entity is available."""
|
||||||
|
return self._sandbox_available
|
||||||
|
|
||||||
|
@callback
|
||||||
|
def sandbox_update_state(self, state: str, attributes: dict[str, Any]) -> None:
|
||||||
|
"""Update state from sandbox push."""
|
||||||
|
self._state_cache.update(attributes)
|
||||||
|
self._state_cache["state"] = state
|
||||||
|
self.async_write_ha_state()
|
||||||
|
|
||||||
|
@callback
|
||||||
|
def sandbox_set_available(self, available: bool) -> None:
|
||||||
|
"""Set availability."""
|
||||||
|
self._sandbox_available = available
|
||||||
|
self.async_write_ha_state()
|
||||||
|
|
||||||
|
async def _forward_method(self, method: str, **kwargs: Any) -> Any:
|
||||||
|
"""Forward a method call to the sandbox entity."""
|
||||||
|
from ..const import DATA_SANDBOX
|
||||||
|
|
||||||
|
sandbox_data = self.hass.data[DATA_SANDBOX]
|
||||||
|
sandbox_info = sandbox_data.sandboxes.get(self._manager.sandbox_id)
|
||||||
|
if sandbox_info is None or sandbox_info.send_command is None:
|
||||||
|
raise RuntimeError("Sandbox not connected")
|
||||||
|
|
||||||
|
call_id = self._manager.next_call_id()
|
||||||
|
future = self._manager.create_call_future(call_id)
|
||||||
|
|
||||||
|
sandbox_info.send_command(
|
||||||
|
{
|
||||||
|
"type": "call_method",
|
||||||
|
"call_id": call_id,
|
||||||
|
"entity_id": self.entity_id,
|
||||||
|
"method": method,
|
||||||
|
"kwargs": kwargs,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return await asyncio.wait_for(future, timeout=30)
|
||||||
|
|
||||||
|
|
||||||
|
from .alarm_control_panel import SandboxAlarmControlPanelEntity
|
||||||
|
from .binary_sensor import SandboxBinarySensorEntity
|
||||||
|
from .button import SandboxButtonEntity
|
||||||
|
from .calendar import SandboxCalendarEntity
|
||||||
|
from .climate import SandboxClimateEntity
|
||||||
|
from .cover import SandboxCoverEntity
|
||||||
|
from .date import SandboxDateEntity
|
||||||
|
from .datetime import SandboxDateTimeEntity
|
||||||
|
from .device_tracker import SandboxScannerEntity, SandboxTrackerEntity
|
||||||
|
from .event import SandboxEventEntity
|
||||||
|
from .fan import SandboxFanEntity
|
||||||
|
from .humidifier import SandboxHumidifierEntity
|
||||||
|
from .lawn_mower import SandboxLawnMowerEntity
|
||||||
|
from .light import SandboxLightEntity
|
||||||
|
from .lock import SandboxLockEntity
|
||||||
|
from .media_player import SandboxMediaPlayerEntity
|
||||||
|
from .notify import SandboxNotifyEntity
|
||||||
|
from .number import SandboxNumberEntity
|
||||||
|
from .remote import SandboxRemoteEntity
|
||||||
|
from .scene import SandboxSceneEntity
|
||||||
|
from .select import SandboxSelectEntity
|
||||||
|
from .sensor import SandboxSensorEntity
|
||||||
|
from .siren import SandboxSirenEntity
|
||||||
|
from .switch import SandboxSwitchEntity
|
||||||
|
from .text import SandboxTextEntity
|
||||||
|
from .time import SandboxTimeEntity
|
||||||
|
from .todo import SandboxTodoListEntity
|
||||||
|
from .update import SandboxUpdateEntity
|
||||||
|
from .vacuum import SandboxVacuumEntity
|
||||||
|
from .valve import SandboxValveEntity
|
||||||
|
from .water_heater import SandboxWaterHeaterEntity
|
||||||
|
from .weather import SandboxWeatherEntity
|
||||||
|
|
||||||
|
_DOMAIN_ENTITY_MAP: dict[str, type[SandboxProxyEntity]] = {
|
||||||
|
"alarm_control_panel": SandboxAlarmControlPanelEntity,
|
||||||
|
"binary_sensor": SandboxBinarySensorEntity,
|
||||||
|
"button": SandboxButtonEntity,
|
||||||
|
"calendar": SandboxCalendarEntity,
|
||||||
|
"climate": SandboxClimateEntity,
|
||||||
|
"cover": SandboxCoverEntity,
|
||||||
|
"date": SandboxDateEntity,
|
||||||
|
"datetime": SandboxDateTimeEntity,
|
||||||
|
"device_tracker": SandboxTrackerEntity,
|
||||||
|
"event": SandboxEventEntity,
|
||||||
|
"fan": SandboxFanEntity,
|
||||||
|
"humidifier": SandboxHumidifierEntity,
|
||||||
|
"lawn_mower": SandboxLawnMowerEntity,
|
||||||
|
"light": SandboxLightEntity,
|
||||||
|
"lock": SandboxLockEntity,
|
||||||
|
"media_player": SandboxMediaPlayerEntity,
|
||||||
|
"notify": SandboxNotifyEntity,
|
||||||
|
"number": SandboxNumberEntity,
|
||||||
|
"remote": SandboxRemoteEntity,
|
||||||
|
"scene": SandboxSceneEntity,
|
||||||
|
"select": SandboxSelectEntity,
|
||||||
|
"sensor": SandboxSensorEntity,
|
||||||
|
"siren": SandboxSirenEntity,
|
||||||
|
"switch": SandboxSwitchEntity,
|
||||||
|
"text": SandboxTextEntity,
|
||||||
|
"time": SandboxTimeEntity,
|
||||||
|
"todo": SandboxTodoListEntity,
|
||||||
|
"update": SandboxUpdateEntity,
|
||||||
|
"vacuum": SandboxVacuumEntity,
|
||||||
|
"valve": SandboxValveEntity,
|
||||||
|
"water_heater": SandboxWaterHeaterEntity,
|
||||||
|
"weather": SandboxWeatherEntity,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _create_proxy_entity(
|
||||||
|
description: SandboxEntityDescription,
|
||||||
|
manager: SandboxEntityManager,
|
||||||
|
) -> SandboxProxyEntity:
|
||||||
|
"""Create the appropriate proxy entity for the domain."""
|
||||||
|
entity_cls = _DOMAIN_ENTITY_MAP.get(description.domain, SandboxProxyEntity)
|
||||||
|
return entity_cls(description, manager)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"SandboxEntityDescription",
|
||||||
|
"SandboxEntityManager",
|
||||||
|
"SandboxProxyEntity",
|
||||||
|
"_DOMAIN_ENTITY_MAP",
|
||||||
|
"_create_proxy_entity",
|
||||||
|
]
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
"""Sandbox proxy for alarm_control_panel entities."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from homeassistant.components.alarm_control_panel import (
|
||||||
|
AlarmControlPanelEntity,
|
||||||
|
AlarmControlPanelEntityFeature,
|
||||||
|
)
|
||||||
|
|
||||||
|
from . import SandboxEntityDescription, SandboxEntityManager, SandboxProxyEntity
|
||||||
|
|
||||||
|
|
||||||
|
class SandboxAlarmControlPanelEntity(SandboxProxyEntity, AlarmControlPanelEntity):
|
||||||
|
"""Proxy for an alarm_control_panel entity in a sandbox."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
description: SandboxEntityDescription,
|
||||||
|
manager: SandboxEntityManager,
|
||||||
|
) -> None:
|
||||||
|
"""Initialize the proxy alarm control panel entity."""
|
||||||
|
super().__init__(description, manager)
|
||||||
|
self._attr_supported_features = AlarmControlPanelEntityFeature(
|
||||||
|
description.supported_features
|
||||||
|
)
|
||||||
|
caps = description.capabilities
|
||||||
|
if code_format := caps.get("code_format"):
|
||||||
|
self._attr_code_format = code_format
|
||||||
|
if (code_arm_required := caps.get("code_arm_required")) is not None:
|
||||||
|
self._attr_code_arm_required = code_arm_required
|
||||||
|
|
||||||
|
@property
|
||||||
|
def alarm_state(self) -> str | None:
|
||||||
|
"""Return the alarm state."""
|
||||||
|
return self._state_cache.get("state")
|
||||||
|
|
||||||
|
async def async_alarm_disarm(self, code: str | None = None) -> None:
|
||||||
|
"""Forward alarm_disarm to sandbox."""
|
||||||
|
await self._forward_method("async_alarm_disarm", code=code)
|
||||||
|
|
||||||
|
async def async_alarm_arm_home(self, code: str | None = None) -> None:
|
||||||
|
"""Forward alarm_arm_home to sandbox."""
|
||||||
|
await self._forward_method("async_alarm_arm_home", code=code)
|
||||||
|
|
||||||
|
async def async_alarm_arm_away(self, code: str | None = None) -> None:
|
||||||
|
"""Forward alarm_arm_away to sandbox."""
|
||||||
|
await self._forward_method("async_alarm_arm_away", code=code)
|
||||||
|
|
||||||
|
async def async_alarm_arm_night(self, code: str | None = None) -> None:
|
||||||
|
"""Forward alarm_arm_night to sandbox."""
|
||||||
|
await self._forward_method("async_alarm_arm_night", code=code)
|
||||||
|
|
||||||
|
async def async_alarm_arm_vacation(self, code: str | None = None) -> None:
|
||||||
|
"""Forward alarm_arm_vacation to sandbox."""
|
||||||
|
await self._forward_method("async_alarm_arm_vacation", code=code)
|
||||||
|
|
||||||
|
async def async_alarm_trigger(self, code: str | None = None) -> None:
|
||||||
|
"""Forward alarm_trigger to sandbox."""
|
||||||
|
await self._forward_method("async_alarm_trigger", code=code)
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
"""Sandbox proxy for binary_sensor entities."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from homeassistant.components.binary_sensor import BinarySensorEntity
|
||||||
|
|
||||||
|
from . import SandboxProxyEntity
|
||||||
|
|
||||||
|
|
||||||
|
class SandboxBinarySensorEntity(SandboxProxyEntity, BinarySensorEntity):
|
||||||
|
"""Proxy for a binary_sensor entity in a sandbox."""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_on(self) -> bool | None:
|
||||||
|
"""Return if the sensor is on."""
|
||||||
|
state = self._state_cache.get("state")
|
||||||
|
if state is None:
|
||||||
|
return None
|
||||||
|
return state == "on"
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
"""Sandbox proxy for button entities."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from homeassistant.components.button import ButtonEntity
|
||||||
|
|
||||||
|
from . import SandboxProxyEntity
|
||||||
|
|
||||||
|
|
||||||
|
class SandboxButtonEntity(SandboxProxyEntity, ButtonEntity):
|
||||||
|
"""Proxy for a button entity in a sandbox."""
|
||||||
|
|
||||||
|
async def async_press(self) -> None:
|
||||||
|
"""Forward press to sandbox."""
|
||||||
|
await self._forward_method("async_press")
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
"""Sandbox proxy for calendar entities."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import date, datetime
|
||||||
|
|
||||||
|
from homeassistant.components.calendar import CalendarEntity, CalendarEvent
|
||||||
|
from homeassistant.core import HomeAssistant
|
||||||
|
|
||||||
|
from . import SandboxProxyEntity
|
||||||
|
|
||||||
|
|
||||||
|
class SandboxCalendarEntity(SandboxProxyEntity, CalendarEntity):
|
||||||
|
"""Proxy for a calendar entity in a sandbox."""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def event(self) -> CalendarEvent | None:
|
||||||
|
"""Return the next event."""
|
||||||
|
event_data = self._state_cache.get("event")
|
||||||
|
if event_data is None:
|
||||||
|
return None
|
||||||
|
start = event_data.get("start")
|
||||||
|
end = event_data.get("end")
|
||||||
|
if isinstance(start, str):
|
||||||
|
start = datetime.fromisoformat(start) if "T" in start else date.fromisoformat(start)
|
||||||
|
if isinstance(end, str):
|
||||||
|
end = datetime.fromisoformat(end) if "T" in end else date.fromisoformat(end)
|
||||||
|
return CalendarEvent(
|
||||||
|
start=start,
|
||||||
|
end=end,
|
||||||
|
summary=event_data.get("summary", ""),
|
||||||
|
description=event_data.get("description"),
|
||||||
|
location=event_data.get("location"),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def async_get_events(self, hass: HomeAssistant, start_date, end_date) -> list[CalendarEvent]:
|
||||||
|
"""Forward get_events to sandbox."""
|
||||||
|
result = await self._forward_method(
|
||||||
|
"async_get_events",
|
||||||
|
start_date=start_date.isoformat(),
|
||||||
|
end_date=end_date.isoformat(),
|
||||||
|
)
|
||||||
|
if not result:
|
||||||
|
return []
|
||||||
|
events = []
|
||||||
|
for ev in result:
|
||||||
|
start = ev.get("start")
|
||||||
|
end = ev.get("end")
|
||||||
|
if isinstance(start, str):
|
||||||
|
start = datetime.fromisoformat(start) if "T" in start else date.fromisoformat(start)
|
||||||
|
if isinstance(end, str):
|
||||||
|
end = datetime.fromisoformat(end) if "T" in end else date.fromisoformat(end)
|
||||||
|
events.append(CalendarEvent(
|
||||||
|
start=start,
|
||||||
|
end=end,
|
||||||
|
summary=ev.get("summary", ""),
|
||||||
|
description=ev.get("description"),
|
||||||
|
location=ev.get("location"),
|
||||||
|
))
|
||||||
|
return events
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
"""Sandbox proxy for climate entities."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from homeassistant.components.climate import ClimateEntity, ClimateEntityFeature, HVACMode
|
||||||
|
|
||||||
|
from . import SandboxEntityDescription, SandboxEntityManager, SandboxProxyEntity
|
||||||
|
|
||||||
|
|
||||||
|
class SandboxClimateEntity(SandboxProxyEntity, ClimateEntity):
|
||||||
|
"""Proxy for a climate entity in a sandbox."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
description: SandboxEntityDescription,
|
||||||
|
manager: SandboxEntityManager,
|
||||||
|
) -> None:
|
||||||
|
"""Initialize the proxy climate entity."""
|
||||||
|
super().__init__(description, manager)
|
||||||
|
self._attr_supported_features = ClimateEntityFeature(
|
||||||
|
description.supported_features
|
||||||
|
)
|
||||||
|
caps = description.capabilities
|
||||||
|
if hvac_modes := caps.get("hvac_modes"):
|
||||||
|
self._attr_hvac_modes = [HVACMode(m) for m in hvac_modes]
|
||||||
|
if fan_modes := caps.get("fan_modes"):
|
||||||
|
self._attr_fan_modes = fan_modes
|
||||||
|
if preset_modes := caps.get("preset_modes"):
|
||||||
|
self._attr_preset_modes = preset_modes
|
||||||
|
if swing_modes := caps.get("swing_modes"):
|
||||||
|
self._attr_swing_modes = swing_modes
|
||||||
|
if (min_temp := caps.get("min_temp")) is not None:
|
||||||
|
self._attr_min_temp = min_temp
|
||||||
|
if (max_temp := caps.get("max_temp")) is not None:
|
||||||
|
self._attr_max_temp = max_temp
|
||||||
|
if (min_humidity := caps.get("min_humidity")) is not None:
|
||||||
|
self._attr_min_humidity = min_humidity
|
||||||
|
if (max_humidity := caps.get("max_humidity")) is not None:
|
||||||
|
self._attr_max_humidity = max_humidity
|
||||||
|
if (temp_step := caps.get("target_temperature_step")) is not None:
|
||||||
|
self._attr_target_temperature_step = temp_step
|
||||||
|
if temp_unit := caps.get("temperature_unit"):
|
||||||
|
self._attr_temperature_unit = temp_unit
|
||||||
|
|
||||||
|
@property
|
||||||
|
def hvac_mode(self) -> HVACMode | None:
|
||||||
|
"""Return the current HVAC mode."""
|
||||||
|
mode = self._state_cache.get("hvac_mode")
|
||||||
|
if mode is None:
|
||||||
|
return None
|
||||||
|
return HVACMode(mode)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def hvac_action(self) -> str | None:
|
||||||
|
"""Return the current HVAC action."""
|
||||||
|
return self._state_cache.get("hvac_action")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def current_temperature(self) -> float | None:
|
||||||
|
"""Return the current temperature."""
|
||||||
|
return self._state_cache.get("current_temperature")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def target_temperature(self) -> float | None:
|
||||||
|
"""Return the target temperature."""
|
||||||
|
return self._state_cache.get("target_temperature")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def target_temperature_high(self) -> float | None:
|
||||||
|
"""Return the high target temperature."""
|
||||||
|
return self._state_cache.get("target_temperature_high")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def target_temperature_low(self) -> float | None:
|
||||||
|
"""Return the low target temperature."""
|
||||||
|
return self._state_cache.get("target_temperature_low")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def current_humidity(self) -> float | None:
|
||||||
|
"""Return the current humidity."""
|
||||||
|
return self._state_cache.get("current_humidity")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def target_humidity(self) -> float | None:
|
||||||
|
"""Return the target humidity."""
|
||||||
|
return self._state_cache.get("target_humidity")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def fan_mode(self) -> str | None:
|
||||||
|
"""Return the current fan mode."""
|
||||||
|
return self._state_cache.get("fan_mode")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def preset_mode(self) -> str | None:
|
||||||
|
"""Return the current preset mode."""
|
||||||
|
return self._state_cache.get("preset_mode")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def swing_mode(self) -> str | None:
|
||||||
|
"""Return the current swing mode."""
|
||||||
|
return self._state_cache.get("swing_mode")
|
||||||
|
|
||||||
|
async def async_set_temperature(self, **kwargs: Any) -> None:
|
||||||
|
"""Forward set_temperature to sandbox."""
|
||||||
|
await self._forward_method("async_set_temperature", **kwargs)
|
||||||
|
|
||||||
|
async def async_set_humidity(self, humidity: int) -> None:
|
||||||
|
"""Forward set_humidity to sandbox."""
|
||||||
|
await self._forward_method("async_set_humidity", humidity=humidity)
|
||||||
|
|
||||||
|
async def async_set_fan_mode(self, fan_mode: str) -> None:
|
||||||
|
"""Forward set_fan_mode to sandbox."""
|
||||||
|
await self._forward_method("async_set_fan_mode", fan_mode=fan_mode)
|
||||||
|
|
||||||
|
async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
|
||||||
|
"""Forward set_hvac_mode to sandbox."""
|
||||||
|
await self._forward_method("async_set_hvac_mode", hvac_mode=hvac_mode)
|
||||||
|
|
||||||
|
async def async_set_preset_mode(self, preset_mode: str) -> None:
|
||||||
|
"""Forward set_preset_mode to sandbox."""
|
||||||
|
await self._forward_method("async_set_preset_mode", preset_mode=preset_mode)
|
||||||
|
|
||||||
|
async def async_set_swing_mode(self, swing_mode: str) -> None:
|
||||||
|
"""Forward set_swing_mode to sandbox."""
|
||||||
|
await self._forward_method("async_set_swing_mode", swing_mode=swing_mode)
|
||||||
|
|
||||||
|
async def async_turn_on(self) -> None:
|
||||||
|
"""Forward turn_on to sandbox."""
|
||||||
|
await self._forward_method("async_turn_on")
|
||||||
|
|
||||||
|
async def async_turn_off(self) -> None:
|
||||||
|
"""Forward turn_off to sandbox."""
|
||||||
|
await self._forward_method("async_turn_off")
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
"""Sandbox proxy for cover entities."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from homeassistant.components.cover import CoverEntity, CoverEntityFeature
|
||||||
|
|
||||||
|
from . import SandboxEntityDescription, SandboxEntityManager, SandboxProxyEntity
|
||||||
|
|
||||||
|
|
||||||
|
class SandboxCoverEntity(SandboxProxyEntity, CoverEntity):
|
||||||
|
"""Proxy for a cover entity in a sandbox."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
description: SandboxEntityDescription,
|
||||||
|
manager: SandboxEntityManager,
|
||||||
|
) -> None:
|
||||||
|
"""Initialize the proxy cover entity."""
|
||||||
|
super().__init__(description, manager)
|
||||||
|
self._attr_supported_features = CoverEntityFeature(
|
||||||
|
description.supported_features
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_closed(self) -> bool | None:
|
||||||
|
"""Return if the cover is closed."""
|
||||||
|
state = self._state_cache.get("state")
|
||||||
|
if state is None:
|
||||||
|
return None
|
||||||
|
return state == "closed"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_opening(self) -> bool | None:
|
||||||
|
"""Return if the cover is opening."""
|
||||||
|
return self._state_cache.get("is_opening")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_closing(self) -> bool | None:
|
||||||
|
"""Return if the cover is closing."""
|
||||||
|
return self._state_cache.get("is_closing")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def current_cover_position(self) -> int | None:
|
||||||
|
"""Return the current cover position."""
|
||||||
|
return self._state_cache.get("current_cover_position")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def current_cover_tilt_position(self) -> int | None:
|
||||||
|
"""Return the current tilt position."""
|
||||||
|
return self._state_cache.get("current_cover_tilt_position")
|
||||||
|
|
||||||
|
async def async_open_cover(self, **kwargs: Any) -> None:
|
||||||
|
"""Forward open_cover to sandbox."""
|
||||||
|
await self._forward_method("async_open_cover", **kwargs)
|
||||||
|
|
||||||
|
async def async_close_cover(self, **kwargs: Any) -> None:
|
||||||
|
"""Forward close_cover to sandbox."""
|
||||||
|
await self._forward_method("async_close_cover", **kwargs)
|
||||||
|
|
||||||
|
async def async_stop_cover(self, **kwargs: Any) -> None:
|
||||||
|
"""Forward stop_cover to sandbox."""
|
||||||
|
await self._forward_method("async_stop_cover", **kwargs)
|
||||||
|
|
||||||
|
async def async_set_cover_position(self, **kwargs: Any) -> None:
|
||||||
|
"""Forward set_cover_position to sandbox."""
|
||||||
|
await self._forward_method("async_set_cover_position", **kwargs)
|
||||||
|
|
||||||
|
async def async_open_cover_tilt(self, **kwargs: Any) -> None:
|
||||||
|
"""Forward open_cover_tilt to sandbox."""
|
||||||
|
await self._forward_method("async_open_cover_tilt", **kwargs)
|
||||||
|
|
||||||
|
async def async_close_cover_tilt(self, **kwargs: Any) -> None:
|
||||||
|
"""Forward close_cover_tilt to sandbox."""
|
||||||
|
await self._forward_method("async_close_cover_tilt", **kwargs)
|
||||||
|
|
||||||
|
async def async_stop_cover_tilt(self, **kwargs: Any) -> None:
|
||||||
|
"""Forward stop_cover_tilt to sandbox."""
|
||||||
|
await self._forward_method("async_stop_cover_tilt", **kwargs)
|
||||||
|
|
||||||
|
async def async_set_cover_tilt_position(self, **kwargs: Any) -> None:
|
||||||
|
"""Forward set_cover_tilt_position to sandbox."""
|
||||||
|
await self._forward_method("async_set_cover_tilt_position", **kwargs)
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
"""Sandbox proxy for date entities."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
from homeassistant.components.date import DateEntity
|
||||||
|
|
||||||
|
from . import SandboxProxyEntity
|
||||||
|
|
||||||
|
|
||||||
|
class SandboxDateEntity(SandboxProxyEntity, DateEntity):
|
||||||
|
"""Proxy for a date entity in a sandbox."""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def native_value(self):
|
||||||
|
"""Return the current date value."""
|
||||||
|
val = self._state_cache.get("state")
|
||||||
|
if val is None:
|
||||||
|
return None
|
||||||
|
if isinstance(val, str):
|
||||||
|
return date.fromisoformat(val)
|
||||||
|
return val
|
||||||
|
|
||||||
|
async def async_set_value(self, value) -> None:
|
||||||
|
"""Forward set_value to sandbox."""
|
||||||
|
await self._forward_method("async_set_value", value=value.isoformat())
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
"""Sandbox proxy for datetime entities."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from homeassistant.components.datetime import DateTimeEntity
|
||||||
|
|
||||||
|
from . import SandboxProxyEntity
|
||||||
|
|
||||||
|
|
||||||
|
class SandboxDateTimeEntity(SandboxProxyEntity, DateTimeEntity):
|
||||||
|
"""Proxy for a datetime entity in a sandbox."""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def native_value(self):
|
||||||
|
"""Return the current datetime value."""
|
||||||
|
val = self._state_cache.get("state")
|
||||||
|
if val is None:
|
||||||
|
return None
|
||||||
|
if isinstance(val, str):
|
||||||
|
dt = datetime.fromisoformat(val)
|
||||||
|
if dt.tzinfo is None:
|
||||||
|
dt = dt.replace(tzinfo=timezone.utc)
|
||||||
|
return dt
|
||||||
|
return val
|
||||||
|
|
||||||
|
async def async_set_value(self, value) -> None:
|
||||||
|
"""Forward set_value to sandbox."""
|
||||||
|
await self._forward_method("async_set_value", value=value.isoformat())
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
"""Sandbox proxy for device_tracker entities."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from homeassistant.components.device_tracker import SourceType
|
||||||
|
from homeassistant.components.device_tracker.config_entry import ScannerEntity, TrackerEntity
|
||||||
|
|
||||||
|
from . import SandboxEntityDescription, SandboxEntityManager, SandboxProxyEntity
|
||||||
|
|
||||||
|
|
||||||
|
class SandboxTrackerEntity(SandboxProxyEntity, TrackerEntity):
|
||||||
|
"""Proxy for a GPS device tracker entity in a sandbox."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
description: SandboxEntityDescription,
|
||||||
|
manager: SandboxEntityManager,
|
||||||
|
) -> None:
|
||||||
|
"""Initialize the proxy tracker entity."""
|
||||||
|
super().__init__(description, manager)
|
||||||
|
if source_type := description.capabilities.get("source_type"):
|
||||||
|
self._attr_source_type = SourceType(source_type)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def latitude(self) -> float | None:
|
||||||
|
"""Return the latitude."""
|
||||||
|
return self._state_cache.get("latitude")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def longitude(self) -> float | None:
|
||||||
|
"""Return the longitude."""
|
||||||
|
return self._state_cache.get("longitude")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def location_accuracy(self) -> float:
|
||||||
|
"""Return the location accuracy."""
|
||||||
|
return self._state_cache.get("location_accuracy", 0)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def location_name(self) -> str | None:
|
||||||
|
"""Return the location name."""
|
||||||
|
return self._state_cache.get("location_name")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def battery_level(self) -> int | None:
|
||||||
|
"""Return the battery level."""
|
||||||
|
return self._state_cache.get("battery_level")
|
||||||
|
|
||||||
|
|
||||||
|
class SandboxScannerEntity(SandboxProxyEntity, ScannerEntity):
|
||||||
|
"""Proxy for a scanner device tracker entity in a sandbox."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
description: SandboxEntityDescription,
|
||||||
|
manager: SandboxEntityManager,
|
||||||
|
) -> None:
|
||||||
|
"""Initialize the proxy scanner entity."""
|
||||||
|
super().__init__(description, manager)
|
||||||
|
if source_type := description.capabilities.get("source_type"):
|
||||||
|
self._attr_source_type = SourceType(source_type)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_connected(self) -> bool:
|
||||||
|
"""Return if the device is connected."""
|
||||||
|
state = self._state_cache.get("state")
|
||||||
|
return state == "home"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def ip_address(self) -> str | None:
|
||||||
|
"""Return the IP address."""
|
||||||
|
return self._state_cache.get("ip_address")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def mac_address(self) -> str | None:
|
||||||
|
"""Return the MAC address."""
|
||||||
|
return self._state_cache.get("mac_address")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def hostname(self) -> str | None:
|
||||||
|
"""Return the hostname."""
|
||||||
|
return self._state_cache.get("hostname")
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
"""Sandbox proxy for event entities."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from homeassistant.components.event import EventEntity
|
||||||
|
from homeassistant.core import callback
|
||||||
|
|
||||||
|
from . import SandboxEntityDescription, SandboxEntityManager, SandboxProxyEntity
|
||||||
|
|
||||||
|
|
||||||
|
class SandboxEventEntity(SandboxProxyEntity, EventEntity):
|
||||||
|
"""Proxy for an event entity in a sandbox."""
|
||||||
|
|
||||||
|
_unrecorded_attributes = frozenset({})
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
description: SandboxEntityDescription,
|
||||||
|
manager: SandboxEntityManager,
|
||||||
|
) -> None:
|
||||||
|
"""Initialize the proxy event entity."""
|
||||||
|
super().__init__(description, manager)
|
||||||
|
self._attr_event_types = description.capabilities.get("event_types", [])
|
||||||
|
|
||||||
|
@callback
|
||||||
|
def sandbox_update_state(self, state: str, attributes: dict[str, Any]) -> None:
|
||||||
|
"""Handle event firing from sandbox."""
|
||||||
|
event_type = attributes.get("event_type")
|
||||||
|
if event_type:
|
||||||
|
event_attributes = {
|
||||||
|
k: v
|
||||||
|
for k, v in attributes.items()
|
||||||
|
if k not in ("event_type", "state")
|
||||||
|
}
|
||||||
|
self._trigger_event(event_type, event_attributes or None)
|
||||||
|
self.async_write_ha_state()
|
||||||
|
else:
|
||||||
|
super().sandbox_update_state(state, attributes)
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
"""Sandbox proxy for fan entities."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from homeassistant.components.fan import FanEntity, FanEntityFeature
|
||||||
|
|
||||||
|
from . import SandboxEntityDescription, SandboxEntityManager, SandboxProxyEntity
|
||||||
|
|
||||||
|
|
||||||
|
class SandboxFanEntity(SandboxProxyEntity, FanEntity):
|
||||||
|
"""Proxy for a fan entity in a sandbox."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
description: SandboxEntityDescription,
|
||||||
|
manager: SandboxEntityManager,
|
||||||
|
) -> None:
|
||||||
|
"""Initialize the proxy fan entity."""
|
||||||
|
super().__init__(description, manager)
|
||||||
|
self._attr_supported_features = FanEntityFeature(
|
||||||
|
description.supported_features
|
||||||
|
)
|
||||||
|
if preset_modes := description.capabilities.get("preset_modes"):
|
||||||
|
self._attr_preset_modes = preset_modes
|
||||||
|
if speed_count := description.capabilities.get("speed_count"):
|
||||||
|
self._attr_speed_count = speed_count
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_on(self) -> bool | None:
|
||||||
|
"""Return if the fan is on."""
|
||||||
|
state = self._state_cache.get("state")
|
||||||
|
if state is None:
|
||||||
|
return None
|
||||||
|
return state == "on"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def percentage(self) -> int | None:
|
||||||
|
"""Return the current speed percentage."""
|
||||||
|
return self._state_cache.get("percentage")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def preset_mode(self) -> str | None:
|
||||||
|
"""Return the current preset mode."""
|
||||||
|
return self._state_cache.get("preset_mode")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def current_direction(self) -> str | None:
|
||||||
|
"""Return the current direction."""
|
||||||
|
return self._state_cache.get("current_direction")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def oscillating(self) -> bool | None:
|
||||||
|
"""Return if the fan is oscillating."""
|
||||||
|
return self._state_cache.get("oscillating")
|
||||||
|
|
||||||
|
async def async_turn_on(self, percentage: int | None = None, preset_mode: str | None = None, **kwargs: Any) -> None:
|
||||||
|
"""Forward turn_on to sandbox."""
|
||||||
|
await self._forward_method("async_turn_on", percentage=percentage, preset_mode=preset_mode, **kwargs)
|
||||||
|
|
||||||
|
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||||
|
"""Forward turn_off to sandbox."""
|
||||||
|
await self._forward_method("async_turn_off", **kwargs)
|
||||||
|
|
||||||
|
async def async_set_percentage(self, percentage: int) -> None:
|
||||||
|
"""Forward set_percentage to sandbox."""
|
||||||
|
await self._forward_method("async_set_percentage", percentage=percentage)
|
||||||
|
|
||||||
|
async def async_set_preset_mode(self, preset_mode: str) -> None:
|
||||||
|
"""Forward set_preset_mode to sandbox."""
|
||||||
|
await self._forward_method("async_set_preset_mode", preset_mode=preset_mode)
|
||||||
|
|
||||||
|
async def async_set_direction(self, direction: str) -> None:
|
||||||
|
"""Forward set_direction to sandbox."""
|
||||||
|
await self._forward_method("async_set_direction", direction=direction)
|
||||||
|
|
||||||
|
async def async_oscillate(self, oscillating: bool) -> None:
|
||||||
|
"""Forward oscillate to sandbox."""
|
||||||
|
await self._forward_method("async_oscillate", oscillating=oscillating)
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
"""Sandbox proxy for humidifier entities."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from homeassistant.components.humidifier import HumidifierEntity, HumidifierEntityFeature
|
||||||
|
|
||||||
|
from . import SandboxEntityDescription, SandboxEntityManager, SandboxProxyEntity
|
||||||
|
|
||||||
|
|
||||||
|
class SandboxHumidifierEntity(SandboxProxyEntity, HumidifierEntity):
|
||||||
|
"""Proxy for a humidifier entity in a sandbox."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
description: SandboxEntityDescription,
|
||||||
|
manager: SandboxEntityManager,
|
||||||
|
) -> None:
|
||||||
|
"""Initialize the proxy humidifier entity."""
|
||||||
|
super().__init__(description, manager)
|
||||||
|
self._attr_supported_features = HumidifierEntityFeature(
|
||||||
|
description.supported_features
|
||||||
|
)
|
||||||
|
caps = description.capabilities
|
||||||
|
if available_modes := caps.get("available_modes"):
|
||||||
|
self._attr_available_modes = available_modes
|
||||||
|
if (min_humidity := caps.get("min_humidity")) is not None:
|
||||||
|
self._attr_min_humidity = min_humidity
|
||||||
|
if (max_humidity := caps.get("max_humidity")) is not None:
|
||||||
|
self._attr_max_humidity = max_humidity
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_on(self) -> bool | None:
|
||||||
|
"""Return if the humidifier is on."""
|
||||||
|
state = self._state_cache.get("state")
|
||||||
|
if state is None:
|
||||||
|
return None
|
||||||
|
return state == "on"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def current_humidity(self) -> float | None:
|
||||||
|
"""Return the current humidity."""
|
||||||
|
return self._state_cache.get("current_humidity")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def target_humidity(self) -> float | None:
|
||||||
|
"""Return the target humidity."""
|
||||||
|
return self._state_cache.get("target_humidity")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def mode(self) -> str | None:
|
||||||
|
"""Return the current mode."""
|
||||||
|
return self._state_cache.get("mode")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def action(self) -> str | None:
|
||||||
|
"""Return the current action."""
|
||||||
|
return self._state_cache.get("action")
|
||||||
|
|
||||||
|
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||||
|
"""Forward turn_on to sandbox."""
|
||||||
|
await self._forward_method("async_turn_on", **kwargs)
|
||||||
|
|
||||||
|
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||||
|
"""Forward turn_off to sandbox."""
|
||||||
|
await self._forward_method("async_turn_off", **kwargs)
|
||||||
|
|
||||||
|
async def async_set_humidity(self, humidity: int) -> None:
|
||||||
|
"""Forward set_humidity to sandbox."""
|
||||||
|
await self._forward_method("async_set_humidity", humidity=humidity)
|
||||||
|
|
||||||
|
async def async_set_mode(self, mode: str) -> None:
|
||||||
|
"""Forward set_mode to sandbox."""
|
||||||
|
await self._forward_method("async_set_mode", mode=mode)
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
"""Sandbox proxy for lawn_mower entities."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from homeassistant.components.lawn_mower import LawnMowerActivity, LawnMowerEntity, LawnMowerEntityFeature
|
||||||
|
|
||||||
|
from . import SandboxEntityDescription, SandboxEntityManager, SandboxProxyEntity
|
||||||
|
|
||||||
|
|
||||||
|
class SandboxLawnMowerEntity(SandboxProxyEntity, LawnMowerEntity):
|
||||||
|
"""Proxy for a lawn_mower entity in a sandbox."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
description: SandboxEntityDescription,
|
||||||
|
manager: SandboxEntityManager,
|
||||||
|
) -> None:
|
||||||
|
"""Initialize the proxy lawn mower entity."""
|
||||||
|
super().__init__(description, manager)
|
||||||
|
self._attr_supported_features = LawnMowerEntityFeature(
|
||||||
|
description.supported_features
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def activity(self) -> LawnMowerActivity | None:
|
||||||
|
"""Return the current activity."""
|
||||||
|
val = self._state_cache.get("activity")
|
||||||
|
if val is None:
|
||||||
|
return None
|
||||||
|
return LawnMowerActivity(val)
|
||||||
|
|
||||||
|
async def async_start_mowing(self) -> None:
|
||||||
|
"""Forward start_mowing to sandbox."""
|
||||||
|
await self._forward_method("async_start_mowing")
|
||||||
|
|
||||||
|
async def async_dock(self) -> None:
|
||||||
|
"""Forward dock to sandbox."""
|
||||||
|
await self._forward_method("async_dock")
|
||||||
|
|
||||||
|
async def async_pause(self) -> None:
|
||||||
|
"""Forward pause to sandbox."""
|
||||||
|
await self._forward_method("async_pause")
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
"""Sandbox proxy for light entities."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import 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 . import SandboxEntityDescription, SandboxEntityManager, SandboxProxyEntity
|
||||||
|
|
||||||
|
|
||||||
|
class SandboxLightEntity(SandboxProxyEntity, LightEntity):
|
||||||
|
"""Proxy for a light entity in a sandbox."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
description: SandboxEntityDescription,
|
||||||
|
manager: SandboxEntityManager,
|
||||||
|
) -> None:
|
||||||
|
"""Initialize the proxy light entity."""
|
||||||
|
super().__init__(description, manager)
|
||||||
|
self._attr_supported_features = LightEntityFeature(
|
||||||
|
description.supported_features
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_on(self) -> bool | None:
|
||||||
|
"""Return if the light is on."""
|
||||||
|
state = self._state_cache.get("state")
|
||||||
|
if state is None:
|
||||||
|
return None
|
||||||
|
return state == "on"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def brightness(self) -> int | None:
|
||||||
|
"""Return the brightness."""
|
||||||
|
return self._state_cache.get(ATTR_BRIGHTNESS)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def color_mode(self) -> ColorMode | str | None:
|
||||||
|
"""Return the color mode."""
|
||||||
|
return self._state_cache.get(ATTR_COLOR_MODE)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def hs_color(self) -> tuple[float, float] | None:
|
||||||
|
"""Return the 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 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 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 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 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 color temperature in kelvin."""
|
||||||
|
return self._state_cache.get(ATTR_COLOR_TEMP_KELVIN)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def min_color_temp_kelvin(self) -> int:
|
||||||
|
"""Return the min color temperature."""
|
||||||
|
return self._description.capabilities.get(
|
||||||
|
ATTR_MIN_COLOR_TEMP_KELVIN, 2000
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def max_color_temp_kelvin(self) -> int:
|
||||||
|
"""Return the max color temperature."""
|
||||||
|
return self._description.capabilities.get(
|
||||||
|
ATTR_MAX_COLOR_TEMP_KELVIN, 6500
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def effect(self) -> str | None:
|
||||||
|
"""Return the current effect."""
|
||||||
|
return self._state_cache.get(ATTR_EFFECT)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def effect_list(self) -> list[str] | None:
|
||||||
|
"""Return the list of supported effects."""
|
||||||
|
return self._description.capabilities.get(ATTR_EFFECT_LIST)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def supported_color_modes(self) -> set[ColorMode] | set[str] | None:
|
||||||
|
"""Return the supported color modes."""
|
||||||
|
modes = self._description.capabilities.get(ATTR_SUPPORTED_COLOR_MODES)
|
||||||
|
if modes is None:
|
||||||
|
return None
|
||||||
|
return {ColorMode(m) for m in modes}
|
||||||
|
|
||||||
|
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||||
|
"""Forward turn_on to sandbox."""
|
||||||
|
await self._forward_method("async_turn_on", **kwargs)
|
||||||
|
|
||||||
|
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||||
|
"""Forward turn_off to sandbox."""
|
||||||
|
await self._forward_method("async_turn_off", **kwargs)
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
"""Sandbox proxy for lock entities."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from homeassistant.components.lock import LockEntity, LockEntityFeature
|
||||||
|
|
||||||
|
from . import SandboxEntityDescription, SandboxEntityManager, SandboxProxyEntity
|
||||||
|
|
||||||
|
|
||||||
|
class SandboxLockEntity(SandboxProxyEntity, LockEntity):
|
||||||
|
"""Proxy for a lock entity in a sandbox."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
description: SandboxEntityDescription,
|
||||||
|
manager: SandboxEntityManager,
|
||||||
|
) -> None:
|
||||||
|
"""Initialize the proxy lock entity."""
|
||||||
|
super().__init__(description, manager)
|
||||||
|
self._attr_supported_features = LockEntityFeature(
|
||||||
|
description.supported_features
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_locked(self) -> bool | None:
|
||||||
|
"""Return if the lock is locked."""
|
||||||
|
state = self._state_cache.get("state")
|
||||||
|
if state is None:
|
||||||
|
return None
|
||||||
|
return state == "locked"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_locking(self) -> bool | None:
|
||||||
|
"""Return if the lock is locking."""
|
||||||
|
return self._state_cache.get("is_locking")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_unlocking(self) -> bool | None:
|
||||||
|
"""Return if the lock is unlocking."""
|
||||||
|
return self._state_cache.get("is_unlocking")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_jammed(self) -> bool | None:
|
||||||
|
"""Return if the lock is jammed."""
|
||||||
|
return self._state_cache.get("is_jammed")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_open(self) -> bool | None:
|
||||||
|
"""Return if the lock is open."""
|
||||||
|
return self._state_cache.get("is_open")
|
||||||
|
|
||||||
|
async def async_lock(self, **kwargs: Any) -> None:
|
||||||
|
"""Forward lock to sandbox."""
|
||||||
|
await self._forward_method("async_lock", **kwargs)
|
||||||
|
|
||||||
|
async def async_unlock(self, **kwargs: Any) -> None:
|
||||||
|
"""Forward unlock to sandbox."""
|
||||||
|
await self._forward_method("async_unlock", **kwargs)
|
||||||
|
|
||||||
|
async def async_open(self, **kwargs: Any) -> None:
|
||||||
|
"""Forward open to sandbox."""
|
||||||
|
await self._forward_method("async_open", **kwargs)
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
"""Sandbox proxy for media_player entities."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from homeassistant.components.media_player import (
|
||||||
|
MediaPlayerEntity,
|
||||||
|
MediaPlayerEntityFeature,
|
||||||
|
MediaPlayerState,
|
||||||
|
RepeatMode,
|
||||||
|
)
|
||||||
|
|
||||||
|
from . import SandboxEntityDescription, SandboxEntityManager, SandboxProxyEntity
|
||||||
|
|
||||||
|
|
||||||
|
class SandboxMediaPlayerEntity(SandboxProxyEntity, MediaPlayerEntity):
|
||||||
|
"""Proxy for a media_player entity in a sandbox."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
description: SandboxEntityDescription,
|
||||||
|
manager: SandboxEntityManager,
|
||||||
|
) -> None:
|
||||||
|
"""Initialize the proxy media player entity."""
|
||||||
|
super().__init__(description, manager)
|
||||||
|
self._attr_supported_features = MediaPlayerEntityFeature(
|
||||||
|
description.supported_features
|
||||||
|
)
|
||||||
|
caps = description.capabilities
|
||||||
|
if source_list := caps.get("source_list"):
|
||||||
|
self._attr_source_list = source_list
|
||||||
|
if sound_mode_list := caps.get("sound_mode_list"):
|
||||||
|
self._attr_sound_mode_list = sound_mode_list
|
||||||
|
|
||||||
|
@property
|
||||||
|
def state(self) -> MediaPlayerState | None:
|
||||||
|
"""Return the current state."""
|
||||||
|
state = self._state_cache.get("state")
|
||||||
|
if state is None:
|
||||||
|
return None
|
||||||
|
return MediaPlayerState(state)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def volume_level(self) -> float | None:
|
||||||
|
"""Return the volume level."""
|
||||||
|
return self._state_cache.get("volume_level")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_volume_muted(self) -> bool | None:
|
||||||
|
"""Return if volume is muted."""
|
||||||
|
return self._state_cache.get("is_volume_muted")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def media_content_id(self) -> str | None:
|
||||||
|
"""Return the media content ID."""
|
||||||
|
return self._state_cache.get("media_content_id")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def media_content_type(self) -> str | None:
|
||||||
|
"""Return the media content type."""
|
||||||
|
return self._state_cache.get("media_content_type")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def media_title(self) -> str | None:
|
||||||
|
"""Return the media title."""
|
||||||
|
return self._state_cache.get("media_title")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def media_artist(self) -> str | None:
|
||||||
|
"""Return the media artist."""
|
||||||
|
return self._state_cache.get("media_artist")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def media_album_name(self) -> str | None:
|
||||||
|
"""Return the media album name."""
|
||||||
|
return self._state_cache.get("media_album_name")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def media_duration(self) -> float | None:
|
||||||
|
"""Return the media duration."""
|
||||||
|
return self._state_cache.get("media_duration")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def media_position(self) -> float | None:
|
||||||
|
"""Return the media position."""
|
||||||
|
return self._state_cache.get("media_position")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def source(self) -> str | None:
|
||||||
|
"""Return the current source."""
|
||||||
|
return self._state_cache.get("source")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def sound_mode(self) -> str | None:
|
||||||
|
"""Return the current sound mode."""
|
||||||
|
return self._state_cache.get("sound_mode")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def shuffle(self) -> bool | None:
|
||||||
|
"""Return if shuffle is enabled."""
|
||||||
|
return self._state_cache.get("shuffle")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def repeat(self) -> RepeatMode | None:
|
||||||
|
"""Return the current repeat mode."""
|
||||||
|
val = self._state_cache.get("repeat")
|
||||||
|
if val is None:
|
||||||
|
return None
|
||||||
|
return RepeatMode(val)
|
||||||
|
|
||||||
|
async def async_turn_on(self) -> None:
|
||||||
|
"""Forward turn_on to sandbox."""
|
||||||
|
await self._forward_method("async_turn_on")
|
||||||
|
|
||||||
|
async def async_turn_off(self) -> None:
|
||||||
|
"""Forward turn_off to sandbox."""
|
||||||
|
await self._forward_method("async_turn_off")
|
||||||
|
|
||||||
|
async def async_volume_up(self) -> None:
|
||||||
|
"""Forward volume_up to sandbox."""
|
||||||
|
await self._forward_method("async_volume_up")
|
||||||
|
|
||||||
|
async def async_volume_down(self) -> None:
|
||||||
|
"""Forward volume_down to sandbox."""
|
||||||
|
await self._forward_method("async_volume_down")
|
||||||
|
|
||||||
|
async def async_set_volume_level(self, volume: float) -> None:
|
||||||
|
"""Forward set_volume_level to sandbox."""
|
||||||
|
await self._forward_method("async_set_volume_level", volume=volume)
|
||||||
|
|
||||||
|
async def async_mute_volume(self, mute: bool) -> None:
|
||||||
|
"""Forward mute_volume to sandbox."""
|
||||||
|
await self._forward_method("async_mute_volume", mute=mute)
|
||||||
|
|
||||||
|
async def async_media_play(self) -> None:
|
||||||
|
"""Forward media_play to sandbox."""
|
||||||
|
await self._forward_method("async_media_play")
|
||||||
|
|
||||||
|
async def async_media_pause(self) -> None:
|
||||||
|
"""Forward media_pause to sandbox."""
|
||||||
|
await self._forward_method("async_media_pause")
|
||||||
|
|
||||||
|
async def async_media_stop(self) -> None:
|
||||||
|
"""Forward media_stop to sandbox."""
|
||||||
|
await self._forward_method("async_media_stop")
|
||||||
|
|
||||||
|
async def async_media_next_track(self) -> None:
|
||||||
|
"""Forward media_next_track to sandbox."""
|
||||||
|
await self._forward_method("async_media_next_track")
|
||||||
|
|
||||||
|
async def async_media_previous_track(self) -> None:
|
||||||
|
"""Forward media_previous_track to sandbox."""
|
||||||
|
await self._forward_method("async_media_previous_track")
|
||||||
|
|
||||||
|
async def async_media_seek(self, position: float) -> None:
|
||||||
|
"""Forward media_seek to sandbox."""
|
||||||
|
await self._forward_method("async_media_seek", position=position)
|
||||||
|
|
||||||
|
async def async_select_source(self, source: str) -> None:
|
||||||
|
"""Forward select_source to sandbox."""
|
||||||
|
await self._forward_method("async_select_source", source=source)
|
||||||
|
|
||||||
|
async def async_select_sound_mode(self, sound_mode: str) -> None:
|
||||||
|
"""Forward select_sound_mode to sandbox."""
|
||||||
|
await self._forward_method("async_select_sound_mode", sound_mode=sound_mode)
|
||||||
|
|
||||||
|
async def async_play_media(self, media_type: str, media_id: str, **kwargs: Any) -> None:
|
||||||
|
"""Forward play_media to sandbox."""
|
||||||
|
await self._forward_method("async_play_media", media_type=media_type, media_id=media_id, **kwargs)
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
"""Sandbox proxy for notify entities."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from homeassistant.components.notify import NotifyEntity, NotifyEntityFeature
|
||||||
|
|
||||||
|
from . import SandboxEntityDescription, SandboxEntityManager, SandboxProxyEntity
|
||||||
|
|
||||||
|
|
||||||
|
class SandboxNotifyEntity(SandboxProxyEntity, NotifyEntity):
|
||||||
|
"""Proxy for a notify entity in a sandbox."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
description: SandboxEntityDescription,
|
||||||
|
manager: SandboxEntityManager,
|
||||||
|
) -> None:
|
||||||
|
"""Initialize the proxy notify entity."""
|
||||||
|
super().__init__(description, manager)
|
||||||
|
self._attr_supported_features = NotifyEntityFeature(
|
||||||
|
description.supported_features
|
||||||
|
)
|
||||||
|
|
||||||
|
async def async_send_message(self, message: str, title: str | None = None) -> None:
|
||||||
|
"""Forward send_message to sandbox."""
|
||||||
|
await self._forward_method("async_send_message", message=message, title=title)
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
"""Sandbox proxy for number entities."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from homeassistant.components.number import NumberEntity, NumberMode
|
||||||
|
|
||||||
|
from . import SandboxEntityDescription, SandboxEntityManager, SandboxProxyEntity
|
||||||
|
|
||||||
|
|
||||||
|
class SandboxNumberEntity(SandboxProxyEntity, NumberEntity):
|
||||||
|
"""Proxy for a number entity in a sandbox."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
description: SandboxEntityDescription,
|
||||||
|
manager: SandboxEntityManager,
|
||||||
|
) -> None:
|
||||||
|
"""Initialize the proxy number entity."""
|
||||||
|
super().__init__(description, manager)
|
||||||
|
caps = description.capabilities
|
||||||
|
if (min_val := caps.get("native_min_value")) is not None:
|
||||||
|
self._attr_native_min_value = min_val
|
||||||
|
if (max_val := caps.get("native_max_value")) is not None:
|
||||||
|
self._attr_native_max_value = max_val
|
||||||
|
if (step := caps.get("native_step")) is not None:
|
||||||
|
self._attr_native_step = step
|
||||||
|
if unit := caps.get("native_unit_of_measurement"):
|
||||||
|
self._attr_native_unit_of_measurement = unit
|
||||||
|
if mode := caps.get("mode"):
|
||||||
|
self._attr_mode = NumberMode(mode)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def native_value(self) -> float | None:
|
||||||
|
"""Return the current value."""
|
||||||
|
val = self._state_cache.get("state")
|
||||||
|
if val is None:
|
||||||
|
return None
|
||||||
|
return float(val)
|
||||||
|
|
||||||
|
async def async_set_native_value(self, value: float) -> None:
|
||||||
|
"""Forward set_native_value to sandbox."""
|
||||||
|
await self._forward_method("async_set_native_value", value=value)
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
"""Sandbox proxy for remote entities."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from homeassistant.components.remote import RemoteEntity, RemoteEntityFeature
|
||||||
|
|
||||||
|
from . import SandboxEntityDescription, SandboxEntityManager, SandboxProxyEntity
|
||||||
|
|
||||||
|
|
||||||
|
class SandboxRemoteEntity(SandboxProxyEntity, RemoteEntity):
|
||||||
|
"""Proxy for a remote entity in a sandbox."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
description: SandboxEntityDescription,
|
||||||
|
manager: SandboxEntityManager,
|
||||||
|
) -> None:
|
||||||
|
"""Initialize the proxy remote entity."""
|
||||||
|
super().__init__(description, manager)
|
||||||
|
self._attr_supported_features = RemoteEntityFeature(
|
||||||
|
description.supported_features
|
||||||
|
)
|
||||||
|
if activity_list := description.capabilities.get("activity_list"):
|
||||||
|
self._attr_activity_list = activity_list
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_on(self) -> bool | None:
|
||||||
|
"""Return if the remote is on."""
|
||||||
|
state = self._state_cache.get("state")
|
||||||
|
if state is None:
|
||||||
|
return None
|
||||||
|
return state == "on"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def current_activity(self) -> str | None:
|
||||||
|
"""Return the current activity."""
|
||||||
|
return self._state_cache.get("current_activity")
|
||||||
|
|
||||||
|
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||||
|
"""Forward turn_on to sandbox."""
|
||||||
|
await self._forward_method("async_turn_on", **kwargs)
|
||||||
|
|
||||||
|
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||||
|
"""Forward turn_off to sandbox."""
|
||||||
|
await self._forward_method("async_turn_off", **kwargs)
|
||||||
|
|
||||||
|
async def async_send_command(self, command: list[str], **kwargs: Any) -> None:
|
||||||
|
"""Forward send_command to sandbox."""
|
||||||
|
await self._forward_method("async_send_command", command=command, **kwargs)
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
"""Sandbox proxy for scene entities."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from homeassistant.components.scene import Scene
|
||||||
|
|
||||||
|
from . import SandboxProxyEntity
|
||||||
|
|
||||||
|
|
||||||
|
class SandboxSceneEntity(SandboxProxyEntity, Scene):
|
||||||
|
"""Proxy for a scene entity in a sandbox."""
|
||||||
|
|
||||||
|
async def async_activate(self, **kwargs: Any) -> None:
|
||||||
|
"""Forward activate to sandbox."""
|
||||||
|
await self._forward_method("async_activate", **kwargs)
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
"""Sandbox proxy for select entities."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from homeassistant.components.select import SelectEntity
|
||||||
|
|
||||||
|
from . import SandboxEntityDescription, SandboxEntityManager, SandboxProxyEntity
|
||||||
|
|
||||||
|
|
||||||
|
class SandboxSelectEntity(SandboxProxyEntity, SelectEntity):
|
||||||
|
"""Proxy for a select entity in a sandbox."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
description: SandboxEntityDescription,
|
||||||
|
manager: SandboxEntityManager,
|
||||||
|
) -> None:
|
||||||
|
"""Initialize the proxy select entity."""
|
||||||
|
super().__init__(description, manager)
|
||||||
|
self._attr_options = description.capabilities.get("options", [])
|
||||||
|
|
||||||
|
@property
|
||||||
|
def current_option(self) -> str | None:
|
||||||
|
"""Return the current option."""
|
||||||
|
return self._state_cache.get("state")
|
||||||
|
|
||||||
|
async def async_select_option(self, option: str) -> None:
|
||||||
|
"""Forward select_option to sandbox."""
|
||||||
|
await self._forward_method("async_select_option", option=option)
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
"""Sandbox proxy for sensor entities."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from homeassistant.components.sensor import SensorEntity, SensorStateClass
|
||||||
|
|
||||||
|
from . import SandboxEntityDescription, SandboxEntityManager, SandboxProxyEntity
|
||||||
|
|
||||||
|
|
||||||
|
class SandboxSensorEntity(SandboxProxyEntity, SensorEntity):
|
||||||
|
"""Proxy for a sensor entity in a sandbox."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
description: SandboxEntityDescription,
|
||||||
|
manager: SandboxEntityManager,
|
||||||
|
) -> None:
|
||||||
|
"""Initialize the proxy sensor entity."""
|
||||||
|
super().__init__(description, manager)
|
||||||
|
if description.state_class:
|
||||||
|
self._attr_state_class = SensorStateClass(description.state_class)
|
||||||
|
unit = description.capabilities.get("native_unit_of_measurement")
|
||||||
|
if unit:
|
||||||
|
self._attr_native_unit_of_measurement = unit
|
||||||
|
|
||||||
|
@property
|
||||||
|
def native_value(self) -> str | int | float | None:
|
||||||
|
"""Return the sensor value."""
|
||||||
|
return self._state_cache.get("state")
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
"""Sandbox proxy for siren entities."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from homeassistant.components.siren import SirenEntity, SirenEntityFeature
|
||||||
|
|
||||||
|
from . import SandboxEntityDescription, SandboxEntityManager, SandboxProxyEntity
|
||||||
|
|
||||||
|
|
||||||
|
class SandboxSirenEntity(SandboxProxyEntity, SirenEntity):
|
||||||
|
"""Proxy for a siren entity in a sandbox."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
description: SandboxEntityDescription,
|
||||||
|
manager: SandboxEntityManager,
|
||||||
|
) -> None:
|
||||||
|
"""Initialize the proxy siren entity."""
|
||||||
|
super().__init__(description, manager)
|
||||||
|
self._attr_supported_features = SirenEntityFeature(
|
||||||
|
description.supported_features
|
||||||
|
)
|
||||||
|
if available_tones := description.capabilities.get("available_tones"):
|
||||||
|
self._attr_available_tones = available_tones
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_on(self) -> bool | None:
|
||||||
|
"""Return if the siren is on."""
|
||||||
|
state = self._state_cache.get("state")
|
||||||
|
if state is None:
|
||||||
|
return None
|
||||||
|
return state == "on"
|
||||||
|
|
||||||
|
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||||
|
"""Forward turn_on to sandbox."""
|
||||||
|
await self._forward_method("async_turn_on", **kwargs)
|
||||||
|
|
||||||
|
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||||
|
"""Forward turn_off to sandbox."""
|
||||||
|
await self._forward_method("async_turn_off", **kwargs)
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
"""Sandbox proxy for switch entities."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from homeassistant.components.switch import SwitchEntity
|
||||||
|
|
||||||
|
from . import SandboxProxyEntity
|
||||||
|
|
||||||
|
|
||||||
|
class SandboxSwitchEntity(SandboxProxyEntity, SwitchEntity):
|
||||||
|
"""Proxy for a switch entity in a sandbox."""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_on(self) -> bool | None:
|
||||||
|
"""Return if the switch is on."""
|
||||||
|
state = self._state_cache.get("state")
|
||||||
|
if state is None:
|
||||||
|
return None
|
||||||
|
return state == "on"
|
||||||
|
|
||||||
|
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||||
|
"""Forward turn_on to sandbox."""
|
||||||
|
await self._forward_method("async_turn_on", **kwargs)
|
||||||
|
|
||||||
|
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||||
|
"""Forward turn_off to sandbox."""
|
||||||
|
await self._forward_method("async_turn_off", **kwargs)
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
"""Sandbox proxy for text entities."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from homeassistant.components.text import TextEntity, TextMode
|
||||||
|
|
||||||
|
from . import SandboxEntityDescription, SandboxEntityManager, SandboxProxyEntity
|
||||||
|
|
||||||
|
|
||||||
|
class SandboxTextEntity(SandboxProxyEntity, TextEntity):
|
||||||
|
"""Proxy for a text entity in a sandbox."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
description: SandboxEntityDescription,
|
||||||
|
manager: SandboxEntityManager,
|
||||||
|
) -> None:
|
||||||
|
"""Initialize the proxy text entity."""
|
||||||
|
super().__init__(description, manager)
|
||||||
|
caps = description.capabilities
|
||||||
|
if (native_min := caps.get("native_min")) is not None:
|
||||||
|
self._attr_native_min = native_min
|
||||||
|
if (native_max := caps.get("native_max")) is not None:
|
||||||
|
self._attr_native_max = native_max
|
||||||
|
if mode := caps.get("mode"):
|
||||||
|
self._attr_mode = TextMode(mode)
|
||||||
|
if pattern := caps.get("pattern"):
|
||||||
|
self._attr_pattern = pattern
|
||||||
|
|
||||||
|
@property
|
||||||
|
def native_value(self) -> str | None:
|
||||||
|
"""Return the current value."""
|
||||||
|
return self._state_cache.get("state")
|
||||||
|
|
||||||
|
async def async_set_value(self, value: str) -> None:
|
||||||
|
"""Forward set_value to sandbox."""
|
||||||
|
await self._forward_method("async_set_value", value=value)
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
"""Sandbox proxy for time entities."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import time
|
||||||
|
|
||||||
|
from homeassistant.components.time import TimeEntity
|
||||||
|
|
||||||
|
from . import SandboxProxyEntity
|
||||||
|
|
||||||
|
|
||||||
|
class SandboxTimeEntity(SandboxProxyEntity, TimeEntity):
|
||||||
|
"""Proxy for a time entity in a sandbox."""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def native_value(self):
|
||||||
|
"""Return the current time value."""
|
||||||
|
val = self._state_cache.get("state")
|
||||||
|
if val is None:
|
||||||
|
return None
|
||||||
|
if isinstance(val, str):
|
||||||
|
return time.fromisoformat(val)
|
||||||
|
return val
|
||||||
|
|
||||||
|
async def async_set_value(self, value) -> None:
|
||||||
|
"""Forward set_value to sandbox."""
|
||||||
|
await self._forward_method("async_set_value", value=value.isoformat())
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
"""Sandbox proxy for todo entities."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from homeassistant.components.todo import TodoItem, TodoItemStatus, TodoListEntity, TodoListEntityFeature
|
||||||
|
from homeassistant.core import callback
|
||||||
|
|
||||||
|
from . import SandboxEntityDescription, SandboxEntityManager, SandboxProxyEntity
|
||||||
|
|
||||||
|
|
||||||
|
class SandboxTodoListEntity(SandboxProxyEntity, TodoListEntity):
|
||||||
|
"""Proxy for a todo list entity in a sandbox."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
description: SandboxEntityDescription,
|
||||||
|
manager: SandboxEntityManager,
|
||||||
|
) -> None:
|
||||||
|
"""Initialize the proxy todo entity."""
|
||||||
|
super().__init__(description, manager)
|
||||||
|
self._attr_supported_features = TodoListEntityFeature(
|
||||||
|
description.supported_features
|
||||||
|
)
|
||||||
|
self._attr_todo_items: list[TodoItem] | None = None
|
||||||
|
|
||||||
|
@callback
|
||||||
|
def sandbox_update_state(self, state: str, attributes: dict[str, Any]) -> None:
|
||||||
|
"""Update todo items from sandbox push."""
|
||||||
|
if "todo_items" in attributes:
|
||||||
|
items = []
|
||||||
|
for item_data in attributes["todo_items"]:
|
||||||
|
items.append(TodoItem(
|
||||||
|
uid=item_data.get("uid"),
|
||||||
|
summary=item_data.get("summary", ""),
|
||||||
|
status=TodoItemStatus(item_data["status"]) if "status" in item_data else None,
|
||||||
|
description=item_data.get("description"),
|
||||||
|
due=item_data.get("due"),
|
||||||
|
))
|
||||||
|
self._attr_todo_items = items
|
||||||
|
self._state_cache["state"] = state
|
||||||
|
self.async_write_ha_state()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def todo_items(self) -> list[TodoItem] | None:
|
||||||
|
"""Return the todo items."""
|
||||||
|
return self._attr_todo_items
|
||||||
|
|
||||||
|
async def async_create_todo_item(self, item: TodoItem) -> None:
|
||||||
|
"""Forward create_todo_item to sandbox."""
|
||||||
|
await self._forward_method("async_create_todo_item", item={
|
||||||
|
"summary": item.summary,
|
||||||
|
"status": item.status.value if item.status else None,
|
||||||
|
"description": item.description,
|
||||||
|
"due": item.due,
|
||||||
|
})
|
||||||
|
|
||||||
|
async def async_update_todo_item(self, item: TodoItem) -> None:
|
||||||
|
"""Forward update_todo_item to sandbox."""
|
||||||
|
await self._forward_method("async_update_todo_item", item={
|
||||||
|
"uid": item.uid,
|
||||||
|
"summary": item.summary,
|
||||||
|
"status": item.status.value if item.status else None,
|
||||||
|
"description": item.description,
|
||||||
|
"due": item.due,
|
||||||
|
})
|
||||||
|
|
||||||
|
async def async_delete_todo_items(self, uids: list[str]) -> None:
|
||||||
|
"""Forward delete_todo_items to sandbox."""
|
||||||
|
await self._forward_method("async_delete_todo_items", uids=uids)
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
"""Sandbox proxy for update entities."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from homeassistant.components.update import UpdateEntity, UpdateEntityFeature
|
||||||
|
|
||||||
|
from . import SandboxEntityDescription, SandboxEntityManager, SandboxProxyEntity
|
||||||
|
|
||||||
|
|
||||||
|
class SandboxUpdateEntity(SandboxProxyEntity, UpdateEntity):
|
||||||
|
"""Proxy for an update entity in a sandbox."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
description: SandboxEntityDescription,
|
||||||
|
manager: SandboxEntityManager,
|
||||||
|
) -> None:
|
||||||
|
"""Initialize the proxy update entity."""
|
||||||
|
super().__init__(description, manager)
|
||||||
|
self._attr_supported_features = UpdateEntityFeature(
|
||||||
|
description.supported_features
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def installed_version(self) -> str | None:
|
||||||
|
"""Return the installed version."""
|
||||||
|
return self._state_cache.get("installed_version")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def latest_version(self) -> str | None:
|
||||||
|
"""Return the latest version."""
|
||||||
|
return self._state_cache.get("latest_version")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def title(self) -> str | None:
|
||||||
|
"""Return the title."""
|
||||||
|
return self._state_cache.get("title")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def release_summary(self) -> str | None:
|
||||||
|
"""Return the release summary."""
|
||||||
|
return self._state_cache.get("release_summary")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def release_url(self) -> str | None:
|
||||||
|
"""Return the release URL."""
|
||||||
|
return self._state_cache.get("release_url")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def in_progress(self) -> bool | int | None:
|
||||||
|
"""Return if update is in progress."""
|
||||||
|
return self._state_cache.get("in_progress")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def auto_update(self) -> bool:
|
||||||
|
"""Return if auto-update is enabled."""
|
||||||
|
return self._state_cache.get("auto_update", False)
|
||||||
|
|
||||||
|
async def async_install(self, version: str | None = None, backup: bool = False, **kwargs: Any) -> None:
|
||||||
|
"""Forward install to sandbox."""
|
||||||
|
await self._forward_method("async_install", version=version, backup=backup, **kwargs)
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
"""Sandbox proxy for vacuum entities."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from homeassistant.components.vacuum import StateVacuumEntity, VacuumEntityFeature
|
||||||
|
|
||||||
|
from . import SandboxEntityDescription, SandboxEntityManager, SandboxProxyEntity
|
||||||
|
|
||||||
|
|
||||||
|
class SandboxVacuumEntity(SandboxProxyEntity, StateVacuumEntity):
|
||||||
|
"""Proxy for a vacuum entity in a sandbox."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
description: SandboxEntityDescription,
|
||||||
|
manager: SandboxEntityManager,
|
||||||
|
) -> None:
|
||||||
|
"""Initialize the proxy vacuum entity."""
|
||||||
|
super().__init__(description, manager)
|
||||||
|
self._attr_supported_features = VacuumEntityFeature(
|
||||||
|
description.supported_features
|
||||||
|
)
|
||||||
|
if fan_speed_list := description.capabilities.get("fan_speed_list"):
|
||||||
|
self._attr_fan_speed_list = fan_speed_list
|
||||||
|
|
||||||
|
@property
|
||||||
|
def activity(self) -> str | None:
|
||||||
|
"""Return the current vacuum activity."""
|
||||||
|
return self._state_cache.get("activity")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def battery_level(self) -> int | None:
|
||||||
|
"""Return the battery level."""
|
||||||
|
return self._state_cache.get("battery_level")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def fan_speed(self) -> str | None:
|
||||||
|
"""Return the current fan speed."""
|
||||||
|
return self._state_cache.get("fan_speed")
|
||||||
|
|
||||||
|
async def async_start(self) -> None:
|
||||||
|
"""Forward start to sandbox."""
|
||||||
|
await self._forward_method("async_start")
|
||||||
|
|
||||||
|
async def async_pause(self) -> None:
|
||||||
|
"""Forward pause to sandbox."""
|
||||||
|
await self._forward_method("async_pause")
|
||||||
|
|
||||||
|
async def async_stop(self, **kwargs: Any) -> None:
|
||||||
|
"""Forward stop to sandbox."""
|
||||||
|
await self._forward_method("async_stop", **kwargs)
|
||||||
|
|
||||||
|
async def async_return_to_base(self, **kwargs: Any) -> None:
|
||||||
|
"""Forward return_to_base to sandbox."""
|
||||||
|
await self._forward_method("async_return_to_base", **kwargs)
|
||||||
|
|
||||||
|
async def async_clean_spot(self, **kwargs: Any) -> None:
|
||||||
|
"""Forward clean_spot to sandbox."""
|
||||||
|
await self._forward_method("async_clean_spot", **kwargs)
|
||||||
|
|
||||||
|
async def async_locate(self, **kwargs: Any) -> None:
|
||||||
|
"""Forward locate to sandbox."""
|
||||||
|
await self._forward_method("async_locate", **kwargs)
|
||||||
|
|
||||||
|
async def async_set_fan_speed(self, fan_speed: str, **kwargs: Any) -> None:
|
||||||
|
"""Forward set_fan_speed to sandbox."""
|
||||||
|
await self._forward_method("async_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 to sandbox."""
|
||||||
|
await self._forward_method("async_send_command", command=command, params=params, **kwargs)
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
"""Sandbox proxy for valve entities."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from homeassistant.components.valve import ValveEntity, ValveEntityFeature
|
||||||
|
|
||||||
|
from . import SandboxEntityDescription, SandboxEntityManager, SandboxProxyEntity
|
||||||
|
|
||||||
|
|
||||||
|
class SandboxValveEntity(SandboxProxyEntity, ValveEntity):
|
||||||
|
"""Proxy for a valve entity in a sandbox."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
description: SandboxEntityDescription,
|
||||||
|
manager: SandboxEntityManager,
|
||||||
|
) -> None:
|
||||||
|
"""Initialize the proxy valve entity."""
|
||||||
|
super().__init__(description, manager)
|
||||||
|
self._attr_supported_features = ValveEntityFeature(
|
||||||
|
description.supported_features
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_closed(self) -> bool | None:
|
||||||
|
"""Return if the valve is closed."""
|
||||||
|
state = self._state_cache.get("state")
|
||||||
|
if state is None:
|
||||||
|
return None
|
||||||
|
return state == "closed"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_opening(self) -> bool | None:
|
||||||
|
"""Return if the valve is opening."""
|
||||||
|
return self._state_cache.get("is_opening")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_closing(self) -> bool | None:
|
||||||
|
"""Return if the valve is closing."""
|
||||||
|
return self._state_cache.get("is_closing")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def current_valve_position(self) -> int | None:
|
||||||
|
"""Return the current valve position."""
|
||||||
|
return self._state_cache.get("current_valve_position")
|
||||||
|
|
||||||
|
async def async_open_valve(self, **kwargs: Any) -> None:
|
||||||
|
"""Forward open_valve to sandbox."""
|
||||||
|
await self._forward_method("async_open_valve", **kwargs)
|
||||||
|
|
||||||
|
async def async_close_valve(self, **kwargs: Any) -> None:
|
||||||
|
"""Forward close_valve to sandbox."""
|
||||||
|
await self._forward_method("async_close_valve", **kwargs)
|
||||||
|
|
||||||
|
async def async_stop_valve(self, **kwargs: Any) -> None:
|
||||||
|
"""Forward stop_valve to sandbox."""
|
||||||
|
await self._forward_method("async_stop_valve", **kwargs)
|
||||||
|
|
||||||
|
async def async_set_valve_position(self, position: int) -> None:
|
||||||
|
"""Forward set_valve_position to sandbox."""
|
||||||
|
await self._forward_method("async_set_valve_position", position=position)
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
"""Sandbox proxy for water_heater entities."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from homeassistant.components.water_heater import WaterHeaterEntity, WaterHeaterEntityFeature
|
||||||
|
|
||||||
|
from . import SandboxEntityDescription, SandboxEntityManager, SandboxProxyEntity
|
||||||
|
|
||||||
|
|
||||||
|
class SandboxWaterHeaterEntity(SandboxProxyEntity, WaterHeaterEntity):
|
||||||
|
"""Proxy for a water_heater entity in a sandbox."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
description: SandboxEntityDescription,
|
||||||
|
manager: SandboxEntityManager,
|
||||||
|
) -> None:
|
||||||
|
"""Initialize the proxy water heater entity."""
|
||||||
|
super().__init__(description, manager)
|
||||||
|
self._attr_supported_features = WaterHeaterEntityFeature(
|
||||||
|
description.supported_features
|
||||||
|
)
|
||||||
|
caps = description.capabilities
|
||||||
|
if operation_list := caps.get("operation_list"):
|
||||||
|
self._attr_operation_list = operation_list
|
||||||
|
if (min_temp := caps.get("min_temp")) is not None:
|
||||||
|
self._attr_min_temp = min_temp
|
||||||
|
if (max_temp := caps.get("max_temp")) is not None:
|
||||||
|
self._attr_max_temp = max_temp
|
||||||
|
if temp_unit := caps.get("temperature_unit"):
|
||||||
|
self._attr_temperature_unit = temp_unit
|
||||||
|
|
||||||
|
@property
|
||||||
|
def current_operation(self) -> str | None:
|
||||||
|
"""Return the current operation."""
|
||||||
|
return self._state_cache.get("current_operation")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def current_temperature(self) -> float | None:
|
||||||
|
"""Return the current temperature."""
|
||||||
|
return self._state_cache.get("current_temperature")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def target_temperature(self) -> float | None:
|
||||||
|
"""Return the target temperature."""
|
||||||
|
return self._state_cache.get("target_temperature")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_away_mode_on(self) -> bool | None:
|
||||||
|
"""Return if away mode is on."""
|
||||||
|
return self._state_cache.get("is_away_mode_on")
|
||||||
|
|
||||||
|
async def async_set_temperature(self, **kwargs: Any) -> None:
|
||||||
|
"""Forward set_temperature to sandbox."""
|
||||||
|
await self._forward_method("async_set_temperature", **kwargs)
|
||||||
|
|
||||||
|
async def async_set_operation_mode(self, operation_mode: str) -> None:
|
||||||
|
"""Forward set_operation_mode to sandbox."""
|
||||||
|
await self._forward_method("async_set_operation_mode", operation_mode=operation_mode)
|
||||||
|
|
||||||
|
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||||
|
"""Forward turn_on to sandbox."""
|
||||||
|
await self._forward_method("async_turn_on", **kwargs)
|
||||||
|
|
||||||
|
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||||
|
"""Forward turn_off to sandbox."""
|
||||||
|
await self._forward_method("async_turn_off", **kwargs)
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
"""Sandbox proxy for weather entities."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from homeassistant.components.weather import Forecast, WeatherEntity, WeatherEntityFeature
|
||||||
|
|
||||||
|
from . import SandboxEntityDescription, SandboxEntityManager, SandboxProxyEntity
|
||||||
|
|
||||||
|
|
||||||
|
class SandboxWeatherEntity(SandboxProxyEntity, WeatherEntity):
|
||||||
|
"""Proxy for a weather entity in a sandbox."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
description: SandboxEntityDescription,
|
||||||
|
manager: SandboxEntityManager,
|
||||||
|
) -> None:
|
||||||
|
"""Initialize the proxy weather entity."""
|
||||||
|
super().__init__(description, manager)
|
||||||
|
self._attr_supported_features = WeatherEntityFeature(
|
||||||
|
description.supported_features
|
||||||
|
)
|
||||||
|
caps = description.capabilities
|
||||||
|
if temp_unit := caps.get("native_temperature_unit"):
|
||||||
|
self._attr_native_temperature_unit = temp_unit
|
||||||
|
if pressure_unit := caps.get("native_pressure_unit"):
|
||||||
|
self._attr_native_pressure_unit = pressure_unit
|
||||||
|
if wind_speed_unit := caps.get("native_wind_speed_unit"):
|
||||||
|
self._attr_native_wind_speed_unit = wind_speed_unit
|
||||||
|
if visibility_unit := caps.get("native_visibility_unit"):
|
||||||
|
self._attr_native_visibility_unit = visibility_unit
|
||||||
|
if precipitation_unit := caps.get("native_precipitation_unit"):
|
||||||
|
self._attr_native_precipitation_unit = precipitation_unit
|
||||||
|
|
||||||
|
@property
|
||||||
|
def condition(self) -> str | None:
|
||||||
|
"""Return the weather condition."""
|
||||||
|
return self._state_cache.get("condition")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def native_temperature(self) -> float | None:
|
||||||
|
"""Return the temperature."""
|
||||||
|
return self._state_cache.get("native_temperature")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def native_apparent_temperature(self) -> float | None:
|
||||||
|
"""Return the apparent temperature."""
|
||||||
|
return self._state_cache.get("native_apparent_temperature")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def native_pressure(self) -> float | None:
|
||||||
|
"""Return the pressure."""
|
||||||
|
return self._state_cache.get("native_pressure")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def humidity(self) -> float | None:
|
||||||
|
"""Return the humidity."""
|
||||||
|
return self._state_cache.get("humidity")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def native_wind_speed(self) -> float | None:
|
||||||
|
"""Return the wind speed."""
|
||||||
|
return self._state_cache.get("native_wind_speed")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def wind_bearing(self) -> float | str | None:
|
||||||
|
"""Return the wind bearing."""
|
||||||
|
return self._state_cache.get("wind_bearing")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def native_visibility(self) -> float | None:
|
||||||
|
"""Return the visibility."""
|
||||||
|
return self._state_cache.get("native_visibility")
|
||||||
|
|
||||||
|
async def async_forecast_daily(self) -> list[Forecast] | None:
|
||||||
|
"""Forward forecast_daily to sandbox."""
|
||||||
|
return await self._forward_method("async_forecast_daily")
|
||||||
|
|
||||||
|
async def async_forecast_hourly(self) -> list[Forecast] | None:
|
||||||
|
"""Forward forecast_hourly to sandbox."""
|
||||||
|
return await self._forward_method("async_forecast_hourly")
|
||||||
|
|
||||||
|
async def async_forecast_twice_daily(self) -> list[Forecast] | None:
|
||||||
|
"""Forward forecast_twice_daily to sandbox."""
|
||||||
|
return await self._forward_method("async_forecast_twice_daily")
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
"""RemoteHostEntityPlatform for sandbox entities.
|
||||||
|
|
||||||
|
Instead of using per-domain platform files and async_forward_entry_setups,
|
||||||
|
the sandbox integration creates RemoteHostEntityPlatform instances directly
|
||||||
|
and adds them to the domain's EntityComponent. This platform manages proxy
|
||||||
|
entities that represent sandbox entities on the host.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import timedelta
|
||||||
|
import logging
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from homeassistant.config_entries import ConfigEntry
|
||||||
|
from homeassistant.core import HomeAssistant
|
||||||
|
from homeassistant.helpers.entity_component import EntityComponent
|
||||||
|
from homeassistant.helpers.entity_platform import (
|
||||||
|
DATA_DOMAIN_PLATFORM_ENTITIES,
|
||||||
|
EntityPlatform,
|
||||||
|
)
|
||||||
|
|
||||||
|
from .entity import SandboxEntityDescription, SandboxEntityManager, SandboxProxyEntity
|
||||||
|
|
||||||
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class RemoteHostEntityPlatform(EntityPlatform):
|
||||||
|
"""EntityPlatform that manages proxy entities for a sandbox connection.
|
||||||
|
|
||||||
|
Added directly to the domain's EntityComponent._platforms instead of
|
||||||
|
being set up through the normal platform discovery mechanism.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
hass: HomeAssistant,
|
||||||
|
domain: str,
|
||||||
|
config_entry: ConfigEntry,
|
||||||
|
manager: SandboxEntityManager,
|
||||||
|
) -> None:
|
||||||
|
"""Initialize the remote host entity platform."""
|
||||||
|
super().__init__(
|
||||||
|
hass=hass,
|
||||||
|
logger=_LOGGER,
|
||||||
|
domain=domain,
|
||||||
|
platform_name="sandbox",
|
||||||
|
platform=None,
|
||||||
|
scan_interval=timedelta(seconds=0),
|
||||||
|
entity_namespace=None,
|
||||||
|
)
|
||||||
|
self.config_entry = config_entry
|
||||||
|
self._manager = manager
|
||||||
|
self.parallel_updates_created = True
|
||||||
|
|
||||||
|
async def async_add_proxy_entity(
|
||||||
|
self, description: SandboxEntityDescription
|
||||||
|
) -> SandboxProxyEntity:
|
||||||
|
"""Create and add a proxy entity from a sandbox registration."""
|
||||||
|
entity = self._manager.add_entity(description)
|
||||||
|
await self.async_add_entities([entity])
|
||||||
|
return entity
|
||||||
|
|
||||||
|
|
||||||
|
def async_get_or_create_host_platform(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
domain: str,
|
||||||
|
config_entry: ConfigEntry,
|
||||||
|
manager: SandboxEntityManager,
|
||||||
|
) -> RemoteHostEntityPlatform:
|
||||||
|
"""Get or create a RemoteHostEntityPlatform for the given domain.
|
||||||
|
|
||||||
|
Adds the platform to the domain's EntityComponent if it doesn't exist yet.
|
||||||
|
"""
|
||||||
|
from homeassistant.helpers.entity_component import DATA_INSTANCES
|
||||||
|
|
||||||
|
entity_components = hass.data.get(DATA_INSTANCES, {})
|
||||||
|
component: EntityComponent[Any] | None = entity_components.get(domain)
|
||||||
|
|
||||||
|
platform_key = f"sandbox_{config_entry.entry_id}"
|
||||||
|
|
||||||
|
if component is not None:
|
||||||
|
existing = component._platforms.get(platform_key)
|
||||||
|
if isinstance(existing, RemoteHostEntityPlatform):
|
||||||
|
return existing
|
||||||
|
|
||||||
|
platform = RemoteHostEntityPlatform(
|
||||||
|
hass=hass,
|
||||||
|
domain=domain,
|
||||||
|
config_entry=config_entry,
|
||||||
|
manager=manager,
|
||||||
|
)
|
||||||
|
platform.async_prepare()
|
||||||
|
|
||||||
|
if component is not None:
|
||||||
|
component._platforms[platform_key] = platform
|
||||||
|
|
||||||
|
return platform
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"domain": "sandbox",
|
||||||
|
"name": "Sandbox",
|
||||||
|
"codeowners": [],
|
||||||
|
"config_flow": true,
|
||||||
|
"dependencies": ["websocket_api"],
|
||||||
|
"documentation": "https://www.home-assistant.io/integrations/sandbox",
|
||||||
|
"integration_type": "system",
|
||||||
|
"iot_class": "local_push",
|
||||||
|
"quality_scale": "internal",
|
||||||
|
"version": "0.1.0"
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"config": {
|
||||||
|
"step": {
|
||||||
|
"user": {
|
||||||
|
"title": "Sandbox Configuration",
|
||||||
|
"description": "Configure entries to run in a sandbox process."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,811 @@
|
|||||||
|
"""Websocket API for the Sandbox integration."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import voluptuous as vol
|
||||||
|
|
||||||
|
from homeassistant.components import websocket_api
|
||||||
|
from homeassistant.core import HomeAssistant, callback
|
||||||
|
from homeassistant.exceptions import Unauthorized
|
||||||
|
from homeassistant.helpers import device_registry as dr, entity_registry as er
|
||||||
|
|
||||||
|
from .const import DATA_SANDBOX
|
||||||
|
|
||||||
|
|
||||||
|
def _require_sandbox_token(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
connection: websocket_api.ActiveConnection,
|
||||||
|
) -> str:
|
||||||
|
"""Validate the connection uses a sandbox token. Return the sandbox_id."""
|
||||||
|
sandbox_data = hass.data[DATA_SANDBOX]
|
||||||
|
token_id = connection.refresh_token_id
|
||||||
|
if token_id is None or token_id not in sandbox_data.token_to_sandbox:
|
||||||
|
raise Unauthorized
|
||||||
|
return sandbox_data.token_to_sandbox[token_id]
|
||||||
|
|
||||||
|
|
||||||
|
@callback
|
||||||
|
def async_setup(hass: HomeAssistant) -> None:
|
||||||
|
"""Register sandbox websocket commands."""
|
||||||
|
websocket_api.async_register_command(hass, ws_get_entries)
|
||||||
|
websocket_api.async_register_command(hass, ws_update_entry)
|
||||||
|
websocket_api.async_register_command(hass, ws_register_device)
|
||||||
|
websocket_api.async_register_command(hass, ws_update_device)
|
||||||
|
websocket_api.async_register_command(hass, ws_remove_device)
|
||||||
|
websocket_api.async_register_command(hass, ws_register_entity)
|
||||||
|
websocket_api.async_register_command(hass, ws_update_entity)
|
||||||
|
websocket_api.async_register_command(hass, ws_remove_entity)
|
||||||
|
websocket_api.async_register_command(hass, ws_update_state)
|
||||||
|
websocket_api.async_register_command(hass, ws_register_service)
|
||||||
|
websocket_api.async_register_command(hass, ws_sandbox_call_service)
|
||||||
|
websocket_api.async_register_command(hass, ws_service_call_result)
|
||||||
|
websocket_api.async_register_command(hass, ws_subscribe_entity_commands)
|
||||||
|
websocket_api.async_register_command(hass, ws_entity_command_result)
|
||||||
|
|
||||||
|
|
||||||
|
@websocket_api.websocket_command(
|
||||||
|
{vol.Required("type"): "sandbox/get_entries"}
|
||||||
|
)
|
||||||
|
@callback
|
||||||
|
def ws_get_entries(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
connection: websocket_api.ActiveConnection,
|
||||||
|
msg: dict[str, Any],
|
||||||
|
) -> None:
|
||||||
|
"""Return config entries assigned to this sandbox token."""
|
||||||
|
sandbox_id = _require_sandbox_token(hass, connection)
|
||||||
|
sandbox_data = hass.data[DATA_SANDBOX]
|
||||||
|
sandbox_info = sandbox_data.sandboxes[sandbox_id]
|
||||||
|
|
||||||
|
entries = []
|
||||||
|
for entry_config in sandbox_info.entries:
|
||||||
|
entries.append(
|
||||||
|
{
|
||||||
|
"entry_id": entry_config["entry_id"],
|
||||||
|
"domain": entry_config["domain"],
|
||||||
|
"title": entry_config.get("title", entry_config["domain"]),
|
||||||
|
"data": entry_config.get("data", {}),
|
||||||
|
"options": entry_config.get("options", {}),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
connection.send_result(msg["id"], entries)
|
||||||
|
|
||||||
|
|
||||||
|
@websocket_api.websocket_command(
|
||||||
|
{
|
||||||
|
vol.Required("type"): "sandbox/update_entry",
|
||||||
|
vol.Required("sandbox_entry_id"): str,
|
||||||
|
vol.Optional("data"): dict,
|
||||||
|
vol.Optional("options"): dict,
|
||||||
|
vol.Optional("title"): str,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
@callback
|
||||||
|
def ws_update_entry(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
connection: websocket_api.ActiveConnection,
|
||||||
|
msg: dict[str, Any],
|
||||||
|
) -> None:
|
||||||
|
"""Update a sandbox config entry's stored data."""
|
||||||
|
sandbox_id = _require_sandbox_token(hass, connection)
|
||||||
|
sandbox_data = hass.data[DATA_SANDBOX]
|
||||||
|
sandbox_info = sandbox_data.sandboxes[sandbox_id]
|
||||||
|
|
||||||
|
sandbox_entry_id = msg["sandbox_entry_id"]
|
||||||
|
entry_config = next(
|
||||||
|
(e for e in sandbox_info.entries if e["entry_id"] == sandbox_entry_id),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
if entry_config is None:
|
||||||
|
connection.send_error(
|
||||||
|
msg["id"], "not_found", "Entry not assigned to this sandbox"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
if "data" in msg:
|
||||||
|
entry_config["data"] = msg["data"]
|
||||||
|
if "options" in msg:
|
||||||
|
entry_config["options"] = msg["options"]
|
||||||
|
if "title" in msg:
|
||||||
|
entry_config["title"] = msg["title"]
|
||||||
|
|
||||||
|
connection.send_result(msg["id"])
|
||||||
|
|
||||||
|
|
||||||
|
@websocket_api.websocket_command(
|
||||||
|
{
|
||||||
|
vol.Required("type"): "sandbox/register_device",
|
||||||
|
vol.Required("sandbox_entry_id"): str,
|
||||||
|
vol.Required("identifiers"): vol.All(
|
||||||
|
[{vol.Required("domain"): str, vol.Required("id"): str}],
|
||||||
|
vol.Length(min=1),
|
||||||
|
),
|
||||||
|
vol.Optional("name"): str,
|
||||||
|
vol.Optional("manufacturer"): str,
|
||||||
|
vol.Optional("model"): str,
|
||||||
|
vol.Optional("sw_version"): str,
|
||||||
|
vol.Optional("hw_version"): str,
|
||||||
|
vol.Optional("entry_type"): str,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
@websocket_api.async_response
|
||||||
|
async def ws_register_device(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
connection: websocket_api.ActiveConnection,
|
||||||
|
msg: dict[str, Any],
|
||||||
|
) -> None:
|
||||||
|
"""Register a device in HA Core on behalf of a sandbox."""
|
||||||
|
sandbox_id = _require_sandbox_token(hass, connection)
|
||||||
|
sandbox_data = hass.data[DATA_SANDBOX]
|
||||||
|
sandbox_info = sandbox_data.sandboxes[sandbox_id]
|
||||||
|
|
||||||
|
sandbox_entry_id = msg["sandbox_entry_id"]
|
||||||
|
if not any(e["entry_id"] == sandbox_entry_id for e in sandbox_info.entries):
|
||||||
|
connection.send_error(
|
||||||
|
msg["id"], "not_found", "Entry not assigned to this sandbox"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
host_entry_id = sandbox_data.get_host_entry_id(sandbox_id)
|
||||||
|
if host_entry_id is None:
|
||||||
|
connection.send_error(
|
||||||
|
msg["id"], "not_found", "No host config entry for sandbox"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
identifiers = {(i["domain"], i["id"]) for i in msg["identifiers"]}
|
||||||
|
|
||||||
|
device_reg = dr.async_get(hass)
|
||||||
|
kwargs: dict[str, Any] = {
|
||||||
|
"config_entry_id": host_entry_id,
|
||||||
|
"identifiers": identifiers,
|
||||||
|
}
|
||||||
|
for key in ("name", "manufacturer", "model", "sw_version", "hw_version"):
|
||||||
|
if key in msg:
|
||||||
|
kwargs[key] = msg[key]
|
||||||
|
if "entry_type" in msg:
|
||||||
|
kwargs["entry_type"] = dr.DeviceEntryType(msg["entry_type"])
|
||||||
|
|
||||||
|
device = device_reg.async_get_or_create(**kwargs)
|
||||||
|
|
||||||
|
connection.send_result(msg["id"], {"device_id": device.id})
|
||||||
|
|
||||||
|
|
||||||
|
@websocket_api.websocket_command(
|
||||||
|
{
|
||||||
|
vol.Required("type"): "sandbox/update_device",
|
||||||
|
vol.Required("device_id"): str,
|
||||||
|
vol.Optional("name"): str,
|
||||||
|
vol.Optional("manufacturer"): str,
|
||||||
|
vol.Optional("model"): str,
|
||||||
|
vol.Optional("sw_version"): str,
|
||||||
|
vol.Optional("hw_version"): str,
|
||||||
|
vol.Optional("name_by_user"): vol.Any(str, None),
|
||||||
|
vol.Optional("disabled_by"): vol.Any(str, None),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
@websocket_api.async_response
|
||||||
|
async def ws_update_device(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
connection: websocket_api.ActiveConnection,
|
||||||
|
msg: dict[str, Any],
|
||||||
|
) -> None:
|
||||||
|
"""Update a device in HA Core on behalf of a sandbox."""
|
||||||
|
_require_sandbox_token(hass, connection)
|
||||||
|
|
||||||
|
device_reg = dr.async_get(hass)
|
||||||
|
device = device_reg.async_get(msg["device_id"])
|
||||||
|
if device is None:
|
||||||
|
connection.send_error(msg["id"], "not_found", "Device not found")
|
||||||
|
return
|
||||||
|
|
||||||
|
kwargs: dict[str, Any] = {}
|
||||||
|
for key in ("name", "manufacturer", "model", "sw_version", "hw_version", "name_by_user"):
|
||||||
|
if key in msg:
|
||||||
|
kwargs[key] = msg[key]
|
||||||
|
if "disabled_by" in msg:
|
||||||
|
kwargs["disabled_by"] = (
|
||||||
|
dr.DeviceEntryDisabler(msg["disabled_by"])
|
||||||
|
if msg["disabled_by"]
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
|
||||||
|
device_reg.async_update_device(device.id, **kwargs)
|
||||||
|
connection.send_result(msg["id"])
|
||||||
|
|
||||||
|
|
||||||
|
@websocket_api.websocket_command(
|
||||||
|
{
|
||||||
|
vol.Required("type"): "sandbox/remove_device",
|
||||||
|
vol.Required("device_id"): str,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
@websocket_api.async_response
|
||||||
|
async def ws_remove_device(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
connection: websocket_api.ActiveConnection,
|
||||||
|
msg: dict[str, Any],
|
||||||
|
) -> None:
|
||||||
|
"""Remove a device from HA Core on behalf of a sandbox."""
|
||||||
|
sandbox_id = _require_sandbox_token(hass, connection)
|
||||||
|
sandbox_data = hass.data[DATA_SANDBOX]
|
||||||
|
host_entry_id = sandbox_data.get_host_entry_id(sandbox_id)
|
||||||
|
|
||||||
|
device_reg = dr.async_get(hass)
|
||||||
|
device = device_reg.async_get(msg["device_id"])
|
||||||
|
if device is None:
|
||||||
|
connection.send_error(msg["id"], "not_found", "Device not found")
|
||||||
|
return
|
||||||
|
|
||||||
|
device_reg.async_remove_device(device.id)
|
||||||
|
connection.send_result(msg["id"])
|
||||||
|
|
||||||
|
|
||||||
|
@websocket_api.websocket_command(
|
||||||
|
{
|
||||||
|
vol.Required("type"): "sandbox/register_entity",
|
||||||
|
vol.Required("sandbox_entry_id"): str,
|
||||||
|
vol.Required("domain"): str,
|
||||||
|
vol.Required("platform"): str,
|
||||||
|
vol.Required("unique_id"): str,
|
||||||
|
vol.Optional("device_id"): str,
|
||||||
|
vol.Optional("original_name"): str,
|
||||||
|
vol.Optional("original_icon"): str,
|
||||||
|
vol.Optional("entity_category"): str,
|
||||||
|
vol.Optional("suggested_object_id"): str,
|
||||||
|
vol.Optional("device_class"): str,
|
||||||
|
vol.Optional("state_class"): str,
|
||||||
|
vol.Optional("capabilities"): dict,
|
||||||
|
vol.Optional("supported_features"): int,
|
||||||
|
vol.Optional("has_entity_name"): bool,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
@websocket_api.async_response
|
||||||
|
async def ws_register_entity(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
connection: websocket_api.ActiveConnection,
|
||||||
|
msg: dict[str, Any],
|
||||||
|
) -> None:
|
||||||
|
"""Register an entity in HA Core on behalf of a sandbox."""
|
||||||
|
sandbox_id = _require_sandbox_token(hass, connection)
|
||||||
|
sandbox_data = hass.data[DATA_SANDBOX]
|
||||||
|
sandbox_info = sandbox_data.sandboxes[sandbox_id]
|
||||||
|
|
||||||
|
sandbox_entry_id = msg["sandbox_entry_id"]
|
||||||
|
if not any(e["entry_id"] == sandbox_entry_id for e in sandbox_info.entries):
|
||||||
|
connection.send_error(
|
||||||
|
msg["id"], "not_found", "Entry not assigned to this sandbox"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
host_entry_id = sandbox_data.get_host_entry_id(sandbox_id)
|
||||||
|
host_entry = hass.config_entries.async_get_entry(host_entry_id) if host_entry_id else None
|
||||||
|
if host_entry is None:
|
||||||
|
connection.send_error(
|
||||||
|
msg["id"], "not_found", "No host config entry for sandbox"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
domain = msg["domain"]
|
||||||
|
manager = sandbox_data.entity_managers.get(sandbox_id)
|
||||||
|
if manager is None:
|
||||||
|
connection.send_error(msg["id"], "not_found", "No entity manager")
|
||||||
|
return
|
||||||
|
|
||||||
|
from .entity import SandboxEntityDescription
|
||||||
|
from .host_platform import async_get_or_create_host_platform
|
||||||
|
|
||||||
|
description = SandboxEntityDescription(
|
||||||
|
domain=domain,
|
||||||
|
platform=msg["platform"],
|
||||||
|
unique_id=f"{sandbox_id}_{msg['unique_id']}",
|
||||||
|
sandbox_id=sandbox_id,
|
||||||
|
sandbox_entry_id=sandbox_entry_id,
|
||||||
|
device_id=msg.get("device_id"),
|
||||||
|
original_name=msg.get("original_name"),
|
||||||
|
original_icon=msg.get("original_icon"),
|
||||||
|
entity_category=msg.get("entity_category"),
|
||||||
|
device_class=msg.get("device_class"),
|
||||||
|
state_class=msg.get("state_class"),
|
||||||
|
supported_features=msg.get("supported_features", 0),
|
||||||
|
capabilities=msg.get("capabilities", {}),
|
||||||
|
has_entity_name=msg.get("has_entity_name", False),
|
||||||
|
)
|
||||||
|
|
||||||
|
platform = async_get_or_create_host_platform(
|
||||||
|
hass, domain, host_entry, manager
|
||||||
|
)
|
||||||
|
|
||||||
|
entity = await platform.async_add_proxy_entity(description)
|
||||||
|
|
||||||
|
connection.send_result(
|
||||||
|
msg["id"],
|
||||||
|
{"entity_id": entity.entity_id or ""},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@websocket_api.websocket_command(
|
||||||
|
{
|
||||||
|
vol.Required("type"): "sandbox/update_entity",
|
||||||
|
vol.Required("entity_id"): str,
|
||||||
|
vol.Optional("name"): vol.Any(str, None),
|
||||||
|
vol.Optional("icon"): vol.Any(str, None),
|
||||||
|
vol.Optional("disabled_by"): vol.Any(str, None),
|
||||||
|
vol.Optional("hidden_by"): vol.Any(str, None),
|
||||||
|
vol.Optional("original_name"): vol.Any(str, None),
|
||||||
|
vol.Optional("original_icon"): vol.Any(str, None),
|
||||||
|
vol.Optional("capabilities"): vol.Any(dict, None),
|
||||||
|
vol.Optional("supported_features"): int,
|
||||||
|
vol.Optional("device_id"): vol.Any(str, None),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
@websocket_api.async_response
|
||||||
|
async def ws_update_entity(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
connection: websocket_api.ActiveConnection,
|
||||||
|
msg: dict[str, Any],
|
||||||
|
) -> None:
|
||||||
|
"""Update an entity registry entry in HA Core on behalf of a sandbox."""
|
||||||
|
_require_sandbox_token(hass, connection)
|
||||||
|
|
||||||
|
entity_reg = er.async_get(hass)
|
||||||
|
entity_entry = entity_reg.async_get(msg["entity_id"])
|
||||||
|
if entity_entry is None:
|
||||||
|
connection.send_error(msg["id"], "not_found", "Entity not found")
|
||||||
|
return
|
||||||
|
|
||||||
|
kwargs: dict[str, Any] = {}
|
||||||
|
for key in (
|
||||||
|
"name",
|
||||||
|
"icon",
|
||||||
|
"original_name",
|
||||||
|
"original_icon",
|
||||||
|
"capabilities",
|
||||||
|
"supported_features",
|
||||||
|
"device_id",
|
||||||
|
):
|
||||||
|
if key in msg:
|
||||||
|
kwargs[key] = msg[key]
|
||||||
|
|
||||||
|
if "disabled_by" in msg:
|
||||||
|
kwargs["disabled_by"] = (
|
||||||
|
er.RegistryEntryDisabler(msg["disabled_by"])
|
||||||
|
if msg["disabled_by"]
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
if "hidden_by" in msg:
|
||||||
|
kwargs["hidden_by"] = (
|
||||||
|
er.RegistryEntryHider(msg["hidden_by"])
|
||||||
|
if msg["hidden_by"]
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
|
||||||
|
entity_reg.async_update_entity(msg["entity_id"], **kwargs)
|
||||||
|
connection.send_result(msg["id"])
|
||||||
|
|
||||||
|
|
||||||
|
@websocket_api.websocket_command(
|
||||||
|
{
|
||||||
|
vol.Required("type"): "sandbox/update_state",
|
||||||
|
vol.Required("entity_id"): str,
|
||||||
|
vol.Required("state"): str,
|
||||||
|
vol.Optional("attributes"): dict,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
@callback
|
||||||
|
def ws_update_state(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
connection: websocket_api.ActiveConnection,
|
||||||
|
msg: dict[str, Any],
|
||||||
|
) -> None:
|
||||||
|
"""Update an entity state in HA Core from a sandbox."""
|
||||||
|
sandbox_id = _require_sandbox_token(hass, connection)
|
||||||
|
sandbox_data = hass.data[DATA_SANDBOX]
|
||||||
|
|
||||||
|
manager = sandbox_data.entity_managers.get(sandbox_id)
|
||||||
|
if manager is not None:
|
||||||
|
entity = manager.get_entity(msg["entity_id"])
|
||||||
|
if entity is not None:
|
||||||
|
entity.sandbox_update_state(msg["state"], msg.get("attributes") or {})
|
||||||
|
connection.send_result(msg["id"])
|
||||||
|
return
|
||||||
|
|
||||||
|
hass.states.async_set(
|
||||||
|
msg["entity_id"],
|
||||||
|
msg["state"],
|
||||||
|
msg.get("attributes"),
|
||||||
|
)
|
||||||
|
connection.send_result(msg["id"])
|
||||||
|
|
||||||
|
|
||||||
|
@websocket_api.websocket_command(
|
||||||
|
{
|
||||||
|
vol.Required("type"): "sandbox/remove_entity",
|
||||||
|
vol.Required("entity_id"): str,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
@callback
|
||||||
|
def ws_remove_entity(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
connection: websocket_api.ActiveConnection,
|
||||||
|
msg: dict[str, Any],
|
||||||
|
) -> None:
|
||||||
|
"""Remove a sandbox entity from HA Core."""
|
||||||
|
_require_sandbox_token(hass, connection)
|
||||||
|
|
||||||
|
entity_reg = er.async_get(hass)
|
||||||
|
entity_entry = entity_reg.async_get(msg["entity_id"])
|
||||||
|
if entity_entry and entity_entry.platform == "sandbox":
|
||||||
|
entity_reg.async_remove(msg["entity_id"])
|
||||||
|
|
||||||
|
hass.states.async_remove(msg["entity_id"])
|
||||||
|
connection.send_result(msg["id"])
|
||||||
|
|
||||||
|
|
||||||
|
@websocket_api.websocket_command(
|
||||||
|
{
|
||||||
|
vol.Required("type"): "sandbox/register_service",
|
||||||
|
vol.Required("domain"): str,
|
||||||
|
vol.Required("service"): str,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
@callback
|
||||||
|
def ws_register_service(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
connection: websocket_api.ActiveConnection,
|
||||||
|
msg: dict[str, Any],
|
||||||
|
) -> None:
|
||||||
|
"""Register a service on the host on behalf of a sandbox.
|
||||||
|
|
||||||
|
If the service already exists (e.g. entity component loaded it),
|
||||||
|
this is a no-op. Otherwise a proxy service is created that forwards
|
||||||
|
calls to the sandbox for execution.
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
sandbox_id = _require_sandbox_token(hass, connection)
|
||||||
|
sandbox_data = hass.data[DATA_SANDBOX]
|
||||||
|
|
||||||
|
domain = msg["domain"]
|
||||||
|
service = msg["service"]
|
||||||
|
|
||||||
|
if hass.services.has_service(domain, service):
|
||||||
|
connection.send_result(msg["id"])
|
||||||
|
return
|
||||||
|
|
||||||
|
sandbox_info = sandbox_data.sandboxes[sandbox_id]
|
||||||
|
|
||||||
|
async def proxy_service_handler(call: Any) -> Any:
|
||||||
|
"""Forward service call to sandbox for execution."""
|
||||||
|
if sandbox_info.send_command is None:
|
||||||
|
from homeassistant.exceptions import ServiceNotFound
|
||||||
|
|
||||||
|
raise ServiceNotFound(domain, service)
|
||||||
|
|
||||||
|
call_id = f"svc_{sandbox_id}_{id(call)}"
|
||||||
|
future: asyncio.Future[Any] = hass.loop.create_future()
|
||||||
|
sandbox_info.pending_service_calls[call_id] = future
|
||||||
|
|
||||||
|
target: dict[str, Any] = {}
|
||||||
|
if hasattr(call, "target") and call.target:
|
||||||
|
target = dict(call.target)
|
||||||
|
|
||||||
|
# Use pending_contexts if sandbox/call_service stored one for
|
||||||
|
# this context. This ensures only contexts originating from the
|
||||||
|
# sandbox client are forwarded — not the auto-generated context
|
||||||
|
# from the standard call_service WS command.
|
||||||
|
context_data: dict[str, str | None] | None = None
|
||||||
|
if call.context:
|
||||||
|
context_data = sandbox_info.pending_contexts.pop(
|
||||||
|
call.context.id, None
|
||||||
|
)
|
||||||
|
|
||||||
|
sandbox_info.send_command(
|
||||||
|
{
|
||||||
|
"type": "call_service",
|
||||||
|
"call_id": call_id,
|
||||||
|
"domain": call.domain,
|
||||||
|
"service": call.service,
|
||||||
|
"service_data": dict(call.data),
|
||||||
|
"target": target,
|
||||||
|
"return_response": call.return_response,
|
||||||
|
"context": context_data,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
return await asyncio.wait_for(future, timeout=30)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
sandbox_info.pending_service_calls.pop(call_id, None)
|
||||||
|
raise
|
||||||
|
|
||||||
|
from homeassistant.core import SupportsResponse
|
||||||
|
|
||||||
|
hass.services.async_register(
|
||||||
|
domain, service, proxy_service_handler,
|
||||||
|
supports_response=SupportsResponse.OPTIONAL,
|
||||||
|
)
|
||||||
|
connection.send_result(msg["id"])
|
||||||
|
|
||||||
|
|
||||||
|
@websocket_api.websocket_command(
|
||||||
|
{
|
||||||
|
vol.Required("type"): "sandbox/call_service",
|
||||||
|
vol.Required("domain"): str,
|
||||||
|
vol.Required("service"): str,
|
||||||
|
vol.Optional("service_data"): dict,
|
||||||
|
vol.Optional("target"): vol.Any(dict, None),
|
||||||
|
vol.Optional("return_response"): bool,
|
||||||
|
vol.Optional("context"): dict,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
@websocket_api.async_response
|
||||||
|
async def ws_sandbox_call_service(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
connection: websocket_api.ActiveConnection,
|
||||||
|
msg: dict[str, Any],
|
||||||
|
) -> None:
|
||||||
|
"""Call a service with full context forwarding.
|
||||||
|
|
||||||
|
Unlike the standard call_service WS command which creates context from the
|
||||||
|
connection, this uses the context passed from the sandbox so that permission
|
||||||
|
checks and context tracking work correctly.
|
||||||
|
"""
|
||||||
|
import voluptuous as _vol
|
||||||
|
|
||||||
|
from homeassistant.components.websocket_api import const
|
||||||
|
from homeassistant.core import Context
|
||||||
|
from homeassistant.exceptions import (
|
||||||
|
HomeAssistantError,
|
||||||
|
ServiceNotFound,
|
||||||
|
ServiceValidationError,
|
||||||
|
)
|
||||||
|
|
||||||
|
sandbox_id = _require_sandbox_token(hass, connection)
|
||||||
|
sandbox_data = hass.data[DATA_SANDBOX]
|
||||||
|
sandbox_info = sandbox_data.sandboxes[sandbox_id]
|
||||||
|
|
||||||
|
domain = msg["domain"]
|
||||||
|
service = msg["service"]
|
||||||
|
service_data = msg.get("service_data") or {}
|
||||||
|
target = msg.get("target")
|
||||||
|
return_response = msg.get("return_response", False)
|
||||||
|
|
||||||
|
# Reconstruct context from sandbox
|
||||||
|
context_data = msg.get("context")
|
||||||
|
if context_data:
|
||||||
|
context = Context(
|
||||||
|
id=context_data.get("id"),
|
||||||
|
user_id=context_data.get("user_id"),
|
||||||
|
parent_id=context_data.get("parent_id"),
|
||||||
|
)
|
||||||
|
# Store context so the proxy_service_handler can forward it
|
||||||
|
# to the sandbox. Only contexts explicitly sent by the sandbox
|
||||||
|
# client are forwarded — not auto-generated ones from standard
|
||||||
|
# call_service.
|
||||||
|
sandbox_info.pending_contexts[context.id] = {
|
||||||
|
"id": context.id,
|
||||||
|
"user_id": context.user_id,
|
||||||
|
"parent_id": context.parent_id,
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
context = connection.context(msg)
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = await hass.services.async_call(
|
||||||
|
domain,
|
||||||
|
service,
|
||||||
|
service_data,
|
||||||
|
blocking=True,
|
||||||
|
context=context,
|
||||||
|
target=target,
|
||||||
|
return_response=return_response,
|
||||||
|
)
|
||||||
|
result: dict[str, Any] = {"context": context.as_dict()}
|
||||||
|
if return_response:
|
||||||
|
result["response"] = response
|
||||||
|
connection.send_result(msg["id"], result)
|
||||||
|
except ServiceNotFound as err:
|
||||||
|
connection.send_error(
|
||||||
|
msg["id"],
|
||||||
|
const.ERR_NOT_FOUND,
|
||||||
|
f"Service {err.domain}.{err.service} not found.",
|
||||||
|
translation_domain=err.translation_domain,
|
||||||
|
translation_key=err.translation_key,
|
||||||
|
translation_placeholders=err.translation_placeholders,
|
||||||
|
)
|
||||||
|
except _vol.Invalid as err:
|
||||||
|
connection.send_error(msg["id"], const.ERR_INVALID_FORMAT, str(err))
|
||||||
|
except ServiceValidationError as err:
|
||||||
|
connection.send_error(
|
||||||
|
msg["id"],
|
||||||
|
const.ERR_SERVICE_VALIDATION_ERROR,
|
||||||
|
f"Validation error: {err}",
|
||||||
|
translation_domain=err.translation_domain,
|
||||||
|
translation_key=err.translation_key,
|
||||||
|
translation_placeholders=err.translation_placeholders,
|
||||||
|
)
|
||||||
|
except Unauthorized:
|
||||||
|
connection.send_error(msg["id"], const.ERR_UNAUTHORIZED, "Unauthorized")
|
||||||
|
except HomeAssistantError as err:
|
||||||
|
connection.send_error(
|
||||||
|
msg["id"],
|
||||||
|
const.ERR_HOME_ASSISTANT_ERROR,
|
||||||
|
str(err),
|
||||||
|
translation_domain=err.translation_domain,
|
||||||
|
translation_key=err.translation_key,
|
||||||
|
translation_placeholders=err.translation_placeholders,
|
||||||
|
)
|
||||||
|
except Exception as err:
|
||||||
|
connection.logger.exception("Unexpected exception in sandbox/call_service")
|
||||||
|
connection.send_error(msg["id"], const.ERR_UNKNOWN_ERROR, str(err))
|
||||||
|
|
||||||
|
|
||||||
|
@websocket_api.websocket_command(
|
||||||
|
{
|
||||||
|
vol.Required("type"): "sandbox/service_call_result",
|
||||||
|
vol.Required("call_id"): str,
|
||||||
|
vol.Required("success"): bool,
|
||||||
|
vol.Optional("result"): vol.Any(dict, list, str, int, float, bool, None),
|
||||||
|
vol.Optional("error"): str,
|
||||||
|
vol.Optional("error_type"): str,
|
||||||
|
vol.Optional("translation_domain"): str,
|
||||||
|
vol.Optional("translation_key"): str,
|
||||||
|
vol.Optional("translation_placeholders"): dict,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
@callback
|
||||||
|
def ws_service_call_result(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
connection: websocket_api.ActiveConnection,
|
||||||
|
msg: dict[str, Any],
|
||||||
|
) -> None:
|
||||||
|
"""Receive the result of a forwarded service call from the sandbox."""
|
||||||
|
import voluptuous as _vol
|
||||||
|
|
||||||
|
from homeassistant.exceptions import (
|
||||||
|
HomeAssistantError,
|
||||||
|
ServiceNotSupported,
|
||||||
|
ServiceValidationError,
|
||||||
|
Unauthorized,
|
||||||
|
)
|
||||||
|
|
||||||
|
sandbox_id = _require_sandbox_token(hass, connection)
|
||||||
|
sandbox_data = hass.data[DATA_SANDBOX]
|
||||||
|
sandbox_info = sandbox_data.sandboxes.get(sandbox_id)
|
||||||
|
|
||||||
|
if sandbox_info is None:
|
||||||
|
connection.send_error(msg["id"], "not_found", "Sandbox not found")
|
||||||
|
return
|
||||||
|
|
||||||
|
future = sandbox_info.pending_service_calls.pop(msg["call_id"], None)
|
||||||
|
if future is None or future.done():
|
||||||
|
connection.send_result(msg["id"])
|
||||||
|
return
|
||||||
|
|
||||||
|
if msg["success"]:
|
||||||
|
future.set_result(msg.get("result"))
|
||||||
|
else:
|
||||||
|
error_msg = msg.get("error", "Unknown error")
|
||||||
|
error_type = msg.get("error_type", "")
|
||||||
|
translation_domain = msg.get("translation_domain")
|
||||||
|
translation_key = msg.get("translation_key")
|
||||||
|
translation_placeholders = msg.get("translation_placeholders")
|
||||||
|
|
||||||
|
if error_type == "Unauthorized":
|
||||||
|
exc: Exception = Unauthorized()
|
||||||
|
elif error_type == "Invalid":
|
||||||
|
exc = _vol.Invalid(error_msg)
|
||||||
|
elif error_type == "MultipleInvalid":
|
||||||
|
exc = _vol.MultipleInvalid([_vol.Invalid(error_msg)])
|
||||||
|
elif error_type == "ServiceNotSupported":
|
||||||
|
placeholders = translation_placeholders or {}
|
||||||
|
domain = placeholders.get("domain", "")
|
||||||
|
service = placeholders.get("service", "")
|
||||||
|
entity_id = placeholders.get("entity_id", "")
|
||||||
|
exc = ServiceNotSupported(domain, service, entity_id)
|
||||||
|
elif error_type == "ServiceValidationError":
|
||||||
|
if translation_domain and translation_key:
|
||||||
|
exc = ServiceValidationError(
|
||||||
|
translation_domain=translation_domain,
|
||||||
|
translation_key=translation_key,
|
||||||
|
translation_placeholders=translation_placeholders,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
exc = ServiceValidationError(error_msg)
|
||||||
|
elif error_type == "HomeAssistantError" or not error_type:
|
||||||
|
if translation_domain and translation_key:
|
||||||
|
exc = HomeAssistantError(
|
||||||
|
translation_domain=translation_domain,
|
||||||
|
translation_key=translation_key,
|
||||||
|
translation_placeholders=translation_placeholders,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
exc = HomeAssistantError(error_msg)
|
||||||
|
else:
|
||||||
|
# Unknown error types — use ServiceValidationError if it looks
|
||||||
|
# like a validation error subclass, otherwise HomeAssistantError
|
||||||
|
if translation_domain and translation_key:
|
||||||
|
exc = ServiceValidationError(
|
||||||
|
translation_domain=translation_domain,
|
||||||
|
translation_key=translation_key,
|
||||||
|
translation_placeholders=translation_placeholders,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
exc = HomeAssistantError(error_msg)
|
||||||
|
future.set_exception(exc)
|
||||||
|
|
||||||
|
connection.send_result(msg["id"])
|
||||||
|
|
||||||
|
|
||||||
|
@websocket_api.websocket_command(
|
||||||
|
{vol.Required("type"): "sandbox/subscribe_entity_commands"}
|
||||||
|
)
|
||||||
|
@callback
|
||||||
|
def ws_subscribe_entity_commands(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
connection: websocket_api.ActiveConnection,
|
||||||
|
msg: dict[str, Any],
|
||||||
|
) -> None:
|
||||||
|
"""Subscribe to entity method calls from the host.
|
||||||
|
|
||||||
|
The host pushes commands as subscription events when proxy entities
|
||||||
|
need to forward method calls to the sandbox. The sandbox responds
|
||||||
|
with sandbox/entity_command_result.
|
||||||
|
"""
|
||||||
|
sandbox_id = _require_sandbox_token(hass, connection)
|
||||||
|
sandbox_data = hass.data[DATA_SANDBOX]
|
||||||
|
sandbox_info = sandbox_data.sandboxes.get(sandbox_id)
|
||||||
|
if sandbox_info is None:
|
||||||
|
connection.send_error(msg["id"], "not_found", "Sandbox not found")
|
||||||
|
return
|
||||||
|
|
||||||
|
@callback
|
||||||
|
def send_command(command: dict[str, Any]) -> None:
|
||||||
|
"""Send a command to the sandbox."""
|
||||||
|
connection.send_message(
|
||||||
|
websocket_api.event_message(msg["id"], command)
|
||||||
|
)
|
||||||
|
|
||||||
|
sandbox_info.send_command = send_command
|
||||||
|
|
||||||
|
@callback
|
||||||
|
def unsub() -> None:
|
||||||
|
sandbox_info.send_command = None
|
||||||
|
|
||||||
|
connection.subscriptions[msg["id"]] = unsub
|
||||||
|
connection.send_result(msg["id"])
|
||||||
|
|
||||||
|
|
||||||
|
@websocket_api.websocket_command(
|
||||||
|
{
|
||||||
|
vol.Required("type"): "sandbox/entity_command_result",
|
||||||
|
vol.Required("call_id"): str,
|
||||||
|
vol.Required("success"): bool,
|
||||||
|
vol.Optional("result"): vol.Any(dict, list, str, int, float, bool, None),
|
||||||
|
vol.Optional("error"): str,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
@callback
|
||||||
|
def ws_entity_command_result(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
connection: websocket_api.ActiveConnection,
|
||||||
|
msg: dict[str, Any],
|
||||||
|
) -> None:
|
||||||
|
"""Receive the result of a forwarded entity method call."""
|
||||||
|
sandbox_id = _require_sandbox_token(hass, connection)
|
||||||
|
sandbox_data = hass.data[DATA_SANDBOX]
|
||||||
|
|
||||||
|
from .entity import SandboxEntityManager
|
||||||
|
|
||||||
|
manager = sandbox_data.entity_managers.get(sandbox_id)
|
||||||
|
if manager is None:
|
||||||
|
connection.send_error(msg["id"], "not_found", "No entity manager")
|
||||||
|
return
|
||||||
|
|
||||||
|
error = msg.get("error") if not msg["success"] else None
|
||||||
|
manager.resolve_call(msg["call_id"], msg.get("result"), error)
|
||||||
|
connection.send_result(msg["id"])
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
"""Sandbox v2 — 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_v2/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 .auth import async_issue_sandbox_access_token
|
||||||
|
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 v2 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 v2 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 _issue_token(group: str) -> str:
|
||||||
|
return await async_issue_sandbox_access_token(hass, group)
|
||||||
|
|
||||||
|
async def _on_shutdown_reply(group: str, reply: dict[str, Any]) -> None:
|
||||||
|
"""Persist the sandbox's restore-state snapshot (Phase 9).
|
||||||
|
|
||||||
|
The runtime ships its ``RestoreEntity`` state in the shutdown
|
||||||
|
reply rather than via ``RemoteStore`` (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.
|
||||||
|
"""
|
||||||
|
restore_state = reply.get("restore_state")
|
||||||
|
if not isinstance(restore_state, dict):
|
||||||
|
return
|
||||||
|
bridge = data.bridges.get(group)
|
||||||
|
if bridge is None:
|
||||||
|
_LOGGER.debug(
|
||||||
|
"sandbox_v2[%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
|
||||||
|
{"key": "core.restore_state", "data": 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,
|
||||||
|
token_factory=_issue_token,
|
||||||
|
)
|
||||||
|
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 Phase 8 ``RemoteStore``
|
||||||
|
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
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
"""Scoped auth tokens for sandbox runtimes (Phase 7).
|
||||||
|
|
||||||
|
Each sandbox group runs against a dedicated system user; the access token
|
||||||
|
the manager hands to the subprocess is issued from a refresh token whose
|
||||||
|
``scopes`` set restricts the websocket API to the ``sandbox_v2/``
|
||||||
|
namespace plus a short allow-list (e.g. ``auth/current_user``). The
|
||||||
|
websocket dispatcher enforces the scope per command — see
|
||||||
|
``homeassistant.components.websocket_api.connection._scope_allows``.
|
||||||
|
|
||||||
|
The sandbox does not currently open a websocket back to main, but the
|
||||||
|
scoped token is still issued and passed on the CLI so that:
|
||||||
|
|
||||||
|
* the manager and runtime agree on a real credential rather than a
|
||||||
|
placeholder, and
|
||||||
|
* future phases that subscribe to main's bus (``share_states=True``)
|
||||||
|
inherit the same scope without a separate code path.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from homeassistant.auth.models import RefreshToken, User
|
||||||
|
from homeassistant.core import HomeAssistant
|
||||||
|
|
||||||
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Websocket-API scopes granted to sandbox tokens.
|
||||||
|
#
|
||||||
|
# Entries ending in ``/`` are prefix grants — ``sandbox_v2/`` permits any
|
||||||
|
# ``sandbox_v2/...`` command. Plain entries are exact matches. Keep this
|
||||||
|
# allow-list minimal: every entry is a public API surface a sandboxed
|
||||||
|
# integration would otherwise be unable to call, so adding to it widens
|
||||||
|
# the trust boundary.
|
||||||
|
SANDBOX_TOKEN_SCOPES: frozenset[str] = frozenset(
|
||||||
|
{
|
||||||
|
"sandbox_v2/",
|
||||||
|
# Lets the sandbox confirm which user it authenticated as.
|
||||||
|
"auth/current_user",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Marker stored on the system user's name + refresh_token client_id so the
|
||||||
|
# manager can recognise (and reuse) an existing sandbox credential across
|
||||||
|
# HA restarts.
|
||||||
|
_USER_NAME_PREFIX = "Sandbox v2: "
|
||||||
|
_CLIENT_ID_PREFIX = "sandbox_v2/"
|
||||||
|
|
||||||
|
|
||||||
|
def _user_name_for_group(group: str) -> str:
|
||||||
|
"""System user name for a given sandbox group."""
|
||||||
|
return f"{_USER_NAME_PREFIX}{group}"
|
||||||
|
|
||||||
|
|
||||||
|
def _client_id_for_group(group: str) -> str:
|
||||||
|
"""Stable client_id for a sandbox group's refresh token."""
|
||||||
|
return f"{_CLIENT_ID_PREFIX}{group}"
|
||||||
|
|
||||||
|
|
||||||
|
async def async_get_or_create_sandbox_user(hass: HomeAssistant, group: str) -> User:
|
||||||
|
"""Return the dedicated system user for ``group``, creating it once."""
|
||||||
|
name = _user_name_for_group(group)
|
||||||
|
for user in await hass.auth.async_get_users():
|
||||||
|
if user.system_generated and user.name == name:
|
||||||
|
return user
|
||||||
|
return await hass.auth.async_create_system_user(name)
|
||||||
|
|
||||||
|
|
||||||
|
async def async_issue_sandbox_access_token(hass: HomeAssistant, group: str) -> str:
|
||||||
|
"""Issue a scoped access token for the sandbox runtime of ``group``.
|
||||||
|
|
||||||
|
Reuses the dedicated system user across calls; rotates the refresh
|
||||||
|
token on each call so a restart hands the subprocess a fresh
|
||||||
|
credential. The returned JWT is the access token the runtime should
|
||||||
|
pass on the websocket ``auth`` message.
|
||||||
|
"""
|
||||||
|
user = await async_get_or_create_sandbox_user(hass, group)
|
||||||
|
refresh_token = await _get_or_create_sandbox_refresh_token(hass, user, group)
|
||||||
|
return hass.auth.async_create_access_token(refresh_token)
|
||||||
|
|
||||||
|
|
||||||
|
async def _get_or_create_sandbox_refresh_token(
|
||||||
|
hass: HomeAssistant, user: User, group: str
|
||||||
|
) -> RefreshToken:
|
||||||
|
"""Return (or create) the sandbox refresh token for ``group``.
|
||||||
|
|
||||||
|
Sandbox users are ``system_generated`` so their tokens are
|
||||||
|
``TOKEN_TYPE_SYSTEM`` and do not carry a ``client_id``. We identify
|
||||||
|
a group's token by matching the ``scopes`` set against
|
||||||
|
:data:`SANDBOX_TOKEN_SCOPES`; on first use, we create one.
|
||||||
|
"""
|
||||||
|
for token in user.refresh_tokens.values():
|
||||||
|
if token.scopes == SANDBOX_TOKEN_SCOPES:
|
||||||
|
return token
|
||||||
|
return await hass.auth.async_create_refresh_token(
|
||||||
|
user,
|
||||||
|
scopes=SANDBOX_TOKEN_SCOPES,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"SANDBOX_TOKEN_SCOPES",
|
||||||
|
"async_get_or_create_sandbox_user",
|
||||||
|
"async_issue_sandbox_access_token",
|
||||||
|
]
|
||||||
@@ -0,0 +1,690 @@
|
|||||||
|
"""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_v2/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_v2/unregister_entity`` — drop the proxy.
|
||||||
|
- ``sandbox_v2/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_v2/store_load`` /
|
||||||
|
``store_save`` / ``store_remove``). A per-group :class:`_SandboxStoreServer`
|
||||||
|
backs them, writing each key to ``<config>/.storage/sandbox_v2/<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.abc import Mapping
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import timedelta
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from homeassistant.config_entries import ConfigEntry
|
||||||
|
from homeassistant.core import HomeAssistant, ServiceCall, SupportsResponse, callback
|
||||||
|
from homeassistant.exceptions import HomeAssistantError
|
||||||
|
from homeassistant.helpers import 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 json as json_util
|
||||||
|
from homeassistant.util.file import write_utf8_file_atomic
|
||||||
|
|
||||||
|
from .channel import Channel, ChannelClosedError, ChannelRemoteError
|
||||||
|
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_v2"
|
||||||
|
|
||||||
|
|
||||||
|
@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)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_payload(cls, payload: Mapping[str, Any]) -> SandboxEntityDescription:
|
||||||
|
"""Build a description from the wire payload."""
|
||||||
|
return cls(
|
||||||
|
entry_id=payload["entry_id"],
|
||||||
|
domain=payload["domain"],
|
||||||
|
sandbox_entity_id=payload["sandbox_entity_id"],
|
||||||
|
unique_id=payload.get("unique_id"),
|
||||||
|
name=payload.get("name"),
|
||||||
|
icon=payload.get("icon"),
|
||||||
|
has_entity_name=bool(payload.get("has_entity_name", False)),
|
||||||
|
entity_category=payload.get("entity_category"),
|
||||||
|
device_class=payload.get("device_class"),
|
||||||
|
supported_features=int(payload.get("supported_features") or 0),
|
||||||
|
capabilities=dict(payload.get("capabilities") or {}),
|
||||||
|
initial_state=payload.get("initial_state"),
|
||||||
|
initial_attributes=dict(payload.get("initial_attributes") or {}),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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_v2/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_v2: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_v2/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)
|
||||||
|
|
||||||
|
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_id: str | 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.
|
||||||
|
"""
|
||||||
|
return await self._batcher.enqueue(
|
||||||
|
domain=domain,
|
||||||
|
service=service,
|
||||||
|
sandbox_entity_id=sandbox_entity_id,
|
||||||
|
service_data=service_data,
|
||||||
|
context_id=context_id,
|
||||||
|
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_v2/call_service`` RPC and translate errors."""
|
||||||
|
payload: dict[str, Any] = {
|
||||||
|
"domain": domain,
|
||||||
|
"service": service,
|
||||||
|
"target": target,
|
||||||
|
"service_data": service_data,
|
||||||
|
"return_response": return_response,
|
||||||
|
}
|
||||||
|
if context_id is not None:
|
||||||
|
payload["context_id"] = context_id
|
||||||
|
try:
|
||||||
|
return await self.channel.call(MSG_CALL_SERVICE, payload)
|
||||||
|
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
|
||||||
|
|
||||||
|
async def _handle_register_entity(
|
||||||
|
self, payload: Mapping[str, Any]
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
description = SandboxEntityDescription.from_payload(payload)
|
||||||
|
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}"
|
||||||
|
)
|
||||||
|
# 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)
|
||||||
|
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 {"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, payload: Mapping[str, Any]
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
sandbox_entity_id = payload["sandbox_entity_id"]
|
||||||
|
proxy = self._entities.pop(sandbox_entity_id, None)
|
||||||
|
if proxy is None:
|
||||||
|
return {"ok": True}
|
||||||
|
entity_id = getattr(proxy, "entity_id", None)
|
||||||
|
if not entity_id:
|
||||||
|
return {"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 {"ok": True}
|
||||||
|
|
||||||
|
async def _handle_state_changed(self, payload: Mapping[str, Any]) -> None:
|
||||||
|
sandbox_entity_id = payload["sandbox_entity_id"]
|
||||||
|
proxy = self._entities.get(sandbox_entity_id)
|
||||||
|
if proxy is None:
|
||||||
|
return
|
||||||
|
new_state = payload.get("new_state") or {}
|
||||||
|
state_str = new_state.get("state")
|
||||||
|
attributes = dict(new_state.get("attributes") or {})
|
||||||
|
proxy.sandbox_apply_state(state_str, attributes)
|
||||||
|
|
||||||
|
async def _handle_register_service(
|
||||||
|
self, payload: Mapping[str, Any]
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Mirror a sandbox-registered service onto main's service registry.
|
||||||
|
|
||||||
|
The handler that gets installed forwards every call back over
|
||||||
|
the shared ``sandbox_v2/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 = str(payload["domain"]).lower()
|
||||||
|
service = str(payload["service"]).lower()
|
||||||
|
supports_response = _parse_supports_response(payload.get("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 {"ok": True, "installed": False}
|
||||||
|
|
||||||
|
forwarder = _build_service_forwarder(self, domain, service, supports_response)
|
||||||
|
schema = reconstruct_schema(payload.get("schema"))
|
||||||
|
self.hass.services.async_register(
|
||||||
|
domain,
|
||||||
|
service,
|
||||||
|
forwarder,
|
||||||
|
schema=schema,
|
||||||
|
supports_response=supports_response,
|
||||||
|
)
|
||||||
|
self._mirrored_services.add((domain, service))
|
||||||
|
return {"ok": True, "installed": True}
|
||||||
|
|
||||||
|
async def _handle_unregister_service(
|
||||||
|
self, payload: Mapping[str, Any]
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
domain = str(payload["domain"]).lower()
|
||||||
|
service = str(payload["service"]).lower()
|
||||||
|
key = (domain, service)
|
||||||
|
if key not in self._mirrored_services:
|
||||||
|
return {"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 {"ok": True, "removed": True}
|
||||||
|
|
||||||
|
async def _handle_store_load(
|
||||||
|
self, payload: Mapping[str, Any]
|
||||||
|
) -> dict[str, Any] | None:
|
||||||
|
"""Serve a sandbox-side ``Store.async_load`` (Phase 8)."""
|
||||||
|
return await self._store_server.async_load(_require_key(payload))
|
||||||
|
|
||||||
|
async def _handle_store_save(self, payload: Mapping[str, Any]) -> dict[str, Any]:
|
||||||
|
"""Persist a sandbox-side ``Store.async_save`` flush (Phase 8)."""
|
||||||
|
data = payload.get("data")
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
raise HomeAssistantError("store_save: missing 'data' dict")
|
||||||
|
await self._store_server.async_save(_require_key(payload), data)
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
async def _handle_store_remove(self, payload: Mapping[str, Any]) -> dict[str, Any]:
|
||||||
|
"""Drop the on-disk file for a sandbox-side ``Store.async_remove``."""
|
||||||
|
await self._store_server.async_remove(_require_key(payload))
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
async def _handle_fire_event(self, payload: Mapping[str, Any]) -> None:
|
||||||
|
"""Re-fire a sandbox-side event on main's bus.
|
||||||
|
|
||||||
|
The sandbox tags every push with ``event_type`` + ``event_data``;
|
||||||
|
the context is reconstructed minimally so listeners on main see a
|
||||||
|
consistent ``Context`` shape (the sandbox's own context id is
|
||||||
|
forwarded but not honoured by main's user resolution — that's
|
||||||
|
intentional for v2).
|
||||||
|
"""
|
||||||
|
event_type = str(payload["event_type"])
|
||||||
|
event_data = payload.get("event_data") or {}
|
||||||
|
self.hass.bus.async_fire(event_type, dict(event_data))
|
||||||
|
|
||||||
|
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 _require_key(payload: Mapping[str, Any]) -> str:
|
||||||
|
"""Extract + validate a ``key`` field from a store payload.
|
||||||
|
|
||||||
|
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.
|
||||||
|
"""
|
||||||
|
key = payload.get("key")
|
||||||
|
if not isinstance(key, str) or 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_v2/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_v2/<group>``."""
|
||||||
|
self.hass = hass
|
||||||
|
self.group = group
|
||||||
|
self._dir = Path(hass.config.path(STORAGE_DIR, "sandbox_v2", 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
|
||||||
|
|
||||||
|
|
||||||
|
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_v2/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:
|
||||||
|
payload: dict[str, Any] = {
|
||||||
|
"domain": domain,
|
||||||
|
"service": service,
|
||||||
|
"service_data": dict(call.data),
|
||||||
|
"target": _target_from_call(call),
|
||||||
|
"return_response": call.return_response,
|
||||||
|
"context_id": call.context.id if call.context is not None else None,
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
response = await bridge.channel.call(MSG_CALL_SERVICE, payload)
|
||||||
|
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 isinstance(response, Mapping):
|
||||||
|
return response.get("response", response)
|
||||||
|
return response
|
||||||
|
|
||||||
|
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 _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`.
|
||||||
|
Callers on main expect ``TypeError`` / ``HomeAssistantError`` shapes,
|
||||||
|
so we translate. Anything we don't have a mapping for surfaces as a
|
||||||
|
plain :class:`HomeAssistantError` with the remote message preserved.
|
||||||
|
"""
|
||||||
|
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,301 @@
|
|||||||
|
"""JSON-line request/response channel between manager and sandbox runtime.
|
||||||
|
|
||||||
|
The wire format is intentionally trivial — one JSON object per line:
|
||||||
|
|
||||||
|
* **request** (call): ``{"id": int, "type": str, "payload": Any}``
|
||||||
|
* **response**: ``{"id": int, "ok": bool, "result": Any}``
|
||||||
|
or ``{"id": int, "ok": false, "error": str, "error_type": str}``
|
||||||
|
* **push** (one-way): ``{"type": str, "payload": Any}`` — no ``id``, no reply
|
||||||
|
|
||||||
|
Each side wraps its inbound/outbound byte streams in a :class:`Channel`. 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
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
Handler = Callable[[Any], Awaitable[Any]]
|
||||||
|
|
||||||
|
DEFAULT_MAX_INFLIGHT = 16
|
||||||
|
|
||||||
|
|
||||||
|
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) -> None:
|
||||||
|
"""Initialise with the remote error message and exception class name."""
|
||||||
|
super().__init__(error)
|
||||||
|
self.error = error
|
||||||
|
self.error_type = error_type
|
||||||
|
|
||||||
|
|
||||||
|
class Channel:
|
||||||
|
"""One bidirectional request/response channel over a line-oriented stream."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
reader: asyncio.StreamReader,
|
||||||
|
writer: asyncio.StreamWriter,
|
||||||
|
*,
|
||||||
|
name: str = "channel",
|
||||||
|
max_inflight: int = DEFAULT_MAX_INFLIGHT,
|
||||||
|
) -> None:
|
||||||
|
"""Wrap a reader/writer pair into a request/response channel.
|
||||||
|
|
||||||
|
``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.
|
||||||
|
"""
|
||||||
|
self._reader = reader
|
||||||
|
self._writer = writer
|
||||||
|
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)
|
||||||
|
|
||||||
|
@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_v2[{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({"id": call_id, "type": msg_type, "payload": 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({"type": msg_type, "payload": 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._writer.close()
|
||||||
|
with contextlib.suppress(asyncio.CancelledError):
|
||||||
|
await self._writer.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, message: dict[str, Any]) -> None:
|
||||||
|
line = json.dumps(message, separators=(",", ":")).encode("utf-8") + b"\n"
|
||||||
|
async with self._write_lock:
|
||||||
|
self._writer.write(line)
|
||||||
|
await self._writer.drain()
|
||||||
|
|
||||||
|
async def _read_loop(self) -> None:
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
line = await self._reader.readline()
|
||||||
|
if not line:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
message = json.loads(line)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
_LOGGER.warning(
|
||||||
|
"Channel %s: dropping malformed line %r", self._name, line
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
self._dispatch(message)
|
||||||
|
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, message: dict[str, Any]) -> None:
|
||||||
|
"""Route an inbound message; non-blocking — handlers run in tasks."""
|
||||||
|
if "id" in message and "type" not in message:
|
||||||
|
# Response to a call we sent out — set the future inline; no I/O.
|
||||||
|
call_id = message["id"]
|
||||||
|
future = self._pending.get(call_id)
|
||||||
|
if future is None or future.done():
|
||||||
|
return
|
||||||
|
if message.get("ok"):
|
||||||
|
future.set_result(message.get("result"))
|
||||||
|
else:
|
||||||
|
future.set_exception(
|
||||||
|
ChannelRemoteError(
|
||||||
|
message.get("error", "unknown error"),
|
||||||
|
message.get("error_type"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
msg_type = message.get("type")
|
||||||
|
if msg_type is None:
|
||||||
|
return
|
||||||
|
handler = self._handlers.get(msg_type)
|
||||||
|
payload = message.get("payload")
|
||||||
|
|
||||||
|
if "id" not in message:
|
||||||
|
# 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(msg_type, handler, payload))
|
||||||
|
return
|
||||||
|
|
||||||
|
call_id = message["id"]
|
||||||
|
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(
|
||||||
|
{
|
||||||
|
"id": call_id,
|
||||||
|
"ok": False,
|
||||||
|
"error": f"no handler for {msg_type!r}",
|
||||||
|
"error_type": "ChannelUnknownType",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
self._spawn_handler(self._run_call_handler(call_id, msg_type, handler, 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_v2[{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
|
||||||
|
with contextlib.suppress(Exception):
|
||||||
|
await self._write(
|
||||||
|
{
|
||||||
|
"id": call_id,
|
||||||
|
"ok": False,
|
||||||
|
"error": str(err) or err.__class__.__name__,
|
||||||
|
"error_type": err.__class__.__name__,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return
|
||||||
|
if self._closed:
|
||||||
|
return
|
||||||
|
with contextlib.suppress(Exception):
|
||||||
|
await self._write({"id": call_id, "ok": True, "result": result})
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"Channel",
|
||||||
|
"ChannelClosedError",
|
||||||
|
"ChannelRemoteError",
|
||||||
|
"Handler",
|
||||||
|
]
|
||||||
@@ -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,57 @@
|
|||||||
|
"""Constants for the Sandbox v2 integration."""
|
||||||
|
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from homeassistant.util.hass_dict import HassKey
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from . import SandboxV2Data
|
||||||
|
|
||||||
|
DOMAIN = "sandbox_v2"
|
||||||
|
|
||||||
|
DATA_SANDBOX_V2: HassKey[SandboxV2Data] = HassKey(DOMAIN)
|
||||||
|
|
||||||
|
# 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_v2): revisit each entry once the protocol can carry the missing
|
||||||
|
# payload shape. Tracked in sandbox_v2/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",
|
||||||
|
}
|
||||||
|
)
|
||||||
@@ -0,0 +1,206 @@
|
|||||||
|
"""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 typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
|
from homeassistant.const import EntityCategory
|
||||||
|
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)
|
||||||
|
|
||||||
|
@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_apply_state(
|
||||||
|
self, state: str | None, attributes: dict[str, Any]
|
||||||
|
) -> None:
|
||||||
|
"""Update the cache from a sandbox push, and notify HA."""
|
||||||
|
self._state_cache = dict(attributes)
|
||||||
|
if state is not None:
|
||||||
|
self._state_cache["state"] = state
|
||||||
|
if self.hass is not None:
|
||||||
|
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.
|
||||||
|
"""
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# 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_v2
|
||||||
|
# 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 v2 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 v2 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,31 @@
|
|||||||
|
"""Sandbox v2 proxy for ``button`` entities."""
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from homeassistant.components.button import ButtonEntity
|
||||||
|
|
||||||
|
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]
|
||||||
|
) -> 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)
|
||||||
|
|
||||||
|
async def async_press(self) -> None:
|
||||||
|
"""Forward press as a ``button.press`` service call."""
|
||||||
|
await self._call_service("press")
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
"""Sandbox v2 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 v2 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 v2 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 v2 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 v2 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 v2 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,40 @@
|
|||||||
|
"""Sandbox v2 proxy for ``event`` entities."""
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from homeassistant.components.event import ATTR_EVENT_TYPE, EventEntity
|
||||||
|
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]
|
||||||
|
) -> 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)
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
"""Sandbox v2 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 v2 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 v2 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 v2 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 v2 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 v2 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,39 @@
|
|||||||
|
"""Sandbox v2 proxy for ``notify`` entities."""
|
||||||
|
|
||||||
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
|
from homeassistant.components.notify import NotifyEntity, NotifyEntityFeature
|
||||||
|
|
||||||
|
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]
|
||||||
|
) -> 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)
|
||||||
|
|
||||||
|
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 v2 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 v2 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,30 @@
|
|||||||
|
"""Sandbox v2 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 . 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]
|
||||||
|
) -> 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)
|
||||||
|
|
||||||
|
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 v2 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 v2 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 v2 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 v2 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 v2 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 v2 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 v2 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 v2 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 v2 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 v2 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 v2 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 v2 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,627 @@
|
|||||||
|
"""Sandbox v2 — 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 websocket protocol between manager and runtime is not yet implemented
|
||||||
|
— Phase 4 plugs it in. For now the contract is just:
|
||||||
|
|
||||||
|
* the manager launches ``python -m hass_client.sandbox_v2``
|
||||||
|
* the runtime prints :data:`READY_MARKER` to stdout once it is up
|
||||||
|
* on ``SIGTERM`` the runtime exits cleanly
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from collections import deque
|
||||||
|
from collections.abc import Awaitable, Callable
|
||||||
|
import contextlib
|
||||||
|
from dataclasses import dataclass
|
||||||
|
import logging
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
|
||||||
|
from homeassistant.core import HomeAssistant
|
||||||
|
|
||||||
|
from .channel import Channel, ChannelClosedError, ChannelRemoteError
|
||||||
|
from .protocol import MSG_SHUTDOWN
|
||||||
|
|
||||||
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Stdout token the runtime prints once it is ready to take work. Kept in
|
||||||
|
# sync with ``hass_client.sandbox.READY_MARKER``.
|
||||||
|
READY_MARKER = "sandbox_v2:ready"
|
||||||
|
|
||||||
|
DEFAULT_RESTART_LIMIT = 3
|
||||||
|
DEFAULT_RESTART_WINDOW = 60.0
|
||||||
|
DEFAULT_RESTART_BACKOFF = 1.0
|
||||||
|
DEFAULT_READY_TIMEOUT = 30.0
|
||||||
|
DEFAULT_SHUTDOWN_GRACE = 10.0
|
||||||
|
|
||||||
|
CommandFactory = Callable[[str], list[str]]
|
||||||
|
TokenFactory = Callable[[str], Awaitable[str]]
|
||||||
|
ShutdownReplyCallback = Callable[[str, dict], Awaitable[None]]
|
||||||
|
|
||||||
|
|
||||||
|
class SandboxV2Error(Exception):
|
||||||
|
"""Base class for sandbox_v2 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
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class SandboxGroupConfig:
|
||||||
|
"""Per-group data-sharing knobs (Phase 7).
|
||||||
|
|
||||||
|
All flags default to ``False`` — the sandbox sees nothing of main's
|
||||||
|
state, registries, or areas unless explicitly opted in. The
|
||||||
|
integration's ``async_setup`` flips ``share_states`` to ``True`` for
|
||||||
|
the ``built-in`` group so existing built-in integrations behave the
|
||||||
|
same as if they ran locally; the ``custom`` group stays locked down.
|
||||||
|
|
||||||
|
The flags are wire-only today — the sandbox runtime reads them from
|
||||||
|
its CLI and decides whether to subscribe to main's bus. Filtering on
|
||||||
|
the main side happens at subscription time and is a follow-up once
|
||||||
|
the sandbox actually opens that subscription.
|
||||||
|
"""
|
||||||
|
|
||||||
|
share_states: bool = False
|
||||||
|
share_entity_registry: bool = False
|
||||||
|
share_areas: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
# Default sharing posture per group. ``custom`` stays locked down; the
|
||||||
|
# ``built-in`` and ``main`` groups inherit main's state stream so
|
||||||
|
# integrations that read from ``hass.states`` continue to work.
|
||||||
|
DEFAULT_GROUP_CONFIGS: dict[str, SandboxGroupConfig] = {
|
||||||
|
"built-in": SandboxGroupConfig(
|
||||||
|
share_states=True, share_entity_registry=True, share_areas=True
|
||||||
|
),
|
||||||
|
"main": SandboxGroupConfig(
|
||||||
|
share_states=True, share_entity_registry=True, share_areas=True
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
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[[], list[str]],
|
||||||
|
config: SandboxConfig,
|
||||||
|
*,
|
||||||
|
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.
|
||||||
|
|
||||||
|
``on_channel_ready`` is invoked with the live :class:`Channel` once
|
||||||
|
the runtime has printed its ready marker. It runs synchronously on
|
||||||
|
the manager's loop — register Phase 4 protocol handlers there
|
||||||
|
before any caller can issue a call.
|
||||||
|
|
||||||
|
``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._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_v2[{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_v2/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 or {})
|
||||||
|
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."""
|
||||||
|
command = self._command_factory()
|
||||||
|
try:
|
||||||
|
proc = 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
|
||||||
|
|
||||||
|
self._process = proc
|
||||||
|
ready_task = asyncio.create_task(self._await_ready(proc))
|
||||||
|
exit_task = asyncio.create_task(proc.wait())
|
||||||
|
stderr_task = asyncio.create_task(self._drain_stream(proc.stderr, "stderr"))
|
||||||
|
|
||||||
|
try:
|
||||||
|
await asyncio.wait(
|
||||||
|
{ready_task, exit_task}, return_when=asyncio.FIRST_COMPLETED
|
||||||
|
)
|
||||||
|
if ready_task.done() and not ready_task.cancelled():
|
||||||
|
if ready_task.exception() is None and ready_task.result():
|
||||||
|
self._channel = self._open_channel(proc)
|
||||||
|
if self._on_channel_ready is not None:
|
||||||
|
try:
|
||||||
|
self._on_channel_ready(self.group, self._channel)
|
||||||
|
except Exception:
|
||||||
|
_LOGGER.exception(
|
||||||
|
"Sandbox %s on_channel_ready callback raised",
|
||||||
|
self.group,
|
||||||
|
)
|
||||||
|
self._channel.start()
|
||||||
|
self._state = "running"
|
||||||
|
self._ready.set()
|
||||||
|
# Hold here until the process exits.
|
||||||
|
await exit_task
|
||||||
|
finally:
|
||||||
|
for task in (ready_task, exit_task):
|
||||||
|
if not task.done():
|
||||||
|
task.cancel()
|
||||||
|
with contextlib.suppress(asyncio.CancelledError):
|
||||||
|
await task
|
||||||
|
if not stderr_task.done():
|
||||||
|
stderr_task.cancel()
|
||||||
|
with contextlib.suppress(asyncio.CancelledError):
|
||||||
|
await stderr_task
|
||||||
|
if self._channel is not None:
|
||||||
|
await self._channel.close()
|
||||||
|
self._channel = None
|
||||||
|
self._process = None
|
||||||
|
self._ready.clear()
|
||||||
|
|
||||||
|
def _open_channel(self, proc: asyncio.subprocess.Process) -> Channel:
|
||||||
|
"""Wrap the subprocess pipes in a :class:`Channel`.
|
||||||
|
|
||||||
|
Stdout is post-marker — the rest is JSON-line protocol. Stdin is
|
||||||
|
always JSON-line.
|
||||||
|
"""
|
||||||
|
assert proc.stdout is not None
|
||||||
|
assert proc.stdin is not None
|
||||||
|
# proc.stdin is a StreamWriter; proc.stdout is a StreamReader. They
|
||||||
|
# are exactly what Channel needs.
|
||||||
|
return Channel(proc.stdout, proc.stdin, name=self.group)
|
||||||
|
|
||||||
|
async def _await_ready(self, proc: asyncio.subprocess.Process) -> bool:
|
||||||
|
"""Read stdout until the ready marker arrives or stdout closes."""
|
||||||
|
stream = proc.stdout
|
||||||
|
if stream is None:
|
||||||
|
return False
|
||||||
|
while True:
|
||||||
|
line = await stream.readline()
|
||||||
|
if not line:
|
||||||
|
return False
|
||||||
|
text = line.decode("utf-8", errors="replace").rstrip()
|
||||||
|
if text:
|
||||||
|
_LOGGER.debug("sandbox %s: %s", self.group, text)
|
||||||
|
if READY_MARKER in text:
|
||||||
|
return True
|
||||||
|
|
||||||
|
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,
|
||||||
|
token_factory: TokenFactory | None = None,
|
||||||
|
group_configs: dict[str, SandboxGroupConfig] | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""Initialise the manager.
|
||||||
|
|
||||||
|
``command_factory`` lets tests substitute the spawned command; the
|
||||||
|
default builds the ``python -m hass_client.sandbox_v2`` argv that
|
||||||
|
:class:`hass_client.sandbox.SandboxRuntime` consumes.
|
||||||
|
|
||||||
|
``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_v2/notify_flow_changed``).
|
||||||
|
|
||||||
|
``token_factory`` returns the scoped access token the manager
|
||||||
|
passes to the subprocess (Phase 7). Awaited once per group and
|
||||||
|
cached on :attr:`_tokens`. Without one, ``_default_command``
|
||||||
|
falls back to a placeholder so tests that don't care about auth
|
||||||
|
still work.
|
||||||
|
|
||||||
|
``group_configs`` overrides the per-group data-sharing posture
|
||||||
|
(Phase 7). Missing groups fall back to :data:`DEFAULT_GROUP_CONFIGS`
|
||||||
|
and finally to ``SandboxGroupConfig()`` (everything off).
|
||||||
|
"""
|
||||||
|
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
|
||||||
|
self._token_factory = token_factory
|
||||||
|
self._group_configs: dict[str, SandboxGroupConfig] = dict(
|
||||||
|
group_configs or DEFAULT_GROUP_CONFIGS
|
||||||
|
)
|
||||||
|
self._tokens: dict[str, str] = {}
|
||||||
|
self._sandboxes: dict[str, SandboxProcess] = {}
|
||||||
|
self._locks: dict[str, asyncio.Lock] = {}
|
||||||
|
|
||||||
|
def group_config(self, group: str) -> SandboxGroupConfig:
|
||||||
|
"""Return the data-sharing config for ``group``."""
|
||||||
|
if (override := self._group_configs.get(group)) is not None:
|
||||||
|
return override
|
||||||
|
return SandboxGroupConfig()
|
||||||
|
|
||||||
|
@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]
|
||||||
|
|
||||||
|
if self._token_factory is not None and group not in self._tokens:
|
||||||
|
self._tokens[group] = await self._token_factory(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() -> list[str]:
|
||||||
|
return self._command_factory(group)
|
||||||
|
|
||||||
|
process = SandboxProcess(
|
||||||
|
group,
|
||||||
|
make_command,
|
||||||
|
self._config,
|
||||||
|
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) -> list[str]:
|
||||||
|
"""Argv for ``python -m hass_client.sandbox_v2``.
|
||||||
|
|
||||||
|
Phase 7 plugs the scoped sandbox access token into the CLI; the
|
||||||
|
runtime does not yet open the websocket but carries the token so
|
||||||
|
future phases can. The URL still defaults to localhost because
|
||||||
|
the runtime does not consume it today.
|
||||||
|
"""
|
||||||
|
token = self._tokens.get(group, "sandbox_v2_placeholder")
|
||||||
|
cfg = self.group_config(group)
|
||||||
|
argv = [
|
||||||
|
sys.executable,
|
||||||
|
"-m",
|
||||||
|
"hass_client.sandbox_v2",
|
||||||
|
"--group",
|
||||||
|
group,
|
||||||
|
"--url",
|
||||||
|
"ws://localhost:8123/api/websocket",
|
||||||
|
"--token",
|
||||||
|
token,
|
||||||
|
]
|
||||||
|
if cfg.share_states:
|
||||||
|
argv.append("--share-states")
|
||||||
|
if cfg.share_entity_registry:
|
||||||
|
argv.append("--share-entity-registry")
|
||||||
|
if cfg.share_areas:
|
||||||
|
argv.append("--share-areas")
|
||||||
|
return argv
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"DEFAULT_GROUP_CONFIGS",
|
||||||
|
"READY_MARKER",
|
||||||
|
"CommandFactory",
|
||||||
|
"SandboxConfig",
|
||||||
|
"SandboxFailedError",
|
||||||
|
"SandboxGroupConfig",
|
||||||
|
"SandboxManager",
|
||||||
|
"SandboxProcess",
|
||||||
|
"SandboxStartError",
|
||||||
|
"SandboxV2Error",
|
||||||
|
"ShutdownReplyCallback",
|
||||||
|
"TokenFactory",
|
||||||
|
]
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"domain": "sandbox_v2",
|
||||||
|
"name": "Sandbox v2",
|
||||||
|
"codeowners": [],
|
||||||
|
"dependencies": ["websocket_api"],
|
||||||
|
"documentation": "https://www.home-assistant.io/integrations/sandbox_v2",
|
||||||
|
"integration_type": "system",
|
||||||
|
"iot_class": "local_push",
|
||||||
|
"quality_scale": "internal"
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
"""Phase 5 wire-protocol constants and payload helpers.
|
||||||
|
|
||||||
|
The integration and the sandbox runtime exchange JSON-line messages over
|
||||||
|
the :class:`Channel` set up in Phase 4. Each message type is namespaced
|
||||||
|
``sandbox_v2/…``. 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.
|
||||||
|
|
||||||
|
Main → Sandbox calls:
|
||||||
|
|
||||||
|
* ``sandbox_v2/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}``.
|
||||||
|
* ``sandbox_v2/entry_unload`` — ask the sandbox to unload an entry by id.
|
||||||
|
* ``sandbox_v2/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_v2/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.
|
||||||
|
* ``sandbox_v2/unregister_entity`` — symmetric counterpart.
|
||||||
|
* ``sandbox_v2/state_changed`` — push (no response). Carries the
|
||||||
|
marshalled state delta for one entity.
|
||||||
|
* ``sandbox_v2/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_v2/call_service``
|
||||||
|
channel.
|
||||||
|
* ``sandbox_v2/unregister_service`` (Phase 6) — symmetric counterpart.
|
||||||
|
* ``sandbox_v2/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_v2/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_v2/store_save`` (Phase 8) — sandbox-side ``Store`` flush.
|
||||||
|
Payload ``{"key": str, "data": dict}``; main writes the wrapped dict
|
||||||
|
to ``<config>/.storage/sandbox_v2/<group>/<key>`` atomically. Response
|
||||||
|
is ``{"ok": True}``.
|
||||||
|
* ``sandbox_v2/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_v2/shutdown`` — ask the runtime to unload its entries, dump
|
||||||
|
``RestoreEntity`` state through the Phase 8 :class:`RemoteStore`, fire
|
||||||
|
``EVENT_HOMEASSISTANT_FINAL_WRITE`` so any pending Stores flush, 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
|
||||||
|
|
||||||
|
# Main → Sandbox
|
||||||
|
MSG_ENTRY_SETUP: Final = "sandbox_v2/entry_setup"
|
||||||
|
MSG_ENTRY_UNLOAD: Final = "sandbox_v2/entry_unload"
|
||||||
|
MSG_CALL_SERVICE: Final = "sandbox_v2/call_service"
|
||||||
|
MSG_SHUTDOWN: Final = "sandbox_v2/shutdown"
|
||||||
|
|
||||||
|
# Sandbox → Main
|
||||||
|
MSG_REGISTER_ENTITY: Final = "sandbox_v2/register_entity"
|
||||||
|
MSG_UNREGISTER_ENTITY: Final = "sandbox_v2/unregister_entity"
|
||||||
|
MSG_STATE_CHANGED: Final = "sandbox_v2/state_changed"
|
||||||
|
MSG_REGISTER_SERVICE: Final = "sandbox_v2/register_service"
|
||||||
|
MSG_UNREGISTER_SERVICE: Final = "sandbox_v2/unregister_service"
|
||||||
|
MSG_FIRE_EVENT: Final = "sandbox_v2/fire_event"
|
||||||
|
MSG_STORE_LOAD: Final = "sandbox_v2/store_load"
|
||||||
|
MSG_STORE_SAVE: Final = "sandbox_v2/store_save"
|
||||||
|
MSG_STORE_REMOVE: Final = "sandbox_v2/store_remove"
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"MSG_CALL_SERVICE",
|
||||||
|
"MSG_ENTRY_SETUP",
|
||||||
|
"MSG_ENTRY_UNLOAD",
|
||||||
|
"MSG_FIRE_EVENT",
|
||||||
|
"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,263 @@
|
|||||||
|
"""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_v2/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_v2/flow_step`` carrying the
|
||||||
|
sandbox's ``flow_id`` and the user input from the framework.
|
||||||
|
4. On ``async_remove`` (framework cleanup) we fire
|
||||||
|
``sandbox_v2/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 .channel import ChannelClosedError, ChannelRemoteError
|
||||||
|
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.
|
||||||
|
payload: dict[str, Any] = {
|
||||||
|
"handler": self._handler_key,
|
||||||
|
"context": dict(self.context),
|
||||||
|
"data": user_input,
|
||||||
|
}
|
||||||
|
result = await channel.call("sandbox_v2/flow_init", payload)
|
||||||
|
self._sandbox_flow_id = result.get("flow_id")
|
||||||
|
else:
|
||||||
|
result = await channel.call(
|
||||||
|
"sandbox_v2/flow_step",
|
||||||
|
{"flow_id": self._sandbox_flow_id, "user_input": user_input},
|
||||||
|
)
|
||||||
|
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: dict[str, Any]) -> 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).
|
||||||
|
"""
|
||||||
|
remote = result.get("context")
|
||||||
|
if not isinstance(remote, dict):
|
||||||
|
return
|
||||||
|
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: dict[str, Any], step_id: str) -> ConfigFlowResult:
|
||||||
|
"""Translate a sandbox-side FlowResult dict 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"])
|
||||||
|
|
||||||
|
if result_type is FlowResultType.CREATE_ENTRY:
|
||||||
|
entry_data = dict(result.get("data") or {})
|
||||||
|
self._terminated = True
|
||||||
|
create_result = self.async_create_entry(
|
||||||
|
title=result.get("title") or self._handler_key,
|
||||||
|
data=entry_data,
|
||||||
|
description=result.get("description"),
|
||||||
|
description_placeholders=result.get("description_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.get("reason", "sandbox_aborted"),
|
||||||
|
description_placeholders=result.get("description_placeholders"),
|
||||||
|
)
|
||||||
|
|
||||||
|
if result_type is FlowResultType.FORM:
|
||||||
|
data_schema = reconstruct_schema(result.get("data_schema"))
|
||||||
|
if data_schema is None and result.get("_has_data_schema"):
|
||||||
|
_LOGGER.debug(
|
||||||
|
"Sandbox %r returned a FORM with an unserialisable"
|
||||||
|
" data_schema; rendering schema-less",
|
||||||
|
self._sandbox_group,
|
||||||
|
)
|
||||||
|
return self.async_show_form(
|
||||||
|
step_id=result.get("step_id", step_id),
|
||||||
|
data_schema=data_schema,
|
||||||
|
errors=result.get("errors") or None,
|
||||||
|
description_placeholders=result.get("description_placeholders"),
|
||||||
|
last_step=result.get("last_step"),
|
||||||
|
preview=result.get("preview"),
|
||||||
|
)
|
||||||
|
|
||||||
|
# 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_v2/flow_abort", {"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,205 @@
|
|||||||
|
"""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_v2/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 .channel import ChannelClosedError, ChannelRemoteError
|
||||||
|
from .classifier import SandboxAssignment, classify
|
||||||
|
from .manager import SandboxManager
|
||||||
|
from .protocol import MSG_ENTRY_SETUP, MSG_ENTRY_UNLOAD
|
||||||
|
from .proxy_flow import SandboxFlowProxy
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
payload = _entry_setup_payload(entry)
|
||||||
|
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.get("ok"):
|
||||||
|
reason = result.get("reason") or "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, {"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 bool(result.get("ok", True))
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
|
||||||
|
def _entry_setup_payload(entry: ConfigEntry) -> dict[str, Any]:
|
||||||
|
"""Build the wire payload for ``sandbox_v2/entry_setup``.
|
||||||
|
|
||||||
|
Surfaces the small subset of entry fields the integration's
|
||||||
|
``async_setup_entry`` reads.
|
||||||
|
"""
|
||||||
|
return {
|
||||||
|
"entry_id": entry.entry_id,
|
||||||
|
"domain": entry.domain,
|
||||||
|
"title": entry.title,
|
||||||
|
"data": dict(entry.data),
|
||||||
|
"options": dict(entry.options),
|
||||||
|
"source": entry.source,
|
||||||
|
"unique_id": entry.unique_id,
|
||||||
|
"version": entry.version,
|
||||||
|
"minor_version": entry.minor_version,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ["SandboxFlowRouter"]
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
"""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.
|
||||||
|
|
||||||
|
The reconstruction is intentionally permissive: the sandbox runs the
|
||||||
|
real validator on the actual call, so main only needs enough structure
|
||||||
|
for forms to render and obviously-broken input to be caught. Unknown
|
||||||
|
field types fall through to a pass-through validator.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from collections.abc import Iterable
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import voluptuous as vol
|
||||||
|
|
||||||
|
_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:
|
||||||
|
"""Best-effort inverse of :func:`voluptuous_serialize.convert` per field."""
|
||||||
|
type_name = entry.get("type")
|
||||||
|
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)
|
||||||
|
# Selectors, expandable sections, constants, datetime/format — 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 v2 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_v2/call_service channel. Those services are
|
||||||
|
# owned by the sandboxed integrations themselves, not by sandbox_v2, 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,3 @@
|
|||||||
|
{
|
||||||
|
"title": "Sandbox v2"
|
||||||
|
}
|
||||||
@@ -44,6 +44,22 @@ type MessageHandler = Callable[[HomeAssistant, ActiveConnection, dict[str, Any]]
|
|||||||
type BinaryHandler = Callable[[HomeAssistant, ActiveConnection, bytes], None]
|
type BinaryHandler = Callable[[HomeAssistant, ActiveConnection, bytes], None]
|
||||||
|
|
||||||
|
|
||||||
|
def _scope_allows(scopes: frozenset[str], type_: str) -> bool:
|
||||||
|
"""Return True if ``type_`` is allowed by the connection's scope set.
|
||||||
|
|
||||||
|
A scope entry ending in ``/`` is a prefix grant
|
||||||
|
(e.g. ``"sandbox_v2/"`` permits any ``sandbox_v2/...`` command).
|
||||||
|
Other entries must match the command type exactly.
|
||||||
|
"""
|
||||||
|
for scope in scopes:
|
||||||
|
if scope.endswith("/"):
|
||||||
|
if type_.startswith(scope):
|
||||||
|
return True
|
||||||
|
elif type_ == scope:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
class ActiveConnection:
|
class ActiveConnection:
|
||||||
"""Handle an active websocket client connection."""
|
"""Handle an active websocket client connection."""
|
||||||
|
|
||||||
@@ -56,6 +72,7 @@ class ActiveConnection:
|
|||||||
"logger",
|
"logger",
|
||||||
"refresh_token_id",
|
"refresh_token_id",
|
||||||
"remote",
|
"remote",
|
||||||
|
"scopes",
|
||||||
"send_message",
|
"send_message",
|
||||||
"subscriptions",
|
"subscriptions",
|
||||||
"supported_features",
|
"supported_features",
|
||||||
@@ -77,6 +94,7 @@ class ActiveConnection:
|
|||||||
self.send_message = send_message
|
self.send_message = send_message
|
||||||
self.user = user
|
self.user = user
|
||||||
self.refresh_token_id = refresh_token.id if refresh_token else None
|
self.refresh_token_id = refresh_token.id if refresh_token else None
|
||||||
|
self.scopes = refresh_token.scopes if refresh_token else None
|
||||||
self.remote = remote
|
self.remote = remote
|
||||||
self.subscriptions: dict[Hashable, Callable[[], Any]] = {}
|
self.subscriptions: dict[Hashable, Callable[[], Any]] = {}
|
||||||
self.last_id = 0
|
self.last_id = 0
|
||||||
@@ -238,6 +256,20 @@ class ActiveConnection:
|
|||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
if (scopes := self.scopes) is not None and not _scope_allows(scopes, type_):
|
||||||
|
self.logger.info(
|
||||||
|
"Rejecting %s — not in connection scope %s", type_, sorted(scopes)
|
||||||
|
)
|
||||||
|
self.send_message(
|
||||||
|
messages.error_message(
|
||||||
|
cur_id,
|
||||||
|
const.ERR_UNAUTHORIZED,
|
||||||
|
f"Command {type_!r} is not in the connection's allowed scope.",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.last_id = cur_id
|
||||||
|
return
|
||||||
|
|
||||||
handler, schema = handler_schema
|
handler, schema = handler_schema
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ from functools import cache
|
|||||||
import logging
|
import logging
|
||||||
from random import randint
|
from random import randint
|
||||||
from types import MappingProxyType
|
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 async_interrupt import interrupt
|
||||||
from propcache.api import cached_property
|
from propcache.api import cached_property
|
||||||
@@ -285,6 +285,7 @@ UPDATE_ENTRY_CONFIG_ENTRY_ATTRS = {
|
|||||||
"pref_disable_polling",
|
"pref_disable_polling",
|
||||||
"minor_version",
|
"minor_version",
|
||||||
"version",
|
"version",
|
||||||
|
"sandbox",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -309,6 +310,7 @@ class ConfigFlowResult(FlowResult[ConfigFlowContext, str], total=False):
|
|||||||
minor_version: int
|
minor_version: int
|
||||||
options: Mapping[str, Any]
|
options: Mapping[str, Any]
|
||||||
result: ConfigEntry
|
result: ConfigEntry
|
||||||
|
sandbox: str
|
||||||
subentries: Iterable[ConfigSubentryData]
|
subentries: Iterable[ConfigSubentryData]
|
||||||
version: int
|
version: int
|
||||||
|
|
||||||
@@ -425,6 +427,7 @@ class ConfigEntry[_DataT = Any]:
|
|||||||
created_at: datetime
|
created_at: datetime
|
||||||
modified_at: datetime
|
modified_at: datetime
|
||||||
discovery_keys: MappingProxyType[str, tuple[DiscoveryKey, ...]]
|
discovery_keys: MappingProxyType[str, tuple[DiscoveryKey, ...]]
|
||||||
|
sandbox: str | None
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
@@ -440,6 +443,7 @@ class ConfigEntry[_DataT = Any]:
|
|||||||
options: Mapping[str, Any] | None,
|
options: Mapping[str, Any] | None,
|
||||||
pref_disable_new_entities: bool | None = None,
|
pref_disable_new_entities: bool | None = None,
|
||||||
pref_disable_polling: bool | None = None,
|
pref_disable_polling: bool | None = None,
|
||||||
|
sandbox: str | None = None,
|
||||||
source: str,
|
source: str,
|
||||||
state: ConfigEntryState = ConfigEntryState.NOT_LOADED,
|
state: ConfigEntryState = ConfigEntryState.NOT_LOADED,
|
||||||
subentries_data: Iterable[ConfigSubentryData | ConfigSubentryDataWithId] | None,
|
subentries_data: Iterable[ConfigSubentryData | ConfigSubentryDataWithId] | None,
|
||||||
@@ -557,6 +561,11 @@ class ConfigEntry[_DataT = Any]:
|
|||||||
_setter(self, "modified_at", modified_at or utcnow())
|
_setter(self, "modified_at", modified_at or utcnow())
|
||||||
_setter(self, "discovery_keys", discovery_keys)
|
_setter(self, "discovery_keys", discovery_keys)
|
||||||
|
|
||||||
|
# Sandbox group this entry belongs to, or None for non-sandboxed
|
||||||
|
# entries. Set by sandbox_v2 at flow completion (CREATE_ENTRY) and
|
||||||
|
# consulted by ConfigEntries.router on every setup/unload.
|
||||||
|
_setter(self, "sandbox", sandbox)
|
||||||
|
|
||||||
def __repr__(self) -> str:
|
def __repr__(self) -> str:
|
||||||
"""Representation of ConfigEntry."""
|
"""Representation of ConfigEntry."""
|
||||||
return (
|
return (
|
||||||
@@ -1189,7 +1198,7 @@ class ConfigEntry[_DataT = Any]:
|
|||||||
|
|
||||||
def as_dict(self) -> dict[str, Any]:
|
def as_dict(self) -> dict[str, Any]:
|
||||||
"""Return dictionary version of this entry."""
|
"""Return dictionary version of this entry."""
|
||||||
return {
|
result: dict[str, Any] = {
|
||||||
"created_at": self.created_at.isoformat(),
|
"created_at": self.created_at.isoformat(),
|
||||||
"data": dict(self.data),
|
"data": dict(self.data),
|
||||||
"discovery_keys": dict(self.discovery_keys),
|
"discovery_keys": dict(self.discovery_keys),
|
||||||
@@ -1207,6 +1216,11 @@ class ConfigEntry[_DataT = Any]:
|
|||||||
"unique_id": self.unique_id,
|
"unique_id": self.unique_id,
|
||||||
"version": self.version,
|
"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
|
@callback
|
||||||
def async_on_unload(
|
def async_on_unload(
|
||||||
@@ -1781,6 +1795,7 @@ class ConfigEntriesFlowManager(
|
|||||||
domain=result["handler"],
|
domain=result["handler"],
|
||||||
minor_version=result["minor_version"],
|
minor_version=result["minor_version"],
|
||||||
options=result["options"],
|
options=result["options"],
|
||||||
|
sandbox=result.get("sandbox"),
|
||||||
source=flow.context["source"],
|
source=flow.context["source"],
|
||||||
subentries_data=result["subentries"],
|
subentries_data=result["subentries"],
|
||||||
title=result["title"],
|
title=result["title"],
|
||||||
@@ -1817,12 +1832,20 @@ class ConfigEntriesFlowManager(
|
|||||||
|
|
||||||
Handler key is the domain of the component that we want to set up.
|
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:
|
if not context or "source" not in context:
|
||||||
raise KeyError("Context not set or doesn't have a source set")
|
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 = handler()
|
||||||
flow.init_step = context["source"]
|
flow.init_step = context["source"]
|
||||||
return flow
|
return flow
|
||||||
@@ -2080,6 +2103,30 @@ class ConfigEntryStore(storage.Store[dict[str, list[dict[str, Any]]]]):
|
|||||||
return data
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigEntryRouter(Protocol):
|
||||||
|
"""Hook protocol for routing config flows and entry setup elsewhere.
|
||||||
|
|
||||||
|
Currently used by `sandbox_v2` 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:
|
class ConfigEntries:
|
||||||
"""Manage the configuration entries.
|
"""Manage the configuration entries.
|
||||||
|
|
||||||
@@ -2095,6 +2142,8 @@ class ConfigEntries:
|
|||||||
self._hass_config = hass_config
|
self._hass_config = hass_config
|
||||||
self._entries = ConfigEntryItems(hass)
|
self._entries = ConfigEntryItems(hass)
|
||||||
self._store = ConfigEntryStore(hass)
|
self._store = ConfigEntryStore(hass)
|
||||||
|
# Optional hook for diverting flows and entry setup (used by sandbox_v2).
|
||||||
|
self.router: ConfigEntryRouter | None = None
|
||||||
EntityRegistryDisabledHandler(hass).async_setup()
|
EntityRegistryDisabledHandler(hass).async_setup()
|
||||||
|
|
||||||
@callback
|
@callback
|
||||||
@@ -2287,6 +2336,8 @@ class ConfigEntries:
|
|||||||
options=entry["options"],
|
options=entry["options"],
|
||||||
pref_disable_new_entities=entry["pref_disable_new_entities"],
|
pref_disable_new_entities=entry["pref_disable_new_entities"],
|
||||||
pref_disable_polling=entry["pref_disable_polling"],
|
pref_disable_polling=entry["pref_disable_polling"],
|
||||||
|
# Optional — pre-Phase-17 entries don't carry this key.
|
||||||
|
sandbox=entry.get("sandbox"),
|
||||||
source=entry["source"],
|
source=entry["source"],
|
||||||
subentries_data=entry["subentries"],
|
subentries_data=entry["subentries"],
|
||||||
title=entry["title"],
|
title=entry["title"],
|
||||||
@@ -2362,6 +2413,11 @@ class ConfigEntries:
|
|||||||
f" be in the {ConfigEntryState.NOT_LOADED} state"
|
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
|
# Setup Component if not set up yet
|
||||||
if entry.domain in self.hass.config.components:
|
if entry.domain in self.hass.config.components:
|
||||||
if _lock:
|
if _lock:
|
||||||
@@ -2393,6 +2449,14 @@ class ConfigEntries:
|
|||||||
f" recoverable state {entry.state}"
|
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:
|
if _lock:
|
||||||
async with entry.setup_lock:
|
async with entry.setup_lock:
|
||||||
return await entry.async_unload(self.hass)
|
return await entry.async_unload(self.hass)
|
||||||
@@ -2493,6 +2557,7 @@ class ConfigEntries:
|
|||||||
options: Mapping[str, Any] | UndefinedType = UNDEFINED,
|
options: Mapping[str, Any] | UndefinedType = UNDEFINED,
|
||||||
pref_disable_new_entities: bool | UndefinedType = UNDEFINED,
|
pref_disable_new_entities: bool | UndefinedType = UNDEFINED,
|
||||||
pref_disable_polling: bool | UndefinedType = UNDEFINED,
|
pref_disable_polling: bool | UndefinedType = UNDEFINED,
|
||||||
|
sandbox: str | None | UndefinedType = UNDEFINED,
|
||||||
title: str | UndefinedType = UNDEFINED,
|
title: str | UndefinedType = UNDEFINED,
|
||||||
unique_id: str | None | UndefinedType = UNDEFINED,
|
unique_id: str | None | UndefinedType = UNDEFINED,
|
||||||
version: int | UndefinedType = UNDEFINED,
|
version: int | UndefinedType = UNDEFINED,
|
||||||
@@ -2513,6 +2578,7 @@ class ConfigEntries:
|
|||||||
options=options,
|
options=options,
|
||||||
pref_disable_new_entities=pref_disable_new_entities,
|
pref_disable_new_entities=pref_disable_new_entities,
|
||||||
pref_disable_polling=pref_disable_polling,
|
pref_disable_polling=pref_disable_polling,
|
||||||
|
sandbox=sandbox,
|
||||||
title=title,
|
title=title,
|
||||||
unique_id=unique_id,
|
unique_id=unique_id,
|
||||||
version=version,
|
version=version,
|
||||||
@@ -2531,6 +2597,7 @@ class ConfigEntries:
|
|||||||
options: Mapping[str, Any] | UndefinedType = UNDEFINED,
|
options: Mapping[str, Any] | UndefinedType = UNDEFINED,
|
||||||
pref_disable_new_entities: bool | UndefinedType = UNDEFINED,
|
pref_disable_new_entities: bool | UndefinedType = UNDEFINED,
|
||||||
pref_disable_polling: bool | UndefinedType = UNDEFINED,
|
pref_disable_polling: bool | UndefinedType = UNDEFINED,
|
||||||
|
sandbox: str | None | UndefinedType = UNDEFINED,
|
||||||
subentries: dict[str, ConfigSubentry] | UndefinedType = UNDEFINED,
|
subentries: dict[str, ConfigSubentry] | UndefinedType = UNDEFINED,
|
||||||
title: str | UndefinedType = UNDEFINED,
|
title: str | UndefinedType = UNDEFINED,
|
||||||
unique_id: str | None | UndefinedType = UNDEFINED,
|
unique_id: str | None | UndefinedType = UNDEFINED,
|
||||||
@@ -2581,6 +2648,7 @@ class ConfigEntries:
|
|||||||
("minor_version", minor_version),
|
("minor_version", minor_version),
|
||||||
("pref_disable_new_entities", pref_disable_new_entities),
|
("pref_disable_new_entities", pref_disable_new_entities),
|
||||||
("pref_disable_polling", pref_disable_polling),
|
("pref_disable_polling", pref_disable_polling),
|
||||||
|
("sandbox", sandbox),
|
||||||
("title", title),
|
("title", title),
|
||||||
("version", version),
|
("version", version),
|
||||||
):
|
):
|
||||||
|
|||||||
Generated
+1
@@ -643,6 +643,7 @@ FLOWS = {
|
|||||||
"sabnzbd",
|
"sabnzbd",
|
||||||
"samsung_infrared",
|
"samsung_infrared",
|
||||||
"samsungtv",
|
"samsungtv",
|
||||||
|
"sandbox",
|
||||||
"sanix",
|
"sanix",
|
||||||
"satel_integra",
|
"satel_integra",
|
||||||
"saunum",
|
"saunum",
|
||||||
|
|||||||
@@ -203,6 +203,26 @@ class EntityComponent[_EntityT: entity.Entity = entity.Entity]:
|
|||||||
await platform.async_reset()
|
await platform.async_reset()
|
||||||
return True
|
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_v2`` 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(
|
async def async_extract_from_service(
|
||||||
self, service_call: ServiceCall, expand_group: bool = True
|
self, service_call: ServiceCall, expand_group: bool = True
|
||||||
) -> list[_EntityT]:
|
) -> list[_EntityT]:
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
# Home Assistant Sandbox
|
||||||
|
|
||||||
|
This project implements a sandbox system for Home Assistant, allowing integrations to run in isolated processes that connect back to a real HA instance.
|
||||||
|
|
||||||
|
All sandbox-specific code and docs live under this directory (`core/sandbox/`) on the `sandbox` branch of the core checkout. The only piece outside this directory is the HA Core integration itself at `homeassistant/components/sandbox/`, which has to live there for HA's integration loader to find it.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
See [OVERVIEW.md](OVERVIEW.md) for the full architecture document.
|
||||||
|
|
||||||
|
## Repository Layout
|
||||||
|
|
||||||
|
This directory (`core/sandbox/`) holds everything sandbox-related:
|
||||||
|
|
||||||
|
- `hass_client/` — Client library that provides `RemoteHomeAssistant`, a HA subclass connected to a real HA via websocket. Extended with sandbox mode for running integrations out-of-process. Brought in as a git subtree from `balloob-travel/hass-client`.
|
||||||
|
- `ARCHITECTURE.md`, `OVERVIEW.md` — design docs.
|
||||||
|
- `analyze_failures.py`, `run_all_sandbox_tests.py`, `TEST_RESULTS.csv` — test driver and results for running HA Core's integration test suites through the sandbox.
|
||||||
|
- `architecture.html` — rendered architecture diagram (publishable via `gh gist create`, see below).
|
||||||
|
|
||||||
|
The HA Core integration lives at `../homeassistant/components/sandbox/` (one level up).
|
||||||
|
|
||||||
|
## Key Concepts
|
||||||
|
|
||||||
|
- **Sandbox integration** (`../homeassistant/components/sandbox/`): HA Core integration that manages sandboxed config entries, creates auth tokens, spawns sandbox processes, and exposes a websocket API for sandbox clients.
|
||||||
|
- **Sandbox token**: A system-generated auth token scoped to a specific sandbox instance. Only connections with a sandbox token can access the `sandbox/*` websocket commands.
|
||||||
|
- **Sandbox client** (`hass_client/hass_client/sandbox.py`): Extends `RemoteHomeAssistant` to fetch config entries from the sandbox API, set up integrations locally, and push entities/state back to HA Core.
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
`hass_client/` has its own Python environment (managed with `uv`). It depends on HA Core packages, and `hass_client/pyproject.toml` uses `[tool.uv.sources]` to link `homeassistant` to the surrounding core checkout (`../..`).
|
||||||
|
|
||||||
|
To run the sandbox client:
|
||||||
|
```
|
||||||
|
python -m hass_client.sandbox --url ws://localhost:8123/api/websocket --token <sandbox_token>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
### Running core integration tests through the sandbox
|
||||||
|
|
||||||
|
Two pytest plugins let us run HA Core's own test suites against the sandbox infrastructure:
|
||||||
|
|
||||||
|
1. **Base plugin** (`-p hass_client.testing.pytest_plugin`): Replaces `HomeAssistant` with `RemoteHomeAssistant` as a drop-in. No real websocket — tests the client library's compatibility layer. Fast but doesn't exercise the real network path.
|
||||||
|
|
||||||
|
2. **Sandbox plugin** (`-p hass_client.testing.conftest_sandbox`): Boots a host HA Core with `websocket_api` + `sandbox`, starts a real aiohttp test server, creates a sandbox auth token, and connects the sandbox `RemoteHomeAssistant` to it via a live websocket. Tests run exactly as they would in a real sandbox deployment.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd core/sandbox/hass_client
|
||||||
|
# Run a single integration
|
||||||
|
uv run python -m pytest -p hass_client.testing.conftest_sandbox \
|
||||||
|
../../tests/components/input_boolean/test_init.py -v
|
||||||
|
|
||||||
|
# Run all passing integrations
|
||||||
|
uv run python -m pytest -p hass_client.testing.conftest_sandbox \
|
||||||
|
../../tests/components/{input_boolean,automation,script,scene,todo,group}/test_init.py
|
||||||
|
```
|
||||||
|
|
||||||
|
See `hass_client/SANDBOX_COMPAT.md` for the full compatibility report (33 integrations, 99.8% pass rate).
|
||||||
|
|
||||||
|
### Key test infrastructure details
|
||||||
|
|
||||||
|
- **Socket bypass**: Core's `pytest-socket` blocks real sockets. The sandbox plugin saves the real socket class at configure time and restores it during the sandbox context manager.
|
||||||
|
- **Freezer fallback**: Tests using `freezer.move_to()` (pytest-freezer) hang with live websocket connections. The sandbox plugin detects the `freezer` fixture and falls back to the base plugin for those tests.
|
||||||
|
- **Host HA lifecycle**: The sandbox plugin creates two HA instances per test (host + sandbox). The host is explicitly stopped in teardown to cancel its timers and prevent `verify_cleanup` errors.
|
||||||
|
- **HybridServiceRegistry**: `RemoteHomeAssistant` uses `HybridServiceRegistry` which tries local services first, then falls back to remote. The fallback only triggers for services that exist in the remote service cache.
|
||||||
|
|
||||||
|
## Publishing HTML Files
|
||||||
|
|
||||||
|
Upload an HTML file as a private GitHub Gist, then it's viewable at `https://gisthost.github.io/?<GIST_ID>`.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
gh gist create architecture.html
|
||||||
|
# Returns gist ID → site is at https://gisthost.github.io/?<ID>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Current State
|
||||||
|
|
||||||
|
33 integrations pass through the real sandbox websocket (878/880 tests). See `hass_client/SANDBOX_COMPAT.md` for details.
|
||||||
@@ -0,0 +1,256 @@
|
|||||||
|
# Home Assistant Sandbox Architecture
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Run built-in Home Assistant integrations in an isolated sandbox process that connects back to a real Home Assistant instance. Entities created in the sandbox appear in the real HA, and service calls are forwarded transparently.
|
||||||
|
|
||||||
|
## High-Level Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────┐
|
||||||
|
│ Home Assistant Core │
|
||||||
|
│ │
|
||||||
|
│ ┌─────────────────────────────────┐ │
|
||||||
|
│ │ sandbox integration │ │
|
||||||
|
│ │ │ │
|
||||||
|
│ │ • finds config entries marked │ │
|
||||||
|
│ │ for sandbox execution │ │
|
||||||
|
│ │ • creates auth tokens per │ │
|
||||||
|
│ │ sandbox instance │ │
|
||||||
|
│ │ • spawns sandbox processes │ │
|
||||||
|
│ │ • exposes websocket API: │ │
|
||||||
|
│ │ sandbox/get_entries │ │
|
||||||
|
│ │ sandbox/register_device │ │
|
||||||
|
│ │ sandbox/register_entity │ │
|
||||||
|
│ │ sandbox/update_state │ │
|
||||||
|
│ └──────────┬──────────────────────┘ │
|
||||||
|
│ │ websocket (sandbox token) │
|
||||||
|
└─────────────┼───────────────────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌─────────────────────────────────────────┐
|
||||||
|
│ Sandbox Process │
|
||||||
|
│ (hass-client) │
|
||||||
|
│ │
|
||||||
|
│ RemoteHomeAssistant subclass that: │
|
||||||
|
│ 1. Connects to HA Core with sandbox │
|
||||||
|
│ token │
|
||||||
|
│ 2. Calls sandbox/get_entries to learn │
|
||||||
|
│ which config entries to represent │
|
||||||
|
│ 3. Sets up the integration locally │
|
||||||
|
│ 4. Registers entities/devices back to │
|
||||||
|
│ HA Core via sandbox API │
|
||||||
|
│ 5. Pushes state updates to HA Core │
|
||||||
|
└─────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## Startup Sequence
|
||||||
|
|
||||||
|
### 1. Host HA startup
|
||||||
|
|
||||||
|
During config entry loading, the host checks each entry for a sandbox marker:
|
||||||
|
|
||||||
|
```
|
||||||
|
config_entry.options["sandbox"] = "<sandbox_id>"
|
||||||
|
```
|
||||||
|
|
||||||
|
Any entry marked with a sandbox ID is **not** set up normally. Instead:
|
||||||
|
|
||||||
|
1. The sandbox integration is loaded (if not already).
|
||||||
|
2. The sandbox integration collects all entries grouped by sandbox ID.
|
||||||
|
3. For each sandbox ID, it:
|
||||||
|
- Creates a system user and authorization token scoped to that sandbox.
|
||||||
|
- Starts a sandbox subprocess, passing the token and host websocket URL.
|
||||||
|
- Tracks which config entry IDs belong to which sandbox connection.
|
||||||
|
|
||||||
|
### 2. Sandbox process startup
|
||||||
|
|
||||||
|
The sandbox process:
|
||||||
|
|
||||||
|
1. Connects to the host via websocket using the sandbox token.
|
||||||
|
2. Reads core config (timezone, units, location) and applies it to the local `hass` object (`dt_util`, `hass.config`).
|
||||||
|
3. Fetches its assigned config entries via `sandbox/get_entries`.
|
||||||
|
4. Sets up each config entry using `async_setup_entry` — the integration runs normally.
|
||||||
|
|
||||||
|
### 3. Entity platform setup (sandbox side)
|
||||||
|
|
||||||
|
When an integration calls `async_add_entities(entities)` inside a sandbox, the platform is a `RemoteClientEntityPlatform`. Instead of registering entities locally only, it:
|
||||||
|
|
||||||
|
1. For each entity, sends a registration to the host: `unique_id`, `entity_id` suggestion, `device_info`, platform capabilities (`supported_features`, `supported_color_modes`, `device_class`, …), entity category, icon, name.
|
||||||
|
2. Receives back from the host the confirmed host-assigned `entity_id` and `device_id`. (The host owns both registries; the sandbox must use the host's IDs.)
|
||||||
|
3. Sets up state forwarding so every `async_write_ha_state()` pushes state + attributes to the host.
|
||||||
|
|
||||||
|
### 4. Entity platform setup (host side)
|
||||||
|
|
||||||
|
When the sandbox integration receives entity registrations on the host, it:
|
||||||
|
|
||||||
|
1. Creates/updates device registry entries.
|
||||||
|
2. Creates/updates entity registry entries.
|
||||||
|
3. For each domain that has entities from this sandbox, ensures a `RemoteHostEntityPlatform` is registered with the domain's `EntityComponent` and adds the appropriate `RemoteEntity` subclass (e.g., `RemoteLightEntity`) via `async_add_entities`.
|
||||||
|
4. The proxy entity holds cached state and attributes, forwards service calls back to the sandbox via websocket, and reports availability based on sandbox connection status.
|
||||||
|
|
||||||
|
## Components
|
||||||
|
|
||||||
|
### 1. Sandbox Integration (HA Core side)
|
||||||
|
|
||||||
|
Lives at `core/homeassistant/components/sandbox/`.
|
||||||
|
|
||||||
|
**Config entries**: Config entries whose `options` contain `"sandbox": "<group_name>"` (a string value) are collected into sandbox groups. Entries sharing the same string run in the same sandbox process. On startup, the sandbox integration:
|
||||||
|
|
||||||
|
1. Queries all config entries where `options.sandbox` matches the group name (or uses the explicit entries list from `entry.data["entries"]` for testing).
|
||||||
|
2. For each sandbox group, creates a system user + refresh token.
|
||||||
|
3. Spawns a subprocess running the sandbox client, passing the access token.
|
||||||
|
|
||||||
|
**Websocket API** (guarded by sandbox tokens — only connections authenticated with a sandbox token can call these):
|
||||||
|
|
||||||
|
| Command | Description |
|
||||||
|
|---|---|
|
||||||
|
| `sandbox/get_entries` | Returns the config entry data assigned to this sandbox token |
|
||||||
|
| `sandbox/register_device` | Creates a device registry entry in HA Core |
|
||||||
|
| `sandbox/register_entity` | Creates an entity registry entry in HA Core |
|
||||||
|
| `sandbox/update_state` | Sets entity state in HA Core (like `hass.states.async_set`) |
|
||||||
|
| `sandbox/fire_event` | Fires an event on the HA Core bus |
|
||||||
|
|
||||||
|
Each command validates `connection.refresh_token_id` against the set of registered sandbox tokens before processing.
|
||||||
|
|
||||||
|
### 2. Sandbox Client (hass-client side)
|
||||||
|
|
||||||
|
Lives at `hass-client/hass_client/sandbox.py` with CLI at `hass-client/sandbox_runner.py`.
|
||||||
|
|
||||||
|
Extends `RemoteHomeAssistant` with sandbox-specific behavior:
|
||||||
|
|
||||||
|
1. **Bootstrap**: Connects to HA Core websocket using the sandbox token.
|
||||||
|
2. **Config fetch**: Calls `sandbox/get_entries` to get assigned config entries.
|
||||||
|
3. **Integration setup**: For each config entry, loads the integration's `async_setup_entry` (or `async_setup` for collection-based integrations like input helpers) and runs it.
|
||||||
|
4. **Entity bridge**: When local entities write state, intercepts and pushes to HA Core via `sandbox/update_state`. Registers entities/devices via the sandbox API.
|
||||||
|
|
||||||
|
### 3. Token System
|
||||||
|
|
||||||
|
For now, tokens are created dynamically at startup:
|
||||||
|
|
||||||
|
1. Sandbox integration creates a system user: `await hass.auth.async_create_system_user("Sandbox <entry_id>")`
|
||||||
|
2. Creates a refresh token: `await hass.auth.async_create_refresh_token(user)`
|
||||||
|
3. Creates an access token: `hass.auth.async_create_access_token(refresh_token)`
|
||||||
|
4. Stores mapping: `refresh_token.id → [config_entry_ids]`
|
||||||
|
5. Passes access token to the spawned sandbox process.
|
||||||
|
|
||||||
|
## Service Call Flow
|
||||||
|
|
||||||
|
`RemoteHomeAssistant` uses `HybridServiceRegistry` which provides local-first service resolution with remote fallback:
|
||||||
|
|
||||||
|
1. Service call arrives (e.g., `input_boolean.turn_on`)
|
||||||
|
2. Try local registry first (integration loaded in sandbox)
|
||||||
|
3. If `ServiceNotFound` locally, check if the service exists in the remote cache
|
||||||
|
4. If it exists remotely, forward via websocket to HA Core
|
||||||
|
5. Fire `EVENT_CALL_SERVICE` locally for event listeners
|
||||||
|
|
||||||
|
This allows sandbox integrations to call services on other integrations running in HA Core, while keeping local services fast.
|
||||||
|
|
||||||
|
## Test Infrastructure
|
||||||
|
|
||||||
|
Two pytest plugins validate compatibility by running HA Core's own test suites:
|
||||||
|
|
||||||
|
### Base Plugin (`hass_client.testing.pytest_plugin`)
|
||||||
|
|
||||||
|
Replaces `HomeAssistant` with `RemoteHomeAssistant` as a drop-in. No real websocket — validates the client library's API compatibility.
|
||||||
|
|
||||||
|
### Sandbox Plugin (`hass_client.testing.conftest_sandbox`)
|
||||||
|
|
||||||
|
Full end-to-end: boots a host HA Core with websocket_api + sandbox, starts a real aiohttp test server, creates a sandbox auth token, and connects the sandbox RemoteHomeAssistant via live websocket. Each test gets a fresh host + sandbox pair.
|
||||||
|
|
||||||
|
Key mechanisms:
|
||||||
|
- **Socket bypass**: Saves real socket before pytest-socket blocks it, restores during sandbox setup
|
||||||
|
- **Freezer detection**: Falls back to base plugin for tests using `freezer.move_to()` (time jumps hang live connections)
|
||||||
|
- **Dual-instance lifecycle**: Host HA is explicitly stopped in teardown to cancel its timers
|
||||||
|
|
||||||
|
### Compatibility Status
|
||||||
|
|
||||||
|
33 integrations tested through real sandbox websocket: 878/880 tests pass (99.8%). Includes input helpers, automation, script, scene, todo, group, recorder, and many sensor/helper platforms. See `hass-client/SANDBOX_COMPAT.md` for the full report.
|
||||||
|
|
||||||
|
## Entity Platform Architecture
|
||||||
|
|
||||||
|
### Host side: RemoteHostEntityPlatform
|
||||||
|
|
||||||
|
When a sandbox registers entities via `sandbox/register_entity`, the host creates a `RemoteHostEntityPlatform` instance (if one doesn't exist for that domain) and adds it directly to the domain's `EntityComponent._platforms`. This platform manages **proxy entities** — real HA entity instances that:
|
||||||
|
|
||||||
|
- Live in the host's entity/device/area registries (enabling targeting)
|
||||||
|
- Cache state pushed from the sandbox via `sandbox/update_state`
|
||||||
|
- Forward service calls (turn_on, activate, etc.) back to the sandbox via a websocket subscription
|
||||||
|
|
||||||
|
The proxy entity classes live in `entity/` (one file per platform, 32 supported domains). `RemoteHostEntityPlatform` replaces the previous approach of 32 identical per-domain platform setup files.
|
||||||
|
|
||||||
|
A typical proxy looks like:
|
||||||
|
|
||||||
|
```python
|
||||||
|
class RemoteLightEntity(LightEntity):
|
||||||
|
"""Proxy for a light entity living in a sandbox."""
|
||||||
|
|
||||||
|
def __init__(self, sandbox_connection, registration_data):
|
||||||
|
self._sandbox = sandbox_connection
|
||||||
|
self._attr_unique_id = registration_data["unique_id"]
|
||||||
|
self._attr_supported_color_modes = registration_data["supported_color_modes"]
|
||||||
|
self._attr_supported_features = registration_data["supported_features"]
|
||||||
|
# ... all static capabilities from registration
|
||||||
|
|
||||||
|
@property
|
||||||
|
def available(self) -> bool:
|
||||||
|
return self._sandbox.connected and self._remote_available
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_on(self) -> bool:
|
||||||
|
return self._state_cache["is_on"]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def brightness(self) -> int | None:
|
||||||
|
return self._state_cache.get("brightness")
|
||||||
|
|
||||||
|
async def async_turn_on(self, **kwargs) -> None:
|
||||||
|
await self._sandbox.forward_service_call(
|
||||||
|
self.entity_id, "turn_on", kwargs
|
||||||
|
)
|
||||||
|
|
||||||
|
async def async_turn_off(self, **kwargs) -> None:
|
||||||
|
await self._sandbox.forward_service_call(
|
||||||
|
self.entity_id, "turn_off", kwargs
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
Service handlers read entity properties synchronously during async service execution (e.g., `light` reads `supported_color_modes` to filter parameters before calling `async_turn_on`), so the proxy keeps both **static** properties (set at registration: `supported_features`, `supported_color_modes`, color-temp range, `device_class`, `entity_category`) and **dynamic** properties (`is_on`, `brightness`, `hs_color`, `color_temp_kelvin`, `effect`, …) cached locally. State updates from the sandbox push both the entity state and all relevant attributes.
|
||||||
|
|
||||||
|
### Sandbox side: RemoteClientEntityPlatform
|
||||||
|
|
||||||
|
On the sandbox side, `RemoteClientEntityPlatform` wraps the integration's `EntityPlatform` to intercept `async_add_entities`. When an integration adds entities:
|
||||||
|
|
||||||
|
1. Entities are added locally as normal (so they work in the sandbox)
|
||||||
|
2. Each entity is registered with the host via `sandbox/register_entity`
|
||||||
|
3. State changes are forwarded to the host via `sandbox/update_state`
|
||||||
|
4. Method calls from the host are dispatched to local entities
|
||||||
|
|
||||||
|
### Supported platforms (32)
|
||||||
|
|
||||||
|
`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`
|
||||||
|
|
||||||
|
### Service call flow
|
||||||
|
|
||||||
|
1. User calls `light.turn_on` targeting a sandbox proxy entity
|
||||||
|
2. HA's service handler invokes `async_turn_on` on the proxy
|
||||||
|
3. Proxy sends command via `send_command` → websocket subscription event
|
||||||
|
4. Sandbox receives the event, executes the method on the real entity
|
||||||
|
5. Sandbox sends `sandbox/entity_command_result` back
|
||||||
|
6. Proxy's future resolves, service call completes
|
||||||
|
|
||||||
|
## Entity Method Compatibility
|
||||||
|
|
||||||
|
Most entity domains already expose `async_*` versions of every service-callable method. Service handlers call those async wrappers, which is exactly what the remote proxies need — the proxy implements the `async_*` methods and forwards the call. No sync-to-async conversion required.
|
||||||
|
|
||||||
|
- **Already fully async** (no changes needed for proxy): `light`, `switch`, `select`, `media_player`, `vacuum`, `camera`, `tts`, `stt`, `todo`, `number`, `button` (`async_press`).
|
||||||
|
- **Sync + async-wrapper pattern** (proxy implements async, works as-is): `climate`, `cover`, `fan`, `lock`, `alarm_control_panel`, `valve`, `water_heater`, `humidifier`, `siren`, `lawn_mower`, `remote`.
|
||||||
|
- **Minor issues**: `cover.toggle` and `cover.toggle_tilt` are called directly without async wrappers in some code paths; they need async versions added.
|
||||||
|
|
||||||
|
## Known Limitations / Future Work
|
||||||
|
|
||||||
|
- **YAML-only integrations**: Not supported in sandbox. We are only interested in config-entry-based integrations. YAML integrations that don't use config entries are out of scope.
|
||||||
|
- **Shutdown / graceful teardown**: When HA Core is shutting down, it should send a shutdown command to each sandbox process. The sandbox should collect restore-state data from its entities and push it back to the host before exiting. The host owns the restore state storage. Not yet implemented.
|
||||||
|
- **Store persistence**: Integrations use `Store` objects for persistent data (e.g., token caches, device databases). These stores should be routed through the sandbox websocket so the host owns and persists them. The sandbox should call a `sandbox/store_save` command when writing, and `sandbox/store_load` on startup. This keeps all persistent state on the host filesystem. Not yet implemented.
|
||||||
|
- **Custom integrations**: Future goal. Current focus is built-in integrations only.
|
||||||
|
- **Logbook platform discovery**: The logbook integration's platform loading doesn't find automation/script logbook callbacks in the sandbox environment. Low priority — cosmetic only.
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
# Home Assistant Sandbox
|
||||||
|
|
||||||
|
Run Home Assistant integrations in isolated subprocesses that connect back to a real HA instance over websocket. The host owns the entity/device registries, areas, and service routing; sandboxed integrations are unaware they're sandboxed.
|
||||||
|
|
||||||
|
This directory is the home for all sandbox-related code and docs. It lives on the `sandbox` branch of the [home-assistant/core](https://github.com/home-assistant/core) checkout, alongside the HA Core integration at `../homeassistant/components/sandbox/`.
|
||||||
|
|
||||||
|
## Layout
|
||||||
|
|
||||||
|
- `hass_client/` — client library (`RemoteHomeAssistant`) plus the sandbox runtime, brought in as a git subtree from [balloob-travel/hass-client](https://github.com/balloob-travel/hass-client).
|
||||||
|
- `OVERVIEW.md` — architecture prose: principles, startup sequence, components, service call flow, entity proxy design, method compatibility, limitations.
|
||||||
|
- `architecture.html` — visual companion with system diagram, flow diagrams, file structure, websocket API, and test results. Publish via `gh gist create architecture.html` and view at `https://gisthost.github.io/?<gist_id>`.
|
||||||
|
- `run_all_sandbox_tests.py` + `analyze_failures.py` + `TEST_RESULTS.csv` — driver and results for running HA Core's per-integration test suites through the sandbox plugin.
|
||||||
|
- The HA integration itself is at [`../homeassistant/components/sandbox/`](../homeassistant/components/sandbox/).
|
||||||
|
|
||||||
|
## Quick start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd core/sandbox/hass_client
|
||||||
|
uv sync
|
||||||
|
|
||||||
|
# Connect a sandbox client to a running HA instance
|
||||||
|
uv run python -m hass_client.sandbox \
|
||||||
|
--url ws://localhost:8123/api/websocket \
|
||||||
|
--token <sandbox_token>
|
||||||
|
```
|
||||||
|
|
||||||
|
The `<sandbox_token>` is issued by the host HA when a config entry is marked `options["sandbox"] = "<sandbox_id>"`. The sandbox integration spawns the subprocess and injects the token automatically — you only need to run the client by hand for debugging.
|
||||||
|
|
||||||
|
## Running HA Core's tests through the sandbox
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd core/sandbox/hass_client
|
||||||
|
|
||||||
|
# A single integration
|
||||||
|
uv run python -m pytest -p hass_client.testing.conftest_sandbox \
|
||||||
|
../../tests/components/input_boolean/test_init.py -v
|
||||||
|
|
||||||
|
# All currently-passing integrations
|
||||||
|
uv run python -m pytest -p hass_client.testing.conftest_sandbox \
|
||||||
|
../../tests/components/{input_boolean,automation,script,scene,todo,group}/test_init.py
|
||||||
|
```
|
||||||
|
|
||||||
|
See [`hass_client/SANDBOX_COMPAT.md`](hass_client/SANDBOX_COMPAT.md) for the full compatibility report (33 integrations, 878/880 tests, 99.8% pass rate).
|
||||||
|
|
||||||
|
Two pytest plugins are available:
|
||||||
|
|
||||||
|
- `hass_client.testing.pytest_plugin` — drop-in `RemoteHomeAssistant`, no websocket. Fast compatibility check.
|
||||||
|
- `hass_client.testing.conftest_sandbox` — full path: host HA + aiohttp test server + sandbox token + live websocket. Exercises the real deployment.
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
33 integrations pass end-to-end through the live websocket. Detailed breakdown in [`hass_client/SANDBOX_COMPAT.md`](hass_client/SANDBOX_COMPAT.md); per-test results in [`TEST_RESULTS.csv`](TEST_RESULTS.csv).
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
integration,passed,failed,errors,total,status,category,reason
|
||||||
|
api,53,2,11,66,issues,error_type_lost,Service call errors - response type lost through WS (2f/11e)
|
||||||
|
automation,108,4,83,195,issues,context_and_reload,Context (1) + reload (2) + trigger (1) + teardown errors (83)
|
||||||
|
backup,13,3,0,16,issues,auth_bypass,Sandbox token bypasses admin check - DID NOT RAISE Unauthorized (3 tests)
|
||||||
|
blue_current,7,1,0,8,issues,config_entry_state,ValueError during button platform setup (1 test)
|
||||||
|
blueprint,0,0,0,0,no_tests,no_tests,No test_init.py tests
|
||||||
|
bluetooth,0,0,0,0,no_tests,no_tests,No test_init.py tests
|
||||||
|
calendar,46,21,0,67,issues,error_type_lost,"Expects ServiceNotSupported, gets HomeAssistantError (21 tests)"
|
||||||
|
climate,7,4,2,13,issues,error_type_lost,ServiceValidationError message format differs; translation_domain lost (4f/2e)
|
||||||
|
cloud,9,1,0,10,issues,auth_bypass,Sandbox token bypasses admin check (1 test)
|
||||||
|
configurator,7,1,0,8,issues,auth_bypass,Sandbox token bypasses admin check (1 test)
|
||||||
|
conversation,0,0,0,0,timeout,timeout,Hangs - complex async setup
|
||||||
|
counter,16,2,0,18,issues,error_type_lost,HomeAssistantError instead of ValueError + context (2 tests)
|
||||||
|
debugpy,0,0,0,0,timeout,timeout,Hangs - debugger attachment
|
||||||
|
demo,0,0,0,0,timeout,timeout,Hangs - large integration with many platforms
|
||||||
|
device_automation,0,0,0,0,timeout,timeout,Hangs - complex automation/device setup
|
||||||
|
device_tracker,24,2,1,27,issues,yaml_config,YAML-based device tracker config not supported (2f/1e)
|
||||||
|
devolo_home_network,0,0,0,0,timeout,timeout,Hangs - network device setup
|
||||||
|
dialogflow,17,0,2,19,issues,teardown,Teardown errors (2)
|
||||||
|
directv,0,2,0,2,issues,config_entry_state,Config entry state mismatch (2 tests)
|
||||||
|
duckdns,7,4,0,11,issues,error_type_lost,ServiceValidationError.translation_key is None (4 tests)
|
||||||
|
dynalite,3,1,0,4,issues,other,Test-specific issue (1 test)
|
||||||
|
fan,9,1,0,10,issues,error_type_lost,"Expects ServiceValidationError, gets HomeAssistantError (1 test)"
|
||||||
|
ffmpeg,0,0,0,0,timeout,timeout,Hangs - binary process interaction
|
||||||
|
file,2,0,2,4,issues,teardown,Teardown errors (2)
|
||||||
|
flume,3,2,0,5,issues,target_config_entry,target.config_entry not supported in WS call_service schema (2 tests)
|
||||||
|
frontend,58,4,0,62,issues,reload,Theme reload/YAML-based services not supported (4 tests)
|
||||||
|
google,31,26,0,57,issues,error_type_lost,"Expects ServiceNotSupported, gets HomeAssistantError (26 tests)"
|
||||||
|
google_assistant,2,1,0,3,issues,other,OAuth/token handling issue (1 test)
|
||||||
|
google_assistant_sdk,14,3,0,17,issues,error_type_lost,ServiceValidationError.translation_key is None (3 tests)
|
||||||
|
google_photos,7,0,7,14,issues,teardown,All 7 errors are fixture teardown failures
|
||||||
|
google_pubsub,3,0,12,15,issues,teardown,All 12 errors are fixture teardown failures
|
||||||
|
google_sheets,16,3,0,19,issues,error_type_lost,ServiceValidationError with missing translation metadata (3 tests)
|
||||||
|
group,0,0,0,0,timeout,timeout,Hangs - complex entity group setup
|
||||||
|
hassio,79,5,0,84,issues,host_specific,Tests require supervisor connection (5 failures)
|
||||||
|
hdmi_cec,9,29,0,38,issues,service_not_registered,Services registered locally but not on host (29 tests)
|
||||||
|
homeassistant,36,3,1,40,issues,auth_bypass,Admin check bypass + reload not supported (3f/1e)
|
||||||
|
homeassistant_connect_zbt2,6,1,0,7,issues,config_entry_state,Config entry loads when test expects SETUP_RETRY (1 test)
|
||||||
|
homeassistant_sky_connect,7,1,0,8,issues,config_entry_state,Config entry loads when test expects SETUP_RETRY (1 test)
|
||||||
|
homematic,1,1,0,2,issues,auth_bypass,Sandbox token bypasses admin check (1 test)
|
||||||
|
humidifier,2,1,0,3,issues,error_type_lost,ServiceValidationError.translation_key is None (1 test)
|
||||||
|
imap,124,2,0,126,issues,error_type_lost,ServiceValidationError.translation_key is None (2 tests)
|
||||||
|
infrared,21,2,0,23,issues,error_type_lost,Error type/message lost (2 tests)
|
||||||
|
input_boolean,15,1,0,16,issues,context,Context not preserved (1 test)
|
||||||
|
input_button,13,1,0,14,issues,context,Context not preserved (1 test)
|
||||||
|
input_datetime,24,2,0,26,issues,context_and_reload,Context (1) + reload (1)
|
||||||
|
input_number,20,2,0,22,issues,context_and_reload,Context (1) + reload (1)
|
||||||
|
input_select,22,2,0,24,issues,context_and_reload,Context (1) + reload (1)
|
||||||
|
input_text,19,2,0,21,issues,context_and_reload,Context (1) + reload (1)
|
||||||
|
intent_script,8,0,6,14,issues,reload,YAML reload + service execution issues (6 errors)
|
||||||
|
kitchen_sink,8,1,8,17,issues,teardown,Service validation (1f) + teardown errors (8e)
|
||||||
|
knx,10,0,8,18,issues,teardown,All 8 errors are fixture teardown failures
|
||||||
|
lametric,4,0,4,8,issues,async_timeout,Config entry setup/teardown times out (4 errors)
|
||||||
|
light,63,3,0,66,issues,error_type_lost,ServiceValidationError type/context lost + context test (3 tests)
|
||||||
|
local_file,2,0,1,3,issues,teardown,Teardown error (1)
|
||||||
|
lock,10,1,0,11,issues,error_type_lost,ServiceValidationError message text differs (1 test)
|
||||||
|
logger,8,2,0,10,issues,auth_bypass,Sandbox token bypasses admin check (2 tests)
|
||||||
|
lojack,8,1,0,9,issues,config_entry_state,Unexpected entities created (1 test)
|
||||||
|
media_extractor,0,0,0,0,timeout,timeout,Hangs - media processing
|
||||||
|
media_player,33,2,0,35,issues,error_type_lost,ServiceValidationError type lost through websocket (2 tests)
|
||||||
|
microsoft_face,10,0,2,12,issues,teardown,Teardown errors (2)
|
||||||
|
mikrotik,0,0,0,0,timeout,timeout,Hangs - network device polling
|
||||||
|
mobile_app,16,0,1,17,issues,teardown,Teardown error (1)
|
||||||
|
modern_forms,1,2,0,3,issues,config_entry_state,async_timeout compat + config entry state (2 tests)
|
||||||
|
network,14,6,0,20,issues,host_specific,Tests inspect host network interfaces (6 failures)
|
||||||
|
ntfy,9,0,9,18,issues,async_timeout,Config entry setup times out (9 errors)
|
||||||
|
number,39,1,0,40,issues,error_type_lost,ServiceValidationError.translation_domain is None (1 test)
|
||||||
|
onedrive,20,0,20,40,issues,teardown,All 20 errors are fixture teardown failures
|
||||||
|
opentherm_gw,2,0,2,4,issues,teardown,Teardown errors (2)
|
||||||
|
pglab,0,0,0,0,no_tests,no_tests,No test_init.py tests
|
||||||
|
pi_hole,0,0,0,0,timeout,timeout,Hangs - network polling
|
||||||
|
pilight,0,0,1,1,issues,teardown,Teardown error (1)
|
||||||
|
python_script,22,7,0,29,issues,service_not_registered,YAML-loaded services don't register on host (7 tests)
|
||||||
|
qwikswitch,2,9,0,11,issues,service_not_registered,async_timeout compat issue + service not found (9 tests)
|
||||||
|
radio_frequency,11,2,0,13,issues,error_type_lost,Error message regex doesn't match formatted websocket error (2 tests)
|
||||||
|
rflink,17,1,0,18,issues,other,Test-specific issue (1 test)
|
||||||
|
schedule,24,1,0,25,issues,other,service get not working through sandbox (1 test)
|
||||||
|
script,0,0,0,0,timeout,timeout,Hangs - complex async script execution
|
||||||
|
select,1,1,0,2,issues,error_type_lost,ServiceValidationError.translation_domain is None (1 test)
|
||||||
|
shell_command,13,1,0,14,issues,error_type_lost,TemplateError becomes HomeAssistantError through websocket (1 test)
|
||||||
|
sun,0,0,0,0,timeout,timeout,Hangs - time-dependent calculations
|
||||||
|
switch,1,1,0,2,issues,context,Context not preserved (1 test)
|
||||||
|
telegram_bot,2,0,2,4,issues,teardown,Teardown errors (2)
|
||||||
|
teslemetry,48,0,32,80,issues,teardown,All 32 errors are fixture teardown failures
|
||||||
|
timer,29,1,0,30,issues,reload,Reload not supported (1 test)
|
||||||
|
todo,67,4,0,71,issues,error_type_lost,"Expects ServiceNotSupported, gets HomeAssistantError (4 tests)"
|
||||||
|
tplink,46,3,0,49,issues,other,Credential migration issues (3 tests)
|
||||||
|
update,20,1,0,21,issues,error_type_lost,"Expects specific error type, gets HomeAssistantError (1 test)"
|
||||||
|
utility_meter,22,3,0,25,issues,error_type_lost,ServiceValidationError raised but tests expect different message (3 tests)
|
||||||
|
vacuum,21,4,0,25,issues,error_type_lost,ServiceValidationError.translation_domain is None through websocket (4 tests)
|
||||||
|
voip,0,0,0,0,no_tests,no_tests,No test_init.py tests
|
||||||
|
wallbox,6,1,0,7,issues,error_type_lost,HomeAssistantError raised when test expects different behavior (1 test)
|
||||||
|
water_heater,4,1,2,7,issues,error_type_lost,ServiceValidationError message text differs (1 test)
|
||||||
|
yeelight,22,0,17,39,issues,async_timeout,Async operations timeout (17 errors)
|
||||||
|
zone,21,1,0,22,issues,reload,Reload not supported (1 test)
|
||||||
|
zwave_js,66,0,65,131,issues,teardown,All 65 errors are fixture teardown failures
|
||||||
|
@@ -0,0 +1,220 @@
|
|||||||
|
"""Analyze sandbox test failures and produce a categorized CSV.
|
||||||
|
|
||||||
|
Categories identified from manual investigation:
|
||||||
|
1. error_type_lost - Exception subclass (ServiceNotSupported, ServiceValidationError)
|
||||||
|
becomes generic HomeAssistantError through websocket serialization. Tests that
|
||||||
|
check for specific exception types or translation_key/translation_domain fail.
|
||||||
|
2. service_not_registered - Service registered in sandbox but call_service goes to
|
||||||
|
host which doesn't have it. Race condition or services that only exist locally.
|
||||||
|
3. context - Context objects don't round-trip through websocket.
|
||||||
|
4. reload - Reload/reconfig not supported in sandbox mode.
|
||||||
|
5. auth_bypass - Sandbox token bypasses user permission checks (admin-only tests).
|
||||||
|
6. teardown - Teardown errors from dual-instance event loop lifecycle.
|
||||||
|
7. timeout - Tests hang (freezer/complex async).
|
||||||
|
8. async_timeout - Config entry setup times out.
|
||||||
|
9. host_specific - Tests need host-only resources (hassio, network interfaces).
|
||||||
|
10. target_config_entry - Service calls with target.config_entry not supported through WS.
|
||||||
|
11. async_timeout_compat - Uses old async_timeout.Timeout (Python 3.14 compat issue).
|
||||||
|
12. config_entry_state - Config entry doesn't reach expected error state.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import csv
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
|
||||||
|
RESULTS_CSV = "/tmp/sandbox_test_results.csv"
|
||||||
|
ERRORS_DIR = "/tmp/sandbox_test_errors"
|
||||||
|
OUTPUT_CSV = os.path.join(os.path.dirname(os.path.abspath(__file__)), "TEST_RESULTS.csv")
|
||||||
|
|
||||||
|
# Manual categorization based on investigation above
|
||||||
|
MANUAL_CATEGORIES = {
|
||||||
|
# error_type_lost: Tests expect ServiceNotSupported/ServiceValidationError but get
|
||||||
|
# HomeAssistantError because exception type is lost in websocket serialization.
|
||||||
|
# Also includes tests that check translation_key/translation_domain on exceptions.
|
||||||
|
"calendar": ("error_type_lost", "Expects ServiceNotSupported, gets HomeAssistantError (21 tests)"),
|
||||||
|
"todo": ("error_type_lost", "Expects ServiceNotSupported, gets HomeAssistantError (4 tests)"),
|
||||||
|
"climate": ("error_type_lost", "ServiceValidationError message format differs; translation_domain lost (4f/2e)"),
|
||||||
|
"vacuum": ("error_type_lost", "ServiceValidationError.translation_domain is None through websocket (4 tests)"),
|
||||||
|
"fan": ("error_type_lost", "Expects ServiceValidationError, gets HomeAssistantError (1 test)"),
|
||||||
|
"number": ("error_type_lost", "ServiceValidationError.translation_domain is None (1 test)"),
|
||||||
|
"humidifier": ("error_type_lost", "ServiceValidationError.translation_key is None (1 test)"),
|
||||||
|
"lock": ("error_type_lost", "ServiceValidationError message text differs (1 test)"),
|
||||||
|
"select": ("error_type_lost", "ServiceValidationError.translation_domain is None (1 test)"),
|
||||||
|
"water_heater": ("error_type_lost", "ServiceValidationError message text differs (1 test)"),
|
||||||
|
"counter": ("error_type_lost", "HomeAssistantError instead of ValueError + context (2 tests)"),
|
||||||
|
"imap": ("error_type_lost", "ServiceValidationError.translation_key is None (2 tests)"),
|
||||||
|
"duckdns": ("error_type_lost", "ServiceValidationError.translation_key is None (4 tests)"),
|
||||||
|
"google_assistant_sdk": ("error_type_lost", "ServiceValidationError.translation_key is None (3 tests)"),
|
||||||
|
"google_sheets": ("error_type_lost", "ServiceValidationError with missing translation metadata (3 tests)"),
|
||||||
|
"utility_meter": ("error_type_lost", "ServiceValidationError raised but tests expect different message (3 tests)"),
|
||||||
|
"google": ("error_type_lost", "Expects ServiceNotSupported, gets HomeAssistantError (26 tests)"),
|
||||||
|
"update": ("error_type_lost", "Expects specific error type, gets HomeAssistantError (1 test)"),
|
||||||
|
"wallbox": ("error_type_lost", "HomeAssistantError raised when test expects different behavior (1 test)"),
|
||||||
|
"shell_command": ("error_type_lost", "TemplateError becomes HomeAssistantError through websocket (1 test)"),
|
||||||
|
"radio_frequency": ("error_type_lost", "Error message regex doesn't match formatted websocket error (2 tests)"),
|
||||||
|
"light": ("error_type_lost", "ServiceValidationError type/context lost + context test (3 tests)"),
|
||||||
|
"media_player": ("error_type_lost", "ServiceValidationError type lost through websocket (2 tests)"),
|
||||||
|
|
||||||
|
# service_not_registered: Service is registered in sandbox but forward to host
|
||||||
|
# fails because register_service hasn't completed or service is YAML-only.
|
||||||
|
"hdmi_cec": ("service_not_registered", "Services registered locally but not on host (29 tests)"),
|
||||||
|
"python_script": ("service_not_registered", "YAML-loaded services don't register on host (7 tests)"),
|
||||||
|
"qwikswitch": ("service_not_registered", "async_timeout compat issue + service not found (9 tests)"),
|
||||||
|
|
||||||
|
# auth_bypass: Sandbox token has full access, so admin-required tests don't raise.
|
||||||
|
"backup": ("auth_bypass", "Sandbox token bypasses admin check - DID NOT RAISE Unauthorized (3 tests)"),
|
||||||
|
"configurator": ("auth_bypass", "Sandbox token bypasses admin check (1 test)"),
|
||||||
|
"cloud": ("auth_bypass", "Sandbox token bypasses admin check (1 test)"),
|
||||||
|
"logger": ("auth_bypass", "Sandbox token bypasses admin check (2 tests)"),
|
||||||
|
"homematic": ("auth_bypass", "Sandbox token bypasses admin check (1 test)"),
|
||||||
|
"homeassistant": ("auth_bypass", "Admin check bypass + reload not supported (3f/1e)"),
|
||||||
|
|
||||||
|
# context: Context objects not preserved through websocket round-trip.
|
||||||
|
"input_boolean": ("context", "Context not preserved (1 test)"),
|
||||||
|
"input_button": ("context", "Context not preserved (1 test)"),
|
||||||
|
"switch": ("context", "Context not preserved (1 test)"),
|
||||||
|
|
||||||
|
# context_and_reload: Both context and reload issues.
|
||||||
|
"automation": ("context_and_reload", "Context (1) + reload (2) + trigger (1) + teardown errors (83)"),
|
||||||
|
"input_datetime": ("context_and_reload", "Context (1) + reload (1)"),
|
||||||
|
"input_number": ("context_and_reload", "Context (1) + reload (1)"),
|
||||||
|
"input_select": ("context_and_reload", "Context (1) + reload (1)"),
|
||||||
|
"input_text": ("context_and_reload", "Context (1) + reload (1)"),
|
||||||
|
|
||||||
|
# reload: Reload not supported in sandbox.
|
||||||
|
"timer": ("reload", "Reload not supported (1 test)"),
|
||||||
|
"zone": ("reload", "Reload not supported (1 test)"),
|
||||||
|
"frontend": ("reload", "Theme reload/YAML-based services not supported (4 tests)"),
|
||||||
|
"intent_script": ("reload", "YAML reload + service execution issues (6 errors)"),
|
||||||
|
|
||||||
|
# teardown: Event loop / fixture teardown issues from dual-instance lifecycle.
|
||||||
|
"zwave_js": ("teardown", "All 65 errors are fixture teardown failures"),
|
||||||
|
"teslemetry": ("teardown", "All 32 errors are fixture teardown failures"),
|
||||||
|
"onedrive": ("teardown", "All 20 errors are fixture teardown failures"),
|
||||||
|
"google_pubsub": ("teardown", "All 12 errors are fixture teardown failures"),
|
||||||
|
"google_photos": ("teardown", "All 7 errors are fixture teardown failures"),
|
||||||
|
"knx": ("teardown", "All 8 errors are fixture teardown failures"),
|
||||||
|
"dialogflow": ("teardown", "Teardown errors (2)"),
|
||||||
|
"file": ("teardown", "Teardown errors (2)"),
|
||||||
|
"local_file": ("teardown", "Teardown error (1)"),
|
||||||
|
"microsoft_face": ("teardown", "Teardown errors (2)"),
|
||||||
|
"mobile_app": ("teardown", "Teardown error (1)"),
|
||||||
|
"opentherm_gw": ("teardown", "Teardown errors (2)"),
|
||||||
|
"pilight": ("teardown", "Teardown error (1)"),
|
||||||
|
"telegram_bot": ("teardown", "Teardown errors (2)"),
|
||||||
|
"kitchen_sink": ("teardown", "Service validation (1f) + teardown errors (8e)"),
|
||||||
|
|
||||||
|
# async_timeout: Config entry setup or operations time out.
|
||||||
|
"lametric": ("async_timeout", "Config entry setup/teardown times out (4 errors)"),
|
||||||
|
"ntfy": ("async_timeout", "Config entry setup times out (9 errors)"),
|
||||||
|
"yeelight": ("async_timeout", "Async operations timeout (17 errors)"),
|
||||||
|
|
||||||
|
# host_specific: Tests need host-only resources.
|
||||||
|
"hassio": ("host_specific", "Tests require supervisor connection (5 failures)"),
|
||||||
|
"network": ("host_specific", "Tests inspect host network interfaces (6 failures)"),
|
||||||
|
|
||||||
|
# target_config_entry: Service calls with target.config_entry not supported.
|
||||||
|
"flume": ("target_config_entry", "target.config_entry not supported in WS call_service schema (2 tests)"),
|
||||||
|
|
||||||
|
# config_entry_state: Config entry doesn't reach expected error state in sandbox.
|
||||||
|
"homeassistant_connect_zbt2": ("config_entry_state", "Config entry loads when test expects SETUP_RETRY (1 test)"),
|
||||||
|
"homeassistant_sky_connect": ("config_entry_state", "Config entry loads when test expects SETUP_RETRY (1 test)"),
|
||||||
|
"directv": ("config_entry_state", "Config entry state mismatch (2 tests)"),
|
||||||
|
"modern_forms": ("config_entry_state", "async_timeout compat + config entry state (2 tests)"),
|
||||||
|
"blue_current": ("config_entry_state", "ValueError during button platform setup (1 test)"),
|
||||||
|
"lojack": ("config_entry_state", "Unexpected entities created (1 test)"),
|
||||||
|
|
||||||
|
# misc
|
||||||
|
"api": ("error_type_lost", "Service call errors - response type lost through WS (2f/11e)"),
|
||||||
|
"device_tracker": ("yaml_config", "YAML-based device tracker config not supported (2f/1e)"),
|
||||||
|
"dynalite": ("other", "Test-specific issue (1 test)"),
|
||||||
|
"rflink": ("other", "Test-specific issue (1 test)"),
|
||||||
|
"schedule": ("other", "service get not working through sandbox (1 test)"),
|
||||||
|
"google_assistant": ("other", "OAuth/token handling issue (1 test)"),
|
||||||
|
"airthings_ble": ("teardown", "Teardown error (1)"),
|
||||||
|
"tplink": ("other", "Credential migration issues (3 tests)"),
|
||||||
|
"infrared": ("error_type_lost", "Error type/message lost (2 tests)"),
|
||||||
|
|
||||||
|
# timeout
|
||||||
|
"conversation": ("timeout", "Hangs - complex async setup"),
|
||||||
|
"debugpy": ("timeout", "Hangs - debugger attachment"),
|
||||||
|
"demo": ("timeout", "Hangs - large integration with many platforms"),
|
||||||
|
"device_automation": ("timeout", "Hangs - complex automation/device setup"),
|
||||||
|
"devolo_home_network": ("timeout", "Hangs - network device setup"),
|
||||||
|
"ffmpeg": ("timeout", "Hangs - binary process interaction"),
|
||||||
|
"group": ("timeout", "Hangs - complex entity group setup"),
|
||||||
|
"media_extractor": ("timeout", "Hangs - media processing"),
|
||||||
|
"mikrotik": ("timeout", "Hangs - network device polling"),
|
||||||
|
"pi_hole": ("timeout", "Hangs - network polling"),
|
||||||
|
"script": ("timeout", "Hangs - complex async script execution"),
|
||||||
|
"sun": ("timeout", "Hangs - time-dependent calculations"),
|
||||||
|
|
||||||
|
# no_tests
|
||||||
|
"blueprint": ("no_tests", "No test_init.py tests"),
|
||||||
|
"bluetooth": ("no_tests", "No test_init.py tests"),
|
||||||
|
"pglab": ("no_tests", "No test_init.py tests"),
|
||||||
|
"voip": ("no_tests", "No test_init.py tests"),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Read results
|
||||||
|
with open(RESULTS_CSV) as f:
|
||||||
|
reader = csv.DictReader(f)
|
||||||
|
results = list(reader)
|
||||||
|
|
||||||
|
categorized = []
|
||||||
|
for row in results:
|
||||||
|
integration = row["integration"]
|
||||||
|
status = row["status"]
|
||||||
|
passed = int(row["passed"])
|
||||||
|
failed = int(row["failed"])
|
||||||
|
errors = int(row["errors"])
|
||||||
|
total = int(row["total"])
|
||||||
|
|
||||||
|
if status == "pass":
|
||||||
|
continue
|
||||||
|
|
||||||
|
if integration in MANUAL_CATEGORIES:
|
||||||
|
category, reason = MANUAL_CATEGORIES[integration]
|
||||||
|
else:
|
||||||
|
category = "unknown"
|
||||||
|
reason = f"Not yet investigated ({failed}f/{errors}e)"
|
||||||
|
|
||||||
|
categorized.append({
|
||||||
|
"integration": integration,
|
||||||
|
"passed": passed,
|
||||||
|
"failed": failed,
|
||||||
|
"errors": errors,
|
||||||
|
"total": total,
|
||||||
|
"status": status,
|
||||||
|
"category": category,
|
||||||
|
"reason": reason,
|
||||||
|
})
|
||||||
|
|
||||||
|
# Write output CSV
|
||||||
|
with open(OUTPUT_CSV, "w", newline="") as f:
|
||||||
|
writer = csv.DictWriter(f, fieldnames=[
|
||||||
|
"integration", "passed", "failed", "errors", "total", "status", "category", "reason"
|
||||||
|
])
|
||||||
|
writer.writeheader()
|
||||||
|
writer.writerows(categorized)
|
||||||
|
|
||||||
|
# Print summary
|
||||||
|
from collections import Counter, defaultdict
|
||||||
|
cat_counts = Counter(r["category"] for r in categorized)
|
||||||
|
cat_integrations = defaultdict(list)
|
||||||
|
for r in categorized:
|
||||||
|
cat_integrations[r["category"]].append(r)
|
||||||
|
|
||||||
|
print(f"Total non-passing: {len(categorized)}")
|
||||||
|
print(f"Total passing: {sum(1 for r in results if r['status'] == 'pass')}")
|
||||||
|
print(f"\n{'='*70}")
|
||||||
|
for cat, count in cat_counts.most_common():
|
||||||
|
items = cat_integrations[cat]
|
||||||
|
total_f = sum(r["failed"] for r in items)
|
||||||
|
total_e = sum(r["errors"] for r in items)
|
||||||
|
print(f"\n[{cat}] - {count} integrations ({total_f} failures, {total_e} errors)")
|
||||||
|
print(f" {'─'*66}")
|
||||||
|
for r in sorted(items, key=lambda x: x["failed"] + x["errors"], reverse=True):
|
||||||
|
print(f" {r['integration']:30s} {r['passed']:3d}p/{r['failed']:2d}f/{r['errors']:2d}e {r['reason']}")
|
||||||
|
print(f"\n{'='*70}")
|
||||||
|
print(f"Results written to: {OUTPUT_CSV}")
|
||||||
@@ -0,0 +1,593 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Home Assistant Sandbox Architecture</title>
|
||||||
|
<style>
|
||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; line-height: 1.6; color: #1a1a2e; background: #f8f9fa; padding: 2rem; }
|
||||||
|
.container { max-width: 1200px; margin: 0 auto; }
|
||||||
|
h1 { font-size: 2rem; margin-bottom: 0.5rem; color: #16213e; }
|
||||||
|
h2 { font-size: 1.4rem; margin-top: 2rem; margin-bottom: 0.75rem; color: #0f3460; border-bottom: 2px solid #e2e8f0; padding-bottom: 0.25rem; }
|
||||||
|
h3 { font-size: 1.1rem; margin-top: 1.2rem; margin-bottom: 0.5rem; color: #16213e; }
|
||||||
|
p, li { margin-bottom: 0.5rem; }
|
||||||
|
ul, ol { padding-left: 1.5rem; }
|
||||||
|
.subtitle { color: #64748b; margin-bottom: 2rem; }
|
||||||
|
.diagram { background: #1a1a2e; border-radius: 12px; padding: 2rem; margin: 1.5rem 0; overflow-x: auto; }
|
||||||
|
.diagram svg { display: block; margin: 0 auto; }
|
||||||
|
.stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 1rem; margin: 1.5rem 0; }
|
||||||
|
.stat { background: white; border-radius: 8px; padding: 1.25rem; box-shadow: 0 1px 3px rgba(0,0,0,0.1); text-align: center; }
|
||||||
|
.stat-value { font-size: 2rem; font-weight: 700; color: #0f3460; }
|
||||||
|
.stat-label { font-size: 0.85rem; color: #64748b; margin-top: 0.25rem; }
|
||||||
|
.changes-table { width: 100%; border-collapse: collapse; margin: 1rem 0; }
|
||||||
|
.changes-table th, .changes-table td { padding: 0.75rem; text-align: left; border-bottom: 1px solid #e2e8f0; }
|
||||||
|
.changes-table th { background: #f1f5f9; font-weight: 600; color: #334155; }
|
||||||
|
.changes-table tr:hover { background: #f8fafc; }
|
||||||
|
code { background: #e2e8f0; padding: 0.15rem 0.4rem; border-radius: 4px; font-size: 0.9em; }
|
||||||
|
.platform-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: 0.5rem; margin: 1rem 0; }
|
||||||
|
.platform-chip { background: #dbeafe; color: #1e40af; padding: 0.35rem 0.75rem; border-radius: 6px; font-size: 0.85rem; text-align: center; }
|
||||||
|
.platform-chip.skip { background: #fee2e2; color: #991b1b; }
|
||||||
|
.flow-section { background: white; border-radius: 12px; padding: 1.5rem; margin: 1.5rem 0; box-shadow: 0 1px 3px rgba(0,0,0,0.1); }
|
||||||
|
.legend { display: flex; gap: 1.5rem; margin: 0.5rem 0 1rem 0; flex-wrap: wrap; }
|
||||||
|
.legend-item { display: flex; align-items: center; gap: 0.4rem; font-size: 0.85rem; }
|
||||||
|
.legend-dot { width: 12px; height: 12px; border-radius: 3px; }
|
||||||
|
.file-tree { font-family: 'SF Mono', Monaco, monospace; font-size: 0.85rem; background: #f1f5f9; padding: 1rem 1.5rem; border-radius: 8px; margin: 1rem 0; }
|
||||||
|
.file-tree .dir { color: #1e40af; font-weight: 600; }
|
||||||
|
.file-tree .file { color: #475569; }
|
||||||
|
.file-tree .desc { color: #94a3b8; font-style: italic; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
|
||||||
|
<h1>Home Assistant Sandbox Architecture</h1>
|
||||||
|
<p class="subtitle">Run HA integrations in isolated processes, with entities appearing seamlessly in the host instance.</p>
|
||||||
|
|
||||||
|
<div class="stats">
|
||||||
|
<div class="stat"><div class="stat-value">~720</div><div class="stat-label">Integrations Passing (of 787)</div></div>
|
||||||
|
<div class="stat"><div class="stat-value">~7,750</div><div class="stat-label">Tests Passing</div></div>
|
||||||
|
<div class="stat"><div class="stat-value">32</div><div class="stat-label">Entity Platforms</div></div>
|
||||||
|
<div class="stat"><div class="stat-value">88%</div><div class="stat-label">Integration Pass Rate</div></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2>System Diagram</h2>
|
||||||
|
|
||||||
|
<div class="diagram">
|
||||||
|
<svg width="900" height="580" viewBox="0 0 900 580" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<!-- Host HA Process -->
|
||||||
|
<rect x="30" y="20" width="400" height="540" rx="12" fill="#1e293b" stroke="#3b82f6" stroke-width="2"/>
|
||||||
|
<text x="230" y="50" text-anchor="middle" fill="#93c5fd" font-size="14" font-weight="bold">HOME ASSISTANT CORE (Host Process)</text>
|
||||||
|
|
||||||
|
<!-- Entity Registry / State Machine -->
|
||||||
|
<rect x="50" y="70" width="160" height="60" rx="8" fill="#0f172a" stroke="#475569" stroke-width="1"/>
|
||||||
|
<text x="130" y="105" text-anchor="middle" fill="#e2e8f0" font-size="11">Entity/Device/etc Registries</text>
|
||||||
|
|
||||||
|
<rect x="230" y="70" width="180" height="60" rx="8" fill="#0f172a" stroke="#475569" stroke-width="1"/>
|
||||||
|
<text x="320" y="95" text-anchor="middle" fill="#e2e8f0" font-size="11">State Machine</text>
|
||||||
|
<text x="320" y="112" text-anchor="middle" fill="#94a3b8" font-size="10">hass.states</text>
|
||||||
|
|
||||||
|
<!-- Sandbox Integration -->
|
||||||
|
<rect x="50" y="150" width="360" height="220" rx="8" fill="#172554" stroke="#3b82f6" stroke-width="1.5"/>
|
||||||
|
<text x="230" y="175" text-anchor="middle" fill="#93c5fd" font-size="12" font-weight="bold">sandbox integration</text>
|
||||||
|
|
||||||
|
<!-- RemoteHostEntityPlatform -->
|
||||||
|
<rect x="70" y="190" width="155" height="60" rx="6" fill="#1e3a5f" stroke="#60a5fa" stroke-width="1"/>
|
||||||
|
<text x="147" y="210" text-anchor="middle" fill="#bfdbfe" font-size="9.5" font-weight="bold">RemoteHostEntity</text>
|
||||||
|
<text x="147" y="224" text-anchor="middle" fill="#bfdbfe" font-size="9.5" font-weight="bold">Platform</text>
|
||||||
|
<text x="147" y="240" text-anchor="middle" fill="#7dd3fc" font-size="8.5">added to EntityComponent</text>
|
||||||
|
|
||||||
|
<!-- Proxy Entities (entity/ package) -->
|
||||||
|
<rect x="240" y="190" width="150" height="60" rx="6" fill="#1e3a5f" stroke="#60a5fa" stroke-width="1"/>
|
||||||
|
<text x="315" y="210" text-anchor="middle" fill="#bfdbfe" font-size="10">Proxy Entities</text>
|
||||||
|
<text x="315" y="226" text-anchor="middle" fill="#7dd3fc" font-size="9">entity/ package</text>
|
||||||
|
<text x="315" y="240" text-anchor="middle" fill="#7dd3fc" font-size="9">32 platform classes</text>
|
||||||
|
|
||||||
|
<!-- Websocket API -->
|
||||||
|
<rect x="70" y="265" width="320" height="75" rx="6" fill="#1e3a5f" stroke="#f59e0b" stroke-width="1"/>
|
||||||
|
<text x="230" y="285" text-anchor="middle" fill="#fde68a" font-size="10">Websocket API (sandbox/* commands)</text>
|
||||||
|
<text x="230" y="302" text-anchor="middle" fill="#fcd34d" font-size="9">register_entity | update_state | register_device</text>
|
||||||
|
<text x="230" y="317" text-anchor="middle" fill="#fcd34d" font-size="9">register_service | service_call_result</text>
|
||||||
|
<text x="230" y="332" text-anchor="middle" fill="#fcd34d" font-size="9">subscribe_entity_commands | entity_command_result</text>
|
||||||
|
|
||||||
|
<!-- Service Handler -->
|
||||||
|
<rect x="50" y="390" width="170" height="50" rx="8" fill="#0f172a" stroke="#475569" stroke-width="1"/>
|
||||||
|
<text x="135" y="412" text-anchor="middle" fill="#e2e8f0" font-size="11">Proxy Service Handler</text>
|
||||||
|
<text x="135" y="429" text-anchor="middle" fill="#94a3b8" font-size="10">forwards calls to sandbox</text>
|
||||||
|
|
||||||
|
<!-- Auth / Token -->
|
||||||
|
<rect x="240" y="390" width="170" height="50" rx="8" fill="#0f172a" stroke="#475569" stroke-width="1"/>
|
||||||
|
<text x="325" y="412" text-anchor="middle" fill="#e2e8f0" font-size="11">Sandbox Authentication</text>
|
||||||
|
<text x="325" y="429" text-anchor="middle" fill="#94a3b8" font-size="10">sandbox tokens</text>
|
||||||
|
|
||||||
|
<!-- Config Entries -->
|
||||||
|
<rect x="50" y="460" width="360" height="75" rx="8" fill="#0f172a" stroke="#475569" stroke-width="1"/>
|
||||||
|
<text x="230" y="482" text-anchor="middle" fill="#e2e8f0" font-size="11">Config Entries</text>
|
||||||
|
<text x="230" y="500" text-anchor="middle" fill="#94a3b8" font-size="10">New property: sandbox = "group_name"</text>
|
||||||
|
<text x="230" y="518" text-anchor="middle" fill="#94a3b8" font-size="10">Same string = same sandbox process</text>
|
||||||
|
|
||||||
|
<!-- Sandbox Process -->
|
||||||
|
<rect x="470" y="20" width="400" height="540" rx="12" fill="#1e293b" stroke="#10b981" stroke-width="2"/>
|
||||||
|
<text x="670" y="50" text-anchor="middle" fill="#6ee7b7" font-size="14" font-weight="bold">SANDBOX PROCESS (Isolated)</text>
|
||||||
|
|
||||||
|
<!-- RemoteHomeAssistant -->
|
||||||
|
<rect x="490" y="70" width="360" height="60" rx="8" fill="#0f172a" stroke="#475569" stroke-width="1"/>
|
||||||
|
<text x="670" y="95" text-anchor="middle" fill="#e2e8f0" font-size="11">RemoteHomeAssistant</text>
|
||||||
|
<text x="670" y="112" text-anchor="middle" fill="#94a3b8" font-size="10">Subclass of HomeAssistant connected via websocket</text>
|
||||||
|
|
||||||
|
<!-- RemoteClientEntityPlatform -->
|
||||||
|
<rect x="490" y="150" width="360" height="70" rx="8" fill="#064e3b" stroke="#10b981" stroke-width="1.5"/>
|
||||||
|
<text x="670" y="172" text-anchor="middle" fill="#6ee7b7" font-size="12" font-weight="bold">RemoteClientEntityPlatform</text>
|
||||||
|
<text x="670" y="192" text-anchor="middle" fill="#a7f3d0" font-size="10">Intercepts async_add_entities() → registers with host</text>
|
||||||
|
<text x="670" y="208" text-anchor="middle" fill="#a7f3d0" font-size="10">Forwards state changes | Dispatches method calls</text>
|
||||||
|
|
||||||
|
<!-- Real Integration -->
|
||||||
|
<rect x="490" y="240" width="360" height="70" rx="8" fill="#0f172a" stroke="#8b5cf6" stroke-width="1.5"/>
|
||||||
|
<text x="670" y="265" text-anchor="middle" fill="#c4b5fd" font-size="12" font-weight="bold">Real Integration Code</text>
|
||||||
|
<text x="670" y="285" text-anchor="middle" fill="#ddd6fe" font-size="10">e.g. Hue, MQTT — unchanged integration code</text>
|
||||||
|
<text x="670" y="300" text-anchor="middle" fill="#ddd6fe" font-size="10">async_setup_entry() runs normally here</text>
|
||||||
|
|
||||||
|
<!-- Real Entities -->
|
||||||
|
<rect x="490" y="330" width="170" height="50" rx="8" fill="#0f172a" stroke="#475569" stroke-width="1"/>
|
||||||
|
<text x="575" y="352" text-anchor="middle" fill="#e2e8f0" font-size="11">Real Entities</text>
|
||||||
|
<text x="575" y="369" text-anchor="middle" fill="#94a3b8" font-size="10">LightEntity, etc.</text>
|
||||||
|
|
||||||
|
<!-- Sandbox Service Registry -->
|
||||||
|
<rect x="680" y="330" width="170" height="50" rx="8" fill="#064e3b" stroke="#10b981" stroke-width="1"/>
|
||||||
|
<text x="765" y="349" text-anchor="middle" fill="#6ee7b7" font-size="10" font-weight="bold">SandboxService</text>
|
||||||
|
<text x="765" y="363" text-anchor="middle" fill="#6ee7b7" font-size="10" font-weight="bold">Registry</text>
|
||||||
|
<text x="765" y="377" text-anchor="middle" fill="#a7f3d0" font-size="9">all calls → host</text>
|
||||||
|
|
||||||
|
<!-- Connection info -->
|
||||||
|
<rect x="490" y="400" width="360" height="80" rx="8" fill="#0f172a" stroke="#475569" stroke-width="1"/>
|
||||||
|
<text x="670" y="425" text-anchor="middle" fill="#e2e8f0" font-size="11">Websocket Client</text>
|
||||||
|
<text x="670" y="445" text-anchor="middle" fill="#94a3b8" font-size="10">Authenticated with sandbox token</text>
|
||||||
|
<text x="670" y="465" text-anchor="middle" fill="#94a3b8" font-size="10">Subscribes to entity commands + service calls</text>
|
||||||
|
|
||||||
|
<!-- Service registration label -->
|
||||||
|
<rect x="490" y="500" width="360" height="50" rx="8" fill="#0f172a" stroke="#f59e0b" stroke-width="1"/>
|
||||||
|
<text x="670" y="522" text-anchor="middle" fill="#fde68a" font-size="10">Services registered on host via sandbox/register_service</text>
|
||||||
|
<text x="670" y="538" text-anchor="middle" fill="#fcd34d" font-size="9">Host creates proxy handler → forwards back to sandbox for execution</text>
|
||||||
|
|
||||||
|
<!-- Arrows: websocket connection -->
|
||||||
|
<defs>
|
||||||
|
<marker id="arrow-blue" markerWidth="8" markerHeight="6" refX="8" refY="3" orient="auto">
|
||||||
|
<polygon points="0 0, 8 3, 0 6" fill="#3b82f6"/>
|
||||||
|
</marker>
|
||||||
|
<marker id="arrow-green" markerWidth="8" markerHeight="6" refX="8" refY="3" orient="auto">
|
||||||
|
<polygon points="0 0, 8 3, 0 6" fill="#10b981"/>
|
||||||
|
</marker>
|
||||||
|
<marker id="arrow-orange" markerWidth="8" markerHeight="6" refX="8" refY="3" orient="auto">
|
||||||
|
<polygon points="0 0, 8 3, 0 6" fill="#f59e0b"/>
|
||||||
|
</marker>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
<!-- State push: sandbox → host -->
|
||||||
|
<line x1="490" y1="185" x2="395" y2="215" stroke="#10b981" stroke-width="2" marker-end="url(#arrow-green)" stroke-dasharray="5,3"/>
|
||||||
|
<text x="442" y="188" text-anchor="middle" fill="#6ee7b7" font-size="9">update_state</text>
|
||||||
|
|
||||||
|
<!-- Command: host → sandbox -->
|
||||||
|
<line x1="395" y1="235" x2="490" y2="195" stroke="#f59e0b" stroke-width="2" marker-end="url(#arrow-orange)" stroke-dasharray="5,3"/>
|
||||||
|
<text x="442" y="230" text-anchor="middle" fill="#fde68a" font-size="9">entity commands</text>
|
||||||
|
|
||||||
|
<!-- Service call flow: sandbox → host → sandbox -->
|
||||||
|
<line x1="680" y1="355" x2="430" y2="415" stroke="#10b981" stroke-width="1.5" marker-end="url(#arrow-green)" stroke-dasharray="4,2"/>
|
||||||
|
<text x="555" y="378" text-anchor="middle" fill="#6ee7b7" font-size="8">call_service</text>
|
||||||
|
|
||||||
|
<line x1="220" y1="415" x2="490" y2="525" stroke="#f59e0b" stroke-width="1.5" marker-end="url(#arrow-orange)" stroke-dasharray="4,2"/>
|
||||||
|
<text x="345" y="480" text-anchor="middle" fill="#fde68a" font-size="8">forward call back</text>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="legend">
|
||||||
|
<div class="legend-item"><div class="legend-dot" style="background:#10b981"></div> Sandbox → Host (state push, service calls)</div>
|
||||||
|
<div class="legend-item"><div class="legend-dot" style="background:#f59e0b"></div> Host → Sandbox (entity commands, forwarded calls)</div>
|
||||||
|
<div class="legend-item"><div class="legend-dot" style="background:#3b82f6"></div> Sandbox integration boundary</div>
|
||||||
|
<div class="legend-item"><div class="legend-dot" style="background:#8b5cf6"></div> Unmodified integration code</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2>How It Works</h2>
|
||||||
|
|
||||||
|
<div class="flow-section">
|
||||||
|
<h3>Entity Registration Flow</h3>
|
||||||
|
<ol>
|
||||||
|
<li>Integration calls <code>async_add_entities([entity1, entity2, ...])</code></li>
|
||||||
|
<li><code>RemoteClientEntityPlatform</code> intercepts the call — adds entities locally, then registers each with the host via <code>sandbox/register_entity</code></li>
|
||||||
|
<li>Host's <code>RemoteHostEntityPlatform</code> creates a <strong>proxy entity</strong> (e.g., <code>SandboxLightEntity</code>) and adds it to the domain's <code>EntityComponent</code></li>
|
||||||
|
<li>Proxy appears in registries, dashboards, automations — indistinguishable from a local entity</li>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flow-section">
|
||||||
|
<h3>State Update Flow</h3>
|
||||||
|
<ol>
|
||||||
|
<li>Real entity in sandbox updates its state (e.g., light brightness changes)</li>
|
||||||
|
<li><code>RemoteClientEntityPlatform</code> listens for <code>EVENT_STATE_CHANGED</code> and sends <code>sandbox/update_state</code></li>
|
||||||
|
<li>Host's proxy entity caches the values and calls <code>async_write_ha_state()</code></li>
|
||||||
|
<li>HA Core propagates the state change to the frontend, automations, etc.</li>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flow-section">
|
||||||
|
<h3>Service Call Flow</h3>
|
||||||
|
<p>All service calls in the sandbox are forwarded to the host via <code>SandboxServiceRegistry</code>. The host handles them in one of two ways:</p>
|
||||||
|
<h3 style="margin-top:1rem">A) Entity method call (e.g., <code>light.turn_on</code> targeting a proxy entity)</h3>
|
||||||
|
<ol>
|
||||||
|
<li>User (or automation) calls <code>light.turn_on</code> targeting a proxy entity</li>
|
||||||
|
<li>HA's service handler invokes <code>async_turn_on()</code> on the proxy</li>
|
||||||
|
<li>Proxy calls <code>_forward_method("async_turn_on")</code> → sends command via <code>subscribe_entity_commands</code> subscription</li>
|
||||||
|
<li><code>RemoteClientEntityPlatform</code> receives the command, dispatches to the real entity's <code>async_turn_on()</code></li>
|
||||||
|
<li>Real entity executes (talks to hardware/API), updates state</li>
|
||||||
|
<li>State update flows back via <code>sandbox/update_state</code></li>
|
||||||
|
<li>Sandbox sends <code>entity_command_result</code> → proxy's future resolves</li>
|
||||||
|
</ol>
|
||||||
|
<h3 style="margin-top:1rem">B) Sandbox-registered service (e.g., <code>input_boolean.reload</code>)</h3>
|
||||||
|
<ol>
|
||||||
|
<li>Sandbox integration registers a service via <code>SandboxServiceRegistry.async_register()</code></li>
|
||||||
|
<li><code>SandboxServiceRegistry</code> registers locally <em>and</em> sends <code>sandbox/register_service</code> to the host</li>
|
||||||
|
<li>Host creates a proxy service handler for that domain/service</li>
|
||||||
|
<li>When the proxy service is called on the host, it sends the call back to the sandbox via <code>subscribe_entity_commands</code></li>
|
||||||
|
<li>Sandbox's <code>SandboxServiceRegistry.async_execute_forwarded_call()</code> runs the local handler</li>
|
||||||
|
<li>Result sent back via <code>sandbox/service_call_result</code> → host's future resolves</li>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flow-section">
|
||||||
|
<h3>Config Entry Grouping</h3>
|
||||||
|
<ol>
|
||||||
|
<li>Config entries set <code>options["sandbox"] = "group_name"</code> (a string value)</li>
|
||||||
|
<li>The sandbox integration discovers all entries with matching group string</li>
|
||||||
|
<li>Entries sharing the same string run in the same sandbox process</li>
|
||||||
|
<li>One auth token + one subprocess per group</li>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2>File Structure</h2>
|
||||||
|
|
||||||
|
<div class="file-tree">
|
||||||
|
<span class="dir">core/homeassistant/components/sandbox/</span><br>
|
||||||
|
<span class="file">__init__.py</span> <span class="desc">— lifecycle, auth, process spawn, group discovery</span><br>
|
||||||
|
<span class="file">const.py</span> <span class="desc">— DATA_SANDBOX key, DOMAIN</span><br>
|
||||||
|
<span class="file">config_flow.py</span> <span class="desc">— config flow for creating sandbox entries</span><br>
|
||||||
|
<span class="file">host_platform.py</span> <span class="desc">— RemoteHostEntityPlatform (added to EntityComponent)</span><br>
|
||||||
|
<span class="file">websocket_api.py</span> <span class="desc">— sandbox/* websocket commands</span><br>
|
||||||
|
<span class="dir">entity/</span> <span class="desc">— proxy entity package (32 platform files)</span><br>
|
||||||
|
<span class="file">__init__.py</span> <span class="desc">— base classes, domain map, factory</span><br>
|
||||||
|
<span class="file">light.py</span> <span class="desc">— SandboxLightEntity</span><br>
|
||||||
|
<span class="file">climate.py</span> <span class="desc">— SandboxClimateEntity</span><br>
|
||||||
|
<span class="file">...</span> <span class="desc">— one file per platform domain</span><br>
|
||||||
|
<br>
|
||||||
|
<span class="dir">hass-client/hass_client/</span><br>
|
||||||
|
<span class="file">sandbox.py</span> <span class="desc">— SandboxClient bootstrap + integration setup</span><br>
|
||||||
|
<span class="file">sandbox_service_registry.py</span> <span class="desc">— SandboxServiceRegistry (forwards all calls to host)</span><br>
|
||||||
|
<span class="file">remote_entity_platform.py</span> <span class="desc">— RemoteClientEntityPlatform</span><br>
|
||||||
|
<span class="file">sandbox_entity_bridge.py</span> <span class="desc">— entity bridge (state forwarding, command dispatch)</span><br>
|
||||||
|
<span class="file">runtime.py</span> <span class="desc">— RemoteHomeAssistant</span><br>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2>Websocket API</h2>
|
||||||
|
|
||||||
|
<table class="changes-table">
|
||||||
|
<thead>
|
||||||
|
<tr><th>Command</th><th>Direction</th><th>Description</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td><code>sandbox/get_entries</code></td><td>Sandbox → Host</td><td>Returns config entry data assigned to this sandbox token</td></tr>
|
||||||
|
<tr><td><code>sandbox/register_device</code></td><td>Sandbox → Host</td><td>Creates a device registry entry in HA Core</td></tr>
|
||||||
|
<tr><td><code>sandbox/update_device</code></td><td>Sandbox → Host</td><td>Updates a device registry entry</td></tr>
|
||||||
|
<tr><td><code>sandbox/remove_device</code></td><td>Sandbox → Host</td><td>Removes a device registry entry</td></tr>
|
||||||
|
<tr><td><code>sandbox/register_entity</code></td><td>Sandbox → Host</td><td>Creates an entity + proxy in HA Core</td></tr>
|
||||||
|
<tr><td><code>sandbox/update_entity</code></td><td>Sandbox → Host</td><td>Updates entity registry attributes</td></tr>
|
||||||
|
<tr><td><code>sandbox/remove_entity</code></td><td>Sandbox → Host</td><td>Removes an entity from HA Core</td></tr>
|
||||||
|
<tr><td><code>sandbox/update_state</code></td><td>Sandbox → Host</td><td>Pushes entity state to HA Core</td></tr>
|
||||||
|
<tr><td><code>sandbox/register_service</code></td><td>Sandbox → Host</td><td>Registers a proxy service on host that forwards calls back to sandbox</td></tr>
|
||||||
|
<tr><td><code>sandbox/service_call_result</code></td><td>Sandbox → Host</td><td>Returns the result of a forwarded service call</td></tr>
|
||||||
|
<tr><td><code>sandbox/subscribe_entity_commands</code></td><td>Subscription</td><td>Receives entity method calls + service calls forwarded from host</td></tr>
|
||||||
|
<tr><td><code>sandbox/entity_command_result</code></td><td>Sandbox → Host</td><td>Returns result of an entity method call</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h2>Supported Platforms (32)</h2>
|
||||||
|
<div class="platform-grid">
|
||||||
|
<div class="platform-chip">alarm_control_panel</div>
|
||||||
|
<div class="platform-chip">binary_sensor</div>
|
||||||
|
<div class="platform-chip">button</div>
|
||||||
|
<div class="platform-chip">calendar</div>
|
||||||
|
<div class="platform-chip">climate</div>
|
||||||
|
<div class="platform-chip">cover</div>
|
||||||
|
<div class="platform-chip">date</div>
|
||||||
|
<div class="platform-chip">datetime</div>
|
||||||
|
<div class="platform-chip">device_tracker</div>
|
||||||
|
<div class="platform-chip">event</div>
|
||||||
|
<div class="platform-chip">fan</div>
|
||||||
|
<div class="platform-chip">humidifier</div>
|
||||||
|
<div class="platform-chip">lawn_mower</div>
|
||||||
|
<div class="platform-chip">light</div>
|
||||||
|
<div class="platform-chip">lock</div>
|
||||||
|
<div class="platform-chip">media_player</div>
|
||||||
|
<div class="platform-chip">notify</div>
|
||||||
|
<div class="platform-chip">number</div>
|
||||||
|
<div class="platform-chip">remote</div>
|
||||||
|
<div class="platform-chip">scene</div>
|
||||||
|
<div class="platform-chip">select</div>
|
||||||
|
<div class="platform-chip">sensor</div>
|
||||||
|
<div class="platform-chip">siren</div>
|
||||||
|
<div class="platform-chip">switch</div>
|
||||||
|
<div class="platform-chip">text</div>
|
||||||
|
<div class="platform-chip">time</div>
|
||||||
|
<div class="platform-chip">todo</div>
|
||||||
|
<div class="platform-chip">update</div>
|
||||||
|
<div class="platform-chip">vacuum</div>
|
||||||
|
<div class="platform-chip">valve</div>
|
||||||
|
<div class="platform-chip">water_heater</div>
|
||||||
|
<div class="platform-chip">weather</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3>Not Yet Supported (need special handling)</h3>
|
||||||
|
<div class="platform-grid">
|
||||||
|
<div class="platform-chip skip">camera (55 integrations blocked)</div>
|
||||||
|
<div class="platform-chip skip">image (18 — binary data)</div>
|
||||||
|
<div class="platform-chip skip">tts (17 — audio output)</div>
|
||||||
|
<div class="platform-chip skip">geo_location (8)</div>
|
||||||
|
<div class="platform-chip skip">image_processing (8)</div>
|
||||||
|
<div class="platform-chip skip">air_quality (7)</div>
|
||||||
|
<div class="platform-chip skip">conversation (7)</div>
|
||||||
|
<div class="platform-chip skip">stt (7 — audio input)</div>
|
||||||
|
<div class="platform-chip skip">ai_task (6)</div>
|
||||||
|
<div class="platform-chip skip">infrared (4)</div>
|
||||||
|
<div class="platform-chip skip">radio_frequency (3)</div>
|
||||||
|
<div class="platform-chip skip">assist_satellite (3)</div>
|
||||||
|
<div class="platform-chip skip">wake_word (1 — audio)</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2>Key Classes</h2>
|
||||||
|
|
||||||
|
<table class="changes-table">
|
||||||
|
<thead>
|
||||||
|
<tr><th>Class</th><th>Location</th><th>Role</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td><code>RemoteHostEntityPlatform</code></td>
|
||||||
|
<td>core: <code>sandbox/host_platform.py</code></td>
|
||||||
|
<td>EntityPlatform subclass added directly to the domain's EntityComponent. Manages proxy entities without needing per-domain setup files.</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>RemoteClientEntityPlatform</code></td>
|
||||||
|
<td>hass-client: <code>remote_entity_platform.py</code></td>
|
||||||
|
<td>Wraps the integration's EntityPlatform to intercept <code>async_add_entities</code>. Registers entities with host, forwards state, handles commands.</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>SandboxProxyEntity</code></td>
|
||||||
|
<td>core: <code>sandbox/entity/__init__.py</code></td>
|
||||||
|
<td>Base class for all proxy entities. Caches state, forwards method calls via <code>_forward_method()</code>.</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>SandboxEntityManager</code></td>
|
||||||
|
<td>core: <code>sandbox/entity/__init__.py</code></td>
|
||||||
|
<td>Tracks proxy entities by entity_id, manages pending call futures, routes state updates.</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>RemoteHomeAssistant</code></td>
|
||||||
|
<td>hass-client: <code>runtime.py</code></td>
|
||||||
|
<td>Subclass of HomeAssistant connected to a real HA via websocket. Integration code runs against this.</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>SandboxServiceRegistry</code></td>
|
||||||
|
<td>hass-client: <code>sandbox_service_registry.py</code></td>
|
||||||
|
<td>Replaces <code>hass.services</code> in the sandbox. All calls forward to host via websocket. Services are registered on both sides — locally for execution, on host as a proxy for forwarding back.</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h2>Changes Made to HA Core</h2>
|
||||||
|
|
||||||
|
<table class="changes-table">
|
||||||
|
<thead>
|
||||||
|
<tr><th>Component</th><th>Change</th><th>Why</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td><code>sandbox/__init__.py</code></td>
|
||||||
|
<td>New integration: lifecycle, auth, process spawn, group discovery</td>
|
||||||
|
<td>Creates auth tokens, spawns sandbox processes, discovers entries by <code>options.sandbox</code> string grouping</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>sandbox/host_platform.py</code></td>
|
||||||
|
<td><code>RemoteHostEntityPlatform</code> added directly to EntityComponent</td>
|
||||||
|
<td>Eliminates 32 identical per-domain platform setup files. Creates proxy entities on-demand when sandbox registers them.</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>sandbox/websocket_api.py</code></td>
|
||||||
|
<td>Websocket commands (<code>sandbox/*</code>) including service registration and call forwarding</td>
|
||||||
|
<td>API for sandbox processes to register devices/entities/services, push state, receive commands and service calls</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>sandbox/entity/</code></td>
|
||||||
|
<td>32 proxy entity classes in a per-platform package</td>
|
||||||
|
<td>Each platform needs a proxy with the right properties/methods. One file per domain for maintainability.</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Config entries</td>
|
||||||
|
<td><code>options["sandbox"] = "group_name"</code></td>
|
||||||
|
<td>Entries with same string value are grouped into one sandbox process. Replaces the old boolean approach.</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h2>Changes Made to the Sandbox Client</h2>
|
||||||
|
|
||||||
|
<table class="changes-table">
|
||||||
|
<thead>
|
||||||
|
<tr><th>Component</th><th>Change</th><th>Why</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td><code>RemoteClientEntityPlatform</code></td>
|
||||||
|
<td>Intercepts <code>async_add_entities</code> at the EntityPlatform level</td>
|
||||||
|
<td>Clean interception point — entities are registered with the host as they're created, not after-the-fact</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>RemoteHomeAssistant</code></td>
|
||||||
|
<td>Subclass of <code>HomeAssistant</code> connected via websocket</td>
|
||||||
|
<td>Integrations expect a <code>HomeAssistant</code> instance. This provides one that routes calls through the websocket.</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>SandboxServiceRegistry</code></td>
|
||||||
|
<td>Replaces <code>hass.services</code>. All service calls forward to host; local services also register on host via <code>sandbox/register_service</code></td>
|
||||||
|
<td>When a sandbox integration registers a service, the host gets a proxy that can forward calls back for execution. All outgoing calls go through the host so services on other integrations work transparently.</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>sandbox.py</code></td>
|
||||||
|
<td>Bootstrap and config entry setup</td>
|
||||||
|
<td>Connects to host, fetches assigned config entries, loads integrations, starts the entity bridge</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h2>Test Results (787 integrations)</h2>
|
||||||
|
|
||||||
|
<div class="stats">
|
||||||
|
<div class="stat"><div class="stat-value">692</div><div class="stat-label">Pass</div></div>
|
||||||
|
<div class="stat"><div class="stat-value">79</div><div class="stat-label">Issues</div></div>
|
||||||
|
<div class="stat"><div class="stat-value">12</div><div class="stat-label">Timeout</div></div>
|
||||||
|
<div class="stat"><div class="stat-value">4</div><div class="stat-label">No Tests</div></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p>Running HA Core's full test suite (<code>test_init.py</code> for each integration) through the sandbox plugin. Each test boots a host HA + sandbox pair connected via real websocket.</p>
|
||||||
|
|
||||||
|
<h3>Failure Categories</h3>
|
||||||
|
|
||||||
|
<table class="changes-table">
|
||||||
|
<thead>
|
||||||
|
<tr><th>Category</th><th>#</th><th>Root Cause</th><th>Fix</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td><strong>error_type_lost</strong></td>
|
||||||
|
<td>25 → 10</td>
|
||||||
|
<td>Exception subclass (<code>ServiceNotSupported</code>, <code>ServiceValidationError</code>) becomes generic <code>HomeAssistantError</code> through websocket. <code>translation_key</code> and <code>translation_domain</code> are lost.</td>
|
||||||
|
<td><strong>✅ Mostly fixed.</strong> Full translation metadata now flows through both websocket hops (sandbox → host → client). <code>ServiceNotSupported</code>, <code>ServiceValidationError</code>, <code>MultipleInvalid</code> all reconstructed correctly. Remaining ~10 integrations have domain-specific subclasses, <code>TemplateError</code>, or <code>ValueError</code> that can't cross WS boundaries.</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><strong>teardown</strong></td>
|
||||||
|
<td>15</td>
|
||||||
|
<td>Fixture teardown errors from dual-instance event loop lifecycle. Tests themselves pass — only cleanup assertions fail.</td>
|
||||||
|
<td>Improve <code>verify_cleanup</code> fixture override to handle dual-instance teardown ordering. Not a functional issue.</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><strong>timeout</strong></td>
|
||||||
|
<td>12</td>
|
||||||
|
<td>Tests hang beyond 120s. Likely use <code>freezer</code> (time manipulation hangs live websocket) or have complex async that doesn't terminate.</td>
|
||||||
|
<td>Improve freezer detection to catch indirect usage. Some integrations (script, recorder, sun) may need the base plugin fallback.</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><strong>auth_bypass</strong></td>
|
||||||
|
<td>6 → 0</td>
|
||||||
|
<td>Sandbox token has full system access. Tests that verify admin-only service calls expect <code>Unauthorized</code> but it never raises.</td>
|
||||||
|
<td><strong>✅ Fixed.</strong> <code>sandbox/call_service</code> WS command forwards full <code>Context</code> (user_id, parent_id, id). Permission checks run in the sandbox against the sandbox's auth system.</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><strong>config_entry_state</strong></td>
|
||||||
|
<td>6</td>
|
||||||
|
<td>Config entry reaches different state in sandbox (e.g., loads successfully when test expects <code>SETUP_RETRY</code>).</td>
|
||||||
|
<td>Integration-specific: some error conditions (hardware not found, network timeout) behave differently in sandbox.</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><strong>context_and_reload</strong></td>
|
||||||
|
<td>5 → reload only</td>
|
||||||
|
<td><code>Context</code> objects don't round-trip through websocket + <code>reload</code> not supported.</td>
|
||||||
|
<td><strong>Context: ✅ Fixed.</strong> Full context forwarding via <code>sandbox/call_service</code> + <code>pending_contexts</code> mechanism. Reload: still needs implementation.</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><strong>reload</strong></td>
|
||||||
|
<td>4</td>
|
||||||
|
<td>YAML reload, theme reload, and service reloading not supported in sandbox mode.</td>
|
||||||
|
<td>Implement reload protocol: host notifies sandbox to re-setup integrations.</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><strong>service_not_registered</strong></td>
|
||||||
|
<td>3</td>
|
||||||
|
<td>Services registered locally in sandbox but <code>register_service</code> to host hasn't completed or service is YAML-only.</td>
|
||||||
|
<td>Ensure <code>register_service</code> completes before first use. YAML-loaded services need explicit registration.</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><strong>context</strong></td>
|
||||||
|
<td>3 → 0</td>
|
||||||
|
<td>Context objects not preserved through websocket round-trip (service call context is lost).</td>
|
||||||
|
<td><strong>✅ Fixed.</strong> Full <code>Context</code> (id, user_id, parent_id) forwarded through <code>sandbox/call_service</code>.</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><strong>async_timeout</strong></td>
|
||||||
|
<td>3</td>
|
||||||
|
<td>Config entry setup operations timeout — sandbox adds latency from websocket round-trips.</td>
|
||||||
|
<td>Increase setup timeout for sandbox mode or optimize round-trip count during setup.</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><strong>host_specific</strong></td>
|
||||||
|
<td>2</td>
|
||||||
|
<td>Tests need host-only resources: supervisor (hassio) or network interface inspection.</td>
|
||||||
|
<td>These integrations cannot run in sandbox by design — they inspect the host system.</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><strong>target_config_entry</strong></td>
|
||||||
|
<td>1</td>
|
||||||
|
<td><code>target.config_entry</code> in service calls not supported in websocket <code>call_service</code> schema.</td>
|
||||||
|
<td>Extend <code>call_service</code> websocket command to accept <code>target.config_entry</code>.</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><strong>yaml_config</strong></td>
|
||||||
|
<td>1</td>
|
||||||
|
<td>YAML-based device tracker configuration not supported.</td>
|
||||||
|
<td>Out of scope — sandbox only supports config-entry-based integrations.</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><strong>no_tests</strong></td>
|
||||||
|
<td>4</td>
|
||||||
|
<td>No <code>test_init.py</code> exists (blueprint, bluetooth, pglab, voip).</td>
|
||||||
|
<td>N/A</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h3>Top Failing Integrations</h3>
|
||||||
|
|
||||||
|
<table class="changes-table">
|
||||||
|
<thead>
|
||||||
|
<tr><th>Integration</th><th>Pass/Fail/Error</th><th>Category</th><th>Notes</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td>automation</td><td>108/4/83</td><td>context + reload + teardown</td><td>83 teardown errors from event loop lifecycle</td></tr>
|
||||||
|
<tr><td>zwave_js</td><td>66/0/65</td><td>teardown</td><td>All errors are fixture teardown</td></tr>
|
||||||
|
<tr><td>teslemetry</td><td>48/0/32</td><td>teardown</td><td>All errors are fixture teardown</td></tr>
|
||||||
|
<tr><td>hdmi_cec</td><td>9/29/0</td><td>service_not_registered</td><td>YAML services not on host</td></tr>
|
||||||
|
<tr><td>google</td><td>56/1/0</td><td>error_type_lost (fixed)</td><td>Was 26 failures, now 1 (token refresh issue)</td></tr>
|
||||||
|
<tr><td>calendar</td><td>67/0/0</td><td>error_type_lost (fixed)</td><td>Was 21 failures, now fully passing ✓</td></tr>
|
||||||
|
<tr><td>onedrive</td><td>20/0/20</td><td>teardown</td><td>Fixture teardown failures</td></tr>
|
||||||
|
<tr><td>yeelight</td><td>22/0/17</td><td>async_timeout</td><td>Operations timeout in sandbox</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h2>Constraints</h2>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Domain isolation</strong>: All integrations of the same domain must run in the same sandbox. Services are registered per-domain and the handler needs access to all active data for that domain. You cannot split one domain across host and sandbox.</li>
|
||||||
|
<li><strong>Config-entry only</strong>: Only config-entry-based integrations can run in the sandbox. YAML-only integrations are out of scope.</li>
|
||||||
|
<li><strong>Same sandbox grouping</strong>: Config entries sharing the same <code>sandbox</code> property value run in one process. One auth token + one subprocess per group.</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h2>Known Limitations / Future Work</h2>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Exception type preservation</strong> (mostly fixed, ~10 remaining): <code>ServiceNotSupported</code>, <code>ServiceValidationError</code> with translation metadata, and <code>MultipleInvalid</code> are now correctly preserved through both websocket hops. Remaining issues: domain-specific exception subclasses (e.g. <code>NotValidPresetModeError</code>), <code>TemplateError</code>, and <code>ValueError</code> cannot be reconstructed from websocket error codes.</li>
|
||||||
|
<li><strong>Context forwarding</strong> (fixed): Full <code>Context</code> (id, user_id, parent_id) is now forwarded through the <code>sandbox/call_service</code> WS command. Permission checks and context tracking work correctly.</li>
|
||||||
|
<li><strong>Reload support</strong> (9 integrations): Reload/reconfig is not supported in sandbox mode. Need a reload protocol where host notifies sandbox to re-fetch and re-setup.</li>
|
||||||
|
<li><strong>Config flows</strong>: Config flows do not run in the sandbox. Discovery, setup, and reconfiguration still happen in the host process.</li>
|
||||||
|
<li><strong>Proxy integrations</strong>: Integrations that act as proxies (e.g., Bluetooth) need individual support inside the sandbox integration. Each proxy type requires its own bridge implementation.</li>
|
||||||
|
<li><strong>Shutdown / restore state</strong>: When HA shuts down, sandboxes should collect restore-state data from entities and push it to the host before exiting.</li>
|
||||||
|
<li><strong>Store persistence</strong>: Integrations use <code>Store</code> for persistent data. These should route through the sandbox websocket so the host owns all persistent state.</li>
|
||||||
|
<li><strong>Stream/binary platforms</strong>: Camera, STT, TTS, and image platforms return binary data or streams that don't serialize well over websocket.</li>
|
||||||
|
<li><strong>Custom integrations</strong>: Future goal. Current focus is built-in integrations.</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user