Compare commits
46 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 28f41a2310 | |||
| b6f38c3cbb | |||
| a0162d2ff0 | |||
| b6f018873b | |||
| 43e21322ea | |||
| 86ccc59a5f | |||
| 2fce2547c7 | |||
| 6b40278d08 | |||
| 05bb8b94fa | |||
| 5ac3a8cdde | |||
| 266fccf0cf | |||
| a1e6a6f9a2 | |||
| 2fe406c6ff | |||
| e1249fef8f | |||
| 6f61e97f8e | |||
| b65751e8ac | |||
| ef4bf77b24 | |||
| 977a9ecdd2 | |||
| 9e79eba970 | |||
| 40073e598c | |||
| 627d5cc110 | |||
| b1dbeca9ed | |||
| 059bc8d676 | |||
| 085f794407 | |||
| 3996db289d | |||
| 291585e48e | |||
| d9a125ce9b | |||
| 786c957909 | |||
| dd6830f1c5 | |||
| 4dbe58afc6 | |||
| 6c72d4337d | |||
| fcff5229d9 | |||
| 8edd813d4b | |||
| 509866c0eb | |||
| 9db5860d6b | |||
| 6917223cb3 | |||
| cc4637a703 | |||
| 2b0d14d71e | |||
| d0d85d8844 | |||
| eea3d9d4c4 | |||
| 48a690b267 | |||
| 07dc2346de | |||
| 711830b01f | |||
| f9fea56a8c | |||
| 8aac0c5b6e | |||
| f2361ef5aa |
+7
-7
@@ -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 }}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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"],
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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."]
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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." }]
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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: |
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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}"
|
||||
)
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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] = {}
|
||||
|
||||
@@ -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: ...
|
||||
@@ -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 sandbox→main 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",
|
||||
]
|
||||
@@ -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"]
|
||||
@@ -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")
|
||||
@@ -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 tag→sha; 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 main→sandbox 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"]
|
||||
@@ -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.
|
||||
@@ -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 / tag→sha 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 tag→sha 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
Reference in New Issue
Block a user