mirror of
https://github.com/home-assistant/core.git
synced 2026-05-24 17:55:24 +02:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 36ee46ca8a | |||
| bfd36858f9 | |||
| 55d7892369 | |||
| 0539296683 |
Generated
+2
@@ -945,6 +945,8 @@ CLAUDE.md @home-assistant/core
|
||||
/tests/components/knx/ @Julius2342 @farmio @marvin-w
|
||||
/homeassistant/components/kodi/ @OnFreund
|
||||
/tests/components/kodi/ @OnFreund
|
||||
/homeassistant/components/konnected/ @heythisisnate
|
||||
/tests/components/konnected/ @heythisisnate
|
||||
/homeassistant/components/kostal_plenticore/ @stegm
|
||||
/tests/components/kostal_plenticore/ @stegm
|
||||
/homeassistant/components/kraken/ @eifinger
|
||||
|
||||
@@ -459,7 +459,6 @@ class AuthManager:
|
||||
token_type: str | None = None,
|
||||
access_token_expiration: timedelta = ACCESS_TOKEN_EXPIRATION,
|
||||
credential: models.Credentials | None = None,
|
||||
scopes: frozenset[str] | None = None,
|
||||
) -> models.RefreshToken:
|
||||
"""Create a new refresh token for a user."""
|
||||
if not user.is_active:
|
||||
@@ -515,7 +514,6 @@ class AuthManager:
|
||||
access_token_expiration,
|
||||
expire_at,
|
||||
credential,
|
||||
scopes,
|
||||
)
|
||||
|
||||
@callback
|
||||
|
||||
@@ -211,7 +211,6 @@ class AuthStore:
|
||||
access_token_expiration: timedelta = ACCESS_TOKEN_EXPIRATION,
|
||||
expire_at: float | None = None,
|
||||
credential: models.Credentials | None = None,
|
||||
scopes: frozenset[str] | None = None,
|
||||
) -> models.RefreshToken:
|
||||
"""Create a new token for a user."""
|
||||
kwargs: dict[str, Any] = {
|
||||
@@ -221,7 +220,6 @@ class AuthStore:
|
||||
"access_token_expiration": access_token_expiration,
|
||||
"expire_at": expire_at,
|
||||
"credential": credential,
|
||||
"scopes": scopes,
|
||||
}
|
||||
if client_name:
|
||||
kwargs["client_name"] = client_name
|
||||
@@ -477,7 +475,6 @@ class AuthStore:
|
||||
else:
|
||||
last_used_at = None
|
||||
|
||||
scopes = rt_dict.get("scopes")
|
||||
token = models.RefreshToken(
|
||||
id=rt_dict["id"],
|
||||
user=users[rt_dict["user_id"]],
|
||||
@@ -496,7 +493,6 @@ class AuthStore:
|
||||
last_used_ip=rt_dict.get("last_used_ip"),
|
||||
expire_at=rt_dict.get("expire_at"),
|
||||
version=rt_dict.get("version"),
|
||||
scopes=frozenset(scopes) if scopes else None,
|
||||
)
|
||||
if "credential_id" in rt_dict:
|
||||
token.credential = credentials.get(rt_dict["credential_id"])
|
||||
@@ -585,9 +581,6 @@ class AuthStore:
|
||||
if refresh_token.credential
|
||||
else None,
|
||||
"version": refresh_token.version,
|
||||
"scopes": sorted(refresh_token.scopes)
|
||||
if refresh_token.scopes is not None
|
||||
else None,
|
||||
}
|
||||
for user in self._users.values()
|
||||
for refresh_token in user.refresh_tokens.values()
|
||||
|
||||
@@ -129,13 +129,6 @@ class RefreshToken:
|
||||
|
||||
version: str | None = attr.ib(default=__version__)
|
||||
|
||||
# Optional set of websocket-API command scopes. ``None`` means the token
|
||||
# has no scope restriction (the default for normal user/system tokens).
|
||||
# When set, the token may only call commands matching an entry in the
|
||||
# set: a scope ending in ``/`` matches any command whose type starts
|
||||
# with the prefix; otherwise the scope is an exact ``type`` match.
|
||||
scopes: frozenset[str] | None = attr.ib(default=None)
|
||||
|
||||
|
||||
@attr.s(slots=True)
|
||||
class Credentials:
|
||||
|
||||
@@ -39,6 +39,7 @@ from homeassistant.const import (
|
||||
ATTR_DEVICE_CLASS,
|
||||
ATTR_SUPPORTED_FEATURES,
|
||||
ATTR_UNIT_OF_MEASUREMENT,
|
||||
CLOUD_NEVER_EXPOSED_ENTITIES,
|
||||
CONF_DESCRIPTION,
|
||||
CONF_NAME,
|
||||
UnitOfTemperature,
|
||||
@@ -372,6 +373,9 @@ def async_get_entities(
|
||||
"""Return all entities that are supported by Alexa."""
|
||||
entities: list[AlexaEntity] = []
|
||||
for state in hass.states.async_all():
|
||||
if state.entity_id in CLOUD_NEVER_EXPOSED_ENTITIES:
|
||||
continue
|
||||
|
||||
if state.domain not in ENTITY_ADAPTERS:
|
||||
continue
|
||||
|
||||
|
||||
@@ -5,8 +5,12 @@ from typing import Any
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components import labs, websocket_api
|
||||
from homeassistant.const import EVENT_HOMEASSISTANT_STARTED
|
||||
from homeassistant.core import Event, HomeAssistant, callback
|
||||
from homeassistant.components.hassio import HassioNotReadyError
|
||||
from homeassistant.config_entries import SOURCE_SYSTEM, ConfigEntry
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
from homeassistant.helpers import discovery_flow
|
||||
from homeassistant.helpers.start import async_at_started
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
from homeassistant.util.hass_dict import HassKey
|
||||
|
||||
@@ -49,6 +53,7 @@ CONFIG_SCHEMA = vol.Schema(
|
||||
)
|
||||
|
||||
DATA_COMPONENT: HassKey[Analytics] = HassKey(DOMAIN)
|
||||
_DATA_SNAPSHOTS_URL: HassKey[str | None] = HassKey(f"{DOMAIN}_snapshots_url")
|
||||
|
||||
LABS_SNAPSHOT_FEATURE = "snapshots"
|
||||
|
||||
@@ -57,18 +62,39 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
"""Set up the analytics integration."""
|
||||
analytics_config = config.get(DOMAIN, {})
|
||||
|
||||
snapshots_url: str | None = None
|
||||
if CONF_SNAPSHOTS_URL in analytics_config:
|
||||
await labs.async_update_preview_feature(
|
||||
hass, DOMAIN, LABS_SNAPSHOT_FEATURE, enabled=True
|
||||
)
|
||||
snapshots_url = analytics_config[CONF_SNAPSHOTS_URL]
|
||||
else:
|
||||
snapshots_url = None
|
||||
|
||||
hass.data[_DATA_SNAPSHOTS_URL] = snapshots_url
|
||||
|
||||
discovery_flow.async_create_flow(
|
||||
hass, DOMAIN, context={"source": SOURCE_SYSTEM}, data={}
|
||||
)
|
||||
|
||||
websocket_api.async_register_command(hass, websocket_analytics)
|
||||
websocket_api.async_register_command(hass, websocket_analytics_preferences)
|
||||
|
||||
hass.http.register_view(AnalyticsDevicesView)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Set up Analytics from a config entry."""
|
||||
snapshots_url = hass.data.get(_DATA_SNAPSHOTS_URL)
|
||||
analytics = Analytics(hass, snapshots_url)
|
||||
|
||||
# Load stored data
|
||||
await analytics.load()
|
||||
try:
|
||||
await analytics.load()
|
||||
except HassioNotReadyError as err:
|
||||
raise ConfigEntryNotReady(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="supervisor_not_ready",
|
||||
) from err
|
||||
|
||||
started = False
|
||||
|
||||
@@ -80,26 +106,30 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
if started:
|
||||
await analytics.async_schedule()
|
||||
|
||||
async def start_schedule(_event: Event) -> None:
|
||||
"""Start the send schedule after the started event."""
|
||||
async def start_schedule(hass: HomeAssistant) -> None:
|
||||
"""Start the send schedule once Home Assistant has started."""
|
||||
nonlocal started
|
||||
started = True
|
||||
await analytics.async_schedule()
|
||||
|
||||
labs.async_subscribe_preview_feature(
|
||||
hass, DOMAIN, LABS_SNAPSHOT_FEATURE, _async_handle_labs_update
|
||||
entry.async_on_unload(
|
||||
labs.async_subscribe_preview_feature(
|
||||
hass, DOMAIN, LABS_SNAPSHOT_FEATURE, _async_handle_labs_update
|
||||
)
|
||||
)
|
||||
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STARTED, start_schedule)
|
||||
|
||||
websocket_api.async_register_command(hass, websocket_analytics)
|
||||
websocket_api.async_register_command(hass, websocket_analytics_preferences)
|
||||
|
||||
hass.http.register_view(AnalyticsDevicesView)
|
||||
entry.async_on_unload(async_at_started(hass, start_schedule))
|
||||
|
||||
hass.data[DATA_COMPONENT] = analytics
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Unload an Analytics config entry."""
|
||||
analytics = hass.data.pop(DATA_COMPONENT)
|
||||
analytics.cancel_scheduled()
|
||||
return True
|
||||
|
||||
|
||||
@callback
|
||||
@websocket_api.require_admin
|
||||
@websocket_api.websocket_command({vol.Required("type"): "analytics"})
|
||||
@@ -109,7 +139,9 @@ def websocket_analytics(
|
||||
msg: dict[str, Any],
|
||||
) -> None:
|
||||
"""Return analytics preferences."""
|
||||
analytics = hass.data[DATA_COMPONENT]
|
||||
if (analytics := hass.data.get(DATA_COMPONENT)) is None:
|
||||
connection.send_error(msg["id"], websocket_api.ERR_NOT_FOUND, "Not loaded")
|
||||
return
|
||||
connection.send_result(
|
||||
msg["id"],
|
||||
{ATTR_PREFERENCES: analytics.preferences, ATTR_ONBOARDED: analytics.onboarded},
|
||||
@@ -130,8 +162,10 @@ async def websocket_analytics_preferences(
|
||||
msg: dict[str, Any],
|
||||
) -> None:
|
||||
"""Update analytics preferences."""
|
||||
if (analytics := hass.data.get(DATA_COMPONENT)) is None:
|
||||
connection.send_error(msg["id"], websocket_api.ERR_NOT_FOUND, "Not loaded")
|
||||
return
|
||||
preferences = msg[ATTR_PREFERENCES]
|
||||
analytics = hass.data[DATA_COMPONENT]
|
||||
|
||||
await analytics.save_preferences(preferences)
|
||||
await analytics.async_schedule()
|
||||
|
||||
@@ -299,12 +299,8 @@ class Analytics:
|
||||
self._data = AnalyticsData.from_dict(stored)
|
||||
|
||||
if self.supervisor and not self.onboarded:
|
||||
# This may raise HassioNotReadyError if Supervisor was unreachable
|
||||
# during setup of the Supervisor integration. That will fail setup
|
||||
# of this integration. However there is no better option at this time
|
||||
# since we need to get the diagnostic setting from Supervisor to correctly
|
||||
# setup this integration and we can't raise ConfigEntryNotReady to
|
||||
# trigger a retry from async_setup.
|
||||
# This may raise HassioNotReadyError if Supervisor was unreachable.
|
||||
# The caller is responsible for handling this and triggering a retry.
|
||||
supervisor_info = hassio.get_supervisor_info(self._hass)
|
||||
|
||||
# User have not configured analytics, get this setting from the supervisor
|
||||
@@ -349,8 +345,7 @@ class Analytics:
|
||||
await self._save()
|
||||
|
||||
if self.supervisor:
|
||||
# get_supervisor_info was called during setup so we can't get here
|
||||
# if it raised. The others may raise HassioNotReadyError if only some
|
||||
# The others may raise HassioNotReadyError if only some
|
||||
# data was successfully fetched from Supervisor
|
||||
supervisor_info = hassio.get_supervisor_info(hass)
|
||||
with contextlib.suppress(hassio.HassioNotReadyError):
|
||||
@@ -630,6 +625,16 @@ class Analytics:
|
||||
err,
|
||||
)
|
||||
|
||||
@callback
|
||||
def cancel_scheduled(self) -> None:
|
||||
"""Cancel all scheduled analytics tasks."""
|
||||
if self._basic_scheduled is not None:
|
||||
self._basic_scheduled()
|
||||
self._basic_scheduled = None
|
||||
if self._snapshot_scheduled is not None:
|
||||
self._snapshot_scheduled()
|
||||
self._snapshot_scheduled = None
|
||||
|
||||
async def async_schedule(self) -> None:
|
||||
"""Schedule analytics."""
|
||||
if not self.onboarded:
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
"""Config flow for Analytics integration."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
|
||||
class AnalyticsConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow for Analytics."""
|
||||
|
||||
VERSION = 1
|
||||
|
||||
async def async_step_system(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle the initial step."""
|
||||
return self.async_create_entry(title="Analytics", data={})
|
||||
@@ -3,6 +3,7 @@
|
||||
"name": "Analytics",
|
||||
"after_dependencies": ["energy", "hassio", "recorder"],
|
||||
"codeowners": ["@home-assistant/core"],
|
||||
"config_flow": true,
|
||||
"dependencies": ["api", "websocket_api", "http"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/analytics",
|
||||
"integration_type": "system",
|
||||
@@ -14,5 +15,6 @@
|
||||
"report_issue_url": "https://github.com/OHF-Device-Database/device-database/issues/new"
|
||||
}
|
||||
},
|
||||
"quality_scale": "internal"
|
||||
"quality_scale": "internal",
|
||||
"single_config_entry": true
|
||||
}
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
{
|
||||
"exceptions": {
|
||||
"supervisor_not_ready": {
|
||||
"message": "Supervisor was not ready during setup, will retry"
|
||||
}
|
||||
},
|
||||
"preview_features": {
|
||||
"snapshots": {
|
||||
"description": "We're creating the [Open Home Foundation Device Database](https://www.home-assistant.io/blog/2026/02/02/about-device-database/): a free, open source community-powered resource to help users find practical information about how smart home devices perform in real installations.\n\nYou can help us build it by opting in to share anonymized data about your devices. This data will only ever include device-specific details (like model or manufacturer) – never personally identifying information (like the names you assign).\n\nFind out how we process your data (should you choose to contribute) in our [Data Use Statement](https://www.openhomefoundation.org/device-database-data-use-statement).",
|
||||
|
||||
@@ -16,13 +16,6 @@ from homeassistant.helpers.service_info.ssdp import ATTR_UPNP_UDN, SsdpServiceIn
|
||||
|
||||
from .const import DEFAULT_NAME, DEFAULT_PORT, DOMAIN
|
||||
|
||||
STEP_DATA_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_HOST): str,
|
||||
vol.Required(CONF_PORT, default=DEFAULT_PORT): int,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class ArcamFmjFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle config flow."""
|
||||
@@ -38,22 +31,13 @@ class ArcamFmjFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
await self.async_set_unique_id(uuid)
|
||||
self._abort_if_unique_id_configured({CONF_HOST: host, CONF_PORT: port})
|
||||
|
||||
async def _async_try_connect(self, host: str, port: int) -> dict[str, str]:
|
||||
"""Verify the device is reachable; return errors keyed by reason."""
|
||||
async def _async_try_connect(self, host: str, port: int) -> None:
|
||||
"""Verify the device is reachable."""
|
||||
client = Client(host, port)
|
||||
try:
|
||||
await client.start()
|
||||
except socket.gaierror:
|
||||
return {"base": "invalid_host"}
|
||||
except TimeoutError:
|
||||
return {"base": "timeout_connect"}
|
||||
except ConnectionRefusedError:
|
||||
return {"base": "connection_refused"}
|
||||
except ConnectionFailed, OSError:
|
||||
return {"base": "cannot_connect"}
|
||||
finally:
|
||||
await client.stop()
|
||||
return {}
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
@@ -69,10 +53,19 @@ class ArcamFmjFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
user_input[CONF_HOST], user_input[CONF_PORT], uuid
|
||||
)
|
||||
|
||||
errors = await self._async_try_connect(
|
||||
user_input[CONF_HOST], user_input[CONF_PORT]
|
||||
)
|
||||
if not errors:
|
||||
try:
|
||||
await self._async_try_connect(
|
||||
user_input[CONF_HOST], user_input[CONF_PORT]
|
||||
)
|
||||
except socket.gaierror:
|
||||
errors["base"] = "invalid_host"
|
||||
except TimeoutError:
|
||||
errors["base"] = "timeout_connect"
|
||||
except ConnectionRefusedError:
|
||||
errors["base"] = "connection_refused"
|
||||
except ConnectionFailed, OSError:
|
||||
errors["base"] = "cannot_connect"
|
||||
else:
|
||||
return self.async_create_entry(
|
||||
title=f"{DEFAULT_NAME} ({user_input[CONF_HOST]})",
|
||||
data={
|
||||
@@ -81,46 +74,16 @@ class ArcamFmjFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
},
|
||||
)
|
||||
|
||||
schema = STEP_DATA_SCHEMA
|
||||
fields = {
|
||||
vol.Required(CONF_HOST): str,
|
||||
vol.Required(CONF_PORT, default=DEFAULT_PORT): int,
|
||||
}
|
||||
schema = vol.Schema(fields)
|
||||
if user_input is not None:
|
||||
schema = self.add_suggested_values_to_schema(schema, user_input)
|
||||
|
||||
return self.async_show_form(step_id="user", data_schema=schema, errors=errors)
|
||||
|
||||
async def async_step_reconfigure(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle reconfiguration of an existing entry."""
|
||||
errors: dict[str, str] = {}
|
||||
reconfigure_entry = self._get_reconfigure_entry()
|
||||
if user_input is not None:
|
||||
uuid = await get_uniqueid_from_host(
|
||||
async_get_clientsession(self.hass), user_input[CONF_HOST]
|
||||
)
|
||||
if uuid:
|
||||
await self.async_set_unique_id(uuid)
|
||||
self._abort_if_unique_id_mismatch()
|
||||
|
||||
errors = await self._async_try_connect(
|
||||
user_input[CONF_HOST], user_input[CONF_PORT]
|
||||
)
|
||||
if not errors:
|
||||
return self.async_update_reload_and_abort(
|
||||
reconfigure_entry,
|
||||
data_updates={
|
||||
CONF_HOST: user_input[CONF_HOST],
|
||||
CONF_PORT: user_input[CONF_PORT],
|
||||
},
|
||||
)
|
||||
|
||||
schema = self.add_suggested_values_to_schema(
|
||||
STEP_DATA_SCHEMA, user_input or reconfigure_entry.data
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="reconfigure", data_schema=schema, errors=errors
|
||||
)
|
||||
|
||||
async def async_step_confirm(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
@@ -150,7 +113,9 @@ class ArcamFmjFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
|
||||
await self._async_set_unique_id_and_update(host, port, uuid)
|
||||
|
||||
if await self._async_try_connect(host, port):
|
||||
try:
|
||||
await self._async_try_connect(host, port)
|
||||
except ConnectionFailed, OSError:
|
||||
return self.async_abort(reason="cannot_connect")
|
||||
|
||||
self.host = host
|
||||
|
||||
@@ -3,9 +3,7 @@
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
|
||||
"already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]",
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]",
|
||||
"unique_id_mismatch": "Please ensure you reconfigure against the same device."
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]"
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
@@ -18,13 +16,6 @@
|
||||
"confirm": {
|
||||
"description": "Do you want to add Arcam FMJ on `{host}` to Home Assistant?"
|
||||
},
|
||||
"reconfigure": {
|
||||
"data": {
|
||||
"host": "[%key:common::config_flow::data::host%]",
|
||||
"port": "[%key:common::config_flow::data::port%]"
|
||||
},
|
||||
"description": "[%key:component::arcam_fmj::config::step::user::description%]"
|
||||
},
|
||||
"user": {
|
||||
"data": {
|
||||
"host": "[%key:common::config_flow::data::host%]",
|
||||
|
||||
@@ -21,6 +21,6 @@
|
||||
"bluetooth-auto-recovery==1.5.3",
|
||||
"bluetooth-data-tools==1.29.11",
|
||||
"dbus-fast==5.0.3",
|
||||
"habluetooth==6.4.0"
|
||||
"habluetooth==6.2.0"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@ from homeassistant.components.homeassistant.exposed_entities import (
|
||||
async_should_expose,
|
||||
)
|
||||
from homeassistant.components.sensor import SensorDeviceClass
|
||||
from homeassistant.const import CLOUD_NEVER_EXPOSED_ENTITIES
|
||||
from homeassistant.core import Event, HomeAssistant, callback, split_entity_id
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import entity_registry as er, start
|
||||
@@ -274,6 +275,9 @@ class CloudAlexaConfig(alexa_config.AbstractConfig):
|
||||
|
||||
def _should_expose_legacy(self, entity_id: str) -> bool:
|
||||
"""If an entity should be exposed."""
|
||||
if entity_id in CLOUD_NEVER_EXPOSED_ENTITIES:
|
||||
return False
|
||||
|
||||
entity_configs = self._prefs.alexa_entity_configs
|
||||
entity_config = entity_configs.get(entity_id, {})
|
||||
entity_expose: bool | None = entity_config.get(PREF_SHOULD_EXPOSE)
|
||||
@@ -304,6 +308,8 @@ class CloudAlexaConfig(alexa_config.AbstractConfig):
|
||||
"""If an entity should be exposed."""
|
||||
entity_filter: EntityFilter = self._config[CONF_FILTER]
|
||||
if not entity_filter.empty_filter:
|
||||
if entity_id in CLOUD_NEVER_EXPOSED_ENTITIES:
|
||||
return False
|
||||
return entity_filter(entity_id)
|
||||
|
||||
return async_should_expose(self.hass, CLOUD_ALEXA, entity_id)
|
||||
|
||||
@@ -22,6 +22,7 @@ from homeassistant.components.homeassistant.exposed_entities import (
|
||||
async_should_expose,
|
||||
)
|
||||
from homeassistant.components.sensor import SensorDeviceClass
|
||||
from homeassistant.const import CLOUD_NEVER_EXPOSED_ENTITIES
|
||||
from homeassistant.core import (
|
||||
CoreState,
|
||||
Event,
|
||||
@@ -281,6 +282,9 @@ class CloudGoogleConfig(AbstractConfig):
|
||||
|
||||
def _should_expose_legacy(self, entity_id: str) -> bool:
|
||||
"""If an entity ID should be exposed."""
|
||||
if entity_id in CLOUD_NEVER_EXPOSED_ENTITIES:
|
||||
return False
|
||||
|
||||
entity_configs = self._prefs.google_entity_configs
|
||||
entity_config = entity_configs.get(entity_id, {})
|
||||
entity_expose: bool | None = entity_config.get(PREF_SHOULD_EXPOSE)
|
||||
@@ -312,6 +316,8 @@ class CloudGoogleConfig(AbstractConfig):
|
||||
"""If an entity should be exposed."""
|
||||
entity_filter: EntityFilter = self._config[CONF_FILTER]
|
||||
if not entity_filter.empty_filter:
|
||||
if entity_id in CLOUD_NEVER_EXPOSED_ENTITIES:
|
||||
return False
|
||||
return entity_filter(entity_id)
|
||||
|
||||
return async_should_expose(self.hass, CLOUD_GOOGLE, entity_id)
|
||||
|
||||
@@ -29,6 +29,7 @@ from homeassistant.components.homeassistant import exposed_entities
|
||||
from homeassistant.components.http import KEY_HASS, HomeAssistantView, require_admin
|
||||
from homeassistant.components.http.data_validator import RequestDataValidator
|
||||
from homeassistant.components.system_health import get_info as get_system_health_info
|
||||
from homeassistant.const import CLOUD_NEVER_EXPOSED_ENTITIES
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
@@ -972,7 +973,7 @@ async def google_assistant_get(
|
||||
return
|
||||
|
||||
entity = google_helpers.GoogleEntity(hass, gconf, state)
|
||||
if not entity.is_supported():
|
||||
if entity_id in CLOUD_NEVER_EXPOSED_ENTITIES or not entity.is_supported():
|
||||
connection.send_error(
|
||||
msg["id"],
|
||||
websocket_api.ERR_NOT_SUPPORTED,
|
||||
@@ -1074,7 +1075,9 @@ async def alexa_get(
|
||||
"""Get data for a single alexa entity."""
|
||||
entity_id: str = msg["entity_id"]
|
||||
|
||||
if not entity_supported_by_alexa(hass, entity_id):
|
||||
if entity_id in CLOUD_NEVER_EXPOSED_ENTITIES or not entity_supported_by_alexa(
|
||||
hass, entity_id
|
||||
):
|
||||
connection.send_error(
|
||||
msg["id"],
|
||||
websocket_api.ERR_NOT_SUPPORTED,
|
||||
|
||||
@@ -17,9 +17,9 @@
|
||||
"mqtt": ["esphome/discover/#"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": [
|
||||
"aioesphomeapi==45.1.0",
|
||||
"aioesphomeapi==45.0.4",
|
||||
"esphome-dashboard-api==1.3.0",
|
||||
"bleak-esphome==3.8.1"
|
||||
"bleak-esphome==3.7.3"
|
||||
],
|
||||
"zeroconf": ["_esphomelib._tcp.local."]
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ from .coordinator import FloDeviceDataUpdateCoordinator
|
||||
class FloEntity(Entity):
|
||||
"""A base class for Flo entities."""
|
||||
|
||||
_attr_force_update = False
|
||||
_attr_has_entity_name = True
|
||||
_attr_should_poll = False
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ from homeassistant.components import webhook
|
||||
from homeassistant.const import (
|
||||
ATTR_DEVICE_CLASS,
|
||||
ATTR_SUPPORTED_FEATURES,
|
||||
CLOUD_NEVER_EXPOSED_ENTITIES,
|
||||
CONF_NAME,
|
||||
STATE_UNAVAILABLE,
|
||||
)
|
||||
@@ -802,6 +803,8 @@ def async_get_entities(
|
||||
is_supported_cache = config.is_supported_cache
|
||||
for state in hass.states.async_all():
|
||||
entity_id = state.entity_id
|
||||
if entity_id in CLOUD_NEVER_EXPOSED_ENTITIES:
|
||||
continue
|
||||
# Check check inlined for performance to avoid
|
||||
# function calls for every entity since we enumerate
|
||||
# the entire state machine here
|
||||
|
||||
@@ -12,6 +12,7 @@ import jwt
|
||||
|
||||
from homeassistant.components import webhook
|
||||
from homeassistant.components.http import KEY_HASS, HomeAssistantView
|
||||
from homeassistant.const import CLOUD_NEVER_EXPOSED_ENTITIES
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
@@ -166,6 +167,9 @@ class GoogleConfig(AbstractConfig):
|
||||
# Ignore entities that are views
|
||||
return False
|
||||
|
||||
if state.entity_id in CLOUD_NEVER_EXPOSED_ENTITIES:
|
||||
return False
|
||||
|
||||
entity_registry = er.async_get(self.hass)
|
||||
registry_entry = entity_registry.async_get(state.entity_id)
|
||||
if registry_entry:
|
||||
|
||||
@@ -116,7 +116,7 @@ async def _validate_auth(
|
||||
|
||||
def _get_current_hosts(entry: HeosConfigEntry) -> set[str]:
|
||||
"""Get a set of current hosts from the entry."""
|
||||
hosts = {entry.data[CONF_HOST]}
|
||||
hosts = set(entry.data[CONF_HOST])
|
||||
if hasattr(entry, "runtime_data"):
|
||||
hosts.update(
|
||||
player.ip_address
|
||||
|
||||
@@ -473,7 +473,6 @@ class HeosMediaPlayer(CoordinatorEntity[HeosCoordinator], MediaPlayerEntity):
|
||||
await self.coordinator.heos.set_group(new_members)
|
||||
return
|
||||
|
||||
@catch_action_error("remove from queue")
|
||||
async def async_remove_from_queue(self, queue_ids: list[int]) -> None:
|
||||
"""Remove items from the queue."""
|
||||
await self._player.remove_from_queue(queue_ids)
|
||||
|
||||
@@ -10,6 +10,7 @@ import voluptuous as vol
|
||||
from homeassistant.components import websocket_api
|
||||
from homeassistant.components.binary_sensor import BinarySensorDeviceClass
|
||||
from homeassistant.components.sensor import SensorDeviceClass
|
||||
from homeassistant.const import CLOUD_NEVER_EXPOSED_ENTITIES
|
||||
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback, split_entity_id
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
@@ -245,6 +246,9 @@ class ExposedEntities:
|
||||
"""Return True if an entity should be exposed to an assistant."""
|
||||
should_expose: bool
|
||||
|
||||
if entity_id in CLOUD_NEVER_EXPOSED_ENTITIES:
|
||||
return False
|
||||
|
||||
entity_registry = er.async_get(self._hass)
|
||||
if not (registry_entry := entity_registry.async_get(entity_id)):
|
||||
return self._async_should_expose_legacy_entity(assistant, entity_id)
|
||||
@@ -402,6 +406,19 @@ def ws_expose_entity(
|
||||
"""Expose an entity to an assistant."""
|
||||
entity_ids: list[str] = msg["entity_ids"]
|
||||
|
||||
if blocked := next(
|
||||
(
|
||||
entity_id
|
||||
for entity_id in entity_ids
|
||||
if entity_id in CLOUD_NEVER_EXPOSED_ENTITIES
|
||||
),
|
||||
None,
|
||||
):
|
||||
connection.send_error(
|
||||
msg["id"], websocket_api.ERR_NOT_ALLOWED, f"can't expose '{blocked}'"
|
||||
)
|
||||
return
|
||||
|
||||
for entity_id in entity_ids:
|
||||
for assistant in msg["assistants"]:
|
||||
async_expose_entity(hass, assistant, entity_id, msg["should_expose"])
|
||||
|
||||
@@ -1,53 +1,450 @@
|
||||
"""The Konnected.io integration."""
|
||||
"""Support for Konnected devices."""
|
||||
# pylint: disable=home-assistant-use-runtime-data # Uses legacy hass.data[DOMAIN] pattern
|
||||
|
||||
import copy
|
||||
import hmac
|
||||
from http import HTTPStatus
|
||||
import json
|
||||
import logging
|
||||
|
||||
from aiohttp.hdrs import AUTHORIZATION
|
||||
from aiohttp.web import Request, Response
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry, ConfigEntryState
|
||||
from homeassistant import config_entries
|
||||
from homeassistant.components.binary_sensor import DEVICE_CLASSES_SCHEMA
|
||||
from homeassistant.components.http import KEY_HASS, HomeAssistantView
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import (
|
||||
ATTR_ENTITY_ID,
|
||||
CONF_ACCESS_TOKEN,
|
||||
CONF_BINARY_SENSORS,
|
||||
CONF_DEVICES,
|
||||
CONF_DISCOVERY,
|
||||
CONF_HOST,
|
||||
CONF_ID,
|
||||
CONF_NAME,
|
||||
CONF_PIN,
|
||||
CONF_PORT,
|
||||
CONF_REPEAT,
|
||||
CONF_SENSORS,
|
||||
CONF_SWITCHES,
|
||||
CONF_TYPE,
|
||||
CONF_ZONE,
|
||||
STATE_OFF,
|
||||
STATE_ON,
|
||||
Platform,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import config_validation as cv, issue_registry as ir
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
from .const import DOMAIN
|
||||
from .config_flow import ( # Loading the config flow file will register the flow
|
||||
CONF_DEFAULT_OPTIONS,
|
||||
CONF_IO,
|
||||
CONF_IO_BIN,
|
||||
CONF_IO_DIG,
|
||||
CONF_IO_SWI,
|
||||
OPTIONS_SCHEMA,
|
||||
)
|
||||
from .const import (
|
||||
CONF_ACTIVATION,
|
||||
CONF_API_HOST,
|
||||
CONF_BLINK,
|
||||
CONF_INVERSE,
|
||||
CONF_MOMENTARY,
|
||||
CONF_PAUSE,
|
||||
CONF_POLL_INTERVAL,
|
||||
DOMAIN,
|
||||
PIN_TO_ZONE,
|
||||
STATE_HIGH,
|
||||
STATE_LOW,
|
||||
UPDATE_ENDPOINT,
|
||||
ZONE_TO_PIN,
|
||||
ZONES,
|
||||
)
|
||||
from .handlers import HANDLERS
|
||||
from .panel import AlarmPanel
|
||||
|
||||
CONFIG_SCHEMA = vol.Schema({DOMAIN: cv.match_all}, extra=vol.ALLOW_EXTRA)
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def ensure_pin(value):
|
||||
"""Check if valid pin and coerce to string."""
|
||||
if value is None:
|
||||
raise vol.Invalid("pin value is None")
|
||||
|
||||
if PIN_TO_ZONE.get(str(value)) is None:
|
||||
raise vol.Invalid("pin not valid")
|
||||
|
||||
return str(value)
|
||||
|
||||
|
||||
def ensure_zone(value):
|
||||
"""Check if valid zone and coerce to string."""
|
||||
if value is None:
|
||||
raise vol.Invalid("zone value is None")
|
||||
|
||||
if str(value) not in ZONES:
|
||||
raise vol.Invalid("zone not valid")
|
||||
|
||||
return str(value)
|
||||
|
||||
|
||||
def import_device_validator(config):
|
||||
"""Validate zones and reformat for import."""
|
||||
config = copy.deepcopy(config)
|
||||
io_cfgs = {}
|
||||
# Replace pins with zones
|
||||
for conf_platform, conf_io in (
|
||||
(CONF_BINARY_SENSORS, CONF_IO_BIN),
|
||||
(CONF_SENSORS, CONF_IO_DIG),
|
||||
(CONF_SWITCHES, CONF_IO_SWI),
|
||||
):
|
||||
for zone in config.get(conf_platform, []):
|
||||
if zone.get(CONF_PIN):
|
||||
zone[CONF_ZONE] = PIN_TO_ZONE[zone[CONF_PIN]]
|
||||
del zone[CONF_PIN]
|
||||
io_cfgs[zone[CONF_ZONE]] = conf_io
|
||||
|
||||
# Migrate config_entry data into default_options structure
|
||||
config[CONF_IO] = io_cfgs
|
||||
config[CONF_DEFAULT_OPTIONS] = OPTIONS_SCHEMA(config)
|
||||
|
||||
# clean up fields migrated to options
|
||||
config.pop(CONF_BINARY_SENSORS, None)
|
||||
config.pop(CONF_SENSORS, None)
|
||||
config.pop(CONF_SWITCHES, None)
|
||||
config.pop(CONF_BLINK, None)
|
||||
config.pop(CONF_DISCOVERY, None)
|
||||
config.pop(CONF_API_HOST, None)
|
||||
config.pop(CONF_IO, None)
|
||||
return config
|
||||
|
||||
|
||||
def import_validator(config):
|
||||
"""Reformat for import."""
|
||||
config = copy.deepcopy(config)
|
||||
|
||||
# push api_host into device configs
|
||||
for device in config.get(CONF_DEVICES, []):
|
||||
device[CONF_API_HOST] = config.get(CONF_API_HOST, "")
|
||||
|
||||
return config
|
||||
|
||||
|
||||
# configuration.yaml schemas (legacy)
|
||||
BINARY_SENSOR_SCHEMA_YAML = vol.All(
|
||||
vol.Schema(
|
||||
{
|
||||
vol.Exclusive(CONF_ZONE, "s_io"): ensure_zone,
|
||||
vol.Exclusive(CONF_PIN, "s_io"): ensure_pin,
|
||||
vol.Required(CONF_TYPE): DEVICE_CLASSES_SCHEMA,
|
||||
vol.Optional(CONF_NAME): cv.string,
|
||||
vol.Optional(CONF_INVERSE, default=False): cv.boolean,
|
||||
}
|
||||
),
|
||||
cv.has_at_least_one_key(CONF_PIN, CONF_ZONE),
|
||||
)
|
||||
|
||||
SENSOR_SCHEMA_YAML = vol.All(
|
||||
vol.Schema(
|
||||
{
|
||||
vol.Exclusive(CONF_ZONE, "s_io"): ensure_zone,
|
||||
vol.Exclusive(CONF_PIN, "s_io"): ensure_pin,
|
||||
vol.Required(CONF_TYPE): vol.All(vol.Lower, vol.In(["dht", "ds18b20"])),
|
||||
vol.Optional(CONF_NAME): cv.string,
|
||||
vol.Optional(CONF_POLL_INTERVAL, default=3): vol.All(
|
||||
vol.Coerce(int), vol.Range(min=1)
|
||||
),
|
||||
}
|
||||
),
|
||||
cv.has_at_least_one_key(CONF_PIN, CONF_ZONE),
|
||||
)
|
||||
|
||||
SWITCH_SCHEMA_YAML = vol.All(
|
||||
vol.Schema(
|
||||
{
|
||||
vol.Exclusive(CONF_ZONE, "s_io"): ensure_zone,
|
||||
vol.Exclusive(CONF_PIN, "s_io"): ensure_pin,
|
||||
vol.Optional(CONF_NAME): cv.string,
|
||||
vol.Optional(CONF_ACTIVATION, default=STATE_HIGH): vol.All(
|
||||
vol.Lower, vol.Any(STATE_HIGH, STATE_LOW)
|
||||
),
|
||||
vol.Optional(CONF_MOMENTARY): vol.All(vol.Coerce(int), vol.Range(min=10)),
|
||||
vol.Optional(CONF_PAUSE): vol.All(vol.Coerce(int), vol.Range(min=10)),
|
||||
vol.Optional(CONF_REPEAT): vol.All(vol.Coerce(int), vol.Range(min=-1)),
|
||||
}
|
||||
),
|
||||
cv.has_at_least_one_key(CONF_PIN, CONF_ZONE),
|
||||
)
|
||||
|
||||
DEVICE_SCHEMA_YAML = vol.All(
|
||||
vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_ID): cv.matches_regex("[0-9a-f]{12}"),
|
||||
vol.Optional(CONF_BINARY_SENSORS): vol.All(
|
||||
cv.ensure_list, [BINARY_SENSOR_SCHEMA_YAML]
|
||||
),
|
||||
vol.Optional(CONF_SENSORS): vol.All(cv.ensure_list, [SENSOR_SCHEMA_YAML]),
|
||||
vol.Optional(CONF_SWITCHES): vol.All(cv.ensure_list, [SWITCH_SCHEMA_YAML]),
|
||||
vol.Inclusive(CONF_HOST, "host_info"): cv.string,
|
||||
vol.Inclusive(CONF_PORT, "host_info"): cv.port,
|
||||
vol.Optional(CONF_BLINK, default=True): cv.boolean,
|
||||
vol.Optional(CONF_API_HOST, default=""): vol.Any("", cv.url),
|
||||
vol.Optional(CONF_DISCOVERY, default=True): cv.boolean,
|
||||
}
|
||||
),
|
||||
import_device_validator,
|
||||
)
|
||||
|
||||
CONFIG_SCHEMA = vol.Schema(
|
||||
{
|
||||
DOMAIN: vol.All(
|
||||
import_validator,
|
||||
vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_ACCESS_TOKEN): cv.string,
|
||||
vol.Optional(CONF_API_HOST): vol.Url(),
|
||||
vol.Optional(CONF_DEVICES): vol.All(
|
||||
cv.ensure_list, [DEVICE_SCHEMA_YAML]
|
||||
),
|
||||
}
|
||||
),
|
||||
)
|
||||
},
|
||||
extra=vol.ALLOW_EXTRA,
|
||||
)
|
||||
|
||||
YAML_CONFIGS = "yaml_configs"
|
||||
PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR, Platform.SWITCH]
|
||||
|
||||
|
||||
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
"""Set up the Konnected.io integration."""
|
||||
if DOMAIN in config:
|
||||
_create_issue(hass)
|
||||
"""Set up the Konnected platform."""
|
||||
ir.async_create_issue(
|
||||
hass,
|
||||
DOMAIN,
|
||||
"deprecated_firmware",
|
||||
breaks_in_ha_version="2026.4.0",
|
||||
is_fixable=False,
|
||||
issue_domain=DOMAIN,
|
||||
severity=ir.IssueSeverity.WARNING,
|
||||
translation_key="deprecated_firmware",
|
||||
translation_placeholders={
|
||||
"kb_page_url": "https://support.konnected.io/migrating-from-konnected-legacy-home-assistant-integration-to-esphome",
|
||||
},
|
||||
)
|
||||
if (cfg := config.get(DOMAIN)) is None:
|
||||
cfg = {}
|
||||
|
||||
if DOMAIN not in hass.data:
|
||||
hass.data[DOMAIN] = {
|
||||
CONF_ACCESS_TOKEN: cfg.get(CONF_ACCESS_TOKEN),
|
||||
CONF_API_HOST: cfg.get(CONF_API_HOST),
|
||||
CONF_DEVICES: {},
|
||||
}
|
||||
|
||||
hass.http.register_view(KonnectedView)
|
||||
|
||||
# Check if they have yaml configured devices
|
||||
if CONF_DEVICES not in cfg:
|
||||
return True
|
||||
|
||||
for device in cfg.get(CONF_DEVICES, []):
|
||||
# Attempt to importing the cfg. Use
|
||||
# hass.async_add_job to avoid a deadlock.
|
||||
hass.async_create_task(
|
||||
hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, data=device
|
||||
)
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Set up Konnected.io from a config entry."""
|
||||
_create_issue(hass)
|
||||
"""Set up panel from a config entry."""
|
||||
client = AlarmPanel(hass, entry)
|
||||
# creates a panel data store in hass.data[DOMAIN][CONF_DEVICES]
|
||||
await client.async_save_data()
|
||||
|
||||
# if the cfg entry was created we know we could connect to the panel at some point
|
||||
# async_connect will handle retries until it establishes a connection
|
||||
await client.async_connect()
|
||||
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
|
||||
entry.async_on_unload(entry.add_update_listener(async_entry_updated))
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Unload a config entry."""
|
||||
if all(
|
||||
config_entry.state is ConfigEntryState.NOT_LOADED
|
||||
for config_entry in hass.config_entries.async_entries(DOMAIN)
|
||||
if config_entry.entry_id != entry.entry_id
|
||||
):
|
||||
ir.async_delete_issue(hass, DOMAIN, DOMAIN)
|
||||
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
|
||||
return True
|
||||
if unload_ok:
|
||||
hass.data[DOMAIN][CONF_DEVICES].pop(entry.data[CONF_ID])
|
||||
|
||||
return unload_ok
|
||||
|
||||
|
||||
def _create_issue(hass: HomeAssistant) -> None:
|
||||
"""Create the integration removed repair issue."""
|
||||
ir.async_create_issue(
|
||||
hass,
|
||||
DOMAIN,
|
||||
DOMAIN,
|
||||
is_fixable=False,
|
||||
severity=ir.IssueSeverity.ERROR,
|
||||
translation_key="integration_removed",
|
||||
translation_placeholders={
|
||||
"entries": "/config/integrations/integration/konnected",
|
||||
"kb_page_url": "https://support.konnected.io/migrating-from-konnected-legacy-home-assistant-integration-to-esphome",
|
||||
},
|
||||
)
|
||||
async def async_entry_updated(hass: HomeAssistant, entry: ConfigEntry) -> None:
|
||||
"""Reload the config entry when options change."""
|
||||
await hass.config_entries.async_reload(entry.entry_id)
|
||||
|
||||
|
||||
class KonnectedView(HomeAssistantView):
|
||||
"""View creates an endpoint to receive push updates from the device."""
|
||||
|
||||
url = UPDATE_ENDPOINT
|
||||
name = "api:konnected"
|
||||
requires_auth = False # Uses access token from configuration
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize the view."""
|
||||
|
||||
@staticmethod
|
||||
def binary_value(state, activation):
|
||||
"""Return binary value for GPIO based on state and activation."""
|
||||
if activation == STATE_HIGH:
|
||||
return 1 if state == STATE_ON else 0
|
||||
return 0 if state == STATE_ON else 1
|
||||
|
||||
async def update_sensor(self, request: Request, device_id) -> Response:
|
||||
"""Process a put or post."""
|
||||
hass = request.app[KEY_HASS]
|
||||
data = hass.data[DOMAIN]
|
||||
|
||||
auth = request.headers.get(AUTHORIZATION)
|
||||
tokens = []
|
||||
if hass.data[DOMAIN].get(CONF_ACCESS_TOKEN):
|
||||
tokens.extend([hass.data[DOMAIN][CONF_ACCESS_TOKEN]])
|
||||
tokens.extend(
|
||||
[
|
||||
entry.data[CONF_ACCESS_TOKEN]
|
||||
for entry in hass.config_entries.async_entries(DOMAIN)
|
||||
if entry.data.get(CONF_ACCESS_TOKEN)
|
||||
]
|
||||
)
|
||||
if auth is None or not next(
|
||||
(True for token in tokens if hmac.compare_digest(f"Bearer {token}", auth)),
|
||||
False,
|
||||
):
|
||||
return self.json_message(
|
||||
"unauthorized", status_code=HTTPStatus.UNAUTHORIZED
|
||||
)
|
||||
|
||||
try: # Konnected 2.2.0 and above supports JSON payloads
|
||||
payload = await request.json()
|
||||
except json.decoder.JSONDecodeError:
|
||||
_LOGGER.error(
|
||||
"Your Konnected device software may be out of "
|
||||
"date. Visit https://help.konnected.io for "
|
||||
"updating instructions"
|
||||
)
|
||||
|
||||
if (device := data[CONF_DEVICES].get(device_id)) is None:
|
||||
return self.json_message(
|
||||
"unregistered device", status_code=HTTPStatus.BAD_REQUEST
|
||||
)
|
||||
|
||||
if (panel := device.get("panel")) is not None:
|
||||
# connect if we haven't already
|
||||
hass.async_create_task(panel.async_connect())
|
||||
|
||||
try:
|
||||
zone_num = str(payload.get(CONF_ZONE) or PIN_TO_ZONE[payload[CONF_PIN]])
|
||||
payload[CONF_ZONE] = zone_num
|
||||
zone_data = (
|
||||
device[CONF_BINARY_SENSORS].get(zone_num)
|
||||
or next(
|
||||
(s for s in device[CONF_SWITCHES] if s[CONF_ZONE] == zone_num), None
|
||||
)
|
||||
or next(
|
||||
(s for s in device[CONF_SENSORS] if s[CONF_ZONE] == zone_num), None
|
||||
)
|
||||
)
|
||||
except KeyError:
|
||||
zone_data = None
|
||||
|
||||
if zone_data is None:
|
||||
return self.json_message(
|
||||
"unregistered sensor/actuator", status_code=HTTPStatus.BAD_REQUEST
|
||||
)
|
||||
|
||||
zone_data["device_id"] = device_id
|
||||
|
||||
for attr in ("state", "temp", "humi", "addr"):
|
||||
value = payload.get(attr)
|
||||
handler = HANDLERS.get(attr)
|
||||
if value is not None and handler:
|
||||
hass.async_create_task(handler(hass, zone_data, payload))
|
||||
|
||||
return self.json_message("ok")
|
||||
|
||||
async def get(self, request: Request, device_id) -> Response:
|
||||
"""Return the current binary state of a switch."""
|
||||
hass = request.app[KEY_HASS]
|
||||
data = hass.data[DOMAIN]
|
||||
|
||||
if not (device := data[CONF_DEVICES].get(device_id)):
|
||||
return self.json_message(
|
||||
f"Device {device_id} not configured", status_code=HTTPStatus.NOT_FOUND
|
||||
)
|
||||
|
||||
if (panel := device.get("panel")) is not None:
|
||||
# connect if we haven't already
|
||||
hass.async_create_task(panel.async_connect())
|
||||
|
||||
# Our data model is based on zone ids but we convert from/to pin ids
|
||||
# based on whether they are specified in the request
|
||||
try:
|
||||
zone_num = str(
|
||||
request.query.get(CONF_ZONE) or PIN_TO_ZONE[request.query[CONF_PIN]]
|
||||
)
|
||||
zone = next(
|
||||
switch
|
||||
for switch in device[CONF_SWITCHES]
|
||||
if switch[CONF_ZONE] == zone_num
|
||||
)
|
||||
|
||||
except StopIteration:
|
||||
zone = None
|
||||
except KeyError:
|
||||
zone = None
|
||||
zone_num = None
|
||||
|
||||
if not zone:
|
||||
target = request.query.get(
|
||||
CONF_ZONE, request.query.get(CONF_PIN, "unknown")
|
||||
)
|
||||
return self.json_message(
|
||||
f"Switch on zone or pin {target} not configured",
|
||||
status_code=HTTPStatus.NOT_FOUND,
|
||||
)
|
||||
|
||||
resp = {}
|
||||
if request.query.get(CONF_ZONE):
|
||||
resp[CONF_ZONE] = zone_num
|
||||
elif zone_num:
|
||||
resp[CONF_PIN] = ZONE_TO_PIN[zone_num]
|
||||
|
||||
# Make sure entity is setup
|
||||
if zone_entity_id := zone.get(ATTR_ENTITY_ID):
|
||||
resp["state"] = self.binary_value(
|
||||
hass.states.get(zone_entity_id).state, # type: ignore[union-attr]
|
||||
zone[CONF_ACTIVATION],
|
||||
)
|
||||
return self.json(resp)
|
||||
|
||||
_LOGGER.warning("Konnected entity not yet setup, returning default")
|
||||
resp["state"] = self.binary_value(STATE_OFF, zone[CONF_ACTIVATION])
|
||||
return self.json(resp)
|
||||
|
||||
async def put(self, request: Request, device_id) -> Response:
|
||||
"""Receive a sensor update via PUT request and async set state."""
|
||||
return await self.update_sensor(request, device_id)
|
||||
|
||||
async def post(self, request: Request, device_id) -> Response:
|
||||
"""Receive a sensor update via POST request and async set state."""
|
||||
return await self.update_sensor(request, device_id)
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
"""Support for wired binary sensors attached to a Konnected device."""
|
||||
|
||||
from homeassistant.components.binary_sensor import BinarySensorEntity
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import (
|
||||
ATTR_ENTITY_ID,
|
||||
ATTR_STATE,
|
||||
CONF_BINARY_SENSORS,
|
||||
CONF_DEVICES,
|
||||
CONF_NAME,
|
||||
CONF_TYPE,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
config_entry: ConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up binary sensors attached to a Konnected device from a config entry."""
|
||||
# Uses legacy hass.data[DOMAIN] pattern
|
||||
# pylint: disable-next=home-assistant-use-runtime-data
|
||||
data = hass.data[DOMAIN]
|
||||
device_id = config_entry.data["id"]
|
||||
sensors = [
|
||||
KonnectedBinarySensor(device_id, pin_num, pin_data)
|
||||
for pin_num, pin_data in data[CONF_DEVICES][device_id][
|
||||
CONF_BINARY_SENSORS
|
||||
].items()
|
||||
]
|
||||
async_add_entities(sensors)
|
||||
|
||||
|
||||
class KonnectedBinarySensor(BinarySensorEntity):
|
||||
"""Representation of a Konnected binary sensor."""
|
||||
|
||||
_attr_should_poll = False
|
||||
|
||||
def __init__(self, device_id, zone_num, data):
|
||||
"""Initialize the Konnected binary sensor."""
|
||||
self._data = data
|
||||
self._attr_is_on = data.get(ATTR_STATE)
|
||||
self._attr_device_class = data.get(CONF_TYPE)
|
||||
self._attr_unique_id = f"{device_id}-{zone_num}"
|
||||
self._attr_name = data.get(CONF_NAME)
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, device_id)},
|
||||
)
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Store entity_id and register state change callback."""
|
||||
self._data[ATTR_ENTITY_ID] = self.entity_id
|
||||
self.async_on_remove(
|
||||
async_dispatcher_connect(
|
||||
self.hass, f"konnected.{self.entity_id}.update", self.async_set_state
|
||||
)
|
||||
)
|
||||
|
||||
@callback
|
||||
def async_set_state(self, state):
|
||||
"""Update the sensor's state."""
|
||||
self._attr_is_on = state
|
||||
self.async_write_ha_state()
|
||||
@@ -1,11 +1,892 @@
|
||||
"""Config flow for Konnected.io integration."""
|
||||
"""Config flow for konnected.io integration."""
|
||||
# pylint: disable=home-assistant-config-flow-name-field # Name field is no longer allowed in config flow schemas
|
||||
|
||||
from homeassistant.config_entries import ConfigFlow
|
||||
import asyncio
|
||||
import copy
|
||||
import logging
|
||||
import random
|
||||
import string
|
||||
from typing import Any
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from .const import DOMAIN
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.binary_sensor import (
|
||||
DEVICE_CLASSES_SCHEMA,
|
||||
BinarySensorDeviceClass,
|
||||
)
|
||||
from homeassistant.config_entries import (
|
||||
ConfigEntry,
|
||||
ConfigFlow,
|
||||
ConfigFlowResult,
|
||||
OptionsFlow,
|
||||
)
|
||||
from homeassistant.const import (
|
||||
CONF_ACCESS_TOKEN,
|
||||
CONF_BINARY_SENSORS,
|
||||
CONF_DISCOVERY,
|
||||
CONF_HOST,
|
||||
CONF_ID,
|
||||
CONF_MODEL,
|
||||
CONF_NAME,
|
||||
CONF_PORT,
|
||||
CONF_REPEAT,
|
||||
CONF_SENSORS,
|
||||
CONF_SWITCHES,
|
||||
CONF_TYPE,
|
||||
CONF_ZONE,
|
||||
)
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.service_info.ssdp import (
|
||||
ATTR_UPNP_MANUFACTURER,
|
||||
ATTR_UPNP_MODEL_NAME,
|
||||
SsdpServiceInfo,
|
||||
)
|
||||
|
||||
from .const import (
|
||||
CONF_ACTIVATION,
|
||||
CONF_API_HOST,
|
||||
CONF_BLINK,
|
||||
CONF_DEFAULT_OPTIONS,
|
||||
CONF_INVERSE,
|
||||
CONF_MOMENTARY,
|
||||
CONF_PAUSE,
|
||||
CONF_POLL_INTERVAL,
|
||||
DOMAIN,
|
||||
STATE_HIGH,
|
||||
STATE_LOW,
|
||||
ZONES,
|
||||
)
|
||||
from .errors import CannotConnect
|
||||
from .panel import KONN_MODEL, KONN_MODEL_PRO, get_status
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
ATTR_KONN_UPNP_MODEL_NAME = "model_name" # standard upnp is modelName
|
||||
CONF_IO = "io"
|
||||
CONF_IO_DIS = "Disabled"
|
||||
CONF_IO_BIN = "Binary Sensor"
|
||||
CONF_IO_DIG = "Digital Sensor"
|
||||
CONF_IO_SWI = "Switchable Output"
|
||||
|
||||
CONF_MORE_STATES = "more_states"
|
||||
CONF_YES = "Yes"
|
||||
CONF_NO = "No"
|
||||
|
||||
CONF_OVERRIDE_API_HOST = "override_api_host"
|
||||
|
||||
KONN_MANUFACTURER = "konnected.io"
|
||||
KONN_PANEL_MODEL_NAMES = {
|
||||
KONN_MODEL: "Konnected Alarm Panel",
|
||||
KONN_MODEL_PRO: "Konnected Alarm Panel Pro",
|
||||
}
|
||||
|
||||
OPTIONS_IO_ANY = vol.In([CONF_IO_DIS, CONF_IO_BIN, CONF_IO_DIG, CONF_IO_SWI])
|
||||
OPTIONS_IO_INPUT_ONLY = vol.In([CONF_IO_DIS, CONF_IO_BIN])
|
||||
OPTIONS_IO_OUTPUT_ONLY = vol.In([CONF_IO_DIS, CONF_IO_SWI])
|
||||
|
||||
|
||||
# Config entry schemas
|
||||
IO_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Optional("1", default=CONF_IO_DIS): OPTIONS_IO_ANY,
|
||||
vol.Optional("2", default=CONF_IO_DIS): OPTIONS_IO_ANY,
|
||||
vol.Optional("3", default=CONF_IO_DIS): OPTIONS_IO_ANY,
|
||||
vol.Optional("4", default=CONF_IO_DIS): OPTIONS_IO_ANY,
|
||||
vol.Optional("5", default=CONF_IO_DIS): OPTIONS_IO_ANY,
|
||||
vol.Optional("6", default=CONF_IO_DIS): OPTIONS_IO_ANY,
|
||||
vol.Optional("7", default=CONF_IO_DIS): OPTIONS_IO_ANY,
|
||||
vol.Optional("8", default=CONF_IO_DIS): OPTIONS_IO_ANY,
|
||||
vol.Optional("9", default=CONF_IO_DIS): OPTIONS_IO_INPUT_ONLY,
|
||||
vol.Optional("10", default=CONF_IO_DIS): OPTIONS_IO_INPUT_ONLY,
|
||||
vol.Optional("11", default=CONF_IO_DIS): OPTIONS_IO_INPUT_ONLY,
|
||||
vol.Optional("12", default=CONF_IO_DIS): OPTIONS_IO_INPUT_ONLY,
|
||||
vol.Optional("out", default=CONF_IO_DIS): OPTIONS_IO_OUTPUT_ONLY,
|
||||
vol.Optional("alarm1", default=CONF_IO_DIS): OPTIONS_IO_OUTPUT_ONLY,
|
||||
vol.Optional("out1", default=CONF_IO_DIS): OPTIONS_IO_OUTPUT_ONLY,
|
||||
vol.Optional("alarm2_out2", default=CONF_IO_DIS): OPTIONS_IO_OUTPUT_ONLY,
|
||||
}
|
||||
)
|
||||
|
||||
BINARY_SENSOR_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_ZONE): vol.In(ZONES),
|
||||
vol.Required(
|
||||
CONF_TYPE, default=BinarySensorDeviceClass.DOOR
|
||||
): DEVICE_CLASSES_SCHEMA,
|
||||
vol.Optional(CONF_NAME): cv.string,
|
||||
vol.Optional(CONF_INVERSE, default=False): cv.boolean,
|
||||
}
|
||||
)
|
||||
|
||||
SENSOR_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_ZONE): vol.In(ZONES),
|
||||
vol.Required(CONF_TYPE, default="dht"): vol.All(
|
||||
vol.Lower, vol.In(["dht", "ds18b20"])
|
||||
),
|
||||
vol.Optional(CONF_NAME): cv.string,
|
||||
vol.Optional(CONF_POLL_INTERVAL, default=3): vol.All(
|
||||
vol.Coerce(int), vol.Range(min=1)
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
SWITCH_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_ZONE): vol.In(ZONES),
|
||||
vol.Optional(CONF_NAME): cv.string,
|
||||
vol.Optional(CONF_ACTIVATION, default=STATE_HIGH): vol.All(
|
||||
vol.Lower, vol.In([STATE_HIGH, STATE_LOW])
|
||||
),
|
||||
vol.Optional(CONF_MOMENTARY): vol.All(vol.Coerce(int), vol.Range(min=10)),
|
||||
vol.Optional(CONF_PAUSE): vol.All(vol.Coerce(int), vol.Range(min=10)),
|
||||
vol.Optional(CONF_REPEAT): vol.All(vol.Coerce(int), vol.Range(min=-1)),
|
||||
}
|
||||
)
|
||||
|
||||
OPTIONS_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_IO): IO_SCHEMA,
|
||||
vol.Optional(CONF_BINARY_SENSORS): vol.All(
|
||||
cv.ensure_list, [BINARY_SENSOR_SCHEMA]
|
||||
),
|
||||
vol.Optional(CONF_SENSORS): vol.All(cv.ensure_list, [SENSOR_SCHEMA]),
|
||||
vol.Optional(CONF_SWITCHES): vol.All(cv.ensure_list, [SWITCH_SCHEMA]),
|
||||
vol.Optional(CONF_BLINK, default=True): cv.boolean,
|
||||
vol.Optional(CONF_API_HOST, default=""): vol.Any("", cv.url),
|
||||
vol.Optional(CONF_DISCOVERY, default=True): cv.boolean,
|
||||
},
|
||||
extra=vol.REMOVE_EXTRA,
|
||||
)
|
||||
|
||||
CONFIG_ENTRY_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_ID): cv.matches_regex("[0-9a-f]{12}"),
|
||||
vol.Required(CONF_HOST): cv.string,
|
||||
vol.Required(CONF_PORT): cv.port,
|
||||
vol.Required(CONF_MODEL): vol.Any(*KONN_PANEL_MODEL_NAMES),
|
||||
vol.Required(CONF_ACCESS_TOKEN): cv.matches_regex("[a-zA-Z0-9]+"),
|
||||
vol.Required(CONF_DEFAULT_OPTIONS): OPTIONS_SCHEMA,
|
||||
},
|
||||
extra=vol.REMOVE_EXTRA,
|
||||
)
|
||||
|
||||
|
||||
class KonnectedFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow for Konnected.io."""
|
||||
"""Handle a config flow for Konnected Panels."""
|
||||
|
||||
VERSION = 1
|
||||
|
||||
# class variable to store/share discovered host information
|
||||
DISCOVERED_HOSTS: dict[str, dict[str, Any]] = {}
|
||||
|
||||
unique_id: str
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize the Konnected flow."""
|
||||
self.data: dict[str, Any] = {}
|
||||
self.options = OPTIONS_SCHEMA({CONF_IO: {}})
|
||||
|
||||
async def async_gen_config(self, host, port):
|
||||
"""Populate self.data based on panel status.
|
||||
|
||||
This will raise CannotConnect if an error occurs
|
||||
"""
|
||||
self.data[CONF_HOST] = host
|
||||
self.data[CONF_PORT] = port
|
||||
try:
|
||||
status = await get_status(self.hass, host, port)
|
||||
self.data[CONF_ID] = status.get("chipId", status["mac"].replace(":", ""))
|
||||
except (CannotConnect, KeyError) as err:
|
||||
raise CannotConnect from err
|
||||
|
||||
self.data[CONF_MODEL] = status.get("model", KONN_MODEL)
|
||||
self.data[CONF_ACCESS_TOKEN] = "".join(
|
||||
random.choices(f"{string.ascii_uppercase}{string.digits}", k=20)
|
||||
)
|
||||
|
||||
async def async_step_import(self, import_data: dict[str, Any]) -> ConfigFlowResult:
|
||||
"""Import a configuration.yaml config.
|
||||
|
||||
This flow is triggered by `async_setup` for configured panels.
|
||||
"""
|
||||
_LOGGER.debug(import_data)
|
||||
|
||||
# save the data and confirm connection via user step
|
||||
await self.async_set_unique_id(import_data["id"])
|
||||
self.options = import_data[CONF_DEFAULT_OPTIONS]
|
||||
|
||||
# config schema ensures we have port if we have host
|
||||
if import_data.get(CONF_HOST):
|
||||
# automatically connect if we have host info
|
||||
return await self.async_step_user(
|
||||
user_input={
|
||||
CONF_HOST: import_data[CONF_HOST],
|
||||
CONF_PORT: import_data[CONF_PORT],
|
||||
}
|
||||
)
|
||||
|
||||
# if we have no host info wait for it or abort if previously configured
|
||||
self._abort_if_unique_id_configured()
|
||||
return await self.async_step_import_confirm()
|
||||
|
||||
async def async_step_import_confirm(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Confirm the user wants to import the config entry."""
|
||||
if user_input is None:
|
||||
return self.async_show_form(
|
||||
step_id="import_confirm",
|
||||
description_placeholders={"id": self.unique_id},
|
||||
)
|
||||
|
||||
# if we have ssdp discovered applicable host info use it
|
||||
if KonnectedFlowHandler.DISCOVERED_HOSTS.get(self.unique_id):
|
||||
return await self.async_step_user(
|
||||
user_input={
|
||||
CONF_HOST: KonnectedFlowHandler.DISCOVERED_HOSTS[self.unique_id][
|
||||
CONF_HOST
|
||||
],
|
||||
CONF_PORT: KonnectedFlowHandler.DISCOVERED_HOSTS[self.unique_id][
|
||||
CONF_PORT
|
||||
],
|
||||
}
|
||||
)
|
||||
return await self.async_step_user()
|
||||
|
||||
async def async_step_ssdp(
|
||||
self, discovery_info: SsdpServiceInfo
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle a discovered konnected panel.
|
||||
|
||||
This flow is triggered by the SSDP component. It will check if the
|
||||
device is already configured and attempt to finish the config if not.
|
||||
"""
|
||||
_LOGGER.debug(discovery_info)
|
||||
|
||||
try:
|
||||
if discovery_info.upnp[ATTR_UPNP_MANUFACTURER] != KONN_MANUFACTURER:
|
||||
return self.async_abort(reason="not_konn_panel")
|
||||
|
||||
if not any(
|
||||
name in discovery_info.upnp[ATTR_UPNP_MODEL_NAME]
|
||||
for name in KONN_PANEL_MODEL_NAMES
|
||||
):
|
||||
_LOGGER.warning(
|
||||
"Discovered unrecognized Konnected device %s",
|
||||
discovery_info.upnp.get(ATTR_UPNP_MODEL_NAME, "Unknown"),
|
||||
)
|
||||
return self.async_abort(reason="not_konn_panel")
|
||||
|
||||
# If MAC is missing it is a bug in the device fw but we'll guard
|
||||
# against it since the field is so vital
|
||||
except KeyError:
|
||||
_LOGGER.error("Malformed Konnected SSDP info")
|
||||
else:
|
||||
# extract host/port from ssdp_location
|
||||
assert discovery_info.ssdp_location
|
||||
netloc = urlparse(discovery_info.ssdp_location).netloc.split(":")
|
||||
self._async_abort_entries_match(
|
||||
{CONF_HOST: netloc[0], CONF_PORT: int(netloc[1])}
|
||||
)
|
||||
|
||||
try:
|
||||
status = await get_status(self.hass, netloc[0], int(netloc[1]))
|
||||
except CannotConnect:
|
||||
return self.async_abort(reason="cannot_connect")
|
||||
|
||||
self.data[CONF_HOST] = netloc[0]
|
||||
self.data[CONF_PORT] = int(netloc[1])
|
||||
self.data[CONF_ID] = status.get("chipId", status["mac"].replace(":", ""))
|
||||
self.data[CONF_MODEL] = status.get("model", KONN_MODEL)
|
||||
|
||||
KonnectedFlowHandler.DISCOVERED_HOSTS[self.data[CONF_ID]] = {
|
||||
CONF_HOST: self.data[CONF_HOST],
|
||||
CONF_PORT: self.data[CONF_PORT],
|
||||
}
|
||||
return await self.async_step_confirm()
|
||||
|
||||
return self.async_abort(reason="unknown")
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Connect to panel and get config."""
|
||||
errors = {}
|
||||
if user_input:
|
||||
# build config info and wait for user confirmation
|
||||
self.data[CONF_HOST] = user_input[CONF_HOST]
|
||||
self.data[CONF_PORT] = user_input[CONF_PORT]
|
||||
|
||||
# brief delay to allow processing of recent status req
|
||||
await asyncio.sleep(0.1)
|
||||
try:
|
||||
status = await get_status(
|
||||
self.hass, self.data[CONF_HOST], self.data[CONF_PORT]
|
||||
)
|
||||
except CannotConnect:
|
||||
errors["base"] = "cannot_connect"
|
||||
else:
|
||||
self.data[CONF_ID] = status.get(
|
||||
"chipId", status["mac"].replace(":", "")
|
||||
)
|
||||
self.data[CONF_MODEL] = status.get("model", KONN_MODEL)
|
||||
|
||||
# save off our discovered host info
|
||||
KonnectedFlowHandler.DISCOVERED_HOSTS[self.data[CONF_ID]] = {
|
||||
CONF_HOST: self.data[CONF_HOST],
|
||||
CONF_PORT: self.data[CONF_PORT],
|
||||
}
|
||||
return await self.async_step_confirm()
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="user",
|
||||
description_placeholders={
|
||||
"host": self.data.get(CONF_HOST, "Unknown"),
|
||||
"port": self.data.get(CONF_PORT, "Unknown"),
|
||||
},
|
||||
data_schema=vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_HOST, default=self.data.get(CONF_HOST)): str,
|
||||
vol.Required(CONF_PORT, default=self.data.get(CONF_PORT)): int,
|
||||
}
|
||||
),
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
async def async_step_confirm(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Attempt to link with the Konnected panel.
|
||||
|
||||
Given a configured host, will ask the user to confirm and finalize
|
||||
the connection.
|
||||
"""
|
||||
if user_input is None:
|
||||
# abort and update an existing config entry if host info changes
|
||||
await self.async_set_unique_id(self.data[CONF_ID])
|
||||
self._abort_if_unique_id_configured(
|
||||
updates=self.data, reload_on_update=False
|
||||
)
|
||||
return self.async_show_form(
|
||||
step_id="confirm",
|
||||
description_placeholders={
|
||||
"model": KONN_PANEL_MODEL_NAMES[self.data[CONF_MODEL]],
|
||||
"id": self.unique_id,
|
||||
"host": self.data[CONF_HOST],
|
||||
"port": self.data[CONF_PORT],
|
||||
},
|
||||
)
|
||||
|
||||
# Create access token, attach default options and create entry
|
||||
self.data[CONF_DEFAULT_OPTIONS] = self.options
|
||||
self.data[CONF_ACCESS_TOKEN] = self.hass.data.get(DOMAIN, {}).get(
|
||||
CONF_ACCESS_TOKEN
|
||||
) or "".join(random.choices(f"{string.ascii_uppercase}{string.digits}", k=20))
|
||||
|
||||
return self.async_create_entry(
|
||||
title=KONN_PANEL_MODEL_NAMES[self.data[CONF_MODEL]],
|
||||
data=self.data,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
@callback
|
||||
def async_get_options_flow(
|
||||
config_entry: ConfigEntry,
|
||||
) -> OptionsFlowHandler:
|
||||
"""Return the Options Flow."""
|
||||
return OptionsFlowHandler(config_entry)
|
||||
|
||||
|
||||
class OptionsFlowHandler(OptionsFlow):
|
||||
"""Handle a option flow for a Konnected Panel."""
|
||||
|
||||
def __init__(self, config_entry: ConfigEntry) -> None:
|
||||
"""Initialize options flow."""
|
||||
self.model = config_entry.data[CONF_MODEL]
|
||||
self.current_opt = (
|
||||
config_entry.options or config_entry.data[CONF_DEFAULT_OPTIONS]
|
||||
)
|
||||
|
||||
# as config proceeds we'll build up new options
|
||||
# and then replace what's in the config entry
|
||||
self.new_opt: dict[str, Any] = {CONF_IO: {}}
|
||||
self.active_cfg: str | None = None
|
||||
self.io_cfg: dict[str, Any] = {}
|
||||
self.current_states: list[dict[str, Any]] = []
|
||||
self.current_state = 1
|
||||
|
||||
@callback
|
||||
def get_current_cfg(self, io_type, zone):
|
||||
"""Get the current zone config."""
|
||||
return next(
|
||||
(
|
||||
cfg
|
||||
for cfg in self.current_opt.get(io_type, [])
|
||||
if cfg[CONF_ZONE] == zone
|
||||
),
|
||||
{},
|
||||
)
|
||||
|
||||
async def async_step_init(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle options flow."""
|
||||
return await self.async_step_options_io()
|
||||
|
||||
async def async_step_options_io(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Configure legacy panel IO or first half of pro IO."""
|
||||
errors: dict[str, str] = {}
|
||||
current_io = self.current_opt.get(CONF_IO, {})
|
||||
|
||||
if user_input is not None:
|
||||
# strip out disabled io and save for options cfg
|
||||
for key, value in user_input.items():
|
||||
if value != CONF_IO_DIS:
|
||||
self.new_opt[CONF_IO][key] = value
|
||||
return await self.async_step_options_io_ext()
|
||||
|
||||
if self.model == KONN_MODEL:
|
||||
return self.async_show_form(
|
||||
step_id="options_io",
|
||||
data_schema=vol.Schema(
|
||||
{
|
||||
vol.Required(
|
||||
"1", default=current_io.get("1", CONF_IO_DIS)
|
||||
): OPTIONS_IO_ANY,
|
||||
vol.Required(
|
||||
"2", default=current_io.get("2", CONF_IO_DIS)
|
||||
): OPTIONS_IO_ANY,
|
||||
vol.Required(
|
||||
"3", default=current_io.get("3", CONF_IO_DIS)
|
||||
): OPTIONS_IO_ANY,
|
||||
vol.Required(
|
||||
"4", default=current_io.get("4", CONF_IO_DIS)
|
||||
): OPTIONS_IO_ANY,
|
||||
vol.Required(
|
||||
"5", default=current_io.get("5", CONF_IO_DIS)
|
||||
): OPTIONS_IO_ANY,
|
||||
vol.Required(
|
||||
"6", default=current_io.get("6", CONF_IO_DIS)
|
||||
): OPTIONS_IO_ANY,
|
||||
vol.Required(
|
||||
"out", default=current_io.get("out", CONF_IO_DIS)
|
||||
): OPTIONS_IO_OUTPUT_ONLY,
|
||||
}
|
||||
),
|
||||
description_placeholders={
|
||||
"model": KONN_PANEL_MODEL_NAMES[self.model],
|
||||
"host": self.config_entry.data[CONF_HOST],
|
||||
},
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
# configure the first half of the pro board io
|
||||
if self.model == KONN_MODEL_PRO:
|
||||
return self.async_show_form(
|
||||
step_id="options_io",
|
||||
data_schema=vol.Schema(
|
||||
{
|
||||
vol.Required(
|
||||
"1", default=current_io.get("1", CONF_IO_DIS)
|
||||
): OPTIONS_IO_ANY,
|
||||
vol.Required(
|
||||
"2", default=current_io.get("2", CONF_IO_DIS)
|
||||
): OPTIONS_IO_ANY,
|
||||
vol.Required(
|
||||
"3", default=current_io.get("3", CONF_IO_DIS)
|
||||
): OPTIONS_IO_ANY,
|
||||
vol.Required(
|
||||
"4", default=current_io.get("4", CONF_IO_DIS)
|
||||
): OPTIONS_IO_ANY,
|
||||
vol.Required(
|
||||
"5", default=current_io.get("5", CONF_IO_DIS)
|
||||
): OPTIONS_IO_ANY,
|
||||
vol.Required(
|
||||
"6", default=current_io.get("6", CONF_IO_DIS)
|
||||
): OPTIONS_IO_ANY,
|
||||
vol.Required(
|
||||
"7", default=current_io.get("7", CONF_IO_DIS)
|
||||
): OPTIONS_IO_ANY,
|
||||
}
|
||||
),
|
||||
description_placeholders={
|
||||
"model": KONN_PANEL_MODEL_NAMES[self.model],
|
||||
"host": self.config_entry.data[CONF_HOST],
|
||||
},
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
return self.async_abort(reason="not_konn_panel")
|
||||
|
||||
async def async_step_options_io_ext(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Allow the user to configure the extended IO for pro."""
|
||||
errors: dict[str, str] = {}
|
||||
current_io = self.current_opt.get(CONF_IO, {})
|
||||
|
||||
if user_input is not None:
|
||||
# strip out disabled io and save for options cfg
|
||||
for key, value in user_input.items():
|
||||
if value != CONF_IO_DIS:
|
||||
self.new_opt[CONF_IO].update({key: value})
|
||||
self.io_cfg = copy.deepcopy(self.new_opt[CONF_IO])
|
||||
return await self.async_step_options_binary()
|
||||
|
||||
if self.model == KONN_MODEL:
|
||||
self.io_cfg = copy.deepcopy(self.new_opt[CONF_IO])
|
||||
return await self.async_step_options_binary()
|
||||
|
||||
if self.model == KONN_MODEL_PRO:
|
||||
return self.async_show_form(
|
||||
step_id="options_io_ext",
|
||||
data_schema=vol.Schema(
|
||||
{
|
||||
vol.Required(
|
||||
"8", default=current_io.get("8", CONF_IO_DIS)
|
||||
): OPTIONS_IO_ANY,
|
||||
vol.Required(
|
||||
"9", default=current_io.get("9", CONF_IO_DIS)
|
||||
): OPTIONS_IO_INPUT_ONLY,
|
||||
vol.Required(
|
||||
"10", default=current_io.get("10", CONF_IO_DIS)
|
||||
): OPTIONS_IO_INPUT_ONLY,
|
||||
vol.Required(
|
||||
"11", default=current_io.get("11", CONF_IO_DIS)
|
||||
): OPTIONS_IO_INPUT_ONLY,
|
||||
vol.Required(
|
||||
"12", default=current_io.get("12", CONF_IO_DIS)
|
||||
): OPTIONS_IO_INPUT_ONLY,
|
||||
vol.Required(
|
||||
"alarm1", default=current_io.get("alarm1", CONF_IO_DIS)
|
||||
): OPTIONS_IO_OUTPUT_ONLY,
|
||||
vol.Required(
|
||||
"out1", default=current_io.get("out1", CONF_IO_DIS)
|
||||
): OPTIONS_IO_OUTPUT_ONLY,
|
||||
vol.Required(
|
||||
"alarm2_out2",
|
||||
default=current_io.get("alarm2_out2", CONF_IO_DIS),
|
||||
): OPTIONS_IO_OUTPUT_ONLY,
|
||||
}
|
||||
),
|
||||
description_placeholders={
|
||||
"model": KONN_PANEL_MODEL_NAMES[self.model],
|
||||
"host": self.config_entry.data[CONF_HOST],
|
||||
},
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
return self.async_abort(reason="not_konn_panel")
|
||||
|
||||
async def async_step_options_binary(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Allow the user to configure the IO options for binary sensors."""
|
||||
errors: dict[str, str] = {}
|
||||
if user_input is not None and self.active_cfg is not None:
|
||||
zone = {"zone": self.active_cfg}
|
||||
zone.update(user_input)
|
||||
self.new_opt[CONF_BINARY_SENSORS] = [
|
||||
*self.new_opt.get(CONF_BINARY_SENSORS, []),
|
||||
zone,
|
||||
]
|
||||
self.io_cfg.pop(self.active_cfg)
|
||||
self.active_cfg = None
|
||||
|
||||
if self.active_cfg:
|
||||
current_cfg = self.get_current_cfg(CONF_BINARY_SENSORS, self.active_cfg)
|
||||
return self.async_show_form(
|
||||
step_id="options_binary",
|
||||
data_schema=vol.Schema(
|
||||
{
|
||||
vol.Required(
|
||||
CONF_TYPE,
|
||||
default=current_cfg.get(
|
||||
CONF_TYPE, BinarySensorDeviceClass.DOOR
|
||||
),
|
||||
): DEVICE_CLASSES_SCHEMA,
|
||||
vol.Optional(
|
||||
CONF_NAME, default=current_cfg.get(CONF_NAME, vol.UNDEFINED)
|
||||
): str,
|
||||
vol.Optional(
|
||||
CONF_INVERSE, default=current_cfg.get(CONF_INVERSE, False)
|
||||
): bool,
|
||||
}
|
||||
),
|
||||
description_placeholders={
|
||||
"zone": f"Zone {self.active_cfg}"
|
||||
if len(self.active_cfg) < 3
|
||||
else self.active_cfg.upper()
|
||||
},
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
# find the next unconfigured binary sensor
|
||||
for key, value in self.io_cfg.items():
|
||||
if value == CONF_IO_BIN:
|
||||
self.active_cfg = key
|
||||
current_cfg = self.get_current_cfg(CONF_BINARY_SENSORS, self.active_cfg)
|
||||
return self.async_show_form(
|
||||
step_id="options_binary",
|
||||
data_schema=vol.Schema(
|
||||
{
|
||||
vol.Required(
|
||||
CONF_TYPE,
|
||||
default=current_cfg.get(
|
||||
CONF_TYPE, BinarySensorDeviceClass.DOOR
|
||||
),
|
||||
): DEVICE_CLASSES_SCHEMA,
|
||||
vol.Optional(
|
||||
CONF_NAME,
|
||||
default=current_cfg.get(CONF_NAME, vol.UNDEFINED),
|
||||
): str,
|
||||
vol.Optional(
|
||||
CONF_INVERSE,
|
||||
default=current_cfg.get(CONF_INVERSE, False),
|
||||
): bool,
|
||||
}
|
||||
),
|
||||
description_placeholders={
|
||||
"zone": f"Zone {self.active_cfg}"
|
||||
if len(self.active_cfg) < 3
|
||||
else self.active_cfg.upper()
|
||||
},
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
return await self.async_step_options_digital()
|
||||
|
||||
async def async_step_options_digital(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Allow the user to configure the IO options for digital sensors."""
|
||||
errors: dict[str, str] = {}
|
||||
if user_input is not None and self.active_cfg is not None:
|
||||
zone = {"zone": self.active_cfg}
|
||||
zone.update(user_input)
|
||||
self.new_opt[CONF_SENSORS] = [*self.new_opt.get(CONF_SENSORS, []), zone]
|
||||
self.io_cfg.pop(self.active_cfg)
|
||||
self.active_cfg = None
|
||||
|
||||
if self.active_cfg:
|
||||
current_cfg = self.get_current_cfg(CONF_SENSORS, self.active_cfg)
|
||||
return self.async_show_form(
|
||||
step_id="options_digital",
|
||||
data_schema=vol.Schema(
|
||||
{
|
||||
vol.Required(
|
||||
CONF_TYPE, default=current_cfg.get(CONF_TYPE, "dht")
|
||||
): vol.All(vol.Lower, vol.In(["dht", "ds18b20"])),
|
||||
vol.Optional(
|
||||
CONF_NAME, default=current_cfg.get(CONF_NAME, vol.UNDEFINED)
|
||||
): str,
|
||||
vol.Optional(
|
||||
CONF_POLL_INTERVAL,
|
||||
default=current_cfg.get(CONF_POLL_INTERVAL, 3),
|
||||
): vol.All(vol.Coerce(int), vol.Range(min=1)),
|
||||
}
|
||||
),
|
||||
description_placeholders={
|
||||
"zone": f"Zone {self.active_cfg}"
|
||||
if len(self.active_cfg) < 3
|
||||
else self.active_cfg.upper()
|
||||
},
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
# find the next unconfigured digital sensor
|
||||
for key, value in self.io_cfg.items():
|
||||
if value == CONF_IO_DIG:
|
||||
self.active_cfg = key
|
||||
current_cfg = self.get_current_cfg(CONF_SENSORS, self.active_cfg)
|
||||
return self.async_show_form(
|
||||
step_id="options_digital",
|
||||
data_schema=vol.Schema(
|
||||
{
|
||||
vol.Required(
|
||||
CONF_TYPE, default=current_cfg.get(CONF_TYPE, "dht")
|
||||
): vol.All(vol.Lower, vol.In(["dht", "ds18b20"])),
|
||||
vol.Optional(
|
||||
CONF_NAME,
|
||||
default=current_cfg.get(CONF_NAME, vol.UNDEFINED),
|
||||
): str,
|
||||
vol.Optional(
|
||||
CONF_POLL_INTERVAL,
|
||||
default=current_cfg.get(CONF_POLL_INTERVAL, 3),
|
||||
): vol.All(vol.Coerce(int), vol.Range(min=1)),
|
||||
}
|
||||
),
|
||||
description_placeholders={
|
||||
"zone": f"Zone {self.active_cfg}"
|
||||
if len(self.active_cfg) < 3
|
||||
else self.active_cfg.upper()
|
||||
},
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
return await self.async_step_options_switch()
|
||||
|
||||
async def async_step_options_switch(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Allow the user to configure the IO options for switches."""
|
||||
errors: dict[str, str] = {}
|
||||
if user_input is not None and self.active_cfg is not None:
|
||||
zone = {"zone": self.active_cfg}
|
||||
zone.update(user_input)
|
||||
del zone[CONF_MORE_STATES]
|
||||
self.new_opt[CONF_SWITCHES] = [*self.new_opt.get(CONF_SWITCHES, []), zone]
|
||||
|
||||
# iterate through multiple switch states
|
||||
if self.current_states:
|
||||
self.current_states.pop(0)
|
||||
|
||||
# only go to next zone if all states are entered
|
||||
self.current_state += 1
|
||||
if user_input[CONF_MORE_STATES] == CONF_NO:
|
||||
self.io_cfg.pop(self.active_cfg)
|
||||
self.active_cfg = None
|
||||
|
||||
if self.active_cfg:
|
||||
current_cfg = next(iter(self.current_states), {})
|
||||
return self.async_show_form(
|
||||
step_id="options_switch",
|
||||
data_schema=vol.Schema(
|
||||
{
|
||||
vol.Optional(
|
||||
CONF_NAME, default=current_cfg.get(CONF_NAME, vol.UNDEFINED)
|
||||
): str,
|
||||
vol.Optional(
|
||||
CONF_ACTIVATION,
|
||||
default=current_cfg.get(CONF_ACTIVATION, STATE_HIGH),
|
||||
): vol.All(vol.Lower, vol.In([STATE_HIGH, STATE_LOW])),
|
||||
vol.Optional(
|
||||
CONF_MOMENTARY,
|
||||
default=current_cfg.get(CONF_MOMENTARY, vol.UNDEFINED),
|
||||
): vol.All(vol.Coerce(int), vol.Range(min=10)),
|
||||
vol.Optional(
|
||||
CONF_PAUSE,
|
||||
default=current_cfg.get(CONF_PAUSE, vol.UNDEFINED),
|
||||
): vol.All(vol.Coerce(int), vol.Range(min=10)),
|
||||
vol.Optional(
|
||||
CONF_REPEAT,
|
||||
default=current_cfg.get(CONF_REPEAT, vol.UNDEFINED),
|
||||
): vol.All(vol.Coerce(int), vol.Range(min=-1)),
|
||||
vol.Required(
|
||||
CONF_MORE_STATES,
|
||||
default=CONF_YES
|
||||
if len(self.current_states) > 1
|
||||
else CONF_NO,
|
||||
): vol.In([CONF_YES, CONF_NO]),
|
||||
}
|
||||
),
|
||||
description_placeholders={
|
||||
"zone": f"Zone {self.active_cfg}"
|
||||
if len(self.active_cfg) < 3
|
||||
else self.active_cfg.upper(),
|
||||
"state": str(self.current_state),
|
||||
},
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
# find the next unconfigured switch
|
||||
for key, value in self.io_cfg.items():
|
||||
if value == CONF_IO_SWI:
|
||||
self.active_cfg = key
|
||||
self.current_states = [
|
||||
cfg
|
||||
for cfg in self.current_opt.get(CONF_SWITCHES, [])
|
||||
if cfg[CONF_ZONE] == self.active_cfg
|
||||
]
|
||||
current_cfg = next(iter(self.current_states), {})
|
||||
self.current_state = 1
|
||||
return self.async_show_form(
|
||||
step_id="options_switch",
|
||||
data_schema=vol.Schema(
|
||||
{
|
||||
vol.Optional(
|
||||
CONF_NAME,
|
||||
default=current_cfg.get(CONF_NAME, vol.UNDEFINED),
|
||||
): str,
|
||||
vol.Optional(
|
||||
CONF_ACTIVATION,
|
||||
default=current_cfg.get(CONF_ACTIVATION, STATE_HIGH),
|
||||
): vol.In(["low", "high"]),
|
||||
vol.Optional(
|
||||
CONF_MOMENTARY,
|
||||
default=current_cfg.get(CONF_MOMENTARY, vol.UNDEFINED),
|
||||
): vol.All(vol.Coerce(int), vol.Range(min=10)),
|
||||
vol.Optional(
|
||||
CONF_PAUSE,
|
||||
default=current_cfg.get(CONF_PAUSE, vol.UNDEFINED),
|
||||
): vol.All(vol.Coerce(int), vol.Range(min=10)),
|
||||
vol.Optional(
|
||||
CONF_REPEAT,
|
||||
default=current_cfg.get(CONF_REPEAT, vol.UNDEFINED),
|
||||
): vol.All(vol.Coerce(int), vol.Range(min=-1)),
|
||||
vol.Required(
|
||||
CONF_MORE_STATES,
|
||||
default=CONF_YES
|
||||
if len(self.current_states) > 1
|
||||
else CONF_NO,
|
||||
): vol.In([CONF_YES, CONF_NO]),
|
||||
}
|
||||
),
|
||||
description_placeholders={
|
||||
"zone": f"Zone {self.active_cfg}"
|
||||
if len(self.active_cfg) < 3
|
||||
else self.active_cfg.upper(),
|
||||
"state": str(self.current_state),
|
||||
},
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
return await self.async_step_options_misc()
|
||||
|
||||
async def async_step_options_misc(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Allow the user to configure the LED behavior."""
|
||||
errors = {}
|
||||
if user_input is not None:
|
||||
# config schema only does basic schema val so check url here
|
||||
try:
|
||||
if user_input[CONF_OVERRIDE_API_HOST]:
|
||||
cv.url(user_input.get(CONF_API_HOST, ""))
|
||||
else:
|
||||
user_input[CONF_API_HOST] = ""
|
||||
except vol.Invalid:
|
||||
errors["base"] = "bad_host"
|
||||
else:
|
||||
# no need to store the override - can infer
|
||||
del user_input[CONF_OVERRIDE_API_HOST]
|
||||
self.new_opt.update(user_input)
|
||||
return self.async_create_entry(title="", data=self.new_opt)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="options_misc",
|
||||
data_schema=vol.Schema(
|
||||
{
|
||||
vol.Required(
|
||||
CONF_DISCOVERY,
|
||||
default=self.current_opt.get(CONF_DISCOVERY, True),
|
||||
): bool,
|
||||
vol.Required(
|
||||
CONF_BLINK, default=self.current_opt.get(CONF_BLINK, True)
|
||||
): bool,
|
||||
vol.Required(
|
||||
CONF_OVERRIDE_API_HOST,
|
||||
default=bool(self.current_opt.get(CONF_API_HOST)),
|
||||
): bool,
|
||||
vol.Optional(
|
||||
CONF_API_HOST, default=self.current_opt.get(CONF_API_HOST, "")
|
||||
): str,
|
||||
}
|
||||
),
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
@@ -1,3 +1,46 @@
|
||||
"""Konnected constants."""
|
||||
|
||||
DOMAIN = "konnected"
|
||||
|
||||
CONF_ACTIVATION = "activation"
|
||||
CONF_API_HOST = "api_host"
|
||||
CONF_DEFAULT_OPTIONS = "default_options"
|
||||
CONF_MOMENTARY = "momentary"
|
||||
CONF_PAUSE = "pause"
|
||||
CONF_POLL_INTERVAL = "poll_interval"
|
||||
CONF_PRECISION = "precision"
|
||||
CONF_INVERSE = "inverse"
|
||||
CONF_BLINK = "blink"
|
||||
CONF_DHT_SENSORS = "dht_sensors"
|
||||
CONF_DS18B20_SENSORS = "ds18b20_sensors"
|
||||
|
||||
STATE_LOW = "low"
|
||||
STATE_HIGH = "high"
|
||||
|
||||
ZONES = [
|
||||
"1",
|
||||
"2",
|
||||
"3",
|
||||
"4",
|
||||
"5",
|
||||
"6",
|
||||
"7",
|
||||
"8",
|
||||
"9",
|
||||
"10",
|
||||
"11",
|
||||
"12",
|
||||
"alarm1",
|
||||
"out1",
|
||||
"alarm2_out2",
|
||||
"out",
|
||||
]
|
||||
|
||||
# alarm panel pro only handles zones,
|
||||
# alarm panel allows specifying pins via configuration.yaml
|
||||
PIN_TO_ZONE = {"1": "1", "2": "2", "5": "3", "6": "4", "7": "5", "8": "out", "9": "6"}
|
||||
ZONE_TO_PIN = {zone: pin for pin, zone in PIN_TO_ZONE.items()}
|
||||
|
||||
ENDPOINT_ROOT = "/api/konnected"
|
||||
UPDATE_ENDPOINT = ENDPOINT_ROOT + r"/device/{device_id:[a-zA-Z0-9]+}"
|
||||
SIGNAL_DS18B20_NEW = "konnected.ds18b20.new"
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
"""Errors for the Konnected component."""
|
||||
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
|
||||
|
||||
class KonnectedException(HomeAssistantError):
|
||||
"""Base class for Konnected exceptions."""
|
||||
|
||||
|
||||
class CannotConnect(KonnectedException):
|
||||
"""Unable to connect to the panel."""
|
||||
@@ -0,0 +1,57 @@
|
||||
"""Handle Konnected messages."""
|
||||
|
||||
import logging
|
||||
|
||||
from homeassistant.components.sensor import SensorDeviceClass
|
||||
from homeassistant.const import ATTR_ENTITY_ID, ATTR_STATE
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_send
|
||||
from homeassistant.util import decorator
|
||||
|
||||
from .const import CONF_INVERSE, SIGNAL_DS18B20_NEW
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
HANDLERS = decorator.Registry() # type: ignore[var-annotated]
|
||||
|
||||
|
||||
@HANDLERS.register("state")
|
||||
async def async_handle_state_update(hass, context, msg):
|
||||
"""Handle a binary sensor or switch state update."""
|
||||
_LOGGER.debug("[state handler] context: %s msg: %s", context, msg)
|
||||
entity_id = context.get(ATTR_ENTITY_ID)
|
||||
state = bool(int(msg.get(ATTR_STATE)))
|
||||
if context.get(CONF_INVERSE):
|
||||
state = not state
|
||||
|
||||
async_dispatcher_send(hass, f"konnected.{entity_id}.update", state)
|
||||
|
||||
|
||||
@HANDLERS.register("temp")
|
||||
async def async_handle_temp_update(hass, context, msg):
|
||||
"""Handle a temperature sensor state update."""
|
||||
_LOGGER.debug("[temp handler] context: %s msg: %s", context, msg)
|
||||
entity_id, temp = context.get(SensorDeviceClass.TEMPERATURE), msg.get("temp")
|
||||
if entity_id:
|
||||
async_dispatcher_send(hass, f"konnected.{entity_id}.update", temp)
|
||||
|
||||
|
||||
@HANDLERS.register("humi")
|
||||
async def async_handle_humi_update(hass, context, msg):
|
||||
"""Handle a humidity sensor state update."""
|
||||
_LOGGER.debug("[humi handler] context: %s msg: %s", context, msg)
|
||||
entity_id, humi = context.get(SensorDeviceClass.HUMIDITY), msg.get("humi")
|
||||
if entity_id:
|
||||
async_dispatcher_send(hass, f"konnected.{entity_id}.update", humi)
|
||||
|
||||
|
||||
@HANDLERS.register("addr")
|
||||
async def async_handle_addr_update(hass, context, msg):
|
||||
"""Handle an addressable sensor update."""
|
||||
_LOGGER.debug("[addr handler] context: %s msg: %s", context, msg)
|
||||
addr, temp = msg.get("addr"), msg.get("temp")
|
||||
if entity_id := context.get(addr):
|
||||
async_dispatcher_send(hass, f"konnected.{entity_id}.update", temp)
|
||||
else:
|
||||
msg["device_id"] = context.get("device_id")
|
||||
msg["temperature"] = temp
|
||||
msg["addr"] = addr
|
||||
async_dispatcher_send(hass, SIGNAL_DS18B20_NEW, msg)
|
||||
@@ -1,9 +1,17 @@
|
||||
{
|
||||
"domain": "konnected",
|
||||
"name": "Konnected.io (Legacy)",
|
||||
"codeowners": [],
|
||||
"codeowners": ["@heythisisnate"],
|
||||
"config_flow": true,
|
||||
"dependencies": ["http"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/konnected",
|
||||
"integration_type": "system",
|
||||
"integration_type": "hub",
|
||||
"iot_class": "local_push",
|
||||
"requirements": []
|
||||
"loggers": ["konnected"],
|
||||
"requirements": ["konnected==1.2.0"],
|
||||
"ssdp": [
|
||||
{
|
||||
"manufacturer": "konnected.io"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,398 @@
|
||||
"""Support for Konnected devices."""
|
||||
# pylint: disable=home-assistant-use-runtime-data # Uses legacy hass.data[DOMAIN] pattern
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
import konnected
|
||||
|
||||
from homeassistant.const import (
|
||||
ATTR_ENTITY_ID,
|
||||
ATTR_STATE,
|
||||
CONF_ACCESS_TOKEN,
|
||||
CONF_BINARY_SENSORS,
|
||||
CONF_DEVICES,
|
||||
CONF_DISCOVERY,
|
||||
CONF_HOST,
|
||||
CONF_ID,
|
||||
CONF_NAME,
|
||||
CONF_PIN,
|
||||
CONF_PORT,
|
||||
CONF_REPEAT,
|
||||
CONF_SENSORS,
|
||||
CONF_SWITCHES,
|
||||
CONF_TYPE,
|
||||
CONF_ZONE,
|
||||
)
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.helpers import aiohttp_client, device_registry as dr
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_send
|
||||
from homeassistant.helpers.event import async_call_later
|
||||
from homeassistant.helpers.network import get_url
|
||||
|
||||
from .const import (
|
||||
CONF_ACTIVATION,
|
||||
CONF_API_HOST,
|
||||
CONF_BLINK,
|
||||
CONF_DEFAULT_OPTIONS,
|
||||
CONF_DHT_SENSORS,
|
||||
CONF_DS18B20_SENSORS,
|
||||
CONF_INVERSE,
|
||||
CONF_MOMENTARY,
|
||||
CONF_PAUSE,
|
||||
CONF_POLL_INTERVAL,
|
||||
DOMAIN,
|
||||
ENDPOINT_ROOT,
|
||||
STATE_LOW,
|
||||
ZONE_TO_PIN,
|
||||
)
|
||||
from .errors import CannotConnect
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
KONN_MODEL = "Konnected"
|
||||
KONN_MODEL_PRO = "Konnected Pro"
|
||||
|
||||
# Indicate how each unit is controlled (pin or zone)
|
||||
KONN_API_VERSIONS = {
|
||||
KONN_MODEL: CONF_PIN,
|
||||
KONN_MODEL_PRO: CONF_ZONE,
|
||||
}
|
||||
|
||||
|
||||
class AlarmPanel:
|
||||
"""A representation of a Konnected alarm panel."""
|
||||
|
||||
def __init__(self, hass, config_entry):
|
||||
"""Initialize the Konnected device."""
|
||||
self.hass = hass
|
||||
self.config_entry = config_entry
|
||||
self.config = config_entry.data
|
||||
self.options = config_entry.options or config_entry.data.get(
|
||||
CONF_DEFAULT_OPTIONS, {}
|
||||
)
|
||||
self.host = self.config.get(CONF_HOST)
|
||||
self.port = self.config.get(CONF_PORT)
|
||||
self.client = None
|
||||
self.status = None
|
||||
self.api_version = KONN_API_VERSIONS[KONN_MODEL]
|
||||
self.connected = False
|
||||
self.connect_attempts = 0
|
||||
self.cancel_connect_retry = None
|
||||
|
||||
@property
|
||||
def device_id(self):
|
||||
"""Device id is the chipId (pro) or MAC address as string."""
|
||||
return self.config.get(CONF_ID)
|
||||
|
||||
@property
|
||||
def stored_configuration(self):
|
||||
"""Return the configuration stored in `hass.data` for this device."""
|
||||
return self.hass.data[DOMAIN][CONF_DEVICES].get(self.device_id)
|
||||
|
||||
@property
|
||||
def available(self):
|
||||
"""Return whether the device is available."""
|
||||
return self.connected
|
||||
|
||||
def format_zone(self, zone, other_items=None):
|
||||
"""Get zone or pin based dict based on the client type."""
|
||||
payload = {
|
||||
self.api_version: zone
|
||||
if self.api_version == CONF_ZONE
|
||||
else ZONE_TO_PIN[zone]
|
||||
}
|
||||
payload.update(other_items or {})
|
||||
return payload
|
||||
|
||||
async def async_connect(self, now=None):
|
||||
"""Connect to and setup a Konnected device."""
|
||||
if self.connected:
|
||||
return
|
||||
|
||||
if self.cancel_connect_retry:
|
||||
# cancel any pending connect attempt and try now
|
||||
self.cancel_connect_retry()
|
||||
|
||||
try:
|
||||
self.client = konnected.Client(
|
||||
host=self.host,
|
||||
port=str(self.port),
|
||||
websession=aiohttp_client.async_get_clientsession(self.hass),
|
||||
)
|
||||
self.status = await self.client.get_status()
|
||||
self.api_version = KONN_API_VERSIONS.get(
|
||||
self.status.get("model", KONN_MODEL), KONN_API_VERSIONS[KONN_MODEL]
|
||||
)
|
||||
_LOGGER.debug(
|
||||
"Connected to new %s device", self.status.get("model", "Konnected")
|
||||
)
|
||||
_LOGGER.debug(self.status)
|
||||
|
||||
await self.async_update_initial_states()
|
||||
# brief delay to allow processing of recent status req
|
||||
await asyncio.sleep(0.1)
|
||||
await self.async_sync_device_config()
|
||||
|
||||
except self.client.ClientError as err:
|
||||
_LOGGER.warning("Exception trying to connect to panel: %s", err)
|
||||
|
||||
# retry in a bit, never more than ~3 min
|
||||
self.connect_attempts += 1
|
||||
self.cancel_connect_retry = async_call_later(
|
||||
self.hass, 2 ** min(self.connect_attempts, 5) * 5, self.async_connect
|
||||
)
|
||||
return
|
||||
|
||||
self.connect_attempts = 0
|
||||
self.connected = True
|
||||
_LOGGER.debug(
|
||||
(
|
||||
"Set up Konnected device %s. Open http://%s:%s in a "
|
||||
"web browser to view device status"
|
||||
),
|
||||
self.device_id,
|
||||
self.host,
|
||||
self.port,
|
||||
)
|
||||
|
||||
device_registry = dr.async_get(self.hass)
|
||||
device_registry.async_get_or_create(
|
||||
config_entry_id=self.config_entry.entry_id,
|
||||
connections={(dr.CONNECTION_NETWORK_MAC, self.status.get("mac"))},
|
||||
identifiers={(DOMAIN, self.device_id)},
|
||||
manufacturer="Konnected.io",
|
||||
name=self.config_entry.title,
|
||||
model=self.config_entry.title,
|
||||
sw_version=self.status.get("swVersion"),
|
||||
)
|
||||
|
||||
async def update_switch(self, zone, state, momentary=None, times=None, pause=None):
|
||||
"""Update the state of a switchable output."""
|
||||
try:
|
||||
if self.client:
|
||||
if self.api_version == CONF_ZONE:
|
||||
return await self.client.put_zone(
|
||||
zone,
|
||||
state,
|
||||
momentary,
|
||||
times,
|
||||
pause,
|
||||
)
|
||||
|
||||
# device endpoint uses pin number instead of zone
|
||||
return await self.client.put_device(
|
||||
ZONE_TO_PIN[zone],
|
||||
state,
|
||||
momentary,
|
||||
times,
|
||||
pause,
|
||||
)
|
||||
|
||||
except self.client.ClientError as err:
|
||||
_LOGGER.warning("Exception trying to update panel: %s", err)
|
||||
|
||||
raise CannotConnect
|
||||
|
||||
async def async_save_data(self):
|
||||
"""Save the device configuration to `hass.data`."""
|
||||
binary_sensors = {}
|
||||
for entity in self.options.get(CONF_BINARY_SENSORS) or []:
|
||||
zone = entity[CONF_ZONE]
|
||||
|
||||
binary_sensors[zone] = {
|
||||
CONF_TYPE: entity[CONF_TYPE],
|
||||
CONF_NAME: entity.get(
|
||||
CONF_NAME, f"Konnected {self.device_id[6:]} Zone {zone}"
|
||||
),
|
||||
CONF_INVERSE: entity.get(CONF_INVERSE),
|
||||
ATTR_STATE: None,
|
||||
}
|
||||
_LOGGER.debug(
|
||||
"Set up binary_sensor %s (initial state: %s)",
|
||||
binary_sensors[zone].get("name"),
|
||||
binary_sensors[zone].get(ATTR_STATE),
|
||||
)
|
||||
|
||||
actuators = []
|
||||
for entity in self.options.get(CONF_SWITCHES) or []:
|
||||
zone = entity[CONF_ZONE]
|
||||
|
||||
act = {
|
||||
CONF_ZONE: zone,
|
||||
CONF_NAME: entity.get(
|
||||
CONF_NAME,
|
||||
f"Konnected {self.device_id[6:]} Actuator {zone}",
|
||||
),
|
||||
ATTR_STATE: None,
|
||||
CONF_ACTIVATION: entity[CONF_ACTIVATION],
|
||||
CONF_MOMENTARY: entity.get(CONF_MOMENTARY),
|
||||
CONF_PAUSE: entity.get(CONF_PAUSE),
|
||||
CONF_REPEAT: entity.get(CONF_REPEAT),
|
||||
}
|
||||
actuators.append(act)
|
||||
_LOGGER.debug("Set up switch %s", act)
|
||||
|
||||
sensors = []
|
||||
for entity in self.options.get(CONF_SENSORS) or []:
|
||||
zone = entity[CONF_ZONE]
|
||||
|
||||
sensor = {
|
||||
CONF_ZONE: zone,
|
||||
CONF_NAME: entity.get(
|
||||
CONF_NAME, f"Konnected {self.device_id[6:]} Sensor {zone}"
|
||||
),
|
||||
CONF_TYPE: entity[CONF_TYPE],
|
||||
CONF_POLL_INTERVAL: entity.get(CONF_POLL_INTERVAL),
|
||||
}
|
||||
sensors.append(sensor)
|
||||
_LOGGER.debug(
|
||||
"Set up %s sensor %s (initial state: %s)",
|
||||
sensor.get(CONF_TYPE),
|
||||
sensor.get(CONF_NAME),
|
||||
sensor.get(ATTR_STATE),
|
||||
)
|
||||
|
||||
device_data = {
|
||||
CONF_BINARY_SENSORS: binary_sensors,
|
||||
CONF_SENSORS: sensors,
|
||||
CONF_SWITCHES: actuators,
|
||||
CONF_BLINK: self.options.get(CONF_BLINK),
|
||||
CONF_DISCOVERY: self.options.get(CONF_DISCOVERY),
|
||||
CONF_HOST: self.host,
|
||||
CONF_PORT: self.port,
|
||||
"panel": self,
|
||||
}
|
||||
|
||||
if CONF_DEVICES not in self.hass.data[DOMAIN]:
|
||||
self.hass.data[DOMAIN][CONF_DEVICES] = {}
|
||||
|
||||
_LOGGER.debug(
|
||||
"Storing data in hass.data[%s][%s][%s]: %s",
|
||||
DOMAIN,
|
||||
CONF_DEVICES,
|
||||
self.device_id,
|
||||
device_data,
|
||||
)
|
||||
self.hass.data[DOMAIN][CONF_DEVICES][self.device_id] = device_data
|
||||
|
||||
@callback
|
||||
def async_binary_sensor_configuration(self):
|
||||
"""Return the configuration map for syncing binary sensors."""
|
||||
return [
|
||||
self.format_zone(p) for p in self.stored_configuration[CONF_BINARY_SENSORS]
|
||||
]
|
||||
|
||||
@callback
|
||||
def async_actuator_configuration(self):
|
||||
"""Return the configuration map for syncing actuators."""
|
||||
return [
|
||||
self.format_zone(
|
||||
data[CONF_ZONE],
|
||||
{"trigger": (0 if data.get(CONF_ACTIVATION) in [0, STATE_LOW] else 1)},
|
||||
)
|
||||
for data in self.stored_configuration[CONF_SWITCHES]
|
||||
]
|
||||
|
||||
@callback
|
||||
def async_dht_sensor_configuration(self):
|
||||
"""Return the configuration map for syncing DHT sensors."""
|
||||
return [
|
||||
self.format_zone(
|
||||
sensor[CONF_ZONE], {CONF_POLL_INTERVAL: sensor[CONF_POLL_INTERVAL]}
|
||||
)
|
||||
for sensor in self.stored_configuration[CONF_SENSORS]
|
||||
if sensor[CONF_TYPE] == "dht"
|
||||
]
|
||||
|
||||
@callback
|
||||
def async_ds18b20_sensor_configuration(self):
|
||||
"""Return the configuration map for syncing DS18B20 sensors."""
|
||||
return [
|
||||
self.format_zone(
|
||||
sensor[CONF_ZONE], {CONF_POLL_INTERVAL: sensor[CONF_POLL_INTERVAL]}
|
||||
)
|
||||
for sensor in self.stored_configuration[CONF_SENSORS]
|
||||
if sensor[CONF_TYPE] == "ds18b20"
|
||||
]
|
||||
|
||||
async def async_update_initial_states(self):
|
||||
"""Update the initial state of each sensor from status poll."""
|
||||
for sensor_data in self.status.get("sensors"):
|
||||
sensor_config = self.stored_configuration[CONF_BINARY_SENSORS].get(
|
||||
sensor_data.get(CONF_ZONE, sensor_data.get(CONF_PIN)), {}
|
||||
)
|
||||
entity_id = sensor_config.get(ATTR_ENTITY_ID)
|
||||
|
||||
state = bool(sensor_data.get(ATTR_STATE))
|
||||
if sensor_config.get(CONF_INVERSE):
|
||||
state = not state
|
||||
|
||||
async_dispatcher_send(self.hass, f"konnected.{entity_id}.update", state)
|
||||
|
||||
@callback
|
||||
def async_desired_settings_payload(self):
|
||||
"""Return a dict representing the desired device configuration."""
|
||||
# keeping self.hass.data check for backwards compatibility
|
||||
# newly configured integrations store this in the config entry
|
||||
desired_api_host = self.options.get(CONF_API_HOST) or (
|
||||
self.hass.data[DOMAIN].get(CONF_API_HOST) or get_url(self.hass)
|
||||
)
|
||||
desired_api_endpoint = desired_api_host + ENDPOINT_ROOT
|
||||
|
||||
return {
|
||||
"sensors": self.async_binary_sensor_configuration(),
|
||||
"actuators": self.async_actuator_configuration(),
|
||||
"dht_sensors": self.async_dht_sensor_configuration(),
|
||||
"ds18b20_sensors": self.async_ds18b20_sensor_configuration(),
|
||||
"auth_token": self.config.get(CONF_ACCESS_TOKEN),
|
||||
"endpoint": desired_api_endpoint,
|
||||
"blink": self.options.get(CONF_BLINK, True),
|
||||
"discovery": self.options.get(CONF_DISCOVERY, True),
|
||||
}
|
||||
|
||||
@callback
|
||||
def async_current_settings_payload(self):
|
||||
"""Return a dict of configuration currently stored on the device."""
|
||||
settings = self.status["settings"] or {}
|
||||
|
||||
return {
|
||||
"sensors": [
|
||||
{self.api_version: s[self.api_version]}
|
||||
for s in self.status.get("sensors")
|
||||
],
|
||||
"actuators": self.status.get("actuators"),
|
||||
"dht_sensors": self.status.get(CONF_DHT_SENSORS),
|
||||
"ds18b20_sensors": self.status.get(CONF_DS18B20_SENSORS),
|
||||
"auth_token": settings.get("token"),
|
||||
"endpoint": settings.get("endpoint"),
|
||||
"blink": settings.get(CONF_BLINK),
|
||||
"discovery": settings.get(CONF_DISCOVERY),
|
||||
}
|
||||
|
||||
async def async_sync_device_config(self):
|
||||
"""Sync the new zone configuration to the Konnected device if needed."""
|
||||
_LOGGER.debug(
|
||||
"Device %s settings payload: %s",
|
||||
self.device_id,
|
||||
self.async_desired_settings_payload(),
|
||||
)
|
||||
if (
|
||||
self.async_desired_settings_payload()
|
||||
!= self.async_current_settings_payload()
|
||||
):
|
||||
_LOGGER.debug("Pushing settings to device %s", self.device_id)
|
||||
await self.client.put_settings(**self.async_desired_settings_payload())
|
||||
|
||||
|
||||
async def get_status(hass, host, port):
|
||||
"""Get the status of a Konnected Panel."""
|
||||
client = konnected.Client(
|
||||
host, str(port), aiohttp_client.async_get_clientsession(hass)
|
||||
)
|
||||
try:
|
||||
return await client.get_status()
|
||||
|
||||
except client.ClientError as err:
|
||||
_LOGGER.error("Exception trying to get panel status: %s", err)
|
||||
raise CannotConnect from err
|
||||
@@ -0,0 +1,141 @@
|
||||
"""Support for DHT and DS18B20 sensors attached to a Konnected device."""
|
||||
|
||||
from homeassistant.components.sensor import (
|
||||
SensorDeviceClass,
|
||||
SensorEntity,
|
||||
SensorEntityDescription,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import (
|
||||
CONF_DEVICES,
|
||||
CONF_NAME,
|
||||
CONF_SENSORS,
|
||||
CONF_TYPE,
|
||||
CONF_ZONE,
|
||||
PERCENTAGE,
|
||||
UnitOfTemperature,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .const import DOMAIN, SIGNAL_DS18B20_NEW
|
||||
|
||||
SENSOR_TYPES: dict[str, SensorEntityDescription] = {
|
||||
"temperature": SensorEntityDescription(
|
||||
key="temperature",
|
||||
name="Temperature",
|
||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||
device_class=SensorDeviceClass.TEMPERATURE,
|
||||
),
|
||||
"humidity": SensorEntityDescription(
|
||||
key="humidity",
|
||||
name="Humidity",
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
device_class=SensorDeviceClass.HUMIDITY,
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
config_entry: ConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up sensors attached to a Konnected device from a config entry."""
|
||||
# Uses legacy hass.data[DOMAIN] pattern
|
||||
# pylint: disable-next=home-assistant-use-runtime-data
|
||||
data = hass.data[DOMAIN]
|
||||
device_id = config_entry.data["id"]
|
||||
|
||||
# Initialize all DHT sensors.
|
||||
dht_sensors = [
|
||||
sensor
|
||||
for sensor in data[CONF_DEVICES][device_id][CONF_SENSORS]
|
||||
if sensor[CONF_TYPE] == "dht"
|
||||
]
|
||||
entities = [
|
||||
KonnectedSensor(device_id, data=sensor_config, description=description)
|
||||
for sensor_config in dht_sensors
|
||||
for description in SENSOR_TYPES.values()
|
||||
]
|
||||
|
||||
async_add_entities(entities)
|
||||
|
||||
@callback
|
||||
def async_add_ds18b20(attrs):
|
||||
"""Add new KonnectedSensor representing a ds18b20 sensor."""
|
||||
sensor_config = next(
|
||||
(
|
||||
s
|
||||
for s in data[CONF_DEVICES][device_id][CONF_SENSORS]
|
||||
if s[CONF_TYPE] == "ds18b20" and s[CONF_ZONE] == attrs.get(CONF_ZONE)
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
async_add_entities(
|
||||
[
|
||||
KonnectedSensor(
|
||||
device_id,
|
||||
sensor_config,
|
||||
SENSOR_TYPES["temperature"],
|
||||
addr=attrs.get("addr"),
|
||||
initial_state=attrs.get("temp"),
|
||||
)
|
||||
],
|
||||
True,
|
||||
)
|
||||
|
||||
# DS18B20 sensors entities are initialized when they report for the first
|
||||
# time. Set up a listener for that signal from the Konnected component.
|
||||
async_dispatcher_connect(hass, SIGNAL_DS18B20_NEW, async_add_ds18b20)
|
||||
|
||||
|
||||
class KonnectedSensor(SensorEntity):
|
||||
"""Represents a Konnected DHT Sensor."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
device_id,
|
||||
data,
|
||||
description: SensorEntityDescription,
|
||||
addr=None,
|
||||
initial_state=None,
|
||||
) -> None:
|
||||
"""Initialize the entity for a single sensor_type."""
|
||||
self.entity_description = description
|
||||
self._addr = addr
|
||||
self._data = data
|
||||
self._zone_num = self._data.get(CONF_ZONE)
|
||||
self._attr_unique_id = addr or f"{device_id}-{self._zone_num}-{description.key}"
|
||||
|
||||
# set initial state if known at initialization
|
||||
self._attr_native_value = initial_state
|
||||
if initial_state:
|
||||
self._attr_native_value = round(float(initial_state), 1)
|
||||
|
||||
# set entity name if given
|
||||
if name := self._data.get(CONF_NAME):
|
||||
name += f" {description.name}"
|
||||
self._attr_name = name
|
||||
|
||||
self._attr_device_info = DeviceInfo(identifiers={(DOMAIN, device_id)})
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Store entity_id and register state change callback."""
|
||||
entity_id_key = self._addr or self.entity_description.key
|
||||
self._data[entity_id_key] = self.entity_id
|
||||
async_dispatcher_connect(
|
||||
self.hass, f"konnected.{self.entity_id}.update", self.async_set_state
|
||||
)
|
||||
|
||||
@callback
|
||||
def async_set_state(self, state):
|
||||
"""Update the sensor's state."""
|
||||
if self.entity_description.key == "humidity":
|
||||
self._attr_native_value = int(float(state))
|
||||
else:
|
||||
self._attr_native_value = round(float(state), 1)
|
||||
self.async_write_ha_state()
|
||||
@@ -1,8 +1,115 @@
|
||||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
|
||||
"already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]",
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
"not_konn_panel": "Not a recognized Konnected.io device",
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]"
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]"
|
||||
},
|
||||
"step": {
|
||||
"confirm": {
|
||||
"description": "Model: {model}\nID: {id}\nHost: {host}\nPort: {port}\n\nYou can configure the IO and panel behavior in the Konnected alarm panel settings.",
|
||||
"title": "Konnected device ready"
|
||||
},
|
||||
"import_confirm": {
|
||||
"description": "A Konnected alarm panel with ID {id} has been discovered in configuration.yaml. This flow will allow you to import it into a config entry.",
|
||||
"title": "Import Konnected device"
|
||||
},
|
||||
"user": {
|
||||
"data": {
|
||||
"host": "[%key:common::config_flow::data::ip%]",
|
||||
"port": "[%key:common::config_flow::data::port%]"
|
||||
},
|
||||
"description": "Please enter the host information for your Konnected panel."
|
||||
}
|
||||
}
|
||||
},
|
||||
"issues": {
|
||||
"integration_removed": {
|
||||
"description": "The Konnected.io (Legacy) integration relied on Konnected's deprecated firmware and has been removed from Home Assistant. Konnected recommends migrating to their ESPHome based firmware and the corresponding Home Assistant integration by following the [migration guide]({kb_page_url}).\n\nTo resolve this issue, migrate your Konnected device(s) to the ESPHome based firmware, then remove any `konnected:` YAML configuration from your `configuration.yaml` file, and remove the (now defunct) integration entries from your Home Assistant setup. [Click here to see your existing Konnected.io (Legacy) integration entries]({entries}).",
|
||||
"title": "The Konnected.io (Legacy) integration has been removed"
|
||||
"deprecated_firmware": {
|
||||
"description": "Konnected's integration is deprecated and Konnected strongly recommends migrating to their ESPHome based firmware and integration by following the guide at {kb_page_url}. After this migration, make sure you don't have any Konnected YAML configuration left in your configuration.yaml file and remove this integration from Home Assistant.",
|
||||
"title": "Konnected firmware is deprecated"
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
"abort": {
|
||||
"not_konn_panel": "[%key:component::konnected::config::abort::not_konn_panel%]"
|
||||
},
|
||||
"error": {
|
||||
"bad_host": "Invalid custom API host URL"
|
||||
},
|
||||
"step": {
|
||||
"options_binary": {
|
||||
"data": {
|
||||
"inverse": "Invert the open/close state",
|
||||
"name": "[%key:common::config_flow::data::name%]",
|
||||
"type": "Binary sensor type"
|
||||
},
|
||||
"description": "{zone} options",
|
||||
"title": "Configure binary sensor"
|
||||
},
|
||||
"options_digital": {
|
||||
"data": {
|
||||
"name": "[%key:common::config_flow::data::name%]",
|
||||
"poll_interval": "Poll interval (minutes)",
|
||||
"type": "Sensor type"
|
||||
},
|
||||
"description": "[%key:component::konnected::options::step::options_binary::description%]",
|
||||
"title": "Configure digital sensor"
|
||||
},
|
||||
"options_io": {
|
||||
"data": {
|
||||
"1": "Zone 1",
|
||||
"2": "Zone 2",
|
||||
"3": "Zone 3",
|
||||
"4": "Zone 4",
|
||||
"5": "Zone 5",
|
||||
"6": "Zone 6",
|
||||
"7": "Zone 7",
|
||||
"out": "OUT"
|
||||
},
|
||||
"description": "Discovered a {model} at {host}. Select the base configuration of each I/O below - depending on the I/O it may allow for binary sensors (open/close contacts), digital sensors (dht and ds18b20), or switchable outputs. You'll be able to configure detailed options in the next steps.",
|
||||
"title": "Configure I/O"
|
||||
},
|
||||
"options_io_ext": {
|
||||
"data": {
|
||||
"8": "Zone 8",
|
||||
"9": "Zone 9",
|
||||
"10": "Zone 10",
|
||||
"11": "Zone 11",
|
||||
"12": "Zone 12",
|
||||
"alarm1": "ALARM1",
|
||||
"alarm2_out2": "OUT2/ALARM2",
|
||||
"out1": "OUT1"
|
||||
},
|
||||
"description": "Select the configuration of the remaining I/O below. You'll be able to configure detailed options in the next steps.",
|
||||
"title": "Configure extended I/O"
|
||||
},
|
||||
"options_misc": {
|
||||
"data": {
|
||||
"api_host": "Custom API host URL",
|
||||
"blink": "Blink panel LED on when sending state change",
|
||||
"discovery": "Respond to discovery requests on your network",
|
||||
"override_api_host": "Override default Home Assistant API host URL"
|
||||
},
|
||||
"description": "Please select the desired behavior for your panel",
|
||||
"title": "Configure misc"
|
||||
},
|
||||
"options_switch": {
|
||||
"data": {
|
||||
"activation": "Output when on",
|
||||
"momentary": "Pulse duration (ms)",
|
||||
"more_states": "Configure additional states for this zone",
|
||||
"name": "[%key:common::config_flow::data::name%]",
|
||||
"pause": "Pause between pulses (ms)",
|
||||
"repeat": "Times to repeat (-1=infinite)"
|
||||
},
|
||||
"description": "{zone} options: state {state}",
|
||||
"title": "Configure switchable output"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
"""Support for wired switches attached to a Konnected device."""
|
||||
# pylint: disable=home-assistant-use-runtime-data # Uses legacy hass.data[DOMAIN] pattern
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.components.switch import SwitchEntity
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import (
|
||||
ATTR_STATE,
|
||||
CONF_DEVICES,
|
||||
CONF_NAME,
|
||||
CONF_REPEAT,
|
||||
CONF_SWITCHES,
|
||||
CONF_ZONE,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .const import (
|
||||
CONF_ACTIVATION,
|
||||
CONF_MOMENTARY,
|
||||
CONF_PAUSE,
|
||||
DOMAIN,
|
||||
STATE_HIGH,
|
||||
STATE_LOW,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
config_entry: ConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up switches attached to a Konnected device from a config entry."""
|
||||
data = hass.data[DOMAIN]
|
||||
device_id = config_entry.data["id"]
|
||||
switches = [
|
||||
KonnectedSwitch(device_id, zone_data.get(CONF_ZONE), zone_data)
|
||||
for zone_data in data[CONF_DEVICES][device_id][CONF_SWITCHES]
|
||||
]
|
||||
async_add_entities(switches)
|
||||
|
||||
|
||||
class KonnectedSwitch(SwitchEntity):
|
||||
"""Representation of a Konnected switch."""
|
||||
|
||||
def __init__(self, device_id, zone_num, data):
|
||||
"""Initialize the Konnected switch."""
|
||||
self._data = data
|
||||
self._device_id = device_id
|
||||
self._zone_num = zone_num
|
||||
self._activation = self._data.get(CONF_ACTIVATION, STATE_HIGH)
|
||||
self._momentary = self._data.get(CONF_MOMENTARY)
|
||||
self._pause = self._data.get(CONF_PAUSE)
|
||||
self._repeat = self._data.get(CONF_REPEAT)
|
||||
self._attr_is_on = self._boolean_state(self._data.get(ATTR_STATE))
|
||||
self._attr_name = self._data.get(CONF_NAME)
|
||||
self._attr_unique_id = (
|
||||
f"{device_id}-{self._zone_num}-{self._momentary}-"
|
||||
f"{self._pause}-{self._repeat}"
|
||||
)
|
||||
self._attr_device_info = DeviceInfo(identifiers={(DOMAIN, device_id)})
|
||||
|
||||
@property
|
||||
def panel(self):
|
||||
"""Return the Konnected HTTP client."""
|
||||
device_data = self.hass.data[DOMAIN][CONF_DEVICES][self._device_id]
|
||||
return device_data.get("panel")
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Return whether the panel is available."""
|
||||
return self.panel.available
|
||||
|
||||
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||
"""Send a command to turn on the switch."""
|
||||
resp = await self.panel.update_switch(
|
||||
self._zone_num,
|
||||
int(self._activation == STATE_HIGH),
|
||||
self._momentary,
|
||||
self._repeat,
|
||||
self._pause,
|
||||
)
|
||||
|
||||
if resp.get(ATTR_STATE) is not None:
|
||||
self._set_state(True)
|
||||
|
||||
if self._momentary and resp.get(ATTR_STATE) != -1:
|
||||
# Immediately set the state back off for momentary switches
|
||||
self._set_state(False)
|
||||
|
||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||
"""Send a command to turn off the switch."""
|
||||
resp = await self.panel.update_switch(
|
||||
self._zone_num, int(self._activation == STATE_LOW)
|
||||
)
|
||||
|
||||
if resp.get(ATTR_STATE) is not None:
|
||||
self._set_state(self._boolean_state(resp.get(ATTR_STATE)))
|
||||
|
||||
def _boolean_state(self, int_state: int | None) -> bool | None:
|
||||
if int_state == 0:
|
||||
return self._activation == STATE_LOW
|
||||
if int_state == 1:
|
||||
return self._activation == STATE_HIGH
|
||||
return None
|
||||
|
||||
def _set_state(self, state):
|
||||
self._attr_is_on = state
|
||||
self.async_write_ha_state()
|
||||
_LOGGER.debug(
|
||||
"Setting status of %s actuator zone %s to %s",
|
||||
self._device_id,
|
||||
self.name,
|
||||
state,
|
||||
)
|
||||
|
||||
@callback
|
||||
def async_set_state(self, state):
|
||||
"""Update the switch state."""
|
||||
self._set_state(state)
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Store entity_id and register state change callback."""
|
||||
self._data["entity_id"] = self.entity_id
|
||||
self.async_on_remove(
|
||||
async_dispatcher_connect(
|
||||
self.hass, f"konnected.{self.entity_id}.update", self.async_set_state
|
||||
)
|
||||
)
|
||||
@@ -29,7 +29,6 @@ from homeassistant.components.mjpeg import (
|
||||
MjpegCamera,
|
||||
)
|
||||
from homeassistant.const import (
|
||||
CONF_ACTION,
|
||||
CONF_AUTHENTICATION,
|
||||
CONF_NAME,
|
||||
CONF_PASSWORD,
|
||||
@@ -44,6 +43,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import get_camera_from_cameras, is_acceptable_camera, listen_for_new_cameras
|
||||
from .const import (
|
||||
CONF_ACTION,
|
||||
CONF_STREAM_URL_TEMPLATE,
|
||||
CONF_SURVEILLANCE_PASSWORD,
|
||||
CONF_SURVEILLANCE_USERNAME,
|
||||
|
||||
@@ -29,6 +29,8 @@ DOMAIN: Final = "motioneye"
|
||||
ATTR_EVENT_TYPE: Final = "event_type"
|
||||
ATTR_WEBHOOK_ID: Final = "webhook_id"
|
||||
|
||||
# pylint: disable-next=home-assistant-duplicate-const
|
||||
CONF_ACTION: Final = "action"
|
||||
CONF_ADMIN_PASSWORD: Final = "admin_password"
|
||||
CONF_ADMIN_USERNAME: Final = "admin_username"
|
||||
CONF_MORE_OPTIONS: Final = "more_options"
|
||||
|
||||
@@ -10,7 +10,6 @@ from pyatmo.event import Event as NaEvent
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.camera import Camera, CameraEntityFeature
|
||||
from homeassistant.const import ATTR_PERSONS
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import config_validation as cv, entity_platform
|
||||
@@ -21,6 +20,7 @@ from .const import (
|
||||
ATTR_CAMERA_LIGHT_MODE,
|
||||
ATTR_EVENT_TYPE,
|
||||
ATTR_PERSON,
|
||||
ATTR_PERSONS,
|
||||
CAMERA_LIGHT_MODES,
|
||||
CAMERA_TRIGGERS,
|
||||
CONF_URL_SECURITY,
|
||||
|
||||
@@ -92,6 +92,8 @@ ATTR_HOME_ID = "home_id"
|
||||
ATTR_HOME_NAME = "home_name"
|
||||
ATTR_IS_KNOWN = "is_known"
|
||||
ATTR_PERSON = "person"
|
||||
# pylint: disable-next=home-assistant-duplicate-const
|
||||
ATTR_PERSONS = "persons"
|
||||
ATTR_PSEUDO = "pseudo"
|
||||
ATTR_SCHEDULE_ID = "schedule_id"
|
||||
ATTR_SCHEDULE_NAME = "schedule_name"
|
||||
|
||||
@@ -5,7 +5,7 @@ import logging
|
||||
|
||||
from aiohttp.web import Request
|
||||
|
||||
from homeassistant.const import ATTR_DEVICE_ID, ATTR_ID, ATTR_NAME, ATTR_PERSONS
|
||||
from homeassistant.const import ATTR_DEVICE_ID, ATTR_ID, ATTR_NAME
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_send
|
||||
|
||||
@@ -14,6 +14,7 @@ from .const import (
|
||||
ATTR_FACE_URL,
|
||||
ATTR_HOME_ID,
|
||||
ATTR_IS_KNOWN,
|
||||
ATTR_PERSONS,
|
||||
DATA_DEVICE_IDS,
|
||||
DATA_PERSONS,
|
||||
DEFAULT_PERSON,
|
||||
|
||||
@@ -8,6 +8,8 @@ DOMAIN = "nice_go"
|
||||
|
||||
# Configuration
|
||||
CONF_SITE_ID = "site_id"
|
||||
# pylint: disable-next=home-assistant-duplicate-const
|
||||
CONF_DEVICE_ID = "device_id"
|
||||
CONF_REFRESH_TOKEN = "refresh_token"
|
||||
CONF_REFRESH_TOKEN_CREATION_TIME = "refresh_token_creation_time"
|
||||
|
||||
|
||||
@@ -7,7 +7,6 @@ from pyrail.models import StationDetails
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||
from homeassistant.const import CONF_SHOW_ON_MAP
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.selector import (
|
||||
BooleanSelector,
|
||||
@@ -17,7 +16,13 @@ from homeassistant.helpers.selector import (
|
||||
SelectSelectorMode,
|
||||
)
|
||||
|
||||
from .const import CONF_EXCLUDE_VIAS, CONF_STATION_FROM, CONF_STATION_TO, DOMAIN
|
||||
from .const import (
|
||||
CONF_EXCLUDE_VIAS,
|
||||
CONF_SHOW_ON_MAP,
|
||||
CONF_STATION_FROM,
|
||||
CONF_STATION_TO,
|
||||
DOMAIN,
|
||||
)
|
||||
|
||||
|
||||
class NMBSConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
|
||||
@@ -13,6 +13,8 @@ CONF_STATION_FROM = "station_from"
|
||||
CONF_STATION_TO = "station_to"
|
||||
CONF_STATION_LIVE = "station_live"
|
||||
CONF_EXCLUDE_VIAS = "exclude_vias"
|
||||
# pylint: disable-next=home-assistant-duplicate-const
|
||||
CONF_SHOW_ON_MAP = "show_on_map"
|
||||
|
||||
|
||||
def find_station_by_name(hass: HomeAssistant, station_name: str):
|
||||
|
||||
@@ -8,7 +8,6 @@ import voluptuous as vol
|
||||
|
||||
from homeassistant.const import (
|
||||
CONF_BINARY_SENSORS,
|
||||
CONF_DEVICES,
|
||||
CONF_ID,
|
||||
CONF_NAME,
|
||||
CONF_SENSORS,
|
||||
@@ -29,6 +28,8 @@ DOMAIN = "numato"
|
||||
|
||||
CONF_INVERT_LOGIC = "invert_logic"
|
||||
CONF_DISCOVER = "discover"
|
||||
# pylint: disable-next=home-assistant-duplicate-const
|
||||
CONF_DEVICES = "devices"
|
||||
CONF_DEVICE_ID = "id"
|
||||
CONF_PORTS = "ports"
|
||||
CONF_SRC_RANGE = "source_range"
|
||||
|
||||
@@ -6,7 +6,7 @@ import logging
|
||||
from numato_gpio import NumatoGpioError
|
||||
|
||||
from homeassistant.components.binary_sensor import BinarySensorEntity
|
||||
from homeassistant.const import CONF_DEVICES, DEVICE_DEFAULT_NAME
|
||||
from homeassistant.const import DEVICE_DEFAULT_NAME
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_connect, dispatcher_send
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
@@ -14,6 +14,7 @@ from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
|
||||
|
||||
from . import (
|
||||
CONF_BINARY_SENSORS,
|
||||
CONF_DEVICES,
|
||||
CONF_ID,
|
||||
CONF_INVERT_LOGIC,
|
||||
CONF_PORTS,
|
||||
|
||||
@@ -5,12 +5,13 @@ import logging
|
||||
from numato_gpio import NumatoGpioError
|
||||
|
||||
from homeassistant.components.sensor import SensorEntity
|
||||
from homeassistant.const import CONF_DEVICES, CONF_ID, CONF_NAME, CONF_SENSORS
|
||||
from homeassistant.const import CONF_ID, CONF_NAME, CONF_SENSORS
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
|
||||
|
||||
from . import (
|
||||
CONF_DEVICES,
|
||||
CONF_DST_RANGE,
|
||||
CONF_DST_UNIT,
|
||||
CONF_PORTS,
|
||||
|
||||
@@ -8,13 +8,7 @@ import httpx
|
||||
import ollama
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry, ConfigSubentry
|
||||
from homeassistant.const import (
|
||||
CONF_API_KEY,
|
||||
CONF_MODEL,
|
||||
CONF_PROMPT,
|
||||
CONF_URL,
|
||||
Platform,
|
||||
)
|
||||
from homeassistant.const import CONF_API_KEY, CONF_URL, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import (
|
||||
ConfigEntryAuthFailed,
|
||||
@@ -32,7 +26,9 @@ from homeassistant.util.ssl import get_default_context
|
||||
from .const import (
|
||||
CONF_KEEP_ALIVE,
|
||||
CONF_MAX_HISTORY,
|
||||
CONF_MODEL,
|
||||
CONF_NUM_CTX,
|
||||
CONF_PROMPT,
|
||||
CONF_THINK,
|
||||
DEFAULT_AI_TASK_NAME,
|
||||
DEFAULT_NAME,
|
||||
|
||||
@@ -18,14 +18,7 @@ from homeassistant.config_entries import (
|
||||
ConfigSubentryFlow,
|
||||
SubentryFlowResult,
|
||||
)
|
||||
from homeassistant.const import (
|
||||
CONF_API_KEY,
|
||||
CONF_LLM_HASS_API,
|
||||
CONF_MODEL,
|
||||
CONF_NAME,
|
||||
CONF_PROMPT,
|
||||
CONF_URL,
|
||||
)
|
||||
from homeassistant.const import CONF_API_KEY, CONF_LLM_HASS_API, CONF_NAME, CONF_URL
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers import config_validation as cv, llm
|
||||
from homeassistant.helpers.selector import (
|
||||
@@ -47,7 +40,9 @@ from . import OllamaConfigEntry
|
||||
from .const import (
|
||||
CONF_KEEP_ALIVE,
|
||||
CONF_MAX_HISTORY,
|
||||
CONF_MODEL,
|
||||
CONF_NUM_CTX,
|
||||
CONF_PROMPT,
|
||||
CONF_THINK,
|
||||
DEFAULT_AI_TASK_NAME,
|
||||
DEFAULT_CONVERSATION_NAME,
|
||||
|
||||
@@ -4,6 +4,10 @@ DOMAIN = "ollama"
|
||||
|
||||
DEFAULT_NAME = "Ollama"
|
||||
|
||||
# pylint: disable-next=home-assistant-duplicate-const
|
||||
CONF_MODEL = "model"
|
||||
# pylint: disable-next=home-assistant-duplicate-const
|
||||
CONF_PROMPT = "prompt"
|
||||
CONF_THINK = "think"
|
||||
|
||||
CONF_KEEP_ALIVE = "keep_alive"
|
||||
|
||||
@@ -4,12 +4,12 @@ from typing import Literal
|
||||
|
||||
from homeassistant.components import conversation
|
||||
from homeassistant.config_entries import ConfigSubentry
|
||||
from homeassistant.const import CONF_LLM_HASS_API, CONF_PROMPT, MATCH_ALL
|
||||
from homeassistant.const import CONF_LLM_HASS_API, MATCH_ALL
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import OllamaConfigEntry
|
||||
from .const import DOMAIN
|
||||
from .const import CONF_PROMPT, DOMAIN
|
||||
from .entity import OllamaBaseLLMEntity
|
||||
|
||||
|
||||
|
||||
@@ -11,7 +11,6 @@ from voluptuous_openapi import convert
|
||||
|
||||
from homeassistant.components import conversation
|
||||
from homeassistant.config_entries import ConfigSubentry
|
||||
from homeassistant.const import CONF_MODEL
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import device_registry as dr, llm
|
||||
from homeassistant.helpers.entity import Entity
|
||||
@@ -21,6 +20,7 @@ from . import OllamaConfigEntry
|
||||
from .const import (
|
||||
CONF_KEEP_ALIVE,
|
||||
CONF_MAX_HISTORY,
|
||||
CONF_MODEL,
|
||||
CONF_NUM_CTX,
|
||||
CONF_THINK,
|
||||
DEFAULT_KEEP_ALIVE,
|
||||
|
||||
@@ -14,7 +14,7 @@ from homeassistant.config_entries import (
|
||||
ConfigFlowResult,
|
||||
OptionsFlowWithReload,
|
||||
)
|
||||
from homeassistant.const import CONF_DEVICE, CONF_HOST
|
||||
from homeassistant.const import CONF_HOST
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.data_entry_flow import section
|
||||
from homeassistant.helpers.selector import (
|
||||
@@ -47,6 +47,9 @@ from .util import get_meaning
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
# pylint: disable-next=home-assistant-duplicate-const
|
||||
CONF_DEVICE = "device"
|
||||
|
||||
INPUT_SOURCES_DEFAULT: list[InputSource] = []
|
||||
LISTENING_MODES_DEFAULT: list[ListeningMode] = []
|
||||
INPUT_SOURCES_ALL_MEANINGS = {
|
||||
|
||||
@@ -9,7 +9,6 @@ from homeassistant.components.sensor import (
|
||||
PLATFORM_SCHEMA as SENSOR_PLATFORM_SCHEMA,
|
||||
SensorEntity,
|
||||
)
|
||||
from homeassistant.const import CONF_NAME
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
@@ -19,6 +18,8 @@ SCAN_INTERVAL = timedelta(hours=12)
|
||||
|
||||
CONF_ZIP = "zip"
|
||||
CONF_WASTE_TYPE = "waste_type"
|
||||
# pylint: disable-next=home-assistant-duplicate-const
|
||||
CONF_NAME = "name"
|
||||
|
||||
PLATFORM_SCHEMA = SENSOR_PLATFORM_SCHEMA.extend(
|
||||
{
|
||||
|
||||
@@ -73,9 +73,6 @@
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
"cannot_connect": {
|
||||
"message": "Failed to connect to OpenRGB SDK server {server_address}: {error}"
|
||||
},
|
||||
"communication_error": {
|
||||
"message": "Failed to communicate with OpenRGB SDK server {server_address}: {error}"
|
||||
},
|
||||
|
||||
@@ -15,7 +15,7 @@ from homeassistant.components.water_heater import (
|
||||
WaterHeaterEntity,
|
||||
WaterHeaterEntityFeature,
|
||||
)
|
||||
from homeassistant.const import SERVICE_TURN_OFF, SERVICE_TURN_ON, UnitOfTemperature
|
||||
from homeassistant.const import UnitOfTemperature
|
||||
from homeassistant.core import HomeAssistant, ServiceResponse, SupportsResponse
|
||||
from homeassistant.helpers import config_validation as cv, entity_platform
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
@@ -45,6 +45,10 @@ SERVICE_GET_PROFILE = "get_profile"
|
||||
SERVICE_SET_PROFILE = "set_profile"
|
||||
SERVICE_SET_V40MIN = "set_v40_min"
|
||||
SERVICE_TURN_AWAY_MODE_ON = "turn_away_mode_on"
|
||||
# pylint: disable-next=home-assistant-duplicate-const
|
||||
SERVICE_TURN_OFF = "turn_off"
|
||||
# pylint: disable-next=home-assistant-duplicate-const
|
||||
SERVICE_TURN_ON = "turn_on"
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
|
||||
@@ -429,9 +429,6 @@ COVER_DESCRIPTIONS: list[OverkizCoverDescription] = [
|
||||
close_command=OverkizCommand.CLOSE,
|
||||
stop_command=OverkizCommand.STOP,
|
||||
is_closed_state=OverkizState.CORE_OPEN_CLOSED,
|
||||
current_tilt_position_state=OverkizState.CORE_SLATE_ORIENTATION,
|
||||
set_tilt_position_command=OverkizCommand.SET_ORIENTATION,
|
||||
stop_tilt_command=OverkizCommand.STOP,
|
||||
),
|
||||
OverkizCoverDescription(
|
||||
key=UIClass.ROLLER_SHUTTER,
|
||||
|
||||
@@ -19,7 +19,7 @@ import voluptuous as vol
|
||||
|
||||
from homeassistant.components import persistent_notification
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_ENABLED, CONF_SCAN_INTERVAL, CONF_TYPE
|
||||
from homeassistant.const import CONF_SCAN_INTERVAL, CONF_TYPE
|
||||
from homeassistant.core import HomeAssistant, ServiceCall, callback
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
@@ -70,6 +70,8 @@ DEFAULT_SCAN_INTERVAL = timedelta(seconds=30)
|
||||
|
||||
DEFAULT_MAX_OBJECTS = 5
|
||||
|
||||
# pylint: disable-next=home-assistant-duplicate-const
|
||||
CONF_ENABLED = "enabled"
|
||||
CONF_SECONDS = "seconds"
|
||||
CONF_MAX_OBJECTS = "max_objects"
|
||||
|
||||
|
||||
@@ -13,7 +13,6 @@ import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import (
|
||||
CONF_CONDITION,
|
||||
CONF_DEVICE_ID,
|
||||
CONF_IP_ADDRESS,
|
||||
CONF_PASSWORD,
|
||||
@@ -68,6 +67,8 @@ PLATFORMS = [
|
||||
Platform.UPDATE,
|
||||
]
|
||||
|
||||
# pylint: disable-next=home-assistant-duplicate-const
|
||||
CONF_CONDITION = "condition"
|
||||
CONF_DEWPOINT = "dewpoint"
|
||||
CONF_ET = "et"
|
||||
CONF_MAXRH = "maxrh"
|
||||
|
||||
@@ -1,243 +0,0 @@
|
||||
"""The Sandbox integration.
|
||||
|
||||
Manages config entries that should run in isolated sandbox processes.
|
||||
Config entries with options["sandbox"] set to a string value are grouped
|
||||
by that value — entries sharing the same string run in the same sandbox
|
||||
process. The sandbox integration spawns one process per group and provides
|
||||
a websocket API for sandbox clients to register entities and push state.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass, field
|
||||
import logging
|
||||
import sys
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.auth.models import RefreshToken, User
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
from .const import DATA_SANDBOX, DOMAIN
|
||||
from .entity import SandboxEntityManager
|
||||
from . import websocket_api as sandbox_ws
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
type SandboxConfigEntry = ConfigEntry[SandboxEntryData]
|
||||
|
||||
|
||||
@dataclass
|
||||
class SandboxInstance:
|
||||
"""A sandbox instance that runs one or more config entries."""
|
||||
|
||||
sandbox_id: str
|
||||
entries: list[dict[str, Any]]
|
||||
user: User | None = None
|
||||
refresh_token: RefreshToken | None = None
|
||||
access_token: str | None = None
|
||||
process: asyncio.subprocess.Process | None = None
|
||||
managed_entity_ids: set[str] = field(default_factory=set)
|
||||
send_command: Callable[[dict[str, Any]], None] | None = None
|
||||
pending_service_calls: dict[str, asyncio.Future[Any]] = field(
|
||||
default_factory=dict
|
||||
)
|
||||
pending_contexts: dict[str, dict[str, str | None]] = field(
|
||||
default_factory=dict
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class SandboxEntryData:
|
||||
"""Runtime data for a sandbox config entry."""
|
||||
|
||||
instance: SandboxInstance | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class SandboxData:
|
||||
"""Global sandbox data stored in hass.data."""
|
||||
|
||||
sandboxes: dict[str, SandboxInstance] = field(default_factory=dict)
|
||||
token_to_sandbox: dict[str, str] = field(default_factory=dict)
|
||||
host_entry_ids: dict[str, str] = field(default_factory=dict)
|
||||
entity_managers: dict[str, SandboxEntityManager] = field(default_factory=dict)
|
||||
|
||||
def get_host_entry_id(self, sandbox_id: str) -> str | None:
|
||||
"""Return the HA Core config entry ID that hosts this sandbox."""
|
||||
return self.host_entry_ids.get(sandbox_id)
|
||||
|
||||
|
||||
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
"""Set up the Sandbox integration."""
|
||||
hass.data[DATA_SANDBOX] = SandboxData()
|
||||
sandbox_ws.async_setup(hass)
|
||||
return True
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: SandboxConfigEntry) -> bool:
|
||||
"""Set up a sandbox from a config entry.
|
||||
|
||||
Supports two modes:
|
||||
1. Explicit entries: entry.data["entries"] contains a list of entry configs
|
||||
(used by test infrastructure).
|
||||
2. Discovery: entry.data["group"] names a sandbox group. All config entries
|
||||
with options["sandbox"] == group are collected automatically.
|
||||
"""
|
||||
sandbox_data = hass.data[DATA_SANDBOX]
|
||||
|
||||
group = entry.data.get("group")
|
||||
if group:
|
||||
sandbox_entries = _discover_group_entries(hass, group)
|
||||
else:
|
||||
sandbox_entries = entry.data.get("entries", [])
|
||||
|
||||
if not sandbox_entries:
|
||||
_LOGGER.warning("Sandbox %s has no entries to run", entry.entry_id)
|
||||
return True
|
||||
|
||||
sandbox_id = entry.entry_id
|
||||
|
||||
instance = SandboxInstance(
|
||||
sandbox_id=sandbox_id,
|
||||
entries=sandbox_entries,
|
||||
)
|
||||
|
||||
user = await hass.auth.async_create_system_user(
|
||||
f"Sandbox {sandbox_id[:8]}",
|
||||
group_ids=["system-admin"],
|
||||
)
|
||||
refresh_token = await hass.auth.async_create_refresh_token(user)
|
||||
access_token = hass.auth.async_create_access_token(refresh_token)
|
||||
|
||||
instance.user = user
|
||||
instance.refresh_token = refresh_token
|
||||
instance.access_token = access_token
|
||||
|
||||
sandbox_data.sandboxes[sandbox_id] = instance
|
||||
sandbox_data.token_to_sandbox[refresh_token.id] = sandbox_id
|
||||
sandbox_data.host_entry_ids[sandbox_id] = entry.entry_id
|
||||
|
||||
manager = SandboxEntityManager(hass, sandbox_id)
|
||||
sandbox_data.entity_managers[sandbox_id] = manager
|
||||
|
||||
entry.runtime_data = SandboxEntryData(instance=instance)
|
||||
|
||||
ws_url = _get_websocket_url(hass)
|
||||
if ws_url:
|
||||
instance.process = await _spawn_sandbox(
|
||||
hass, ws_url, access_token, sandbox_id
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: SandboxConfigEntry) -> bool:
|
||||
"""Unload a sandbox config entry."""
|
||||
sandbox_data = hass.data[DATA_SANDBOX]
|
||||
sandbox_id = entry.entry_id
|
||||
instance = sandbox_data.sandboxes.pop(sandbox_id, None)
|
||||
|
||||
if instance is None:
|
||||
return True
|
||||
|
||||
if instance.process is not None:
|
||||
try:
|
||||
instance.process.terminate()
|
||||
await asyncio.wait_for(instance.process.wait(), timeout=10)
|
||||
except (ProcessLookupError, asyncio.TimeoutError):
|
||||
if instance.process.returncode is None:
|
||||
instance.process.kill()
|
||||
|
||||
if instance.refresh_token is not None:
|
||||
sandbox_data.token_to_sandbox.pop(instance.refresh_token.id, None)
|
||||
hass.auth.async_remove_refresh_token(instance.refresh_token)
|
||||
|
||||
if instance.user is not None:
|
||||
await hass.auth.async_remove_user(instance.user)
|
||||
|
||||
sandbox_data.host_entry_ids.pop(sandbox_id, None)
|
||||
sandbox_data.entity_managers.pop(sandbox_id, None)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def _discover_group_entries(
|
||||
hass: HomeAssistant, group: str
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Find all config entries whose options.sandbox matches the group string."""
|
||||
entries = []
|
||||
for entry in hass.config_entries.async_entries():
|
||||
if entry.domain == DOMAIN:
|
||||
continue
|
||||
sandbox_opt = entry.options.get("sandbox")
|
||||
if sandbox_opt == group:
|
||||
entries.append(
|
||||
{
|
||||
"entry_id": entry.entry_id,
|
||||
"domain": entry.domain,
|
||||
"title": entry.title,
|
||||
"data": dict(entry.data),
|
||||
"options": {
|
||||
k: v
|
||||
for k, v in entry.options.items()
|
||||
if k != "sandbox"
|
||||
},
|
||||
}
|
||||
)
|
||||
return entries
|
||||
|
||||
|
||||
@callback
|
||||
def _get_websocket_url(hass: HomeAssistant) -> str | None:
|
||||
"""Build the local websocket URL."""
|
||||
if not hasattr(hass, "http") or hass.http is None:
|
||||
return None
|
||||
port = hass.http.server_port or 8123
|
||||
return f"ws://127.0.0.1:{port}/api/websocket"
|
||||
|
||||
|
||||
async def _spawn_sandbox(
|
||||
hass: HomeAssistant,
|
||||
ws_url: str,
|
||||
access_token: str,
|
||||
sandbox_id: str,
|
||||
) -> asyncio.subprocess.Process:
|
||||
"""Spawn a sandbox subprocess."""
|
||||
_LOGGER.info("Spawning sandbox process for %s", sandbox_id)
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
sys.executable,
|
||||
"-m",
|
||||
"hass_client.sandbox",
|
||||
"--url",
|
||||
ws_url,
|
||||
"--token",
|
||||
access_token,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
|
||||
async def _log_stream(
|
||||
stream: asyncio.StreamReader, level: int, prefix: str
|
||||
) -> None:
|
||||
while True:
|
||||
line = await stream.readline()
|
||||
if not line:
|
||||
break
|
||||
_LOGGER.log(level, "[sandbox %s] %s", prefix, line.decode().rstrip())
|
||||
|
||||
if process.stdout:
|
||||
hass.async_create_background_task(
|
||||
_log_stream(process.stdout, logging.INFO, sandbox_id[:8]),
|
||||
f"sandbox_stdout_{sandbox_id}",
|
||||
)
|
||||
if process.stderr:
|
||||
hass.async_create_background_task(
|
||||
_log_stream(process.stderr, logging.WARNING, sandbox_id[:8]),
|
||||
f"sandbox_stderr_{sandbox_id}",
|
||||
)
|
||||
|
||||
return process
|
||||
@@ -1,27 +0,0 @@
|
||||
"""Config flow for the Sandbox integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
|
||||
class SandboxConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Config flow for Sandbox."""
|
||||
|
||||
VERSION = 1
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle the initial step."""
|
||||
if user_input is not None:
|
||||
return self.async_create_entry(
|
||||
title="Sandbox",
|
||||
data=user_input,
|
||||
)
|
||||
|
||||
return self.async_show_form(step_id="user")
|
||||
@@ -1,7 +0,0 @@
|
||||
"""Constants for the Sandbox integration."""
|
||||
|
||||
from homeassistant.util.hass_dict import HassKey
|
||||
|
||||
DOMAIN = "sandbox"
|
||||
|
||||
DATA_SANDBOX: HassKey["SandboxData"] = HassKey(DOMAIN)
|
||||
@@ -1,280 +0,0 @@
|
||||
"""Remote entity proxies for sandboxed integrations."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from dataclasses import dataclass, field
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.entity import Entity
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class SandboxEntityDescription:
|
||||
"""Description of a remote entity from a sandbox."""
|
||||
|
||||
domain: str
|
||||
platform: str
|
||||
unique_id: str
|
||||
sandbox_id: str
|
||||
sandbox_entry_id: str
|
||||
device_id: str | None = None
|
||||
original_name: str | None = None
|
||||
original_icon: str | None = None
|
||||
entity_category: str | None = None
|
||||
device_class: str | None = None
|
||||
state_class: str | None = None
|
||||
supported_features: int = 0
|
||||
capabilities: dict[str, Any] = field(default_factory=dict)
|
||||
has_entity_name: bool = False
|
||||
|
||||
|
||||
class SandboxEntityManager:
|
||||
"""Manages proxy entities for a sandbox connection."""
|
||||
|
||||
def __init__(self, hass: HomeAssistant, sandbox_id: str) -> None:
|
||||
"""Initialize the entity manager."""
|
||||
self.hass = hass
|
||||
self.sandbox_id = sandbox_id
|
||||
self._entities: dict[str, SandboxProxyEntity] = {}
|
||||
self._pending_calls: dict[str, asyncio.Future[Any]] = {}
|
||||
self._call_id_counter = 0
|
||||
|
||||
@callback
|
||||
def add_entity(self, description: SandboxEntityDescription) -> SandboxProxyEntity:
|
||||
"""Create a proxy entity (not yet tracked by entity_id)."""
|
||||
return _create_proxy_entity(description, self)
|
||||
|
||||
@callback
|
||||
def track_entity(self, entity_id: str, entity: SandboxProxyEntity) -> None:
|
||||
"""Track a proxy entity by its assigned entity_id."""
|
||||
self._entities[entity_id] = entity
|
||||
|
||||
@callback
|
||||
def get_entity(self, entity_id: str) -> SandboxProxyEntity | None:
|
||||
"""Get a proxy entity by entity_id."""
|
||||
return self._entities.get(entity_id)
|
||||
|
||||
@callback
|
||||
def remove_entity(self, entity_id: str) -> None:
|
||||
"""Remove a proxy entity."""
|
||||
self._entities.pop(entity_id, None)
|
||||
|
||||
@callback
|
||||
def update_state(
|
||||
self, entity_id: str, state: str, attributes: dict[str, Any] | None
|
||||
) -> None:
|
||||
"""Update a proxy entity's state from sandbox push."""
|
||||
entity = self._entities.get(entity_id)
|
||||
if entity is None:
|
||||
return
|
||||
entity.sandbox_update_state(state, attributes or {})
|
||||
|
||||
@callback
|
||||
def mark_all_unavailable(self) -> None:
|
||||
"""Mark all entities as unavailable (sandbox disconnected)."""
|
||||
for entity in self._entities.values():
|
||||
entity.sandbox_set_available(False)
|
||||
|
||||
@callback
|
||||
def mark_all_available(self) -> None:
|
||||
"""Mark all entities as available (sandbox reconnected)."""
|
||||
for entity in self._entities.values():
|
||||
entity.sandbox_set_available(True)
|
||||
|
||||
def next_call_id(self) -> str:
|
||||
"""Generate a unique call ID."""
|
||||
self._call_id_counter += 1
|
||||
return f"{self.sandbox_id}_{self._call_id_counter}"
|
||||
|
||||
@callback
|
||||
def resolve_call(self, call_id: str, result: Any, error: str | None) -> None:
|
||||
"""Resolve a pending method call from the sandbox."""
|
||||
future = self._pending_calls.pop(call_id, None)
|
||||
if future is None or future.done():
|
||||
return
|
||||
if error:
|
||||
future.set_exception(Exception(error))
|
||||
else:
|
||||
future.set_result(result)
|
||||
|
||||
def create_call_future(self, call_id: str) -> asyncio.Future[Any]:
|
||||
"""Create a future for a pending call."""
|
||||
future: asyncio.Future[Any] = self.hass.loop.create_future()
|
||||
self._pending_calls[call_id] = future
|
||||
return future
|
||||
|
||||
|
||||
class SandboxProxyEntity(Entity):
|
||||
"""Base class for proxy entities that live on the host."""
|
||||
|
||||
_attr_should_poll = False
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
description: SandboxEntityDescription,
|
||||
manager: SandboxEntityManager,
|
||||
) -> None:
|
||||
"""Initialize the proxy entity."""
|
||||
self._description = description
|
||||
self._manager = manager
|
||||
self._sandbox_available = True
|
||||
self._state_cache: dict[str, Any] = {}
|
||||
self._attr_unique_id = description.unique_id
|
||||
self._attr_has_entity_name = description.has_entity_name
|
||||
if description.original_name:
|
||||
self._attr_name = description.original_name
|
||||
if description.original_icon:
|
||||
self._attr_icon = description.original_icon
|
||||
if description.device_class:
|
||||
self._attr_device_class = description.device_class
|
||||
self._attr_supported_features = description.supported_features
|
||||
|
||||
@property
|
||||
def device_info(self) -> DeviceInfo | None:
|
||||
"""Return device info to associate with the correct device."""
|
||||
if self._description.device_id is None:
|
||||
return None
|
||||
device_reg = dr.async_get(self.hass)
|
||||
device = device_reg.async_get(self._description.device_id)
|
||||
if device is None:
|
||||
return None
|
||||
return DeviceInfo(identifiers=device.identifiers)
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Register with entity manager once we have our entity_id."""
|
||||
self._manager.track_entity(self.entity_id, self)
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Return if entity is available."""
|
||||
return self._sandbox_available
|
||||
|
||||
@callback
|
||||
def sandbox_update_state(self, state: str, attributes: dict[str, Any]) -> None:
|
||||
"""Update state from sandbox push."""
|
||||
self._state_cache.update(attributes)
|
||||
self._state_cache["state"] = state
|
||||
self.async_write_ha_state()
|
||||
|
||||
@callback
|
||||
def sandbox_set_available(self, available: bool) -> None:
|
||||
"""Set availability."""
|
||||
self._sandbox_available = available
|
||||
self.async_write_ha_state()
|
||||
|
||||
async def _forward_method(self, method: str, **kwargs: Any) -> Any:
|
||||
"""Forward a method call to the sandbox entity."""
|
||||
from ..const import DATA_SANDBOX
|
||||
|
||||
sandbox_data = self.hass.data[DATA_SANDBOX]
|
||||
sandbox_info = sandbox_data.sandboxes.get(self._manager.sandbox_id)
|
||||
if sandbox_info is None or sandbox_info.send_command is None:
|
||||
raise RuntimeError("Sandbox not connected")
|
||||
|
||||
call_id = self._manager.next_call_id()
|
||||
future = self._manager.create_call_future(call_id)
|
||||
|
||||
sandbox_info.send_command(
|
||||
{
|
||||
"type": "call_method",
|
||||
"call_id": call_id,
|
||||
"entity_id": self.entity_id,
|
||||
"method": method,
|
||||
"kwargs": kwargs,
|
||||
}
|
||||
)
|
||||
|
||||
return await asyncio.wait_for(future, timeout=30)
|
||||
|
||||
|
||||
from .alarm_control_panel import SandboxAlarmControlPanelEntity
|
||||
from .binary_sensor import SandboxBinarySensorEntity
|
||||
from .button import SandboxButtonEntity
|
||||
from .calendar import SandboxCalendarEntity
|
||||
from .climate import SandboxClimateEntity
|
||||
from .cover import SandboxCoverEntity
|
||||
from .date import SandboxDateEntity
|
||||
from .datetime import SandboxDateTimeEntity
|
||||
from .device_tracker import SandboxScannerEntity, SandboxTrackerEntity
|
||||
from .event import SandboxEventEntity
|
||||
from .fan import SandboxFanEntity
|
||||
from .humidifier import SandboxHumidifierEntity
|
||||
from .lawn_mower import SandboxLawnMowerEntity
|
||||
from .light import SandboxLightEntity
|
||||
from .lock import SandboxLockEntity
|
||||
from .media_player import SandboxMediaPlayerEntity
|
||||
from .notify import SandboxNotifyEntity
|
||||
from .number import SandboxNumberEntity
|
||||
from .remote import SandboxRemoteEntity
|
||||
from .scene import SandboxSceneEntity
|
||||
from .select import SandboxSelectEntity
|
||||
from .sensor import SandboxSensorEntity
|
||||
from .siren import SandboxSirenEntity
|
||||
from .switch import SandboxSwitchEntity
|
||||
from .text import SandboxTextEntity
|
||||
from .time import SandboxTimeEntity
|
||||
from .todo import SandboxTodoListEntity
|
||||
from .update import SandboxUpdateEntity
|
||||
from .vacuum import SandboxVacuumEntity
|
||||
from .valve import SandboxValveEntity
|
||||
from .water_heater import SandboxWaterHeaterEntity
|
||||
from .weather import SandboxWeatherEntity
|
||||
|
||||
_DOMAIN_ENTITY_MAP: dict[str, type[SandboxProxyEntity]] = {
|
||||
"alarm_control_panel": SandboxAlarmControlPanelEntity,
|
||||
"binary_sensor": SandboxBinarySensorEntity,
|
||||
"button": SandboxButtonEntity,
|
||||
"calendar": SandboxCalendarEntity,
|
||||
"climate": SandboxClimateEntity,
|
||||
"cover": SandboxCoverEntity,
|
||||
"date": SandboxDateEntity,
|
||||
"datetime": SandboxDateTimeEntity,
|
||||
"device_tracker": SandboxTrackerEntity,
|
||||
"event": SandboxEventEntity,
|
||||
"fan": SandboxFanEntity,
|
||||
"humidifier": SandboxHumidifierEntity,
|
||||
"lawn_mower": SandboxLawnMowerEntity,
|
||||
"light": SandboxLightEntity,
|
||||
"lock": SandboxLockEntity,
|
||||
"media_player": SandboxMediaPlayerEntity,
|
||||
"notify": SandboxNotifyEntity,
|
||||
"number": SandboxNumberEntity,
|
||||
"remote": SandboxRemoteEntity,
|
||||
"scene": SandboxSceneEntity,
|
||||
"select": SandboxSelectEntity,
|
||||
"sensor": SandboxSensorEntity,
|
||||
"siren": SandboxSirenEntity,
|
||||
"switch": SandboxSwitchEntity,
|
||||
"text": SandboxTextEntity,
|
||||
"time": SandboxTimeEntity,
|
||||
"todo": SandboxTodoListEntity,
|
||||
"update": SandboxUpdateEntity,
|
||||
"vacuum": SandboxVacuumEntity,
|
||||
"valve": SandboxValveEntity,
|
||||
"water_heater": SandboxWaterHeaterEntity,
|
||||
"weather": SandboxWeatherEntity,
|
||||
}
|
||||
|
||||
|
||||
def _create_proxy_entity(
|
||||
description: SandboxEntityDescription,
|
||||
manager: SandboxEntityManager,
|
||||
) -> SandboxProxyEntity:
|
||||
"""Create the appropriate proxy entity for the domain."""
|
||||
entity_cls = _DOMAIN_ENTITY_MAP.get(description.domain, SandboxProxyEntity)
|
||||
return entity_cls(description, manager)
|
||||
|
||||
__all__ = [
|
||||
"SandboxEntityDescription",
|
||||
"SandboxEntityManager",
|
||||
"SandboxProxyEntity",
|
||||
"_DOMAIN_ENTITY_MAP",
|
||||
"_create_proxy_entity",
|
||||
]
|
||||
@@ -1,59 +0,0 @@
|
||||
"""Sandbox proxy for alarm_control_panel entities."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from homeassistant.components.alarm_control_panel import (
|
||||
AlarmControlPanelEntity,
|
||||
AlarmControlPanelEntityFeature,
|
||||
)
|
||||
|
||||
from . import SandboxEntityDescription, SandboxEntityManager, SandboxProxyEntity
|
||||
|
||||
|
||||
class SandboxAlarmControlPanelEntity(SandboxProxyEntity, AlarmControlPanelEntity):
|
||||
"""Proxy for an alarm_control_panel entity in a sandbox."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
description: SandboxEntityDescription,
|
||||
manager: SandboxEntityManager,
|
||||
) -> None:
|
||||
"""Initialize the proxy alarm control panel entity."""
|
||||
super().__init__(description, manager)
|
||||
self._attr_supported_features = AlarmControlPanelEntityFeature(
|
||||
description.supported_features
|
||||
)
|
||||
caps = description.capabilities
|
||||
if code_format := caps.get("code_format"):
|
||||
self._attr_code_format = code_format
|
||||
if (code_arm_required := caps.get("code_arm_required")) is not None:
|
||||
self._attr_code_arm_required = code_arm_required
|
||||
|
||||
@property
|
||||
def alarm_state(self) -> str | None:
|
||||
"""Return the alarm state."""
|
||||
return self._state_cache.get("state")
|
||||
|
||||
async def async_alarm_disarm(self, code: str | None = None) -> None:
|
||||
"""Forward alarm_disarm to sandbox."""
|
||||
await self._forward_method("async_alarm_disarm", code=code)
|
||||
|
||||
async def async_alarm_arm_home(self, code: str | None = None) -> None:
|
||||
"""Forward alarm_arm_home to sandbox."""
|
||||
await self._forward_method("async_alarm_arm_home", code=code)
|
||||
|
||||
async def async_alarm_arm_away(self, code: str | None = None) -> None:
|
||||
"""Forward alarm_arm_away to sandbox."""
|
||||
await self._forward_method("async_alarm_arm_away", code=code)
|
||||
|
||||
async def async_alarm_arm_night(self, code: str | None = None) -> None:
|
||||
"""Forward alarm_arm_night to sandbox."""
|
||||
await self._forward_method("async_alarm_arm_night", code=code)
|
||||
|
||||
async def async_alarm_arm_vacation(self, code: str | None = None) -> None:
|
||||
"""Forward alarm_arm_vacation to sandbox."""
|
||||
await self._forward_method("async_alarm_arm_vacation", code=code)
|
||||
|
||||
async def async_alarm_trigger(self, code: str | None = None) -> None:
|
||||
"""Forward alarm_trigger to sandbox."""
|
||||
await self._forward_method("async_alarm_trigger", code=code)
|
||||
@@ -1,19 +0,0 @@
|
||||
"""Sandbox proxy for binary_sensor entities."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from homeassistant.components.binary_sensor import BinarySensorEntity
|
||||
|
||||
from . import SandboxProxyEntity
|
||||
|
||||
|
||||
class SandboxBinarySensorEntity(SandboxProxyEntity, BinarySensorEntity):
|
||||
"""Proxy for a binary_sensor entity in a sandbox."""
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool | None:
|
||||
"""Return if the sensor is on."""
|
||||
state = self._state_cache.get("state")
|
||||
if state is None:
|
||||
return None
|
||||
return state == "on"
|
||||
@@ -1,15 +0,0 @@
|
||||
"""Sandbox proxy for button entities."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from homeassistant.components.button import ButtonEntity
|
||||
|
||||
from . import SandboxProxyEntity
|
||||
|
||||
|
||||
class SandboxButtonEntity(SandboxProxyEntity, ButtonEntity):
|
||||
"""Proxy for a button entity in a sandbox."""
|
||||
|
||||
async def async_press(self) -> None:
|
||||
"""Forward press to sandbox."""
|
||||
await self._forward_method("async_press")
|
||||
@@ -1,60 +0,0 @@
|
||||
"""Sandbox proxy for calendar entities."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, datetime
|
||||
|
||||
from homeassistant.components.calendar import CalendarEntity, CalendarEvent
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from . import SandboxProxyEntity
|
||||
|
||||
|
||||
class SandboxCalendarEntity(SandboxProxyEntity, CalendarEntity):
|
||||
"""Proxy for a calendar entity in a sandbox."""
|
||||
|
||||
@property
|
||||
def event(self) -> CalendarEvent | None:
|
||||
"""Return the next event."""
|
||||
event_data = self._state_cache.get("event")
|
||||
if event_data is None:
|
||||
return None
|
||||
start = event_data.get("start")
|
||||
end = event_data.get("end")
|
||||
if isinstance(start, str):
|
||||
start = datetime.fromisoformat(start) if "T" in start else date.fromisoformat(start)
|
||||
if isinstance(end, str):
|
||||
end = datetime.fromisoformat(end) if "T" in end else date.fromisoformat(end)
|
||||
return CalendarEvent(
|
||||
start=start,
|
||||
end=end,
|
||||
summary=event_data.get("summary", ""),
|
||||
description=event_data.get("description"),
|
||||
location=event_data.get("location"),
|
||||
)
|
||||
|
||||
async def async_get_events(self, hass: HomeAssistant, start_date, end_date) -> list[CalendarEvent]:
|
||||
"""Forward get_events to sandbox."""
|
||||
result = await self._forward_method(
|
||||
"async_get_events",
|
||||
start_date=start_date.isoformat(),
|
||||
end_date=end_date.isoformat(),
|
||||
)
|
||||
if not result:
|
||||
return []
|
||||
events = []
|
||||
for ev in result:
|
||||
start = ev.get("start")
|
||||
end = ev.get("end")
|
||||
if isinstance(start, str):
|
||||
start = datetime.fromisoformat(start) if "T" in start else date.fromisoformat(start)
|
||||
if isinstance(end, str):
|
||||
end = datetime.fromisoformat(end) if "T" in end else date.fromisoformat(end)
|
||||
events.append(CalendarEvent(
|
||||
start=start,
|
||||
end=end,
|
||||
summary=ev.get("summary", ""),
|
||||
description=ev.get("description"),
|
||||
location=ev.get("location"),
|
||||
))
|
||||
return events
|
||||
@@ -1,135 +0,0 @@
|
||||
"""Sandbox proxy for climate entities."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.components.climate import ClimateEntity, ClimateEntityFeature, HVACMode
|
||||
|
||||
from . import SandboxEntityDescription, SandboxEntityManager, SandboxProxyEntity
|
||||
|
||||
|
||||
class SandboxClimateEntity(SandboxProxyEntity, ClimateEntity):
|
||||
"""Proxy for a climate entity in a sandbox."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
description: SandboxEntityDescription,
|
||||
manager: SandboxEntityManager,
|
||||
) -> None:
|
||||
"""Initialize the proxy climate entity."""
|
||||
super().__init__(description, manager)
|
||||
self._attr_supported_features = ClimateEntityFeature(
|
||||
description.supported_features
|
||||
)
|
||||
caps = description.capabilities
|
||||
if hvac_modes := caps.get("hvac_modes"):
|
||||
self._attr_hvac_modes = [HVACMode(m) for m in hvac_modes]
|
||||
if fan_modes := caps.get("fan_modes"):
|
||||
self._attr_fan_modes = fan_modes
|
||||
if preset_modes := caps.get("preset_modes"):
|
||||
self._attr_preset_modes = preset_modes
|
||||
if swing_modes := caps.get("swing_modes"):
|
||||
self._attr_swing_modes = swing_modes
|
||||
if (min_temp := caps.get("min_temp")) is not None:
|
||||
self._attr_min_temp = min_temp
|
||||
if (max_temp := caps.get("max_temp")) is not None:
|
||||
self._attr_max_temp = max_temp
|
||||
if (min_humidity := caps.get("min_humidity")) is not None:
|
||||
self._attr_min_humidity = min_humidity
|
||||
if (max_humidity := caps.get("max_humidity")) is not None:
|
||||
self._attr_max_humidity = max_humidity
|
||||
if (temp_step := caps.get("target_temperature_step")) is not None:
|
||||
self._attr_target_temperature_step = temp_step
|
||||
if temp_unit := caps.get("temperature_unit"):
|
||||
self._attr_temperature_unit = temp_unit
|
||||
|
||||
@property
|
||||
def hvac_mode(self) -> HVACMode | None:
|
||||
"""Return the current HVAC mode."""
|
||||
mode = self._state_cache.get("hvac_mode")
|
||||
if mode is None:
|
||||
return None
|
||||
return HVACMode(mode)
|
||||
|
||||
@property
|
||||
def hvac_action(self) -> str | None:
|
||||
"""Return the current HVAC action."""
|
||||
return self._state_cache.get("hvac_action")
|
||||
|
||||
@property
|
||||
def current_temperature(self) -> float | None:
|
||||
"""Return the current temperature."""
|
||||
return self._state_cache.get("current_temperature")
|
||||
|
||||
@property
|
||||
def target_temperature(self) -> float | None:
|
||||
"""Return the target temperature."""
|
||||
return self._state_cache.get("target_temperature")
|
||||
|
||||
@property
|
||||
def target_temperature_high(self) -> float | None:
|
||||
"""Return the high target temperature."""
|
||||
return self._state_cache.get("target_temperature_high")
|
||||
|
||||
@property
|
||||
def target_temperature_low(self) -> float | None:
|
||||
"""Return the low target temperature."""
|
||||
return self._state_cache.get("target_temperature_low")
|
||||
|
||||
@property
|
||||
def current_humidity(self) -> float | None:
|
||||
"""Return the current humidity."""
|
||||
return self._state_cache.get("current_humidity")
|
||||
|
||||
@property
|
||||
def target_humidity(self) -> float | None:
|
||||
"""Return the target humidity."""
|
||||
return self._state_cache.get("target_humidity")
|
||||
|
||||
@property
|
||||
def fan_mode(self) -> str | None:
|
||||
"""Return the current fan mode."""
|
||||
return self._state_cache.get("fan_mode")
|
||||
|
||||
@property
|
||||
def preset_mode(self) -> str | None:
|
||||
"""Return the current preset mode."""
|
||||
return self._state_cache.get("preset_mode")
|
||||
|
||||
@property
|
||||
def swing_mode(self) -> str | None:
|
||||
"""Return the current swing mode."""
|
||||
return self._state_cache.get("swing_mode")
|
||||
|
||||
async def async_set_temperature(self, **kwargs: Any) -> None:
|
||||
"""Forward set_temperature to sandbox."""
|
||||
await self._forward_method("async_set_temperature", **kwargs)
|
||||
|
||||
async def async_set_humidity(self, humidity: int) -> None:
|
||||
"""Forward set_humidity to sandbox."""
|
||||
await self._forward_method("async_set_humidity", humidity=humidity)
|
||||
|
||||
async def async_set_fan_mode(self, fan_mode: str) -> None:
|
||||
"""Forward set_fan_mode to sandbox."""
|
||||
await self._forward_method("async_set_fan_mode", fan_mode=fan_mode)
|
||||
|
||||
async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
|
||||
"""Forward set_hvac_mode to sandbox."""
|
||||
await self._forward_method("async_set_hvac_mode", hvac_mode=hvac_mode)
|
||||
|
||||
async def async_set_preset_mode(self, preset_mode: str) -> None:
|
||||
"""Forward set_preset_mode to sandbox."""
|
||||
await self._forward_method("async_set_preset_mode", preset_mode=preset_mode)
|
||||
|
||||
async def async_set_swing_mode(self, swing_mode: str) -> None:
|
||||
"""Forward set_swing_mode to sandbox."""
|
||||
await self._forward_method("async_set_swing_mode", swing_mode=swing_mode)
|
||||
|
||||
async def async_turn_on(self) -> None:
|
||||
"""Forward turn_on to sandbox."""
|
||||
await self._forward_method("async_turn_on")
|
||||
|
||||
async def async_turn_off(self) -> None:
|
||||
"""Forward turn_off to sandbox."""
|
||||
await self._forward_method("async_turn_off")
|
||||
@@ -1,84 +0,0 @@
|
||||
"""Sandbox proxy for cover entities."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.components.cover import CoverEntity, CoverEntityFeature
|
||||
|
||||
from . import SandboxEntityDescription, SandboxEntityManager, SandboxProxyEntity
|
||||
|
||||
|
||||
class SandboxCoverEntity(SandboxProxyEntity, CoverEntity):
|
||||
"""Proxy for a cover entity in a sandbox."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
description: SandboxEntityDescription,
|
||||
manager: SandboxEntityManager,
|
||||
) -> None:
|
||||
"""Initialize the proxy cover entity."""
|
||||
super().__init__(description, manager)
|
||||
self._attr_supported_features = CoverEntityFeature(
|
||||
description.supported_features
|
||||
)
|
||||
|
||||
@property
|
||||
def is_closed(self) -> bool | None:
|
||||
"""Return if the cover is closed."""
|
||||
state = self._state_cache.get("state")
|
||||
if state is None:
|
||||
return None
|
||||
return state == "closed"
|
||||
|
||||
@property
|
||||
def is_opening(self) -> bool | None:
|
||||
"""Return if the cover is opening."""
|
||||
return self._state_cache.get("is_opening")
|
||||
|
||||
@property
|
||||
def is_closing(self) -> bool | None:
|
||||
"""Return if the cover is closing."""
|
||||
return self._state_cache.get("is_closing")
|
||||
|
||||
@property
|
||||
def current_cover_position(self) -> int | None:
|
||||
"""Return the current cover position."""
|
||||
return self._state_cache.get("current_cover_position")
|
||||
|
||||
@property
|
||||
def current_cover_tilt_position(self) -> int | None:
|
||||
"""Return the current tilt position."""
|
||||
return self._state_cache.get("current_cover_tilt_position")
|
||||
|
||||
async def async_open_cover(self, **kwargs: Any) -> None:
|
||||
"""Forward open_cover to sandbox."""
|
||||
await self._forward_method("async_open_cover", **kwargs)
|
||||
|
||||
async def async_close_cover(self, **kwargs: Any) -> None:
|
||||
"""Forward close_cover to sandbox."""
|
||||
await self._forward_method("async_close_cover", **kwargs)
|
||||
|
||||
async def async_stop_cover(self, **kwargs: Any) -> None:
|
||||
"""Forward stop_cover to sandbox."""
|
||||
await self._forward_method("async_stop_cover", **kwargs)
|
||||
|
||||
async def async_set_cover_position(self, **kwargs: Any) -> None:
|
||||
"""Forward set_cover_position to sandbox."""
|
||||
await self._forward_method("async_set_cover_position", **kwargs)
|
||||
|
||||
async def async_open_cover_tilt(self, **kwargs: Any) -> None:
|
||||
"""Forward open_cover_tilt to sandbox."""
|
||||
await self._forward_method("async_open_cover_tilt", **kwargs)
|
||||
|
||||
async def async_close_cover_tilt(self, **kwargs: Any) -> None:
|
||||
"""Forward close_cover_tilt to sandbox."""
|
||||
await self._forward_method("async_close_cover_tilt", **kwargs)
|
||||
|
||||
async def async_stop_cover_tilt(self, **kwargs: Any) -> None:
|
||||
"""Forward stop_cover_tilt to sandbox."""
|
||||
await self._forward_method("async_stop_cover_tilt", **kwargs)
|
||||
|
||||
async def async_set_cover_tilt_position(self, **kwargs: Any) -> None:
|
||||
"""Forward set_cover_tilt_position to sandbox."""
|
||||
await self._forward_method("async_set_cover_tilt_position", **kwargs)
|
||||
@@ -1,27 +0,0 @@
|
||||
"""Sandbox proxy for date entities."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
|
||||
from homeassistant.components.date import DateEntity
|
||||
|
||||
from . import SandboxProxyEntity
|
||||
|
||||
|
||||
class SandboxDateEntity(SandboxProxyEntity, DateEntity):
|
||||
"""Proxy for a date entity in a sandbox."""
|
||||
|
||||
@property
|
||||
def native_value(self):
|
||||
"""Return the current date value."""
|
||||
val = self._state_cache.get("state")
|
||||
if val is None:
|
||||
return None
|
||||
if isinstance(val, str):
|
||||
return date.fromisoformat(val)
|
||||
return val
|
||||
|
||||
async def async_set_value(self, value) -> None:
|
||||
"""Forward set_value to sandbox."""
|
||||
await self._forward_method("async_set_value", value=value.isoformat())
|
||||
@@ -1,30 +0,0 @@
|
||||
"""Sandbox proxy for datetime entities."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from homeassistant.components.datetime import DateTimeEntity
|
||||
|
||||
from . import SandboxProxyEntity
|
||||
|
||||
|
||||
class SandboxDateTimeEntity(SandboxProxyEntity, DateTimeEntity):
|
||||
"""Proxy for a datetime entity in a sandbox."""
|
||||
|
||||
@property
|
||||
def native_value(self):
|
||||
"""Return the current datetime value."""
|
||||
val = self._state_cache.get("state")
|
||||
if val is None:
|
||||
return None
|
||||
if isinstance(val, str):
|
||||
dt = datetime.fromisoformat(val)
|
||||
if dt.tzinfo is None:
|
||||
dt = dt.replace(tzinfo=timezone.utc)
|
||||
return dt
|
||||
return val
|
||||
|
||||
async def async_set_value(self, value) -> None:
|
||||
"""Forward set_value to sandbox."""
|
||||
await self._forward_method("async_set_value", value=value.isoformat())
|
||||
@@ -1,82 +0,0 @@
|
||||
"""Sandbox proxy for device_tracker entities."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from homeassistant.components.device_tracker import SourceType
|
||||
from homeassistant.components.device_tracker.config_entry import ScannerEntity, TrackerEntity
|
||||
|
||||
from . import SandboxEntityDescription, SandboxEntityManager, SandboxProxyEntity
|
||||
|
||||
|
||||
class SandboxTrackerEntity(SandboxProxyEntity, TrackerEntity):
|
||||
"""Proxy for a GPS device tracker entity in a sandbox."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
description: SandboxEntityDescription,
|
||||
manager: SandboxEntityManager,
|
||||
) -> None:
|
||||
"""Initialize the proxy tracker entity."""
|
||||
super().__init__(description, manager)
|
||||
if source_type := description.capabilities.get("source_type"):
|
||||
self._attr_source_type = SourceType(source_type)
|
||||
|
||||
@property
|
||||
def latitude(self) -> float | None:
|
||||
"""Return the latitude."""
|
||||
return self._state_cache.get("latitude")
|
||||
|
||||
@property
|
||||
def longitude(self) -> float | None:
|
||||
"""Return the longitude."""
|
||||
return self._state_cache.get("longitude")
|
||||
|
||||
@property
|
||||
def location_accuracy(self) -> float:
|
||||
"""Return the location accuracy."""
|
||||
return self._state_cache.get("location_accuracy", 0)
|
||||
|
||||
@property
|
||||
def location_name(self) -> str | None:
|
||||
"""Return the location name."""
|
||||
return self._state_cache.get("location_name")
|
||||
|
||||
@property
|
||||
def battery_level(self) -> int | None:
|
||||
"""Return the battery level."""
|
||||
return self._state_cache.get("battery_level")
|
||||
|
||||
|
||||
class SandboxScannerEntity(SandboxProxyEntity, ScannerEntity):
|
||||
"""Proxy for a scanner device tracker entity in a sandbox."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
description: SandboxEntityDescription,
|
||||
manager: SandboxEntityManager,
|
||||
) -> None:
|
||||
"""Initialize the proxy scanner entity."""
|
||||
super().__init__(description, manager)
|
||||
if source_type := description.capabilities.get("source_type"):
|
||||
self._attr_source_type = SourceType(source_type)
|
||||
|
||||
@property
|
||||
def is_connected(self) -> bool:
|
||||
"""Return if the device is connected."""
|
||||
state = self._state_cache.get("state")
|
||||
return state == "home"
|
||||
|
||||
@property
|
||||
def ip_address(self) -> str | None:
|
||||
"""Return the IP address."""
|
||||
return self._state_cache.get("ip_address")
|
||||
|
||||
@property
|
||||
def mac_address(self) -> str | None:
|
||||
"""Return the MAC address."""
|
||||
return self._state_cache.get("mac_address")
|
||||
|
||||
@property
|
||||
def hostname(self) -> str | None:
|
||||
"""Return the hostname."""
|
||||
return self._state_cache.get("hostname")
|
||||
@@ -1,40 +0,0 @@
|
||||
"""Sandbox proxy for event entities."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.components.event import EventEntity
|
||||
from homeassistant.core import callback
|
||||
|
||||
from . import SandboxEntityDescription, SandboxEntityManager, SandboxProxyEntity
|
||||
|
||||
|
||||
class SandboxEventEntity(SandboxProxyEntity, EventEntity):
|
||||
"""Proxy for an event entity in a sandbox."""
|
||||
|
||||
_unrecorded_attributes = frozenset({})
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
description: SandboxEntityDescription,
|
||||
manager: SandboxEntityManager,
|
||||
) -> None:
|
||||
"""Initialize the proxy event entity."""
|
||||
super().__init__(description, manager)
|
||||
self._attr_event_types = description.capabilities.get("event_types", [])
|
||||
|
||||
@callback
|
||||
def sandbox_update_state(self, state: str, attributes: dict[str, Any]) -> None:
|
||||
"""Handle event firing from sandbox."""
|
||||
event_type = attributes.get("event_type")
|
||||
if event_type:
|
||||
event_attributes = {
|
||||
k: v
|
||||
for k, v in attributes.items()
|
||||
if k not in ("event_type", "state")
|
||||
}
|
||||
self._trigger_event(event_type, event_attributes or None)
|
||||
self.async_write_ha_state()
|
||||
else:
|
||||
super().sandbox_update_state(state, attributes)
|
||||
@@ -1,80 +0,0 @@
|
||||
"""Sandbox proxy for fan entities."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.components.fan import FanEntity, FanEntityFeature
|
||||
|
||||
from . import SandboxEntityDescription, SandboxEntityManager, SandboxProxyEntity
|
||||
|
||||
|
||||
class SandboxFanEntity(SandboxProxyEntity, FanEntity):
|
||||
"""Proxy for a fan entity in a sandbox."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
description: SandboxEntityDescription,
|
||||
manager: SandboxEntityManager,
|
||||
) -> None:
|
||||
"""Initialize the proxy fan entity."""
|
||||
super().__init__(description, manager)
|
||||
self._attr_supported_features = FanEntityFeature(
|
||||
description.supported_features
|
||||
)
|
||||
if preset_modes := description.capabilities.get("preset_modes"):
|
||||
self._attr_preset_modes = preset_modes
|
||||
if speed_count := description.capabilities.get("speed_count"):
|
||||
self._attr_speed_count = speed_count
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool | None:
|
||||
"""Return if the fan is on."""
|
||||
state = self._state_cache.get("state")
|
||||
if state is None:
|
||||
return None
|
||||
return state == "on"
|
||||
|
||||
@property
|
||||
def percentage(self) -> int | None:
|
||||
"""Return the current speed percentage."""
|
||||
return self._state_cache.get("percentage")
|
||||
|
||||
@property
|
||||
def preset_mode(self) -> str | None:
|
||||
"""Return the current preset mode."""
|
||||
return self._state_cache.get("preset_mode")
|
||||
|
||||
@property
|
||||
def current_direction(self) -> str | None:
|
||||
"""Return the current direction."""
|
||||
return self._state_cache.get("current_direction")
|
||||
|
||||
@property
|
||||
def oscillating(self) -> bool | None:
|
||||
"""Return if the fan is oscillating."""
|
||||
return self._state_cache.get("oscillating")
|
||||
|
||||
async def async_turn_on(self, percentage: int | None = None, preset_mode: str | None = None, **kwargs: Any) -> None:
|
||||
"""Forward turn_on to sandbox."""
|
||||
await self._forward_method("async_turn_on", percentage=percentage, preset_mode=preset_mode, **kwargs)
|
||||
|
||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||
"""Forward turn_off to sandbox."""
|
||||
await self._forward_method("async_turn_off", **kwargs)
|
||||
|
||||
async def async_set_percentage(self, percentage: int) -> None:
|
||||
"""Forward set_percentage to sandbox."""
|
||||
await self._forward_method("async_set_percentage", percentage=percentage)
|
||||
|
||||
async def async_set_preset_mode(self, preset_mode: str) -> None:
|
||||
"""Forward set_preset_mode to sandbox."""
|
||||
await self._forward_method("async_set_preset_mode", preset_mode=preset_mode)
|
||||
|
||||
async def async_set_direction(self, direction: str) -> None:
|
||||
"""Forward set_direction to sandbox."""
|
||||
await self._forward_method("async_set_direction", direction=direction)
|
||||
|
||||
async def async_oscillate(self, oscillating: bool) -> None:
|
||||
"""Forward oscillate to sandbox."""
|
||||
await self._forward_method("async_oscillate", oscillating=oscillating)
|
||||
@@ -1,75 +0,0 @@
|
||||
"""Sandbox proxy for humidifier entities."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.components.humidifier import HumidifierEntity, HumidifierEntityFeature
|
||||
|
||||
from . import SandboxEntityDescription, SandboxEntityManager, SandboxProxyEntity
|
||||
|
||||
|
||||
class SandboxHumidifierEntity(SandboxProxyEntity, HumidifierEntity):
|
||||
"""Proxy for a humidifier entity in a sandbox."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
description: SandboxEntityDescription,
|
||||
manager: SandboxEntityManager,
|
||||
) -> None:
|
||||
"""Initialize the proxy humidifier entity."""
|
||||
super().__init__(description, manager)
|
||||
self._attr_supported_features = HumidifierEntityFeature(
|
||||
description.supported_features
|
||||
)
|
||||
caps = description.capabilities
|
||||
if available_modes := caps.get("available_modes"):
|
||||
self._attr_available_modes = available_modes
|
||||
if (min_humidity := caps.get("min_humidity")) is not None:
|
||||
self._attr_min_humidity = min_humidity
|
||||
if (max_humidity := caps.get("max_humidity")) is not None:
|
||||
self._attr_max_humidity = max_humidity
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool | None:
|
||||
"""Return if the humidifier is on."""
|
||||
state = self._state_cache.get("state")
|
||||
if state is None:
|
||||
return None
|
||||
return state == "on"
|
||||
|
||||
@property
|
||||
def current_humidity(self) -> float | None:
|
||||
"""Return the current humidity."""
|
||||
return self._state_cache.get("current_humidity")
|
||||
|
||||
@property
|
||||
def target_humidity(self) -> float | None:
|
||||
"""Return the target humidity."""
|
||||
return self._state_cache.get("target_humidity")
|
||||
|
||||
@property
|
||||
def mode(self) -> str | None:
|
||||
"""Return the current mode."""
|
||||
return self._state_cache.get("mode")
|
||||
|
||||
@property
|
||||
def action(self) -> str | None:
|
||||
"""Return the current action."""
|
||||
return self._state_cache.get("action")
|
||||
|
||||
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||
"""Forward turn_on to sandbox."""
|
||||
await self._forward_method("async_turn_on", **kwargs)
|
||||
|
||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||
"""Forward turn_off to sandbox."""
|
||||
await self._forward_method("async_turn_off", **kwargs)
|
||||
|
||||
async def async_set_humidity(self, humidity: int) -> None:
|
||||
"""Forward set_humidity to sandbox."""
|
||||
await self._forward_method("async_set_humidity", humidity=humidity)
|
||||
|
||||
async def async_set_mode(self, mode: str) -> None:
|
||||
"""Forward set_mode to sandbox."""
|
||||
await self._forward_method("async_set_mode", mode=mode)
|
||||
@@ -1,42 +0,0 @@
|
||||
"""Sandbox proxy for lawn_mower entities."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from homeassistant.components.lawn_mower import LawnMowerActivity, LawnMowerEntity, LawnMowerEntityFeature
|
||||
|
||||
from . import SandboxEntityDescription, SandboxEntityManager, SandboxProxyEntity
|
||||
|
||||
|
||||
class SandboxLawnMowerEntity(SandboxProxyEntity, LawnMowerEntity):
|
||||
"""Proxy for a lawn_mower entity in a sandbox."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
description: SandboxEntityDescription,
|
||||
manager: SandboxEntityManager,
|
||||
) -> None:
|
||||
"""Initialize the proxy lawn mower entity."""
|
||||
super().__init__(description, manager)
|
||||
self._attr_supported_features = LawnMowerEntityFeature(
|
||||
description.supported_features
|
||||
)
|
||||
|
||||
@property
|
||||
def activity(self) -> LawnMowerActivity | None:
|
||||
"""Return the current activity."""
|
||||
val = self._state_cache.get("activity")
|
||||
if val is None:
|
||||
return None
|
||||
return LawnMowerActivity(val)
|
||||
|
||||
async def async_start_mowing(self) -> None:
|
||||
"""Forward start_mowing to sandbox."""
|
||||
await self._forward_method("async_start_mowing")
|
||||
|
||||
async def async_dock(self) -> None:
|
||||
"""Forward dock to sandbox."""
|
||||
await self._forward_method("async_dock")
|
||||
|
||||
async def async_pause(self) -> None:
|
||||
"""Forward pause to sandbox."""
|
||||
await self._forward_method("async_pause")
|
||||
@@ -1,134 +0,0 @@
|
||||
"""Sandbox proxy for light entities."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import 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 . import SandboxEntityDescription, SandboxEntityManager, SandboxProxyEntity
|
||||
|
||||
|
||||
class SandboxLightEntity(SandboxProxyEntity, LightEntity):
|
||||
"""Proxy for a light entity in a sandbox."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
description: SandboxEntityDescription,
|
||||
manager: SandboxEntityManager,
|
||||
) -> None:
|
||||
"""Initialize the proxy light entity."""
|
||||
super().__init__(description, manager)
|
||||
self._attr_supported_features = LightEntityFeature(
|
||||
description.supported_features
|
||||
)
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool | None:
|
||||
"""Return if the light is on."""
|
||||
state = self._state_cache.get("state")
|
||||
if state is None:
|
||||
return None
|
||||
return state == "on"
|
||||
|
||||
@property
|
||||
def brightness(self) -> int | None:
|
||||
"""Return the brightness."""
|
||||
return self._state_cache.get(ATTR_BRIGHTNESS)
|
||||
|
||||
@property
|
||||
def color_mode(self) -> ColorMode | str | None:
|
||||
"""Return the color mode."""
|
||||
return self._state_cache.get(ATTR_COLOR_MODE)
|
||||
|
||||
@property
|
||||
def hs_color(self) -> tuple[float, float] | None:
|
||||
"""Return the 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 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 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 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 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 color temperature in kelvin."""
|
||||
return self._state_cache.get(ATTR_COLOR_TEMP_KELVIN)
|
||||
|
||||
@property
|
||||
def min_color_temp_kelvin(self) -> int:
|
||||
"""Return the min color temperature."""
|
||||
return self._description.capabilities.get(
|
||||
ATTR_MIN_COLOR_TEMP_KELVIN, 2000
|
||||
)
|
||||
|
||||
@property
|
||||
def max_color_temp_kelvin(self) -> int:
|
||||
"""Return the max color temperature."""
|
||||
return self._description.capabilities.get(
|
||||
ATTR_MAX_COLOR_TEMP_KELVIN, 6500
|
||||
)
|
||||
|
||||
@property
|
||||
def effect(self) -> str | None:
|
||||
"""Return the current effect."""
|
||||
return self._state_cache.get(ATTR_EFFECT)
|
||||
|
||||
@property
|
||||
def effect_list(self) -> list[str] | None:
|
||||
"""Return the list of supported effects."""
|
||||
return self._description.capabilities.get(ATTR_EFFECT_LIST)
|
||||
|
||||
@property
|
||||
def supported_color_modes(self) -> set[ColorMode] | set[str] | None:
|
||||
"""Return the supported color modes."""
|
||||
modes = self._description.capabilities.get(ATTR_SUPPORTED_COLOR_MODES)
|
||||
if modes is None:
|
||||
return None
|
||||
return {ColorMode(m) for m in modes}
|
||||
|
||||
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||
"""Forward turn_on to sandbox."""
|
||||
await self._forward_method("async_turn_on", **kwargs)
|
||||
|
||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||
"""Forward turn_off to sandbox."""
|
||||
await self._forward_method("async_turn_off", **kwargs)
|
||||
@@ -1,64 +0,0 @@
|
||||
"""Sandbox proxy for lock entities."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.components.lock import LockEntity, LockEntityFeature
|
||||
|
||||
from . import SandboxEntityDescription, SandboxEntityManager, SandboxProxyEntity
|
||||
|
||||
|
||||
class SandboxLockEntity(SandboxProxyEntity, LockEntity):
|
||||
"""Proxy for a lock entity in a sandbox."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
description: SandboxEntityDescription,
|
||||
manager: SandboxEntityManager,
|
||||
) -> None:
|
||||
"""Initialize the proxy lock entity."""
|
||||
super().__init__(description, manager)
|
||||
self._attr_supported_features = LockEntityFeature(
|
||||
description.supported_features
|
||||
)
|
||||
|
||||
@property
|
||||
def is_locked(self) -> bool | None:
|
||||
"""Return if the lock is locked."""
|
||||
state = self._state_cache.get("state")
|
||||
if state is None:
|
||||
return None
|
||||
return state == "locked"
|
||||
|
||||
@property
|
||||
def is_locking(self) -> bool | None:
|
||||
"""Return if the lock is locking."""
|
||||
return self._state_cache.get("is_locking")
|
||||
|
||||
@property
|
||||
def is_unlocking(self) -> bool | None:
|
||||
"""Return if the lock is unlocking."""
|
||||
return self._state_cache.get("is_unlocking")
|
||||
|
||||
@property
|
||||
def is_jammed(self) -> bool | None:
|
||||
"""Return if the lock is jammed."""
|
||||
return self._state_cache.get("is_jammed")
|
||||
|
||||
@property
|
||||
def is_open(self) -> bool | None:
|
||||
"""Return if the lock is open."""
|
||||
return self._state_cache.get("is_open")
|
||||
|
||||
async def async_lock(self, **kwargs: Any) -> None:
|
||||
"""Forward lock to sandbox."""
|
||||
await self._forward_method("async_lock", **kwargs)
|
||||
|
||||
async def async_unlock(self, **kwargs: Any) -> None:
|
||||
"""Forward unlock to sandbox."""
|
||||
await self._forward_method("async_unlock", **kwargs)
|
||||
|
||||
async def async_open(self, **kwargs: Any) -> None:
|
||||
"""Forward open to sandbox."""
|
||||
await self._forward_method("async_open", **kwargs)
|
||||
@@ -1,170 +0,0 @@
|
||||
"""Sandbox proxy for media_player entities."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.components.media_player import (
|
||||
MediaPlayerEntity,
|
||||
MediaPlayerEntityFeature,
|
||||
MediaPlayerState,
|
||||
RepeatMode,
|
||||
)
|
||||
|
||||
from . import SandboxEntityDescription, SandboxEntityManager, SandboxProxyEntity
|
||||
|
||||
|
||||
class SandboxMediaPlayerEntity(SandboxProxyEntity, MediaPlayerEntity):
|
||||
"""Proxy for a media_player entity in a sandbox."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
description: SandboxEntityDescription,
|
||||
manager: SandboxEntityManager,
|
||||
) -> None:
|
||||
"""Initialize the proxy media player entity."""
|
||||
super().__init__(description, manager)
|
||||
self._attr_supported_features = MediaPlayerEntityFeature(
|
||||
description.supported_features
|
||||
)
|
||||
caps = description.capabilities
|
||||
if source_list := caps.get("source_list"):
|
||||
self._attr_source_list = source_list
|
||||
if sound_mode_list := caps.get("sound_mode_list"):
|
||||
self._attr_sound_mode_list = sound_mode_list
|
||||
|
||||
@property
|
||||
def state(self) -> MediaPlayerState | None:
|
||||
"""Return the current state."""
|
||||
state = self._state_cache.get("state")
|
||||
if state is None:
|
||||
return None
|
||||
return MediaPlayerState(state)
|
||||
|
||||
@property
|
||||
def volume_level(self) -> float | None:
|
||||
"""Return the volume level."""
|
||||
return self._state_cache.get("volume_level")
|
||||
|
||||
@property
|
||||
def is_volume_muted(self) -> bool | None:
|
||||
"""Return if volume is muted."""
|
||||
return self._state_cache.get("is_volume_muted")
|
||||
|
||||
@property
|
||||
def media_content_id(self) -> str | None:
|
||||
"""Return the media content ID."""
|
||||
return self._state_cache.get("media_content_id")
|
||||
|
||||
@property
|
||||
def media_content_type(self) -> str | None:
|
||||
"""Return the media content type."""
|
||||
return self._state_cache.get("media_content_type")
|
||||
|
||||
@property
|
||||
def media_title(self) -> str | None:
|
||||
"""Return the media title."""
|
||||
return self._state_cache.get("media_title")
|
||||
|
||||
@property
|
||||
def media_artist(self) -> str | None:
|
||||
"""Return the media artist."""
|
||||
return self._state_cache.get("media_artist")
|
||||
|
||||
@property
|
||||
def media_album_name(self) -> str | None:
|
||||
"""Return the media album name."""
|
||||
return self._state_cache.get("media_album_name")
|
||||
|
||||
@property
|
||||
def media_duration(self) -> float | None:
|
||||
"""Return the media duration."""
|
||||
return self._state_cache.get("media_duration")
|
||||
|
||||
@property
|
||||
def media_position(self) -> float | None:
|
||||
"""Return the media position."""
|
||||
return self._state_cache.get("media_position")
|
||||
|
||||
@property
|
||||
def source(self) -> str | None:
|
||||
"""Return the current source."""
|
||||
return self._state_cache.get("source")
|
||||
|
||||
@property
|
||||
def sound_mode(self) -> str | None:
|
||||
"""Return the current sound mode."""
|
||||
return self._state_cache.get("sound_mode")
|
||||
|
||||
@property
|
||||
def shuffle(self) -> bool | None:
|
||||
"""Return if shuffle is enabled."""
|
||||
return self._state_cache.get("shuffle")
|
||||
|
||||
@property
|
||||
def repeat(self) -> RepeatMode | None:
|
||||
"""Return the current repeat mode."""
|
||||
val = self._state_cache.get("repeat")
|
||||
if val is None:
|
||||
return None
|
||||
return RepeatMode(val)
|
||||
|
||||
async def async_turn_on(self) -> None:
|
||||
"""Forward turn_on to sandbox."""
|
||||
await self._forward_method("async_turn_on")
|
||||
|
||||
async def async_turn_off(self) -> None:
|
||||
"""Forward turn_off to sandbox."""
|
||||
await self._forward_method("async_turn_off")
|
||||
|
||||
async def async_volume_up(self) -> None:
|
||||
"""Forward volume_up to sandbox."""
|
||||
await self._forward_method("async_volume_up")
|
||||
|
||||
async def async_volume_down(self) -> None:
|
||||
"""Forward volume_down to sandbox."""
|
||||
await self._forward_method("async_volume_down")
|
||||
|
||||
async def async_set_volume_level(self, volume: float) -> None:
|
||||
"""Forward set_volume_level to sandbox."""
|
||||
await self._forward_method("async_set_volume_level", volume=volume)
|
||||
|
||||
async def async_mute_volume(self, mute: bool) -> None:
|
||||
"""Forward mute_volume to sandbox."""
|
||||
await self._forward_method("async_mute_volume", mute=mute)
|
||||
|
||||
async def async_media_play(self) -> None:
|
||||
"""Forward media_play to sandbox."""
|
||||
await self._forward_method("async_media_play")
|
||||
|
||||
async def async_media_pause(self) -> None:
|
||||
"""Forward media_pause to sandbox."""
|
||||
await self._forward_method("async_media_pause")
|
||||
|
||||
async def async_media_stop(self) -> None:
|
||||
"""Forward media_stop to sandbox."""
|
||||
await self._forward_method("async_media_stop")
|
||||
|
||||
async def async_media_next_track(self) -> None:
|
||||
"""Forward media_next_track to sandbox."""
|
||||
await self._forward_method("async_media_next_track")
|
||||
|
||||
async def async_media_previous_track(self) -> None:
|
||||
"""Forward media_previous_track to sandbox."""
|
||||
await self._forward_method("async_media_previous_track")
|
||||
|
||||
async def async_media_seek(self, position: float) -> None:
|
||||
"""Forward media_seek to sandbox."""
|
||||
await self._forward_method("async_media_seek", position=position)
|
||||
|
||||
async def async_select_source(self, source: str) -> None:
|
||||
"""Forward select_source to sandbox."""
|
||||
await self._forward_method("async_select_source", source=source)
|
||||
|
||||
async def async_select_sound_mode(self, sound_mode: str) -> None:
|
||||
"""Forward select_sound_mode to sandbox."""
|
||||
await self._forward_method("async_select_sound_mode", sound_mode=sound_mode)
|
||||
|
||||
async def async_play_media(self, media_type: str, media_id: str, **kwargs: Any) -> None:
|
||||
"""Forward play_media to sandbox."""
|
||||
await self._forward_method("async_play_media", media_type=media_type, media_id=media_id, **kwargs)
|
||||
@@ -1,26 +0,0 @@
|
||||
"""Sandbox proxy for notify entities."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from homeassistant.components.notify import NotifyEntity, NotifyEntityFeature
|
||||
|
||||
from . import SandboxEntityDescription, SandboxEntityManager, SandboxProxyEntity
|
||||
|
||||
|
||||
class SandboxNotifyEntity(SandboxProxyEntity, NotifyEntity):
|
||||
"""Proxy for a notify entity in a sandbox."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
description: SandboxEntityDescription,
|
||||
manager: SandboxEntityManager,
|
||||
) -> None:
|
||||
"""Initialize the proxy notify entity."""
|
||||
super().__init__(description, manager)
|
||||
self._attr_supported_features = NotifyEntityFeature(
|
||||
description.supported_features
|
||||
)
|
||||
|
||||
async def async_send_message(self, message: str, title: str | None = None) -> None:
|
||||
"""Forward send_message to sandbox."""
|
||||
await self._forward_method("async_send_message", message=message, title=title)
|
||||
@@ -1,42 +0,0 @@
|
||||
"""Sandbox proxy for number entities."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from homeassistant.components.number import NumberEntity, NumberMode
|
||||
|
||||
from . import SandboxEntityDescription, SandboxEntityManager, SandboxProxyEntity
|
||||
|
||||
|
||||
class SandboxNumberEntity(SandboxProxyEntity, NumberEntity):
|
||||
"""Proxy for a number entity in a sandbox."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
description: SandboxEntityDescription,
|
||||
manager: SandboxEntityManager,
|
||||
) -> None:
|
||||
"""Initialize the proxy number entity."""
|
||||
super().__init__(description, manager)
|
||||
caps = description.capabilities
|
||||
if (min_val := caps.get("native_min_value")) is not None:
|
||||
self._attr_native_min_value = min_val
|
||||
if (max_val := caps.get("native_max_value")) is not None:
|
||||
self._attr_native_max_value = max_val
|
||||
if (step := caps.get("native_step")) is not None:
|
||||
self._attr_native_step = step
|
||||
if unit := caps.get("native_unit_of_measurement"):
|
||||
self._attr_native_unit_of_measurement = unit
|
||||
if mode := caps.get("mode"):
|
||||
self._attr_mode = NumberMode(mode)
|
||||
|
||||
@property
|
||||
def native_value(self) -> float | None:
|
||||
"""Return the current value."""
|
||||
val = self._state_cache.get("state")
|
||||
if val is None:
|
||||
return None
|
||||
return float(val)
|
||||
|
||||
async def async_set_native_value(self, value: float) -> None:
|
||||
"""Forward set_native_value to sandbox."""
|
||||
await self._forward_method("async_set_native_value", value=value)
|
||||
@@ -1,51 +0,0 @@
|
||||
"""Sandbox proxy for remote entities."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.components.remote import RemoteEntity, RemoteEntityFeature
|
||||
|
||||
from . import SandboxEntityDescription, SandboxEntityManager, SandboxProxyEntity
|
||||
|
||||
|
||||
class SandboxRemoteEntity(SandboxProxyEntity, RemoteEntity):
|
||||
"""Proxy for a remote entity in a sandbox."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
description: SandboxEntityDescription,
|
||||
manager: SandboxEntityManager,
|
||||
) -> None:
|
||||
"""Initialize the proxy remote entity."""
|
||||
super().__init__(description, manager)
|
||||
self._attr_supported_features = RemoteEntityFeature(
|
||||
description.supported_features
|
||||
)
|
||||
if activity_list := description.capabilities.get("activity_list"):
|
||||
self._attr_activity_list = activity_list
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool | None:
|
||||
"""Return if the remote is on."""
|
||||
state = self._state_cache.get("state")
|
||||
if state is None:
|
||||
return None
|
||||
return state == "on"
|
||||
|
||||
@property
|
||||
def current_activity(self) -> str | None:
|
||||
"""Return the current activity."""
|
||||
return self._state_cache.get("current_activity")
|
||||
|
||||
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||
"""Forward turn_on to sandbox."""
|
||||
await self._forward_method("async_turn_on", **kwargs)
|
||||
|
||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||
"""Forward turn_off to sandbox."""
|
||||
await self._forward_method("async_turn_off", **kwargs)
|
||||
|
||||
async def async_send_command(self, command: list[str], **kwargs: Any) -> None:
|
||||
"""Forward send_command to sandbox."""
|
||||
await self._forward_method("async_send_command", command=command, **kwargs)
|
||||
@@ -1,17 +0,0 @@
|
||||
"""Sandbox proxy for scene entities."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.components.scene import Scene
|
||||
|
||||
from . import SandboxProxyEntity
|
||||
|
||||
|
||||
class SandboxSceneEntity(SandboxProxyEntity, Scene):
|
||||
"""Proxy for a scene entity in a sandbox."""
|
||||
|
||||
async def async_activate(self, **kwargs: Any) -> None:
|
||||
"""Forward activate to sandbox."""
|
||||
await self._forward_method("async_activate", **kwargs)
|
||||
@@ -1,29 +0,0 @@
|
||||
"""Sandbox proxy for select entities."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from homeassistant.components.select import SelectEntity
|
||||
|
||||
from . import SandboxEntityDescription, SandboxEntityManager, SandboxProxyEntity
|
||||
|
||||
|
||||
class SandboxSelectEntity(SandboxProxyEntity, SelectEntity):
|
||||
"""Proxy for a select entity in a sandbox."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
description: SandboxEntityDescription,
|
||||
manager: SandboxEntityManager,
|
||||
) -> None:
|
||||
"""Initialize the proxy select entity."""
|
||||
super().__init__(description, manager)
|
||||
self._attr_options = description.capabilities.get("options", [])
|
||||
|
||||
@property
|
||||
def current_option(self) -> str | None:
|
||||
"""Return the current option."""
|
||||
return self._state_cache.get("state")
|
||||
|
||||
async def async_select_option(self, option: str) -> None:
|
||||
"""Forward select_option to sandbox."""
|
||||
await self._forward_method("async_select_option", option=option)
|
||||
@@ -1,29 +0,0 @@
|
||||
"""Sandbox proxy for sensor entities."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from homeassistant.components.sensor import SensorEntity, SensorStateClass
|
||||
|
||||
from . import SandboxEntityDescription, SandboxEntityManager, SandboxProxyEntity
|
||||
|
||||
|
||||
class SandboxSensorEntity(SandboxProxyEntity, SensorEntity):
|
||||
"""Proxy for a sensor entity in a sandbox."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
description: SandboxEntityDescription,
|
||||
manager: SandboxEntityManager,
|
||||
) -> None:
|
||||
"""Initialize the proxy sensor entity."""
|
||||
super().__init__(description, manager)
|
||||
if description.state_class:
|
||||
self._attr_state_class = SensorStateClass(description.state_class)
|
||||
unit = description.capabilities.get("native_unit_of_measurement")
|
||||
if unit:
|
||||
self._attr_native_unit_of_measurement = unit
|
||||
|
||||
@property
|
||||
def native_value(self) -> str | int | float | None:
|
||||
"""Return the sensor value."""
|
||||
return self._state_cache.get("state")
|
||||
@@ -1,42 +0,0 @@
|
||||
"""Sandbox proxy for siren entities."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.components.siren import SirenEntity, SirenEntityFeature
|
||||
|
||||
from . import SandboxEntityDescription, SandboxEntityManager, SandboxProxyEntity
|
||||
|
||||
|
||||
class SandboxSirenEntity(SandboxProxyEntity, SirenEntity):
|
||||
"""Proxy for a siren entity in a sandbox."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
description: SandboxEntityDescription,
|
||||
manager: SandboxEntityManager,
|
||||
) -> None:
|
||||
"""Initialize the proxy siren entity."""
|
||||
super().__init__(description, manager)
|
||||
self._attr_supported_features = SirenEntityFeature(
|
||||
description.supported_features
|
||||
)
|
||||
if available_tones := description.capabilities.get("available_tones"):
|
||||
self._attr_available_tones = available_tones
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool | None:
|
||||
"""Return if the siren is on."""
|
||||
state = self._state_cache.get("state")
|
||||
if state is None:
|
||||
return None
|
||||
return state == "on"
|
||||
|
||||
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||
"""Forward turn_on to sandbox."""
|
||||
await self._forward_method("async_turn_on", **kwargs)
|
||||
|
||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||
"""Forward turn_off to sandbox."""
|
||||
await self._forward_method("async_turn_off", **kwargs)
|
||||
@@ -1,29 +0,0 @@
|
||||
"""Sandbox proxy for switch entities."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.components.switch import SwitchEntity
|
||||
|
||||
from . import SandboxProxyEntity
|
||||
|
||||
|
||||
class SandboxSwitchEntity(SandboxProxyEntity, SwitchEntity):
|
||||
"""Proxy for a switch entity in a sandbox."""
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool | None:
|
||||
"""Return if the switch is on."""
|
||||
state = self._state_cache.get("state")
|
||||
if state is None:
|
||||
return None
|
||||
return state == "on"
|
||||
|
||||
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||
"""Forward turn_on to sandbox."""
|
||||
await self._forward_method("async_turn_on", **kwargs)
|
||||
|
||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||
"""Forward turn_off to sandbox."""
|
||||
await self._forward_method("async_turn_off", **kwargs)
|
||||
@@ -1,37 +0,0 @@
|
||||
"""Sandbox proxy for text entities."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from homeassistant.components.text import TextEntity, TextMode
|
||||
|
||||
from . import SandboxEntityDescription, SandboxEntityManager, SandboxProxyEntity
|
||||
|
||||
|
||||
class SandboxTextEntity(SandboxProxyEntity, TextEntity):
|
||||
"""Proxy for a text entity in a sandbox."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
description: SandboxEntityDescription,
|
||||
manager: SandboxEntityManager,
|
||||
) -> None:
|
||||
"""Initialize the proxy text entity."""
|
||||
super().__init__(description, manager)
|
||||
caps = description.capabilities
|
||||
if (native_min := caps.get("native_min")) is not None:
|
||||
self._attr_native_min = native_min
|
||||
if (native_max := caps.get("native_max")) is not None:
|
||||
self._attr_native_max = native_max
|
||||
if mode := caps.get("mode"):
|
||||
self._attr_mode = TextMode(mode)
|
||||
if pattern := caps.get("pattern"):
|
||||
self._attr_pattern = pattern
|
||||
|
||||
@property
|
||||
def native_value(self) -> str | None:
|
||||
"""Return the current value."""
|
||||
return self._state_cache.get("state")
|
||||
|
||||
async def async_set_value(self, value: str) -> None:
|
||||
"""Forward set_value to sandbox."""
|
||||
await self._forward_method("async_set_value", value=value)
|
||||
@@ -1,27 +0,0 @@
|
||||
"""Sandbox proxy for time entities."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import time
|
||||
|
||||
from homeassistant.components.time import TimeEntity
|
||||
|
||||
from . import SandboxProxyEntity
|
||||
|
||||
|
||||
class SandboxTimeEntity(SandboxProxyEntity, TimeEntity):
|
||||
"""Proxy for a time entity in a sandbox."""
|
||||
|
||||
@property
|
||||
def native_value(self):
|
||||
"""Return the current time value."""
|
||||
val = self._state_cache.get("state")
|
||||
if val is None:
|
||||
return None
|
||||
if isinstance(val, str):
|
||||
return time.fromisoformat(val)
|
||||
return val
|
||||
|
||||
async def async_set_value(self, value) -> None:
|
||||
"""Forward set_value to sandbox."""
|
||||
await self._forward_method("async_set_value", value=value.isoformat())
|
||||
@@ -1,71 +0,0 @@
|
||||
"""Sandbox proxy for todo entities."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.components.todo import TodoItem, TodoItemStatus, TodoListEntity, TodoListEntityFeature
|
||||
from homeassistant.core import callback
|
||||
|
||||
from . import SandboxEntityDescription, SandboxEntityManager, SandboxProxyEntity
|
||||
|
||||
|
||||
class SandboxTodoListEntity(SandboxProxyEntity, TodoListEntity):
|
||||
"""Proxy for a todo list entity in a sandbox."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
description: SandboxEntityDescription,
|
||||
manager: SandboxEntityManager,
|
||||
) -> None:
|
||||
"""Initialize the proxy todo entity."""
|
||||
super().__init__(description, manager)
|
||||
self._attr_supported_features = TodoListEntityFeature(
|
||||
description.supported_features
|
||||
)
|
||||
self._attr_todo_items: list[TodoItem] | None = None
|
||||
|
||||
@callback
|
||||
def sandbox_update_state(self, state: str, attributes: dict[str, Any]) -> None:
|
||||
"""Update todo items from sandbox push."""
|
||||
if "todo_items" in attributes:
|
||||
items = []
|
||||
for item_data in attributes["todo_items"]:
|
||||
items.append(TodoItem(
|
||||
uid=item_data.get("uid"),
|
||||
summary=item_data.get("summary", ""),
|
||||
status=TodoItemStatus(item_data["status"]) if "status" in item_data else None,
|
||||
description=item_data.get("description"),
|
||||
due=item_data.get("due"),
|
||||
))
|
||||
self._attr_todo_items = items
|
||||
self._state_cache["state"] = state
|
||||
self.async_write_ha_state()
|
||||
|
||||
@property
|
||||
def todo_items(self) -> list[TodoItem] | None:
|
||||
"""Return the todo items."""
|
||||
return self._attr_todo_items
|
||||
|
||||
async def async_create_todo_item(self, item: TodoItem) -> None:
|
||||
"""Forward create_todo_item to sandbox."""
|
||||
await self._forward_method("async_create_todo_item", item={
|
||||
"summary": item.summary,
|
||||
"status": item.status.value if item.status else None,
|
||||
"description": item.description,
|
||||
"due": item.due,
|
||||
})
|
||||
|
||||
async def async_update_todo_item(self, item: TodoItem) -> None:
|
||||
"""Forward update_todo_item to sandbox."""
|
||||
await self._forward_method("async_update_todo_item", item={
|
||||
"uid": item.uid,
|
||||
"summary": item.summary,
|
||||
"status": item.status.value if item.status else None,
|
||||
"description": item.description,
|
||||
"due": item.due,
|
||||
})
|
||||
|
||||
async def async_delete_todo_items(self, uids: list[str]) -> None:
|
||||
"""Forward delete_todo_items to sandbox."""
|
||||
await self._forward_method("async_delete_todo_items", uids=uids)
|
||||
@@ -1,63 +0,0 @@
|
||||
"""Sandbox proxy for update entities."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.components.update import UpdateEntity, UpdateEntityFeature
|
||||
|
||||
from . import SandboxEntityDescription, SandboxEntityManager, SandboxProxyEntity
|
||||
|
||||
|
||||
class SandboxUpdateEntity(SandboxProxyEntity, UpdateEntity):
|
||||
"""Proxy for an update entity in a sandbox."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
description: SandboxEntityDescription,
|
||||
manager: SandboxEntityManager,
|
||||
) -> None:
|
||||
"""Initialize the proxy update entity."""
|
||||
super().__init__(description, manager)
|
||||
self._attr_supported_features = UpdateEntityFeature(
|
||||
description.supported_features
|
||||
)
|
||||
|
||||
@property
|
||||
def installed_version(self) -> str | None:
|
||||
"""Return the installed version."""
|
||||
return self._state_cache.get("installed_version")
|
||||
|
||||
@property
|
||||
def latest_version(self) -> str | None:
|
||||
"""Return the latest version."""
|
||||
return self._state_cache.get("latest_version")
|
||||
|
||||
@property
|
||||
def title(self) -> str | None:
|
||||
"""Return the title."""
|
||||
return self._state_cache.get("title")
|
||||
|
||||
@property
|
||||
def release_summary(self) -> str | None:
|
||||
"""Return the release summary."""
|
||||
return self._state_cache.get("release_summary")
|
||||
|
||||
@property
|
||||
def release_url(self) -> str | None:
|
||||
"""Return the release URL."""
|
||||
return self._state_cache.get("release_url")
|
||||
|
||||
@property
|
||||
def in_progress(self) -> bool | int | None:
|
||||
"""Return if update is in progress."""
|
||||
return self._state_cache.get("in_progress")
|
||||
|
||||
@property
|
||||
def auto_update(self) -> bool:
|
||||
"""Return if auto-update is enabled."""
|
||||
return self._state_cache.get("auto_update", False)
|
||||
|
||||
async def async_install(self, version: str | None = None, backup: bool = False, **kwargs: Any) -> None:
|
||||
"""Forward install to sandbox."""
|
||||
await self._forward_method("async_install", version=version, backup=backup, **kwargs)
|
||||
@@ -1,73 +0,0 @@
|
||||
"""Sandbox proxy for vacuum entities."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.components.vacuum import StateVacuumEntity, VacuumEntityFeature
|
||||
|
||||
from . import SandboxEntityDescription, SandboxEntityManager, SandboxProxyEntity
|
||||
|
||||
|
||||
class SandboxVacuumEntity(SandboxProxyEntity, StateVacuumEntity):
|
||||
"""Proxy for a vacuum entity in a sandbox."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
description: SandboxEntityDescription,
|
||||
manager: SandboxEntityManager,
|
||||
) -> None:
|
||||
"""Initialize the proxy vacuum entity."""
|
||||
super().__init__(description, manager)
|
||||
self._attr_supported_features = VacuumEntityFeature(
|
||||
description.supported_features
|
||||
)
|
||||
if fan_speed_list := description.capabilities.get("fan_speed_list"):
|
||||
self._attr_fan_speed_list = fan_speed_list
|
||||
|
||||
@property
|
||||
def activity(self) -> str | None:
|
||||
"""Return the current vacuum activity."""
|
||||
return self._state_cache.get("activity")
|
||||
|
||||
@property
|
||||
def battery_level(self) -> int | None:
|
||||
"""Return the battery level."""
|
||||
return self._state_cache.get("battery_level")
|
||||
|
||||
@property
|
||||
def fan_speed(self) -> str | None:
|
||||
"""Return the current fan speed."""
|
||||
return self._state_cache.get("fan_speed")
|
||||
|
||||
async def async_start(self) -> None:
|
||||
"""Forward start to sandbox."""
|
||||
await self._forward_method("async_start")
|
||||
|
||||
async def async_pause(self) -> None:
|
||||
"""Forward pause to sandbox."""
|
||||
await self._forward_method("async_pause")
|
||||
|
||||
async def async_stop(self, **kwargs: Any) -> None:
|
||||
"""Forward stop to sandbox."""
|
||||
await self._forward_method("async_stop", **kwargs)
|
||||
|
||||
async def async_return_to_base(self, **kwargs: Any) -> None:
|
||||
"""Forward return_to_base to sandbox."""
|
||||
await self._forward_method("async_return_to_base", **kwargs)
|
||||
|
||||
async def async_clean_spot(self, **kwargs: Any) -> None:
|
||||
"""Forward clean_spot to sandbox."""
|
||||
await self._forward_method("async_clean_spot", **kwargs)
|
||||
|
||||
async def async_locate(self, **kwargs: Any) -> None:
|
||||
"""Forward locate to sandbox."""
|
||||
await self._forward_method("async_locate", **kwargs)
|
||||
|
||||
async def async_set_fan_speed(self, fan_speed: str, **kwargs: Any) -> None:
|
||||
"""Forward set_fan_speed to sandbox."""
|
||||
await self._forward_method("async_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 to sandbox."""
|
||||
await self._forward_method("async_send_command", command=command, params=params, **kwargs)
|
||||
@@ -1,63 +0,0 @@
|
||||
"""Sandbox proxy for valve entities."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.components.valve import ValveEntity, ValveEntityFeature
|
||||
|
||||
from . import SandboxEntityDescription, SandboxEntityManager, SandboxProxyEntity
|
||||
|
||||
|
||||
class SandboxValveEntity(SandboxProxyEntity, ValveEntity):
|
||||
"""Proxy for a valve entity in a sandbox."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
description: SandboxEntityDescription,
|
||||
manager: SandboxEntityManager,
|
||||
) -> None:
|
||||
"""Initialize the proxy valve entity."""
|
||||
super().__init__(description, manager)
|
||||
self._attr_supported_features = ValveEntityFeature(
|
||||
description.supported_features
|
||||
)
|
||||
|
||||
@property
|
||||
def is_closed(self) -> bool | None:
|
||||
"""Return if the valve is closed."""
|
||||
state = self._state_cache.get("state")
|
||||
if state is None:
|
||||
return None
|
||||
return state == "closed"
|
||||
|
||||
@property
|
||||
def is_opening(self) -> bool | None:
|
||||
"""Return if the valve is opening."""
|
||||
return self._state_cache.get("is_opening")
|
||||
|
||||
@property
|
||||
def is_closing(self) -> bool | None:
|
||||
"""Return if the valve is closing."""
|
||||
return self._state_cache.get("is_closing")
|
||||
|
||||
@property
|
||||
def current_valve_position(self) -> int | None:
|
||||
"""Return the current valve position."""
|
||||
return self._state_cache.get("current_valve_position")
|
||||
|
||||
async def async_open_valve(self, **kwargs: Any) -> None:
|
||||
"""Forward open_valve to sandbox."""
|
||||
await self._forward_method("async_open_valve", **kwargs)
|
||||
|
||||
async def async_close_valve(self, **kwargs: Any) -> None:
|
||||
"""Forward close_valve to sandbox."""
|
||||
await self._forward_method("async_close_valve", **kwargs)
|
||||
|
||||
async def async_stop_valve(self, **kwargs: Any) -> None:
|
||||
"""Forward stop_valve to sandbox."""
|
||||
await self._forward_method("async_stop_valve", **kwargs)
|
||||
|
||||
async def async_set_valve_position(self, position: int) -> None:
|
||||
"""Forward set_valve_position to sandbox."""
|
||||
await self._forward_method("async_set_valve_position", position=position)
|
||||
@@ -1,69 +0,0 @@
|
||||
"""Sandbox proxy for water_heater entities."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.components.water_heater import WaterHeaterEntity, WaterHeaterEntityFeature
|
||||
|
||||
from . import SandboxEntityDescription, SandboxEntityManager, SandboxProxyEntity
|
||||
|
||||
|
||||
class SandboxWaterHeaterEntity(SandboxProxyEntity, WaterHeaterEntity):
|
||||
"""Proxy for a water_heater entity in a sandbox."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
description: SandboxEntityDescription,
|
||||
manager: SandboxEntityManager,
|
||||
) -> None:
|
||||
"""Initialize the proxy water heater entity."""
|
||||
super().__init__(description, manager)
|
||||
self._attr_supported_features = WaterHeaterEntityFeature(
|
||||
description.supported_features
|
||||
)
|
||||
caps = description.capabilities
|
||||
if operation_list := caps.get("operation_list"):
|
||||
self._attr_operation_list = operation_list
|
||||
if (min_temp := caps.get("min_temp")) is not None:
|
||||
self._attr_min_temp = min_temp
|
||||
if (max_temp := caps.get("max_temp")) is not None:
|
||||
self._attr_max_temp = max_temp
|
||||
if temp_unit := caps.get("temperature_unit"):
|
||||
self._attr_temperature_unit = temp_unit
|
||||
|
||||
@property
|
||||
def current_operation(self) -> str | None:
|
||||
"""Return the current operation."""
|
||||
return self._state_cache.get("current_operation")
|
||||
|
||||
@property
|
||||
def current_temperature(self) -> float | None:
|
||||
"""Return the current temperature."""
|
||||
return self._state_cache.get("current_temperature")
|
||||
|
||||
@property
|
||||
def target_temperature(self) -> float | None:
|
||||
"""Return the target temperature."""
|
||||
return self._state_cache.get("target_temperature")
|
||||
|
||||
@property
|
||||
def is_away_mode_on(self) -> bool | None:
|
||||
"""Return if away mode is on."""
|
||||
return self._state_cache.get("is_away_mode_on")
|
||||
|
||||
async def async_set_temperature(self, **kwargs: Any) -> None:
|
||||
"""Forward set_temperature to sandbox."""
|
||||
await self._forward_method("async_set_temperature", **kwargs)
|
||||
|
||||
async def async_set_operation_mode(self, operation_mode: str) -> None:
|
||||
"""Forward set_operation_mode to sandbox."""
|
||||
await self._forward_method("async_set_operation_mode", operation_mode=operation_mode)
|
||||
|
||||
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||
"""Forward turn_on to sandbox."""
|
||||
await self._forward_method("async_turn_on", **kwargs)
|
||||
|
||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||
"""Forward turn_off to sandbox."""
|
||||
await self._forward_method("async_turn_off", **kwargs)
|
||||
@@ -1,85 +0,0 @@
|
||||
"""Sandbox proxy for weather entities."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from homeassistant.components.weather import Forecast, WeatherEntity, WeatherEntityFeature
|
||||
|
||||
from . import SandboxEntityDescription, SandboxEntityManager, SandboxProxyEntity
|
||||
|
||||
|
||||
class SandboxWeatherEntity(SandboxProxyEntity, WeatherEntity):
|
||||
"""Proxy for a weather entity in a sandbox."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
description: SandboxEntityDescription,
|
||||
manager: SandboxEntityManager,
|
||||
) -> None:
|
||||
"""Initialize the proxy weather entity."""
|
||||
super().__init__(description, manager)
|
||||
self._attr_supported_features = WeatherEntityFeature(
|
||||
description.supported_features
|
||||
)
|
||||
caps = description.capabilities
|
||||
if temp_unit := caps.get("native_temperature_unit"):
|
||||
self._attr_native_temperature_unit = temp_unit
|
||||
if pressure_unit := caps.get("native_pressure_unit"):
|
||||
self._attr_native_pressure_unit = pressure_unit
|
||||
if wind_speed_unit := caps.get("native_wind_speed_unit"):
|
||||
self._attr_native_wind_speed_unit = wind_speed_unit
|
||||
if visibility_unit := caps.get("native_visibility_unit"):
|
||||
self._attr_native_visibility_unit = visibility_unit
|
||||
if precipitation_unit := caps.get("native_precipitation_unit"):
|
||||
self._attr_native_precipitation_unit = precipitation_unit
|
||||
|
||||
@property
|
||||
def condition(self) -> str | None:
|
||||
"""Return the weather condition."""
|
||||
return self._state_cache.get("condition")
|
||||
|
||||
@property
|
||||
def native_temperature(self) -> float | None:
|
||||
"""Return the temperature."""
|
||||
return self._state_cache.get("native_temperature")
|
||||
|
||||
@property
|
||||
def native_apparent_temperature(self) -> float | None:
|
||||
"""Return the apparent temperature."""
|
||||
return self._state_cache.get("native_apparent_temperature")
|
||||
|
||||
@property
|
||||
def native_pressure(self) -> float | None:
|
||||
"""Return the pressure."""
|
||||
return self._state_cache.get("native_pressure")
|
||||
|
||||
@property
|
||||
def humidity(self) -> float | None:
|
||||
"""Return the humidity."""
|
||||
return self._state_cache.get("humidity")
|
||||
|
||||
@property
|
||||
def native_wind_speed(self) -> float | None:
|
||||
"""Return the wind speed."""
|
||||
return self._state_cache.get("native_wind_speed")
|
||||
|
||||
@property
|
||||
def wind_bearing(self) -> float | str | None:
|
||||
"""Return the wind bearing."""
|
||||
return self._state_cache.get("wind_bearing")
|
||||
|
||||
@property
|
||||
def native_visibility(self) -> float | None:
|
||||
"""Return the visibility."""
|
||||
return self._state_cache.get("native_visibility")
|
||||
|
||||
async def async_forecast_daily(self) -> list[Forecast] | None:
|
||||
"""Forward forecast_daily to sandbox."""
|
||||
return await self._forward_method("async_forecast_daily")
|
||||
|
||||
async def async_forecast_hourly(self) -> list[Forecast] | None:
|
||||
"""Forward forecast_hourly to sandbox."""
|
||||
return await self._forward_method("async_forecast_hourly")
|
||||
|
||||
async def async_forecast_twice_daily(self) -> list[Forecast] | None:
|
||||
"""Forward forecast_twice_daily to sandbox."""
|
||||
return await self._forward_method("async_forecast_twice_daily")
|
||||
@@ -1,98 +0,0 @@
|
||||
"""RemoteHostEntityPlatform for sandbox entities.
|
||||
|
||||
Instead of using per-domain platform files and async_forward_entry_setups,
|
||||
the sandbox integration creates RemoteHostEntityPlatform instances directly
|
||||
and adds them to the domain's EntityComponent. This platform manages proxy
|
||||
entities that represent sandbox entities on the host.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_component import EntityComponent
|
||||
from homeassistant.helpers.entity_platform import (
|
||||
DATA_DOMAIN_PLATFORM_ENTITIES,
|
||||
EntityPlatform,
|
||||
)
|
||||
|
||||
from .entity import SandboxEntityDescription, SandboxEntityManager, SandboxProxyEntity
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class RemoteHostEntityPlatform(EntityPlatform):
|
||||
"""EntityPlatform that manages proxy entities for a sandbox connection.
|
||||
|
||||
Added directly to the domain's EntityComponent._platforms instead of
|
||||
being set up through the normal platform discovery mechanism.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
domain: str,
|
||||
config_entry: ConfigEntry,
|
||||
manager: SandboxEntityManager,
|
||||
) -> None:
|
||||
"""Initialize the remote host entity platform."""
|
||||
super().__init__(
|
||||
hass=hass,
|
||||
logger=_LOGGER,
|
||||
domain=domain,
|
||||
platform_name="sandbox",
|
||||
platform=None,
|
||||
scan_interval=timedelta(seconds=0),
|
||||
entity_namespace=None,
|
||||
)
|
||||
self.config_entry = config_entry
|
||||
self._manager = manager
|
||||
self.parallel_updates_created = True
|
||||
|
||||
async def async_add_proxy_entity(
|
||||
self, description: SandboxEntityDescription
|
||||
) -> SandboxProxyEntity:
|
||||
"""Create and add a proxy entity from a sandbox registration."""
|
||||
entity = self._manager.add_entity(description)
|
||||
await self.async_add_entities([entity])
|
||||
return entity
|
||||
|
||||
|
||||
def async_get_or_create_host_platform(
|
||||
hass: HomeAssistant,
|
||||
domain: str,
|
||||
config_entry: ConfigEntry,
|
||||
manager: SandboxEntityManager,
|
||||
) -> RemoteHostEntityPlatform:
|
||||
"""Get or create a RemoteHostEntityPlatform for the given domain.
|
||||
|
||||
Adds the platform to the domain's EntityComponent if it doesn't exist yet.
|
||||
"""
|
||||
from homeassistant.helpers.entity_component import DATA_INSTANCES
|
||||
|
||||
entity_components = hass.data.get(DATA_INSTANCES, {})
|
||||
component: EntityComponent[Any] | None = entity_components.get(domain)
|
||||
|
||||
platform_key = f"sandbox_{config_entry.entry_id}"
|
||||
|
||||
if component is not None:
|
||||
existing = component._platforms.get(platform_key)
|
||||
if isinstance(existing, RemoteHostEntityPlatform):
|
||||
return existing
|
||||
|
||||
platform = RemoteHostEntityPlatform(
|
||||
hass=hass,
|
||||
domain=domain,
|
||||
config_entry=config_entry,
|
||||
manager=manager,
|
||||
)
|
||||
platform.async_prepare()
|
||||
|
||||
if component is not None:
|
||||
component._platforms[platform_key] = platform
|
||||
|
||||
return platform
|
||||
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"domain": "sandbox",
|
||||
"name": "Sandbox",
|
||||
"codeowners": [],
|
||||
"config_flow": true,
|
||||
"dependencies": ["websocket_api"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/sandbox",
|
||||
"integration_type": "system",
|
||||
"iot_class": "local_push",
|
||||
"quality_scale": "internal",
|
||||
"version": "0.1.0"
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
{
|
||||
"config": {
|
||||
"step": {
|
||||
"user": {
|
||||
"title": "Sandbox Configuration",
|
||||
"description": "Configure entries to run in a sandbox process."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,811 +0,0 @@
|
||||
"""Websocket API for the Sandbox integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components import websocket_api
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.exceptions import Unauthorized
|
||||
from homeassistant.helpers import device_registry as dr, entity_registry as er
|
||||
|
||||
from .const import DATA_SANDBOX
|
||||
|
||||
|
||||
def _require_sandbox_token(
|
||||
hass: HomeAssistant,
|
||||
connection: websocket_api.ActiveConnection,
|
||||
) -> str:
|
||||
"""Validate the connection uses a sandbox token. Return the sandbox_id."""
|
||||
sandbox_data = hass.data[DATA_SANDBOX]
|
||||
token_id = connection.refresh_token_id
|
||||
if token_id is None or token_id not in sandbox_data.token_to_sandbox:
|
||||
raise Unauthorized
|
||||
return sandbox_data.token_to_sandbox[token_id]
|
||||
|
||||
|
||||
@callback
|
||||
def async_setup(hass: HomeAssistant) -> None:
|
||||
"""Register sandbox websocket commands."""
|
||||
websocket_api.async_register_command(hass, ws_get_entries)
|
||||
websocket_api.async_register_command(hass, ws_update_entry)
|
||||
websocket_api.async_register_command(hass, ws_register_device)
|
||||
websocket_api.async_register_command(hass, ws_update_device)
|
||||
websocket_api.async_register_command(hass, ws_remove_device)
|
||||
websocket_api.async_register_command(hass, ws_register_entity)
|
||||
websocket_api.async_register_command(hass, ws_update_entity)
|
||||
websocket_api.async_register_command(hass, ws_remove_entity)
|
||||
websocket_api.async_register_command(hass, ws_update_state)
|
||||
websocket_api.async_register_command(hass, ws_register_service)
|
||||
websocket_api.async_register_command(hass, ws_sandbox_call_service)
|
||||
websocket_api.async_register_command(hass, ws_service_call_result)
|
||||
websocket_api.async_register_command(hass, ws_subscribe_entity_commands)
|
||||
websocket_api.async_register_command(hass, ws_entity_command_result)
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
{vol.Required("type"): "sandbox/get_entries"}
|
||||
)
|
||||
@callback
|
||||
def ws_get_entries(
|
||||
hass: HomeAssistant,
|
||||
connection: websocket_api.ActiveConnection,
|
||||
msg: dict[str, Any],
|
||||
) -> None:
|
||||
"""Return config entries assigned to this sandbox token."""
|
||||
sandbox_id = _require_sandbox_token(hass, connection)
|
||||
sandbox_data = hass.data[DATA_SANDBOX]
|
||||
sandbox_info = sandbox_data.sandboxes[sandbox_id]
|
||||
|
||||
entries = []
|
||||
for entry_config in sandbox_info.entries:
|
||||
entries.append(
|
||||
{
|
||||
"entry_id": entry_config["entry_id"],
|
||||
"domain": entry_config["domain"],
|
||||
"title": entry_config.get("title", entry_config["domain"]),
|
||||
"data": entry_config.get("data", {}),
|
||||
"options": entry_config.get("options", {}),
|
||||
}
|
||||
)
|
||||
|
||||
connection.send_result(msg["id"], entries)
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): "sandbox/update_entry",
|
||||
vol.Required("sandbox_entry_id"): str,
|
||||
vol.Optional("data"): dict,
|
||||
vol.Optional("options"): dict,
|
||||
vol.Optional("title"): str,
|
||||
}
|
||||
)
|
||||
@callback
|
||||
def ws_update_entry(
|
||||
hass: HomeAssistant,
|
||||
connection: websocket_api.ActiveConnection,
|
||||
msg: dict[str, Any],
|
||||
) -> None:
|
||||
"""Update a sandbox config entry's stored data."""
|
||||
sandbox_id = _require_sandbox_token(hass, connection)
|
||||
sandbox_data = hass.data[DATA_SANDBOX]
|
||||
sandbox_info = sandbox_data.sandboxes[sandbox_id]
|
||||
|
||||
sandbox_entry_id = msg["sandbox_entry_id"]
|
||||
entry_config = next(
|
||||
(e for e in sandbox_info.entries if e["entry_id"] == sandbox_entry_id),
|
||||
None,
|
||||
)
|
||||
if entry_config is None:
|
||||
connection.send_error(
|
||||
msg["id"], "not_found", "Entry not assigned to this sandbox"
|
||||
)
|
||||
return
|
||||
|
||||
if "data" in msg:
|
||||
entry_config["data"] = msg["data"]
|
||||
if "options" in msg:
|
||||
entry_config["options"] = msg["options"]
|
||||
if "title" in msg:
|
||||
entry_config["title"] = msg["title"]
|
||||
|
||||
connection.send_result(msg["id"])
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): "sandbox/register_device",
|
||||
vol.Required("sandbox_entry_id"): str,
|
||||
vol.Required("identifiers"): vol.All(
|
||||
[{vol.Required("domain"): str, vol.Required("id"): str}],
|
||||
vol.Length(min=1),
|
||||
),
|
||||
vol.Optional("name"): str,
|
||||
vol.Optional("manufacturer"): str,
|
||||
vol.Optional("model"): str,
|
||||
vol.Optional("sw_version"): str,
|
||||
vol.Optional("hw_version"): str,
|
||||
vol.Optional("entry_type"): str,
|
||||
}
|
||||
)
|
||||
@websocket_api.async_response
|
||||
async def ws_register_device(
|
||||
hass: HomeAssistant,
|
||||
connection: websocket_api.ActiveConnection,
|
||||
msg: dict[str, Any],
|
||||
) -> None:
|
||||
"""Register a device in HA Core on behalf of a sandbox."""
|
||||
sandbox_id = _require_sandbox_token(hass, connection)
|
||||
sandbox_data = hass.data[DATA_SANDBOX]
|
||||
sandbox_info = sandbox_data.sandboxes[sandbox_id]
|
||||
|
||||
sandbox_entry_id = msg["sandbox_entry_id"]
|
||||
if not any(e["entry_id"] == sandbox_entry_id for e in sandbox_info.entries):
|
||||
connection.send_error(
|
||||
msg["id"], "not_found", "Entry not assigned to this sandbox"
|
||||
)
|
||||
return
|
||||
|
||||
host_entry_id = sandbox_data.get_host_entry_id(sandbox_id)
|
||||
if host_entry_id is None:
|
||||
connection.send_error(
|
||||
msg["id"], "not_found", "No host config entry for sandbox"
|
||||
)
|
||||
return
|
||||
|
||||
identifiers = {(i["domain"], i["id"]) for i in msg["identifiers"]}
|
||||
|
||||
device_reg = dr.async_get(hass)
|
||||
kwargs: dict[str, Any] = {
|
||||
"config_entry_id": host_entry_id,
|
||||
"identifiers": identifiers,
|
||||
}
|
||||
for key in ("name", "manufacturer", "model", "sw_version", "hw_version"):
|
||||
if key in msg:
|
||||
kwargs[key] = msg[key]
|
||||
if "entry_type" in msg:
|
||||
kwargs["entry_type"] = dr.DeviceEntryType(msg["entry_type"])
|
||||
|
||||
device = device_reg.async_get_or_create(**kwargs)
|
||||
|
||||
connection.send_result(msg["id"], {"device_id": device.id})
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): "sandbox/update_device",
|
||||
vol.Required("device_id"): str,
|
||||
vol.Optional("name"): str,
|
||||
vol.Optional("manufacturer"): str,
|
||||
vol.Optional("model"): str,
|
||||
vol.Optional("sw_version"): str,
|
||||
vol.Optional("hw_version"): str,
|
||||
vol.Optional("name_by_user"): vol.Any(str, None),
|
||||
vol.Optional("disabled_by"): vol.Any(str, None),
|
||||
}
|
||||
)
|
||||
@websocket_api.async_response
|
||||
async def ws_update_device(
|
||||
hass: HomeAssistant,
|
||||
connection: websocket_api.ActiveConnection,
|
||||
msg: dict[str, Any],
|
||||
) -> None:
|
||||
"""Update a device in HA Core on behalf of a sandbox."""
|
||||
_require_sandbox_token(hass, connection)
|
||||
|
||||
device_reg = dr.async_get(hass)
|
||||
device = device_reg.async_get(msg["device_id"])
|
||||
if device is None:
|
||||
connection.send_error(msg["id"], "not_found", "Device not found")
|
||||
return
|
||||
|
||||
kwargs: dict[str, Any] = {}
|
||||
for key in ("name", "manufacturer", "model", "sw_version", "hw_version", "name_by_user"):
|
||||
if key in msg:
|
||||
kwargs[key] = msg[key]
|
||||
if "disabled_by" in msg:
|
||||
kwargs["disabled_by"] = (
|
||||
dr.DeviceEntryDisabler(msg["disabled_by"])
|
||||
if msg["disabled_by"]
|
||||
else None
|
||||
)
|
||||
|
||||
device_reg.async_update_device(device.id, **kwargs)
|
||||
connection.send_result(msg["id"])
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): "sandbox/remove_device",
|
||||
vol.Required("device_id"): str,
|
||||
}
|
||||
)
|
||||
@websocket_api.async_response
|
||||
async def ws_remove_device(
|
||||
hass: HomeAssistant,
|
||||
connection: websocket_api.ActiveConnection,
|
||||
msg: dict[str, Any],
|
||||
) -> None:
|
||||
"""Remove a device from HA Core on behalf of a sandbox."""
|
||||
sandbox_id = _require_sandbox_token(hass, connection)
|
||||
sandbox_data = hass.data[DATA_SANDBOX]
|
||||
host_entry_id = sandbox_data.get_host_entry_id(sandbox_id)
|
||||
|
||||
device_reg = dr.async_get(hass)
|
||||
device = device_reg.async_get(msg["device_id"])
|
||||
if device is None:
|
||||
connection.send_error(msg["id"], "not_found", "Device not found")
|
||||
return
|
||||
|
||||
device_reg.async_remove_device(device.id)
|
||||
connection.send_result(msg["id"])
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): "sandbox/register_entity",
|
||||
vol.Required("sandbox_entry_id"): str,
|
||||
vol.Required("domain"): str,
|
||||
vol.Required("platform"): str,
|
||||
vol.Required("unique_id"): str,
|
||||
vol.Optional("device_id"): str,
|
||||
vol.Optional("original_name"): str,
|
||||
vol.Optional("original_icon"): str,
|
||||
vol.Optional("entity_category"): str,
|
||||
vol.Optional("suggested_object_id"): str,
|
||||
vol.Optional("device_class"): str,
|
||||
vol.Optional("state_class"): str,
|
||||
vol.Optional("capabilities"): dict,
|
||||
vol.Optional("supported_features"): int,
|
||||
vol.Optional("has_entity_name"): bool,
|
||||
}
|
||||
)
|
||||
@websocket_api.async_response
|
||||
async def ws_register_entity(
|
||||
hass: HomeAssistant,
|
||||
connection: websocket_api.ActiveConnection,
|
||||
msg: dict[str, Any],
|
||||
) -> None:
|
||||
"""Register an entity in HA Core on behalf of a sandbox."""
|
||||
sandbox_id = _require_sandbox_token(hass, connection)
|
||||
sandbox_data = hass.data[DATA_SANDBOX]
|
||||
sandbox_info = sandbox_data.sandboxes[sandbox_id]
|
||||
|
||||
sandbox_entry_id = msg["sandbox_entry_id"]
|
||||
if not any(e["entry_id"] == sandbox_entry_id for e in sandbox_info.entries):
|
||||
connection.send_error(
|
||||
msg["id"], "not_found", "Entry not assigned to this sandbox"
|
||||
)
|
||||
return
|
||||
|
||||
host_entry_id = sandbox_data.get_host_entry_id(sandbox_id)
|
||||
host_entry = hass.config_entries.async_get_entry(host_entry_id) if host_entry_id else None
|
||||
if host_entry is None:
|
||||
connection.send_error(
|
||||
msg["id"], "not_found", "No host config entry for sandbox"
|
||||
)
|
||||
return
|
||||
|
||||
domain = msg["domain"]
|
||||
manager = sandbox_data.entity_managers.get(sandbox_id)
|
||||
if manager is None:
|
||||
connection.send_error(msg["id"], "not_found", "No entity manager")
|
||||
return
|
||||
|
||||
from .entity import SandboxEntityDescription
|
||||
from .host_platform import async_get_or_create_host_platform
|
||||
|
||||
description = SandboxEntityDescription(
|
||||
domain=domain,
|
||||
platform=msg["platform"],
|
||||
unique_id=f"{sandbox_id}_{msg['unique_id']}",
|
||||
sandbox_id=sandbox_id,
|
||||
sandbox_entry_id=sandbox_entry_id,
|
||||
device_id=msg.get("device_id"),
|
||||
original_name=msg.get("original_name"),
|
||||
original_icon=msg.get("original_icon"),
|
||||
entity_category=msg.get("entity_category"),
|
||||
device_class=msg.get("device_class"),
|
||||
state_class=msg.get("state_class"),
|
||||
supported_features=msg.get("supported_features", 0),
|
||||
capabilities=msg.get("capabilities", {}),
|
||||
has_entity_name=msg.get("has_entity_name", False),
|
||||
)
|
||||
|
||||
platform = async_get_or_create_host_platform(
|
||||
hass, domain, host_entry, manager
|
||||
)
|
||||
|
||||
entity = await platform.async_add_proxy_entity(description)
|
||||
|
||||
connection.send_result(
|
||||
msg["id"],
|
||||
{"entity_id": entity.entity_id or ""},
|
||||
)
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): "sandbox/update_entity",
|
||||
vol.Required("entity_id"): str,
|
||||
vol.Optional("name"): vol.Any(str, None),
|
||||
vol.Optional("icon"): vol.Any(str, None),
|
||||
vol.Optional("disabled_by"): vol.Any(str, None),
|
||||
vol.Optional("hidden_by"): vol.Any(str, None),
|
||||
vol.Optional("original_name"): vol.Any(str, None),
|
||||
vol.Optional("original_icon"): vol.Any(str, None),
|
||||
vol.Optional("capabilities"): vol.Any(dict, None),
|
||||
vol.Optional("supported_features"): int,
|
||||
vol.Optional("device_id"): vol.Any(str, None),
|
||||
}
|
||||
)
|
||||
@websocket_api.async_response
|
||||
async def ws_update_entity(
|
||||
hass: HomeAssistant,
|
||||
connection: websocket_api.ActiveConnection,
|
||||
msg: dict[str, Any],
|
||||
) -> None:
|
||||
"""Update an entity registry entry in HA Core on behalf of a sandbox."""
|
||||
_require_sandbox_token(hass, connection)
|
||||
|
||||
entity_reg = er.async_get(hass)
|
||||
entity_entry = entity_reg.async_get(msg["entity_id"])
|
||||
if entity_entry is None:
|
||||
connection.send_error(msg["id"], "not_found", "Entity not found")
|
||||
return
|
||||
|
||||
kwargs: dict[str, Any] = {}
|
||||
for key in (
|
||||
"name",
|
||||
"icon",
|
||||
"original_name",
|
||||
"original_icon",
|
||||
"capabilities",
|
||||
"supported_features",
|
||||
"device_id",
|
||||
):
|
||||
if key in msg:
|
||||
kwargs[key] = msg[key]
|
||||
|
||||
if "disabled_by" in msg:
|
||||
kwargs["disabled_by"] = (
|
||||
er.RegistryEntryDisabler(msg["disabled_by"])
|
||||
if msg["disabled_by"]
|
||||
else None
|
||||
)
|
||||
if "hidden_by" in msg:
|
||||
kwargs["hidden_by"] = (
|
||||
er.RegistryEntryHider(msg["hidden_by"])
|
||||
if msg["hidden_by"]
|
||||
else None
|
||||
)
|
||||
|
||||
entity_reg.async_update_entity(msg["entity_id"], **kwargs)
|
||||
connection.send_result(msg["id"])
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): "sandbox/update_state",
|
||||
vol.Required("entity_id"): str,
|
||||
vol.Required("state"): str,
|
||||
vol.Optional("attributes"): dict,
|
||||
}
|
||||
)
|
||||
@callback
|
||||
def ws_update_state(
|
||||
hass: HomeAssistant,
|
||||
connection: websocket_api.ActiveConnection,
|
||||
msg: dict[str, Any],
|
||||
) -> None:
|
||||
"""Update an entity state in HA Core from a sandbox."""
|
||||
sandbox_id = _require_sandbox_token(hass, connection)
|
||||
sandbox_data = hass.data[DATA_SANDBOX]
|
||||
|
||||
manager = sandbox_data.entity_managers.get(sandbox_id)
|
||||
if manager is not None:
|
||||
entity = manager.get_entity(msg["entity_id"])
|
||||
if entity is not None:
|
||||
entity.sandbox_update_state(msg["state"], msg.get("attributes") or {})
|
||||
connection.send_result(msg["id"])
|
||||
return
|
||||
|
||||
hass.states.async_set(
|
||||
msg["entity_id"],
|
||||
msg["state"],
|
||||
msg.get("attributes"),
|
||||
)
|
||||
connection.send_result(msg["id"])
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): "sandbox/remove_entity",
|
||||
vol.Required("entity_id"): str,
|
||||
}
|
||||
)
|
||||
@callback
|
||||
def ws_remove_entity(
|
||||
hass: HomeAssistant,
|
||||
connection: websocket_api.ActiveConnection,
|
||||
msg: dict[str, Any],
|
||||
) -> None:
|
||||
"""Remove a sandbox entity from HA Core."""
|
||||
_require_sandbox_token(hass, connection)
|
||||
|
||||
entity_reg = er.async_get(hass)
|
||||
entity_entry = entity_reg.async_get(msg["entity_id"])
|
||||
if entity_entry and entity_entry.platform == "sandbox":
|
||||
entity_reg.async_remove(msg["entity_id"])
|
||||
|
||||
hass.states.async_remove(msg["entity_id"])
|
||||
connection.send_result(msg["id"])
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): "sandbox/register_service",
|
||||
vol.Required("domain"): str,
|
||||
vol.Required("service"): str,
|
||||
}
|
||||
)
|
||||
@callback
|
||||
def ws_register_service(
|
||||
hass: HomeAssistant,
|
||||
connection: websocket_api.ActiveConnection,
|
||||
msg: dict[str, Any],
|
||||
) -> None:
|
||||
"""Register a service on the host on behalf of a sandbox.
|
||||
|
||||
If the service already exists (e.g. entity component loaded it),
|
||||
this is a no-op. Otherwise a proxy service is created that forwards
|
||||
calls to the sandbox for execution.
|
||||
"""
|
||||
import asyncio
|
||||
|
||||
sandbox_id = _require_sandbox_token(hass, connection)
|
||||
sandbox_data = hass.data[DATA_SANDBOX]
|
||||
|
||||
domain = msg["domain"]
|
||||
service = msg["service"]
|
||||
|
||||
if hass.services.has_service(domain, service):
|
||||
connection.send_result(msg["id"])
|
||||
return
|
||||
|
||||
sandbox_info = sandbox_data.sandboxes[sandbox_id]
|
||||
|
||||
async def proxy_service_handler(call: Any) -> Any:
|
||||
"""Forward service call to sandbox for execution."""
|
||||
if sandbox_info.send_command is None:
|
||||
from homeassistant.exceptions import ServiceNotFound
|
||||
|
||||
raise ServiceNotFound(domain, service)
|
||||
|
||||
call_id = f"svc_{sandbox_id}_{id(call)}"
|
||||
future: asyncio.Future[Any] = hass.loop.create_future()
|
||||
sandbox_info.pending_service_calls[call_id] = future
|
||||
|
||||
target: dict[str, Any] = {}
|
||||
if hasattr(call, "target") and call.target:
|
||||
target = dict(call.target)
|
||||
|
||||
# Use pending_contexts if sandbox/call_service stored one for
|
||||
# this context. This ensures only contexts originating from the
|
||||
# sandbox client are forwarded — not the auto-generated context
|
||||
# from the standard call_service WS command.
|
||||
context_data: dict[str, str | None] | None = None
|
||||
if call.context:
|
||||
context_data = sandbox_info.pending_contexts.pop(
|
||||
call.context.id, None
|
||||
)
|
||||
|
||||
sandbox_info.send_command(
|
||||
{
|
||||
"type": "call_service",
|
||||
"call_id": call_id,
|
||||
"domain": call.domain,
|
||||
"service": call.service,
|
||||
"service_data": dict(call.data),
|
||||
"target": target,
|
||||
"return_response": call.return_response,
|
||||
"context": context_data,
|
||||
}
|
||||
)
|
||||
|
||||
try:
|
||||
return await asyncio.wait_for(future, timeout=30)
|
||||
except asyncio.TimeoutError:
|
||||
sandbox_info.pending_service_calls.pop(call_id, None)
|
||||
raise
|
||||
|
||||
from homeassistant.core import SupportsResponse
|
||||
|
||||
hass.services.async_register(
|
||||
domain, service, proxy_service_handler,
|
||||
supports_response=SupportsResponse.OPTIONAL,
|
||||
)
|
||||
connection.send_result(msg["id"])
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): "sandbox/call_service",
|
||||
vol.Required("domain"): str,
|
||||
vol.Required("service"): str,
|
||||
vol.Optional("service_data"): dict,
|
||||
vol.Optional("target"): vol.Any(dict, None),
|
||||
vol.Optional("return_response"): bool,
|
||||
vol.Optional("context"): dict,
|
||||
}
|
||||
)
|
||||
@websocket_api.async_response
|
||||
async def ws_sandbox_call_service(
|
||||
hass: HomeAssistant,
|
||||
connection: websocket_api.ActiveConnection,
|
||||
msg: dict[str, Any],
|
||||
) -> None:
|
||||
"""Call a service with full context forwarding.
|
||||
|
||||
Unlike the standard call_service WS command which creates context from the
|
||||
connection, this uses the context passed from the sandbox so that permission
|
||||
checks and context tracking work correctly.
|
||||
"""
|
||||
import voluptuous as _vol
|
||||
|
||||
from homeassistant.components.websocket_api import const
|
||||
from homeassistant.core import Context
|
||||
from homeassistant.exceptions import (
|
||||
HomeAssistantError,
|
||||
ServiceNotFound,
|
||||
ServiceValidationError,
|
||||
)
|
||||
|
||||
sandbox_id = _require_sandbox_token(hass, connection)
|
||||
sandbox_data = hass.data[DATA_SANDBOX]
|
||||
sandbox_info = sandbox_data.sandboxes[sandbox_id]
|
||||
|
||||
domain = msg["domain"]
|
||||
service = msg["service"]
|
||||
service_data = msg.get("service_data") or {}
|
||||
target = msg.get("target")
|
||||
return_response = msg.get("return_response", False)
|
||||
|
||||
# Reconstruct context from sandbox
|
||||
context_data = msg.get("context")
|
||||
if context_data:
|
||||
context = Context(
|
||||
id=context_data.get("id"),
|
||||
user_id=context_data.get("user_id"),
|
||||
parent_id=context_data.get("parent_id"),
|
||||
)
|
||||
# Store context so the proxy_service_handler can forward it
|
||||
# to the sandbox. Only contexts explicitly sent by the sandbox
|
||||
# client are forwarded — not auto-generated ones from standard
|
||||
# call_service.
|
||||
sandbox_info.pending_contexts[context.id] = {
|
||||
"id": context.id,
|
||||
"user_id": context.user_id,
|
||||
"parent_id": context.parent_id,
|
||||
}
|
||||
else:
|
||||
context = connection.context(msg)
|
||||
|
||||
try:
|
||||
response = await hass.services.async_call(
|
||||
domain,
|
||||
service,
|
||||
service_data,
|
||||
blocking=True,
|
||||
context=context,
|
||||
target=target,
|
||||
return_response=return_response,
|
||||
)
|
||||
result: dict[str, Any] = {"context": context.as_dict()}
|
||||
if return_response:
|
||||
result["response"] = response
|
||||
connection.send_result(msg["id"], result)
|
||||
except ServiceNotFound as err:
|
||||
connection.send_error(
|
||||
msg["id"],
|
||||
const.ERR_NOT_FOUND,
|
||||
f"Service {err.domain}.{err.service} not found.",
|
||||
translation_domain=err.translation_domain,
|
||||
translation_key=err.translation_key,
|
||||
translation_placeholders=err.translation_placeholders,
|
||||
)
|
||||
except _vol.Invalid as err:
|
||||
connection.send_error(msg["id"], const.ERR_INVALID_FORMAT, str(err))
|
||||
except ServiceValidationError as err:
|
||||
connection.send_error(
|
||||
msg["id"],
|
||||
const.ERR_SERVICE_VALIDATION_ERROR,
|
||||
f"Validation error: {err}",
|
||||
translation_domain=err.translation_domain,
|
||||
translation_key=err.translation_key,
|
||||
translation_placeholders=err.translation_placeholders,
|
||||
)
|
||||
except Unauthorized:
|
||||
connection.send_error(msg["id"], const.ERR_UNAUTHORIZED, "Unauthorized")
|
||||
except HomeAssistantError as err:
|
||||
connection.send_error(
|
||||
msg["id"],
|
||||
const.ERR_HOME_ASSISTANT_ERROR,
|
||||
str(err),
|
||||
translation_domain=err.translation_domain,
|
||||
translation_key=err.translation_key,
|
||||
translation_placeholders=err.translation_placeholders,
|
||||
)
|
||||
except Exception as err:
|
||||
connection.logger.exception("Unexpected exception in sandbox/call_service")
|
||||
connection.send_error(msg["id"], const.ERR_UNKNOWN_ERROR, str(err))
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): "sandbox/service_call_result",
|
||||
vol.Required("call_id"): str,
|
||||
vol.Required("success"): bool,
|
||||
vol.Optional("result"): vol.Any(dict, list, str, int, float, bool, None),
|
||||
vol.Optional("error"): str,
|
||||
vol.Optional("error_type"): str,
|
||||
vol.Optional("translation_domain"): str,
|
||||
vol.Optional("translation_key"): str,
|
||||
vol.Optional("translation_placeholders"): dict,
|
||||
}
|
||||
)
|
||||
@callback
|
||||
def ws_service_call_result(
|
||||
hass: HomeAssistant,
|
||||
connection: websocket_api.ActiveConnection,
|
||||
msg: dict[str, Any],
|
||||
) -> None:
|
||||
"""Receive the result of a forwarded service call from the sandbox."""
|
||||
import voluptuous as _vol
|
||||
|
||||
from homeassistant.exceptions import (
|
||||
HomeAssistantError,
|
||||
ServiceNotSupported,
|
||||
ServiceValidationError,
|
||||
Unauthorized,
|
||||
)
|
||||
|
||||
sandbox_id = _require_sandbox_token(hass, connection)
|
||||
sandbox_data = hass.data[DATA_SANDBOX]
|
||||
sandbox_info = sandbox_data.sandboxes.get(sandbox_id)
|
||||
|
||||
if sandbox_info is None:
|
||||
connection.send_error(msg["id"], "not_found", "Sandbox not found")
|
||||
return
|
||||
|
||||
future = sandbox_info.pending_service_calls.pop(msg["call_id"], None)
|
||||
if future is None or future.done():
|
||||
connection.send_result(msg["id"])
|
||||
return
|
||||
|
||||
if msg["success"]:
|
||||
future.set_result(msg.get("result"))
|
||||
else:
|
||||
error_msg = msg.get("error", "Unknown error")
|
||||
error_type = msg.get("error_type", "")
|
||||
translation_domain = msg.get("translation_domain")
|
||||
translation_key = msg.get("translation_key")
|
||||
translation_placeholders = msg.get("translation_placeholders")
|
||||
|
||||
if error_type == "Unauthorized":
|
||||
exc: Exception = Unauthorized()
|
||||
elif error_type == "Invalid":
|
||||
exc = _vol.Invalid(error_msg)
|
||||
elif error_type == "MultipleInvalid":
|
||||
exc = _vol.MultipleInvalid([_vol.Invalid(error_msg)])
|
||||
elif error_type == "ServiceNotSupported":
|
||||
placeholders = translation_placeholders or {}
|
||||
domain = placeholders.get("domain", "")
|
||||
service = placeholders.get("service", "")
|
||||
entity_id = placeholders.get("entity_id", "")
|
||||
exc = ServiceNotSupported(domain, service, entity_id)
|
||||
elif error_type == "ServiceValidationError":
|
||||
if translation_domain and translation_key:
|
||||
exc = ServiceValidationError(
|
||||
translation_domain=translation_domain,
|
||||
translation_key=translation_key,
|
||||
translation_placeholders=translation_placeholders,
|
||||
)
|
||||
else:
|
||||
exc = ServiceValidationError(error_msg)
|
||||
elif error_type == "HomeAssistantError" or not error_type:
|
||||
if translation_domain and translation_key:
|
||||
exc = HomeAssistantError(
|
||||
translation_domain=translation_domain,
|
||||
translation_key=translation_key,
|
||||
translation_placeholders=translation_placeholders,
|
||||
)
|
||||
else:
|
||||
exc = HomeAssistantError(error_msg)
|
||||
else:
|
||||
# Unknown error types — use ServiceValidationError if it looks
|
||||
# like a validation error subclass, otherwise HomeAssistantError
|
||||
if translation_domain and translation_key:
|
||||
exc = ServiceValidationError(
|
||||
translation_domain=translation_domain,
|
||||
translation_key=translation_key,
|
||||
translation_placeholders=translation_placeholders,
|
||||
)
|
||||
else:
|
||||
exc = HomeAssistantError(error_msg)
|
||||
future.set_exception(exc)
|
||||
|
||||
connection.send_result(msg["id"])
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
{vol.Required("type"): "sandbox/subscribe_entity_commands"}
|
||||
)
|
||||
@callback
|
||||
def ws_subscribe_entity_commands(
|
||||
hass: HomeAssistant,
|
||||
connection: websocket_api.ActiveConnection,
|
||||
msg: dict[str, Any],
|
||||
) -> None:
|
||||
"""Subscribe to entity method calls from the host.
|
||||
|
||||
The host pushes commands as subscription events when proxy entities
|
||||
need to forward method calls to the sandbox. The sandbox responds
|
||||
with sandbox/entity_command_result.
|
||||
"""
|
||||
sandbox_id = _require_sandbox_token(hass, connection)
|
||||
sandbox_data = hass.data[DATA_SANDBOX]
|
||||
sandbox_info = sandbox_data.sandboxes.get(sandbox_id)
|
||||
if sandbox_info is None:
|
||||
connection.send_error(msg["id"], "not_found", "Sandbox not found")
|
||||
return
|
||||
|
||||
@callback
|
||||
def send_command(command: dict[str, Any]) -> None:
|
||||
"""Send a command to the sandbox."""
|
||||
connection.send_message(
|
||||
websocket_api.event_message(msg["id"], command)
|
||||
)
|
||||
|
||||
sandbox_info.send_command = send_command
|
||||
|
||||
@callback
|
||||
def unsub() -> None:
|
||||
sandbox_info.send_command = None
|
||||
|
||||
connection.subscriptions[msg["id"]] = unsub
|
||||
connection.send_result(msg["id"])
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): "sandbox/entity_command_result",
|
||||
vol.Required("call_id"): str,
|
||||
vol.Required("success"): bool,
|
||||
vol.Optional("result"): vol.Any(dict, list, str, int, float, bool, None),
|
||||
vol.Optional("error"): str,
|
||||
}
|
||||
)
|
||||
@callback
|
||||
def ws_entity_command_result(
|
||||
hass: HomeAssistant,
|
||||
connection: websocket_api.ActiveConnection,
|
||||
msg: dict[str, Any],
|
||||
) -> None:
|
||||
"""Receive the result of a forwarded entity method call."""
|
||||
sandbox_id = _require_sandbox_token(hass, connection)
|
||||
sandbox_data = hass.data[DATA_SANDBOX]
|
||||
|
||||
from .entity import SandboxEntityManager
|
||||
|
||||
manager = sandbox_data.entity_managers.get(sandbox_id)
|
||||
if manager is None:
|
||||
connection.send_error(msg["id"], "not_found", "No entity manager")
|
||||
return
|
||||
|
||||
error = msg.get("error") if not msg["success"] else None
|
||||
manager.resolve_call(msg["call_id"], msg.get("result"), error)
|
||||
connection.send_result(msg["id"])
|
||||
@@ -1,122 +0,0 @@
|
||||
"""Sandbox v2 — run integrations in isolated subprocesses.
|
||||
|
||||
The integration owns three runtime objects, all hung off
|
||||
:class:`SandboxV2Data`:
|
||||
|
||||
* :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`` (Phase 4). 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_v2/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.typing import ConfigType
|
||||
|
||||
from .auth import async_issue_sandbox_access_token
|
||||
from .bridge import SandboxBridge, async_create_bridge
|
||||
from .channel import Channel
|
||||
from .const import DATA_SANDBOX_V2, DOMAIN
|
||||
from .manager import SandboxManager
|
||||
from .router import SandboxFlowRouter
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN)
|
||||
|
||||
|
||||
@dataclass
|
||||
class SandboxV2Data:
|
||||
"""Global Sandbox v2 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 v2 integration."""
|
||||
data = SandboxV2Data()
|
||||
hass.data[DATA_SANDBOX_V2] = 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 _issue_token(group: str) -> str:
|
||||
return await async_issue_sandbox_access_token(hass, group)
|
||||
|
||||
async def _on_shutdown_reply(group: str, reply: dict[str, Any]) -> None:
|
||||
"""Persist the sandbox's restore-state snapshot (Phase 9).
|
||||
|
||||
The runtime ships its ``RestoreEntity`` state in the shutdown
|
||||
reply rather than via ``RemoteStore`` (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.
|
||||
"""
|
||||
restore_state = reply.get("restore_state")
|
||||
if not isinstance(restore_state, dict):
|
||||
return
|
||||
bridge = data.bridges.get(group)
|
||||
if bridge is None:
|
||||
_LOGGER.debug(
|
||||
"sandbox_v2[%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
|
||||
{"key": "core.restore_state", "data": 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,
|
||||
token_factory=_issue_token,
|
||||
)
|
||||
router = SandboxFlowRouter(hass, manager, data=data)
|
||||
data.manager = manager
|
||||
data.router = router
|
||||
|
||||
hass.config_entries.router = router
|
||||
|
||||
async def _on_stop(_event: Event) -> None:
|
||||
"""Stop every sandbox process on HA shutdown.
|
||||
|
||||
Phase 9: ask each sandbox to unload its entries and flush
|
||||
``RestoreEntity`` state through the Phase 8 ``RemoteStore``
|
||||
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
|
||||
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
|
||||
@@ -1,103 +0,0 @@
|
||||
"""Scoped auth tokens for sandbox runtimes (Phase 7).
|
||||
|
||||
Each sandbox group runs against a dedicated system user; the access token
|
||||
the manager hands to the subprocess is issued from a refresh token whose
|
||||
``scopes`` set restricts the websocket API to the ``sandbox_v2/``
|
||||
namespace plus a short allow-list (e.g. ``auth/current_user``). The
|
||||
websocket dispatcher enforces the scope per command — see
|
||||
``homeassistant.components.websocket_api.connection._scope_allows``.
|
||||
|
||||
The sandbox does not currently open a websocket back to main, but the
|
||||
scoped token is still issued and passed on the CLI so that:
|
||||
|
||||
* the manager and runtime agree on a real credential rather than a
|
||||
placeholder, and
|
||||
* future phases that subscribe to main's bus (``share_states=True``)
|
||||
inherit the same scope without a separate code path.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from homeassistant.auth.models import RefreshToken, User
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
# Websocket-API scopes granted to sandbox tokens.
|
||||
#
|
||||
# Entries ending in ``/`` are prefix grants — ``sandbox_v2/`` permits any
|
||||
# ``sandbox_v2/...`` command. Plain entries are exact matches. Keep this
|
||||
# allow-list minimal: every entry is a public API surface a sandboxed
|
||||
# integration would otherwise be unable to call, so adding to it widens
|
||||
# the trust boundary.
|
||||
SANDBOX_TOKEN_SCOPES: frozenset[str] = frozenset(
|
||||
{
|
||||
"sandbox_v2/",
|
||||
# Lets the sandbox confirm which user it authenticated as.
|
||||
"auth/current_user",
|
||||
}
|
||||
)
|
||||
|
||||
# Marker stored on the system user's name + refresh_token client_id so the
|
||||
# manager can recognise (and reuse) an existing sandbox credential across
|
||||
# HA restarts.
|
||||
_USER_NAME_PREFIX = "Sandbox v2: "
|
||||
_CLIENT_ID_PREFIX = "sandbox_v2/"
|
||||
|
||||
|
||||
def _user_name_for_group(group: str) -> str:
|
||||
"""System user name for a given sandbox group."""
|
||||
return f"{_USER_NAME_PREFIX}{group}"
|
||||
|
||||
|
||||
def _client_id_for_group(group: str) -> str:
|
||||
"""Stable client_id for a sandbox group's refresh token."""
|
||||
return f"{_CLIENT_ID_PREFIX}{group}"
|
||||
|
||||
|
||||
async def async_get_or_create_sandbox_user(hass: HomeAssistant, group: str) -> User:
|
||||
"""Return the dedicated system user for ``group``, creating it once."""
|
||||
name = _user_name_for_group(group)
|
||||
for user in await hass.auth.async_get_users():
|
||||
if user.system_generated and user.name == name:
|
||||
return user
|
||||
return await hass.auth.async_create_system_user(name)
|
||||
|
||||
|
||||
async def async_issue_sandbox_access_token(hass: HomeAssistant, group: str) -> str:
|
||||
"""Issue a scoped access token for the sandbox runtime of ``group``.
|
||||
|
||||
Reuses the dedicated system user across calls; rotates the refresh
|
||||
token on each call so a restart hands the subprocess a fresh
|
||||
credential. The returned JWT is the access token the runtime should
|
||||
pass on the websocket ``auth`` message.
|
||||
"""
|
||||
user = await async_get_or_create_sandbox_user(hass, group)
|
||||
refresh_token = await _get_or_create_sandbox_refresh_token(hass, user, group)
|
||||
return hass.auth.async_create_access_token(refresh_token)
|
||||
|
||||
|
||||
async def _get_or_create_sandbox_refresh_token(
|
||||
hass: HomeAssistant, user: User, group: str
|
||||
) -> RefreshToken:
|
||||
"""Return (or create) the sandbox refresh token for ``group``.
|
||||
|
||||
Sandbox users are ``system_generated`` so their tokens are
|
||||
``TOKEN_TYPE_SYSTEM`` and do not carry a ``client_id``. We identify
|
||||
a group's token by matching the ``scopes`` set against
|
||||
:data:`SANDBOX_TOKEN_SCOPES`; on first use, we create one.
|
||||
"""
|
||||
for token in user.refresh_tokens.values():
|
||||
if token.scopes == SANDBOX_TOKEN_SCOPES:
|
||||
return token
|
||||
return await hass.auth.async_create_refresh_token(
|
||||
user,
|
||||
scopes=SANDBOX_TOKEN_SCOPES,
|
||||
)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"SANDBOX_TOKEN_SCOPES",
|
||||
"async_get_or_create_sandbox_user",
|
||||
"async_issue_sandbox_access_token",
|
||||
]
|
||||
@@ -1,690 +0,0 @@
|
||||
"""Main-side bridge — owns the per-sandbox entity registry + outbound dispatch.
|
||||
|
||||
Responsibilities (Phase 5):
|
||||
|
||||
* 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_v2/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_v2/unregister_entity`` — drop the proxy.
|
||||
- ``sandbox_v2/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. The forwarder coalesces calls
|
||||
made within the same event-loop tick using
|
||||
:class:`_CallServiceBatcher` so a 200-entity area call pays one RPC
|
||||
instead of 200.
|
||||
* Translate sandbox-side exceptions back into the exception types proxy
|
||||
callers would have raised locally (``vol.Invalid`` → ``TypeError``,
|
||||
unknown service / entity → ``HomeAssistantError``).
|
||||
|
||||
Phase 8 adds the Store routing handlers (``sandbox_v2/store_load`` /
|
||||
``store_save`` / ``store_remove``). A per-group :class:`_SandboxStoreServer`
|
||||
backs them, writing each key to ``<config>/.storage/sandbox_v2/<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.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant, ServiceCall, SupportsResponse, callback
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import 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 json as json_util
|
||||
from homeassistant.util.file import write_utf8_file_atomic
|
||||
|
||||
from .channel import Channel, ChannelClosedError, ChannelRemoteError
|
||||
from .protocol import (
|
||||
MSG_CALL_SERVICE,
|
||||
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_v2"
|
||||
|
||||
|
||||
@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)
|
||||
|
||||
@classmethod
|
||||
def from_payload(cls, payload: Mapping[str, Any]) -> SandboxEntityDescription:
|
||||
"""Build a description from the wire payload."""
|
||||
return cls(
|
||||
entry_id=payload["entry_id"],
|
||||
domain=payload["domain"],
|
||||
sandbox_entity_id=payload["sandbox_entity_id"],
|
||||
unique_id=payload.get("unique_id"),
|
||||
name=payload.get("name"),
|
||||
icon=payload.get("icon"),
|
||||
has_entity_name=bool(payload.get("has_entity_name", False)),
|
||||
entity_category=payload.get("entity_category"),
|
||||
device_class=payload.get("device_class"),
|
||||
supported_features=int(payload.get("supported_features") or 0),
|
||||
capabilities=dict(payload.get("capabilities") or {}),
|
||||
initial_state=payload.get("initial_state"),
|
||||
initial_attributes=dict(payload.get("initial_attributes") or {}),
|
||||
)
|
||||
|
||||
|
||||
class _CallServiceBatcher:
|
||||
"""Per-loop-tick coalescer keyed by (domain, service, frozen kwargs).
|
||||
|
||||
Proxy entities call :meth:`enqueue` for every method invocation. The
|
||||
batcher gathers everything that arrived this tick, fires one
|
||||
``sandbox_v2/call_service`` per (domain, service, kwargs-shape) bucket
|
||||
with a multi-entity ``target.entity_id`` list, and resolves all the
|
||||
waiting futures with the same response.
|
||||
|
||||
Kwargs are not hashable (they include nested dicts/lists), so the key
|
||||
is the JSON-canonical form of the kwargs dict. Only entities that
|
||||
happen to use *identical* kwargs collapse into one RPC, which matches
|
||||
how an area call resolves: HA applies the same kwargs to every
|
||||
targeted entity.
|
||||
"""
|
||||
|
||||
def __init__(self, bridge: SandboxBridge) -> None:
|
||||
"""Initialise the batcher with its owning bridge."""
|
||||
self._bridge = bridge
|
||||
self._buckets: dict[tuple[str, str, str], _BatchBucket] = {}
|
||||
self._flush_handle: asyncio.Handle | None = None
|
||||
|
||||
async def enqueue(
|
||||
self,
|
||||
*,
|
||||
domain: str,
|
||||
service: str,
|
||||
sandbox_entity_id: str,
|
||||
service_data: dict[str, Any],
|
||||
context_id: str | None = None,
|
||||
return_response: bool = False,
|
||||
) -> Any:
|
||||
"""Queue one entity into the next batched ``call_service`` RPC."""
|
||||
import json # noqa: PLC0415 — local import keeps json off integration boot path
|
||||
|
||||
kwargs_key = json.dumps(
|
||||
service_data, sort_keys=True, separators=(",", ":"), default=str
|
||||
)
|
||||
bucket_key = (domain, service, kwargs_key)
|
||||
bucket = self._buckets.get(bucket_key)
|
||||
if bucket is None:
|
||||
future: asyncio.Future[Any] = asyncio.get_running_loop().create_future()
|
||||
bucket = _BatchBucket(
|
||||
domain=domain,
|
||||
service=service,
|
||||
service_data=service_data,
|
||||
context_id=context_id,
|
||||
return_response=return_response,
|
||||
future=future,
|
||||
)
|
||||
self._buckets[bucket_key] = bucket
|
||||
bucket.sandbox_entity_ids.append(sandbox_entity_id)
|
||||
self._schedule_flush()
|
||||
return await bucket.future
|
||||
|
||||
def _schedule_flush(self) -> None:
|
||||
if self._flush_handle is not None:
|
||||
return
|
||||
loop = asyncio.get_running_loop()
|
||||
self._flush_handle = loop.call_soon(self._flush)
|
||||
|
||||
def _flush(self) -> None:
|
||||
self._flush_handle = None
|
||||
buckets = self._buckets
|
||||
self._buckets = {}
|
||||
for bucket in buckets.values():
|
||||
asyncio.create_task( # noqa: RUF006 — fire-and-forget; bucket.future is the join point
|
||||
self._dispatch(bucket), name="sandbox_v2:call_service:flush"
|
||||
)
|
||||
|
||||
async def _dispatch(self, bucket: _BatchBucket) -> None:
|
||||
try:
|
||||
result = await self._bridge._raw_call_service( # noqa: SLF001
|
||||
domain=bucket.domain,
|
||||
service=bucket.service,
|
||||
target={"entity_id": bucket.sandbox_entity_ids},
|
||||
service_data=bucket.service_data,
|
||||
context_id=bucket.context_id,
|
||||
return_response=bucket.return_response,
|
||||
)
|
||||
except BaseException as err: # noqa: BLE001
|
||||
if not bucket.future.done():
|
||||
bucket.future.set_exception(err)
|
||||
return
|
||||
if not bucket.future.done():
|
||||
bucket.future.set_result(result)
|
||||
|
||||
|
||||
@dataclass
|
||||
class _BatchBucket:
|
||||
"""One coalesced ``sandbox_v2/call_service`` invocation in flight."""
|
||||
|
||||
domain: str
|
||||
service: str
|
||||
service_data: dict[str, Any]
|
||||
context_id: str | None
|
||||
return_response: bool
|
||||
future: asyncio.Future[Any]
|
||||
sandbox_entity_ids: list[str] = field(default_factory=list)
|
||||
|
||||
|
||||
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._batcher = _CallServiceBatcher(self)
|
||||
|
||||
self._store_server = _SandboxStoreServer(hass, group)
|
||||
|
||||
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_id: str | None = None,
|
||||
return_response: bool = False,
|
||||
) -> Any:
|
||||
"""Forward one entity service call to the sandbox.
|
||||
|
||||
Calls made in the same tick with matching ``(domain, service,
|
||||
service_data)`` coalesce into a single RPC with a multi-entity
|
||||
target.
|
||||
"""
|
||||
return await self._batcher.enqueue(
|
||||
domain=domain,
|
||||
service=service,
|
||||
sandbox_entity_id=sandbox_entity_id,
|
||||
service_data=service_data,
|
||||
context_id=context_id,
|
||||
return_response=return_response,
|
||||
)
|
||||
|
||||
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_v2/call_service`` RPC and translate errors."""
|
||||
payload: dict[str, Any] = {
|
||||
"domain": domain,
|
||||
"service": service,
|
||||
"target": target,
|
||||
"service_data": service_data,
|
||||
"return_response": return_response,
|
||||
}
|
||||
if context_id is not None:
|
||||
payload["context_id"] = context_id
|
||||
try:
|
||||
return await self.channel.call(MSG_CALL_SERVICE, payload)
|
||||
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
|
||||
|
||||
async def _handle_register_entity(
|
||||
self, payload: Mapping[str, Any]
|
||||
) -> dict[str, Any]:
|
||||
description = SandboxEntityDescription.from_payload(payload)
|
||||
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}"
|
||||
)
|
||||
# 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)
|
||||
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 {"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, payload: Mapping[str, Any]
|
||||
) -> dict[str, Any]:
|
||||
sandbox_entity_id = payload["sandbox_entity_id"]
|
||||
proxy = self._entities.pop(sandbox_entity_id, None)
|
||||
if proxy is None:
|
||||
return {"ok": True}
|
||||
entity_id = getattr(proxy, "entity_id", None)
|
||||
if not entity_id:
|
||||
return {"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 {"ok": True}
|
||||
|
||||
async def _handle_state_changed(self, payload: Mapping[str, Any]) -> None:
|
||||
sandbox_entity_id = payload["sandbox_entity_id"]
|
||||
proxy = self._entities.get(sandbox_entity_id)
|
||||
if proxy is None:
|
||||
return
|
||||
new_state = payload.get("new_state") or {}
|
||||
state_str = new_state.get("state")
|
||||
attributes = dict(new_state.get("attributes") or {})
|
||||
proxy.sandbox_apply_state(state_str, attributes)
|
||||
|
||||
async def _handle_register_service(
|
||||
self, payload: Mapping[str, Any]
|
||||
) -> dict[str, Any]:
|
||||
"""Mirror a sandbox-registered service onto main's service registry.
|
||||
|
||||
The handler that gets installed forwards every call back over
|
||||
the shared ``sandbox_v2/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 = str(payload["domain"]).lower()
|
||||
service = str(payload["service"]).lower()
|
||||
supports_response = _parse_supports_response(payload.get("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 {"ok": True, "installed": False}
|
||||
|
||||
forwarder = _build_service_forwarder(self, domain, service, supports_response)
|
||||
schema = reconstruct_schema(payload.get("schema"))
|
||||
self.hass.services.async_register(
|
||||
domain,
|
||||
service,
|
||||
forwarder,
|
||||
schema=schema,
|
||||
supports_response=supports_response,
|
||||
)
|
||||
self._mirrored_services.add((domain, service))
|
||||
return {"ok": True, "installed": True}
|
||||
|
||||
async def _handle_unregister_service(
|
||||
self, payload: Mapping[str, Any]
|
||||
) -> dict[str, Any]:
|
||||
domain = str(payload["domain"]).lower()
|
||||
service = str(payload["service"]).lower()
|
||||
key = (domain, service)
|
||||
if key not in self._mirrored_services:
|
||||
return {"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 {"ok": True, "removed": True}
|
||||
|
||||
async def _handle_store_load(
|
||||
self, payload: Mapping[str, Any]
|
||||
) -> dict[str, Any] | None:
|
||||
"""Serve a sandbox-side ``Store.async_load`` (Phase 8)."""
|
||||
return await self._store_server.async_load(_require_key(payload))
|
||||
|
||||
async def _handle_store_save(self, payload: Mapping[str, Any]) -> dict[str, Any]:
|
||||
"""Persist a sandbox-side ``Store.async_save`` flush (Phase 8)."""
|
||||
data = payload.get("data")
|
||||
if not isinstance(data, dict):
|
||||
raise HomeAssistantError("store_save: missing 'data' dict")
|
||||
await self._store_server.async_save(_require_key(payload), data)
|
||||
return {"ok": True}
|
||||
|
||||
async def _handle_store_remove(self, payload: Mapping[str, Any]) -> dict[str, Any]:
|
||||
"""Drop the on-disk file for a sandbox-side ``Store.async_remove``."""
|
||||
await self._store_server.async_remove(_require_key(payload))
|
||||
return {"ok": True}
|
||||
|
||||
async def _handle_fire_event(self, payload: Mapping[str, Any]) -> None:
|
||||
"""Re-fire a sandbox-side event on main's bus.
|
||||
|
||||
The sandbox tags every push with ``event_type`` + ``event_data``;
|
||||
the context is reconstructed minimally so listeners on main see a
|
||||
consistent ``Context`` shape (the sandbox's own context id is
|
||||
forwarded but not honoured by main's user resolution — that's
|
||||
intentional for v2).
|
||||
"""
|
||||
event_type = str(payload["event_type"])
|
||||
event_data = payload.get("event_data") or {}
|
||||
self.hass.bus.async_fire(event_type, dict(event_data))
|
||||
|
||||
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 _require_key(payload: Mapping[str, Any]) -> str:
|
||||
"""Extract + validate a ``key`` field from a store payload.
|
||||
|
||||
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.
|
||||
"""
|
||||
key = payload.get("key")
|
||||
if not isinstance(key, str) or 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_v2/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_v2/<group>``."""
|
||||
self.hass = hass
|
||||
self.group = group
|
||||
self._dir = Path(hass.config.path(STORAGE_DIR, "sandbox_v2", 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
|
||||
|
||||
|
||||
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_v2/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:
|
||||
payload: dict[str, Any] = {
|
||||
"domain": domain,
|
||||
"service": service,
|
||||
"service_data": dict(call.data),
|
||||
"target": _target_from_call(call),
|
||||
"return_response": call.return_response,
|
||||
"context_id": call.context.id if call.context is not None else None,
|
||||
}
|
||||
try:
|
||||
response = await bridge.channel.call(MSG_CALL_SERVICE, payload)
|
||||
except ChannelRemoteError as err:
|
||||
raise _translate_remote_error(err) from err
|
||||
except ChannelClosedError as err:
|
||||
raise HomeAssistantError(
|
||||
f"Sandbox {bridge.group!r} channel closed during {domain}.{service}"
|
||||
) from err
|
||||
if supports_response is SupportsResponse.NONE:
|
||||
return None
|
||||
if isinstance(response, Mapping):
|
||||
return response.get("response", response)
|
||||
return response
|
||||
|
||||
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 _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`.
|
||||
Callers on main expect ``TypeError`` / ``HomeAssistantError`` shapes,
|
||||
so we translate. Anything we don't have a mapping for surfaces as a
|
||||
plain :class:`HomeAssistantError` with the remote message preserved.
|
||||
"""
|
||||
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",
|
||||
]
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user