Compare commits

..

3 Commits

Author SHA1 Message Date
Jan Bouwhuis f26f9874d9 Merge branch 'dev' into mqtt-datetime-subentry-support 2026-05-22 21:39:29 +02:00
Jan Bouwhuis 2b80a66026 Apply suggestion from @jbouwh 2026-05-20 23:23:57 +02:00
jbouwh 9815dd74fd Add subentry support for MQTT date, datetime and time entities 2026-05-19 20:05:08 +00:00
172 changed files with 5984 additions and 12800 deletions
Generated
+2
View File
@@ -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
@@ -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
@@ -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)
+5 -2
View File
@@ -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."]
}
@@ -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:
+1 -1
View File
@@ -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"])
+427 -30
View File
@@ -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"
}
]
}
+398
View File
@@ -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()
+110 -3
View File
@@ -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
)
)
+1 -1
View File
@@ -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"
@@ -354,6 +354,7 @@ from .const import (
CONF_TILT_STATE_OPTIMISTIC,
CONF_TILT_STATUS_TEMPLATE,
CONF_TILT_STATUS_TOPIC,
CONF_TIMEZONE,
CONF_TLS_INSECURE,
CONF_TRANSITION,
CONF_TRANSPORT,
@@ -461,6 +462,8 @@ SUBENTRY_PLATFORMS = [
Platform.BUTTON,
Platform.CLIMATE,
Platform.COVER,
Platform.DATE,
Platform.DATETIME,
Platform.FAN,
Platform.IMAGE,
Platform.LIGHT,
@@ -472,6 +475,7 @@ SUBENTRY_PLATFORMS = [
Platform.SIREN,
Platform.SWITCH,
Platform.TEXT,
Platform.TIME,
Platform.VALVE,
Platform.WATER_HEATER,
]
@@ -485,6 +489,10 @@ PWD_NOT_CHANGED = "__**password_not_changed**__"
DEVELOPER_DOCUMENTATION_URL = "https://developers.home-assistant.io/"
USER_DOCUMENTATION_URL = "https://www.home-assistant.io/"
TZ_ZONE_ABBR_URL = (
"https://en.wikipedia.org/wiki/List_of_tz_database_time_zones"
"#Time_zone_abbreviations"
)
INTEGRATION_URL = f"{USER_DOCUMENTATION_URL}integrations/{DOMAIN}/"
TEMPLATING_URL = f"{USER_DOCUMENTATION_URL}docs/configuration/templating/"
@@ -504,6 +512,7 @@ TRANSLATION_DESCRIPTION_PLACEHOLDERS = {
"available_state_classes_url": AVAILABLE_STATE_CLASSES_URL,
"naming_entities_url": NAMING_ENTITIES_URL,
"registry_properties_url": REGISTRY_PROPERTIES_URL,
"tz_abbr_url": TZ_ZONE_ABBR_URL,
}
# Common selectors
@@ -1237,6 +1246,8 @@ ENTITY_CONFIG_VALIDATOR: dict[
Platform.BUTTON: None,
Platform.CLIMATE: validate_climate_platform_config,
Platform.COVER: validate_cover_platform_config,
Platform.DATE: None,
Platform.DATETIME: None,
Platform.FAN: validate_fan_platform_config,
Platform.IMAGE: None,
Platform.LIGHT: validate_light_platform_config,
@@ -1248,6 +1259,7 @@ ENTITY_CONFIG_VALIDATOR: dict[
Platform.SIREN: None,
Platform.SWITCH: None,
Platform.TEXT: validate_text_platform_config,
Platform.TIME: None,
Platform.VALVE: None,
Platform.WATER_HEATER: validate_water_heater_platform_config,
}
@@ -1413,6 +1425,8 @@ PLATFORM_ENTITY_FIELDS: dict[Platform, dict[str, PlatformField]] = {
required=False,
),
},
Platform.DATE: {},
Platform.DATETIME: {},
Platform.FAN: {
"fan_feature_speed": PlatformField(
selector=BOOLEAN_SELECTOR,
@@ -1517,6 +1531,7 @@ PLATFORM_ENTITY_FIELDS: dict[Platform, dict[str, PlatformField]] = {
),
},
Platform.TEXT: {},
Platform.TIME: {},
Platform.VALVE: {
CONF_DEVICE_CLASS: PlatformField(
selector=VALVE_DEVICE_CLASS_SELECTOR, required=False, default=None
@@ -2366,6 +2381,61 @@ PLATFORM_MQTT_FIELDS: dict[Platform, dict[str, PlatformField]] = {
section="cover_tilt_settings",
),
},
Platform.DATE: {
CONF_COMMAND_TOPIC: PlatformField(
selector=TEXT_SELECTOR,
required=True,
validator=valid_publish_topic,
error="invalid_publish_topic",
),
CONF_COMMAND_TEMPLATE: PlatformField(
selector=TEMPLATE_SELECTOR,
required=False,
validator=validate(cv.template),
error="invalid_template",
),
CONF_STATE_TOPIC: PlatformField(
selector=TEXT_SELECTOR,
required=False,
validator=valid_subscribe_topic,
error="invalid_subscribe_topic",
),
CONF_VALUE_TEMPLATE: PlatformField(
selector=TEMPLATE_SELECTOR,
required=False,
validator=validate(cv.template),
error="invalid_template",
),
CONF_RETAIN: PlatformField(selector=BOOLEAN_SELECTOR, required=False),
},
Platform.DATETIME: {
CONF_COMMAND_TOPIC: PlatformField(
selector=TEXT_SELECTOR,
required=True,
validator=valid_publish_topic,
error="invalid_publish_topic",
),
CONF_COMMAND_TEMPLATE: PlatformField(
selector=TEMPLATE_SELECTOR,
required=False,
validator=validate(cv.template),
error="invalid_template",
),
CONF_STATE_TOPIC: PlatformField(
selector=TEXT_SELECTOR,
required=False,
validator=valid_subscribe_topic,
error="invalid_subscribe_topic",
),
CONF_VALUE_TEMPLATE: PlatformField(
selector=TEMPLATE_SELECTOR,
required=False,
validator=validate(cv.template),
error="invalid_template",
),
CONF_TIMEZONE: PlatformField(selector=TEXT_SELECTOR, required=False),
CONF_RETAIN: PlatformField(selector=BOOLEAN_SELECTOR, required=False),
},
Platform.FAN: {
CONF_COMMAND_TOPIC: PlatformField(
selector=TEXT_SELECTOR,
@@ -3473,6 +3543,33 @@ PLATFORM_MQTT_FIELDS: dict[Platform, dict[str, PlatformField]] = {
section="text_advanced_settings",
),
},
Platform.TIME: {
CONF_COMMAND_TOPIC: PlatformField(
selector=TEXT_SELECTOR,
required=True,
validator=valid_publish_topic,
error="invalid_publish_topic",
),
CONF_COMMAND_TEMPLATE: PlatformField(
selector=TEMPLATE_SELECTOR,
required=False,
validator=validate(cv.template),
error="invalid_template",
),
CONF_STATE_TOPIC: PlatformField(
selector=TEXT_SELECTOR,
required=False,
validator=valid_subscribe_topic,
error="invalid_subscribe_topic",
),
CONF_VALUE_TEMPLATE: PlatformField(
selector=TEMPLATE_SELECTOR,
required=False,
validator=validate(cv.template),
error="invalid_template",
),
CONF_RETAIN: PlatformField(selector=BOOLEAN_SELECTOR, required=False),
},
Platform.VALVE: {
CONF_COMMAND_TOPIC: PlatformField(
selector=TEXT_SELECTOR,
+1
View File
@@ -56,6 +56,7 @@ CONF_RETAIN = ATTR_RETAIN
CONF_SCHEMA = "schema"
CONF_STATE_TOPIC = "state_topic"
CONF_STATE_VALUE_TEMPLATE = "state_value_template"
CONF_TIMEZONE = "timezone"
CONF_TOPIC = "topic"
CONF_TRANSPORT = "transport"
CONF_WS_PATH = "ws_path"
+1 -2
View File
@@ -27,6 +27,7 @@ from .const import (
CONF_COMMAND_TEMPLATE,
CONF_COMMAND_TOPIC,
CONF_STATE_TOPIC,
CONF_TIMEZONE,
PAYLOAD_NONE,
)
from .entity import MqttEntity, async_setup_entity_entry_helper
@@ -40,8 +41,6 @@ from .schemas import MQTT_ENTITY_COMMON_SCHEMA
_LOGGER = logging.getLogger(__name__)
CONF_TIMEZONE = "timezone"
PARALLEL_UPDATES = 0
DEFAULT_NAME = "MQTT Date/Time"
@@ -378,6 +378,7 @@
"support_duration": "Duration support",
"support_volume_set": "Set volume support",
"supported_color_modes": "Supported color modes",
"timezone": "Time zone",
"url_template": "URL template",
"url_topic": "URL topic",
"value_template": "Value template"
@@ -430,6 +431,7 @@
"support_duration": "The siren supports setting a duration in second. The `duration` variable will become available for use in the \"Command template\" setting. [Learn more.]({url}#support_duration)",
"support_volume_set": "The siren supports setting a volume. The `volume_level` variable will become available for use in the \"Command template\" setting. [Learn more.]({url}#support_volume_set)",
"supported_color_modes": "A list of color modes supported by the light. Possible color modes are On/Off, Brightness, Color temperature, HS, XY, RGB, RGBW, RGBWW, White. Note that if On/Off or Brightness are used, that must be the only value in the list. [Learn more.]({url}#supported_color_modes)",
"timezone": "Set to a valid [IANA time zone identifier]({tz_abbr_url}). Do not set this option if the date/time structure is providing time zone information via the status update.",
"url_template": "[Template]({value_templating_url}) to extract an URL from the received URL topic payload value. [Learn more.]({url}#url_template)",
"url_topic": "The MQTT topic subscribed to receive messages containing the image URL. [Learn more.]({url}#url_topic)",
"value_template": "Defines a [template]({value_templating_url}) to extract the {platform} entity value. [Learn more.]({url}#value_template)"
@@ -1468,6 +1470,8 @@
"button": "[%key:component::button::title%]",
"climate": "[%key:component::climate::title%]",
"cover": "[%key:component::cover::title%]",
"date": "[%key:component::date::title%]",
"datetime": "[%key:component::datetime::title%]",
"fan": "[%key:component::fan::title%]",
"image": "[%key:component::image::title%]",
"light": "[%key:component::light::title%]",
@@ -1479,6 +1483,7 @@
"siren": "[%key:component::siren::title%]",
"switch": "[%key:component::switch::title%]",
"text": "[%key:component::text::title%]",
"time": "[%key:component::time::title%]",
"valve": "[%key:component::valve::title%]",
"water_heater": "[%key:component::water_heater::title%]"
}
+1 -1
View File
@@ -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"
+2 -1
View File
@@ -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 -2
View File
@@ -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):
+2
View File
@@ -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):
+2 -1
View File
@@ -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,
+2 -1
View File
@@ -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,
+3 -7
View File
@@ -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
View File
@@ -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
+1 -1
View File
@@ -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 = {
+2 -1
View File
@@ -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"])
-2
View File
@@ -343,5 +343,3 @@ TRV_CHANNEL = 0
ATTR_KEY = "key"
ATTR_VALUE = "value"
DRIVER_MISSING_ERROR = "Sensor driver missing from firmware"
+1 -7
View File
@@ -42,7 +42,7 @@ from homeassistant.helpers.entity_registry import RegistryEntry
from homeassistant.helpers.typing import StateType
from homeassistant.util.dt import utcnow
from .const import CONF_SLEEP_PERIOD, DRIVER_MISSING_ERROR, ROLE_GENERIC
from .const import CONF_SLEEP_PERIOD, ROLE_GENERIC
from .coordinator import ShellyBlockCoordinator, ShellyConfigEntry, ShellyRpcCoordinator
from .entity import (
BlockEntityDescription,
@@ -1225,9 +1225,6 @@ RPC_SENSORS: Final = {
suggested_display_precision=1,
device_class=SensorDeviceClass.TEMPERATURE,
state_class=SensorStateClass.MEASUREMENT,
removal_condition=lambda _, status, key: (
DRIVER_MISSING_ERROR in status[key].get("errors", [])
),
),
"rssi": RpcSensorDescription(
key="wifi",
@@ -1256,9 +1253,6 @@ RPC_SENSORS: Final = {
suggested_display_precision=1,
device_class=SensorDeviceClass.HUMIDITY,
state_class=SensorStateClass.MEASUREMENT,
removal_condition=lambda _, status, key: (
DRIVER_MISSING_ERROR in status[key].get("errors", [])
),
),
"battery": RpcSensorDescription(
key="devicepower",
+4
View File
@@ -946,6 +946,10 @@ PRECISION_WHOLE: Final = 1
PRECISION_HALVES: Final = 0.5
PRECISION_TENTHS: Final = 0.1
# Static list of entities that will never be exposed to
# cloud, alexa, or google_home components
CLOUD_NEVER_EXPOSED_ENTITIES: Final[list[str]] = ["group.all_locks"]
class EntityCategory(StrEnum):
"""Category of an entity.
+1
View File
@@ -384,6 +384,7 @@ FLOWS = {
"knocki",
"knx",
"kodi",
"konnected",
"kostal_plenticore",
"kraken",
"kulersky",
@@ -3574,6 +3574,12 @@
"konnected": {
"name": "Konnected",
"integrations": {
"konnected": {
"integration_type": "hub",
"config_flow": true,
"iot_class": "local_push",
"name": "Konnected.io (Legacy)"
},
"konnected_esphome": {
"integration_type": "virtual",
"config_flow": false,
+5
View File
@@ -201,6 +201,11 @@ SSDP = {
"manufacturer": "ZyXEL Communications Corp.",
},
],
"konnected": [
{
"manufacturer": "konnected.io",
},
],
"lametric": [
{
"deviceType": "urn:schemas-upnp-org:device:LaMetric:1",
+1 -1
View File
@@ -35,7 +35,7 @@ file-read-backwards==2.0.0
fnv-hash-fast==2.0.2
go2rtc-client==0.4.0
ha-ffmpeg==3.2.2
habluetooth==6.4.0
habluetooth==6.2.0
hass-nabucasa==2.2.0
hassil==3.5.0
home-assistant-bluetooth==2.0.0
+6 -3
View File
@@ -254,7 +254,7 @@ aioelectricitymaps==1.1.1
aioemonitor==1.0.5
# homeassistant.components.esphome
aioesphomeapi==45.1.0
aioesphomeapi==45.0.4
# homeassistant.components.matrix
# homeassistant.components.slack
@@ -648,7 +648,7 @@ beautifulsoup4==4.13.3
bizkaibus==0.1.1
# homeassistant.components.esphome
bleak-esphome==3.8.1
bleak-esphome==3.7.3
# homeassistant.components.bluetooth
bleak-retry-connector==4.6.0
@@ -1210,7 +1210,7 @@ ha-xthings-cloud==1.0.5
habiticalib==0.4.7
# homeassistant.components.bluetooth
habluetooth==6.4.0
habluetooth==6.2.0
# homeassistant.components.hanna
hanna-cloud==0.0.7
@@ -1422,6 +1422,9 @@ knocki==0.4.2
# homeassistant.components.knx
knx-frontend==2026.4.30.60856
# homeassistant.components.konnected
konnected==1.2.0
# homeassistant.components.kraken
krakenex==2.2.2

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