diff --git a/homeassistant/components/sandbox/bridge.py b/homeassistant/components/sandbox/bridge.py index 0a9e163dfc19..3170b36c151b 100644 --- a/homeassistant/components/sandbox/bridge.py +++ b/homeassistant/components/sandbox/bridge.py @@ -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) diff --git a/homeassistant/components/sandbox/entity/__init__.py b/homeassistant/components/sandbox/entity/__init__.py index 99936106b1c3..3e681a33a8a4 100644 --- a/homeassistant/components/sandbox/entity/__init__.py +++ b/homeassistant/components/sandbox/entity/__init__.py @@ -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) diff --git a/homeassistant/components/sandbox/entity/sensor.py b/homeassistant/components/sandbox/entity/sensor.py index f966e7122a55..7b25395115e8 100644 --- a/homeassistant/components/sandbox/entity/sensor.py +++ b/homeassistant/components/sandbox/entity/sensor.py @@ -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 diff --git a/homeassistant/components/sandbox/messages.py b/homeassistant/components/sandbox/messages.py index 066ab650604a..c90766002188 100644 --- a/homeassistant/components/sandbox/messages.py +++ b/homeassistant/components/sandbox/messages.py @@ -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", diff --git a/homeassistant/components/sandbox/router.py b/homeassistant/components/sandbox/router.py index 49eeb63fd9b8..38cd030e2b47 100644 --- a/homeassistant/components/sandbox/router.py +++ b/homeassistant/components/sandbox/router.py @@ -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 diff --git a/sandbox/hass_client/hass_client/entity_bridge.py b/sandbox/hass_client/hass_client/entity_bridge.py index d8a8ebca51aa..8e61478fdc9f 100644 --- a/sandbox/hass_client/hass_client/entity_bridge.py +++ b/sandbox/hass_client/hass_client/entity_bridge.py @@ -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", diff --git a/sandbox/hass_client/hass_client/entry_runner.py b/sandbox/hass_client/hass_client/entry_runner.py index 7a8eeb3dc594..0ccf9e982633 100644 --- a/sandbox/hass_client/hass_client/entry_runner.py +++ b/sandbox/hass_client/hass_client/entry_runner.py @@ -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 diff --git a/sandbox/hass_client/hass_client/messages.py b/sandbox/hass_client/hass_client/messages.py index 066ab650604a..c90766002188 100644 --- a/sandbox/hass_client/hass_client/messages.py +++ b/sandbox/hass_client/hass_client/messages.py @@ -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", diff --git a/sandbox/hass_client/hass_client/sandbox/__init__.py b/sandbox/hass_client/hass_client/sandbox/__init__.py index 85a071687251..d7a0abe01b50 100644 --- a/sandbox/hass_client/hass_client/sandbox/__init__.py +++ b/sandbox/hass_client/hass_client/sandbox/__init__.py @@ -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. diff --git a/sandbox/hass_client/hass_client/testing/pytest_plugin.py b/sandbox/hass_client/hass_client/testing/pytest_plugin.py index 3ca497a8b39f..ad24186bcb87 100644 --- a/sandbox/hass_client/hass_client/testing/pytest_plugin.py +++ b/sandbox/hass_client/hass_client/testing/pytest_plugin.py @@ -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) diff --git a/sandbox/hass_client/tests/test_entity_bridge.py b/sandbox/hass_client/tests/test_entity_bridge.py index cd20f6712497..e77c98da9962 100644 --- a/sandbox/hass_client/tests/test_entity_bridge.py +++ b/sandbox/hass_client/tests/test_entity_bridge.py @@ -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) diff --git a/sandbox/hass_client/tests/test_sandbox_runtime.py b/sandbox/hass_client/tests/test_sandbox_runtime.py index 2333be9db163..18c05bcae7ca 100644 --- a/sandbox/hass_client/tests/test_sandbox_runtime.py +++ b/sandbox/hass_client/tests/test_sandbox_runtime.py @@ -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: diff --git a/sandbox/plans/plan-review-overhead.md b/sandbox/plans/plan-review-overhead.md index 3f21ee5ecf9c..6b50acda6cc6 100644 --- a/sandbox/plans/plan-review-overhead.md +++ b/sandbox/plans/plan-review-overhead.md @@ -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 `:` 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 diff --git a/sandbox/run_compat.py b/sandbox/run_compat.py index 497d39da8088..b7587bee933f 100644 --- a/sandbox/run_compat.py +++ b/sandbox/run_compat.py @@ -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) diff --git a/tests/components/sandbox/test_bridge.py b/tests/components/sandbox/test_bridge.py index 3793171a8d28..5e83867db3d5 100644 --- a/tests/components/sandbox/test_bridge.py +++ b/tests/components/sandbox/test_bridge.py @@ -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( diff --git a/tests/components/sandbox/test_domain_proxies.py b/tests/components/sandbox/test_domain_proxies.py index 51252fd55b40..3955bd5a1216 100644 --- a/tests/components/sandbox/test_domain_proxies.py +++ b/tests/components/sandbox/test_domain_proxies.py @@ -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: