sandbox: bridge own-domain entities, typed timestamp sensors, live core-config, honest lane settling

Follow-ups from running the now-real compat lane against sun (8 -> 4
failures, and no more silent gaps):

- Sensor proxies rebuild native_value as datetime/date for
  timestamp/date device classes — SensorEntity.state required a real
  datetime and an ISO string killed the entity add ('str' object has no
  attribute 'tzinfo'), leaving timestamp sensors unbridgeable.
- An integration's own-domain entities (sun.sun) now bridge: the bridge
  falls back to a bare main-side EntityComponent when the domain's
  component only exists inside the (sandboxed) integration setup —
  restricted to the entry's own domain so a compromised sandbox cannot
  mint entities in arbitrary made-up domains — and a GenericSandboxEntity
  passes the pushed state + attributes through verbatim (typed domain
  proxies keep their contract). The client attributes such entities to
  the sole entry owning their domain when platform/registry linkage is
  absent.
- Live core-config propagation: entry_setup's CoreConfig snapshot went
  stale when the user changed the home location/units/language on main.
  The bridge now pushes sandbox/core_config on EVENT_CORE_CONFIG_UPDATE;
  the runtime applies it and re-fires the event on the private bus so
  integrations recompute exactly as they would locally.
- The in-proc compat lane settles the whole sandbox round-trip inside
  async_block_till_done (private hass + entity-bridge queue + channel
  dispatch), giving vanilla tests their local synchronous semantics.
  Both the settle loop and EntityBridge.async_drain are wall-clock
  bounded: py-spy showed a clock-jump test's self-rescheduling entity
  storm refilling the queue every tick, turning one teardown into 190s
  of drain-spin (196s -> 12s for the sun suite).
- run_compat.py gains --jobs (thread pool of pytest subprocesses) so a
  full-tree baseline doesn't take all night.

