Home Assistant Unofficial Reference 2024.12.1
__init__.py
Go to the documentation of this file.
1 """Component to embed TP-Link smart home devices."""
2 
3 from __future__ import annotations
4 
5 import asyncio
6 from collections.abc import Iterable
7 from datetime import timedelta
8 import logging
9 from typing import Any
10 
11 from aiohttp import ClientSession
12 from kasa import (
13  AuthenticationError,
14  Credentials,
15  Device,
16  DeviceConfig,
17  Discover,
18  KasaException,
19 )
20 from kasa.httpclient import get_cookie_jar
21 from kasa.iot import IotStrip
22 
23 from homeassistant import config_entries
24 from homeassistant.components import network
25 from homeassistant.config_entries import ConfigEntry
26 from homeassistant.const import (
27  CONF_ALIAS,
28  CONF_AUTHENTICATION,
29  CONF_DEVICE,
30  CONF_HOST,
31  CONF_MAC,
32  CONF_MODEL,
33  CONF_PASSWORD,
34  CONF_PORT,
35  CONF_USERNAME,
36 )
37 from homeassistant.core import HomeAssistant, callback
38 from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
39 from homeassistant.helpers import (
40  config_validation as cv,
41  device_registry as dr,
42  discovery_flow,
43 )
44 from homeassistant.helpers.aiohttp_client import async_create_clientsession
45 from homeassistant.helpers.event import async_track_time_interval
46 from homeassistant.helpers.typing import ConfigType
47 
48 from .const import (
49  CONF_AES_KEYS,
50  CONF_CONFIG_ENTRY_MINOR_VERSION,
51  CONF_CONNECTION_PARAMETERS,
52  CONF_CREDENTIALS_HASH,
53  CONF_DEVICE_CONFIG,
54  CONF_USES_HTTP,
55  CONNECT_TIMEOUT,
56  DISCOVERY_TIMEOUT,
57  DOMAIN,
58  PLATFORMS,
59 )
60 from .coordinator import TPLinkDataUpdateCoordinator
61 from .models import TPLinkData
62 
63 type TPLinkConfigEntry = ConfigEntry[TPLinkData]
64 
65 DISCOVERY_INTERVAL = timedelta(minutes=15)
66 CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
67 
68 _LOGGER = logging.getLogger(__name__)
69 
70 
71 def create_async_tplink_clientsession(hass: HomeAssistant) -> ClientSession:
72  """Return aiohttp clientsession with cookie jar configured."""
74  hass, verify_ssl=False, cookie_jar=get_cookie_jar()
75  )
76 
77 
78 @callback
80  hass: HomeAssistant,
81  discovered_devices: dict[str, Device],
82 ) -> None:
83  """Trigger config flows for discovered devices."""
84 
85  for formatted_mac, device in discovered_devices.items():
86  discovery_flow.async_create_flow(
87  hass,
88  DOMAIN,
89  context={"source": config_entries.SOURCE_INTEGRATION_DISCOVERY},
90  data={
91  CONF_ALIAS: device.alias or mac_alias(device.mac),
92  CONF_HOST: device.host,
93  CONF_MAC: formatted_mac,
94  CONF_DEVICE: device,
95  },
96  )
97 
98 
99 async def async_discover_devices(hass: HomeAssistant) -> dict[str, Device]:
100  """Discover TPLink devices on configured network interfaces."""
101 
102  credentials = await get_credentials(hass)
103  broadcast_addresses = await network.async_get_ipv4_broadcast_addresses(hass)
104  tasks = [
105  Discover.discover(
106  target=str(address),
107  discovery_timeout=DISCOVERY_TIMEOUT,
108  timeout=CONNECT_TIMEOUT,
109  credentials=credentials,
110  )
111  for address in broadcast_addresses
112  ]
113  discovered_devices: dict[str, Device] = {}
114  for device_list in await asyncio.gather(*tasks):
115  for device in device_list.values():
116  discovered_devices[dr.format_mac(device.mac)] = device
117  return discovered_devices
118 
119 
120 async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
121  """Set up the TP-Link component."""
122  hass.data.setdefault(DOMAIN, {})
123 
124  async def _async_discovery(*_: Any) -> None:
125  if discovered := await async_discover_devices(hass):
126  async_trigger_discovery(hass, discovered)
127 
128  hass.async_create_background_task(
129  _async_discovery(), "tplink first discovery", eager_start=True
130  )
132  hass, _async_discovery, DISCOVERY_INTERVAL, cancel_on_shutdown=True
133  )
134 
135  return True
136 
137 
138 async def async_setup_entry(hass: HomeAssistant, entry: TPLinkConfigEntry) -> bool:
139  """Set up TPLink from a config entry."""
140  host: str = entry.data[CONF_HOST]
141  credentials = await get_credentials(hass)
142  entry_credentials_hash = entry.data.get(CONF_CREDENTIALS_HASH)
143  entry_use_http = entry.data.get(CONF_USES_HTTP, False)
144  entry_aes_keys = entry.data.get(CONF_AES_KEYS)
145  port_override = entry.data.get(CONF_PORT)
146 
147  conn_params: Device.ConnectionParameters | None = None
148  if conn_params_dict := entry.data.get(CONF_CONNECTION_PARAMETERS):
149  try:
150  conn_params = Device.ConnectionParameters.from_dict(conn_params_dict)
151  except (KasaException, TypeError, ValueError, LookupError):
152  _LOGGER.warning(
153  "Invalid connection parameters dict for %s: %s", host, conn_params_dict
154  )
155 
156  client = create_async_tplink_clientsession(hass) if entry_use_http else None
157  config = DeviceConfig(
158  host,
159  timeout=CONNECT_TIMEOUT,
160  http_client=client,
161  aes_keys=entry_aes_keys,
162  port_override=port_override,
163  )
164  if conn_params:
165  config.connection_type = conn_params
166  # If we have in memory credentials use them otherwise check for credentials_hash
167  if credentials:
168  config.credentials = credentials
169  elif entry_credentials_hash:
170  config.credentials_hash = entry_credentials_hash
171 
172  try:
173  device: Device = await Device.connect(config=config)
174  except AuthenticationError as ex:
175  # If the stored credentials_hash was used but doesn't work remove it
176  if not credentials and entry_credentials_hash:
177  data = {k: v for k, v in entry.data.items() if k != CONF_CREDENTIALS_HASH}
178  hass.config_entries.async_update_entry(entry, data=data)
179  raise ConfigEntryAuthFailed from ex
180  except KasaException as ex:
181  raise ConfigEntryNotReady from ex
182 
183  device_credentials_hash = device.credentials_hash
184 
185  # We not need to update the connection parameters or the use_http here
186  # because if they were wrong we would have failed to connect.
187  # Discovery will update those if necessary.
188  updates: dict[str, Any] = {}
189  if device_credentials_hash and device_credentials_hash != entry_credentials_hash:
190  updates[CONF_CREDENTIALS_HASH] = device_credentials_hash
191  if entry_aes_keys != device.config.aes_keys:
192  updates[CONF_AES_KEYS] = device.config.aes_keys
193  if entry.data.get(CONF_ALIAS) != device.alias:
194  updates[CONF_ALIAS] = device.alias
195  if entry.data.get(CONF_MODEL) != device.model:
196  updates[CONF_MODEL] = device.model
197  if updates:
198  hass.config_entries.async_update_entry(
199  entry,
200  data={
201  **entry.data,
202  **updates,
203  },
204  )
205  found_mac = dr.format_mac(device.mac)
206  if found_mac != entry.unique_id:
207  # If the mac address of the device does not match the unique_id
208  # of the config entry, it likely means the DHCP lease has expired
209  # and the device has been assigned a new IP address. We need to
210  # wait for the next discovery to find the device at its new address
211  # and update the config entry so we do not mix up devices.
212  raise ConfigEntryNotReady(
213  f"Unexpected device found at {host}; expected {entry.unique_id}, found {found_mac}"
214  )
215 
216  parent_coordinator = TPLinkDataUpdateCoordinator(hass, device, timedelta(seconds=5))
217  child_coordinators: list[TPLinkDataUpdateCoordinator] = []
218 
219  # The iot HS300 allows a limited number of concurrent requests and fetching the
220  # emeter information requires separate ones so create child coordinators here.
221  if isinstance(device, IotStrip):
222  child_coordinators = [
223  # The child coordinators only update energy data so we can
224  # set a longer update interval to avoid flooding the device
225  TPLinkDataUpdateCoordinator(hass, child, timedelta(seconds=60))
226  for child in device.children
227  ]
228 
229  entry.runtime_data = TPLinkData(parent_coordinator, child_coordinators)
230  await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
231 
232  return True
233 
234 
235 async def async_unload_entry(hass: HomeAssistant, entry: TPLinkConfigEntry) -> bool:
236  """Unload a config entry."""
237  data = entry.runtime_data
238  device = data.parent_coordinator.device
239  unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
240  await device.protocol.close()
241 
242  return unload_ok
243 
244 
245 def legacy_device_id(device: Device) -> str:
246  """Convert the device id so it matches what was used in the original version."""
247  device_id: str = device.device_id
248  # Plugs are prefixed with the mac in python-kasa but not
249  # in pyHS100 so we need to strip off the mac
250  if "_" not in device_id:
251  return device_id
252  return device_id.split("_")[1]
253 
254 
255 def get_device_name(device: Device, parent: Device | None = None) -> str:
256  """Get a name for the device. alias can be none on some devices."""
257  if device.alias:
258  return device.alias
259  # Return the child device type with an index if there's more than one child device
260  # of the same type. i.e. Devices like the ks240 with one child of each type
261  # skip the suffix
262  if parent:
263  devices = [
264  child.device_id
265  for child in parent.children
266  if child.device_type is device.device_type
267  ]
268  suffix = f" {devices.index(device.device_id) + 1}" if len(devices) > 1 else ""
269  return f"{device.device_type.value.capitalize()}{suffix}"
270  return f"Unnamed {device.model}"
271 
272 
273 async def get_credentials(hass: HomeAssistant) -> Credentials | None:
274  """Retrieve the credentials from hass data."""
275  if DOMAIN in hass.data and CONF_AUTHENTICATION in hass.data[DOMAIN]:
276  auth = hass.data[DOMAIN][CONF_AUTHENTICATION]
277  return Credentials(auth[CONF_USERNAME], auth[CONF_PASSWORD])
278 
279  return None
280 
281 
282 async def set_credentials(hass: HomeAssistant, username: str, password: str) -> None:
283  """Save the credentials to HASS data."""
284  hass.data.setdefault(DOMAIN, {})[CONF_AUTHENTICATION] = {
285  CONF_USERNAME: username,
286  CONF_PASSWORD: password,
287  }
288 
289 
290 def mac_alias(mac: str) -> str:
291  """Convert a MAC address to a short address for the UI."""
292  return mac.replace(":", "")[-4:].upper()
293 
294 
295 def _mac_connection_or_none(device: dr.DeviceEntry) -> str | None:
296  return next(
297  (
298  conn
299  for type_, conn in device.connections
300  if type_ == dr.CONNECTION_NETWORK_MAC
301  ),
302  None,
303  )
304 
305 
306 def _device_id_is_mac_or_none(mac: str, device_ids: Iterable[str]) -> str | None:
307  # Previously only iot devices had child devices and iot devices use
308  # the upper and lcase MAC addresses as device_id so match on case
309  # insensitive mac address as the parent device.
310  upper_mac = mac.upper()
311  return next(
312  (device_id for device_id in device_ids if device_id.upper() == upper_mac),
313  None,
314  )
315 
316 
317 async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool:
318  """Migrate old entry."""
319  entry_version = config_entry.version
320  entry_minor_version = config_entry.minor_version
321  # having a condition to check for the current version allows
322  # tests to be written per migration step.
323  config_flow_minor_version = CONF_CONFIG_ENTRY_MINOR_VERSION
324 
325  new_minor_version = 3
326  if (
327  entry_version == 1
328  and entry_minor_version < new_minor_version <= config_flow_minor_version
329  ):
330  _LOGGER.debug(
331  "Migrating from version %s.%s", entry_version, entry_minor_version
332  )
333  # Previously entities on child devices added themselves to the parent
334  # device and set their device id as identifiers along with mac
335  # as a connection which creates a single device entry linked by all
336  # identifiers. Now we create separate devices connected with via_device
337  # so the identifier linkage must be removed otherwise the devices will
338  # always be linked into one device.
339  dev_reg = dr.async_get(hass)
340  for device in dr.async_entries_for_config_entry(dev_reg, config_entry.entry_id):
341  original_identifiers = device.identifiers
342  # Get only the tplink identifier, could be tapo or other integrations.
343  tplink_identifiers = [
344  ident[1] for ident in original_identifiers if ident[0] == DOMAIN
345  ]
346  # Nothing to fix if there's only one identifier. mac connection
347  # should never be none but if it is there's no problem.
348  if len(tplink_identifiers) <= 1 or not (
349  mac := _mac_connection_or_none(device)
350  ):
351  continue
352  if not (
353  tplink_parent_device_id := _device_id_is_mac_or_none(
354  mac, tplink_identifiers
355  )
356  ):
357  # No match on mac so raise an error.
358  _LOGGER.error(
359  "Unable to replace identifiers for device %s (%s): %s",
360  device.name,
361  device.model,
362  device.identifiers,
363  )
364  continue
365  # Retain any identifiers for other domains
366  new_identifiers = {
367  ident for ident in device.identifiers if ident[0] != DOMAIN
368  }
369  new_identifiers.add((DOMAIN, tplink_parent_device_id))
370  dev_reg.async_update_device(device.id, new_identifiers=new_identifiers)
371  _LOGGER.debug(
372  "Replaced identifiers for device %s (%s): %s with: %s",
373  device.name,
374  device.model,
375  original_identifiers,
376  new_identifiers,
377  )
378 
379  hass.config_entries.async_update_entry(
380  config_entry, minor_version=new_minor_version
381  )
382 
383  _LOGGER.debug(
384  "Migration to version %s.%s complete", entry_version, new_minor_version
385  )
386 
387  new_minor_version = 4
388  if (
389  entry_version == 1
390  and entry_minor_version < new_minor_version <= config_flow_minor_version
391  ):
392  # credentials_hash stored in the device_config should be moved to data.
393  updates: dict[str, Any] = {}
394  if config_dict := config_entry.data.get(CONF_DEVICE_CONFIG):
395  assert isinstance(config_dict, dict)
396  if credentials_hash := config_dict.pop(CONF_CREDENTIALS_HASH, None):
397  updates[CONF_CREDENTIALS_HASH] = credentials_hash
398  updates[CONF_DEVICE_CONFIG] = config_dict
399  hass.config_entries.async_update_entry(
400  config_entry,
401  data={
402  **config_entry.data,
403  **updates,
404  },
405  minor_version=new_minor_version,
406  )
407  _LOGGER.debug(
408  "Migration to version %s.%s complete", entry_version, new_minor_version
409  )
410 
411  new_minor_version = 5
412  if (
413  entry_version == 1
414  and entry_minor_version < new_minor_version <= config_flow_minor_version
415  ):
416  # complete device config no longer to be stored, only required
417  # attributes like connection parameters and aes_keys
418  updates = {}
419  entry_data = {
420  k: v for k, v in config_entry.data.items() if k != CONF_DEVICE_CONFIG
421  }
422  if config_dict := config_entry.data.get(CONF_DEVICE_CONFIG):
423  assert isinstance(config_dict, dict)
424  if connection_parameters := config_dict.get("connection_type"):
425  updates[CONF_CONNECTION_PARAMETERS] = connection_parameters
426  if (use_http := config_dict.get(CONF_USES_HTTP)) is not None:
427  updates[CONF_USES_HTTP] = use_http
428  hass.config_entries.async_update_entry(
429  config_entry,
430  data={
431  **entry_data,
432  **updates,
433  },
434  minor_version=new_minor_version,
435  )
436  _LOGGER.debug(
437  "Migration to version %s.%s complete", entry_version, new_minor_version
438  )
439  return True
aiohttp.ClientSession async_create_clientsession()
Definition: coordinator.py:51
CALLBACK_TYPE async_track_time_interval(HomeAssistant hass, Callable[[datetime], Coroutine[Any, Any, None]|None] action, timedelta interval, *str|None name=None, bool|None cancel_on_shutdown=None)
Definition: event.py:1679