mirror of
https://github.com/home-assistant/core.git
synced 2026-05-24 17:55:24 +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,
|
||||
access_token_expiration: timedelta = ACCESS_TOKEN_EXPIRATION,
|
||||
credential: models.Credentials | None = None,
|
||||
scopes: frozenset[str] | None = None,
|
||||
) -> models.RefreshToken:
|
||||
"""Create a new refresh token for a user."""
|
||||
if not user.is_active:
|
||||
@@ -514,6 +515,7 @@ class AuthManager:
|
||||
access_token_expiration,
|
||||
expire_at,
|
||||
credential,
|
||||
scopes,
|
||||
)
|
||||
|
||||
@callback
|
||||
|
||||
@@ -211,6 +211,7 @@ class AuthStore:
|
||||
access_token_expiration: timedelta = ACCESS_TOKEN_EXPIRATION,
|
||||
expire_at: float | None = None,
|
||||
credential: models.Credentials | None = None,
|
||||
scopes: frozenset[str] | None = None,
|
||||
) -> models.RefreshToken:
|
||||
"""Create a new token for a user."""
|
||||
kwargs: dict[str, Any] = {
|
||||
@@ -220,6 +221,7 @@ class AuthStore:
|
||||
"access_token_expiration": access_token_expiration,
|
||||
"expire_at": expire_at,
|
||||
"credential": credential,
|
||||
"scopes": scopes,
|
||||
}
|
||||
if client_name:
|
||||
kwargs["client_name"] = client_name
|
||||
@@ -475,6 +477,7 @@ class AuthStore:
|
||||
else:
|
||||
last_used_at = None
|
||||
|
||||
scopes = rt_dict.get("scopes")
|
||||
token = models.RefreshToken(
|
||||
id=rt_dict["id"],
|
||||
user=users[rt_dict["user_id"]],
|
||||
@@ -493,6 +496,7 @@ class AuthStore:
|
||||
last_used_ip=rt_dict.get("last_used_ip"),
|
||||
expire_at=rt_dict.get("expire_at"),
|
||||
version=rt_dict.get("version"),
|
||||
scopes=frozenset(scopes) if scopes else None,
|
||||
)
|
||||
if "credential_id" in rt_dict:
|
||||
token.credential = credentials.get(rt_dict["credential_id"])
|
||||
@@ -581,6 +585,9 @@ class AuthStore:
|
||||
if refresh_token.credential
|
||||
else None,
|
||||
"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 refresh_token in user.refresh_tokens.values()
|
||||
|
||||
@@ -129,6 +129,13 @@ class RefreshToken:
|
||||
|
||||
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)
|
||||
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]
|
||||
|
||||
|
||||
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:
|
||||
"""Handle an active websocket client connection."""
|
||||
|
||||
@@ -56,6 +72,7 @@ class ActiveConnection:
|
||||
"logger",
|
||||
"refresh_token_id",
|
||||
"remote",
|
||||
"scopes",
|
||||
"send_message",
|
||||
"subscriptions",
|
||||
"supported_features",
|
||||
@@ -77,6 +94,7 @@ class ActiveConnection:
|
||||
self.send_message = send_message
|
||||
self.user = user
|
||||
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.subscriptions: dict[Hashable, Callable[[], Any]] = {}
|
||||
self.last_id = 0
|
||||
@@ -238,6 +256,20 @@ class ActiveConnection:
|
||||
)
|
||||
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
|
||||
|
||||
try:
|
||||
|
||||
@@ -21,7 +21,7 @@ from functools import cache
|
||||
import logging
|
||||
from random import randint
|
||||
from types import MappingProxyType
|
||||
from typing import TYPE_CHECKING, Any, Self, TypedDict, cast
|
||||
from typing import TYPE_CHECKING, Any, Protocol, Self, TypedDict, cast
|
||||
|
||||
from async_interrupt import interrupt
|
||||
from propcache.api import cached_property
|
||||
@@ -285,6 +285,7 @@ UPDATE_ENTRY_CONFIG_ENTRY_ATTRS = {
|
||||
"pref_disable_polling",
|
||||
"minor_version",
|
||||
"version",
|
||||
"sandbox",
|
||||
}
|
||||
|
||||
|
||||
@@ -309,6 +310,7 @@ class ConfigFlowResult(FlowResult[ConfigFlowContext, str], total=False):
|
||||
minor_version: int
|
||||
options: Mapping[str, Any]
|
||||
result: ConfigEntry
|
||||
sandbox: str
|
||||
subentries: Iterable[ConfigSubentryData]
|
||||
version: int
|
||||
|
||||
@@ -425,6 +427,7 @@ class ConfigEntry[_DataT = Any]:
|
||||
created_at: datetime
|
||||
modified_at: datetime
|
||||
discovery_keys: MappingProxyType[str, tuple[DiscoveryKey, ...]]
|
||||
sandbox: str | None
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -440,6 +443,7 @@ class ConfigEntry[_DataT = Any]:
|
||||
options: Mapping[str, Any] | None,
|
||||
pref_disable_new_entities: bool | None = None,
|
||||
pref_disable_polling: bool | None = None,
|
||||
sandbox: str | None = None,
|
||||
source: str,
|
||||
state: ConfigEntryState = ConfigEntryState.NOT_LOADED,
|
||||
subentries_data: Iterable[ConfigSubentryData | ConfigSubentryDataWithId] | None,
|
||||
@@ -557,6 +561,11 @@ class ConfigEntry[_DataT = Any]:
|
||||
_setter(self, "modified_at", modified_at or utcnow())
|
||||
_setter(self, "discovery_keys", discovery_keys)
|
||||
|
||||
# Sandbox group this entry belongs to, or None for non-sandboxed
|
||||
# entries. Set by sandbox_v2 at flow completion (CREATE_ENTRY) and
|
||||
# consulted by ConfigEntries.router on every setup/unload.
|
||||
_setter(self, "sandbox", sandbox)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
"""Representation of ConfigEntry."""
|
||||
return (
|
||||
@@ -1189,7 +1198,7 @@ class ConfigEntry[_DataT = Any]:
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
"""Return dictionary version of this entry."""
|
||||
return {
|
||||
result: dict[str, Any] = {
|
||||
"created_at": self.created_at.isoformat(),
|
||||
"data": dict(self.data),
|
||||
"discovery_keys": dict(self.discovery_keys),
|
||||
@@ -1207,6 +1216,11 @@ class ConfigEntry[_DataT = Any]:
|
||||
"unique_id": self.unique_id,
|
||||
"version": self.version,
|
||||
}
|
||||
# Persist sandbox tag only when set, to keep on-disk shape lean
|
||||
# for the common (non-sandboxed) case.
|
||||
if self.sandbox is not None:
|
||||
result["sandbox"] = self.sandbox
|
||||
return result
|
||||
|
||||
@callback
|
||||
def async_on_unload(
|
||||
@@ -1781,6 +1795,7 @@ class ConfigEntriesFlowManager(
|
||||
domain=result["handler"],
|
||||
minor_version=result["minor_version"],
|
||||
options=result["options"],
|
||||
sandbox=result.get("sandbox"),
|
||||
source=flow.context["source"],
|
||||
subentries_data=result["subentries"],
|
||||
title=result["title"],
|
||||
@@ -1817,12 +1832,20 @@ class ConfigEntriesFlowManager(
|
||||
|
||||
Handler key is the domain of the component that we want to set up.
|
||||
"""
|
||||
handler = await _async_get_flow_handler(
|
||||
self.hass, handler_key, self._hass_config
|
||||
)
|
||||
if not context or "source" not in context:
|
||||
raise KeyError("Context not set or doesn't have a source set")
|
||||
|
||||
if (router := self.config_entries.router) is not None and (
|
||||
flow := await router.async_create_flow(
|
||||
handler_key, context=context, data=data
|
||||
)
|
||||
) is not None:
|
||||
flow.init_step = context["source"]
|
||||
return flow
|
||||
|
||||
handler = await _async_get_flow_handler(
|
||||
self.hass, handler_key, self._hass_config
|
||||
)
|
||||
flow = handler()
|
||||
flow.init_step = context["source"]
|
||||
return flow
|
||||
@@ -2080,6 +2103,30 @@ class ConfigEntryStore(storage.Store[dict[str, list[dict[str, Any]]]]):
|
||||
return data
|
||||
|
||||
|
||||
class ConfigEntryRouter(Protocol):
|
||||
"""Hook protocol for routing config flows and entry setup elsewhere.
|
||||
|
||||
Currently used by `sandbox_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:
|
||||
"""Manage the configuration entries.
|
||||
|
||||
@@ -2095,6 +2142,8 @@ class ConfigEntries:
|
||||
self._hass_config = hass_config
|
||||
self._entries = ConfigEntryItems(hass)
|
||||
self._store = ConfigEntryStore(hass)
|
||||
# Optional hook for diverting flows and entry setup (used by sandbox_v2).
|
||||
self.router: ConfigEntryRouter | None = None
|
||||
EntityRegistryDisabledHandler(hass).async_setup()
|
||||
|
||||
@callback
|
||||
@@ -2287,6 +2336,8 @@ class ConfigEntries:
|
||||
options=entry["options"],
|
||||
pref_disable_new_entities=entry["pref_disable_new_entities"],
|
||||
pref_disable_polling=entry["pref_disable_polling"],
|
||||
# Optional — pre-Phase-17 entries don't carry this key.
|
||||
sandbox=entry.get("sandbox"),
|
||||
source=entry["source"],
|
||||
subentries_data=entry["subentries"],
|
||||
title=entry["title"],
|
||||
@@ -2362,6 +2413,11 @@ class ConfigEntries:
|
||||
f" be in the {ConfigEntryState.NOT_LOADED} state"
|
||||
)
|
||||
|
||||
if self.router is not None:
|
||||
result = await self.router.async_setup_entry(entry)
|
||||
if result is not None:
|
||||
return result
|
||||
|
||||
# Setup Component if not set up yet
|
||||
if entry.domain in self.hass.config.components:
|
||||
if _lock:
|
||||
@@ -2393,6 +2449,14 @@ class ConfigEntries:
|
||||
f" recoverable state {entry.state}"
|
||||
)
|
||||
|
||||
if self.router is not None:
|
||||
result = await self.router.async_unload_entry(entry)
|
||||
if result is not None:
|
||||
entry._async_set_state( # noqa: SLF001
|
||||
self.hass, ConfigEntryState.NOT_LOADED, None
|
||||
)
|
||||
return result
|
||||
|
||||
if _lock:
|
||||
async with entry.setup_lock:
|
||||
return await entry.async_unload(self.hass)
|
||||
@@ -2493,6 +2557,7 @@ class ConfigEntries:
|
||||
options: Mapping[str, Any] | UndefinedType = UNDEFINED,
|
||||
pref_disable_new_entities: bool | UndefinedType = UNDEFINED,
|
||||
pref_disable_polling: bool | UndefinedType = UNDEFINED,
|
||||
sandbox: str | None | UndefinedType = UNDEFINED,
|
||||
title: str | UndefinedType = UNDEFINED,
|
||||
unique_id: str | None | UndefinedType = UNDEFINED,
|
||||
version: int | UndefinedType = UNDEFINED,
|
||||
@@ -2513,6 +2578,7 @@ class ConfigEntries:
|
||||
options=options,
|
||||
pref_disable_new_entities=pref_disable_new_entities,
|
||||
pref_disable_polling=pref_disable_polling,
|
||||
sandbox=sandbox,
|
||||
title=title,
|
||||
unique_id=unique_id,
|
||||
version=version,
|
||||
@@ -2531,6 +2597,7 @@ class ConfigEntries:
|
||||
options: Mapping[str, Any] | UndefinedType = UNDEFINED,
|
||||
pref_disable_new_entities: bool | UndefinedType = UNDEFINED,
|
||||
pref_disable_polling: bool | UndefinedType = UNDEFINED,
|
||||
sandbox: str | None | UndefinedType = UNDEFINED,
|
||||
subentries: dict[str, ConfigSubentry] | UndefinedType = UNDEFINED,
|
||||
title: str | UndefinedType = UNDEFINED,
|
||||
unique_id: str | None | UndefinedType = UNDEFINED,
|
||||
@@ -2581,6 +2648,7 @@ class ConfigEntries:
|
||||
("minor_version", minor_version),
|
||||
("pref_disable_new_entities", pref_disable_new_entities),
|
||||
("pref_disable_polling", pref_disable_polling),
|
||||
("sandbox", sandbox),
|
||||
("title", title),
|
||||
("version", version),
|
||||
):
|
||||
|
||||
Generated
+1
@@ -643,6 +643,7 @@ FLOWS = {
|
||||
"sabnzbd",
|
||||
"samsung_infrared",
|
||||
"samsungtv",
|
||||
"sandbox",
|
||||
"sanix",
|
||||
"satel_integra",
|
||||
"saunum",
|
||||
|
||||
@@ -203,6 +203,26 @@ class EntityComponent[_EntityT: entity.Entity = entity.Entity]:
|
||||
await platform.async_reset()
|
||||
return True
|
||||
|
||||
@callback
|
||||
def async_register_remote_platform(
|
||||
self, config_entry: ConfigEntry, platform: EntityPlatform
|
||||
) -> None:
|
||||
"""Register a pre-built EntityPlatform for a remote integration.
|
||||
|
||||
Used by ``sandbox_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(
|
||||
self, service_call: ServiceCall, expand_group: bool = True
|
||||
) -> 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