Skip to content

Commit

Permalink
Add authentication to tplink integration for newer devices (home-assi…
Browse files Browse the repository at this point in the history
…stant#105143)

* Add authentication flows to tplink integration to enable newer device protocol support

* Add websession passing to tplink integration discover methods

* Use SmartDevice.connect()

* Update to use DeviceConfig

* Use credential hashes

* Bump python-kasa to 0.6.0.dev0

* Fix tests and address review comments

* Add autodetection for L530, P110, and L900

This adds mac address prefixes for the devices I have.
The wildcards are left quite lax assuming different series may share the same prefix.

* Bump tplink to 0.6.0.dev1

* Add config flow tests

* Use short_mac if alias is None and try legacy connect on discovery timeout

* Add config_flow tests

* Add init tests

* Migrate to aiohttp

* add some more ouis

* final

* ip change fix

* add fixmes

* fix O(n) searching

* fix O(n) searching

* move code that cannot fail outside of try block

* fix missing reauth_successful string

* add doc strings, cleanups

* error message by password

* dry

* adjust discovery timeout

* integration discovery already formats mac

* tweaks

* cleanups

* cleanups

* Update post review and fix broken tests

* Fix TODOs and FIXMEs in test_config_flow

* Add pragma no cover

* bump, apply suggestions

* remove no cover

* use iden check

* Apply suggestions from code review

* Fix branched test and update integration title

* legacy typing

* Update homeassistant/components/tplink/__init__.py

* lint

* Remove more unused consts

* Update test docstrings

* Add sdb9696 to tplink codeowners

* Update docstring on test for invalid DeviceConfig

* Update test stored credentials test

---------

Co-authored-by: Teemu Rytilahti <tpr@iki.fi>
Co-authored-by: J. Nick Koston <nick@koston.org>
  • Loading branch information
3 people authored Jan 21, 2024
1 parent c3da51d commit 9b3d3b3
Show file tree
Hide file tree
Showing 18 changed files with 1,661 additions and 161 deletions.
4 changes: 2 additions & 2 deletions CODEOWNERS
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
125 changes: 115 additions & 10 deletions homeassistant/components/tplink/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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():
Expand All @@ -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)
Expand All @@ -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
Expand Down Expand Up @@ -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


Expand All @@ -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()
Loading

0 comments on commit 9b3d3b3

Please sign in to comment.