-
-
Notifications
You must be signed in to change notification settings - Fork 31.6k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
* 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
1 parent
258357c
commit ce9a514
Showing
21 changed files
with
957 additions
and
10 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Validating CODEOWNERS rules …
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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"] | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,3 @@ | ||
"""Constants for the TP-Link Omada integration.""" | ||
|
||
DOMAIN = "tplink_omada" |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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, | ||
) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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" | ||
} |
Oops, something went wrong.