diff --git a/CODEOWNERS b/CODEOWNERS index 73110f757fcdbf..f4a1d72edc0662 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1371,8 +1371,8 @@ build.json @home-assistant/supervisor /tests/components/tomorrowio/ @raman325 @lymanepp /homeassistant/components/totalconnect/ @austinmroczek /tests/components/totalconnect/ @austinmroczek -/homeassistant/components/tplink/ @rytilahti @thegardenmonkey @bdraco -/tests/components/tplink/ @rytilahti @thegardenmonkey @bdraco +/homeassistant/components/tplink/ @rytilahti @thegardenmonkey @bdraco @sdb9696 +/tests/components/tplink/ @rytilahti @thegardenmonkey @bdraco @sdb9696 /homeassistant/components/tplink_omada/ @MarkGodwin /tests/components/tplink_omada/ @MarkGodwin /homeassistant/components/traccar/ @ludeeus diff --git a/homeassistant/components/tplink/__init__.py b/homeassistant/components/tplink/__init__.py index 4efd7ffdf0bead..4b684abf280163 100644 --- a/homeassistant/components/tplink/__init__.py +++ b/homeassistant/components/tplink/__init__.py @@ -3,37 +3,66 @@ import asyncio from datetime import timedelta +import logging from typing import Any -from kasa import SmartDevice, SmartDeviceException -from kasa.discover import Discover +from aiohttp import ClientSession +from kasa import ( + AuthenticationException, + Credentials, + DeviceConfig, + Discover, + SmartDevice, + SmartDeviceException, +) +from kasa.httpclient import get_cookie_jar from homeassistant import config_entries from homeassistant.components import network from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( + CONF_ALIAS, + CONF_AUTHENTICATION, CONF_HOST, CONF_MAC, - CONF_NAME, + CONF_MODEL, + CONF_PASSWORD, + CONF_USERNAME, EVENT_HOMEASSISTANT_STARTED, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import ( config_validation as cv, device_registry as dr, discovery_flow, ) +from homeassistant.helpers.aiohttp_client import async_create_clientsession from homeassistant.helpers.event import async_track_time_interval from homeassistant.helpers.typing import ConfigType -from .const import DOMAIN, PLATFORMS +from .const import ( + CONF_DEVICE_CONFIG, + CONNECT_TIMEOUT, + DISCOVERY_TIMEOUT, + DOMAIN, + PLATFORMS, +) from .coordinator import TPLinkDataUpdateCoordinator from .models import TPLinkData DISCOVERY_INTERVAL = timedelta(minutes=15) CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) +_LOGGER = logging.getLogger(__name__) + + +def create_async_tplink_clientsession(hass: HomeAssistant) -> ClientSession: + """Return aiohttp clientsession with cookie jar configured.""" + return async_create_clientsession( + hass, verify_ssl=False, cookie_jar=get_cookie_jar() + ) + @callback def async_trigger_discovery( @@ -47,17 +76,31 @@ def async_trigger_discovery( DOMAIN, context={"source": config_entries.SOURCE_INTEGRATION_DISCOVERY}, data={ - CONF_NAME: device.alias, + CONF_ALIAS: device.alias or mac_alias(device.mac), CONF_HOST: device.host, CONF_MAC: formatted_mac, + CONF_DEVICE_CONFIG: device.config.to_dict( + credentials_hash=device.credentials_hash, + exclude_credentials=True, + ), }, ) async def async_discover_devices(hass: HomeAssistant) -> dict[str, SmartDevice]: """Discover TPLink devices on configured network interfaces.""" + + credentials = await get_credentials(hass) broadcast_addresses = await network.async_get_ipv4_broadcast_addresses(hass) - tasks = [Discover.discover(target=str(address)) for address in broadcast_addresses] + tasks = [ + Discover.discover( + target=str(address), + discovery_timeout=DISCOVERY_TIMEOUT, + timeout=CONNECT_TIMEOUT, + credentials=credentials, + ) + for address in broadcast_addresses + ] discovered_devices: dict[str, SmartDevice] = {} for device_list in await asyncio.gather(*tasks): for device in device_list.values(): @@ -67,7 +110,7 @@ async def async_discover_devices(hass: HomeAssistant) -> dict[str, SmartDevice]: async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the TP-Link component.""" - hass.data[DOMAIN] = {} + hass.data.setdefault(DOMAIN, {}) if discovered_devices := await async_discover_devices(hass): async_trigger_discovery(hass, discovered_devices) @@ -86,12 +129,51 @@ async def _async_discovery(*_: Any) -> None: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up TPLink from a config entry.""" - host = entry.data[CONF_HOST] + host: str = entry.data[CONF_HOST] + credentials = await get_credentials(hass) + + config: DeviceConfig | None = None + if config_dict := entry.data.get(CONF_DEVICE_CONFIG): + try: + config = DeviceConfig.from_dict(config_dict) + except SmartDeviceException: + _LOGGER.warning( + "Invalid connection type dict for %s: %s", host, config_dict + ) + + if not config: + config = DeviceConfig(host) + + config.timeout = CONNECT_TIMEOUT + if config.uses_http is True: + config.http_client = create_async_tplink_clientsession(hass) + if credentials: + config.credentials = credentials try: - device: SmartDevice = await Discover.discover_single(host, timeout=10) + device: SmartDevice = await SmartDevice.connect(config=config) + except AuthenticationException as ex: + raise ConfigEntryAuthFailed from ex except SmartDeviceException as ex: raise ConfigEntryNotReady from ex + device_config_dict = device.config.to_dict( + credentials_hash=device.credentials_hash, exclude_credentials=True + ) + updates: dict[str, Any] = {} + if device_config_dict != config_dict: + updates[CONF_DEVICE_CONFIG] = device_config_dict + if entry.data.get(CONF_ALIAS) != device.alias: + updates[CONF_ALIAS] = device.alias + if entry.data.get(CONF_MODEL) != device.model: + updates[CONF_MODEL] = device.model + if updates: + hass.config_entries.async_update_entry( + entry, + data={ + **entry.data, + **updates, + }, + ) found_mac = dr.format_mac(device.mac) if found_mac != entry.unique_id: # If the mac address of the device does not match the unique_id @@ -130,6 +212,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): hass_data.pop(entry.entry_id) await device.protocol.close() + return unload_ok @@ -141,3 +224,25 @@ def legacy_device_id(device: SmartDevice) -> str: if "_" not in device_id: return device_id return device_id.split("_")[1] + + +async def get_credentials(hass: HomeAssistant) -> Credentials | None: + """Retrieve the credentials from hass data.""" + if DOMAIN in hass.data and CONF_AUTHENTICATION in hass.data[DOMAIN]: + auth = hass.data[DOMAIN][CONF_AUTHENTICATION] + return Credentials(auth[CONF_USERNAME], auth[CONF_PASSWORD]) + + return None + + +async def set_credentials(hass: HomeAssistant, username: str, password: str) -> None: + """Save the credentials to HASS data.""" + hass.data.setdefault(DOMAIN, {})[CONF_AUTHENTICATION] = { + CONF_USERNAME: username, + CONF_PASSWORD: password, + } + + +def mac_alias(mac: str) -> str: + """Convert a MAC address to a short address for the UI.""" + return mac.replace(":", "")[-4:].upper() diff --git a/homeassistant/components/tplink/config_flow.py b/homeassistant/components/tplink/config_flow.py index a783c7b902fa6b..68a40d81415922 100644 --- a/homeassistant/components/tplink/config_flow.py +++ b/homeassistant/components/tplink/config_flow.py @@ -1,28 +1,57 @@ """Config flow for TP-Link.""" from __future__ import annotations +from collections.abc import Mapping from typing import Any -from kasa import SmartDevice, SmartDeviceException -from kasa.discover import Discover +from kasa import ( + AuthenticationException, + Credentials, + DeviceConfig, + Discover, + SmartDevice, + SmartDeviceException, + TimeoutException, +) import voluptuous as vol from homeassistant import config_entries from homeassistant.components import dhcp -from homeassistant.const import CONF_DEVICE, CONF_HOST, CONF_MAC +from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntry, ConfigEntryState +from homeassistant.const import ( + CONF_ALIAS, + CONF_DEVICE, + CONF_HOST, + CONF_MAC, + CONF_MODEL, + CONF_PASSWORD, + CONF_USERNAME, +) from homeassistant.core import callback -from homeassistant.data_entry_flow import FlowResult +from homeassistant.data_entry_flow import AbortFlow, FlowResult from homeassistant.helpers import device_registry as dr from homeassistant.helpers.typing import DiscoveryInfoType -from . import async_discover_devices -from .const import DOMAIN +from . import ( + async_discover_devices, + create_async_tplink_clientsession, + get_credentials, + mac_alias, + set_credentials, +) +from .const import CONF_DEVICE_CONFIG, CONNECT_TIMEOUT, DOMAIN + +STEP_AUTH_DATA_SCHEMA = vol.Schema( + {vol.Required(CONF_USERNAME): str, vol.Required(CONF_PASSWORD): str} +) class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Handle a config flow for tplink.""" VERSION = 1 + MINOR_VERSION = 2 + reauth_entry: ConfigEntry | None = None def __init__(self) -> None: """Initialize the config flow.""" @@ -40,27 +69,114 @@ async def async_step_integration_discovery( ) -> FlowResult: """Handle integration discovery.""" return await self._async_handle_discovery( - discovery_info[CONF_HOST], discovery_info[CONF_MAC] + discovery_info[CONF_HOST], + discovery_info[CONF_MAC], + discovery_info[CONF_DEVICE_CONFIG], + ) + + @callback + def _update_config_if_entry_in_setup_error( + self, entry: ConfigEntry, host: str, config: dict + ) -> None: + """If discovery encounters a device that is in SETUP_ERROR update the device config.""" + if entry.state is not ConfigEntryState.SETUP_ERROR: + return + entry_data = entry.data + entry_config_dict = entry_data.get(CONF_DEVICE_CONFIG) + if entry_config_dict == config and entry_data[CONF_HOST] == host: + return + self.hass.config_entries.async_update_entry( + entry, data={**entry.data, CONF_DEVICE_CONFIG: config, CONF_HOST: host} + ) + self.hass.async_create_task( + self.hass.config_entries.async_reload(entry.entry_id), + f"config entry reload {entry.title} {entry.domain} {entry.entry_id}", ) + raise AbortFlow("already_configured") - async def _async_handle_discovery(self, host: str, mac: str) -> FlowResult: + async def _async_handle_discovery( + self, host: str, formatted_mac: str, config: dict | None = None + ) -> FlowResult: """Handle any discovery.""" - await self.async_set_unique_id(dr.format_mac(mac)) + current_entry = await self.async_set_unique_id( + formatted_mac, raise_on_progress=False + ) + if config and current_entry: + self._update_config_if_entry_in_setup_error(current_entry, host, config) self._abort_if_unique_id_configured(updates={CONF_HOST: host}) self._async_abort_entries_match({CONF_HOST: host}) self.context[CONF_HOST] = host for progress in self._async_in_progress(): if progress.get("context", {}).get(CONF_HOST) == host: return self.async_abort(reason="already_in_progress") - + credentials = await get_credentials(self.hass) try: - self._discovered_device = await self._async_try_connect( - host, raise_on_progress=True + await self._async_try_discover_and_update( + host, credentials, raise_on_progress=True ) + except AuthenticationException: + return await self.async_step_discovery_auth_confirm() except SmartDeviceException: return self.async_abort(reason="cannot_connect") + return await self.async_step_discovery_confirm() + async def async_step_discovery_auth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Dialog that informs the user that auth is required.""" + assert self._discovered_device is not None + errors = {} + + credentials = await get_credentials(self.hass) + if credentials and credentials != self._discovered_device.config.credentials: + try: + device = await self._async_try_connect( + self._discovered_device, credentials + ) + except AuthenticationException: + pass # Authentication exceptions should continue to the rest of the step + else: + self._discovered_device = device + return await self.async_step_discovery_confirm() + + if user_input: + username = user_input[CONF_USERNAME] + password = user_input[CONF_PASSWORD] + credentials = Credentials(username, password) + try: + device = await self._async_try_connect( + self._discovered_device, credentials + ) + except AuthenticationException: + errors[CONF_PASSWORD] = "invalid_auth" + except SmartDeviceException: + errors["base"] = "cannot_connect" + else: + self._discovered_device = device + await set_credentials(self.hass, username, password) + self.hass.async_create_task(self._async_reload_requires_auth_entries()) + return await self.async_step_discovery_confirm() + + placeholders = self._async_make_placeholders_from_discovery() + self.context["title_placeholders"] = placeholders + return self.async_show_form( + step_id="discovery_auth_confirm", + data_schema=STEP_AUTH_DATA_SCHEMA, + errors=errors, + description_placeholders=placeholders, + ) + + def _async_make_placeholders_from_discovery(self) -> dict[str, str]: + """Make placeholders for the discovery steps.""" + discovered_device = self._discovered_device + assert discovered_device is not None + return { + "name": discovered_device.alias or mac_alias(discovered_device.mac), + "model": discovered_device.model, + "host": discovered_device.host, + } + async def async_step_discovery_confirm( self, user_input: dict[str, Any] | None = None ) -> FlowResult: @@ -70,11 +186,7 @@ async def async_step_discovery_confirm( return self._async_create_entry_from_device(self._discovered_device) self._set_confirm_only() - placeholders = { - "name": self._discovered_device.alias, - "model": self._discovered_device.model, - "host": self._discovered_device.host, - } + placeholders = self._async_make_placeholders_from_discovery() self.context["title_placeholders"] = placeholders return self.async_show_form( step_id="discovery_confirm", description_placeholders=placeholders @@ -88,8 +200,15 @@ async def async_step_user( if user_input is not None: if not (host := user_input[CONF_HOST]): return await self.async_step_pick_device() + self._async_abort_entries_match({CONF_HOST: host}) + self.context[CONF_HOST] = host + credentials = await get_credentials(self.hass) try: - device = await self._async_try_connect(host, raise_on_progress=False) + device = await self._async_try_discover_and_update( + host, credentials, raise_on_progress=False + ) + except AuthenticationException: + return await self.async_step_user_auth_confirm() except SmartDeviceException: errors["base"] = "cannot_connect" else: @@ -101,6 +220,37 @@ async def async_step_user( errors=errors, ) + async def async_step_user_auth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Dialog that informs the user that auth is required.""" + errors = {} + host = self.context[CONF_HOST] + assert self._discovered_device is not None + if user_input: + username = user_input[CONF_USERNAME] + password = user_input[CONF_PASSWORD] + credentials = Credentials(username, password) + try: + device = await self._async_try_connect( + self._discovered_device, credentials + ) + except AuthenticationException: + errors[CONF_PASSWORD] = "invalid_auth" + except SmartDeviceException: + errors["base"] = "cannot_connect" + else: + await set_credentials(self.hass, username, password) + self.hass.async_create_task(self._async_reload_requires_auth_entries()) + return self._async_create_entry_from_device(device) + + return self.async_show_form( + step_id="user_auth_confirm", + data_schema=STEP_AUTH_DATA_SCHEMA, + errors=errors, + description_placeholders={CONF_HOST: host}, + ) + async def async_step_pick_device( self, user_input: dict[str, Any] | None = None ) -> FlowResult: @@ -108,7 +258,21 @@ async def async_step_pick_device( if user_input is not None: mac = user_input[CONF_DEVICE] await self.async_set_unique_id(mac, raise_on_progress=False) - return self._async_create_entry_from_device(self._discovered_devices[mac]) + self._discovered_device = self._discovered_devices[mac] + host = self._discovered_device.host + + self.context[CONF_HOST] = host + credentials = await get_credentials(self.hass) + + try: + device = await self._async_try_connect( + self._discovered_device, credentials + ) + except AuthenticationException: + return await self.async_step_user_auth_confirm() + except SmartDeviceException: + return self.async_abort(reason="cannot_connect") + return self._async_create_entry_from_device(device) configured_devices = { entry.unique_id for entry in self._async_current_entries() @@ -116,7 +280,7 @@ async def async_step_pick_device( self._discovered_devices = await async_discover_devices(self.hass) devices_name = { formatted_mac: ( - f"{device.alias} {device.model} ({device.host}) {formatted_mac}" + f"{device.alias or mac_alias(device.mac)} {device.model} ({device.host}) {formatted_mac}" ) for formatted_mac, device in self._discovered_devices.items() if formatted_mac not in configured_devices @@ -129,6 +293,25 @@ async def async_step_pick_device( data_schema=vol.Schema({vol.Required(CONF_DEVICE): vol.In(devices_name)}), ) + async def _async_reload_requires_auth_entries(self) -> None: + """Reload any in progress config flow that now have credentials.""" + _config_entries = self.hass.config_entries + + if reauth_entry := self.reauth_entry: + await _config_entries.async_reload(reauth_entry.entry_id) + + for flow in _config_entries.flow.async_progress_by_handler( + DOMAIN, include_uninitialized=True + ): + context: dict[str, Any] = flow["context"] + if context.get("source") != SOURCE_REAUTH: + continue + entry_id: str = context["entry_id"] + if entry := _config_entries.async_get_entry(entry_id): + await _config_entries.async_reload(entry.entry_id) + if entry.state is ConfigEntryState.LOADED: + _config_entries.flow.async_abort(flow["flow_id"]) + @callback def _async_create_entry_from_device(self, device: SmartDevice) -> FlowResult: """Create a config entry from a smart device.""" @@ -137,16 +320,113 @@ def _async_create_entry_from_device(self, device: SmartDevice) -> FlowResult: title=f"{device.alias} {device.model}", data={ CONF_HOST: device.host, + CONF_ALIAS: device.alias, + CONF_MODEL: device.model, + CONF_DEVICE_CONFIG: device.config.to_dict( + credentials_hash=device.credentials_hash, + exclude_credentials=True, + ), }, ) + async def _async_try_discover_and_update( + self, + host: str, + credentials: Credentials | None, + raise_on_progress: bool, + ) -> SmartDevice: + """Try to discover the device and call update. + + Will try to connect to legacy devices if discovery fails. + """ + try: + self._discovered_device = await Discover.discover_single( + host, credentials=credentials + ) + except TimeoutException: + # Try connect() to legacy devices if discovery fails + self._discovered_device = await SmartDevice.connect( + config=DeviceConfig(host) + ) + else: + if self._discovered_device.config.uses_http: + self._discovered_device.config.http_client = ( + create_async_tplink_clientsession(self.hass) + ) + await self._discovered_device.update() + await self.async_set_unique_id( + dr.format_mac(self._discovered_device.mac), + raise_on_progress=raise_on_progress, + ) + return self._discovered_device + async def _async_try_connect( - self, host: str, raise_on_progress: bool = True + self, + discovered_device: SmartDevice, + credentials: Credentials | None, ) -> SmartDevice: """Try to connect.""" - self._async_abort_entries_match({CONF_HOST: host}) - device: SmartDevice = await Discover.discover_single(host) + self._async_abort_entries_match({CONF_HOST: discovered_device.host}) + + config = discovered_device.config + if credentials: + config.credentials = credentials + config.timeout = CONNECT_TIMEOUT + if config.uses_http: + config.http_client = create_async_tplink_clientsession(self.hass) + + self._discovered_device = await SmartDevice.connect(config=config) await self.async_set_unique_id( - dr.format_mac(device.mac), raise_on_progress=raise_on_progress + dr.format_mac(self._discovered_device.mac), + raise_on_progress=False, + ) + return self._discovered_device + + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: + """Start the reauthentication flow if the device needs updated credentials.""" + self.reauth_entry = self.hass.config_entries.async_get_entry( + self.context["entry_id"] + ) + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Dialog that informs the user that reauth is required.""" + errors: dict[str, str] = {} + reauth_entry = self.reauth_entry + assert reauth_entry is not None + entry_data = reauth_entry.data + host = entry_data[CONF_HOST] + + if user_input: + username = user_input[CONF_USERNAME] + password = user_input[CONF_PASSWORD] + credentials = Credentials(username, password) + try: + await self._async_try_discover_and_update( + host, + credentials=credentials, + raise_on_progress=True, + ) + except AuthenticationException: + errors[CONF_PASSWORD] = "invalid_auth" + except SmartDeviceException: + errors["base"] = "cannot_connect" + else: + await set_credentials(self.hass, username, password) + self.hass.async_create_task(self._async_reload_requires_auth_entries()) + return self.async_abort(reason="reauth_successful") + + # Old config entries will not have these values. + alias = entry_data.get(CONF_ALIAS) or "unknown" + model = entry_data.get(CONF_MODEL) or "unknown" + + placeholders = {"name": alias, "model": model, "host": host} + self.context["title_placeholders"] = placeholders + return self.async_show_form( + step_id="reauth_confirm", + data_schema=STEP_AUTH_DATA_SCHEMA, + errors=errors, + description_placeholders=placeholders, ) - return device diff --git a/homeassistant/components/tplink/const.py b/homeassistant/components/tplink/const.py index 22b5741fceb615..57047af8092d84 100644 --- a/homeassistant/components/tplink/const.py +++ b/homeassistant/components/tplink/const.py @@ -7,15 +7,14 @@ DOMAIN = "tplink" +DISCOVERY_TIMEOUT = 5 # Home Assistant will complain if startup takes > 10s +CONNECT_TIMEOUT = 5 + ATTR_CURRENT_A: Final = "current_a" ATTR_CURRENT_POWER_W: Final = "current_power_w" ATTR_TODAY_ENERGY_KWH: Final = "today_energy_kwh" ATTR_TOTAL_ENERGY_KWH: Final = "total_energy_kwh" -CONF_DIMMER: Final = "dimmer" -CONF_LIGHT: Final = "light" -CONF_STRIP: Final = "strip" -CONF_SWITCH: Final = "switch" -CONF_SENSOR: Final = "sensor" +CONF_DEVICE_CONFIG: Final = "device_config" PLATFORMS: Final = [Platform.LIGHT, Platform.SENSOR, Platform.SWITCH] diff --git a/homeassistant/components/tplink/manifest.json b/homeassistant/components/tplink/manifest.json index 162344f04ec20b..5791e429d71c0a 100644 --- a/homeassistant/components/tplink/manifest.json +++ b/homeassistant/components/tplink/manifest.json @@ -1,13 +1,17 @@ { "domain": "tplink", - "name": "TP-Link Kasa Smart", - "codeowners": ["@rytilahti", "@thegardenmonkey", "@bdraco"], + "name": "TP-Link Smart Home", + "codeowners": ["@rytilahti", "@thegardenmonkey", "@bdraco", "@sdb9696"], "config_flow": true, "dependencies": ["network"], "dhcp": [ { "registered_devices": true }, + { + "hostname": "e[sp]*", + "macaddress": "3C52A1*" + }, { "hostname": "e[sp]*", "macaddress": "54AF97*" @@ -32,6 +36,10 @@ "hostname": "hs*", "macaddress": "9C5322*" }, + { + "hostname": "k[lps]*", + "macaddress": "5091E3*" + }, { "hostname": "k[lps]*", "macaddress": "9C5322*" @@ -163,11 +171,31 @@ { "hostname": "k[lps]*", "macaddress": "1C61B4*" + }, + { + "hostname": "l5*", + "macaddress": "5CE931*" + }, + { + "hostname": "p1*", + "macaddress": "482254*" + }, + { + "hostname": "p1*", + "macaddress": "30DE4B*" + }, + { + "hostname": "l9*", + "macaddress": "A842A1*" + }, + { + "hostname": "l9*", + "macaddress": "3460F9*" } ], "documentation": "https://www.home-assistant.io/integrations/tplink", "iot_class": "local_polling", "loggers": ["kasa"], "quality_scale": "platinum", - "requirements": ["python-kasa[speedups]==0.5.4"] + "requirements": ["python-kasa[speedups]==0.6.0.1"] } diff --git a/homeassistant/components/tplink/strings.json b/homeassistant/components/tplink/strings.json index 3b4024c07b4140..3c4711d16321a0 100644 --- a/homeassistant/components/tplink/strings.json +++ b/homeassistant/components/tplink/strings.json @@ -18,6 +18,34 @@ }, "discovery_confirm": { "description": "Do you want to set up {name} {model} ({host})?" + }, + "user_auth_confirm": { + "title": "Authenticate", + "description": "The device requires authentication, please input your credentials below.", + "data": { + "username": "[%key:common::config_flow::data::username%]", + "password": "[%key:common::config_flow::data::password%]" + } + }, + "discovery_auth_confirm": { + "title": "Authenticate", + "description": "The device requires authentication, please input your credentials below.", + "data": { + "username": "[%key:common::config_flow::data::username%]", + "password": "[%key:common::config_flow::data::password%]" + } + }, + "reauth": { + "title": "[%key:common::config_flow::title::reauth%]", + "description": "The device needs updated credentials, please input your credentials below." + }, + "reauth_confirm": { + "title": "[%key:common::config_flow::title::reauth%]", + "description": "The device needs updated credentials, please input your credentials below.", + "data": { + "username": "[%key:common::config_flow::data::username%]", + "password": "[%key:common::config_flow::data::password%]" + } } }, "error": { @@ -25,7 +53,8 @@ }, "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", - "no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]" + "no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" } }, "entity": { diff --git a/homeassistant/components/tplink/switch.py b/homeassistant/components/tplink/switch.py index b1ca848260f040..9a54a952666a90 100644 --- a/homeassistant/components/tplink/switch.py +++ b/homeassistant/components/tplink/switch.py @@ -41,7 +41,9 @@ async def async_setup_entry( elif device.is_plug: entities.append(SmartPlugSwitch(device, parent_coordinator)) - entities.append(SmartPlugLedSwitch(device, parent_coordinator)) + # this will be removed on the led is implemented + if hasattr(device, "led"): + entities.append(SmartPlugLedSwitch(device, parent_coordinator)) async_add_entities(entities) @@ -86,7 +88,7 @@ def is_on(self) -> bool: class SmartPlugSwitch(CoordinatedTPLinkEntity, SwitchEntity): """Representation of a TPLink Smart Plug switch.""" - _attr_name = None + _attr_name: str | None = None def __init__( self, diff --git a/homeassistant/generated/dhcp.py b/homeassistant/generated/dhcp.py index 33d069c5663ed9..a63c814d5985ad 100644 --- a/homeassistant/generated/dhcp.py +++ b/homeassistant/generated/dhcp.py @@ -603,6 +603,11 @@ "domain": "tplink", "registered_devices": True, }, + { + "domain": "tplink", + "hostname": "e[sp]*", + "macaddress": "3C52A1*", + }, { "domain": "tplink", "hostname": "e[sp]*", @@ -633,6 +638,11 @@ "hostname": "hs*", "macaddress": "9C5322*", }, + { + "domain": "tplink", + "hostname": "k[lps]*", + "macaddress": "5091E3*", + }, { "domain": "tplink", "hostname": "k[lps]*", @@ -798,6 +808,31 @@ "hostname": "k[lps]*", "macaddress": "1C61B4*", }, + { + "domain": "tplink", + "hostname": "l5*", + "macaddress": "5CE931*", + }, + { + "domain": "tplink", + "hostname": "p1*", + "macaddress": "482254*", + }, + { + "domain": "tplink", + "hostname": "p1*", + "macaddress": "30DE4B*", + }, + { + "domain": "tplink", + "hostname": "l9*", + "macaddress": "A842A1*", + }, + { + "domain": "tplink", + "hostname": "l9*", + "macaddress": "3460F9*", + }, { "domain": "tuya", "macaddress": "105A17*", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 2aa315a2daf4c6..1cb43016efcb12 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -6086,7 +6086,7 @@ "integration_type": "hub", "config_flow": true, "iot_class": "local_polling", - "name": "TP-Link Kasa Smart" + "name": "TP-Link Smart Home" }, "tplink_omada": { "integration_type": "hub", diff --git a/requirements_all.txt b/requirements_all.txt index 8ec8525e36c533..cd43f8a13394cb 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2213,7 +2213,7 @@ python-join-api==0.0.9 python-juicenet==1.1.0 # homeassistant.components.tplink -python-kasa[speedups]==0.5.4 +python-kasa[speedups]==0.6.0.1 # homeassistant.components.lirc # python-lirc==1.2.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 8d38f2703227ec..a2618222da8aee 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1683,7 +1683,7 @@ python-izone==1.2.9 python-juicenet==1.1.0 # homeassistant.components.tplink -python-kasa[speedups]==0.5.4 +python-kasa[speedups]==0.6.0.1 # homeassistant.components.matter python-matter-server==5.1.1 diff --git a/tests/components/tplink/__init__.py b/tests/components/tplink/__init__.py index 9006a058c57845..4a79f39f6a7bd9 100644 --- a/tests/components/tplink/__init__.py +++ b/tests/components/tplink/__init__.py @@ -3,6 +3,10 @@ from unittest.mock import AsyncMock, MagicMock, patch from kasa import ( + ConnectionType, + DeviceConfig, + DeviceFamilyType, + EncryptType, SmartBulb, SmartDevice, SmartDimmer, @@ -13,7 +17,13 @@ from kasa.exceptions import SmartDeviceException from kasa.protocol import TPLinkSmartHomeProtocol -from homeassistant.components.tplink import CONF_HOST +from homeassistant.components.tplink import ( + CONF_ALIAS, + CONF_DEVICE_CONFIG, + CONF_HOST, + CONF_MODEL, + Credentials, +) from homeassistant.components.tplink.const import DOMAIN from homeassistant.core import HomeAssistant @@ -22,10 +32,61 @@ MODULE = "homeassistant.components.tplink" MODULE_CONFIG_FLOW = "homeassistant.components.tplink.config_flow" IP_ADDRESS = "127.0.0.1" +IP_ADDRESS2 = "127.0.0.2" ALIAS = "My Bulb" MODEL = "HS100" MAC_ADDRESS = "aa:bb:cc:dd:ee:ff" +MAC_ADDRESS2 = "11:22:33:44:55:66" DEFAULT_ENTRY_TITLE = f"{ALIAS} {MODEL}" +CREDENTIALS_HASH_LEGACY = "" +DEVICE_CONFIG_LEGACY = DeviceConfig(IP_ADDRESS) +DEVICE_CONFIG_DICT_LEGACY = DEVICE_CONFIG_LEGACY.to_dict( + credentials_hash=CREDENTIALS_HASH_LEGACY, exclude_credentials=True +) +CREDENTIALS = Credentials("foo", "bar") +CREDENTIALS_HASH_AUTH = "abcdefghijklmnopqrstuv==" +DEVICE_CONFIG_AUTH = DeviceConfig( + IP_ADDRESS, + credentials=CREDENTIALS, + connection_type=ConnectionType( + DeviceFamilyType.IotSmartPlugSwitch, EncryptType.Klap + ), + uses_http=True, +) +DEVICE_CONFIG_AUTH2 = DeviceConfig( + IP_ADDRESS2, + credentials=CREDENTIALS, + connection_type=ConnectionType( + DeviceFamilyType.IotSmartPlugSwitch, EncryptType.Klap + ), + uses_http=True, +) +DEVICE_CONFIG_DICT_AUTH = DEVICE_CONFIG_AUTH.to_dict( + credentials_hash=CREDENTIALS_HASH_AUTH, exclude_credentials=True +) +DEVICE_CONFIG_DICT_AUTH2 = DEVICE_CONFIG_AUTH2.to_dict( + credentials_hash=CREDENTIALS_HASH_AUTH, exclude_credentials=True +) + +CREATE_ENTRY_DATA_LEGACY = { + CONF_HOST: IP_ADDRESS, + CONF_ALIAS: ALIAS, + CONF_MODEL: MODEL, + CONF_DEVICE_CONFIG: DEVICE_CONFIG_DICT_LEGACY, +} + +CREATE_ENTRY_DATA_AUTH = { + CONF_HOST: IP_ADDRESS, + CONF_ALIAS: ALIAS, + CONF_MODEL: MODEL, + CONF_DEVICE_CONFIG: DEVICE_CONFIG_DICT_AUTH, +} +CREATE_ENTRY_DATA_AUTH2 = { + CONF_HOST: IP_ADDRESS2, + CONF_ALIAS: ALIAS, + CONF_MODEL: MODEL, + CONF_DEVICE_CONFIG: DEVICE_CONFIG_DICT_AUTH2, +} def _mock_protocol() -> TPLinkSmartHomeProtocol: @@ -34,11 +95,16 @@ def _mock_protocol() -> TPLinkSmartHomeProtocol: return protocol -def _mocked_bulb() -> SmartBulb: +def _mocked_bulb( + device_config=DEVICE_CONFIG_LEGACY, + credentials_hash=CREDENTIALS_HASH_LEGACY, + mac=MAC_ADDRESS, + alias=ALIAS, +) -> SmartBulb: bulb = MagicMock(auto_spec=SmartBulb, name="Mocked bulb") bulb.update = AsyncMock() - bulb.mac = MAC_ADDRESS - bulb.alias = ALIAS + bulb.mac = mac + bulb.alias = alias bulb.model = MODEL bulb.host = IP_ADDRESS bulb.brightness = 50 @@ -52,7 +118,7 @@ def _mocked_bulb() -> SmartBulb: bulb.effect = None bulb.effect_list = None bulb.hsv = (10, 30, 5) - bulb.device_id = MAC_ADDRESS + bulb.device_id = mac bulb.valid_temperature_range.min = 4000 bulb.valid_temperature_range.max = 9000 bulb.hw_info = {"sw_ver": "1.0.0", "hw_ver": "1.0.0"} @@ -62,6 +128,8 @@ def _mocked_bulb() -> SmartBulb: bulb.set_hsv = AsyncMock() bulb.set_color_temp = AsyncMock() bulb.protocol = _mock_protocol() + bulb.config = device_config + bulb.credentials_hash = credentials_hash return bulb @@ -103,6 +171,8 @@ def _mocked_smart_light_strip() -> SmartLightStrip: strip.set_effect = AsyncMock() strip.set_custom_effect = AsyncMock() strip.protocol = _mock_protocol() + strip.config = DEVICE_CONFIG_LEGACY + strip.credentials_hash = CREDENTIALS_HASH_LEGACY return strip @@ -134,6 +204,8 @@ def _mocked_dimmer() -> SmartDimmer: dimmer.set_color_temp = AsyncMock() dimmer.set_led = AsyncMock() dimmer.protocol = _mock_protocol() + dimmer.config = DEVICE_CONFIG_LEGACY + dimmer.credentials_hash = CREDENTIALS_HASH_LEGACY return dimmer @@ -155,6 +227,8 @@ def _mocked_plug() -> SmartPlug: plug.turn_on = AsyncMock() plug.set_led = AsyncMock() plug.protocol = _mock_protocol() + plug.config = DEVICE_CONFIG_LEGACY + plug.credentials_hash = CREDENTIALS_HASH_LEGACY return plug @@ -176,6 +250,8 @@ def _mocked_strip() -> SmartStrip: strip.turn_on = AsyncMock() strip.set_led = AsyncMock() strip.protocol = _mock_protocol() + strip.config = DEVICE_CONFIG_LEGACY + strip.credentials_hash = CREDENTIALS_HASH_LEGACY plug0 = _mocked_plug() plug0.alias = "Plug0" plug0.device_id = "bb:bb:cc:dd:ee:ff_PLUG0DEVICEID" @@ -212,6 +288,15 @@ async def _discover_single(*args, **kwargs): ) +def _patch_connect(device=None, no_device=False): + async def _connect(*args, **kwargs): + if no_device: + raise SmartDeviceException + return device if device else _mocked_bulb() + + return patch("homeassistant.components.tplink.SmartDevice.connect", new=_connect) + + async def initialize_config_entry_for_device( hass: HomeAssistant, dev: SmartDevice ) -> MockConfigEntry: @@ -225,7 +310,9 @@ async def initialize_config_entry_for_device( ) config_entry.add_to_hass(hass) - with _patch_discovery(device=dev), _patch_single_discovery(device=dev): + with _patch_discovery(device=dev), _patch_single_discovery( + device=dev + ), _patch_connect(device=dev): await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() diff --git a/tests/components/tplink/conftest.py b/tests/components/tplink/conftest.py index 20ce09b9ec8533..7e7e6961b91adc 100644 --- a/tests/components/tplink/conftest.py +++ b/tests/components/tplink/conftest.py @@ -1,18 +1,75 @@ """tplink conftest.""" +from collections.abc import Generator +import copy +from unittest.mock import DEFAULT, AsyncMock, patch + import pytest -from . import _patch_discovery +from homeassistant.components.tplink import DOMAIN +from homeassistant.core import HomeAssistant + +from . import ( + CREATE_ENTRY_DATA_LEGACY, + CREDENTIALS_HASH_AUTH, + DEVICE_CONFIG_AUTH, + IP_ADDRESS, + IP_ADDRESS2, + MAC_ADDRESS, + MAC_ADDRESS2, + _mocked_bulb, +) -from tests.common import mock_device_registry, mock_registry +from tests.common import MockConfigEntry, mock_device_registry, mock_registry @pytest.fixture def mock_discovery(): """Mock python-kasa discovery.""" - with _patch_discovery() as mock_discover: - mock_discover.return_value = {} - yield mock_discover + with patch.multiple( + "homeassistant.components.tplink.Discover", + discover=DEFAULT, + discover_single=DEFAULT, + ) as mock_discovery: + device = _mocked_bulb( + device_config=copy.deepcopy(DEVICE_CONFIG_AUTH), + credentials_hash=CREDENTIALS_HASH_AUTH, + alias=None, + ) + devices = { + "127.0.0.1": _mocked_bulb( + device_config=copy.deepcopy(DEVICE_CONFIG_AUTH), + credentials_hash=CREDENTIALS_HASH_AUTH, + alias=None, + ) + } + mock_discovery["discover"].return_value = devices + mock_discovery["discover_single"].return_value = device + mock_discovery["mock_device"] = device + yield mock_discovery + + +@pytest.fixture +def mock_connect(): + """Mock python-kasa connect.""" + with patch("homeassistant.components.tplink.SmartDevice.connect") as mock_connect: + devices = { + IP_ADDRESS: _mocked_bulb( + device_config=DEVICE_CONFIG_AUTH, credentials_hash=CREDENTIALS_HASH_AUTH + ), + IP_ADDRESS2: _mocked_bulb( + device_config=DEVICE_CONFIG_AUTH, + credentials_hash=CREDENTIALS_HASH_AUTH, + mac=MAC_ADDRESS2, + ), + } + + def get_device(config): + nonlocal devices + return devices[config.host] + + mock_connect.side_effect = get_device + yield {"connect": mock_connect, "mock_devices": devices} @pytest.fixture(name="device_reg") @@ -30,3 +87,55 @@ def entity_reg_fixture(hass): @pytest.fixture(autouse=True) def tplink_mock_get_source_ip(mock_get_source_ip): """Mock network util's async_get_source_ip.""" + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock, None, None]: + """Override async_setup_entry.""" + with patch.multiple( + async_setup=DEFAULT, + async_setup_entry=DEFAULT, + ) as mock_setup_entry: + mock_setup_entry["async_setup"].return_value = True + mock_setup_entry["async_setup_entry"].return_value = True + yield mock_setup_entry + + +@pytest.fixture +def mock_init() -> Generator[AsyncMock, None, None]: + """Override async_setup_entry.""" + with patch.multiple( + "homeassistant.components.tplink", + async_setup=DEFAULT, + async_setup_entry=DEFAULT, + async_unload_entry=DEFAULT, + ) as mock_init: + mock_init["async_setup"].return_value = True + mock_init["async_setup_entry"].return_value = True + mock_init["async_unload_entry"].return_value = True + yield mock_init + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Mock ConfigEntry.""" + return MockConfigEntry( + title="TPLink", + domain=DOMAIN, + data={**CREATE_ENTRY_DATA_LEGACY}, + unique_id=MAC_ADDRESS, + ) + + +@pytest.fixture +async def mock_added_config_entry( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_init, +) -> MockConfigEntry: + """Mock ConfigEntry that's been added to HA.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + assert DOMAIN in hass.config_entries.async_domains() + return mock_config_entry diff --git a/tests/components/tplink/test_config_flow.py b/tests/components/tplink/test_config_flow.py index 65be41a5655251..96cfbead5e453e 100644 --- a/tests/components/tplink/test_config_flow.py +++ b/tests/components/tplink/test_config_flow.py @@ -1,21 +1,42 @@ """Test the tplink config flow.""" -from unittest.mock import patch +from unittest.mock import AsyncMock, patch +from kasa import TimeoutException import pytest from homeassistant import config_entries from homeassistant.components import dhcp -from homeassistant.components.tplink import DOMAIN -from homeassistant.const import CONF_DEVICE, CONF_HOST, CONF_MAC, CONF_NAME +from homeassistant.components.tplink import ( + DOMAIN, + AuthenticationException, + Credentials, + SmartDeviceException, +) +from homeassistant.components.tplink.const import CONF_DEVICE_CONFIG +from homeassistant.const import ( + CONF_ALIAS, + CONF_DEVICE, + CONF_HOST, + CONF_MAC, + CONF_PASSWORD, + CONF_USERNAME, +) from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from . import ( ALIAS, + CREATE_ENTRY_DATA_AUTH, + CREATE_ENTRY_DATA_AUTH2, + CREATE_ENTRY_DATA_LEGACY, DEFAULT_ENTRY_TITLE, + DEVICE_CONFIG_DICT_AUTH, + DEVICE_CONFIG_DICT_LEGACY, IP_ADDRESS, MAC_ADDRESS, + MAC_ADDRESS2, MODULE, + _patch_connect, _patch_discovery, _patch_single_discovery, ) @@ -25,7 +46,7 @@ async def test_discovery(hass: HomeAssistant) -> None: """Test setting up discovery.""" - with _patch_discovery(), _patch_single_discovery(): + with _patch_discovery(), _patch_single_discovery(), _patch_connect(): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) @@ -54,7 +75,7 @@ async def test_discovery(hass: HomeAssistant) -> None: assert result2["step_id"] == "pick_device" assert not result2["errors"] - with _patch_discovery(), _patch_single_discovery(), patch( + with _patch_discovery(), _patch_single_discovery(), _patch_connect(), patch( f"{MODULE}.async_setup", return_value=True ) as mock_setup, patch( f"{MODULE}.async_setup_entry", return_value=True @@ -67,7 +88,7 @@ async def test_discovery(hass: HomeAssistant) -> None: assert result3["type"] == "create_entry" assert result3["title"] == DEFAULT_ENTRY_TITLE - assert result3["data"] == {CONF_HOST: IP_ADDRESS} + assert result3["data"] == CREATE_ENTRY_DATA_LEGACY mock_setup.assert_called_once() mock_setup_entry.assert_called_once() @@ -75,18 +96,244 @@ async def test_discovery(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - assert result["type"] == "form" + assert result["type"] == FlowResultType.FORM assert result["step_id"] == "user" assert not result["errors"] - with _patch_discovery(), _patch_single_discovery(): + with _patch_discovery(), _patch_single_discovery(), _patch_connect(): result2 = await hass.config_entries.flow.async_configure(result["flow_id"], {}) await hass.async_block_till_done() - assert result2["type"] == "abort" + assert result2["type"] == FlowResultType.ABORT assert result2["reason"] == "no_devices_found" +async def test_discovery_auth( + hass: HomeAssistant, mock_discovery: AsyncMock, mock_connect: AsyncMock, mock_init +) -> None: + """Test authenticated discovery.""" + + mock_discovery["mock_device"].update.side_effect = AuthenticationException + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_INTEGRATION_DISCOVERY}, + data={ + CONF_HOST: IP_ADDRESS, + CONF_MAC: MAC_ADDRESS, + CONF_ALIAS: ALIAS, + CONF_DEVICE_CONFIG: DEVICE_CONFIG_DICT_AUTH, + }, + ) + await hass.async_block_till_done() + assert result["type"] == "form" + assert result["step_id"] == "discovery_auth_confirm" + assert not result["errors"] + + mock_discovery["mock_device"].update.reset_mock(side_effect=True) + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_USERNAME: "fake_username", + CONF_PASSWORD: "fake_password", + }, + ) + + assert result2["type"] == "form" + assert result2["step_id"] == "discovery_confirm" + assert not result2["errors"] + + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], user_input={} + ) + + await hass.async_block_till_done() + assert result3["type"] is FlowResultType.CREATE_ENTRY + assert result3["title"] == DEFAULT_ENTRY_TITLE + assert result3["data"] == CREATE_ENTRY_DATA_AUTH + + +@pytest.mark.parametrize( + ("error_type", "errors_msg", "error_placement"), + [ + (AuthenticationException, "invalid_auth", CONF_PASSWORD), + (SmartDeviceException, "cannot_connect", "base"), + ], + ids=["invalid-auth", "unknown-error"], +) +async def test_discovery_auth_errors( + hass: HomeAssistant, + mock_discovery: AsyncMock, + mock_connect: AsyncMock, + mock_init, + error_type, + errors_msg, + error_placement, +) -> None: + """Test handling of discovery authentication errors.""" + mock_discovery["mock_device"].update.side_effect = AuthenticationException + default_connect_side_effect = mock_connect["connect"].side_effect + mock_connect["connect"].side_effect = error_type + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_INTEGRATION_DISCOVERY}, + data={ + CONF_HOST: IP_ADDRESS, + CONF_MAC: MAC_ADDRESS, + CONF_ALIAS: ALIAS, + CONF_DEVICE_CONFIG: DEVICE_CONFIG_DICT_AUTH, + }, + ) + await hass.async_block_till_done() + assert result["type"] == "form" + assert result["step_id"] == "discovery_auth_confirm" + assert not result["errors"] + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_USERNAME: "fake_username", + CONF_PASSWORD: "fake_password", + }, + ) + + assert result2["type"] is FlowResultType.FORM + assert result2["errors"] == {error_placement: errors_msg} + + await hass.async_block_till_done() + + mock_connect["connect"].side_effect = default_connect_side_effect + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + { + CONF_USERNAME: "fake_username", + CONF_PASSWORD: "fake_password", + }, + ) + assert result3["type"] is FlowResultType.FORM + assert result3["step_id"] == "discovery_confirm" + + await hass.async_block_till_done() + + result4 = await hass.config_entries.flow.async_configure( + result3["flow_id"], + {}, + ) + assert result4["type"] is FlowResultType.CREATE_ENTRY + assert result4["data"] == CREATE_ENTRY_DATA_AUTH + + +async def test_discovery_new_credentials( + hass: HomeAssistant, + mock_discovery: AsyncMock, + mock_connect: AsyncMock, + mock_init, +) -> None: + """Test setting up discovery with new credentials.""" + mock_discovery["mock_device"].update.side_effect = AuthenticationException + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_INTEGRATION_DISCOVERY}, + data={ + CONF_HOST: IP_ADDRESS, + CONF_MAC: MAC_ADDRESS, + CONF_ALIAS: ALIAS, + CONF_DEVICE_CONFIG: DEVICE_CONFIG_DICT_AUTH, + }, + ) + await hass.async_block_till_done() + assert result["type"] == "form" + assert result["step_id"] == "discovery_auth_confirm" + assert not result["errors"] + + assert mock_connect["connect"].call_count == 0 + + with patch( + "homeassistant.components.tplink.config_flow.get_credentials", + return_value=Credentials("fake_user", "fake_pass"), + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + ) + + assert mock_connect["connect"].call_count == 1 + assert result2["type"] is FlowResultType.FORM + assert result2["step_id"] == "discovery_confirm" + + await hass.async_block_till_done() + + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + {}, + ) + assert result3["type"] is FlowResultType.CREATE_ENTRY + assert result3["data"] == CREATE_ENTRY_DATA_AUTH + + +async def test_discovery_new_credentials_invalid( + hass: HomeAssistant, + mock_discovery: AsyncMock, + mock_connect: AsyncMock, + mock_init, +) -> None: + """Test setting up discovery with new invalid credentials.""" + mock_discovery["mock_device"].update.side_effect = AuthenticationException + default_connect_side_effect = mock_connect["connect"].side_effect + + mock_connect["connect"].side_effect = AuthenticationException + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_INTEGRATION_DISCOVERY}, + data={ + CONF_HOST: IP_ADDRESS, + CONF_MAC: MAC_ADDRESS, + CONF_ALIAS: ALIAS, + CONF_DEVICE_CONFIG: DEVICE_CONFIG_DICT_AUTH, + }, + ) + await hass.async_block_till_done() + assert result["type"] == "form" + assert result["step_id"] == "discovery_auth_confirm" + assert not result["errors"] + + assert mock_connect["connect"].call_count == 0 + + with patch( + "homeassistant.components.tplink.config_flow.get_credentials", + return_value=Credentials("fake_user", "fake_pass"), + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + ) + + assert mock_connect["connect"].call_count == 1 + assert result2["type"] is FlowResultType.FORM + assert result2["step_id"] == "discovery_auth_confirm" + + await hass.async_block_till_done() + + mock_connect["connect"].side_effect = default_connect_side_effect + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + { + CONF_USERNAME: "fake_username", + CONF_PASSWORD: "fake_password", + }, + ) + assert result3["type"] is FlowResultType.FORM + assert result3["step_id"] == "discovery_confirm" + + result4 = await hass.config_entries.flow.async_configure( + result3["flow_id"], + {}, + ) + assert result4["type"] is FlowResultType.CREATE_ENTRY + assert result4["data"] == CREATE_ENTRY_DATA_AUTH + + async def test_discovery_with_existing_device_present(hass: HomeAssistant) -> None: """Test setting up discovery.""" config_entry = MockConfigEntry( @@ -94,22 +341,24 @@ async def test_discovery_with_existing_device_present(hass: HomeAssistant) -> No ) config_entry.add_to_hass(hass) - with _patch_discovery(), _patch_single_discovery(no_device=True): + with _patch_discovery(), _patch_single_discovery(no_device=True), _patch_connect( + no_device=True + ): await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - assert result["type"] == "form" + assert result["type"] is FlowResultType.FORM assert result["step_id"] == "user" assert not result["errors"] - with _patch_discovery(), _patch_single_discovery(): + with _patch_discovery(), _patch_single_discovery(), _patch_connect(): result2 = await hass.config_entries.flow.async_configure(result["flow_id"], {}) await hass.async_block_till_done() - assert result2["type"] == "form" + assert result2["type"] is FlowResultType.FORM assert result2["step_id"] == "pick_device" assert not result2["errors"] @@ -118,29 +367,27 @@ async def test_discovery_with_existing_device_present(hass: HomeAssistant) -> No result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - assert result["type"] == "form" + assert result["type"] is FlowResultType.FORM assert result["step_id"] == "user" assert not result["errors"] - with _patch_discovery(), _patch_single_discovery(): + with _patch_discovery(), _patch_single_discovery(), _patch_connect(): result2 = await hass.config_entries.flow.async_configure(result["flow_id"], {}) await hass.async_block_till_done() - assert result2["type"] == "form" + assert result2["type"] is FlowResultType.FORM assert result2["step_id"] == "pick_device" assert not result2["errors"] - with _patch_discovery(), _patch_single_discovery(), patch( + with _patch_discovery(), _patch_single_discovery(), _patch_connect(), patch( f"{MODULE}.async_setup_entry", return_value=True ) as mock_setup_entry: result3 = await hass.config_entries.flow.async_configure( result["flow_id"], {CONF_DEVICE: MAC_ADDRESS} ) - assert result3["type"] == "create_entry" + assert result3["type"] is FlowResultType.CREATE_ENTRY assert result3["title"] == DEFAULT_ENTRY_TITLE - assert result3["data"] == { - CONF_HOST: IP_ADDRESS, - } + assert result3["data"] == CREATE_ENTRY_DATA_LEGACY await hass.async_block_till_done() mock_setup_entry.assert_called_once() @@ -149,15 +396,15 @@ async def test_discovery_with_existing_device_present(hass: HomeAssistant) -> No result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - assert result["type"] == "form" + assert result["type"] is FlowResultType.FORM assert result["step_id"] == "user" assert not result["errors"] - with _patch_discovery(), _patch_single_discovery(): + with _patch_discovery(), _patch_single_discovery(), _patch_connect(): result2 = await hass.config_entries.flow.async_configure(result["flow_id"], {}) await hass.async_block_till_done() - assert result2["type"] == "abort" + assert result2["type"] is FlowResultType.ABORT assert result2["reason"] == "no_devices_found" @@ -167,11 +414,11 @@ async def test_discovery_no_device(hass: HomeAssistant) -> None: DOMAIN, context={"source": config_entries.SOURCE_USER} ) - with _patch_discovery(no_device=True), _patch_single_discovery(): + with _patch_discovery(no_device=True), _patch_single_discovery(), _patch_connect(): result2 = await hass.config_entries.flow.async_configure(result["flow_id"], {}) await hass.async_block_till_done() - assert result2["type"] == "abort" + assert result2["type"] is FlowResultType.ABORT assert result2["reason"] == "no_devices_found" @@ -180,46 +427,48 @@ async def test_manual(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - assert result["type"] == "form" + assert result["type"] is FlowResultType.FORM assert result["step_id"] == "user" assert not result["errors"] # Cannot connect (timeout) - with _patch_discovery(no_device=True), _patch_single_discovery(no_device=True): + with _patch_discovery(no_device=True), _patch_single_discovery( + no_device=True + ), _patch_connect(no_device=True): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], {CONF_HOST: IP_ADDRESS} ) await hass.async_block_till_done() - assert result2["type"] == "form" + assert result2["type"] is FlowResultType.FORM assert result2["step_id"] == "user" assert result2["errors"] == {"base": "cannot_connect"} # Success - with _patch_discovery(), _patch_single_discovery(), patch( + with _patch_discovery(), _patch_single_discovery(), _patch_connect(), patch( f"{MODULE}.async_setup", return_value=True ), patch(f"{MODULE}.async_setup_entry", return_value=True): result4 = await hass.config_entries.flow.async_configure( result["flow_id"], {CONF_HOST: IP_ADDRESS} ) await hass.async_block_till_done() - assert result4["type"] == "create_entry" + assert result4["type"] is FlowResultType.CREATE_ENTRY assert result4["title"] == DEFAULT_ENTRY_TITLE - assert result4["data"] == { - CONF_HOST: IP_ADDRESS, - } + assert result4["data"] == CREATE_ENTRY_DATA_LEGACY # Duplicate result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - with _patch_discovery(no_device=True), _patch_single_discovery(no_device=True): + with _patch_discovery(no_device=True), _patch_single_discovery( + no_device=True + ), _patch_connect(no_device=True): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], {CONF_HOST: IP_ADDRESS} ) await hass.async_block_till_done() - assert result2["type"] == "abort" + assert result2["type"] is FlowResultType.ABORT assert result2["reason"] == "already_configured" @@ -228,11 +477,13 @@ async def test_manual_no_capabilities(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - assert result["type"] == "form" + assert result["type"] is FlowResultType.FORM assert result["step_id"] == "user" assert not result["errors"] - with _patch_discovery(no_device=True), _patch_single_discovery(), patch( + with _patch_discovery( + no_device=True + ), _patch_single_discovery(), _patch_connect(), patch( f"{MODULE}.async_setup", return_value=True ), patch(f"{MODULE}.async_setup_entry", return_value=True): result = await hass.config_entries.flow.async_configure( @@ -240,26 +491,133 @@ async def test_manual_no_capabilities(hass: HomeAssistant) -> None: ) await hass.async_block_till_done() - assert result["type"] == "create_entry" - assert result["data"] == { - CONF_HOST: IP_ADDRESS, - } + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"] == CREATE_ENTRY_DATA_LEGACY + + +async def test_manual_auth( + hass: HomeAssistant, + mock_discovery: AsyncMock, + mock_connect: AsyncMock, + mock_init, +) -> None: + """Test manually setup.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert not result["errors"] + + mock_discovery["mock_device"].update.side_effect = AuthenticationException + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_HOST: IP_ADDRESS} + ) + await hass.async_block_till_done() + + assert result2["type"] is FlowResultType.FORM + assert result2["step_id"] == "user_auth_confirm" + assert not result2["errors"] + + mock_discovery["mock_device"].update.reset_mock(side_effect=True) + + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + user_input={ + CONF_USERNAME: "fake_username", + CONF_PASSWORD: "fake_password", + }, + ) + await hass.async_block_till_done() + assert result3["type"] is FlowResultType.CREATE_ENTRY + assert result3["title"] == DEFAULT_ENTRY_TITLE + assert result3["data"] == CREATE_ENTRY_DATA_AUTH + + +@pytest.mark.parametrize( + ("error_type", "errors_msg", "error_placement"), + [ + (AuthenticationException, "invalid_auth", CONF_PASSWORD), + (SmartDeviceException, "cannot_connect", "base"), + ], + ids=["invalid-auth", "unknown-error"], +) +async def test_manual_auth_errors( + hass: HomeAssistant, + mock_discovery: AsyncMock, + mock_connect: AsyncMock, + mock_init, + error_type, + errors_msg, + error_placement, +) -> None: + """Test manually setup auth errors.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert not result["errors"] + + mock_discovery["mock_device"].update.side_effect = AuthenticationException + default_connect_side_effect = mock_connect["connect"].side_effect + mock_connect["connect"].side_effect = error_type + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_HOST: IP_ADDRESS} + ) + assert result2["type"] is FlowResultType.FORM + assert result2["step_id"] == "user_auth_confirm" + assert not result2["errors"] + + await hass.async_block_till_done() + + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + user_input={ + CONF_USERNAME: "fake_username", + CONF_PASSWORD: "fake_password", + }, + ) + await hass.async_block_till_done() + assert result3["type"] is FlowResultType.FORM + assert result3["step_id"] == "user_auth_confirm" + assert result3["errors"] == {error_placement: errors_msg} + + mock_connect["connect"].side_effect = default_connect_side_effect + result4 = await hass.config_entries.flow.async_configure( + result3["flow_id"], + { + CONF_USERNAME: "fake_username", + CONF_PASSWORD: "fake_password", + }, + ) + assert result4["type"] is FlowResultType.CREATE_ENTRY + assert result4["data"] == CREATE_ENTRY_DATA_AUTH + + await hass.async_block_till_done() async def test_discovered_by_discovery_and_dhcp(hass: HomeAssistant) -> None: """Test we get the form with discovery and abort for dhcp source when we get both.""" - with _patch_discovery(), _patch_single_discovery(): + with _patch_discovery(), _patch_single_discovery(), _patch_connect(): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_INTEGRATION_DISCOVERY}, - data={CONF_HOST: IP_ADDRESS, CONF_MAC: MAC_ADDRESS, CONF_NAME: ALIAS}, + data={ + CONF_HOST: IP_ADDRESS, + CONF_MAC: MAC_ADDRESS, + CONF_ALIAS: ALIAS, + CONF_DEVICE_CONFIG: DEVICE_CONFIG_DICT_LEGACY, + }, ) await hass.async_block_till_done() - assert result["type"] == FlowResultType.FORM + assert result["type"] is FlowResultType.FORM assert result["errors"] is None - with _patch_discovery(), _patch_single_discovery(): + with _patch_discovery(), _patch_single_discovery(), _patch_connect(): result2 = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, @@ -268,10 +626,10 @@ async def test_discovered_by_discovery_and_dhcp(hass: HomeAssistant) -> None: ), ) await hass.async_block_till_done() - assert result2["type"] == FlowResultType.ABORT + assert result2["type"] is FlowResultType.ABORT assert result2["reason"] == "already_in_progress" - with _patch_discovery(), _patch_single_discovery(): + with _patch_discovery(), _patch_single_discovery(), _patch_connect(): result3 = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, @@ -280,10 +638,12 @@ async def test_discovered_by_discovery_and_dhcp(hass: HomeAssistant) -> None: ), ) await hass.async_block_till_done() - assert result3["type"] == FlowResultType.ABORT + assert result3["type"] is FlowResultType.ABORT assert result3["reason"] == "already_in_progress" - with _patch_discovery(no_device=True), _patch_single_discovery(no_device=True): + with _patch_discovery(no_device=True), _patch_single_discovery( + no_device=True + ), _patch_connect(no_device=True): result3 = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, @@ -305,7 +665,12 @@ async def test_discovered_by_discovery_and_dhcp(hass: HomeAssistant) -> None: ), ( config_entries.SOURCE_INTEGRATION_DISCOVERY, - {CONF_HOST: IP_ADDRESS, CONF_MAC: MAC_ADDRESS, CONF_NAME: ALIAS}, + { + CONF_HOST: IP_ADDRESS, + CONF_MAC: MAC_ADDRESS, + CONF_ALIAS: ALIAS, + CONF_DEVICE_CONFIG: DEVICE_CONFIG_DICT_LEGACY, + }, ), ], ) @@ -314,16 +679,16 @@ async def test_discovered_by_dhcp_or_discovery( ) -> None: """Test we can setup when discovered from dhcp or discovery.""" - with _patch_discovery(), _patch_single_discovery(): + with _patch_discovery(), _patch_single_discovery(), _patch_connect(): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": source}, data=data ) await hass.async_block_till_done() - assert result["type"] == FlowResultType.FORM + assert result["type"] is FlowResultType.FORM assert result["errors"] is None - with _patch_discovery(), _patch_single_discovery(), patch( + with _patch_discovery(), _patch_single_discovery(), _patch_connect(), patch( f"{MODULE}.async_setup", return_value=True ) as mock_async_setup, patch( f"{MODULE}.async_setup_entry", return_value=True @@ -331,10 +696,8 @@ async def test_discovered_by_dhcp_or_discovery( result2 = await hass.config_entries.flow.async_configure(result["flow_id"], {}) await hass.async_block_till_done() - assert result2["type"] == "create_entry" - assert result2["data"] == { - CONF_HOST: IP_ADDRESS, - } + assert result2["type"] is FlowResultType.CREATE_ENTRY + assert result2["data"] == CREATE_ENTRY_DATA_LEGACY assert mock_async_setup.called assert mock_async_setup_entry.called @@ -348,7 +711,12 @@ async def test_discovered_by_dhcp_or_discovery( ), ( config_entries.SOURCE_INTEGRATION_DISCOVERY, - {CONF_HOST: IP_ADDRESS, CONF_MAC: MAC_ADDRESS, CONF_NAME: ALIAS}, + { + CONF_HOST: IP_ADDRESS, + CONF_MAC: MAC_ADDRESS, + CONF_ALIAS: ALIAS, + CONF_DEVICE_CONFIG: DEVICE_CONFIG_DICT_LEGACY, + }, ), ], ) @@ -357,10 +725,350 @@ async def test_discovered_by_dhcp_or_discovery_failed_to_get_device( ) -> None: """Test we abort if we cannot get the unique id when discovered from dhcp.""" - with _patch_discovery(no_device=True), _patch_single_discovery(no_device=True): + with _patch_discovery(no_device=True), _patch_single_discovery( + no_device=True + ), _patch_connect(no_device=True): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": source}, data=data ) await hass.async_block_till_done() - assert result["type"] == FlowResultType.ABORT + assert result["type"] is FlowResultType.ABORT assert result["reason"] == "cannot_connect" + + +async def test_reauth( + hass: HomeAssistant, + mock_added_config_entry: MockConfigEntry, + mock_discovery: AsyncMock, + mock_connect: AsyncMock, +) -> None: + """Test reauth flow.""" + mock_added_config_entry.async_start_reauth(hass) + await hass.async_block_till_done() + + assert mock_added_config_entry.state == config_entries.ConfigEntryState.LOADED + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + [result] = flows + assert result["step_id"] == "reauth_confirm" + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_USERNAME: "fake_username", + CONF_PASSWORD: "fake_password", + }, + ) + credentials = Credentials("fake_username", "fake_password") + mock_discovery["discover_single"].assert_called_once_with( + "127.0.0.1", credentials=credentials + ) + mock_discovery["mock_device"].update.assert_called_once_with() + assert result2["type"] is FlowResultType.ABORT + assert result2["reason"] == "reauth_successful" + + await hass.async_block_till_done() + + +async def test_reauth_update_from_discovery( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_discovery: AsyncMock, + mock_connect: AsyncMock, +) -> None: + """Test reauth flow.""" + mock_connect["connect"].side_effect = AuthenticationException + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state == config_entries.ConfigEntryState.SETUP_ERROR + + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + [result] = flows + assert result["step_id"] == "reauth_confirm" + assert mock_config_entry.data[CONF_DEVICE_CONFIG] == DEVICE_CONFIG_DICT_LEGACY + + discovery_result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_INTEGRATION_DISCOVERY}, + data={ + CONF_HOST: IP_ADDRESS, + CONF_MAC: MAC_ADDRESS, + CONF_ALIAS: ALIAS, + CONF_DEVICE_CONFIG: DEVICE_CONFIG_DICT_AUTH, + }, + ) + await hass.async_block_till_done() + assert discovery_result["type"] is FlowResultType.ABORT + assert discovery_result["reason"] == "already_configured" + assert mock_config_entry.data[CONF_DEVICE_CONFIG] == DEVICE_CONFIG_DICT_AUTH + + +async def test_reauth_update_from_discovery_with_ip_change( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_discovery: AsyncMock, + mock_connect: AsyncMock, +) -> None: + """Test reauth flow.""" + mock_connect["connect"].side_effect = AuthenticationException() + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + assert mock_config_entry.state == config_entries.ConfigEntryState.SETUP_ERROR + + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + [result] = flows + assert result["step_id"] == "reauth_confirm" + assert mock_config_entry.data[CONF_DEVICE_CONFIG] == DEVICE_CONFIG_DICT_LEGACY + + discovery_result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_INTEGRATION_DISCOVERY}, + data={ + CONF_HOST: "127.0.0.2", + CONF_MAC: MAC_ADDRESS, + CONF_ALIAS: ALIAS, + CONF_DEVICE_CONFIG: DEVICE_CONFIG_DICT_AUTH, + }, + ) + await hass.async_block_till_done() + assert discovery_result["type"] is FlowResultType.ABORT + assert discovery_result["reason"] == "already_configured" + assert mock_config_entry.data[CONF_DEVICE_CONFIG] == DEVICE_CONFIG_DICT_AUTH + assert mock_config_entry.data[CONF_HOST] == "127.0.0.2" + + +async def test_reauth_no_update_if_config_and_ip_the_same( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_discovery: AsyncMock, + mock_connect: AsyncMock, +) -> None: + """Test reauth discovery does not update when the host and config are the same.""" + mock_connect["connect"].side_effect = AuthenticationException() + mock_config_entry.data = { + **mock_config_entry.data, + CONF_DEVICE_CONFIG: DEVICE_CONFIG_DICT_AUTH, + } + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + assert mock_config_entry.state is config_entries.ConfigEntryState.SETUP_ERROR + + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + [result] = flows + assert result["step_id"] == "reauth_confirm" + assert mock_config_entry.data[CONF_DEVICE_CONFIG] == DEVICE_CONFIG_DICT_AUTH + + discovery_result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_INTEGRATION_DISCOVERY}, + data={ + CONF_HOST: IP_ADDRESS, + CONF_MAC: MAC_ADDRESS, + CONF_ALIAS: ALIAS, + CONF_DEVICE_CONFIG: DEVICE_CONFIG_DICT_AUTH, + }, + ) + await hass.async_block_till_done() + assert discovery_result["type"] is FlowResultType.ABORT + assert discovery_result["reason"] == "already_configured" + assert mock_config_entry.data[CONF_DEVICE_CONFIG] == DEVICE_CONFIG_DICT_AUTH + assert mock_config_entry.data[CONF_HOST] == IP_ADDRESS + + +@pytest.mark.parametrize( + ("error_type", "errors_msg", "error_placement"), + [ + (AuthenticationException, "invalid_auth", CONF_PASSWORD), + (SmartDeviceException, "cannot_connect", "base"), + ], + ids=["invalid-auth", "unknown-error"], +) +async def test_reauth_errors( + hass: HomeAssistant, + mock_added_config_entry: MockConfigEntry, + mock_discovery: AsyncMock, + mock_connect: AsyncMock, + error_type, + errors_msg, + error_placement, +) -> None: + """Test reauth errors.""" + mock_added_config_entry.async_start_reauth(hass) + await hass.async_block_till_done() + + assert mock_added_config_entry.state is config_entries.ConfigEntryState.LOADED + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + [result] = flows + assert result["step_id"] == "reauth_confirm" + + mock_discovery["mock_device"].update.side_effect = error_type + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_USERNAME: "fake_username", + CONF_PASSWORD: "fake_password", + }, + ) + credentials = Credentials("fake_username", "fake_password") + + mock_discovery["discover_single"].assert_called_once_with( + "127.0.0.1", credentials=credentials + ) + mock_discovery["mock_device"].update.assert_called_once_with() + assert result2["type"] is FlowResultType.FORM + assert result2["errors"] == {error_placement: errors_msg} + + mock_discovery["discover_single"].reset_mock() + mock_discovery["mock_device"].update.reset_mock(side_effect=True) + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + user_input={ + CONF_USERNAME: "fake_username", + CONF_PASSWORD: "fake_password", + }, + ) + + mock_discovery["discover_single"].assert_called_once_with( + "127.0.0.1", credentials=credentials + ) + mock_discovery["mock_device"].update.assert_called_once_with() + + assert result3["type"] is FlowResultType.ABORT + assert result3["reason"] == "reauth_successful" + + +@pytest.mark.parametrize( + ("error_type", "expected_flow"), + [ + (AuthenticationException, FlowResultType.FORM), + (SmartDeviceException, FlowResultType.ABORT), + ], + ids=["invalid-auth", "unknown-error"], +) +async def test_pick_device_errors( + hass: HomeAssistant, + mock_discovery: AsyncMock, + mock_connect: AsyncMock, + error_type, + expected_flow, +) -> None: + """Test errors on pick_device.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + await hass.async_block_till_done() + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert not result["errors"] + + result2 = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + await hass.async_block_till_done() + assert result2["type"] is FlowResultType.FORM + assert result2["step_id"] == "pick_device" + assert not result2["errors"] + + default_connect_side_effect = mock_connect["connect"].side_effect + mock_connect["connect"].side_effect = error_type + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + {CONF_DEVICE: MAC_ADDRESS}, + ) + await hass.async_block_till_done() + assert result3["type"] == expected_flow + + if expected_flow != FlowResultType.ABORT: + mock_connect["connect"].side_effect = default_connect_side_effect + result4 = await hass.config_entries.flow.async_configure( + result3["flow_id"], + user_input={ + CONF_USERNAME: "fake_username", + CONF_PASSWORD: "fake_password", + }, + ) + assert result4["type"] == FlowResultType.CREATE_ENTRY + + +async def test_discovery_timeout_connect( + hass: HomeAssistant, + mock_discovery: AsyncMock, + mock_connect: AsyncMock, + mock_init, +) -> None: + """Test discovery tries legacy connect on timeout.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + mock_discovery["discover_single"].side_effect = TimeoutException + await hass.async_block_till_done() + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert not result["errors"] + assert mock_connect["connect"].call_count == 0 + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_HOST: IP_ADDRESS} + ) + await hass.async_block_till_done() + assert result2["type"] is FlowResultType.CREATE_ENTRY + assert mock_connect["connect"].call_count == 1 + + +async def test_reauth_update_other_flows( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_discovery: AsyncMock, + mock_connect: AsyncMock, + # mock_init, +) -> None: + """Test reauth updates other reauth flows.""" + mock_config_entry2 = MockConfigEntry( + title="TPLink", + domain=DOMAIN, + data={**CREATE_ENTRY_DATA_AUTH2}, + unique_id=MAC_ADDRESS2, + ) + default_side_effect = mock_connect["connect"].side_effect + mock_connect["connect"].side_effect = AuthenticationException() + mock_config_entry.add_to_hass(hass) + mock_config_entry2.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry2.state == config_entries.ConfigEntryState.SETUP_ERROR + assert mock_config_entry.state == config_entries.ConfigEntryState.SETUP_ERROR + mock_connect["connect"].side_effect = default_side_effect + + await hass.async_block_till_done() + + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 2 + result = flows[0] + assert result["step_id"] == "reauth_confirm" + assert mock_config_entry.data[CONF_DEVICE_CONFIG] == DEVICE_CONFIG_DICT_LEGACY + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_USERNAME: "fake_username", + CONF_PASSWORD: "fake_password", + }, + ) + credentials = Credentials("fake_username", "fake_password") + mock_discovery["discover_single"].assert_called_once_with( + "127.0.0.1", credentials=credentials + ) + mock_discovery["mock_device"].update.assert_called_once_with() + assert result2["type"] is FlowResultType.ABORT + assert result2["reason"] == "reauth_successful" + + await hass.async_block_till_done() + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 0 diff --git a/tests/components/tplink/test_init.py b/tests/components/tplink/test_init.py index c40560d2a89c5f..e6297cf65539bc 100644 --- a/tests/components/tplink/test_init.py +++ b/tests/components/tplink/test_init.py @@ -1,25 +1,35 @@ """Tests for the TP-Link component.""" from __future__ import annotations +import copy from datetime import timedelta -from unittest.mock import MagicMock, patch +from unittest.mock import AsyncMock, MagicMock, patch import pytest from homeassistant import setup from homeassistant.components import tplink -from homeassistant.components.tplink.const import DOMAIN -from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import CONF_HOST, EVENT_HOMEASSISTANT_STARTED +from homeassistant.components.tplink.const import CONF_DEVICE_CONFIG, DOMAIN +from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState +from homeassistant.const import ( + CONF_AUTHENTICATION, + CONF_HOST, + CONF_PASSWORD, + CONF_USERNAME, + EVENT_HOMEASSISTANT_STARTED, +) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_registry import EntityRegistry from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util from . import ( + CREATE_ENTRY_DATA_AUTH, + DEVICE_CONFIG_AUTH, IP_ADDRESS, MAC_ADDRESS, _mocked_dimmer, + _patch_connect, _patch_discovery, _patch_single_discovery, ) @@ -57,7 +67,7 @@ async def test_config_entry_reload(hass: HomeAssistant) -> None: domain=DOMAIN, data={}, unique_id=MAC_ADDRESS ) already_migrated_config_entry.add_to_hass(hass) - with _patch_discovery(), _patch_single_discovery(): + with _patch_discovery(), _patch_single_discovery(), _patch_connect(): await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) await hass.async_block_till_done() assert already_migrated_config_entry.state == ConfigEntryState.LOADED @@ -72,7 +82,9 @@ async def test_config_entry_retry(hass: HomeAssistant) -> None: domain=DOMAIN, data={CONF_HOST: IP_ADDRESS}, unique_id=MAC_ADDRESS ) already_migrated_config_entry.add_to_hass(hass) - with _patch_discovery(no_device=True), _patch_single_discovery(no_device=True): + with _patch_discovery(no_device=True), _patch_single_discovery( + no_device=True + ), _patch_connect(no_device=True): await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) await hass.async_block_till_done() assert already_migrated_config_entry.state == ConfigEntryState.SETUP_RETRY @@ -102,7 +114,9 @@ async def test_dimmer_switch_unique_id_fix_original_entity_still_exists( original_name="Rollout dimmer", ) - with _patch_discovery(device=dimmer), _patch_single_discovery(device=dimmer): + with _patch_discovery(device=dimmer), _patch_single_discovery( + device=dimmer + ), _patch_connect(device=dimmer): await setup.async_setup_component(hass, DOMAIN, {}) await hass.async_block_till_done() @@ -126,7 +140,7 @@ async def test_config_entry_wrong_mac_Address( domain=DOMAIN, data={CONF_HOST: "127.0.0.1"}, unique_id=mismatched_mac ) already_migrated_config_entry.add_to_hass(hass) - with _patch_discovery(), _patch_single_discovery(): + with _patch_discovery(), _patch_single_discovery(), _patch_connect(): await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) await hass.async_block_till_done() assert already_migrated_config_entry.state == ConfigEntryState.SETUP_RETRY @@ -135,3 +149,110 @@ async def test_config_entry_wrong_mac_Address( "Unexpected device found at 127.0.0.1; expected aa:bb:cc:dd:ee:f0, found aa:bb:cc:dd:ee:ff" in caplog.text ) + + +async def test_config_entry_device_config( + hass: HomeAssistant, + mock_discovery: AsyncMock, + mock_connect: AsyncMock, +) -> None: + """Test that a config entry can be loaded with DeviceConfig.""" + mock_config_entry = MockConfigEntry( + title="TPLink", + domain=DOMAIN, + data={**CREATE_ENTRY_DATA_AUTH}, + unique_id=MAC_ADDRESS, + ) + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + assert mock_config_entry.state is ConfigEntryState.LOADED + + +async def test_config_entry_with_stored_credentials( + hass: HomeAssistant, + mock_discovery: AsyncMock, + mock_connect: AsyncMock, +) -> None: + """Test that a config entry can be loaded when stored credentials are set.""" + stored_credentials = tplink.Credentials("fake_username1", "fake_password1") + mock_config_entry = MockConfigEntry( + title="TPLink", + domain=DOMAIN, + data={**CREATE_ENTRY_DATA_AUTH}, + unique_id=MAC_ADDRESS, + ) + auth = { + CONF_USERNAME: stored_credentials.username, + CONF_PASSWORD: stored_credentials.password, + } + + hass.data.setdefault(DOMAIN, {})[CONF_AUTHENTICATION] = auth + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + assert mock_config_entry.state is ConfigEntryState.LOADED + config = DEVICE_CONFIG_AUTH + assert config.credentials != stored_credentials + config.credentials = stored_credentials + mock_connect["connect"].assert_called_once_with(config=config) + + +async def test_config_entry_device_config_invalid( + hass: HomeAssistant, + mock_discovery: AsyncMock, + mock_connect: AsyncMock, + caplog, +) -> None: + """Test that an invalid device config logs an error and loads the config entry.""" + entry_data = copy.deepcopy(CREATE_ENTRY_DATA_AUTH) + entry_data[CONF_DEVICE_CONFIG] = {"foo": "bar"} + mock_config_entry = MockConfigEntry( + title="TPLink", + domain=DOMAIN, + data={**entry_data}, + unique_id=MAC_ADDRESS, + ) + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + assert mock_config_entry.state is ConfigEntryState.LOADED + + assert ( + f"Invalid connection type dict for {IP_ADDRESS}: {entry_data.get(CONF_DEVICE_CONFIG)}" + in caplog.text + ) + + +@pytest.mark.parametrize( + ("error_type", "entry_state", "reauth_flows"), + [ + (tplink.AuthenticationException, ConfigEntryState.SETUP_ERROR, True), + (tplink.SmartDeviceException, ConfigEntryState.SETUP_RETRY, False), + ], + ids=["invalid-auth", "unknown-error"], +) +async def test_config_entry_errors( + hass: HomeAssistant, + mock_discovery: AsyncMock, + mock_connect: AsyncMock, + error_type, + entry_state, + reauth_flows, +) -> None: + """Test that device exceptions are handled correctly during init.""" + mock_connect["connect"].side_effect = error_type + mock_config_entry = MockConfigEntry( + title="TPLink", + domain=DOMAIN, + data={**CREATE_ENTRY_DATA_AUTH}, + unique_id=MAC_ADDRESS, + ) + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + assert mock_config_entry.state is entry_state + assert ( + any(mock_config_entry.async_get_active_flows(hass, {SOURCE_REAUTH})) + == reauth_flows + ) diff --git a/tests/components/tplink/test_light.py b/tests/components/tplink/test_light.py index ada454e0192bf2..c541551a2503c4 100644 --- a/tests/components/tplink/test_light.py +++ b/tests/components/tplink/test_light.py @@ -33,6 +33,7 @@ MAC_ADDRESS, _mocked_bulb, _mocked_smart_light_strip, + _patch_connect, _patch_discovery, _patch_single_discovery, ) @@ -48,7 +49,7 @@ async def test_light_unique_id(hass: HomeAssistant) -> None: already_migrated_config_entry.add_to_hass(hass) bulb = _mocked_bulb() bulb.color_temp = None - with _patch_discovery(device=bulb), _patch_single_discovery(device=bulb): + with _patch_discovery(device=bulb), _patch_connect(device=bulb): await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) await hass.async_block_till_done() @@ -69,7 +70,7 @@ async def test_color_light( ) already_migrated_config_entry.add_to_hass(hass) bulb.color_temp = None - with _patch_discovery(device=bulb), _patch_single_discovery(device=bulb): + with _patch_discovery(device=bulb), _patch_connect(device=bulb): await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) await hass.async_block_till_done() @@ -151,7 +152,7 @@ async def test_color_light_no_temp(hass: HomeAssistant) -> None: bulb = _mocked_bulb() bulb.is_variable_color_temp = False type(bulb).color_temp = PropertyMock(side_effect=Exception) - with _patch_discovery(device=bulb), _patch_single_discovery(device=bulb): + with _patch_discovery(device=bulb), _patch_connect(device=bulb): await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) await hass.async_block_till_done() @@ -212,7 +213,7 @@ async def test_color_temp_light( bulb.color_temp = 4000 bulb.is_variable_color_temp = True - with _patch_discovery(device=bulb), _patch_single_discovery(device=bulb): + with _patch_discovery(device=bulb), _patch_connect(device=bulb): await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) await hass.async_block_till_done() @@ -295,7 +296,7 @@ async def test_brightness_only_light(hass: HomeAssistant) -> None: bulb.is_color = False bulb.is_variable_color_temp = False - with _patch_discovery(device=bulb), _patch_single_discovery(device=bulb): + with _patch_discovery(device=bulb), _patch_connect(device=bulb): await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) await hass.async_block_till_done() @@ -340,7 +341,7 @@ async def test_on_off_light(hass: HomeAssistant) -> None: bulb.is_variable_color_temp = False bulb.is_dimmable = False - with _patch_discovery(device=bulb), _patch_single_discovery(device=bulb): + with _patch_discovery(device=bulb), _patch_connect(device=bulb): await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) await hass.async_block_till_done() @@ -375,7 +376,7 @@ async def test_off_at_start_light(hass: HomeAssistant) -> None: bulb.is_dimmable = False bulb.is_on = False - with _patch_discovery(device=bulb), _patch_single_discovery(device=bulb): + with _patch_discovery(device=bulb), _patch_connect(device=bulb): await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) await hass.async_block_till_done() @@ -397,7 +398,7 @@ async def test_dimmer_turn_on_fix(hass: HomeAssistant) -> None: bulb.is_dimmer = True bulb.is_on = False - with _patch_discovery(device=bulb), _patch_single_discovery(device=bulb): + with _patch_discovery(device=bulb), _patch_connect(device=bulb): await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) await hass.async_block_till_done() @@ -421,7 +422,9 @@ async def test_smart_strip_effects(hass: HomeAssistant) -> None: already_migrated_config_entry.add_to_hass(hass) strip = _mocked_smart_light_strip() - with _patch_discovery(device=strip), _patch_single_discovery(device=strip): + with _patch_discovery(device=strip), _patch_single_discovery( + device=strip + ), _patch_connect(device=strip): await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) await hass.async_block_till_done() @@ -501,7 +504,7 @@ async def test_smart_strip_custom_random_effect(hass: HomeAssistant) -> None: already_migrated_config_entry.add_to_hass(hass) strip = _mocked_smart_light_strip() - with _patch_discovery(device=strip), _patch_single_discovery(device=strip): + with _patch_discovery(device=strip), _patch_connect(device=strip): await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) await hass.async_block_till_done() @@ -664,7 +667,7 @@ async def test_smart_strip_custom_random_effect_at_start(hass: HomeAssistant) -> "name": "Custom", "enable": 0, } - with _patch_discovery(device=strip), _patch_single_discovery(device=strip): + with _patch_discovery(device=strip), _patch_connect(device=strip): await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) await hass.async_block_till_done() @@ -691,7 +694,7 @@ async def test_smart_strip_custom_sequence_effect(hass: HomeAssistant) -> None: already_migrated_config_entry.add_to_hass(hass) strip = _mocked_smart_light_strip() - with _patch_discovery(device=strip), _patch_single_discovery(device=strip): + with _patch_discovery(device=strip), _patch_connect(device=strip): await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) await hass.async_block_till_done() diff --git a/tests/components/tplink/test_sensor.py b/tests/components/tplink/test_sensor.py index 5413e036d96de1..b67ed031df3867 100644 --- a/tests/components/tplink/test_sensor.py +++ b/tests/components/tplink/test_sensor.py @@ -8,13 +8,7 @@ from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component -from . import ( - MAC_ADDRESS, - _mocked_bulb, - _mocked_plug, - _patch_discovery, - _patch_single_discovery, -) +from . import MAC_ADDRESS, _mocked_bulb, _mocked_plug, _patch_connect, _patch_discovery from tests.common import MockConfigEntry @@ -35,7 +29,7 @@ async def test_color_light_with_an_emeter(hass: HomeAssistant) -> None: current=5, ) bulb.emeter_today = 5000.0036 - with _patch_discovery(device=bulb), _patch_single_discovery(device=bulb): + with _patch_discovery(device=bulb), _patch_connect(device=bulb): await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) await hass.async_block_till_done() await hass.async_block_till_done() @@ -75,7 +69,7 @@ async def test_plug_with_an_emeter(hass: HomeAssistant) -> None: current=5.035, ) plug.emeter_today = None - with _patch_discovery(device=plug), _patch_single_discovery(device=plug): + with _patch_discovery(device=plug), _patch_connect(device=plug): await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) await hass.async_block_till_done() await hass.async_block_till_done() @@ -103,7 +97,7 @@ async def test_color_light_no_emeter(hass: HomeAssistant) -> None: bulb = _mocked_bulb() bulb.color_temp = None bulb.has_emeter = False - with _patch_discovery(device=bulb), _patch_single_discovery(device=bulb): + with _patch_discovery(device=bulb), _patch_connect(device=bulb): await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) await hass.async_block_till_done() await hass.async_block_till_done() @@ -139,7 +133,7 @@ async def test_sensor_unique_id(hass: HomeAssistant) -> None: current=5, ) plug.emeter_today = None - with _patch_discovery(device=plug), _patch_single_discovery(device=plug): + with _patch_discovery(device=plug), _patch_connect(device=plug): await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) await hass.async_block_till_done() diff --git a/tests/components/tplink/test_switch.py b/tests/components/tplink/test_switch.py index 05286e5ff48cb9..372651ea250c02 100644 --- a/tests/components/tplink/test_switch.py +++ b/tests/components/tplink/test_switch.py @@ -20,8 +20,8 @@ _mocked_dimmer, _mocked_plug, _mocked_strip, + _patch_connect, _patch_discovery, - _patch_single_discovery, ) from tests.common import MockConfigEntry, async_fire_time_changed @@ -34,7 +34,7 @@ async def test_plug(hass: HomeAssistant) -> None: ) already_migrated_config_entry.add_to_hass(hass) plug = _mocked_plug() - with _patch_discovery(device=plug), _patch_single_discovery(device=plug): + with _patch_discovery(device=plug), _patch_connect(device=plug): await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) await hass.async_block_till_done() @@ -69,7 +69,7 @@ async def test_led_switch(hass: HomeAssistant, dev, domain: str) -> None: domain=DOMAIN, data={}, unique_id=MAC_ADDRESS ) already_migrated_config_entry.add_to_hass(hass) - with _patch_discovery(device=dev), _patch_single_discovery(device=dev): + with _patch_discovery(device=dev), _patch_connect(device=dev): await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) await hass.async_block_till_done() @@ -100,7 +100,7 @@ async def test_plug_unique_id(hass: HomeAssistant) -> None: ) already_migrated_config_entry.add_to_hass(hass) plug = _mocked_plug() - with _patch_discovery(device=plug), _patch_single_discovery(device=plug): + with _patch_discovery(device=plug), _patch_connect(device=plug): await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) await hass.async_block_till_done() @@ -116,7 +116,7 @@ async def test_plug_update_fails(hass: HomeAssistant) -> None: ) already_migrated_config_entry.add_to_hass(hass) plug = _mocked_plug() - with _patch_discovery(device=plug), _patch_single_discovery(device=plug): + with _patch_discovery(device=plug), _patch_connect(device=plug): await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) await hass.async_block_till_done() @@ -138,7 +138,7 @@ async def test_strip(hass: HomeAssistant) -> None: ) already_migrated_config_entry.add_to_hass(hass) strip = _mocked_strip() - with _patch_discovery(device=strip), _patch_single_discovery(device=strip): + with _patch_discovery(device=strip), _patch_connect(device=strip): await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) await hass.async_block_till_done() @@ -186,7 +186,7 @@ async def test_strip_unique_ids(hass: HomeAssistant) -> None: ) already_migrated_config_entry.add_to_hass(hass) strip = _mocked_strip() - with _patch_discovery(device=strip), _patch_single_discovery(device=strip): + with _patch_discovery(device=strip), _patch_connect(device=strip): await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) await hass.async_block_till_done()