From 890b54e36f3406a436ca5d6b40daa887dd0b7e7d Mon Sep 17 00:00:00 2001 From: GeoffAtHome Date: Sun, 21 Jul 2024 18:57:41 +0100 Subject: [PATCH 1/8] Add config flow to Genius hub (#116173) * Adding config flow * Fix setup issues. * Added test for config_flow * Refactor schemas. * Fixed ruff-format on const.py * Added geniushub-cleint to requirements_test_all.txt * Updates from review. * Correct multiple logger comment errors. * User menu rather than check box. * Correct logger messages. * Correct test_config_flow * Import config entry from YAML * Config flow integration * Refactor genius hub test_config_flow. * Improvements and simplification from code review. * Correct tests * Stop device being added twice. * Correct validate_input. * Changes to meet code review three week ago. * Fix Ruff undefined error * Update homeassistant/components/geniushub/config_flow.py Co-authored-by: Joost Lekkerkerker * Update homeassistant/components/geniushub/config_flow.py Co-authored-by: Joost Lekkerkerker * Change case Cloud and Local to CLOUD and LOCAL. * More from code review * Fix * Fix * Update homeassistant/components/geniushub/strings.json --------- Co-authored-by: Joost Lekkerkerker --- CODEOWNERS | 1 + .../components/geniushub/__init__.py | 117 ++++- .../components/geniushub/binary_sensor.py | 20 +- homeassistant/components/geniushub/climate.py | 20 +- .../components/geniushub/config_flow.py | 136 +++++ homeassistant/components/geniushub/const.py | 19 + .../components/geniushub/manifest.json | 1 + homeassistant/components/geniushub/sensor.py | 14 +- .../components/geniushub/strings.json | 35 ++ homeassistant/components/geniushub/switch.py | 21 +- .../components/geniushub/water_heater.py | 22 +- homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 2 +- requirements_test_all.txt | 3 + tests/components/geniushub/__init__.py | 1 + tests/components/geniushub/conftest.py | 65 +++ .../components/geniushub/test_config_flow.py | 482 ++++++++++++++++++ 17 files changed, 869 insertions(+), 91 deletions(-) create mode 100644 homeassistant/components/geniushub/config_flow.py create mode 100644 homeassistant/components/geniushub/const.py create mode 100644 tests/components/geniushub/__init__.py create mode 100644 tests/components/geniushub/conftest.py create mode 100644 tests/components/geniushub/test_config_flow.py diff --git a/CODEOWNERS b/CODEOWNERS index f79da235bb68..b382d63cf449 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -505,6 +505,7 @@ build.json @home-assistant/supervisor /homeassistant/components/generic_hygrostat/ @Shulyaka /tests/components/generic_hygrostat/ @Shulyaka /homeassistant/components/geniushub/ @manzanotti +/tests/components/geniushub/ @manzanotti /homeassistant/components/geo_json_events/ @exxamalte /tests/components/geo_json_events/ @exxamalte /homeassistant/components/geo_location/ @home-assistant/core diff --git a/homeassistant/components/geniushub/__init__.py b/homeassistant/components/geniushub/__init__.py index 05afb121d44f..84e835ac2bbe 100644 --- a/homeassistant/components/geniushub/__init__.py +++ b/homeassistant/components/geniushub/__init__.py @@ -10,6 +10,8 @@ import aiohttp from geniushubclient import GeniusHub import voluptuous as vol +from homeassistant import config_entries +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_ENTITY_ID, ATTR_TEMPERATURE, @@ -21,23 +23,29 @@ from homeassistant.const import ( Platform, UnitOfTemperature, ) -from homeassistant.core import HomeAssistant, ServiceCall, callback +from homeassistant.core import ( + DOMAIN as HOMEASSISTANT_DOMAIN, + HomeAssistant, + ServiceCall, + callback, +) +from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers import config_validation as cv, entity_registry as er from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.helpers.discovery import async_load_platform from homeassistant.helpers.dispatcher import ( async_dispatcher_connect, async_dispatcher_send, ) from homeassistant.helpers.entity import Entity from homeassistant.helpers.event import async_track_time_interval +from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.service import verify_domain_control from homeassistant.helpers.typing import ConfigType import homeassistant.util.dt as dt_util -_LOGGER = logging.getLogger(__name__) +from .const import DOMAIN -DOMAIN = "geniushub" +_LOGGER = logging.getLogger(__name__) # temperature is repeated here, as it gives access to high-precision temps GH_ZONE_ATTRS = ["mode", "temperature", "type", "occupied", "override"] @@ -54,13 +62,15 @@ SCAN_INTERVAL = timedelta(seconds=60) MAC_ADDRESS_REGEXP = r"^([0-9A-F]{2}:){5}([0-9A-F]{2})$" -V1_API_SCHEMA = vol.Schema( +CLOUD_API_SCHEMA = vol.Schema( { vol.Required(CONF_TOKEN): cv.string, vol.Required(CONF_MAC): vol.Match(MAC_ADDRESS_REGEXP), } ) -V3_API_SCHEMA = vol.Schema( + + +LOCAL_API_SCHEMA = vol.Schema( { vol.Required(CONF_HOST): cv.string, vol.Required(CONF_USERNAME): cv.string, @@ -68,8 +78,9 @@ V3_API_SCHEMA = vol.Schema( vol.Optional(CONF_MAC): vol.Match(MAC_ADDRESS_REGEXP), } ) + CONFIG_SCHEMA = vol.Schema( - {DOMAIN: vol.Any(V3_API_SCHEMA, V1_API_SCHEMA)}, extra=vol.ALLOW_EXTRA + {DOMAIN: vol.Any(LOCAL_API_SCHEMA, CLOUD_API_SCHEMA)}, extra=vol.ALLOW_EXTRA ) ATTR_ZONE_MODE = "mode" @@ -106,20 +117,78 @@ PLATFORMS = ( ) -async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: +async def _async_import(hass: HomeAssistant, base_config: ConfigType) -> None: + """Import a config entry from configuration.yaml.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data=base_config[DOMAIN], + ) + if ( + result["type"] is FlowResultType.CREATE_ENTRY + or result["reason"] == "already_configured" + ): + async_create_issue( + hass, + HOMEASSISTANT_DOMAIN, + f"deprecated_yaml_{DOMAIN}", + breaks_in_ha_version="2024.12.0", + is_fixable=False, + issue_domain=DOMAIN, + severity=IssueSeverity.WARNING, + translation_key="deprecated_yaml", + translation_placeholders={ + "domain": DOMAIN, + "integration_title": "Genius Hub", + }, + ) + return + async_create_issue( + hass, + DOMAIN, + f"deprecated_yaml_import_issue_{result['reason']}", + breaks_in_ha_version="2024.12.0", + is_fixable=False, + issue_domain=DOMAIN, + severity=IssueSeverity.WARNING, + translation_key=f"deprecated_yaml_import_issue_{result['reason']}", + translation_placeholders={ + "domain": DOMAIN, + "integration_title": "Genius Hub", + }, + ) + + +async def async_setup(hass: HomeAssistant, base_config: ConfigType) -> bool: + """Set up a Genius Hub system.""" + if DOMAIN in base_config: + hass.async_create_task(_async_import(hass, base_config)) + return True + + +type GeniusHubConfigEntry = ConfigEntry[GeniusBroker] + + +async def async_setup_entry(hass: HomeAssistant, entry: GeniusHubConfigEntry) -> bool: """Create a Genius Hub system.""" - hass.data[DOMAIN] = {} - kwargs = dict(config[DOMAIN]) - if CONF_HOST in kwargs: - args = (kwargs.pop(CONF_HOST),) + session = async_get_clientsession(hass) + if CONF_HOST in entry.data: + client = GeniusHub( + entry.data[CONF_HOST], + username=entry.data[CONF_USERNAME], + password=entry.data[CONF_PASSWORD], + session=session, + ) else: - args = (kwargs.pop(CONF_TOKEN),) - hub_uid = kwargs.pop(CONF_MAC, None) + client = GeniusHub(entry.data[CONF_TOKEN], session=session) - client = GeniusHub(*args, **kwargs, session=async_get_clientsession(hass)) + unique_id = entry.unique_id or entry.entry_id - broker = hass.data[DOMAIN]["broker"] = GeniusBroker(hass, client, hub_uid) + broker = entry.runtime_data = GeniusBroker( + hass, client, entry.data.get(CONF_MAC, unique_id) + ) try: await client.update() @@ -130,11 +199,10 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async_track_time_interval(hass, broker.async_update, SCAN_INTERVAL) - for platform in PLATFORMS: - hass.async_create_task(async_load_platform(hass, platform, DOMAIN, {}, config)) - setup_service_functions(hass, broker) + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + return True @@ -175,20 +243,13 @@ def setup_service_functions(hass: HomeAssistant, broker): class GeniusBroker: """Container for geniushub client and data.""" - def __init__( - self, hass: HomeAssistant, client: GeniusHub, hub_uid: str | None - ) -> None: + def __init__(self, hass: HomeAssistant, client: GeniusHub, hub_uid: str) -> None: """Initialize the geniushub client.""" self.hass = hass self.client = client - self._hub_uid = hub_uid + self.hub_uid = hub_uid self._connect_error = False - @property - def hub_uid(self) -> str: - """Return the Hub UID (MAC address).""" - return self._hub_uid if self._hub_uid is not None else self.client.uid - async def async_update(self, now, **kwargs) -> None: """Update the geniushub client's data.""" try: diff --git a/homeassistant/components/geniushub/binary_sensor.py b/homeassistant/components/geniushub/binary_sensor.py index f078bb4b363d..2d6acf0c955c 100644 --- a/homeassistant/components/geniushub/binary_sensor.py +++ b/homeassistant/components/geniushub/binary_sensor.py @@ -5,33 +5,27 @@ from __future__ import annotations from homeassistant.components.binary_sensor import BinarySensorEntity from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from . import DOMAIN, GeniusDevice +from . import GeniusDevice, GeniusHubConfigEntry GH_STATE_ATTR = "outputOnOff" GH_TYPE = "Receiver" -async def async_setup_platform( +async def async_setup_entry( hass: HomeAssistant, - config: ConfigType, + entry: GeniusHubConfigEntry, async_add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, ) -> None: - """Set up the Genius Hub sensor entities.""" - if discovery_info is None: - return + """Set up the Genius Hub binary sensor entities.""" - broker = hass.data[DOMAIN]["broker"] + broker = entry.runtime_data - switches = [ + async_add_entities( GeniusBinarySensor(broker, d, GH_STATE_ATTR) for d in broker.client.device_objs if GH_TYPE in d.data["type"] - ] - - async_add_entities(switches, update_before_add=True) + ) class GeniusBinarySensor(GeniusDevice, BinarySensorEntity): diff --git a/homeassistant/components/geniushub/climate.py b/homeassistant/components/geniushub/climate.py index 02038ced198e..ea2a79be7677 100644 --- a/homeassistant/components/geniushub/climate.py +++ b/homeassistant/components/geniushub/climate.py @@ -12,9 +12,8 @@ from homeassistant.components.climate import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from . import DOMAIN, GeniusHeatingZone +from . import GeniusHeatingZone, GeniusHubConfigEntry # GeniusHub Zones support: Off, Timer, Override/Boost, Footprint & Linked modes HA_HVAC_TO_GH = {HVACMode.OFF: "off", HVACMode.HEAT: "timer"} @@ -26,24 +25,19 @@ GH_PRESET_TO_HA = {v: k for k, v in HA_PRESET_TO_GH.items()} GH_ZONES = ["radiator", "wet underfloor"] -async def async_setup_platform( +async def async_setup_entry( hass: HomeAssistant, - config: ConfigType, + entry: GeniusHubConfigEntry, async_add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the Genius Hub climate entities.""" - if discovery_info is None: - return - broker = hass.data[DOMAIN]["broker"] + broker = entry.runtime_data async_add_entities( - [ - GeniusClimateZone(broker, z) - for z in broker.client.zone_objs - if z.data.get("type") in GH_ZONES - ] + GeniusClimateZone(broker, z) + for z in broker.client.zone_objs + if z.data.get("type") in GH_ZONES ) diff --git a/homeassistant/components/geniushub/config_flow.py b/homeassistant/components/geniushub/config_flow.py new file mode 100644 index 000000000000..5f026c91ee10 --- /dev/null +++ b/homeassistant/components/geniushub/config_flow.py @@ -0,0 +1,136 @@ +"""Config flow for Geniushub integration.""" + +from __future__ import annotations + +from http import HTTPStatus +import logging +import socket +from typing import Any + +import aiohttp +from geniushubclient import GeniusService +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_TOKEN, CONF_USERNAME +from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +CLOUD_API_SCHEMA = vol.Schema( + { + vol.Required(CONF_TOKEN): str, + } +) + + +LOCAL_API_SCHEMA = vol.Schema( + { + vol.Required(CONF_HOST): str, + vol.Required(CONF_USERNAME): str, + vol.Required(CONF_PASSWORD): str, + } +) + + +class GeniusHubConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for Geniushub.""" + + VERSION = 1 + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """User config step for determine cloud or local.""" + return self.async_show_menu( + step_id="user", + menu_options=["local_api", "cloud_api"], + ) + + async def async_step_local_api( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Version 3 configuration.""" + errors: dict[str, str] = {} + if user_input is not None: + self._async_abort_entries_match( + { + CONF_HOST: user_input[CONF_HOST], + CONF_USERNAME: user_input[CONF_USERNAME], + } + ) + service = GeniusService( + user_input[CONF_HOST], + username=user_input[CONF_USERNAME], + password=user_input[CONF_PASSWORD], + session=async_get_clientsession(self.hass), + ) + try: + response = await service.request("GET", "auth/release") + except socket.gaierror: + errors["base"] = "invalid_host" + except aiohttp.ClientResponseError as err: + if err.status == HTTPStatus.UNAUTHORIZED: + errors["base"] = "invalid_auth" + else: + errors["base"] = "invalid_host" + except (TimeoutError, aiohttp.ClientConnectionError): + errors["base"] = "cannot_connect" + except Exception: # noqa: BLE001 + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + await self.async_set_unique_id(response["data"]["UID"]) + self._abort_if_unique_id_configured() + return self.async_create_entry( + title=user_input[CONF_HOST], data=user_input + ) + + return self.async_show_form( + step_id="local_api", errors=errors, data_schema=LOCAL_API_SCHEMA + ) + + async def async_step_cloud_api( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Version 1 configuration.""" + errors: dict[str, str] = {} + if user_input is not None: + self._async_abort_entries_match(user_input) + service = GeniusService( + user_input[CONF_TOKEN], session=async_get_clientsession(self.hass) + ) + try: + await service.request("GET", "version") + except aiohttp.ClientResponseError as err: + if err.status == HTTPStatus.UNAUTHORIZED: + errors["base"] = "invalid_auth" + else: + errors["base"] = "invalid_host" + except socket.gaierror: + errors["base"] = "invalid_host" + except (TimeoutError, aiohttp.ClientConnectionError): + errors["base"] = "cannot_connect" + except Exception: # noqa: BLE001 + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + return self.async_create_entry(title="Genius hub", data=user_input) + + return self.async_show_form( + step_id="cloud_api", errors=errors, data_schema=CLOUD_API_SCHEMA + ) + + async def async_step_import(self, user_input: dict[str, Any]) -> ConfigFlowResult: + """Import the yaml config.""" + if CONF_HOST in user_input: + result = await self.async_step_local_api(user_input) + else: + result = await self.async_step_cloud_api(user_input) + if result["type"] is FlowResultType.FORM: + assert result["errors"] + return self.async_abort(reason=result["errors"]["base"]) + return result diff --git a/homeassistant/components/geniushub/const.py b/homeassistant/components/geniushub/const.py new file mode 100644 index 000000000000..4601eca5f9bd --- /dev/null +++ b/homeassistant/components/geniushub/const.py @@ -0,0 +1,19 @@ +"""Constants for Genius Hub.""" + +from datetime import timedelta + +from homeassistant.const import Platform + +DOMAIN = "geniushub" + +SCAN_INTERVAL = timedelta(seconds=60) + +SENSOR_PREFIX = "Genius" + +PLATFORMS = ( + Platform.BINARY_SENSOR, + Platform.CLIMATE, + Platform.SENSOR, + Platform.SWITCH, + Platform.WATER_HEATER, +) diff --git a/homeassistant/components/geniushub/manifest.json b/homeassistant/components/geniushub/manifest.json index 280792938218..c6444bdb95da 100644 --- a/homeassistant/components/geniushub/manifest.json +++ b/homeassistant/components/geniushub/manifest.json @@ -2,6 +2,7 @@ "domain": "geniushub", "name": "Genius Hub", "codeowners": ["@manzanotti"], + "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/geniushub", "iot_class": "local_polling", "loggers": ["geniushubclient"], diff --git a/homeassistant/components/geniushub/sensor.py b/homeassistant/components/geniushub/sensor.py index f5cd8625e8bc..ee65e6794981 100644 --- a/homeassistant/components/geniushub/sensor.py +++ b/homeassistant/components/geniushub/sensor.py @@ -9,10 +9,9 @@ from homeassistant.components.sensor import SensorDeviceClass, SensorEntity from homeassistant.const import PERCENTAGE from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType import homeassistant.util.dt as dt_util -from . import DOMAIN, GeniusDevice, GeniusEntity +from . import GeniusDevice, GeniusEntity, GeniusHubConfigEntry GH_STATE_ATTR = "batteryLevel" @@ -23,17 +22,14 @@ GH_LEVEL_MAPPING = { } -async def async_setup_platform( +async def async_setup_entry( hass: HomeAssistant, - config: ConfigType, + entry: GeniusHubConfigEntry, async_add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the Genius Hub sensor entities.""" - if discovery_info is None: - return - broker = hass.data[DOMAIN]["broker"] + broker = entry.runtime_data entities: list[GeniusBattery | GeniusIssue] = [ GeniusBattery(broker, d, GH_STATE_ATTR) @@ -42,7 +38,7 @@ async def async_setup_platform( ] entities.extend([GeniusIssue(broker, i) for i in list(GH_LEVEL_MAPPING)]) - async_add_entities(entities, update_before_add=True) + async_add_entities(entities) class GeniusBattery(GeniusDevice, SensorEntity): diff --git a/homeassistant/components/geniushub/strings.json b/homeassistant/components/geniushub/strings.json index ac057f5c639d..faf5011d7526 100644 --- a/homeassistant/components/geniushub/strings.json +++ b/homeassistant/components/geniushub/strings.json @@ -1,4 +1,39 @@ { + "config": { + "step": { + "user": { + "title": "Genius Hub configuration", + "menu_options": { + "local_api": "Local: IP address and user credentials", + "cloud_api": "Cloud: API token" + } + }, + "local_api": { + "title": "Genius Hub local configuration", + "data": { + "host": "[%key:common::config_flow::data::host%]", + "password": "[%key:common::config_flow::data::password%]", + "username": "[%key:common::config_flow::data::username%]" + } + }, + "cloud_api": { + "title": "Genius Hub cloud configuration", + "data": { + "token": "[%key:common::config_flow::data::access_token%]" + } + } + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "invalid_host": "[%key:common::config_flow::error::invalid_host%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + } + }, + "services": { "set_zone_mode": { "name": "Set zone mode", diff --git a/homeassistant/components/geniushub/switch.py b/homeassistant/components/geniushub/switch.py index 85f7f1bb03ae..2fffbddde017 100644 --- a/homeassistant/components/geniushub/switch.py +++ b/homeassistant/components/geniushub/switch.py @@ -11,9 +11,9 @@ from homeassistant.components.switch import SwitchDeviceClass, SwitchEntity from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType, VolDictType +from homeassistant.helpers.typing import VolDictType -from . import ATTR_DURATION, DOMAIN, GeniusZone +from . import ATTR_DURATION, GeniusHubConfigEntry, GeniusZone GH_ON_OFF_ZONE = "on / off" @@ -27,24 +27,19 @@ SET_SWITCH_OVERRIDE_SCHEMA: VolDictType = { } -async def async_setup_platform( +async def async_setup_entry( hass: HomeAssistant, - config: ConfigType, + entry: GeniusHubConfigEntry, async_add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the Genius Hub switch entities.""" - if discovery_info is None: - return - broker = hass.data[DOMAIN]["broker"] + broker = entry.runtime_data async_add_entities( - [ - GeniusSwitch(broker, z) - for z in broker.client.zone_objs - if z.data.get("type") == GH_ON_OFF_ZONE - ] + GeniusSwitch(broker, z) + for z in broker.client.zone_objs + if z.data.get("type") == GH_ON_OFF_ZONE ) # Register custom services diff --git a/homeassistant/components/geniushub/water_heater.py b/homeassistant/components/geniushub/water_heater.py index f17560ebc625..6d3da570547a 100644 --- a/homeassistant/components/geniushub/water_heater.py +++ b/homeassistant/components/geniushub/water_heater.py @@ -9,9 +9,8 @@ from homeassistant.components.water_heater import ( from homeassistant.const import STATE_OFF from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from . import DOMAIN, GeniusHeatingZone +from . import GeniusHeatingZone, GeniusHubConfigEntry STATE_AUTO = "auto" STATE_MANUAL = "manual" @@ -33,24 +32,19 @@ GH_STATE_TO_HA = { GH_HEATERS = ["hot water temperature"] -async def async_setup_platform( +async def async_setup_entry( hass: HomeAssistant, - config: ConfigType, + entry: GeniusHubConfigEntry, async_add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, ) -> None: - """Set up the Genius Hub water_heater entities.""" - if discovery_info is None: - return + """Set up the Genius Hub water heater entities.""" - broker = hass.data[DOMAIN]["broker"] + broker = entry.runtime_data async_add_entities( - [ - GeniusWaterHeater(broker, z) - for z in broker.client.zone_objs - if z.data.get("type") in GH_HEATERS - ] + GeniusWaterHeater(broker, z) + for z in broker.client.zone_objs + if z.data.get("type") in GH_HEATERS ) diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index b86147058235..96875e247f1a 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -202,6 +202,7 @@ FLOWS = { "gardena_bluetooth", "gdacs", "generic", + "geniushub", "geo_json_events", "geocaching", "geofency", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 84d69c868db3..f60028240fb5 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -2124,7 +2124,7 @@ "geniushub": { "name": "Genius Hub", "integration_type": "hub", - "config_flow": false, + "config_flow": true, "iot_class": "local_polling" }, "geo_json_events": { diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 4d1562f340ac..449c95e88e40 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -773,6 +773,9 @@ gassist-text==0.0.11 # homeassistant.components.google gcal-sync==6.1.4 +# homeassistant.components.geniushub +geniushub-client==0.7.1 + # homeassistant.components.geocaching geocachingapi==0.2.1 diff --git a/tests/components/geniushub/__init__.py b/tests/components/geniushub/__init__.py new file mode 100644 index 000000000000..15886486e387 --- /dev/null +++ b/tests/components/geniushub/__init__.py @@ -0,0 +1 @@ +"""Tests for the geniushub integration.""" diff --git a/tests/components/geniushub/conftest.py b/tests/components/geniushub/conftest.py new file mode 100644 index 000000000000..125f1cfa80c4 --- /dev/null +++ b/tests/components/geniushub/conftest.py @@ -0,0 +1,65 @@ +"""GeniusHub tests configuration.""" + +from collections.abc import Generator +from unittest.mock import patch + +import pytest + +from homeassistant.components.geniushub.const import DOMAIN +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_TOKEN, CONF_USERNAME + +from tests.common import MockConfigEntry +from tests.components.smhi.common import AsyncMock + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.geniushub.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +def mock_geniushub_client() -> Generator[AsyncMock]: + """Mock a GeniusHub client.""" + with patch( + "homeassistant.components.geniushub.config_flow.GeniusService", + autospec=True, + ) as mock_client: + client = mock_client.return_value + client.request.return_value = { + "data": { + "UID": "aa:bb:cc:dd:ee:ff", + } + } + yield client + + +@pytest.fixture +def mock_local_config_entry() -> MockConfigEntry: + """Mock a local config entry.""" + return MockConfigEntry( + domain=DOMAIN, + title="aa:bb:cc:dd:ee:ff", + data={ + CONF_HOST: "10.0.0.131", + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + }, + unique_id="aa:bb:cc:dd:ee:ff", + ) + + +@pytest.fixture +def mock_cloud_config_entry() -> MockConfigEntry: + """Mock a cloud config entry.""" + return MockConfigEntry( + domain=DOMAIN, + title="Genius hub", + data={ + CONF_TOKEN: "abcdef", + }, + ) diff --git a/tests/components/geniushub/test_config_flow.py b/tests/components/geniushub/test_config_flow.py new file mode 100644 index 000000000000..9234e03e35a2 --- /dev/null +++ b/tests/components/geniushub/test_config_flow.py @@ -0,0 +1,482 @@ +"""Test the Geniushub config flow.""" + +from http import HTTPStatus +import socket +from typing import Any +from unittest.mock import AsyncMock + +from aiohttp import ClientConnectionError, ClientResponseError +import pytest + +from homeassistant.components.geniushub import DOMAIN +from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER +from homeassistant.const import ( + CONF_HOST, + CONF_MAC, + CONF_PASSWORD, + CONF_TOKEN, + CONF_USERNAME, +) +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from tests.common import MockConfigEntry + + +async def test_full_local_flow( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_geniushub_client: AsyncMock, +) -> None: + """Test full local flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"next_step_id": "local_api"}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "local_api" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: "10.0.0.130", + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + }, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "10.0.0.130" + assert result["data"] == { + CONF_HOST: "10.0.0.130", + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + } + assert result["result"].unique_id == "aa:bb:cc:dd:ee:ff" + + +@pytest.mark.parametrize( + ("exception", "error"), + [ + (socket.gaierror, "invalid_host"), + ( + ClientResponseError(AsyncMock(), (), status=HTTPStatus.UNAUTHORIZED), + "invalid_auth", + ), + ( + ClientResponseError(AsyncMock(), (), status=HTTPStatus.NOT_FOUND), + "invalid_host", + ), + (TimeoutError, "cannot_connect"), + (ClientConnectionError, "cannot_connect"), + (Exception, "unknown"), + ], +) +async def test_local_flow_exceptions( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_geniushub_client: AsyncMock, + exception: Exception, + error: str, +) -> None: + """Test local flow exceptions.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"next_step_id": "local_api"}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "local_api" + + mock_geniushub_client.request.side_effect = exception + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: "10.0.0.130", + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + }, + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": error} + + mock_geniushub_client.request.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: "10.0.0.130", + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + + +async def test_local_duplicate_data( + hass: HomeAssistant, + mock_geniushub_client: AsyncMock, + mock_local_config_entry: MockConfigEntry, +) -> None: + """Test local flow aborts on duplicate data.""" + mock_local_config_entry.add_to_hass(hass) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"next_step_id": "local_api"}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "local_api" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: "10.0.0.130", + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + }, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_local_duplicate_mac( + hass: HomeAssistant, + mock_geniushub_client: AsyncMock, + mock_local_config_entry: MockConfigEntry, +) -> None: + """Test local flow aborts on duplicate MAC.""" + mock_local_config_entry.add_to_hass(hass) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"next_step_id": "local_api"}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "local_api" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: "10.0.0.131", + CONF_USERNAME: "test-username1", + CONF_PASSWORD: "test-password", + }, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_full_cloud_flow( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_geniushub_client: AsyncMock, +) -> None: + """Test full cloud flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"next_step_id": "cloud_api"}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "cloud_api" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_TOKEN: "abcdef", + }, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Genius hub" + assert result["data"] == { + CONF_TOKEN: "abcdef", + } + + +@pytest.mark.parametrize( + ("exception", "error"), + [ + (socket.gaierror, "invalid_host"), + ( + ClientResponseError(AsyncMock(), (), status=HTTPStatus.UNAUTHORIZED), + "invalid_auth", + ), + ( + ClientResponseError(AsyncMock(), (), status=HTTPStatus.NOT_FOUND), + "invalid_host", + ), + (TimeoutError, "cannot_connect"), + (ClientConnectionError, "cannot_connect"), + (Exception, "unknown"), + ], +) +async def test_cloud_flow_exceptions( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_geniushub_client: AsyncMock, + exception: Exception, + error: str, +) -> None: + """Test cloud flow exceptions.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"next_step_id": "cloud_api"}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "cloud_api" + + mock_geniushub_client.request.side_effect = exception + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_TOKEN: "abcdef", + }, + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": error} + + mock_geniushub_client.request.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_TOKEN: "abcdef", + }, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + + +async def test_cloud_duplicate( + hass: HomeAssistant, + mock_geniushub_client: AsyncMock, + mock_cloud_config_entry: MockConfigEntry, +) -> None: + """Test cloud flow aborts on duplicate data.""" + mock_cloud_config_entry.add_to_hass(hass) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"next_step_id": "cloud_api"}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "cloud_api" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_TOKEN: "abcdef", + }, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +@pytest.mark.parametrize( + ("data"), + [ + { + CONF_HOST: "10.0.0.130", + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + }, + { + CONF_HOST: "10.0.0.130", + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + CONF_MAC: "aa:bb:cc:dd:ee:ff", + }, + ], +) +async def test_import_local_flow( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_geniushub_client: AsyncMock, + data: dict[str, Any], +) -> None: + """Test full local import flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data=data, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "10.0.0.130" + assert result["data"] == data + assert result["result"].unique_id == "aa:bb:cc:dd:ee:ff" + + +@pytest.mark.parametrize( + ("data"), + [ + { + CONF_TOKEN: "abcdef", + }, + { + CONF_TOKEN: "abcdef", + CONF_MAC: "aa:bb:cc:dd:ee:ff", + }, + ], +) +async def test_import_cloud_flow( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_geniushub_client: AsyncMock, + data: dict[str, Any], +) -> None: + """Test full cloud import flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data=data, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Genius hub" + assert result["data"] == data + + +@pytest.mark.parametrize( + ("data"), + [ + { + CONF_HOST: "10.0.0.130", + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + }, + { + CONF_HOST: "10.0.0.130", + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + CONF_MAC: "aa:bb:cc:dd:ee:ff", + }, + { + CONF_TOKEN: "abcdef", + }, + { + CONF_TOKEN: "abcdef", + CONF_MAC: "aa:bb:cc:dd:ee:ff", + }, + ], +) +@pytest.mark.parametrize( + ("exception", "reason"), + [ + (socket.gaierror, "invalid_host"), + ( + ClientResponseError(AsyncMock(), (), status=HTTPStatus.UNAUTHORIZED), + "invalid_auth", + ), + ( + ClientResponseError(AsyncMock(), (), status=HTTPStatus.NOT_FOUND), + "invalid_host", + ), + (TimeoutError, "cannot_connect"), + (ClientConnectionError, "cannot_connect"), + (Exception, "unknown"), + ], +) +async def test_import_flow_exceptions( + hass: HomeAssistant, + mock_geniushub_client: AsyncMock, + data: dict[str, Any], + exception: Exception, + reason: str, +) -> None: + """Test import flow exceptions.""" + mock_geniushub_client.request.side_effect = exception + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data=data, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == reason + + +@pytest.mark.parametrize( + ("data"), + [ + { + CONF_HOST: "10.0.0.130", + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + }, + { + CONF_HOST: "10.0.0.131", + CONF_USERNAME: "test-username1", + CONF_PASSWORD: "test-password", + }, + ], +) +async def test_import_flow_local_duplicate( + hass: HomeAssistant, + mock_geniushub_client: AsyncMock, + mock_local_config_entry: MockConfigEntry, + data: dict[str, Any], +) -> None: + """Test import flow aborts on local duplicate data.""" + mock_local_config_entry.add_to_hass(hass) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data=data, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_import_flow_cloud_duplicate( + hass: HomeAssistant, + mock_geniushub_client: AsyncMock, + mock_cloud_config_entry: MockConfigEntry, +) -> None: + """Test import flow aborts on cloud duplicate data.""" + mock_cloud_config_entry.add_to_hass(hass) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data={ + CONF_TOKEN: "abcdef", + }, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" From d418a4085646404b5c44a99d6133d7ecbe32aab4 Mon Sep 17 00:00:00 2001 From: Matthias Alphart Date: Sun, 21 Jul 2024 20:01:48 +0200 Subject: [PATCH 2/8] Create, update and delete KNX entities from UI / WS-commands (#104079) * knx entity CRUD - initial commit - switch * platform dependent schema * coerce empty GA-lists to None * read entity configuration from WS * use entity_id instead of unique_id for lookup * Add device support * Rename KNXEntityStore to KNXConfigStore * fix test after rename * Send schema options for creating / editing entities * Return entity_id after entity creation * remove device_class config in favour of more-info-dialog settings * refactor group address schema for custom selector * Rename GA keys and remove invalid keys from schema * fix rebase * Fix deleting devices and their entities * Validate entity schema in extra step - return validation infos * Use exception to signal validation error; return validated data * Forward validation result when editing entities * Get proper validation error message for optional GAs * Add entity validation only WS command * use ulid instead of uuid * Fix error handling for edit unknown entity * Remove unused optional group address sets from validated schema * Add optional dpt field for ga_schema * Move knx config things to sub-key * Add light platform * async_forward_entry_setups only once * Test crate and remove devices * Test removing entities of a removed device * Test entity creation and storage * Test deleting entities * Test unsuccessful entity creation * Test updating entity data * Test get entity config * Test validate entity * Update entity data by entity_id instead of unique_id * Remove unnecessary uid unique check * remove schema_options * test fixture for entity creation * clean up group address schema class can be used to add custom serializer later * Revert: Add light platfrom * remove unused optional_ga_schema * Test GASelector * lint tests * Review * group entities before adding * fix / ignore mypy * always has_entity_name * Entity name: check for empty string when no device * use constants instead of strings in schema * Fix mypy errors for voluptuous schemas --------- Co-authored-by: Marc Mueller <30130371+cdce8p@users.noreply.github.com> --- homeassistant/components/knx/__init__.py | 49 ++- homeassistant/components/knx/config_flow.py | 2 +- homeassistant/components/knx/const.py | 13 +- .../knx/{helpers => storage}/__init__.py | 0 .../components/knx/storage/config_store.py | 145 ++++++ homeassistant/components/knx/storage/const.py | 14 + .../knx/storage/entity_store_schema.py | 94 ++++ .../knx/storage/entity_store_validation.py | 69 +++ .../knx/{helpers => storage}/keyring.py | 0 .../components/knx/storage/knx_selector.py | 81 ++++ homeassistant/components/knx/switch.py | 114 ++++- homeassistant/components/knx/validation.py | 15 + homeassistant/components/knx/websocket.py | 220 ++++++++++ tests/components/knx/README.md | 3 +- tests/components/knx/__init__.py | 6 + tests/components/knx/conftest.py | 75 +++- .../components/knx/fixtures/config_store.json | 29 ++ tests/components/knx/test_config_flow.py | 4 +- tests/components/knx/test_config_store.py | 412 ++++++++++++++++++ tests/components/knx/test_device.py | 77 ++++ tests/components/knx/test_interface_device.py | 31 +- tests/components/knx/test_knx_selectors.py | 122 ++++++ tests/components/knx/test_switch.py | 27 +- tests/components/knx/test_websocket.py | 9 +- 24 files changed, 1557 insertions(+), 54 deletions(-) rename homeassistant/components/knx/{helpers => storage}/__init__.py (100%) create mode 100644 homeassistant/components/knx/storage/config_store.py create mode 100644 homeassistant/components/knx/storage/const.py create mode 100644 homeassistant/components/knx/storage/entity_store_schema.py create mode 100644 homeassistant/components/knx/storage/entity_store_validation.py rename homeassistant/components/knx/{helpers => storage}/keyring.py (100%) create mode 100644 homeassistant/components/knx/storage/knx_selector.py create mode 100644 tests/components/knx/fixtures/config_store.json create mode 100644 tests/components/knx/test_config_store.py create mode 100644 tests/components/knx/test_device.py create mode 100644 tests/components/knx/test_knx_selectors.py diff --git a/homeassistant/components/knx/__init__.py b/homeassistant/components/knx/__init__.py index 9c64b4e1b310..f7e9b1619623 100644 --- a/homeassistant/components/knx/__init__.py +++ b/homeassistant/components/knx/__init__.py @@ -31,6 +31,7 @@ from homeassistant.core import Event, HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import discovery import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.device_registry import DeviceEntry from homeassistant.helpers.reload import async_integration_yaml_config from homeassistant.helpers.storage import STORAGE_DIR from homeassistant.helpers.typing import ConfigType @@ -62,7 +63,8 @@ from .const import ( DATA_KNX_CONFIG, DOMAIN, KNX_ADDRESS, - SUPPORTED_PLATFORMS, + SUPPORTED_PLATFORMS_UI, + SUPPORTED_PLATFORMS_YAML, TELEGRAM_LOG_DEFAULT, ) from .device import KNXInterfaceDevice @@ -90,6 +92,7 @@ from .schema import ( WeatherSchema, ) from .services import register_knx_services +from .storage.config_store import KNXConfigStore from .telegrams import STORAGE_KEY as TELEGRAMS_STORAGE_KEY, Telegrams from .websocket import register_panel @@ -190,10 +193,16 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: knx_module.exposures.append( create_knx_exposure(hass, knx_module.xknx, expose_config) ) - # always forward sensor for system entities (telegram counter, etc.) - platforms = {platform for platform in SUPPORTED_PLATFORMS if platform in config} - platforms.add(Platform.SENSOR) - await hass.config_entries.async_forward_entry_setups(entry, platforms) + await hass.config_entries.async_forward_entry_setups( + entry, + { + Platform.SENSOR, # always forward sensor for system entities (telegram counter, etc.) + *SUPPORTED_PLATFORMS_UI, # forward all platforms that support UI entity management + *{ # forward yaml-only managed platforms on demand + platform for platform in SUPPORTED_PLATFORMS_YAML if platform in config + }, + }, + ) # set up notify service for backwards compatibility - remove 2024.11 if NotifySchema.PLATFORM in config: @@ -220,15 +229,15 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: unload_ok = await hass.config_entries.async_unload_platforms( entry, - [ + { Platform.SENSOR, # always unload system entities (telegram counter, etc.) - *[ + *SUPPORTED_PLATFORMS_UI, # unload all platforms that support UI entity management + *{ # unload yaml-only managed platforms if configured platform - for platform in SUPPORTED_PLATFORMS + for platform in SUPPORTED_PLATFORMS_YAML if platform in hass.data[DATA_KNX_CONFIG] - and platform is not Platform.SENSOR - ], - ], + }, + }, ) if unload_ok: await knx_module.stop() @@ -263,6 +272,22 @@ async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: await hass.async_add_executor_job(remove_files, storage_dir, knxkeys_filename) +async def async_remove_config_entry_device( + hass: HomeAssistant, config_entry: ConfigEntry, device_entry: DeviceEntry +) -> bool: + """Remove a config entry from a device.""" + knx_module: KNXModule = hass.data[DOMAIN] + if not device_entry.identifiers.isdisjoint( + knx_module.interface_device.device_info["identifiers"] + ): + # can not remove interface device + return False + for entity in knx_module.config_store.get_entity_entries(): + if entity.device_id == device_entry.id: + await knx_module.config_store.delete_entity(entity.entity_id) + return True + + class KNXModule: """Representation of KNX Object.""" @@ -278,6 +303,7 @@ class KNXModule: self.entry = entry self.project = KNXProject(hass=hass, entry=entry) + self.config_store = KNXConfigStore(hass=hass, entry=entry) self.xknx = XKNX( connection_config=self.connection_config(), @@ -309,6 +335,7 @@ class KNXModule: async def start(self) -> None: """Start XKNX object. Connect to tunneling or Routing device.""" await self.project.load_project() + await self.config_store.load_data() await self.telegrams.load_history() await self.xknx.start() diff --git a/homeassistant/components/knx/config_flow.py b/homeassistant/components/knx/config_flow.py index 7d6443bd9ef4..2fc1f49800c8 100644 --- a/homeassistant/components/knx/config_flow.py +++ b/homeassistant/components/knx/config_flow.py @@ -62,7 +62,7 @@ from .const import ( TELEGRAM_LOG_MAX, KNXConfigEntryData, ) -from .helpers.keyring import DEFAULT_KNX_KEYRING_FILENAME, save_uploaded_knxkeys_file +from .storage.keyring import DEFAULT_KNX_KEYRING_FILENAME, save_uploaded_knxkeys_file from .validation import ia_validator, ip_v4_validator CONF_KNX_GATEWAY: Final = "gateway" diff --git a/homeassistant/components/knx/const.py b/homeassistant/components/knx/const.py index 6cec901adc79..c63a31f0bb54 100644 --- a/homeassistant/components/knx/const.py +++ b/homeassistant/components/knx/const.py @@ -127,12 +127,13 @@ class KNXConfigEntryData(TypedDict, total=False): class ColorTempModes(Enum): """Color temperature modes for config validation.""" - ABSOLUTE = "DPT-7.600" - ABSOLUTE_FLOAT = "DPT-9" - RELATIVE = "DPT-5.001" + # YAML uses Enum.name (with vol.Upper), UI uses Enum.value for lookup + ABSOLUTE = "7.600" + ABSOLUTE_FLOAT = "9" + RELATIVE = "5.001" -SUPPORTED_PLATFORMS: Final = [ +SUPPORTED_PLATFORMS_YAML: Final = { Platform.BINARY_SENSOR, Platform.BUTTON, Platform.CLIMATE, @@ -150,7 +151,9 @@ SUPPORTED_PLATFORMS: Final = [ Platform.TEXT, Platform.TIME, Platform.WEATHER, -] +} + +SUPPORTED_PLATFORMS_UI: Final = {Platform.SWITCH} # Map KNX controller modes to HA modes. This list might not be complete. CONTROLLER_MODES: Final = { diff --git a/homeassistant/components/knx/helpers/__init__.py b/homeassistant/components/knx/storage/__init__.py similarity index 100% rename from homeassistant/components/knx/helpers/__init__.py rename to homeassistant/components/knx/storage/__init__.py diff --git a/homeassistant/components/knx/storage/config_store.py b/homeassistant/components/knx/storage/config_store.py new file mode 100644 index 000000000000..7ea61e1dd3e6 --- /dev/null +++ b/homeassistant/components/knx/storage/config_store.py @@ -0,0 +1,145 @@ +"""KNX entity configuration store.""" + +from collections.abc import Callable +import logging +from typing import TYPE_CHECKING, Any, Final, TypedDict + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_PLATFORM, Platform +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.storage import Store +from homeassistant.util.ulid import ulid_now + +from ..const import DOMAIN +from .const import CONF_DATA + +if TYPE_CHECKING: + from ..knx_entity import KnxEntity + +_LOGGER = logging.getLogger(__name__) + +STORAGE_VERSION: Final = 1 +STORAGE_KEY: Final = f"{DOMAIN}/config_store.json" + +KNXPlatformStoreModel = dict[str, dict[str, Any]] # unique_id: configuration +KNXEntityStoreModel = dict[ + str, KNXPlatformStoreModel +] # platform: KNXPlatformStoreModel + + +class KNXConfigStoreModel(TypedDict): + """Represent KNX configuration store data.""" + + entities: KNXEntityStoreModel + + +class KNXConfigStore: + """Manage KNX config store data.""" + + def __init__( + self, + hass: HomeAssistant, + entry: ConfigEntry, + ) -> None: + """Initialize config store.""" + self.hass = hass + self._store = Store[KNXConfigStoreModel](hass, STORAGE_VERSION, STORAGE_KEY) + self.data = KNXConfigStoreModel(entities={}) + + # entities and async_add_entity are filled by platform setups + self.entities: dict[str, KnxEntity] = {} # unique_id as key + self.async_add_entity: dict[ + Platform, Callable[[str, dict[str, Any]], None] + ] = {} + + async def load_data(self) -> None: + """Load config store data from storage.""" + if data := await self._store.async_load(): + self.data = KNXConfigStoreModel(**data) + _LOGGER.debug( + "Loaded KNX config data from storage. %s entity platforms", + len(self.data["entities"]), + ) + + async def create_entity( + self, platform: Platform, data: dict[str, Any] + ) -> str | None: + """Create a new entity.""" + if platform not in self.async_add_entity: + raise ConfigStoreException(f"Entity platform not ready: {platform}") + unique_id = f"knx_es_{ulid_now()}" + self.async_add_entity[platform](unique_id, data) + # store data after entity was added to be sure config didn't raise exceptions + self.data["entities"].setdefault(platform, {})[unique_id] = data + await self._store.async_save(self.data) + + entity_registry = er.async_get(self.hass) + return entity_registry.async_get_entity_id(platform, DOMAIN, unique_id) + + @callback + def get_entity_config(self, entity_id: str) -> dict[str, Any]: + """Return KNX entity configuration.""" + entity_registry = er.async_get(self.hass) + if (entry := entity_registry.async_get(entity_id)) is None: + raise ConfigStoreException(f"Entity not found: {entity_id}") + try: + return { + CONF_PLATFORM: entry.domain, + CONF_DATA: self.data["entities"][entry.domain][entry.unique_id], + } + except KeyError as err: + raise ConfigStoreException(f"Entity data not found: {entity_id}") from err + + async def update_entity( + self, platform: Platform, entity_id: str, data: dict[str, Any] + ) -> None: + """Update an existing entity.""" + if platform not in self.async_add_entity: + raise ConfigStoreException(f"Entity platform not ready: {platform}") + entity_registry = er.async_get(self.hass) + if (entry := entity_registry.async_get(entity_id)) is None: + raise ConfigStoreException(f"Entity not found: {entity_id}") + unique_id = entry.unique_id + if ( + platform not in self.data["entities"] + or unique_id not in self.data["entities"][platform] + ): + raise ConfigStoreException( + f"Entity not found in storage: {entity_id} - {unique_id}" + ) + await self.entities.pop(unique_id).async_remove() + self.async_add_entity[platform](unique_id, data) + # store data after entity is added to make sure config doesn't raise exceptions + self.data["entities"][platform][unique_id] = data + await self._store.async_save(self.data) + + async def delete_entity(self, entity_id: str) -> None: + """Delete an existing entity.""" + entity_registry = er.async_get(self.hass) + if (entry := entity_registry.async_get(entity_id)) is None: + raise ConfigStoreException(f"Entity not found: {entity_id}") + try: + del self.data["entities"][entry.domain][entry.unique_id] + except KeyError as err: + raise ConfigStoreException( + f"Entity not found in {entry.domain}: {entry.unique_id}" + ) from err + try: + del self.entities[entry.unique_id] + except KeyError: + _LOGGER.warning("Entity not initialized when deleted: %s", entity_id) + entity_registry.async_remove(entity_id) + await self._store.async_save(self.data) + + def get_entity_entries(self) -> list[er.RegistryEntry]: + """Get entity_ids of all configured entities by platform.""" + return [ + entity.registry_entry + for entity in self.entities.values() + if entity.registry_entry is not None + ] + + +class ConfigStoreException(Exception): + """KNX config store exception.""" diff --git a/homeassistant/components/knx/storage/const.py b/homeassistant/components/knx/storage/const.py new file mode 100644 index 000000000000..6453b77ed3b9 --- /dev/null +++ b/homeassistant/components/knx/storage/const.py @@ -0,0 +1,14 @@ +"""Constants used in KNX config store.""" + +from typing import Final + +CONF_DATA: Final = "data" +CONF_ENTITY: Final = "entity" +CONF_DEVICE_INFO: Final = "device_info" +CONF_GA_WRITE: Final = "write" +CONF_GA_STATE: Final = "state" +CONF_GA_PASSIVE: Final = "passive" +CONF_DPT: Final = "dpt" + + +CONF_GA_SWITCH: Final = "ga_switch" diff --git a/homeassistant/components/knx/storage/entity_store_schema.py b/homeassistant/components/knx/storage/entity_store_schema.py new file mode 100644 index 000000000000..e2f9e786300e --- /dev/null +++ b/homeassistant/components/knx/storage/entity_store_schema.py @@ -0,0 +1,94 @@ +"""KNX entity store schema.""" + +import voluptuous as vol + +from homeassistant.const import ( + CONF_ENTITY_CATEGORY, + CONF_ENTITY_ID, + CONF_NAME, + CONF_PLATFORM, + Platform, +) +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.entity import ENTITY_CATEGORIES_SCHEMA +from homeassistant.helpers.typing import VolDictType, VolSchemaType + +from ..const import ( + CONF_INVERT, + CONF_RESPOND_TO_READ, + CONF_SYNC_STATE, + DOMAIN, + SUPPORTED_PLATFORMS_UI, +) +from ..validation import sync_state_validator +from .const import CONF_DATA, CONF_DEVICE_INFO, CONF_ENTITY, CONF_GA_SWITCH +from .knx_selector import GASelector + +BASE_ENTITY_SCHEMA = vol.All( + { + vol.Optional(CONF_NAME, default=None): vol.Maybe(str), + vol.Optional(CONF_DEVICE_INFO, default=None): vol.Maybe(str), + vol.Optional(CONF_ENTITY_CATEGORY, default=None): vol.Any( + ENTITY_CATEGORIES_SCHEMA, vol.SetTo(None) + ), + }, + vol.Any( + vol.Schema( + { + vol.Required(CONF_NAME): vol.All(str, vol.IsTrue()), + }, + extra=vol.ALLOW_EXTRA, + ), + vol.Schema( + { + vol.Required(CONF_DEVICE_INFO): str, + }, + extra=vol.ALLOW_EXTRA, + ), + msg="One of `Device` or `Name` is required", + ), +) + +SWITCH_SCHEMA = vol.Schema( + { + vol.Required(CONF_ENTITY): BASE_ENTITY_SCHEMA, + vol.Required(DOMAIN): { + vol.Optional(CONF_INVERT, default=False): bool, + vol.Required(CONF_GA_SWITCH): GASelector(write_required=True), + vol.Optional(CONF_RESPOND_TO_READ, default=False): bool, + vol.Optional(CONF_SYNC_STATE, default=True): sync_state_validator, + }, + } +) + + +ENTITY_STORE_DATA_SCHEMA: VolSchemaType = vol.All( + vol.Schema( + { + vol.Required(CONF_PLATFORM): vol.All( + vol.Coerce(Platform), + vol.In(SUPPORTED_PLATFORMS_UI), + ), + vol.Required(CONF_DATA): dict, + }, + extra=vol.ALLOW_EXTRA, + ), + cv.key_value_schemas( + CONF_PLATFORM, + { + Platform.SWITCH: vol.Schema( + {vol.Required(CONF_DATA): SWITCH_SCHEMA}, extra=vol.ALLOW_EXTRA + ), + }, + ), +) + +CREATE_ENTITY_BASE_SCHEMA: VolDictType = { + vol.Required(CONF_PLATFORM): str, + vol.Required(CONF_DATA): dict, # validated by ENTITY_STORE_DATA_SCHEMA for platform +} + +UPDATE_ENTITY_BASE_SCHEMA = { + vol.Required(CONF_ENTITY_ID): str, + **CREATE_ENTITY_BASE_SCHEMA, +} diff --git a/homeassistant/components/knx/storage/entity_store_validation.py b/homeassistant/components/knx/storage/entity_store_validation.py new file mode 100644 index 000000000000..e9997bd9f1a2 --- /dev/null +++ b/homeassistant/components/knx/storage/entity_store_validation.py @@ -0,0 +1,69 @@ +"""KNX Entity Store Validation.""" + +from typing import Literal, TypedDict + +import voluptuous as vol + +from .entity_store_schema import ENTITY_STORE_DATA_SCHEMA + + +class _ErrorDescription(TypedDict): + path: list[str] | None + error_message: str + error_class: str + + +class EntityStoreValidationError(TypedDict): + """Negative entity store validation result.""" + + success: Literal[False] + error_base: str + errors: list[_ErrorDescription] + + +class EntityStoreValidationSuccess(TypedDict): + """Positive entity store validation result.""" + + success: Literal[True] + entity_id: str | None + + +def parse_invalid(exc: vol.Invalid) -> _ErrorDescription: + """Parse a vol.Invalid exception.""" + return _ErrorDescription( + path=[str(path) for path in exc.path], # exc.path: str | vol.Required + error_message=exc.msg, + error_class=type(exc).__name__, + ) + + +def validate_entity_data(entity_data: dict) -> dict: + """Validate entity data. Return validated data or raise EntityStoreValidationException.""" + try: + # return so defaults are applied + return ENTITY_STORE_DATA_SCHEMA(entity_data) # type: ignore[no-any-return] + except vol.MultipleInvalid as exc: + raise EntityStoreValidationException( + validation_error={ + "success": False, + "error_base": str(exc), + "errors": [parse_invalid(invalid) for invalid in exc.errors], + } + ) from exc + except vol.Invalid as exc: + raise EntityStoreValidationException( + validation_error={ + "success": False, + "error_base": str(exc), + "errors": [parse_invalid(exc)], + } + ) from exc + + +class EntityStoreValidationException(Exception): + """Entity store validation exception.""" + + def __init__(self, validation_error: EntityStoreValidationError) -> None: + """Initialize.""" + super().__init__(validation_error) + self.validation_error = validation_error diff --git a/homeassistant/components/knx/helpers/keyring.py b/homeassistant/components/knx/storage/keyring.py similarity index 100% rename from homeassistant/components/knx/helpers/keyring.py rename to homeassistant/components/knx/storage/keyring.py diff --git a/homeassistant/components/knx/storage/knx_selector.py b/homeassistant/components/knx/storage/knx_selector.py new file mode 100644 index 000000000000..396cde67fbdc --- /dev/null +++ b/homeassistant/components/knx/storage/knx_selector.py @@ -0,0 +1,81 @@ +"""Selectors for KNX.""" + +from enum import Enum +from typing import Any + +import voluptuous as vol + +from ..validation import ga_validator, maybe_ga_validator +from .const import CONF_DPT, CONF_GA_PASSIVE, CONF_GA_STATE, CONF_GA_WRITE + + +class GASelector: + """Selector for a KNX group address structure.""" + + schema: vol.Schema + + def __init__( + self, + write: bool = True, + state: bool = True, + passive: bool = True, + write_required: bool = False, + state_required: bool = False, + dpt: type[Enum] | None = None, + ) -> None: + """Initialize the group address selector.""" + self.write = write + self.state = state + self.passive = passive + self.write_required = write_required + self.state_required = state_required + self.dpt = dpt + + self.schema = self.build_schema() + + def __call__(self, data: Any) -> Any: + """Validate the passed data.""" + return self.schema(data) + + def build_schema(self) -> vol.Schema: + """Create the schema based on configuration.""" + schema: dict[vol.Marker, Any] = {} # will be modified in-place + self._add_group_addresses(schema) + self._add_passive(schema) + self._add_dpt(schema) + return vol.Schema(schema) + + def _add_group_addresses(self, schema: dict[vol.Marker, Any]) -> None: + """Add basic group address items to the schema.""" + + def add_ga_item(key: str, allowed: bool, required: bool) -> None: + """Add a group address item validator to the schema.""" + if not allowed: + schema[vol.Remove(key)] = object + return + if required: + schema[vol.Required(key)] = ga_validator + else: + schema[vol.Optional(key, default=None)] = maybe_ga_validator + + add_ga_item(CONF_GA_WRITE, self.write, self.write_required) + add_ga_item(CONF_GA_STATE, self.state, self.state_required) + + def _add_passive(self, schema: dict[vol.Marker, Any]) -> None: + """Add passive group addresses validator to the schema.""" + if self.passive: + schema[vol.Optional(CONF_GA_PASSIVE, default=list)] = vol.Any( + [ga_validator], + vol.All( # Coerce `None` to an empty list if passive is allowed + vol.IsFalse(), vol.SetTo(list) + ), + ) + else: + schema[vol.Remove(CONF_GA_PASSIVE)] = object + + def _add_dpt(self, schema: dict[vol.Marker, Any]) -> None: + """Add DPT validator to the schema.""" + if self.dpt is not None: + schema[vol.Required(CONF_DPT)] = vol.In([item.value for item in self.dpt]) + else: + schema[vol.Remove(CONF_DPT)] = object diff --git a/homeassistant/components/knx/switch.py b/homeassistant/components/knx/switch.py index 096ce235e2cb..94f5592db909 100644 --- a/homeassistant/components/knx/switch.py +++ b/homeassistant/components/knx/switch.py @@ -18,14 +18,30 @@ from homeassistant.const import ( STATE_UNKNOWN, Platform, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.typing import ConfigType -from .const import CONF_RESPOND_TO_READ, DATA_KNX_CONFIG, DOMAIN, KNX_ADDRESS +from . import KNXModule +from .const import ( + CONF_INVERT, + CONF_RESPOND_TO_READ, + DATA_KNX_CONFIG, + DOMAIN, + KNX_ADDRESS, +) from .knx_entity import KnxEntity from .schema import SwitchSchema +from .storage.const import ( + CONF_DEVICE_INFO, + CONF_ENTITY, + CONF_GA_PASSIVE, + CONF_GA_STATE, + CONF_GA_SWITCH, + CONF_GA_WRITE, +) async def async_setup_entry( @@ -34,33 +50,35 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up switch(es) for KNX platform.""" - xknx: XKNX = hass.data[DOMAIN].xknx - config: list[ConfigType] = hass.data[DATA_KNX_CONFIG][Platform.SWITCH] + knx_module: KNXModule = hass.data[DOMAIN] - async_add_entities(KNXSwitch(xknx, entity_config) for entity_config in config) + entities: list[KnxEntity] = [] + if yaml_config := hass.data[DATA_KNX_CONFIG].get(Platform.SWITCH): + entities.extend( + KnxYamlSwitch(knx_module.xknx, entity_config) + for entity_config in yaml_config + ) + if ui_config := knx_module.config_store.data["entities"].get(Platform.SWITCH): + entities.extend( + KnxUiSwitch(knx_module, unique_id, config) + for unique_id, config in ui_config.items() + ) + if entities: + async_add_entities(entities) + + @callback + def add_new_ui_switch(unique_id: str, config: dict[str, Any]) -> None: + """Add KNX entity at runtime.""" + async_add_entities([KnxUiSwitch(knx_module, unique_id, config)]) + + knx_module.config_store.async_add_entity[Platform.SWITCH] = add_new_ui_switch -class KNXSwitch(KnxEntity, SwitchEntity, RestoreEntity): - """Representation of a KNX switch.""" +class _KnxSwitch(KnxEntity, SwitchEntity, RestoreEntity): + """Base class for a KNX switch.""" _device: XknxSwitch - def __init__(self, xknx: XKNX, config: ConfigType) -> None: - """Initialize of KNX switch.""" - super().__init__( - device=XknxSwitch( - xknx, - name=config[CONF_NAME], - group_address=config[KNX_ADDRESS], - group_address_state=config.get(SwitchSchema.CONF_STATE_ADDRESS), - respond_to_read=config[CONF_RESPOND_TO_READ], - invert=config[SwitchSchema.CONF_INVERT], - ) - ) - self._attr_entity_category = config.get(CONF_ENTITY_CATEGORY) - self._attr_device_class = config.get(CONF_DEVICE_CLASS) - self._attr_unique_id = str(self._device.switch.group_address) - async def async_added_to_hass(self) -> None: """Restore last state.""" await super().async_added_to_hass() @@ -82,3 +100,53 @@ class KNXSwitch(KnxEntity, SwitchEntity, RestoreEntity): async def async_turn_off(self, **kwargs: Any) -> None: """Turn the device off.""" await self._device.set_off() + + +class KnxYamlSwitch(_KnxSwitch): + """Representation of a KNX switch configured from YAML.""" + + def __init__(self, xknx: XKNX, config: ConfigType) -> None: + """Initialize of KNX switch.""" + super().__init__( + device=XknxSwitch( + xknx, + name=config[CONF_NAME], + group_address=config[KNX_ADDRESS], + group_address_state=config.get(SwitchSchema.CONF_STATE_ADDRESS), + respond_to_read=config[CONF_RESPOND_TO_READ], + invert=config[SwitchSchema.CONF_INVERT], + ) + ) + self._attr_entity_category = config.get(CONF_ENTITY_CATEGORY) + self._attr_device_class = config.get(CONF_DEVICE_CLASS) + self._attr_unique_id = str(self._device.switch.group_address) + + +class KnxUiSwitch(_KnxSwitch): + """Representation of a KNX switch configured from UI.""" + + _attr_has_entity_name = True + + def __init__( + self, knx_module: KNXModule, unique_id: str, config: dict[str, Any] + ) -> None: + """Initialize of KNX switch.""" + super().__init__( + device=XknxSwitch( + knx_module.xknx, + name=config[CONF_ENTITY][CONF_NAME], + group_address=config[DOMAIN][CONF_GA_SWITCH][CONF_GA_WRITE], + group_address_state=[ + config[DOMAIN][CONF_GA_SWITCH][CONF_GA_STATE], + *config[DOMAIN][CONF_GA_SWITCH][CONF_GA_PASSIVE], + ], + respond_to_read=config[DOMAIN][CONF_RESPOND_TO_READ], + invert=config[DOMAIN][CONF_INVERT], + ) + ) + self._attr_entity_category = config[CONF_ENTITY][CONF_ENTITY_CATEGORY] + self._attr_unique_id = unique_id + if device_info := config[CONF_ENTITY].get(CONF_DEVICE_INFO): + self._attr_device_info = DeviceInfo(identifiers={(DOMAIN, device_info)}) + + knx_module.config_store.entities[unique_id] = self diff --git a/homeassistant/components/knx/validation.py b/homeassistant/components/knx/validation.py index 4e56314a6770..9ed4f32c9206 100644 --- a/homeassistant/components/knx/validation.py +++ b/homeassistant/components/knx/validation.py @@ -50,12 +50,27 @@ def ga_validator(value: Any) -> str | int: return value +def maybe_ga_validator(value: Any) -> str | int | None: + """Validate a group address or None.""" + # this is a version of vol.Maybe(ga_validator) that delivers the + # error message of ga_validator if validation fails. + return ga_validator(value) if value is not None else None + + ga_list_validator = vol.All( cv.ensure_list, [ga_validator], vol.IsTrue("value must be a group address or a list containing group addresses"), ) +ga_list_validator_optional = vol.Maybe( + vol.All( + cv.ensure_list, + [ga_validator], + vol.Any(vol.IsTrue(), vol.SetTo(None)), # avoid empty lists -> None + ) +) + ia_validator = vol.Any( vol.All(str, str.strip, cv.matches_regex(IndividualAddress.ADDRESS_RE.pattern)), vol.All(vol.Coerce(int), vol.Range(min=1, max=65535)), diff --git a/homeassistant/components/knx/websocket.py b/homeassistant/components/knx/websocket.py index 0ac5a21d3339..bca1b119ef77 100644 --- a/homeassistant/components/knx/websocket.py +++ b/homeassistant/components/knx/websocket.py @@ -10,9 +10,24 @@ from xknxproject.exceptions import XknxProjectException from homeassistant.components import panel_custom, websocket_api from homeassistant.components.http import StaticPathConfig +from homeassistant.const import CONF_ENTITY_ID, CONF_PLATFORM from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.typing import UNDEFINED +from homeassistant.util.ulid import ulid_now from .const import DOMAIN +from .storage.config_store import ConfigStoreException +from .storage.const import CONF_DATA +from .storage.entity_store_schema import ( + CREATE_ENTITY_BASE_SCHEMA, + UPDATE_ENTITY_BASE_SCHEMA, +) +from .storage.entity_store_validation import ( + EntityStoreValidationException, + EntityStoreValidationSuccess, + validate_entity_data, +) from .telegrams import TelegramDict if TYPE_CHECKING: @@ -30,6 +45,13 @@ async def register_panel(hass: HomeAssistant) -> None: websocket_api.async_register_command(hass, ws_group_monitor_info) websocket_api.async_register_command(hass, ws_subscribe_telegram) websocket_api.async_register_command(hass, ws_get_knx_project) + websocket_api.async_register_command(hass, ws_validate_entity) + websocket_api.async_register_command(hass, ws_create_entity) + websocket_api.async_register_command(hass, ws_update_entity) + websocket_api.async_register_command(hass, ws_delete_entity) + websocket_api.async_register_command(hass, ws_get_entity_config) + websocket_api.async_register_command(hass, ws_get_entity_entries) + websocket_api.async_register_command(hass, ws_create_device) if DOMAIN not in hass.data.get("frontend_panels", {}): await hass.http.async_register_static_paths( @@ -213,3 +235,201 @@ def ws_subscribe_telegram( name="KNX GroupMonitor subscription", ) connection.send_result(msg["id"]) + + +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required("type"): "knx/validate_entity", + **CREATE_ENTITY_BASE_SCHEMA, + } +) +@callback +def ws_validate_entity( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict, +) -> None: + """Validate entity data.""" + try: + validate_entity_data(msg) + except EntityStoreValidationException as exc: + connection.send_result(msg["id"], exc.validation_error) + return + connection.send_result( + msg["id"], EntityStoreValidationSuccess(success=True, entity_id=None) + ) + + +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required("type"): "knx/create_entity", + **CREATE_ENTITY_BASE_SCHEMA, + } +) +@websocket_api.async_response +async def ws_create_entity( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict, +) -> None: + """Create entity in entity store and load it.""" + try: + validated_data = validate_entity_data(msg) + except EntityStoreValidationException as exc: + connection.send_result(msg["id"], exc.validation_error) + return + knx: KNXModule = hass.data[DOMAIN] + try: + entity_id = await knx.config_store.create_entity( + # use validation result so defaults are applied + validated_data[CONF_PLATFORM], + validated_data[CONF_DATA], + ) + except ConfigStoreException as err: + connection.send_error( + msg["id"], websocket_api.const.ERR_HOME_ASSISTANT_ERROR, str(err) + ) + return + connection.send_result( + msg["id"], EntityStoreValidationSuccess(success=True, entity_id=entity_id) + ) + + +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required("type"): "knx/update_entity", + **UPDATE_ENTITY_BASE_SCHEMA, + } +) +@websocket_api.async_response +async def ws_update_entity( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict, +) -> None: + """Update entity in entity store and reload it.""" + try: + validated_data = validate_entity_data(msg) + except EntityStoreValidationException as exc: + connection.send_result(msg["id"], exc.validation_error) + return + knx: KNXModule = hass.data[DOMAIN] + try: + await knx.config_store.update_entity( + validated_data[CONF_PLATFORM], + validated_data[CONF_ENTITY_ID], + validated_data[CONF_DATA], + ) + except ConfigStoreException as err: + connection.send_error( + msg["id"], websocket_api.const.ERR_HOME_ASSISTANT_ERROR, str(err) + ) + return + connection.send_result( + msg["id"], EntityStoreValidationSuccess(success=True, entity_id=None) + ) + + +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required("type"): "knx/delete_entity", + vol.Required(CONF_ENTITY_ID): str, + } +) +@websocket_api.async_response +async def ws_delete_entity( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict, +) -> None: + """Delete entity from entity store and remove it.""" + knx: KNXModule = hass.data[DOMAIN] + try: + await knx.config_store.delete_entity(msg[CONF_ENTITY_ID]) + except ConfigStoreException as err: + connection.send_error( + msg["id"], websocket_api.const.ERR_HOME_ASSISTANT_ERROR, str(err) + ) + return + connection.send_result(msg["id"]) + + +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required("type"): "knx/get_entity_entries", + } +) +@callback +def ws_get_entity_entries( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict, +) -> None: + """Get entities configured from entity store.""" + knx: KNXModule = hass.data[DOMAIN] + entity_entries = [ + entry.extended_dict for entry in knx.config_store.get_entity_entries() + ] + connection.send_result(msg["id"], entity_entries) + + +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required("type"): "knx/get_entity_config", + vol.Required(CONF_ENTITY_ID): str, + } +) +@callback +def ws_get_entity_config( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict, +) -> None: + """Get entity configuration from entity store.""" + knx: KNXModule = hass.data[DOMAIN] + try: + config_info = knx.config_store.get_entity_config(msg[CONF_ENTITY_ID]) + except ConfigStoreException as err: + connection.send_error( + msg["id"], websocket_api.const.ERR_HOME_ASSISTANT_ERROR, str(err) + ) + return + connection.send_result(msg["id"], config_info) + + +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required("type"): "knx/create_device", + vol.Required("name"): str, + vol.Optional("area_id"): str, + } +) +@callback +def ws_create_device( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict, +) -> None: + """Create a new KNX device.""" + knx: KNXModule = hass.data[DOMAIN] + identifier = f"knx_vdev_{ulid_now()}" + device_registry = dr.async_get(hass) + _device = device_registry.async_get_or_create( + config_entry_id=knx.entry.entry_id, + manufacturer="KNX", + name=msg["name"], + identifiers={(DOMAIN, identifier)}, + ) + device_registry.async_update_device( + _device.id, + area_id=msg.get("area_id") or UNDEFINED, + configuration_url=f"homeassistant://knx/entities/view?device_id={_device.id}", + ) + connection.send_result(msg["id"], _device.dict_repr) diff --git a/tests/components/knx/README.md b/tests/components/knx/README.md index 930b9e71c288..8778feb2251c 100644 --- a/tests/components/knx/README.md +++ b/tests/components/knx/README.md @@ -24,9 +24,10 @@ All outgoing telegrams are pushed to an assertion queue. Assert them in order th Asserts that no telegram was sent (assertion queue is empty). - `knx.assert_telegram_count(count: int)` Asserts that `count` telegrams were sent. -- `knx.assert_read(group_address: str)` +- `knx.assert_read(group_address: str, response: int | tuple[int, ...] | None = None)` Asserts that a GroupValueRead telegram was sent to `group_address`. The telegram will be removed from the assertion queue. + Optionally inject incoming GroupValueResponse telegram after reception to clear the value reader waiting task. This can also be done manually with `knx.receive_response`. - `knx.assert_response(group_address: str, payload: int | tuple[int, ...])` Asserts that a GroupValueResponse telegram with `payload` was sent to `group_address`. The telegram will be removed from the assertion queue. diff --git a/tests/components/knx/__init__.py b/tests/components/knx/__init__.py index eaa84714dc5a..76ae91a193de 100644 --- a/tests/components/knx/__init__.py +++ b/tests/components/knx/__init__.py @@ -1 +1,7 @@ """Tests for the KNX integration.""" + +from collections.abc import Awaitable, Callable + +from homeassistant.helpers import entity_registry as er + +KnxEntityGenerator = Callable[..., Awaitable[er.RegistryEntry]] diff --git a/tests/components/knx/conftest.py b/tests/components/knx/conftest.py index cd7146b565be..749d1c4252ab 100644 --- a/tests/components/knx/conftest.py +++ b/tests/components/knx/conftest.py @@ -3,7 +3,6 @@ from __future__ import annotations import asyncio -import json from typing import Any from unittest.mock import DEFAULT, AsyncMock, Mock, patch @@ -30,13 +29,22 @@ from homeassistant.components.knx.const import ( DOMAIN as KNX_DOMAIN, ) from homeassistant.components.knx.project import STORAGE_KEY as KNX_PROJECT_STORAGE_KEY +from homeassistant.components.knx.storage.config_store import ( + STORAGE_KEY as KNX_CONFIG_STORAGE_KEY, +) +from homeassistant.const import Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.typing import ConfigType from homeassistant.setup import async_setup_component -from tests.common import MockConfigEntry, load_fixture +from . import KnxEntityGenerator -FIXTURE_PROJECT_DATA = json.loads(load_fixture("project.json", KNX_DOMAIN)) +from tests.common import MockConfigEntry, load_json_object_fixture +from tests.typing import WebSocketGenerator + +FIXTURE_PROJECT_DATA = load_json_object_fixture("project.json", KNX_DOMAIN) +FIXTURE_CONFIG_STORAGE_DATA = load_json_object_fixture("config_store.json", KNX_DOMAIN) class KNXTestKit: @@ -166,9 +174,16 @@ class KNXTestKit: telegram.payload.value.value == payload # type: ignore[attr-defined] ), f"Payload mismatch in {telegram} - Expected: {payload}" - async def assert_read(self, group_address: str) -> None: - """Assert outgoing GroupValueRead telegram. One by one in timely order.""" + async def assert_read( + self, group_address: str, response: int | tuple[int, ...] | None = None + ) -> None: + """Assert outgoing GroupValueRead telegram. One by one in timely order. + + Optionally inject incoming GroupValueResponse telegram after reception. + """ await self.assert_telegram(group_address, None, GroupValueRead) + if response is not None: + await self.receive_response(group_address, response) async def assert_response( self, group_address: str, payload: int | tuple[int, ...] @@ -280,3 +295,53 @@ def load_knxproj(hass_storage: dict[str, Any]) -> None: "version": 1, "data": FIXTURE_PROJECT_DATA, } + + +@pytest.fixture +def load_config_store(hass_storage: dict[str, Any]) -> None: + """Mock KNX config store data.""" + hass_storage[KNX_CONFIG_STORAGE_KEY] = FIXTURE_CONFIG_STORAGE_DATA + + +@pytest.fixture +async def create_ui_entity( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + hass_ws_client: WebSocketGenerator, + hass_storage: dict[str, Any], +) -> KnxEntityGenerator: + """Return a helper to create a KNX entities via WS. + + The KNX integration must be set up before using the helper. + """ + ws_client = await hass_ws_client(hass) + + async def _create_ui_entity( + platform: Platform, + knx_data: dict[str, Any], + entity_data: dict[str, Any] | None = None, + ) -> er.RegistryEntry: + """Create a KNX entity from WS with given configuration.""" + if entity_data is None: + entity_data = {"name": "Test"} + + await ws_client.send_json_auto_id( + { + "type": "knx/create_entity", + "platform": platform, + "data": { + "entity": entity_data, + "knx": knx_data, + }, + } + ) + res = await ws_client.receive_json() + assert res["success"], res + assert res["result"]["success"] is True + entity_id = res["result"]["entity_id"] + + entity = entity_registry.async_get(entity_id) + assert entity + return entity + + return _create_ui_entity diff --git a/tests/components/knx/fixtures/config_store.json b/tests/components/knx/fixtures/config_store.json new file mode 100644 index 000000000000..971b692ade13 --- /dev/null +++ b/tests/components/knx/fixtures/config_store.json @@ -0,0 +1,29 @@ +{ + "version": 1, + "minor_version": 1, + "key": "knx/config_store.json", + "data": { + "entities": { + "switch": { + "knx_es_9d97829f47f1a2a3176a7c5b4216070c": { + "entity": { + "entity_category": null, + "name": "test", + "device_info": "knx_vdev_4c80a564f5fe5da701ed293966d6384d" + }, + "knx": { + "ga_switch": { + "write": "1/1/45", + "state": "1/0/45", + "passive": [] + }, + "invert": false, + "sync_state": true, + "respond_to_read": false + } + } + }, + "light": {} + } + } +} diff --git a/tests/components/knx/test_config_flow.py b/tests/components/knx/test_config_flow.py index f12a57f97ba1..3dad9320e213 100644 --- a/tests/components/knx/test_config_flow.py +++ b/tests/components/knx/test_config_flow.py @@ -76,10 +76,10 @@ def patch_file_upload(return_value=FIXTURE_KEYRING, side_effect=None): """Patch file upload. Yields the Keyring instance (return_value).""" with ( patch( - "homeassistant.components.knx.helpers.keyring.process_uploaded_file" + "homeassistant.components.knx.storage.keyring.process_uploaded_file" ) as file_upload_mock, patch( - "homeassistant.components.knx.helpers.keyring.sync_load_keyring", + "homeassistant.components.knx.storage.keyring.sync_load_keyring", return_value=return_value, side_effect=side_effect, ), diff --git a/tests/components/knx/test_config_store.py b/tests/components/knx/test_config_store.py new file mode 100644 index 000000000000..116f4b5d8397 --- /dev/null +++ b/tests/components/knx/test_config_store.py @@ -0,0 +1,412 @@ +"""Test KNX config store.""" + +from typing import Any + +import pytest + +from homeassistant.components.knx.storage.config_store import ( + STORAGE_KEY as KNX_CONFIG_STORAGE_KEY, +) +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import KnxEntityGenerator +from .conftest import KNXTestKit + +from tests.typing import WebSocketGenerator + + +async def test_create_entity( + hass: HomeAssistant, + knx: KNXTestKit, + hass_ws_client: WebSocketGenerator, + hass_storage: dict[str, Any], + create_ui_entity: KnxEntityGenerator, +) -> None: + """Test entity creation.""" + await knx.setup_integration({}) + client = await hass_ws_client(hass) + + test_name = "Test no device" + test_entity = await create_ui_entity( + platform=Platform.SWITCH, + knx_data={"ga_switch": {"write": "1/2/3"}}, + entity_data={"name": test_name}, + ) + + # Test if entity is correctly stored in registry + await client.send_json_auto_id({"type": "knx/get_entity_entries"}) + res = await client.receive_json() + assert res["success"], res + assert res["result"] == [ + test_entity.extended_dict, + ] + # Test if entity is correctly stored in config store + test_storage_data = next( + iter( + hass_storage[KNX_CONFIG_STORAGE_KEY]["data"]["entities"]["switch"].values() + ) + ) + assert test_storage_data == { + "entity": { + "name": test_name, + "device_info": None, + "entity_category": None, + }, + "knx": { + "ga_switch": {"write": "1/2/3", "state": None, "passive": []}, + "invert": False, + "respond_to_read": False, + "sync_state": True, + }, + } + + +async def test_create_entity_error( + hass: HomeAssistant, + knx: KNXTestKit, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test unsuccessful entity creation.""" + await knx.setup_integration({}) + client = await hass_ws_client(hass) + + # create entity with invalid platform + await client.send_json_auto_id( + { + "type": "knx/create_entity", + "platform": "invalid_platform", + "data": { + "entity": {"name": "Test invalid platform"}, + "knx": {"ga_switch": {"write": "1/2/3"}}, + }, + } + ) + res = await client.receive_json() + assert res["success"], res + assert not res["result"]["success"] + assert res["result"]["errors"][0]["path"] == ["platform"] + assert res["result"]["error_base"].startswith("expected Platform or one of") + + # create entity with unsupported platform + await client.send_json_auto_id( + { + "type": "knx/create_entity", + "platform": Platform.TTS, # "tts" is not a supported platform (and is unlikely to ever be) + "data": { + "entity": {"name": "Test invalid platform"}, + "knx": {"ga_switch": {"write": "1/2/3"}}, + }, + } + ) + res = await client.receive_json() + assert res["success"], res + assert not res["result"]["success"] + assert res["result"]["errors"][0]["path"] == ["platform"] + assert res["result"]["error_base"].startswith("value must be one of") + + +async def test_update_entity( + hass: HomeAssistant, + knx: KNXTestKit, + entity_registry: er.EntityRegistry, + hass_ws_client: WebSocketGenerator, + hass_storage: dict[str, Any], + create_ui_entity: KnxEntityGenerator, +) -> None: + """Test entity update.""" + await knx.setup_integration({}) + client = await hass_ws_client(hass) + + test_entity = await create_ui_entity( + platform=Platform.SWITCH, + knx_data={"ga_switch": {"write": "1/2/3"}}, + entity_data={"name": "Test"}, + ) + test_entity_id = test_entity.entity_id + + # update entity + new_name = "Updated name" + new_ga_switch_write = "4/5/6" + await client.send_json_auto_id( + { + "type": "knx/update_entity", + "platform": Platform.SWITCH, + "entity_id": test_entity_id, + "data": { + "entity": {"name": new_name}, + "knx": {"ga_switch": {"write": new_ga_switch_write}}, + }, + } + ) + res = await client.receive_json() + assert res["success"], res + assert res["result"]["success"] + + entity = entity_registry.async_get(test_entity_id) + assert entity + assert entity.original_name == new_name + + assert ( + hass_storage[KNX_CONFIG_STORAGE_KEY]["data"]["entities"]["switch"][ + test_entity.unique_id + ]["knx"]["ga_switch"]["write"] + == new_ga_switch_write + ) + + +async def test_update_entity_error( + hass: HomeAssistant, + knx: KNXTestKit, + hass_ws_client: WebSocketGenerator, + create_ui_entity: KnxEntityGenerator, +) -> None: + """Test entity update.""" + await knx.setup_integration({}) + client = await hass_ws_client(hass) + + test_entity = await create_ui_entity( + platform=Platform.SWITCH, + knx_data={"ga_switch": {"write": "1/2/3"}}, + entity_data={"name": "Test"}, + ) + + # update unsupported platform + new_name = "Updated name" + new_ga_switch_write = "4/5/6" + await client.send_json_auto_id( + { + "type": "knx/update_entity", + "platform": Platform.TTS, + "entity_id": test_entity.entity_id, + "data": { + "entity": {"name": new_name}, + "knx": {"ga_switch": {"write": new_ga_switch_write}}, + }, + } + ) + res = await client.receive_json() + assert res["success"], res + assert not res["result"]["success"] + assert res["result"]["errors"][0]["path"] == ["platform"] + assert res["result"]["error_base"].startswith("value must be one of") + + # entity not found + await client.send_json_auto_id( + { + "type": "knx/update_entity", + "platform": Platform.SWITCH, + "entity_id": "non_existing_entity_id", + "data": { + "entity": {"name": new_name}, + "knx": {"ga_switch": {"write": new_ga_switch_write}}, + }, + } + ) + res = await client.receive_json() + assert not res["success"], res + assert res["error"]["code"] == "home_assistant_error" + assert res["error"]["message"].startswith("Entity not found:") + + # entity not in storage + await client.send_json_auto_id( + { + "type": "knx/update_entity", + "platform": Platform.SWITCH, + # `sensor` isn't yet supported, but we only have sensor entities automatically + # created with no configuration - it doesn't ,atter for the test though + "entity_id": "sensor.knx_interface_individual_address", + "data": { + "entity": {"name": new_name}, + "knx": {"ga_switch": {"write": new_ga_switch_write}}, + }, + } + ) + res = await client.receive_json() + assert not res["success"], res + assert res["error"]["code"] == "home_assistant_error" + assert res["error"]["message"].startswith("Entity not found in storage") + + +async def test_delete_entity( + hass: HomeAssistant, + knx: KNXTestKit, + entity_registry: er.EntityRegistry, + hass_ws_client: WebSocketGenerator, + hass_storage: dict[str, Any], + create_ui_entity: KnxEntityGenerator, +) -> None: + """Test entity deletion.""" + await knx.setup_integration({}) + client = await hass_ws_client(hass) + + test_entity = await create_ui_entity( + platform=Platform.SWITCH, + knx_data={"ga_switch": {"write": "1/2/3"}}, + entity_data={"name": "Test"}, + ) + test_entity_id = test_entity.entity_id + + # delete entity + await client.send_json_auto_id( + { + "type": "knx/delete_entity", + "entity_id": test_entity_id, + } + ) + res = await client.receive_json() + assert res["success"], res + + assert not entity_registry.async_get(test_entity_id) + assert not hass_storage[KNX_CONFIG_STORAGE_KEY]["data"]["entities"].get("switch") + + +async def test_delete_entity_error( + hass: HomeAssistant, + knx: KNXTestKit, + entity_registry: er.EntityRegistry, + hass_ws_client: WebSocketGenerator, + hass_storage: dict[str, Any], +) -> None: + """Test unsuccessful entity deletion.""" + await knx.setup_integration({}) + client = await hass_ws_client(hass) + + # delete unknown entity + await client.send_json_auto_id( + { + "type": "knx/delete_entity", + "entity_id": "switch.non_existing_entity", + } + ) + res = await client.receive_json() + assert not res["success"], res + assert res["error"]["code"] == "home_assistant_error" + assert res["error"]["message"].startswith("Entity not found") + + # delete entity not in config store + test_entity_id = "sensor.knx_interface_individual_address" + assert entity_registry.async_get(test_entity_id) + await client.send_json_auto_id( + { + "type": "knx/delete_entity", + "entity_id": test_entity_id, + } + ) + res = await client.receive_json() + assert not res["success"], res + assert res["error"]["code"] == "home_assistant_error" + assert res["error"]["message"].startswith("Entity not found") + + +async def test_get_entity_config( + hass: HomeAssistant, + knx: KNXTestKit, + hass_ws_client: WebSocketGenerator, + create_ui_entity: KnxEntityGenerator, +) -> None: + """Test entity config retrieval.""" + await knx.setup_integration({}) + client = await hass_ws_client(hass) + + test_entity = await create_ui_entity( + platform=Platform.SWITCH, + knx_data={"ga_switch": {"write": "1/2/3"}}, + entity_data={"name": "Test"}, + ) + + await client.send_json_auto_id( + { + "type": "knx/get_entity_config", + "entity_id": test_entity.entity_id, + } + ) + res = await client.receive_json() + assert res["success"], res + assert res["result"]["platform"] == Platform.SWITCH + assert res["result"]["data"] == { + "entity": { + "name": "Test", + "device_info": None, + "entity_category": None, + }, + "knx": { + "ga_switch": {"write": "1/2/3", "passive": [], "state": None}, + "respond_to_read": False, + "invert": False, + "sync_state": True, + }, + } + + +@pytest.mark.parametrize( + ("test_entity_id", "error_message_start"), + [ + ("switch.non_existing_entity", "Entity not found"), + ("sensor.knx_interface_individual_address", "Entity data not found"), + ], +) +async def test_get_entity_config_error( + hass: HomeAssistant, + knx: KNXTestKit, + hass_ws_client: WebSocketGenerator, + test_entity_id: str, + error_message_start: str, +) -> None: + """Test entity config retrieval errors.""" + await knx.setup_integration({}) + client = await hass_ws_client(hass) + + await client.send_json_auto_id( + { + "type": "knx/get_entity_config", + "entity_id": test_entity_id, + } + ) + res = await client.receive_json() + assert not res["success"], res + assert res["error"]["code"] == "home_assistant_error" + assert res["error"]["message"].startswith(error_message_start) + + +async def test_validate_entity( + hass: HomeAssistant, + knx: KNXTestKit, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test entity validation.""" + await knx.setup_integration({}) + client = await hass_ws_client(hass) + + await client.send_json_auto_id( + { + "type": "knx/validate_entity", + "platform": Platform.SWITCH, + "data": { + "entity": {"name": "test_name"}, + "knx": {"ga_switch": {"write": "1/2/3"}}, + }, + } + ) + res = await client.receive_json() + assert res["success"], res + assert res["result"]["success"] is True + + # invalid data + await client.send_json_auto_id( + { + "type": "knx/validate_entity", + "platform": Platform.SWITCH, + "data": { + "entity": {"name": "test_name"}, + "knx": {"ga_switch": {}}, + }, + } + ) + res = await client.receive_json() + assert res["success"], res + assert res["result"]["success"] is False + assert res["result"]["errors"][0]["path"] == ["data", "knx", "ga_switch", "write"] + assert res["result"]["errors"][0]["error_message"] == "required key not provided" + assert res["result"]["error_base"].startswith("required key not provided") diff --git a/tests/components/knx/test_device.py b/tests/components/knx/test_device.py new file mode 100644 index 000000000000..330fd854a502 --- /dev/null +++ b/tests/components/knx/test_device.py @@ -0,0 +1,77 @@ +"""Test KNX devices.""" + +from typing import Any + +from homeassistant.components.knx.const import DOMAIN +from homeassistant.components.knx.storage.config_store import ( + STORAGE_KEY as KNX_CONFIG_STORAGE_KEY, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.setup import async_setup_component + +from .conftest import KNXTestKit + +from tests.typing import WebSocketGenerator + + +async def test_create_device( + hass: HomeAssistant, + knx: KNXTestKit, + device_registry: dr.DeviceRegistry, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test device creation.""" + await knx.setup_integration({}) + client = await hass_ws_client(hass) + + await client.send_json_auto_id( + { + "type": "knx/create_device", + "name": "Test Device", + } + ) + res = await client.receive_json() + assert res["success"], res + assert res["result"]["name"] == "Test Device" + assert res["result"]["manufacturer"] == "KNX" + assert res["result"]["identifiers"] + assert res["result"]["config_entries"][0] == knx.mock_config_entry.entry_id + + device_identifier = res["result"]["identifiers"][0][1] + assert device_registry.async_get_device({(DOMAIN, device_identifier)}) + device_id = res["result"]["id"] + assert device_registry.async_get(device_id) + + +async def test_remove_device( + hass: HomeAssistant, + knx: KNXTestKit, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + hass_ws_client: WebSocketGenerator, + load_config_store: None, + hass_storage: dict[str, Any], +) -> None: + """Test device removal.""" + assert await async_setup_component(hass, "config", {}) + await knx.setup_integration({}) + client = await hass_ws_client(hass) + + await knx.assert_read("1/0/45", response=True) + + assert hass_storage[KNX_CONFIG_STORAGE_KEY]["data"]["entities"].get("switch") + test_device = device_registry.async_get_device( + {(DOMAIN, "knx_vdev_4c80a564f5fe5da701ed293966d6384d")} + ) + device_id = test_device.id + device_entities = entity_registry.entities.get_entries_for_device_id(device_id) + assert len(device_entities) == 1 + + response = await client.remove_device(device_id, knx.mock_config_entry.entry_id) + assert response["success"] + assert not device_registry.async_get_device( + {(DOMAIN, "knx_vdev_4c80a564f5fe5da701ed293966d6384d")} + ) + assert not entity_registry.entities.get_entries_for_device_id(device_id) + assert not hass_storage[KNX_CONFIG_STORAGE_KEY]["data"]["entities"].get("switch") diff --git a/tests/components/knx/test_interface_device.py b/tests/components/knx/test_interface_device.py index 6cf5d8026b9d..c21c25b6fad7 100644 --- a/tests/components/knx/test_interface_device.py +++ b/tests/components/knx/test_interface_device.py @@ -1,4 +1,4 @@ -"""Test KNX scene.""" +"""Test KNX interface device.""" from unittest.mock import patch @@ -8,12 +8,14 @@ from xknx.telegram import IndividualAddress from homeassistant.components.knx.sensor import SCAN_INTERVAL from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry as er +from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util from .conftest import KNXTestKit from tests.common import async_capture_events, async_fire_time_changed +from tests.typing import WebSocketGenerator async def test_diagnostic_entities( @@ -111,3 +113,28 @@ async def test_removed_entity( ) await hass.async_block_till_done() unregister_mock.assert_called_once() + + +async def test_remove_interface_device( + hass: HomeAssistant, + knx: KNXTestKit, + device_registry: dr.DeviceRegistry, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test device removal.""" + assert await async_setup_component(hass, "config", {}) + await knx.setup_integration({}) + client = await hass_ws_client(hass) + knx_devices = device_registry.devices.get_devices_for_config_entry_id( + knx.mock_config_entry.entry_id + ) + assert len(knx_devices) == 1 + assert knx_devices[0].name == "KNX Interface" + device_id = knx_devices[0].id + # interface device can't be removed + res = await client.remove_device(device_id, knx.mock_config_entry.entry_id) + assert not res["success"] + assert ( + res["error"]["message"] + == "Failed to remove device entry, rejected by integration" + ) diff --git a/tests/components/knx/test_knx_selectors.py b/tests/components/knx/test_knx_selectors.py new file mode 100644 index 000000000000..432a0fb9f803 --- /dev/null +++ b/tests/components/knx/test_knx_selectors.py @@ -0,0 +1,122 @@ +"""Test KNX selectors.""" + +import pytest +import voluptuous as vol + +from homeassistant.components.knx.const import ColorTempModes +from homeassistant.components.knx.storage.knx_selector import GASelector + +INVALID = "invalid" + + +@pytest.mark.parametrize( + ("selector_config", "data", "expected"), + [ + ( + {}, + {}, + {"write": None, "state": None, "passive": []}, + ), + ( + {}, + {"write": "1/2/3"}, + {"write": "1/2/3", "state": None, "passive": []}, + ), + ( + {}, + {"state": "1/2/3"}, + {"write": None, "state": "1/2/3", "passive": []}, + ), + ( + {}, + {"passive": ["1/2/3"]}, + {"write": None, "state": None, "passive": ["1/2/3"]}, + ), + ( + {}, + {"write": "1", "state": 2, "passive": ["1/2/3"]}, + {"write": "1", "state": 2, "passive": ["1/2/3"]}, + ), + ( + {"write": False}, + {"write": "1/2/3"}, + {"state": None, "passive": []}, + ), + ( + {"write": False}, + {"state": "1/2/3"}, + {"state": "1/2/3", "passive": []}, + ), + ( + {"write": False}, + {"passive": ["1/2/3"]}, + {"state": None, "passive": ["1/2/3"]}, + ), + ( + {"passive": False}, + {"passive": ["1/2/3"]}, + {"write": None, "state": None}, + ), + ( + {"passive": False}, + {"write": "1/2/3"}, + {"write": "1/2/3", "state": None}, + ), + # required keys + ( + {"write_required": True}, + {}, + INVALID, + ), + ( + {"state_required": True}, + {}, + INVALID, + ), + ( + {"write_required": True}, + {"write": "1/2/3"}, + {"write": "1/2/3", "state": None, "passive": []}, + ), + ( + {"state_required": True}, + {"state": "1/2/3"}, + {"write": None, "state": "1/2/3", "passive": []}, + ), + ( + {"write_required": True}, + {"state": "1/2/3"}, + INVALID, + ), + ( + {"state_required": True}, + {"write": "1/2/3"}, + INVALID, + ), + # dpt key + ( + {"dpt": ColorTempModes}, + {"write": "1/2/3"}, + INVALID, + ), + ( + {"dpt": ColorTempModes}, + {"write": "1/2/3", "dpt": "7.600"}, + {"write": "1/2/3", "state": None, "passive": [], "dpt": "7.600"}, + ), + ( + {"dpt": ColorTempModes}, + {"write": "1/2/3", "state": None, "passive": [], "dpt": "invalid"}, + INVALID, + ), + ], +) +def test_ga_selector(selector_config, data, expected): + """Test GASelector.""" + selector = GASelector(**selector_config) + if expected == INVALID: + with pytest.raises(vol.Invalid): + selector(data) + else: + result = selector(data) + assert result == expected diff --git a/tests/components/knx/test_switch.py b/tests/components/knx/test_switch.py index 8dce4cf9c277..bc0a6b27675f 100644 --- a/tests/components/knx/test_switch.py +++ b/tests/components/knx/test_switch.py @@ -6,9 +6,10 @@ from homeassistant.components.knx.const import ( KNX_ADDRESS, ) from homeassistant.components.knx.schema import SwitchSchema -from homeassistant.const import CONF_NAME, STATE_OFF, STATE_ON +from homeassistant.const import CONF_NAME, STATE_OFF, STATE_ON, Platform from homeassistant.core import HomeAssistant, State +from . import KnxEntityGenerator from .conftest import KNXTestKit from tests.common import mock_restore_cache @@ -146,3 +147,27 @@ async def test_switch_restore_and_respond(hass: HomeAssistant, knx) -> None: # respond to new state await knx.receive_read(_ADDRESS) await knx.assert_response(_ADDRESS, False) + + +async def test_switch_ui_create( + hass: HomeAssistant, + knx: KNXTestKit, + create_ui_entity: KnxEntityGenerator, +) -> None: + """Test creating a switch.""" + await knx.setup_integration({}) + await create_ui_entity( + platform=Platform.SWITCH, + entity_data={"name": "test"}, + knx_data={ + "ga_switch": {"write": "1/1/1", "state": "2/2/2"}, + "respond_to_read": True, + "sync_state": True, + "invert": False, + }, + ) + # created entity sends read-request to KNX bus + await knx.assert_read("2/2/2") + await knx.receive_response("2/2/2", True) + state = hass.states.get("switch.test") + assert state.state is STATE_ON diff --git a/tests/components/knx/test_websocket.py b/tests/components/knx/test_websocket.py index ca60905b0ba7..eb22bac85bc4 100644 --- a/tests/components/knx/test_websocket.py +++ b/tests/components/knx/test_websocket.py @@ -4,6 +4,7 @@ from typing import Any from unittest.mock import patch from homeassistant.components.knx import DOMAIN, KNX_ADDRESS, SwitchSchema +from homeassistant.components.knx.project import STORAGE_KEY as KNX_PROJECT_STORAGE_KEY from homeassistant.const import CONF_NAME from homeassistant.core import HomeAssistant @@ -87,6 +88,7 @@ async def test_knx_project_file_process( assert res["success"], res assert hass.data[DOMAIN].project.loaded + assert hass_storage[KNX_PROJECT_STORAGE_KEY]["data"] == _parse_result async def test_knx_project_file_process_error( @@ -126,19 +128,20 @@ async def test_knx_project_file_remove( knx: KNXTestKit, hass_ws_client: WebSocketGenerator, load_knxproj: None, + hass_storage: dict[str, Any], ) -> None: """Test knx/project_file_remove command.""" await knx.setup_integration({}) + assert hass_storage[KNX_PROJECT_STORAGE_KEY] client = await hass_ws_client(hass) assert hass.data[DOMAIN].project.loaded await client.send_json({"id": 6, "type": "knx/project_file_remove"}) - with patch("homeassistant.helpers.storage.Store.async_remove") as remove_mock: - res = await client.receive_json() - remove_mock.assert_called_once_with() + res = await client.receive_json() assert res["success"], res assert not hass.data[DOMAIN].project.loaded + assert not hass_storage.get(KNX_PROJECT_STORAGE_KEY) async def test_knx_get_project( From 94ce02f40678574be2c5c1016dfd38927e4e6320 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Sun, 21 Jul 2024 20:18:43 +0200 Subject: [PATCH 3/8] Bump renault-api to 2.0.5 (#122326) * Bump renault-api to 2.0.5 * Update requirements_all.txt * Update requirements_test_all.txt --- homeassistant/components/renault/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/renault/manifest.json b/homeassistant/components/renault/manifest.json index ffa1cd6acef1..6691921e8506 100644 --- a/homeassistant/components/renault/manifest.json +++ b/homeassistant/components/renault/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "loggers": ["renault_api"], "quality_scale": "platinum", - "requirements": ["renault-api==0.2.4"] + "requirements": ["renault-api==0.2.5"] } diff --git a/requirements_all.txt b/requirements_all.txt index 5203599a3681..2dd241827077 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2462,7 +2462,7 @@ refoss-ha==1.2.4 regenmaschine==2024.03.0 # homeassistant.components.renault -renault-api==0.2.4 +renault-api==0.2.5 # homeassistant.components.renson renson-endura-delta==1.7.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 449c95e88e40..84cf24875452 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1935,7 +1935,7 @@ refoss-ha==1.2.4 regenmaschine==2024.03.0 # homeassistant.components.renault -renault-api==0.2.4 +renault-api==0.2.5 # homeassistant.components.renson renson-endura-delta==1.7.1 From 7e1fb88e4ef04a6a4da16032cf1b9862e708aa60 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sun, 21 Jul 2024 20:55:02 +0200 Subject: [PATCH 4/8] Post merge review for Feedreader (#122327) * remove unneccessary typing * assert type list while type checking * remove summary, since feedparser parse it already into content * add further tests --- .../components/feedreader/coordinator.py | 6 +++- homeassistant/components/feedreader/event.py | 4 +-- tests/components/feedreader/conftest.py | 12 ++++++++ .../feedreader/fixtures/feedreader7.xml | 11 +++++++ .../feedreader/fixtures/feedreader8.xml | 21 ++++++++++++++ tests/components/feedreader/test_event.py | 14 +++++++-- tests/components/feedreader/test_init.py | 29 +++++++++++++++++++ 7 files changed, 91 insertions(+), 6 deletions(-) create mode 100644 tests/components/feedreader/fixtures/feedreader7.xml create mode 100644 tests/components/feedreader/fixtures/feedreader8.xml diff --git a/homeassistant/components/feedreader/coordinator.py b/homeassistant/components/feedreader/coordinator.py index e429979b1da1..6608c4312fe1 100644 --- a/homeassistant/components/feedreader/coordinator.py +++ b/homeassistant/components/feedreader/coordinator.py @@ -6,6 +6,7 @@ from calendar import timegm from datetime import datetime from logging import getLogger from time import gmtime, struct_time +from typing import TYPE_CHECKING from urllib.error import URLError import feedparser @@ -120,10 +121,13 @@ class FeedReaderCoordinator( len(self._feed.entries), self.url, ) - if not isinstance(self._feed.entries, list): + if not self._feed.entries: self._log_no_entries() return None + if TYPE_CHECKING: + assert isinstance(self._feed.entries, list) + self._filter_entries() self._publish_new_entries() diff --git a/homeassistant/components/feedreader/event.py b/homeassistant/components/feedreader/event.py index c9bf39e83caa..48c18c4e70db 100644 --- a/homeassistant/components/feedreader/event.py +++ b/homeassistant/components/feedreader/event.py @@ -29,7 +29,7 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up event entities for feedreader.""" - coordinator: FeedReaderCoordinator = entry.runtime_data + coordinator = entry.runtime_data async_add_entities([FeedReaderEvent(coordinator)]) @@ -76,8 +76,6 @@ class FeedReaderEvent(CoordinatorEntity[FeedReaderCoordinator], EventEntity): if content := feed_data.get("content"): if isinstance(content, list) and isinstance(content[0], dict): content = content[0].get("value") - else: - content = feed_data.get("summary") self._trigger_event( EVENT_FEEDREADER, diff --git a/tests/components/feedreader/conftest.py b/tests/components/feedreader/conftest.py index 0a5342615a95..8eeb89e00cda 100644 --- a/tests/components/feedreader/conftest.py +++ b/tests/components/feedreader/conftest.py @@ -52,6 +52,18 @@ def fixture_feed_identically_timed_events(hass: HomeAssistant) -> bytes: return load_fixture_bytes("feedreader6.xml") +@pytest.fixture(name="feed_without_items") +def fixture_feed_without_items(hass: HomeAssistant) -> bytes: + """Load test feed without any items.""" + return load_fixture_bytes("feedreader7.xml") + + +@pytest.fixture(name="feed_only_summary") +def fixture_feed_only_summary(hass: HomeAssistant) -> bytes: + """Load test feed data with one event containing only a summary, no content.""" + return load_fixture_bytes("feedreader8.xml") + + @pytest.fixture(name="events") async def fixture_events(hass: HomeAssistant) -> list[Event]: """Fixture that catches alexa events.""" diff --git a/tests/components/feedreader/fixtures/feedreader7.xml b/tests/components/feedreader/fixtures/feedreader7.xml new file mode 100644 index 000000000000..0ffac8dd2eed --- /dev/null +++ b/tests/components/feedreader/fixtures/feedreader7.xml @@ -0,0 +1,11 @@ + + + + RSS Sample + This is an example of an RSS feed + http://www.example.com/main.html + Mon, 30 Apr 2018 12:00:00 +1000 + Mon, 30 Apr 2018 15:00:00 +1000 + 1800 + + diff --git a/tests/components/feedreader/fixtures/feedreader8.xml b/tests/components/feedreader/fixtures/feedreader8.xml new file mode 100644 index 000000000000..d1c167352f8b --- /dev/null +++ b/tests/components/feedreader/fixtures/feedreader8.xml @@ -0,0 +1,21 @@ + + + + RSS Sample + This is an example of an RSS feed + http://www.example.com/main.html + Mon, 30 Apr 2018 12:00:00 +1000 + Mon, 30 Apr 2018 15:00:00 +1000 + 1800 + + + Title 1 + Description 1 + http://www.example.com/link/1 + GUID 1 + Mon, 30 Apr 2018 15:10:00 +1000 + This is a summary + + + + diff --git a/tests/components/feedreader/test_event.py b/tests/components/feedreader/test_event.py index 23fec371860f..5d903383c056 100644 --- a/tests/components/feedreader/test_event.py +++ b/tests/components/feedreader/test_event.py @@ -18,14 +18,14 @@ from tests.common import async_fire_time_changed async def test_event_entity( - hass: HomeAssistant, feed_one_event, feed_two_event + hass: HomeAssistant, feed_one_event, feed_two_event, feed_only_summary ) -> None: """Test feed event entity.""" entry = create_mock_entry(VALID_CONFIG_DEFAULT) entry.add_to_hass(hass) with patch( "homeassistant.components.feedreader.coordinator.feedparser.http.get", - side_effect=[feed_one_event, feed_two_event], + side_effect=[feed_one_event, feed_two_event, feed_only_summary], ): assert await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() @@ -45,3 +45,13 @@ async def test_event_entity( assert state.attributes[ATTR_TITLE] == "Title 2" assert state.attributes[ATTR_LINK] == "http://www.example.com/link/2" assert state.attributes[ATTR_CONTENT] == "Content 2" + + future = dt_util.utcnow() + timedelta(hours=2, seconds=2) + async_fire_time_changed(hass, future) + await hass.async_block_till_done(wait_background_tasks=True) + + state = hass.states.get("event.mock_title") + assert state + assert state.attributes[ATTR_TITLE] == "Title 1" + assert state.attributes[ATTR_LINK] == "http://www.example.com/link/1" + assert state.attributes[ATTR_CONTENT] == "This is a summary" diff --git a/tests/components/feedreader/test_init.py b/tests/components/feedreader/test_init.py index 61e3f13ced7c..d7700d79e3b2 100644 --- a/tests/components/feedreader/test_init.py +++ b/tests/components/feedreader/test_init.py @@ -165,6 +165,21 @@ async def test_feed_identical_timestamps( ) +async def test_feed_with_only_summary( + hass: HomeAssistant, events, feed_only_summary +) -> None: + """Test simple feed with only summary, no content.""" + assert await async_setup_config_entry( + hass, VALID_CONFIG_DEFAULT, return_value=feed_only_summary + ) + await hass.async_block_till_done() + + assert len(events) == 1 + assert events[0].data.title == "Title 1" + assert events[0].data.description == "Description 1" + assert events[0].data.content[0].value == "This is a summary" + + async def test_feed_updates( hass: HomeAssistant, events, feed_one_event, feed_two_event ) -> None: @@ -247,6 +262,20 @@ async def test_feed_with_unrecognized_publication_date( assert len(events) == 1 +async def test_feed_without_items( + hass: HomeAssistant, events, feed_without_items, caplog: pytest.LogCaptureFixture +) -> None: + """Test simple feed without any items.""" + assert "No new entries to be published in feed" not in caplog.text + assert await async_setup_config_entry( + hass, VALID_CONFIG_DEFAULT, return_value=feed_without_items + ) + await hass.async_block_till_done() + + assert "No new entries to be published in feed" in caplog.text + assert len(events) == 0 + + async def test_feed_invalid_data(hass: HomeAssistant, events) -> None: """Test feed with invalid data.""" assert await async_setup_config_entry( From 7d468908043ae5469258b0d5fe6c1cbf199eb3ac Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sun, 21 Jul 2024 20:57:49 +0200 Subject: [PATCH 5/8] Add support for grouping notify entities (#122123) * Add support for grouping notify entities * Add support for grouping notify entities * Add support for grouping notify entities * Fix test * Fix feedback * Update homeassistant/components/group/notify.py Co-authored-by: TheJulianJES * Test config flow changes * Test config flow changes --------- Co-authored-by: TheJulianJES --- homeassistant/components/group/config_flow.py | 12 ++ homeassistant/components/group/notify.py | 87 ++++++++- homeassistant/components/group/strings.json | 15 ++ tests/components/group/test_config_flow.py | 7 + tests/components/group/test_notify.py | 173 +++++++++++++++++- 5 files changed, 289 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/group/config_flow.py b/homeassistant/components/group/config_flow.py index 54ef7d0626f6..ee8d11d035dd 100644 --- a/homeassistant/components/group/config_flow.py +++ b/homeassistant/components/group/config_flow.py @@ -32,6 +32,7 @@ from .fan import async_create_preview_fan from .light import async_create_preview_light from .lock import async_create_preview_lock from .media_player import MediaPlayerGroup, async_create_preview_media_player +from .notify import async_create_preview_notify from .sensor import async_create_preview_sensor from .switch import async_create_preview_switch @@ -154,6 +155,7 @@ GROUP_TYPES = [ "light", "lock", "media_player", + "notify", "sensor", "switch", ] @@ -222,6 +224,11 @@ CONFIG_FLOW = { preview="group", validate_user_input=set_group_type("media_player"), ), + "notify": SchemaFlowFormStep( + basic_group_config_schema("notify"), + preview="group", + validate_user_input=set_group_type("notify"), + ), "sensor": SchemaFlowFormStep( SENSOR_CONFIG_SCHEMA, preview="group", @@ -269,6 +276,10 @@ OPTIONS_FLOW = { partial(basic_group_options_schema, "media_player"), preview="group", ), + "notify": SchemaFlowFormStep( + partial(basic_group_options_schema, "notify"), + preview="group", + ), "sensor": SchemaFlowFormStep( partial(sensor_options_schema, "sensor"), preview="group", @@ -293,6 +304,7 @@ CREATE_PREVIEW_ENTITY: dict[ "light": async_create_preview_light, "lock": async_create_preview_lock, "media_player": async_create_preview_media_player, + "notify": async_create_preview_notify, "sensor": async_create_preview_sensor, "switch": async_create_preview_switch, } diff --git a/homeassistant/components/group/notify.py b/homeassistant/components/group/notify.py index 444658a6112e..8294b55be5ef 100644 --- a/homeassistant/components/group/notify.py +++ b/homeassistant/components/group/notify.py @@ -12,15 +12,28 @@ import voluptuous as vol from homeassistant.components.notify import ( ATTR_DATA, ATTR_MESSAGE, + ATTR_TITLE, DOMAIN, PLATFORM_SCHEMA as NOTIFY_PLATFORM_SCHEMA, + SERVICE_SEND_MESSAGE, BaseNotificationService, + NotifyEntity, ) -from homeassistant.const import ATTR_SERVICE -from homeassistant.core import HomeAssistant +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + ATTR_ENTITY_ID, + ATTR_SERVICE, + CONF_ENTITIES, + STATE_UNAVAILABLE, +) +from homeassistant.core import HomeAssistant, callback import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity_platform import AddEntitiesCallback +import homeassistant.helpers.entity_registry as er from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from .entity import GroupEntity + CONF_SERVICES = "services" PLATFORM_SCHEMA = NOTIFY_PLATFORM_SCHEMA.extend( @@ -82,3 +95,73 @@ class GroupNotifyPlatform(BaseNotificationService): if tasks: await asyncio.wait(tasks) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Initialize Notify Group config entry.""" + registry = er.async_get(hass) + entities = er.async_validate_entity_ids( + registry, config_entry.options[CONF_ENTITIES] + ) + + async_add_entities( + [NotifyGroup(config_entry.entry_id, config_entry.title, entities)] + ) + + +@callback +def async_create_preview_notify( + hass: HomeAssistant, name: str, validated_config: dict[str, Any] +) -> NotifyGroup: + """Create a preview notify group.""" + return NotifyGroup( + None, + name, + validated_config[CONF_ENTITIES], + ) + + +class NotifyGroup(GroupEntity, NotifyEntity): + """Representation of a NotifyGroup.""" + + _attr_available: bool = False + + def __init__( + self, + unique_id: str | None, + name: str, + entity_ids: list[str], + ) -> None: + """Initialize a NotifyGroup.""" + self._entity_ids = entity_ids + self._attr_name = name + self._attr_extra_state_attributes = {ATTR_ENTITY_ID: entity_ids} + self._attr_unique_id = unique_id + + async def async_send_message(self, message: str, title: str | None = None) -> None: + """Send a message to all members of the group.""" + await self.hass.services.async_call( + DOMAIN, + SERVICE_SEND_MESSAGE, + { + ATTR_MESSAGE: message, + ATTR_TITLE: title, + ATTR_ENTITY_ID: self._entity_ids, + }, + blocking=True, + context=self._context, + ) + + @callback + def async_update_group_state(self) -> None: + """Query all members and determine the notify group state.""" + # Set group as unavailable if all members are unavailable or missing + self._attr_available = any( + state.state != STATE_UNAVAILABLE + for entity_id in self._entity_ids + if (state := self.hass.states.get(entity_id)) is not None + ) diff --git a/homeassistant/components/group/strings.json b/homeassistant/components/group/strings.json index dc850804d947..dbb6fb01f7b0 100644 --- a/homeassistant/components/group/strings.json +++ b/homeassistant/components/group/strings.json @@ -14,6 +14,7 @@ "light": "Light group", "lock": "Lock group", "media_player": "Media player group", + "notify": "Notify group", "sensor": "Sensor group", "switch": "Switch group" } @@ -84,6 +85,14 @@ "name": "[%key:common::config_flow::data::name%]" } }, + "notify": { + "title": "[%key:component::group::config::step::user::title%]", + "data": { + "entities": "[%key:component::group::config::step::binary_sensor::data::entities%]", + "hide_members": "[%key:component::group::config::step::binary_sensor::data::hide_members%]", + "name": "[%key:common::config_flow::data::name%]" + } + }, "sensor": { "title": "[%key:component::group::config::step::user::title%]", "data": { @@ -156,6 +165,12 @@ "hide_members": "[%key:component::group::config::step::binary_sensor::data::hide_members%]" } }, + "notify": { + "data": { + "entities": "[%key:component::group::config::step::binary_sensor::data::entities%]", + "hide_members": "[%key:component::group::config::step::binary_sensor::data::hide_members%]" + } + }, "sensor": { "description": "If \"ignore non-numeric\" is enabled, the group's state is calculated if at least one member has a numerical value. If \"ignore non-numeric\" is disabled, the group's state is calculated only if all group members have numerical values.", "data": { diff --git a/tests/components/group/test_config_flow.py b/tests/components/group/test_config_flow.py index dc40b647e2e4..461df19ebf80 100644 --- a/tests/components/group/test_config_flow.py +++ b/tests/components/group/test_config_flow.py @@ -46,6 +46,7 @@ from tests.typing import WebSocketGenerator ("fan", "on", "on", {}, {}, {}, {}), ("light", "on", "on", {}, {}, {}, {}), ("lock", "locked", "locked", {}, {}, {}, {}), + ("notify", STATE_UNKNOWN, "2021-01-01T23:59:59.123+00:00", {}, {}, {}, {}), ("media_player", "on", "on", {}, {}, {}, {}), ( "sensor", @@ -142,6 +143,7 @@ async def test_config_flow( ("fan", {}), ("light", {}), ("lock", {}), + ("notify", {}), ("media_player", {}), ("switch", {}), ], @@ -220,6 +222,7 @@ def get_suggested(schema, key): ("fan", "on", {}, {}), ("light", "on", {"all": False}, {}), ("lock", "locked", {}, {}), + ("notify", "2021-01-01T23:59:59.123+00:00", {}, {}), ("media_player", "on", {}, {}), ( "sensor", @@ -405,6 +408,7 @@ async def test_all_options( ("fan", {}), ("light", {}), ("lock", {}), + ("notify", {}), ("media_player", {}), ("switch", {}), ], @@ -487,6 +491,7 @@ LIGHT_ATTRS = [ {"color_mode": "unknown"}, ] LOCK_ATTRS = [{"supported_features": 1}, {}] +NOTIFY_ATTRS = [{"supported_features": 0}, {}] MEDIA_PLAYER_ATTRS = [{"supported_features": 0}, {}] SENSOR_ATTRS = [{"icon": "mdi:calculator"}, {"max_entity_id": "sensor.input_two"}] @@ -501,6 +506,7 @@ SENSOR_ATTRS = [{"icon": "mdi:calculator"}, {"max_entity_id": "sensor.input_two" ("fan", {}, ["on", "off"], "on", FAN_ATTRS), ("light", {}, ["on", "off"], "on", LIGHT_ATTRS), ("lock", {}, ["unlocked", "locked"], "unlocked", LOCK_ATTRS), + ("notify", {}, ["", ""], "unknown", NOTIFY_ATTRS), ("media_player", {}, ["on", "off"], "on", MEDIA_PLAYER_ATTRS), ("sensor", {"type": "max"}, ["10", "20"], "20.0", SENSOR_ATTRS), ("switch", {}, ["on", "off"], "on", [{}, {}]), @@ -611,6 +617,7 @@ async def test_config_flow_preview( ("fan", {}, {}, ["on", "off"], "on", FAN_ATTRS), ("light", {}, {}, ["on", "off"], "on", LIGHT_ATTRS), ("lock", {}, {}, ["unlocked", "locked"], "unlocked", LOCK_ATTRS), + ("notify", {}, {}, ["", ""], "unknown", NOTIFY_ATTRS), ("media_player", {}, {}, ["on", "off"], "on", MEDIA_PLAYER_ATTRS), ( "sensor", diff --git a/tests/components/group/test_notify.py b/tests/components/group/test_notify.py index dfd200a1542f..2595b211dae8 100644 --- a/tests/components/group/test_notify.py +++ b/tests/components/group/test_notify.py @@ -1,18 +1,44 @@ """The tests for the notify.group platform.""" -from collections.abc import Mapping +from collections.abc import Generator, Mapping from pathlib import Path from typing import Any from unittest.mock import MagicMock, call, patch +import pytest + from homeassistant import config as hass_config from homeassistant.components import notify -from homeassistant.components.group import SERVICE_RELOAD +from homeassistant.components.group import DOMAIN, SERVICE_RELOAD +from homeassistant.components.notify import ( + ATTR_MESSAGE, + ATTR_TITLE, + DOMAIN as NOTIFY_DOMAIN, + SERVICE_SEND_MESSAGE, + NotifyEntity, +) +from homeassistant.config_entries import ConfigEntry, ConfigFlow +from homeassistant.const import ( + ATTR_ENTITY_ID, + STATE_UNAVAILABLE, + STATE_UNKNOWN, + Platform, +) from homeassistant.core import HomeAssistant from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.setup import async_setup_component -from tests.common import MockPlatform, get_fixture_path, mock_platform +from tests.common import ( + MockConfigEntry, + MockEntity, + MockModule, + MockPlatform, + get_fixture_path, + mock_config_flow, + mock_integration, + mock_platform, + setup_test_component_platform, +) class MockNotifyPlatform(MockPlatform): @@ -217,3 +243,144 @@ async def test_reload_notify(hass: HomeAssistant, tmp_path: Path) -> None: assert hass.services.has_service(notify.DOMAIN, "test_service2") assert not hass.services.has_service(notify.DOMAIN, "group_notify") assert hass.services.has_service(notify.DOMAIN, "new_group_notify") + + +class MockFlow(ConfigFlow): + """Test flow.""" + + +@pytest.fixture +def config_flow_fixture(hass: HomeAssistant) -> Generator[None]: + """Mock config flow.""" + mock_platform(hass, "test.config_flow") + + with mock_config_flow("test", MockFlow): + yield + + +class MockNotifyEntity(MockEntity, NotifyEntity): + """Mock Email notifier entity to use in tests.""" + + def __init__(self, **values: Any) -> None: + """Initialize the mock entity.""" + super().__init__(**values) + self.send_message_mock_calls = MagicMock() + + async def async_send_message(self, message: str, title: str | None = None) -> None: + """Send a notification message.""" + self.send_message_mock_calls(message, title=title) + + +async def help_async_setup_entry_init( + hass: HomeAssistant, config_entry: ConfigEntry +) -> bool: + """Set up test config entry.""" + await hass.config_entries.async_forward_entry_setups( + config_entry, [Platform.NOTIFY] + ) + return True + + +async def help_async_unload_entry( + hass: HomeAssistant, config_entry: ConfigEntry +) -> bool: + """Unload test config entry.""" + return await hass.config_entries.async_unload_platforms( + config_entry, [Platform.NOTIFY] + ) + + +@pytest.fixture +async def mock_notifiers( + hass: HomeAssistant, config_flow_fixture: None +) -> list[NotifyEntity]: + """Set up the notify entities.""" + entity = MockNotifyEntity(name="test", entity_id="notify.test") + entity2 = MockNotifyEntity(name="test2", entity_id="notify.test2") + entities = [entity, entity2] + test_entry = MockConfigEntry(domain="test") + test_entry.add_to_hass(hass) + mock_integration( + hass, + MockModule( + "test", + async_setup_entry=help_async_setup_entry_init, + async_unload_entry=help_async_unload_entry, + ), + ) + setup_test_component_platform(hass, NOTIFY_DOMAIN, entities, from_config_entry=True) + assert await hass.config_entries.async_setup(test_entry.entry_id) + await hass.async_block_till_done() + return entities + + +async def test_notify_entity_group( + hass: HomeAssistant, mock_notifiers: list[NotifyEntity] +) -> None: + """Test sending a message to a notify group.""" + entity, entity2 = mock_notifiers + assert entity.send_message_mock_calls.call_count == 0 + assert entity2.send_message_mock_calls.call_count == 0 + + config_entry = MockConfigEntry( + domain=DOMAIN, + options={ + "group_type": "notify", + "name": "Test Group", + "entities": ["notify.test", "notify.test2"], + "hide_members": True, + }, + title="Test Group", + ) + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + await hass.services.async_call( + NOTIFY_DOMAIN, + SERVICE_SEND_MESSAGE, + { + ATTR_MESSAGE: "Hello", + ATTR_TITLE: "Test notification", + ATTR_ENTITY_ID: "notify.test_group", + }, + blocking=True, + ) + + assert entity.send_message_mock_calls.call_count == 1 + assert entity.send_message_mock_calls.call_args == call( + "Hello", title="Test notification" + ) + assert entity2.send_message_mock_calls.call_count == 1 + assert entity2.send_message_mock_calls.call_args == call( + "Hello", title="Test notification" + ) + + +async def test_state_reporting(hass: HomeAssistant) -> None: + """Test sending a message to a notify group.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + options={ + "group_type": "notify", + "name": "Test Group", + "entities": ["notify.test", "notify.test2"], + "hide_members": True, + }, + title="Test Group", + ) + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert hass.states.get("notify.test_group").state == STATE_UNAVAILABLE + + hass.states.async_set("notify.test", STATE_UNAVAILABLE) + hass.states.async_set("notify.test2", STATE_UNAVAILABLE) + await hass.async_block_till_done() + assert hass.states.get("notify.test_group").state == STATE_UNAVAILABLE + + hass.states.async_set("notify.test", "2021-01-01T23:59:59.123+00:00") + hass.states.async_set("notify.test2", "2021-01-01T23:59:59.123+00:00") + await hass.async_block_till_done() + assert hass.states.get("notify.test_group").state == STATE_UNKNOWN From 453848bcdc1ae0a05263d3e36354d25ac470d65e Mon Sep 17 00:00:00 2001 From: Lorzware Date: Sun, 21 Jul 2024 22:03:41 +0200 Subject: [PATCH 6/8] APSystems - add configuration option 'port' in config flow (#122144) * Add configuration option 'port' in config flow --- .../components/apsystems/__init__.py | 9 +++- .../components/apsystems/config_flow.py | 14 ++++-- homeassistant/components/apsystems/const.py | 1 + .../components/apsystems/strings.json | 6 ++- .../components/apsystems/test_config_flow.py | 47 ++++++++++++++++++- 5 files changed, 69 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/apsystems/__init__.py b/homeassistant/components/apsystems/__init__.py index 2df267dda0ba..40e62a32475d 100644 --- a/homeassistant/components/apsystems/__init__.py +++ b/homeassistant/components/apsystems/__init__.py @@ -7,9 +7,10 @@ from dataclasses import dataclass from APsystemsEZ1 import APsystemsEZ1M from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_IP_ADDRESS, Platform +from homeassistant.const import CONF_IP_ADDRESS, CONF_PORT, Platform from homeassistant.core import HomeAssistant +from .const import DEFAULT_PORT from .coordinator import ApSystemsDataCoordinator PLATFORMS: list[Platform] = [Platform.NUMBER, Platform.SENSOR] @@ -28,7 +29,11 @@ type ApSystemsConfigEntry = ConfigEntry[ApSystemsData] async def async_setup_entry(hass: HomeAssistant, entry: ApSystemsConfigEntry) -> bool: """Set up this integration using UI.""" - api = APsystemsEZ1M(ip_address=entry.data[CONF_IP_ADDRESS], timeout=8) + api = APsystemsEZ1M( + ip_address=entry.data[CONF_IP_ADDRESS], + port=entry.data.get(CONF_PORT, DEFAULT_PORT), + timeout=8, + ) coordinator = ApSystemsDataCoordinator(hass, api) await coordinator.async_config_entry_first_refresh() assert entry.unique_id diff --git a/homeassistant/components/apsystems/config_flow.py b/homeassistant/components/apsystems/config_flow.py index f49237ce4504..5f2f1393aa04 100644 --- a/homeassistant/components/apsystems/config_flow.py +++ b/homeassistant/components/apsystems/config_flow.py @@ -7,14 +7,16 @@ from APsystemsEZ1 import APsystemsEZ1M import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult -from homeassistant.const import CONF_IP_ADDRESS +from homeassistant.const import CONF_IP_ADDRESS, CONF_PORT from homeassistant.helpers.aiohttp_client import async_get_clientsession +import homeassistant.helpers.config_validation as cv -from .const import DOMAIN +from .const import DEFAULT_PORT, DOMAIN DATA_SCHEMA = vol.Schema( { - vol.Required(CONF_IP_ADDRESS): str, + vol.Required(CONF_IP_ADDRESS): cv.string, + vol.Optional(CONF_PORT): cv.port, } ) @@ -32,7 +34,11 @@ class APsystemsLocalAPIFlow(ConfigFlow, domain=DOMAIN): if user_input is not None: session = async_get_clientsession(self.hass, False) - api = APsystemsEZ1M(user_input[CONF_IP_ADDRESS], session=session) + api = APsystemsEZ1M( + ip_address=user_input[CONF_IP_ADDRESS], + port=user_input.get(CONF_PORT, DEFAULT_PORT), + session=session, + ) try: device_info = await api.get_device_info() except (TimeoutError, ClientConnectionError): diff --git a/homeassistant/components/apsystems/const.py b/homeassistant/components/apsystems/const.py index 857652aeae83..4edf0f4122a1 100644 --- a/homeassistant/components/apsystems/const.py +++ b/homeassistant/components/apsystems/const.py @@ -4,3 +4,4 @@ from logging import Logger, getLogger LOGGER: Logger = getLogger(__package__) DOMAIN = "apsystems" +DEFAULT_PORT = 8050 diff --git a/homeassistant/components/apsystems/strings.json b/homeassistant/components/apsystems/strings.json index cfd246753115..95499e96b4d6 100644 --- a/homeassistant/components/apsystems/strings.json +++ b/homeassistant/components/apsystems/strings.json @@ -3,7 +3,11 @@ "step": { "user": { "data": { - "ip_address": "[%key:common::config_flow::data::ip%]" + "ip_address": "[%key:common::config_flow::data::ip%]", + "port": "[%key:common::config_flow::data::port%]" + }, + "data_description": { + "port": "The integration will default to 8050, if not set, which should be suitable for most installs" } } }, diff --git a/tests/components/apsystems/test_config_flow.py b/tests/components/apsystems/test_config_flow.py index e3fcdf67dcc9..3d78524a529e 100644 --- a/tests/components/apsystems/test_config_flow.py +++ b/tests/components/apsystems/test_config_flow.py @@ -4,7 +4,7 @@ from unittest.mock import AsyncMock from homeassistant.components.apsystems.const import DOMAIN from homeassistant.config_entries import SOURCE_USER -from homeassistant.const import CONF_IP_ADDRESS +from homeassistant.const import CONF_IP_ADDRESS, CONF_PORT from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -27,6 +27,24 @@ async def test_form_create_success( assert result["data"].get(CONF_IP_ADDRESS) == "127.0.0.1" +async def test_form_create_success_custom_port( + hass: HomeAssistant, mock_setup_entry: AsyncMock, mock_apsystems: AsyncMock +) -> None: + """Test we handle creating with custom port with success.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data={ + CONF_IP_ADDRESS: "127.0.0.1", + CONF_PORT: 8042, + }, + ) + assert result["result"].unique_id == "MY_SERIAL_NUMBER" + assert result.get("type") is FlowResultType.CREATE_ENTRY + assert result["data"].get(CONF_IP_ADDRESS) == "127.0.0.1" + assert result["data"].get(CONF_PORT) == 8042 + + async def test_form_cannot_connect_and_recover( hass: HomeAssistant, mock_apsystems: AsyncMock, mock_setup_entry: AsyncMock ) -> None: @@ -57,6 +75,33 @@ async def test_form_cannot_connect_and_recover( assert result2["data"].get(CONF_IP_ADDRESS) == "127.0.0.1" +async def test_form_cannot_connect_and_recover_custom_port( + hass: HomeAssistant, mock_apsystems: AsyncMock, mock_setup_entry: AsyncMock +) -> None: + """Test we handle cannot connect error but recovering with custom port.""" + + mock_apsystems.get_device_info.side_effect = TimeoutError + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data={CONF_IP_ADDRESS: "127.0.0.2", CONF_PORT: 8042}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "cannot_connect"} + + mock_apsystems.get_device_info.side_effect = None + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_IP_ADDRESS: "127.0.0.1", CONF_PORT: 8042}, + ) + assert result2["result"].unique_id == "MY_SERIAL_NUMBER" + assert result2.get("type") is FlowResultType.CREATE_ENTRY + assert result2["data"].get(CONF_IP_ADDRESS) == "127.0.0.1" + assert result2["data"].get(CONF_PORT) == 8042 + + async def test_form_unique_id_already_configured( hass: HomeAssistant, mock_setup_entry: AsyncMock, From c98c80ce69f55cfd5e138ac228e1bf495379ffd3 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Sun, 21 Jul 2024 13:37:18 -0700 Subject: [PATCH 7/8] Change OpenAI default recommended model to gpt-4o-mini (#122333) --- homeassistant/components/openai_conversation/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/openai_conversation/const.py b/homeassistant/components/openai_conversation/const.py index f362f4278a1d..e8ee003fcca5 100644 --- a/homeassistant/components/openai_conversation/const.py +++ b/homeassistant/components/openai_conversation/const.py @@ -8,7 +8,7 @@ LOGGER = logging.getLogger(__package__) CONF_RECOMMENDED = "recommended" CONF_PROMPT = "prompt" CONF_CHAT_MODEL = "chat_model" -RECOMMENDED_CHAT_MODEL = "gpt-4o" +RECOMMENDED_CHAT_MODEL = "gpt-4o-mini" CONF_MAX_TOKENS = "max_tokens" RECOMMENDED_MAX_TOKENS = 150 CONF_TOP_P = "top_p" From 9a3c7111f7d8da6e4d06d55ce21654d49c9775ea Mon Sep 17 00:00:00 2001 From: Alexander Schneider Date: Sun, 21 Jul 2024 14:51:10 -0700 Subject: [PATCH 8/8] Add Z-Wave discovery schema for ZVIDAR roller shades (#122332) Add discovery schema for ZVIDAR roller shades --- .../components/zwave_js/discovery.py | 9 + tests/components/zwave_js/conftest.py | 14 + .../zwave_js/fixtures/cover_zvidar_state.json | 1120 +++++++++++++++++ tests/components/zwave_js/test_discovery.py | 12 + 4 files changed, 1155 insertions(+) create mode 100644 tests/components/zwave_js/fixtures/cover_zvidar_state.json diff --git a/homeassistant/components/zwave_js/discovery.py b/homeassistant/components/zwave_js/discovery.py index c3a2884cb7a5..6e750ee8b2d3 100644 --- a/homeassistant/components/zwave_js/discovery.py +++ b/homeassistant/components/zwave_js/discovery.py @@ -579,6 +579,15 @@ DISCOVERY_SCHEMAS = [ ), entity_registry_enabled_default=False, ), + # ZVIDAR Z-CM-V01 (SmartWings/Deyi WM25L/V Z-Wave Motor for Roller Shade) + ZWaveDiscoverySchema( + platform=Platform.COVER, + hint="shade", + manufacturer_id={0x045A}, + product_id={0x0507}, + product_type={0x0904}, + primary_value=SWITCH_MULTILEVEL_CURRENT_VALUE_SCHEMA, + ), # Vision Security ZL7432 In Wall Dual Relay Switch ZWaveDiscoverySchema( platform=Platform.SWITCH, diff --git a/tests/components/zwave_js/conftest.py b/tests/components/zwave_js/conftest.py index a98a057b2932..60deb7dbce88 100644 --- a/tests/components/zwave_js/conftest.py +++ b/tests/components/zwave_js/conftest.py @@ -488,6 +488,12 @@ def iblinds_v3_state_fixture(): return json.loads(load_fixture("zwave_js/cover_iblinds_v3_state.json")) +@pytest.fixture(name="zvidar_state", scope="package") +def zvidar_state_fixture(): + """Load the ZVIDAR node state fixture data.""" + return json.loads(load_fixture("zwave_js/cover_zvidar_state.json")) + + @pytest.fixture(name="qubino_shutter_state", scope="package") def qubino_shutter_state_fixture(): """Load the Qubino Shutter node state fixture data.""" @@ -1097,6 +1103,14 @@ def iblinds_v3_cover_fixture(client, iblinds_v3_state): return node +@pytest.fixture(name="zvidar") +def zvidar_cover_fixture(client, zvidar_state): + """Mock a ZVIDAR window cover node.""" + node = Node(client, copy.deepcopy(zvidar_state)) + client.driver.controller.nodes[node.node_id] = node + return node + + @pytest.fixture(name="qubino_shutter") def qubino_shutter_cover_fixture(client, qubino_shutter_state): """Mock a Qubino flush shutter node.""" diff --git a/tests/components/zwave_js/fixtures/cover_zvidar_state.json b/tests/components/zwave_js/fixtures/cover_zvidar_state.json new file mode 100644 index 000000000000..05118931026f --- /dev/null +++ b/tests/components/zwave_js/fixtures/cover_zvidar_state.json @@ -0,0 +1,1120 @@ +{ + "nodeId": 270, + "index": 0, + "installerIcon": 6656, + "userIcon": 6656, + "status": 4, + "ready": true, + "isListening": false, + "isRouting": false, + "isSecure": true, + "manufacturerId": 1114, + "productId": 1287, + "productType": 2308, + "firmwareVersion": "1.10.0", + "zwavePlusVersion": 2, + "name": "Window Blind Controller", + "location": "**REDACTED**", + "deviceConfig": { + "filename": "/snapshot/build/node_modules/@zwave-js/config/config/devices/0x045a/Z-CM-V01.json", + "isEmbedded": true, + "manufacturer": "ZVIDAR", + "manufacturerId": 1114, + "label": "Z-CM-V01", + "description": "Smart Curtain Motor", + "devices": [ + { + "productType": 2308, + "productId": 1287 + } + ], + "firmwareVersion": { + "min": "0.0", + "max": "255.255" + }, + "preferred": false, + "paramInformation": { + "_map": {} + }, + "compat": { + "removeCCs": {} + } + }, + "label": "Z-CM-V01", + "interviewAttempts": 0, + "isFrequentListening": "1000ms", + "maxDataRate": 100000, + "supportedDataRates": [100000], + "protocolVersion": 3, + "supportsBeaming": false, + "supportsSecurity": true, + "nodeType": 1, + "zwavePlusNodeType": 0, + "zwavePlusRoleType": 7, + "deviceClass": { + "basic": { + "key": 3, + "label": "End Node" + }, + "generic": { + "key": 17, + "label": "Multilevel Switch" + }, + "specific": { + "key": 0, + "label": "Unused" + } + }, + "interviewStage": "Complete", + "deviceDatabaseUrl": "https://devices.zwave-js.io/?jumpTo=0x045a:0x0904:0x0507:1.10.0", + "statistics": { + "commandsTX": 2, + "commandsRX": 1, + "commandsDroppedRX": 1, + "commandsDroppedTX": 0, + "timeoutResponse": 0, + "rtt": 357.6, + "lastSeen": "2024-07-21T16:42:38.086Z", + "rssi": -89, + "lwr": { + "protocolDataRate": 4, + "repeaters": [], + "rssi": -91, + "repeaterRSSI": [] + } + }, + "highestSecurityClass": 1, + "isControllerNode": false, + "keepAwake": false, + "lastSeen": "2024-07-21T16:42:38.086Z", + "protocol": 1, + "values": [ + { + "endpoint": 0, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "targetValue", + "propertyName": "targetValue", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Target value", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 99, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "duration", + "propertyName": "duration", + "ccVersion": 4, + "metadata": { + "type": "duration", + "readable": true, + "writeable": false, + "label": "Remaining duration", + "stateful": true, + "secret": false + }, + "value": "unknown" + }, + { + "endpoint": 0, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "currentValue", + "propertyName": "currentValue", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Current value", + "min": 0, + "max": 99, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "Up", + "propertyName": "Up", + "ccVersion": 4, + "metadata": { + "type": "boolean", + "readable": false, + "writeable": true, + "label": "Perform a level change (Up)", + "ccSpecific": { + "switchType": 2 + }, + "valueChangeOptions": ["transitionDuration"], + "states": { + "true": "Start", + "false": "Stop" + }, + "stateful": true, + "secret": false + }, + "value": true + }, + { + "endpoint": 0, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "Down", + "propertyName": "Down", + "ccVersion": 4, + "metadata": { + "type": "boolean", + "readable": false, + "writeable": true, + "label": "Perform a level change (Down)", + "ccSpecific": { + "switchType": 2 + }, + "valueChangeOptions": ["transitionDuration"], + "states": { + "true": "Start", + "false": "Stop" + }, + "stateful": true, + "secret": false + }, + "value": true + }, + { + "endpoint": 0, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "restorePrevious", + "propertyName": "restorePrevious", + "ccVersion": 4, + "metadata": { + "type": "boolean", + "readable": false, + "writeable": true, + "label": "Restore previous value", + "states": { + "true": "Restore" + }, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 1, + "propertyName": "Hand Button Action", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Hand Button Action", + "default": 1, + "min": 0, + "max": 1, + "states": { + "0": "Close", + "1": "Open" + }, + "valueSize": 1, + "format": 0, + "allowManualEntry": false, + "isFromConfig": true + }, + "value": 1 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 2, + "propertyName": "Motor Direction", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Motor Direction", + "default": 1, + "min": 1, + "max": 3, + "states": { + "1": "Forward", + "2": "Opposite", + "3": "Reverse" + }, + "valueSize": 1, + "format": 0, + "allowManualEntry": false, + "isFromConfig": true + }, + "value": 1 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 3, + "propertyName": "Manually Set Open Boundary", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Manually Set Open Boundary", + "default": 0, + "min": 0, + "max": 1, + "states": { + "0": "Cancel", + "1": "Start" + }, + "valueSize": 1, + "format": 0, + "allowManualEntry": false, + "isFromConfig": true + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 4, + "propertyName": "Manually Set Closed Boundary", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Manually Set Closed Boundary", + "default": 0, + "min": 0, + "max": 1, + "states": { + "0": "Cancel", + "1": "Start" + }, + "valueSize": 1, + "format": 0, + "allowManualEntry": false, + "isFromConfig": true + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 5, + "propertyName": "Control Motor", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Control Motor", + "default": 3, + "min": 1, + "max": 3, + "states": { + "1": "Open (Up)", + "2": "Close (Down)", + "3": "Stop" + }, + "valueSize": 1, + "format": 0, + "allowManualEntry": false, + "isFromConfig": true + }, + "value": 3 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 6, + "propertyName": "Calibrate Limit Position", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Calibrate Limit Position", + "default": 1, + "min": 1, + "max": 3, + "states": { + "1": "Upper limit", + "2": "Lower limit", + "3": "Third limit" + }, + "valueSize": 1, + "format": 0, + "allowManualEntry": false, + "isFromConfig": true + }, + "value": 1 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 7, + "propertyName": "Delete Limit Position", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Delete Limit Position", + "default": 0, + "min": 0, + "max": 3, + "states": { + "0": "All limits", + "1": "Only upper limit", + "2": "Only lower limit", + "3": "Only third limit" + }, + "valueSize": 1, + "format": 0, + "allowManualEntry": false, + "isFromConfig": true + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 8, + "propertyName": "Low Battery Level Alarm Threshold", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Low Battery Level Alarm Threshold", + "default": 10, + "min": 0, + "max": 50, + "unit": "%", + "valueSize": 1, + "format": 0, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 10 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 9, + "propertyName": "Battery Report Interval", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Battery Report Interval", + "default": 3600, + "min": 0, + "max": 2678400, + "unit": "seconds", + "valueSize": 4, + "format": 0, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 3600 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 10, + "propertyName": "Battery Change Report Threshold", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Battery Change Report Threshold", + "default": 5, + "min": 0, + "max": 50, + "unit": "%", + "valueSize": 1, + "format": 0, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 5 + }, + { + "endpoint": 0, + "commandClass": 113, + "commandClassName": "Notification", + "property": "Power Management", + "propertyKey": "Mains status", + "propertyName": "Power Management", + "propertyKeyName": "Mains status", + "ccVersion": 8, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Mains status", + "ccSpecific": { + "notificationType": 8 + }, + "min": 0, + "max": 255, + "states": { + "2": "AC mains disconnected", + "3": "AC mains re-connected" + }, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 113, + "commandClassName": "Notification", + "property": "alarmType", + "propertyName": "alarmType", + "ccVersion": 8, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Alarm Type", + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 113, + "commandClassName": "Notification", + "property": "alarmLevel", + "propertyName": "alarmLevel", + "ccVersion": 8, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Alarm Level", + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 114, + "commandClassName": "Manufacturer Specific", + "property": "manufacturerId", + "propertyName": "manufacturerId", + "ccVersion": 2, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Manufacturer ID", + "min": 0, + "max": 65535, + "stateful": true, + "secret": false + }, + "value": 1114 + }, + { + "endpoint": 0, + "commandClass": 114, + "commandClassName": "Manufacturer Specific", + "property": "productType", + "propertyName": "productType", + "ccVersion": 2, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Product type", + "min": 0, + "max": 65535, + "stateful": true, + "secret": false + }, + "value": 2308 + }, + { + "endpoint": 0, + "commandClass": 114, + "commandClassName": "Manufacturer Specific", + "property": "productId", + "propertyName": "productId", + "ccVersion": 2, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Product ID", + "min": 0, + "max": 65535, + "stateful": true, + "secret": false + }, + "value": 1287 + }, + { + "endpoint": 0, + "commandClass": 128, + "commandClassName": "Battery", + "property": "level", + "propertyName": "level", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Battery level", + "min": 0, + "max": 100, + "unit": "%", + "stateful": true, + "secret": false + }, + "value": 86 + }, + { + "endpoint": 0, + "commandClass": 128, + "commandClassName": "Battery", + "property": "isLow", + "propertyName": "isLow", + "ccVersion": 1, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": false, + "label": "Low battery level", + "stateful": true, + "secret": false + }, + "value": false + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "libraryType", + "propertyName": "libraryType", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Library type", + "states": { + "0": "Unknown", + "1": "Static Controller", + "2": "Controller", + "3": "Enhanced Slave", + "4": "Slave", + "5": "Installer", + "6": "Routing Slave", + "7": "Bridge Controller", + "8": "Device under Test", + "9": "N/A", + "10": "AV Remote", + "11": "AV Device" + }, + "stateful": true, + "secret": false + }, + "value": 3 + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "protocolVersion", + "propertyName": "protocolVersion", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Z-Wave protocol version", + "stateful": true, + "secret": false + }, + "value": "7.16" + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "firmwareVersions", + "propertyName": "firmwareVersions", + "ccVersion": 3, + "metadata": { + "type": "string[]", + "readable": true, + "writeable": false, + "label": "Z-Wave chip firmware versions", + "stateful": true, + "secret": false + }, + "value": ["1.10"] + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "hardwareVersion", + "propertyName": "hardwareVersion", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Z-Wave chip hardware version", + "stateful": true, + "secret": false + }, + "value": 1 + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "sdkVersion", + "propertyName": "sdkVersion", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "SDK version", + "stateful": true, + "secret": false + }, + "value": "7.16.3" + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "applicationFrameworkAPIVersion", + "propertyName": "applicationFrameworkAPIVersion", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Z-Wave application framework API version", + "stateful": true, + "secret": false + }, + "value": "10.16.3" + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "applicationFrameworkBuildNumber", + "propertyName": "applicationFrameworkBuildNumber", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Z-Wave application framework API build number", + "stateful": true, + "secret": false + }, + "value": 297 + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "hostInterfaceVersion", + "propertyName": "hostInterfaceVersion", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Serial API version", + "stateful": true, + "secret": false + }, + "value": "unused" + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "hostInterfaceBuildNumber", + "propertyName": "hostInterfaceBuildNumber", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Serial API build number", + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "zWaveProtocolVersion", + "propertyName": "zWaveProtocolVersion", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Z-Wave protocol version", + "stateful": true, + "secret": false + }, + "value": "7.16.3" + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "zWaveProtocolBuildNumber", + "propertyName": "zWaveProtocolBuildNumber", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Z-Wave protocol build number", + "stateful": true, + "secret": false + }, + "value": 297 + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "applicationVersion", + "propertyName": "applicationVersion", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Application version", + "stateful": true, + "secret": false + }, + "value": "1.10.0" + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "applicationBuildNumber", + "propertyName": "applicationBuildNumber", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Application build number", + "stateful": true, + "secret": false + }, + "value": 43707 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 80, + "propertyKey": 3, + "propertyName": "Node Identify", + "propertyKeyName": "On/Off Period: Duration", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Sets the duration of an on/off period in 1/10th seconds. Must be set together with \"On/Off Cycle Count\"", + "label": "Node Identify - On/Off Period: Duration", + "ccSpecific": { + "indicatorId": 80, + "propertyId": 3 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 80, + "propertyKey": 4, + "propertyName": "Node Identify", + "propertyKeyName": "On/Off Cycle Count", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Sets the number of on/off periods. 0xff means infinite. Must be set together with \"On/Off Period duration\"", + "label": "Node Identify - On/Off Cycle Count", + "ccSpecific": { + "indicatorId": 80, + "propertyId": 4 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 80, + "propertyKey": 5, + "propertyName": "Node Identify", + "propertyKeyName": "On/Off Period: On time", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "This property is used to set the length of the On time during an On/Off period. It allows asymmetric On/Off periods. The value 0x00 MUST represent symmetric On/Off period (On time equal to Off time)", + "label": "Node Identify - On/Off Period: On time", + "ccSpecific": { + "indicatorId": 80, + "propertyId": 5 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": "value", + "propertyName": "value", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Indicator value", + "ccSpecific": { + "indicatorId": 0 + }, + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": "identify", + "propertyName": "identify", + "ccVersion": 3, + "metadata": { + "type": "boolean", + "readable": false, + "writeable": true, + "label": "Identify", + "states": { + "true": "Identify" + }, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": "timeout", + "propertyName": "timeout", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": true, + "label": "Timeout", + "stateful": true, + "secret": false + } + } + ], + "endpoints": [ + { + "nodeId": 261, + "index": 0, + "installerIcon": 6656, + "userIcon": 6656, + "deviceClass": { + "basic": { + "key": 3, + "label": "End Node" + }, + "generic": { + "key": 17, + "label": "Multilevel Switch" + }, + "specific": { + "key": 0, + "label": "Unused" + } + }, + "commandClasses": [ + { + "id": 94, + "name": "Z-Wave Plus Info", + "version": 2, + "isSecure": false + }, + { + "id": 85, + "name": "Transport Service", + "version": 2, + "isSecure": false + }, + { + "id": 159, + "name": "Security 2", + "version": 1, + "isSecure": true + }, + { + "id": 108, + "name": "Supervision", + "version": 1, + "isSecure": false + }, + { + "id": 38, + "name": "Multilevel Switch", + "version": 4, + "isSecure": true + }, + { + "id": 112, + "name": "Configuration", + "version": 4, + "isSecure": true + }, + { + "id": 133, + "name": "Association", + "version": 2, + "isSecure": true + }, + { + "id": 89, + "name": "Association Group Information", + "version": 3, + "isSecure": true + }, + { + "id": 142, + "name": "Multi Channel Association", + "version": 3, + "isSecure": true + }, + { + "id": 134, + "name": "Version", + "version": 3, + "isSecure": true + }, + { + "id": 114, + "name": "Manufacturer Specific", + "version": 2, + "isSecure": true + }, + { + "id": 90, + "name": "Device Reset Locally", + "version": 1, + "isSecure": true + }, + { + "id": 128, + "name": "Battery", + "version": 1, + "isSecure": true + }, + { + "id": 113, + "name": "Notification", + "version": 8, + "isSecure": true + }, + { + "id": 122, + "name": "Firmware Update Meta Data", + "version": 5, + "isSecure": true + }, + { + "id": 115, + "name": "Powerlevel", + "version": 1, + "isSecure": true + }, + { + "id": 135, + "name": "Indicator", + "version": 3, + "isSecure": true + } + ] + } + ] +} diff --git a/tests/components/zwave_js/test_discovery.py b/tests/components/zwave_js/test_discovery.py index 1179d8e843c8..57841ef2a832 100644 --- a/tests/components/zwave_js/test_discovery.py +++ b/tests/components/zwave_js/test_discovery.py @@ -49,6 +49,18 @@ async def test_iblinds_v2(hass: HomeAssistant, client, iblinds_v2, integration) assert state +async def test_zvidar_state(hass: HomeAssistant, client, zvidar, integration) -> None: + """Test that an ZVIDAR Z-CM-V01 multilevel switch value is discovered as a cover.""" + node = zvidar + assert node.device_class.specific.label == "Unused" + + state = hass.states.get("light.window_blind_controller") + assert not state + + state = hass.states.get("cover.window_blind_controller") + assert state + + async def test_ge_12730(hass: HomeAssistant, client, ge_12730, integration) -> None: """Test GE 12730 Fan Controller v2.0 multilevel switch is discovered as a fan.""" node = ge_12730