Merge branch 'dev' into yamaha

This commit is contained in:
Phill (pssc)
2024-07-21 23:16:27 +01:00
committed by GitHub
65 changed files with 4034 additions and 168 deletions
+1
View File
@@ -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
@@ -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
@@ -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):
@@ -4,3 +4,4 @@ from logging import Logger, getLogger
LOGGER: Logger = getLogger(__package__)
DOMAIN = "apsystems"
DEFAULT_PORT = 8050
@@ -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"
}
}
},
@@ -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()
+1 -3
View File
@@ -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,
+89 -28
View File
@@ -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:
@@ -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):
+7 -13
View File
@@ -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
)
@@ -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
@@ -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,
)
@@ -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"],
+5 -9
View File
@@ -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):
@@ -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",
+8 -13
View File
@@ -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
@@ -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
)
@@ -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,
}
+85 -2
View File
@@ -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
)
@@ -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": {
+38 -11
View File
@@ -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()
+1 -1
View File
@@ -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"
+8 -5
View File
@@ -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 = {
@@ -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."""
@@ -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"
@@ -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,
}
@@ -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
@@ -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
+91 -23
View File
@@ -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
@@ -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)),
+220
View File
@@ -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)
@@ -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"
@@ -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"]
}
@@ -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,
+1
View File
@@ -202,6 +202,7 @@ FLOWS = {
"gardena_bluetooth",
"gdacs",
"generic",
"geniushub",
"geo_json_events",
"geocaching",
"geofency",
+1 -1
View File
@@ -2124,7 +2124,7 @@
"geniushub": {
"name": "Genius Hub",
"integration_type": "hub",
"config_flow": false,
"config_flow": true,
"iot_class": "local_polling"
},
"geo_json_events": {
+1 -1
View File
@@ -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
+4 -1
View File
@@ -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
+46 -1
View File
@@ -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,
+12
View File
@@ -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."""
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8" ?>
<rss version="2.0">
<channel>
<title>RSS Sample</title>
<description>This is an example of an RSS feed</description>
<link>http://www.example.com/main.html</link>
<lastBuildDate>Mon, 30 Apr 2018 12:00:00 +1000 </lastBuildDate>
<pubDate>Mon, 30 Apr 2018 15:00:00 +1000</pubDate>
<ttl>1800</ttl>
</channel>
</rss>
@@ -0,0 +1,21 @@
<?xml version="1.0" encoding="UTF-8" ?>
<rss version="2.0">
<channel>
<title>RSS Sample</title>
<description>This is an example of an RSS feed</description>
<link>http://www.example.com/main.html</link>
<lastBuildDate>Mon, 30 Apr 2018 12:00:00 +1000 </lastBuildDate>
<pubDate>Mon, 30 Apr 2018 15:00:00 +1000</pubDate>
<ttl>1800</ttl>
<item>
<title>Title 1</title>
<description>Description 1</description>
<link>http://www.example.com/link/1</link>
<guid isPermaLink="false">GUID 1</guid>
<pubDate>Mon, 30 Apr 2018 15:10:00 +1000</pubDate>
<summary>This is a summary</summary>
</item>
</channel>
</rss>
+12 -2
View File
@@ -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"
+29
View File
@@ -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(
+1
View File
@@ -0,0 +1 @@
"""Tests for the geniushub integration."""
+65
View File
@@ -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",
},
)
@@ -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"
@@ -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",
+170 -3
View File
@@ -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
+2 -1
View File
@@ -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.
+6
View File
@@ -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]]
+70 -5
View File
@@ -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
@@ -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": {}
}
}
}
+2 -2
View File
@@ -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,
),
+412
View File
@@ -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")
+77
View File
@@ -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")
+29 -2
View File
@@ -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"
)
+122
View File
@@ -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
+26 -1
View File
@@ -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
+6 -3
View File
@@ -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(
+14
View File
@@ -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."""
File diff suppressed because it is too large Load Diff
@@ -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