diff --git a/.coveragerc b/.coveragerc index 25ef70250cc4..8a9fdf070c40 100644 --- a/.coveragerc +++ b/.coveragerc @@ -540,7 +540,18 @@ omit = homeassistant/components/keyboard_remote/* homeassistant/components/kira/* homeassistant/components/kiwi/lock.py - homeassistant/components/knx/* + homeassistant/components/knx/__init__.py + homeassistant/components/knx/climate.py + homeassistant/components/knx/const.py + homeassistant/components/knx/cover.py + homeassistant/components/knx/expose.py + homeassistant/components/knx/knx_entity.py + homeassistant/components/knx/light.py + homeassistant/components/knx/notify.py + homeassistant/components/knx/scene.py + homeassistant/components/knx/schema.py + homeassistant/components/knx/switch.py + homeassistant/components/knx/weather.py homeassistant/components/kodi/__init__.py homeassistant/components/kodi/browse_media.py homeassistant/components/kodi/const.py diff --git a/homeassistant/components/knx/__init__.py b/homeassistant/components/knx/__init__.py index 0cdd92abe647..d98dd6663a34 100644 --- a/homeassistant/components/knx/__init__.py +++ b/homeassistant/components/knx/__init__.py @@ -21,28 +21,30 @@ from xknx.telegram.address import ( ) from xknx.telegram.apci import GroupValueRead, GroupValueResponse, GroupValueWrite +from homeassistant import config_entries +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_EVENT, CONF_HOST, CONF_PORT, CONF_TYPE, EVENT_HOMEASSISTANT_STOP, - SERVICE_RELOAD, ) from homeassistant.core import Event, HomeAssistant, ServiceCall -from homeassistant.exceptions import HomeAssistantError +from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError from homeassistant.helpers import discovery import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.entity_platform import async_get_platforms from homeassistant.helpers.reload import async_integration_yaml_config from homeassistant.helpers.service import async_register_admin_service from homeassistant.helpers.typing import ConfigType from .const import ( + CONF_KNX_CONNECTION_TYPE, CONF_KNX_EXPOSE, CONF_KNX_INDIVIDUAL_ADDRESS, CONF_KNX_ROUTING, CONF_KNX_TUNNELING, + DATA_KNX_CONFIG, DOMAIN, KNX_ADDRESS, SupportedPlatforms, @@ -87,6 +89,13 @@ CONFIG_SCHEMA = vol.Schema( { DOMAIN: vol.All( # deprecated since 2021.12 + cv.deprecated(ConnectionSchema.CONF_KNX_STATE_UPDATER), + cv.deprecated(ConnectionSchema.CONF_KNX_RATE_LIMIT), + cv.deprecated(CONF_KNX_ROUTING), + cv.deprecated(CONF_KNX_TUNNELING), + cv.deprecated(CONF_KNX_INDIVIDUAL_ADDRESS), + cv.deprecated(ConnectionSchema.CONF_KNX_MCAST_GRP), + cv.deprecated(ConnectionSchema.CONF_KNX_MCAST_PORT), cv.deprecated(CONF_KNX_EVENT_FILTER), # deprecated since 2021.4 cv.deprecated("config_file"), @@ -185,35 +194,73 @@ SERVICE_KNX_EXPOSURE_REGISTER_SCHEMA = vol.Any( async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: - """Set up the KNX integration.""" - try: - knx_module = KNXModule(hass, config) - hass.data[DOMAIN] = knx_module - await knx_module.start() - except XKNXException as ex: - _LOGGER.warning("Could not connect to KNX interface: %s", ex) - hass.components.persistent_notification.async_create( - f"Could not connect to KNX interface:
{ex}", title="KNX" + """Start the KNX integration.""" + conf: ConfigType | None = config.get(DOMAIN) + + if conf is None: + # If we have a config entry, setup is done by that config entry. + # If there is no config entry, this should fail. + return bool(hass.config_entries.async_entries(DOMAIN)) + + conf = dict(conf) + + hass.data[DATA_KNX_CONFIG] = conf + + # Only import if we haven't before. + if not hass.config_entries.async_entries(DOMAIN): + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, data=conf + ) ) - if CONF_KNX_EXPOSE in config[DOMAIN]: - for expose_config in config[DOMAIN][CONF_KNX_EXPOSE]: + return True + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Load a config entry.""" + conf = hass.data.get(DATA_KNX_CONFIG) + + # When reloading + if conf is None: + conf = await async_integration_yaml_config(hass, DOMAIN) + if not conf or DOMAIN not in conf: + return False + + conf = conf[DOMAIN] + + # If user didn't have configuration.yaml config, generate defaults + if conf is None: + conf = CONFIG_SCHEMA({DOMAIN: dict(entry.data)})[DOMAIN] + + config = {**conf, **entry.data} + + try: + knx_module = KNXModule(hass, config, entry) + await knx_module.start() + except XKNXException as ex: + raise ConfigEntryNotReady from ex + + hass.data[DATA_KNX_CONFIG] = conf + hass.data[DOMAIN] = knx_module + + if CONF_KNX_EXPOSE in config: + for expose_config in config[CONF_KNX_EXPOSE]: knx_module.exposures.append( create_knx_exposure(hass, knx_module.xknx, expose_config) ) - for platform in SupportedPlatforms: - if platform.value not in config[DOMAIN]: - continue + hass.config_entries.async_setup_platforms( + entry, + [platform.value for platform in SupportedPlatforms if platform.value in config], + ) + + # set up notify platform, no entry support for notify component yet, + # have to use discovery to load platform. + if NotifySchema.PLATFORM_NAME in conf: hass.async_create_task( discovery.async_load_platform( - hass, - platform.value, - DOMAIN, - { - "platform_config": config[DOMAIN][platform.value], - }, - config, + hass, "notify", DOMAIN, conf[NotifySchema.PLATFORM_NAME], config ) ) @@ -247,39 +294,53 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: schema=SERVICE_KNX_EXPOSURE_REGISTER_SCHEMA, ) - async def reload_service_handler(service_call: ServiceCall) -> None: - """Remove all KNX components and load new ones from config.""" - - # First check for config file. If for some reason it is no longer there - # or knx is no longer mentioned, stop the reload. - config = await async_integration_yaml_config(hass, DOMAIN) - if not config or DOMAIN not in config: - return - - await asyncio.gather( - *(platform.async_reset() for platform in async_get_platforms(hass, DOMAIN)) - ) - await knx_module.xknx.stop() - - await async_setup(hass, config) - - async_register_admin_service( - hass, DOMAIN, SERVICE_RELOAD, reload_service_handler, schema=vol.Schema({}) - ) - return True +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unloading the KNX platforms.""" + # if not loaded directly return + if not hass.data.get(DOMAIN): + return True + + knx_module: KNXModule = hass.data[DOMAIN] + for exposure in knx_module.exposures: + exposure.shutdown() + + unload_ok = await hass.config_entries.async_unload_platforms( + entry, + [ + platform.value + for platform in SupportedPlatforms + if platform.value in hass.data[DATA_KNX_CONFIG] + ], + ) + if unload_ok: + await knx_module.stop() + hass.data.pop(DOMAIN) + hass.data.pop(DATA_KNX_CONFIG) + + return unload_ok + + +async def async_update_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Update a given config entry.""" + return await hass.config_entries.async_reload(entry.entry_id) + + class KNXModule: """Representation of KNX Object.""" - def __init__(self, hass: HomeAssistant, config: ConfigType) -> None: + def __init__( + self, hass: HomeAssistant, config: ConfigType, entry: ConfigEntry + ) -> None: """Initialize KNX module.""" self.hass = hass self.config = config self.connected = False self.exposures: list[KNXExposeSensor | KNXExposeTime] = [] self.service_exposures: dict[str, KNXExposeSensor | KNXExposeTime] = {} + self.entry = entry self.init_xknx() self.xknx.connection_manager.register_connection_state_changed_cb( @@ -292,64 +353,49 @@ class KNXModule: self.register_event_callback() ) + self.entry.async_on_unload( + self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, self.stop) + ) + + self.entry.async_on_unload(self.entry.add_update_listener(async_update_entry)) + def init_xknx(self) -> None: """Initialize XKNX object.""" self.xknx = XKNX( - own_address=self.config[DOMAIN][CONF_KNX_INDIVIDUAL_ADDRESS], - rate_limit=self.config[DOMAIN][ConnectionSchema.CONF_KNX_RATE_LIMIT], - multicast_group=self.config[DOMAIN][ConnectionSchema.CONF_KNX_MCAST_GRP], - multicast_port=self.config[DOMAIN][ConnectionSchema.CONF_KNX_MCAST_PORT], + own_address=self.config[CONF_KNX_INDIVIDUAL_ADDRESS], + rate_limit=self.config[ConnectionSchema.CONF_KNX_RATE_LIMIT], + multicast_group=self.config[ConnectionSchema.CONF_KNX_MCAST_GRP], + multicast_port=self.config[ConnectionSchema.CONF_KNX_MCAST_PORT], connection_config=self.connection_config(), - state_updater=self.config[DOMAIN][ConnectionSchema.CONF_KNX_STATE_UPDATER], + state_updater=self.config[ConnectionSchema.CONF_KNX_STATE_UPDATER], ) async def start(self) -> None: """Start XKNX object. Connect to tunneling or Routing device.""" await self.xknx.start() - self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, self.stop) - async def stop(self, event: Event) -> None: + async def stop(self, event: Event | None = None) -> None: """Stop XKNX object. Disconnect from tunneling or Routing device.""" await self.xknx.stop() def connection_config(self) -> ConnectionConfig: """Return the connection_config.""" - if CONF_KNX_TUNNELING in self.config[DOMAIN]: - return self.connection_config_tunneling() - if CONF_KNX_ROUTING in self.config[DOMAIN]: - return self.connection_config_routing() - return ConnectionConfig(auto_reconnect=True) - - def connection_config_routing(self) -> ConnectionConfig: - """Return the connection_config if routing is configured.""" - local_ip = None - # all configuration values are optional - if self.config[DOMAIN][CONF_KNX_ROUTING] is not None: - local_ip = self.config[DOMAIN][CONF_KNX_ROUTING].get( - ConnectionSchema.CONF_KNX_LOCAL_IP + _conn_type: str = self.config[CONF_KNX_CONNECTION_TYPE] + if _conn_type == CONF_KNX_ROUTING: + return ConnectionConfig( + connection_type=ConnectionType.ROUTING, + auto_reconnect=True, + ) + if _conn_type == CONF_KNX_TUNNELING: + return ConnectionConfig( + connection_type=ConnectionType.TUNNELING, + gateway_ip=self.config[CONF_HOST], + gateway_port=self.config[CONF_PORT], + route_back=self.config.get(ConnectionSchema.CONF_KNX_ROUTE_BACK, False), + auto_reconnect=True, ) - return ConnectionConfig( - connection_type=ConnectionType.ROUTING, local_ip=local_ip - ) - def connection_config_tunneling(self) -> ConnectionConfig: - """Return the connection_config if tunneling is configured.""" - gateway_ip = self.config[DOMAIN][CONF_KNX_TUNNELING][CONF_HOST] - gateway_port = self.config[DOMAIN][CONF_KNX_TUNNELING][CONF_PORT] - local_ip = self.config[DOMAIN][CONF_KNX_TUNNELING].get( - ConnectionSchema.CONF_KNX_LOCAL_IP - ) - route_back = self.config[DOMAIN][CONF_KNX_TUNNELING][ - ConnectionSchema.CONF_KNX_ROUTE_BACK - ] - return ConnectionConfig( - connection_type=ConnectionType.TUNNELING, - gateway_ip=gateway_ip, - gateway_port=gateway_port, - local_ip=local_ip, - route_back=route_back, - auto_reconnect=True, - ) + return ConnectionConfig(auto_reconnect=True) async def connection_state_changed_cb(self, state: XknxConnectionState) -> None: """Call invoked after a KNX connection state change was received.""" @@ -409,10 +455,8 @@ class KNXModule: """Register callback for knx_event within XKNX TelegramQueue.""" # backwards compatibility for deprecated CONF_KNX_EVENT_FILTER # use `address_filters = []` when this is not needed anymore - address_filters = list( - map(AddressFilter, self.config[DOMAIN][CONF_KNX_EVENT_FILTER]) - ) - for filter_set in self.config[DOMAIN][CONF_EVENT]: + address_filters = list(map(AddressFilter, self.config[CONF_KNX_EVENT_FILTER])) + for filter_set in self.config[CONF_EVENT]: _filters = list(map(AddressFilter, filter_set[KNX_ADDRESS])) address_filters.extend(_filters) if (dpt := filter_set.get(CONF_TYPE)) and ( diff --git a/homeassistant/components/knx/binary_sensor.py b/homeassistant/components/knx/binary_sensor.py index b4e3354fc376..b3dbdc0db12d 100644 --- a/homeassistant/components/knx/binary_sensor.py +++ b/homeassistant/components/knx/binary_sensor.py @@ -6,6 +6,7 @@ from typing import Any from xknx import XKNX from xknx.devices import BinarySensor as XknxBinarySensor +from homeassistant import config_entries from homeassistant.components.binary_sensor import BinarySensorEntity from homeassistant.const import ( CONF_DEVICE_CLASS, @@ -18,28 +19,31 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from homeassistant.helpers.typing import ConfigType -from .const import ATTR_COUNTER, ATTR_SOURCE, DOMAIN +from .const import ( + ATTR_COUNTER, + ATTR_SOURCE, + DATA_KNX_CONFIG, + DOMAIN, + SupportedPlatforms, +) from .knx_entity import KnxEntity from .schema import BinarySensorSchema -async def async_setup_platform( +async def async_setup_entry( hass: HomeAssistant, - config: ConfigType, + config_entry: config_entries.ConfigEntry, async_add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, ) -> None: - """Set up binary sensor(s) for KNX platform.""" - if not discovery_info or not discovery_info["platform_config"]: - return - - platform_config = discovery_info["platform_config"] + """Set up the KNX binary sensor platform.""" xknx: XKNX = hass.data[DOMAIN].xknx + config: ConfigType = hass.data[DATA_KNX_CONFIG] async_add_entities( - KNXBinarySensor(xknx, entity_config) for entity_config in platform_config + KNXBinarySensor(xknx, entity_config) + for entity_config in config[SupportedPlatforms.BINARY_SENSOR.value] ) diff --git a/homeassistant/components/knx/button.py b/homeassistant/components/knx/button.py index 72bb807abb07..457e1d6d43d4 100644 --- a/homeassistant/components/knx/button.py +++ b/homeassistant/components/knx/button.py @@ -4,30 +4,36 @@ from __future__ import annotations from xknx import XKNX from xknx.devices import RawValue as XknxRawValue +from homeassistant import config_entries from homeassistant.components.button import ButtonEntity from homeassistant.const import CONF_ENTITY_CATEGORY, CONF_NAME from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from homeassistant.helpers.typing import ConfigType -from .const import CONF_PAYLOAD, CONF_PAYLOAD_LENGTH, DOMAIN, KNX_ADDRESS +from .const import ( + CONF_PAYLOAD, + CONF_PAYLOAD_LENGTH, + DATA_KNX_CONFIG, + DOMAIN, + KNX_ADDRESS, + SupportedPlatforms, +) from .knx_entity import KnxEntity -async def async_setup_platform( +async def async_setup_entry( hass: HomeAssistant, - config: ConfigType, + config_entry: config_entries.ConfigEntry, async_add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, ) -> None: - """Set up buttons for KNX platform.""" - if not discovery_info or not discovery_info["platform_config"]: - return - platform_config = discovery_info["platform_config"] + """Set up the KNX binary sensor platform.""" xknx: XKNX = hass.data[DOMAIN].xknx + config: ConfigType = hass.data[DATA_KNX_CONFIG] async_add_entities( - KNXButton(xknx, entity_config) for entity_config in platform_config + KNXButton(xknx, entity_config) + for entity_config in config[SupportedPlatforms.BUTTON.value] ) diff --git a/homeassistant/components/knx/climate.py b/homeassistant/components/knx/climate.py index 6d3194731f1a..791e897f4d37 100644 --- a/homeassistant/components/knx/climate.py +++ b/homeassistant/components/knx/climate.py @@ -8,6 +8,7 @@ from xknx.devices import Climate as XknxClimate, ClimateMode as XknxClimateMode from xknx.dpt.dpt_hvac_mode import HVACControllerMode, HVACOperationMode from xknx.telegram.address import parse_device_group_address +from homeassistant import config_entries from homeassistant.components.climate import ClimateEntity from homeassistant.components.climate.const import ( CURRENT_HVAC_IDLE, @@ -26,9 +27,16 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from homeassistant.helpers.typing import ConfigType -from .const import CONTROLLER_MODES, CURRENT_HVAC_ACTIONS, DOMAIN, PRESET_MODES +from .const import ( + CONTROLLER_MODES, + CURRENT_HVAC_ACTIONS, + DATA_KNX_CONFIG, + DOMAIN, + PRESET_MODES, + SupportedPlatforms, +) from .knx_entity import KnxEntity from .schema import ClimateSchema @@ -37,23 +45,19 @@ CONTROLLER_MODES_INV = {value: key for key, value in CONTROLLER_MODES.items()} PRESET_MODES_INV = {value: key for key, value in PRESET_MODES.items()} -async def async_setup_platform( +async def async_setup_entry( hass: HomeAssistant, - config: ConfigType, + config_entry: config_entries.ConfigEntry, async_add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up climate(s) for KNX platform.""" - if not discovery_info or not discovery_info["platform_config"]: - return - - platform_config = discovery_info["platform_config"] xknx: XKNX = hass.data[DOMAIN].xknx + config: list[ConfigType] = hass.data[DATA_KNX_CONFIG][ + SupportedPlatforms.CLIMATE.value + ] - _async_migrate_unique_id(hass, platform_config) - async_add_entities( - KNXClimate(xknx, entity_config) for entity_config in platform_config - ) + _async_migrate_unique_id(hass, config) + async_add_entities(KNXClimate(xknx, entity_config) for entity_config in config) @callback diff --git a/homeassistant/components/knx/config_flow.py b/homeassistant/components/knx/config_flow.py new file mode 100644 index 000000000000..3fcf40696241 --- /dev/null +++ b/homeassistant/components/knx/config_flow.py @@ -0,0 +1,409 @@ +"""Config flow for KNX.""" +from __future__ import annotations + +from typing import Any, Final + +import voluptuous as vol +from xknx import XKNX +from xknx.io import DEFAULT_MCAST_GRP, DEFAULT_MCAST_PORT +from xknx.io.gateway_scanner import GatewayDescriptor, GatewayScanner + +from homeassistant import config_entries +from homeassistant.config_entries import ConfigEntry, OptionsFlow +from homeassistant.const import CONF_HOST, CONF_PORT +from homeassistant.core import callback +from homeassistant.data_entry_flow import FlowResult +import homeassistant.helpers.config_validation as cv + +from .const import ( + CONF_KNX_AUTOMATIC, + CONF_KNX_CONNECTION_TYPE, + CONF_KNX_INDIVIDUAL_ADDRESS, + CONF_KNX_INITIAL_CONNECTION_TYPES, + CONF_KNX_ROUTING, + CONF_KNX_TUNNELING, + DOMAIN, +) +from .schema import ConnectionSchema + +CONF_KNX_GATEWAY: Final = "gateway" +CONF_MAX_RATE_LIMIT: Final = 60 + +DEFAULT_ENTRY_DATA: Final = { + ConnectionSchema.CONF_KNX_STATE_UPDATER: ConnectionSchema.CONF_KNX_DEFAULT_STATE_UPDATER, + ConnectionSchema.CONF_KNX_RATE_LIMIT: ConnectionSchema.CONF_KNX_DEFAULT_RATE_LIMIT, + CONF_KNX_INDIVIDUAL_ADDRESS: XKNX.DEFAULT_ADDRESS, + ConnectionSchema.CONF_KNX_MCAST_GRP: DEFAULT_MCAST_GRP, + ConnectionSchema.CONF_KNX_MCAST_PORT: DEFAULT_MCAST_PORT, +} + + +class FlowHandler(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a KNX config flow.""" + + VERSION = 1 + + _tunnels: list + _gateway_ip: str = "" + _gateway_port: int = DEFAULT_MCAST_PORT + + @staticmethod + @callback + def async_get_options_flow(config_entry: ConfigEntry) -> KNXOptionsFlowHandler: + """Get the options flow for this handler.""" + return KNXOptionsFlowHandler(config_entry) + + async def async_step_user(self, user_input: dict | None = None) -> FlowResult: + """Handle a flow initialized by the user.""" + if self._async_current_entries(): + return self.async_abort(reason="single_instance_allowed") + + self._tunnels = [] + return await self.async_step_type() + + async def async_step_type(self, user_input: dict | None = None) -> FlowResult: + """Handle connection type configuration.""" + errors: dict = {} + supported_connection_types = CONF_KNX_INITIAL_CONNECTION_TYPES.copy() + fields = {} + + if user_input is None: + gateways = await scan_for_gateways() + + if gateways: + supported_connection_types.insert(0, CONF_KNX_AUTOMATIC) + self._tunnels = [ + gateway for gateway in gateways if gateway.supports_tunnelling + ] + + fields = { + vol.Required(CONF_KNX_CONNECTION_TYPE): vol.In( + supported_connection_types + ) + } + + if user_input is not None: + connection_type = user_input[CONF_KNX_CONNECTION_TYPE] + if connection_type == CONF_KNX_AUTOMATIC: + return self.async_create_entry( + title=CONF_KNX_AUTOMATIC.capitalize(), + data={**DEFAULT_ENTRY_DATA, **user_input}, + ) + + if connection_type == CONF_KNX_ROUTING: + return await self.async_step_routing() + + if connection_type == CONF_KNX_TUNNELING and self._tunnels: + return await self.async_step_tunnel() + + return await self.async_step_manual_tunnel() + + return self.async_show_form( + step_id="type", data_schema=vol.Schema(fields), errors=errors + ) + + async def async_step_manual_tunnel( + self, user_input: dict | None = None + ) -> FlowResult: + """General setup.""" + errors: dict = {} + + if user_input is not None: + return self.async_create_entry( + title=f"{CONF_KNX_TUNNELING.capitalize()} @ {user_input[CONF_HOST]}", + data={ + **DEFAULT_ENTRY_DATA, + CONF_HOST: user_input[CONF_HOST], + CONF_PORT: user_input[CONF_PORT], + CONF_KNX_INDIVIDUAL_ADDRESS: user_input[ + CONF_KNX_INDIVIDUAL_ADDRESS + ], + ConnectionSchema.CONF_KNX_ROUTE_BACK: user_input[ + ConnectionSchema.CONF_KNX_ROUTE_BACK + ], + CONF_KNX_CONNECTION_TYPE: CONF_KNX_TUNNELING, + }, + ) + + fields = { + vol.Required(CONF_HOST, default=self._gateway_ip): str, + vol.Required(CONF_PORT, default=self._gateway_port): vol.Coerce(int), + vol.Required( + CONF_KNX_INDIVIDUAL_ADDRESS, default=XKNX.DEFAULT_ADDRESS + ): str, + vol.Required( + ConnectionSchema.CONF_KNX_ROUTE_BACK, default=False + ): vol.Coerce(bool), + } + + return self.async_show_form( + step_id="manual_tunnel", data_schema=vol.Schema(fields), errors=errors + ) + + async def async_step_tunnel(self, user_input: dict | None = None) -> FlowResult: + """Select a tunnel from a list. Will be skipped if the gateway scan was unsuccessful or if only one gateway was found.""" + errors: dict = {} + + if user_input is not None: + gateway: GatewayDescriptor = next( + gateway + for gateway in self._tunnels + if user_input[CONF_KNX_GATEWAY] == str(gateway) + ) + + self._gateway_ip = gateway.ip_addr + self._gateway_port = gateway.port + + return await self.async_step_manual_tunnel() + + tunnel_repr = { + str(tunnel) for tunnel in self._tunnels if tunnel.supports_tunnelling + } + + # skip this step if the user has only one unique gateway. + if len(tunnel_repr) == 1: + _gateway: GatewayDescriptor = self._tunnels[0] + self._gateway_ip = _gateway.ip_addr + self._gateway_port = _gateway.port + return await self.async_step_manual_tunnel() + + fields = {vol.Required(CONF_KNX_GATEWAY): vol.In(tunnel_repr)} + + return self.async_show_form( + step_id="tunnel", data_schema=vol.Schema(fields), errors=errors + ) + + async def async_step_routing(self, user_input: dict | None = None) -> FlowResult: + """Routing setup.""" + errors: dict = {} + + if user_input is not None: + return self.async_create_entry( + title=CONF_KNX_ROUTING.capitalize(), + data={ + **DEFAULT_ENTRY_DATA, + ConnectionSchema.CONF_KNX_MCAST_GRP: user_input[ + ConnectionSchema.CONF_KNX_MCAST_GRP + ], + ConnectionSchema.CONF_KNX_MCAST_PORT: user_input[ + ConnectionSchema.CONF_KNX_MCAST_PORT + ], + CONF_KNX_INDIVIDUAL_ADDRESS: user_input[ + CONF_KNX_INDIVIDUAL_ADDRESS + ], + CONF_KNX_CONNECTION_TYPE: CONF_KNX_ROUTING, + }, + ) + + fields = { + vol.Required( + CONF_KNX_INDIVIDUAL_ADDRESS, default=XKNX.DEFAULT_ADDRESS + ): str, + vol.Required( + ConnectionSchema.CONF_KNX_MCAST_GRP, default=DEFAULT_MCAST_GRP + ): str, + vol.Required( + ConnectionSchema.CONF_KNX_MCAST_PORT, default=DEFAULT_MCAST_PORT + ): cv.port, + } + + return self.async_show_form( + step_id="routing", data_schema=vol.Schema(fields), errors=errors + ) + + async def async_step_import(self, config: dict | None = None) -> FlowResult: + """Import a config entry. + + Performs a one time import of the YAML configuration and creates a config entry based on it + if not already done before. + """ + if self._async_current_entries() or not config: + return self.async_abort(reason="single_instance_allowed") + + data = { + ConnectionSchema.CONF_KNX_RATE_LIMIT: min( + config[ConnectionSchema.CONF_KNX_RATE_LIMIT], CONF_MAX_RATE_LIMIT + ), + ConnectionSchema.CONF_KNX_STATE_UPDATER: config[ + ConnectionSchema.CONF_KNX_STATE_UPDATER + ], + ConnectionSchema.CONF_KNX_MCAST_GRP: config[ + ConnectionSchema.CONF_KNX_MCAST_GRP + ], + ConnectionSchema.CONF_KNX_MCAST_PORT: config[ + ConnectionSchema.CONF_KNX_MCAST_PORT + ], + CONF_KNX_INDIVIDUAL_ADDRESS: config[CONF_KNX_INDIVIDUAL_ADDRESS], + } + + if CONF_KNX_TUNNELING in config: + return self.async_create_entry( + title=f"{CONF_KNX_TUNNELING.capitalize()} @ {config[CONF_KNX_TUNNELING][CONF_HOST]}", + data={ + **DEFAULT_ENTRY_DATA, + CONF_HOST: config[CONF_KNX_TUNNELING][CONF_HOST], + CONF_PORT: config[CONF_KNX_TUNNELING][CONF_PORT], + ConnectionSchema.CONF_KNX_ROUTE_BACK: config[CONF_KNX_TUNNELING][ + ConnectionSchema.CONF_KNX_ROUTE_BACK + ], + CONF_KNX_CONNECTION_TYPE: CONF_KNX_TUNNELING, + **data, + }, + ) + + if CONF_KNX_ROUTING in config: + return self.async_create_entry( + title=CONF_KNX_ROUTING.capitalize(), + data={ + **DEFAULT_ENTRY_DATA, + CONF_KNX_CONNECTION_TYPE: CONF_KNX_ROUTING, + **data, + }, + ) + + return self.async_create_entry( + title=CONF_KNX_AUTOMATIC.capitalize(), + data={ + **DEFAULT_ENTRY_DATA, + CONF_KNX_CONNECTION_TYPE: CONF_KNX_AUTOMATIC, + **data, + }, + ) + + +class KNXOptionsFlowHandler(OptionsFlow): + """Handle KNX options.""" + + general_settings: dict + current_config: dict + + def __init__(self, config_entry: ConfigEntry) -> None: + """Initialize KNX options flow.""" + self.config_entry = config_entry + + async def async_step_tunnel( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Manage KNX tunneling options.""" + if ( + self.general_settings.get(CONF_KNX_CONNECTION_TYPE) == CONF_KNX_TUNNELING + and user_input is None + ): + return self.async_show_form( + step_id="tunnel", + data_schema=vol.Schema( + { + vol.Required( + CONF_HOST, default=self.current_config.get(CONF_HOST) + ): str, + vol.Required( + CONF_PORT, default=self.current_config.get(CONF_PORT, 3671) + ): cv.port, + vol.Required( + ConnectionSchema.CONF_KNX_ROUTE_BACK, + default=self.current_config.get( + ConnectionSchema.CONF_KNX_ROUTE_BACK, False + ), + ): vol.Coerce(bool), + } + ), + last_step=True, + ) + + entry_data = { + **DEFAULT_ENTRY_DATA, + **self.general_settings, + CONF_HOST: self.current_config.get(CONF_HOST, ""), + } + + if user_input is not None: + entry_data = { + **entry_data, + **user_input, + } + + entry_title = entry_data[CONF_KNX_CONNECTION_TYPE].capitalize() + if entry_data[CONF_KNX_CONNECTION_TYPE] == CONF_KNX_TUNNELING: + entry_title = f"{CONF_KNX_TUNNELING.capitalize()} @ {entry_data[CONF_HOST]}" + + self.hass.config_entries.async_update_entry( + self.config_entry, + data=entry_data, + title=entry_title, + ) + + return self.async_create_entry(title="", data={}) + + async def async_step_init( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Manage KNX options.""" + if user_input is not None: + self.general_settings = user_input + return await self.async_step_tunnel() + + supported_connection_types = [ + CONF_KNX_AUTOMATIC, + CONF_KNX_TUNNELING, + CONF_KNX_ROUTING, + ] + self.current_config = self.config_entry.data # type: ignore + + data_schema = { + vol.Required( + CONF_KNX_CONNECTION_TYPE, + default=self.current_config.get(CONF_KNX_CONNECTION_TYPE), + ): vol.In(supported_connection_types), + vol.Required( + CONF_KNX_INDIVIDUAL_ADDRESS, + default=self.current_config[CONF_KNX_INDIVIDUAL_ADDRESS], + ): str, + vol.Required( + ConnectionSchema.CONF_KNX_MCAST_GRP, + default=self.current_config.get( + ConnectionSchema.CONF_KNX_MCAST_GRP, DEFAULT_MCAST_GRP + ), + ): str, + vol.Required( + ConnectionSchema.CONF_KNX_MCAST_PORT, + default=self.current_config.get( + ConnectionSchema.CONF_KNX_MCAST_PORT, DEFAULT_MCAST_PORT + ), + ): cv.port, + } + + if self.show_advanced_options: + data_schema[ + vol.Required( + ConnectionSchema.CONF_KNX_STATE_UPDATER, + default=self.current_config.get( + ConnectionSchema.CONF_KNX_STATE_UPDATER, + ConnectionSchema.CONF_KNX_DEFAULT_STATE_UPDATER, + ), + ) + ] = bool + data_schema[ + vol.Required( + ConnectionSchema.CONF_KNX_RATE_LIMIT, + default=self.current_config.get( + ConnectionSchema.CONF_KNX_RATE_LIMIT, + ConnectionSchema.CONF_KNX_DEFAULT_RATE_LIMIT, + ), + ) + ] = vol.All(vol.Coerce(int), vol.Range(min=1, max=CONF_MAX_RATE_LIMIT)) + + return self.async_show_form( + step_id="init", + data_schema=vol.Schema(data_schema), + last_step=self.current_config.get(CONF_KNX_CONNECTION_TYPE) + != CONF_KNX_TUNNELING, + ) + + +async def scan_for_gateways(stop_on_found: int = 0) -> list: + """Scan for gateways within the network.""" + xknx = XKNX() + gatewayscanner = GatewayScanner( + xknx, stop_on_found=stop_on_found, timeout_in_seconds=2 + ) + return await gatewayscanner.scan() diff --git a/homeassistant/components/knx/const.py b/homeassistant/components/knx/const.py index f3468fc8dc29..e12d95947958 100644 --- a/homeassistant/components/knx/const.py +++ b/homeassistant/components/knx/const.py @@ -29,6 +29,8 @@ KNX_ADDRESS: Final = "address" CONF_INVERT: Final = "invert" CONF_KNX_EXPOSE: Final = "expose" CONF_KNX_INDIVIDUAL_ADDRESS: Final = "individual_address" +CONF_KNX_CONNECTION_TYPE: Final = "connection_type" +CONF_KNX_AUTOMATIC: Final = "automatic" CONF_KNX_ROUTING: Final = "routing" CONF_KNX_TUNNELING: Final = "tunneling" CONF_PAYLOAD: Final = "payload" @@ -37,6 +39,9 @@ CONF_RESET_AFTER: Final = "reset_after" CONF_RESPOND_TO_READ: Final = "respond_to_read" CONF_STATE_ADDRESS: Final = "state_address" CONF_SYNC_STATE: Final = "sync_state" +CONF_KNX_INITIAL_CONNECTION_TYPES: Final = [CONF_KNX_TUNNELING, CONF_KNX_ROUTING] + +DATA_KNX_CONFIG: Final = "knx_config" ATTR_COUNTER: Final = "counter" ATTR_SOURCE: Final = "source" diff --git a/homeassistant/components/knx/cover.py b/homeassistant/components/knx/cover.py index 8eb906d1ba09..62fd3a1ba08e 100644 --- a/homeassistant/components/knx/cover.py +++ b/homeassistant/components/knx/cover.py @@ -9,6 +9,7 @@ from xknx import XKNX from xknx.devices import Cover as XknxCover, Device as XknxDevice from xknx.telegram.address import parse_device_group_address +from homeassistant import config_entries from homeassistant.components.cover import ( ATTR_POSITION, ATTR_TILT_POSITION, @@ -28,29 +29,26 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_track_utc_time_change -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from homeassistant.helpers.typing import ConfigType -from .const import DOMAIN +from .const import DATA_KNX_CONFIG, DOMAIN, SupportedPlatforms from .knx_entity import KnxEntity from .schema import CoverSchema -async def async_setup_platform( +async def async_setup_entry( hass: HomeAssistant, - config: ConfigType, + config_entry: config_entries.ConfigEntry, async_add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up cover(s) for KNX platform.""" - if not discovery_info or not discovery_info["platform_config"]: - return - platform_config = discovery_info["platform_config"] xknx: XKNX = hass.data[DOMAIN].xknx + config: list[ConfigType] = hass.data[DATA_KNX_CONFIG][ + SupportedPlatforms.COVER.value + ] - _async_migrate_unique_id(hass, platform_config) - async_add_entities( - KNXCover(xknx, entity_config) for entity_config in platform_config - ) + _async_migrate_unique_id(hass, config) + async_add_entities(KNXCover(xknx, entity_config) for entity_config in config) @callback diff --git a/homeassistant/components/knx/fan.py b/homeassistant/components/knx/fan.py index a000cdec9737..bdb0bbf9dcca 100644 --- a/homeassistant/components/knx/fan.py +++ b/homeassistant/components/knx/fan.py @@ -7,37 +7,35 @@ from typing import Any, Final from xknx import XKNX from xknx.devices import Fan as XknxFan +from homeassistant import config_entries from homeassistant.components.fan import SUPPORT_OSCILLATE, SUPPORT_SET_SPEED, FanEntity from homeassistant.const import CONF_ENTITY_CATEGORY, CONF_NAME from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from homeassistant.helpers.typing import ConfigType from homeassistant.util.percentage import ( int_states_in_range, percentage_to_ranged_value, ranged_value_to_percentage, ) -from .const import DOMAIN, KNX_ADDRESS +from .const import DATA_KNX_CONFIG, DOMAIN, KNX_ADDRESS, SupportedPlatforms from .knx_entity import KnxEntity from .schema import FanSchema DEFAULT_PERCENTAGE: Final = 50 -async def async_setup_platform( +async def async_setup_entry( hass: HomeAssistant, - config: ConfigType, + config_entry: config_entries.ConfigEntry, async_add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, ) -> None: - """Set up fans for KNX platform.""" - if not discovery_info or not discovery_info["platform_config"]: - return - platform_config = discovery_info["platform_config"] + """Set up fan(s) for KNX platform.""" xknx: XKNX = hass.data[DOMAIN].xknx + config: list[ConfigType] = hass.data[DATA_KNX_CONFIG][SupportedPlatforms.FAN.value] - async_add_entities(KNXFan(xknx, entity_config) for entity_config in platform_config) + async_add_entities(KNXFan(xknx, entity_config) for entity_config in config) class KNXFan(KnxEntity, FanEntity): diff --git a/homeassistant/components/knx/light.py b/homeassistant/components/knx/light.py index 4841b056ebc0..f9223d69d74d 100644 --- a/homeassistant/components/knx/light.py +++ b/homeassistant/components/knx/light.py @@ -7,6 +7,7 @@ from xknx import XKNX from xknx.devices.light import Light as XknxLight, XYYColor from xknx.telegram.address import parse_device_group_address +from homeassistant import config_entries from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_COLOR_TEMP, @@ -27,30 +28,33 @@ from homeassistant.const import CONF_ENTITY_CATEGORY, CONF_NAME from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from homeassistant.helpers.typing import ConfigType import homeassistant.util.color as color_util -from .const import DOMAIN, KNX_ADDRESS, ColorTempModes +from .const import ( + DATA_KNX_CONFIG, + DOMAIN, + KNX_ADDRESS, + ColorTempModes, + SupportedPlatforms, +) from .knx_entity import KnxEntity from .schema import LightSchema -async def async_setup_platform( +async def async_setup_entry( hass: HomeAssistant, - config: ConfigType, + config_entry: config_entries.ConfigEntry, async_add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, ) -> None: - """Set up lights for KNX platform.""" - if not discovery_info or not discovery_info["platform_config"]: - return - platform_config = discovery_info["platform_config"] + """Set up light(s) for KNX platform.""" xknx: XKNX = hass.data[DOMAIN].xknx + config: list[ConfigType] = hass.data[DATA_KNX_CONFIG][ + SupportedPlatforms.LIGHT.value + ] - _async_migrate_unique_id(hass, platform_config) - async_add_entities( - KNXLight(xknx, entity_config) for entity_config in platform_config - ) + _async_migrate_unique_id(hass, config) + async_add_entities(KNXLight(xknx, entity_config) for entity_config in config) @callback diff --git a/homeassistant/components/knx/manifest.json b/homeassistant/components/knx/manifest.json index 30dafe7d7f95..b793c6673538 100644 --- a/homeassistant/components/knx/manifest.json +++ b/homeassistant/components/knx/manifest.json @@ -1,9 +1,16 @@ { "domain": "knx", "name": "KNX", + "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/knx", - "requirements": ["xknx==0.18.13"], - "codeowners": ["@Julius2342", "@farmio", "@marvin-w"], + "requirements": [ + "xknx==0.18.13" + ], + "codeowners": [ + "@Julius2342", + "@farmio", + "@marvin-w" + ], "quality_scale": "silver", "iot_class": "local_push" -} +} \ No newline at end of file diff --git a/homeassistant/components/knx/notify.py b/homeassistant/components/knx/notify.py index 6f549d9cfac4..61bee14e5e24 100644 --- a/homeassistant/components/knx/notify.py +++ b/homeassistant/components/knx/notify.py @@ -20,10 +20,10 @@ async def async_get_service( discovery_info: DiscoveryInfoType | None = None, ) -> KNXNotificationService | None: """Get the KNX notification service.""" - if not discovery_info or not discovery_info["platform_config"]: + if not discovery_info: return None - platform_config = discovery_info["platform_config"] + platform_config: dict = discovery_info xknx: XKNX = hass.data[DOMAIN].xknx notification_devices = [] diff --git a/homeassistant/components/knx/number.py b/homeassistant/components/knx/number.py index 659c334fd4a2..7d4ea8a717b4 100644 --- a/homeassistant/components/knx/number.py +++ b/homeassistant/components/knx/number.py @@ -6,6 +6,7 @@ from typing import cast from xknx import XKNX from xknx.devices import NumericValue +from homeassistant import config_entries from homeassistant.components.number import NumberEntity from homeassistant.const import ( CONF_ENTITY_CATEGORY, @@ -18,28 +19,32 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from homeassistant.helpers.typing import ConfigType -from .const import CONF_RESPOND_TO_READ, CONF_STATE_ADDRESS, DOMAIN, KNX_ADDRESS +from .const import ( + CONF_RESPOND_TO_READ, + CONF_STATE_ADDRESS, + DATA_KNX_CONFIG, + DOMAIN, + KNX_ADDRESS, + SupportedPlatforms, +) from .knx_entity import KnxEntity from .schema import NumberSchema -async def async_setup_platform( +async def async_setup_entry( hass: HomeAssistant, - config: ConfigType, + config_entry: config_entries.ConfigEntry, async_add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, ) -> None: - """Set up number entities for KNX platform.""" - if not discovery_info or not discovery_info["platform_config"]: - return - platform_config = discovery_info["platform_config"] + """Set up number(s) for KNX platform.""" xknx: XKNX = hass.data[DOMAIN].xknx + config: list[ConfigType] = hass.data[DATA_KNX_CONFIG][ + SupportedPlatforms.NUMBER.value + ] - async_add_entities( - KNXNumber(xknx, entity_config) for entity_config in platform_config - ) + async_add_entities(KNXNumber(xknx, entity_config) for entity_config in config) def _create_numeric_value(xknx: XKNX, config: ConfigType) -> NumericValue: diff --git a/homeassistant/components/knx/scene.py b/homeassistant/components/knx/scene.py index b09dc678be36..658c6d6d2984 100644 --- a/homeassistant/components/knx/scene.py +++ b/homeassistant/components/knx/scene.py @@ -6,32 +6,30 @@ from typing import Any from xknx import XKNX from xknx.devices import Scene as XknxScene +from homeassistant import config_entries from homeassistant.components.scene import Scene from homeassistant.const import CONF_ENTITY_CATEGORY, CONF_NAME from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from homeassistant.helpers.typing import ConfigType -from .const import DOMAIN, KNX_ADDRESS +from .const import DATA_KNX_CONFIG, DOMAIN, KNX_ADDRESS, SupportedPlatforms from .knx_entity import KnxEntity from .schema import SceneSchema -async def async_setup_platform( +async def async_setup_entry( hass: HomeAssistant, - config: ConfigType, + config_entry: config_entries.ConfigEntry, async_add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, ) -> None: - """Set up the scenes for KNX platform.""" - if not discovery_info or not discovery_info["platform_config"]: - return - platform_config = discovery_info["platform_config"] + """Set up scene(s) for KNX platform.""" xknx: XKNX = hass.data[DOMAIN].xknx + config: list[ConfigType] = hass.data[DATA_KNX_CONFIG][ + SupportedPlatforms.SCENE.value + ] - async_add_entities( - KNXScene(xknx, entity_config) for entity_config in platform_config - ) + async_add_entities(KNXScene(xknx, entity_config) for entity_config in config) class KNXScene(KnxEntity, Scene): diff --git a/homeassistant/components/knx/schema.py b/homeassistant/components/knx/schema.py index d84531bbcaf5..02e4803b1639 100644 --- a/homeassistant/components/knx/schema.py +++ b/homeassistant/components/knx/schema.py @@ -201,7 +201,11 @@ sync_state_validator = vol.Any( class ConnectionSchema: - """Voluptuous schema for KNX connection.""" + """ + Voluptuous schema for KNX connection. + + DEPRECATED: Migrated to config and options flow. Will be removed in a future version of Home Assistant. + """ CONF_KNX_LOCAL_IP = "local_ip" CONF_KNX_MCAST_GRP = "multicast_group" @@ -210,6 +214,9 @@ class ConnectionSchema: CONF_KNX_ROUTE_BACK = "route_back" CONF_KNX_STATE_UPDATER = "state_updater" + CONF_KNX_DEFAULT_STATE_UPDATER = True + CONF_KNX_DEFAULT_RATE_LIMIT = 20 + TUNNELING_SCHEMA = vol.Schema( { vol.Optional(CONF_PORT, default=DEFAULT_MCAST_PORT): cv.port, @@ -229,8 +236,10 @@ class ConnectionSchema: ): ia_validator, vol.Optional(CONF_KNX_MCAST_GRP, default=DEFAULT_MCAST_GRP): cv.string, vol.Optional(CONF_KNX_MCAST_PORT, default=DEFAULT_MCAST_PORT): cv.port, - vol.Optional(CONF_KNX_STATE_UPDATER, default=True): cv.boolean, - vol.Optional(CONF_KNX_RATE_LIMIT, default=20): vol.All( + vol.Optional( + CONF_KNX_STATE_UPDATER, default=CONF_KNX_DEFAULT_STATE_UPDATER + ): cv.boolean, + vol.Optional(CONF_KNX_RATE_LIMIT, default=CONF_KNX_DEFAULT_RATE_LIMIT): vol.All( vol.Coerce(int), vol.Range(min=1, max=100) ), } diff --git a/homeassistant/components/knx/select.py b/homeassistant/components/knx/select.py index f002bad37ce0..aefa4749e883 100644 --- a/homeassistant/components/knx/select.py +++ b/homeassistant/components/knx/select.py @@ -4,6 +4,7 @@ from __future__ import annotations from xknx import XKNX from xknx.devices import Device as XknxDevice, RawValue +from homeassistant import config_entries from homeassistant.components.select import SelectEntity from homeassistant.const import ( CONF_ENTITY_CATEGORY, @@ -14,7 +15,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from homeassistant.helpers.typing import ConfigType from .const import ( CONF_PAYLOAD, @@ -22,28 +23,27 @@ from .const import ( CONF_RESPOND_TO_READ, CONF_STATE_ADDRESS, CONF_SYNC_STATE, + DATA_KNX_CONFIG, DOMAIN, KNX_ADDRESS, + SupportedPlatforms, ) from .knx_entity import KnxEntity from .schema import SelectSchema -async def async_setup_platform( +async def async_setup_entry( hass: HomeAssistant, - config: ConfigType, + config_entry: config_entries.ConfigEntry, async_add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, ) -> None: - """Set up select entities for KNX platform.""" - if not discovery_info or not discovery_info["platform_config"]: - return - platform_config = discovery_info["platform_config"] + """Set up select(s) for KNX platform.""" xknx: XKNX = hass.data[DOMAIN].xknx + config: list[ConfigType] = hass.data[DATA_KNX_CONFIG][ + SupportedPlatforms.SELECT.value + ] - async_add_entities( - KNXSelect(xknx, entity_config) for entity_config in platform_config - ) + async_add_entities(KNXSelect(xknx, entity_config) for entity_config in config) def _create_raw_value(xknx: XKNX, config: ConfigType) -> RawValue: diff --git a/homeassistant/components/knx/sensor.py b/homeassistant/components/knx/sensor.py index 84f535fdc8f8..a9a1feca9e35 100644 --- a/homeassistant/components/knx/sensor.py +++ b/homeassistant/components/knx/sensor.py @@ -6,6 +6,7 @@ from typing import Any from xknx import XKNX from xknx.devices import Sensor as XknxSensor +from homeassistant import config_entries from homeassistant.components.sensor import ( CONF_STATE_CLASS, DEVICE_CLASSES, @@ -14,28 +15,25 @@ from homeassistant.components.sensor import ( from homeassistant.const import CONF_ENTITY_CATEGORY, CONF_NAME, CONF_TYPE from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType, StateType +from homeassistant.helpers.typing import ConfigType, StateType -from .const import ATTR_SOURCE, DOMAIN +from .const import ATTR_SOURCE, DATA_KNX_CONFIG, DOMAIN, SupportedPlatforms from .knx_entity import KnxEntity from .schema import SensorSchema -async def async_setup_platform( +async def async_setup_entry( hass: HomeAssistant, - config: ConfigType, + config_entry: config_entries.ConfigEntry, async_add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up sensor(s) for KNX platform.""" - if not discovery_info or not discovery_info["platform_config"]: - return - platform_config = discovery_info["platform_config"] xknx: XKNX = hass.data[DOMAIN].xknx + config: list[ConfigType] = hass.data[DATA_KNX_CONFIG][ + SupportedPlatforms.SENSOR.value + ] - async_add_entities( - KNXSensor(xknx, entity_config) for entity_config in platform_config - ) + async_add_entities(KNXSensor(xknx, entity_config) for entity_config in config) def _create_sensor(xknx: XKNX, config: ConfigType) -> XknxSensor: diff --git a/homeassistant/components/knx/services.yaml b/homeassistant/components/knx/services.yaml index fca5f4fe07dc..11519be48f31 100644 --- a/homeassistant/components/knx/services.yaml +++ b/homeassistant/components/knx/services.yaml @@ -100,6 +100,3 @@ exposure_register: default: false selector: boolean: -reload: - name: "Reload KNX configuration" - description: "Reload the KNX configuration from YAML." diff --git a/homeassistant/components/knx/strings.json b/homeassistant/components/knx/strings.json new file mode 100644 index 000000000000..ff191f7a4ce6 --- /dev/null +++ b/homeassistant/components/knx/strings.json @@ -0,0 +1,63 @@ +{ + "config": { + "step": { + "type": { + "description": "Please enter the connection type we should use for your KNX connection. \n AUTOMATIC - The integration takes care of the connectivity to your KNX Bus by performing a gateway scan. \n TUNNELING - The integration will connect to your KNX bus via tunneling. \n ROUTING - The integration will connect to your KNX bus via routing.", + "data": { + "connection_type": "KNX Connection Type" + } + }, + "tunnel": { + "description": "Please select a gateway from the list.", + "data": { + "gateway": "KNX Tunnel Connection" + } + }, + "manual_tunnel": { + "description": "Please enter the connection information of your tunneling device.", + "data": { + "port": "[%key:common::config_flow::data::port%]", + "host": "[%key:common::config_flow::data::host%]", + "individual_address": "Individual address for the connection", + "route_back": "Route Back / NAT Mode" + } + }, + "routing": { + "description": "Please configure the routing options.", + "data": { + "individual_address": "Individual address for the routing connection", + "multicast_group": "The multicast group used for routing", + "multicast_port": "The multicast port used for routing" + } + } + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_service%]", + "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]" + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" + } + }, + "options": { + "step": { + "init": { + "data": { + "connection_type": "KNX Connection Type", + "individual_address": "Default individual address", + "multicast_group": "Multicast group used for routing and discovery", + "multicast_port": "Multicast port used for routing and discovery", + "state_updater": "Globally enable reading states from the KNX Bus", + "rate_limit": "Maximum outgoing telegrams per second" + } + }, + "tunnel": { + "data": { + "port": "[%key:common::config_flow::data::port%]", + "host": "[%key:common::config_flow::data::host%]", + "route_back": "Route Back / NAT Mode" + } + } + } + } +} diff --git a/homeassistant/components/knx/switch.py b/homeassistant/components/knx/switch.py index c775ce70d32c..3bbb419a22b2 100644 --- a/homeassistant/components/knx/switch.py +++ b/homeassistant/components/knx/switch.py @@ -6,6 +6,7 @@ from typing import Any from xknx import XKNX from xknx.devices import Switch as XknxSwitch +from homeassistant import config_entries from homeassistant.components.switch import SwitchEntity from homeassistant.const import ( CONF_ENTITY_CATEGORY, @@ -17,28 +18,31 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from homeassistant.helpers.typing import ConfigType -from .const import CONF_RESPOND_TO_READ, DOMAIN, KNX_ADDRESS +from .const import ( + CONF_RESPOND_TO_READ, + DATA_KNX_CONFIG, + DOMAIN, + KNX_ADDRESS, + SupportedPlatforms, +) from .knx_entity import KnxEntity from .schema import SwitchSchema -async def async_setup_platform( +async def async_setup_entry( hass: HomeAssistant, - config: ConfigType, + config_entry: config_entries.ConfigEntry, async_add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up switch(es) for KNX platform.""" - if not discovery_info or not discovery_info["platform_config"]: - return - platform_config = discovery_info["platform_config"] xknx: XKNX = hass.data[DOMAIN].xknx + config: list[ConfigType] = hass.data[DATA_KNX_CONFIG][ + SupportedPlatforms.SWITCH.value + ] - async_add_entities( - KNXSwitch(xknx, entity_config) for entity_config in platform_config - ) + async_add_entities(KNXSwitch(xknx, entity_config) for entity_config in config) class KNXSwitch(KnxEntity, SwitchEntity, RestoreEntity): diff --git a/homeassistant/components/knx/translations/en.json b/homeassistant/components/knx/translations/en.json new file mode 100644 index 000000000000..231cc790db88 --- /dev/null +++ b/homeassistant/components/knx/translations/en.json @@ -0,0 +1,63 @@ +{ + "config": { + "step": { + "type": { + "description": "Please enter the connection type we should use for your KNX connection. \n AUTOMATIC - The integration takes care of the connectivity to your KNX Bus by performing a gateway scan. \n TUNNELING - The integration will connect to your KNX bus via tunneling. \n ROUTING - The integration will connect to your KNX bus via routing.", + "data": { + "connection_type": "KNX Connection Type" + } + }, + "tunnel": { + "description": "Please select a gateway from the list.", + "data": { + "gateway": "KNX Tunnel Connection" + } + }, + "manual_tunnel": { + "description": "Please enter the connection information of your tunneling device.", + "data": { + "host": "IP Address of KNX gateway", + "port": "Port of KNX gateway", + "individual_address": "Individual address for the connection", + "route_back": "Route Back / NAT Mode" + } + }, + "routing": { + "description": "Please configure the routing options.", + "data": { + "individual_address": "Individual address for the routing connection", + "multicast_group": "The multicast group used for routing", + "multicast_port": "The multicast port used for routing" + } + } + }, + "abort": { + "already_configured": "Service is already configured", + "single_instance_allowed": "Already configured. Only a single configuration possible." + }, + "error": { + "cannot_connect": "Failed to connect." + } + }, + "options": { + "step": { + "init": { + "data": { + "connection_type": "KNX Connection Type", + "individual_address": "Default individual address", + "multicast_group": "Multicast group used for routing and discovery", + "multicast_port": "Multicast port used for routing and discovery", + "state_updater": "Globally enable reading states from the KNX Bus", + "rate_limit": "Maximum outgoing telegrams per second" + } + }, + "tunnel": { + "data": { + "port": "Port of KNX gateway", + "host": "IP Address of KNX gateway", + "route_back": "Route Back / NAT Mode" + } + } + } + } +} diff --git a/homeassistant/components/knx/weather.py b/homeassistant/components/knx/weather.py index 13ebd2480e3e..b52ee644b394 100644 --- a/homeassistant/components/knx/weather.py +++ b/homeassistant/components/knx/weather.py @@ -4,32 +4,30 @@ from __future__ import annotations from xknx import XKNX from xknx.devices import Weather as XknxWeather +from homeassistant import config_entries from homeassistant.components.weather import WeatherEntity from homeassistant.const import CONF_ENTITY_CATEGORY, CONF_NAME, TEMP_CELSIUS from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from homeassistant.helpers.typing import ConfigType -from .const import DOMAIN +from .const import DATA_KNX_CONFIG, DOMAIN, SupportedPlatforms from .knx_entity import KnxEntity from .schema import WeatherSchema -async def async_setup_platform( +async def async_setup_entry( hass: HomeAssistant, - config: ConfigType, + config_entry: config_entries.ConfigEntry, async_add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, ) -> None: - """Set up weather entities for KNX platform.""" - if not discovery_info or not discovery_info["platform_config"]: - return - platform_config = discovery_info["platform_config"] + """Set up switch(es) for KNX platform.""" xknx: XKNX = hass.data[DOMAIN].xknx + config: list[ConfigType] = hass.data[DATA_KNX_CONFIG][ + SupportedPlatforms.WEATHER.value + ] - async_add_entities( - KNXWeather(xknx, entity_config) for entity_config in platform_config - ) + async_add_entities(KNXWeather(xknx, entity_config) for entity_config in config) def _create_weather(xknx: XKNX, config: ConfigType) -> XknxWeather: diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 5d865465e61f..4d9cb7d7bc4a 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -152,6 +152,7 @@ FLOWS = [ "juicenet", "keenetic_ndms2", "kmtronic", + "knx", "kodi", "konnected", "kostal_plenticore", diff --git a/tests/components/knx/conftest.py b/tests/components/knx/conftest.py index ca6f81057e1d..a692fa978140 100644 --- a/tests/components/knx/conftest.py +++ b/tests/components/knx/conftest.py @@ -8,23 +8,33 @@ import pytest from xknx import XKNX from xknx.core import XknxConnectionState from xknx.dpt import DPTArray, DPTBinary +from xknx.io import DEFAULT_MCAST_GRP, DEFAULT_MCAST_PORT from xknx.telegram import Telegram, TelegramDirection from xknx.telegram.address import GroupAddress, IndividualAddress from xknx.telegram.apci import APCI, GroupValueRead, GroupValueResponse, GroupValueWrite -from homeassistant.components.knx.const import DOMAIN as KNX_DOMAIN +from homeassistant.components.knx import ConnectionSchema +from homeassistant.components.knx.const import ( + CONF_KNX_AUTOMATIC, + CONF_KNX_CONNECTION_TYPE, + CONF_KNX_INDIVIDUAL_ADDRESS, + DOMAIN as KNX_DOMAIN, +) from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component +from tests.common import MockConfigEntry + class KNXTestKit: """Test helper for the KNX integration.""" INDIVIDUAL_ADDRESS = "1.2.3" - def __init__(self, hass: HomeAssistant): + def __init__(self, hass: HomeAssistant, mock_config_entry: MockConfigEntry): """Init KNX test helper class.""" self.hass: HomeAssistant = hass + self.mock_config_entry: MockConfigEntry = mock_config_entry self.xknx: XKNX # outgoing telegrams will be put in the Queue instead of sent to the interface # telegrams to an InternalGroupAddress won't be queued here @@ -60,6 +70,7 @@ class KNXTestKit: return_value=knx_ip_interface_mock(), side_effect=fish_xknx, ): + self.mock_config_entry.add_to_hass(self.hass) await async_setup_component(self.hass, KNX_DOMAIN, {KNX_DOMAIN: config}) await self.xknx.connection_manager.connection_state_changed( XknxConnectionState.CONNECTED @@ -191,8 +202,23 @@ class KNXTestKit: @pytest.fixture -async def knx(request, hass): +def mock_config_entry() -> MockConfigEntry: + """Return the default mocked config entry.""" + return MockConfigEntry( + title="KNX", + domain=KNX_DOMAIN, + data={ + CONF_KNX_INDIVIDUAL_ADDRESS: XKNX.DEFAULT_ADDRESS, + ConnectionSchema.CONF_KNX_MCAST_GRP: DEFAULT_MCAST_GRP, + ConnectionSchema.CONF_KNX_MCAST_PORT: DEFAULT_MCAST_PORT, + CONF_KNX_CONNECTION_TYPE: CONF_KNX_AUTOMATIC, + }, + ) + + +@pytest.fixture +async def knx(request, hass, mock_config_entry: MockConfigEntry): """Create a KNX TestKit instance.""" - knx_test_kit = KNXTestKit(hass) + knx_test_kit = KNXTestKit(hass, mock_config_entry) yield knx_test_kit await knx_test_kit.assert_no_telegram() diff --git a/tests/components/knx/test_config_flow.py b/tests/components/knx/test_config_flow.py new file mode 100644 index 000000000000..2b792044fe50 --- /dev/null +++ b/tests/components/knx/test_config_flow.py @@ -0,0 +1,573 @@ +"""Test the KNX config flow.""" +from unittest.mock import patch + +from xknx import XKNX +from xknx.io import DEFAULT_MCAST_GRP +from xknx.io.gateway_scanner import GatewayDescriptor + +from homeassistant import config_entries +from homeassistant.components.knx import ConnectionSchema +from homeassistant.components.knx.config_flow import ( + CONF_KNX_GATEWAY, + DEFAULT_ENTRY_DATA, +) +from homeassistant.components.knx.const import ( + CONF_KNX_AUTOMATIC, + CONF_KNX_CONNECTION_TYPE, + CONF_KNX_INDIVIDUAL_ADDRESS, + CONF_KNX_ROUTING, + CONF_KNX_TUNNELING, + DOMAIN, +) +from homeassistant.const import CONF_HOST, CONF_PORT +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import RESULT_TYPE_CREATE_ENTRY, RESULT_TYPE_FORM + +from tests.common import MockConfigEntry + + +def _gateway_descriptor(ip: str, port: int) -> GatewayDescriptor: + """Get mock gw descriptor.""" + return GatewayDescriptor("Test", ip, port, "eth0", "127.0.0.1", True) + + +async def test_user_single_instance(hass): + """Test we only allow a single config flow.""" + MockConfigEntry(domain=DOMAIN).add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == "abort" + assert result["reason"] == "single_instance_allowed" + + +async def test_routing_setup(hass: HomeAssistant) -> None: + """Test routing setup.""" + with patch("xknx.io.gateway_scanner.GatewayScanner.scan") as gateways: + gateways.return_value = [] + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == RESULT_TYPE_FORM + assert not result["errors"] + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_KNX_CONNECTION_TYPE: CONF_KNX_ROUTING, + }, + ) + await hass.async_block_till_done() + assert result2["type"] == RESULT_TYPE_FORM + assert result2["step_id"] == "routing" + assert not result2["errors"] + + with patch( + "homeassistant.components.knx.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + { + ConnectionSchema.CONF_KNX_MCAST_GRP: DEFAULT_MCAST_GRP, + ConnectionSchema.CONF_KNX_MCAST_PORT: 3675, + CONF_KNX_INDIVIDUAL_ADDRESS: "1.1.110", + }, + ) + await hass.async_block_till_done() + assert result3["type"] == RESULT_TYPE_CREATE_ENTRY + assert result3["title"] == CONF_KNX_ROUTING.capitalize() + assert result3["data"] == { + **DEFAULT_ENTRY_DATA, + CONF_KNX_CONNECTION_TYPE: CONF_KNX_ROUTING, + ConnectionSchema.CONF_KNX_MCAST_GRP: DEFAULT_MCAST_GRP, + ConnectionSchema.CONF_KNX_MCAST_PORT: 3675, + CONF_KNX_INDIVIDUAL_ADDRESS: "1.1.110", + } + + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_tunneling_setup(hass: HomeAssistant) -> None: + """Test tunneling if only one gateway is found.""" + gateway = _gateway_descriptor("192.168.0.1", 3675) + with patch("xknx.io.gateway_scanner.GatewayScanner.scan") as gateways: + gateways.return_value = [gateway] + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == RESULT_TYPE_FORM + assert not result["errors"] + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_KNX_CONNECTION_TYPE: CONF_KNX_TUNNELING, + }, + ) + await hass.async_block_till_done() + assert result2["type"] == RESULT_TYPE_FORM + assert result2["step_id"] == "manual_tunnel" + assert not result2["errors"] + + with patch( + "homeassistant.components.knx.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + { + CONF_HOST: "192.168.0.1", + CONF_PORT: 3675, + }, + ) + await hass.async_block_till_done() + assert result3["type"] == RESULT_TYPE_CREATE_ENTRY + assert result3["title"] == "Tunneling @ 192.168.0.1" + assert result3["data"] == { + **DEFAULT_ENTRY_DATA, + CONF_KNX_CONNECTION_TYPE: CONF_KNX_TUNNELING, + CONF_HOST: "192.168.0.1", + CONF_PORT: 3675, + CONF_KNX_INDIVIDUAL_ADDRESS: "15.15.250", + ConnectionSchema.CONF_KNX_ROUTE_BACK: False, + } + + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_tunneling_setup_for_multiple_found_gateways(hass: HomeAssistant) -> None: + """Test tunneling if only one gateway is found.""" + gateway = _gateway_descriptor("192.168.0.1", 3675) + gateway2 = _gateway_descriptor("192.168.1.100", 3675) + with patch("xknx.io.gateway_scanner.GatewayScanner.scan") as gateways: + gateways.return_value = [gateway, gateway2] + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == RESULT_TYPE_FORM + assert not result["errors"] + + tunnel_flow = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_KNX_CONNECTION_TYPE: CONF_KNX_TUNNELING, + }, + ) + await hass.async_block_till_done() + assert tunnel_flow["type"] == RESULT_TYPE_FORM + assert tunnel_flow["step_id"] == "tunnel" + assert not tunnel_flow["errors"] + + manual_tunnel = await hass.config_entries.flow.async_configure( + tunnel_flow["flow_id"], + {CONF_KNX_GATEWAY: str(gateway)}, + ) + await hass.async_block_till_done() + assert manual_tunnel["type"] == RESULT_TYPE_FORM + assert manual_tunnel["step_id"] == "manual_tunnel" + + with patch( + "homeassistant.components.knx.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + manual_tunnel_flow = await hass.config_entries.flow.async_configure( + manual_tunnel["flow_id"], + { + CONF_HOST: "192.168.0.1", + CONF_PORT: 3675, + }, + ) + await hass.async_block_till_done() + assert manual_tunnel_flow["type"] == RESULT_TYPE_CREATE_ENTRY + assert manual_tunnel_flow["data"] == { + **DEFAULT_ENTRY_DATA, + CONF_KNX_CONNECTION_TYPE: CONF_KNX_TUNNELING, + CONF_HOST: "192.168.0.1", + CONF_PORT: 3675, + CONF_KNX_INDIVIDUAL_ADDRESS: "15.15.250", + ConnectionSchema.CONF_KNX_ROUTE_BACK: False, + } + + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_manual_tunnel_step_when_no_gateway(hass: HomeAssistant) -> None: + """Test manual tunnel if no gateway is found and tunneling is selected.""" + with patch("xknx.io.gateway_scanner.GatewayScanner.scan") as gateways: + gateways.return_value = [] + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == RESULT_TYPE_FORM + assert not result["errors"] + + tunnel_flow = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_KNX_CONNECTION_TYPE: CONF_KNX_TUNNELING, + }, + ) + await hass.async_block_till_done() + assert tunnel_flow["type"] == RESULT_TYPE_FORM + assert tunnel_flow["step_id"] == "manual_tunnel" + assert not tunnel_flow["errors"] + + +async def test_form_with_automatic_connection_handling(hass: HomeAssistant) -> None: + """Test we get the form.""" + with patch("xknx.io.gateway_scanner.GatewayScanner.scan") as gateways: + gateways.return_value = [_gateway_descriptor("192.168.0.1", 3675)] + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == RESULT_TYPE_FORM + assert not result["errors"] + + with patch( + "homeassistant.components.knx.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_KNX_CONNECTION_TYPE: CONF_KNX_AUTOMATIC, + }, + ) + await hass.async_block_till_done() + + assert result2["type"] == RESULT_TYPE_CREATE_ENTRY + assert result2["title"] == CONF_KNX_AUTOMATIC.capitalize() + assert result2["data"] == { + **DEFAULT_ENTRY_DATA, + CONF_KNX_CONNECTION_TYPE: CONF_KNX_AUTOMATIC, + } + + assert len(mock_setup_entry.mock_calls) == 1 + + +## +# Import Tests +## +async def test_import_config_tunneling(hass: HomeAssistant) -> None: + """Test tunneling import from config.yaml.""" + config = { + CONF_KNX_INDIVIDUAL_ADDRESS: XKNX.DEFAULT_ADDRESS, + ConnectionSchema.CONF_KNX_MCAST_GRP: DEFAULT_MCAST_GRP, # has a default in the original config + ConnectionSchema.CONF_KNX_MCAST_PORT: 3675, # has a default in the original config + ConnectionSchema.CONF_KNX_RATE_LIMIT: 20, # has a default in the original config + ConnectionSchema.CONF_KNX_STATE_UPDATER: True, # has a default in the original config + CONF_KNX_TUNNELING: { + CONF_HOST: "192.168.1.1", + CONF_PORT: 3675, + ConnectionSchema.CONF_KNX_ROUTE_BACK: True, + }, + } + + with patch( + "homeassistant.components.knx.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, data=config + ) + await hass.async_block_till_done() + assert result["type"] == RESULT_TYPE_CREATE_ENTRY + assert result["title"] == "Tunneling @ 192.168.1.1" + assert result["data"] == { + CONF_KNX_CONNECTION_TYPE: CONF_KNX_TUNNELING, + CONF_HOST: "192.168.1.1", + CONF_PORT: 3675, + CONF_KNX_INDIVIDUAL_ADDRESS: "15.15.250", + ConnectionSchema.CONF_KNX_ROUTE_BACK: True, + ConnectionSchema.CONF_KNX_STATE_UPDATER: True, + ConnectionSchema.CONF_KNX_RATE_LIMIT: 20, + ConnectionSchema.CONF_KNX_MCAST_PORT: 3675, + ConnectionSchema.CONF_KNX_MCAST_GRP: DEFAULT_MCAST_GRP, + } + + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_import_config_routing(hass: HomeAssistant) -> None: + """Test routing import from config.yaml.""" + config = { + CONF_KNX_INDIVIDUAL_ADDRESS: XKNX.DEFAULT_ADDRESS, # has a default in the original config + ConnectionSchema.CONF_KNX_MCAST_GRP: DEFAULT_MCAST_GRP, # has a default in the original config + ConnectionSchema.CONF_KNX_MCAST_PORT: 3675, # has a default in the original config + ConnectionSchema.CONF_KNX_RATE_LIMIT: 20, # has a default in the original config + ConnectionSchema.CONF_KNX_STATE_UPDATER: True, # has a default in the original config + CONF_KNX_ROUTING: {}, # is required when using routing + } + + with patch( + "homeassistant.components.knx.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, data=config + ) + await hass.async_block_till_done() + assert result["type"] == RESULT_TYPE_CREATE_ENTRY + assert result["title"] == CONF_KNX_ROUTING.capitalize() + assert result["data"] == { + CONF_KNX_CONNECTION_TYPE: CONF_KNX_ROUTING, + CONF_KNX_INDIVIDUAL_ADDRESS: "15.15.250", + ConnectionSchema.CONF_KNX_MCAST_PORT: 3675, + ConnectionSchema.CONF_KNX_MCAST_GRP: DEFAULT_MCAST_GRP, + ConnectionSchema.CONF_KNX_STATE_UPDATER: True, + ConnectionSchema.CONF_KNX_RATE_LIMIT: 20, + } + + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_import_config_automatic(hass: HomeAssistant) -> None: + """Test automatic import from config.yaml.""" + config = { + CONF_KNX_INDIVIDUAL_ADDRESS: XKNX.DEFAULT_ADDRESS, # has a default in the original config + ConnectionSchema.CONF_KNX_MCAST_GRP: DEFAULT_MCAST_GRP, # has a default in the original config + ConnectionSchema.CONF_KNX_MCAST_PORT: 3675, # has a default in the original config + ConnectionSchema.CONF_KNX_RATE_LIMIT: 20, # has a default in the original config + ConnectionSchema.CONF_KNX_STATE_UPDATER: True, # has a default in the original config + } + + with patch( + "homeassistant.components.knx.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, data=config + ) + await hass.async_block_till_done() + assert result["type"] == RESULT_TYPE_CREATE_ENTRY + assert result["title"] == CONF_KNX_AUTOMATIC.capitalize() + assert result["data"] == { + CONF_KNX_CONNECTION_TYPE: CONF_KNX_AUTOMATIC, + CONF_KNX_INDIVIDUAL_ADDRESS: "15.15.250", + ConnectionSchema.CONF_KNX_MCAST_PORT: 3675, + ConnectionSchema.CONF_KNX_MCAST_GRP: DEFAULT_MCAST_GRP, + ConnectionSchema.CONF_KNX_STATE_UPDATER: True, + ConnectionSchema.CONF_KNX_RATE_LIMIT: 20, + } + + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_import_rate_limit_out_of_range(hass: HomeAssistant) -> None: + """Test automatic import from config.yaml.""" + config = { + CONF_KNX_INDIVIDUAL_ADDRESS: XKNX.DEFAULT_ADDRESS, # has a default in the original config + ConnectionSchema.CONF_KNX_MCAST_GRP: DEFAULT_MCAST_GRP, # has a default in the original config + ConnectionSchema.CONF_KNX_MCAST_PORT: 3675, # has a default in the original config + ConnectionSchema.CONF_KNX_RATE_LIMIT: 80, + ConnectionSchema.CONF_KNX_STATE_UPDATER: True, # has a default in the original config + } + + with patch( + "homeassistant.components.knx.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, data=config + ) + await hass.async_block_till_done() + assert result["type"] == RESULT_TYPE_CREATE_ENTRY + assert result["title"] == CONF_KNX_AUTOMATIC.capitalize() + assert result["data"] == { + CONF_KNX_CONNECTION_TYPE: CONF_KNX_AUTOMATIC, + CONF_KNX_INDIVIDUAL_ADDRESS: "15.15.250", + ConnectionSchema.CONF_KNX_MCAST_PORT: 3675, + ConnectionSchema.CONF_KNX_MCAST_GRP: DEFAULT_MCAST_GRP, + ConnectionSchema.CONF_KNX_STATE_UPDATER: True, + ConnectionSchema.CONF_KNX_RATE_LIMIT: 60, + } + + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_import_options(hass: HomeAssistant) -> None: + """Test import from config.yaml with options.""" + config = { + CONF_KNX_INDIVIDUAL_ADDRESS: XKNX.DEFAULT_ADDRESS, # has a default in the original config + ConnectionSchema.CONF_KNX_MCAST_GRP: DEFAULT_MCAST_GRP, # has a default in the original config + ConnectionSchema.CONF_KNX_MCAST_PORT: 3675, # has a default in the original config + ConnectionSchema.CONF_KNX_STATE_UPDATER: False, + ConnectionSchema.CONF_KNX_RATE_LIMIT: 30, + } + + with patch( + "homeassistant.components.knx.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, data=config + ) + await hass.async_block_till_done() + assert result["type"] == RESULT_TYPE_CREATE_ENTRY + assert result["title"] == CONF_KNX_AUTOMATIC.capitalize() + assert result["data"] == { + CONF_KNX_CONNECTION_TYPE: CONF_KNX_AUTOMATIC, + CONF_KNX_INDIVIDUAL_ADDRESS: "15.15.250", + ConnectionSchema.CONF_KNX_MCAST_PORT: 3675, + ConnectionSchema.CONF_KNX_MCAST_GRP: DEFAULT_MCAST_GRP, + ConnectionSchema.CONF_KNX_STATE_UPDATER: False, + ConnectionSchema.CONF_KNX_RATE_LIMIT: 30, + } + + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_abort_if_entry_exists_already(hass: HomeAssistant) -> None: + """Test routing import from config.yaml.""" + MockConfigEntry(domain=DOMAIN).add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_IMPORT} + ) + assert result["type"] == "abort" + assert result["reason"] == "single_instance_allowed" + + +async def test_options_flow( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Test options config flow.""" + mock_config_entry.add_to_hass(hass) + + gateway = _gateway_descriptor("192.168.0.1", 3675) + with patch("xknx.io.gateway_scanner.GatewayScanner.scan") as gateways: + gateways.return_value = [gateway] + result = await hass.config_entries.options.async_init( + mock_config_entry.entry_id + ) + + assert result.get("type") == RESULT_TYPE_FORM + assert result.get("step_id") == "init" + assert "flow_id" in result + + result2 = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + CONF_KNX_CONNECTION_TYPE: CONF_KNX_AUTOMATIC, + CONF_KNX_INDIVIDUAL_ADDRESS: "15.15.255", + ConnectionSchema.CONF_KNX_MCAST_PORT: 3675, + ConnectionSchema.CONF_KNX_MCAST_GRP: DEFAULT_MCAST_GRP, + }, + ) + + await hass.async_block_till_done() + assert result2.get("type") == RESULT_TYPE_CREATE_ENTRY + assert not result2.get("data") + + assert mock_config_entry.data == { + CONF_KNX_CONNECTION_TYPE: CONF_KNX_AUTOMATIC, + CONF_KNX_INDIVIDUAL_ADDRESS: "15.15.255", + CONF_HOST: "", + ConnectionSchema.CONF_KNX_MCAST_PORT: 3675, + ConnectionSchema.CONF_KNX_MCAST_GRP: DEFAULT_MCAST_GRP, + ConnectionSchema.CONF_KNX_RATE_LIMIT: 20, + ConnectionSchema.CONF_KNX_STATE_UPDATER: True, + } + + +async def test_tunneling_options_flow( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Test options flow for tunneling.""" + mock_config_entry.add_to_hass(hass) + + gateway = _gateway_descriptor("192.168.0.1", 3675) + with patch("xknx.io.gateway_scanner.GatewayScanner.scan") as gateways: + gateways.return_value = [gateway] + result = await hass.config_entries.options.async_init( + mock_config_entry.entry_id + ) + + assert result.get("type") == RESULT_TYPE_FORM + assert result.get("step_id") == "init" + assert "flow_id" in result + + result2 = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + CONF_KNX_CONNECTION_TYPE: CONF_KNX_TUNNELING, + CONF_KNX_INDIVIDUAL_ADDRESS: "15.15.255", + ConnectionSchema.CONF_KNX_MCAST_PORT: 3675, + ConnectionSchema.CONF_KNX_MCAST_GRP: DEFAULT_MCAST_GRP, + }, + ) + + assert result2.get("type") == RESULT_TYPE_FORM + assert not result2.get("data") + assert "flow_id" in result2 + + result3 = await hass.config_entries.options.async_configure( + result2["flow_id"], + user_input={ + CONF_HOST: "192.168.1.1", + CONF_PORT: 3675, + ConnectionSchema.CONF_KNX_ROUTE_BACK: True, + }, + ) + + await hass.async_block_till_done() + assert result3.get("type") == RESULT_TYPE_CREATE_ENTRY + assert not result3.get("data") + + assert mock_config_entry.data == { + CONF_KNX_CONNECTION_TYPE: CONF_KNX_TUNNELING, + CONF_KNX_INDIVIDUAL_ADDRESS: "15.15.255", + ConnectionSchema.CONF_KNX_MCAST_PORT: 3675, + ConnectionSchema.CONF_KNX_MCAST_GRP: DEFAULT_MCAST_GRP, + ConnectionSchema.CONF_KNX_RATE_LIMIT: 20, + ConnectionSchema.CONF_KNX_STATE_UPDATER: True, + CONF_HOST: "192.168.1.1", + CONF_PORT: 3675, + ConnectionSchema.CONF_KNX_ROUTE_BACK: True, + } + + +async def test_advanced_options( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Test options config flow.""" + mock_config_entry.add_to_hass(hass) + + gateway = _gateway_descriptor("192.168.0.1", 3675) + with patch("xknx.io.gateway_scanner.GatewayScanner.scan") as gateways: + gateways.return_value = [gateway] + result = await hass.config_entries.options.async_init( + mock_config_entry.entry_id, context={"show_advanced_options": True} + ) + + assert result.get("type") == RESULT_TYPE_FORM + assert result.get("step_id") == "init" + assert "flow_id" in result + + result2 = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + CONF_KNX_CONNECTION_TYPE: CONF_KNX_AUTOMATIC, + CONF_KNX_INDIVIDUAL_ADDRESS: "15.15.250", + ConnectionSchema.CONF_KNX_MCAST_PORT: 3675, + ConnectionSchema.CONF_KNX_MCAST_GRP: DEFAULT_MCAST_GRP, + ConnectionSchema.CONF_KNX_RATE_LIMIT: 25, + ConnectionSchema.CONF_KNX_STATE_UPDATER: False, + }, + ) + + await hass.async_block_till_done() + assert result2.get("type") == RESULT_TYPE_CREATE_ENTRY + assert not result2.get("data") + + assert mock_config_entry.data == { + CONF_KNX_CONNECTION_TYPE: CONF_KNX_AUTOMATIC, + CONF_KNX_INDIVIDUAL_ADDRESS: "15.15.250", + CONF_HOST: "", + ConnectionSchema.CONF_KNX_MCAST_PORT: 3675, + ConnectionSchema.CONF_KNX_MCAST_GRP: DEFAULT_MCAST_GRP, + ConnectionSchema.CONF_KNX_RATE_LIMIT: 25, + ConnectionSchema.CONF_KNX_STATE_UPDATER: False, + }