mirror of
https://github.com/home-assistant/core.git
synced 2026-05-24 01:35:22 +02:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 296d625121 | |||
| 7bc7694e14 | |||
| c45c949080 | |||
| ec4f64172b |
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"features": {
|
||||
"ghcr.io/devcontainers/features/github-cli:1": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "ghcr.io/devcontainers/features/github-cli@sha256:d22f50b70ed75339b4eed1ba9ecde3a1791f90e88d37936517e3bace0bbad671",
|
||||
"integrity": "sha256:d22f50b70ed75339b4eed1ba9ecde3a1791f90e88d37936517e3bace0bbad671"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,7 @@
|
||||
"already_configured": "Accessory is already configured with this controller.",
|
||||
"already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]",
|
||||
"already_paired": "This accessory is already paired to another device. Please reset the accessory and try again.",
|
||||
"ignored_model": "HomeKit support for this model is blocked as a more feature complete native integration is available.",
|
||||
"ignored_model": "HomeKit support for this model is blocked as a more feature-complete native integration is available.",
|
||||
"invalid_config_entry": "This device is showing as ready to pair but there is already a conflicting configuration entry for it in Home Assistant that must first be removed.",
|
||||
"invalid_properties": "Invalid properties announced by device.",
|
||||
"no_devices": "No unpaired devices could be found"
|
||||
@@ -22,11 +22,11 @@
|
||||
"flow_title": "{name} ({category})",
|
||||
"step": {
|
||||
"busy_error": {
|
||||
"description": "Abort pairing on all controllers, or try restarting the device, then continue to resume pairing.",
|
||||
"description": "Abort pairing on all controllers, or try restarting the device, then try pairing again.",
|
||||
"title": "The device is already pairing with another controller"
|
||||
},
|
||||
"max_tries_error": {
|
||||
"description": "The device has received more than 100 unsuccessful authentication attempts. Try restarting the device, then continue to resume pairing.",
|
||||
"description": "The device has received more than 100 unsuccessful authentication attempts. Try restarting the device, then try pairing again.",
|
||||
"title": "Maximum authentication attempts exceeded"
|
||||
},
|
||||
"pair": {
|
||||
@@ -38,7 +38,7 @@
|
||||
"title": "Pair with a device via HomeKit Accessory Protocol"
|
||||
},
|
||||
"protocol_error": {
|
||||
"description": "The device may not be in pairing mode and may require a physical or virtual button press. Ensure the device is in pairing mode or try restarting the device, then continue to resume pairing.",
|
||||
"description": "The device may not be in pairing mode and may require a physical or virtual button press. Ensure the device is in pairing mode or try restarting the device, then try pairing again.",
|
||||
"title": "Error communicating with the accessory"
|
||||
},
|
||||
"user": {
|
||||
|
||||
@@ -10,7 +10,7 @@ import voluptuous as vol
|
||||
|
||||
from homeassistant import config_entries
|
||||
from homeassistant.components.http import KEY_HASS, HomeAssistantView
|
||||
from homeassistant.const import Platform
|
||||
from homeassistant.const import CONF_ACTIONS, Platform
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import config_validation as cv, discovery
|
||||
@@ -41,7 +41,6 @@ from .const import (
|
||||
CONF_ACTION_SHOW_IN_CARPLAY,
|
||||
CONF_ACTION_SHOW_IN_WATCH,
|
||||
CONF_ACTION_USE_CUSTOM_COLORS,
|
||||
CONF_ACTIONS,
|
||||
DOMAIN,
|
||||
)
|
||||
|
||||
|
||||
@@ -32,8 +32,6 @@ CONF_ACTION_LABEL_TEXT = "text"
|
||||
CONF_ACTION_ICON = "icon"
|
||||
CONF_ACTION_ICON_COLOR = "color"
|
||||
CONF_ACTION_ICON_ICON = "icon"
|
||||
# pylint: disable-next=home-assistant-duplicate-const
|
||||
CONF_ACTIONS = "actions"
|
||||
CONF_ACTION_SHOW_IN_CARPLAY = "show_in_carplay"
|
||||
CONF_ACTION_SHOW_IN_WATCH = "show_in_watch"
|
||||
CONF_ACTION_USE_CUSTOM_COLORS = "use_custom_colors"
|
||||
|
||||
@@ -1,243 +0,0 @@
|
||||
"""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
|
||||
@@ -1,27 +0,0 @@
|
||||
"""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")
|
||||
@@ -1,7 +0,0 @@
|
||||
"""Constants for the Sandbox integration."""
|
||||
|
||||
from homeassistant.util.hass_dict import HassKey
|
||||
|
||||
DOMAIN = "sandbox"
|
||||
|
||||
DATA_SANDBOX: HassKey["SandboxData"] = HassKey(DOMAIN)
|
||||
@@ -1,280 +0,0 @@
|
||||
"""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",
|
||||
]
|
||||
@@ -1,59 +0,0 @@
|
||||
"""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)
|
||||
@@ -1,19 +0,0 @@
|
||||
"""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"
|
||||
@@ -1,15 +0,0 @@
|
||||
"""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")
|
||||
@@ -1,60 +0,0 @@
|
||||
"""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
|
||||
@@ -1,135 +0,0 @@
|
||||
"""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")
|
||||
@@ -1,84 +0,0 @@
|
||||
"""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)
|
||||
@@ -1,27 +0,0 @@
|
||||
"""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())
|
||||
@@ -1,30 +0,0 @@
|
||||
"""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())
|
||||
@@ -1,82 +0,0 @@
|
||||
"""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")
|
||||
@@ -1,40 +0,0 @@
|
||||
"""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)
|
||||
@@ -1,80 +0,0 @@
|
||||
"""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)
|
||||
@@ -1,75 +0,0 @@
|
||||
"""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)
|
||||
@@ -1,42 +0,0 @@
|
||||
"""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")
|
||||
@@ -1,134 +0,0 @@
|
||||
"""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)
|
||||
@@ -1,64 +0,0 @@
|
||||
"""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)
|
||||
@@ -1,170 +0,0 @@
|
||||
"""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)
|
||||
@@ -1,26 +0,0 @@
|
||||
"""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)
|
||||
@@ -1,42 +0,0 @@
|
||||
"""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)
|
||||
@@ -1,51 +0,0 @@
|
||||
"""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)
|
||||
@@ -1,17 +0,0 @@
|
||||
"""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)
|
||||
@@ -1,29 +0,0 @@
|
||||
"""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)
|
||||
@@ -1,29 +0,0 @@
|
||||
"""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")
|
||||
@@ -1,42 +0,0 @@
|
||||
"""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)
|
||||
@@ -1,29 +0,0 @@
|
||||
"""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)
|
||||
@@ -1,37 +0,0 @@
|
||||
"""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)
|
||||
@@ -1,27 +0,0 @@
|
||||
"""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())
|
||||
@@ -1,71 +0,0 @@
|
||||
"""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)
|
||||
@@ -1,63 +0,0 @@
|
||||
"""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)
|
||||
@@ -1,73 +0,0 @@
|
||||
"""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)
|
||||
@@ -1,63 +0,0 @@
|
||||
"""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)
|
||||
@@ -1,69 +0,0 @@
|
||||
"""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)
|
||||
@@ -1,85 +0,0 @@
|
||||
"""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")
|
||||
@@ -1,98 +0,0 @@
|
||||
"""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
|
||||
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
{
|
||||
"config": {
|
||||
"step": {
|
||||
"user": {
|
||||
"title": "Sandbox Configuration",
|
||||
"description": "Configure entries to run in a sandbox process."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,811 +0,0 @@
|
||||
"""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"])
|
||||
@@ -10,7 +10,7 @@ import voluptuous as vol
|
||||
|
||||
from homeassistant.components import onboarding
|
||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||
from homeassistant.const import CONF_HOST
|
||||
from homeassistant.const import CONF_DEVICE, CONF_HOST
|
||||
from homeassistant.data_entry_flow import AbortFlow
|
||||
from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo
|
||||
from homeassistant.util.network import is_ip_address
|
||||
@@ -21,9 +21,6 @@ from .utils import _short_mac, name_from_bulb_type_and_mac
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
# pylint: disable-next=home-assistant-duplicate-const
|
||||
CONF_DEVICE = "device"
|
||||
|
||||
|
||||
class WizConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow for WiZ."""
|
||||
|
||||
@@ -1,78 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,256 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,52 +0,0 @@
|
||||
# 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).
|
||||
@@ -1,96 +0,0 @@
|
||||
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
|
||||
|
@@ -1,220 +0,0 @@
|
||||
"""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}")
|
||||
@@ -1,593 +0,0 @@
|
||||
<!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>
|
||||
-42
@@ -1,42 +0,0 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
test-core:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup uv
|
||||
uses: astral-sh/setup-uv@v7
|
||||
with:
|
||||
python-version: "3.14"
|
||||
enable-cache: true
|
||||
cache-dependency-glob: |
|
||||
pyproject.toml
|
||||
uv.lock
|
||||
core/**/*requirements*.txt
|
||||
core/**/*constraints*.txt
|
||||
|
||||
- name: Setup Home Assistant core
|
||||
run: ./script/setup
|
||||
|
||||
- name: Verify lockfile
|
||||
run: uv lock --check
|
||||
|
||||
- name: Run repo tests
|
||||
run: ./script/test-api -q
|
||||
|
||||
- name: Run compatibility tests
|
||||
run: ./script/test-core -q
|
||||
@@ -1,12 +0,0 @@
|
||||
.cache/
|
||||
.coverage
|
||||
.mypy_cache/
|
||||
.pytest_cache/
|
||||
.ruff_cache/
|
||||
.venv/
|
||||
core/
|
||||
*.egg-info/
|
||||
*.pyc
|
||||
__pycache__/
|
||||
build/
|
||||
dist/
|
||||
@@ -1 +0,0 @@
|
||||
3.14
|
||||
@@ -1,73 +0,0 @@
|
||||
# hass-client
|
||||
|
||||
`hass-client` is a Python compatibility layer for Home Assistant that keeps the
|
||||
`homeassistant.core.HomeAssistant` API surface available in a standalone Python
|
||||
process while sourcing remote data from a Home Assistant websocket connection.
|
||||
|
||||
The current focus is the compatibility harness:
|
||||
|
||||
- Preserve `HomeAssistant`, `StateMachine`, `EventBus`, and `ServiceRegistry`
|
||||
semantics from core.
|
||||
- Add an opt-in remote websocket client for state, service, and entity-registry
|
||||
sync.
|
||||
- Run Home Assistant core tests against a `RemoteHomeAssistant` subclass by
|
||||
loading a pytest bridge instead of modifying the core checkout.
|
||||
|
||||
## Test Harness
|
||||
|
||||
This repository is set up for `uv`. Pin the local interpreter with:
|
||||
|
||||
```bash
|
||||
uv python pin 3.14
|
||||
```
|
||||
|
||||
Set up the repo-local Home Assistant core checkout with:
|
||||
|
||||
```bash
|
||||
./script/setup
|
||||
```
|
||||
|
||||
This does a shallow clone into `./core` and keeps remote branch tips available,
|
||||
so you can check out a different Home Assistant branch in place when needed.
|
||||
|
||||
Update the currently checked out `./core` branch from its configured upstream with:
|
||||
|
||||
```bash
|
||||
./script/bootstrap
|
||||
```
|
||||
|
||||
Run the Home Assistant core compatibility suite with:
|
||||
|
||||
```bash
|
||||
./script/test-core -q
|
||||
```
|
||||
|
||||
Run the repo-local API tests with:
|
||||
|
||||
```bash
|
||||
./script/test-api -q
|
||||
```
|
||||
|
||||
By default this runs:
|
||||
|
||||
- `tests/test_*.py`
|
||||
- `tests/helpers`
|
||||
|
||||
The compatibility harness expects Home Assistant core to live in `./core`.
|
||||
|
||||
Useful environment variables:
|
||||
|
||||
- `HASS_CLIENT_TOKEN`
|
||||
- `HASS_CLIENT_SSL` (`true` / `false`)
|
||||
- `HASS_CLIENT_SYNC_STATES`
|
||||
- `HASS_CLIENT_SYNC_ENTITY_REGISTRY`
|
||||
- `HASS_CLIENT_SYNC_REMOTE_SERVICES`
|
||||
- `HASS_CLIENT_CORE_REPO`
|
||||
- `HASS_CLIENT_CORE_BRANCH`
|
||||
Used by `./script/setup` for the initial checkout branch.
|
||||
- `HASS_CLIENT_PYTHON`
|
||||
- `HASS_CLIENT_TEST_TARGET`
|
||||
Use a whitespace-separated pytest target list, for example:
|
||||
`HASS_CLIENT_TEST_TARGET="tests/test_core.py tests/helpers/test_event.py"`
|
||||
|
||||
Remote sync is enabled only when `HASS_CLIENT_WS_URL` is set.
|
||||
@@ -1,116 +0,0 @@
|
||||
# Remote API Gaps In Home Assistant Core
|
||||
|
||||
This note captures the Home Assistant APIs that are still missing, or not strong enough, to make `hass-client` a perfect remote implementation of `hass`.
|
||||
|
||||
It is intentionally not a `hass-client` TODO list. Some features below already exist in Home Assistant and just need to be consumed by the client. The focus here is the gap in Home Assistant core itself.
|
||||
|
||||
## Biggest Gaps
|
||||
|
||||
### 1. Remote entity ownership API
|
||||
|
||||
This is the main blocker.
|
||||
|
||||
Home Assistant exposes:
|
||||
|
||||
- websocket state subscriptions
|
||||
- service calls
|
||||
- raw state writes through `/api/states/<entity_id>`
|
||||
|
||||
What it does not expose is a public API to let an external process behave like a real integration platform that owns entities. A complete remote API needs operations to:
|
||||
|
||||
- register a remote entity
|
||||
- update entity state through the entity model instead of raw state injection
|
||||
- remove an entity
|
||||
- attach integration metadata such as `unique_id`, `device_info`, entity category, capabilities, translation metadata, and ownership details
|
||||
|
||||
Without that, a remote integration can imitate state, but not actually behave like a real Home Assistant integration.
|
||||
|
||||
### 2. Device registry create and lookup APIs
|
||||
|
||||
The current public device registry websocket API is missing the operations needed for remote `device_info` support.
|
||||
|
||||
Missing:
|
||||
|
||||
- get a single device by id
|
||||
- create a device
|
||||
- get-or-create semantics
|
||||
- a full subscription API with enough data to maintain a local mirror efficiently
|
||||
|
||||
Without these APIs, a remote integration host cannot correctly model device ownership.
|
||||
|
||||
### 3. Entity registry create/register API
|
||||
|
||||
The entity registry websocket API supports reading, updating, and removing entries, but not creating them.
|
||||
|
||||
Missing:
|
||||
|
||||
- create/register entity registry entry
|
||||
|
||||
This prevents a remote integration host from creating entity registry entries through the public API.
|
||||
|
||||
## Sync And Delta Gaps
|
||||
|
||||
### 4. Full-entry events for registries
|
||||
|
||||
Area, floor, label, and category registries mostly expose CRUD APIs already. The remaining problem is efficient synchronization.
|
||||
|
||||
Current registry events generally provide only:
|
||||
|
||||
- action
|
||||
- id
|
||||
|
||||
They usually do not include the full updated entry payload. For a remote mirror, this means:
|
||||
|
||||
- every change may require re-fetching the whole registry
|
||||
- there is no efficient incremental sync path
|
||||
|
||||
The ideal fix is either:
|
||||
|
||||
- full-entry payloads on create and update events, or
|
||||
- a `get_single` API for each registry
|
||||
|
||||
### 5. Better device registry delta events
|
||||
|
||||
Device registry events do not provide enough payload on create and update to keep a local mirror current without re-listing everything.
|
||||
|
||||
The ideal fix is:
|
||||
|
||||
- full device payload on create
|
||||
- full device payload on update
|
||||
- optionally a dedicated `config/device_registry/get`
|
||||
|
||||
### 6. Service registry delta API with descriptions
|
||||
|
||||
Home Assistant exposes a full service snapshot, but register/remove events only identify the domain and service name.
|
||||
|
||||
Missing:
|
||||
|
||||
- service delta events that include the full service description and field schema
|
||||
|
||||
Without that, a remote mirror has to re-fetch the full service map whenever services are added or removed.
|
||||
|
||||
## Already Present In Home Assistant
|
||||
|
||||
These are not missing in Home Assistant core. They are available already and only need to be consumed by `hass-client`.
|
||||
|
||||
### Config entries
|
||||
|
||||
Config entries already have a usable remote API surface for listing, fetching, and subscribing.
|
||||
|
||||
### Area, floor, label, and category CRUD
|
||||
|
||||
These registries already expose CRUD operations. The missing part is efficient synchronization, not basic access.
|
||||
|
||||
## Suggested HA-Core Wishlist
|
||||
|
||||
If we want to propose concrete Home Assistant additions, the highest value items are:
|
||||
|
||||
1. A remote entity platform API for create, update, and remove with full integration metadata.
|
||||
2. Device registry `get`, `create`, and `get_or_create` APIs.
|
||||
3. Entity registry create/register API.
|
||||
4. Full-entry delta subscriptions, or `get_single`, for area, floor, label, and category registries.
|
||||
5. Service registry delta events with full service descriptions.
|
||||
|
||||
## Why This Matters
|
||||
|
||||
`hass-client` can already get close by mirroring states, services, and parts of the registries. The remaining gap is not simple data access. The real missing piece is the ability for an external process to participate in Home Assistant with the same ownership and lifecycle semantics as an in-process integration.
|
||||
@@ -1,123 +0,0 @@
|
||||
# Sandbox Integration Compatibility Report
|
||||
|
||||
Tested with `pytest -p hass_client.testing.conftest_sandbox` which runs each
|
||||
integration's test suite through a real websocket connection to a host HA Core
|
||||
with the sandbox integration.
|
||||
|
||||
## Setup
|
||||
|
||||
The sandbox client's `pyproject.toml` only pulls in the minimal deps needed to
|
||||
run the client and its own tests. To run HA Core's per-integration test suites
|
||||
through it, also install Core's full dependency tree:
|
||||
|
||||
```
|
||||
cd sandbox/hass_client
|
||||
uv sync
|
||||
uv pip install -r requirements_ha.txt
|
||||
```
|
||||
|
||||
`requirements_ha.txt` references `../../requirements_all.txt` (and
|
||||
`../../requirements_test.txt`) so it stays in sync with Core's pinned deps.
|
||||
On macOS, `pyitachip2ir` (the `itach` integration) fails to compile — see the
|
||||
comment in `requirements_ha.txt` for the workaround.
|
||||
|
||||
## Results Summary
|
||||
|
||||
| Integration | Tests | Passed | Failed | Status |
|
||||
|----------------------|-------|--------|--------|--------|
|
||||
| input_boolean | 16 | 16 | 0 | PASS |
|
||||
| input_button | 14 | 14 | 0 | PASS |
|
||||
| input_datetime | 26 | 26 | 0 | PASS |
|
||||
| input_number | 22 | 22 | 0 | PASS |
|
||||
| input_select | 24 | 24 | 0 | PASS |
|
||||
| input_text | 21 | 21 | 0 | PASS |
|
||||
| counter | 18 | 18 | 0 | PASS |
|
||||
| timer | 30 | 30 | 0 | PASS |
|
||||
| schedule | 25 | 25 | 0 | PASS |
|
||||
| zone | 22 | 22 | 0 | PASS |
|
||||
| tag | 7 | 7 | 0 | PASS |
|
||||
| group | 130 | 130 | 0 | PASS |
|
||||
| person | 32 | 32 | 0 | PASS |
|
||||
| scene | 7 | 7 | 0 | PASS |
|
||||
| todo | 71 | 71 | 0 | PASS |
|
||||
| automation | 112 | 111 | 1 | PARTIAL |
|
||||
| script | 62 | 61 | 1 | PARTIAL |
|
||||
| alert | 17 | 17 | 0 | PASS |
|
||||
| template | 20 | 20 | 0 | PASS |
|
||||
| plant | 11 | 11 | 0 | PASS |
|
||||
| proximity | 22 | 22 | 0 | PASS |
|
||||
| min_max | 1 | 1 | 0 | PASS |
|
||||
| statistics | 8 | 8 | 0 | PASS |
|
||||
| utility_meter | 25 | 25 | 0 | PASS |
|
||||
| derivative | 13 | 13 | 0 | PASS |
|
||||
| integration | 9 | 9 | 0 | PASS |
|
||||
| generic_thermostat | 13 | 13 | 0 | PASS |
|
||||
| generic_hygrostat | 12 | 12 | 0 | PASS |
|
||||
| history_stats | 9 | 9 | 0 | PASS |
|
||||
| threshold | 9 | 9 | 0 | PASS |
|
||||
| filter | 1 | 1 | 0 | PASS |
|
||||
| mqtt_statestream | 18 | 18 | 0 | PASS |
|
||||
| recorder | 93 | 93 | 0 | PASS |
|
||||
| rest | 10 | 10 | 0 | PASS |
|
||||
| logbook | 55 | 55 | 0 | PASS |
|
||||
| command_line | 7 | 7 | 0 | PASS |
|
||||
| trend | 9 | 9 | 0 | PASS |
|
||||
|
||||
**35 of 37 integrations fully pass. 955 of 957 tests pass (99.8%).**
|
||||
|
||||
## Remaining Failures
|
||||
|
||||
### automation: 1 failure (pre-existing)
|
||||
|
||||
- `test_logbook_humanify_automation_triggered_event`: `mock_humanify` returns 0
|
||||
events. The logbook platform discovery doesn't find the automation logbook
|
||||
callback. This also fails with the base plugin (no websocket) — it is a
|
||||
pre-existing issue in the hass-client test environment, not a sandbox bug.
|
||||
|
||||
### script: 1 failure (pre-existing)
|
||||
|
||||
- `test_logbook_humanify_script_started_event`: Same root cause as the automation
|
||||
logbook test. Also fails with the base plugin.
|
||||
|
||||
## Newly Runnable, Still Investigating
|
||||
|
||||
### conversation: 8 fail, 11 pass, 2 hang (out of 21)
|
||||
|
||||
Now that `hassil` is installed, conversation tests collect and partially run.
|
||||
Of the 21 collected tests, 8 fail in the first batch, 11 pass, and the run
|
||||
deadlocks before completing tests 20–21 (perl alarm SIGTERM after 600s).
|
||||
Failures and the hang are unrelated to missing deps and need their own
|
||||
investigation — likely interaction between conversation's chat-session helpers
|
||||
and the live sandbox websocket. Not counted in the 35/37 figure above.
|
||||
|
||||
## Not Tested
|
||||
|
||||
These integrations were previously listed as missing deps; after running
|
||||
`uv pip install -r requirements_ha.txt` they import and run normally. No
|
||||
remaining "missing dependency" cases in this report.
|
||||
|
||||
## Fixes Applied
|
||||
|
||||
### Fix 1: Freezer detection fallback (conftest_sandbox.py)
|
||||
|
||||
Tests using `freezer.move_to()` hang with live websocket connections because
|
||||
time jumps break async heartbeat timers. The sandbox plugin detects the `freezer`
|
||||
fixture in `pytest_runtest_setup` and falls back to the base plugin (no websocket)
|
||||
for those tests.
|
||||
|
||||
### Fix 2: Host HA cleanup (conftest_sandbox.py)
|
||||
|
||||
The host HA instance (running websocket_api + sandbox) was never explicitly
|
||||
stopped after tests. Its scheduled timers (storage delayed writes, cleanup
|
||||
intervals) lingered on the event loop, causing `verify_cleanup` teardown errors
|
||||
in integrations that load more components. Fixed by calling
|
||||
`await host_hass.async_stop(force=True)` in the sandbox teardown.
|
||||
|
||||
### Fix 3: Service fallback guard (runtime.py)
|
||||
|
||||
`HybridServiceRegistry.async_call` caught `ServiceNotFound` for any service and
|
||||
tried the remote API, even for services that don't exist anywhere. This broke
|
||||
tests that expect `ServiceNotFound` for genuinely nonexistent services (e.g.,
|
||||
`non.existing` in a script action). Fixed by checking the remote service cache
|
||||
before falling through — only attempt remote calls for services known to exist
|
||||
remotely.
|
||||
@@ -1,8 +0,0 @@
|
||||
"""Root conftest - add HA Core tests to sys.path."""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
core_root = Path(__file__).parent / ".." / "core"
|
||||
if core_root.exists() and str(core_root) not in sys.path:
|
||||
sys.path.insert(0, str(core_root))
|
||||
@@ -1,15 +0,0 @@
|
||||
"""Remote-capable Home Assistant compatibility helpers."""
|
||||
|
||||
from .api import HomeAssistantAPI
|
||||
from .config import RemoteConfig
|
||||
|
||||
__all__ = ["HomeAssistantAPI", "RemoteConfig", "RemoteHomeAssistant"]
|
||||
|
||||
|
||||
def __getattr__(name: str):
|
||||
"""Lazily import runtime objects to avoid importing Home Assistant too early."""
|
||||
if name == "RemoteHomeAssistant":
|
||||
from .runtime import RemoteHomeAssistant
|
||||
|
||||
return RemoteHomeAssistant
|
||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||
@@ -1,661 +0,0 @@
|
||||
"""Home Assistant websocket API client."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Awaitable, Callable, Mapping
|
||||
import logging
|
||||
import os
|
||||
from ssl import SSLContext
|
||||
from typing import Any, cast
|
||||
|
||||
import aiohttp
|
||||
from aiohttp import (
|
||||
ClientSession,
|
||||
ClientWebSocketResponse,
|
||||
Fingerprint,
|
||||
TCPConnector,
|
||||
WSMsgType,
|
||||
client_exceptions,
|
||||
)
|
||||
|
||||
from .exceptions import (
|
||||
AuthenticationFailed,
|
||||
CannotConnect,
|
||||
ConnectionFailed,
|
||||
ConnectionFailedDueToLargeMessage,
|
||||
FailedCommand,
|
||||
InvalidMessage,
|
||||
NotConnected,
|
||||
)
|
||||
|
||||
LOGGER = logging.getLogger(__name__)
|
||||
MAX_MESSAGE_SIZE = 16 * 1024 * 1024
|
||||
MATCH_ALL = "*"
|
||||
|
||||
SubscriptionCallback = Callable[[dict[str, Any]], Awaitable[None] | None]
|
||||
|
||||
|
||||
def _normalize_translation_placeholders(
|
||||
placeholders: Any,
|
||||
) -> dict[str, str] | None:
|
||||
"""Normalize websocket translation placeholders into Home Assistant format."""
|
||||
if not isinstance(placeholders, Mapping):
|
||||
return None
|
||||
return {str(key): str(value) for key, value in placeholders.items()}
|
||||
|
||||
|
||||
def _build_failed_command(
|
||||
message: str,
|
||||
*,
|
||||
command: str | None,
|
||||
code: str | None,
|
||||
translation_domain: str | None,
|
||||
translation_key: str | None,
|
||||
translation_placeholders: dict[str, str] | None,
|
||||
) -> FailedCommand:
|
||||
"""Build the generic websocket command failure."""
|
||||
return FailedCommand(
|
||||
message,
|
||||
command=command,
|
||||
code=code,
|
||||
translation_domain=translation_domain,
|
||||
translation_key=translation_key,
|
||||
translation_placeholders=translation_placeholders,
|
||||
)
|
||||
|
||||
|
||||
def _build_homeassistant_error(
|
||||
exception_type,
|
||||
message: str,
|
||||
*,
|
||||
translation_domain: str | None,
|
||||
translation_key: str | None,
|
||||
translation_placeholders: dict[str, str] | None,
|
||||
):
|
||||
"""Build a Home Assistant exception from websocket translation metadata."""
|
||||
if translation_domain and translation_key:
|
||||
return exception_type(
|
||||
translation_domain=translation_domain,
|
||||
translation_key=translation_key,
|
||||
translation_placeholders=translation_placeholders,
|
||||
)
|
||||
return exception_type(message)
|
||||
|
||||
|
||||
def _translate_command_error(
|
||||
command_message: Mapping[str, Any] | None,
|
||||
error: Mapping[str, Any],
|
||||
) -> Exception:
|
||||
"""Translate websocket command errors into Home Assistant exceptions when possible."""
|
||||
command = command_message.get("type") if command_message else None
|
||||
message = str(error.get("message", "Command failed"))
|
||||
code = error.get("code")
|
||||
code = str(code) if code is not None else None
|
||||
translation_domain = error.get("translation_domain")
|
||||
translation_domain = (
|
||||
str(translation_domain) if translation_domain is not None else None
|
||||
)
|
||||
translation_key = error.get("translation_key")
|
||||
translation_key = str(translation_key) if translation_key is not None else None
|
||||
translation_placeholders = _normalize_translation_placeholders(
|
||||
error.get("translation_placeholders")
|
||||
)
|
||||
|
||||
try:
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.websocket_api import const as websocket_api_const
|
||||
from homeassistant.exceptions import (
|
||||
HomeAssistantError,
|
||||
ServiceNotFound,
|
||||
ServiceNotSupported,
|
||||
ServiceValidationError,
|
||||
TemplateError,
|
||||
Unauthorized,
|
||||
)
|
||||
except ImportError:
|
||||
return _build_failed_command(
|
||||
message,
|
||||
command=command,
|
||||
code=code,
|
||||
translation_domain=translation_domain,
|
||||
translation_key=translation_key,
|
||||
translation_placeholders=translation_placeholders,
|
||||
)
|
||||
|
||||
if command in ("call_service", "sandbox/call_service"):
|
||||
if (
|
||||
code == websocket_api_const.ERR_NOT_FOUND
|
||||
and translation_key == "service_not_found"
|
||||
):
|
||||
domain = translation_placeholders.get("domain") if translation_placeholders else None
|
||||
service = (
|
||||
translation_placeholders.get("service")
|
||||
if translation_placeholders
|
||||
else None
|
||||
)
|
||||
if domain is None and command_message is not None:
|
||||
raw_domain = command_message.get("domain")
|
||||
if isinstance(raw_domain, str):
|
||||
domain = raw_domain
|
||||
if service is None and command_message is not None:
|
||||
raw_service = command_message.get("service")
|
||||
if isinstance(raw_service, str):
|
||||
service = raw_service
|
||||
if domain is not None and service is not None:
|
||||
return ServiceNotFound(domain, service)
|
||||
|
||||
if code == websocket_api_const.ERR_INVALID_FORMAT:
|
||||
return vol.MultipleInvalid([vol.Invalid(message)])
|
||||
|
||||
if code == websocket_api_const.ERR_SERVICE_VALIDATION_ERROR:
|
||||
# Reconstruct specific subclasses based on translation_key
|
||||
if (
|
||||
translation_key == "service_not_supported"
|
||||
and translation_placeholders
|
||||
):
|
||||
domain = translation_placeholders.get("domain")
|
||||
service = translation_placeholders.get("service")
|
||||
entity_id = translation_placeholders.get("entity_id")
|
||||
if domain and service and entity_id:
|
||||
return ServiceNotSupported(domain, service, entity_id)
|
||||
|
||||
return _build_homeassistant_error(
|
||||
ServiceValidationError,
|
||||
message,
|
||||
translation_domain=translation_domain,
|
||||
translation_key=translation_key,
|
||||
translation_placeholders=translation_placeholders,
|
||||
)
|
||||
|
||||
if code == websocket_api_const.ERR_HOME_ASSISTANT_ERROR:
|
||||
return _build_homeassistant_error(
|
||||
HomeAssistantError,
|
||||
message,
|
||||
translation_domain=translation_domain,
|
||||
translation_key=translation_key,
|
||||
translation_placeholders=translation_placeholders,
|
||||
)
|
||||
|
||||
if code == websocket_api_const.ERR_TEMPLATE_ERROR:
|
||||
return TemplateError(message)
|
||||
|
||||
if code == websocket_api_const.ERR_UNAUTHORIZED:
|
||||
return Unauthorized()
|
||||
|
||||
return _build_failed_command(
|
||||
message,
|
||||
command=command,
|
||||
code=code,
|
||||
translation_domain=translation_domain,
|
||||
translation_key=translation_key,
|
||||
translation_placeholders=translation_placeholders,
|
||||
)
|
||||
|
||||
|
||||
class HomeAssistantAPI:
|
||||
"""Async websocket client for Home Assistant."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
websocket_url: str,
|
||||
token: str | None,
|
||||
aiohttp_session: ClientSession | None = None,
|
||||
) -> None:
|
||||
"""Initialize the API client."""
|
||||
self._websocket_url = websocket_url
|
||||
self._token = token
|
||||
self._loop = asyncio.get_running_loop()
|
||||
self._http_session_provided = aiohttp_session is not None
|
||||
self._http_session = aiohttp_session
|
||||
self._client: ClientWebSocketResponse | None = None
|
||||
self._listener_task: asyncio.Task[None] | None = None
|
||||
self._shutdown_complete: asyncio.Event | None = None
|
||||
self._subscriptions: dict[int, tuple[dict[str, Any], SubscriptionCallback]] = {}
|
||||
self._result_futures: dict[int, asyncio.Future[Any]] = {}
|
||||
self._result_messages: dict[int, dict[str, Any]] = {}
|
||||
self._last_msg_id = 1
|
||||
self._msg_id_lock = asyncio.Lock()
|
||||
self._version: str | None = None
|
||||
|
||||
@property
|
||||
def connected(self) -> bool:
|
||||
"""Return if the websocket is connected."""
|
||||
return self._client is not None and not self._client.closed
|
||||
|
||||
@property
|
||||
def version(self) -> str | None:
|
||||
"""Return the remote Home Assistant version."""
|
||||
return self._version
|
||||
|
||||
async def start(self, ssl: SSLContext | bool | Fingerprint | None = True) -> None:
|
||||
"""Connect and start the listener."""
|
||||
await self.connect(ssl=ssl)
|
||||
if self._listener_task is None or self._listener_task.done():
|
||||
self._listener_task = self._loop.create_task(self._listen())
|
||||
|
||||
async def stop(self) -> None:
|
||||
"""Stop the listener and disconnect."""
|
||||
if not self.connected and self._listener_task is None:
|
||||
return
|
||||
await self.disconnect()
|
||||
if self._listener_task is not None:
|
||||
await self._listener_task
|
||||
self._listener_task = None
|
||||
|
||||
async def connect(
|
||||
self, ssl: SSLContext | bool | Fingerprint | None = True
|
||||
) -> None:
|
||||
"""Connect to the websocket server."""
|
||||
if self.connected:
|
||||
return
|
||||
|
||||
if not self._http_session_provided and self._http_session is None:
|
||||
self._http_session = ClientSession(
|
||||
connector=TCPConnector(enable_cleanup_closed=True)
|
||||
)
|
||||
|
||||
ws_token = self._token or os.environ.get("HASSIO_TOKEN")
|
||||
if ws_token is None:
|
||||
raise AuthenticationFailed("No Home Assistant access token provided")
|
||||
|
||||
try:
|
||||
assert self._http_session is not None
|
||||
self._client = await self._http_session.ws_connect(
|
||||
self._websocket_url,
|
||||
heartbeat=55,
|
||||
max_msg_size=MAX_MESSAGE_SIZE,
|
||||
ssl=ssl,
|
||||
)
|
||||
hello = await self._client.receive_json()
|
||||
if hello.get("type") != "auth_required":
|
||||
raise InvalidMessage(f"Unexpected auth hello: {hello}")
|
||||
self._version = hello.get("ha_version")
|
||||
await self._client.send_json({"type": "auth", "access_token": ws_token})
|
||||
auth_result = await self._client.receive_json()
|
||||
if auth_result.get("type") != "auth_ok":
|
||||
await self._client.close()
|
||||
raise AuthenticationFailed(
|
||||
auth_result.get("message", "Authentication failed")
|
||||
)
|
||||
except (
|
||||
client_exceptions.WSServerHandshakeError,
|
||||
client_exceptions.ClientError,
|
||||
) as err:
|
||||
raise CannotConnect(err) from err
|
||||
|
||||
async def disconnect(self) -> None:
|
||||
"""Disconnect from Home Assistant."""
|
||||
if self._client is None:
|
||||
return
|
||||
|
||||
self._shutdown_complete = asyncio.Event()
|
||||
await self._client.close()
|
||||
await self._shutdown_complete.wait()
|
||||
self._client = None
|
||||
|
||||
if not self._http_session_provided and self._http_session is not None:
|
||||
await self._http_session.close()
|
||||
self._http_session = None
|
||||
|
||||
async def async_call_service(
|
||||
self,
|
||||
domain: str,
|
||||
service: str,
|
||||
service_data: dict[str, Any] | None = None,
|
||||
target: dict[str, Any] | None = None,
|
||||
return_response: bool = False,
|
||||
context: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Call a Home Assistant service over the websocket API."""
|
||||
payload: dict[str, Any] = {
|
||||
"domain": domain,
|
||||
"service": service,
|
||||
"return_response": return_response,
|
||||
}
|
||||
if service_data:
|
||||
payload["service_data"] = service_data
|
||||
if target:
|
||||
payload["target"] = target
|
||||
|
||||
# Use sandbox/call_service when context is provided, which forwards
|
||||
# the full context (user_id, parent_id, id) for permission checks
|
||||
# and context tracking.
|
||||
if context:
|
||||
payload["context"] = context
|
||||
return cast(
|
||||
dict[str, Any],
|
||||
await self.send_command("sandbox/call_service", **payload),
|
||||
)
|
||||
|
||||
return cast(dict[str, Any], await self.send_command("call_service", **payload))
|
||||
|
||||
async def async_get_states(self) -> list[dict[str, Any]]:
|
||||
"""Fetch all remote states."""
|
||||
return cast(list[dict[str, Any]], await self.send_command("get_states"))
|
||||
|
||||
async def async_get_config(self) -> dict[str, Any]:
|
||||
"""Fetch remote config."""
|
||||
return cast(dict[str, Any], await self.send_command("get_config"))
|
||||
|
||||
async def async_get_services(self) -> dict[str, dict[str, Any]]:
|
||||
"""Fetch remote services."""
|
||||
return cast(dict[str, dict[str, Any]], await self.send_command("get_services"))
|
||||
|
||||
async def async_get_entity_registry(self) -> list[dict[str, Any]]:
|
||||
"""Fetch remote entity registry entries."""
|
||||
return cast(
|
||||
list[dict[str, Any]],
|
||||
await self.send_command("config/entity_registry/list"),
|
||||
)
|
||||
|
||||
async def async_get_entity_registry_entry(self, entity_id: str) -> dict[str, Any]:
|
||||
"""Fetch a single remote entity registry entry."""
|
||||
return cast(
|
||||
dict[str, Any],
|
||||
await self.send_command("config/entity_registry/get", entity_id=entity_id),
|
||||
)
|
||||
|
||||
# -- Sandbox API methods --
|
||||
|
||||
async def async_sandbox_get_entries(self) -> list[dict[str, Any]]:
|
||||
"""Fetch config entries assigned to this sandbox token."""
|
||||
return cast(
|
||||
list[dict[str, Any]],
|
||||
await self.send_command("sandbox/get_entries"),
|
||||
)
|
||||
|
||||
async def async_sandbox_update_entry(
|
||||
self,
|
||||
sandbox_entry_id: str,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
"""Update a sandbox config entry's stored data."""
|
||||
await self.send_command(
|
||||
"sandbox/update_entry",
|
||||
sandbox_entry_id=sandbox_entry_id,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
async def async_sandbox_register_device(
|
||||
self,
|
||||
sandbox_entry_id: str,
|
||||
identifiers: list[dict[str, str]],
|
||||
**kwargs: Any,
|
||||
) -> dict[str, Any]:
|
||||
"""Register a device in HA Core via the sandbox API."""
|
||||
return cast(
|
||||
dict[str, Any],
|
||||
await self.send_command(
|
||||
"sandbox/register_device",
|
||||
sandbox_entry_id=sandbox_entry_id,
|
||||
identifiers=identifiers,
|
||||
**kwargs,
|
||||
),
|
||||
)
|
||||
|
||||
async def async_sandbox_update_device(
|
||||
self, device_id: str, **kwargs: Any
|
||||
) -> None:
|
||||
"""Update a device in HA Core via the sandbox API."""
|
||||
await self.send_command(
|
||||
"sandbox/update_device", device_id=device_id, **kwargs
|
||||
)
|
||||
|
||||
async def async_sandbox_remove_device(self, device_id: str) -> None:
|
||||
"""Remove a device from HA Core via the sandbox API."""
|
||||
await self.send_command("sandbox/remove_device", device_id=device_id)
|
||||
|
||||
async def async_sandbox_register_entity(
|
||||
self,
|
||||
sandbox_entry_id: str,
|
||||
domain: str,
|
||||
platform: str,
|
||||
unique_id: str,
|
||||
**kwargs: Any,
|
||||
) -> dict[str, Any]:
|
||||
"""Register an entity in HA Core via the sandbox API."""
|
||||
return cast(
|
||||
dict[str, Any],
|
||||
await self.send_command(
|
||||
"sandbox/register_entity",
|
||||
sandbox_entry_id=sandbox_entry_id,
|
||||
domain=domain,
|
||||
platform=platform,
|
||||
unique_id=unique_id,
|
||||
**kwargs,
|
||||
),
|
||||
)
|
||||
|
||||
async def async_sandbox_update_entity(
|
||||
self, entity_id: str, **kwargs: Any
|
||||
) -> None:
|
||||
"""Update an entity registry entry in HA Core via the sandbox API."""
|
||||
await self.send_command(
|
||||
"sandbox/update_entity", entity_id=entity_id, **kwargs
|
||||
)
|
||||
|
||||
async def async_sandbox_update_state(
|
||||
self,
|
||||
entity_id: str,
|
||||
state: str,
|
||||
attributes: dict[str, Any] | None = None,
|
||||
) -> None:
|
||||
"""Push an entity state update to HA Core via the sandbox API."""
|
||||
kwargs: dict[str, Any] = {
|
||||
"entity_id": entity_id,
|
||||
"state": state,
|
||||
}
|
||||
if attributes is not None:
|
||||
kwargs["attributes"] = attributes
|
||||
await self.send_command("sandbox/update_state", **kwargs)
|
||||
|
||||
async def async_sandbox_remove_entity(self, entity_id: str) -> None:
|
||||
"""Remove a sandbox entity from HA Core."""
|
||||
await self.send_command("sandbox/remove_entity", entity_id=entity_id)
|
||||
|
||||
async def async_sandbox_entity_command_result(
|
||||
self,
|
||||
call_id: str,
|
||||
success: bool,
|
||||
result: Any = None,
|
||||
error: str | None = None,
|
||||
) -> None:
|
||||
"""Send the result of an entity command back to the host."""
|
||||
kwargs: dict[str, Any] = {
|
||||
"call_id": call_id,
|
||||
"success": success,
|
||||
}
|
||||
if result is not None:
|
||||
kwargs["result"] = result
|
||||
if error is not None:
|
||||
kwargs["error"] = error
|
||||
await self.send_command("sandbox/entity_command_result", **kwargs)
|
||||
|
||||
async def async_sandbox_register_service(
|
||||
self, domain: str, service: str
|
||||
) -> None:
|
||||
"""Register a service on the host on behalf of the sandbox."""
|
||||
await self.send_command(
|
||||
"sandbox/register_service", domain=domain, service=service
|
||||
)
|
||||
|
||||
async def async_sandbox_service_call_result(
|
||||
self,
|
||||
call_id: str,
|
||||
success: bool,
|
||||
result: Any = None,
|
||||
error: str | None = None,
|
||||
error_type: str | None = None,
|
||||
translation_domain: str | None = None,
|
||||
translation_key: str | None = None,
|
||||
translation_placeholders: dict[str, str] | None = None,
|
||||
) -> None:
|
||||
"""Send the result of a forwarded service call back to the host."""
|
||||
kwargs: dict[str, Any] = {
|
||||
"call_id": call_id,
|
||||
"success": success,
|
||||
}
|
||||
if result is not None:
|
||||
kwargs["result"] = result
|
||||
if error is not None:
|
||||
kwargs["error"] = error
|
||||
if error_type is not None:
|
||||
kwargs["error_type"] = error_type
|
||||
if translation_domain is not None:
|
||||
kwargs["translation_domain"] = translation_domain
|
||||
if translation_key is not None:
|
||||
kwargs["translation_key"] = translation_key
|
||||
if translation_placeholders is not None:
|
||||
kwargs["translation_placeholders"] = translation_placeholders
|
||||
await self.send_command("sandbox/service_call_result", **kwargs)
|
||||
|
||||
async def send_command(self, command: str, **payload: Any) -> Any:
|
||||
"""Send a command and await the result."""
|
||||
if not self.connected:
|
||||
raise NotConnected("Call start() before sending commands")
|
||||
|
||||
future: asyncio.Future[Any] = self._loop.create_future()
|
||||
message_id = await self._next_message_id()
|
||||
message = {"id": message_id, "type": command, **payload}
|
||||
self._result_futures[message_id] = future
|
||||
self._result_messages[message_id] = message
|
||||
await self._send_json(message)
|
||||
try:
|
||||
return await future
|
||||
finally:
|
||||
self._result_futures.pop(message_id, None)
|
||||
self._result_messages.pop(message_id, None)
|
||||
|
||||
async def send_command_no_wait(self, command: str, **payload: Any) -> None:
|
||||
"""Send a command without waiting for the result."""
|
||||
if not self.connected:
|
||||
raise NotConnected("Call start() before sending commands")
|
||||
message_id = await self._next_message_id()
|
||||
await self._send_json({"id": message_id, "type": command, **payload})
|
||||
|
||||
async def subscribe_events(
|
||||
self,
|
||||
callback: SubscriptionCallback,
|
||||
event_type: str = MATCH_ALL,
|
||||
) -> Callable[[], None]:
|
||||
"""Subscribe to Home Assistant events."""
|
||||
return await self.subscribe(callback, "subscribe_events", event_type=event_type)
|
||||
|
||||
async def subscribe_entities(
|
||||
self,
|
||||
callback: SubscriptionCallback,
|
||||
entity_ids: list[str],
|
||||
) -> Callable[[], None]:
|
||||
"""Subscribe to entity websocket updates."""
|
||||
return await self.subscribe(
|
||||
callback, "subscribe_entities", entity_ids=entity_ids
|
||||
)
|
||||
|
||||
async def subscribe(
|
||||
self,
|
||||
callback: SubscriptionCallback,
|
||||
command: str,
|
||||
**payload: Any,
|
||||
) -> Callable[[], None]:
|
||||
"""Register a websocket subscription."""
|
||||
message_id = await self._next_message_id()
|
||||
message = {"id": message_id, "type": command, **payload}
|
||||
|
||||
future: asyncio.Future[Any] = self._loop.create_future()
|
||||
self._result_futures[message_id] = future
|
||||
self._result_messages[message_id] = message
|
||||
try:
|
||||
await self._send_json(message)
|
||||
await future
|
||||
finally:
|
||||
self._result_futures.pop(message_id, None)
|
||||
self._result_messages.pop(message_id, None)
|
||||
|
||||
self._subscriptions[message_id] = (message, callback)
|
||||
|
||||
def unsubscribe() -> None:
|
||||
"""Remove the subscription and unsubscribe remotely."""
|
||||
message = self._subscriptions.pop(message_id, None)
|
||||
if message is None:
|
||||
return
|
||||
if command == "subscribe_events":
|
||||
self._loop.create_task(
|
||||
self.send_command_no_wait(
|
||||
"unsubscribe_events", subscription=message_id
|
||||
)
|
||||
)
|
||||
|
||||
return unsubscribe
|
||||
|
||||
async def _listen(self) -> None:
|
||||
"""Process inbound websocket frames."""
|
||||
assert self._client is not None
|
||||
|
||||
try:
|
||||
while not self._client.closed:
|
||||
msg = await self._client.receive()
|
||||
|
||||
if msg.type in (WSMsgType.CLOSE, WSMsgType.CLOSED, WSMsgType.CLOSING):
|
||||
break
|
||||
|
||||
if msg.type == WSMsgType.ERROR:
|
||||
if msg.data.code == aiohttp.WSCloseCode.MESSAGE_TOO_BIG:
|
||||
raise ConnectionFailedDueToLargeMessage
|
||||
raise ConnectionFailed
|
||||
|
||||
if msg.type != WSMsgType.TEXT:
|
||||
raise InvalidMessage(f"Unexpected websocket message type: {msg.type}")
|
||||
|
||||
try:
|
||||
data = msg.json()
|
||||
except ValueError as err:
|
||||
raise InvalidMessage("Received invalid JSON") from err
|
||||
|
||||
self._handle_message(data)
|
||||
finally:
|
||||
for future in self._result_futures.values():
|
||||
if not future.done():
|
||||
future.cancel()
|
||||
|
||||
if self._shutdown_complete is not None:
|
||||
self._shutdown_complete.set()
|
||||
|
||||
def _handle_message(self, message: dict[str, Any]) -> None:
|
||||
"""Handle an inbound websocket message."""
|
||||
if message.get("type") == "result":
|
||||
message_id = message["id"]
|
||||
future = self._result_futures.get(message_id)
|
||||
if future is None:
|
||||
return
|
||||
if message.get("success"):
|
||||
future.set_result(message.get("result"))
|
||||
return
|
||||
error = message.get("error", {})
|
||||
future.set_exception(
|
||||
_translate_command_error(self._result_messages.get(message_id), error)
|
||||
)
|
||||
return
|
||||
|
||||
subscription_id = message.get("id")
|
||||
if subscription_id in self._subscriptions:
|
||||
callback = self._subscriptions[subscription_id][1]
|
||||
result = callback(message)
|
||||
if asyncio.iscoroutine(result):
|
||||
self._loop.create_task(result)
|
||||
return
|
||||
|
||||
LOGGER.debug("Ignoring unexpected websocket message: %s", message)
|
||||
|
||||
async def _send_json(self, message: dict[str, Any]) -> None:
|
||||
"""Send a websocket JSON message."""
|
||||
if not self.connected or self._client is None:
|
||||
raise NotConnected("The websocket client is not connected")
|
||||
await self._client.send_json(message)
|
||||
|
||||
async def _next_message_id(self) -> int:
|
||||
"""Allocate the next websocket message id."""
|
||||
async with self._msg_id_lock:
|
||||
self._last_msg_id += 1
|
||||
return self._last_msg_id
|
||||
@@ -1,41 +0,0 @@
|
||||
"""Runtime configuration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import os
|
||||
|
||||
|
||||
def _env_flag(name: str, default: bool) -> bool:
|
||||
"""Parse a boolean environment flag."""
|
||||
value = os.environ.get(name)
|
||||
if value is None:
|
||||
return default
|
||||
return value.strip().lower() in {"1", "true", "yes", "on"}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class RemoteConfig:
|
||||
"""Configuration for remote Home Assistant sync."""
|
||||
|
||||
websocket_url: str | None = None
|
||||
token: str | None = None
|
||||
ssl: bool = True
|
||||
sync_states: bool = True
|
||||
sync_entity_registry: bool = True
|
||||
|
||||
@property
|
||||
def enabled(self) -> bool:
|
||||
"""Return if remote sync is configured."""
|
||||
return bool(self.websocket_url)
|
||||
|
||||
@classmethod
|
||||
def from_env(cls) -> RemoteConfig:
|
||||
"""Load configuration from the environment."""
|
||||
return cls(
|
||||
websocket_url=os.environ.get("HASS_CLIENT_WS_URL"),
|
||||
token=os.environ.get("HASS_CLIENT_TOKEN"),
|
||||
ssl=_env_flag("HASS_CLIENT_SSL", True),
|
||||
sync_states=_env_flag("HASS_CLIENT_SYNC_STATES", True),
|
||||
sync_entity_registry=_env_flag("HASS_CLIENT_SYNC_ENTITY_REGISTRY", True),
|
||||
)
|
||||
@@ -1,53 +0,0 @@
|
||||
"""Client exceptions."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
class HassClientError(Exception):
|
||||
"""Base client exception."""
|
||||
|
||||
|
||||
class AuthenticationFailed(HassClientError):
|
||||
"""Authentication failed."""
|
||||
|
||||
|
||||
class CannotConnect(HassClientError):
|
||||
"""The websocket connection could not be established."""
|
||||
|
||||
|
||||
class ConnectionFailed(HassClientError):
|
||||
"""The websocket listener failed."""
|
||||
|
||||
|
||||
class ConnectionFailedDueToLargeMessage(ConnectionFailed):
|
||||
"""The websocket listener failed due to a large message."""
|
||||
|
||||
|
||||
class FailedCommand(HassClientError):
|
||||
"""A websocket command returned an error."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
message: str,
|
||||
*,
|
||||
command: str | None = None,
|
||||
code: str | None = None,
|
||||
translation_domain: str | None = None,
|
||||
translation_key: str | None = None,
|
||||
translation_placeholders: dict[str, str] | None = None,
|
||||
) -> None:
|
||||
"""Initialize the failed command error."""
|
||||
super().__init__(message)
|
||||
self.command = command
|
||||
self.code = code
|
||||
self.translation_domain = translation_domain
|
||||
self.translation_key = translation_key
|
||||
self.translation_placeholders = translation_placeholders
|
||||
|
||||
|
||||
class InvalidMessage(HassClientError):
|
||||
"""The websocket server returned an invalid message."""
|
||||
|
||||
|
||||
class NotConnected(HassClientError):
|
||||
"""The client is not connected."""
|
||||
@@ -1,91 +0,0 @@
|
||||
"""Typed websocket payload models."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, NotRequired, TypedDict
|
||||
|
||||
|
||||
class ContextPayload(TypedDict):
|
||||
"""Serialized Home Assistant context."""
|
||||
|
||||
id: str
|
||||
parent_id: str | None
|
||||
user_id: str | None
|
||||
|
||||
|
||||
class CommandMessage(TypedDict):
|
||||
"""Base outbound websocket command."""
|
||||
|
||||
id: int
|
||||
type: str
|
||||
|
||||
|
||||
class CommandResultMessage(TypedDict):
|
||||
"""Inbound websocket command result."""
|
||||
|
||||
id: int
|
||||
type: str
|
||||
success: bool
|
||||
result: Any
|
||||
error: NotRequired[dict[str, Any]]
|
||||
|
||||
|
||||
class EventPayload(TypedDict):
|
||||
"""Inbound websocket event wrapper."""
|
||||
|
||||
event_type: str
|
||||
time_fired: str
|
||||
origin: str
|
||||
context: ContextPayload
|
||||
data: dict[str, Any]
|
||||
|
||||
|
||||
class EventMessage(TypedDict):
|
||||
"""Inbound websocket subscription event."""
|
||||
|
||||
id: int
|
||||
type: str
|
||||
event: EventPayload
|
||||
|
||||
|
||||
class StatePayload(TypedDict):
|
||||
"""Serialized state from the websocket API."""
|
||||
|
||||
entity_id: str
|
||||
state: str
|
||||
attributes: dict[str, Any]
|
||||
last_changed: str
|
||||
last_updated: str
|
||||
last_reported: NotRequired[str]
|
||||
context: ContextPayload
|
||||
|
||||
|
||||
class EntityRegistryEntryPayload(TypedDict):
|
||||
"""Serialized entity registry entry from config websocket commands."""
|
||||
|
||||
entity_id: str
|
||||
id: str
|
||||
platform: str
|
||||
unique_id: str
|
||||
area_id: str | None
|
||||
categories: dict[str, str]
|
||||
config_entry_id: str | None
|
||||
config_subentry_id: str | None
|
||||
created_at: float | str
|
||||
device_id: str | None
|
||||
disabled_by: str | None
|
||||
entity_category: str | None
|
||||
has_entity_name: bool
|
||||
hidden_by: str | None
|
||||
icon: str | None
|
||||
labels: list[str]
|
||||
modified_at: float | str
|
||||
name: str | None
|
||||
options: dict[str, Any]
|
||||
original_name: str | None
|
||||
translation_key: str | None
|
||||
aliases: NotRequired[list[str]]
|
||||
capabilities: NotRequired[dict[str, Any] | None]
|
||||
device_class: NotRequired[str | None]
|
||||
original_device_class: NotRequired[str | None]
|
||||
original_icon: NotRequired[str | None]
|
||||
@@ -1 +0,0 @@
|
||||
|
||||
@@ -1,224 +0,0 @@
|
||||
"""RemoteClientEntityPlatform for sandbox integrations.
|
||||
|
||||
Intercepts async_add_entities on the sandbox side. When an integration
|
||||
adds entities, this platform registers them with the host HA instance
|
||||
via the sandbox websocket API and forwards state changes.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.const import EVENT_STATE_CHANGED
|
||||
from homeassistant.core import EventOrigin, HomeAssistant, callback
|
||||
from homeassistant.helpers.entity import Entity
|
||||
from homeassistant.helpers.entity_platform import EntityPlatform
|
||||
|
||||
from .api import HomeAssistantAPI
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class RemoteClientEntityPlatform:
|
||||
"""Wraps an EntityPlatform to intercept async_add_entities.
|
||||
|
||||
When entities are added to the platform by the integration, this class:
|
||||
1. Lets them be added normally (so they work locally)
|
||||
2. Registers each entity with the host via sandbox/register_entity
|
||||
3. Forwards state changes to the host
|
||||
4. Handles method calls from the host back to local entities
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
api: HomeAssistantAPI,
|
||||
sandbox_entry_id: str,
|
||||
platform: EntityPlatform,
|
||||
) -> None:
|
||||
"""Initialize the remote client entity platform."""
|
||||
self.hass = hass
|
||||
self.api = api
|
||||
self.sandbox_entry_id = sandbox_entry_id
|
||||
self.platform = platform
|
||||
self._local_entities: dict[str, Entity] = {}
|
||||
self._entity_id_to_host_id: dict[str, str] = {}
|
||||
self._host_id_to_entity_id: dict[str, str] = {}
|
||||
self._forwarding_active = False
|
||||
self._commands_subscribed = False
|
||||
|
||||
async def async_add_entities(
|
||||
self,
|
||||
entities: list[Entity],
|
||||
update_before_add: bool = False,
|
||||
) -> None:
|
||||
"""Add entities locally and register them with the host."""
|
||||
await self.platform.async_add_entities(entities, update_before_add)
|
||||
|
||||
for entity in entities:
|
||||
if entity.entity_id is None:
|
||||
continue
|
||||
await self._register_entity(entity)
|
||||
|
||||
if not self._forwarding_active:
|
||||
self._start_state_forwarding()
|
||||
|
||||
if not self._commands_subscribed:
|
||||
await self._subscribe_entity_commands()
|
||||
|
||||
async def _register_entity(self, entity: Entity) -> None:
|
||||
"""Register a single entity with the host."""
|
||||
entity_id = entity.entity_id
|
||||
if entity_id is None:
|
||||
return
|
||||
|
||||
kwargs: dict[str, Any] = {}
|
||||
|
||||
if entity.unique_id:
|
||||
kwargs["unique_id"] = entity.unique_id
|
||||
else:
|
||||
kwargs["unique_id"] = entity_id
|
||||
|
||||
if entity.name:
|
||||
kwargs["original_name"] = str(entity.name)
|
||||
if entity.icon:
|
||||
kwargs["original_icon"] = entity.icon
|
||||
if entity.supported_features:
|
||||
kwargs["supported_features"] = entity.supported_features
|
||||
if hasattr(entity, "_attr_has_entity_name"):
|
||||
kwargs["has_entity_name"] = entity._attr_has_entity_name
|
||||
|
||||
if entity.device_class:
|
||||
kwargs["device_class"] = str(entity.device_class)
|
||||
|
||||
capabilities = {}
|
||||
cap_attrs = entity.capability_attributes
|
||||
if cap_attrs:
|
||||
capabilities.update(cap_attrs)
|
||||
if capabilities:
|
||||
kwargs["capabilities"] = capabilities
|
||||
|
||||
suggested_object_id = entity_id.split(".", 1)[1] if "." in entity_id else None
|
||||
if suggested_object_id:
|
||||
kwargs["suggested_object_id"] = suggested_object_id
|
||||
|
||||
result = await self.api.async_sandbox_register_entity(
|
||||
sandbox_entry_id=self.sandbox_entry_id,
|
||||
domain=self.platform.domain,
|
||||
platform=self.platform.platform_name,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
host_entity_id = result["entity_id"]
|
||||
self._local_entities[entity_id] = entity
|
||||
self._entity_id_to_host_id[entity_id] = host_entity_id
|
||||
self._host_id_to_entity_id[host_entity_id] = entity_id
|
||||
|
||||
state = self.hass.states.get(entity_id)
|
||||
if state:
|
||||
await self.api.async_sandbox_update_state(
|
||||
host_entity_id,
|
||||
state.state,
|
||||
dict(state.attributes),
|
||||
)
|
||||
|
||||
@callback
|
||||
def _start_state_forwarding(self) -> None:
|
||||
"""Start forwarding local state changes to the host."""
|
||||
self._forwarding_active = True
|
||||
|
||||
async def _on_state_changed(event: Any) -> None:
|
||||
if event.origin == EventOrigin.remote:
|
||||
return
|
||||
|
||||
entity_id = event.data.get("entity_id", "")
|
||||
host_entity_id = self._entity_id_to_host_id.get(entity_id)
|
||||
if host_entity_id is None:
|
||||
return
|
||||
|
||||
new_state = event.data.get("new_state")
|
||||
if new_state is None:
|
||||
return
|
||||
|
||||
try:
|
||||
await self.api.async_sandbox_update_state(
|
||||
host_entity_id,
|
||||
new_state.state,
|
||||
dict(new_state.attributes),
|
||||
)
|
||||
except Exception:
|
||||
_LOGGER.exception("Failed to push state for %s", host_entity_id)
|
||||
|
||||
self.hass.bus.async_listen(EVENT_STATE_CHANGED, _on_state_changed)
|
||||
|
||||
async def _subscribe_entity_commands(self) -> None:
|
||||
"""Subscribe to entity method calls from the host."""
|
||||
self._commands_subscribed = True
|
||||
|
||||
async def _on_entity_command(message: dict[str, Any]) -> None:
|
||||
event_data = message.get("event", {})
|
||||
cmd_type = event_data.get("type")
|
||||
if cmd_type != "call_method":
|
||||
return
|
||||
|
||||
call_id = event_data.get("call_id")
|
||||
host_entity_id = event_data.get("entity_id")
|
||||
method_name = event_data.get("method")
|
||||
kwargs = event_data.get("kwargs", {})
|
||||
|
||||
local_entity_id = self._host_id_to_entity_id.get(host_entity_id, "")
|
||||
entity = self._local_entities.get(local_entity_id)
|
||||
|
||||
if entity is None:
|
||||
await self.api.async_sandbox_entity_command_result(
|
||||
call_id=call_id,
|
||||
success=False,
|
||||
error=f"Entity {host_entity_id} not found in sandbox",
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
method = getattr(entity, method_name, None)
|
||||
if method is None:
|
||||
raise AttributeError(
|
||||
f"Entity {local_entity_id} has no method {method_name}"
|
||||
)
|
||||
|
||||
result = await method(**kwargs)
|
||||
|
||||
await self.api.async_sandbox_entity_command_result(
|
||||
call_id=call_id,
|
||||
success=True,
|
||||
result=result if _is_serializable(result) else None,
|
||||
)
|
||||
except Exception as err:
|
||||
_LOGGER.exception(
|
||||
"Error executing %s on %s", method_name, local_entity_id
|
||||
)
|
||||
await self.api.async_sandbox_entity_command_result(
|
||||
call_id=call_id,
|
||||
success=False,
|
||||
error=str(err),
|
||||
)
|
||||
|
||||
await self.api.subscribe(
|
||||
_on_entity_command,
|
||||
"sandbox/subscribe_entity_commands",
|
||||
)
|
||||
|
||||
|
||||
def _is_serializable(value: Any) -> bool:
|
||||
"""Check if a value is JSON-serializable."""
|
||||
if value is None:
|
||||
return True
|
||||
if isinstance(value, (str, int, float, bool)):
|
||||
return True
|
||||
if isinstance(value, (list, tuple)):
|
||||
return all(_is_serializable(v) for v in value)
|
||||
if isinstance(value, dict):
|
||||
return all(
|
||||
isinstance(k, str) and _is_serializable(v)
|
||||
for k, v in value.items()
|
||||
)
|
||||
return False
|
||||
@@ -1,2 +0,0 @@
|
||||
"""Remote Home Assistant mirrors."""
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
"""Shared helpers for remote Home Assistant mirrors."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.core import Context
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
|
||||
def parse_datetime(value: float | str | None) -> datetime:
|
||||
"""Parse a Home Assistant timestamp."""
|
||||
if isinstance(value, int | float):
|
||||
return dt_util.utc_from_timestamp(float(value))
|
||||
if isinstance(value, str):
|
||||
parsed = dt_util.parse_datetime(value)
|
||||
if parsed is not None:
|
||||
return parsed
|
||||
return dt_util.utcnow()
|
||||
|
||||
|
||||
def context_from_payload(payload: Mapping[str, Any] | None) -> Context | None:
|
||||
"""Build a Home Assistant context from websocket payload data."""
|
||||
if not payload:
|
||||
return None
|
||||
return Context(
|
||||
user_id=payload.get("user_id"),
|
||||
parent_id=payload.get("parent_id"),
|
||||
id=payload.get("id"),
|
||||
)
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
"""Remote helper mirrors."""
|
||||
|
||||
from .entity_registry import RemoteEntityRegistryManager
|
||||
|
||||
__all__ = ["RemoteEntityRegistryManager"]
|
||||
|
||||
@@ -1,170 +0,0 @@
|
||||
"""Remote entity registry synchronization helpers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable, Mapping
|
||||
from typing import Any
|
||||
|
||||
from ...api import HomeAssistantAPI
|
||||
from ...exceptions import FailedCommand, NotConnected
|
||||
from ..core import context_from_payload, parse_datetime
|
||||
|
||||
from homeassistant.const import EntityCategory
|
||||
from homeassistant.core import EventOrigin, HomeAssistant
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
|
||||
|
||||
def _build_registry_entry(payload: Mapping[str, Any]) -> er.RegistryEntry:
|
||||
"""Build a local entity registry entry from a websocket payload."""
|
||||
return er.RegistryEntry(
|
||||
aliases=list(payload["aliases"]),
|
||||
area_id=payload["area_id"],
|
||||
categories=dict(payload["categories"]),
|
||||
capabilities=payload["capabilities"],
|
||||
config_entry_id=payload["config_entry_id"],
|
||||
config_subentry_id=payload["config_subentry_id"],
|
||||
created_at=parse_datetime(payload["created_at"]),
|
||||
device_class=payload["device_class"],
|
||||
device_id=payload["device_id"],
|
||||
disabled_by=er.RegistryEntryDisabler(payload["disabled_by"])
|
||||
if payload["disabled_by"]
|
||||
else None,
|
||||
entity_category=EntityCategory(payload["entity_category"])
|
||||
if payload["entity_category"]
|
||||
else None,
|
||||
entity_id=payload["entity_id"].lower(),
|
||||
hidden_by=er.RegistryEntryHider(payload["hidden_by"])
|
||||
if payload["hidden_by"]
|
||||
else None,
|
||||
icon=payload["icon"],
|
||||
id=payload["id"],
|
||||
has_entity_name=payload["has_entity_name"],
|
||||
labels=set(payload["labels"]),
|
||||
modified_at=parse_datetime(payload["modified_at"]),
|
||||
name=payload["name"],
|
||||
object_id_base=payload["original_name"],
|
||||
options=payload["options"],
|
||||
original_device_class=payload["original_device_class"],
|
||||
original_icon=payload["original_icon"],
|
||||
original_name=payload["original_name"],
|
||||
platform=payload["platform"],
|
||||
suggested_object_id=None,
|
||||
supported_features=0,
|
||||
translation_key=payload["translation_key"],
|
||||
unique_id=payload["unique_id"],
|
||||
previous_unique_id=None,
|
||||
unit_of_measurement=None,
|
||||
)
|
||||
|
||||
|
||||
class RemoteEntityRegistryManager:
|
||||
"""Synchronize the local entity registry with a remote Home Assistant."""
|
||||
|
||||
__slots__ = (
|
||||
"_hass",
|
||||
"_remote_entity_registry_ids",
|
||||
"_unsubscribe",
|
||||
)
|
||||
|
||||
def __init__(self, hass: HomeAssistant) -> None:
|
||||
"""Initialize the remote entity registry manager."""
|
||||
self._hass = hass
|
||||
self._remote_entity_registry_ids: set[str] = set()
|
||||
self._unsubscribe: Callable[[], None] | None = None
|
||||
|
||||
@property
|
||||
def remote_api(self) -> HomeAssistantAPI | None:
|
||||
"""Return the live remote API bound to the runtime."""
|
||||
return getattr(self._hass, "remote_api", None)
|
||||
|
||||
async def async_setup(self) -> None:
|
||||
"""Fetch the initial snapshot and subscribe to updates."""
|
||||
remote_api = self.remote_api
|
||||
if remote_api is None or self._unsubscribe is not None:
|
||||
return
|
||||
|
||||
await self.async_refresh()
|
||||
self._unsubscribe = await remote_api.subscribe_events(
|
||||
self._handle_event,
|
||||
er.EVENT_ENTITY_REGISTRY_UPDATED,
|
||||
)
|
||||
|
||||
def unsubscribe(self) -> None:
|
||||
"""Cancel the remote entity registry subscription."""
|
||||
if self._unsubscribe is None:
|
||||
return
|
||||
unsubscribe = self._unsubscribe
|
||||
self._unsubscribe = None
|
||||
unsubscribe()
|
||||
|
||||
async def _async_get_registry(self) -> er.EntityRegistry:
|
||||
"""Return a loaded local entity registry."""
|
||||
registry = er.async_get(self._hass)
|
||||
if not hasattr(registry, "entities"):
|
||||
await registry.async_load(load_empty=True)
|
||||
return registry
|
||||
|
||||
async def async_refresh(self) -> None:
|
||||
"""Fetch the remote entity registry snapshot."""
|
||||
remote_api = self.remote_api
|
||||
if remote_api is None:
|
||||
return
|
||||
|
||||
registry = await self._async_get_registry()
|
||||
entries = await remote_api.async_get_entity_registry()
|
||||
remote_ids: set[str] = set()
|
||||
|
||||
for partial_entry in entries:
|
||||
entity_id = partial_entry["entity_id"].lower()
|
||||
try:
|
||||
full_entry = await remote_api.async_get_entity_registry_entry(entity_id)
|
||||
except FailedCommand:
|
||||
continue
|
||||
entry = _build_registry_entry(full_entry)
|
||||
registry.entities[entry.entity_id] = entry
|
||||
remote_ids.add(entry.entity_id)
|
||||
|
||||
for entity_id in self._remote_entity_registry_ids - remote_ids:
|
||||
registry.entities.pop(entity_id, None)
|
||||
|
||||
registry._entities_data = registry.entities.data
|
||||
self._remote_entity_registry_ids = remote_ids
|
||||
|
||||
async def _handle_event(self, message: dict[str, Any]) -> None:
|
||||
"""Apply a remote entity_registry_updated event locally."""
|
||||
event = message["event"]
|
||||
data = dict(event["data"])
|
||||
context = context_from_payload(event.get("context"))
|
||||
registry = await self._async_get_registry()
|
||||
action = data["action"]
|
||||
entity_id = data["entity_id"].lower()
|
||||
remote_api = self.remote_api
|
||||
|
||||
if action == "remove":
|
||||
registry.entities.pop(entity_id, None)
|
||||
registry._entities_data = registry.entities.data
|
||||
self._remote_entity_registry_ids.discard(entity_id)
|
||||
else:
|
||||
try:
|
||||
assert remote_api is not None
|
||||
payload = await remote_api.async_get_entity_registry_entry(entity_id)
|
||||
except (FailedCommand, NotConnected):
|
||||
if action == "create":
|
||||
return
|
||||
else:
|
||||
entry = _build_registry_entry(payload)
|
||||
old_entity_id = data.get("old_entity_id")
|
||||
if old_entity_id and old_entity_id in registry.entities:
|
||||
registry.entities.pop(old_entity_id, None)
|
||||
self._remote_entity_registry_ids.discard(old_entity_id)
|
||||
registry.entities[entry.entity_id] = entry
|
||||
registry._entities_data = registry.entities.data
|
||||
self._remote_entity_registry_ids.add(entry.entity_id)
|
||||
|
||||
self._hass.bus.async_fire_internal(
|
||||
er.EVENT_ENTITY_REGISTRY_UPDATED,
|
||||
data,
|
||||
origin=EventOrigin.remote,
|
||||
context=context,
|
||||
time_fired=parse_datetime(event.get("time_fired")).timestamp(),
|
||||
)
|
||||
@@ -1,214 +0,0 @@
|
||||
"""Remote-capable Home Assistant runtime."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Callable
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from .api import HomeAssistantAPI
|
||||
from .config import RemoteConfig
|
||||
from .remotes.core import context_from_payload, parse_datetime
|
||||
from .remotes.helpers import RemoteEntityRegistryManager
|
||||
|
||||
LOGGER = logging.getLogger(__name__)
|
||||
|
||||
from homeassistant.const import (
|
||||
EVENT_STATE_CHANGED,
|
||||
EVENT_STATE_REPORTED,
|
||||
)
|
||||
from homeassistant.core import (
|
||||
Context,
|
||||
EventOrigin,
|
||||
HomeAssistant as CoreHomeAssistant,
|
||||
State,
|
||||
callback,
|
||||
)
|
||||
|
||||
|
||||
|
||||
class RemoteHomeAssistant(CoreHomeAssistant):
|
||||
"""Home Assistant subclass with remote websocket sync hooks."""
|
||||
|
||||
def __new__(cls, config_dir: str, **_kwargs: Any) -> RemoteHomeAssistant:
|
||||
"""Allow extra keyword arguments through __new__."""
|
||||
return super().__new__(cls, config_dir)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config_dir: str,
|
||||
*,
|
||||
remote_config: RemoteConfig | None = None,
|
||||
) -> None:
|
||||
"""Initialize a remote-capable Home Assistant instance."""
|
||||
super().__init__(config_dir)
|
||||
self.remote_config = remote_config or RemoteConfig.from_env()
|
||||
self.remote_api: HomeAssistantAPI | None = None
|
||||
self.remote_ready = False
|
||||
self._remote_state_ids: set[str] = set()
|
||||
self._remote_unsubscribers: list[Callable[[], None]] = []
|
||||
|
||||
if self.remote_config.enabled:
|
||||
self.remote_api = HomeAssistantAPI(
|
||||
websocket_url=self.remote_config.websocket_url,
|
||||
token=self.remote_config.token,
|
||||
)
|
||||
|
||||
self.remote_entity_registry = RemoteEntityRegistryManager(self)
|
||||
|
||||
async def async_setup_remote(self) -> None:
|
||||
"""Initialize remote sync."""
|
||||
if self.remote_ready or self.remote_api is None:
|
||||
return
|
||||
|
||||
await self.remote_api.start(ssl=self.remote_config.ssl)
|
||||
await self.async_refresh_remote_config()
|
||||
|
||||
if self.remote_config.sync_states:
|
||||
await self.async_refresh_remote_states()
|
||||
self._remote_unsubscribers.append(
|
||||
await self.remote_api.subscribe_events(
|
||||
self._handle_remote_state_changed,
|
||||
EVENT_STATE_CHANGED,
|
||||
)
|
||||
)
|
||||
self._remote_unsubscribers.append(
|
||||
await self.remote_api.subscribe_events(
|
||||
self._handle_remote_state_reported,
|
||||
EVENT_STATE_REPORTED,
|
||||
)
|
||||
)
|
||||
|
||||
if self.remote_config.sync_entity_registry:
|
||||
await self.remote_entity_registry.async_setup()
|
||||
|
||||
self.remote_ready = True
|
||||
|
||||
async def async_teardown_remote(self) -> None:
|
||||
"""Stop remote sync."""
|
||||
self.remote_entity_registry.unsubscribe()
|
||||
|
||||
while self._remote_unsubscribers:
|
||||
unsubscribe = self._remote_unsubscribers.pop()
|
||||
unsubscribe()
|
||||
|
||||
if self.remote_api is not None:
|
||||
await self.remote_api.stop()
|
||||
|
||||
self.remote_ready = False
|
||||
|
||||
async def async_refresh_remote_config(self) -> None:
|
||||
"""Fetch and apply the remote core config."""
|
||||
if self.remote_api is None:
|
||||
return
|
||||
config = await self.remote_api.async_get_config()
|
||||
await self.config.async_set_time_zone(config["time_zone"])
|
||||
|
||||
async def async_refresh_remote_states(self) -> None:
|
||||
"""Fetch the remote state snapshot."""
|
||||
if self.remote_api is None:
|
||||
return
|
||||
states = await self.remote_api.async_get_states()
|
||||
self._apply_remote_state_snapshot(states)
|
||||
|
||||
async def async_refresh_remote_entity_registry(self) -> None:
|
||||
"""Fetch the remote entity registry snapshot."""
|
||||
await self.remote_entity_registry.async_refresh()
|
||||
|
||||
@callback
|
||||
def _apply_remote_state_snapshot(self, states: list[dict[str, Any]]) -> None:
|
||||
"""Apply a remote state snapshot without firing change events."""
|
||||
remote_ids: set[str] = set()
|
||||
state_store = self.states._states
|
||||
|
||||
for state_payload in states:
|
||||
state = State.from_dict(state_payload)
|
||||
if state is None:
|
||||
continue
|
||||
entity_id = state.entity_id.lower()
|
||||
state_store[entity_id] = state
|
||||
remote_ids.add(entity_id)
|
||||
|
||||
for entity_id in self._remote_state_ids - remote_ids:
|
||||
if entity_id in state_store:
|
||||
del state_store[entity_id]
|
||||
|
||||
self._remote_state_ids = remote_ids
|
||||
|
||||
@callback
|
||||
def _handle_remote_state_changed(self, message: dict[str, Any]) -> None:
|
||||
"""Apply a remote state_changed event locally."""
|
||||
event = message["event"]
|
||||
data = event["data"]
|
||||
entity_id = data["entity_id"].lower()
|
||||
context = context_from_payload(event.get("context"))
|
||||
timestamp = parse_datetime(event.get("time_fired")).timestamp()
|
||||
|
||||
old_state = self.states.get(entity_id)
|
||||
if old_state is not None:
|
||||
old_state.expire()
|
||||
|
||||
new_state_payload = data.get("new_state")
|
||||
if new_state_payload is None:
|
||||
self.states._states.pop(entity_id, None)
|
||||
self._remote_state_ids.discard(entity_id)
|
||||
self.bus.async_fire_internal(
|
||||
EVENT_STATE_CHANGED,
|
||||
{
|
||||
"entity_id": entity_id,
|
||||
"old_state": old_state,
|
||||
"new_state": None,
|
||||
},
|
||||
origin=EventOrigin.remote,
|
||||
context=context,
|
||||
time_fired=timestamp,
|
||||
)
|
||||
return
|
||||
|
||||
new_state = State.from_dict(new_state_payload)
|
||||
if new_state is None:
|
||||
return
|
||||
|
||||
self.states._states[entity_id] = new_state
|
||||
self._remote_state_ids.add(entity_id)
|
||||
self.bus.async_fire_internal(
|
||||
EVENT_STATE_CHANGED,
|
||||
{
|
||||
"entity_id": entity_id,
|
||||
"old_state": old_state,
|
||||
"new_state": new_state,
|
||||
},
|
||||
origin=EventOrigin.remote,
|
||||
context=context,
|
||||
time_fired=timestamp,
|
||||
)
|
||||
|
||||
@callback
|
||||
def _handle_remote_state_reported(self, message: dict[str, Any]) -> None:
|
||||
"""Apply a remote state_reported event locally."""
|
||||
event = message["event"]
|
||||
data = event["data"]
|
||||
entity_id = data["entity_id"].lower()
|
||||
context = context_from_payload(event.get("context"))
|
||||
state = self.states.get(entity_id)
|
||||
new_state_payload = data.get("new_state")
|
||||
if state is None or new_state_payload is None:
|
||||
return
|
||||
|
||||
last_reported = parse_datetime(data.get("last_reported"))
|
||||
old_last_reported = parse_datetime(data.get("old_last_reported"))
|
||||
state.last_reported = last_reported
|
||||
state._cache["last_reported_timestamp"] = last_reported.timestamp()
|
||||
self.bus.async_fire_internal(
|
||||
EVENT_STATE_REPORTED,
|
||||
{
|
||||
"entity_id": entity_id,
|
||||
"last_reported": last_reported,
|
||||
"old_last_reported": old_last_reported,
|
||||
"new_state": state,
|
||||
},
|
||||
origin=EventOrigin.remote,
|
||||
context=context,
|
||||
time_fired=parse_datetime(event.get("time_fired")).timestamp(),
|
||||
)
|
||||
@@ -1,331 +0,0 @@
|
||||
"""Sandbox client that runs HA integrations out-of-process.
|
||||
|
||||
Connects to a real Home Assistant instance using a sandbox token,
|
||||
fetches assigned config entries, sets up the integrations locally,
|
||||
and pushes entity state back to HA Core.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import signal
|
||||
import sys
|
||||
from typing import Any
|
||||
|
||||
from .api import HomeAssistantAPI
|
||||
from .config import RemoteConfig
|
||||
from .sandbox_entity_bridge import SandboxEntityBridge
|
||||
from .sandbox_service_registry import SandboxServiceRegistry
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.DEBUG,
|
||||
format="%(asctime)s %(levelname)s [%(name)s] %(message)s",
|
||||
stream=sys.stderr,
|
||||
)
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SandboxClient:
|
||||
"""Sandbox process that loads HA integrations and bridges to HA Core."""
|
||||
|
||||
def __init__(self, ws_url: str, token: str) -> None:
|
||||
"""Initialize the sandbox client."""
|
||||
self._ws_url = ws_url
|
||||
self._token = token
|
||||
self._api: HomeAssistantAPI | None = None
|
||||
self._entries: list[dict[str, Any]] = []
|
||||
self._hass: Any = None
|
||||
self._entity_map: dict[str, str] = {}
|
||||
self._bridges: list[SandboxEntityBridge] = []
|
||||
self._stop_event = asyncio.Event()
|
||||
|
||||
async def start(self) -> None:
|
||||
"""Connect to HA Core and set up sandbox integrations."""
|
||||
self._api = HomeAssistantAPI(
|
||||
websocket_url=self._ws_url,
|
||||
token=self._token,
|
||||
)
|
||||
await self._api.start(ssl=False)
|
||||
_LOGGER.info("Connected to HA Core at %s", self._ws_url)
|
||||
|
||||
self._entries = await self._api.async_sandbox_get_entries()
|
||||
_LOGGER.info(
|
||||
"Received %d config entries: %s",
|
||||
len(self._entries),
|
||||
[e["domain"] for e in self._entries],
|
||||
)
|
||||
|
||||
await self._setup_hass()
|
||||
await self._setup_integrations()
|
||||
|
||||
_LOGGER.info("Sandbox is running. Waiting for stop signal.")
|
||||
await self._stop_event.wait()
|
||||
|
||||
async def stop(self) -> None:
|
||||
"""Shut down the sandbox."""
|
||||
_LOGGER.info("Stopping sandbox")
|
||||
self._stop_event.set()
|
||||
if self._api is not None:
|
||||
await self._api.stop()
|
||||
|
||||
async def _setup_hass(self) -> None:
|
||||
"""Create a minimal local HomeAssistant instance for integrations."""
|
||||
from .runtime import RemoteHomeAssistant
|
||||
|
||||
config = RemoteConfig(
|
||||
websocket_url=self._ws_url,
|
||||
token=self._token,
|
||||
ssl=False,
|
||||
sync_states=False,
|
||||
sync_entity_registry=False,
|
||||
)
|
||||
|
||||
self._hass = RemoteHomeAssistant(
|
||||
config_dir=os.path.join(os.getcwd(), ".sandbox_config"),
|
||||
remote_config=config,
|
||||
)
|
||||
self._hass.remote_api = self._api
|
||||
self._hass.services = SandboxServiceRegistry(self._hass, self._api)
|
||||
|
||||
os.makedirs(self._hass.config.config_dir, exist_ok=True)
|
||||
|
||||
await self._hass.async_setup_remote()
|
||||
|
||||
async def _setup_integrations(self) -> None:
|
||||
"""Set up each assigned integration inside the sandbox."""
|
||||
for entry_config in self._entries:
|
||||
domain = entry_config["domain"]
|
||||
_LOGGER.info("Setting up integration: %s", domain)
|
||||
|
||||
try:
|
||||
await self._setup_single_integration(entry_config)
|
||||
except Exception:
|
||||
_LOGGER.exception("Failed to set up %s", domain)
|
||||
|
||||
async def _setup_single_integration(
|
||||
self, entry_config: dict[str, Any]
|
||||
) -> None:
|
||||
"""Set up a single integration and bridge its entities."""
|
||||
domain = entry_config["domain"]
|
||||
entry_id = entry_config["entry_id"]
|
||||
data = entry_config.get("data", {})
|
||||
|
||||
if domain in ("input_boolean", "input_number", "input_text", "input_select", "input_datetime"):
|
||||
await self._setup_input_helper(domain, entry_id, data)
|
||||
else:
|
||||
await self._setup_config_entry_integration(entry_config)
|
||||
|
||||
async def _setup_input_helper(
|
||||
self,
|
||||
domain: str,
|
||||
entry_id: str,
|
||||
data: dict[str, Any],
|
||||
) -> None:
|
||||
"""Set up an input helper integration in the sandbox."""
|
||||
hass = self._hass
|
||||
api = self._api
|
||||
assert api is not None
|
||||
|
||||
items = data.get("items", [])
|
||||
if not items:
|
||||
_LOGGER.warning("No items configured for %s", domain)
|
||||
return
|
||||
|
||||
integration = await self._load_integration(domain)
|
||||
if integration is None:
|
||||
return
|
||||
|
||||
config = {domain: {}}
|
||||
for item in items:
|
||||
item_id = item.get("id", item.get("name", "").lower().replace(" ", "_"))
|
||||
config[domain][item_id] = {
|
||||
k: v for k, v in item.items() if k != "id"
|
||||
}
|
||||
|
||||
module = integration.get_component()
|
||||
if hasattr(module, "async_setup"):
|
||||
await module.async_setup(hass, config)
|
||||
|
||||
await hass.async_block_till_done()
|
||||
|
||||
for entity_id, state in hass.states._states.items():
|
||||
if entity_id.startswith(f"{domain}."):
|
||||
result = await api.async_sandbox_register_entity(
|
||||
sandbox_entry_id=entry_id,
|
||||
domain=domain,
|
||||
platform=domain,
|
||||
unique_id=entity_id.split(".", 1)[1],
|
||||
original_name=state.attributes.get("friendly_name"),
|
||||
suggested_object_id=entity_id.split(".", 1)[1],
|
||||
)
|
||||
ha_entity_id = result["entity_id"]
|
||||
self._entity_map[entity_id] = ha_entity_id
|
||||
_LOGGER.info(
|
||||
"Registered entity: %s -> %s", entity_id, ha_entity_id
|
||||
)
|
||||
|
||||
await api.async_sandbox_update_state(
|
||||
ha_entity_id,
|
||||
state.state,
|
||||
dict(state.attributes),
|
||||
)
|
||||
_LOGGER.info(
|
||||
"Pushed initial state for %s: %s", ha_entity_id, state.state
|
||||
)
|
||||
|
||||
self._subscribe_state_changes(domain)
|
||||
|
||||
async def _setup_config_entry_integration(
|
||||
self, entry_config: dict[str, Any]
|
||||
) -> None:
|
||||
"""Set up a config-entry-based integration in the sandbox."""
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.helpers.entity_platform import EntityPlatform
|
||||
|
||||
hass = self._hass
|
||||
api = self._api
|
||||
assert api is not None
|
||||
|
||||
domain = entry_config["domain"]
|
||||
entry_id = entry_config["entry_id"]
|
||||
data = entry_config.get("data", {})
|
||||
options = entry_config.get("options", {})
|
||||
title = entry_config.get("title", domain)
|
||||
|
||||
integration = await self._load_integration(domain)
|
||||
if integration is None:
|
||||
return
|
||||
|
||||
from types import MappingProxyType
|
||||
from homeassistant.config_entries import ConfigEntryState
|
||||
|
||||
entry = ConfigEntry(
|
||||
data=data,
|
||||
discovery_keys=MappingProxyType({}),
|
||||
domain=domain,
|
||||
entry_id=entry_id,
|
||||
minor_version=1,
|
||||
options=options,
|
||||
source="sandbox",
|
||||
subentries_data=None,
|
||||
title=title,
|
||||
unique_id=None,
|
||||
version=1,
|
||||
)
|
||||
entry._async_set_state(hass, ConfigEntryState.SETUP_IN_PROGRESS, None)
|
||||
hass.config_entries._entries[entry.entry_id] = entry
|
||||
|
||||
module = integration.get_component()
|
||||
if hasattr(module, "async_setup"):
|
||||
await module.async_setup(hass, {domain: {}})
|
||||
|
||||
if hasattr(module, "async_setup_entry"):
|
||||
await module.async_setup_entry(hass, entry)
|
||||
|
||||
await hass.async_block_till_done()
|
||||
|
||||
bridge = SandboxEntityBridge(hass, api, entry_id)
|
||||
self._bridges.append(bridge)
|
||||
|
||||
from homeassistant.helpers.entity_platform import DATA_DOMAIN_PLATFORM_ENTITIES
|
||||
|
||||
domain_platform_entities = hass.data.get(DATA_DOMAIN_PLATFORM_ENTITIES, {})
|
||||
for (plat_domain, plat_name), entities in domain_platform_entities.items():
|
||||
for eid, entity in list(entities.items()):
|
||||
if entity.platform and entity.platform.config_entry == entry:
|
||||
await bridge._register_entity(eid, entity, entity.platform)
|
||||
|
||||
bridge.start_state_forwarding()
|
||||
await bridge.subscribe_entity_commands()
|
||||
|
||||
_LOGGER.info(
|
||||
"Config entry integration %s set up with %d entities",
|
||||
domain,
|
||||
len(bridge._local_entities),
|
||||
)
|
||||
|
||||
async def _load_integration(self, domain: str) -> Any:
|
||||
"""Load a HA integration by domain."""
|
||||
from homeassistant.loader import async_get_integration
|
||||
|
||||
try:
|
||||
return await async_get_integration(self._hass, domain)
|
||||
except Exception:
|
||||
_LOGGER.exception("Failed to load integration %s", domain)
|
||||
return None
|
||||
|
||||
def _subscribe_state_changes(self, domain: str) -> None:
|
||||
"""Watch for local state changes and push them to HA Core."""
|
||||
from homeassistant.const import EVENT_STATE_CHANGED
|
||||
from homeassistant.core import EventOrigin
|
||||
|
||||
async def _on_state_changed(event: Any) -> None:
|
||||
if event.origin == EventOrigin.remote:
|
||||
return
|
||||
|
||||
entity_id = event.data.get("entity_id", "")
|
||||
if not entity_id.startswith(f"{domain}."):
|
||||
return
|
||||
|
||||
ha_entity_id = self._entity_map.get(entity_id)
|
||||
if ha_entity_id is None:
|
||||
return
|
||||
|
||||
new_state = event.data.get("new_state")
|
||||
if new_state is None:
|
||||
return
|
||||
|
||||
api = self._api
|
||||
if api is None:
|
||||
return
|
||||
|
||||
try:
|
||||
await api.async_sandbox_update_state(
|
||||
ha_entity_id,
|
||||
new_state.state,
|
||||
dict(new_state.attributes),
|
||||
)
|
||||
except Exception:
|
||||
_LOGGER.exception("Failed to push state for %s", ha_entity_id)
|
||||
|
||||
self._hass.bus.async_listen(EVENT_STATE_CHANGED, _on_state_changed)
|
||||
|
||||
|
||||
async def async_main(args: argparse.Namespace) -> None:
|
||||
"""Entry point for the sandbox process."""
|
||||
client = SandboxClient(args.url, args.token)
|
||||
|
||||
loop = asyncio.get_running_loop()
|
||||
for sig in (signal.SIGINT, signal.SIGTERM):
|
||||
loop.add_signal_handler(sig, lambda: asyncio.ensure_future(client.stop()))
|
||||
|
||||
try:
|
||||
await client.start()
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
finally:
|
||||
await client.stop()
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""CLI entry point."""
|
||||
parser = argparse.ArgumentParser(description="Home Assistant Sandbox Client")
|
||||
parser.add_argument(
|
||||
"--url",
|
||||
required=True,
|
||||
help="WebSocket URL of the HA Core instance",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--token",
|
||||
required=True,
|
||||
help="Sandbox access token",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
asyncio.run(async_main(args))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,267 +0,0 @@
|
||||
"""Entity bridge for sandbox integrations.
|
||||
|
||||
Intercepts entities created by integrations running in a sandbox,
|
||||
registers them with the host HA instance, forwards state changes,
|
||||
and dispatches method calls from the host back to local entities.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.const import EVENT_STATE_CHANGED
|
||||
from homeassistant.core import EventOrigin, HomeAssistant, callback
|
||||
from homeassistant.helpers.entity import Entity
|
||||
from homeassistant.helpers.entity_platform import EntityPlatform
|
||||
|
||||
from .api import HomeAssistantAPI
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SandboxEntityBridge:
|
||||
"""Bridges local entities to the host HA instance."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
api: HomeAssistantAPI,
|
||||
sandbox_entry_id: str,
|
||||
) -> None:
|
||||
"""Initialize the entity bridge."""
|
||||
self.hass = hass
|
||||
self.api = api
|
||||
self.sandbox_entry_id = sandbox_entry_id
|
||||
self._local_entities: dict[str, Entity] = {}
|
||||
self._entity_id_to_host_id: dict[str, str] = {}
|
||||
self._host_id_to_entity_id: dict[str, str] = {}
|
||||
self._subscribed = False
|
||||
|
||||
async def register_entities(self, platform: EntityPlatform) -> None:
|
||||
"""Register all entities from a platform with the host."""
|
||||
for entity_id, entity in platform.entities.items():
|
||||
await self._register_entity(entity_id, entity, platform)
|
||||
|
||||
async def _register_entity(
|
||||
self, entity_id: str, entity: Entity, platform: EntityPlatform
|
||||
) -> None:
|
||||
"""Register a single entity with the host."""
|
||||
kwargs: dict[str, Any] = {}
|
||||
|
||||
if entity.unique_id:
|
||||
kwargs["unique_id"] = entity.unique_id
|
||||
else:
|
||||
kwargs["unique_id"] = entity_id
|
||||
|
||||
if entity.name:
|
||||
kwargs["original_name"] = str(entity.name)
|
||||
if entity.icon:
|
||||
kwargs["original_icon"] = entity.icon
|
||||
if entity.supported_features:
|
||||
kwargs["supported_features"] = entity.supported_features
|
||||
if entity.entity_description and hasattr(entity.entity_description, "has_entity_name"):
|
||||
kwargs["has_entity_name"] = entity.entity_description.has_entity_name
|
||||
elif hasattr(entity, "_attr_has_entity_name"):
|
||||
kwargs["has_entity_name"] = entity._attr_has_entity_name
|
||||
|
||||
capabilities = self._get_capabilities(entity)
|
||||
if capabilities:
|
||||
kwargs["capabilities"] = capabilities
|
||||
|
||||
suggested_object_id = entity_id.split(".", 1)[1] if "." in entity_id else None
|
||||
if suggested_object_id:
|
||||
kwargs["suggested_object_id"] = suggested_object_id
|
||||
|
||||
result = await self.api.async_sandbox_register_entity(
|
||||
sandbox_entry_id=self.sandbox_entry_id,
|
||||
domain=platform.domain,
|
||||
platform=platform.platform_name,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
host_entity_id = result["entity_id"]
|
||||
self._local_entities[entity_id] = entity
|
||||
self._entity_id_to_host_id[entity_id] = host_entity_id
|
||||
self._host_id_to_entity_id[host_entity_id] = entity_id
|
||||
|
||||
_LOGGER.info("Registered entity: %s -> %s", entity_id, host_entity_id)
|
||||
|
||||
state = self.hass.states.get(entity_id)
|
||||
if state:
|
||||
await self.api.async_sandbox_update_state(
|
||||
host_entity_id,
|
||||
state.state,
|
||||
dict(state.attributes),
|
||||
)
|
||||
|
||||
def _get_capabilities(self, entity: Entity) -> dict[str, Any]:
|
||||
"""Extract capability attributes from an entity."""
|
||||
caps: dict[str, Any] = {}
|
||||
cap_attrs = entity.capability_attributes
|
||||
if cap_attrs:
|
||||
caps.update(cap_attrs)
|
||||
return caps
|
||||
|
||||
@callback
|
||||
def start_state_forwarding(self) -> None:
|
||||
"""Start forwarding local state changes to the host."""
|
||||
|
||||
async def _on_state_changed(event: Any) -> None:
|
||||
if event.origin == EventOrigin.remote:
|
||||
return
|
||||
|
||||
entity_id = event.data.get("entity_id", "")
|
||||
host_entity_id = self._entity_id_to_host_id.get(entity_id)
|
||||
if host_entity_id is None:
|
||||
return
|
||||
|
||||
new_state = event.data.get("new_state")
|
||||
if new_state is None:
|
||||
return
|
||||
|
||||
try:
|
||||
await self.api.async_sandbox_update_state(
|
||||
host_entity_id,
|
||||
new_state.state,
|
||||
dict(new_state.attributes),
|
||||
)
|
||||
except Exception:
|
||||
_LOGGER.exception("Failed to push state for %s", host_entity_id)
|
||||
|
||||
self.hass.bus.async_listen(EVENT_STATE_CHANGED, _on_state_changed)
|
||||
|
||||
async def subscribe_entity_commands(self) -> None:
|
||||
"""Subscribe to entity method calls and service calls from the host."""
|
||||
if self._subscribed:
|
||||
return
|
||||
self._subscribed = True
|
||||
|
||||
async def _on_command(message: dict[str, Any]) -> None:
|
||||
event_data = message.get("event", {})
|
||||
cmd_type = event_data.get("type")
|
||||
|
||||
if cmd_type == "call_method":
|
||||
await self._handle_entity_command(event_data)
|
||||
elif cmd_type == "call_service":
|
||||
await self._handle_service_call(event_data)
|
||||
|
||||
await self.api.subscribe(
|
||||
_on_command,
|
||||
"sandbox/subscribe_entity_commands",
|
||||
)
|
||||
_LOGGER.info("Subscribed to commands from host")
|
||||
|
||||
async def _handle_entity_command(self, event_data: dict[str, Any]) -> None:
|
||||
"""Handle an entity method call from the host."""
|
||||
call_id = event_data.get("call_id")
|
||||
host_entity_id = event_data.get("entity_id")
|
||||
method_name = event_data.get("method")
|
||||
kwargs = event_data.get("kwargs", {})
|
||||
|
||||
local_entity_id = self._host_id_to_entity_id.get(host_entity_id, "")
|
||||
entity = self._local_entities.get(local_entity_id)
|
||||
|
||||
if entity is None:
|
||||
_LOGGER.warning(
|
||||
"Entity command for unknown entity: %s", host_entity_id
|
||||
)
|
||||
await self.api.async_sandbox_entity_command_result(
|
||||
call_id=call_id,
|
||||
success=False,
|
||||
error=f"Entity {host_entity_id} not found in sandbox",
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
method = getattr(entity, method_name, None)
|
||||
if method is None:
|
||||
raise AttributeError(
|
||||
f"Entity {local_entity_id} has no method {method_name}"
|
||||
)
|
||||
|
||||
result = await method(**kwargs)
|
||||
|
||||
await self.api.async_sandbox_entity_command_result(
|
||||
call_id=call_id,
|
||||
success=True,
|
||||
result=result if _is_serializable(result) else None,
|
||||
)
|
||||
except Exception as err:
|
||||
_LOGGER.exception(
|
||||
"Error executing %s on %s", method_name, local_entity_id
|
||||
)
|
||||
await self.api.async_sandbox_entity_command_result(
|
||||
call_id=call_id,
|
||||
success=False,
|
||||
error=str(err),
|
||||
)
|
||||
|
||||
async def _handle_service_call(self, event_data: dict[str, Any]) -> None:
|
||||
"""Handle a service call forwarded from the host."""
|
||||
from .sandbox_service_registry import SandboxServiceRegistry
|
||||
|
||||
call_id = event_data.get("call_id")
|
||||
domain = event_data.get("domain", "")
|
||||
service = event_data.get("service", "")
|
||||
service_data = event_data.get("service_data", {})
|
||||
target = event_data.get("target")
|
||||
return_response = event_data.get("return_response", False)
|
||||
context_data = event_data.get("context")
|
||||
|
||||
services = self.hass.services
|
||||
if not isinstance(services, SandboxServiceRegistry):
|
||||
await self.api.async_sandbox_service_call_result(
|
||||
call_id=call_id,
|
||||
success=False,
|
||||
error="Service registry not in sandbox mode",
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
result = await services.async_execute_forwarded_call(
|
||||
domain, service, service_data,
|
||||
target=target,
|
||||
return_response=return_response,
|
||||
context_data=context_data,
|
||||
)
|
||||
await self.api.async_sandbox_service_call_result(
|
||||
call_id=call_id,
|
||||
success=True,
|
||||
result=result if _is_serializable(result) else None,
|
||||
)
|
||||
except Exception as err:
|
||||
_LOGGER.debug(
|
||||
"Error executing forwarded service %s.%s: %s",
|
||||
domain, service, err,
|
||||
)
|
||||
kwargs: dict[str, Any] = {
|
||||
"call_id": call_id,
|
||||
"success": False,
|
||||
"error": str(err),
|
||||
"error_type": type(err).__name__,
|
||||
}
|
||||
if hasattr(err, "translation_domain") and err.translation_domain:
|
||||
kwargs["translation_domain"] = err.translation_domain
|
||||
if hasattr(err, "translation_key") and err.translation_key:
|
||||
kwargs["translation_key"] = err.translation_key
|
||||
if hasattr(err, "translation_placeholders") and err.translation_placeholders:
|
||||
kwargs["translation_placeholders"] = err.translation_placeholders
|
||||
await self.api.async_sandbox_service_call_result(**kwargs)
|
||||
|
||||
|
||||
def _is_serializable(value: Any) -> bool:
|
||||
"""Check if a value is JSON-serializable."""
|
||||
if value is None:
|
||||
return True
|
||||
if isinstance(value, (str, int, float, bool)):
|
||||
return True
|
||||
if isinstance(value, (list, tuple)):
|
||||
return all(_is_serializable(v) for v in value)
|
||||
if isinstance(value, dict):
|
||||
return all(
|
||||
isinstance(k, str) and _is_serializable(v)
|
||||
for k, v in value.items()
|
||||
)
|
||||
return False
|
||||
@@ -1,192 +0,0 @@
|
||||
"""Service registry for sandbox mode.
|
||||
|
||||
Replaces HybridServiceRegistry. All service registrations are forwarded
|
||||
to the host via sandbox/register_service. All service calls are forwarded
|
||||
to the host via the standard call_service websocket command. When the host
|
||||
forwards a call back (for sandbox-registered proxy services), it is
|
||||
executed locally.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime as dt
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.core import (
|
||||
HomeAssistant,
|
||||
ServiceCall,
|
||||
ServiceRegistry,
|
||||
ServiceResponse,
|
||||
SupportsResponse,
|
||||
callback,
|
||||
)
|
||||
from homeassistant.exceptions import ServiceNotFound
|
||||
|
||||
from .api import HomeAssistantAPI
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SandboxServiceRegistry(ServiceRegistry):
|
||||
"""Service registry that registers on host and forwards calls."""
|
||||
|
||||
def __init__(self, hass: HomeAssistant, api: HomeAssistantAPI) -> None:
|
||||
"""Initialize the sandbox service registry."""
|
||||
super().__init__(hass)
|
||||
self._api = api
|
||||
self._executing_forwarded = False
|
||||
|
||||
@callback
|
||||
def async_register(
|
||||
self,
|
||||
domain: str,
|
||||
service: str,
|
||||
service_func: Any,
|
||||
schema: Any = None,
|
||||
supports_response: SupportsResponse = SupportsResponse.NONE,
|
||||
job_type: Any = None,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
"""Register service locally and schedule registration on host."""
|
||||
super().async_register(
|
||||
domain, service, service_func, schema,
|
||||
supports_response=supports_response,
|
||||
job_type=job_type,
|
||||
**kwargs,
|
||||
)
|
||||
self._hass.async_create_task(
|
||||
self._register_on_host(domain, service),
|
||||
f"sandbox_register_service_{domain}.{service}",
|
||||
eager_start=True,
|
||||
)
|
||||
|
||||
async def _register_on_host(self, domain: str, service: str) -> None:
|
||||
"""Register a service on the host."""
|
||||
if not self._api.connected:
|
||||
return
|
||||
try:
|
||||
await self._api.async_sandbox_register_service(domain, service)
|
||||
_LOGGER.debug("Registered %s.%s on host", domain, service)
|
||||
except Exception:
|
||||
_LOGGER.debug(
|
||||
"Failed to register %s.%s on host", domain, service,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
async def async_call(
|
||||
self,
|
||||
domain: str,
|
||||
service: str,
|
||||
service_data: dict[str, Any] | None = None,
|
||||
blocking: bool = False,
|
||||
context: Any = None,
|
||||
target: dict[str, Any] | None = None,
|
||||
return_response: bool = False,
|
||||
) -> ServiceResponse:
|
||||
"""Forward service call to host."""
|
||||
if self._executing_forwarded:
|
||||
return await super().async_call(
|
||||
domain, service, service_data, blocking,
|
||||
context, target, return_response,
|
||||
)
|
||||
|
||||
if not self._api.connected:
|
||||
return await super().async_call(
|
||||
domain, service, service_data, blocking,
|
||||
context, target, return_response,
|
||||
)
|
||||
|
||||
serialized_data = _make_serializable(service_data) if service_data else None
|
||||
|
||||
# Serialize context for forwarding to host
|
||||
context_data: dict[str, Any] | None = None
|
||||
if context is not None:
|
||||
context_data = {
|
||||
"id": context.id,
|
||||
"user_id": context.user_id,
|
||||
"parent_id": context.parent_id,
|
||||
}
|
||||
|
||||
try:
|
||||
response = await self._api.async_call_service(
|
||||
domain=domain,
|
||||
service=service,
|
||||
service_data=serialized_data,
|
||||
target=target,
|
||||
return_response=return_response,
|
||||
context=context_data,
|
||||
)
|
||||
except Exception:
|
||||
if blocking or return_response:
|
||||
raise
|
||||
# Non-blocking calls silently swallow errors, matching standard
|
||||
# ServiceRegistry behavior where fire-and-forget calls log but
|
||||
# don't propagate exceptions to the caller.
|
||||
return None
|
||||
|
||||
if not return_response:
|
||||
return None
|
||||
return response.get("response") if response else None
|
||||
|
||||
async def async_execute_forwarded_call(
|
||||
self,
|
||||
domain: str,
|
||||
service: str,
|
||||
service_data: dict[str, Any],
|
||||
target: dict[str, Any] | None = None,
|
||||
return_response: bool = False,
|
||||
context_data: dict[str, Any] | None = None,
|
||||
) -> Any:
|
||||
"""Execute a service call forwarded from the host."""
|
||||
from homeassistant.core import Context
|
||||
|
||||
if not super().has_service(domain, service):
|
||||
raise ServiceNotFound(domain, service)
|
||||
|
||||
# Reconstruct context from forwarded data
|
||||
context: Context | None = None
|
||||
if context_data:
|
||||
context = Context(
|
||||
id=context_data.get("id"),
|
||||
user_id=context_data.get("user_id"),
|
||||
parent_id=context_data.get("parent_id"),
|
||||
)
|
||||
|
||||
self._executing_forwarded = True
|
||||
try:
|
||||
merged_data = dict(service_data)
|
||||
if target:
|
||||
merged_data.update(target)
|
||||
return await super().async_call(
|
||||
domain,
|
||||
service,
|
||||
merged_data,
|
||||
blocking=True,
|
||||
context=context,
|
||||
return_response=return_response,
|
||||
)
|
||||
finally:
|
||||
self._executing_forwarded = False
|
||||
|
||||
|
||||
def _make_serializable(data: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Convert non-JSON-serializable values to strings."""
|
||||
result = {}
|
||||
for key, value in data.items():
|
||||
if isinstance(value, dt.datetime):
|
||||
result[key] = value.isoformat()
|
||||
elif isinstance(value, dt.date):
|
||||
result[key] = value.isoformat()
|
||||
elif isinstance(value, dt.time):
|
||||
result[key] = value.isoformat()
|
||||
elif isinstance(value, dict):
|
||||
result[key] = _make_serializable(value)
|
||||
elif isinstance(value, (list, tuple)):
|
||||
result[key] = [
|
||||
v.isoformat() if isinstance(v, (dt.datetime, dt.date, dt.time)) else v
|
||||
for v in value
|
||||
]
|
||||
else:
|
||||
result[key] = value
|
||||
return result
|
||||
@@ -1 +0,0 @@
|
||||
"""Testing helpers for Home Assistant core compatibility runs."""
|
||||
@@ -1,273 +0,0 @@
|
||||
"""Pytest plugin for running HA Core integration tests through a real sandbox websocket.
|
||||
|
||||
Layers on top of hass_client.testing.pytest_plugin: instead of creating a bare
|
||||
RemoteHomeAssistant, this boots a host HA Core with websocket_api + sandbox
|
||||
integration, starts a real aiohttp test server, creates a sandbox auth token,
|
||||
and connects the sandbox RemoteHomeAssistant to it via a live websocket.
|
||||
|
||||
Tests that use the ``freezer`` fixture (pytest-freezer's FrozenDateTimeFactory)
|
||||
fall back to the base plugin without a real websocket, because mid-test time
|
||||
jumps hang live async connections.
|
||||
|
||||
Usage:
|
||||
pytest -p hass_client.testing.conftest_sandbox \
|
||||
../core/tests/components/input_boolean/test_init.py
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Generator as _Generator
|
||||
from contextlib import suppress
|
||||
import threading
|
||||
|
||||
import pytest_asyncio
|
||||
|
||||
_pytest_fixture = pytest_asyncio.fixture
|
||||
|
||||
_state = threading.local()
|
||||
|
||||
|
||||
def pytest_runtest_setup(item) -> None:
|
||||
"""Detect tests that use the freezer fixture before hass setup runs."""
|
||||
_state.uses_freezer = "freezer" in getattr(item, "fixturenames", ())
|
||||
|
||||
|
||||
@_pytest_fixture(autouse=True)
|
||||
def verify_cleanup(
|
||||
expected_lingering_tasks: bool,
|
||||
expected_lingering_timers: bool,
|
||||
) -> _Generator[None]:
|
||||
"""Override verify_cleanup to tolerate ImportExecutor threads.
|
||||
|
||||
The sandbox creates a host HA instance whose import executor thread
|
||||
may still be running when cleanup checks happen.
|
||||
"""
|
||||
import asyncio as _asyncio
|
||||
|
||||
event_loop = _asyncio.get_event_loop()
|
||||
threads_before = frozenset(threading.enumerate())
|
||||
tasks_before = _asyncio.all_tasks(event_loop)
|
||||
yield
|
||||
|
||||
event_loop.run_until_complete(event_loop.shutdown_default_executor())
|
||||
|
||||
from tests.common import INSTANCES
|
||||
|
||||
if len(INSTANCES) >= 2:
|
||||
count = len(INSTANCES)
|
||||
for inst in INSTANCES:
|
||||
inst.stop()
|
||||
import pytest as _pytest
|
||||
_pytest.exit(
|
||||
f"Detected non stopped instances ({count}), aborting test run"
|
||||
)
|
||||
|
||||
tasks = _asyncio.all_tasks(event_loop) - tasks_before
|
||||
for task in tasks:
|
||||
if expected_lingering_tasks:
|
||||
pass
|
||||
else:
|
||||
task.cancel()
|
||||
if tasks:
|
||||
event_loop.run_until_complete(_asyncio.wait(tasks))
|
||||
|
||||
threads = frozenset(threading.enumerate()) - threads_before
|
||||
for thread in threads:
|
||||
if thread.name.startswith("ImportExecutor"):
|
||||
thread.join(timeout=5)
|
||||
continue
|
||||
assert (
|
||||
isinstance(thread, threading._DummyThread)
|
||||
or thread.name.startswith("waitpid-")
|
||||
or "_run_safe_shutdown_loop" in thread.name
|
||||
)
|
||||
|
||||
|
||||
def pytest_runtest_teardown(item, nextitem) -> None:
|
||||
"""Clear per-test flag."""
|
||||
_state.uses_freezer = False
|
||||
|
||||
|
||||
def pytest_configure() -> None:
|
||||
"""Patch async_test_home_assistant to boot a real sandbox websocket server."""
|
||||
from contextlib import asynccontextmanager
|
||||
from unittest.mock import patch
|
||||
|
||||
from aiohttp.test_utils import TestServer
|
||||
|
||||
import hass_client.testing.pytest_plugin as base_plugin
|
||||
|
||||
base_plugin.pytest_configure()
|
||||
|
||||
from homeassistant.components.sandbox import async_setup as sandbox_async_setup
|
||||
from homeassistant.components.sandbox.const import (
|
||||
DATA_SANDBOX,
|
||||
DOMAIN as SANDBOX_DOMAIN,
|
||||
)
|
||||
from homeassistant.setup import async_setup_component
|
||||
|
||||
from hass_client.api import HomeAssistantAPI
|
||||
from hass_client.config import RemoteConfig
|
||||
from hass_client.runtime import RemoteHomeAssistant
|
||||
|
||||
import tests.conftest as tests_conftest
|
||||
import tests.common as tests_common
|
||||
from tests.common import MockConfigEntry
|
||||
|
||||
if getattr(tests_common, "_sandbox_ws_patched", False):
|
||||
return
|
||||
|
||||
patched_async_test_home_assistant = tests_common.async_test_home_assistant
|
||||
|
||||
import socket as _socket_mod
|
||||
|
||||
_real_socket = _socket_mod.socket
|
||||
|
||||
@asynccontextmanager
|
||||
async def sandbox_async_test_home_assistant(*args, **kwargs):
|
||||
"""Create a sandbox-connected test HA instance with a real websocket.
|
||||
|
||||
Falls back to the base plugin (no websocket) when the test uses the
|
||||
freezer fixture, since freezer.move_to() hangs live connections.
|
||||
"""
|
||||
if getattr(_state, "uses_freezer", False):
|
||||
async with patched_async_test_home_assistant(*args, **kwargs) as hass:
|
||||
yield hass
|
||||
return
|
||||
|
||||
saved_socket = _socket_mod.socket
|
||||
_socket_mod.socket = _real_socket
|
||||
|
||||
try:
|
||||
async with patched_async_test_home_assistant(*args, **kwargs) as host_hass:
|
||||
await async_setup_component(host_hass, "websocket_api", {})
|
||||
await sandbox_async_setup(host_hass, {})
|
||||
|
||||
server = TestServer(host_hass.http.app)
|
||||
await server.start_server()
|
||||
|
||||
ws_url = f"ws://127.0.0.1:{server.port}/api/websocket"
|
||||
|
||||
sandbox_entry = MockConfigEntry(
|
||||
domain=SANDBOX_DOMAIN,
|
||||
data={
|
||||
"entries": [
|
||||
{
|
||||
"entry_id": "sandbox_test",
|
||||
"domain": "input_boolean",
|
||||
"title": "Sandbox Test",
|
||||
"data": {},
|
||||
}
|
||||
]
|
||||
},
|
||||
)
|
||||
sandbox_entry.add_to_hass(host_hass)
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.sandbox._spawn_sandbox",
|
||||
return_value=None,
|
||||
):
|
||||
await host_hass.config_entries.async_setup(
|
||||
sandbox_entry.entry_id
|
||||
)
|
||||
|
||||
sandbox_data = host_hass.data[DATA_SANDBOX]
|
||||
instance = sandbox_data.sandboxes[sandbox_entry.entry_id]
|
||||
access_token = instance.access_token
|
||||
|
||||
async with patched_async_test_home_assistant(
|
||||
*args, **kwargs
|
||||
) as sandbox_hass:
|
||||
sandbox_hass.remote_config = RemoteConfig(
|
||||
websocket_url=ws_url,
|
||||
token=access_token,
|
||||
ssl=False,
|
||||
sync_states=False,
|
||||
sync_entity_registry=False,
|
||||
)
|
||||
sandbox_api = HomeAssistantAPI(
|
||||
websocket_url=ws_url,
|
||||
token=access_token,
|
||||
)
|
||||
sandbox_hass.remote_api = sandbox_api
|
||||
|
||||
from hass_client.sandbox_service_registry import (
|
||||
SandboxServiceRegistry,
|
||||
)
|
||||
|
||||
sandbox_svc_registry = SandboxServiceRegistry(
|
||||
sandbox_hass, sandbox_api
|
||||
)
|
||||
sandbox_hass.services = sandbox_svc_registry
|
||||
await sandbox_hass.async_setup_remote()
|
||||
|
||||
async def _on_command(message):
|
||||
"""Handle commands forwarded from host."""
|
||||
event_data = message.get("event", {})
|
||||
cmd_type = event_data.get("type")
|
||||
if cmd_type == "call_service":
|
||||
call_id = event_data.get("call_id")
|
||||
domain = event_data.get("domain", "")
|
||||
service = event_data.get("service", "")
|
||||
service_data = event_data.get("service_data", {})
|
||||
target = event_data.get("target")
|
||||
return_response = event_data.get(
|
||||
"return_response", False
|
||||
)
|
||||
context_data = event_data.get("context")
|
||||
try:
|
||||
result = (
|
||||
await sandbox_svc_registry.async_execute_forwarded_call(
|
||||
domain, service, service_data,
|
||||
target=target,
|
||||
return_response=return_response,
|
||||
context_data=context_data,
|
||||
)
|
||||
)
|
||||
await sandbox_api.async_sandbox_service_call_result(
|
||||
call_id=call_id,
|
||||
success=True,
|
||||
result=result,
|
||||
)
|
||||
except Exception as err:
|
||||
kwargs = {
|
||||
"call_id": call_id,
|
||||
"success": False,
|
||||
"error": str(err),
|
||||
"error_type": type(err).__name__,
|
||||
}
|
||||
if hasattr(err, "translation_domain") and err.translation_domain:
|
||||
kwargs["translation_domain"] = err.translation_domain
|
||||
if hasattr(err, "translation_key") and err.translation_key:
|
||||
kwargs["translation_key"] = err.translation_key
|
||||
if hasattr(err, "translation_placeholders") and err.translation_placeholders:
|
||||
kwargs["translation_placeholders"] = err.translation_placeholders
|
||||
await sandbox_api.async_sandbox_service_call_result(
|
||||
**kwargs
|
||||
)
|
||||
|
||||
await sandbox_api.subscribe(
|
||||
_on_command,
|
||||
"sandbox/subscribe_entity_commands",
|
||||
)
|
||||
|
||||
try:
|
||||
yield sandbox_hass
|
||||
finally:
|
||||
await sandbox_hass.async_teardown_remote()
|
||||
await server.close()
|
||||
await host_hass.async_stop(force=True)
|
||||
# Clear the shutdown flag so pytest-asyncio can
|
||||
# still finalize fixtures on the shared loop.
|
||||
with suppress(AttributeError):
|
||||
delattr(
|
||||
host_hass.loop,
|
||||
"_shutdown_run_callback_threadsafe",
|
||||
)
|
||||
finally:
|
||||
_socket_mod.socket = saved_socket
|
||||
|
||||
tests_common.async_test_home_assistant = sandbox_async_test_home_assistant
|
||||
tests_conftest.async_test_home_assistant = sandbox_async_test_home_assistant
|
||||
tests_common._sandbox_ws_patched = True
|
||||
@@ -1,347 +0,0 @@
|
||||
"""Pytest bridge for running Home Assistant core tests against hass-client."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from contextlib import asynccontextmanager
|
||||
from functools import cached_property
|
||||
import json
|
||||
from pathlib import Path
|
||||
import re
|
||||
import threading
|
||||
from typing import Any
|
||||
from unittest.mock import Mock
|
||||
|
||||
KEY_REFERENCE_RE = re.compile(r"\[%key:([a-z0-9_]+(?:::(?:[a-z0-9-_])+)+)%\]")
|
||||
|
||||
|
||||
def _noop_timer() -> None:
|
||||
"""Compatibility no-op callback for mocked loop timer handles."""
|
||||
|
||||
|
||||
def _flatten_translations(
|
||||
translations: dict[str, Any],
|
||||
*,
|
||||
prefix: tuple[str, ...] = (),
|
||||
) -> dict[str, str]:
|
||||
"""Flatten nested translations into Lokalise reference keys."""
|
||||
flattened: dict[str, str] = {}
|
||||
for key, value in translations.items():
|
||||
new_prefix = (*prefix, key)
|
||||
if isinstance(value, dict):
|
||||
flattened.update(_flatten_translations(value, prefix=new_prefix))
|
||||
elif isinstance(value, str):
|
||||
flattened["::".join(new_prefix)] = value
|
||||
return flattened
|
||||
|
||||
|
||||
def _substitute_translation_string(
|
||||
value: str,
|
||||
substitutions: dict[str, str],
|
||||
resolved: dict[str, str],
|
||||
stack: set[str],
|
||||
) -> str | None:
|
||||
"""Substitute Lokalise-style references in a translation string."""
|
||||
new_value = value
|
||||
for reference in KEY_REFERENCE_RE.findall(value):
|
||||
if reference in stack:
|
||||
return None
|
||||
if reference in resolved:
|
||||
replacement = resolved[reference]
|
||||
else:
|
||||
raw_replacement = substitutions.get(reference)
|
||||
if raw_replacement is None:
|
||||
return None
|
||||
replacement = _substitute_translation_string(
|
||||
raw_replacement,
|
||||
substitutions,
|
||||
resolved,
|
||||
stack | {reference},
|
||||
)
|
||||
if replacement is None:
|
||||
return None
|
||||
resolved[reference] = replacement
|
||||
new_value = new_value.replace(f"[%key:{reference}%]", replacement)
|
||||
return new_value
|
||||
|
||||
|
||||
def _resolve_translations(
|
||||
translations: dict[str, Any],
|
||||
substitutions: dict[str, str],
|
||||
resolved: dict[str, str],
|
||||
) -> dict[str, Any]:
|
||||
"""Resolve Lokalise-style references in nested translation data."""
|
||||
result: dict[str, Any] = {}
|
||||
for key, value in translations.items():
|
||||
if isinstance(value, dict):
|
||||
nested = _resolve_translations(value, substitutions, resolved)
|
||||
if nested:
|
||||
result[key] = nested
|
||||
continue
|
||||
|
||||
if not isinstance(value, str):
|
||||
continue
|
||||
|
||||
substituted = _substitute_translation_string(value, substitutions, resolved, set())
|
||||
if substituted is not None:
|
||||
result[key] = substituted
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def _merge_missing_translations(
|
||||
target: dict[str, Any],
|
||||
source: dict[str, Any],
|
||||
) -> None:
|
||||
"""Recursively merge missing translation keys into a target dict."""
|
||||
for key, value in source.items():
|
||||
if key not in target:
|
||||
target[key] = value
|
||||
continue
|
||||
|
||||
existing = target[key]
|
||||
if isinstance(existing, dict) and isinstance(value, dict):
|
||||
_merge_missing_translations(existing, value)
|
||||
|
||||
|
||||
class _StringsResolver:
|
||||
"""Resolve Home Assistant strings.json content into translation payloads."""
|
||||
|
||||
def __init__(self, package_root: Path) -> None:
|
||||
"""Initialize the resolver."""
|
||||
self._package_root = package_root
|
||||
self._flattened_index: dict[str, str] | None = None
|
||||
self._component_cache: dict[str, dict[str, Any]] = {}
|
||||
self._resolved_references: dict[str, str] = {}
|
||||
|
||||
def _load_json(self, path: Path) -> dict[str, Any]:
|
||||
"""Load JSON from a path, returning an empty dict when absent."""
|
||||
if not path.is_file():
|
||||
return {}
|
||||
data = json.loads(path.read_text())
|
||||
return data if isinstance(data, dict) else {}
|
||||
|
||||
def _build_flattened_index(self) -> dict[str, str]:
|
||||
"""Build the flattened strings index across common and component strings."""
|
||||
flattened = _flatten_translations(self._load_json(self._package_root / "strings.json"))
|
||||
components_dir = self._package_root / "components"
|
||||
for strings_path in components_dir.glob("*/strings.json"):
|
||||
domain = strings_path.parent.name
|
||||
flattened.update(
|
||||
_flatten_translations(
|
||||
{"component": {domain: self._load_json(strings_path)}}
|
||||
)
|
||||
)
|
||||
return flattened
|
||||
|
||||
def resolve_component(self, domain: str) -> dict[str, Any]:
|
||||
"""Resolve a component strings.json file into translation data."""
|
||||
if domain not in self._component_cache:
|
||||
strings_path = self._package_root / "components" / domain / "strings.json"
|
||||
component_strings = self._load_json(strings_path)
|
||||
if not component_strings:
|
||||
self._component_cache[domain] = {}
|
||||
else:
|
||||
if self._flattened_index is None:
|
||||
self._flattened_index = self._build_flattened_index()
|
||||
self._component_cache[domain] = _resolve_translations(
|
||||
component_strings,
|
||||
self._flattened_index,
|
||||
self._resolved_references,
|
||||
)
|
||||
return self._component_cache[domain]
|
||||
|
||||
def get_exception_message(self, domain: str, key: str) -> str | None:
|
||||
"""Return an exception message from strings.json when available."""
|
||||
message = (
|
||||
self.resolve_component(domain)
|
||||
.get("exceptions", {})
|
||||
.get(key, {})
|
||||
.get("message")
|
||||
)
|
||||
return message if isinstance(message, str) else None
|
||||
|
||||
|
||||
def _build_exception_message_fallback(original, strings_resolver: _StringsResolver):
|
||||
"""Build a fallback that reads strings.json when translations are not generated."""
|
||||
|
||||
def async_get_exception_message(
|
||||
translation_domain: str,
|
||||
translation_key: str,
|
||||
translation_placeholders: dict[str, str] | None = None,
|
||||
) -> str:
|
||||
message = original(
|
||||
translation_domain,
|
||||
translation_key,
|
||||
translation_placeholders,
|
||||
)
|
||||
if message != translation_key:
|
||||
return message
|
||||
|
||||
component_message = strings_resolver.get_exception_message(
|
||||
translation_domain, translation_key
|
||||
)
|
||||
if not component_message:
|
||||
return message
|
||||
|
||||
component_message = component_message.rstrip(".")
|
||||
if not translation_placeholders:
|
||||
return component_message
|
||||
|
||||
try:
|
||||
return component_message.format(**translation_placeholders)
|
||||
except KeyError:
|
||||
return component_message
|
||||
|
||||
return async_get_exception_message
|
||||
|
||||
|
||||
def pytest_configure() -> None:
|
||||
"""Patch the core test fixture to use RemoteHomeAssistant."""
|
||||
import tests.conftest as tests_conftest
|
||||
import tests.common as tests_common
|
||||
import homeassistant.components.ffmpeg as ffmpeg_component
|
||||
import homeassistant
|
||||
import homeassistant.core as ha_core
|
||||
import homeassistant.helpers.entity_platform as entity_platform_helper
|
||||
import homeassistant.exceptions as ha_exceptions
|
||||
import homeassistant.loader as ha_loader
|
||||
import homeassistant.helpers.translation as translation_helper
|
||||
from homeassistant.util.async_ import cancelling
|
||||
|
||||
if getattr(tests_common, "_hass_client_patched", False):
|
||||
return
|
||||
|
||||
original_async_test_home_assistant = tests_common.async_test_home_assistant
|
||||
original_async_get_component_strings = translation_helper._async_get_component_strings
|
||||
original_platform_get_translations = (
|
||||
entity_platform_helper.PlatformData._async_get_translations
|
||||
)
|
||||
original_entity_platform_setup = entity_platform_helper.EntityPlatform._async_setup_platform
|
||||
original_ffmpeg_get_version = ffmpeg_component.FFmpegManager.async_get_version
|
||||
from hass_client.runtime import RemoteHomeAssistant
|
||||
original_async_block_till_done = RemoteHomeAssistant.async_block_till_done
|
||||
original_create_task = RemoteHomeAssistant.create_task
|
||||
|
||||
strings_resolver = _StringsResolver(Path(homeassistant.__file__).resolve().parent)
|
||||
fallback_exception_message = _build_exception_message_fallback(
|
||||
translation_helper.async_get_exception_message,
|
||||
strings_resolver,
|
||||
)
|
||||
|
||||
def has_translations(self) -> bool:
|
||||
"""Treat strings.json as a translation source for tests."""
|
||||
return (
|
||||
"translations" in self._top_level_files
|
||||
or "strings.json" in self._top_level_files
|
||||
)
|
||||
|
||||
async def async_get_component_strings(hass, languages, components, integrations):
|
||||
"""Load generated translations and synthesize English fallback from strings.json."""
|
||||
translations = await original_async_get_component_strings(
|
||||
hass, languages, components, integrations
|
||||
)
|
||||
|
||||
if "en" not in languages:
|
||||
return translations
|
||||
|
||||
english_translations = translations.setdefault("en", {})
|
||||
for domain in components:
|
||||
integration = integrations.get(domain)
|
||||
if integration is None:
|
||||
continue
|
||||
if (integration.file_path / "translations" / "en.json").is_file():
|
||||
continue
|
||||
synthesized = strings_resolver.resolve_component(domain)
|
||||
if not synthesized:
|
||||
continue
|
||||
_merge_missing_translations(
|
||||
english_translations.setdefault(domain, {}),
|
||||
synthesized,
|
||||
)
|
||||
|
||||
return translations
|
||||
|
||||
async def platform_get_translations(self, language, category, integration):
|
||||
"""Delegate to the platform translation loader."""
|
||||
return await original_platform_get_translations(
|
||||
self, language, category, integration
|
||||
)
|
||||
|
||||
async def entity_platform_setup(self, async_create_setup_awaitable, tries=0):
|
||||
"""Preserve mocked call_at trace shape expected by core helper tests."""
|
||||
if isinstance(self.hass.loop.call_at, Mock):
|
||||
self.hass.loop.call_later(0, _noop_timer)
|
||||
return await original_entity_platform_setup(
|
||||
self, async_create_setup_awaitable, tries
|
||||
)
|
||||
|
||||
async def async_block_till_done(self, wait_background_tasks: bool = False) -> None:
|
||||
"""Avoid scheduling timeout handles when loop.call_later is mocked in tests."""
|
||||
if not isinstance(self.loop.call_later, Mock):
|
||||
await original_async_block_till_done(self, wait_background_tasks)
|
||||
return
|
||||
|
||||
await asyncio.sleep(0)
|
||||
current_task = asyncio.current_task()
|
||||
while tasks := [
|
||||
task
|
||||
for task in (
|
||||
self._tasks | self._background_tasks
|
||||
if wait_background_tasks
|
||||
else self._tasks
|
||||
)
|
||||
if task is not current_task and not cancelling(task)
|
||||
]:
|
||||
await asyncio.wait(tasks)
|
||||
|
||||
def create_task(self, target, name=None) -> None:
|
||||
"""Run eager task creation immediately when already on the loop thread."""
|
||||
if self.loop_thread_id == threading.get_ident() and not isinstance(
|
||||
self.loop.call_at, Mock
|
||||
):
|
||||
self.async_create_task_internal(target, name, eager_start=True)
|
||||
return
|
||||
original_create_task(self, target, name)
|
||||
|
||||
async def ffmpeg_get_version(self):
|
||||
"""Avoid spawning subprocesses during ffmpeg setup in the compatibility suite."""
|
||||
if self._version is None:
|
||||
self._version = ffmpeg_component.OFFICIAL_IMAGE_VERSION
|
||||
self._major_version = int(self._version.split(".")[0])
|
||||
return self._version, self._major_version
|
||||
|
||||
tests_common.HomeAssistant = RemoteHomeAssistant
|
||||
ha_core.HomeAssistant = RemoteHomeAssistant
|
||||
RemoteHomeAssistant.async_block_till_done = async_block_till_done
|
||||
RemoteHomeAssistant.create_task = create_task
|
||||
ffmpeg_component.FFmpegManager.async_get_version = ffmpeg_get_version
|
||||
has_translations_descriptor = cached_property(has_translations)
|
||||
has_translations_descriptor.__set_name__(
|
||||
ha_loader.Integration, "has_translations"
|
||||
)
|
||||
ha_loader.Integration.has_translations = has_translations_descriptor
|
||||
entity_platform_helper.PlatformData._async_get_translations = (
|
||||
platform_get_translations
|
||||
)
|
||||
entity_platform_helper.EntityPlatform._async_setup_platform = entity_platform_setup
|
||||
translation_helper._async_get_component_strings = async_get_component_strings
|
||||
translation_helper.async_get_exception_message = fallback_exception_message
|
||||
ha_exceptions._function_cache["async_get_exception_message"] = (
|
||||
fallback_exception_message
|
||||
)
|
||||
|
||||
@asynccontextmanager
|
||||
async def async_test_home_assistant(*args, **kwargs):
|
||||
async with original_async_test_home_assistant(*args, **kwargs) as hass:
|
||||
if isinstance(hass, RemoteHomeAssistant):
|
||||
await hass.async_setup_remote()
|
||||
try:
|
||||
yield hass
|
||||
finally:
|
||||
if isinstance(hass, RemoteHomeAssistant):
|
||||
await hass.async_teardown_remote()
|
||||
|
||||
tests_common.async_test_home_assistant = async_test_home_assistant
|
||||
tests_conftest.async_test_home_assistant = async_test_home_assistant
|
||||
tests_common._hass_client_patched = True
|
||||
@@ -1,58 +0,0 @@
|
||||
[build-system]
|
||||
requires = ["setuptools>=69", "wheel"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "hass-client"
|
||||
version = "0.1.0"
|
||||
description = "A remote-capable Home Assistant compatibility layer for running integrations outside Home Assistant."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.14.2"
|
||||
license = { text = "Apache-2.0" }
|
||||
authors = [{ name = "Paulus Schoutsen" }]
|
||||
dependencies = [
|
||||
"aiohttp>=3.11.0",
|
||||
"hassil>=3.5.0",
|
||||
"home-assistant-intents>=2026.5.5",
|
||||
"homeassistant",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
test = [
|
||||
"pytest>=8.3.0",
|
||||
]
|
||||
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
"pytest>=8.3.0",
|
||||
"pytest-asyncio>=1.3.0",
|
||||
"pytest-socket>=0.7.0",
|
||||
"freezegun>=1.5.0",
|
||||
"bcrypt>=4.0.0",
|
||||
"requests-mock>=1.12.0",
|
||||
"respx>=0.23.0",
|
||||
"syrupy>=5.0.0",
|
||||
"paho-mqtt>=2.1.0",
|
||||
"aiohasupervisor>=0.4.3",
|
||||
"ha-ffmpeg>=3.2.2",
|
||||
"pytest-freezer>=0.4.9",
|
||||
"aiohue>=4.8.1",
|
||||
"python-picnic-api2>=1.3.4",
|
||||
"pytest-unordered>=0.7.0",
|
||||
]
|
||||
|
||||
[tool.setuptools]
|
||||
include-package-data = true
|
||||
zip-safe = false
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
include = ["hass_client*"]
|
||||
|
||||
[tool.setuptools.package-data]
|
||||
hass_client = ["py.typed"]
|
||||
|
||||
[tool.uv]
|
||||
default-groups = ["dev"]
|
||||
|
||||
[tool.uv.sources]
|
||||
homeassistant = { path = "../..", editable = true }
|
||||
@@ -1,21 +0,0 @@
|
||||
# Install all Home Assistant Core integration dependencies into the
|
||||
# sandbox client venv. Required for running HA Core's per-integration
|
||||
# test suites through the sandbox plugin — without these, integrations
|
||||
# like `conversation` (needs hassil), `rest` (needs xmltodict), and
|
||||
# `logbook` (needs sqlalchemy + numpy + turbojpeg at collection time)
|
||||
# fail to import and never get a chance to run.
|
||||
#
|
||||
# Use after `uv sync`:
|
||||
# uv pip install -r requirements_ha.txt
|
||||
#
|
||||
# macOS caveat: `pyitachip2ir` (used by the `itach` integration) has
|
||||
# C++ source that doesn't compile against modern macOS headers. The
|
||||
# install will abort on that package. To skip it:
|
||||
#
|
||||
# grep -v '^pyitachip2ir' ../../requirements_all.txt > /tmp/req.txt
|
||||
# cp ../../requirements_all.txt ../../requirements_all_filtered.txt # keep relative -r paths working
|
||||
# # …then install from the filtered copy.
|
||||
#
|
||||
# Paths in the -r directives below are resolved relative to this file.
|
||||
-r ../../requirements_all.txt
|
||||
-r ../../requirements_test.txt
|
||||
@@ -1,27 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
CORE_DIR="$ROOT/core"
|
||||
|
||||
if [[ ! -d "$CORE_DIR/.git" ]]; then
|
||||
exec "$ROOT/script/setup"
|
||||
fi
|
||||
|
||||
if [[ -n "$(git -C "$CORE_DIR" status --porcelain)" ]]; then
|
||||
echo "error: $CORE_DIR has local changes; refusing to update it" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
CURRENT_BRANCH="$(git -C "$CORE_DIR" branch --show-current)"
|
||||
if [[ -z "$CURRENT_BRANCH" ]]; then
|
||||
echo "error: $CORE_DIR is in detached HEAD state; check out a branch first" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! git -C "$CORE_DIR" rev-parse --abbrev-ref --symbolic-full-name '@{u}' >/dev/null 2>&1; then
|
||||
echo "error: $CORE_DIR branch '$CURRENT_BRANCH' has no upstream; configure one first" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
git -C "$CORE_DIR" pull --ff-only
|
||||
@@ -1,24 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
CORE_DIR="$ROOT/core"
|
||||
CORE_REPO="${HASS_CLIENT_CORE_REPO:-https://github.com/home-assistant/core.git}"
|
||||
CORE_BRANCH="${HASS_CLIENT_CORE_BRANCH:-dev}"
|
||||
|
||||
if [[ -e "$CORE_DIR" && ! -d "$CORE_DIR/.git" ]]; then
|
||||
echo "error: $CORE_DIR exists but is not a git checkout" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ -d "$CORE_DIR/.git" ]]; then
|
||||
echo "core already checked out at $CORE_DIR"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
git clone \
|
||||
--branch "$CORE_BRANCH" \
|
||||
--no-single-branch \
|
||||
--depth 1 \
|
||||
"$CORE_REPO" \
|
||||
"$CORE_DIR"
|
||||
@@ -1,23 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
CORE_DIR="$ROOT/core"
|
||||
PYTHON_VERSION="${HASS_CLIENT_PYTHON:-3.14}"
|
||||
|
||||
if [[ ! -d "$CORE_DIR/.git" ]]; then
|
||||
"$ROOT/script/setup"
|
||||
fi
|
||||
|
||||
(
|
||||
cd "$CORE_DIR"
|
||||
PYTHONPATH="$ROOT${PYTHONPATH:+:$PYTHONPATH}" \
|
||||
uv run \
|
||||
--project "$ROOT" \
|
||||
--python "$PYTHON_VERSION" \
|
||||
--with-requirements "$CORE_DIR/requirements.txt" \
|
||||
--with-requirements "$CORE_DIR/requirements_test_all.txt" \
|
||||
python -m pytest \
|
||||
"$ROOT/tests" \
|
||||
"$@"
|
||||
)
|
||||
@@ -1,32 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
CORE_DIR="$ROOT/core"
|
||||
PYTHON_VERSION="${HASS_CLIENT_PYTHON:-3.14}"
|
||||
|
||||
if [[ ! -d "$CORE_DIR/.git" ]]; then
|
||||
"$ROOT/script/setup"
|
||||
fi
|
||||
|
||||
(
|
||||
cd "$CORE_DIR"
|
||||
if [[ -n "${HASS_CLIENT_TEST_TARGET:-}" ]]; then
|
||||
read -r -a TEST_TARGETS <<<"$HASS_CLIENT_TEST_TARGET"
|
||||
else
|
||||
shopt -s nullglob
|
||||
TEST_TARGETS=(tests/test_*.py tests/helpers)
|
||||
shopt -u nullglob
|
||||
fi
|
||||
|
||||
PYTHONPATH="$ROOT${PYTHONPATH:+:$PYTHONPATH}" \
|
||||
uv run \
|
||||
--project "$ROOT" \
|
||||
--python "$PYTHON_VERSION" \
|
||||
--with-requirements "$CORE_DIR/requirements.txt" \
|
||||
--with-requirements "$CORE_DIR/requirements_test_all.txt" \
|
||||
python -m pytest \
|
||||
-p hass_client.testing.pytest_plugin \
|
||||
"${TEST_TARGETS[@]}" \
|
||||
"$@"
|
||||
)
|
||||
@@ -1,25 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
CORE_DIR="$ROOT/core"
|
||||
PYTHON_VERSION="${HASS_CLIENT_PYTHON:-3.14}"
|
||||
|
||||
if [[ ! -d "$CORE_DIR/.git" ]]; then
|
||||
"$ROOT/script/setup"
|
||||
fi
|
||||
|
||||
(
|
||||
cd "$CORE_DIR"
|
||||
PYTHONPATH="$ROOT${PYTHONPATH:+:$PYTHONPATH}" \
|
||||
uv run \
|
||||
--project "$ROOT" \
|
||||
--python "$PYTHON_VERSION" \
|
||||
--with-requirements "$CORE_DIR/requirements.txt" \
|
||||
--with-requirements "$CORE_DIR/requirements_test_all.txt" \
|
||||
python -m pytest \
|
||||
--rootdir="$CORE_DIR" \
|
||||
-c "$CORE_DIR/pyproject.toml" \
|
||||
"$ROOT/tests/test_sandbox_e2e.py" \
|
||||
"$@"
|
||||
)
|
||||
@@ -1,5 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
exec "$ROOT/script/test-core" "$@"
|
||||
@@ -1,97 +0,0 @@
|
||||
"""Tests for websocket error translation."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from hass_client.api import _translate_command_error
|
||||
from hass_client.exceptions import FailedCommand
|
||||
from homeassistant.components.websocket_api import const as websocket_api_const
|
||||
from homeassistant.exceptions import (
|
||||
ServiceNotFound,
|
||||
ServiceValidationError,
|
||||
TemplateError,
|
||||
)
|
||||
|
||||
|
||||
def test_translate_call_service_not_found() -> None:
|
||||
"""Translate remote service lookup failures into ServiceNotFound."""
|
||||
exception = _translate_command_error(
|
||||
{"type": "call_service", "domain": "light", "service": "turn_on"},
|
||||
{
|
||||
"code": websocket_api_const.ERR_NOT_FOUND,
|
||||
"message": "Service light.turn_on not found.",
|
||||
"translation_domain": "homeassistant",
|
||||
"translation_key": "service_not_found",
|
||||
},
|
||||
)
|
||||
|
||||
assert isinstance(exception, ServiceNotFound)
|
||||
assert exception.domain == "light"
|
||||
assert exception.service == "turn_on"
|
||||
|
||||
|
||||
def test_translate_call_service_validation_error() -> None:
|
||||
"""Translate remote validation failures into ServiceValidationError."""
|
||||
exception = _translate_command_error(
|
||||
{"type": "call_service", "domain": "light", "service": "turn_on"},
|
||||
{
|
||||
"code": websocket_api_const.ERR_SERVICE_VALIDATION_ERROR,
|
||||
"message": "Validation error: return_response can only be used with blocking calls",
|
||||
"translation_domain": "homeassistant",
|
||||
"translation_key": "service_should_be_blocking",
|
||||
"translation_placeholders": {
|
||||
"return_response": "return_response=True",
|
||||
"non_blocking_argument": "blocking=False",
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
assert isinstance(exception, ServiceValidationError)
|
||||
assert exception.translation_domain == "homeassistant"
|
||||
assert exception.translation_key == "service_should_be_blocking"
|
||||
assert exception.translation_placeholders == {
|
||||
"return_response": "return_response=True",
|
||||
"non_blocking_argument": "blocking=False",
|
||||
}
|
||||
|
||||
|
||||
def test_translate_call_service_invalid_format() -> None:
|
||||
"""Translate websocket invalid format errors into vol.Invalid."""
|
||||
exception = _translate_command_error(
|
||||
{"type": "call_service", "domain": "light", "service": "turn_on"},
|
||||
{
|
||||
"code": websocket_api_const.ERR_INVALID_FORMAT,
|
||||
"message": "extra keys not allowed @ data['invalid']",
|
||||
},
|
||||
)
|
||||
|
||||
assert isinstance(exception, vol.Invalid)
|
||||
|
||||
|
||||
def test_translate_generic_error_falls_back_to_failed_command() -> None:
|
||||
"""Keep generic websocket failures as FailedCommand when there is no HA analogue."""
|
||||
exception = _translate_command_error(
|
||||
{"type": "config/entity_registry/get", "entity_id": "light.kitchen"},
|
||||
{
|
||||
"code": websocket_api_const.ERR_NOT_FOUND,
|
||||
"message": "Entity not found",
|
||||
},
|
||||
)
|
||||
|
||||
assert isinstance(exception, FailedCommand)
|
||||
assert exception.command == "config/entity_registry/get"
|
||||
assert exception.code == websocket_api_const.ERR_NOT_FOUND
|
||||
|
||||
|
||||
def test_translate_template_error() -> None:
|
||||
"""Translate template websocket failures into TemplateError."""
|
||||
exception = _translate_command_error(
|
||||
{"type": "render_template"},
|
||||
{
|
||||
"code": websocket_api_const.ERR_TEMPLATE_ERROR,
|
||||
"message": "Template rendered invalid output",
|
||||
},
|
||||
)
|
||||
|
||||
assert isinstance(exception, TemplateError)
|
||||
@@ -1,111 +0,0 @@
|
||||
"""Tests for the remote entity registry manager."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
from hass_client.remotes.helpers.entity_registry import RemoteEntityRegistryManager
|
||||
from hass_client.runtime import RemoteHomeAssistant
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
|
||||
|
||||
def _entry_payload(entity_id: str, *, name: str) -> dict[str, object]:
|
||||
"""Build a websocket entity registry payload."""
|
||||
return {
|
||||
"aliases": [f"{name} alias"],
|
||||
"area_id": None,
|
||||
"categories": {},
|
||||
"capabilities": None,
|
||||
"config_entry_id": None,
|
||||
"config_subentry_id": None,
|
||||
"created_at": 1_700_000_000.0,
|
||||
"device_class": None,
|
||||
"device_id": None,
|
||||
"disabled_by": None,
|
||||
"entity_category": None,
|
||||
"entity_id": entity_id,
|
||||
"has_entity_name": True,
|
||||
"hidden_by": None,
|
||||
"icon": None,
|
||||
"id": f"{entity_id}-id",
|
||||
"labels": ["test"],
|
||||
"modified_at": 1_700_000_001.0,
|
||||
"name": name,
|
||||
"options": {},
|
||||
"original_device_class": None,
|
||||
"original_icon": None,
|
||||
"original_name": name,
|
||||
"platform": "demo",
|
||||
"translation_key": None,
|
||||
"unique_id": f"{entity_id}-unique",
|
||||
}
|
||||
|
||||
|
||||
def test_remote_entity_registry_manager_refreshes_snapshot(tmp_path: Path) -> None:
|
||||
"""Load the remote entity registry snapshot into the local registry."""
|
||||
|
||||
async def run_test() -> None:
|
||||
remote_api = AsyncMock()
|
||||
remote_api.async_get_entity_registry.return_value = [
|
||||
{"entity_id": "light.kitchen"}
|
||||
]
|
||||
remote_api.async_get_entity_registry_entry.return_value = _entry_payload(
|
||||
"light.kitchen",
|
||||
name="Kitchen",
|
||||
)
|
||||
|
||||
hass = RemoteHomeAssistant(str(tmp_path))
|
||||
hass.remote_api = remote_api
|
||||
manager = RemoteEntityRegistryManager(hass)
|
||||
|
||||
await manager.async_refresh()
|
||||
|
||||
entry = er.async_get(hass).entities["light.kitchen"]
|
||||
assert entry.entity_id == "light.kitchen"
|
||||
assert entry.aliases == ["Kitchen alias"]
|
||||
assert entry.labels == {"test"}
|
||||
|
||||
asyncio.run(run_test())
|
||||
|
||||
|
||||
def test_remote_entity_registry_manager_handles_renames(tmp_path: Path) -> None:
|
||||
"""Replace the old entity id when the remote registry renames an entry."""
|
||||
|
||||
async def run_test() -> None:
|
||||
remote_api = AsyncMock()
|
||||
old_payload = _entry_payload("light.old_name", name="Old")
|
||||
new_payload = _entry_payload("light.new_name", name="New")
|
||||
remote_api.async_get_entity_registry.return_value = [
|
||||
{"entity_id": "light.old_name"}
|
||||
]
|
||||
remote_api.async_get_entity_registry_entry.side_effect = [
|
||||
old_payload,
|
||||
new_payload,
|
||||
]
|
||||
|
||||
hass = RemoteHomeAssistant(str(tmp_path))
|
||||
hass.remote_api = remote_api
|
||||
manager = RemoteEntityRegistryManager(hass)
|
||||
|
||||
await manager.async_refresh()
|
||||
await manager._handle_event(
|
||||
{
|
||||
"event": {
|
||||
"context": None,
|
||||
"data": {
|
||||
"action": "update",
|
||||
"entity_id": "light.new_name",
|
||||
"old_entity_id": "light.old_name",
|
||||
},
|
||||
"time_fired": 1_700_000_002.0,
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
entities = er.async_get(hass).entities
|
||||
assert "light.old_name" not in entities
|
||||
assert entities["light.new_name"].name == "New"
|
||||
|
||||
asyncio.run(run_test())
|
||||
@@ -1,70 +0,0 @@
|
||||
"""Tests for the remote runtime."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
import zoneinfo
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
from hass_client.config import RemoteConfig
|
||||
from hass_client.runtime import RemoteHomeAssistant
|
||||
from hass_client.sandbox_service_registry import SandboxServiceRegistry
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
|
||||
def test_async_setup_remote_syncs_time_zone(tmp_path: Path) -> None:
|
||||
"""Sync the remote timezone into hass.config and dt_util on connect."""
|
||||
|
||||
async def run_test() -> None:
|
||||
original_time_zone = dt_util.get_default_time_zone()
|
||||
remote_time_zone = "Europe/Amsterdam"
|
||||
remote_api = AsyncMock()
|
||||
remote_api.async_get_config.return_value = {"time_zone": remote_time_zone}
|
||||
remote_api.subscribe_events.return_value = lambda: None
|
||||
|
||||
hass = RemoteHomeAssistant(str(tmp_path))
|
||||
hass.remote_config = RemoteConfig(
|
||||
sync_states=False,
|
||||
sync_entity_registry=False,
|
||||
)
|
||||
hass.remote_api = remote_api
|
||||
|
||||
try:
|
||||
await hass.async_setup_remote()
|
||||
|
||||
assert hass.config.time_zone == remote_time_zone
|
||||
assert dt_util.get_default_time_zone() == zoneinfo.ZoneInfo(
|
||||
remote_time_zone
|
||||
)
|
||||
remote_api.async_get_config.assert_awaited_once()
|
||||
finally:
|
||||
dt_util.set_default_time_zone(original_time_zone)
|
||||
await hass.async_teardown_remote()
|
||||
|
||||
asyncio.run(run_test())
|
||||
|
||||
|
||||
def test_sandbox_service_registry_forwards_calls(tmp_path: Path) -> None:
|
||||
"""SandboxServiceRegistry forwards async_call to the remote API."""
|
||||
|
||||
async def run_test() -> None:
|
||||
remote_api = AsyncMock()
|
||||
remote_api.connected = True
|
||||
remote_api.async_call_service.return_value = {}
|
||||
|
||||
hass = RemoteHomeAssistant(str(tmp_path))
|
||||
hass.remote_api = remote_api
|
||||
hass.services = SandboxServiceRegistry(hass, remote_api)
|
||||
|
||||
await hass.services.async_call("light", "turn_on", blocking=True)
|
||||
|
||||
remote_api.async_call_service.assert_awaited_once_with(
|
||||
domain="light",
|
||||
service="turn_on",
|
||||
service_data=None,
|
||||
target=None,
|
||||
return_response=False,
|
||||
)
|
||||
|
||||
asyncio.run(run_test())
|
||||
@@ -1,127 +0,0 @@
|
||||
"""End-to-end test for sandbox integration with input_boolean."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from unittest.mock import patch
|
||||
|
||||
from homeassistant.components.sandbox.const import DATA_SANDBOX, DOMAIN as SANDBOX_DOMAIN
|
||||
from homeassistant.components.sandbox import async_setup as sandbox_async_setup
|
||||
|
||||
from tests.common import MockConfigEntry, async_test_home_assistant
|
||||
|
||||
|
||||
def test_sandbox_setup_creates_token_and_instance() -> None:
|
||||
"""Test that sandbox setup creates auth tokens and sandbox instances."""
|
||||
|
||||
async def run() -> None:
|
||||
async with async_test_home_assistant() as hass:
|
||||
await sandbox_async_setup(hass, {})
|
||||
|
||||
entry = MockConfigEntry(
|
||||
domain=SANDBOX_DOMAIN,
|
||||
data={
|
||||
"entries": [
|
||||
{
|
||||
"entry_id": "test_input_bool_1",
|
||||
"domain": "input_boolean",
|
||||
"title": "Test Switch",
|
||||
"data": {
|
||||
"items": [
|
||||
{
|
||||
"id": "my_switch",
|
||||
"name": "My Switch",
|
||||
"initial": False,
|
||||
}
|
||||
]
|
||||
},
|
||||
}
|
||||
]
|
||||
},
|
||||
)
|
||||
entry.add_to_hass(hass)
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.sandbox._spawn_sandbox",
|
||||
return_value=None,
|
||||
):
|
||||
result = await hass.config_entries.async_setup(entry.entry_id)
|
||||
assert result is True
|
||||
|
||||
sandbox_data = hass.data[DATA_SANDBOX]
|
||||
instance = sandbox_data.sandboxes[entry.entry_id]
|
||||
assert instance is not None
|
||||
assert instance.access_token is not None
|
||||
assert instance.refresh_token is not None
|
||||
assert len(instance.entries) == 1
|
||||
assert instance.entries[0]["domain"] == "input_boolean"
|
||||
assert instance.refresh_token.id in sandbox_data.token_to_sandbox
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
def test_sandbox_state_update() -> None:
|
||||
"""Test that state can be set (simulating sandbox/update_state)."""
|
||||
|
||||
async def run() -> None:
|
||||
async with async_test_home_assistant() as hass:
|
||||
hass.states.async_set(
|
||||
"input_boolean.test_switch",
|
||||
"off",
|
||||
{"friendly_name": "Test"},
|
||||
)
|
||||
state = hass.states.get("input_boolean.test_switch")
|
||||
assert state is not None
|
||||
assert state.state == "off"
|
||||
|
||||
hass.states.async_set(
|
||||
"input_boolean.test_switch",
|
||||
"on",
|
||||
{"friendly_name": "Test", "editable": True},
|
||||
)
|
||||
state = hass.states.get("input_boolean.test_switch")
|
||||
assert state is not None
|
||||
assert state.state == "on"
|
||||
assert state.attributes["editable"] is True
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
def test_sandbox_unload_cleans_up() -> None:
|
||||
"""Test that unloading a sandbox config entry cleans up resources."""
|
||||
|
||||
async def run() -> None:
|
||||
async with async_test_home_assistant() as hass:
|
||||
await sandbox_async_setup(hass, {})
|
||||
|
||||
entry = MockConfigEntry(
|
||||
domain=SANDBOX_DOMAIN,
|
||||
data={
|
||||
"entries": [
|
||||
{
|
||||
"entry_id": "test_1",
|
||||
"domain": "input_boolean",
|
||||
"title": "Test",
|
||||
"data": {"items": [{"id": "x", "name": "X"}]},
|
||||
}
|
||||
]
|
||||
},
|
||||
)
|
||||
entry.add_to_hass(hass)
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.sandbox._spawn_sandbox",
|
||||
return_value=None,
|
||||
):
|
||||
await hass.config_entries.async_setup(entry.entry_id)
|
||||
|
||||
sandbox_data = hass.data[DATA_SANDBOX]
|
||||
assert entry.entry_id in sandbox_data.sandboxes
|
||||
token_id = sandbox_data.sandboxes[entry.entry_id].refresh_token.id
|
||||
|
||||
await hass.config_entries.async_unload(entry.entry_id)
|
||||
|
||||
assert entry.entry_id not in sandbox_data.sandboxes
|
||||
assert token_id not in sandbox_data.token_to_sandbox
|
||||
|
||||
asyncio.run(run())
|
||||
Generated
-2446
File diff suppressed because it is too large
Load Diff
@@ -1,128 +0,0 @@
|
||||
"""Run all tests for each integration through the sandbox plugin and collect results."""
|
||||
|
||||
import csv
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
|
||||
RESULTS_FILE = "/tmp/sandbox_test_results.csv"
|
||||
ERRORS_DIR = "/tmp/sandbox_test_errors"
|
||||
# Paths are relative to this file (core/sandbox/run_all_sandbox_tests.py).
|
||||
_HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
CORE_TESTS_DIR = os.path.abspath(os.path.join(_HERE, "..", "tests", "components"))
|
||||
HASS_CLIENT_DIR = os.path.join(_HERE, "hass_client")
|
||||
|
||||
os.makedirs(ERRORS_DIR, exist_ok=True)
|
||||
|
||||
# Get all integration directories that have test files
|
||||
integrations = sorted([
|
||||
d for d in os.listdir(CORE_TESTS_DIR)
|
||||
if os.path.isdir(os.path.join(CORE_TESTS_DIR, d))
|
||||
and any(f.startswith("test_") and f.endswith(".py")
|
||||
for f in os.listdir(os.path.join(CORE_TESTS_DIR, d)))
|
||||
])
|
||||
|
||||
total = len(integrations)
|
||||
results = []
|
||||
|
||||
start_time = time.time()
|
||||
|
||||
for i, integration in enumerate(integrations, 1):
|
||||
test_dir = os.path.join(CORE_TESTS_DIR, integration)
|
||||
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
[
|
||||
"uv", "run", "python", "-m", "pytest",
|
||||
"-p", "hass_client.testing.conftest_sandbox",
|
||||
test_dir,
|
||||
"--tb=no", "-q", "--no-header",
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=300, # 5 minutes per integration (full directory)
|
||||
cwd=HASS_CLIENT_DIR,
|
||||
)
|
||||
output = proc.stdout + proc.stderr
|
||||
exit_code = proc.returncode
|
||||
timed_out = False
|
||||
except subprocess.TimeoutExpired:
|
||||
output = ""
|
||||
exit_code = -1
|
||||
timed_out = True
|
||||
|
||||
# Parse pytest summary line
|
||||
passed = failed = errors = 0
|
||||
for line in output.splitlines()[-10:]:
|
||||
m_passed = re.search(r'(\d+) passed', line)
|
||||
m_failed = re.search(r'(\d+) failed', line)
|
||||
m_errors = re.search(r'(\d+) error', line)
|
||||
if m_passed:
|
||||
passed = int(m_passed.group(1))
|
||||
if m_failed:
|
||||
failed = int(m_failed.group(1))
|
||||
if m_errors:
|
||||
errors = int(m_errors.group(1))
|
||||
|
||||
total_tests = passed + failed + errors
|
||||
|
||||
if timed_out:
|
||||
status = "timeout"
|
||||
elif total_tests == 0:
|
||||
status = "no_tests"
|
||||
elif errors == 0 and failed == 0:
|
||||
status = "pass"
|
||||
else:
|
||||
status = "issues"
|
||||
|
||||
if status != "pass":
|
||||
with open(os.path.join(ERRORS_DIR, f"{integration}.txt"), "w") as ef:
|
||||
ef.write(output)
|
||||
|
||||
results.append({
|
||||
"integration": integration,
|
||||
"passed": passed,
|
||||
"failed": failed,
|
||||
"errors": errors,
|
||||
"total": total_tests,
|
||||
"status": status,
|
||||
})
|
||||
|
||||
if i % 25 == 0:
|
||||
elapsed = time.time() - start_time
|
||||
rate = i / elapsed
|
||||
eta = (total - i) / rate if rate > 0 else 0
|
||||
print(f"[{i}/{total}] {integration} -> {status} ({passed}p/{failed}f/{errors}e) | ETA: {eta/60:.0f}m")
|
||||
sys.stdout.flush()
|
||||
|
||||
# Write CSV
|
||||
with open(RESULTS_FILE, "w", newline="") as f:
|
||||
writer = csv.DictWriter(f, fieldnames=["integration", "passed", "failed", "errors", "total", "status"])
|
||||
writer.writeheader()
|
||||
writer.writerows(results)
|
||||
|
||||
# Summary
|
||||
pass_count = sum(1 for r in results if r["status"] == "pass")
|
||||
issues_count = sum(1 for r in results if r["status"] == "issues")
|
||||
timeout_count = sum(1 for r in results if r["status"] == "timeout")
|
||||
no_tests_count = sum(1 for r in results if r["status"] == "no_tests")
|
||||
total_passed = sum(r["passed"] for r in results)
|
||||
total_failed = sum(r["failed"] for r in results)
|
||||
total_errors = sum(r["errors"] for r in results)
|
||||
|
||||
print(f"\n{'='*60}")
|
||||
print(f"DONE - {len(results)} integrations tested (full test directories)")
|
||||
print(f"{'='*60}")
|
||||
print(f"Pass: {pass_count}")
|
||||
print(f"Issues: {issues_count}")
|
||||
print(f"Timeout: {timeout_count}")
|
||||
print(f"No tests: {no_tests_count}")
|
||||
print(f"")
|
||||
print(f"Total tests: {total_passed + total_failed + total_errors}")
|
||||
print(f" Passed: {total_passed}")
|
||||
print(f" Failed: {total_failed}")
|
||||
print(f" Errors: {total_errors}")
|
||||
print(f"\nElapsed: {(time.time() - start_time)/60:.1f} minutes")
|
||||
print(f"Results: {RESULTS_FILE}")
|
||||
@@ -1 +0,0 @@
|
||||
"""Tests for the sandbox integration."""
|
||||
@@ -1,195 +0,0 @@
|
||||
"""Test sandbox entity proxy architecture."""
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from homeassistant.components.sandbox import (
|
||||
SandboxData,
|
||||
SandboxEntryData,
|
||||
SandboxInstance,
|
||||
)
|
||||
from homeassistant.components.sandbox.const import DATA_SANDBOX
|
||||
from homeassistant.components.sandbox.entity import (
|
||||
SandboxEntityManager,
|
||||
SandboxLightEntity,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.setup import async_setup_component
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def sandbox_setup(hass: HomeAssistant) -> tuple[str, MockConfigEntry]:
|
||||
"""Set up the sandbox integration with a mock config entry."""
|
||||
assert await async_setup_component(hass, "sandbox", {})
|
||||
|
||||
sandbox_id = "test_sandbox_123"
|
||||
entry = MockConfigEntry(
|
||||
domain="sandbox",
|
||||
entry_id=sandbox_id,
|
||||
data={
|
||||
"entries": [
|
||||
{
|
||||
"entry_id": "hue_entry_1",
|
||||
"domain": "hue",
|
||||
"title": "Hue Bridge",
|
||||
"data": {"host": "192.168.1.100"},
|
||||
}
|
||||
]
|
||||
},
|
||||
)
|
||||
entry.add_to_hass(hass)
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.sandbox._spawn_sandbox",
|
||||
return_value=None,
|
||||
):
|
||||
await hass.config_entries.async_setup(entry.entry_id)
|
||||
|
||||
await hass.async_block_till_done()
|
||||
|
||||
return sandbox_id, entry
|
||||
|
||||
|
||||
async def test_entity_manager_created(
|
||||
hass: HomeAssistant, sandbox_setup: tuple[str, MockConfigEntry]
|
||||
) -> None:
|
||||
"""Test that entity manager is created during setup."""
|
||||
sandbox_id, _ = sandbox_setup
|
||||
sandbox_data: SandboxData = hass.data[DATA_SANDBOX]
|
||||
assert sandbox_id in sandbox_data.entity_managers
|
||||
manager = sandbox_data.entity_managers[sandbox_id]
|
||||
assert isinstance(manager, SandboxEntityManager)
|
||||
|
||||
|
||||
async def test_register_entity_creates_proxy(
|
||||
hass: HomeAssistant, sandbox_setup: tuple[str, MockConfigEntry]
|
||||
) -> None:
|
||||
"""Test that registering an entity creates a proxy and tracks it."""
|
||||
sandbox_id, entry = sandbox_setup
|
||||
sandbox_data: SandboxData = hass.data[DATA_SANDBOX]
|
||||
manager = sandbox_data.entity_managers[sandbox_id]
|
||||
|
||||
from homeassistant.components.sandbox.entity import SandboxEntityDescription
|
||||
|
||||
description = SandboxEntityDescription(
|
||||
domain="light",
|
||||
platform="hue",
|
||||
unique_id=f"{sandbox_id}_light_living_room",
|
||||
sandbox_id=sandbox_id,
|
||||
sandbox_entry_id="hue_entry_1",
|
||||
original_name="Living Room",
|
||||
supported_features=0,
|
||||
capabilities={"supported_color_modes": ["brightness"]},
|
||||
)
|
||||
|
||||
# Forward the light platform setup
|
||||
await hass.config_entries.async_forward_entry_setups(entry, ["light"])
|
||||
|
||||
# Verify platform callback is registered
|
||||
assert "light" in manager._platform_add_callbacks
|
||||
|
||||
# Create and add the entity
|
||||
entity = manager.add_entity(description)
|
||||
assert isinstance(entity, SandboxLightEntity)
|
||||
|
||||
add_entities = manager._platform_add_callbacks["light"]
|
||||
add_entities([entity])
|
||||
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# Entity should now have an entity_id and be tracked
|
||||
assert entity.entity_id is not None
|
||||
assert entity.entity_id.startswith("light.")
|
||||
assert manager.get_entity(entity.entity_id) is entity
|
||||
|
||||
|
||||
async def test_proxy_entity_state_update(
|
||||
hass: HomeAssistant, sandbox_setup: tuple[str, MockConfigEntry]
|
||||
) -> None:
|
||||
"""Test that state updates from sandbox reach the proxy entity."""
|
||||
sandbox_id, entry = sandbox_setup
|
||||
sandbox_data: SandboxData = hass.data[DATA_SANDBOX]
|
||||
manager = sandbox_data.entity_managers[sandbox_id]
|
||||
|
||||
from homeassistant.components.sandbox.entity import SandboxEntityDescription
|
||||
|
||||
description = SandboxEntityDescription(
|
||||
domain="light",
|
||||
platform="hue",
|
||||
unique_id=f"{sandbox_id}_light_kitchen",
|
||||
sandbox_id=sandbox_id,
|
||||
sandbox_entry_id="hue_entry_1",
|
||||
original_name="Kitchen",
|
||||
supported_features=0,
|
||||
capabilities={"supported_color_modes": ["brightness"]},
|
||||
)
|
||||
|
||||
await hass.config_entries.async_forward_entry_setups(entry, ["light"])
|
||||
|
||||
entity = manager.add_entity(description)
|
||||
add_entities = manager._platform_add_callbacks["light"]
|
||||
add_entities([entity])
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# Update state from sandbox
|
||||
entity.sandbox_update_state("on", {"brightness": 200, "color_mode": "brightness"})
|
||||
|
||||
state = hass.states.get(entity.entity_id)
|
||||
assert state is not None
|
||||
assert state.state == "on"
|
||||
assert state.attributes.get("brightness") == 200
|
||||
|
||||
|
||||
async def test_proxy_entity_forwards_method(
|
||||
hass: HomeAssistant, sandbox_setup: tuple[str, MockConfigEntry]
|
||||
) -> None:
|
||||
"""Test that proxy entity forwards method calls to sandbox."""
|
||||
sandbox_id, entry = sandbox_setup
|
||||
sandbox_data: SandboxData = hass.data[DATA_SANDBOX]
|
||||
manager = sandbox_data.entity_managers[sandbox_id]
|
||||
sandbox_info = sandbox_data.sandboxes[sandbox_id]
|
||||
|
||||
from homeassistant.components.sandbox.entity import SandboxEntityDescription
|
||||
|
||||
description = SandboxEntityDescription(
|
||||
domain="light",
|
||||
platform="hue",
|
||||
unique_id=f"{sandbox_id}_light_bedroom",
|
||||
sandbox_id=sandbox_id,
|
||||
sandbox_entry_id="hue_entry_1",
|
||||
original_name="Bedroom",
|
||||
supported_features=0,
|
||||
capabilities={"supported_color_modes": ["brightness"]},
|
||||
)
|
||||
|
||||
await hass.config_entries.async_forward_entry_setups(entry, ["light"])
|
||||
|
||||
entity = manager.add_entity(description)
|
||||
add_entities = manager._platform_add_callbacks["light"]
|
||||
add_entities([entity])
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# Set up a mock send_command
|
||||
sent_commands: list[dict] = []
|
||||
|
||||
def mock_send_command(command: dict) -> None:
|
||||
sent_commands.append(command)
|
||||
# Simulate immediate success response
|
||||
call_id = command["call_id"]
|
||||
manager.resolve_call(call_id, None, None)
|
||||
|
||||
sandbox_info.send_command = mock_send_command
|
||||
|
||||
# Call turn_on on the proxy
|
||||
await entity.async_turn_on(brightness=128)
|
||||
|
||||
assert len(sent_commands) == 1
|
||||
cmd = sent_commands[0]
|
||||
assert cmd["type"] == "call_method"
|
||||
assert cmd["entity_id"] == entity.entity_id
|
||||
assert cmd["method"] == "async_turn_on"
|
||||
assert cmd["kwargs"] == {"brightness": 128}
|
||||
@@ -1,539 +0,0 @@
|
||||
"""Test multiple sandboxes with area/device/entity targeting."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from homeassistant.components.light import ATTR_BRIGHTNESS, ColorMode, LightEntity
|
||||
from homeassistant.components.sandbox.const import DATA_SANDBOX
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import (
|
||||
area_registry as ar,
|
||||
device_registry as dr,
|
||||
entity_registry as er,
|
||||
)
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.setup import async_setup_component
|
||||
|
||||
from tests.common import (
|
||||
MockConfigEntry,
|
||||
mock_platform,
|
||||
MockEntity,
|
||||
MockPlatform,
|
||||
)
|
||||
from tests.typing import WebSocketGenerator
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ignore_translations_for_mock_domains() -> list[str]:
|
||||
"""Don't check translations for our mock domains."""
|
||||
return ["test_native"]
|
||||
|
||||
|
||||
class MockNativeLight(LightEntity):
|
||||
"""A native light entity on the host."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
_attr_supported_color_modes = {ColorMode.BRIGHTNESS}
|
||||
_attr_color_mode = ColorMode.BRIGHTNESS
|
||||
_attr_should_poll = False
|
||||
|
||||
def __init__(self, unique_id: str, name: str, device_info: DeviceInfo) -> None:
|
||||
"""Initialize."""
|
||||
self._attr_unique_id = unique_id
|
||||
self._attr_name = name
|
||||
self._attr_device_info = device_info
|
||||
self._attr_is_on = False
|
||||
self._attr_brightness = 0
|
||||
self.turn_on_calls: list[dict] = []
|
||||
self.turn_off_calls: list[dict] = []
|
||||
|
||||
async def async_turn_on(self, **kwargs) -> None:
|
||||
"""Turn on."""
|
||||
self._attr_is_on = True
|
||||
self._attr_brightness = kwargs.get(ATTR_BRIGHTNESS, 255)
|
||||
self.turn_on_calls.append(kwargs)
|
||||
self.async_write_ha_state()
|
||||
|
||||
async def async_turn_off(self, **kwargs) -> None:
|
||||
"""Turn off."""
|
||||
self._attr_is_on = False
|
||||
self.turn_off_calls.append(kwargs)
|
||||
self.async_write_ha_state()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def multi_sandbox_setup(
|
||||
hass: HomeAssistant,
|
||||
hass_ws_client: WebSocketGenerator,
|
||||
) -> dict:
|
||||
"""Set up 2 sandboxes + 1 native light, all in the same area."""
|
||||
assert await async_setup_component(hass, "sandbox", {})
|
||||
assert await async_setup_component(hass, "light", {})
|
||||
|
||||
# Create area
|
||||
area_reg = ar.async_get(hass)
|
||||
area = area_reg.async_create("Living Room")
|
||||
|
||||
# --- Sandbox A: "Hue light" ---
|
||||
sandbox_a_id = "sandbox_a_001"
|
||||
entry_a = MockConfigEntry(
|
||||
domain="sandbox",
|
||||
entry_id=sandbox_a_id,
|
||||
data={
|
||||
"entries": [
|
||||
{
|
||||
"entry_id": "hue_entry_a",
|
||||
"domain": "hue",
|
||||
"title": "Hue Bridge",
|
||||
"data": {},
|
||||
}
|
||||
]
|
||||
},
|
||||
)
|
||||
entry_a.add_to_hass(hass)
|
||||
|
||||
# --- Sandbox B: "IKEA light" ---
|
||||
sandbox_b_id = "sandbox_b_002"
|
||||
entry_b = MockConfigEntry(
|
||||
domain="sandbox",
|
||||
entry_id=sandbox_b_id,
|
||||
data={
|
||||
"entries": [
|
||||
{
|
||||
"entry_id": "ikea_entry_b",
|
||||
"domain": "ikea",
|
||||
"title": "IKEA Gateway",
|
||||
"data": {},
|
||||
}
|
||||
]
|
||||
},
|
||||
)
|
||||
entry_b.add_to_hass(hass)
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.sandbox._spawn_sandbox",
|
||||
return_value=None,
|
||||
):
|
||||
await hass.config_entries.async_setup(entry_a.entry_id)
|
||||
await hass.config_entries.async_setup(entry_b.entry_id)
|
||||
|
||||
await hass.async_block_till_done()
|
||||
|
||||
sandbox_data = hass.data[DATA_SANDBOX]
|
||||
|
||||
# Connect sandbox A ws client
|
||||
instance_a = sandbox_data.sandboxes[sandbox_a_id]
|
||||
ws_a = await hass_ws_client(hass, access_token=instance_a.access_token)
|
||||
|
||||
# Connect sandbox B ws client
|
||||
instance_b = sandbox_data.sandboxes[sandbox_b_id]
|
||||
ws_b = await hass_ws_client(hass, access_token=instance_b.access_token)
|
||||
|
||||
# Subscribe both to entity commands
|
||||
await ws_a.send_json({"id": 1, "type": "sandbox/subscribe_entity_commands"})
|
||||
resp = await ws_a.receive_json()
|
||||
assert resp["success"]
|
||||
|
||||
await ws_b.send_json({"id": 1, "type": "sandbox/subscribe_entity_commands"})
|
||||
resp = await ws_b.receive_json()
|
||||
assert resp["success"]
|
||||
|
||||
# Register device + light for sandbox A
|
||||
device_reg = dr.async_get(hass)
|
||||
|
||||
await ws_a.send_json(
|
||||
{
|
||||
"id": 2,
|
||||
"type": "sandbox/register_device",
|
||||
"sandbox_entry_id": "hue_entry_a",
|
||||
"identifiers": [{"domain": "hue", "id": "hue_bulb_1"}],
|
||||
"name": "Hue Bulb",
|
||||
"manufacturer": "Philips",
|
||||
}
|
||||
)
|
||||
resp = await ws_a.receive_json()
|
||||
assert resp["success"]
|
||||
device_a_id = resp["result"]["device_id"]
|
||||
|
||||
# Assign device A to area
|
||||
device_reg.async_update_device(device_a_id, area_id=area.id)
|
||||
|
||||
await ws_a.send_json(
|
||||
{
|
||||
"id": 3,
|
||||
"type": "sandbox/register_entity",
|
||||
"sandbox_entry_id": "hue_entry_a",
|
||||
"domain": "light",
|
||||
"platform": "hue",
|
||||
"unique_id": "hue_bulb_1_light",
|
||||
"device_id": device_a_id,
|
||||
"original_name": "Hue Bulb",
|
||||
"supported_features": 0,
|
||||
"capabilities": {"supported_color_modes": ["brightness"]},
|
||||
"suggested_object_id": "hue_bulb",
|
||||
}
|
||||
)
|
||||
resp = await ws_a.receive_json()
|
||||
assert resp["success"]
|
||||
entity_a_id = resp["result"]["entity_id"]
|
||||
|
||||
# Push initial state for A
|
||||
await ws_a.send_json(
|
||||
{
|
||||
"id": 4,
|
||||
"type": "sandbox/update_state",
|
||||
"entity_id": entity_a_id,
|
||||
"state": "off",
|
||||
"attributes": {"brightness": None, "color_mode": None},
|
||||
}
|
||||
)
|
||||
resp = await ws_a.receive_json()
|
||||
assert resp["success"]
|
||||
|
||||
# Register device + light for sandbox B
|
||||
await ws_b.send_json(
|
||||
{
|
||||
"id": 2,
|
||||
"type": "sandbox/register_device",
|
||||
"sandbox_entry_id": "ikea_entry_b",
|
||||
"identifiers": [{"domain": "ikea", "id": "ikea_bulb_1"}],
|
||||
"name": "IKEA Bulb",
|
||||
"manufacturer": "IKEA",
|
||||
}
|
||||
)
|
||||
resp = await ws_b.receive_json()
|
||||
assert resp["success"]
|
||||
device_b_id = resp["result"]["device_id"]
|
||||
|
||||
# Assign device B to same area
|
||||
device_reg.async_update_device(device_b_id, area_id=area.id)
|
||||
|
||||
await ws_b.send_json(
|
||||
{
|
||||
"id": 3,
|
||||
"type": "sandbox/register_entity",
|
||||
"sandbox_entry_id": "ikea_entry_b",
|
||||
"domain": "light",
|
||||
"platform": "ikea",
|
||||
"unique_id": "ikea_bulb_1_light",
|
||||
"device_id": device_b_id,
|
||||
"original_name": "IKEA Bulb",
|
||||
"supported_features": 0,
|
||||
"capabilities": {"supported_color_modes": ["brightness"]},
|
||||
"suggested_object_id": "ikea_bulb",
|
||||
}
|
||||
)
|
||||
resp = await ws_b.receive_json()
|
||||
assert resp["success"]
|
||||
entity_b_id = resp["result"]["entity_id"]
|
||||
|
||||
# Push initial state for B
|
||||
await ws_b.send_json(
|
||||
{
|
||||
"id": 4,
|
||||
"type": "sandbox/update_state",
|
||||
"entity_id": entity_b_id,
|
||||
"state": "off",
|
||||
"attributes": {"brightness": None, "color_mode": None},
|
||||
}
|
||||
)
|
||||
resp = await ws_b.receive_json()
|
||||
assert resp["success"]
|
||||
|
||||
# --- Native light on host ---
|
||||
native_entry = MockConfigEntry(domain="test_native", entry_id="native_entry_1")
|
||||
native_entry.add_to_hass(hass)
|
||||
|
||||
native_device = device_reg.async_get_or_create(
|
||||
config_entry_id=native_entry.entry_id,
|
||||
identifiers={("test_native", "ceiling_1")},
|
||||
name="Ceiling Light",
|
||||
manufacturer="Generic",
|
||||
)
|
||||
device_reg.async_update_device(native_device.id, area_id=area.id)
|
||||
|
||||
native_light = MockNativeLight(
|
||||
"native_ceiling",
|
||||
"Ceiling Light",
|
||||
DeviceInfo(identifiers={("test_native", "ceiling_1")}),
|
||||
)
|
||||
|
||||
from homeassistant.helpers.entity_platform import EntityPlatform
|
||||
import logging
|
||||
from datetime import timedelta
|
||||
|
||||
platform = EntityPlatform(
|
||||
hass=hass,
|
||||
logger=logging.getLogger("test"),
|
||||
domain="light",
|
||||
platform_name="test_native",
|
||||
platform=None,
|
||||
scan_interval=timedelta(seconds=30),
|
||||
entity_namespace=None,
|
||||
)
|
||||
platform.config_entry = native_entry
|
||||
await platform.async_add_entities([native_light])
|
||||
|
||||
native_entity_id = native_light.entity_id
|
||||
|
||||
await hass.async_block_till_done()
|
||||
|
||||
return {
|
||||
"hass": hass,
|
||||
"area": area,
|
||||
"ws_a": ws_a,
|
||||
"ws_b": ws_b,
|
||||
"entity_a_id": entity_a_id,
|
||||
"entity_b_id": entity_b_id,
|
||||
"native_entity_id": native_entity_id,
|
||||
"native_light": native_light,
|
||||
"device_a_id": device_a_id,
|
||||
"device_b_id": device_b_id,
|
||||
"native_device_id": native_device.id,
|
||||
}
|
||||
|
||||
|
||||
async def _respond_to_command(ws, msg_id: int) -> dict:
|
||||
"""Read one command from the subscription and respond with success."""
|
||||
cmd_msg = await asyncio.wait_for(ws.receive_json(), timeout=5)
|
||||
assert cmd_msg["type"] == "event"
|
||||
event = cmd_msg["event"]
|
||||
|
||||
await ws.send_json(
|
||||
{
|
||||
"id": msg_id,
|
||||
"type": "sandbox/entity_command_result",
|
||||
"call_id": event["call_id"],
|
||||
"success": True,
|
||||
}
|
||||
)
|
||||
resp = await ws.receive_json()
|
||||
assert resp["success"]
|
||||
return event
|
||||
|
||||
|
||||
async def test_turn_on_by_entity_id(
|
||||
hass: HomeAssistant, multi_sandbox_setup: dict
|
||||
) -> None:
|
||||
"""Test turning on each light individually by entity_id."""
|
||||
setup = multi_sandbox_setup
|
||||
ws_a = setup["ws_a"]
|
||||
ws_b = setup["ws_b"]
|
||||
native_light = setup["native_light"]
|
||||
|
||||
# Turn on sandbox A light
|
||||
task = asyncio.create_task(
|
||||
hass.services.async_call(
|
||||
"light",
|
||||
"turn_on",
|
||||
{"brightness": 100},
|
||||
target={"entity_id": setup["entity_a_id"]},
|
||||
blocking=True,
|
||||
)
|
||||
)
|
||||
event = await _respond_to_command(ws_a, msg_id=10)
|
||||
await task
|
||||
|
||||
assert event["method"] == "async_turn_on"
|
||||
assert event["entity_id"] == setup["entity_a_id"]
|
||||
assert event["kwargs"]["brightness"] == 100
|
||||
|
||||
# Turn on sandbox B light
|
||||
task = asyncio.create_task(
|
||||
hass.services.async_call(
|
||||
"light",
|
||||
"turn_on",
|
||||
{"brightness": 200},
|
||||
target={"entity_id": setup["entity_b_id"]},
|
||||
blocking=True,
|
||||
)
|
||||
)
|
||||
event = await _respond_to_command(ws_b, msg_id=10)
|
||||
await task
|
||||
|
||||
assert event["method"] == "async_turn_on"
|
||||
assert event["entity_id"] == setup["entity_b_id"]
|
||||
assert event["kwargs"]["brightness"] == 200
|
||||
|
||||
# Turn on native light
|
||||
await hass.services.async_call(
|
||||
"light",
|
||||
"turn_on",
|
||||
{"brightness": 150},
|
||||
target={"entity_id": setup["native_entity_id"]},
|
||||
blocking=True,
|
||||
)
|
||||
assert native_light.is_on
|
||||
assert native_light.brightness == 150
|
||||
|
||||
|
||||
async def test_turn_off_by_device_id(
|
||||
hass: HomeAssistant, multi_sandbox_setup: dict
|
||||
) -> None:
|
||||
"""Test turning off by device_id targets the correct sandbox."""
|
||||
setup = multi_sandbox_setup
|
||||
ws_a = setup["ws_a"]
|
||||
ws_b = setup["ws_b"]
|
||||
native_light = setup["native_light"]
|
||||
|
||||
# Turn on all lights first (set initial state)
|
||||
for ws, eid, msg_id in [
|
||||
(ws_a, setup["entity_a_id"], 5),
|
||||
(ws_b, setup["entity_b_id"], 5),
|
||||
]:
|
||||
await ws.send_json(
|
||||
{
|
||||
"id": msg_id,
|
||||
"type": "sandbox/update_state",
|
||||
"entity_id": eid,
|
||||
"state": "on",
|
||||
"attributes": {"brightness": 255, "color_mode": "brightness"},
|
||||
}
|
||||
)
|
||||
resp = await ws.receive_json()
|
||||
assert resp["success"]
|
||||
|
||||
await hass.services.async_call(
|
||||
"light",
|
||||
"turn_on",
|
||||
target={"entity_id": setup["native_entity_id"]},
|
||||
blocking=True,
|
||||
)
|
||||
|
||||
# Turn off device A → should only affect sandbox A's light
|
||||
task = asyncio.create_task(
|
||||
hass.services.async_call(
|
||||
"light",
|
||||
"turn_off",
|
||||
target={"device_id": setup["device_a_id"]},
|
||||
blocking=True,
|
||||
)
|
||||
)
|
||||
event = await _respond_to_command(ws_a, msg_id=11)
|
||||
await task
|
||||
|
||||
assert event["method"] == "async_turn_off"
|
||||
assert event["entity_id"] == setup["entity_a_id"]
|
||||
|
||||
# Native light should be unaffected
|
||||
assert native_light.is_on
|
||||
|
||||
# Turn off native device
|
||||
await hass.services.async_call(
|
||||
"light",
|
||||
"turn_off",
|
||||
target={"device_id": setup["native_device_id"]},
|
||||
blocking=True,
|
||||
)
|
||||
assert not native_light.is_on
|
||||
|
||||
|
||||
async def test_turn_on_by_area(
|
||||
hass: HomeAssistant, multi_sandbox_setup: dict
|
||||
) -> None:
|
||||
"""Test turning on by area targets all 3 lights."""
|
||||
setup = multi_sandbox_setup
|
||||
ws_a = setup["ws_a"]
|
||||
ws_b = setup["ws_b"]
|
||||
native_light = setup["native_light"]
|
||||
area = setup["area"]
|
||||
|
||||
# Turn on all lights in the area
|
||||
task = asyncio.create_task(
|
||||
hass.services.async_call(
|
||||
"light",
|
||||
"turn_on",
|
||||
{"brightness": 180},
|
||||
target={"area_id": area.id},
|
||||
blocking=True,
|
||||
)
|
||||
)
|
||||
|
||||
# Both sandbox lights should get commands (order may vary)
|
||||
events = []
|
||||
for ws, msg_id in [(ws_a, 20), (ws_b, 20)]:
|
||||
event = await _respond_to_command(ws, msg_id=msg_id)
|
||||
events.append(event)
|
||||
|
||||
await task
|
||||
|
||||
# Verify both sandbox lights received turn_on
|
||||
sandbox_entity_ids = {e["entity_id"] for e in events}
|
||||
assert setup["entity_a_id"] in sandbox_entity_ids
|
||||
assert setup["entity_b_id"] in sandbox_entity_ids
|
||||
|
||||
for event in events:
|
||||
assert event["method"] == "async_turn_on"
|
||||
assert event["kwargs"]["brightness"] == 180
|
||||
|
||||
# Native light should also be on
|
||||
assert native_light.is_on
|
||||
assert native_light.brightness == 180
|
||||
|
||||
|
||||
async def test_turn_off_by_area(
|
||||
hass: HomeAssistant, multi_sandbox_setup: dict
|
||||
) -> None:
|
||||
"""Test turning off by area targets all 3 lights."""
|
||||
setup = multi_sandbox_setup
|
||||
ws_a = setup["ws_a"]
|
||||
ws_b = setup["ws_b"]
|
||||
native_light = setup["native_light"]
|
||||
area = setup["area"]
|
||||
|
||||
# Set all lights to on first
|
||||
for ws, eid, msg_id in [
|
||||
(ws_a, setup["entity_a_id"], 5),
|
||||
(ws_b, setup["entity_b_id"], 5),
|
||||
]:
|
||||
await ws.send_json(
|
||||
{
|
||||
"id": msg_id,
|
||||
"type": "sandbox/update_state",
|
||||
"entity_id": eid,
|
||||
"state": "on",
|
||||
"attributes": {"brightness": 255, "color_mode": "brightness"},
|
||||
}
|
||||
)
|
||||
resp = await ws.receive_json()
|
||||
assert resp["success"]
|
||||
|
||||
await hass.services.async_call(
|
||||
"light",
|
||||
"turn_on",
|
||||
target={"entity_id": setup["native_entity_id"]},
|
||||
blocking=True,
|
||||
)
|
||||
assert native_light.is_on
|
||||
|
||||
# Turn off everything in the area
|
||||
task = asyncio.create_task(
|
||||
hass.services.async_call(
|
||||
"light",
|
||||
"turn_off",
|
||||
target={"area_id": area.id},
|
||||
blocking=True,
|
||||
)
|
||||
)
|
||||
|
||||
# Both sandbox lights should get turn_off commands
|
||||
events = []
|
||||
for ws, msg_id in [(ws_a, 21), (ws_b, 21)]:
|
||||
event = await _respond_to_command(ws, msg_id=msg_id)
|
||||
events.append(event)
|
||||
|
||||
await task
|
||||
|
||||
sandbox_entity_ids = {e["entity_id"] for e in events}
|
||||
assert setup["entity_a_id"] in sandbox_entity_ids
|
||||
assert setup["entity_b_id"] in sandbox_entity_ids
|
||||
|
||||
for event in events:
|
||||
assert event["method"] == "async_turn_off"
|
||||
|
||||
# Native light should also be off
|
||||
assert not native_light.is_on
|
||||
@@ -1,180 +0,0 @@
|
||||
"""Test sandbox websocket entity registration and service forwarding."""
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from homeassistant.components.sandbox.const import DATA_SANDBOX
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.setup import async_setup_component
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
from tests.typing import WebSocketGenerator
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def sandbox_ws(
|
||||
hass: HomeAssistant,
|
||||
hass_ws_client: WebSocketGenerator,
|
||||
) -> tuple:
|
||||
"""Set up sandbox with a websocket client authenticated as the sandbox token."""
|
||||
assert await async_setup_component(hass, "sandbox", {})
|
||||
|
||||
sandbox_id = "test_sandbox_ws"
|
||||
entry = MockConfigEntry(
|
||||
domain="sandbox",
|
||||
entry_id=sandbox_id,
|
||||
data={
|
||||
"entries": [
|
||||
{
|
||||
"entry_id": "hue_entry_ws",
|
||||
"domain": "hue",
|
||||
"title": "Hue Bridge",
|
||||
"data": {"host": "192.168.1.100"},
|
||||
}
|
||||
]
|
||||
},
|
||||
)
|
||||
entry.add_to_hass(hass)
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.sandbox._spawn_sandbox",
|
||||
return_value=None,
|
||||
):
|
||||
await hass.config_entries.async_setup(entry.entry_id)
|
||||
|
||||
await hass.async_block_till_done()
|
||||
|
||||
sandbox_data = hass.data[DATA_SANDBOX]
|
||||
instance = sandbox_data.sandboxes[sandbox_id]
|
||||
|
||||
client = await hass_ws_client(hass, access_token=instance.access_token)
|
||||
return hass, client, sandbox_id
|
||||
|
||||
|
||||
async def test_register_entity_via_ws(sandbox_ws: tuple) -> None:
|
||||
"""Test registering an entity via websocket creates a proxy light."""
|
||||
hass, client, sandbox_id = sandbox_ws
|
||||
|
||||
# Subscribe to entity commands first
|
||||
await client.send_json({"id": 1, "type": "sandbox/subscribe_entity_commands"})
|
||||
resp = await client.receive_json()
|
||||
assert resp["success"]
|
||||
|
||||
# Register a light entity
|
||||
await client.send_json(
|
||||
{
|
||||
"id": 2,
|
||||
"type": "sandbox/register_entity",
|
||||
"sandbox_entry_id": "hue_entry_ws",
|
||||
"domain": "light",
|
||||
"platform": "hue",
|
||||
"unique_id": "hue_light_1",
|
||||
"original_name": "Living Room Light",
|
||||
"supported_features": 0,
|
||||
"capabilities": {"supported_color_modes": ["brightness"]},
|
||||
"suggested_object_id": "living_room_light",
|
||||
}
|
||||
)
|
||||
resp = await client.receive_json()
|
||||
assert resp["success"]
|
||||
entity_id = resp["result"]["entity_id"]
|
||||
assert entity_id.startswith("light.")
|
||||
|
||||
# Push state update
|
||||
await client.send_json(
|
||||
{
|
||||
"id": 3,
|
||||
"type": "sandbox/update_state",
|
||||
"entity_id": entity_id,
|
||||
"state": "on",
|
||||
"attributes": {"brightness": 255, "color_mode": "brightness"},
|
||||
}
|
||||
)
|
||||
resp = await client.receive_json()
|
||||
assert resp["success"]
|
||||
|
||||
await hass.async_block_till_done()
|
||||
|
||||
state = hass.states.get(entity_id)
|
||||
assert state is not None
|
||||
assert state.state == "on"
|
||||
assert state.attributes.get("brightness") == 255
|
||||
|
||||
|
||||
async def test_entity_command_forwarding_via_ws(sandbox_ws: tuple) -> None:
|
||||
"""Test that service calls on proxy entities forward to sandbox via ws."""
|
||||
hass, client, sandbox_id = sandbox_ws
|
||||
|
||||
# Subscribe to entity commands
|
||||
await client.send_json({"id": 1, "type": "sandbox/subscribe_entity_commands"})
|
||||
resp = await client.receive_json()
|
||||
assert resp["success"]
|
||||
|
||||
# Register a light entity
|
||||
await client.send_json(
|
||||
{
|
||||
"id": 2,
|
||||
"type": "sandbox/register_entity",
|
||||
"sandbox_entry_id": "hue_entry_ws",
|
||||
"domain": "light",
|
||||
"platform": "hue",
|
||||
"unique_id": "hue_light_cmd",
|
||||
"original_name": "Command Light",
|
||||
"supported_features": 0,
|
||||
"capabilities": {"supported_color_modes": ["brightness"]},
|
||||
"suggested_object_id": "command_light",
|
||||
}
|
||||
)
|
||||
resp = await client.receive_json()
|
||||
assert resp["success"]
|
||||
entity_id = resp["result"]["entity_id"]
|
||||
|
||||
# Push initial state so entity is "on"
|
||||
await client.send_json(
|
||||
{
|
||||
"id": 3,
|
||||
"type": "sandbox/update_state",
|
||||
"entity_id": entity_id,
|
||||
"state": "on",
|
||||
"attributes": {"brightness": 128, "color_mode": "brightness"},
|
||||
}
|
||||
)
|
||||
resp = await client.receive_json()
|
||||
assert resp["success"]
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# Call turn_off on the entity via HA services
|
||||
import asyncio
|
||||
|
||||
async def call_service():
|
||||
await hass.services.async_call(
|
||||
"light", "turn_off", {"entity_id": entity_id}, blocking=True
|
||||
)
|
||||
|
||||
# Start the service call in the background (it will block waiting for response)
|
||||
task = asyncio.create_task(call_service())
|
||||
|
||||
# Receive the command forwarded to sandbox
|
||||
cmd_msg = await client.receive_json()
|
||||
assert cmd_msg["type"] == "event"
|
||||
event = cmd_msg["event"]
|
||||
assert event["type"] == "call_method"
|
||||
assert event["entity_id"] == entity_id
|
||||
assert event["method"] == "async_turn_off"
|
||||
|
||||
# Send result back
|
||||
call_id = event["call_id"]
|
||||
await client.send_json(
|
||||
{
|
||||
"id": 4,
|
||||
"type": "sandbox/entity_command_result",
|
||||
"call_id": call_id,
|
||||
"success": True,
|
||||
}
|
||||
)
|
||||
resp = await client.receive_json()
|
||||
assert resp["success"]
|
||||
|
||||
# Service call should complete
|
||||
await task
|
||||
@@ -6,10 +6,9 @@ import pytest
|
||||
from pywizlight.exceptions import WizLightConnectionError, WizLightTimeOutError
|
||||
|
||||
from homeassistant import config_entries
|
||||
from homeassistant.components.wiz.config_flow import CONF_DEVICE
|
||||
from homeassistant.components.wiz.const import DOMAIN
|
||||
from homeassistant.config_entries import ConfigEntryState
|
||||
from homeassistant.const import CONF_HOST
|
||||
from homeassistant.const import CONF_DEVICE, CONF_HOST
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.data_entry_flow import FlowResultType
|
||||
from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"version": 1,
|
||||
"minor_version": 1,
|
||||
"key": "http.auth",
|
||||
"data": {
|
||||
"content_user": "081896ed31bf45738ff572dd937ec6c2"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user