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/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/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/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/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/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/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"
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/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/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_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 4d1562f340ac..84cf24875452 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
@@ -1932,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
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,
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(
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"
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
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(
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