Compare commits

..

46 Commits

Author SHA1 Message Date
Franck Nijhof 28f41a2310 Merge branch 'dev' into drop-ignore-missing-annotations 2026-06-06 15:35:11 +02:00
Klaas Schoute b6f38c3cbb Update forecast_solar integration to v5.0.1 (#173151) 2026-06-06 15:26:14 +02:00
Bouwe Westerdijk a0162d2ff0 Bump plugwise to v1.11.4 (#173147) 2026-06-06 12:03:32 +02:00
robotsnh b6f018873b refactor(dwd_weather_warnings): change datetime.now to dt_util.utcnow (#173149) 2026-06-06 11:58:06 +02:00
Crocmagnon 43e21322ea Bump data-grand-lyon-ha to 0.8.0 (#173108) 2026-06-06 11:08:43 +02:00
tronikos 86ccc59a5f Bump opower to 0.18.3 (#173141) 2026-06-06 08:53:52 +02:00
Luke Lashley 2fce2547c7 Close the connection for disabled Roborock devices (#172277) 2026-06-06 08:19:23 +02:00
Luke Lashley 6b40278d08 Allow using a custom server for Roborock setup. (#171645)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-06-05 21:36:39 -07:00
jasonjhofmann 05bb8b94fa Add network MAC connection to AirVisual Pro devices (#173071)
Co-authored-by: jasonjhofmann <16144532+jasonjhofmann@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 06:29:32 +02:00
Joakim Plate 5ac3a8cdde Switch to active scanner for gardena (#173062) 2026-06-06 04:08:10 +02:00
Paulus Schoutsen 266fccf0cf Use SerialPortSelector for DSMR serial port configuration (#171103)
Co-authored-by: Claude <noreply@anthropic.com>
2026-06-05 21:35:07 -04:00
Joakim Plate a1e6a6f9a2 Fix process advertisement for active scans (#173116) 2026-06-05 19:42:45 +02:00
renovate[bot] 2fe406c6ff Update uv to 0.11.17 (#173060) 2026-06-05 19:34:33 +02:00
Paul Bottein e1249fef8f Bump yoto-api to 3.2.0 (#173119) 2026-06-05 19:33:13 +02:00
Michael Hansen 6f61e97f8e Bump voip-utils to 0.4.0 (#173118) 2026-06-05 19:21:49 +02:00
Noah Husby b65751e8ac Bump aiostreammagic to 2.13.2 (#173114) 2026-06-05 19:11:55 +02:00
dependabot[bot] ef4bf77b24 Bump github/gh-aw-actions from 0.77.0 to 0.77.3 (#173073)
Signed-off-by: dependabot[bot] <support@github.com>
2026-06-05 19:10:35 +02:00
Markus Tuominen 977a9ecdd2 Add entity-unique-id pylint quality scale checker (#172815)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Ariel Ebersberger <31776703+justanotherariel@users.noreply.github.com>
2026-06-05 17:35:48 +02:00
Erik Montnemery 9e79eba970 Give any connected scanner highest priority when deriving person state (#173107) 2026-06-05 17:30:39 +02:00
Martin Hjelmare 40073e598c Fix pylint utcnow checker for dt_util.UTC (#173083) 2026-06-05 16:14:57 +02:00
Erik Montnemery 627d5cc110 Do not use home zone coordinates for person when detected home by scanner (#173042) 2026-06-05 16:05:58 +02:00
Paul Bottein b1dbeca9ed Bump yoto-api to 3.1.6 (#173104) 2026-06-05 15:58:11 +02:00
Robert Resch 059bc8d676 Unify query token auth in http views (#173082) 2026-06-05 15:57:16 +02:00
Erik Montnemery 085f794407 Add test showing zone.async_active_zone prefers zone closest to center (#173099) 2026-06-05 15:27:44 +02:00
Jan Bouwhuis 3996db289d Clean up unused MQTT constants (#173095) 2026-06-05 14:33:14 +02:00
Ronald van der Meer 291585e48e Fix Duco mode end time sensor name (#173045) 2026-06-05 14:23:13 +02:00
Erwin Douna d9a125ce9b Portainer extend timeout for disk space coordinator (#173032) 2026-06-05 14:21:09 +02:00
Erik Montnemery 786c957909 Teach legacy zone condition and trigger about in_zones state attribute (#173074) 2026-06-05 14:04:23 +02:00
Markus Tuominen dd6830f1c5 Add sub-devices for Reolink dual lens cameras with per-lens sensors (#173037) 2026-06-05 14:04:10 +02:00
epenet 4dbe58afc6 Use explicit DOMAIN import in mqtt tests (#173093) 2026-06-05 14:01:28 +02:00
Nikolai Rahimi 6c72d4337d Fix Mitsubishi Comfort devices skipped due to unresolved local address (#172959) 2026-06-05 13:53:13 +02:00
epenet fcff5229d9 Fix incorrect constant usage in mqtt config flow (#172557) 2026-06-05 13:53:10 +02:00
Joost Lekkerkerker 8edd813d4b Bump pySmartThings to 4.0.1 (#173092) 2026-06-05 13:38:32 +02:00
Robert Resch 509866c0eb Bump wheels to 2026.06.0 (#173089) 2026-06-05 13:28:31 +02:00
EnjoyingM 9db5860d6b Wolflink Fix state_class for long term statistics (#173048) 2026-06-05 12:52:06 +02:00
Erwin Douna 6917223cb3 Tado refactor to utilize get_zone_states (#173075) 2026-06-05 12:39:43 +02:00
Jan Bouwhuis cc4637a703 Create certificate files before trying to migrate the MQTT config entry (#173087)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-06-05 12:12:34 +02:00
A. Gideonse 2b0d14d71e Bump api-indevolt to 1.8.5 (#173078) 2026-06-05 10:57:08 +02:00
renovate[bot] d0d85d8844 Update ruff (#173059)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-05 09:48:36 +02:00
BrettLynch123 eea3d9d4c4 Bump tesla-powerwall to 0.5.3 (#173058) 2026-06-05 09:29:13 +02:00
Erik Montnemery 48a690b267 Derive zone entity state from person in_zones state attribute (#172942) 2026-06-05 08:12:22 +02:00
Michael 07dc2346de Bump py-synologydsm-api to 2.9.0 (#173041) 2026-06-04 22:26:18 +02:00
Erik Montnemery 711830b01f Add tracking_type capability attribute to device tracker (#173027) 2026-06-04 21:19:19 +02:00
Erik Montnemery f9fea56a8c Add tests of legacy device tracker states to person tests (#173023) 2026-06-04 21:12:24 +02:00
Franck Nijhof 8aac0c5b6e Convert LinkPlay configuration_url to string for device registry (#173034) 2026-06-04 20:17:50 +02:00
epenet f2361ef5aa Drop ignore-missing-annotations from pylint 2026-04-07 11:25:00 +00:00
382 changed files with 4652 additions and 36600 deletions
+7 -7
View File
@@ -36,7 +36,7 @@
# - actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
# - actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
# - actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
# - github/gh-aw-actions/setup@b11be78086764c43fa463398aed7ffdcf40549c1 # v0.77.0
# - github/gh-aw-actions/setup@97f280b14527ca95859c0facba201aeccb2c097f # v0.77.3
#
# Container images used:
# - ghcr.io/github/gh-aw-firewall/agent:0.25.46
@@ -90,7 +90,7 @@ jobs:
steps:
- name: Setup Scripts
id: setup
uses: github/gh-aw-actions/setup@b11be78086764c43fa463398aed7ffdcf40549c1 # v0.77.0
uses: github/gh-aw-actions/setup@97f280b14527ca95859c0facba201aeccb2c097f # v0.77.3
with:
destination: ${{ runner.temp }}/gh-aw/actions
job-name: ${{ github.job }}
@@ -352,7 +352,7 @@ jobs:
steps:
- name: Setup Scripts
id: setup
uses: github/gh-aw-actions/setup@b11be78086764c43fa463398aed7ffdcf40549c1 # v0.77.0
uses: github/gh-aw-actions/setup@97f280b14527ca95859c0facba201aeccb2c097f # v0.77.3
with:
destination: ${{ runner.temp }}/gh-aw/actions
job-name: ${{ github.job }}
@@ -961,7 +961,7 @@ jobs:
steps:
- name: Setup Scripts
id: setup
uses: github/gh-aw-actions/setup@b11be78086764c43fa463398aed7ffdcf40549c1 # v0.77.0
uses: github/gh-aw-actions/setup@97f280b14527ca95859c0facba201aeccb2c097f # v0.77.3
with:
destination: ${{ runner.temp }}/gh-aw/actions
job-name: ${{ github.job }}
@@ -1100,7 +1100,7 @@ jobs:
steps:
- name: Setup Scripts
id: setup
uses: github/gh-aw-actions/setup@b11be78086764c43fa463398aed7ffdcf40549c1 # v0.77.0
uses: github/gh-aw-actions/setup@97f280b14527ca95859c0facba201aeccb2c097f # v0.77.3
with:
destination: ${{ runner.temp }}/gh-aw/actions
job-name: ${{ github.job }}
@@ -1325,7 +1325,7 @@ jobs:
steps:
- name: Setup Scripts
id: setup
uses: github/gh-aw-actions/setup@b11be78086764c43fa463398aed7ffdcf40549c1 # v0.77.0
uses: github/gh-aw-actions/setup@97f280b14527ca95859c0facba201aeccb2c097f # v0.77.3
with:
destination: ${{ runner.temp }}/gh-aw/actions
job-name: ${{ github.job }}
@@ -1383,7 +1383,7 @@ jobs:
steps:
- name: Setup Scripts
id: setup
uses: github/gh-aw-actions/setup@b11be78086764c43fa463398aed7ffdcf40549c1 # v0.77.0
uses: github/gh-aw-actions/setup@97f280b14527ca95859c0facba201aeccb2c097f # v0.77.3
with:
destination: ${{ runner.temp }}/gh-aw/actions
job-name: ${{ github.job }}
+2 -2
View File
@@ -680,7 +680,7 @@ jobs:
run: |
. venv/bin/activate
python --version
pylint --ignore-missing-annotations=y homeassistant
pylint homeassistant
- name: Run pylint (partially)
if: needs.info.outputs.test_full_suite == 'false'
shell: bash
@@ -689,7 +689,7 @@ jobs:
run: |
. venv/bin/activate
python --version
pylint --ignore-missing-annotations=y $(printf "homeassistant/components/%s " ${INTEGRATIONS_GLOB})
pylint $(printf "homeassistant/components/%s " ${INTEGRATIONS_GLOB})
pylint-tests:
name: Check pylint on tests
+2 -2
View File
@@ -137,7 +137,7 @@ jobs:
sed -i "/uv/d" requirements_diff.txt
- name: Build wheels
uses: home-assistant/wheels@e5742a69d69f0e274e2689c998900c7d19652c21 # 2025.12.0
uses: home-assistant/wheels@34957438948e0b3dcde73c77750643dadae594f5 # 2026.06.0
with:
abi: ${{ matrix.abi }}
tag: musllinux_1_2
@@ -195,7 +195,7 @@ jobs:
sed -i "/uv/d" requirements_diff.txt
- name: Build wheels
uses: home-assistant/wheels@e5742a69d69f0e274e2689c998900c7d19652c21 # 2025.12.0
uses: home-assistant/wheels@34957438948e0b3dcde73c77750643dadae594f5 # 2026.06.0
with:
abi: ${{ matrix.abi }}
tag: musllinux_1_2
+1 -15
View File
@@ -1,6 +1,6 @@
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.15.14
rev: v0.15.15
hooks:
- id: ruff-check
args:
@@ -64,17 +64,6 @@ repos:
files: ^(homeassistant|tests|script)/.+\.py$
- repo: local
hooks:
# Drift guard for the checked-in sandbox protobuf gencode. Manual
# stage only (grpcio-tools is not a project dep, so it bootstraps a
# throwaway venv and degrades gracefully when uv is absent): run with
# `prek run --hook-stage manual sandbox-proto-drift` or in a CI lane.
- id: sandbox-proto-drift
name: sandbox protobuf gencode drift guard
entry: sandbox/proto/check_drift.sh
language: script
pass_filenames: false
stages: [manual]
files: ^sandbox/proto/sandbox\.proto$
# Run mypy through our wrapper script in order to get the possible
# pyenv and/or virtualenv activated; it may not have been e.g. if
# committing from a GUI tool that was not launched from an activated
@@ -86,9 +75,6 @@ repos:
require_serial: true
types_or: [python, pyi]
files: ^(homeassistant|pylint)/.+\.(py|pyi)$
# Checked-in protobuf gencode (sandbox): the .py + .pyi pair trips
# mypy's duplicate-module check, and it is machine-generated anyway.
exclude: _pb2\.(py|pyi)$
- id: pylint
name: pylint
entry: script/run-in-env.sh pylint --ignore-missing-annotations=y
@@ -1,6 +1,10 @@
"""The AirVisual Pro integration."""
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.device_registry import (
CONNECTION_NETWORK_MAC,
DeviceInfo,
format_mac,
)
from homeassistant.helpers.entity import EntityDescription
from homeassistant.helpers.update_coordinator import CoordinatorEntity
@@ -25,6 +29,12 @@ class AirVisualProEntity(CoordinatorEntity[AirVisualProCoordinator]):
"""Return device registry information for this entity."""
return DeviceInfo(
identifiers={(DOMAIN, self.coordinator.data["serial_number"])},
connections={
(
CONNECTION_NETWORK_MAC,
format_mac(self.coordinator.data["status"]["mac_address"]),
)
},
manufacturer="AirVisual",
model=self.coordinator.data["status"]["model"],
name=self.coordinator.data["settings"]["node_name"],
+12 -6
View File
@@ -6,6 +6,7 @@ These APIs are the only documented way to interact with the bluetooth integratio
import asyncio
from asyncio import Future
from collections.abc import Callable, Iterable
from contextlib import ExitStack
from typing import TYPE_CHECKING, cast
from bleak import BleakScanner
@@ -178,15 +179,20 @@ async def async_process_advertisements(
if not done.done() and callback(service_info):
done.set_result(service_info)
unload = _get_manager(hass).async_register_callback(
_async_discovered_device, match_dict, mode, scan_duration=timeout
)
manager = _get_manager(hass)
with ExitStack() as stack:
unload = manager.async_register_callback(
_async_discovered_device, match_dict, mode
)
stack.callback(unload)
if mode == BluetoothScanningMode.ACTIVE:
task = hass.async_create_task(manager.async_request_active_scan(timeout))
stack.callback(task.cancel)
try:
async with asyncio.timeout(timeout):
return await done
finally:
unload()
@hass_callback
+10 -18
View File
@@ -1,18 +1,19 @@
"""The Brands integration."""
from collections import deque
from collections.abc import Container, Mapping
from http import HTTPStatus
import logging
from pathlib import Path
from random import SystemRandom
import time
from typing import Any, Final
from typing import Any, Final, override
from aiohttp import ClientError, hdrs, web
from aiohttp import ClientError, web
import voluptuous as vol
from homeassistant.components import websocket_api
from homeassistant.components.http import KEY_AUTHENTICATED, HomeAssistantView
from homeassistant.components.http import HomeAssistantView
from homeassistant.core import HomeAssistant, callback, valid_domain
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.aiohttp_client import async_get_clientsession
@@ -108,23 +109,18 @@ def _read_brand_file(brand_dir: Path, image: str) -> bytes | None:
class _BrandsBaseView(HomeAssistantView):
"""Base view for serving brand images."""
requires_auth = False
use_query_token_for_auth = True
def __init__(self, hass: HomeAssistant) -> None:
"""Initialize the view."""
self._hass = hass
self._cache_dir = Path(hass.config.cache_path(DOMAIN))
def _authenticate(self, request: web.Request) -> None:
"""Authenticate the request using Bearer token or query token."""
access_tokens: deque[str] = self._hass.data[DOMAIN]
authenticated = (
request[KEY_AUTHENTICATED] or request.query.get("token") in access_tokens
)
if not authenticated:
if hdrs.AUTHORIZATION in request.headers:
raise web.HTTPUnauthorized
raise web.HTTPForbidden
@callback
@override
def get_valid_auth_tokens(self, match_info: Mapping[str, str]) -> Container[str]:
"""Return valid auth tokens, which can be used for query token authentication."""
return self._hass.data[DOMAIN]
async def _serve_from_custom_integration(
self,
@@ -240,8 +236,6 @@ class BrandsIntegrationView(_BrandsBaseView):
image: str,
) -> web.Response:
"""Handle GET request for an integration brand image."""
self._authenticate(request)
if not valid_domain(domain) or image not in ALLOWED_IMAGES:
return web.Response(status=HTTPStatus.NOT_FOUND)
@@ -274,8 +268,6 @@ class BrandsHardwareView(_BrandsBaseView):
image: str,
) -> web.Response:
"""Handle GET request for a hardware brand image."""
self._authenticate(request)
if not CATEGORY_RE.match(category):
return web.Response(status=HTTPStatus.NOT_FOUND)
# Hardware images have dynamic names like "manufacturer_model.png"
@@ -8,6 +8,6 @@
"iot_class": "local_push",
"loggers": ["aiostreammagic"],
"quality_scale": "platinum",
"requirements": ["aiostreammagic==2.13.1"],
"requirements": ["aiostreammagic==2.13.2"],
"zeroconf": ["_stream-magic._tcp.local.", "_smoip._tcp.local."]
}
+14 -18
View File
@@ -2,7 +2,7 @@
import asyncio
import collections
from collections.abc import Awaitable, Callable, Coroutine
from collections.abc import Awaitable, Callable, Container, Coroutine, Mapping
from contextlib import suppress
from dataclasses import asdict, dataclass
from datetime import datetime, timedelta
@@ -12,16 +12,16 @@ import logging
import os
from random import SystemRandom
import time
from typing import Any, Final, final
from typing import Any, Final, final, override
from aiohttp import hdrs, web
from aiohttp import web
import attr
from propcache.api import cached_property, under_cached_property
import voluptuous as vol
from webrtc_models import RTCIceCandidateInit
from homeassistant.components import websocket_api
from homeassistant.components.http import KEY_AUTHENTICATED, HomeAssistantView
from homeassistant.components.http import HomeAssistantView
from homeassistant.components.media_player import (
ATTR_MEDIA_CONTENT_ID,
ATTR_MEDIA_CONTENT_TYPE,
@@ -776,30 +776,26 @@ class Camera(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
class CameraView(HomeAssistantView):
"""Base CameraView."""
requires_auth = False
use_query_token_for_auth = True
def __init__(self, component: EntityComponent[Camera]) -> None:
"""Initialize a basic camera view."""
self.component = component
@callback
@override
def get_valid_auth_tokens(self, match_info: Mapping[str, str]) -> Container[str]:
"""Return valid auth tokens, which can be used for query token authentication."""
if (camera := self.component.get_entity(match_info["entity_id"])) is None:
return ()
return camera.access_tokens
async def get(self, request: web.Request, entity_id: str) -> web.StreamResponse:
"""Start a GET request."""
if (camera := self.component.get_entity(entity_id)) is None:
raise web.HTTPNotFound
authenticated = (
request[KEY_AUTHENTICATED]
or request.query.get("token") in camera.access_tokens
)
if not authenticated:
# Attempt with invalid bearer token, raise unauthorized
# so ban middleware can handle it.
if hdrs.AUTHORIZATION in request.headers:
raise web.HTTPUnauthorized
# Invalid sigAuth or camera access token
raise web.HTTPForbidden
if not camera.is_on:
_LOGGER.debug("Camera is off")
raise web.HTTPServiceUnavailable
@@ -7,5 +7,5 @@
"integration_type": "service",
"iot_class": "cloud_polling",
"quality_scale": "platinum",
"requirements": ["data-grand-lyon-ha==0.7.0"]
"requirements": ["data-grand-lyon-ha==0.8.0"]
}
@@ -22,6 +22,7 @@ from .const import ( # noqa: F401
ATTR_LOCATION_NAME,
ATTR_MAC,
ATTR_SOURCE_TYPE,
ATTR_TRACKING_TYPE,
CONF_ASSOCIATED_ZONE,
CONF_CONSIDER_HOME,
CONF_NEW_DEVICE_DEFAULTS,
@@ -36,6 +37,7 @@ from .const import ( # noqa: F401
PLATFORM_TYPE_LEGACY,
SCAN_INTERVAL,
SourceType,
TrackingType,
)
from .entity import ( # noqa: F401
BaseScannerEntity,
@@ -25,6 +25,18 @@ class SourceType(StrEnum):
BLUETOOTH_LE = "bluetooth_le"
class TrackingType(StrEnum):
"""Tracking type for device trackers.
Describes how the tracker determines presence: by the device's geographic
position (e.g. GPS) or by its connection to a known endpoint (e.g. a router
or beacon associated with a zone).
"""
CONNECTION = "connection"
POSITION = "position"
CONF_SCAN_INTERVAL: Final = "interval_seconds"
SCAN_INTERVAL: Final = timedelta(seconds=12)
@@ -47,6 +59,7 @@ ATTR_IN_ZONES: Final = "in_zones"
ATTR_LOCATION_NAME: Final = "location_name"
ATTR_MAC: Final = "mac"
ATTR_SOURCE_TYPE: Final = "source_type"
ATTR_TRACKING_TYPE: Final = "tracking_type"
ATTR_CONSIDER_HOME: Final = "consider_home"
ATTR_IP: Final = "ip"
@@ -48,11 +48,13 @@ from .const import (
ATTR_IP,
ATTR_MAC,
ATTR_SOURCE_TYPE,
ATTR_TRACKING_TYPE,
CONF_ASSOCIATED_ZONE,
CONNECTED_DEVICE_REGISTERED,
DOMAIN,
LOGGER,
SourceType,
TrackingType,
)
_LOGGER = logging.getLogger(__name__)
@@ -238,6 +240,9 @@ class TrackerEntity(
"""Base class for a tracked device."""
entity_description: TrackerEntityDescription
_attr_capability_attributes: dict[str, Any] = {
ATTR_TRACKING_TYPE: TrackingType.POSITION
}
_attr_in_zones: list[str] | None = None
_attr_latitude: float | None = None
_attr_location_accuracy: float = 0
@@ -411,6 +416,9 @@ class BaseScannerEntity(BaseTrackerEntity):
addresses being used to identify the device.
"""
_attr_capability_attributes: dict[str, Any] = {
ATTR_TRACKING_TYPE: TrackingType.CONNECTION
}
_scanner_option_associated_zone: str = zone.ENTITY_ID_HOME
_scanner_option_associated_zone_unsub: CALLBACK_TYPE | None = None
@@ -40,6 +40,13 @@
"gps": "GPS",
"router": "Router"
}
},
"tracking_type": {
"name": "Tracking type",
"state": {
"connection": "Connection",
"position": "Position"
}
}
}
}
+3 -49
View File
@@ -13,7 +13,6 @@ from dsmr_parser.clients.rfxtrx_protocol import (
from dsmr_parser.objects import DSMRObject
import voluptuous as vol
from homeassistant.components import usb
from homeassistant.config_entries import (
ConfigEntry,
ConfigFlow,
@@ -23,6 +22,7 @@ from homeassistant.config_entries import (
from homeassistant.const import CONF_HOST, CONF_PORT, CONF_PROTOCOL, CONF_TYPE
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.selector import SerialPortSelector
from .const import (
CONF_DSMR_VERSION,
@@ -37,8 +37,6 @@ from .const import (
RFXTRX_DSMR_PROTOCOL,
)
CONF_MANUAL_PATH = "Enter Manually"
class DSMRConnection:
"""Test the connection to DSMR and receive telegram to read serial ids."""
@@ -165,8 +163,6 @@ class DSMRFlowHandler(ConfigFlow, domain=DOMAIN):
VERSION = 1
_dsmr_version: str | None = None
@staticmethod
@callback
def async_get_options_flow(
@@ -222,34 +218,13 @@ class DSMRFlowHandler(ConfigFlow, domain=DOMAIN):
"""Step when setting up serial configuration."""
errors: dict[str, str] = {}
if user_input is not None:
user_selection = user_input[CONF_PORT]
if user_selection == CONF_MANUAL_PATH:
self._dsmr_version = user_input[CONF_DSMR_VERSION]
return await self.async_step_setup_serial_manual_path()
dev_path = user_selection
validate_data = {
CONF_PORT: dev_path,
CONF_DSMR_VERSION: user_input[CONF_DSMR_VERSION],
}
data = await self.async_validate_dsmr(validate_data, errors)
data = await self.async_validate_dsmr(user_input, errors)
if not errors:
return self.async_create_entry(title=data[CONF_PORT], data=data)
ports = await usb.async_scan_serial_ports(self.hass)
list_of_ports = {
port.device: f"{port.device} - {port.description or 'n/a'}"
f", s/n: {port.serial_number or 'n/a'}"
+ (f" - {port.manufacturer}" if port.manufacturer else "")
for port in ports
}
list_of_ports[CONF_MANUAL_PATH] = CONF_MANUAL_PATH
schema = vol.Schema(
{
vol.Required(CONF_PORT): vol.In(list_of_ports),
vol.Required(CONF_PORT): SerialPortSelector(),
vol.Required(CONF_DSMR_VERSION): vol.In(DSMR_VERSIONS),
}
)
@@ -259,27 +234,6 @@ class DSMRFlowHandler(ConfigFlow, domain=DOMAIN):
errors=errors,
)
async def async_step_setup_serial_manual_path(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Select path manually."""
if user_input is not None:
validate_data = {
CONF_PORT: user_input[CONF_PORT],
CONF_DSMR_VERSION: self._dsmr_version,
}
errors: dict[str, str] = {}
data = await self.async_validate_dsmr(validate_data, errors)
if not errors:
return self.async_create_entry(title=data[CONF_PORT], data=data)
schema = vol.Schema({vol.Required(CONF_PORT): str})
return self.async_show_form(
step_id="setup_serial_manual_path",
data_schema=schema,
)
async def async_validate_dsmr(
self, input_data: dict[str, Any], errors: dict[str, str]
) -> dict[str, Any]:
@@ -26,12 +26,6 @@
},
"title": "[%key:common::config_flow::data::device%]"
},
"setup_serial_manual_path": {
"data": {
"port": "[%key:common::config_flow::data::usb_path%]"
},
"title": "[%key:common::config_flow::data::path%]"
},
"user": {
"data": {
"type": "Connection type"
+1 -1
View File
@@ -59,7 +59,7 @@
"name": "Target flow level"
},
"time_state_end": {
"name": "Mode end time"
"name": "State end time"
},
"ventilation_state": {
"name": "Ventilation state",
@@ -9,7 +9,6 @@ Warnungen vor markantem Wetter (Stufe 2) # codespell:ignore vor
Wetterwarnungen (Stufe 1)
"""
from datetime import UTC, datetime
from typing import Any
from homeassistant.components.sensor import SensorEntity, SensorEntityDescription
@@ -17,6 +16,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from homeassistant.util import dt as dt_util
from .const import (
ADVANCE_WARNING_SENSOR,
@@ -100,7 +100,7 @@ class DwdWeatherWarningsSensor(
if warnings is None:
return []
now = datetime.now(UTC) # pylint: disable=home-assistant-enforce-utcnow
now = dt_util.utcnow()
return [warning for warning in warnings if warning[API_ATTR_WARNING_END] > now]
@property
@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/forecast_solar",
"integration_type": "service",
"iot_class": "cloud_polling",
"requirements": ["forecast-solar==5.0.0"]
"requirements": ["forecast-solar==5.0.1"]
}
@@ -1,11 +1,13 @@
"""The Gardena Bluetooth integration."""
from contextlib import suppress
import logging
from bleak.backends.device import BLEDevice
from gardena_bluetooth.client import CachedConnection, Client
from gardena_bluetooth.const import ProductType
from gardena_bluetooth.scan import async_get_manufacturer_data
from gardena_bluetooth.const import ScanService
from gardena_bluetooth.parse import ManufacturerData, ProductType
from habluetooth import BluetoothServiceInfoBleak
from homeassistant.components import bluetooth
from homeassistant.const import CONF_ADDRESS, Platform
@@ -30,6 +32,64 @@ PLATFORMS: list[Platform] = [
]
LOGGER = logging.getLogger(__name__)
DISCONNECT_DELAY = 5
PRODUCTS_SCAN_TIMEOUT = 10
PRODUCT_TYPE_TIMEOUT = 30
async def async_get_product_type(hass: HomeAssistant, address: str) -> ProductType:
"""Get a product type for the given address."""
data = ManufacturerData()
def _data_callback(info: BluetoothServiceInfoBleak) -> bool:
LOGGER.debug("Processing advertisement from %s: %s", info.address, info)
if info.device.address != address:
return False
data.update(info.manufacturer_data.get(ManufacturerData.company, b""))
return data.product_type is not ProductType.UNKNOWN
with suppress(TimeoutError):
await bluetooth.async_process_advertisements(
hass,
_data_callback,
bluetooth.BluetoothCallbackMatcher(
address=address, manufacturer_id=ManufacturerData.company
),
mode=bluetooth.BluetoothScanningMode.ACTIVE,
timeout=PRODUCT_TYPE_TIMEOUT,
)
return data.product_type
async def async_get_products(hass: HomeAssistant) -> dict[str, ManufacturerData]:
"""Get all products that are currently advertising."""
products: dict[str, ManufacturerData] = {}
def _data_callback(info: BluetoothServiceInfoBleak) -> bool:
LOGGER.debug("Processing advertisement from %s: %s", info.address, info)
if ScanService not in info.service_uuids:
return False
raw = info.manufacturer_data.get(ManufacturerData.company, b"")
if (data := products.get(info.device.address)) is None:
data = ManufacturerData()
products[info.device.address] = data
data.update(raw)
return False
with suppress(TimeoutError):
await bluetooth.async_process_advertisements(
hass,
_data_callback,
bluetooth.BluetoothCallbackMatcher(
manufacturer_id=ManufacturerData.company
),
mode=bluetooth.BluetoothScanningMode.ACTIVE,
timeout=PRODUCTS_SCAN_TIMEOUT,
)
return products
def get_connection(hass: HomeAssistant, address: str) -> CachedConnection:
@@ -53,12 +113,7 @@ async def async_setup_entry(
address = entry.data[CONF_ADDRESS]
try:
mfg_data = await async_get_manufacturer_data({address})
except TimeoutError as exc:
raise ConfigEntryNotReady("Unable to find product type") from exc
product_type = mfg_data[address].product_type
product_type = await async_get_product_type(hass, address)
if product_type is ProductType.UNKNOWN:
raise ConfigEntryNotReady("Unable to find product type")
@@ -4,21 +4,17 @@ import logging
from typing import Any
from gardena_bluetooth.client import Client
from gardena_bluetooth.const import PRODUCT_NAMES, DeviceInformation, ScanService
from gardena_bluetooth.const import PRODUCT_NAMES, DeviceInformation
from gardena_bluetooth.exceptions import CharacteristicNotFound, CommunicationFailure
from gardena_bluetooth.parse import ManufacturerData, ProductType
from gardena_bluetooth.scan import async_get_manufacturer_data
from gardena_bluetooth.parse import ProductType
import voluptuous as vol
from homeassistant.components.bluetooth import (
BluetoothServiceInfo,
async_discovered_service_info,
)
from homeassistant.components.bluetooth import BluetoothServiceInfo
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_ADDRESS
from homeassistant.data_entry_flow import AbortFlow
from . import get_connection
from . import async_get_product_type, async_get_products, get_connection
from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
@@ -33,17 +29,6 @@ _SUPPORTED_PRODUCT_TYPES = {
}
def _is_supported(discovery_info: BluetoothServiceInfo):
"""Check if device is supported."""
if ScanService not in discovery_info.service_uuids:
return False
if discovery_info.manufacturer_data.get(ManufacturerData.company) is None:
_LOGGER.debug("Missing manufacturer data: %s", discovery_info)
return False
return True
class GardenaBluetoothConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Gardena Bluetooth."""
@@ -75,8 +60,7 @@ class GardenaBluetoothConfigFlow(ConfigFlow, domain=DOMAIN):
) -> ConfigFlowResult:
"""Handle the bluetooth discovery step."""
_LOGGER.debug("Discovered device: %s", discovery_info)
data = await async_get_manufacturer_data({discovery_info.address})
product_type = data[discovery_info.address].product_type
product_type = await async_get_product_type(self.hass, discovery_info.address)
if product_type not in _SUPPORTED_PRODUCT_TYPES:
return self.async_abort(reason="no_devices_found")
@@ -117,22 +101,16 @@ class GardenaBluetoothConfigFlow(ConfigFlow, domain=DOMAIN):
self._abort_if_unique_id_configured()
return await self.async_step_confirm()
current_addresses = self._async_current_ids(include_ignore=False)
candidates = set()
for discovery_info in async_discovered_service_info(self.hass):
address = discovery_info.address
if address in current_addresses or not _is_supported(discovery_info):
continue
candidates.add(address)
data = await async_get_manufacturer_data(candidates)
for address, mfg_data in data.items():
if mfg_data.product_type not in _SUPPORTED_PRODUCT_TYPES:
continue
self.devices[address] = PRODUCT_NAMES[mfg_data.product_type]
current = self._async_current_ids(include_ignore=False)
devices = await async_get_products(self.hass)
# Keep selection sorted by address to ensure stable tests
self.devices = dict(sorted(self.devices.items(), key=lambda x: x[0]))
self.devices = {
address: PRODUCT_NAMES[data.product_type]
for address in sorted(devices)
if address not in current
and (data := devices[address]).product_type in _SUPPORTED_PRODUCT_TYPES
}
if not self.devices:
return self.async_abort(reason="no_devices_found")
+19 -23
View File
@@ -2,20 +2,21 @@
import asyncio
import collections
from collections.abc import Container, Mapping
from contextlib import suppress
from dataclasses import dataclass
from datetime import datetime, timedelta
import logging
import os
from random import SystemRandom
from typing import Final, final
from typing import Final, final, override
from aiohttp import hdrs, web
from aiohttp import web
import httpx
from propcache.api import cached_property
import voluptuous as vol
from homeassistant.components.http import KEY_AUTHENTICATED, KEY_HASS, HomeAssistantView
from homeassistant.components.http import KEY_HASS, HomeAssistantView
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONTENT_TYPE_MULTIPART, EVENT_HOMEASSISTANT_STOP
from homeassistant.core import (
@@ -314,33 +315,28 @@ class ImageView(HomeAssistantView):
"""View to serve an image."""
name = "api:image:image"
requires_auth = False
use_query_token_for_auth = True
url = "/api/image_proxy/{entity_id}"
def __init__(self, component: EntityComponent[ImageEntity]) -> None:
"""Initialize an image view."""
self.component = component
async def _authenticate_request(
self, request: web.Request, entity_id: str
) -> ImageEntity:
"""Authenticate request and return image entity."""
@callback
@override
def get_valid_auth_tokens(self, match_info: Mapping[str, str]) -> Container[str]:
"""Return valid auth tokens, which can be used for query token authentication."""
if (image_entity := self.component.get_entity(match_info["entity_id"])) is None:
return ()
return image_entity.access_tokens
@callback
def _get_image_entity(self, entity_id: str) -> ImageEntity:
"""Get image entity from request."""
if (image_entity := self.component.get_entity(entity_id)) is None:
raise web.HTTPNotFound
authenticated = (
request[KEY_AUTHENTICATED]
or request.query.get("token") in image_entity.access_tokens
)
if not authenticated:
# Attempt with invalid bearer token, raise unauthorized
# so ban middleware can handle it.
if hdrs.AUTHORIZATION in request.headers:
raise web.HTTPUnauthorized
# Invalid sigAuth or image entity access token
raise web.HTTPForbidden
return image_entity
async def head(self, request: web.Request, entity_id: str) -> web.Response:
@@ -349,7 +345,7 @@ class ImageView(HomeAssistantView):
This is sent by some DLNA renderers, like Samsung ones, prior to sending
the GET request.
"""
image_entity = await self._authenticate_request(request, entity_id)
image_entity = self._get_image_entity(entity_id)
# Don't use `handle` as we don't care about the stream case, we only want
# to verify that the image exists.
@@ -365,7 +361,7 @@ class ImageView(HomeAssistantView):
async def get(self, request: web.Request, entity_id: str) -> web.StreamResponse:
"""Start a GET request."""
image_entity = await self._authenticate_request(request, entity_id)
image_entity = self._get_image_entity(entity_id)
return await self.handle(request, image_entity)
async def handle(
@@ -8,6 +8,6 @@
"integration_type": "device",
"iot_class": "local_polling",
"quality_scale": "platinum",
"requirements": ["indevolt-api==1.8.3"],
"requirements": ["indevolt-api==1.8.5"],
"zeroconf": [{ "name": "igen_fw*", "type": "_http._tcp.local." }]
}
+1 -1
View File
@@ -51,7 +51,7 @@ class LinkPlayBaseEntity(Entity):
)
self._attr_device_info = dr.DeviceInfo(
configuration_url=bridge.endpoint,
configuration_url=str(bridge.endpoint),
connections=connections,
hw_version=bridge.device.properties["hardware"],
identifiers={(DOMAIN, bridge.device.uuid)},
@@ -2,7 +2,7 @@
import asyncio
import collections
from collections.abc import Callable
from collections.abc import Callable, Container, Mapping
from contextlib import suppress
import datetime as dt
from enum import StrEnum
@@ -12,7 +12,7 @@ import hashlib
from http import HTTPStatus
import logging
import secrets
from typing import Any, Final, Required, TypedDict, final
from typing import Any, Final, Required, TypedDict, final, override
from urllib.parse import quote, urlparse
import aiohttp
@@ -24,7 +24,7 @@ import voluptuous as vol
from yarl import URL
from homeassistant.components import websocket_api
from homeassistant.components.http import KEY_AUTHENTICATED, HomeAssistantView
from homeassistant.components.http import HomeAssistantView
from homeassistant.components.websocket_api import ERR_NOT_SUPPORTED, ERR_UNKNOWN_ERROR
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ( # noqa: F401
@@ -50,7 +50,7 @@ from homeassistant.const import ( # noqa: F401
STATE_PLAYING,
STATE_STANDBY,
)
from homeassistant.core import HomeAssistant, SupportsResponse
from homeassistant.core import HomeAssistant, SupportsResponse, callback
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.entity import Entity, EntityDescription
@@ -1249,7 +1249,7 @@ class MediaPlayerEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
class MediaPlayerImageView(HomeAssistantView):
"""Media player view to serve an image."""
requires_auth = False
use_query_token_for_auth = True
url = "/api/media_player_proxy/{entity_id}"
name = "api:media_player:image"
extra_urls = [
@@ -1262,6 +1262,15 @@ class MediaPlayerImageView(HomeAssistantView):
"""Initialize a media player view."""
self.component = component
@callback
@override
def get_valid_auth_tokens(self, match_info: Mapping[str, str]) -> Container[str]:
"""Return valid auth tokens, which can be used for query token authentication."""
if (player := self.component.get_entity(match_info["entity_id"])) is None:
return ()
return (player.access_token,)
async def get(
self,
request: web.Request,
@@ -1271,21 +1280,9 @@ class MediaPlayerImageView(HomeAssistantView):
) -> web.Response:
"""Start a get request."""
if (player := self.component.get_entity(entity_id)) is None:
status = (
HTTPStatus.NOT_FOUND
if request[KEY_AUTHENTICATED]
else HTTPStatus.UNAUTHORIZED
)
return web.Response(status=status)
return web.Response(status=HTTPStatus.NOT_FOUND)
assert isinstance(player, MediaPlayerEntity)
authenticated = (
request[KEY_AUTHENTICATED]
or request.query.get("token") == player.access_token
)
if not authenticated:
return web.Response(status=HTTPStatus.UNAUTHORIZED)
if media_content_type and media_content_id:
media_image_id = request.query.get("media_image_id")
@@ -14,9 +14,16 @@ from mitsubishi_comfort.exceptions import AuthenticationError, DeviceConnectionE
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import DEFAULT_CONNECT_TIMEOUT, DEFAULT_RESPONSE_TIMEOUT, DOMAIN, PLATFORMS
from .const import (
CONF_ADDRESSES,
DEFAULT_CONNECT_TIMEOUT,
DEFAULT_RESPONSE_TIMEOUT,
DOMAIN,
PLATFORMS,
)
from .coordinator import MitsubishiComfortConfigEntry, MitsubishiComfortCoordinator
_LOGGER = logging.getLogger(__name__)
@@ -25,13 +32,14 @@ _LOGGER = logging.getLogger(__name__)
def _make_device(
info: DeviceInfo,
serial: str,
address: str,
session,
) -> IndoorUnit | KumoStation:
"""Create the appropriate device instance from DeviceInfo."""
cls = IndoorUnit if info.is_indoor_unit else KumoStation
return cls(
name=info.label,
address=info.address,
address=address,
password_b64=info.password,
crypto_serial_hex=info.crypto_serial,
serial=serial,
@@ -64,12 +72,39 @@ async def async_setup_entry(
translation_key="no_devices",
)
# The cloud provides each device's MAC but never its LAN IP. Register every
# device with its MAC so the manifest's "registered_devices" DHCP matcher
# tracks it; DHCP discovery then supplies the IP via async_step_dhcp.
device_registry = dr.async_get(hass)
owned_macs = {dr.format_mac(info.mac) for info in devices.values()}
for serial, info in devices.items():
device_registry.async_get_or_create(
config_entry_id=entry.entry_id,
identifiers={(DOMAIN, serial)},
connections={(dr.CONNECTION_NETWORK_MAC, dr.format_mac(info.mac))},
manufacturer="Mitsubishi",
name=info.label,
serial_number=serial,
)
# Resolved IPs are stored keyed by MAC. Drop any for devices that are no
# longer on the account.
stored: dict[str, str] = entry.data.get(CONF_ADDRESSES, {})
addresses = {mac: ip for mac, ip in stored.items() if mac in owned_macs}
if addresses != stored:
hass.config_entries.async_update_entry(
entry, data={**entry.data, CONF_ADDRESSES: addresses}
)
coordinators: dict[str, MitsubishiComfortCoordinator] = {}
for serial, info in devices.items():
if not info.address or not info.password or not info.crypto_serial:
_LOGGER.warning("Device %s missing credentials, skipping", info.label)
address = addresses.get(dr.format_mac(info.mac))
if not address or not info.password or not info.crypto_serial:
# No LAN address yet: the device is registered, so DHCP discovery
# supplies its IP and reloads the entry to add it.
_LOGGER.debug("Device %s has no known LAN address yet", info.label)
continue
device = _make_device(info, serial, session)
device = _make_device(info, serial, address, session)
coordinators[serial] = MitsubishiComfortCoordinator(
hass, entry, device, info.mac
)
@@ -9,9 +9,11 @@ import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo
from .const import DOMAIN
from .const import CONF_ADDRESSES, DOMAIN
_LOGGER = logging.getLogger(__name__)
@@ -71,3 +73,41 @@ class MitsubishiComfortConfigFlow(ConfigFlow, domain=DOMAIN):
return self.async_show_form(
step_id="user", data_schema=USER_SCHEMA, errors=errors
)
async def async_step_dhcp(
self, discovery_info: DhcpServiceInfo
) -> ConfigFlowResult:
"""Handle a registered device discovered on the local network via DHCP.
The cloud API never returns a device's LAN IP, so DHCP discovery is the
source of addresses. Each device is registered with its MAC during setup,
so "registered_devices" discovery only fires for our own devices: record
the IP on the owning entry and reload to set the device up or recover a
changed IP.
"""
mac = dr.format_mac(discovery_info.macaddress)
device = dr.async_get(self.hass).async_get_device(
connections={(dr.CONNECTION_NETWORK_MAC, mac)}
)
entry = next(
(
entry
for entry in self._async_current_entries(include_ignore=False)
if device is not None and entry.entry_id in device.config_entries
),
None,
)
if entry is None:
return self.async_abort(reason="already_configured")
addresses = entry.data.get(CONF_ADDRESSES, {})
if addresses.get(mac) != discovery_info.ip:
self.hass.config_entries.async_update_entry(
entry,
data={
**entry.data,
CONF_ADDRESSES: {**addresses, mac: discovery_info.ip},
},
)
self.hass.config_entries.async_schedule_reload(entry.entry_id)
return self.async_abort(reason="already_configured")
@@ -7,6 +7,13 @@ from homeassistant.const import Platform
DOMAIN: Final = "mitsubishi_comfort"
PLATFORMS: Final = [Platform.CLIMATE]
# Config entry data key holding the per-device LAN address cache, keyed by the
# device's formatted MAC. The cloud API only returns each device's MAC, never
# its LAN IP, so addresses are resolved from DHCP discovery and persisted here
# to survive restarts without re-discovery.
CONF_ADDRESSES: Final = "addresses"
DEFAULT_SCAN_INTERVAL = timedelta(seconds=60)
DEFAULT_CONNECT_TIMEOUT: Final = 1.2
DEFAULT_RESPONSE_TIMEOUT: Final = 8.0
@@ -3,9 +3,10 @@
"name": "Mitsubishi Comfort",
"codeowners": ["@nikolairahimi"],
"config_flow": true,
"dhcp": [{ "registered_devices": true }],
"documentation": "https://www.home-assistant.io/integrations/mitsubishi_comfort",
"integration_type": "hub",
"iot_class": "cloud_polling",
"iot_class": "local_polling",
"quality_scale": "bronze",
"requirements": ["mitsubishi-comfort==0.3.0"]
}
@@ -56,7 +56,7 @@ rules:
icon-translations: todo
reconfiguration-flow: todo
dynamic-devices: todo
discovery-update-info: todo
discovery-update-info: done
repair-issues: todo
docs-use-cases: done
docs-supported-devices: done
@@ -504,6 +504,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
# Can be removed with HA Core 2027.1
new_entry_data = entry.data.copy()
new_entry_data[CONF_PROTOCOL] = PROTOCOL_5
# Create temporary certificate files from entry
await async_create_certificate_temp_files(hass, new_entry_data)
# Try the connection with protocol version 5
# And update the protocol if successful
if await hass.async_add_executor_job(
+13 -14
View File
@@ -70,13 +70,6 @@ from homeassistant.config_entries import (
SubentryFlowResult,
)
from homeassistant.const import (
ATTR_CONFIGURATION_URL,
ATTR_HW_VERSION,
ATTR_MANUFACTURER,
ATTR_MODEL,
ATTR_MODEL_ID,
ATTR_NAME,
ATTR_SW_VERSION,
CONF_BRIGHTNESS,
CONF_CLIENT_ID,
CONF_CODE,
@@ -87,6 +80,8 @@ from homeassistant.const import (
CONF_ENTITY_CATEGORY,
CONF_HOST,
CONF_MODE,
CONF_MODEL,
CONF_MODEL_ID,
CONF_NAME,
CONF_OPTIMISTIC,
CONF_OPTIONS,
@@ -181,6 +176,7 @@ from .const import (
CONF_COMMAND_ON_TEMPLATE,
CONF_COMMAND_TEMPLATE,
CONF_COMMAND_TOPIC,
CONF_CONFIGURATION_URL,
CONF_CONTENT_TYPE,
CONF_CURRENT_HUMIDITY_TEMPLATE,
CONF_CURRENT_HUMIDITY_TOPIC,
@@ -221,10 +217,12 @@ from .const import (
CONF_HUMIDITY_MIN,
CONF_HUMIDITY_STATE_TEMPLATE,
CONF_HUMIDITY_STATE_TOPIC,
CONF_HW_VERSION,
CONF_IMAGE_ENCODING,
CONF_IMAGE_TOPIC,
CONF_KEEPALIVE,
CONF_LAST_RESET_VALUE_TEMPLATE,
CONF_MANUFACTURER,
CONF_MAX,
CONF_MAX_KELVIN,
CONF_MESSAGE_EXPIRY_INTERVAL,
@@ -317,6 +315,7 @@ from .const import (
CONF_SUPPORT_VOLUME_SET,
CONF_SUPPORTED_COLOR_MODES,
CONF_SUPPORTED_FEATURES,
CONF_SW_VERSION,
CONF_SWING_HORIZONTAL_MODE_COMMAND_TEMPLATE,
CONF_SWING_HORIZONTAL_MODE_COMMAND_TOPIC,
CONF_SWING_HORIZONTAL_MODE_LIST,
@@ -3797,17 +3796,17 @@ PLATFORM_MQTT_FIELDS: dict[Platform, dict[str, PlatformField]] = {
},
}
MQTT_DEVICE_PLATFORM_FIELDS = {
ATTR_NAME: PlatformField(selector=TEXT_SELECTOR, required=True),
ATTR_SW_VERSION: PlatformField(
CONF_NAME: PlatformField(selector=TEXT_SELECTOR, required=True),
CONF_SW_VERSION: PlatformField(
selector=TEXT_SELECTOR, required=False, section="advanced_settings"
),
ATTR_HW_VERSION: PlatformField(
CONF_HW_VERSION: PlatformField(
selector=TEXT_SELECTOR, required=False, section="advanced_settings"
),
ATTR_MODEL: PlatformField(selector=TEXT_SELECTOR, required=False),
ATTR_MODEL_ID: PlatformField(selector=TEXT_SELECTOR, required=False),
ATTR_MANUFACTURER: PlatformField(selector=TEXT_SELECTOR, required=False),
ATTR_CONFIGURATION_URL: PlatformField(
CONF_MODEL: PlatformField(selector=TEXT_SELECTOR, required=False),
CONF_MODEL_ID: PlatformField(selector=TEXT_SELECTOR, required=False),
CONF_MANUFACTURER: PlatformField(selector=TEXT_SELECTOR, required=False),
CONF_CONFIGURATION_URL: PlatformField(
selector=TEXT_SELECTOR, required=False, validator=cv.url, error="invalid_url"
),
CONF_QOS: PlatformField(
@@ -2,31 +2,9 @@
import voluptuous as vol
from homeassistant.const import (
CONF_CLIENT_ID,
CONF_DISCOVERY,
CONF_PASSWORD,
CONF_PORT,
CONF_PROTOCOL,
CONF_USERNAME,
Platform,
)
from homeassistant.const import Platform
from homeassistant.helpers import config_validation as cv
from .const import (
CONF_BIRTH_MESSAGE,
CONF_BROKER,
CONF_CERTIFICATE,
CONF_CLIENT_CERT,
CONF_CLIENT_KEY,
CONF_DISCOVERY_PREFIX,
CONF_KEEPALIVE,
CONF_TLS_INSECURE,
CONF_WILL_MESSAGE,
)
DEFAULT_TLS_PROTOCOL = "auto"
CONFIG_SCHEMA_BASE = vol.Schema(
{
Platform.ALARM_CONTROL_PANEL.value: vol.All(cv.ensure_list, [dict]),
@@ -60,29 +38,3 @@ CONFIG_SCHEMA_BASE = vol.Schema(
Platform.WATER_HEATER.value: vol.All(cv.ensure_list, [dict]),
}
)
CLIENT_KEY_AUTH_MSG = (
"client_key and client_cert must both be present in the MQTT broker configuration"
)
DEPRECATED_CONFIG_KEYS = [
CONF_BIRTH_MESSAGE,
CONF_BROKER,
CONF_CLIENT_ID,
CONF_DISCOVERY,
CONF_DISCOVERY_PREFIX,
CONF_KEEPALIVE,
CONF_PASSWORD,
CONF_PORT,
CONF_PROTOCOL,
CONF_TLS_INSECURE,
CONF_USERNAME,
CONF_WILL_MESSAGE,
]
DEPRECATED_CERTIFICATE_CONFIG_KEYS = [
CONF_CERTIFICATE,
CONF_CLIENT_CERT,
CONF_CLIENT_KEY,
]
@@ -9,5 +9,5 @@
"iot_class": "cloud_polling",
"loggers": ["opower"],
"quality_scale": "platinum",
"requirements": ["opower==0.18.2"]
"requirements": ["opower==0.18.3"]
}
+36 -26
View File
@@ -11,8 +11,10 @@ from homeassistant.components import persistent_notification, websocket_api
from homeassistant.components.device_tracker import (
ATTR_IN_ZONES,
ATTR_SOURCE_TYPE,
ATTR_TRACKING_TYPE,
DOMAIN as DEVICE_TRACKER_DOMAIN,
SourceType,
TrackingType,
)
from homeassistant.components.zone import ENTITY_ID_HOME
from homeassistant.const import (
@@ -460,7 +462,7 @@ class Person(
"""Register device trackers."""
await super().async_added_to_hass()
if state := await self.async_get_last_state():
self._parse_source_state(state, state)
self._parse_source_state(state)
if self.hass.is_running:
# Update person now if hass is already running.
@@ -510,39 +512,32 @@ class Person(
@callback
def _update_state(self) -> None:
"""Update the state."""
latest_non_gps_home = latest_not_home = latest_gps = latest = coordinates = None
latest_connected = latest_legacy_home = latest_not_home = latest_gps = None
for entity_id in self._config[CONF_DEVICE_TRACKERS]:
state = self.hass.states.get(entity_id)
if not state or state.state in IGNORE_STATES:
continue
if state.attributes.get(ATTR_SOURCE_TYPE) == SourceType.GPS:
if state.attributes.get(
ATTR_TRACKING_TYPE
) == TrackingType.CONNECTION and state.attributes.get(ATTR_IN_ZONES):
latest_connected = _get_latest(latest_connected, state)
elif state.attributes.get(ATTR_SOURCE_TYPE) == SourceType.GPS:
latest_gps = _get_latest(latest_gps, state)
elif state.state == STATE_HOME:
latest_non_gps_home = _get_latest(latest_non_gps_home, state)
# Legacy scanner without tracking type
latest_legacy_home = _get_latest(latest_legacy_home, state)
else:
latest_not_home = _get_latest(latest_not_home, state)
if latest_non_gps_home:
latest = latest_non_gps_home
if (
latest_non_gps_home.attributes.get(ATTR_LATITUDE) is None
and latest_non_gps_home.attributes.get(ATTR_LONGITUDE) is None
and (home_zone := self.hass.states.get(ENTITY_ID_HOME))
):
coordinates = home_zone
else:
coordinates = latest_non_gps_home
elif latest_gps:
latest = latest_gps
coordinates = latest_gps
else:
latest = latest_not_home
coordinates = latest_not_home
# A scanner (e.g. a router or beacon) that reports
# being in a zone is the most reliable presence signal, so it
# takes precedence over everything else.
latest = latest_connected or latest_legacy_home or latest_gps or latest_not_home
if latest and coordinates:
self._parse_source_state(latest, coordinates)
if latest:
self._parse_source_state(latest)
else:
self._attr_state = None
self._source = None
@@ -555,18 +550,33 @@ class Person(
self.async_write_ha_state()
@callback
def _parse_source_state(self, state: State, coordinates: State) -> None:
def _parse_source_state(self, state: State) -> None:
"""Parse source state and set person attributes.
This is a device tracker state or the restored person state.
"""
self._attr_state = state.state
self._source = state.entity_id
self._latitude = coordinates.attributes.get(ATTR_LATITUDE)
self._longitude = coordinates.attributes.get(ATTR_LONGITUDE)
self._gps_accuracy = coordinates.attributes.get(ATTR_GPS_ACCURACY)
self._latitude = state.attributes.get(ATTR_LATITUDE)
self._longitude = state.attributes.get(ATTR_LONGITUDE)
self._gps_accuracy = state.attributes.get(ATTR_GPS_ACCURACY)
self._in_zones = state.attributes.get(ATTR_IN_ZONES, [])
# A legacy scanner (one that doesn't report in_zones) reports "home"
# without coordinates. Use the home zone's coordinates for backwards
# compatibility with legacy zone conditions and triggers. Modern
# trackers report in_zones and keep their own (possibly absent)
# coordinates.
if (
ATTR_IN_ZONES not in state.attributes
and state.state == STATE_HOME
and self._latitude is None
and self._longitude is None
and (home_zone := self.hass.states.get(ENTITY_ID_HOME)) is not None
):
self._latitude = home_zone.attributes.get(ATTR_LATITUDE)
self._longitude = home_zone.attributes.get(ATTR_LONGITUDE)
@callback
def _update_extra_state_attributes(self) -> None:
"""Update extra state attributes."""
@@ -8,6 +8,6 @@
"iot_class": "local_polling",
"loggers": ["plugwise"],
"quality_scale": "platinum",
"requirements": ["plugwise==1.11.3"],
"requirements": ["plugwise==1.11.4"],
"zeroconf": ["_plugwise._tcp.local."]
}
@@ -63,7 +63,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: PortainerConfigEntry) ->
api_url=entry.data[CONF_URL],
api_key=entry.data[CONF_API_TOKEN],
session=session,
request_timeout=60,
request_timeout=120,
max_retries=API_MAX_RETRIES,
)
@@ -15,5 +15,5 @@
"integration_type": "device",
"iot_class": "local_polling",
"loggers": ["tesla_powerwall"],
"requirements": ["tesla-powerwall==0.5.2"]
"requirements": ["tesla-powerwall==0.5.3"]
}
@@ -25,7 +25,7 @@ rules:
status: exempt
comment: Integration is polling and does not subscribe to events.
unique-config-entry: done
entity-unique-id: done
entity-unique-id: todo
docs-installation-instructions:
status: todo
comment: |
+16 -3
View File
@@ -7,7 +7,7 @@ from random import uniform
from time import time
from typing import Any
from reolink_aio.api import RETRY_ATTEMPTS
from reolink_aio.api import DUAL_LENS_DUAL_MOTION_MODELS, RETRY_ATTEMPTS
from reolink_aio.exceptions import CredentialsInvalidError, ReolinkError
from homeassistant.const import CONF_PORT, EVENT_HOMEASSISTANT_STOP, Platform
@@ -210,6 +210,19 @@ async def async_setup_entry(
connections={(dr.CONNECTION_NETWORK_MAC, host.api.mac_address)},
)
if host.api.is_nvr and host.api.model in DUAL_LENS_DUAL_MOTION_MODELS:
# ensure the camera device is setup before
# the lens sub-devices that use via_device
if host.api.supported(0, "UID"):
camera_dev_id = f"{host.unique_id}_{host.api.camera_uid(0)}"
else:
camera_dev_id = f"{host.unique_id}_ch0"
device_registry.async_get_or_create(
config_entry_id=config_entry.entry_id,
identifiers={(DOMAIN, camera_dev_id)},
via_device=(DOMAIN, host.unique_id),
)
await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS)
return True
@@ -423,8 +436,8 @@ def migrate_entity_ids(
device_reg.async_update_device(device.id, new_identifiers=new_identifiers)
break
if ch is None or is_chime:
continue # Do not consider the NVR itself or chimes
if ch is None or is_chime or device_uid[1].startswith("lens"):
continue # Do not consider the NVR itself, chimes or lens sub-devices
# Check for wrongfully added MAC of the NVR/Hub to the camera
# Can be removed in HA 2025.12
@@ -4,7 +4,6 @@ from collections.abc import Callable
from dataclasses import dataclass
from reolink_aio.api import (
DUAL_LENS_DUAL_MOTION_MODELS,
FACE_DETECTION_TYPE,
PACKAGE_DETECTION_TYPE,
PERSON_DETECTION_TYPE,
@@ -71,6 +70,7 @@ BINARY_PUSH_SENSORS = (
key="motion",
cmd_id=33,
device_class=BinarySensorDeviceClass.MOTION,
lens_entity=True,
value=lambda api, ch: api.motion_detected(ch),
supported=lambda api, ch: api.supported(ch, "motion_detection"),
),
@@ -78,6 +78,7 @@ BINARY_PUSH_SENSORS = (
key=FACE_DETECTION_TYPE,
cmd_id=33,
translation_key="face",
lens_entity=True,
value=lambda api, ch: api.ai_detected(ch, FACE_DETECTION_TYPE),
supported=lambda api, ch: api.ai_supported(ch, FACE_DETECTION_TYPE),
),
@@ -85,6 +86,7 @@ BINARY_PUSH_SENSORS = (
key=PERSON_DETECTION_TYPE,
cmd_id=[33, 600, 696],
translation_key="person",
lens_entity=True,
value=lambda api, ch: api.ai_detected(ch, PERSON_DETECTION_TYPE),
supported=lambda api, ch: api.ai_supported(ch, PERSON_DETECTION_TYPE),
),
@@ -92,6 +94,7 @@ BINARY_PUSH_SENSORS = (
key=VEHICLE_DETECTION_TYPE,
cmd_id=[33, 600, 696],
translation_key="vehicle",
lens_entity=True,
value=lambda api, ch: api.ai_detected(ch, VEHICLE_DETECTION_TYPE),
supported=lambda api, ch: api.ai_supported(ch, VEHICLE_DETECTION_TYPE),
),
@@ -99,6 +102,7 @@ BINARY_PUSH_SENSORS = (
key="non-motor_vehicle",
cmd_id=[600, 696],
translation_key="non-motor_vehicle",
lens_entity=True,
value=lambda api, ch: api.ai_detected(ch, "non-motor vehicle"),
supported=lambda api, ch: api.supported(ch, "ai_non-motor vehicle"),
),
@@ -106,6 +110,7 @@ BINARY_PUSH_SENSORS = (
key=PET_DETECTION_TYPE,
cmd_id=[33, 600, 696],
translation_key="pet",
lens_entity=True,
value=lambda api, ch: api.ai_detected(ch, PET_DETECTION_TYPE),
supported=lambda api, ch: (
api.ai_supported(ch, PET_DETECTION_TYPE)
@@ -116,6 +121,7 @@ BINARY_PUSH_SENSORS = (
key=PET_DETECTION_TYPE,
cmd_id=[33, 600, 696],
translation_key="animal",
lens_entity=True,
value=lambda api, ch: api.ai_detected(ch, PET_DETECTION_TYPE),
supported=lambda api, ch: api.supported(ch, "ai_animal"),
),
@@ -123,6 +129,7 @@ BINARY_PUSH_SENSORS = (
key=PACKAGE_DETECTION_TYPE,
cmd_id=[33, 600, 696],
translation_key="package",
lens_entity=True,
value=lambda api, ch: api.ai_detected(ch, PACKAGE_DETECTION_TYPE),
supported=lambda api, ch: api.ai_supported(ch, PACKAGE_DETECTION_TYPE),
),
@@ -355,13 +362,6 @@ class ReolinkBinarySensorEntity(ReolinkChannelCoordinatorEntity, BinarySensorEnt
self.entity_description = entity_description
super().__init__(reolink_data, channel)
if self._host.api.model in DUAL_LENS_DUAL_MOTION_MODELS:
if entity_description.translation_key is not None:
key = entity_description.translation_key
else:
key = entity_description.key
self._attr_translation_key = f"{key}_lens_{self._channel}"
@property
def is_on(self) -> bool:
"""State of the sensor."""
+4 -2
View File
@@ -3,7 +3,7 @@
from dataclasses import dataclass
import logging
from reolink_aio.api import DUAL_LENS_MODELS
from reolink_aio.api import DUAL_LENS_SINGLE_MOTION_MODELS
from homeassistant.components.camera import (
Camera,
@@ -28,6 +28,8 @@ class ReolinkCameraEntityDescription(
"""A class that describes camera entities for a camera channel."""
stream: str
# a camera stream always comes from a single lens
lens_entity: bool = True
CAMERA_ENTITIES = (
@@ -138,7 +140,7 @@ class ReolinkCamera(ReolinkChannelCoordinatorEntity, Camera):
if "snapshots" not in entity_description.stream:
self._attr_supported_features = CameraEntityFeature.STREAM
if self._host.api.model in DUAL_LENS_MODELS:
if self._host.api.model in DUAL_LENS_SINGLE_MOTION_MODELS:
self._attr_translation_key = (
f"{entity_description.translation_key}_lens_{self._channel}"
)
+21 -1
View File
@@ -3,7 +3,7 @@
from collections.abc import Callable
from dataclasses import dataclass
from reolink_aio.api import DUAL_LENS_MODELS, Chime, Host
from reolink_aio.api import DUAL_LENS_DUAL_MOTION_MODELS, DUAL_LENS_MODELS, Chime, Host
from homeassistant.core import callback
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo
@@ -22,6 +22,9 @@ class ReolinkEntityDescription(EntityDescription):
cmd_key: str | None = None
cmd_id: int | list[int] | None = None
always_available: bool = False
# Whether the entity measures a property of a single lens
# of a dual lens camera, instead of the camera as a whole
lens_entity: bool = False
@dataclass(frozen=True, kw_only=True)
@@ -217,6 +220,23 @@ class ReolinkChannelCoordinatorEntity(ReolinkHostCoordinatorEntity):
configuration_url=conf_url,
)
if (
self.entity_description.lens_entity
and self._host.api.model in DUAL_LENS_DUAL_MOTION_MODELS
):
# Dual lens cameras with separate sensors per lens
# use a sub-device per lens
parent_dev_id = self._dev_id
self._dev_id = f"{self._host.unique_id}_lens{channel}"
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, self._dev_id)},
via_device=(DOMAIN, parent_dev_id),
name=f"{self._host.api.camera_name(dev_ch)} lens {channel}",
model=self._host.api.camera_model(channel),
manufacturer=self._host.api.manufacturer,
configuration_url=self._conf_url,
)
@property
def available(self) -> bool:
"""Return True if entity is available."""
@@ -52,20 +52,6 @@
"on": "[%key:component::binary_sensor::entity_component::gas::state::on%]"
}
},
"animal_lens_0": {
"name": "Animal lens 0",
"state": {
"off": "[%key:component::binary_sensor::entity_component::gas::state::off%]",
"on": "[%key:component::binary_sensor::entity_component::gas::state::on%]"
}
},
"animal_lens_1": {
"name": "Animal lens 1",
"state": {
"off": "[%key:component::binary_sensor::entity_component::gas::state::off%]",
"on": "[%key:component::binary_sensor::entity_component::gas::state::on%]"
}
},
"crossline_dog_cat": {
"name": "Crossline {zone_name} animal",
"state": {
@@ -101,20 +87,6 @@
"on": "[%key:component::binary_sensor::entity_component::gas::state::on%]"
}
},
"face_lens_0": {
"name": "Face lens 0",
"state": {
"off": "[%key:component::binary_sensor::entity_component::gas::state::off%]",
"on": "[%key:component::binary_sensor::entity_component::gas::state::on%]"
}
},
"face_lens_1": {
"name": "Face lens 1",
"state": {
"off": "[%key:component::binary_sensor::entity_component::gas::state::off%]",
"on": "[%key:component::binary_sensor::entity_component::gas::state::on%]"
}
},
"forgotten_item": {
"name": "Item forgotten {zone_name}",
"state": {
@@ -171,20 +143,6 @@
"on": "[%key:component::binary_sensor::entity_component::gas::state::on%]"
}
},
"motion_lens_0": {
"name": "Motion lens 0",
"state": {
"off": "[%key:component::binary_sensor::entity_component::gas::state::off%]",
"on": "[%key:component::binary_sensor::entity_component::gas::state::on%]"
}
},
"motion_lens_1": {
"name": "Motion lens 1",
"state": {
"off": "[%key:component::binary_sensor::entity_component::gas::state::off%]",
"on": "[%key:component::binary_sensor::entity_component::gas::state::on%]"
}
},
"non-motor_vehicle": {
"name": "Bicycle",
"state": {
@@ -199,20 +157,6 @@
"on": "[%key:component::binary_sensor::entity_component::gas::state::on%]"
}
},
"package_lens_0": {
"name": "Package lens 0",
"state": {
"off": "[%key:component::binary_sensor::entity_component::gas::state::off%]",
"on": "[%key:component::binary_sensor::entity_component::gas::state::on%]"
}
},
"package_lens_1": {
"name": "Package lens 1",
"state": {
"off": "[%key:component::binary_sensor::entity_component::gas::state::off%]",
"on": "[%key:component::binary_sensor::entity_component::gas::state::on%]"
}
},
"person": {
"name": "Person",
"state": {
@@ -220,20 +164,6 @@
"on": "[%key:component::binary_sensor::entity_component::gas::state::on%]"
}
},
"person_lens_0": {
"name": "Person lens 0",
"state": {
"off": "[%key:component::binary_sensor::entity_component::gas::state::off%]",
"on": "[%key:component::binary_sensor::entity_component::gas::state::on%]"
}
},
"person_lens_1": {
"name": "Person lens 1",
"state": {
"off": "[%key:component::binary_sensor::entity_component::gas::state::off%]",
"on": "[%key:component::binary_sensor::entity_component::gas::state::on%]"
}
},
"pet": {
"name": "Pet",
"state": {
@@ -241,20 +171,6 @@
"on": "[%key:component::binary_sensor::entity_component::gas::state::on%]"
}
},
"pet_lens_0": {
"name": "Pet lens 0",
"state": {
"off": "[%key:component::binary_sensor::entity_component::gas::state::off%]",
"on": "[%key:component::binary_sensor::entity_component::gas::state::on%]"
}
},
"pet_lens_1": {
"name": "Pet lens 1",
"state": {
"off": "[%key:component::binary_sensor::entity_component::gas::state::off%]",
"on": "[%key:component::binary_sensor::entity_component::gas::state::on%]"
}
},
"sleep": {
"name": "Sleep status",
"state": {
@@ -276,28 +192,8 @@
"on": "[%key:component::binary_sensor::entity_component::gas::state::on%]"
}
},
"vehicle_lens_0": {
"name": "Vehicle lens 0",
"state": {
"off": "[%key:component::binary_sensor::entity_component::gas::state::off%]",
"on": "[%key:component::binary_sensor::entity_component::gas::state::on%]"
}
},
"vehicle_lens_1": {
"name": "Vehicle lens 1",
"state": {
"off": "[%key:component::binary_sensor::entity_component::gas::state::off%]",
"on": "[%key:component::binary_sensor::entity_component::gas::state::on%]"
}
},
"visitor": {
"name": "Visitor"
},
"visitor_lens_0": {
"name": "Visitor lens 0"
},
"visitor_lens_1": {
"name": "Visitor lens 1"
}
},
"button": {
+2
View File
@@ -100,6 +100,8 @@ def get_device_uid_and_ch(
elif device_uid[1].startswith("chime"):
ch = int(device_uid[1][5:])
is_chime = True
elif device_uid[1].startswith("lens"):
ch = int(device_uid[1][4:])
else:
device_uid_part = "_".join(device_uid[1:])
ch = host.api.channel_for_uid(device_uid_part)
+23 -3
View File
@@ -141,11 +141,31 @@ async def async_setup_entry(hass: HomeAssistant, entry: RoborockConfigEntry) ->
**get_device_info(device),
)
enabled_devices = [
device for device in devices if not _is_device_disabled(device_registry, device)
]
enabled_devices = []
disabled_devices = []
for device in devices:
if _is_device_disabled(device_registry, device):
disabled_devices.append(device)
else:
enabled_devices.append(device)
_LOGGER.debug("%d of %d devices are enabled", len(enabled_devices), len(devices))
# Close connections for disabled devices to prevent their background
# reconnect loops from triggering MQTT session restarts that would
# disrupt coordinator setup for the enabled devices.
if disabled_devices:
close_results = await asyncio.gather(
*[device.close() for device in disabled_devices],
return_exceptions=True,
)
for device, close_result in zip(disabled_devices, close_results, strict=True):
if isinstance(close_result, Exception):
_LOGGER.debug(
"Failed to close disabled Roborock device %s: %s",
device.duid,
close_result,
)
coordinators = await asyncio.gather(
*build_setup_functions(hass, entry, enabled_devices, user_data),
return_exceptions=True,
@@ -4,6 +4,7 @@ from collections.abc import Mapping
from copy import deepcopy
import logging
from typing import Any
from urllib.parse import urlparse
from roborock.data import UserData
from roborock.exceptions import (
@@ -31,6 +32,9 @@ from homeassistant.helpers.selector import (
SelectSelector,
SelectSelectorConfig,
SelectSelectorMode,
TextSelector,
TextSelectorConfig,
TextSelectorType,
)
from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo
@@ -38,6 +42,7 @@ from . import RoborockConfigEntry
from .const import (
CONF_BASE_URL,
CONF_ENTRY_CODE,
CONF_ROBOROCK_SERVER_URL,
CONF_SHOW_BACKGROUND,
CONF_SHOW_ROOMS,
CONF_SHOW_WALLS,
@@ -45,6 +50,8 @@ from .const import (
DEFAULT_DRAWABLES,
DOMAIN,
DRAWABLES,
REGION_AUTO,
REGION_CUSTOM,
REGION_OPTIONS,
)
@@ -73,8 +80,10 @@ class RoborockFlowHandler(ConfigFlow, domain=DOMAIN):
region = user_input[CONF_REGION]
self._username = username
_LOGGER.debug("Requesting code for Roborock account")
if region == REGION_CUSTOM:
return await self.async_step_custom_url()
base_url = None
if region != "auto":
if region != REGION_AUTO:
base_url = f"https://{region}iot.roborock.com"
self._client = RoborockApiClient(
username,
@@ -90,7 +99,7 @@ class RoborockFlowHandler(ConfigFlow, domain=DOMAIN):
data_schema=vol.Schema(
{
vol.Required(CONF_USERNAME): str,
vol.Required(CONF_REGION, default="auto"): SelectSelector(
vol.Required(CONF_REGION, default=REGION_AUTO): SelectSelector(
SelectSelectorConfig(
options=REGION_OPTIONS,
mode=SelectSelectorMode.DROPDOWN,
@@ -102,6 +111,44 @@ class RoborockFlowHandler(ConfigFlow, domain=DOMAIN):
errors=errors,
)
async def async_step_custom_url(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle custom server URL entry."""
errors: dict[str, str] = {}
assert self._username
if user_input is not None:
url = user_input[CONF_ROBOROCK_SERVER_URL].strip()
parsed = urlparse(url)
if parsed.scheme not in ("http", "https") or not parsed.netloc:
errors[CONF_ROBOROCK_SERVER_URL] = "invalid_url_format"
else:
self._client = RoborockApiClient(
self._username,
base_url=url,
session=async_get_clientsession(self.hass),
)
errors = await self._request_code()
if not errors:
return await self.async_step_code()
return self.async_show_form(
step_id="custom_url",
data_schema=vol.Schema(
{
vol.Required(
CONF_ROBOROCK_SERVER_URL,
default=(
user_input[CONF_ROBOROCK_SERVER_URL]
if user_input is not None
else "https://usiot.roborock.com"
),
): TextSelector(TextSelectorConfig(type=TextSelectorType.URL)),
}
),
errors=errors,
)
async def _request_code(self) -> dict:
assert self._client
errors: dict[str, str] = {}
+15 -1
View File
@@ -13,7 +13,21 @@ CONF_USER_DATA = "user_data"
CONF_SHOW_BACKGROUND = "show_background"
CONF_SHOW_WALLS = "show_walls"
CONF_SHOW_ROOMS = "show_rooms"
REGION_OPTIONS = ["auto", "us", "eu", "ru", "cn"]
CONF_ROBOROCK_SERVER_URL = "roborock_server_url"
REGION_AUTO = "auto"
REGION_CUSTOM = "custom"
REGION_US = "us"
REGION_EU = "eu"
REGION_RU = "ru"
REGION_CN = "cn"
REGION_OPTIONS = [
REGION_AUTO,
REGION_US,
REGION_EU,
REGION_RU,
REGION_CN,
REGION_CUSTOM,
]
# Option Flow steps
DRAWABLES = "drawables"
@@ -10,6 +10,7 @@
"invalid_email": "There is no account associated with the email you entered, please try again.",
"invalid_email_format": "There is an issue with the formatting of your email - please try again.",
"invalid_email_or_region": "Either there is no account associated with the email you entered, or there is no account in the selected region.",
"invalid_url_format": "The URL must start with http:// or https:// and include a valid host.",
"too_frequent_code_requests": "You have attempted to request too many codes. Try again later.",
"unknown": "[%key:common::config_flow::error::unknown%]",
"unknown_roborock": "There was an unknown Roborock exception - please check your logs.",
@@ -25,6 +26,15 @@
},
"description": "Type the verification code sent to your email"
},
"custom_url": {
"data": {
"roborock_server_url": "Roborock Server URL"
},
"data_description": {
"roborock_server_url": "The URL of the Roborock server."
},
"description": "Enter the Roborock server URL to connect to."
},
"reauth_confirm": {
"description": "The Roborock integration needs to re-authenticate your account",
"title": "[%key:common::config_flow::title::reauth%]"
@@ -764,6 +774,7 @@
"options": {
"auto": "Auto",
"cn": "CN",
"custom": "Manual",
"eu": "EU",
"ru": "RU",
"us": "US"
@@ -1,129 +0,0 @@
"""Sandbox — run integrations in isolated subprocesses.
The integration owns three runtime objects, all hung off
:class:`SandboxData`:
* :class:`SandboxManager` supervises one subprocess per sandbox group
("main", "built-in", "custom"), lazily spawning them on first need.
* :class:`SandboxFlowRouter` installed as
``hass.config_entries.router``. Diverts new config flows to
sandbox runtimes and routes ``async_setup_entry`` for tagged entries.
* :class:`SandboxBridge` (one per running sandbox) owns the entity-side
protocol: receives ``register_entity`` + ``state_changed`` pushes from
the sandbox, instantiates proxy entities, and forwards entity service
calls back via the shared ``sandbox/call_service`` channel.
"""
from dataclasses import dataclass, field
import logging
from typing import Any
from homeassistant.const import EVENT_HOMEASSISTANT_STOP
from homeassistant.core import Event, HomeAssistant
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.translation import (
async_register_sandbox_translation_provider,
)
from homeassistant.helpers.typing import ConfigType
from ._proto import sandbox_pb2 as pb
from .bridge import SandboxBridge, async_create_bridge
from .channel import Channel
from .const import DATA_SANDBOX, DOMAIN
from .manager import SandboxManager
from .router import SandboxFlowRouter
from .translation import SandboxTranslationProvider
_LOGGER = logging.getLogger(__name__)
CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN)
@dataclass
class SandboxData:
"""Global Sandbox runtime data."""
manager: SandboxManager | None = None
router: SandboxFlowRouter | None = None
channels: dict[str, Channel] = field(default_factory=dict)
bridges: dict[str, SandboxBridge] = field(default_factory=dict)
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the Sandbox integration."""
data = SandboxData()
hass.data[DATA_SANDBOX] = data
def _on_channel_ready(group: str, channel: Channel) -> None:
# Drop any prior bridge for this group (a sandbox restart hands us
# a fresh channel — the previous bridge owned the dead one).
data.channels[group] = channel
data.bridges[group] = async_create_bridge(hass, group=group, channel=channel)
async def _on_shutdown_reply(group: str, reply: Any) -> None:
"""Persist the sandbox's restore-state snapshot.
The runtime ships its ``RestoreEntity`` state in the shutdown
reply (a ``ShutdownResult``) rather than via the sandbox store
bridge (the reader task is busy dispatching the shutdown handler
a re-entrant store_save would deadlock). We route the payload
through the bridge's store server so it lands at the same path the
next run's warm-load reads from.
"""
if not reply.HasField("restore_state"):
return
bridge = data.bridges.get(group)
if bridge is None:
_LOGGER.debug(
"sandbox[%s]: shutdown reply carried restore_state but"
" no bridge is registered; dropping",
group,
)
return
try:
await bridge._handle_store_save( # noqa: SLF001 — internal write path
pb.StoreSave(key="core.restore_state", data=reply.restore_state)
)
except Exception:
_LOGGER.exception(
"Failed to persist restore_state snapshot for sandbox %s",
group,
)
manager = SandboxManager(
hass,
on_channel_ready=_on_channel_ready,
on_shutdown_reply=_on_shutdown_reply,
)
router = SandboxFlowRouter(hass, manager, data=data)
data.manager = manager
data.router = router
hass.config_entries.router = router
# Feed sandboxed integrations' frontend translations into core's cache.
# Built-in domains read main's own disk; only customs pull over RPC.
translation_provider = SandboxTranslationProvider(hass, data)
unregister_translation_provider = async_register_sandbox_translation_provider(
hass, translation_provider.async_get_translations
)
async def _on_stop(_event: Event) -> None:
"""Stop every sandbox process on HA shutdown.
Ask each sandbox to unload its entries and flush
``RestoreEntity`` state through the ``current_sandbox`` store
bridge before pulling the plug. ``async_stop_all`` then handles SIGTERM
/ SIGKILL for any sandbox that didn't ack the graceful request
within the grace.
"""
hass.config_entries.router = None
unregister_translation_provider()
await manager.async_graceful_shutdown_all(timeout=manager.shutdown_grace)
await manager.async_stop_all()
data.channels.clear()
data.bridges.clear()
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _on_stop)
return True
File diff suppressed because one or more lines are too long
@@ -1,479 +0,0 @@
from google.protobuf import struct_pb2 as _struct_pb2
from google.protobuf.internal import containers as _containers
from google.protobuf import descriptor as _descriptor
from google.protobuf import message as _message
from collections.abc import Iterable as _Iterable, Mapping as _Mapping
from typing import ClassVar as _ClassVar, Optional as _Optional, Union as _Union
DESCRIPTOR: _descriptor.FileDescriptor
class Frame(_message.Message):
__slots__ = ("id", "type", "request", "response")
ID_FIELD_NUMBER: _ClassVar[int]
TYPE_FIELD_NUMBER: _ClassVar[int]
REQUEST_FIELD_NUMBER: _ClassVar[int]
RESPONSE_FIELD_NUMBER: _ClassVar[int]
id: int
type: str
request: bytes
response: Response
def __init__(self, id: _Optional[int] = ..., type: _Optional[str] = ..., request: _Optional[bytes] = ..., response: _Optional[_Union[Response, _Mapping]] = ...) -> None: ...
class Response(_message.Message):
__slots__ = ("ok", "result", "error")
OK_FIELD_NUMBER: _ClassVar[int]
RESULT_FIELD_NUMBER: _ClassVar[int]
ERROR_FIELD_NUMBER: _ClassVar[int]
ok: bool
result: bytes
error: Error
def __init__(self, ok: bool = ..., result: _Optional[bytes] = ..., error: _Optional[_Union[Error, _Mapping]] = ...) -> None: ...
class Error(_message.Message):
__slots__ = ("message", "type", "invalid", "multiple")
MESSAGE_FIELD_NUMBER: _ClassVar[int]
TYPE_FIELD_NUMBER: _ClassVar[int]
INVALID_FIELD_NUMBER: _ClassVar[int]
MULTIPLE_FIELD_NUMBER: _ClassVar[int]
message: str
type: str
invalid: _containers.RepeatedCompositeFieldContainer[InvalidError]
multiple: bool
def __init__(self, message: _Optional[str] = ..., type: _Optional[str] = ..., invalid: _Optional[_Iterable[_Union[InvalidError, _Mapping]]] = ..., multiple: bool = ...) -> None: ...
class InvalidError(_message.Message):
__slots__ = ("message", "path")
MESSAGE_FIELD_NUMBER: _ClassVar[int]
PATH_FIELD_NUMBER: _ClassVar[int]
message: str
path: _containers.RepeatedScalarFieldContainer[str]
def __init__(self, message: _Optional[str] = ..., path: _Optional[_Iterable[str]] = ...) -> None: ...
class DevicePair(_message.Message):
__slots__ = ("key", "value")
KEY_FIELD_NUMBER: _ClassVar[int]
VALUE_FIELD_NUMBER: _ClassVar[int]
key: str
value: str
def __init__(self, key: _Optional[str] = ..., value: _Optional[str] = ...) -> None: ...
class DeviceInfo(_message.Message):
__slots__ = ("identifiers", "connections", "via_device", "entry_type", "name", "manufacturer", "model", "model_id", "sw_version", "hw_version", "serial_number", "suggested_area", "configuration_url", "default_name", "default_manufacturer", "default_model", "translation_key")
IDENTIFIERS_FIELD_NUMBER: _ClassVar[int]
CONNECTIONS_FIELD_NUMBER: _ClassVar[int]
VIA_DEVICE_FIELD_NUMBER: _ClassVar[int]
ENTRY_TYPE_FIELD_NUMBER: _ClassVar[int]
NAME_FIELD_NUMBER: _ClassVar[int]
MANUFACTURER_FIELD_NUMBER: _ClassVar[int]
MODEL_FIELD_NUMBER: _ClassVar[int]
MODEL_ID_FIELD_NUMBER: _ClassVar[int]
SW_VERSION_FIELD_NUMBER: _ClassVar[int]
HW_VERSION_FIELD_NUMBER: _ClassVar[int]
SERIAL_NUMBER_FIELD_NUMBER: _ClassVar[int]
SUGGESTED_AREA_FIELD_NUMBER: _ClassVar[int]
CONFIGURATION_URL_FIELD_NUMBER: _ClassVar[int]
DEFAULT_NAME_FIELD_NUMBER: _ClassVar[int]
DEFAULT_MANUFACTURER_FIELD_NUMBER: _ClassVar[int]
DEFAULT_MODEL_FIELD_NUMBER: _ClassVar[int]
TRANSLATION_KEY_FIELD_NUMBER: _ClassVar[int]
identifiers: _containers.RepeatedCompositeFieldContainer[DevicePair]
connections: _containers.RepeatedCompositeFieldContainer[DevicePair]
via_device: DevicePair
entry_type: str
name: str
manufacturer: str
model: str
model_id: str
sw_version: str
hw_version: str
serial_number: str
suggested_area: str
configuration_url: str
default_name: str
default_manufacturer: str
default_model: str
translation_key: str
def __init__(self, identifiers: _Optional[_Iterable[_Union[DevicePair, _Mapping]]] = ..., connections: _Optional[_Iterable[_Union[DevicePair, _Mapping]]] = ..., via_device: _Optional[_Union[DevicePair, _Mapping]] = ..., entry_type: _Optional[str] = ..., name: _Optional[str] = ..., manufacturer: _Optional[str] = ..., model: _Optional[str] = ..., model_id: _Optional[str] = ..., sw_version: _Optional[str] = ..., hw_version: _Optional[str] = ..., serial_number: _Optional[str] = ..., suggested_area: _Optional[str] = ..., configuration_url: _Optional[str] = ..., default_name: _Optional[str] = ..., default_manufacturer: _Optional[str] = ..., default_model: _Optional[str] = ..., translation_key: _Optional[str] = ...) -> None: ...
class IntegrationSource(_message.Message):
__slots__ = ("kind", "url", "ref", "tag", "domain", "subdir")
KIND_FIELD_NUMBER: _ClassVar[int]
URL_FIELD_NUMBER: _ClassVar[int]
REF_FIELD_NUMBER: _ClassVar[int]
TAG_FIELD_NUMBER: _ClassVar[int]
DOMAIN_FIELD_NUMBER: _ClassVar[int]
SUBDIR_FIELD_NUMBER: _ClassVar[int]
kind: str
url: str
ref: str
tag: str
domain: str
subdir: str
def __init__(self, kind: _Optional[str] = ..., url: _Optional[str] = ..., ref: _Optional[str] = ..., tag: _Optional[str] = ..., domain: _Optional[str] = ..., subdir: _Optional[str] = ...) -> None: ...
class EntrySetup(_message.Message):
__slots__ = ("entry_id", "domain", "title", "data", "options", "source", "unique_id", "version", "minor_version", "integration_source")
ENTRY_ID_FIELD_NUMBER: _ClassVar[int]
DOMAIN_FIELD_NUMBER: _ClassVar[int]
TITLE_FIELD_NUMBER: _ClassVar[int]
DATA_FIELD_NUMBER: _ClassVar[int]
OPTIONS_FIELD_NUMBER: _ClassVar[int]
SOURCE_FIELD_NUMBER: _ClassVar[int]
UNIQUE_ID_FIELD_NUMBER: _ClassVar[int]
VERSION_FIELD_NUMBER: _ClassVar[int]
MINOR_VERSION_FIELD_NUMBER: _ClassVar[int]
INTEGRATION_SOURCE_FIELD_NUMBER: _ClassVar[int]
entry_id: str
domain: str
title: str
data: _struct_pb2.Struct
options: _struct_pb2.Struct
source: str
unique_id: str
version: int
minor_version: int
integration_source: IntegrationSource
def __init__(self, entry_id: _Optional[str] = ..., domain: _Optional[str] = ..., title: _Optional[str] = ..., data: _Optional[_Union[_struct_pb2.Struct, _Mapping]] = ..., options: _Optional[_Union[_struct_pb2.Struct, _Mapping]] = ..., source: _Optional[str] = ..., unique_id: _Optional[str] = ..., version: _Optional[int] = ..., minor_version: _Optional[int] = ..., integration_source: _Optional[_Union[IntegrationSource, _Mapping]] = ...) -> None: ...
class EntrySetupResult(_message.Message):
__slots__ = ("ok", "reason")
OK_FIELD_NUMBER: _ClassVar[int]
REASON_FIELD_NUMBER: _ClassVar[int]
ok: bool
reason: str
def __init__(self, ok: bool = ..., reason: _Optional[str] = ...) -> None: ...
class EntryUnload(_message.Message):
__slots__ = ("entry_id",)
ENTRY_ID_FIELD_NUMBER: _ClassVar[int]
entry_id: str
def __init__(self, entry_id: _Optional[str] = ...) -> None: ...
class EntryUnloadResult(_message.Message):
__slots__ = ("ok",)
OK_FIELD_NUMBER: _ClassVar[int]
ok: bool
def __init__(self, ok: bool = ...) -> None: ...
class CallService(_message.Message):
__slots__ = ("domain", "service", "target", "service_data", "context_id", "return_response")
DOMAIN_FIELD_NUMBER: _ClassVar[int]
SERVICE_FIELD_NUMBER: _ClassVar[int]
TARGET_FIELD_NUMBER: _ClassVar[int]
SERVICE_DATA_FIELD_NUMBER: _ClassVar[int]
CONTEXT_ID_FIELD_NUMBER: _ClassVar[int]
RETURN_RESPONSE_FIELD_NUMBER: _ClassVar[int]
domain: str
service: str
target: _struct_pb2.Struct
service_data: _struct_pb2.Struct
context_id: str
return_response: bool
def __init__(self, domain: _Optional[str] = ..., service: _Optional[str] = ..., target: _Optional[_Union[_struct_pb2.Struct, _Mapping]] = ..., service_data: _Optional[_Union[_struct_pb2.Struct, _Mapping]] = ..., context_id: _Optional[str] = ..., return_response: bool = ...) -> None: ...
class ServiceResponse(_message.Message):
__slots__ = ("data",)
DATA_FIELD_NUMBER: _ClassVar[int]
data: _struct_pb2.Struct
def __init__(self, data: _Optional[_Union[_struct_pb2.Struct, _Mapping]] = ...) -> None: ...
class CallServiceResult(_message.Message):
__slots__ = ("response",)
RESPONSE_FIELD_NUMBER: _ClassVar[int]
response: ServiceResponse
def __init__(self, response: _Optional[_Union[ServiceResponse, _Mapping]] = ...) -> None: ...
class EntityQuery(_message.Message):
__slots__ = ("sandbox_entity_id", "method", "args", "context_id")
SANDBOX_ENTITY_ID_FIELD_NUMBER: _ClassVar[int]
METHOD_FIELD_NUMBER: _ClassVar[int]
ARGS_FIELD_NUMBER: _ClassVar[int]
CONTEXT_ID_FIELD_NUMBER: _ClassVar[int]
sandbox_entity_id: str
method: str
args: _struct_pb2.Struct
context_id: str
def __init__(self, sandbox_entity_id: _Optional[str] = ..., method: _Optional[str] = ..., args: _Optional[_Union[_struct_pb2.Struct, _Mapping]] = ..., context_id: _Optional[str] = ...) -> None: ...
class EntityQueryResult(_message.Message):
__slots__ = ("result",)
RESULT_FIELD_NUMBER: _ClassVar[int]
result: _struct_pb2.Struct
def __init__(self, result: _Optional[_Union[_struct_pb2.Struct, _Mapping]] = ...) -> None: ...
class GetTranslations(_message.Message):
__slots__ = ("language", "domains")
LANGUAGE_FIELD_NUMBER: _ClassVar[int]
DOMAINS_FIELD_NUMBER: _ClassVar[int]
language: str
domains: _containers.RepeatedScalarFieldContainer[str]
def __init__(self, language: _Optional[str] = ..., domains: _Optional[_Iterable[str]] = ...) -> None: ...
class GetTranslationsResult(_message.Message):
__slots__ = ("language", "strings")
LANGUAGE_FIELD_NUMBER: _ClassVar[int]
STRINGS_FIELD_NUMBER: _ClassVar[int]
language: str
strings: _struct_pb2.Struct
def __init__(self, language: _Optional[str] = ..., strings: _Optional[_Union[_struct_pb2.Struct, _Mapping]] = ...) -> None: ...
class Shutdown(_message.Message):
__slots__ = ()
def __init__(self) -> None: ...
class ShutdownResult(_message.Message):
__slots__ = ("ok", "unloaded", "restore_state")
OK_FIELD_NUMBER: _ClassVar[int]
UNLOADED_FIELD_NUMBER: _ClassVar[int]
RESTORE_STATE_FIELD_NUMBER: _ClassVar[int]
ok: bool
unloaded: int
restore_state: _struct_pb2.Struct
def __init__(self, ok: bool = ..., unloaded: _Optional[int] = ..., restore_state: _Optional[_Union[_struct_pb2.Struct, _Mapping]] = ...) -> None: ...
class Ping(_message.Message):
__slots__ = ()
def __init__(self) -> None: ...
class PingResult(_message.Message):
__slots__ = ("pong",)
PONG_FIELD_NUMBER: _ClassVar[int]
pong: str
def __init__(self, pong: _Optional[str] = ...) -> None: ...
class Ready(_message.Message):
__slots__ = ()
def __init__(self) -> None: ...
class FlowInit(_message.Message):
__slots__ = ("handler", "context", "data")
HANDLER_FIELD_NUMBER: _ClassVar[int]
CONTEXT_FIELD_NUMBER: _ClassVar[int]
DATA_FIELD_NUMBER: _ClassVar[int]
handler: str
context: _struct_pb2.Struct
data: _struct_pb2.Struct
def __init__(self, handler: _Optional[str] = ..., context: _Optional[_Union[_struct_pb2.Struct, _Mapping]] = ..., data: _Optional[_Union[_struct_pb2.Struct, _Mapping]] = ...) -> None: ...
class FlowStep(_message.Message):
__slots__ = ("flow_id", "user_input")
FLOW_ID_FIELD_NUMBER: _ClassVar[int]
USER_INPUT_FIELD_NUMBER: _ClassVar[int]
flow_id: str
user_input: _struct_pb2.Struct
def __init__(self, flow_id: _Optional[str] = ..., user_input: _Optional[_Union[_struct_pb2.Struct, _Mapping]] = ...) -> None: ...
class FlowAbort(_message.Message):
__slots__ = ("flow_id",)
FLOW_ID_FIELD_NUMBER: _ClassVar[int]
flow_id: str
def __init__(self, flow_id: _Optional[str] = ...) -> None: ...
class FlowAbortResult(_message.Message):
__slots__ = ()
def __init__(self) -> None: ...
class FlowResult(_message.Message):
__slots__ = ("type", "flow_id", "handler", "step_id", "reason", "title", "description", "last_step", "preview", "version", "minor_version", "data", "options", "errors", "description_placeholders", "context", "data_schema", "has_data_schema")
TYPE_FIELD_NUMBER: _ClassVar[int]
FLOW_ID_FIELD_NUMBER: _ClassVar[int]
HANDLER_FIELD_NUMBER: _ClassVar[int]
STEP_ID_FIELD_NUMBER: _ClassVar[int]
REASON_FIELD_NUMBER: _ClassVar[int]
TITLE_FIELD_NUMBER: _ClassVar[int]
DESCRIPTION_FIELD_NUMBER: _ClassVar[int]
LAST_STEP_FIELD_NUMBER: _ClassVar[int]
PREVIEW_FIELD_NUMBER: _ClassVar[int]
VERSION_FIELD_NUMBER: _ClassVar[int]
MINOR_VERSION_FIELD_NUMBER: _ClassVar[int]
DATA_FIELD_NUMBER: _ClassVar[int]
OPTIONS_FIELD_NUMBER: _ClassVar[int]
ERRORS_FIELD_NUMBER: _ClassVar[int]
DESCRIPTION_PLACEHOLDERS_FIELD_NUMBER: _ClassVar[int]
CONTEXT_FIELD_NUMBER: _ClassVar[int]
DATA_SCHEMA_FIELD_NUMBER: _ClassVar[int]
HAS_DATA_SCHEMA_FIELD_NUMBER: _ClassVar[int]
type: str
flow_id: str
handler: str
step_id: str
reason: str
title: str
description: str
last_step: bool
preview: str
version: int
minor_version: int
data: _struct_pb2.Struct
options: _struct_pb2.Struct
errors: _struct_pb2.Struct
description_placeholders: _struct_pb2.Struct
context: _struct_pb2.Struct
data_schema: _struct_pb2.ListValue
has_data_schema: bool
def __init__(self, type: _Optional[str] = ..., flow_id: _Optional[str] = ..., handler: _Optional[str] = ..., step_id: _Optional[str] = ..., reason: _Optional[str] = ..., title: _Optional[str] = ..., description: _Optional[str] = ..., last_step: bool = ..., preview: _Optional[str] = ..., version: _Optional[int] = ..., minor_version: _Optional[int] = ..., data: _Optional[_Union[_struct_pb2.Struct, _Mapping]] = ..., options: _Optional[_Union[_struct_pb2.Struct, _Mapping]] = ..., errors: _Optional[_Union[_struct_pb2.Struct, _Mapping]] = ..., description_placeholders: _Optional[_Union[_struct_pb2.Struct, _Mapping]] = ..., context: _Optional[_Union[_struct_pb2.Struct, _Mapping]] = ..., data_schema: _Optional[_Union[_struct_pb2.ListValue, _Mapping]] = ..., has_data_schema: bool = ...) -> None: ...
class EntityInfo(_message.Message):
__slots__ = ("description", "device_info")
class Description(_message.Message):
__slots__ = ("name", "icon", "entity_category", "device_class", "supported_features", "translation_key")
NAME_FIELD_NUMBER: _ClassVar[int]
ICON_FIELD_NUMBER: _ClassVar[int]
ENTITY_CATEGORY_FIELD_NUMBER: _ClassVar[int]
DEVICE_CLASS_FIELD_NUMBER: _ClassVar[int]
SUPPORTED_FEATURES_FIELD_NUMBER: _ClassVar[int]
TRANSLATION_KEY_FIELD_NUMBER: _ClassVar[int]
name: str
icon: str
entity_category: str
device_class: str
supported_features: int
translation_key: str
def __init__(self, name: _Optional[str] = ..., icon: _Optional[str] = ..., entity_category: _Optional[str] = ..., device_class: _Optional[str] = ..., supported_features: _Optional[int] = ..., translation_key: _Optional[str] = ...) -> None: ...
DESCRIPTION_FIELD_NUMBER: _ClassVar[int]
DEVICE_INFO_FIELD_NUMBER: _ClassVar[int]
description: EntityInfo.Description
device_info: DeviceInfo
def __init__(self, description: _Optional[_Union[EntityInfo.Description, _Mapping]] = ..., device_info: _Optional[_Union[DeviceInfo, _Mapping]] = ...) -> None: ...
class InitialState(_message.Message):
__slots__ = ("state", "capabilities", "attributes")
STATE_FIELD_NUMBER: _ClassVar[int]
CAPABILITIES_FIELD_NUMBER: _ClassVar[int]
ATTRIBUTES_FIELD_NUMBER: _ClassVar[int]
state: str
capabilities: _struct_pb2.Struct
attributes: _struct_pb2.Struct
def __init__(self, state: _Optional[str] = ..., capabilities: _Optional[_Union[_struct_pb2.Struct, _Mapping]] = ..., attributes: _Optional[_Union[_struct_pb2.Struct, _Mapping]] = ...) -> None: ...
class EntityDescription(_message.Message):
__slots__ = ("entry_id", "domain", "sandbox_entity_id", "unique_id", "has_entity_name", "info", "initial")
ENTRY_ID_FIELD_NUMBER: _ClassVar[int]
DOMAIN_FIELD_NUMBER: _ClassVar[int]
SANDBOX_ENTITY_ID_FIELD_NUMBER: _ClassVar[int]
UNIQUE_ID_FIELD_NUMBER: _ClassVar[int]
HAS_ENTITY_NAME_FIELD_NUMBER: _ClassVar[int]
INFO_FIELD_NUMBER: _ClassVar[int]
INITIAL_FIELD_NUMBER: _ClassVar[int]
entry_id: str
domain: str
sandbox_entity_id: str
unique_id: str
has_entity_name: bool
info: EntityInfo
initial: InitialState
def __init__(self, entry_id: _Optional[str] = ..., domain: _Optional[str] = ..., sandbox_entity_id: _Optional[str] = ..., unique_id: _Optional[str] = ..., has_entity_name: bool = ..., info: _Optional[_Union[EntityInfo, _Mapping]] = ..., initial: _Optional[_Union[InitialState, _Mapping]] = ...) -> None: ...
class RegisterEntityResult(_message.Message):
__slots__ = ("entity_id",)
ENTITY_ID_FIELD_NUMBER: _ClassVar[int]
entity_id: str
def __init__(self, entity_id: _Optional[str] = ...) -> None: ...
class UnregisterEntity(_message.Message):
__slots__ = ("sandbox_entity_id",)
SANDBOX_ENTITY_ID_FIELD_NUMBER: _ClassVar[int]
sandbox_entity_id: str
def __init__(self, sandbox_entity_id: _Optional[str] = ...) -> None: ...
class UnregisterEntityResult(_message.Message):
__slots__ = ("ok",)
OK_FIELD_NUMBER: _ClassVar[int]
ok: bool
def __init__(self, ok: bool = ...) -> None: ...
class StateChanged(_message.Message):
__slots__ = ("sandbox_entity_id", "state", "attributes", "context_id")
SANDBOX_ENTITY_ID_FIELD_NUMBER: _ClassVar[int]
STATE_FIELD_NUMBER: _ClassVar[int]
ATTRIBUTES_FIELD_NUMBER: _ClassVar[int]
CONTEXT_ID_FIELD_NUMBER: _ClassVar[int]
sandbox_entity_id: str
state: str
attributes: _struct_pb2.Struct
context_id: str
def __init__(self, sandbox_entity_id: _Optional[str] = ..., state: _Optional[str] = ..., attributes: _Optional[_Union[_struct_pb2.Struct, _Mapping]] = ..., context_id: _Optional[str] = ...) -> None: ...
class RegisterService(_message.Message):
__slots__ = ("domain", "service", "supports_response", "schema")
DOMAIN_FIELD_NUMBER: _ClassVar[int]
SERVICE_FIELD_NUMBER: _ClassVar[int]
SUPPORTS_RESPONSE_FIELD_NUMBER: _ClassVar[int]
SCHEMA_FIELD_NUMBER: _ClassVar[int]
domain: str
service: str
supports_response: str
schema: _struct_pb2.ListValue
def __init__(self, domain: _Optional[str] = ..., service: _Optional[str] = ..., supports_response: _Optional[str] = ..., schema: _Optional[_Union[_struct_pb2.ListValue, _Mapping]] = ...) -> None: ...
class RegisterServiceResult(_message.Message):
__slots__ = ("ok", "installed")
OK_FIELD_NUMBER: _ClassVar[int]
INSTALLED_FIELD_NUMBER: _ClassVar[int]
ok: bool
installed: bool
def __init__(self, ok: bool = ..., installed: bool = ...) -> None: ...
class UnregisterService(_message.Message):
__slots__ = ("domain", "service")
DOMAIN_FIELD_NUMBER: _ClassVar[int]
SERVICE_FIELD_NUMBER: _ClassVar[int]
domain: str
service: str
def __init__(self, domain: _Optional[str] = ..., service: _Optional[str] = ...) -> None: ...
class UnregisterServiceResult(_message.Message):
__slots__ = ("ok", "removed")
OK_FIELD_NUMBER: _ClassVar[int]
REMOVED_FIELD_NUMBER: _ClassVar[int]
ok: bool
removed: bool
def __init__(self, ok: bool = ..., removed: bool = ...) -> None: ...
class FireEvent(_message.Message):
__slots__ = ("event_type", "event_data", "context_id")
EVENT_TYPE_FIELD_NUMBER: _ClassVar[int]
EVENT_DATA_FIELD_NUMBER: _ClassVar[int]
CONTEXT_ID_FIELD_NUMBER: _ClassVar[int]
event_type: str
event_data: _struct_pb2.Struct
context_id: str
def __init__(self, event_type: _Optional[str] = ..., event_data: _Optional[_Union[_struct_pb2.Struct, _Mapping]] = ..., context_id: _Optional[str] = ...) -> None: ...
class StoreLoad(_message.Message):
__slots__ = ("key",)
KEY_FIELD_NUMBER: _ClassVar[int]
key: str
def __init__(self, key: _Optional[str] = ...) -> None: ...
class StoreLoadResult(_message.Message):
__slots__ = ("data",)
DATA_FIELD_NUMBER: _ClassVar[int]
data: _struct_pb2.Struct
def __init__(self, data: _Optional[_Union[_struct_pb2.Struct, _Mapping]] = ...) -> None: ...
class StoreSave(_message.Message):
__slots__ = ("key", "data")
KEY_FIELD_NUMBER: _ClassVar[int]
DATA_FIELD_NUMBER: _ClassVar[int]
key: str
data: _struct_pb2.Struct
def __init__(self, key: _Optional[str] = ..., data: _Optional[_Union[_struct_pb2.Struct, _Mapping]] = ...) -> None: ...
class StoreSaveResult(_message.Message):
__slots__ = ("ok",)
OK_FIELD_NUMBER: _ClassVar[int]
ok: bool
def __init__(self, ok: bool = ...) -> None: ...
class StoreRemove(_message.Message):
__slots__ = ("key",)
KEY_FIELD_NUMBER: _ClassVar[int]
key: str
def __init__(self, key: _Optional[str] = ...) -> None: ...
class StoreRemoveResult(_message.Message):
__slots__ = ("ok",)
OK_FIELD_NUMBER: _ClassVar[int]
ok: bool
def __init__(self, ok: bool = ...) -> None: ...
-866
View File
@@ -1,866 +0,0 @@
"""Main-side bridge — owns the per-sandbox entity registry + outbound dispatch.
Responsibilities:
* Hold a :class:`SandboxBridge` per sandbox group. Each one knows its
:class:`Channel` plus the set of proxy entities the sandbox has
registered with it.
* Handle inbound sandboxmain calls:
- ``sandbox/register_entity`` instantiate a proxy entity, add it to
the matching :class:`EntityComponent` via
:meth:`async_register_remote_platform`, and reply with the assigned
main-side ``entity_id``.
- ``sandbox/unregister_entity`` drop the proxy.
- ``sandbox/state_changed`` push state/attributes into the cached
state of the matching proxy entity.
* Expose :meth:`SandboxBridge.async_call_service` for proxy entities to
forward action calls back to the sandbox one RPC per call. (Coalescing
same-tick calls for the same service into a single multi-entity RPC is a
possible future optimisation; the first iteration keeps it simple.)
* Translate sandbox-side exceptions back into the exception types proxy
callers would have raised locally (``vol.Invalid`` ``TypeError``,
unknown service / entity ``HomeAssistantError``).
The Store routing handlers (``sandbox/store_load`` /
``store_save`` / ``store_remove``) are backed by a per-group
:class:`_SandboxStoreServer`, writing each key to
``<config>/.storage/sandbox/<group>/<key>``.
Scope isolation is by construction each bridge owns one channel for
one group, so a sandbox can't reach another sandbox's files.
"""
from collections import OrderedDict
from collections.abc import Mapping
from dataclasses import dataclass, field
from datetime import datetime, timedelta
import logging
import os
from pathlib import Path
from typing import Any, NamedTuple
import voluptuous as vol
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import (
Context,
HomeAssistant,
ServiceCall,
SupportsResponse,
callback,
)
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import device_registry as dr, json as json_helper
from homeassistant.helpers.entity_component import DATA_INSTANCES, EntityComponent
from homeassistant.helpers.entity_platform import EntityPlatform
from homeassistant.helpers.storage import STORAGE_DIR
from homeassistant.setup import async_setup_component
from homeassistant.util import dt as dt_util, json as json_util
from homeassistant.util.file import write_utf8_file_atomic
from ._proto import sandbox_pb2 as pb
from .channel import Channel, ChannelClosedError, ChannelRemoteError
from .const import UNIQUE_ID_SEPARATOR
from .messages import dict_to_struct, listvalue_to_list, struct_to_dict
from .protocol import (
MSG_CALL_SERVICE,
MSG_ENTITY_QUERY,
MSG_FIRE_EVENT,
MSG_REGISTER_ENTITY,
MSG_REGISTER_SERVICE,
MSG_STATE_CHANGED,
MSG_STORE_LOAD,
MSG_STORE_REMOVE,
MSG_STORE_SAVE,
MSG_UNREGISTER_ENTITY,
MSG_UNREGISTER_SERVICE,
)
from .schema_bridge import reconstruct_schema
_LOGGER = logging.getLogger(__name__)
_REMOTE_PLATFORM_NAME = "sandbox"
# Lifetime of a remembered context_id → Context mapping. Only contexts main
# hands *down* to the sandbox (service calls) are cached, and the sandbox
# echoes them back within the same operation (seconds), so a 15-minute TTL is
# generous headroom while keeping the cache naturally tiny. A miss is always
# safe — it degrades to a fresh ``user_id=None`` Context — so expiry only ever
# loses attribution on a pathologically delayed echo, never correctness.
_CONTEXT_TTL = timedelta(minutes=15)
# Sanity backstop only; the TTL does the real bounding given the low volume.
_CONTEXT_CACHE_MAX = 2048
class _CachedContext(NamedTuple):
"""A remembered Context plus the instant its TTL lapses."""
context: Context
expires_at: datetime
@dataclass
class SandboxEntityDescription:
"""Snapshot of a sandbox-side entity, sent at registration time."""
entry_id: str
domain: str
sandbox_entity_id: str
unique_id: str | None = None
name: str | None = None
icon: str | None = None
has_entity_name: bool = False
entity_category: str | None = None
device_class: str | None = None
supported_features: int = 0
capabilities: dict[str, Any] = field(default_factory=dict)
initial_state: str | None = None
initial_attributes: dict[str, Any] = field(default_factory=dict)
device_info: dict[str, Any] | None = None
device_id: str | None = None
@classmethod
def from_proto(cls, msg: pb.EntityDescription) -> SandboxEntityDescription:
"""Build a description from the typed ``EntityDescription`` message.
Flattens the nested ``EntityInfo`` / ``InitialState`` sub-messages back
into the flat shape the proxy entities consume.
"""
description = msg.info.description
initial = msg.initial
device_info = (
_deserialise_device_info(msg.info.device_info)
if msg.info.HasField("device_info")
else None
)
return cls(
entry_id=msg.entry_id,
domain=msg.domain,
sandbox_entity_id=msg.sandbox_entity_id,
unique_id=msg.unique_id if msg.HasField("unique_id") else None,
name=description.name if description.HasField("name") else None,
icon=description.icon if description.HasField("icon") else None,
has_entity_name=msg.has_entity_name,
entity_category=(
description.entity_category
if description.HasField("entity_category")
else None
),
device_class=(
description.device_class
if description.HasField("device_class")
else None
),
supported_features=description.supported_features,
capabilities=struct_to_dict(initial.capabilities),
initial_state=initial.state if initial.HasField("state") else None,
initial_attributes=struct_to_dict(initial.attributes),
device_info=device_info,
)
class SandboxBridge:
"""Per-sandbox-group bridge owning entities + outbound RPC dispatch."""
def __init__(
self,
hass: HomeAssistant,
*,
group: str,
channel: Channel,
) -> None:
"""Initialise the bridge for one sandbox group's live channel."""
self.hass = hass
self.group = group
self.channel = channel
# Map sandbox-side entity_id → live proxy. Used for state-push
# routing and unregister calls.
self._entities: dict[str, Any] = {}
# Map config_entry_id → EntityPlatform we own for that (domain, entry).
# Keyed by (entry_id, domain) so different domains for the same entry
# land in their own EntityComponent slot.
self._platforms: dict[tuple[str, str], EntityPlatform] = {}
# (domain, service) pairs this bridge has mirrored onto main.
# Used to clean up on shutdown / unregister.
self._mirrored_services: set[tuple[str, str]] = set()
self._store_server = _SandboxStoreServer(hass, group)
# Context security + restoration: the sandbox only ever sends a
# context_id (a string) — it can never set parent_id / user_id on the
# wire. Main records every Context it hands *down* to the sandbox
# (service forwards, entity service calls) keyed by id; when the
# sandbox echoes that id back (state_changed / fire_event), main
# restores the original Context verbatim, so a user-initiated action's
# attribution survives the round-trip. An id main never issued (or one
# whose entry has expired) resolves to a brand-new main-owned Context
# with no fabricated parentage — main never adopts the sandbox's id
# (it is an untrusted ULID; see ``_resolve_context``). The cache is
# TTL-bounded (``_CONTEXT_TTL``) and ordered by insertion so expiry
# pruning is a cheap front-to-back walk; a miss is always safe.
self._contexts: OrderedDict[str, _CachedContext] = OrderedDict()
channel.register(MSG_REGISTER_ENTITY, self._handle_register_entity)
channel.register(MSG_UNREGISTER_ENTITY, self._handle_unregister_entity)
channel.register(MSG_STATE_CHANGED, self._handle_state_changed)
channel.register(MSG_REGISTER_SERVICE, self._handle_register_service)
channel.register(MSG_UNREGISTER_SERVICE, self._handle_unregister_service)
channel.register(MSG_FIRE_EVENT, self._handle_fire_event)
channel.register(MSG_STORE_LOAD, self._handle_store_load)
channel.register(MSG_STORE_SAVE, self._handle_store_save)
channel.register(MSG_STORE_REMOVE, self._handle_store_remove)
async def async_call_service(
self,
*,
domain: str,
service: str,
sandbox_entity_id: str,
service_data: dict[str, Any],
context: Context | None = None,
return_response: bool = False,
) -> Any:
"""Forward one entity service call to the sandbox as a single RPC.
``context`` is the main-side Context driving the entity call. It is
remembered here (before the id is reduced to a bare wire value) so that
when the sandbox echoes the same id back on a resulting state change
or event, :meth:`_resolve_context` restores the original
``parent_id`` / ``user_id`` instead of minting a fresh attribution.
One RPC per call keeps the first iteration simple. Coalescing same-tick
calls for one service into a single multi-entity RPC (so a 200-entity
area call pays one round-trip instead of 200) is a possible future
optimisation see ``docs/FOLLOWUPS.md``.
"""
self._remember_context(context)
return await self._raw_call_service(
domain=domain,
service=service,
target={"entity_id": [sandbox_entity_id]},
service_data=service_data,
context_id=context.id if context is not None else None,
return_response=return_response,
)
async def async_entity_query(
self,
*,
sandbox_entity_id: str,
method: str,
args: dict[str, Any],
context: Context | None = None,
) -> Any:
"""Forward one server-side entity query to the sandbox as a single RPC.
The companion to :meth:`async_call_service` for the query-shaped entity
APIs that have no ``SupportsResponse`` service to ride (media search,
update release notes, vacuum segments, the WS-only calendar event
edits). ``method`` names the real entity method; ``args`` are its
kwargs. Like a service call the ``context`` is remembered before its id
is reduced to a bare wire value, errors translate through the same
:func:`_translate_remote_error` / ``ChannelClosedError`` paths, and the
wrapped ``{"value": }`` return is unwrapped.
"""
self._remember_context(context)
request = pb.EntityQuery(
sandbox_entity_id=sandbox_entity_id,
method=method,
args=dict_to_struct(args),
)
if context is not None:
request.context_id = context.id
try:
result = await self.channel.call(MSG_ENTITY_QUERY, request)
except ChannelRemoteError as err:
raise _translate_remote_error(err) from err
except ChannelClosedError as err:
raise HomeAssistantError(
f"Sandbox {self.group!r} channel closed mid-query"
) from err
return struct_to_dict(result.result).get("value")
async def _raw_call_service(
self,
*,
domain: str,
service: str,
target: dict[str, Any],
service_data: dict[str, Any],
context_id: str | None,
return_response: bool,
) -> Any:
"""Send one ``sandbox/call_service`` RPC and translate errors."""
request = pb.CallService(
domain=domain,
service=service,
target=dict_to_struct(target),
service_data=dict_to_struct(service_data),
return_response=return_response,
)
if context_id is not None:
request.context_id = context_id
try:
return await self.channel.call(MSG_CALL_SERVICE, request)
except ChannelRemoteError as err:
raise _translate_remote_error(err) from err
except ChannelClosedError as err:
raise HomeAssistantError(
f"Sandbox {self.group!r} channel closed mid-call"
) from err
def _prune_contexts(self, now: datetime) -> None:
"""Drop expired entries from the front of the context cache.
The cache is kept ordered by insertion (every write moves its key to
the end), and the TTL is constant, so insertion order *is* expiry
order expired entries always cluster at the front and a single walk
that stops at the first live entry prunes everything stale.
"""
contexts = self._contexts
while contexts:
key = next(iter(contexts))
if contexts[key].expires_at > now:
break
del contexts[key]
@callback
def _remember_context(self, context: Context | None) -> None:
"""Record a Context main is handing down to the sandbox.
Keyed by its (trusted, main-issued) id so an echoed id resolves back
to the original Context, restoring ``parent_id`` / ``user_id``. The
entry lives for ``_CONTEXT_TTL``; re-recording refreshes it and moves
it to the end so the cache stays ordered by expiry. Expiry only loses
attribution on a later echo (it degrades to a fresh Context), never
correctness.
"""
if context is None:
return
now = dt_util.utcnow()
self._prune_contexts(now)
contexts = self._contexts
contexts[context.id] = _CachedContext(context, now + _CONTEXT_TTL)
contexts.move_to_end(context.id)
# TTL + low volume keep this tiny; the cap is only a sanity backstop.
while len(contexts) > _CONTEXT_CACHE_MAX:
contexts.popitem(last=False)
@callback
def _resolve_context(self, context_id: str | None) -> Context:
"""Resolve a sandbox-supplied context_id to an authoritative Context.
The sandbox can never set ``parent_id`` / ``user_id`` on the wire
main owns that. A context_id main handed down (and still remembers)
resolves back to the original Context verbatim, so a user-initiated
action's attribution survives the round-trip.
An id main never issued or whose entry has expired yields a
**brand-new** main-owned ``Context(user_id=None)``: a fresh
main-generated id, no fabricated parentage. Main never adopts the
sandbox-supplied id: context ids are ULIDs carrying an embedded
millisecond timestamp, and main cannot trust the sandbox's clock (a
crafted id could back- or forward-date the event for recorder /
logbook ordering). The sandbox string is used only as the cache
**key**, never as the resulting Context's identity. Caching the fresh
context under that key lets repeated echoes within one operation map
to the same stable Context.
"""
now = dt_util.utcnow()
self._prune_contexts(now)
if context_id is None:
return Context(user_id=None)
cached = self._contexts.get(context_id)
if cached is not None:
return cached.context
context = Context(user_id=None)
self._contexts[context_id] = _CachedContext(context, now + _CONTEXT_TTL)
self._contexts.move_to_end(context_id)
return context
async def _handle_register_entity(
self, msg: pb.EntityDescription
) -> pb.RegisterEntityResult:
description = SandboxEntityDescription.from_proto(msg)
entry = self.hass.config_entries.async_get_entry(description.entry_id)
if entry is None:
raise HomeAssistantError(
f"register_entity: unknown entry_id {description.entry_id!r}"
)
# Namespace the proxy unique_id with the source integration domain so
# two integrations in one group reusing the same unique_id don't
# collide on the shared sandbox platform_name. A None unique_id
# stays None (the entity opts out of the registry).
if description.unique_id is not None:
description.unique_id = (
f"{entry.domain}{UNIQUE_ID_SEPARATOR}{description.unique_id}"
)
# 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)
# 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,
# connections) and will reuse the same DeviceEntry.
if description.device_info is not None:
try:
device = dr.async_get(self.hass).async_get_or_create(
config_entry_id=description.entry_id,
**description.device_info,
)
except dr.DeviceInfoError as err:
raise HomeAssistantError(
f"register_entity: invalid device_info for "
f"{description.sandbox_entity_id!r}: {err}"
) from err
description.device_id = device.id
# MSG_REGISTER_ENTITY is an upsert: a re-send for an already-tracked
# entity (the client re-describes on registry/device updates) refreshes
# the existing proxy in place rather than adding a duplicate. The
# device pre-creation above already refreshed the DeviceEntry via the
# idempotent async_get_or_create.
existing = self._entities.get(description.sandbox_entity_id)
if existing is not None:
existing.sandbox_update_description(description)
return pb.RegisterEntityResult(entity_id=existing.entity_id or "")
proxy = self._build_proxy(description)
platform = self._ensure_platform(entry, description.domain)
await platform.async_add_entities([proxy])
self._entities[description.sandbox_entity_id] = proxy
return pb.RegisterEntityResult(entity_id=proxy.entity_id or "")
async def _ensure_domain_loaded(self, domain: str) -> None:
"""Make sure the domain's :class:`EntityComponent` is loaded on main."""
components = self.hass.data.get(DATA_INSTANCES, {})
if domain in components:
return
# Empty config — we never own the domain ourselves; we just want
# the EntityComponent so we can attach a proxy platform to it.
await async_setup_component(self.hass, domain, {})
async def _handle_unregister_entity(
self, msg: pb.UnregisterEntity
) -> pb.UnregisterEntityResult:
sandbox_entity_id = msg.sandbox_entity_id
proxy = self._entities.pop(sandbox_entity_id, None)
if proxy is None:
return pb.UnregisterEntityResult(ok=True)
entity_id = getattr(proxy, "entity_id", None)
if not entity_id:
return pb.UnregisterEntityResult(ok=True)
domain = entity_id.split(".", 1)[0]
component: EntityComponent[Any] | None = self.hass.data.get(
DATA_INSTANCES, {}
).get(domain)
if component is not None:
await component.async_remove_entity(entity_id)
return pb.UnregisterEntityResult(ok=True)
async def _handle_state_changed(self, msg: pb.StateChanged) -> None:
proxy = self._entities.get(msg.sandbox_entity_id)
if proxy is None:
return
state_str = msg.state if msg.HasField("state") else None
attributes = struct_to_dict(msg.attributes)
context = (
self._resolve_context(msg.context_id)
if msg.HasField("context_id")
else None
)
proxy.sandbox_apply_state(state_str, attributes, context)
async def _handle_register_service(
self, msg: pb.RegisterService
) -> pb.RegisterServiceResult:
"""Mirror a sandbox-registered service onto main's service registry.
The handler that gets installed forwards every call back over
the shared ``sandbox/call_service`` channel, so the
integration's real handler (and its real schema) runs on the
sandbox side. Exception translation reuses
:func:`_translate_remote_error`.
If a service with the same ``(domain, service)`` already exists
on main (e.g. the host ``light`` EntityComponent registered
``light.turn_on`` for our proxy entities, or another integration
already owns the slot) we skip the install the existing
handler stays in charge.
"""
domain = msg.domain.lower()
service = msg.service.lower()
supports_response = _parse_supports_response(msg.supports_response)
if self.hass.services.has_service(domain, service):
_LOGGER.debug(
"SandboxBridge[%s]: %s.%s already on main, not replacing",
self.group,
domain,
service,
)
return pb.RegisterServiceResult(ok=True, installed=False)
forwarder = _build_service_forwarder(self, domain, service, supports_response)
schema = reconstruct_schema(listvalue_to_list(msg.schema))
self.hass.services.async_register(
domain,
service,
forwarder,
schema=schema,
supports_response=supports_response,
)
self._mirrored_services.add((domain, service))
return pb.RegisterServiceResult(ok=True, installed=True)
async def _handle_unregister_service(
self, msg: pb.UnregisterService
) -> pb.UnregisterServiceResult:
domain = msg.domain.lower()
service = msg.service.lower()
key = (domain, service)
if key not in self._mirrored_services:
return pb.UnregisterServiceResult(ok=True, removed=False)
self._mirrored_services.discard(key)
if self.hass.services.has_service(domain, service):
self.hass.services.async_remove(domain, service)
return pb.UnregisterServiceResult(ok=True, removed=True)
async def _handle_store_load(self, msg: pb.StoreLoad) -> pb.StoreLoadResult:
"""Serve a sandbox-side ``Store.async_load``."""
data = await self._store_server.async_load(_validate_key(msg.key))
result = pb.StoreLoadResult()
if data is not None:
result.data.update(data)
return result
async def _handle_store_save(self, msg: pb.StoreSave) -> pb.StoreSaveResult:
"""Persist a sandbox-side ``Store.async_save`` flush."""
await self._store_server.async_save(
_validate_key(msg.key), struct_to_dict(msg.data)
)
return pb.StoreSaveResult(ok=True)
async def _handle_store_remove(self, msg: pb.StoreRemove) -> pb.StoreRemoveResult:
"""Drop the on-disk file for a sandbox-side ``Store.async_remove``."""
await self._store_server.async_remove(_validate_key(msg.key))
return pb.StoreRemoveResult(ok=True)
async def _handle_fire_event(self, msg: pb.FireEvent) -> None:
"""Re-fire a sandbox-side event on main's bus.
The sandbox tags every push with ``event_type`` + ``event_data`` and,
optionally, a ``context_id``. Main resolves that id to an authoritative
Context restoring the original attribution for an id it handed down,
or a fresh ``user_id=None`` Context otherwise. The sandbox can never
inject a ``parent_id`` / ``user_id``.
"""
event_data = struct_to_dict(msg.event_data)
context = (
self._resolve_context(msg.context_id)
if msg.HasField("context_id")
else None
)
self.hass.bus.async_fire(msg.event_type, event_data, context=context)
def _ensure_platform(self, entry: ConfigEntry, domain: str) -> EntityPlatform:
key = (entry.entry_id, domain)
existing = self._platforms.get(key)
if existing is not None:
return existing
component: EntityComponent[Any] | None = self.hass.data.get(
DATA_INSTANCES, {}
).get(domain)
if component is None:
raise HomeAssistantError(
f"register_entity: no EntityComponent for {domain!r}; the"
" host integration is not loaded"
)
platform = EntityPlatform(
hass=self.hass,
logger=_LOGGER,
domain=domain,
platform_name=_REMOTE_PLATFORM_NAME,
platform=None,
scan_interval=timedelta(seconds=0),
entity_namespace=None,
)
platform.config_entry = entry
platform.async_prepare()
component.async_register_remote_platform(entry, platform)
self._platforms[key] = platform
return platform
def _build_proxy(self, description: SandboxEntityDescription) -> Any:
from .entity import build_proxy # noqa: PLC0415 — break import cycle
return build_proxy(self, description)
async def async_unload_entry(self, entry: ConfigEntry) -> None:
"""Drop every platform and proxy this bridge added for ``entry``."""
domains = [d for (eid, d) in list(self._platforms) if eid == entry.entry_id]
for domain in domains:
platform = self._platforms.pop((entry.entry_id, domain), None)
if platform is None:
continue
await platform.async_destroy()
component: EntityComponent[Any] | None = self.hass.data.get(
DATA_INSTANCES, {}
).get(domain)
if component is not None:
# Mirror the EntityComponent.async_unload_entry side-effect.
component._platforms.pop(entry.entry_id, None) # noqa: SLF001
# Forget proxies that were owned by this entry.
survivors = {
sid: proxy
for sid, proxy in self._entities.items()
if getattr(proxy.description, "entry_id", None) != entry.entry_id
}
self._entities = survivors
_STORE_KEY_FORBIDDEN = ("/", "\\", "\x00")
def _validate_key(key: str) -> str:
"""Validate a store ``key`` from the wire.
Defends the host filesystem from a compromised sandbox: a key must
be a non-empty string with no path separators, no null bytes, and
no parent-directory hop. Anything else trips a
:class:`HomeAssistantError`, which the channel framework turns into
a remote-error frame for the sandbox.
"""
if not key:
raise HomeAssistantError("store request: missing 'key'")
if any(ch in key for ch in _STORE_KEY_FORBIDDEN):
raise HomeAssistantError(f"store request: invalid key {key!r}")
if key in {".", ".."} or key.startswith(".."):
raise HomeAssistantError(f"store request: invalid key {key!r}")
return key
class _SandboxStoreServer:
"""Per-group store backend on main.
Each :class:`SandboxBridge` owns one of these. The bridge's channel
is dedicated to one sandbox group, so scope isolation is enforced by
construction: sandbox "built-in" only ever talks to its own bridge,
which only ever reads/writes ``<config>/.storage/sandbox/built-in/``.
Cross-group access requires forging a channel, which the sandbox
cannot do.
"""
def __init__(self, hass: HomeAssistant, group: str) -> None:
"""Pin the storage directory to ``<config>/.storage/sandbox/<group>``."""
self.hass = hass
self.group = group
self._dir = Path(hass.config.path(STORAGE_DIR, "sandbox", group))
def _path_for(self, key: str) -> Path:
# ``_require_key`` has already rejected slashes / ``..`` / NUL.
return self._dir / key
async def async_load(self, key: str) -> dict[str, Any] | None:
"""Return the wrapped Store payload or ``None`` if missing."""
path = self._path_for(key)
try:
data = await self.hass.async_add_executor_job(
json_util.load_json, str(path), None
)
except HomeAssistantError as err:
_LOGGER.warning(
"Sandbox %s store_load(%s) failed: %s", self.group, key, err
)
return None
if data is None or data == {}:
return None
if not isinstance(data, dict):
_LOGGER.warning(
"Sandbox %s store_load(%s): non-dict on disk (%s)",
self.group,
key,
type(data).__name__,
)
return None
return data
async def async_save(self, key: str, data: dict[str, Any]) -> None:
"""Write the wrapped Store payload atomically."""
path = self._path_for(key)
await self.hass.async_add_executor_job(self._write_sync, path, data)
def _write_sync(self, path: Path, data: dict[str, Any]) -> None:
os.makedirs(path.parent, exist_ok=True)
mode, json_data = json_helper.prepare_save_json(data, encoder=None)
write_utf8_file_atomic(str(path), json_data, False, mode=mode)
async def async_remove(self, key: str) -> None:
"""Unlink the file backing ``key`` if it exists."""
path = self._path_for(key)
await self.hass.async_add_executor_job(self._remove_sync, path)
def _remove_sync(self, path: Path) -> None:
try:
os.unlink(path)
except FileNotFoundError:
return
_DEVICE_INFO_STR_FIELDS = (
"name",
"manufacturer",
"model",
"model_id",
"sw_version",
"hw_version",
"serial_number",
"suggested_area",
"configuration_url",
"default_name",
"default_manufacturer",
"default_model",
"translation_key",
)
def _deserialise_device_info(info: pb.DeviceInfo) -> dict[str, Any] | None:
"""Rebuild a ``DeviceInfo`` TypedDict from the typed proto.
``identifiers`` / ``connections`` come back as sets of tuples and
``via_device`` as a tuple the shapes
:func:`device_registry.async_get_or_create` validates. ``entry_type`` is
rebuilt as a :class:`DeviceEntryType` enum value.
"""
out: dict[str, Any] = {}
if info.identifiers:
out["identifiers"] = {(pair.key, pair.value) for pair in info.identifiers}
if info.connections:
out["connections"] = {(pair.key, pair.value) for pair in info.connections}
if info.HasField("via_device"):
out["via_device"] = (info.via_device.key, info.via_device.value)
if info.entry_type:
try:
out["entry_type"] = dr.DeviceEntryType(info.entry_type)
except ValueError:
_LOGGER.debug(
"register_entity: unknown entry_type %r — dropping", info.entry_type
)
for field_name in _DEVICE_INFO_STR_FIELDS:
value = getattr(info, field_name)
if value:
out[field_name] = value
return out or None
def _parse_supports_response(value: Any) -> SupportsResponse:
"""Coerce the wire ``supports_response`` field into the enum."""
if isinstance(value, SupportsResponse):
return value
if value is None:
return SupportsResponse.NONE
try:
return SupportsResponse(str(value).lower())
except ValueError:
return SupportsResponse.NONE
def _build_service_forwarder(
bridge: SandboxBridge,
domain: str,
service: str,
supports_response: SupportsResponse,
):
"""Return a callable suitable for :meth:`ServiceRegistry.async_register`.
The forwarder rebuilds the original service-call payload and ships it
back over the sandbox's shared ``sandbox/call_service`` channel.
Schema validation already ran on the way in (main's registry runs
``schema=None`` because the sandbox owns the schema); the sandbox
runs the real handler against its own entities and registry.
"""
async def _forward(call: ServiceCall) -> Any:
# Remember the real (main-issued) Context so the sandbox echoing this
# id back on a derived state/event restores it verbatim.
bridge._remember_context(call.context) # noqa: SLF001
response = await bridge._raw_call_service( # noqa: SLF001
domain=domain,
service=service,
target=_target_from_call(call),
service_data=dict(call.data),
context_id=call.context.id if call.context is not None else None,
return_response=call.return_response,
)
if supports_response is SupportsResponse.NONE:
return None
if response.HasField("response"):
return struct_to_dict(response.response.data)
return None
return _forward
def _target_from_call(call: ServiceCall) -> dict[str, Any]:
"""Extract a ``target`` dict from the (already-validated) service call."""
target: dict[str, Any] = {}
if not call.data:
return target
for key in ("entity_id", "area_id", "device_id", "floor_id", "label_id"):
value = call.data.get(key)
if value is None:
continue
target[key] = list(value) if isinstance(value, (list, tuple, set)) else value
return target
def _rebuild_invalid(data: Mapping[str, Any]) -> vol.Invalid:
"""Rebuild a single :class:`vol.Invalid` from its serialized payload."""
path = data.get("path") or None
return vol.Invalid(data.get("msg", ""), path=path)
def _translate_remote_error(err: ChannelRemoteError) -> Exception:
"""Map a sandbox-side exception class name to a sensible main-side one.
Service-handler errors come back from the sandbox as whatever
``services.async_call`` raised most often :class:`vol.Invalid`. When
the error frame carries structured ``error_data`` (set for voluptuous
errors), the original :class:`vol.Invalid` / :class:`vol.MultipleInvalid`
is rebuilt with its ``path`` intact callers on main (service/flow
framework) handle real voluptuous errors correctly. Older/edge frames
without ``error_data`` fall back to the class-name mapping. Anything we
don't have a mapping for surfaces as a plain :class:`HomeAssistantError`
with the remote message preserved.
"""
if (error_data := err.error_data) is not None:
kind = error_data.get("kind")
if kind == "invalid":
return _rebuild_invalid(error_data)
if kind == "multiple":
return vol.MultipleInvalid(
[_rebuild_invalid(child) for child in error_data.get("errors", [])]
)
name = err.error_type or ""
msg = err.error
if name in {"Invalid", "MultipleInvalid"}:
return TypeError(msg)
if name in {"ServiceNotFound", "ServiceValidationError"}:
return HomeAssistantError(msg)
if name == "HomeAssistantError":
return HomeAssistantError(msg)
return HomeAssistantError(f"sandbox error ({name or 'unknown'}): {msg}")
@callback
def async_create_bridge(
hass: HomeAssistant, *, group: str, channel: Channel
) -> SandboxBridge:
"""Public constructor used by ``__init__.async_setup``'s channel callback."""
return SandboxBridge(hass, group=group, channel=channel)
__all__ = [
"SandboxBridge",
"SandboxEntityDescription",
"async_create_bridge",
]
@@ -1,42 +0,0 @@
"""Picker catalog hook for sandbox-only custom integrations.
A custom (HACS) integration that runs in a stateless sandbox has its code
fetched at ``entry_setup`` and never lands under ``<config>/custom_components``
on the main install. The add-integration picker is built from an on-disk scan
(``loader.async_get_integration_descriptions``), so such an integration has no
picker row and no display name a discoverability gap, of which ``title`` is a
subset.
This module is the sandbox-namespaced face of the catalog hook, parallel to the
:mod:`~homeassistant.components.sandbox.sources` source resolver: HACS or any
distribution mechanism registers a provider that *enumerates* the custom
integrations it knows about, and core merges those descriptors into the picker
and the ``title`` fallback. The hook itself lives in
:mod:`homeassistant.loader` because core (not the sandbox component) consumes it;
this re-export keeps HACS's registration surface in one place.
Contract (decision (a), display-only):
* Deliberately **separate** from the source resolver. The resolver is lazy,
per-domain and security-critical (it pins ``ref`` to an exact commit sha); the
catalog is eager, enumerable and purely cosmetic. Fusing them would drag
display strings through the sha-validation path.
* ``name`` is the load-bearing field it feeds both the picker row and the
``title`` fallback. ``title_translations`` is **optional**: HACS may not have
the un-fetched tarball's ``translations/`` indexed, and absent it the picker
degrades to ``name``.
* A wrong or missing ``name`` is cosmetic, so unlike ``ref`` core does **no**
validation of catalog descriptors.
"""
from homeassistant.loader import (
SandboxCatalogProvider,
SandboxIntegrationDescriptor,
async_register_sandbox_catalog_provider,
)
__all__ = [
"SandboxCatalogProvider",
"SandboxIntegrationDescriptor",
"async_register_sandbox_catalog_provider",
]
-605
View File
@@ -1,605 +0,0 @@
"""Request/response channel between manager and sandbox runtime.
The channel is split into three layers so the wire format and the byte
transport can each be swapped without touching the concurrency-critical
dispatch core:
* :class:`Channel` the dispatch core: pending-id map, inflight
semaphore, ``register`` / ``call`` / ``push`` / ``close``. It speaks in
:class:`Frame` objects and never touches raw bytes.
* :class:`Codec` turns a :class:`Frame` into bytes and back.
:class:`~.codec_protobuf.ProtobufCodec` is the production wire (a typed
protobuf ``Frame`` envelope; the codec owns the ``type message`` registry
so this dispatch core stays codec-agnostic). :class:`JsonCodec` (one JSON
object per frame) is retained only as the channel-core test/debug wire.
* :class:`Transport` moves whole frame blobs over some byte channel.
:class:`StreamTransport` length-prefixes each frame (4-byte big-endian
length + body) over an :class:`asyncio.StreamReader` /
:class:`asyncio.StreamWriter` pair (stdio, unix socket). A future
``WebSocketTransport`` drops in via :meth:`Channel.from_transport` using
aiohttp's native binary framing.
The :class:`Frame` shape mirrors the three message kinds that cross the
wire:
* **call**: ``id`` (>0), ``type``, ``payload`` expects a reply
* **push**: ``id`` 0, ``type``, ``payload`` one-way, no reply
* **response**: ``id`` (>0), ``ok``, and either ``result`` or
``error`` / ``error_type`` / ``error_data``
The channel is symmetric: either side may call or be called on. The same
class runs in the HA Core integration and inside the sandbox subprocess
(the sandbox side lives at :mod:`hass_client.channel`; the two are kept in
sync by the protocol shape rather than a shared import the integration
must not depend on ``hass_client``).
Inbound calls and pushes are dispatched in their own tasks so a handler
that itself issues :meth:`Channel.call` does not block the reader the
reply for the nested call has to come back through the same reader. A
bounded semaphore caps how many handlers can run concurrently; the N+1th
inbound message queues at the semaphore (not at the reader) until a slot
frees up.
"""
import asyncio
from collections.abc import Awaitable, Callable, Coroutine
import contextlib
from dataclasses import dataclass, field
from enum import StrEnum
import json
import logging
import struct
from typing import Any, Protocol
import voluptuous as vol
_LOGGER = logging.getLogger(__name__)
Handler = Callable[[Any], Awaitable[Any]]
DEFAULT_MAX_INFLIGHT = 16
# Hard cap on a single frame's body. A length prefix larger than this aborts
# the channel rather than letting a compromised sandbox allocate the host to
# death (same hardening spirit as the auth key check).
MAX_FRAME_SIZE = 16 * 1024 * 1024
_LENGTH_PREFIX = struct.Struct(">I")
def _serialize_invalid(err: vol.Invalid) -> dict[str, Any]:
"""Capture a ``vol.Invalid``'s message + path so the peer can rebuild it.
Path parts may be ``vol.Marker``s or other non-JSON objects, so each
part is stringified.
"""
return {
"kind": "invalid",
"msg": err.error_message,
"path": [str(part) for part in (err.path or [])],
}
def error_data_for(err: BaseException) -> dict[str, Any] | None:
"""Structured payload that lets the peer reconstruct a voluptuous error.
``MultipleInvalid`` is a subclass of ``Invalid``, so it is checked first.
Returns ``None`` for anything that is not a voluptuous error.
"""
if isinstance(err, vol.MultipleInvalid):
return {
"kind": "multiple",
"errors": [_serialize_invalid(child) for child in err.errors],
}
if isinstance(err, vol.Invalid):
return _serialize_invalid(err)
return None
class FrameKind(StrEnum):
"""Which of the three wire shapes a :class:`Frame` carries."""
CALL = "call"
PUSH = "push"
RESPONSE = "response"
@dataclass(slots=True)
class Frame:
"""Transport/codec-neutral representation of one wire message."""
kind: FrameKind
id: int = 0
type: str = ""
payload: Any = None
ok: bool = False
result: Any = None
error: str | None = None
error_type: str | None = None
error_data: dict[str, Any] | None = field(default=None)
@classmethod
def call(cls, call_id: int, msg_type: str, payload: Any) -> Frame:
"""Build a request frame that expects a reply."""
return cls(FrameKind.CALL, id=call_id, type=msg_type, payload=payload)
@classmethod
def push(cls, msg_type: str, payload: Any) -> Frame:
"""Build a one-way push frame."""
return cls(FrameKind.PUSH, id=0, type=msg_type, payload=payload)
@classmethod
def ok_response(cls, call_id: int, result: Any, msg_type: str = "") -> Frame:
"""Build a success response frame.
``msg_type`` is carried so a stateless codec (the protobuf one) can
look up the result message class on encode + decode.
"""
return cls(
FrameKind.RESPONSE, id=call_id, type=msg_type, ok=True, result=result
)
@classmethod
def error_response(
cls,
call_id: int,
error: str,
error_type: str | None,
error_data: dict[str, Any] | None = None,
msg_type: str = "",
) -> Frame:
"""Build a failure response frame."""
return cls(
FrameKind.RESPONSE,
id=call_id,
type=msg_type,
ok=False,
error=error,
error_type=error_type,
error_data=error_data,
)
class Codec(Protocol):
"""Serialises a :class:`Frame` to bytes and back."""
def encode(self, frame: Frame) -> bytes:
"""Return the wire bytes for ``frame``."""
def decode(self, data: bytes) -> Frame:
"""Rebuild a :class:`Frame` from wire bytes."""
class JsonCodec:
"""One-JSON-object-per-frame codec.
The registry-free test/debug wire: it passes frame payloads through as
plain JSON (no ``type``-to-proto lookup), so the concurrency-critical
channel core can be exercised with synthetic message types and arbitrary
dict/int payloads. Production rides :class:`ProtobufCodec`; this stays
for the channel-core tests only.
"""
def encode(self, frame: Frame) -> bytes:
"""Encode a frame to a compact JSON object."""
message: dict[str, Any]
if frame.kind is FrameKind.CALL:
message = {"id": frame.id, "type": frame.type, "payload": frame.payload}
elif frame.kind is FrameKind.PUSH:
message = {"type": frame.type, "payload": frame.payload}
elif frame.ok:
message = {"id": frame.id, "ok": True, "result": frame.result}
else:
message = {
"id": frame.id,
"ok": False,
"error": frame.error,
"error_type": frame.error_type,
}
if frame.error_data is not None:
message["error_data"] = frame.error_data
return json.dumps(message, separators=(",", ":")).encode("utf-8")
def decode(self, data: bytes) -> Frame:
"""Decode a JSON object into a frame, inferring the kind from keys."""
message = json.loads(data)
has_id = "id" in message
has_type = "type" in message
if has_id and not has_type:
# Response to a call we sent out.
if message.get("ok"):
return Frame.ok_response(message["id"], message.get("result"))
return Frame.error_response(
message["id"],
message.get("error", "unknown error"),
message.get("error_type"),
message.get("error_data"),
)
if not has_id:
return Frame.push(message.get("type", ""), message.get("payload"))
return Frame.call(message["id"], message["type"], message.get("payload"))
class Transport(Protocol):
"""Moves whole frame blobs over some byte channel."""
async def read_frame(self) -> bytes | None:
"""Return the next frame's bytes, or ``None`` at end-of-stream."""
async def write_frame(self, data: bytes) -> None:
"""Write one frame's bytes."""
def close(self) -> None:
"""Begin closing the underlying channel."""
async def wait_closed(self) -> None:
"""Wait for the underlying channel to finish closing."""
class FrameTooLargeError(Exception):
"""A peer announced a frame larger than :data:`MAX_FRAME_SIZE`."""
class StreamTransport:
"""Length-prefixed framing over a reader/writer pair.
Each frame is a 4-byte big-endian length followed by exactly that many
body bytes. Used for stdio and unix-socket connections anywhere the
byte channel is an :class:`asyncio.StreamReader` /
:class:`asyncio.StreamWriter` pair.
"""
def __init__(
self,
reader: asyncio.StreamReader,
writer: asyncio.StreamWriter,
) -> None:
"""Wrap a reader/writer pair with length-prefixed framing."""
self._reader = reader
self._writer = writer
async def read_frame(self) -> bytes | None:
"""Read one length-prefixed frame, or ``None`` at clean EOF."""
try:
header = await self._reader.readexactly(_LENGTH_PREFIX.size)
except asyncio.IncompleteReadError:
return None
(length,) = _LENGTH_PREFIX.unpack(header)
if length > MAX_FRAME_SIZE:
raise FrameTooLargeError(
f"frame length {length} exceeds cap {MAX_FRAME_SIZE}"
)
try:
return await self._reader.readexactly(length)
except asyncio.IncompleteReadError:
return None
async def write_frame(self, data: bytes) -> None:
"""Write one length-prefixed frame and flush it."""
self._writer.write(_LENGTH_PREFIX.pack(len(data)) + data)
await self._writer.drain()
def close(self) -> None:
"""Close the writer side of the connection."""
self._writer.close()
async def wait_closed(self) -> None:
"""Wait for the writer to finish closing."""
await self._writer.wait_closed()
class ChannelClosedError(Exception):
"""Raised when an operation is attempted on a closed channel."""
class ChannelRemoteError(Exception):
"""Raised when the remote side returns an error response."""
def __init__(
self,
error: str,
error_type: str | None = None,
error_data: dict[str, Any] | None = None,
) -> None:
"""Initialise with the remote error message and exception class name.
``error_data`` carries a structured payload (set for voluptuous
errors) so the receiver can rebuild the original exception shape.
"""
super().__init__(error)
self.error = error
self.error_type = error_type
self.error_data = error_data
class Channel:
"""One bidirectional request/response channel over a transport + codec."""
def __init__(
self,
reader: asyncio.StreamReader | None = None,
writer: asyncio.StreamWriter | None = None,
*,
transport: Transport | None = None,
codec: Codec | None = None,
name: str = "channel",
max_inflight: int = DEFAULT_MAX_INFLIGHT,
) -> None:
"""Wrap a reader/writer pair (or a transport) into a channel.
The common case passes a ``reader``/``writer`` pair, framed with
:class:`StreamTransport` (length-prefixed). To run over a non-stream
transport (e.g. websockets), pass ``transport=`` instead see
:meth:`from_transport`.
``codec`` defaults to :class:`JsonCodec`. ``max_inflight`` bounds how
many handler tasks may run at once. Once the cap is reached, the read
loop keeps draining the wire but newly-spawned handlers wait on the
semaphore until a slot frees up so a misbehaving integration can't
starve the reader by fanning out unbounded inbound work.
"""
if transport is None:
if reader is None or writer is None:
raise TypeError("Channel needs a reader/writer pair or a transport")
transport = StreamTransport(reader, writer)
self._transport: Transport = transport
self._codec: Codec = codec if codec is not None else JsonCodec()
self._name = name
self._next_id = 1
self._pending: dict[int, asyncio.Future[Any]] = {}
self._handlers: dict[str, Handler] = {}
self._reader_task: asyncio.Task[None] | None = None
self._closed: bool = False
self._write_lock = asyncio.Lock()
self._inflight: set[asyncio.Task[None]] = set()
self._inflight_sem = asyncio.Semaphore(max_inflight)
@classmethod
def from_transport(
cls,
transport: Transport,
*,
codec: Codec | None = None,
name: str = "channel",
max_inflight: int = DEFAULT_MAX_INFLIGHT,
) -> Channel:
"""Build a channel over an arbitrary :class:`Transport`.
This is the seam a future ``WebSocketTransport`` drops into the
dispatch core is identical regardless of how frames reach the wire.
"""
return cls(
transport=transport, codec=codec, name=name, max_inflight=max_inflight
)
@property
def closed(self) -> bool:
"""Return True once the channel has been closed."""
return self._closed
def register(self, msg_type: str, handler: Handler) -> None:
"""Register an async handler for inbound calls of this type."""
self._handlers[msg_type] = handler
def start(self) -> None:
"""Begin reading messages off the wire."""
if self._reader_task is not None:
return
self._reader_task = asyncio.create_task(
self._read_loop(), name=f"sandbox[{self._name}]:reader"
)
async def call(
self, msg_type: str, payload: Any = None, *, timeout: float | None = None
) -> Any:
"""Send a request and await its response.
Raises :class:`ChannelClosedError` if the channel closes while the
call is in flight and :class:`ChannelRemoteError` if the remote
returns an error response.
"""
if self._closed:
raise ChannelClosedError(f"channel {self._name!r} is closed")
call_id = self._next_id
self._next_id += 1
future: asyncio.Future[Any] = asyncio.get_running_loop().create_future()
self._pending[call_id] = future
try:
await self._write(Frame.call(call_id, msg_type, payload))
if timeout is None:
return await future
return await asyncio.wait_for(future, timeout=timeout)
finally:
self._pending.pop(call_id, None)
async def push(self, msg_type: str, payload: Any = None) -> None:
"""Send a one-way push message; the remote does not reply."""
if self._closed:
raise ChannelClosedError(f"channel {self._name!r} is closed")
await self._write(Frame.push(msg_type, payload))
async def close(self) -> None:
"""Close the channel and cancel any in-flight calls."""
if self._closed:
return
self._closed = True
for future in self._pending.values():
if not future.done():
future.set_exception(
ChannelClosedError(f"channel {self._name!r} is closed")
)
self._pending.clear()
inflight = list(self._inflight)
for task in inflight:
task.cancel()
with contextlib.suppress(Exception):
self._transport.close()
with contextlib.suppress(asyncio.CancelledError):
await self._transport.wait_closed()
if self._reader_task is not None:
self._reader_task.cancel()
with contextlib.suppress(asyncio.CancelledError, Exception):
await self._reader_task
self._reader_task = None
if inflight:
await asyncio.gather(*inflight, return_exceptions=True)
async def _write(self, frame: Frame) -> None:
data = self._codec.encode(frame)
async with self._write_lock:
await self._transport.write_frame(data)
async def _read_loop(self) -> None:
try:
while True:
try:
data = await self._transport.read_frame()
except FrameTooLargeError as err:
_LOGGER.error("Channel %s: %s; aborting channel", self._name, err)
return
if data is None:
return
try:
frame = self._codec.decode(data)
except Exception: # noqa: BLE001
_LOGGER.warning(
"Channel %s: dropping undecodable frame (%d bytes)",
self._name,
len(data),
)
continue
self._dispatch(frame)
except asyncio.CancelledError:
raise
except Exception:
_LOGGER.exception("Channel %s: read loop crashed", self._name)
finally:
# Mark closed so any pending calls don't hang forever.
if not self._closed:
self._closed = True
for future in self._pending.values():
if not future.done():
future.set_exception(
ChannelClosedError(f"channel {self._name!r} stream ended")
)
self._pending.clear()
for task in list(self._inflight):
task.cancel()
def _dispatch(self, frame: Frame) -> None:
"""Route an inbound frame; non-blocking — handlers run in tasks."""
if frame.kind is FrameKind.RESPONSE:
# Response to a call we sent out — set the future inline; no I/O.
future = self._pending.get(frame.id)
if future is None or future.done():
return
if frame.ok:
future.set_result(frame.result)
else:
future.set_exception(
ChannelRemoteError(
frame.error or "unknown error",
frame.error_type,
frame.error_data,
)
)
return
handler = self._handlers.get(frame.type)
if frame.kind is FrameKind.PUSH:
# One-way push. Dispatch in a task so a slow push handler
# cannot block the reader from draining the next message.
if handler is not None:
self._spawn_handler(
self._run_push_handler(frame.type, handler, frame.payload)
)
return
if handler is None:
# No work to do — write the unknown-type error directly. Still
# spawn it so a stalled writer cannot stall the reader.
self._spawn_handler(
self._write(
Frame.error_response(
frame.id,
f"no handler for {frame.type!r}",
"ChannelUnknownType",
msg_type=frame.type,
)
)
)
return
self._spawn_handler(
self._run_call_handler(frame.id, frame.type, handler, frame.payload)
)
def _spawn_handler(self, coro: Coroutine[Any, Any, Any]) -> None:
"""Start a handler task and track it for cancellation on close."""
task = asyncio.create_task(coro, name=f"sandbox[{self._name}]:dispatch")
self._inflight.add(task)
task.add_done_callback(self._inflight.discard)
async def _run_push_handler(
self, msg_type: str, handler: Handler, payload: Any
) -> None:
"""Run a push handler under the inflight cap; swallow exceptions."""
async with self._inflight_sem:
try:
await handler(payload)
except asyncio.CancelledError:
raise
except Exception:
_LOGGER.exception(
"Channel %s: push handler for %s raised",
self._name,
msg_type,
)
async def _run_call_handler(
self,
call_id: int,
msg_type: str,
handler: Handler,
payload: Any,
) -> None:
"""Run a call handler under the inflight cap and write its reply."""
async with self._inflight_sem:
try:
result = await handler(payload)
except asyncio.CancelledError:
raise
except Exception as err: # noqa: BLE001
if self._closed:
return
frame = Frame.error_response(
call_id,
str(err) or err.__class__.__name__,
err.__class__.__name__,
error_data_for(err),
msg_type=msg_type,
)
with contextlib.suppress(Exception):
await self._write(frame)
return
if self._closed:
return
with contextlib.suppress(Exception):
await self._write(Frame.ok_response(call_id, result, msg_type))
__all__ = [
"Channel",
"ChannelClosedError",
"ChannelRemoteError",
"Codec",
"Frame",
"FrameKind",
"FrameTooLargeError",
"Handler",
"JsonCodec",
"StreamTransport",
"Transport",
"error_data_for",
]
@@ -1,76 +0,0 @@
"""Routing rules: which sandbox should host a given integration?
`classify(integration)` is a pure function from a loaded `Integration`
(manifest + on-disk shape) to a `SandboxAssignment`. It is called by the
config-flow router and by config-entry setup interception every
decision about "main vs sandbox" funnels through here.
Rule order (first match wins):
1. `integration_type == "system"` Main. System integrations are part of
the HA runtime; sandboxing them is meaningless.
2. `domain in ALWAYS_MAIN` Main. Hand-picked deny-list for integrations
the bridge cannot host correctly today (see `const.py` for the why).
3. Any platform file in `SANDBOX_INCOMPATIBLE_PLATFORMS` Main. Platform-
level deny-list for shapes the websocket bridge can't ferry yet.
4. Custom (non-built-in) integration `Sandbox("custom")`.
5. Otherwise `Sandbox("built-in")`.
The check uses `Integration.platforms_exists()` so we never have to import
the integration to classify it.
"""
from dataclasses import dataclass
from typing import Final
from homeassistant.const import BASE_PLATFORMS
from homeassistant.loader import Integration
from .const import ALWAYS_MAIN, SANDBOX_INCOMPATIBLE_PLATFORMS
GROUP_BUILT_IN: Final = "built-in"
GROUP_CUSTOM: Final = "custom"
@dataclass(frozen=True, slots=True)
class SandboxAssignment:
"""Where an integration should run.
`group is None` means "stay on main"; otherwise it's the name of the
sandbox process that should host the integration.
"""
group: str | None
@property
def is_main(self) -> bool:
"""Return True if the integration runs on main."""
return self.group is None
MAIN: Final = SandboxAssignment(group=None)
def _sandbox(group: str) -> SandboxAssignment:
return SandboxAssignment(group=group)
def classify(integration: Integration) -> SandboxAssignment:
"""Return the sandbox assignment for an integration."""
if integration.integration_type == "system":
return MAIN
if integration.domain in ALWAYS_MAIN:
return MAIN
incompatible = (
set(integration.platforms_exists(BASE_PLATFORMS))
& SANDBOX_INCOMPATIBLE_PLATFORMS
)
if incompatible:
return MAIN
if not integration.is_built_in:
return _sandbox(GROUP_CUSTOM)
return _sandbox(GROUP_BUILT_IN)
@@ -1,134 +0,0 @@
"""Protobuf :class:`~.channel.Codec` — the production wire.
Serialises a :class:`~.channel.Frame` to the protobuf ``Frame`` envelope and
back. The envelope carries ``type`` on responses too, so this stateless codec
can look up the result message class from ``frame.type`` on both encode and
decode the dispatch core never has to know about proto types (the registry
lives here, not on :meth:`Channel.register`).
Mirrored verbatim across the no-cross-import boundary (the same file lives at
``hass_client.codec_protobuf``); the relative imports resolve to each side's
own :mod:`messages` + ``_proto`` gencode.
"""
from typing import Any
from google.protobuf.message import Message
from ._proto import sandbox_pb2 as pb
from .channel import Frame, FrameKind
from .messages import REGISTRY
Registry = dict[str, tuple[type[Message], type[Message] | None]]
class ProtobufCodec:
"""Encode/decode :class:`Frame` objects as protobuf ``Frame`` envelopes."""
def __init__(self, registry: Registry | None = None) -> None:
"""Build the codec over a ``type → (request_cls, result_cls)`` map."""
self._registry = registry if registry is not None else REGISTRY
def _classes(
self, msg_type: str
) -> tuple[type[Message] | None, type[Message] | None]:
return self._registry.get(msg_type, (None, None))
def encode(self, frame: Frame) -> bytes:
"""Serialise a frame to the protobuf ``Frame`` envelope bytes."""
envelope = pb.Frame(id=frame.id, type=frame.type)
if frame.kind is FrameKind.RESPONSE:
response = envelope.response
response.ok = frame.ok
if frame.ok:
_, result_cls = self._classes(frame.type)
response.result = _serialize_body(frame.result, result_cls)
else:
_fill_error(response.error, frame)
else:
request_cls, _ = self._classes(frame.type)
envelope.request = _serialize_body(frame.payload, request_cls)
return envelope.SerializeToString()
def decode(self, data: bytes) -> Frame:
"""Rebuild a frame from protobuf ``Frame`` envelope bytes."""
envelope = pb.Frame.FromString(data)
msg_type = envelope.type
body = envelope.WhichOneof("body")
if body == "response":
response = envelope.response
if response.ok:
_, result_cls = self._classes(msg_type)
result = _parse_body(response.result, result_cls)
return Frame.ok_response(envelope.id, result, msg_type)
error, error_type, error_data = _read_error(response.error)
return Frame.error_response(
envelope.id, error, error_type, error_data, msg_type
)
request_cls, _ = self._classes(msg_type)
payload = _parse_body(envelope.request, request_cls)
if envelope.id == 0:
return Frame.push(msg_type, payload)
return Frame.call(envelope.id, msg_type, payload)
def _serialize_body(body: Any, cls: type[Message] | None) -> bytes:
"""Serialise a proto-message body; ``None`` becomes an empty message."""
if body is None:
return cls().SerializeToString() if cls is not None else b""
if isinstance(body, Message):
return body.SerializeToString()
raise TypeError(
f"ProtobufCodec expected a proto message body, got {type(body).__name__}"
)
def _parse_body(raw: bytes, cls: type[Message] | None) -> Any:
"""Deserialise a body into ``cls``; an unregistered type decodes to None."""
if cls is None:
return None
return cls.FromString(raw)
def _fill_error(error: pb.Error, frame: Frame) -> None:
"""Populate the proto ``Error`` from a failure frame.
Carries fidelity #7's structured voluptuous data: the ``multiple`` flag
distinguishes a ``MultipleInvalid`` from a single ``Invalid`` so the peer
rebuilds the right exception.
"""
error.message = frame.error or ""
error.type = frame.error_type or ""
data = frame.error_data
if not data:
return
if data.get("kind") == "multiple":
error.multiple = True
for child in data.get("errors", []):
error.invalid.add(message=child.get("msg", ""), path=child.get("path", []))
elif data.get("kind") == "invalid":
error.invalid.add(message=data.get("msg", ""), path=data.get("path", []))
def _read_error(error: pb.Error) -> tuple[str, str | None, dict[str, Any] | None]:
"""Rebuild ``(message, type, error_data)`` from the proto ``Error``."""
error_data: dict[str, Any] | None = None
if error.multiple:
error_data = {
"kind": "multiple",
"errors": [
{"kind": "invalid", "msg": item.message, "path": list(item.path)}
for item in error.invalid
],
}
elif len(error.invalid) == 1:
item = error.invalid[0]
error_data = {
"kind": "invalid",
"msg": item.message,
"path": list(item.path),
}
return error.message, (error.type or None), error_data
__all__ = ["ProtobufCodec"]
-116
View File
@@ -1,116 +0,0 @@
"""Constants for the Sandbox integration."""
from typing import TYPE_CHECKING
from homeassistant.util.hass_dict import HassKey
if TYPE_CHECKING:
from . import SandboxData
DOMAIN = "sandbox"
DATA_SANDBOX: HassKey[SandboxData] = HassKey(DOMAIN)
# Proxy entities all register under the shared ``sandbox`` platform_name,
# so the entity-registry uniqueness key ``(domain, "sandbox", unique_id)``
# would collide when two integrations in one group reuse a unique_id. The
# proxy unique_id is therefore namespaced as
# ``f"{source_domain}{UNIQUE_ID_SEPARATOR}{unique_id}"``. ``:`` is chosen
# because HA's default slug logic never produces it, so it cannot clash with
# a real unique_id segment.
UNIQUE_ID_SEPARATOR = ":"
# Platforms that the sandbox cannot host today. Any integration that ships a
# platform file in this set is forced onto `main`. Each entry needs a one-line
# "why" so the deny-list is reviewable.
#
# TODO(sandbox): revisit each entry once the protocol can carry the missing
# payload shape. Tracked in sandbox/plan.md "Risks → Deny-list rot".
SANDBOX_INCOMPATIBLE_PLATFORMS: frozenset[str] = frozenset(
{
# stt: streams audio chunks via async generator; not serializable over WS.
"stt",
# tts: returns audio bytes + streaming variants the bridge has no path for.
"tts",
# conversation: agent API exchanges live chat objects and tool callbacks.
"conversation",
# assist_satellite: bidirectional audio pipeline + wake/voice runtime state.
"assist_satellite",
# wake_word: streaming detector entities yielding bytes/audio chunks.
"wake_word",
# camera: entity surface returns image/stream bytes; needs a byte channel.
"camera",
# To-do lists: the panel reads the sync `todo_items` property (which
# also feeds `TodoListEntity.state`), so it can't be satisfied by a
# request/response query — it needs the sandbox to push the item list
# into a proxy cache. Until that subscription/push primitive lands a
# sandboxed list would render empty in the UI while looking supported,
# so route it to main. See sandbox/docs/query-shaped-rpcs.md.
"todo",
}
)
# Integrations that must always run on main, regardless of platform shape.
ALWAYS_MAIN: frozenset[str] = frozenset(
{
"script",
"automation",
"scene",
"cloud",
# ai_task's service handler resolves attachments into Attachment
# objects with Path values + temp files before the entity method
# runs. Neither bridge option intercepts at service-call level yet,
# and resolution depends on camera/image bytes (deny-listed). Folded
# into ALWAYS_MAIN — revisit when ai_task is made sandbox-aware or
# we add service-handler-level interception.
"ai_task",
# image owns the same bytes-returning entity surface camera does;
# the deny-list above catches integrations *providing* an image
# platform, but the image integration itself needs to stay on main
# so consumers (ai_task, etc.) can fetch bytes locally.
"image",
# Broad readers — read ALL entities / registries, not narrowly
# scopable, so they break under sandbox lockdown. See
# sandbox/plans/research/builtin-lockdown-breakage.md (point 1,
# decision: blanket ALWAYS_MAIN).
# template: Jinja states()/is_state() over any entity at render time.
"template",
# group: state/attrs derive entirely from foreign member entities.
"group",
# homekit: hass.states.async_all() + entity/device registries.
"homekit",
# Source-entity helpers — read a declared set of foreign entities
# (and sometimes the registries). ALWAYS_MAIN until the share-states
# consumer lands a scoped declared-source-entity allow-list.
# min_max: min/max/mean over foreign sensors.
"min_max",
# statistics: stats buffer over a foreign entity.
"statistics",
# trend: gradient of a foreign sensor.
"trend",
# threshold: compares a foreign sensor to bounds.
"threshold",
# derivative: time-derivative of a foreign sensor.
"derivative",
# integration: Riemann integral of a foreign sensor.
"integration",
# utility_meter: tracks a foreign energy sensor.
"utility_meter",
# filter: filtered passthrough of a foreign sensor.
"filter",
# mold_indicator: computes from foreign temp + humidity sensors.
"mold_indicator",
# bayesian: probability from many foreign states.
"bayesian",
# generic_thermostat: reads a foreign sensor, drives a foreign switch.
"generic_thermostat",
# generic_hygrostat: same as generic_thermostat for humidity.
"generic_hygrostat",
# switch_as_x: mirrors a foreign switch; also reads the registry.
"switch_as_x",
# history_stats: needs foreign state + recorder history.
"history_stats",
# proximity: distance of foreign trackers to a foreign zone.
"proximity",
}
)
@@ -1,313 +0,0 @@
"""Per-domain proxy entities for sandboxed integrations.
The :class:`SandboxProxyEntity` base holds the cached state and the
``async_call_service`` plumbing every proxy shares. Domain-specific
subclasses add typed properties that pull values out of the cache so
service-handler kwarg filtering (``light.filter_turn_on_params``,
``climate`` schema validation, ) and frontend rendering see the same
shape they would for a local entity.
A small "rich" set of domains ships typed proxies; the remaining
domains use the same mechanical pattern.
"""
import contextlib
from enum import IntFlag
from typing import TYPE_CHECKING, Any, NoReturn, cast
from homeassistant.const import EntityCategory
from homeassistant.core import Context
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity import Entity
from ..messages import struct_to_dict
if TYPE_CHECKING:
from ..bridge import SandboxBridge, SandboxEntityDescription
def raise_not_proxied(operation: str) -> NoReturn:
"""Raise for a query/subscribe entity API the bridge can't proxy yet.
The entity-method bridge only forwards fire-and-forget service calls. The
server-side query, subscription, and WS-only mutation APIs (calendar
listings/event edits, weather forecasts, media browsing/search, update
release notes, vacuum segments, ) need a request/response RPC that does not
exist yet. Until it lands the proxy fails loudly with a clear message
instead of silently returning empty results. See
``sandbox/docs/query-shaped-rpcs.md``.
"""
raise HomeAssistantError(f"{operation} is not yet supported for sandboxed entities")
class SandboxProxyEntity(Entity):
"""Base class for proxy entities backed by a sandboxed entity."""
_attr_should_poll = False
def __init__(
self,
bridge: SandboxBridge,
description: SandboxEntityDescription,
) -> None:
"""Initialise the proxy entity from its sandbox-side description."""
self._bridge = bridge
self.description = description
self._state_cache: dict[str, Any] = dict(description.initial_attributes)
if description.initial_state is not None:
self._state_cache["state"] = description.initial_state
self._sandbox_available: bool = True
self._attr_unique_id = description.unique_id
self._attr_has_entity_name = description.has_entity_name
if description.name:
self._attr_name = description.name
if description.icon:
self._attr_icon = description.icon
if description.entity_category:
with contextlib.suppress(ValueError):
self._attr_entity_category = EntityCategory(description.entity_category)
if description.device_class:
self._attr_device_class = description.device_class
# Domains like ``light`` index supported_features with bitwise
# ``in``; ``None`` blows up the check, so default to 0.
self._attr_supported_features = int(description.supported_features or 0)
# Surface the sandbox-side DeviceInfo so EntityPlatform's normal
# async_add_entities path runs dr.async_get_or_create and links
# the proxy to the matching DeviceEntry (idempotent with the
# pre-creation the bridge does).
if description.device_info is not None:
self._attr_device_info = cast(DeviceInfo, description.device_info)
@property
def available(self) -> bool:
"""Available iff the sandbox is reachable and the entity has state."""
if not self._sandbox_available:
return False
state = self._state_cache.get("state")
return state not in (None, "unavailable")
@property
def extra_state_attributes(self) -> dict[str, Any] | None:
"""Sandbox proxies expose attributes through typed properties.
Anything domain-specific (``brightness``, ``hvac_mode``, ) is
surfaced by the domain proxy's own ``@property`` declarations
reading from ``_state_cache``. Returning extras here would
duplicate those values in the state-machine attributes dict.
"""
return None
def sandbox_update_description(self, description: SandboxEntityDescription) -> None:
"""Refresh mirrored attributes from a re-sent registration (upsert).
The unique_id is deliberately left untouched changing it would
orphan the entity-registry row. State flows via the separate
``state_changed`` push path, so only the registration-carried
fields (name / icon / category / device_class / features /
device_info) are refreshed here.
"""
self.description = description
self._attr_has_entity_name = description.has_entity_name
self._attr_name = description.name or None
self._attr_icon = description.icon or None
if description.entity_category:
with contextlib.suppress(ValueError):
self._attr_entity_category = EntityCategory(description.entity_category)
else:
self._attr_entity_category = None
if description.device_class:
self._attr_device_class = description.device_class
# Domain subclasses store supported_features as their own IntFlag
# (light's capability_attributes does ``X in supported_features``,
# which only works on the flag). Preserve that type when refreshing.
features = int(description.supported_features or 0)
current = self._attr_supported_features
if isinstance(current, IntFlag):
self._attr_supported_features = type(current)(features)
else:
self._attr_supported_features = features
if description.device_info is not None:
self._attr_device_info = cast(DeviceInfo, description.device_info)
if self.hass is not None:
self.async_write_ha_state()
def sandbox_apply_state(
self,
state: str | None,
attributes: dict[str, Any],
context: Context | None = None,
) -> None:
"""Update the cache from a sandbox push, and notify HA.
``context`` is the main-side authoritative Context the bridge resolved
from the sandbox's ``context_id`` — the original Context for an id main
handed down, or a fresh ``user_id=None`` one otherwise, never carrying
a sandbox-supplied parent_id / user_id. When absent the entity writes
with its own context as before.
"""
self._state_cache = dict(attributes)
if state is not None:
self._state_cache["state"] = state
if self.hass is not None:
if context is not None:
self.async_set_context(context)
self.async_write_ha_state()
def sandbox_set_available(self, available: bool) -> None:
"""Toggle availability — used when the sandbox channel drops."""
if self._sandbox_available == available:
return
self._sandbox_available = available
if self.hass is not None:
self.async_write_ha_state()
async def _call_service(
self, service: str, *, return_response: bool = False, **service_data: Any
) -> Any:
"""Forward a service call to the sandbox.
Domain proxies translate each entity method into one of these
calls (the spike's Option B); the bridge sends one RPC per call.
``self._context`` is the main-side Context the service framework set
for this call. Passing it lets the bridge remember it, so a state
change the sandbox derives from this call resolves back to the
original attribution instead of a fresh context.
When ``return_response`` is set, the call forwards a
``SupportsResponse`` service (``calendar.get_events``,
``weather.get_forecasts``, ``media_player.browse_media``) and the
decoded service-response dict is returned (``{}`` when the sandbox
sent no response). Otherwise the raw ``CallServiceResult`` is returned
and ignored by command-style proxies.
"""
result = await self._bridge.async_call_service(
domain=self.description.domain,
service=service,
sandbox_entity_id=self.description.sandbox_entity_id,
service_data=service_data,
context=self._context,
return_response=return_response,
)
if not return_response:
return result
if result.HasField("response"):
return struct_to_dict(result.response.data)
return {}
async def _entity_query(self, method: str, **args: Any) -> Any:
"""Forward a server-side entity query to the sandbox.
The request/response companion to :meth:`_call_service` for the
query-shaped entity APIs that have no ``SupportsResponse`` service to
ride. ``method`` names the real entity method to invoke on the sandbox
side; ``args`` are its kwargs. Returns the deserialised return value
(``None`` for mutations). ``self._context`` is forwarded so attribution
survives exactly as it does for a service call.
"""
return await self._bridge.async_entity_query(
sandbox_entity_id=self.description.sandbox_entity_id,
method=method,
args=args,
context=self._context,
)
# Lazy import to avoid a circular dependency at module import time
# (bridge imports build_proxy → entity imports proxies → proxies import
# the domain platform; the domain platforms can import sandbox
# indirectly via helpers).
def build_proxy(
bridge: SandboxBridge, description: SandboxEntityDescription
) -> SandboxProxyEntity:
"""Return the domain-specific proxy class for ``description.domain``."""
cls = _DOMAIN_PROXIES.get(description.domain, SandboxProxyEntity)
return cls(bridge, description)
def _build_registry() -> dict[str, type[SandboxProxyEntity]]:
"""Lazy-build the domain → proxy-class map.
Importing every domain proxy eagerly at module import time would force
every domain platform module (``homeassistant.components.light``, )
to load on integration boot. Hand-rolled to avoid the import storm.
"""
from . import ( # noqa: PLC0415
alarm_control_panel,
binary_sensor,
button,
calendar,
climate,
cover,
date,
datetime,
device_tracker,
event,
fan,
humidifier,
lawn_mower,
light,
lock,
media_player,
notify,
number,
remote,
scene,
select,
sensor,
siren,
switch,
text,
time,
update,
vacuum,
valve,
water_heater,
weather,
)
return {
"alarm_control_panel": alarm_control_panel.SandboxAlarmControlPanelEntity,
"binary_sensor": binary_sensor.SandboxBinarySensorEntity,
"button": button.SandboxButtonEntity,
"calendar": calendar.SandboxCalendarEntity,
"climate": climate.SandboxClimateEntity,
"cover": cover.SandboxCoverEntity,
"date": date.SandboxDateEntity,
"datetime": datetime.SandboxDateTimeEntity,
"device_tracker": device_tracker.SandboxDeviceTrackerEntity,
"event": event.SandboxEventEntity,
"fan": fan.SandboxFanEntity,
"humidifier": humidifier.SandboxHumidifierEntity,
"lawn_mower": lawn_mower.SandboxLawnMowerEntity,
"light": light.SandboxLightEntity,
"lock": lock.SandboxLockEntity,
"media_player": media_player.SandboxMediaPlayerEntity,
"notify": notify.SandboxNotifyEntity,
"number": number.SandboxNumberEntity,
"remote": remote.SandboxRemoteEntity,
"scene": scene.SandboxSceneEntity,
"select": select.SandboxSelectEntity,
"sensor": sensor.SandboxSensorEntity,
"siren": siren.SandboxSirenEntity,
"switch": switch.SandboxSwitchEntity,
"text": text.SandboxTextEntity,
"time": time.SandboxTimeEntity,
"update": update.SandboxUpdateEntity,
"vacuum": vacuum.SandboxVacuumEntity,
"valve": valve.SandboxValveEntity,
"water_heater": water_heater.SandboxWaterHeaterEntity,
"weather": weather.SandboxWeatherEntity,
}
_DOMAIN_PROXIES: dict[str, type[SandboxProxyEntity]] = _build_registry()
__all__ = [
"SandboxProxyEntity",
"build_proxy",
"raise_not_proxied",
]
@@ -1,91 +0,0 @@
"""Sandbox proxy for ``alarm_control_panel`` entities."""
from typing import TYPE_CHECKING
from homeassistant.components.alarm_control_panel import (
AlarmControlPanelEntity,
AlarmControlPanelEntityFeature,
AlarmControlPanelState,
CodeFormat,
)
from . import SandboxProxyEntity
if TYPE_CHECKING:
from ..bridge import SandboxBridge, SandboxEntityDescription
# pylint: disable-next=home-assistant-enforce-class-module
class SandboxAlarmControlPanelEntity(SandboxProxyEntity, AlarmControlPanelEntity):
"""Proxy for an ``alarm_control_panel`` entity in a sandbox."""
def __init__(
self,
bridge: SandboxBridge,
description: SandboxEntityDescription,
) -> None:
"""Wrap ``supported_features`` as ``AlarmControlPanelEntityFeature``."""
super().__init__(bridge, description)
self._attr_supported_features = AlarmControlPanelEntityFeature(
description.supported_features or 0
)
@property
def alarm_state(self) -> AlarmControlPanelState | None:
"""Return the cached alarm state."""
value = self._state_cache.get("state")
if value is None:
return None
try:
return AlarmControlPanelState(value)
except ValueError:
return None
@property
def code_format(self) -> CodeFormat | None:
"""Return the configured code format."""
value = self.description.capabilities.get("code_format")
if value is None:
return None
try:
return CodeFormat(value)
except ValueError:
return None
@property
def changed_by(self) -> str | None:
"""Return the cached changed_by user."""
return self._state_cache.get("changed_by")
@property
def code_arm_required(self) -> bool:
"""Mirror the sandbox-side requirement flag."""
return bool(self.description.capabilities.get("code_arm_required", True))
async def async_alarm_disarm(self, code: str | None = None) -> None:
"""Forward disarm as ``alarm_control_panel.alarm_disarm``."""
await self._call_service("alarm_disarm", code=code)
async def async_alarm_arm_home(self, code: str | None = None) -> None:
"""Forward arm_home as ``alarm_control_panel.alarm_arm_home``."""
await self._call_service("alarm_arm_home", code=code)
async def async_alarm_arm_away(self, code: str | None = None) -> None:
"""Forward arm_away as ``alarm_control_panel.alarm_arm_away``."""
await self._call_service("alarm_arm_away", code=code)
async def async_alarm_arm_night(self, code: str | None = None) -> None:
"""Forward arm_night as ``alarm_control_panel.alarm_arm_night``."""
await self._call_service("alarm_arm_night", code=code)
async def async_alarm_arm_vacation(self, code: str | None = None) -> None:
"""Forward arm_vacation as ``alarm_control_panel.alarm_arm_vacation``."""
await self._call_service("alarm_arm_vacation", code=code)
async def async_alarm_trigger(self, code: str | None = None) -> None:
"""Forward trigger as ``alarm_control_panel.alarm_trigger``."""
await self._call_service("alarm_trigger", code=code)
async def async_alarm_arm_custom_bypass(self, code: str | None = None) -> None:
"""Forward arm_custom_bypass."""
await self._call_service("alarm_arm_custom_bypass", code=code)
@@ -1,19 +0,0 @@
"""Sandbox proxy for ``binary_sensor`` entities."""
from homeassistant.components.binary_sensor import BinarySensorEntity
from homeassistant.const import STATE_ON
from . import SandboxProxyEntity
# pylint: disable-next=home-assistant-enforce-class-module
class SandboxBinarySensorEntity(SandboxProxyEntity, BinarySensorEntity):
"""Proxy for a ``binary_sensor`` entity in a sandbox."""
@property
def is_on(self) -> bool | None:
"""Return whether the cached state is ``on``."""
state = self._state_cache.get("state")
if state is None:
return None
return state == STATE_ON
@@ -1,35 +0,0 @@
"""Sandbox proxy for ``button`` entities."""
from typing import Any
from homeassistant.components.button import ButtonEntity
from homeassistant.core import Context
from . import SandboxProxyEntity
# pylint: disable-next=home-assistant-enforce-class-module
class SandboxButtonEntity(SandboxProxyEntity, ButtonEntity):
"""Proxy for a ``button`` entity in a sandbox."""
def sandbox_apply_state(
self,
state: str | None,
attributes: dict[str, Any],
context: Context | None = None,
) -> None:
"""Forward sandbox state into ButtonEntity's last-pressed field.
``ButtonEntity.state`` is ``@final`` and reads the name-mangled
``__last_pressed_isoformat`` attribute. Setting the cache alone
wouldn't surface as the state on main, so we update the private
field directly before the framework recomputes state.
"""
if state is not None:
# pylint: disable-next=attribute-defined-outside-init
self._ButtonEntity__last_pressed_isoformat = state
super().sandbox_apply_state(state, attributes, context)
async def async_press(self) -> None:
"""Forward press as a ``button.press`` service call."""
await self._call_service("press")
@@ -1,116 +0,0 @@
"""Sandbox proxy for ``calendar`` entities."""
import datetime
from typing import Any
from homeassistant.components.calendar import CalendarEntity, CalendarEvent
from . import SandboxProxyEntity
def _parse_calendar_date(value: Any) -> datetime.date | datetime.datetime | Any:
"""Parse an ISO date/datetime string back into a date or datetime.
The ``calendar.get_events`` service serialises every event date through
``CalendarEvent.as_dict``'s factory, which emits ``isoformat()`` strings.
All-day events carry a bare ``YYYY-MM-DD`` (a ``date``); timed events carry
a full timestamp (a ``datetime``). ``CalendarEvent`` keys its all-day check
off the start being a plain ``date``, so the two must rebuild distinctly.
"""
if isinstance(value, str):
if "T" in value:
return datetime.datetime.fromisoformat(value)
return datetime.date.fromisoformat(value)
return value
def _calendar_event_from_dict(data: dict[str, Any]) -> CalendarEvent:
"""Rebuild a :class:`CalendarEvent` from a ``get_events`` response entry.
``CalendarEvent`` is a dataclass whose ``as_dict`` shape uses the field
names directly, so fields map across explicitly (no ``**data`` splat the
response also carries the derived ``all_day`` key the constructor rejects).
``get_events`` only returns start/end/summary/description/location; the
uid/recurrence_id/rrule keys are read defensively in case a richer payload
arrives.
"""
return CalendarEvent(
start=_parse_calendar_date(data["start"]),
end=_parse_calendar_date(data["end"]),
summary=data["summary"],
description=data.get("description"),
location=data.get("location"),
uid=data.get("uid"),
recurrence_id=data.get("recurrence_id"),
rrule=data.get("rrule"),
)
# pylint: disable-next=home-assistant-enforce-class-module
class SandboxCalendarEntity(SandboxProxyEntity, CalendarEntity):
"""Proxy for a ``calendar`` entity in a sandbox.
``create_event`` forwards through the standard ``calendar.create_event``
service. The listing query (``async_get_events``) rides the
``calendar.get_events`` ``SupportsResponse`` service; the WS-only event
edits (``calendar/event/update`` / ``delete``) cross via the generic
``EntityQuery`` RPC. The recurrence-timer subscription
(``calendar/event/subscribe``) is deferred the next/current event is not
pushed, so ``event`` returns ``None``. See
``sandbox/docs/query-shaped-rpcs.md``.
"""
@property
def event(self) -> CalendarEvent | None:
"""Return ``None`` — the next-event listing is not proxied yet."""
return None
async def async_get_events(
self, hass: Any, start_date: Any, end_date: Any
) -> list[CalendarEvent]:
"""Forward the listing query as the ``calendar.get_events`` service."""
response = await self._call_service(
"get_events",
return_response=True,
start_date_time=start_date.isoformat(),
end_date_time=end_date.isoformat(),
)
entity_response = response.get(self.description.sandbox_entity_id, {})
return [
_calendar_event_from_dict(event)
for event in entity_response.get("events", [])
]
async def async_create_event(self, **kwargs: Any) -> None:
"""Forward create as ``calendar.create_event``."""
await self._call_service("create_event", **kwargs)
async def async_update_event(
self,
uid: str,
event: dict[str, Any],
recurrence_id: str | None = None,
recurrence_range: str | None = None,
) -> None:
"""Forward the WS-only event update through ``EntityQuery``."""
await self._entity_query(
"async_update_event",
uid=uid,
event=event,
recurrence_id=recurrence_id,
recurrence_range=recurrence_range,
)
async def async_delete_event(
self,
uid: str,
recurrence_id: str | None = None,
recurrence_range: str | None = None,
) -> None:
"""Forward the WS-only event delete through ``EntityQuery``."""
await self._entity_query(
"async_delete_event",
uid=uid,
recurrence_id=recurrence_id,
recurrence_range=recurrence_range,
)
@@ -1,239 +0,0 @@
"""Sandbox proxy for ``climate`` entities."""
from typing import TYPE_CHECKING, Any
from homeassistant.components.climate import (
ATTR_CURRENT_HUMIDITY,
ATTR_CURRENT_TEMPERATURE,
ATTR_FAN_MODE,
ATTR_FAN_MODES,
ATTR_HUMIDITY,
ATTR_HVAC_ACTION,
ATTR_HVAC_MODES,
ATTR_MAX_HUMIDITY,
ATTR_MAX_TEMP,
ATTR_MIN_HUMIDITY,
ATTR_MIN_TEMP,
ATTR_PRESET_MODE,
ATTR_PRESET_MODES,
ATTR_SWING_HORIZONTAL_MODE,
ATTR_SWING_HORIZONTAL_MODES,
ATTR_SWING_MODE,
ATTR_SWING_MODES,
ATTR_TARGET_TEMP_HIGH,
ATTR_TARGET_TEMP_LOW,
ATTR_TARGET_TEMP_STEP,
ATTR_TEMPERATURE,
ClimateEntity,
ClimateEntityFeature,
HVACAction,
HVACMode,
)
from . import SandboxProxyEntity
if TYPE_CHECKING:
from ..bridge import SandboxBridge, SandboxEntityDescription
# pylint: disable-next=home-assistant-enforce-class-module
class SandboxClimateEntity(SandboxProxyEntity, ClimateEntity):
"""Proxy for a ``climate`` entity in a sandbox."""
def __init__(
self,
bridge: SandboxBridge,
description: SandboxEntityDescription,
) -> None:
"""Wrap ``supported_features`` as ``ClimateEntityFeature``."""
super().__init__(bridge, description)
self._attr_supported_features = ClimateEntityFeature(
description.supported_features or 0
)
@property
def temperature_unit(self) -> str:
"""Return the unit declared by the sandbox-side entity."""
from homeassistant.const import UnitOfTemperature # noqa: PLC0415
return str(
self.description.capabilities.get(
"temperature_unit", UnitOfTemperature.CELSIUS
)
)
@property
def hvac_mode(self) -> HVACMode | None:
"""Return the cached HVAC mode."""
value = self._state_cache.get("state")
if value is None or value == "unavailable":
return None
try:
return HVACMode(value)
except ValueError:
return None
@property
def hvac_modes(self) -> list[HVACMode]:
"""Return advertised HVAC modes."""
modes = self.description.capabilities.get(ATTR_HVAC_MODES) or []
return [HVACMode(m) for m in modes if m in HVACMode._value2member_map_]
@property
def hvac_action(self) -> HVACAction | None:
"""Return the cached current HVAC action."""
value = self._state_cache.get(ATTR_HVAC_ACTION)
if value is None:
return None
try:
return HVACAction(value)
except ValueError:
return None
@property
def current_temperature(self) -> float | None:
"""Return the cached current temperature."""
value = self._state_cache.get(ATTR_CURRENT_TEMPERATURE)
return None if value is None else float(value)
@property
def target_temperature(self) -> float | None:
"""Return the cached target temperature."""
value = self._state_cache.get(ATTR_TEMPERATURE)
return None if value is None else float(value)
@property
def target_temperature_high(self) -> float | None:
"""Return the cached high target temperature."""
value = self._state_cache.get(ATTR_TARGET_TEMP_HIGH)
return None if value is None else float(value)
@property
def target_temperature_low(self) -> float | None:
"""Return the cached low target temperature."""
value = self._state_cache.get(ATTR_TARGET_TEMP_LOW)
return None if value is None else float(value)
@property
def target_temperature_step(self) -> float | None:
"""Return the cached target temperature step."""
value = self._state_cache.get(ATTR_TARGET_TEMP_STEP)
return None if value is None else float(value)
@property
def current_humidity(self) -> float | None:
"""Return the cached current humidity."""
value = self._state_cache.get(ATTR_CURRENT_HUMIDITY)
return None if value is None else float(value)
@property
def target_humidity(self) -> float | None:
"""Return the cached target humidity."""
value = self._state_cache.get(ATTR_HUMIDITY)
return None if value is None else float(value)
@property
def fan_mode(self) -> str | None:
"""Return the cached fan mode."""
return self._state_cache.get(ATTR_FAN_MODE)
@property
def fan_modes(self) -> list[str] | None:
"""Return advertised fan modes."""
return self.description.capabilities.get(ATTR_FAN_MODES)
@property
def swing_mode(self) -> str | None:
"""Return the cached swing mode."""
return self._state_cache.get(ATTR_SWING_MODE)
@property
def swing_modes(self) -> list[str] | None:
"""Return advertised swing modes."""
return self.description.capabilities.get(ATTR_SWING_MODES)
@property
def swing_horizontal_mode(self) -> str | None:
"""Return the cached horizontal swing mode."""
return self._state_cache.get(ATTR_SWING_HORIZONTAL_MODE)
@property
def swing_horizontal_modes(self) -> list[str] | None:
"""Return advertised horizontal swing modes."""
return self.description.capabilities.get(ATTR_SWING_HORIZONTAL_MODES)
@property
def preset_mode(self) -> str | None:
"""Return the cached preset mode."""
return self._state_cache.get(ATTR_PRESET_MODE)
@property
def preset_modes(self) -> list[str] | None:
"""Return advertised preset modes."""
return self.description.capabilities.get(ATTR_PRESET_MODES)
@property
def min_temp(self) -> float:
"""Return the cached minimum temperature."""
value = self.description.capabilities.get(ATTR_MIN_TEMP)
return float(value) if value is not None else super().min_temp
@property
def max_temp(self) -> float:
"""Return the cached maximum temperature."""
value = self.description.capabilities.get(ATTR_MAX_TEMP)
return float(value) if value is not None else super().max_temp
@property
def min_humidity(self) -> float:
"""Return the cached minimum humidity."""
value = self.description.capabilities.get(ATTR_MIN_HUMIDITY)
return float(value) if value is not None else super().min_humidity
@property
def max_humidity(self) -> float:
"""Return the cached maximum humidity."""
value = self.description.capabilities.get(ATTR_MAX_HUMIDITY)
return float(value) if value is not None else super().max_humidity
async def async_set_temperature(self, **kwargs: Any) -> None:
"""Forward set_temperature."""
await self._call_service("set_temperature", **kwargs)
async def async_set_humidity(self, humidity: int) -> None:
"""Forward set_humidity."""
await self._call_service("set_humidity", humidity=humidity)
async def async_set_fan_mode(self, fan_mode: str) -> None:
"""Forward set_fan_mode."""
await self._call_service("set_fan_mode", fan_mode=fan_mode)
async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
"""Forward set_hvac_mode."""
await self._call_service("set_hvac_mode", hvac_mode=hvac_mode)
async def async_set_swing_mode(self, swing_mode: str) -> None:
"""Forward set_swing_mode."""
await self._call_service("set_swing_mode", swing_mode=swing_mode)
async def async_set_swing_horizontal_mode(self, swing_horizontal_mode: str) -> None:
"""Forward set_swing_horizontal_mode."""
await self._call_service(
"set_swing_horizontal_mode", swing_horizontal_mode=swing_horizontal_mode
)
async def async_set_preset_mode(self, preset_mode: str) -> None:
"""Forward set_preset_mode."""
await self._call_service("set_preset_mode", preset_mode=preset_mode)
async def async_turn_on(self) -> None:
"""Forward turn_on."""
await self._call_service("turn_on")
async def async_turn_off(self) -> None:
"""Forward turn_off."""
await self._call_service("turn_off")
async def async_toggle(self) -> None:
"""Forward toggle."""
await self._call_service("toggle")
@@ -1,99 +0,0 @@
"""Sandbox proxy for ``cover`` entities."""
from typing import TYPE_CHECKING, Any
from homeassistant.components.cover import (
ATTR_CURRENT_POSITION,
ATTR_CURRENT_TILT_POSITION,
ATTR_IS_CLOSED,
CoverEntity,
CoverEntityFeature,
CoverState,
)
from . import SandboxProxyEntity
if TYPE_CHECKING:
from ..bridge import SandboxBridge, SandboxEntityDescription
# pylint: disable-next=home-assistant-enforce-class-module
class SandboxCoverEntity(SandboxProxyEntity, CoverEntity):
"""Proxy for a ``cover`` entity in a sandbox."""
def __init__(
self,
bridge: SandboxBridge,
description: SandboxEntityDescription,
) -> None:
"""Wrap ``supported_features`` as ``CoverEntityFeature``."""
super().__init__(bridge, description)
self._attr_supported_features = CoverEntityFeature(
description.supported_features or 0
)
@property
def is_opening(self) -> bool | None:
"""True iff the cached state is ``opening``."""
return self._state_cache.get("state") == CoverState.OPENING
@property
def is_closing(self) -> bool | None:
"""True iff the cached state is ``closing``."""
return self._state_cache.get("state") == CoverState.CLOSING
@property
def is_closed(self) -> bool | None:
"""Derive closed from cached state / ATTR_IS_CLOSED."""
if (value := self._state_cache.get(ATTR_IS_CLOSED)) is not None:
return bool(value)
state = self._state_cache.get("state")
if state == CoverState.CLOSED:
return True
if state in (CoverState.OPEN, CoverState.OPENING, CoverState.CLOSING):
return False
return None
@property
def current_cover_position(self) -> int | None:
"""Return the cached current position."""
value = self._state_cache.get(ATTR_CURRENT_POSITION)
return None if value is None else int(value)
@property
def current_cover_tilt_position(self) -> int | None:
"""Return the cached current tilt position."""
value = self._state_cache.get(ATTR_CURRENT_TILT_POSITION)
return None if value is None else int(value)
async def async_open_cover(self, **kwargs: Any) -> None:
"""Forward open_cover."""
await self._call_service("open_cover", **kwargs)
async def async_close_cover(self, **kwargs: Any) -> None:
"""Forward close_cover."""
await self._call_service("close_cover", **kwargs)
async def async_set_cover_position(self, **kwargs: Any) -> None:
"""Forward set_cover_position."""
await self._call_service("set_cover_position", **kwargs)
async def async_stop_cover(self, **kwargs: Any) -> None:
"""Forward stop_cover."""
await self._call_service("stop_cover", **kwargs)
async def async_open_cover_tilt(self, **kwargs: Any) -> None:
"""Forward open_cover_tilt."""
await self._call_service("open_cover_tilt", **kwargs)
async def async_close_cover_tilt(self, **kwargs: Any) -> None:
"""Forward close_cover_tilt."""
await self._call_service("close_cover_tilt", **kwargs)
async def async_set_cover_tilt_position(self, **kwargs: Any) -> None:
"""Forward set_cover_tilt_position."""
await self._call_service("set_cover_tilt_position", **kwargs)
async def async_stop_cover_tilt(self, **kwargs: Any) -> None:
"""Forward stop_cover_tilt."""
await self._call_service("stop_cover_tilt", **kwargs)
@@ -1,28 +0,0 @@
"""Sandbox proxy for ``date`` entities."""
from datetime import date
from homeassistant.components.date import DateEntity
from homeassistant.util import dt as dt_util
from . import SandboxProxyEntity
# pylint: disable-next=home-assistant-enforce-class-module
class SandboxDateEntity(SandboxProxyEntity, DateEntity):
"""Proxy for a ``date`` entity in a sandbox."""
@property
def native_value(self) -> date | None:
"""Parse the cached ISO date string."""
value = self._state_cache.get("state")
if not isinstance(value, str) or value in ("unavailable", "unknown"):
return None
try:
return dt_util.parse_date(value)
except TypeError, ValueError:
return None
async def async_set_value(self, value: date) -> None:
"""Forward set_value as ``date.set_value``."""
await self._call_service("set_value", date=value.isoformat())
@@ -1,28 +0,0 @@
"""Sandbox proxy for ``datetime`` entities."""
from datetime import datetime
from homeassistant.components.datetime import DateTimeEntity
from homeassistant.util import dt as dt_util
from . import SandboxProxyEntity
# pylint: disable-next=home-assistant-enforce-class-module
class SandboxDateTimeEntity(SandboxProxyEntity, DateTimeEntity):
"""Proxy for a ``datetime`` entity in a sandbox."""
@property
def native_value(self) -> datetime | None:
"""Parse the cached ISO datetime string."""
value = self._state_cache.get("state")
if not isinstance(value, str) or value in ("unavailable", "unknown"):
return None
try:
return dt_util.parse_datetime(value)
except TypeError, ValueError:
return None
async def async_set_value(self, value: datetime) -> None:
"""Forward set_value as ``datetime.set_value``."""
await self._call_service("set_value", datetime=value.isoformat())
@@ -1,38 +0,0 @@
"""Sandbox proxy for ``device_tracker`` entities."""
from homeassistant.components.device_tracker import (
ATTR_SOURCE_TYPE,
BaseTrackerEntity,
SourceType,
)
from . import SandboxProxyEntity
# pylint: disable-next=home-assistant-enforce-class-module
class SandboxDeviceTrackerEntity(SandboxProxyEntity, BaseTrackerEntity):
"""Proxy for a ``device_tracker`` entity in a sandbox.
Subclasses the abstract :class:`BaseTrackerEntity` so we can override
both ``state`` and ``state_attributes`` (the GPS-specific
:class:`TrackerEntity` marks ``state_attributes`` ``@final``).
"""
@property
def state(self) -> str | None:
"""Mirror the sandbox-side state directly."""
return self._state_cache.get("state")
@property
def source_type(self) -> SourceType:
"""Return the cached source_type (gps / router / bluetooth / …)."""
value = self._state_cache.get(
ATTR_SOURCE_TYPE,
self.description.capabilities.get(ATTR_SOURCE_TYPE),
)
if value is None:
return SourceType.ROUTER
try:
return SourceType(value)
except ValueError:
return SourceType.ROUTER
@@ -1,44 +0,0 @@
"""Sandbox proxy for ``event`` entities."""
from typing import Any
from homeassistant.components.event import ATTR_EVENT_TYPE, EventEntity
from homeassistant.core import Context
from homeassistant.util import dt as dt_util
from . import SandboxProxyEntity
# pylint: disable-next=home-assistant-enforce-class-module
class SandboxEventEntity(SandboxProxyEntity, EventEntity):
"""Proxy for an ``event`` entity in a sandbox.
``EventEntity`` marks ``state`` and ``state_attributes`` ``@final``,
so we set the name-mangled fields directly in
:meth:`sandbox_apply_state` and let the framework recompute the
state through the existing getters.
"""
@property
def event_types(self) -> list[str]:
"""Surface the cached list of event types."""
return list(self.description.capabilities.get("event_types") or [])
def sandbox_apply_state(
self,
state: str | None,
attributes: dict[str, Any],
context: Context | None = None,
) -> None:
"""Replay the sandbox-side event into the EventEntity fields."""
# pylint: disable=attribute-defined-outside-init
if state is None or state in ("unavailable", "unknown"):
self._EventEntity__last_event_triggered = None
self._EventEntity__last_event_type = None
self._EventEntity__last_event_attributes = None
else:
self._EventEntity__last_event_triggered = dt_util.parse_datetime(state)
event_attrs = dict(attributes)
self._EventEntity__last_event_type = event_attrs.pop(ATTR_EVENT_TYPE, None)
self._EventEntity__last_event_attributes = event_attrs or None
super().sandbox_apply_state(state, attributes, context)
@@ -1,105 +0,0 @@
"""Sandbox proxy for ``fan`` entities."""
from typing import TYPE_CHECKING, Any
from homeassistant.components.fan import (
ATTR_DIRECTION,
ATTR_OSCILLATING,
ATTR_PERCENTAGE,
ATTR_PRESET_MODE,
ATTR_PRESET_MODES,
FanEntity,
FanEntityFeature,
)
from homeassistant.const import STATE_ON
from . import SandboxProxyEntity
if TYPE_CHECKING:
from ..bridge import SandboxBridge, SandboxEntityDescription
# pylint: disable-next=home-assistant-enforce-class-module
class SandboxFanEntity(SandboxProxyEntity, FanEntity):
"""Proxy for a ``fan`` entity in a sandbox."""
def __init__(
self,
bridge: SandboxBridge,
description: SandboxEntityDescription,
) -> None:
"""Wrap ``supported_features`` as ``FanEntityFeature``."""
super().__init__(bridge, description)
self._attr_supported_features = FanEntityFeature(
description.supported_features or 0
)
@property
def is_on(self) -> bool | None:
"""Return whether the cached state is ``on``."""
state = self._state_cache.get("state")
if state is None:
return None
return state == STATE_ON
@property
def percentage(self) -> int | None:
"""Return the cached fan percentage."""
value = self._state_cache.get(ATTR_PERCENTAGE)
return None if value is None else int(value)
@property
def current_direction(self) -> str | None:
"""Return the cached direction."""
return self._state_cache.get(ATTR_DIRECTION)
@property
def oscillating(self) -> bool | None:
"""Return the cached oscillation state."""
value = self._state_cache.get(ATTR_OSCILLATING)
return None if value is None else bool(value)
@property
def preset_mode(self) -> str | None:
"""Return the cached preset mode."""
return self._state_cache.get(ATTR_PRESET_MODE)
@property
def preset_modes(self) -> list[str] | None:
"""Return the configured preset modes."""
modes = self.description.capabilities.get(ATTR_PRESET_MODES)
return list(modes) if modes else None
async def async_turn_on(
self,
percentage: int | None = None,
preset_mode: str | None = None,
**kwargs: Any,
) -> None:
"""Forward turn_on."""
payload: dict[str, Any] = dict(kwargs)
if percentage is not None:
payload[ATTR_PERCENTAGE] = percentage
if preset_mode is not None:
payload[ATTR_PRESET_MODE] = preset_mode
await self._call_service("turn_on", **payload)
async def async_turn_off(self, **kwargs: Any) -> None:
"""Forward turn_off."""
await self._call_service("turn_off", **kwargs)
async def async_set_percentage(self, percentage: int) -> None:
"""Forward set_percentage."""
await self._call_service("set_percentage", percentage=percentage)
async def async_set_preset_mode(self, preset_mode: str) -> None:
"""Forward set_preset_mode."""
await self._call_service("set_preset_mode", preset_mode=preset_mode)
async def async_set_direction(self, direction: str) -> None:
"""Forward set_direction."""
await self._call_service("set_direction", direction=direction)
async def async_oscillate(self, oscillating: bool) -> None:
"""Forward oscillate."""
await self._call_service("oscillate", oscillating=oscillating)
@@ -1,108 +0,0 @@
"""Sandbox proxy for ``humidifier`` entities."""
from typing import TYPE_CHECKING
from homeassistant.components.humidifier import (
ATTR_ACTION,
ATTR_AVAILABLE_MODES,
ATTR_CURRENT_HUMIDITY,
ATTR_HUMIDITY,
ATTR_MAX_HUMIDITY,
ATTR_MIN_HUMIDITY,
ATTR_MODE,
HumidifierAction,
HumidifierEntity,
HumidifierEntityFeature,
)
from homeassistant.const import STATE_ON
from . import SandboxProxyEntity
if TYPE_CHECKING:
from ..bridge import SandboxBridge, SandboxEntityDescription
# pylint: disable-next=home-assistant-enforce-class-module
class SandboxHumidifierEntity(SandboxProxyEntity, HumidifierEntity):
"""Proxy for a ``humidifier`` entity in a sandbox."""
def __init__(
self,
bridge: SandboxBridge,
description: SandboxEntityDescription,
) -> None:
"""Wrap ``supported_features`` as ``HumidifierEntityFeature``."""
super().__init__(bridge, description)
self._attr_supported_features = HumidifierEntityFeature(
description.supported_features or 0
)
@property
def is_on(self) -> bool | None:
"""Return whether the cached state is ``on``."""
state = self._state_cache.get("state")
if state is None:
return None
return state == STATE_ON
@property
def action(self) -> HumidifierAction | None:
"""Return the cached current action."""
value = self._state_cache.get(ATTR_ACTION)
if value is None:
return None
try:
return HumidifierAction(value)
except ValueError:
return None
@property
def current_humidity(self) -> float | None:
"""Return the cached current humidity."""
value = self._state_cache.get(ATTR_CURRENT_HUMIDITY)
return None if value is None else float(value)
@property
def target_humidity(self) -> float | None:
"""Return the cached target humidity."""
value = self._state_cache.get(ATTR_HUMIDITY)
return None if value is None else float(value)
@property
def mode(self) -> str | None:
"""Return the cached mode."""
return self._state_cache.get(ATTR_MODE)
@property
def available_modes(self) -> list[str] | None:
"""Return the configured available modes."""
modes = self.description.capabilities.get(ATTR_AVAILABLE_MODES)
return list(modes) if modes else None
@property
def min_humidity(self) -> float:
"""Return the configured minimum humidity."""
value = self.description.capabilities.get(ATTR_MIN_HUMIDITY)
return float(value) if value is not None else super().min_humidity
@property
def max_humidity(self) -> float:
"""Return the configured maximum humidity."""
value = self.description.capabilities.get(ATTR_MAX_HUMIDITY)
return float(value) if value is not None else super().max_humidity
async def async_turn_on(self, **kwargs: object) -> None:
"""Forward turn_on."""
await self._call_service("turn_on")
async def async_turn_off(self, **kwargs: object) -> None:
"""Forward turn_off."""
await self._call_service("turn_off")
async def async_set_humidity(self, humidity: int) -> None:
"""Forward set_humidity."""
await self._call_service("set_humidity", humidity=humidity)
async def async_set_mode(self, mode: str) -> None:
"""Forward set_mode."""
await self._call_service("set_mode", mode=mode)
@@ -1,53 +0,0 @@
"""Sandbox proxy for ``lawn_mower`` entities."""
from typing import TYPE_CHECKING
from homeassistant.components.lawn_mower import (
LawnMowerActivity,
LawnMowerEntity,
LawnMowerEntityFeature,
)
from . import SandboxProxyEntity
if TYPE_CHECKING:
from ..bridge import SandboxBridge, SandboxEntityDescription
# pylint: disable-next=home-assistant-enforce-class-module
class SandboxLawnMowerEntity(SandboxProxyEntity, LawnMowerEntity):
"""Proxy for a ``lawn_mower`` entity in a sandbox."""
def __init__(
self,
bridge: SandboxBridge,
description: SandboxEntityDescription,
) -> None:
"""Wrap ``supported_features`` as ``LawnMowerEntityFeature``."""
super().__init__(bridge, description)
self._attr_supported_features = LawnMowerEntityFeature(
description.supported_features or 0
)
@property
def activity(self) -> LawnMowerActivity | None:
"""Return the cached mowing activity."""
value = self._state_cache.get("state")
if value is None or value == "unavailable":
return None
try:
return LawnMowerActivity(value)
except ValueError:
return None
async def async_start_mowing(self) -> None:
"""Forward start_mowing."""
await self._call_service("start_mowing")
async def async_dock(self) -> None:
"""Forward dock."""
await self._call_service("dock")
async def async_pause(self) -> None:
"""Forward pause."""
await self._call_service("pause")
@@ -1,141 +0,0 @@
"""Sandbox proxy for ``light`` entities."""
from typing import TYPE_CHECKING, Any
from homeassistant.components.light import (
ATTR_BRIGHTNESS,
ATTR_COLOR_MODE,
ATTR_COLOR_TEMP_KELVIN,
ATTR_EFFECT,
ATTR_EFFECT_LIST,
ATTR_HS_COLOR,
ATTR_MAX_COLOR_TEMP_KELVIN,
ATTR_MIN_COLOR_TEMP_KELVIN,
ATTR_RGB_COLOR,
ATTR_RGBW_COLOR,
ATTR_RGBWW_COLOR,
ATTR_SUPPORTED_COLOR_MODES,
ATTR_XY_COLOR,
ColorMode,
LightEntity,
LightEntityFeature,
)
from homeassistant.const import STATE_ON
from . import SandboxProxyEntity
if TYPE_CHECKING:
from ..bridge import SandboxBridge, SandboxEntityDescription
# pylint: disable-next=home-assistant-enforce-class-module
class SandboxLightEntity(SandboxProxyEntity, LightEntity):
"""Proxy for a ``light`` entity in a sandbox."""
def __init__(
self,
bridge: SandboxBridge,
description: SandboxEntityDescription,
) -> None:
"""Initialise the proxy with ``supported_features`` as a LightEntityFeature."""
super().__init__(bridge, description)
# ``light``'s capability_attributes does ``X in supported_features``,
# which only works on the IntFlag. The base class stores the int.
self._attr_supported_features = LightEntityFeature(
description.supported_features or 0
)
@property
def is_on(self) -> bool | None:
"""Return whether the cached state is ``on``."""
state = self._state_cache.get("state")
if state is None:
return None
return state == STATE_ON
@property
def brightness(self) -> int | None:
"""Return the cached brightness."""
value = self._state_cache.get(ATTR_BRIGHTNESS)
return None if value is None else int(value)
@property
def color_mode(self) -> ColorMode | None:
"""Return the cached color mode."""
value = self._state_cache.get(ATTR_COLOR_MODE)
if value is None:
return None
return ColorMode(value)
@property
def hs_color(self) -> tuple[float, float] | None:
"""Return the cached hs color."""
val = self._state_cache.get(ATTR_HS_COLOR)
return tuple(val) if val else None
@property
def rgb_color(self) -> tuple[int, int, int] | None:
"""Return the cached rgb color."""
val = self._state_cache.get(ATTR_RGB_COLOR)
return tuple(val) if val else None
@property
def rgbw_color(self) -> tuple[int, int, int, int] | None:
"""Return the cached rgbw color."""
val = self._state_cache.get(ATTR_RGBW_COLOR)
return tuple(val) if val else None
@property
def rgbww_color(self) -> tuple[int, int, int, int, int] | None:
"""Return the cached rgbww color."""
val = self._state_cache.get(ATTR_RGBWW_COLOR)
return tuple(val) if val else None
@property
def xy_color(self) -> tuple[float, float] | None:
"""Return the cached xy color."""
val = self._state_cache.get(ATTR_XY_COLOR)
return tuple(val) if val else None
@property
def color_temp_kelvin(self) -> int | None:
"""Return the cached color temperature in kelvin."""
value = self._state_cache.get(ATTR_COLOR_TEMP_KELVIN)
return None if value is None else int(value)
@property
def min_color_temp_kelvin(self) -> int:
"""Return the cached or default min color temperature."""
return int(self.description.capabilities.get(ATTR_MIN_COLOR_TEMP_KELVIN, 2000))
@property
def max_color_temp_kelvin(self) -> int:
"""Return the cached or default max color temperature."""
return int(self.description.capabilities.get(ATTR_MAX_COLOR_TEMP_KELVIN, 6500))
@property
def effect(self) -> str | None:
"""Return the active effect."""
return self._state_cache.get(ATTR_EFFECT)
@property
def effect_list(self) -> list[str] | None:
"""Return the list of supported effects."""
effects = self.description.capabilities.get(ATTR_EFFECT_LIST)
return list(effects) if effects else None
@property
def supported_color_modes(self) -> set[ColorMode] | None:
"""Return the cached supported color modes set."""
modes = self.description.capabilities.get(ATTR_SUPPORTED_COLOR_MODES)
if not modes:
return None
return {ColorMode(m) for m in modes}
async def async_turn_on(self, **kwargs: Any) -> None:
"""Forward turn_on as a ``light.turn_on`` service call."""
await self._call_service("turn_on", **kwargs)
async def async_turn_off(self, **kwargs: Any) -> None:
"""Forward turn_off as a ``light.turn_off`` service call."""
await self._call_service("turn_off", **kwargs)
@@ -1,82 +0,0 @@
"""Sandbox proxy for ``lock`` entities."""
from typing import TYPE_CHECKING, Any
from homeassistant.components.lock import LockEntity, LockEntityFeature, LockState
from . import SandboxProxyEntity
if TYPE_CHECKING:
from ..bridge import SandboxBridge, SandboxEntityDescription
# pylint: disable-next=home-assistant-enforce-class-module
class SandboxLockEntity(SandboxProxyEntity, LockEntity):
"""Proxy for a ``lock`` entity in a sandbox."""
def __init__(
self,
bridge: SandboxBridge,
description: SandboxEntityDescription,
) -> None:
"""Wrap ``supported_features`` as ``LockEntityFeature``."""
super().__init__(bridge, description)
self._attr_supported_features = LockEntityFeature(
description.supported_features or 0
)
@property
def is_locked(self) -> bool | None:
"""Derive locked from cached state."""
state = self._state_cache.get("state")
if state is None:
return None
return state == LockState.LOCKED
@property
def is_locking(self) -> bool | None:
"""True iff cached state is ``locking``."""
return self._state_cache.get("state") == LockState.LOCKING
@property
def is_unlocking(self) -> bool | None:
"""True iff cached state is ``unlocking``."""
return self._state_cache.get("state") == LockState.UNLOCKING
@property
def is_open(self) -> bool | None:
"""True iff cached state is ``open``."""
return self._state_cache.get("state") == LockState.OPEN
@property
def is_opening(self) -> bool | None:
"""True iff cached state is ``opening``."""
return self._state_cache.get("state") == LockState.OPENING
@property
def is_jammed(self) -> bool | None:
"""True iff cached state is ``jammed``."""
return self._state_cache.get("state") == LockState.JAMMED
@property
def code_format(self) -> str | None:
"""Return the configured code format."""
value = self.description.capabilities.get("code_format")
return str(value) if value is not None else None
@property
def changed_by(self) -> str | None:
"""Return the cached changed_by."""
return self._state_cache.get("changed_by")
async def async_lock(self, **kwargs: Any) -> None:
"""Forward lock."""
await self._call_service("lock", **kwargs)
async def async_unlock(self, **kwargs: Any) -> None:
"""Forward unlock."""
await self._call_service("unlock", **kwargs)
async def async_open(self, **kwargs: Any) -> None:
"""Forward open."""
await self._call_service("open", **kwargs)
@@ -1,320 +0,0 @@
"""Sandbox proxy for ``media_player`` entities."""
from typing import TYPE_CHECKING, Any
from homeassistant.components.media_player import (
ATTR_APP_ID,
ATTR_APP_NAME,
ATTR_INPUT_SOURCE,
ATTR_INPUT_SOURCE_LIST,
ATTR_MEDIA_ALBUM_ARTIST,
ATTR_MEDIA_ALBUM_NAME,
ATTR_MEDIA_ARTIST,
ATTR_MEDIA_CONTENT_ID,
ATTR_MEDIA_CONTENT_TYPE,
ATTR_MEDIA_DURATION,
ATTR_MEDIA_POSITION,
ATTR_MEDIA_TITLE,
ATTR_MEDIA_TRACK,
ATTR_MEDIA_VOLUME_LEVEL,
ATTR_MEDIA_VOLUME_MUTED,
ATTR_SOUND_MODE,
ATTR_SOUND_MODE_LIST,
BrowseMedia,
MediaPlayerEntity,
MediaPlayerEntityFeature,
MediaPlayerState,
MediaType,
RepeatMode,
SearchMedia,
SearchMediaQuery,
)
from homeassistant.exceptions import HomeAssistantError
from . import SandboxProxyEntity
if TYPE_CHECKING:
from ..bridge import SandboxBridge, SandboxEntityDescription
def _browse_media_from_dict(data: dict[str, Any]) -> BrowseMedia:
"""Rebuild a :class:`BrowseMedia` tree from its ``as_dict`` shape.
``BrowseMedia.as_dict`` is frontend-shaped it carries
``children_media_class`` and emits ``not_shown`` / ``children`` only at the
parent level so fields map across explicitly rather than via a ``**data``
splat. ``children`` recurses; numbers arriving as floats through the wire
Struct are coerced back to the constructor's ``int`` / ``bool`` types.
"""
children = data.get("children")
return BrowseMedia(
media_class=data["media_class"],
media_content_id=data["media_content_id"],
media_content_type=data["media_content_type"],
title=data["title"],
can_play=bool(data["can_play"]),
can_expand=bool(data["can_expand"]),
children=(
[_browse_media_from_dict(child) for child in children] if children else None
),
children_media_class=data.get("children_media_class"),
thumbnail=data.get("thumbnail"),
not_shown=int(data.get("not_shown") or 0),
can_search=bool(data.get("can_search", False)),
)
def _search_media_from_dict(data: dict[str, Any]) -> SearchMedia:
"""Rebuild a :class:`SearchMedia` from its ``as_dict`` shape.
``SearchMedia.as_dict`` holds its results under ``result`` as a list of
``BrowseMedia`` dicts, so the rebuild reuses :func:`_browse_media_from_dict`
per item. ``version`` is constructor-defaulted.
"""
return SearchMedia(
result=[_browse_media_from_dict(item) for item in data.get("result", [])]
)
# pylint: disable-next=home-assistant-enforce-class-module
class SandboxMediaPlayerEntity(SandboxProxyEntity, MediaPlayerEntity):
"""Proxy for a ``media_player`` entity in a sandbox."""
def __init__(
self,
bridge: SandboxBridge,
description: SandboxEntityDescription,
) -> None:
"""Wrap ``supported_features`` as ``MediaPlayerEntityFeature``."""
super().__init__(bridge, description)
self._attr_supported_features = MediaPlayerEntityFeature(
description.supported_features or 0
)
@property
def state(self) -> MediaPlayerState | None:
"""Return the cached state."""
value = self._state_cache.get("state")
if value is None or value == "unavailable":
return None
try:
return MediaPlayerState(value)
except ValueError:
return None
@property
def volume_level(self) -> float | None:
"""Return the cached volume level."""
value = self._state_cache.get(ATTR_MEDIA_VOLUME_LEVEL)
return None if value is None else float(value)
@property
def is_volume_muted(self) -> bool | None:
"""Return the cached mute state."""
value = self._state_cache.get(ATTR_MEDIA_VOLUME_MUTED)
return None if value is None else bool(value)
@property
def media_content_id(self) -> str | None:
"""Return cached media_content_id."""
return self._state_cache.get(ATTR_MEDIA_CONTENT_ID)
@property
def media_content_type(self) -> str | None:
"""Return cached media_content_type."""
return self._state_cache.get(ATTR_MEDIA_CONTENT_TYPE)
@property
def media_duration(self) -> int | None:
"""Return cached media_duration."""
value = self._state_cache.get(ATTR_MEDIA_DURATION)
return None if value is None else int(value)
@property
def media_position(self) -> int | None:
"""Return cached media_position."""
value = self._state_cache.get(ATTR_MEDIA_POSITION)
return None if value is None else int(value)
@property
def media_title(self) -> str | None:
"""Return cached media_title."""
return self._state_cache.get(ATTR_MEDIA_TITLE)
@property
def media_artist(self) -> str | None:
"""Return cached media_artist."""
return self._state_cache.get(ATTR_MEDIA_ARTIST)
@property
def media_album_name(self) -> str | None:
"""Return cached media_album_name."""
return self._state_cache.get(ATTR_MEDIA_ALBUM_NAME)
@property
def media_album_artist(self) -> str | None:
"""Return cached media_album_artist."""
return self._state_cache.get(ATTR_MEDIA_ALBUM_ARTIST)
@property
def media_track(self) -> int | None:
"""Return cached media_track."""
value = self._state_cache.get(ATTR_MEDIA_TRACK)
return None if value is None else int(value)
@property
def source(self) -> str | None:
"""Return cached source."""
return self._state_cache.get(ATTR_INPUT_SOURCE)
@property
def source_list(self) -> list[str] | None:
"""Return cached source list."""
value = self._state_cache.get(
ATTR_INPUT_SOURCE_LIST,
self.description.capabilities.get(ATTR_INPUT_SOURCE_LIST),
)
return list(value) if value else None
@property
def sound_mode(self) -> str | None:
"""Return cached sound_mode."""
return self._state_cache.get(ATTR_SOUND_MODE)
@property
def sound_mode_list(self) -> list[str] | None:
"""Return cached sound_mode_list."""
value = self._state_cache.get(
ATTR_SOUND_MODE_LIST,
self.description.capabilities.get(ATTR_SOUND_MODE_LIST),
)
return list(value) if value else None
@property
def app_id(self) -> str | None:
"""Return cached app_id."""
return self._state_cache.get(ATTR_APP_ID)
@property
def app_name(self) -> str | None:
"""Return cached app_name."""
return self._state_cache.get(ATTR_APP_NAME)
async def async_turn_on(self) -> None:
"""Forward turn_on."""
await self._call_service("turn_on")
async def async_turn_off(self) -> None:
"""Forward turn_off."""
await self._call_service("turn_off")
async def async_mute_volume(self, mute: bool) -> None:
"""Forward volume_mute."""
await self._call_service("volume_mute", is_volume_muted=mute)
async def async_set_volume_level(self, volume: float) -> None:
"""Forward volume_set."""
await self._call_service("volume_set", volume_level=volume)
async def async_media_play(self) -> None:
"""Forward media_play."""
await self._call_service("media_play")
async def async_media_pause(self) -> None:
"""Forward media_pause."""
await self._call_service("media_pause")
async def async_media_stop(self) -> None:
"""Forward media_stop."""
await self._call_service("media_stop")
async def async_media_next_track(self) -> None:
"""Forward media_next_track."""
await self._call_service("media_next_track")
async def async_media_previous_track(self) -> None:
"""Forward media_previous_track."""
await self._call_service("media_previous_track")
async def async_media_seek(self, position: float) -> None:
"""Forward media_seek."""
await self._call_service("media_seek", seek_position=position)
async def async_play_media(
self, media_type: str, media_id: str, **kwargs: Any
) -> None:
"""Forward play_media."""
await self._call_service(
"play_media",
media_content_type=media_type,
media_content_id=media_id,
**kwargs,
)
async def async_select_source(self, source: str) -> None:
"""Forward select_source."""
await self._call_service("select_source", source=source)
async def async_select_sound_mode(self, sound_mode: str) -> None:
"""Forward select_sound_mode."""
await self._call_service("select_sound_mode", sound_mode=sound_mode)
async def async_browse_media(
self,
media_content_type: MediaType | str | None = None,
media_content_id: str | None = None,
) -> BrowseMedia:
"""Browse via the ``media_player.browse_media`` service.
Caveat: a sandboxed player's browse surfaces only its OWN sources. The
``media_source`` tree a player normally merges in (via
``media_source.async_browse_media(self.hass, )``) is empty here
``media_source`` runs on main, outside the sandbox boundary, so the
sandbox's private hass has nothing to resolve against. Not a bug;
closing it needs a cross-boundary hook (pairs with the opt-in sharing
work). See ``sandbox/docs/query-shaped-rpcs.md``.
"""
service_data: dict[str, Any] = {}
if media_content_type is not None:
service_data["media_content_type"] = media_content_type
if media_content_id is not None:
service_data["media_content_id"] = media_content_id
response = await self._call_service(
"browse_media", return_response=True, **service_data
)
entity_response = response.get(self.description.sandbox_entity_id)
if not entity_response:
raise HomeAssistantError("Sandbox returned no browse_media result")
return _browse_media_from_dict(entity_response)
async def async_search_media(self, query: SearchMediaQuery) -> SearchMedia:
"""Search via ``EntityQuery`` against the real entity.
Forwarded to ``async_internal_search_media`` (which rebuilds the
``SearchMediaQuery`` from flat kwargs on the sandbox side) rather than
``async_search_media``, so the query crosses as plain JSON kwargs.
``media_filter_classes`` cross as their ``MediaClass`` string values.
"""
args: dict[str, Any] = {"search_query": query.search_query}
if query.media_content_type is not None:
args["media_content_type"] = query.media_content_type
if query.media_content_id is not None:
args["media_content_id"] = query.media_content_id
if query.media_filter_classes is not None:
args["media_filter_classes"] = [
getattr(item, "value", item) for item in query.media_filter_classes
]
response = await self._entity_query("async_internal_search_media", **args)
return _search_media_from_dict(response or {})
async def async_clear_playlist(self) -> None:
"""Forward clear_playlist."""
await self._call_service("clear_playlist")
async def async_set_shuffle(self, shuffle: bool) -> None:
"""Forward shuffle_set."""
await self._call_service("shuffle_set", shuffle=shuffle)
async def async_set_repeat(self, repeat: RepeatMode) -> None:
"""Forward repeat_set."""
await self._call_service("repeat_set", repeat=repeat)
@@ -1,43 +0,0 @@
"""Sandbox proxy for ``notify`` entities."""
from typing import TYPE_CHECKING, Any
from homeassistant.components.notify import NotifyEntity, NotifyEntityFeature
from homeassistant.core import Context
from . import SandboxProxyEntity
if TYPE_CHECKING:
from ..bridge import SandboxBridge, SandboxEntityDescription
# pylint: disable-next=home-assistant-enforce-class-module
class SandboxNotifyEntity(SandboxProxyEntity, NotifyEntity):
"""Proxy for a ``notify`` entity in a sandbox."""
def __init__(
self,
bridge: SandboxBridge,
description: SandboxEntityDescription,
) -> None:
"""Wrap ``supported_features`` as ``NotifyEntityFeature``."""
super().__init__(bridge, description)
self._attr_supported_features = NotifyEntityFeature(
description.supported_features or 0
)
def sandbox_apply_state(
self,
state: str | None,
attributes: dict[str, Any],
context: Context | None = None,
) -> None:
"""Mirror ``__last_notified_isoformat`` for state computation."""
if state is not None:
# pylint: disable-next=attribute-defined-outside-init
self._NotifyEntity__last_notified_isoformat = state
super().sandbox_apply_state(state, attributes, context)
async def async_send_message(self, message: str, title: str | None = None) -> None:
"""Forward send_message."""
await self._call_service("send_message", message=message, title=title)
@@ -1,60 +0,0 @@
"""Sandbox proxy for ``number`` entities."""
from homeassistant.components.number import (
ATTR_MAX,
ATTR_MIN,
ATTR_STEP,
NumberEntity,
NumberMode,
)
from . import SandboxProxyEntity
# pylint: disable-next=home-assistant-enforce-class-module
class SandboxNumberEntity(SandboxProxyEntity, NumberEntity):
"""Proxy for a ``number`` entity in a sandbox."""
@property
def native_value(self) -> float | None:
"""Parse the cached number state."""
value = self._state_cache.get("state")
if value is None or value in ("unavailable", "unknown"):
return None
try:
return float(value)
except TypeError, ValueError:
return None
@property
def native_min_value(self) -> float:
"""Return the configured minimum."""
value = self.description.capabilities.get(ATTR_MIN)
return float(value) if value is not None else super().native_min_value
@property
def native_max_value(self) -> float:
"""Return the configured maximum."""
value = self.description.capabilities.get(ATTR_MAX)
return float(value) if value is not None else super().native_max_value
@property
def native_step(self) -> float | None:
"""Return the configured step."""
value = self.description.capabilities.get(ATTR_STEP)
return float(value) if value is not None else None
@property
def mode(self) -> NumberMode:
"""Return the configured display mode."""
value = self.description.capabilities.get("mode")
if value is None:
return NumberMode.AUTO
try:
return NumberMode(value)
except ValueError:
return NumberMode.AUTO
async def async_set_native_value(self, value: float) -> None:
"""Forward set_value as ``number.set_value``."""
await self._call_service("set_value", value=value)
@@ -1,76 +0,0 @@
"""Sandbox proxy for ``remote`` entities."""
from collections.abc import Iterable
from typing import TYPE_CHECKING, Any
from homeassistant.components.remote import (
ATTR_ACTIVITY_LIST,
ATTR_CURRENT_ACTIVITY,
RemoteEntity,
RemoteEntityFeature,
)
from homeassistant.const import STATE_ON
from . import SandboxProxyEntity
if TYPE_CHECKING:
from ..bridge import SandboxBridge, SandboxEntityDescription
# pylint: disable-next=home-assistant-enforce-class-module
class SandboxRemoteEntity(SandboxProxyEntity, RemoteEntity):
"""Proxy for a ``remote`` entity in a sandbox."""
def __init__(
self,
bridge: SandboxBridge,
description: SandboxEntityDescription,
) -> None:
"""Wrap ``supported_features`` as ``RemoteEntityFeature``."""
super().__init__(bridge, description)
self._attr_supported_features = RemoteEntityFeature(
description.supported_features or 0
)
@property
def is_on(self) -> bool | None:
"""Return whether the cached state is ``on``."""
state = self._state_cache.get("state")
if state is None:
return None
return state == STATE_ON
@property
def current_activity(self) -> str | None:
"""Return the cached current activity."""
return self._state_cache.get(ATTR_CURRENT_ACTIVITY)
@property
def activity_list(self) -> list[str] | None:
"""Return the configured activity list."""
value = self.description.capabilities.get(ATTR_ACTIVITY_LIST)
return list(value) if value else None
async def async_turn_on(self, **kwargs: Any) -> None:
"""Forward turn_on."""
await self._call_service("turn_on", **kwargs)
async def async_turn_off(self, **kwargs: Any) -> None:
"""Forward turn_off."""
await self._call_service("turn_off", **kwargs)
async def async_toggle(self, **kwargs: Any) -> None:
"""Forward toggle."""
await self._call_service("toggle", **kwargs)
async def async_send_command(self, command: Iterable[str], **kwargs: Any) -> None:
"""Forward send_command."""
await self._call_service("send_command", command=list(command), **kwargs)
async def async_learn_command(self, **kwargs: Any) -> None:
"""Forward learn_command."""
await self._call_service("learn_command", **kwargs)
async def async_delete_command(self, **kwargs: Any) -> None:
"""Forward delete_command."""
await self._call_service("delete_command", **kwargs)
@@ -1,34 +0,0 @@
"""Sandbox proxy for ``scene`` entities.
``scene`` is in ``ALWAYS_MAIN`` so the classifier never routes it to a
sandbox in practice. The proxy ships anyway for symmetry the full
set is covered so a future classifier change doesn't surprise us.
"""
from typing import Any
from homeassistant.components.scene import Scene
from homeassistant.core import Context
from . import SandboxProxyEntity
# pylint: disable-next=home-assistant-enforce-class-module
class SandboxSceneEntity(SandboxProxyEntity, Scene):
"""Proxy for a ``scene`` entity in a sandbox."""
def sandbox_apply_state(
self,
state: str | None,
attributes: dict[str, Any],
context: Context | None = None,
) -> None:
"""Mirror the sandbox-side last-activated timestamp."""
if state is not None:
# pylint: disable-next=attribute-defined-outside-init
self._BaseScene__last_activated = state
super().sandbox_apply_state(state, attributes, context)
async def async_activate(self, **kwargs: Any) -> None:
"""Forward activate as ``scene.turn_on``."""
await self._call_service("turn_on", **kwargs)
@@ -1,28 +0,0 @@
"""Sandbox proxy for ``select`` entities."""
from homeassistant.components.select import ATTR_OPTIONS, SelectEntity
from . import SandboxProxyEntity
# pylint: disable-next=home-assistant-enforce-class-module
class SandboxSelectEntity(SandboxProxyEntity, SelectEntity):
"""Proxy for a ``select`` entity in a sandbox."""
@property
def current_option(self) -> str | None:
"""Return the cached current option."""
value = self._state_cache.get("state")
if value in (None, "unavailable", "unknown"):
return None
return value
@property
def options(self) -> list[str]:
"""Return the cached options list."""
value = self.description.capabilities.get(ATTR_OPTIONS) or []
return list(value)
async def async_select_option(self, option: str) -> None:
"""Forward select_option."""
await self._call_service("select_option", option=option)
@@ -1,24 +0,0 @@
"""Sandbox proxy for ``sensor`` entities."""
from homeassistant.components.sensor import SensorEntity
from homeassistant.const import ATTR_UNIT_OF_MEASUREMENT
from . import SandboxProxyEntity
# pylint: disable-next=home-assistant-enforce-class-module
class SandboxSensorEntity(SandboxProxyEntity, SensorEntity):
"""Proxy for a ``sensor`` entity in a sandbox."""
@property
def native_value(self) -> str | int | float | None:
"""Return the cached state as the sensor's native value."""
return self._state_cache.get("state")
@property
def native_unit_of_measurement(self) -> str | None:
"""Return the cached unit of measurement."""
return self._state_cache.get(
ATTR_UNIT_OF_MEASUREMENT,
self.description.capabilities.get(ATTR_UNIT_OF_MEASUREMENT),
)
@@ -1,56 +0,0 @@
"""Sandbox proxy for ``siren`` entities."""
from typing import TYPE_CHECKING, Any
from homeassistant.components.siren import (
ATTR_AVAILABLE_TONES,
SirenEntity,
SirenEntityFeature,
)
from homeassistant.const import STATE_ON
from . import SandboxProxyEntity
if TYPE_CHECKING:
from ..bridge import SandboxBridge, SandboxEntityDescription
# pylint: disable-next=home-assistant-enforce-class-module
class SandboxSirenEntity(SandboxProxyEntity, SirenEntity):
"""Proxy for a ``siren`` entity in a sandbox."""
def __init__(
self,
bridge: SandboxBridge,
description: SandboxEntityDescription,
) -> None:
"""Wrap ``supported_features`` as ``SirenEntityFeature``."""
super().__init__(bridge, description)
self._attr_supported_features = SirenEntityFeature(
description.supported_features or 0
)
@property
def is_on(self) -> bool | None:
"""Return whether the cached state is ``on``."""
state = self._state_cache.get("state")
if state is None:
return None
return state == STATE_ON
@property
def available_tones(self) -> list[int | str] | dict[int, str] | None:
"""Return the configured available tones."""
return self.description.capabilities.get(ATTR_AVAILABLE_TONES)
async def async_turn_on(self, **kwargs: Any) -> None:
"""Forward turn_on."""
await self._call_service("turn_on", **kwargs)
async def async_turn_off(self, **kwargs: Any) -> None:
"""Forward turn_off."""
await self._call_service("turn_off", **kwargs)
async def async_toggle(self, **kwargs: Any) -> None:
"""Forward toggle."""
await self._call_service("toggle", **kwargs)
@@ -1,33 +0,0 @@
"""Sandbox proxy for ``switch`` entities."""
from typing import Any
from homeassistant.components.switch import SwitchEntity
from homeassistant.const import STATE_ON
from . import SandboxProxyEntity
# pylint: disable-next=home-assistant-enforce-class-module
class SandboxSwitchEntity(SandboxProxyEntity, SwitchEntity):
"""Proxy for a ``switch`` entity in a sandbox."""
@property
def is_on(self) -> bool | None:
"""Return whether the cached state is ``on``."""
state = self._state_cache.get("state")
if state is None:
return None
return state == STATE_ON
async def async_turn_on(self, **kwargs: Any) -> None:
"""Forward turn_on as a ``switch.turn_on`` service call."""
await self._call_service("turn_on", **kwargs)
async def async_turn_off(self, **kwargs: Any) -> None:
"""Forward turn_off as a ``switch.turn_off`` service call."""
await self._call_service("turn_off", **kwargs)
async def async_toggle(self, **kwargs: Any) -> None:
"""Forward toggle as a ``switch.toggle`` service call."""
await self._call_service("toggle", **kwargs)
@@ -1,58 +0,0 @@
"""Sandbox proxy for ``text`` entities."""
from homeassistant.components.text import (
ATTR_MAX,
ATTR_MIN,
ATTR_MODE,
ATTR_PATTERN,
TextEntity,
TextMode,
)
from . import SandboxProxyEntity
# pylint: disable-next=home-assistant-enforce-class-module
class SandboxTextEntity(SandboxProxyEntity, TextEntity):
"""Proxy for a ``text`` entity in a sandbox."""
@property
def native_value(self) -> str | None:
"""Return the cached text value."""
value = self._state_cache.get("state")
if value in (None, "unavailable", "unknown"):
return None
return str(value)
@property
def native_min(self) -> int:
"""Return the configured minimum length."""
value = self.description.capabilities.get(ATTR_MIN)
return int(value) if value is not None else 0
@property
def native_max(self) -> int:
"""Return the configured maximum length."""
value = self.description.capabilities.get(ATTR_MAX)
return int(value) if value is not None else super().native_max
@property
def pattern(self) -> str | None:
"""Return the configured pattern."""
value = self.description.capabilities.get(ATTR_PATTERN)
return str(value) if value is not None else None
@property
def mode(self) -> TextMode:
"""Return the configured display mode."""
value = self.description.capabilities.get(ATTR_MODE)
if value is None:
return TextMode.TEXT
try:
return TextMode(value)
except ValueError:
return TextMode.TEXT
async def async_set_value(self, value: str) -> None:
"""Forward set_value as ``text.set_value``."""
await self._call_service("set_value", value=value)
@@ -1,28 +0,0 @@
"""Sandbox proxy for ``time`` entities."""
from datetime import time
from homeassistant.components.time import TimeEntity
from homeassistant.util import dt as dt_util
from . import SandboxProxyEntity
# pylint: disable-next=home-assistant-enforce-class-module
class SandboxTimeEntity(SandboxProxyEntity, TimeEntity):
"""Proxy for a ``time`` entity in a sandbox."""
@property
def native_value(self) -> time | None:
"""Parse the cached ISO time string."""
value = self._state_cache.get("state")
if not isinstance(value, str) or value in ("unavailable", "unknown"):
return None
try:
return dt_util.parse_time(value)
except TypeError, ValueError:
return None
async def async_set_value(self, value: time) -> None:
"""Forward set_value as ``time.set_value``."""
await self._call_service("set_value", time=value.isoformat())
@@ -1,103 +0,0 @@
"""Sandbox proxy for ``update`` entities."""
from typing import TYPE_CHECKING, Any
from homeassistant.components.update import (
ATTR_INSTALLED_VERSION,
ATTR_LATEST_VERSION,
UpdateEntity,
UpdateEntityFeature,
)
from . import SandboxProxyEntity
if TYPE_CHECKING:
from ..bridge import SandboxBridge, SandboxEntityDescription
# These attribute names are emitted by ``UpdateEntity.state_attributes``
# (see ``components/update/__init__.py``). They're defined in
# ``update.const`` but not exported from the package root, so we hold the
# string keys locally rather than chase the pylint / mypy conflict on
# importing from ``.const``.
_ATTR_AUTO_UPDATE = "auto_update"
_ATTR_IN_PROGRESS = "in_progress"
_ATTR_RELEASE_SUMMARY = "release_summary"
_ATTR_RELEASE_URL = "release_url"
_ATTR_TITLE = "title"
_ATTR_UPDATE_PERCENTAGE = "update_percentage"
# pylint: disable-next=home-assistant-enforce-class-module
class SandboxUpdateEntity(SandboxProxyEntity, UpdateEntity):
"""Proxy for an ``update`` entity in a sandbox."""
def __init__(
self,
bridge: SandboxBridge,
description: SandboxEntityDescription,
) -> None:
"""Wrap ``supported_features`` as ``UpdateEntityFeature``."""
super().__init__(bridge, description)
self._attr_supported_features = UpdateEntityFeature(
description.supported_features or 0
)
@property
def installed_version(self) -> str | None:
"""Return the cached installed version."""
return self._state_cache.get(ATTR_INSTALLED_VERSION)
@property
def latest_version(self) -> str | None:
"""Return the cached latest version."""
return self._state_cache.get(ATTR_LATEST_VERSION)
@property
def release_summary(self) -> str | None:
"""Return the cached release summary."""
return self._state_cache.get(_ATTR_RELEASE_SUMMARY)
@property
def release_url(self) -> str | None:
"""Return the cached release URL."""
return self._state_cache.get(_ATTR_RELEASE_URL)
@property
def title(self) -> str | None:
"""Return the cached title."""
return self._state_cache.get(_ATTR_TITLE)
@property
def in_progress(self) -> bool | None:
"""Return the cached progress flag."""
value = self._state_cache.get(_ATTR_IN_PROGRESS)
return None if value is None else bool(value)
@property
def update_percentage(self) -> int | float | None:
"""Return the cached progress percentage."""
value = self._state_cache.get(_ATTR_UPDATE_PERCENTAGE)
if value is None:
return None
try:
return float(value)
except TypeError, ValueError:
return None
@property
def auto_update(self) -> bool:
"""Return the cached auto-update flag."""
return bool(self._state_cache.get(_ATTR_AUTO_UPDATE, False))
async def async_install(
self, version: str | None, backup: bool, **kwargs: Any
) -> None:
"""Forward install."""
payload: dict[str, Any] = {"backup": backup, **kwargs}
if version is not None:
payload["version"] = version
await self._call_service("install", **payload)
async def async_release_notes(self) -> str | None:
"""Return the release notes via ``EntityQuery`` (a plain str/None)."""
return await self._entity_query("async_release_notes")
@@ -1,104 +0,0 @@
"""Sandbox proxy for ``vacuum`` entities."""
from typing import TYPE_CHECKING, Any
from homeassistant.components.vacuum import (
ATTR_FAN_SPEED,
ATTR_FAN_SPEED_LIST,
Segment,
StateVacuumEntity,
VacuumActivity,
VacuumEntityFeature,
)
from . import SandboxProxyEntity
if TYPE_CHECKING:
from ..bridge import SandboxBridge, SandboxEntityDescription
def _segment_from_dict(data: dict[str, Any]) -> Segment:
"""Rebuild a :class:`Segment` dataclass from its serialised dict."""
return Segment(id=data["id"], name=data["name"], group=data.get("group"))
# pylint: disable-next=home-assistant-enforce-class-module
class SandboxVacuumEntity(SandboxProxyEntity, StateVacuumEntity):
"""Proxy for a ``vacuum`` entity in a sandbox."""
def __init__(
self,
bridge: SandboxBridge,
description: SandboxEntityDescription,
) -> None:
"""Wrap ``supported_features`` as ``VacuumEntityFeature``."""
super().__init__(bridge, description)
self._attr_supported_features = VacuumEntityFeature(
description.supported_features or 0
)
@property
def activity(self) -> VacuumActivity | None:
"""Return the cached vacuum activity."""
value = self._state_cache.get("state")
if value is None or value == "unavailable":
return None
try:
return VacuumActivity(value)
except ValueError:
return None
@property
def fan_speed(self) -> str | None:
"""Return the cached fan speed."""
return self._state_cache.get(ATTR_FAN_SPEED)
@property
def fan_speed_list(self) -> list[str]:
"""Return the configured fan speed list."""
return list(self.description.capabilities.get(ATTR_FAN_SPEED_LIST) or [])
async def async_start(self) -> None:
"""Forward start."""
await self._call_service("start")
async def async_pause(self) -> None:
"""Forward pause."""
await self._call_service("pause")
async def async_stop(self, **kwargs: Any) -> None:
"""Forward stop."""
await self._call_service("stop", **kwargs)
async def async_return_to_base(self, **kwargs: Any) -> None:
"""Forward return_to_base."""
await self._call_service("return_to_base", **kwargs)
async def async_clean_spot(self, **kwargs: Any) -> None:
"""Forward clean_spot."""
await self._call_service("clean_spot", **kwargs)
async def async_locate(self, **kwargs: Any) -> None:
"""Forward locate."""
await self._call_service("locate", **kwargs)
async def async_set_fan_speed(self, fan_speed: str, **kwargs: Any) -> None:
"""Forward set_fan_speed."""
await self._call_service("set_fan_speed", fan_speed=fan_speed, **kwargs)
async def async_send_command(
self,
command: str,
params: dict[str, Any] | list[Any] | None = None,
**kwargs: Any,
) -> None:
"""Forward send_command."""
payload: dict[str, Any] = {"command": command, **kwargs}
if params is not None:
payload["params"] = params
await self._call_service("send_command", **payload)
async def async_get_segments(self) -> list[Segment]:
"""Return the cleanable segments via ``EntityQuery``."""
response = await self._entity_query("async_get_segments")
return [_segment_from_dict(segment) for segment in response or []]
@@ -1,81 +0,0 @@
"""Sandbox proxy for ``valve`` entities."""
from typing import TYPE_CHECKING
from homeassistant.components.valve import (
ATTR_CURRENT_POSITION,
ATTR_IS_CLOSED,
ValveEntity,
ValveEntityFeature,
ValveState,
)
from . import SandboxProxyEntity
if TYPE_CHECKING:
from ..bridge import SandboxBridge, SandboxEntityDescription
# pylint: disable-next=home-assistant-enforce-class-module
class SandboxValveEntity(SandboxProxyEntity, ValveEntity):
"""Proxy for a ``valve`` entity in a sandbox."""
def __init__(
self,
bridge: SandboxBridge,
description: SandboxEntityDescription,
) -> None:
"""Wrap ``supported_features`` as ``ValveEntityFeature``."""
super().__init__(bridge, description)
self._attr_supported_features = ValveEntityFeature(
description.supported_features or 0
)
@property
def reports_position(self) -> bool:
"""Mirror the sandbox-side flag."""
return bool(self.description.capabilities.get("reports_position", False))
@property
def is_opening(self) -> bool | None:
"""True iff cached state is ``opening``."""
return self._state_cache.get("state") == ValveState.OPENING
@property
def is_closing(self) -> bool | None:
"""True iff cached state is ``closing``."""
return self._state_cache.get("state") == ValveState.CLOSING
@property
def is_closed(self) -> bool | None:
"""Derive closed from cached state / ATTR_IS_CLOSED."""
if (value := self._state_cache.get(ATTR_IS_CLOSED)) is not None:
return bool(value)
state = self._state_cache.get("state")
if state == ValveState.CLOSED:
return True
if state == ValveState.OPEN:
return False
return None
@property
def current_valve_position(self) -> int | None:
"""Return the cached current position."""
value = self._state_cache.get(ATTR_CURRENT_POSITION)
return None if value is None else int(value)
async def async_open_valve(self) -> None:
"""Forward open_valve."""
await self._call_service("open_valve")
async def async_close_valve(self) -> None:
"""Forward close_valve."""
await self._call_service("close_valve")
async def async_set_valve_position(self, position: int) -> None:
"""Forward set_valve_position."""
await self._call_service("set_valve_position", position=position)
async def async_stop_valve(self) -> None:
"""Forward stop_valve."""
await self._call_service("stop_valve")
@@ -1,135 +0,0 @@
"""Sandbox proxy for ``water_heater`` entities."""
from typing import TYPE_CHECKING, Any
from homeassistant.components.water_heater import (
ATTR_CURRENT_TEMPERATURE,
ATTR_MAX_TEMP,
ATTR_MIN_TEMP,
ATTR_OPERATION_LIST,
ATTR_TARGET_TEMP_HIGH,
ATTR_TARGET_TEMP_LOW,
ATTR_TARGET_TEMP_STEP,
ATTR_TEMPERATURE,
WaterHeaterEntity,
WaterHeaterEntityFeature,
)
from homeassistant.const import UnitOfTemperature
from . import SandboxProxyEntity
if TYPE_CHECKING:
from ..bridge import SandboxBridge, SandboxEntityDescription
# pylint: disable-next=home-assistant-enforce-class-module
class SandboxWaterHeaterEntity(SandboxProxyEntity, WaterHeaterEntity):
"""Proxy for a ``water_heater`` entity in a sandbox."""
def __init__(
self,
bridge: SandboxBridge,
description: SandboxEntityDescription,
) -> None:
"""Wrap ``supported_features`` as ``WaterHeaterEntityFeature``."""
super().__init__(bridge, description)
self._attr_supported_features = WaterHeaterEntityFeature(
description.supported_features or 0
)
@property
def temperature_unit(self) -> str:
"""Return the unit declared by the sandbox-side entity."""
return str(
self.description.capabilities.get(
"temperature_unit", UnitOfTemperature.CELSIUS
)
)
@property
def current_operation(self) -> str | None:
"""Return the cached current operation."""
value = self._state_cache.get("state")
if value in (None, "unavailable", "unknown"):
return None
return value
@property
def operation_list(self) -> list[str] | None:
"""Return the configured operation list."""
value = self.description.capabilities.get(ATTR_OPERATION_LIST)
return list(value) if value else None
@property
def current_temperature(self) -> float | None:
"""Return the cached current temperature."""
value = self._state_cache.get(ATTR_CURRENT_TEMPERATURE)
return None if value is None else float(value)
@property
def target_temperature(self) -> float | None:
"""Return the cached target temperature."""
value = self._state_cache.get(ATTR_TEMPERATURE)
return None if value is None else float(value)
@property
def target_temperature_high(self) -> float | None:
"""Return the cached high target temperature."""
value = self._state_cache.get(ATTR_TARGET_TEMP_HIGH)
return None if value is None else float(value)
@property
def target_temperature_low(self) -> float | None:
"""Return the cached low target temperature."""
value = self._state_cache.get(ATTR_TARGET_TEMP_LOW)
return None if value is None else float(value)
@property
def target_temperature_step(self) -> float | None:
"""Return the configured target temperature step."""
value = self.description.capabilities.get(ATTR_TARGET_TEMP_STEP)
return float(value) if value is not None else None
@property
def min_temp(self) -> float:
"""Return the configured minimum temperature."""
value = self.description.capabilities.get(ATTR_MIN_TEMP)
return float(value) if value is not None else super().min_temp
@property
def max_temp(self) -> float:
"""Return the configured maximum temperature."""
value = self.description.capabilities.get(ATTR_MAX_TEMP)
return float(value) if value is not None else super().max_temp
@property
def is_away_mode_on(self) -> bool | None:
"""Return the cached away-mode flag."""
value = self._state_cache.get("away_mode")
if value is None:
return None
return value == "on"
async def async_set_temperature(self, **kwargs: Any) -> None:
"""Forward set_temperature."""
await self._call_service("set_temperature", **kwargs)
async def async_set_operation_mode(self, operation_mode: str) -> None:
"""Forward set_operation_mode."""
await self._call_service("set_operation_mode", operation_mode=operation_mode)
async def async_turn_on(self, **kwargs: Any) -> None:
"""Forward turn_on."""
await self._call_service("turn_on", **kwargs)
async def async_turn_off(self, **kwargs: Any) -> None:
"""Forward turn_off."""
await self._call_service("turn_off", **kwargs)
async def async_turn_away_mode_on(self) -> None:
"""Forward turn_away_mode_on."""
await self._call_service("turn_away_mode_on")
async def async_turn_away_mode_off(self) -> None:
"""Forward turn_away_mode_off."""
await self._call_service("turn_away_mode_off")
@@ -1,110 +0,0 @@
"""Sandbox proxy for ``weather`` entities."""
from typing import TYPE_CHECKING
from homeassistant.components.weather import (
ATTR_WEATHER_HUMIDITY,
ATTR_WEATHER_TEMPERATURE,
ATTR_WEATHER_TEMPERATURE_UNIT,
ATTR_WEATHER_WIND_BEARING,
ATTR_WEATHER_WIND_SPEED,
ATTR_WEATHER_WIND_SPEED_UNIT,
Forecast,
WeatherEntity,
WeatherEntityFeature,
)
from . import SandboxProxyEntity
if TYPE_CHECKING:
from ..bridge import SandboxBridge, SandboxEntityDescription
# pylint: disable-next=home-assistant-enforce-class-module
class SandboxWeatherEntity(SandboxProxyEntity, WeatherEntity):
"""Proxy for a ``weather`` entity in a sandbox.
The proxy mirrors the condition + instantaneous attributes. Forecasts ride
the ``weather.get_forecasts`` ``SupportsResponse`` service: each
``async_forecast_*`` method forwards a one-shot query and returns the real
entity's forecast list. The streaming ``weather/subscribe_forecast`` WS
command still has no push primitive, so it sees only that first fetch. See
``sandbox/docs/query-shaped-rpcs.md``.
"""
def __init__(
self,
bridge: SandboxBridge,
description: SandboxEntityDescription,
) -> None:
"""Wrap ``supported_features`` as ``WeatherEntityFeature``."""
super().__init__(bridge, description)
self._attr_supported_features = WeatherEntityFeature(
description.supported_features or 0
)
@property
def condition(self) -> str | None:
"""Return the cached weather condition."""
value = self._state_cache.get("state")
if value in (None, "unavailable", "unknown"):
return None
return value
@property
def native_temperature(self) -> float | None:
"""Return the cached temperature."""
value = self._state_cache.get(ATTR_WEATHER_TEMPERATURE)
return None if value is None else float(value)
@property
def native_temperature_unit(self) -> str | None:
"""Return the cached temperature unit."""
return self._state_cache.get(ATTR_WEATHER_TEMPERATURE_UNIT)
@property
def humidity(self) -> float | None:
"""Return the cached humidity."""
value = self._state_cache.get(ATTR_WEATHER_HUMIDITY)
return None if value is None else float(value)
@property
def native_wind_speed(self) -> float | None:
"""Return the cached wind speed."""
value = self._state_cache.get(ATTR_WEATHER_WIND_SPEED)
return None if value is None else float(value)
@property
def native_wind_speed_unit(self) -> str | None:
"""Return the cached wind speed unit."""
return self._state_cache.get(ATTR_WEATHER_WIND_SPEED_UNIT)
@property
def wind_bearing(self) -> float | str | None:
"""Return the cached wind bearing."""
return self._state_cache.get(ATTR_WEATHER_WIND_BEARING)
async def _async_forecast(self, forecast_type: str) -> list[Forecast]:
"""Forward a forecast query as the ``weather.get_forecasts`` service.
The service response is keyed by the (sandbox-side) entity_id and wraps
the list under ``forecast``. ``Forecast`` is a plain TypedDict, so the
unwrapped list crosses verbatim with no rebuild.
"""
response = await self._call_service(
"get_forecasts", return_response=True, type=forecast_type
)
entity_response = response.get(self.description.sandbox_entity_id, {})
return entity_response.get("forecast", [])
async def async_forecast_daily(self) -> list[Forecast] | None:
"""Return the daily forecast via ``weather.get_forecasts``."""
return await self._async_forecast("daily")
async def async_forecast_hourly(self) -> list[Forecast] | None:
"""Return the hourly forecast via ``weather.get_forecasts``."""
return await self._async_forecast("hourly")
async def async_forecast_twice_daily(self) -> list[Forecast] | None:
"""Return the twice-daily forecast via ``weather.get_forecasts``."""
return await self._async_forecast("twice_daily")
-686
View File
@@ -1,686 +0,0 @@
"""Sandbox — subprocess lifecycle and supervision.
The manager owns one supervised subprocess per sandbox group
(``main`` / ``built-in`` / ``custom``); callers invoke
:meth:`SandboxManager.ensure_started` lazily as config entries are routed.
The contract between manager and runtime is:
* the manager launches ``python -m hass_client.sandbox`` and tells it
which control-channel transport to use via ``--url``
* the runtime opens the control channel and sends a :data:`MSG_READY`
frame as its first message once it is up (no stdout text marker)
* on ``SIGTERM`` the runtime exits cleanly
Two transports are supported (selected by :class:`SandboxManager`'s
``transport`` option, defaulting to ``stdio``):
* **stdio** frames ride the subprocess's stdin/stdout pipes
(``--url stdio://``); the default, unchanged from earlier phases.
* **unix** the manager opens a unix-domain socket, passes its path as
``--url unix://<path>``, and the runtime dials back; the manager is the
server. Both transports share :class:`~.channel.StreamTransport`'s
length-prefixed framing, so there is no dedicated unix transport class.
"""
import asyncio
from collections import deque
from collections.abc import Awaitable, Callable
import contextlib
from dataclasses import dataclass
import logging
import os
import shutil
import sys
import tempfile
import time
from typing import Any
from homeassistant.core import HomeAssistant
from .channel import Channel, ChannelClosedError, ChannelRemoteError
from .codec_protobuf import ProtobufCodec
from .protocol import MSG_READY, MSG_SHUTDOWN
_LOGGER = logging.getLogger(__name__)
DEFAULT_RESTART_LIMIT = 3
DEFAULT_RESTART_WINDOW = 60.0
DEFAULT_RESTART_BACKOFF = 1.0
DEFAULT_READY_TIMEOUT = 30.0
DEFAULT_SHUTDOWN_GRACE = 10.0
# A command factory receives ``(group, url)`` — the manager decides the
# control-channel URL from its transport and hands it to the factory so the
# spawned argv carries the right ``--url``.
CommandFactory = Callable[[str, str], list[str]]
# Supported control-channel transports.
TRANSPORT_STDIO = "stdio"
TRANSPORT_UNIX = "unix"
_TRANSPORTS = (TRANSPORT_STDIO, TRANSPORT_UNIX)
# The reply is a protobuf ``ShutdownResult``; typed loosely to keep the
# manager free of a proto import.
ShutdownReplyCallback = Callable[[str, Any], Awaitable[None]]
class SandboxError(Exception):
"""Base class for sandbox lifecycle errors."""
class SandboxStartError(SandboxError):
"""Sandbox did not reach the ``running`` state."""
class SandboxFailedError(SandboxError):
"""Sandbox crashed more than the configured restart limit allows."""
@dataclass(frozen=True)
class SandboxConfig:
"""Tunables for one supervised sandbox process."""
restart_limit: int = DEFAULT_RESTART_LIMIT
restart_window: float = DEFAULT_RESTART_WINDOW
restart_backoff: float = DEFAULT_RESTART_BACKOFF
ready_timeout: float = DEFAULT_READY_TIMEOUT
shutdown_grace: float = DEFAULT_SHUTDOWN_GRACE
class SandboxProcess:
"""One supervised sandbox subprocess.
States cycle through ``stopped`` ``starting`` ``running``
(``starting`` on crash) ``failed`` once the restart budget is spent.
"""
def __init__(
self,
group: str,
command_factory: Callable[[str], list[str]],
config: SandboxConfig,
*,
transport: str = TRANSPORT_STDIO,
on_failed: Callable[[str], None] | None = None,
on_channel_ready: Callable[[str, Channel], None] | None = None,
on_shutdown_reply: ShutdownReplyCallback | None = None,
) -> None:
"""Initialise a supervised sandbox subprocess.
``command_factory`` is called with the control-channel URL the
chosen ``transport`` requires (``stdio://`` or ``unix://<path>``)
and returns the argv to spawn.
``on_channel_ready`` is invoked with the live :class:`Channel` as
soon as it is opened before the runtime's :data:`MSG_READY`
frame arrives so its handlers are in place before the runtime's
own warm-load round-trip lands. It runs synchronously on the
manager's loop.
``on_shutdown_reply`` is invoked with the runtime's reply to
:data:`MSG_SHUTDOWN` so the caller can persist any
``restore_state`` payload before the subprocess exits.
"""
self.group = group
self._command_factory = command_factory
self._config = config
self._transport = transport
self._on_failed = on_failed
self._on_channel_ready = on_channel_ready
self._on_shutdown_reply = on_shutdown_reply
self._state: str = "stopped"
self._process: asyncio.subprocess.Process | None = None
self._supervisor: asyncio.Task[None] | None = None
self._ready: asyncio.Event = asyncio.Event()
self._stopped: asyncio.Event = asyncio.Event()
self._stopped.set()
self._stopping: bool = False
self._attempts: deque[float] = deque()
self._channel: Channel | None = None
@property
def state(self) -> str:
"""Current lifecycle state."""
return self._state
@property
def pid(self) -> int | None:
"""PID of the live subprocess, or ``None`` if not running."""
proc = self._process
return proc.pid if proc is not None and proc.returncode is None else None
@property
def channel(self) -> Channel | None:
"""The active control channel, or None when not running."""
return self._channel
async def start(self) -> None:
"""Spawn the subprocess and block until it is ``running``.
Raises :class:`SandboxStartError` if the supervisor gives up or the
ready handshake times out.
"""
if self._supervisor is not None:
return
self._stopping = False
self._stopped.clear()
self._ready.clear()
self._state = "starting"
self._attempts.clear()
self._supervisor = asyncio.create_task(
self._supervise(), name=f"sandbox[{self.group}]"
)
ready_task = asyncio.create_task(self._ready.wait())
stopped_task = asyncio.create_task(self._stopped.wait())
try:
await asyncio.wait(
{ready_task, stopped_task},
return_when=asyncio.FIRST_COMPLETED,
timeout=self._config.ready_timeout,
)
finally:
for task in (ready_task, stopped_task):
if not task.done():
task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await task
if self._state == "running":
return
await self.stop()
raise SandboxStartError(
f"Sandbox {self.group!r} failed to start (state={self._state})"
)
async def stop(self) -> None:
"""Terminate the subprocess and wait for the supervisor to exit."""
self._stopping = True
proc = self._process
if proc is not None and proc.returncode is None:
with contextlib.suppress(ProcessLookupError):
proc.terminate()
try:
await asyncio.wait_for(proc.wait(), timeout=self._config.shutdown_grace)
except TimeoutError:
_LOGGER.warning(
"Sandbox %s did not exit on SIGTERM within %.1fs; sending SIGKILL",
self.group,
self._config.shutdown_grace,
)
with contextlib.suppress(ProcessLookupError):
proc.kill()
with contextlib.suppress(BaseException):
await proc.wait()
supervisor = self._supervisor
if supervisor is not None:
try:
await supervisor
finally:
self._supervisor = None
if self._state != "failed":
self._state = "stopped"
async def async_graceful_shutdown(self, *, timeout: float) -> bool:
"""Ask the runtime to unload + flush, then wait for exit.
Sends ``sandbox/shutdown`` over the live channel and waits up
to ``timeout`` for the runtime to reply and then exit on its
own. Sets :attr:`_stopping` first so the supervisor does not
treat the clean exit as a crash. Returns ``True`` if the process
exited within the grace, ``False`` if anything went wrong
(timeout, no channel, channel closed) in which case the
caller should fall through to :meth:`stop` for SIGTERM/SIGKILL.
``on_reply`` is invoked with the dict the runtime returns (the
``restore_state`` payload + summary counters) so the caller can
persist it before the channel goes away.
"""
self._stopping = True
channel = self._channel
proc = self._process
if channel is None or channel.closed or proc is None:
return False
if proc.returncode is not None:
return True
try:
reply = await channel.call(MSG_SHUTDOWN, None, timeout=timeout)
except TimeoutError:
_LOGGER.warning(
"Sandbox %s did not reply to shutdown within %.1fs",
self.group,
timeout,
)
return False
except (ChannelClosedError, ChannelRemoteError) as err:
_LOGGER.debug(
"Sandbox %s shutdown call failed (%s); falling back to SIGTERM",
self.group,
err,
)
return False
callback = self._on_shutdown_reply
if callback is not None:
try:
await callback(self.group, reply)
except Exception:
_LOGGER.exception(
"Sandbox %s on_shutdown_reply callback raised", self.group
)
try:
await asyncio.wait_for(proc.wait(), timeout=timeout)
except TimeoutError:
_LOGGER.warning(
"Sandbox %s acked shutdown but did not exit within %.1fs",
self.group,
timeout,
)
return False
return True
async def _supervise(self) -> None:
"""Loop spawning the subprocess, applying the restart budget."""
try:
while not self._stopping:
now = time.monotonic()
while (
self._attempts
and now - self._attempts[0] > self._config.restart_window
):
self._attempts.popleft()
if len(self._attempts) >= self._config.restart_limit:
_LOGGER.error(
"Sandbox %s exceeded restart limit (%d attempts in %.0fs);"
" marking failed",
self.group,
self._config.restart_limit,
self._config.restart_window,
)
self._state = "failed"
if self._on_failed is not None:
try:
self._on_failed(self.group)
except Exception:
_LOGGER.exception(
"Sandbox %s on_failed callback raised", self.group
)
return
self._attempts.append(now)
self._state = "starting"
self._ready.clear()
await self._run_one()
if self._stopping:
return
_LOGGER.warning(
"Sandbox %s exited unexpectedly; restarting in %.2fs",
self.group,
self._config.restart_backoff,
)
try:
await asyncio.sleep(self._config.restart_backoff)
except asyncio.CancelledError:
return
finally:
if self._state != "failed":
self._state = "stopped"
self._stopped.set()
async def _run_one(self) -> None:
"""Spawn one process attempt and wait for it to exit."""
if self._transport == TRANSPORT_UNIX:
await self._run_one_unix()
else:
await self._run_one_stdio()
async def _run_one_stdio(self) -> None:
"""Spawn over stdio: the channel rides the subprocess's pipes."""
proc = await self._spawn(self._command_factory("stdio://"))
if proc is None:
return
self._process = proc
try:
# Open the channel up front — stdout carries nothing but frames
# now. Handlers go on before the reader starts so the runtime's
# warm-load round-trip (and any early push) is never dropped.
assert proc.stdout is not None
assert proc.stdin is not None
self._channel = self._build_channel(proc.stdout, proc.stdin)
await self._supervise_until_exit(proc, self._channel, drain_stdout=False)
finally:
self._process = None
async def _run_one_unix(self) -> None:
"""Spawn over a unix socket: the manager listens, runtime dials back.
The socket lives in a short-lived per-attempt tempdir rather than
under the (possibly long) config dir, sidestepping the ~108-char
``sun_path`` limit on Linux. It is unlinked when the server closes
and the tempdir is removed on the way out no leaked socket file.
"""
socket_dir = tempfile.mkdtemp(prefix=f"sandbox_{self.group}_")
socket_path = os.path.join(socket_dir, "control.sock")
loop = asyncio.get_running_loop()
connected: asyncio.Future[tuple[asyncio.StreamReader, asyncio.StreamWriter]] = (
loop.create_future()
)
def _on_connect(
reader: asyncio.StreamReader, writer: asyncio.StreamWriter
) -> None:
if connected.done():
# Only the first (runtime) connection is honoured.
writer.close()
return
connected.set_result((reader, writer))
server = await asyncio.start_unix_server(_on_connect, path=socket_path)
try:
proc = await self._spawn(self._command_factory(f"unix://{socket_path}"))
if proc is None:
return
self._process = proc
try:
# The runtime connects back as part of its startup; race the
# accept against an early exit so a crash-before-connect does
# not hang here forever.
exit_task = asyncio.create_task(proc.wait())
waiters: set[asyncio.Future[Any]] = {connected, exit_task}
try:
await asyncio.wait(waiters, return_when=asyncio.FIRST_COMPLETED)
finally:
if not exit_task.done():
exit_task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await exit_task
if not connected.done():
_LOGGER.warning(
"Sandbox %s exited before connecting to its control socket",
self.group,
)
return
reader, writer = connected.result()
self._channel = self._build_channel(reader, writer)
await self._supervise_until_exit(proc, self._channel, drain_stdout=True)
finally:
self._process = None
finally:
server.close()
# The accepted connection may linger in the server's client set:
# when the runtime exits, the channel's read loop sees EOF and
# marks the channel closed, so the later ``channel.close()`` is a
# no-op that never closes the accepted transport. Force-close any
# such leftover so ``wait_closed()`` cannot block forever.
server.close_clients()
with contextlib.suppress(Exception):
await server.wait_closed()
shutil.rmtree(socket_dir, ignore_errors=True)
async def _spawn(self, command: list[str]) -> asyncio.subprocess.Process | None:
"""Spawn the subprocess, returning ``None`` if it cannot start."""
try:
return await asyncio.create_subprocess_exec(
*command,
stdin=asyncio.subprocess.PIPE,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
except OSError:
_LOGGER.exception(
"Sandbox %s could not be spawned (%s)", self.group, command
)
return None
async def _supervise_until_exit(
self,
proc: asyncio.subprocess.Process,
channel: Channel,
*,
drain_stdout: bool,
) -> None:
"""Wire the ready handshake, run until the process exits, clean up.
Shared by both transports they reach here with a live channel and
a running process; only how the channel's byte pipe was obtained
differs. ``drain_stdout`` is set for the unix transport, where the
subprocess's stdout pipe is unused (frames ride the socket) and must
still be drained so its buffer never fills.
"""
ready_frame = asyncio.Event()
async def _on_ready(_payload: object) -> None:
ready_frame.set()
channel.register(MSG_READY, _on_ready)
if self._on_channel_ready is not None:
try:
self._on_channel_ready(self.group, channel)
except Exception:
_LOGGER.exception(
"Sandbox %s on_channel_ready callback raised", self.group
)
channel.start()
ready_task = asyncio.create_task(ready_frame.wait())
exit_task = asyncio.create_task(proc.wait())
drain_tasks = [asyncio.create_task(self._drain_stream(proc.stderr, "stderr"))]
if drain_stdout:
drain_tasks.append(
asyncio.create_task(self._drain_stream(proc.stdout, "stdout"))
)
try:
await asyncio.wait(
{ready_task, exit_task}, return_when=asyncio.FIRST_COMPLETED
)
if ready_task.done() and not ready_task.cancelled():
self._state = "running"
self._ready.set()
# Hold here until the process exits.
await exit_task
finally:
for task in (ready_task, exit_task, *drain_tasks):
if not task.done():
task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await task
if self._channel is not None:
await self._channel.close()
self._channel = None
self._ready.clear()
def _build_channel(
self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter
) -> Channel:
"""Wrap a reader/writer pair in a :class:`Channel`.
Length-prefixed channel frames cross end-to-end there is no text
preamble. The pair comes from the subprocess's stdout/stdin (stdio)
or from the accepted unix-socket connection (unix); the channel core
is identical either way.
"""
return Channel(reader, writer, name=self.group, codec=ProtobufCodec())
async def _drain_stream(
self, stream: asyncio.StreamReader | None, name: str
) -> None:
"""Read a child stream so its buffer never fills."""
if stream is None:
return
while True:
line = await stream.readline()
if not line:
return
text = line.decode("utf-8", errors="replace").rstrip()
if text:
_LOGGER.debug("sandbox %s %s: %s", self.group, name, text)
class SandboxManager:
"""Owns one :class:`SandboxProcess` per group, started lazily."""
def __init__(
self,
hass: HomeAssistant,
*,
command_factory: CommandFactory | None = None,
config: SandboxConfig | None = None,
on_failed: Callable[[str], None] | None = None,
on_channel_ready: Callable[[str, Channel], None] | None = None,
on_shutdown_reply: ShutdownReplyCallback | None = None,
transport: str = TRANSPORT_STDIO,
) -> None:
"""Initialise the manager.
``command_factory`` lets tests substitute the spawned command; it is
called with ``(group, url)`` and the default builds the
``python -m hass_client.sandbox`` argv that
:class:`hass_client.sandbox.SandboxRuntime` consumes.
``transport`` selects the control-channel transport for every
spawned sandbox: ``"stdio"`` (default unchanged behavior) or
``"unix"`` (the manager opens a unix socket and the runtime dials
back). Unix is opt-in so existing deployments keep using stdio.
``on_channel_ready`` is invoked once a sandbox's control channel is
live; the router uses it to register inbound flow handlers
(e.g., ``sandbox/notify_flow_changed``).
"""
self._hass = hass
self._command_factory = command_factory or self._default_command
self._config = config or SandboxConfig()
self._on_failed = on_failed
self._on_channel_ready = on_channel_ready
self._on_shutdown_reply = on_shutdown_reply
if transport not in _TRANSPORTS:
raise ValueError(
f"unknown sandbox transport {transport!r}; expected one of "
f"{_TRANSPORTS}"
)
self._transport = transport
self._sandboxes: dict[str, SandboxProcess] = {}
self._locks: dict[str, asyncio.Lock] = {}
@property
def shutdown_grace(self) -> float:
"""Configured grace window for ``async_graceful_shutdown_all``."""
return self._config.shutdown_grace
@property
def sandboxes(self) -> dict[str, SandboxProcess]:
"""Live read-only-ish view of the supervised processes."""
return dict(self._sandboxes)
def get(self, group: str) -> SandboxProcess | None:
"""Return the sandbox for ``group`` if one has ever been requested."""
return self._sandboxes.get(group)
async def ensure_started(self, group: str) -> SandboxProcess:
"""Return a running sandbox for ``group``, spawning it if needed.
Raises :class:`SandboxFailedError` if the sandbox has already
exhausted its restart budget and :class:`SandboxStartError` if a
fresh spawn cannot reach ``running``.
"""
lock = self._locks.setdefault(group, asyncio.Lock())
async with lock:
existing = self._sandboxes.get(group)
if existing is not None:
if existing.state in ("starting", "running"):
return existing
if existing.state == "failed":
raise SandboxFailedError(f"Sandbox {group!r} is in a failed state")
# Was stopped — drop the stale process and re-spawn.
del self._sandboxes[group]
# Keeping the SandboxProcess in the map after a failed start lets
# callers observe its state — ensure_started won't try to
# restart a failed sandbox.
def make_command(url: str) -> list[str]:
return self._command_factory(group, url)
process = SandboxProcess(
group,
make_command,
self._config,
transport=self._transport,
on_failed=self._on_failed,
on_channel_ready=self._on_channel_ready,
on_shutdown_reply=self._on_shutdown_reply,
)
self._sandboxes[group] = process
await process.start()
return process
async def async_stop(self, group: str) -> None:
"""Stop one sandbox if it exists."""
process = self._sandboxes.get(group)
if process is None:
return
await process.stop()
async def async_stop_all(self) -> None:
"""Stop every supervised sandbox in parallel."""
if not self._sandboxes:
return
await asyncio.gather(
*(process.stop() for process in self._sandboxes.values()),
return_exceptions=True,
)
async def async_graceful_shutdown_all(self, *, timeout: float) -> None:
"""Ask every running sandbox to shut down gracefully.
Best-effort fan-out. Sandboxes that did not ack inside ``timeout``
are left for :meth:`async_stop_all` to clean up with SIGTERM /
SIGKILL this method never raises.
"""
if not self._sandboxes:
return
await asyncio.gather(
*(
process.async_graceful_shutdown(timeout=timeout)
for process in self._sandboxes.values()
if process.state == "running"
),
return_exceptions=True,
)
def _default_command(self, group: str, url: str) -> list[str]:
"""Argv for ``python -m hass_client.sandbox``.
``url`` is the control-channel URL the manager's transport requires
(``stdio://`` or ``unix://<path>``) the runtime reads its scheme
to pick the transport.
"""
return [
sys.executable,
"-m",
"hass_client.sandbox",
"--name",
group,
"--url",
url,
]
__all__ = [
"TRANSPORT_STDIO",
"TRANSPORT_UNIX",
"CommandFactory",
"SandboxConfig",
"SandboxError",
"SandboxFailedError",
"SandboxManager",
"SandboxProcess",
"SandboxStartError",
"ShutdownReplyCallback",
]
@@ -1,11 +0,0 @@
{
"domain": "sandbox",
"name": "Sandbox",
"codeowners": [],
"dependencies": ["websocket_api"],
"documentation": "https://www.home-assistant.io/integrations/sandbox",
"integration_type": "system",
"iot_class": "local_push",
"quality_scale": "internal",
"requirements": ["protobuf==6.32.0"]
}
@@ -1,224 +0,0 @@
"""Typed protobuf message registry + dynamic-field helpers.
This module is the codec's view of the wire: the ``type → (request_cls,
result_cls)`` registry plus the small Struct/ListValue helpers that carry the
genuinely dynamic payloads (service_data, target, state attributes,
capabilities, the wrapped Store envelope, flow ``data``/``errors``/``context``)
and the serialized voluptuous schema.
Mirrored verbatim across the no-cross-import boundary, exactly like
:mod:`channel` / :mod:`protocol`: the same file lives at
``hass_client.messages``. The relative ``._proto`` import resolves to each
side's own checked-in gencode, so the two copies are byte-identical.
Numbers note: ``google.protobuf.Struct`` stores every number as a double, so
an ``int`` that crosses inside a dynamic field comes back as a ``float``
(``255`` ``255.0``). Python's ``==`` treats the two as equal, so dict
comparisons still hold; only an ``isinstance(x, int)`` check would notice.
Everything with integer semantics that matters (``version``, ``minor_version``,
``supported_features``) is an explicit ``int32`` field, not a Struct value.
"""
from typing import Any
from google.protobuf.message import Message
# pylint: disable-next=no-name-in-module
from google.protobuf.struct_pb2 import ListValue, Struct, Value
from ._proto import sandbox_pb2 as pb
# Wire type → (request message class, result message class). The result class
# is ``None`` for one-way pushes (ready / state_changed / fire_event). The
# codec resolves these from ``frame.type`` on both encode and decode.
REGISTRY: dict[str, tuple[type[Message], type[Message] | None]] = {
# handshake (push)
"sandbox/ready": (pb.Ready, None),
# main → sandbox
"sandbox/entry_setup": (pb.EntrySetup, pb.EntrySetupResult),
"sandbox/entry_unload": (pb.EntryUnload, pb.EntryUnloadResult),
"sandbox/call_service": (pb.CallService, pb.CallServiceResult),
"sandbox/entity_query": (pb.EntityQuery, pb.EntityQueryResult),
"sandbox/get_translations": (pb.GetTranslations, pb.GetTranslationsResult),
"sandbox/shutdown": (pb.Shutdown, pb.ShutdownResult),
"sandbox/ping": (pb.Ping, pb.PingResult),
"sandbox/flow_init": (pb.FlowInit, pb.FlowResult),
"sandbox/flow_step": (pb.FlowStep, pb.FlowResult),
"sandbox/flow_abort": (pb.FlowAbort, pb.FlowAbortResult),
# sandbox → main
"sandbox/register_entity": (pb.EntityDescription, pb.RegisterEntityResult),
"sandbox/unregister_entity": (pb.UnregisterEntity, pb.UnregisterEntityResult),
"sandbox/state_changed": (pb.StateChanged, None),
"sandbox/register_service": (pb.RegisterService, pb.RegisterServiceResult),
"sandbox/unregister_service": (
pb.UnregisterService,
pb.UnregisterServiceResult,
),
"sandbox/fire_event": (pb.FireEvent, None),
"sandbox/store_load": (pb.StoreLoad, pb.StoreLoadResult),
"sandbox/store_save": (pb.StoreSave, pb.StoreSaveResult),
"sandbox/store_remove": (pb.StoreRemove, pb.StoreRemoveResult),
}
# --- Struct / ListValue helpers -------------------------------------------
def _value_to_py(value: Value) -> Any:
"""Convert one ``google.protobuf.Value`` into a plain Python value."""
kind = value.WhichOneof("kind")
if kind == "null_value" or kind is None:
return None
if kind == "number_value":
return value.number_value
if kind == "string_value":
return value.string_value
if kind == "bool_value":
return value.bool_value
if kind == "struct_value":
return struct_to_dict(value.struct_value)
return [_value_to_py(item) for item in value.list_value.values]
def struct_to_dict(struct: Struct) -> dict[str, Any]:
"""Convert a ``Struct`` into a plain ``dict`` (empty Struct → ``{}``)."""
return {key: _value_to_py(val) for key, val in struct.fields.items()}
def dict_to_struct(data: dict[str, Any] | None) -> Struct:
"""Convert a ``dict`` (or ``None``) into a ``Struct``."""
struct = Struct()
if data:
struct.update(data)
return struct
def listvalue_to_list(list_value: ListValue) -> list[Any]:
"""Convert a ``ListValue`` into a plain ``list``."""
return [_value_to_py(item) for item in list_value.values]
def list_to_listvalue(items: list[Any] | None) -> ListValue:
"""Convert a ``list`` (or ``None``) into a ``ListValue``."""
list_value = ListValue()
if items:
list_value.extend(items)
return list_value
# --- DeviceInfo bridging --------------------------------------------------
# Scalar string fields of the DeviceInfo proto, copied through verbatim when
# present in the JSON-flattened device_info dict.
_DEVICE_INFO_SCALARS = (
"entry_type",
"name",
"manufacturer",
"model",
"model_id",
"sw_version",
"hw_version",
"serial_number",
"suggested_area",
"configuration_url",
"default_name",
"default_manufacturer",
"default_model",
"translation_key",
)
def device_info_to_proto(flat: dict[str, Any] | None) -> pb.DeviceInfo | None:
"""Build a ``DeviceInfo`` proto from the JSON-flattened device_info dict.
The sandbox-side serializer (``entity_bridge._serialise_device_info``)
already flattens sets/tuples/enums: ``identifiers`` / ``connections`` are
lists of two-element lists, ``via_device`` is a two-element list, and
``entry_type`` is the enum's string value. This maps that shape onto the
explicit proto fields.
"""
if not flat:
return None
info = pb.DeviceInfo()
for key, raw in flat.items():
if raw is None:
continue
if key in ("identifiers", "connections"):
for pair in raw:
if len(pair) == 2:
getattr(info, key).add(key=str(pair[0]), value=str(pair[1]))
elif key == "via_device":
if len(raw) == 2:
info.via_device.key = str(raw[0])
info.via_device.value = str(raw[1])
elif key in _DEVICE_INFO_SCALARS:
setattr(info, key, str(raw))
return info
def make_entity_description(
*,
entry_id: str,
domain: str,
sandbox_entity_id: str,
unique_id: str | None = None,
name: str | None = None,
icon: str | None = None,
has_entity_name: bool = False,
entity_category: str | None = None,
device_class: str | None = None,
supported_features: int = 0,
translation_key: str | None = None,
capabilities: dict[str, Any] | None = None,
initial_state: str | None = None,
initial_attributes: dict[str, Any] | None = None,
device_info: dict[str, Any] | None = None,
) -> pb.EntityDescription:
"""Build a nested ``EntityDescription`` proto from flat fields.
Used by the sandbox entity bridge and by tests so neither has to hand-nest
the ``EntityInfo`` / ``InitialState`` sub-messages. ``device_info`` is the
JSON-flattened dict the entity bridge produces (see
:func:`device_info_to_proto`).
"""
msg = pb.EntityDescription(
entry_id=entry_id,
domain=domain,
sandbox_entity_id=sandbox_entity_id,
has_entity_name=has_entity_name,
)
if unique_id is not None:
msg.unique_id = unique_id
description = msg.info.description
if name is not None:
description.name = name
if icon is not None:
description.icon = icon
if entity_category is not None:
description.entity_category = entity_category
if device_class is not None:
description.device_class = device_class
description.supported_features = int(supported_features or 0)
if translation_key is not None:
description.translation_key = translation_key
device = device_info_to_proto(device_info)
if device is not None:
msg.info.device_info.CopyFrom(device)
if initial_state is not None:
msg.initial.state = initial_state
if capabilities:
msg.initial.capabilities.update(capabilities)
if initial_attributes:
msg.initial.attributes.update(initial_attributes)
return msg
__all__ = [
"REGISTRY",
"device_info_to_proto",
"dict_to_struct",
"list_to_listvalue",
"listvalue_to_list",
"make_entity_description",
"struct_to_dict",
]
@@ -1,143 +0,0 @@
"""Wire-protocol message-type constants.
The integration and the sandbox runtime exchange typed protobuf messages
over the :class:`Channel`. Each message type is namespaced ``sandbox/``;
this module holds the type-string constants. Both sides share the same
names kept here on the HA side and mirrored verbatim in
:mod:`hass_client.protocol` so neither has to import the other.
The wire is protobuf (default codec :class:`~.codec_protobuf.ProtobufCodec`):
each ``type`` maps to a request/result proto message pair in
:mod:`.messages` (the `REGISTRY`), generated from
``sandbox/proto/sandbox.proto``. The payload shapes described below
are the *logical* contract for each call they are carried as those typed
proto messages, not free-form dicts (only genuinely dynamic fields, e.g.
``service_data`` / state attributes / serialized voluptuous schemas, cross
as ``Struct`` / ``ListValue``). The line-oriented :class:`~.channel.JsonCodec`
is retained only as the channel-core test/debug wire.
Main Sandbox calls:
* ``sandbox/entry_setup`` push a serialised :class:`ConfigEntry` into
the sandbox, asking it to load the owning integration and run
``async_setup_entry``. Returns ``{"ok": bool, "reason": str | None}``.
Carries an ``integration_source`` sub-message telling a stateless sandbox
where to fetch the integration code: ``{kind: "builtin"}`` (the bundled
``homeassistant`` package provides it a no-op) or ``{kind: "git", url,
ref, tag, domain, subdir}`` for custom (HACS) integrations. ``ref`` is an
exact commit sha (main pins tagsha; see ``sources.py``); the sandbox
fetches the code before setup (see ``hass_client.sources``).
* ``sandbox/entry_unload`` ask the sandbox to unload an entry by id.
* ``sandbox/call_service`` generic service dispatch (shared with
the mainsandbox service mirroring path). Payload mirrors a
``ServiceCall``: ``(domain, service, target, service_data, context,
return_response)``. Returns either ``None`` or a service-response dict.
* ``sandbox/entity_query`` generic request/response RPC for the
server-side entity queries with no ``SupportsResponse`` service to ride
(media search, update release notes, vacuum segments, the WS-only calendar
event edits). Payload ``{sandbox_entity_id, method, args, context_id}``;
the sandbox resolves the entity, invokes ``method`` with ``args`` as kwargs,
and returns the serialised result wrapped as ``{"value": <return>}``.
Ops that map to a ``SupportsResponse`` service use ``call_service`` instead.
* ``sandbox/get_translations`` pull a sandboxed integration's frontend
translation strings. Payload ``{language, domains: [str]}`` (main batches
every owned custom domain of one group into a single request). Response
``{language, strings: {domain: <raw strings.json dict>}}`` the
un-flattened nesting a ``translations/<lang>.json`` holds, with ``title``
pre-filled from the integration name (main has no ``Integration`` for a
custom domain, so it cannot run that fallback). Built-in domains never
cross the wire main reads its byte-identical disk copy.
Sandbox Main calls:
* ``sandbox/register_entity`` sandbox tells main "I just added an
entity, here's its description". Main builds the proxy and replies
``{"entity_id": <main-side id>}`` so the sandbox can route later
``call_service`` requests back to the right local entity. Optional
``device_info`` field: a JSON-flattened ``DeviceInfo`` dict
sets become lists of two-element lists (``identifiers`` /
``connections``), tuples become lists (``via_device``), and
``entry_type`` is the enum's string value. When present, main calls
:func:`device_registry.async_get_or_create` so the sandbox's devices
surface in main's device_registry tied to the sandboxed entry.
* ``sandbox/unregister_entity`` symmetric counterpart.
* ``sandbox/state_changed`` push (no response). Carries the
marshalled state delta for one entity.
* ``sandbox/register_service`` sandbox tells main "I just
registered a service, please mirror it". Main installs a thin handler
that forwards calls back over the shared ``sandbox/call_service``
channel.
* ``sandbox/unregister_service`` symmetric counterpart.
* ``sandbox/fire_event`` push (no response). The sandbox
forwards each ``<owned_domain>_*`` event so main listeners (notably
``automation``) can react as if the integration ran locally.
* ``sandbox/store_load`` sandbox-side ``Store.async_load``
proxies to this RPC. Payload ``{"key": str}``; response is the wrapped
``{"version", "minor_version", "key", "data"}`` dict the sandbox last
saved, or ``None`` if no data exists yet. The group is implicit from
the channel each :class:`SandboxBridge` only ever serves one group.
* ``sandbox/store_save`` sandbox-side ``Store`` flush.
Payload ``{"key": str, "data": dict}``; main writes the wrapped dict
to ``<config>/.storage/sandbox/<group>/<key>`` atomically. Response
is ``{"ok": True}``.
* ``sandbox/store_remove`` sandbox-side
``Store.async_remove``. Payload ``{"key": str}``; main unlinks the
file (if any). Response is ``{"ok": True}``.
Main Sandbox shutdown:
* ``sandbox/shutdown`` ask the runtime to unload its entries, dump
``RestoreEntity`` state, fire ``EVENT_HOMEASSISTANT_FINAL_WRITE`` so any
pending Stores flush to main via the ``current_sandbox`` store bridge,
and exit cleanly. Response ``{"ok": True, "unloaded": int, "restored":
int}``. The runtime sets its shutdown event right after writing the
reply, so the subprocess exits 0 on its own main only needs SIGTERM
if the round-trip times out.
"""
from typing import Final
# Handshake (Sandbox → Main): the runtime's first frame on the channel.
# Replaces the old ``sandbox:ready`` stdout text marker — the manager
# registers a handler for this push and treats its arrival as "running",
# so stdout carries nothing but channel frames.
MSG_READY: Final = "sandbox/ready"
# Main → Sandbox
MSG_ENTRY_SETUP: Final = "sandbox/entry_setup"
MSG_ENTRY_UNLOAD: Final = "sandbox/entry_unload"
MSG_CALL_SERVICE: Final = "sandbox/call_service"
MSG_ENTITY_QUERY: Final = "sandbox/entity_query"
MSG_GET_TRANSLATIONS: Final = "sandbox/get_translations"
MSG_SHUTDOWN: Final = "sandbox/shutdown"
# Sandbox → Main
MSG_REGISTER_ENTITY: Final = "sandbox/register_entity"
MSG_UNREGISTER_ENTITY: Final = "sandbox/unregister_entity"
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"
MSG_STORE_LOAD: Final = "sandbox/store_load"
MSG_STORE_SAVE: Final = "sandbox/store_save"
MSG_STORE_REMOVE: Final = "sandbox/store_remove"
__all__ = [
"MSG_CALL_SERVICE",
"MSG_ENTITY_QUERY",
"MSG_ENTRY_SETUP",
"MSG_ENTRY_UNLOAD",
"MSG_FIRE_EVENT",
"MSG_GET_TRANSLATIONS",
"MSG_READY",
"MSG_REGISTER_ENTITY",
"MSG_REGISTER_SERVICE",
"MSG_SHUTDOWN",
"MSG_STATE_CHANGED",
"MSG_STORE_LOAD",
"MSG_STORE_REMOVE",
"MSG_STORE_SAVE",
"MSG_UNREGISTER_ENTITY",
"MSG_UNREGISTER_SERVICE",
]
@@ -1,293 +0,0 @@
"""Proxy :class:`ConfigFlow` that forwards every step to a sandbox runtime.
Behaviour:
1. The framework dispatches a flow step by name (``async_step_user``,
``async_step_reauth``, ) on the flow object. We catch *any* such
call via ``__getattr__``.
2. On the **first** call we issue ``sandbox/flow_init`` with the
integration domain plus the initial context/user input; the sandbox
returns its own ``flow_id`` and the initial step's result.
3. **Subsequent** calls go out as ``sandbox/flow_step`` carrying the
sandbox's ``flow_id`` and the user input from the framework.
4. On ``async_remove`` (framework cleanup) we fire
``sandbox/flow_abort`` so the sandbox tears its flow down too.
5. On the CREATE_ENTRY step we attach ``sandbox=<group>`` to the
``ConfigFlowResult`` so the framework's entry constructor sets
:attr:`ConfigEntry.sandbox` before ``async_setup`` runs that's
where the router consults it.
The proxy never touches ``data_schema`` on the wire schema-driven
validation happens *inside* the sandbox where the real schema lives. The
proxy treats the sandbox's reply as authoritative; a re-shown form (with
``errors`` set) is just another ``FORM`` result that the framework will
forward to the user as usual.
"""
import logging
from typing import TYPE_CHECKING, Any
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.data_entry_flow import FlowResultType
from ._proto import sandbox_pb2 as pb
from .channel import ChannelClosedError, ChannelRemoteError
from .messages import dict_to_struct, listvalue_to_list, struct_to_dict
from .schema_bridge import reconstruct_schema
if TYPE_CHECKING:
from .manager import SandboxManager
_LOGGER = logging.getLogger(__name__)
# Holds fire-and-forget abort tasks alive long enough to complete; the
# framework's ``async_remove`` is synchronous so we can't await them inline.
_BACKGROUND_ABORTS: set = set()
class SandboxFlowProxy(ConfigFlow):
"""A flow handler that forwards each step to a sandbox runtime."""
# Marker so other code (e.g. tests) can spot a proxy without isinstance
# importing the sandbox package eagerly.
_is_sandbox_proxy = True
def __init__(
self,
*,
sandbox_group: str,
manager: SandboxManager,
handler_key: str,
) -> None:
"""Initialise the proxy flow."""
super().__init__()
self._sandbox_group = sandbox_group
self._manager = manager
self._handler_key = handler_key
self._sandbox_flow_id: str | None = None
self._terminated: bool = False
@property
def sandbox_group(self) -> str:
"""The sandbox group this in-progress flow forwards to.
Read by the translation provider to resolve a brand-new custom
integration's group before any ``ConfigEntry`` exists.
"""
return self._sandbox_group
def __getattribute__(self, name: str) -> Any:
"""Catch every ``async_step_*`` access and forward to the sandbox.
ConfigFlow's base class already defines several step methods (e.g.
``async_step_user``, ``async_step_ignore``, ``async_step_reauth*``),
so we cannot rely on ``__getattr__`` those names resolve in the
normal MRO before ``__getattr__`` is consulted. ``__getattribute__``
runs for every attribute access; we only re-wrap the
``async_step_*`` family.
"""
if name.startswith("async_step_"):
step_id = name[len("async_step_") :]
forward = object.__getattribute__(self, "_forward_step")
async def _step(
user_input: dict[str, Any] | None = None,
) -> ConfigFlowResult:
return await forward(step_id, user_input)
_step.__name__ = name
return _step
return object.__getattribute__(self, name)
async def _forward_step(
self, step_id: str, user_input: dict[str, Any] | None
) -> ConfigFlowResult:
if self._terminated:
return self.async_abort(reason="sandbox_flow_terminated")
sandbox = await self._manager.ensure_started(self._sandbox_group)
channel = sandbox.channel
if channel is None: # pragma: no cover - manager guarantees this
return self.async_abort(reason="sandbox_unavailable")
try:
if self._sandbox_flow_id is None:
# First step — bootstrap the flow on the sandbox. The
# framework's first call passes the initial data; for a
# USER source this is None. Everything else (REAUTH,
# DISCOVERY, …) gets its discovery payload here.
request = pb.FlowInit(
handler=self._handler_key,
context=dict_to_struct(dict(self.context)),
)
if user_input is not None:
request.data.CopyFrom(dict_to_struct(user_input))
result = await channel.call("sandbox/flow_init", request)
self._sandbox_flow_id = (
result.flow_id if result.HasField("flow_id") else None
)
else:
step = pb.FlowStep(flow_id=self._sandbox_flow_id)
if user_input is not None:
step.user_input.CopyFrom(dict_to_struct(user_input))
result = await channel.call("sandbox/flow_step", step)
except ChannelClosedError:
self._terminated = True
_LOGGER.warning(
"Sandbox %r channel closed mid-flow; aborting %s flow",
self._sandbox_group,
self._handler_key,
)
return self.async_abort(reason="sandbox_unavailable")
except ChannelRemoteError as err:
_LOGGER.warning(
"Sandbox %r raised %s on %s step %s: %s",
self._sandbox_group,
err.error_type or "error",
self._handler_key,
step_id,
err,
)
return self.async_abort(reason="sandbox_flow_error")
await self._apply_remote_context(result)
return self._adapt_result(result, step_id)
async def _apply_remote_context(self, result: pb.FlowResult) -> None:
"""Mirror ``unique_id`` (and other context bits) onto our own flow.
The sandbox's :meth:`ConfigFlow.async_set_unique_id` mutates the
sandbox flow's ``context["unique_id"]``; the flow-runner surfaces
it in the marshalled result. We pass it through
:meth:`async_set_unique_id` so main's duplicate detection fires
(it raises :class:`AbortFlow` for an in-progress collision,
which the flow framework turns into an ABORT result).
"""
if not result.HasField("context"):
return
remote = struct_to_dict(result.context)
if "unique_id" not in remote:
return
unique_id = remote["unique_id"]
if self.context.get("unique_id") == unique_id:
return
# ``async_set_unique_id`` raises ``AbortFlow("already_in_progress")``
# if another flow for the same handler already has this unique
# id; that's exactly the duplicate-rejection signal we want.
await self.async_set_unique_id(unique_id)
def _adapt_result(self, result: pb.FlowResult, step_id: str) -> ConfigFlowResult:
"""Translate a sandbox-side ``FlowResult`` message into a main-side one.
The sandbox's ``flow_id`` and ``handler`` are replaced with main's
view (so HA's frontend / FlowManager keep tracking the proxy
flow), and CREATE_ENTRY data is tagged with the sandbox group so
the setup interceptor knows where to route the entry.
"""
result_type = FlowResultType(result.type)
placeholders = (
struct_to_dict(result.description_placeholders)
if result.HasField("description_placeholders")
else None
)
if result_type is FlowResultType.CREATE_ENTRY:
entry_data = struct_to_dict(result.data)
self._terminated = True
create_result = self.async_create_entry(
title=(
result.title
if result.HasField("title") and result.title
else self._handler_key
),
data=entry_data,
description=(
result.description if result.HasField("description") else None
),
description_placeholders=placeholders,
)
# Tag the FlowResult so the framework's entry constructor in
# ``ConfigEntriesFlowManager.async_finish_flow`` reads it into
# ``ConfigEntry.sandbox`` — this lands the tag *before*
# ``async_setup`` runs, where the router needs it.
create_result["sandbox"] = self._sandbox_group
return create_result
if result_type is FlowResultType.ABORT:
self._terminated = True
return self.async_abort(
reason=(
result.reason if result.HasField("reason") else "sandbox_aborted"
),
description_placeholders=placeholders,
)
if result_type is FlowResultType.FORM:
data_schema = reconstruct_schema(listvalue_to_list(result.data_schema))
if data_schema is None and result.has_data_schema:
_LOGGER.debug(
"Sandbox %r returned a FORM with an unserialisable"
" data_schema; rendering schema-less",
self._sandbox_group,
)
errors = (
struct_to_dict(result.errors) if result.HasField("errors") else None
)
return self.async_show_form(
step_id=result.step_id if result.HasField("step_id") else step_id,
data_schema=data_schema,
errors=errors or None,
description_placeholders=placeholders,
last_step=result.last_step if result.HasField("last_step") else None,
preview=result.preview if result.HasField("preview") else None,
)
# Any other type (MENU, EXTERNAL_STEP, SHOW_PROGRESS, …) is
# not supported; surface a noisy abort so a follow-up doesn't
# silently drop the flow on the floor.
self._terminated = True
_LOGGER.warning(
"Sandbox %r returned unsupported flow result type %s for %s;"
" aborting (only FORM/CREATE_ENTRY/ABORT are supported)",
self._sandbox_group,
result_type,
self._handler_key,
)
return self.async_abort(reason="sandbox_unsupported_result_type")
def async_remove(self) -> None:
"""Tell the sandbox to drop its flow when the framework discards us."""
if self._sandbox_flow_id is None or self._terminated:
return
sandbox = self._manager.get(self._sandbox_group)
channel = sandbox.channel if sandbox is not None else None
if channel is None:
return
# async_remove is a sync framework callback, but we're inside a
# running HA loop — schedule the abort and move on.
import asyncio # noqa: PLC0415
flow_id = self._sandbox_flow_id
self._terminated = True
try:
loop = asyncio.get_running_loop()
except RuntimeError:
# Called outside an event loop (teardown path); nothing useful
# we can do — the sandbox's flow will GC when the process dies.
return
task = loop.create_task(
_safe_abort(channel, flow_id, self._sandbox_group, self._handler_key)
)
_BACKGROUND_ABORTS.add(task)
task.add_done_callback(_BACKGROUND_ABORTS.discard)
async def _safe_abort(channel: Any, flow_id: str, group: str, handler: str) -> None:
"""Fire ``flow_abort`` on the sandbox and swallow errors."""
try:
await channel.call("sandbox/flow_abort", pb.FlowAbort(flow_id=flow_id))
except (ChannelClosedError, ChannelRemoteError) as err:
_LOGGER.debug("Sandbox %r flow_abort for %s failed: %s", group, handler, err)
__all__ = ["SandboxFlowProxy"]
-237
View File
@@ -1,237 +0,0 @@
"""Main-side :class:`ConfigEntryRouter` implementation.
Bridges :class:`homeassistant.config_entries.ConfigEntries` to the sandbox
manager:
* New flows for sandboxed integrations are diverted to a
:class:`SandboxFlowProxy` that forwards each step over the sandbox's
control :class:`Channel`.
* Existing config-entry setup is intercepted when ``entry.sandbox`` is
set the entry is handed to the sandbox manager and pushed into the
sandbox runtime via ``sandbox/entry_setup``.
The router treats classifier output as the source of truth for which
sandbox a new entry should go into. Once an entry exists, the
``sandbox`` field stored on it wins (so a re-classification later
doesn't yank a running entry into a different sandbox).
"""
import logging
from typing import TYPE_CHECKING, Any
from homeassistant.config_entries import (
ConfigEntry,
ConfigEntryState,
ConfigFlow,
ConfigFlowContext,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.translation import async_invalidate_translations
from homeassistant.loader import async_get_integration
from ._proto import sandbox_pb2 as pb
from .channel import ChannelClosedError, ChannelRemoteError
from .classifier import SandboxAssignment, classify
from .manager import SandboxManager
from .messages import dict_to_struct
from .protocol import MSG_ENTRY_SETUP, MSG_ENTRY_UNLOAD
from .proxy_flow import SandboxFlowProxy
from .sources import SandboxSourceError, async_resolve_integration_source
if TYPE_CHECKING:
from . import SandboxData
_LOGGER = logging.getLogger(__name__)
class SandboxFlowRouter:
"""Route config flows and entry setup to sandbox processes.
Structurally implements the :class:`ConfigEntryRouter` Protocol from
``homeassistant.config_entries``; declared as a plain class so the
sandbox integration does not pull a runtime dependency on the
protocol's import side-effects.
"""
def __init__(
self,
hass: HomeAssistant,
manager: SandboxManager,
*,
data: SandboxData | None = None,
) -> None:
"""Initialise the router with the active sandbox manager."""
self._hass = hass
self._manager = manager
self._data = data
async def async_create_flow(
self,
handler_key: str,
*,
context: ConfigFlowContext,
data: Any,
) -> ConfigFlow | None:
"""Return a :class:`SandboxFlowProxy` if the integration is sandboxed."""
assignment = await self._assignment_for_new_flow(handler_key)
if assignment.is_main:
return None
assert assignment.group is not None
return SandboxFlowProxy(
sandbox_group=assignment.group,
manager=self._manager,
handler_key=handler_key,
)
async def async_setup_entry(self, entry: ConfigEntry) -> bool | None:
"""Hand a sandboxed entry to the manager and run its setup remotely."""
group = entry.sandbox
if group is None:
return None
try:
sandbox = await self._manager.ensure_started(group)
except Exception:
_LOGGER.exception(
"Sandbox group %r failed to start for entry %s (%s)",
group,
entry.title,
entry.domain,
)
entry._async_set_state( # noqa: SLF001
self._hass, ConfigEntryState.SETUP_ERROR, "Sandbox failed to start"
)
return False
channel = sandbox.channel
if channel is None:
_LOGGER.error(
"Sandbox %r has no live channel for entry %s (%s)",
group,
entry.title,
entry.domain,
)
entry._async_set_state( # noqa: SLF001
self._hass, ConfigEntryState.SETUP_ERROR, "Sandbox channel down"
)
return False
try:
payload = await _entry_setup_payload(self._hass, entry)
except SandboxSourceError as err:
_LOGGER.error(
"Cannot resolve integration source for entry %s (%s): %s",
entry.title,
entry.domain,
err,
)
entry._async_set_state( # noqa: SLF001
self._hass, ConfigEntryState.SETUP_ERROR, str(err)
)
return False
try:
result = await channel.call(MSG_ENTRY_SETUP, payload)
except ChannelClosedError:
entry._async_set_state( # noqa: SLF001
self._hass,
ConfigEntryState.SETUP_RETRY,
"Sandbox channel closed during setup",
)
return False
except ChannelRemoteError as err:
entry._async_set_state( # noqa: SLF001
self._hass,
ConfigEntryState.SETUP_ERROR,
f"Sandbox raised {err.error_type or 'error'}: {err.error}",
)
return False
if not result.ok:
reason = (
result.reason if result.HasField("reason") else "sandbox refused setup"
)
entry._async_set_state( # noqa: SLF001
self._hass, ConfigEntryState.SETUP_ERROR, reason
)
return False
entry._async_set_state(self._hass, ConfigEntryState.LOADED, None) # noqa: SLF001
return True
async def async_unload_entry(self, entry: ConfigEntry) -> bool | None:
"""Push the unload back to the sandbox if the entry is sandboxed.
Returns ``None`` for non-sandbox entries so the normal HA unload
path runs.
"""
group = entry.sandbox
if group is None:
return None
# A reload re-fetches the integration code (possibly at a new commit
# ref) and re-runs setup, so its translation strings may have changed.
# Drop the cached strings; the next frontend fetch re-pulls them.
async_invalidate_translations(self._hass, {entry.domain})
sandbox = self._manager.get(group)
if sandbox is None or sandbox.channel is None:
return True
try:
result = await sandbox.channel.call(
MSG_ENTRY_UNLOAD, pb.EntryUnload(entry_id=entry.entry_id)
)
except ChannelClosedError, ChannelRemoteError:
_LOGGER.exception(
"Sandbox %r failed to unload entry %s (%s)",
group,
entry.title,
entry.domain,
)
return False
if self._data is not None:
bridge = self._data.bridges.get(group)
if bridge is not None:
await bridge.async_unload_entry(entry)
return result.ok
async def _assignment_for_new_flow(self, handler_key: str) -> SandboxAssignment:
"""Decide where a new flow for ``handler_key`` should run.
First an existing entry's ``sandbox`` wins (so a flow for a
domain that already has sandboxed entries goes to the same
sandbox). Otherwise the classifier picks.
"""
for existing in self._hass.config_entries.async_entries(handler_key):
if (group := existing.sandbox) is not None:
return SandboxAssignment(group=group)
integration = await async_get_integration(self._hass, handler_key)
return classify(integration)
async def _entry_setup_payload(
hass: HomeAssistant, entry: ConfigEntry
) -> pb.EntrySetup:
"""Build the typed ``EntrySetup`` message for ``sandbox/entry_setup``.
Surfaces the small subset of entry fields the integration's
``async_setup_entry`` reads, plus the ``integration_source`` descriptor
telling a stateless sandbox where to fetch the code (built-in no-op;
custom a git source pinned to an exact sha). May raise
:class:`SandboxSourceError` if a custom integration has no source resolver.
"""
msg = pb.EntrySetup(
entry_id=entry.entry_id,
domain=entry.domain,
title=entry.title,
data=dict_to_struct(dict(entry.data)),
options=dict_to_struct(dict(entry.options)),
source=entry.source,
version=entry.version,
minor_version=entry.minor_version,
)
if entry.unique_id is not None:
msg.unique_id = entry.unique_id
msg.integration_source.CopyFrom(
await async_resolve_integration_source(hass, entry.domain)
)
return msg
__all__ = ["SandboxFlowRouter"]
@@ -1,122 +0,0 @@
"""Main-side reconstruction of voluptuous schemas serialised by the sandbox.
The sandbox sends a list-of-fields rendering (the same shape
:func:`voluptuous_serialize.convert` would produce against
:func:`cv.custom_serializer`). We rebuild a :class:`vol.Schema` from it
so:
* :meth:`hass.services.async_register` gets a real schema (good input
passes, blatantly bad input is rejected before we round-trip to the
sandbox).
* The flow-manager view's :func:`_prepare_result_json` can re-render the
same list back through :func:`voluptuous_serialize.convert` for the
frontend.
Selectors and expandable sections are rebuilt as the **real**
:class:`selector.Selector` / :class:`data_entry_flow.section` objects, so
when the flow manager re-serialises main's reconstructed schema for the
frontend it reproduces the sandbox's original list verbatim (the form
renders with the right widget instead of a bare text box). Only genuinely
unknown field types fall through to a pass-through validator.
"""
from collections.abc import Iterable
import logging
from typing import Any
import voluptuous as vol
from homeassistant import data_entry_flow
from homeassistant.helpers import selector
_LOGGER = logging.getLogger(__name__)
_SCHEMA_TYPES_BY_NAME: dict[str, type] = {
"string": str,
"integer": int,
"float": float,
"boolean": bool,
}
def reconstruct_schema(
serialized: list[dict[str, Any]] | None,
) -> vol.Schema | None:
"""Build a :class:`vol.Schema` from the wire form.
Returns ``None`` for an empty list (no fields) or ``None`` input so
callers can short-circuit straight to ``schema=None``.
"""
if not serialized:
return None
fields: dict[Any, Any] = {}
for entry in serialized:
name = entry.get("name")
if name is None:
continue
marker_cls = vol.Required if entry.get("required") else vol.Optional
kwargs: dict[str, Any] = {}
if "default" in entry:
kwargs["default"] = entry["default"]
if "description" in entry:
kwargs["description"] = entry["description"]
marker = marker_cls(name, **kwargs)
fields[marker] = _validator_from_entry(entry)
return vol.Schema(fields)
def _validator_from_entry(entry: dict[str, Any]) -> Any:
"""Inverse of :func:`voluptuous_serialize.convert` per field.
Rebuilds the real object where re-serialising it has to reproduce the
original (selectors, sections) and falls back to a pass-through for
anything we can't faithfully reconstruct.
"""
# A selector field carries its config under ``selector`` (no ``type``);
# rebuild the real Selector so it re-serialises to the same shape.
if "selector" in entry:
try:
return selector.selector(entry["selector"])
except vol.Invalid:
_LOGGER.warning(
"Could not rebuild selector from %r; using pass-through",
entry["selector"],
)
return _passthrough
type_name = entry.get("type")
if type_name == "expandable":
# An ``data_entry_flow.section`` — rebuild it with its nested schema
# so the frontend still renders the collapsible section.
nested = reconstruct_schema(entry.get("schema")) or vol.Schema({})
collapsed = not entry.get("expanded", True)
return data_entry_flow.section(nested, {"collapsed": collapsed})
if type_name in _SCHEMA_TYPES_BY_NAME:
return _SCHEMA_TYPES_BY_NAME[type_name]
if type_name == "select":
options = entry.get("options") or []
values = _select_values(options)
if values:
return vol.In(values)
# Constants, datetime/format, and other shapes we don't reconstruct —
# the sandbox owns the strict validator; on main, accept any value so
# the caller's payload reaches the sandbox-side handler.
return _passthrough
def _select_values(options: Iterable[Any]) -> list[Any]:
"""Pull the value half out of a serialised select's ``options``."""
out: list[Any] = []
for opt in options:
if isinstance(opt, (list, tuple)) and opt:
out.append(opt[0])
else:
out.append(opt)
return out
def _passthrough(value: Any) -> Any:
"""Identity validator — sandbox-side handler does the real validation."""
return value
__all__ = ["reconstruct_schema"]
@@ -1,12 +0,0 @@
# Sandbox does not declare any user-facing services.
#
# The integration calls hass.services.async_register dynamically (see
# bridge.py::SandboxBridge._handle_register_service) to install forwarders
# that route each sandboxed integration's service back to the sandbox
# subprocess over the sandbox/call_service channel. Those services are
# owned by the sandboxed integrations themselves, not by sandbox, and
# their schemas + descriptions live with those integrations.
#
# This file exists to satisfy hassfest's "Registers services but has no
# services.yaml" gate, which uses a regex grep that can't tell static and
# dynamic registrations apart.
-152
View File
@@ -1,152 +0,0 @@
"""Main-side integration-source resolution for stateless sandboxes.
A sandbox holds no persistent state. The last stateful bit was the
integration *code*: built-ins ride the bundled ``homeassistant`` package, but
custom (HACS) integrations live under ``<config>/custom_components`` on the
main install and are absent from a fresh sandbox. This module lets main tell
the sandbox *where to fetch the code* on ``entry_setup``; the sandbox fetches
it before setup (see ``hass_client.sources``).
Core stays HACS-agnostic via a registered-resolver hook (decision (c),
2026-06-03): HACS or any other distribution mechanism registers a
resolver mapping a custom domain to a git source. Core ships only the
builtin-vs-git decision; with no resolver registered the default is
builtin-only, and a custom domain raises rather than silently falling back.
Security / tagsha contract: the ``ref`` that crosses the wire must be an
exact commit sha, never a moving tag. Core performs **no network I/O** here,
so the resolver is responsible for pinning the installed version to a sha and
returning it in ``ref`` (HACS already knows the sha of what the user
installed). ``tag`` is informational only (logs). If a resolver returns a git
source without a ``ref``, that is an error main refuses to ship a sandbox a
moving reference.
"""
from collections.abc import Callable
import logging
from typing import TypedDict
from homeassistant.core import HomeAssistant, callback
from homeassistant.loader import async_get_integration
from homeassistant.util.hass_dict import HassKey
from ._proto import sandbox_pb2 as pb
_LOGGER = logging.getLogger(__name__)
class IntegrationSourceDict(TypedDict, total=False):
"""The dict shape a resolver returns for a custom (git) integration.
``kind`` is always ``"git"`` (built-ins never reach a resolver). ``url``
and ``ref`` (an exact commit sha) are required; ``domain`` and ``subdir``
default from the domain being resolved when omitted.
"""
kind: str
url: str
ref: str
tag: str
domain: str
subdir: str
# A resolver maps a custom integration domain to its git source, or ``None``
# if it does not know that domain. Called only for non-built-in integrations.
SandboxSourceResolver = Callable[[str], IntegrationSourceDict | None]
DATA_SOURCE_RESOLVERS: HassKey[list[SandboxSourceResolver]] = HassKey(
"sandbox_source_resolvers"
)
class SandboxSourceError(Exception):
"""Raised when an integration's source cannot be resolved."""
@callback
def async_register_sandbox_source_resolver(
hass: HomeAssistant, resolver: SandboxSourceResolver
) -> Callable[[], None]:
"""Register a resolver mapping a custom domain to its git source.
HACS (or any custom-integration distribution mechanism) calls this to
teach the sandbox where to fetch code from. Resolvers are consulted in
registration order; the first to return a non-``None`` source wins. The
resolver MUST pin ``ref`` to an exact commit sha (see module docstring).
Returns a callback that unregisters the resolver.
"""
resolvers = hass.data.setdefault(DATA_SOURCE_RESOLVERS, [])
resolvers.append(resolver)
@callback
def _unregister() -> None:
resolvers.remove(resolver)
return _unregister
async def async_resolve_integration_source(
hass: HomeAssistant, domain: str
) -> pb.IntegrationSource:
"""Resolve the source descriptor for ``domain``'s code.
Built-in integrations short-circuit to ``{kind: "builtin"}`` (the bundled
``homeassistant`` package provides them). For a custom integration the
registered resolvers are consulted in order; the first git source returned
is used. If no resolver knows the domain, raises :class:`SandboxSourceError`
a custom integration with no source cannot run in a stateless sandbox, so
the failure is surfaced rather than masked.
"""
integration = await async_get_integration(hass, domain)
if integration.is_built_in:
return pb.IntegrationSource(kind="builtin")
for resolver in hass.data.get(DATA_SOURCE_RESOLVERS, []):
source = resolver(domain)
if source is not None:
return _git_source_from_dict(domain, source)
raise SandboxSourceError(
f"no sandbox source resolver knows custom integration {domain!r}; "
"a custom integration cannot run in a stateless sandbox without one"
)
def _git_source_from_dict(
domain: str, source: IntegrationSourceDict
) -> pb.IntegrationSource:
"""Build a typed git ``IntegrationSource`` from a resolver's dict.
Validates the tagsha pinning contract: ``url`` and an exact-sha ``ref``
are required. ``domain`` and ``subdir`` default from ``domain``.
"""
url = source.get("url")
if not url:
raise SandboxSourceError(
f"resolver returned a git source for {domain!r} without a url"
)
ref = source.get("ref")
if not ref:
raise SandboxSourceError(
f"resolver returned a git source for {domain!r} without a ref; "
"the resolver must pin the version to an exact commit sha"
)
return pb.IntegrationSource(
kind="git",
url=url,
ref=ref,
tag=source.get("tag", ""),
domain=source.get("domain", domain),
subdir=source.get("subdir", f"custom_components/{domain}"),
)
__all__ = [
"IntegrationSourceDict",
"SandboxSourceError",
"SandboxSourceResolver",
"async_register_sandbox_source_resolver",
"async_resolve_integration_source",
]

Some files were not shown because too many files have changed in this diff Show More