Skip to content

Commit

Permalink
TP-Link Omada integration (#81223)
Browse files Browse the repository at this point in the history
* TP-Link Omada integration
Support for PoE config of network switch ports

* Bump omada client version

* Fixing tests

* Refactored site config flow

* Code review comments

* Fixed tests and device display name issue

* Bump isort to fix pre-commit hooks

* Hassfest for the win

* Omada code review

* Black

* More config flow test coverage

* Full coverage for omada config_flow

---------

Co-authored-by: Paulus Schoutsen <balloob@gmail.com>
  • Loading branch information
MarkGodwin and balloob authored Feb 6, 2023
1 parent 258357c commit ce9a514
Show file tree
Hide file tree
Showing 21 changed files with 957 additions and 10 deletions.
4 changes: 4 additions & 0 deletions .coveragerc
Original file line number Diff line number Diff line change
Expand Up @@ -1268,6 +1268,10 @@ omit =
homeassistant/components/totalconnect/binary_sensor.py
homeassistant/components/touchline/climate.py
homeassistant/components/tplink_lte/*
homeassistant/components/tplink_omada/__init__.py
homeassistant/components/tplink_omada/coordinator.py
homeassistant/components/tplink_omada/entity.py
homeassistant/components/tplink_omada/switch.py
homeassistant/components/traccar/device_tracker.py
homeassistant/components/tractive/__init__.py
homeassistant/components/tractive/binary_sensor.py
Expand Down
1 change: 1 addition & 0 deletions .strict-typing
Original file line number Diff line number Diff line change
Expand Up @@ -299,6 +299,7 @@ homeassistant.components.tile.*
homeassistant.components.tilt_ble.*
homeassistant.components.tolo.*
homeassistant.components.tplink.*
homeassistant.components.tplink_omada.*
homeassistant.components.tractive.*
homeassistant.components.tradfri.*
homeassistant.components.trafikverket_ferry.*
Expand Down
2 changes: 2 additions & 0 deletions CODEOWNERS
Validating CODEOWNERS rules …
Original file line number Diff line number Diff line change
Expand Up @@ -1231,6 +1231,8 @@ build.json @home-assistant/supervisor
/tests/components/totalconnect/ @austinmroczek
/homeassistant/components/tplink/ @rytilahti @thegardenmonkey
/tests/components/tplink/ @rytilahti @thegardenmonkey
/homeassistant/components/tplink_omada/ @MarkGodwin
/tests/components/tplink_omada/ @MarkGodwin
/homeassistant/components/traccar/ @ludeeus
/tests/components/traccar/ @ludeeus
/homeassistant/components/trace/ @home-assistant/core
Expand Down
5 changes: 5 additions & 0 deletions homeassistant/brands/tplink.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"domain": "tplink",
"name": "TP-Link",
"integrations": ["tplink", "tplink_omada", "tplink_lte"]
}
60 changes: 60 additions & 0 deletions homeassistant/components/tplink_omada/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
"""The TP-Link Omada integration."""
from __future__ import annotations

from tplink_omada_client.exceptions import (
ConnectionFailed,
LoginFailed,
OmadaClientException,
UnsupportedControllerVersion,
)
from tplink_omada_client.omadaclient import OmadaSite

from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady

from .config_flow import CONF_SITE, create_omada_client
from .const import DOMAIN

PLATFORMS: list[Platform] = [Platform.SWITCH]


async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up TP-Link Omada from a config entry."""

hass.data.setdefault(DOMAIN, {})

try:
client = await create_omada_client(hass, entry.data)
await client.login()

except (LoginFailed, UnsupportedControllerVersion) as ex:
raise ConfigEntryAuthFailed(
f"Omada controller refused login attempt: {ex}"
) from ex
except ConnectionFailed as ex:
raise ConfigEntryNotReady(
f"Omada controller could not be reached: {ex}"
) from ex

except OmadaClientException as ex:
raise ConfigEntryNotReady(
f"Unexpected error connecting to Omada controller: {ex}"
) from ex

site_client = await client.get_site_client(OmadaSite(None, entry.data[CONF_SITE]))

hass.data[DOMAIN][entry.entry_id] = site_client

await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)

return True


async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
hass.data[DOMAIN].pop(entry.entry_id)

return unload_ok
199 changes: 199 additions & 0 deletions homeassistant/components/tplink_omada/config_flow.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,199 @@
"""Config flow for TP-Link Omada integration."""
from __future__ import annotations

from collections.abc import Mapping
import logging
from types import MappingProxyType
from typing import Any, NamedTuple

from tplink_omada_client.exceptions import (
ConnectionFailed,
LoginFailed,
OmadaClientException,
UnsupportedControllerVersion,
)
from tplink_omada_client.omadaclient import OmadaClient, OmadaSite
import voluptuous as vol

from homeassistant import config_entries
from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME, CONF_VERIFY_SSL
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResult
from homeassistant.helpers import selector
from homeassistant.helpers.aiohttp_client import async_get_clientsession

from .const import DOMAIN

_LOGGER = logging.getLogger(__name__)

CONF_SITE = "site"

STEP_USER_DATA_SCHEMA = vol.Schema(
{
vol.Required(CONF_HOST): str,
vol.Required(CONF_VERIFY_SSL, default=True): bool,
vol.Required(CONF_USERNAME): str,
vol.Required(CONF_PASSWORD): str,
}
)


async def create_omada_client(
hass: HomeAssistant, data: MappingProxyType[str, Any]
) -> OmadaClient:
"""Create a TP-Link Omada client API for the given config entry."""
host = data[CONF_HOST]
verify_ssl = bool(data[CONF_VERIFY_SSL])
username = data[CONF_USERNAME]
password = data[CONF_PASSWORD]
websession = async_get_clientsession(hass, verify_ssl=verify_ssl)
return OmadaClient(host, username, password, websession=websession)


class HubInfo(NamedTuple):
"""Discovered controller information."""

controller_id: str
name: str
sites: list[OmadaSite]


async def _validate_input(hass: HomeAssistant, data: dict[str, Any]) -> HubInfo:
"""Validate the user input allows us to connect."""

client = await create_omada_client(hass, MappingProxyType(data))
controller_id = await client.login()
name = await client.get_controller_name()
sites = await client.get_sites()

return HubInfo(controller_id, name, sites)


class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
"""Handle a config flow for TP-Link Omada."""

VERSION = 1

def __init__(self) -> None:
"""Create the config flow for a new integration."""
self._omada_opts: dict[str, Any] = {}
self._sites: list[OmadaSite] = []
self._controller_name = ""

async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Handle the initial step."""

errors: dict[str, str] = {}
info = None
if user_input is not None:
info = await self._test_login(user_input, errors)

if info is None or user_input is None:
return self.async_show_form(
step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors
)

await self.async_set_unique_id(info.controller_id)
self._abort_if_unique_id_configured()

self._omada_opts.update(user_input)
self._sites = info.sites
self._controller_name = info.name
if len(self._sites) > 1:
return await self.async_step_site()
return await self.async_step_site({CONF_SITE: self._sites[0].id})

async def async_step_site(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Handle step to select site to manage."""

if user_input is None:
schema = vol.Schema(
{
vol.Required(CONF_SITE, "site"): selector.SelectSelector(
selector.SelectSelectorConfig(
options=[
selector.SelectOptionDict(value=s.id, label=s.name)
for s in self._sites
],
multiple=False,
mode=selector.SelectSelectorMode.DROPDOWN,
)
)
}
)

return self.async_show_form(step_id="site", data_schema=schema)

self._omada_opts.update(user_input)
site_name = next(
site for site in self._sites if site.id == user_input["site"]
).name
display_name = f"{self._controller_name} ({site_name})"

return self.async_create_entry(title=display_name, data=self._omada_opts)

async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult:
"""Perform reauth upon an API authentication error."""
self._omada_opts = dict(entry_data)
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] = {}

if user_input is not None:
self._omada_opts.update(user_input)
info = await self._test_login(self._omada_opts, errors)

if info is not None:
# Auth successful - update the config entry with the new credentials
entry = self.hass.config_entries.async_get_entry(
self.context["entry_id"]
)
assert entry is not None
self.hass.config_entries.async_update_entry(
entry, data=self._omada_opts
)
await self.hass.config_entries.async_reload(entry.entry_id)
return self.async_abort(reason="reauth_successful")

return self.async_show_form(
step_id="reauth_confirm",
data_schema=vol.Schema(
{
vol.Required(CONF_USERNAME): str,
vol.Required(CONF_PASSWORD): str,
}
),
errors=errors,
)

async def _test_login(
self, data: dict[str, Any], errors: dict[str, str]
) -> HubInfo | None:
try:
info = await _validate_input(self.hass, data)
if len(info.sites) > 0:
return info
errors["base"] = "no_sites_found"

except ConnectionFailed:
errors["base"] = "cannot_connect"
except LoginFailed:
errors["base"] = "invalid_auth"
except UnsupportedControllerVersion:
errors["base"] = "unsupported_controller"
except OmadaClientException as ex:
_LOGGER.error("Unexpected API error: %s", ex)
errors["base"] = "unknown"
except Exception: # pylint: disable=broad-except
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
return None
3 changes: 3 additions & 0 deletions homeassistant/components/tplink_omada/const.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
"""Constants for the TP-Link Omada integration."""

DOMAIN = "tplink_omada"
44 changes: 44 additions & 0 deletions homeassistant/components/tplink_omada/coordinator.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
"""Generic Omada API coordinator."""
from collections.abc import Awaitable, Callable
from datetime import timedelta
import logging
from typing import Generic, TypeVar

import async_timeout
from tplink_omada_client.exceptions import OmadaClientException
from tplink_omada_client.omadaclient import OmadaClient

from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed

_LOGGER = logging.getLogger(__name__)

T = TypeVar("T")


class OmadaCoordinator(DataUpdateCoordinator[dict[str, T]], Generic[T]):
"""Coordinator for synchronizing bulk Omada data."""

def __init__(
self,
hass: HomeAssistant,
omada_client: OmadaClient,
update_func: Callable[[OmadaClient], Awaitable[dict[str, T]]],
) -> None:
"""Initialize my coordinator."""
super().__init__(
hass,
_LOGGER,
name="Omada API Data",
update_interval=timedelta(seconds=300),
)
self.omada_client = omada_client
self._update_func = update_func

async def _async_update_data(self) -> dict[str, T]:
"""Fetch data from API endpoint."""
try:
async with async_timeout.timeout(10):
return await self._update_func(self.omada_client)
except OmadaClientException as err:
raise UpdateFailed(f"Error communicating with API: {err}") from err
33 changes: 33 additions & 0 deletions homeassistant/components/tplink_omada/entity.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
"""Base entity definitions."""
from tplink_omada_client.devices import OmadaSwitch, OmadaSwitchPortDetails

from homeassistant.helpers import device_registry
from homeassistant.helpers.entity import DeviceInfo
from homeassistant.helpers.update_coordinator import CoordinatorEntity

from .const import DOMAIN
from .coordinator import OmadaCoordinator


class OmadaSwitchDeviceEntity(
CoordinatorEntity[OmadaCoordinator[OmadaSwitchPortDetails]]
):
"""Common base class for all entities attached to Omada network switches."""

def __init__(
self, coordinator: OmadaCoordinator[OmadaSwitchPortDetails], device: OmadaSwitch
) -> None:
"""Initialize the switch."""
super().__init__(coordinator)
self.device = device

@property
def device_info(self) -> DeviceInfo:
"""Return information about the device."""
return DeviceInfo(
connections={(device_registry.CONNECTION_NETWORK_MAC, self.device.mac)},
identifiers={(DOMAIN, (self.device.mac))},
manufacturer="TP-Link",
model=self.device.model_display_name,
name=self.device.name,
)
10 changes: 10 additions & 0 deletions homeassistant/components/tplink_omada/manifest.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"domain": "tplink_omada",
"name": "TP-Link Omada",
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/tplink_omada",
"integration_type": "hub",
"requirements": ["tplink-omada-client==1.1.0"],
"codeowners": ["@MarkGodwin"],
"iot_class": "local_polling"
}
Loading

0 comments on commit ce9a514

Please sign in to comment.