diff --git a/homeassistant/components/tplink/__init__.py b/homeassistant/components/tplink/__init__.py index 764867f0bee..6d300f68aa0 100644 --- a/homeassistant/components/tplink/__init__.py +++ b/homeassistant/components/tplink/__init__.py @@ -43,6 +43,7 @@ from homeassistant.helpers.event import async_track_time_interval from homeassistant.helpers.typing import ConfigType from .const import ( + CONF_CREDENTIALS_HASH, CONF_DEVICE_CONFIG, CONNECT_TIMEOUT, DISCOVERY_TIMEOUT, @@ -73,6 +74,7 @@ def async_trigger_discovery( discovered_devices: dict[str, Device], ) -> None: """Trigger config flows for discovered devices.""" + for formatted_mac, device in discovered_devices.items(): discovery_flow.async_create_flow( hass, @@ -83,7 +85,6 @@ def async_trigger_discovery( CONF_HOST: device.host, CONF_MAC: formatted_mac, CONF_DEVICE_CONFIG: device.config.to_dict( - credentials_hash=device.credentials_hash, exclude_credentials=True, ), }, @@ -133,6 +134,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: TPLinkConfigEntry) -> bo """Set up TPLink from a config entry.""" host: str = entry.data[CONF_HOST] credentials = await get_credentials(hass) + entry_credentials_hash = entry.data.get(CONF_CREDENTIALS_HASH) config: DeviceConfig | None = None if config_dict := entry.data.get(CONF_DEVICE_CONFIG): @@ -151,19 +153,31 @@ async def async_setup_entry(hass: HomeAssistant, entry: TPLinkConfigEntry) -> bo config.timeout = CONNECT_TIMEOUT if config.uses_http is True: config.http_client = create_async_tplink_clientsession(hass) + + # If we have in memory credentials use them otherwise check for credentials_hash if credentials: config.credentials = credentials + elif entry_credentials_hash: + config.credentials_hash = entry_credentials_hash + try: device: Device = await Device.connect(config=config) except AuthenticationError as ex: + # If the stored credentials_hash was used but doesn't work remove it + if not credentials and entry_credentials_hash: + data = {k: v for k, v in entry.data.items() if k != CONF_CREDENTIALS_HASH} + hass.config_entries.async_update_entry(entry, data=data) raise ConfigEntryAuthFailed from ex except KasaException as ex: raise ConfigEntryNotReady from ex - device_config_dict = device.config.to_dict( - credentials_hash=device.credentials_hash, exclude_credentials=True - ) + device_credentials_hash = device.credentials_hash + device_config_dict = device.config.to_dict(exclude_credentials=True) + # Do not store the credentials hash inside the device_config + device_config_dict.pop(CONF_CREDENTIALS_HASH, None) updates: dict[str, Any] = {} + if device_credentials_hash and device_credentials_hash != entry_credentials_hash: + updates[CONF_CREDENTIALS_HASH] = device_credentials_hash if device_config_dict != config_dict: updates[CONF_DEVICE_CONFIG] = device_config_dict if entry.data.get(CONF_ALIAS) != device.alias: @@ -326,7 +340,25 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> minor_version = 3 hass.config_entries.async_update_entry(config_entry, minor_version=3) - _LOGGER.debug("Migration to version %s.%s successful", version, minor_version) + if version == 1 and minor_version == 3: + # credentials_hash stored in the device_config should be moved to data. + updates: dict[str, Any] = {} + if config_dict := config_entry.data.get(CONF_DEVICE_CONFIG): + assert isinstance(config_dict, dict) + if credentials_hash := config_dict.pop(CONF_CREDENTIALS_HASH, None): + updates[CONF_CREDENTIALS_HASH] = credentials_hash + updates[CONF_DEVICE_CONFIG] = config_dict + minor_version = 4 + hass.config_entries.async_update_entry( + config_entry, + data={ + **config_entry.data, + **updates, + }, + minor_version=minor_version, + ) + _LOGGER.debug("Migration to version %s.%s complete", version, minor_version) + return True diff --git a/homeassistant/components/tplink/config_flow.py b/homeassistant/components/tplink/config_flow.py index 7bead2207a3..5608ccfa72f 100644 --- a/homeassistant/components/tplink/config_flow.py +++ b/homeassistant/components/tplink/config_flow.py @@ -44,7 +44,13 @@ from . import ( mac_alias, set_credentials, ) -from .const import CONF_DEVICE_CONFIG, CONNECT_TIMEOUT, DOMAIN +from .const import ( + CONF_CONNECTION_TYPE, + CONF_CREDENTIALS_HASH, + CONF_DEVICE_CONFIG, + CONNECT_TIMEOUT, + DOMAIN, +) STEP_AUTH_DATA_SCHEMA = vol.Schema( {vol.Required(CONF_USERNAME): str, vol.Required(CONF_PASSWORD): str} @@ -55,7 +61,7 @@ class TPLinkConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for tplink.""" VERSION = 1 - MINOR_VERSION = 3 + MINOR_VERSION = 4 reauth_entry: ConfigEntry | None = None def __init__(self) -> None: @@ -95,9 +101,18 @@ class TPLinkConfigFlow(ConfigFlow, domain=DOMAIN): entry_config_dict = entry_data.get(CONF_DEVICE_CONFIG) if entry_config_dict == config and entry_data[CONF_HOST] == host: return None + updates = {**entry.data, CONF_DEVICE_CONFIG: config, CONF_HOST: host} + # If the connection parameters have changed the credentials_hash will be invalid. + if ( + entry_config_dict + and isinstance(entry_config_dict, dict) + and entry_config_dict.get(CONF_CONNECTION_TYPE) + != config.get(CONF_CONNECTION_TYPE) + ): + updates.pop(CONF_CREDENTIALS_HASH, None) return self.async_update_reload_and_abort( entry, - data={**entry.data, CONF_DEVICE_CONFIG: config, CONF_HOST: host}, + data=updates, reason="already_configured", ) @@ -345,18 +360,22 @@ class TPLinkConfigFlow(ConfigFlow, domain=DOMAIN): @callback def _async_create_entry_from_device(self, device: Device) -> ConfigFlowResult: """Create a config entry from a smart device.""" + # This is only ever called after a successful device update so we know that + # the credential_hash is correct and should be saved. self._abort_if_unique_id_configured(updates={CONF_HOST: device.host}) + data = { + CONF_HOST: device.host, + CONF_ALIAS: device.alias, + CONF_MODEL: device.model, + CONF_DEVICE_CONFIG: device.config.to_dict( + exclude_credentials=True, + ), + } + if device.credentials_hash: + data[CONF_CREDENTIALS_HASH] = device.credentials_hash return self.async_create_entry( 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, - ), - }, + data=data, ) async def _async_try_discover_and_update( diff --git a/homeassistant/components/tplink/const.py b/homeassistant/components/tplink/const.py index d77d415aa9c..babd92e2c34 100644 --- a/homeassistant/components/tplink/const.py +++ b/homeassistant/components/tplink/const.py @@ -20,6 +20,8 @@ ATTR_TODAY_ENERGY_KWH: Final = "today_energy_kwh" ATTR_TOTAL_ENERGY_KWH: Final = "total_energy_kwh" CONF_DEVICE_CONFIG: Final = "device_config" +CONF_CREDENTIALS_HASH: Final = "credentials_hash" +CONF_CONNECTION_TYPE: Final = "connection_type" PLATFORMS: Final = [ Platform.BINARY_SENSOR, diff --git a/tests/components/tplink/__init__.py b/tests/components/tplink/__init__.py index 9c8aeb99be1..b3092d62904 100644 --- a/tests/components/tplink/__init__.py +++ b/tests/components/tplink/__init__.py @@ -22,6 +22,7 @@ from syrupy import SnapshotAssertion from homeassistant.components.tplink import ( CONF_ALIAS, + CONF_CREDENTIALS_HASH, CONF_DEVICE_CONFIG, CONF_HOST, CONF_MODEL, @@ -53,9 +54,7 @@ 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 -) +DEVICE_CONFIG_DICT_LEGACY = DEVICE_CONFIG_LEGACY.to_dict(exclude_credentials=True) CREDENTIALS = Credentials("foo", "bar") CREDENTIALS_HASH_AUTH = "abcdefghijklmnopqrstuv==" DEVICE_CONFIG_AUTH = DeviceConfig( @@ -74,12 +73,8 @@ DEVICE_CONFIG_AUTH2 = DeviceConfig( ), 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 -) +DEVICE_CONFIG_DICT_AUTH = DEVICE_CONFIG_AUTH.to_dict(exclude_credentials=True) +DEVICE_CONFIG_DICT_AUTH2 = DEVICE_CONFIG_AUTH2.to_dict(exclude_credentials=True) CREATE_ENTRY_DATA_LEGACY = { CONF_HOST: IP_ADDRESS, @@ -92,14 +87,20 @@ CREATE_ENTRY_DATA_AUTH = { CONF_HOST: IP_ADDRESS, CONF_ALIAS: ALIAS, CONF_MODEL: MODEL, + CONF_CREDENTIALS_HASH: CREDENTIALS_HASH_AUTH, CONF_DEVICE_CONFIG: DEVICE_CONFIG_DICT_AUTH, } CREATE_ENTRY_DATA_AUTH2 = { CONF_HOST: IP_ADDRESS2, CONF_ALIAS: ALIAS, CONF_MODEL: MODEL, + CONF_CREDENTIALS_HASH: CREDENTIALS_HASH_AUTH, CONF_DEVICE_CONFIG: DEVICE_CONFIG_DICT_AUTH2, } +NEW_CONNECTION_TYPE = DeviceConnectionParameters( + DeviceFamily.IotSmartPlugSwitch, DeviceEncryptionType.Aes +) +NEW_CONNECTION_TYPE_DICT = NEW_CONNECTION_TYPE.to_dict() def _load_feature_fixtures(): diff --git a/tests/components/tplink/test_config_flow.py b/tests/components/tplink/test_config_flow.py index 7560ff4a72d..e9ae7957520 100644 --- a/tests/components/tplink/test_config_flow.py +++ b/tests/components/tplink/test_config_flow.py @@ -14,8 +14,12 @@ from homeassistant.components.tplink import ( DeviceConfig, KasaException, ) -from homeassistant.components.tplink.const import CONF_DEVICE_CONFIG -from homeassistant.config_entries import ConfigEntryState +from homeassistant.components.tplink.const import ( + CONF_CONNECTION_TYPE, + CONF_CREDENTIALS_HASH, + CONF_DEVICE_CONFIG, +) +from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState from homeassistant.const import ( CONF_ALIAS, CONF_DEVICE, @@ -32,6 +36,7 @@ from . import ( CREATE_ENTRY_DATA_AUTH, CREATE_ENTRY_DATA_AUTH2, CREATE_ENTRY_DATA_LEGACY, + CREDENTIALS_HASH_AUTH, DEFAULT_ENTRY_TITLE, DEVICE_CONFIG_DICT_AUTH, DEVICE_CONFIG_DICT_LEGACY, @@ -40,6 +45,7 @@ from . import ( MAC_ADDRESS, MAC_ADDRESS2, MODULE, + NEW_CONNECTION_TYPE_DICT, _mocked_device, _patch_connect, _patch_discovery, @@ -811,6 +817,77 @@ async def test_integration_discovery_with_ip_change( mock_connect["connect"].assert_awaited_once_with(config=config) +async def test_integration_discovery_with_connection_change( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_discovery: AsyncMock, + mock_connect: AsyncMock, +) -> None: + """Test that config entry is updated with new device config. + + And that connection_hash is removed as it will be invalid. + """ + mock_connect["connect"].side_effect = KasaException() + + mock_config_entry = MockConfigEntry( + title="TPLink", + domain=DOMAIN, + data=CREATE_ENTRY_DATA_AUTH, + unique_id=MAC_ADDRESS, + ) + mock_config_entry.add_to_hass(hass) + with patch("homeassistant.components.tplink.Discover.discover", return_value={}): + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done(wait_background_tasks=True) + + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + assert ( + len( + hass.config_entries.flow.async_progress_by_handler( + DOMAIN, match_context={"source": SOURCE_REAUTH} + ) + ) + == 0 + ) + assert mock_config_entry.data[CONF_DEVICE_CONFIG] == DEVICE_CONFIG_DICT_AUTH + assert mock_config_entry.data[CONF_DEVICE_CONFIG].get(CONF_HOST) == "127.0.0.1" + assert mock_config_entry.data[CONF_CREDENTIALS_HASH] == CREDENTIALS_HASH_AUTH + + NEW_DEVICE_CONFIG = { + **DEVICE_CONFIG_DICT_AUTH, + CONF_CONNECTION_TYPE: NEW_CONNECTION_TYPE_DICT, + } + config = DeviceConfig.from_dict(NEW_DEVICE_CONFIG) + # Reset the connect mock so when the config flow reloads the entry it succeeds + mock_connect["connect"].reset_mock(side_effect=True) + bulb = _mocked_device( + device_config=config, + mac=mock_config_entry.unique_id, + ) + mock_connect["connect"].return_value = bulb + + discovery_result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_INTEGRATION_DISCOVERY}, + data={ + CONF_HOST: "127.0.0.1", + CONF_MAC: MAC_ADDRESS, + CONF_ALIAS: ALIAS, + CONF_DEVICE_CONFIG: NEW_DEVICE_CONFIG, + }, + ) + await hass.async_block_till_done(wait_background_tasks=True) + assert discovery_result["type"] is FlowResultType.ABORT + assert discovery_result["reason"] == "already_configured" + assert mock_config_entry.data[CONF_DEVICE_CONFIG] == NEW_DEVICE_CONFIG + assert mock_config_entry.data[CONF_HOST] == "127.0.0.1" + assert CREDENTIALS_HASH_AUTH not in mock_config_entry.data + + assert mock_config_entry.state is ConfigEntryState.LOADED + + mock_connect["connect"].assert_awaited_once_with(config=config) + + async def test_dhcp_discovery_with_ip_change( hass: HomeAssistant, mock_config_entry: MockConfigEntry, diff --git a/tests/components/tplink/test_init.py b/tests/components/tplink/test_init.py index 61ec9decc10..bfb7e02b63d 100644 --- a/tests/components/tplink/test_init.py +++ b/tests/components/tplink/test_init.py @@ -7,12 +7,16 @@ from datetime import timedelta from unittest.mock import AsyncMock, MagicMock, PropertyMock, patch from freezegun.api import FrozenDateTimeFactory -from kasa import AuthenticationError, Feature, KasaException, Module +from kasa import AuthenticationError, DeviceConfig, Feature, KasaException, Module import pytest from homeassistant import setup from homeassistant.components import tplink -from homeassistant.components.tplink.const import CONF_DEVICE_CONFIG, DOMAIN +from homeassistant.components.tplink.const import ( + CONF_CREDENTIALS_HASH, + CONF_DEVICE_CONFIG, + DOMAIN, +) from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState from homeassistant.const import ( CONF_AUTHENTICATION, @@ -458,7 +462,214 @@ async def test_unlink_devices( expected_identifiers = identifiers[:expected_count] assert device_entries[0].identifiers == set(expected_identifiers) assert entry.version == 1 - assert entry.minor_version == 3 + assert entry.minor_version == 4 msg = f"{expected_message} identifiers for device dummy (hs300): {set(identifiers)}" assert msg in caplog.text + + +async def test_move_credentials_hash( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test credentials hash moved to parent. + + As async_setup_entry will succeed the hash on the parent is updated + from the device. + """ + device_config = { + **DEVICE_CONFIG_AUTH.to_dict( + exclude_credentials=True, credentials_hash="theHash" + ) + } + entry_data = {**CREATE_ENTRY_DATA_AUTH, CONF_DEVICE_CONFIG: device_config} + + entry = MockConfigEntry( + title="TPLink", + domain=DOMAIN, + data=entry_data, + entry_id="123456", + unique_id=MAC_ADDRESS, + version=1, + minor_version=3, + ) + assert entry.data[CONF_DEVICE_CONFIG][CONF_CREDENTIALS_HASH] == "theHash" + entry.add_to_hass(hass) + + async def _connect(config): + config.credentials_hash = "theNewHash" + return _mocked_device(device_config=config, credentials_hash="theNewHash") + + with ( + patch("homeassistant.components.tplink.Device.connect", new=_connect), + patch("homeassistant.components.tplink.PLATFORMS", []), + ): + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert entry.minor_version == 4 + assert entry.state is ConfigEntryState.LOADED + assert CONF_CREDENTIALS_HASH not in entry.data[CONF_DEVICE_CONFIG] + assert CONF_CREDENTIALS_HASH in entry.data + # Gets the new hash from the successful connection. + assert entry.data[CONF_CREDENTIALS_HASH] == "theNewHash" + assert "Migration to version 1.4 complete" in caplog.text + + +async def test_move_credentials_hash_auth_error( + hass: HomeAssistant, +) -> None: + """Test credentials hash moved to parent. + + If there is an auth error it should be deleted after migration + in async_setup_entry. + """ + device_config = { + **DEVICE_CONFIG_AUTH.to_dict( + exclude_credentials=True, credentials_hash="theHash" + ) + } + entry_data = {**CREATE_ENTRY_DATA_AUTH, CONF_DEVICE_CONFIG: device_config} + + entry = MockConfigEntry( + title="TPLink", + domain=DOMAIN, + data=entry_data, + unique_id=MAC_ADDRESS, + version=1, + minor_version=3, + ) + assert entry.data[CONF_DEVICE_CONFIG][CONF_CREDENTIALS_HASH] == "theHash" + + with ( + patch( + "homeassistant.components.tplink.Device.connect", + side_effect=AuthenticationError, + ), + patch("homeassistant.components.tplink.PLATFORMS", []), + ): + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert entry.minor_version == 4 + assert entry.state is ConfigEntryState.SETUP_ERROR + assert CONF_CREDENTIALS_HASH not in entry.data[CONF_DEVICE_CONFIG] + # Auth failure deletes the hash + assert CONF_CREDENTIALS_HASH not in entry.data + + +async def test_move_credentials_hash_other_error( + hass: HomeAssistant, +) -> None: + """Test credentials hash moved to parent. + + When there is a KasaException the same hash should still be on the parent + at the end of the test. + """ + device_config = { + **DEVICE_CONFIG_AUTH.to_dict( + exclude_credentials=True, credentials_hash="theHash" + ) + } + entry_data = {**CREATE_ENTRY_DATA_AUTH, CONF_DEVICE_CONFIG: device_config} + + entry = MockConfigEntry( + title="TPLink", + domain=DOMAIN, + data=entry_data, + unique_id=MAC_ADDRESS, + version=1, + minor_version=3, + ) + assert entry.data[CONF_DEVICE_CONFIG][CONF_CREDENTIALS_HASH] == "theHash" + + with ( + patch( + "homeassistant.components.tplink.Device.connect", side_effect=KasaException + ), + patch("homeassistant.components.tplink.PLATFORMS", []), + ): + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert entry.minor_version == 4 + assert entry.state is ConfigEntryState.SETUP_RETRY + assert CONF_CREDENTIALS_HASH not in entry.data[CONF_DEVICE_CONFIG] + assert CONF_CREDENTIALS_HASH in entry.data + assert entry.data[CONF_CREDENTIALS_HASH] == "theHash" + + +async def test_credentials_hash( + hass: HomeAssistant, +) -> None: + """Test credentials_hash used to call connect.""" + device_config = {**DEVICE_CONFIG_AUTH.to_dict(exclude_credentials=True)} + entry_data = { + **CREATE_ENTRY_DATA_AUTH, + CONF_DEVICE_CONFIG: device_config, + CONF_CREDENTIALS_HASH: "theHash", + } + + entry = MockConfigEntry( + title="TPLink", + domain=DOMAIN, + data=entry_data, + unique_id=MAC_ADDRESS, + ) + + async def _connect(config): + config.credentials_hash = "theHash" + return _mocked_device(device_config=config, credentials_hash="theHash") + + with ( + patch("homeassistant.components.tplink.PLATFORMS", []), + patch("homeassistant.components.tplink.Device.connect", new=_connect), + ): + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state is ConfigEntryState.LOADED + assert CONF_CREDENTIALS_HASH not in entry.data[CONF_DEVICE_CONFIG] + assert CONF_CREDENTIALS_HASH in entry.data + assert entry.data[CONF_DEVICE_CONFIG] == device_config + assert entry.data[CONF_CREDENTIALS_HASH] == "theHash" + + +async def test_credentials_hash_auth_error( + hass: HomeAssistant, +) -> None: + """Test credentials_hash is deleted after an auth failure.""" + device_config = {**DEVICE_CONFIG_AUTH.to_dict(exclude_credentials=True)} + entry_data = { + **CREATE_ENTRY_DATA_AUTH, + CONF_DEVICE_CONFIG: device_config, + CONF_CREDENTIALS_HASH: "theHash", + } + + entry = MockConfigEntry( + title="TPLink", + domain=DOMAIN, + data=entry_data, + unique_id=MAC_ADDRESS, + ) + + with ( + patch("homeassistant.components.tplink.PLATFORMS", []), + patch( + "homeassistant.components.tplink.Device.connect", + side_effect=AuthenticationError, + ) as connect_mock, + ): + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + expected_config = DeviceConfig.from_dict( + DEVICE_CONFIG_AUTH.to_dict(exclude_credentials=True, credentials_hash="theHash") + ) + connect_mock.assert_called_with(config=expected_config) + assert entry.state is ConfigEntryState.SETUP_ERROR + assert CONF_CREDENTIALS_HASH not in entry.data