An earlier attempt forwarded tests.common.async_fire_time_changed to
the private hass; reverted within this change after it broke sun's
trigger tests — the in-proc lane shares one event loop, so main-hass
time firing already reaches the private hass's timers.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QCotUYum6AoisyrxshoiJJ
This commit is contained in:
Paulus Schoutsen
2026-07-07 18:33:36 -04:00
co-authored by Claude Fable 5
parent f530bd1801
commit af231de9c0
16 changed files with 605 additions and 58 deletions
+43 -3
View File
@@ -59,7 +59,7 @@ from homeassistant.const import (
EVENT_STATE_CHANGED,
EVENT_STATE_REPORTED,
)
from homeassistant.core import Context, HomeAssistant, callback
from homeassistant.core import CALLBACK_TYPE, Context, HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.dispatcher import async_dispatcher_connect
@@ -74,6 +74,7 @@ from .const import UNIQUE_ID_SEPARATOR
from .description import SandboxEntityDescription
from .messages import (
MSG_CALL_SERVICE,
MSG_CORE_CONFIG,
MSG_ENTITY_QUERY,
MSG_FIRE_EVENT,
MSG_REGISTER_ENTITY,
@@ -84,6 +85,7 @@ from .messages import (
MSG_STORE_SAVE,
MSG_UNREGISTER_ENTITY,
MSG_UNREGISTER_SERVICE,
core_config_to_proto,
decode_json,
decode_json_dict,
encode_json,
@@ -177,6 +179,12 @@ class SandboxBridge:
self._unsub_entry_changed = async_dispatcher_connect(
hass, SIGNAL_CONFIG_ENTRY_CHANGED, self._on_config_entry_changed
)
# Keep a running sandbox's core config in step with main — the
# entry_setup snapshot goes stale when the user changes the home
# location / units / language.
self._unsub_core_config: CALLBACK_TYPE | None = self.hass.bus.async_listen(
EVENT_CORE_CONFIG_UPDATE, self._on_core_config_update
)
# Context security + restoration: the sandbox only ever sends a
# context_id (a string) — it can never set parent_id / user_id on the
@@ -417,7 +425,14 @@ class SandboxBridge:
# The proxy entity subclasses the domain's *EntityBase* (LightEntity,
# SwitchEntity, …); for the framework to host it the domain
# component itself has to be set up so its EntityComponent exists.
await self._ensure_domain_loaded(description.domain)
await self._ensure_domain_loaded(
description.domain,
# Only the entry's own integration domain may fall back to a
# bare EntityComponent — anything else must be a real,
# loadable platform domain, or a compromised sandbox could
# mint entities in arbitrary made-up domains.
allow_bare=description.domain == entry.domain,
)
# Pre-create the device entry so its id is known before the proxy
# registers; the framework's own async_get_or_create call inside
# EntityPlatform.async_add_entities is idempotent on (identifiers,
@@ -499,7 +514,7 @@ class SandboxBridge:
f"entry outside group {self.group!r}; refusing to merge"
)
async def _ensure_domain_loaded(self, domain: str) -> None:
async def _ensure_domain_loaded(self, domain: str, *, allow_bare: bool) -> None:
"""Make sure the domain's :class:`EntityComponent` is loaded on main."""
components = self.hass.data.get(DATA_INSTANCES, {})
if domain in components:
@@ -507,6 +522,14 @@ class SandboxBridge:
# Empty config — we never own the domain ourselves; we just want
# the EntityComponent so we can attach a proxy platform to it.
await async_setup_component(self.hass, domain, {})
if domain in self.hass.data.get(DATA_INSTANCES, {}) or not allow_bare:
return
# An integration's *own* domain (sun.sun, …): its EntityComponent is
# only built inside its own async_setup_entry, which runs sandboxed —
# a bare component gives the proxies a home without running any
# integration code on main. EntityComponent self-registers into
# DATA_INSTANCES.
EntityComponent(_LOGGER, domain, self.hass)
async def _handle_unregister_entity(
self, msg: pb.UnregisterEntity
@@ -636,6 +659,20 @@ class SandboxBridge:
await self._store_server.async_remove(validate_key(msg.key))
return pb.StoreRemoveResult(ok=True)
async def _on_core_config_update(self, _event: Any) -> None:
"""Push main's updated core config down to the sandbox."""
try:
await self.channel.push(
MSG_CORE_CONFIG, core_config_to_proto(self.hass.config)
)
except Exception: # noqa: BLE001
# A dead channel just means the respawned sandbox will get the
# fresh snapshot on its next entry_setup.
_LOGGER.debug(
"SandboxBridge[%s]: core-config push failed (channel down?)",
self.group,
)
@callback
def _owned_domains(self) -> set[str]:
"""Return the set of domains this sandbox group legitimately owns.
@@ -804,6 +841,9 @@ class SandboxBridge:
# bridge's (now dead) channel, and a respawned sandbox's
# re-registration is skipped by the has_service() guard, so a stale
# forwarder would fail every call until HA restarts.
if self._unsub_core_config is not None:
self._unsub_core_config()
self._unsub_core_config = None
for domain, service in self._mirrored_services:
if self.hass.services.has_service(domain, service):
self.hass.services.async_remove(domain, service)
@@ -20,6 +20,7 @@ from homeassistant.const import EntityCategory
from homeassistant.core import Context
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity import Entity
from homeassistant.helpers.typing import StateType
from ..messages import decode_json_dict
@@ -79,12 +80,13 @@ class SandboxProxyEntity(Entity):
@property
@override
def extra_state_attributes(self) -> dict[str, Any] | None:
"""Sandbox proxies expose attributes through typed properties.
"""Typed domain proxies expose attributes through typed properties.
Anything domain-specific (``brightness``, ``hvac_mode``, …) is
surfaced by the domain proxy's own ``@property`` declarations
reading from ``_state_cache``. Returning extras here would
duplicate those values in the state-machine attributes dict.
reading from ``_state_cache``. The generic fallback
(:class:`GenericSandboxEntity`) overrides this to pass the pushed
attributes through instead.
"""
return None
@@ -216,6 +218,31 @@ class SandboxProxyEntity(Entity):
)
class GenericSandboxEntity(SandboxProxyEntity):
"""Fallback proxy for domains without a typed module.
Hosts an integration's own-domain entities (``sun.sun``, …): no typed
contract exists, so the pushed state string and attribute dict pass
through verbatim.
"""
@property
@override
def state(self) -> StateType:
"""Return the pushed state verbatim."""
state: StateType = self._state_cache.get("state")
return state
@property
@override
def extra_state_attributes(self) -> dict[str, Any] | None:
"""Return the pushed attributes verbatim."""
attrs = {
key: value for key, value in self._state_cache.items() if key != "state"
}
return attrs or None
def build_proxy(
bridge: SandboxBridge, description: SandboxEntityDescription
) -> SandboxProxyEntity:
@@ -243,7 +270,7 @@ def proxy_class_for(domain: str) -> type[SandboxProxyEntity]:
try:
module = importlib.import_module(f".{domain}", __package__)
except ImportError:
cls = SandboxProxyEntity
cls = GenericSandboxEntity
else:
# Each proxy module defines exactly one SandboxProxyEntity subclass.
cls = next(
@@ -254,7 +281,7 @@ def proxy_class_for(domain: str) -> type[SandboxProxyEntity]:
and issubclass(obj, SandboxProxyEntity)
and obj.__module__ == module.__name__
),
SandboxProxyEntity,
GenericSandboxEntity,
)
return _DOMAIN_PROXIES.setdefault(domain, cls)
@@ -1,9 +1,11 @@
"""Sandbox proxy for ``sensor`` entities."""
from datetime import date, datetime
from typing import override
from homeassistant.components.sensor import SensorEntity
from homeassistant.components.sensor import SensorDeviceClass, SensorEntity
from homeassistant.const import ATTR_UNIT_OF_MEASUREMENT
from homeassistant.util import dt as dt_util
from . import SandboxProxyEntity
@@ -14,9 +16,23 @@ class SandboxSensorEntity(SandboxProxyEntity, SensorEntity):
@property
@override
def native_value(self) -> str | int | float | None:
"""Return the cached state as the sensor's native value."""
return self._state_cache.get("state")
def native_value(self) -> datetime | date | str | int | float | None:
"""Return the cached state as the sensor's native value.
The sandbox pushes the already-formatted state string; timestamp /
date sensors must hand ``SensorEntity`` a real ``datetime`` /
``date`` back or its ``state`` property dies on ``value.tzinfo``.
An unparsable string (``unknown`` and friends) degrades to None.
"""
value = self._state_cache.get("state")
if not isinstance(value, str):
return value
device_class = self.device_class
if device_class == SensorDeviceClass.TIMESTAMP:
return dt_util.parse_datetime(value)
if device_class == SensorDeviceClass.DATE:
return dt_util.parse_date(value)
return value
@property
@override
@@ -143,6 +143,10 @@ MSG_STATE_CHANGED: Final = "sandbox/state_changed"
MSG_REGISTER_SERVICE: Final = "sandbox/register_service"
MSG_UNREGISTER_SERVICE: Final = "sandbox/unregister_service"
MSG_FIRE_EVENT: Final = "sandbox/fire_event"
# main -> sandbox one-way push: live core-config update (entry_setup carries
# the initial snapshot; this keeps a running sandbox in step when the user
# changes the home location / units / language on main).
MSG_CORE_CONFIG = "sandbox/core_config"
MSG_STORE_LOAD: Final = "sandbox/store_load"
MSG_STORE_SAVE: Final = "sandbox/store_save"
MSG_STORE_REMOVE: Final = "sandbox/store_remove"
@@ -174,6 +178,7 @@ REGISTRY: dict[str, tuple[type[Message], type[Message] | None]] = {
pb.UnregisterServiceResult,
),
MSG_FIRE_EVENT: (pb.FireEvent, None),
MSG_CORE_CONFIG: (pb.CoreConfig, None),
MSG_STORE_LOAD: (pb.StoreLoad, pb.StoreLoadResult),
MSG_STORE_SAVE: (pb.StoreSave, pb.StoreSaveResult),
MSG_STORE_REMOVE: (pb.StoreRemove, pb.StoreRemoveResult),
@@ -323,6 +328,29 @@ def make_entity_description(
return msg
def core_config_to_proto(config: Any) -> pb.CoreConfig:
"""Snapshot a hass ``Config`` into the wire ``CoreConfig`` message.
Shared by the ``entry_setup`` payload and the live
``sandbox/core_config`` push so the two can't drift.
"""
msg = pb.CoreConfig(
latitude=config.latitude,
longitude=config.longitude,
elevation=config.elevation,
time_zone=config.time_zone,
# The unit system carries no public name accessor; core itself reads
# ``units._name`` when persisting (core_config.py).
unit_system=config.units._name, # noqa: SLF001
language=config.language,
currency=config.currency,
location_name=config.location_name,
)
if config.country is not None:
msg.country = config.country
return msg
__all__ = [
"MSG_CALL_SERVICE",
"MSG_ENTITY_QUERY",
@@ -345,6 +373,7 @@ __all__ = [
"MSG_UNREGISTER_ENTITY",
"MSG_UNREGISTER_SERVICE",
"REGISTRY",
"core_config_to_proto",
"decode_json",
"decode_json_dict",
"device_info_to_proto",
+7 -15
View File
@@ -29,7 +29,12 @@ from ._proto import sandbox_pb2 as pb
from .channel import ChannelClosedError, ChannelRemoteError
from .classifier import SandboxAssignment, classify
from .manager import SandboxManager
from .messages import MSG_ENTRY_SETUP, MSG_ENTRY_UNLOAD, encode_json
from .messages import (
MSG_ENTRY_SETUP,
MSG_ENTRY_UNLOAD,
core_config_to_proto,
encode_json,
)
from .proxy_flow import SandboxFlowProxy
from .sources import SandboxSourceError, async_resolve_integration_source
@@ -259,20 +264,7 @@ async def _entry_setup_payload(
msg.integration_source.CopyFrom(
await async_resolve_integration_source(hass, entry.domain)
)
config = hass.config
core_config = msg.core_config
core_config.latitude = config.latitude
core_config.longitude = config.longitude
core_config.elevation = config.elevation
core_config.time_zone = config.time_zone
# The unit system carries no public name accessor; core itself reads
# ``units._name`` when persisting (core_config.py).
core_config.unit_system = config.units._name # noqa: SLF001
core_config.language = config.language
if config.country is not None:
core_config.country = config.country
core_config.currency = config.currency
core_config.location_name = config.location_name
msg.core_config.CopyFrom(core_config_to_proto(hass.config))
return msg
@@ -121,6 +121,22 @@ class EntityBridge:
EVENT_DEVICE_REGISTRY_UPDATED, self._on_device_registry_updated
)
async def async_drain(self, *, timeout: float = 2.0) -> None:
"""Wait until every queued slot has been written to the channel.
Test-lane hook: vanilla integration tests assume local synchronous
setup semantics, so the compat plugins settle the bridge after
``async_block_till_done``. Bounded: an entity storm (clock-jump
tests leave self-rescheduling updates behind) refills the queue
every loop tick, and an unbounded drain would spin until the test's
teardown finally kills the churn.
"""
deadline = asyncio.get_running_loop().time() + timeout
while (
self._pending or self._writing is not None
) and asyncio.get_running_loop().time() < deadline:
await asyncio.sleep(0)
async def async_stop(self) -> None:
"""Detach the listeners and stop the writer task."""
for attr in (
@@ -260,6 +276,13 @@ class EntityBridge:
)
return None
entry_id = _entry_id_for(entity)
if entry_id is None:
# An integration's own-domain entity (sun.sun, …) is added via a
# bare EntityComponent with no config-entry linkage. When exactly
# one loaded entry owns the entity's domain, it is that entry's.
entries = self.hass.config_entries.async_entries(domain)
if len(entries) == 1:
entry_id = entries[0].entry_id
if entry_id is None:
_LOGGER.debug(
"EntityBridge: %s has no owning config entry; not bridging",
@@ -73,7 +73,7 @@ class EntryRunner:
# sun times / distances / unit conversions against main's location
# and units, not the bare-hass defaults. Idempotent, cheap.
if msg.HasField("core_config"):
await _apply_core_config(self.hass, msg.core_config)
await apply_core_config(self.hass, msg.core_config)
# Fetch the integration code before setup so a stateless sandbox can
# load custom (HACS) integrations whose code isn't bundled. Built-in
@@ -205,12 +205,15 @@ class EntryRunner:
return result
async def _apply_core_config(hass: HomeAssistant, cfg: pb.CoreConfig) -> None:
async def apply_core_config(hass: HomeAssistant, cfg: pb.CoreConfig) -> None:
"""Apply main's core-config snapshot to the sandbox's private hass.
Direct attribute writes, deliberately not ``Config.async_update``: the
private hass has no Store backing and must not persist the values or fire
``EVENT_CORE_CONFIG_UPDATE`` on its bus. The time zone goes through the
private hass has no Store backing and must not persist the values. This
function fires no event either — the live-update handler
(``SandboxRuntime._handle_core_config``) fires
``EVENT_CORE_CONFIG_UPDATE`` itself, while the entry_setup snapshot is
applied silently before setup. The time zone goes through the
async setter so ``dt_util``'s default timezone follows. Every value comes
from main's own (validated) config, so a bad unit-system/time-zone name is
a contract violation and surfaces as a failed entry_setup rather than
@@ -143,6 +143,10 @@ MSG_STATE_CHANGED: Final = "sandbox/state_changed"
MSG_REGISTER_SERVICE: Final = "sandbox/register_service"
MSG_UNREGISTER_SERVICE: Final = "sandbox/unregister_service"
MSG_FIRE_EVENT: Final = "sandbox/fire_event"
# main -> sandbox one-way push: live core-config update (entry_setup carries
# the initial snapshot; this keeps a running sandbox in step when the user
# changes the home location / units / language on main).
MSG_CORE_CONFIG = "sandbox/core_config"
MSG_STORE_LOAD: Final = "sandbox/store_load"
MSG_STORE_SAVE: Final = "sandbox/store_save"
MSG_STORE_REMOVE: Final = "sandbox/store_remove"
@@ -174,6 +178,7 @@ REGISTRY: dict[str, tuple[type[Message], type[Message] | None]] = {
pb.UnregisterServiceResult,
),
MSG_FIRE_EVENT: (pb.FireEvent, None),
MSG_CORE_CONFIG: (pb.CoreConfig, None),
MSG_STORE_LOAD: (pb.StoreLoad, pb.StoreLoadResult),
MSG_STORE_SAVE: (pb.StoreSave, pb.StoreSaveResult),
MSG_STORE_REMOVE: (pb.StoreRemove, pb.StoreRemoveResult),
@@ -323,6 +328,29 @@ def make_entity_description(
return msg
def core_config_to_proto(config: Any) -> pb.CoreConfig:
"""Snapshot a hass ``Config`` into the wire ``CoreConfig`` message.
Shared by the ``entry_setup`` payload and the live
``sandbox/core_config`` push so the two can't drift.
"""
msg = pb.CoreConfig(
latitude=config.latitude,
longitude=config.longitude,
elevation=config.elevation,
time_zone=config.time_zone,
# The unit system carries no public name accessor; core itself reads
# ``units._name`` when persisting (core_config.py).
unit_system=config.units._name, # noqa: SLF001
language=config.language,
currency=config.currency,
location_name=config.location_name,
)
if config.country is not None:
msg.country = config.country
return msg
__all__ = [
"MSG_CALL_SERVICE",
"MSG_ENTITY_QUERY",
@@ -345,6 +373,7 @@ __all__ = [
"MSG_UNREGISTER_ENTITY",
"MSG_UNREGISTER_SERVICE",
"REGISTRY",
"core_config_to_proto",
"decode_json",
"decode_json_dict",
"device_info_to_proto",
@@ -35,10 +35,11 @@ from hass_client.approved_domains import ApprovedDomains
from hass_client.channel import Channel
from hass_client.codec_protobuf import ProtobufCodec
from hass_client.entity_bridge import EntityBridge
from hass_client.entry_runner import EntryRunner
from hass_client.entry_runner import EntryRunner, apply_core_config
from hass_client.event_mirror import EventMirror
from hass_client.flow_runner import FlowRunner
from hass_client.messages import (
MSG_CORE_CONFIG,
MSG_GET_TRANSLATIONS,
MSG_PING,
MSG_READY,
@@ -47,7 +48,10 @@ from hass_client.messages import (
)
from hass_client.sandbox_bridge import ChannelSandboxBridge
from hass_client.service_mirror import ServiceMirror
from homeassistant.const import EVENT_HOMEASSISTANT_FINAL_WRITE
from homeassistant.const import (
EVENT_CORE_CONFIG_UPDATE,
EVENT_HOMEASSISTANT_FINAL_WRITE,
)
from homeassistant.core import CoreState, HomeAssistant
from homeassistant.helpers import json as json_helper, restore_state
from homeassistant.helpers.sandbox_context import current_sandbox
@@ -121,6 +125,16 @@ class SandboxRuntime:
"""The runtime's control channel, once ``run()`` has started it."""
return self._channel
@property
def hass(self) -> HomeAssistant | None:
"""The sandbox-private hass, once ``run()`` has created it."""
return self._flow_runner.hass if self._flow_runner is not None else None
@property
def entity_bridge(self) -> EntityBridge | None:
"""The runtime's entity bridge, once ``run()`` has created it."""
return self._entity_bridge
def request_shutdown(self) -> None:
"""Request a graceful shutdown of the runtime."""
if self._shutdown is None:
@@ -202,6 +216,7 @@ class SandboxRuntime:
self._channel.register(
MSG_GET_TRANSLATIONS, self._handle_get_translations
)
self._channel.register(MSG_CORE_CONFIG, self._handle_core_config)
self._flow_runner.register(self._channel)
self._entry_runner.register(self._channel)
self._entity_bridge.register(self._channel)
@@ -304,6 +319,22 @@ class SandboxRuntime:
result.strings = encode_json(strings)
return result
async def _handle_core_config(self, msg: pb.CoreConfig) -> None:
"""Apply a live core-config update pushed from main.
``entry_setup`` carries the initial snapshot; this keeps a running
sandbox in step when the user changes the home location / units /
language on main. Fires ``EVENT_CORE_CONFIG_UPDATE`` on the private
bus so integrations recompute exactly as they would locally (sun
re-derives its observer, coordinators re-localize).
"""
flow_runner = self._flow_runner
if flow_runner is None:
return
hass = flow_runner.hass
await apply_core_config(hass, msg)
hass.bus.async_fire(EVENT_CORE_CONFIG_UPDATE)
async def _run_graceful_shutdown(self) -> pb.ShutdownResult:
"""Unload every loaded entry and snapshot RestoreEntity state.
@@ -177,6 +177,51 @@ class _InProcessSandboxProcess:
return True
def _install_settling_block_till_done(
hass: HomeAssistant, runtime: SandboxRuntime, mgr_channel: Any
) -> None:
"""Make main's ``async_block_till_done`` settle the sandbox round-trip.
Vanilla integration tests assume local synchronous semantics: after
``await hass.async_block_till_done()`` they expect entities and states
to be visible. With a sandbox in the middle, registrations and pushes
are still in flight on the (same-loop) channel and the entity bridge's
single-writer queue. Iterate until a pass sees the private hass idle,
the bridge queue drained, and no channel dispatch task in flight, then
settle main once more for anything late pushes scheduled.
"""
original = hass.async_block_till_done
async def settled(wait_background_tasks: bool = False) -> None:
await original(wait_background_tasks=wait_background_tasks)
rt_channel = runtime.channel
# Wall-clock bound, not just an iteration cap: a pathological private
# hass (timer churn from clock-jump tests) must cost each
# block_till_done a couple of seconds at worst, not minutes.
deadline = asyncio.get_running_loop().time() + 2.0
for _ in range(200):
if asyncio.get_running_loop().time() > deadline:
break
sandbox_hass = runtime.hass
bridge = runtime.entity_bridge
if sandbox_hass is not None:
await sandbox_hass.async_block_till_done()
if bridge is not None:
await bridge.async_drain()
if not mgr_channel._inflight and ( # noqa: SLF001
rt_channel is None or not rt_channel._inflight # noqa: SLF001
):
await asyncio.sleep(0)
if not mgr_channel._inflight and ( # noqa: SLF001
rt_channel is None or not rt_channel._inflight # noqa: SLF001
):
break
await asyncio.sleep(0)
await original(wait_background_tasks=wait_background_tasks)
hass.async_block_till_done = settled # type: ignore[method-assign]
async def async_setup_inprocess_sandbox(
hass: HomeAssistant,
*,
@@ -240,6 +285,10 @@ async def async_setup_inprocess_sandbox(
process = _InProcessSandboxProcess(group, mgr_channel)
manager._sandboxes[group] = process # noqa: SLF001
# Tests observe the bridge through main's block_till_done — teach it
# to settle the whole sandbox round-trip first.
_install_settling_block_till_done(hass, runtime, mgr_channel)
# Mirror what the integration's ``_on_channel_ready`` does when the
# real ``SandboxProcess`` opens its channel — register the bridge.
data.bridges[group] = async_create_bridge(hass, group=group, channel=mgr_channel)
@@ -350,6 +350,66 @@ async def test_state_push_serialises_datetime_attributes(
await bridge.async_stop()
async def test_register_attributes_sole_domain_entry_without_linkage(
channels: tuple[Channel, Channel], hass_with_demo_component
) -> None:
"""An entity with no registry/platform linkage falls back to the domain's entry.
``registry_entry`` is None and ``platform.config_entry`` is None (an
own-domain entity on a bare EntityComponent) when exactly one loaded
config entry owns the entity's domain, the registration is attributed
to it instead of being skipped.
"""
main, sandbox = channels
hass, component = hass_with_demo_component
register_calls: list[pb.EntityDescription] = []
async def _on_register(msg: pb.EntityDescription) -> pb.RegisterEntityResult:
register_calls.append(msg)
return pb.RegisterEntityResult(entity_id="demo.lamp_main")
main.register("sandbox/register_entity", _on_register)
main.start()
sandbox.start()
config_entry = ConfigEntry(
version=1,
minor_version=1,
domain="demo",
title="Demo",
data={},
options={},
source="user",
unique_id=None,
discovery_keys={},
subentries_data=(),
)
hass.config_entries._entries[config_entry.entry_id] = config_entry # noqa: SLF001
class _UnlinkedEntity(_FakeEntity):
@property
def platform(self) -> Any:
# No config-entry linkage anywhere: registry_entry is None
# (inherited) and the platform carries no config_entry either.
mock = MagicMock()
mock.config_entry = None
mock.domain = "demo"
return mock
entity = _UnlinkedEntity()
component._entities[entity.entity_id] = entity # noqa: SLF001
bridge = EntityBridge(hass)
bridge.register(sandbox)
await _register_initial(bridge, hass, entity)
assert len(register_calls) == 1
assert register_calls[0].entry_id == config_entry.entry_id
await bridge.async_stop()
async def _register_initial(bridge: EntityBridge, hass: Any, entity: Entity) -> None:
"""Drive the first state-change so ``entity`` is tracked + registered."""
now = datetime.now(tz=datetime.now().astimezone().tzinfo)
@@ -11,11 +11,15 @@ import asyncio
from hass_client._proto import sandbox_pb2 as pb
from hass_client.channel import Channel, ChannelRemoteError
from hass_client.codec_protobuf import ProtobufCodec
from hass_client.messages import MSG_READY
from hass_client.messages import MSG_CORE_CONFIG, MSG_READY
from hass_client.sandbox import SandboxRuntime
from hass_client.sandbox.__main__ import _build_parser
import pytest
from homeassistant.const import EVENT_CORE_CONFIG_UPDATE
from homeassistant.core import Event, callback
from homeassistant.util import dt as dt_util
async def _noop_channel_factory() -> Channel | None:
"""Channel factory that opens no channel — for in-process shutdown tests."""
@@ -125,6 +129,78 @@ async def test_handlers_registered_before_ready(
capsys.readouterr()
async def test_core_config_push_updates_private_hass(
capsys: pytest.CaptureFixture[str],
) -> None:
"""A live ``sandbox/core_config`` push lands on the private hass.
The config values must be applied AND ``EVENT_CORE_CONFIG_UPDATE`` must
fire on the private bus, so sandboxed integrations recompute exactly as
they would locally.
"""
main_channel, sandbox_channel = _make_channel_pair()
ready_seen = asyncio.Event()
async def _on_ready(_payload: object) -> None:
ready_seen.set()
main_channel.register(MSG_READY, _on_ready)
main_channel.start()
async def _channel_factory() -> Channel:
return sandbox_channel
runtime = SandboxRuntime(
url="ws://x",
group="custom",
channel_factory=_channel_factory,
)
task = asyncio.create_task(runtime.run())
original_tz = dt_util.get_default_time_zone()
try:
await asyncio.wait_for(ready_seen.wait(), timeout=5.0)
flow_runner = runtime._flow_runner # noqa: SLF001
assert flow_runner is not None
hass = flow_runner.hass
events: list[Event] = []
@callback
def _on_core_config_update(event: Event) -> None:
events.append(event)
# Subscribe BEFORE pushing so the fired event cannot be missed.
hass.bus.async_listen(EVENT_CORE_CONFIG_UPDATE, _on_core_config_update)
await main_channel.push(
MSG_CORE_CONFIG,
pb.CoreConfig(
latitude=52.3731,
longitude=4.8926,
time_zone="Europe/Amsterdam",
),
)
for _ in range(100):
if events:
break
await asyncio.sleep(0.01)
assert hass.config.latitude == 52.3731
assert hass.config.longitude == 4.8926
assert hass.config.time_zone == "Europe/Amsterdam"
assert len(events) == 1
finally:
# The time-zone setter updates dt_util's process-global default —
# restore it so the rest of the suite is unaffected.
dt_util.set_default_time_zone(original_tz)
runtime.request_shutdown()
await asyncio.wait_for(task, timeout=5.0)
await main_channel.close()
await sandbox_channel.close()
capsys.readouterr()
async def test_runtime_starts_in_locked_down_sharing_posture(
capsys: pytest.CaptureFixture[str],
) -> None:
+19 -11
View File
@@ -352,17 +352,25 @@ pops it).
## Discovered during execution (open, not in the original findings)
- **Timestamp sensor proxies break on state fidelity**: a sandboxed sensor
with `device_class: timestamp` pushes its state as a string, but
`SensorEntity.state` requires a `datetime` (`AttributeError: 'str' object
has no attribute 'tzinfo'` kills the proxy's entity add). Blocks several
sun compat tests even after core-config mirroring (E3). Fix shape: the
sensor proxy should rebuild `native_value` typed from the pushed state
for timestamp/date device classes.
- The compat lane (now real, E2) reports 8 failing sun tests — the honest
baseline replacing the no-op 99.97%. Failure notes: listener-count
assertions see the proxy architecture; unload/remove semantics; recorder
attribute exclusion; the timestamp issue above.
- ~~**Timestamp sensor proxies break on state fidelity**~~ FIXED: the
sensor proxy rebuilds `native_value` as `datetime`/`date` for
timestamp/date device classes.
- Fixed alongside it (same follow-up batch): own-domain entities
(`sun.sun`) bridge via a bare main-side `EntityComponent` (restricted to
the entry's own domain) + a `GenericSandboxEntity` passing state and
attributes through verbatim + single-entry domain attribution in
`_describe`; live core-config propagation (`sandbox/core_config` push —
main's location/unit changes reach a running sandbox and re-fire
`EVENT_CORE_CONFIG_UPDATE` there); the in-proc lane settles the full
bridge round-trip inside `async_block_till_done` (bounded, so clock-jump
entity storms cost ≤ ~2 s per call, not minutes).
- Sun compat: 8 failed → **4 failed / 97 passed**. The residual four are
inherent white-box divergences, not bugs: two tests assert the raw
registry `unique_id` (proxies namespace as `<domain>:<unique_id>` for
collision safety), one pre-seeds a registry row and expects the
integration's setup-time cleanup to touch main's registry (it runs on the
private one), and recorder `exclude_attributes` needs the integration's
recorder module on main (real gap, tracked).
## Suggested execution order
+30 -12
View File
@@ -24,6 +24,7 @@ Usage::
# ruff: noqa: INP001, T201, S108, PERF401
import argparse
from concurrent.futures import ThreadPoolExecutor
import csv
from dataclasses import dataclass
import os
@@ -236,6 +237,15 @@ def main(argv: list[str] | None = None) -> int:
default="inprocess",
help="Which sandbox plugin to drive (default: inprocess).",
)
parser.add_argument(
"--jobs",
type=int,
default=1,
help=(
"Concurrent pytest subprocesses (default: 1). Each runs one"
" integration's suite; 4-8 is reasonable on a workstation."
),
)
parser.add_argument(
"--timeout",
type=float,
@@ -265,18 +275,26 @@ def main(argv: list[str] | None = None) -> int:
start = time.monotonic()
results: list[Result] = []
for idx, integration in enumerate(integrations, 1):
result = run_one(integration, plugin, timeout=args.timeout)
results.append(result)
elapsed = time.monotonic() - start
rate = idx / elapsed if elapsed > 0 else 0
eta_minutes = (total - idx) / rate / 60 if rate else 0
print(
f"[{idx}/{total}] {integration} -> {result.status}"
f" ({result.passed}p/{result.failed}f/{result.errors}e/{result.skipped}s)"
f" | ETA: {eta_minutes:.0f}m",
flush=True,
)
# Results in input order regardless of completion order; progress prints
# as each integration finishes.
with ThreadPoolExecutor(max_workers=max(1, args.jobs)) as pool:
futures = [
pool.submit(run_one, integration, plugin, timeout=args.timeout)
for integration in integrations
]
by_future = dict(zip(futures, integrations, strict=True))
for done, future in enumerate(futures, 1):
result = future.result()
results.append(result)
elapsed = time.monotonic() - start
rate = done / elapsed if elapsed > 0 else 0
eta_minutes = (total - done) / rate / 60 if rate else 0
print(
f"[{done}/{total}] {by_future[future]} -> {result.status}"
f" ({result.passed}p/{result.failed}f/{result.errors}e/{result.skipped}s)"
f" | ETA: {eta_minutes:.0f}m",
flush=True,
)
write_csv(results, args.csv)
write_report(results, plugin, args.report)
+96 -1
View File
@@ -17,7 +17,7 @@ from homeassistant.components.sandbox.messages import (
)
from homeassistant.components.sandbox.service_forwarder import translate_remote_error
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import STATE_ON
from homeassistant.const import EVENT_CORE_CONFIG_UPDATE, STATE_ON
from homeassistant.core import Context, HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import device_registry as dr, entity_registry as er
@@ -541,6 +541,101 @@ async def test_register_entity_auto_loads_domain_component(
assert "switch" in hass.config.components
async def test_register_entity_own_domain_gets_bare_component_passthrough(
hass: HomeAssistant,
) -> None:
"""An own-domain entity works even when the domain can't load on main.
``mysun.sun`` for an entry owning domain ``mysun``: no integration of
that name exists on main, so ``async_setup_component`` fails the
bridge falls back to a bare EntityComponent and the generic proxy
surfaces the pushed state and attributes verbatim.
"""
entry = MockConfigEntry(domain="mysun", title="My Sun", sandbox="built-in")
entry.add_to_hass(hass)
_bridge, main_channel, sandbox_channel = await _wire(hass)
try:
result = await sandbox_channel.call(
"sandbox/register_entity",
make_entity_description(
entry_id=entry.entry_id,
domain="mysun",
sandbox_entity_id="mysun.sun",
unique_id="sandbox-sun",
initial_state="above_horizon",
initial_attributes={"elevation": 12.5, "azimuth": 180.25},
),
)
finally:
await main_channel.close()
await sandbox_channel.close()
assert result.entity_id.startswith("mysun.")
state = hass.states.get(result.entity_id)
assert state is not None
assert state.state == "above_horizon"
assert state.attributes["elevation"] == 12.5
assert state.attributes["azimuth"] == 180.25
async def test_register_entity_bare_component_foreign_domain_rejected(
hass: HomeAssistant,
) -> None:
"""The bare-EntityComponent fallback is own-domain only.
A made-up entity domain that differs from the entry's domain must not
get a minted component the registration errors and no proxy lands.
"""
entry = MockConfigEntry(domain="generic", title="Generic", sandbox="built-in")
entry.add_to_hass(hass)
bridge, main_channel, sandbox_channel = await _wire(hass)
try:
with pytest.raises(ChannelRemoteError, match="no EntityComponent"):
await sandbox_channel.call(
"sandbox/register_entity",
make_entity_description(
entry_id=entry.entry_id,
domain="madeup",
sandbox_entity_id="madeup.widget",
unique_id="sandbox-widget",
initial_state="on",
),
)
finally:
await main_channel.close()
await sandbox_channel.close()
assert "madeup.widget" not in bridge._entities
assert hass.states.async_entity_ids("madeup") == []
async def test_core_config_update_pushed_to_sandbox(hass: HomeAssistant) -> None:
"""A main-side core-config change pushes ``sandbox/core_config`` live."""
_bridge, main_channel, sandbox_channel = await _wire(hass)
pushes: list[pb.CoreConfig] = []
async def _on_core_config(msg: pb.CoreConfig) -> None:
pushes.append(msg)
sandbox_channel.register("sandbox/core_config", _on_core_config)
try:
hass.config.latitude = 52.3731
hass.bus.async_fire(EVENT_CORE_CONFIG_UPDATE)
for _ in range(50):
if pushes:
break
await asyncio.sleep(0)
finally:
await main_channel.close()
await sandbox_channel.close()
assert len(pushes) == 1
assert pushes[0].latitude == 52.3731
async def test_register_service_installs_forwarder(hass: HomeAssistant) -> None:
"""A sandbox-registered service appears on main and forwards calls back."""
MockConfigEntry(
@@ -20,6 +20,8 @@ still ships a proxy for symmetry).
from __future__ import annotations
import asyncio
from collections.abc import Callable
from datetime import date, datetime
from typing import Any
import pytest
@@ -34,6 +36,7 @@ from homeassistant.components.sandbox.messages import (
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.util import dt as dt_util
from ._helpers import make_channel_pair
@@ -423,6 +426,54 @@ async def test_phase13_proxy_smoke(
assert decode_json_dict(calls[0].target) == {"entity_id": [sandbox_entity_id]}
@pytest.mark.parametrize(
("device_class", "pushed_state", "parse"),
[
pytest.param(
"timestamp",
"2026-05-23T10:30:00+00:00",
dt_util.parse_datetime,
id="timestamp",
),
pytest.param("date", "2026-05-23", dt_util.parse_date, id="date"),
],
)
async def test_sensor_timestamp_and_date_states_surface(
hass: HomeAssistant,
entry: ConfigEntry,
device_class: str,
pushed_state: str,
parse: Callable[[str], datetime | date | None],
) -> None:
"""A timestamp/date sensor's pushed ISO string surfaces a working state.
``SensorEntity.state`` needs a real ``datetime`` / ``date`` back from
``native_value`` handing it the raw pushed string used to die on
``'str' object has no attribute 'tzinfo'``.
"""
_bridge, main_channel, sandbox_channel = await _wire(hass)
payload = make_entity_description(
entry_id=entry.entry_id,
domain="sensor",
sandbox_entity_id=f"sensor.synthetic_{device_class}",
unique_id=f"sandbox-sensor-{device_class}",
device_class=device_class,
initial_state=pushed_state,
)
try:
result = await sandbox_channel.call("sandbox/register_entity", payload)
finally:
await main_channel.close()
await sandbox_channel.close()
state = hass.states.get(result.entity_id)
assert state is not None
assert parse(state.state) is not None
assert parse(state.state) == parse(pushed_state)
async def test_upsert_clears_dropped_device_class(
hass: HomeAssistant, entry: ConfigEntry
) -> None: