mirror of
https://github.com/home-assistant/core.git
synced 2026-08-28 02:24:46 -05:00
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:
co-authored by
Claude Fable 5
parent
f530bd1801
commit
af231de9c0
@@ -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",
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user