From accba85e35d0e7e19f5bcb6855f429c6ef52b197 Mon Sep 17 00:00:00 2001 From: Andrey Kupreychik Date: Sun, 14 Feb 2021 19:09:19 +0700 Subject: [PATCH] Add keenetic_ndms2 config flow (#38353) --- .coveragerc | 4 + .../components/keenetic_ndms2/__init__.py | 91 ++++++ .../keenetic_ndms2/binary_sensor.py | 72 +++++ .../components/keenetic_ndms2/config_flow.py | 159 +++++++++ .../components/keenetic_ndms2/const.py | 21 ++ .../keenetic_ndms2/device_tracker.py | 301 +++++++++++++----- .../components/keenetic_ndms2/manifest.json | 5 +- .../components/keenetic_ndms2/router.py | 187 +++++++++++ .../components/keenetic_ndms2/strings.json | 36 +++ .../keenetic_ndms2/translations/en.json | 36 +++ homeassistant/generated/config_flows.py | 1 + requirements_all.txt | 2 +- requirements_test_all.txt | 3 + tests/components/keenetic_ndms2/__init__.py | 27 ++ .../keenetic_ndms2/test_config_flow.py | 169 ++++++++++ 15 files changed, 1036 insertions(+), 78 deletions(-) create mode 100644 homeassistant/components/keenetic_ndms2/binary_sensor.py create mode 100644 homeassistant/components/keenetic_ndms2/config_flow.py create mode 100644 homeassistant/components/keenetic_ndms2/const.py create mode 100644 homeassistant/components/keenetic_ndms2/router.py create mode 100644 homeassistant/components/keenetic_ndms2/strings.json create mode 100644 homeassistant/components/keenetic_ndms2/translations/en.json create mode 100644 tests/components/keenetic_ndms2/__init__.py create mode 100644 tests/components/keenetic_ndms2/test_config_flow.py diff --git a/.coveragerc b/.coveragerc index 8c7d4b3393d..17dd078d15b 100644 --- a/.coveragerc +++ b/.coveragerc @@ -466,7 +466,11 @@ omit = homeassistant/components/kaiterra/* homeassistant/components/kankun/switch.py homeassistant/components/keba/* + homeassistant/components/keenetic_ndms2/__init__.py + homeassistant/components/keenetic_ndms2/binary_sensor.py + homeassistant/components/keenetic_ndms2/const.py homeassistant/components/keenetic_ndms2/device_tracker.py + homeassistant/components/keenetic_ndms2/router.py homeassistant/components/kef/* homeassistant/components/keyboard/* homeassistant/components/keyboard_remote/* diff --git a/homeassistant/components/keenetic_ndms2/__init__.py b/homeassistant/components/keenetic_ndms2/__init__.py index cb0a718d716..42d747b5238 100644 --- a/homeassistant/components/keenetic_ndms2/__init__.py +++ b/homeassistant/components/keenetic_ndms2/__init__.py @@ -1 +1,92 @@ """The keenetic_ndms2 component.""" + +from homeassistant.components import binary_sensor, device_tracker +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST, CONF_SCAN_INTERVAL +from homeassistant.core import Config, HomeAssistant + +from .const import ( + CONF_CONSIDER_HOME, + CONF_INCLUDE_ARP, + CONF_INCLUDE_ASSOCIATED, + CONF_INTERFACES, + CONF_TRY_HOTSPOT, + DEFAULT_CONSIDER_HOME, + DEFAULT_INTERFACE, + DEFAULT_SCAN_INTERVAL, + DOMAIN, + ROUTER, + UNDO_UPDATE_LISTENER, +) +from .router import KeeneticRouter + +PLATFORMS = [device_tracker.DOMAIN, binary_sensor.DOMAIN] + + +async def async_setup(hass: HomeAssistant, _config: Config) -> bool: + """Set up configured entries.""" + hass.data.setdefault(DOMAIN, {}) + return True + + +async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: + """Set up the component.""" + + async_add_defaults(hass, config_entry) + + router = KeeneticRouter(hass, config_entry) + await router.async_setup() + + undo_listener = config_entry.add_update_listener(update_listener) + + hass.data[DOMAIN][config_entry.entry_id] = { + ROUTER: router, + UNDO_UPDATE_LISTENER: undo_listener, + } + + for component in PLATFORMS: + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(config_entry, component) + ) + + return True + + +async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: + """Unload a config entry.""" + hass.data[DOMAIN][config_entry.entry_id][UNDO_UPDATE_LISTENER]() + + for component in PLATFORMS: + await hass.config_entries.async_forward_entry_unload(config_entry, component) + + router: KeeneticRouter = hass.data[DOMAIN][config_entry.entry_id][ROUTER] + + await router.async_teardown() + + hass.data[DOMAIN].pop(config_entry.entry_id) + + return True + + +async def update_listener(hass, config_entry): + """Handle options update.""" + await hass.config_entries.async_reload(config_entry.entry_id) + + +def async_add_defaults(hass: HomeAssistant, config_entry: ConfigEntry): + """Populate default options.""" + host: str = config_entry.data[CONF_HOST] + imported_options: dict = hass.data[DOMAIN].get(f"imported_options_{host}", {}) + options = { + CONF_SCAN_INTERVAL: DEFAULT_SCAN_INTERVAL, + CONF_CONSIDER_HOME: DEFAULT_CONSIDER_HOME, + CONF_INTERFACES: [DEFAULT_INTERFACE], + CONF_TRY_HOTSPOT: True, + CONF_INCLUDE_ARP: True, + CONF_INCLUDE_ASSOCIATED: True, + **imported_options, + **config_entry.options, + } + + if options.keys() - config_entry.options.keys(): + hass.config_entries.async_update_entry(config_entry, options=options) diff --git a/homeassistant/components/keenetic_ndms2/binary_sensor.py b/homeassistant/components/keenetic_ndms2/binary_sensor.py new file mode 100644 index 00000000000..5da52eff00d --- /dev/null +++ b/homeassistant/components/keenetic_ndms2/binary_sensor.py @@ -0,0 +1,72 @@ +"""The Keenetic Client class.""" +import logging + +from homeassistant.components.binary_sensor import ( + DEVICE_CLASS_CONNECTIVITY, + BinarySensorEntity, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.dispatcher import async_dispatcher_connect + +from . import KeeneticRouter +from .const import DOMAIN, ROUTER + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, config_entry: ConfigEntry, async_add_entities +): + """Set up device tracker for Keenetic NDMS2 component.""" + router: KeeneticRouter = hass.data[DOMAIN][config_entry.entry_id][ROUTER] + + async_add_entities([RouterOnlineBinarySensor(router)]) + + +class RouterOnlineBinarySensor(BinarySensorEntity): + """Representation router connection status.""" + + def __init__(self, router: KeeneticRouter): + """Initialize the APCUPSd binary device.""" + self._router = router + + @property + def name(self): + """Return the name of the online status sensor.""" + return f"{self._router.name} Online" + + @property + def unique_id(self) -> str: + """Return a unique identifier for this device.""" + return f"online_{self._router.config_entry.entry_id}" + + @property + def is_on(self): + """Return true if the UPS is online, else false.""" + return self._router.available + + @property + def device_class(self): + """Return the class of this device, from component DEVICE_CLASSES.""" + return DEVICE_CLASS_CONNECTIVITY + + @property + def should_poll(self) -> bool: + """Return False since entity pushes its state to HA.""" + return False + + @property + def device_info(self): + """Return a client description for device registry.""" + return self._router.device_info + + async def async_added_to_hass(self): + """Client entity created.""" + self.async_on_remove( + async_dispatcher_connect( + self.hass, + self._router.signal_update, + self.async_write_ha_state, + ) + ) diff --git a/homeassistant/components/keenetic_ndms2/config_flow.py b/homeassistant/components/keenetic_ndms2/config_flow.py new file mode 100644 index 00000000000..9338cb05935 --- /dev/null +++ b/homeassistant/components/keenetic_ndms2/config_flow.py @@ -0,0 +1,159 @@ +"""Config flow for Keenetic NDMS2.""" +from typing import List + +from ndms2_client import Client, ConnectionException, InterfaceInfo, TelnetConnection +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + CONF_HOST, + CONF_PASSWORD, + CONF_PORT, + CONF_SCAN_INTERVAL, + CONF_USERNAME, +) +from homeassistant.core import callback +import homeassistant.helpers.config_validation as cv + +from .const import ( + CONF_CONSIDER_HOME, + CONF_INCLUDE_ARP, + CONF_INCLUDE_ASSOCIATED, + CONF_INTERFACES, + CONF_TRY_HOTSPOT, + DEFAULT_CONSIDER_HOME, + DEFAULT_INTERFACE, + DEFAULT_SCAN_INTERVAL, + DEFAULT_TELNET_PORT, + DOMAIN, + ROUTER, +) +from .router import KeeneticRouter + + +class KeeneticFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow.""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_POLL + + @staticmethod + @callback + def async_get_options_flow(config_entry): + """Get the options flow for this handler.""" + return KeeneticOptionsFlowHandler(config_entry) + + async def async_step_user(self, user_input=None): + """Handle a flow initialized by the user.""" + errors = {} + if user_input is not None: + for entry in self.hass.config_entries.async_entries(DOMAIN): + if entry.data[CONF_HOST] == user_input[CONF_HOST]: + return self.async_abort(reason="already_configured") + + _client = Client( + TelnetConnection( + user_input[CONF_HOST], + user_input[CONF_PORT], + user_input[CONF_USERNAME], + user_input[CONF_PASSWORD], + timeout=10, + ) + ) + + try: + router_info = await self.hass.async_add_executor_job( + _client.get_router_info + ) + except ConnectionException: + errors["base"] = "cannot_connect" + else: + return self.async_create_entry(title=router_info.name, data=user_input) + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Required(CONF_HOST): str, + vol.Required(CONF_USERNAME): str, + vol.Required(CONF_PASSWORD): str, + vol.Optional(CONF_PORT, default=DEFAULT_TELNET_PORT): int, + } + ), + errors=errors, + ) + + async def async_step_import(self, user_input=None): + """Import a config entry.""" + return await self.async_step_user(user_input) + + +class KeeneticOptionsFlowHandler(config_entries.OptionsFlow): + """Handle options.""" + + def __init__(self, config_entry: ConfigEntry): + """Initialize options flow.""" + self.config_entry = config_entry + self._interface_options = {} + + async def async_step_init(self, _user_input=None): + """Manage the options.""" + router: KeeneticRouter = self.hass.data[DOMAIN][self.config_entry.entry_id][ + ROUTER + ] + + interfaces: List[InterfaceInfo] = await self.hass.async_add_executor_job( + router.client.get_interfaces + ) + + self._interface_options = { + interface.name: (interface.description or interface.name) + for interface in interfaces + if interface.type.lower() == "bridge" + } + return await self.async_step_user() + + async def async_step_user(self, user_input=None): + """Manage the device tracker options.""" + if user_input is not None: + return self.async_create_entry(title="", data=user_input) + + options = vol.Schema( + { + vol.Required( + CONF_SCAN_INTERVAL, + default=self.config_entry.options.get( + CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL + ), + ): int, + vol.Required( + CONF_CONSIDER_HOME, + default=self.config_entry.options.get( + CONF_CONSIDER_HOME, DEFAULT_CONSIDER_HOME + ), + ): int, + vol.Required( + CONF_INTERFACES, + default=self.config_entry.options.get( + CONF_INTERFACES, [DEFAULT_INTERFACE] + ), + ): cv.multi_select(self._interface_options), + vol.Optional( + CONF_TRY_HOTSPOT, + default=self.config_entry.options.get(CONF_TRY_HOTSPOT, True), + ): bool, + vol.Optional( + CONF_INCLUDE_ARP, + default=self.config_entry.options.get(CONF_INCLUDE_ARP, True), + ): bool, + vol.Optional( + CONF_INCLUDE_ASSOCIATED, + default=self.config_entry.options.get( + CONF_INCLUDE_ASSOCIATED, True + ), + ): bool, + } + ) + + return self.async_show_form(step_id="user", data_schema=options) diff --git a/homeassistant/components/keenetic_ndms2/const.py b/homeassistant/components/keenetic_ndms2/const.py new file mode 100644 index 00000000000..1818cfab6a6 --- /dev/null +++ b/homeassistant/components/keenetic_ndms2/const.py @@ -0,0 +1,21 @@ +"""Constants used in the Keenetic NDMS2 components.""" + +from homeassistant.components.device_tracker.const import ( + DEFAULT_CONSIDER_HOME as _DEFAULT_CONSIDER_HOME, +) + +DOMAIN = "keenetic_ndms2" +ROUTER = "router" +UNDO_UPDATE_LISTENER = "undo_update_listener" +DEFAULT_TELNET_PORT = 23 +DEFAULT_SCAN_INTERVAL = 120 +DEFAULT_CONSIDER_HOME = _DEFAULT_CONSIDER_HOME.seconds +DEFAULT_INTERFACE = "Home" + +CONF_CONSIDER_HOME = "consider_home" +CONF_INTERFACES = "interfaces" +CONF_TRY_HOTSPOT = "try_hotspot" +CONF_INCLUDE_ARP = "include_arp" +CONF_INCLUDE_ASSOCIATED = "include_associated" + +CONF_LEGACY_INTERFACE = "interface" diff --git a/homeassistant/components/keenetic_ndms2/device_tracker.py b/homeassistant/components/keenetic_ndms2/device_tracker.py index d98806dfc05..9df222a326c 100644 --- a/homeassistant/components/keenetic_ndms2/device_tracker.py +++ b/homeassistant/components/keenetic_ndms2/device_tracker.py @@ -1,102 +1,253 @@ -"""Support for Zyxel Keenetic NDMS2 based routers.""" +"""Support for Keenetic routers as device tracker.""" +from datetime import timedelta import logging +from typing import List, Optional, Set -from ndms2_client import Client, ConnectionException, TelnetConnection +from ndms2_client import Device import voluptuous as vol from homeassistant.components.device_tracker import ( - DOMAIN, - PLATFORM_SCHEMA, - DeviceScanner, + DOMAIN as DEVICE_TRACKER_DOMAIN, + PLATFORM_SCHEMA as DEVICE_TRACKER_SCHEMA, + SOURCE_TYPE_ROUTER, ) -from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_USERNAME +from homeassistant.components.device_tracker.config_entry import ScannerEntity +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry +from homeassistant.const import ( + CONF_HOST, + CONF_PASSWORD, + CONF_PORT, + CONF_SCAN_INTERVAL, + CONF_USERNAME, +) +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import entity_registry import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC +from homeassistant.helpers.dispatcher import async_dispatcher_connect +import homeassistant.util.dt as dt_util + +from .const import ( + CONF_CONSIDER_HOME, + CONF_INTERFACES, + CONF_LEGACY_INTERFACE, + DEFAULT_CONSIDER_HOME, + DEFAULT_INTERFACE, + DEFAULT_SCAN_INTERVAL, + DEFAULT_TELNET_PORT, + DOMAIN, + ROUTER, +) +from .router import KeeneticRouter _LOGGER = logging.getLogger(__name__) -# Interface name to track devices for. Most likely one will not need to -# change it from default 'Home'. This is needed not to track Guest WI-FI- -# clients and router itself -CONF_INTERFACE = "interface" - -DEFAULT_INTERFACE = "Home" -DEFAULT_PORT = 23 - - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( +PLATFORM_SCHEMA = DEVICE_TRACKER_SCHEMA.extend( { vol.Required(CONF_HOST): cv.string, vol.Required(CONF_USERNAME): cv.string, - vol.Required(CONF_PORT, default=DEFAULT_PORT): cv.port, + vol.Required(CONF_PORT, default=DEFAULT_TELNET_PORT): cv.port, vol.Required(CONF_PASSWORD): cv.string, - vol.Required(CONF_INTERFACE, default=DEFAULT_INTERFACE): cv.string, + vol.Required(CONF_LEGACY_INTERFACE, default=DEFAULT_INTERFACE): cv.string, } ) -def get_scanner(_hass, config): - """Validate the configuration and return a Keenetic NDMS2 scanner.""" - scanner = KeeneticNDMS2DeviceScanner(config[DOMAIN]) +async def async_get_scanner(hass: HomeAssistant, config): + """Import legacy configuration from YAML.""" - return scanner if scanner.success_init else None + scanner_config = config[DEVICE_TRACKER_DOMAIN] + scan_interval: Optional[timedelta] = scanner_config.get(CONF_SCAN_INTERVAL) + consider_home: Optional[timedelta] = scanner_config.get(CONF_CONSIDER_HOME) + + host: str = scanner_config[CONF_HOST] + hass.data[DOMAIN][f"imported_options_{host}"] = { + CONF_INTERFACES: [scanner_config[CONF_LEGACY_INTERFACE]], + CONF_SCAN_INTERVAL: int(scan_interval.total_seconds()) + if scan_interval + else DEFAULT_SCAN_INTERVAL, + CONF_CONSIDER_HOME: int(consider_home.total_seconds()) + if consider_home + else DEFAULT_CONSIDER_HOME, + } + + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data={ + CONF_HOST: scanner_config[CONF_HOST], + CONF_PORT: scanner_config[CONF_PORT], + CONF_USERNAME: scanner_config[CONF_USERNAME], + CONF_PASSWORD: scanner_config[CONF_PASSWORD], + }, + ) + ) + + _LOGGER.warning( + "Your Keenetic NDMS2 configuration has been imported into the UI, " + "please remove it from configuration.yaml. " + "Loading Keenetic NDMS2 via scanner setup is now deprecated" + ) + + return None -class KeeneticNDMS2DeviceScanner(DeviceScanner): - """This class scans for devices using keenetic NDMS2 web interface.""" +async def async_setup_entry( + hass: HomeAssistant, config_entry: ConfigEntry, async_add_entities +): + """Set up device tracker for Keenetic NDMS2 component.""" + router: KeeneticRouter = hass.data[DOMAIN][config_entry.entry_id][ROUTER] - def __init__(self, config): - """Initialize the scanner.""" + tracked = set() - self.last_results = [] + @callback + def update_from_router(): + """Update the status of devices.""" + update_items(router, async_add_entities, tracked) - self._interface = config[CONF_INTERFACE] + update_from_router() - self._client = Client( - TelnetConnection( - config.get(CONF_HOST), - config.get(CONF_PORT), - config.get(CONF_USERNAME), - config.get(CONF_PASSWORD), + registry = await entity_registry.async_get_registry(hass) + # Restore devices that are not a part of active clients list. + restored = [] + for entity_entry in registry.entities.values(): + if ( + entity_entry.config_entry_id == config_entry.entry_id + and entity_entry.domain == DEVICE_TRACKER_DOMAIN + ): + mac = entity_entry.unique_id.partition("_")[0] + if mac not in tracked: + tracked.add(mac) + restored.append( + KeeneticTracker( + Device( + mac=mac, + # restore the original name as set by the router before + name=entity_entry.original_name, + ip=None, + interface=None, + ), + router, + ) + ) + + if restored: + async_add_entities(restored) + + async_dispatcher_connect(hass, router.signal_update, update_from_router) + + +@callback +def update_items(router: KeeneticRouter, async_add_entities, tracked: Set[str]): + """Update tracked device state from the hub.""" + new_tracked: List[KeeneticTracker] = [] + for mac, device in router.last_devices.items(): + if mac not in tracked: + tracked.add(mac) + new_tracked.append(KeeneticTracker(device, router)) + + if new_tracked: + async_add_entities(new_tracked) + + +class KeeneticTracker(ScannerEntity): + """Representation of network device.""" + + def __init__(self, device: Device, router: KeeneticRouter): + """Initialize the tracked device.""" + self._device = device + self._router = router + self._last_seen = ( + dt_util.utcnow() if device.mac in router.last_devices else None + ) + + @property + def should_poll(self) -> bool: + """Return False since entity pushes its state to HA.""" + return False + + @property + def is_connected(self): + """Return true if the device is connected to the network.""" + return ( + self._last_seen + and (dt_util.utcnow() - self._last_seen) + < self._router.consider_home_interval + ) + + @property + def source_type(self): + """Return the source type of the client.""" + return SOURCE_TYPE_ROUTER + + @property + def name(self) -> str: + """Return the name of the device.""" + return self._device.name or self._device.mac + + @property + def unique_id(self) -> str: + """Return a unique identifier for this device.""" + return f"{self._device.mac}_{self._router.config_entry.entry_id}" + + @property + def ip_address(self) -> str: + """Return the primary ip address of the device.""" + return self._device.ip if self.is_connected else None + + @property + def mac_address(self) -> str: + """Return the mac address of the device.""" + return self._device.mac + + @property + def available(self) -> bool: + """Return if controller is available.""" + return self._router.available + + @property + def device_state_attributes(self): + """Return the device state attributes.""" + if self.is_connected: + return { + "interface": self._device.interface, + } + return None + + @property + def device_info(self): + """Return a client description for device registry.""" + info = { + "connections": {(CONNECTION_NETWORK_MAC, self._device.mac)}, + "identifiers": {(DOMAIN, self._device.mac)}, + } + + if self._device.name: + info["name"] = self._device.name + + return info + + async def async_added_to_hass(self): + """Client entity created.""" + _LOGGER.debug("New network device tracker %s (%s)", self.name, self.unique_id) + + @callback + def update_device(): + _LOGGER.debug( + "Updating Keenetic tracked device %s (%s)", + self.entity_id, + self.unique_id, + ) + new_device = self._router.last_devices.get(self._device.mac) + if new_device: + self._device = new_device + self._last_seen = dt_util.utcnow() + + self.async_write_ha_state() + + self.async_on_remove( + async_dispatcher_connect( + self.hass, self._router.signal_update, update_device ) ) - - self.success_init = self._update_info() - _LOGGER.info("Scanner initialized") - - def scan_devices(self): - """Scan for new devices and return a list with found device IDs.""" - self._update_info() - - return [device.mac for device in self.last_results] - - def get_device_name(self, device): - """Return the name of the given device or None if we don't know.""" - name = next( - (result.name for result in self.last_results if result.mac == device), None - ) - return name - - def get_extra_attributes(self, device): - """Return the IP of the given device.""" - attributes = next( - ({"ip": result.ip} for result in self.last_results if result.mac == device), - {}, - ) - return attributes - - def _update_info(self): - """Get ARP from keenetic router.""" - _LOGGER.debug("Fetching devices from router...") - - try: - self.last_results = [ - dev - for dev in self._client.get_devices() - if dev.interface == self._interface - ] - _LOGGER.debug("Successfully fetched data from router") - return True - - except ConnectionException: - _LOGGER.error("Error fetching data from router") - return False diff --git a/homeassistant/components/keenetic_ndms2/manifest.json b/homeassistant/components/keenetic_ndms2/manifest.json index 9d4c9f35716..da8321a8bdc 100644 --- a/homeassistant/components/keenetic_ndms2/manifest.json +++ b/homeassistant/components/keenetic_ndms2/manifest.json @@ -1,7 +1,8 @@ { "domain": "keenetic_ndms2", - "name": "Keenetic NDMS2 Routers", + "name": "Keenetic NDMS2 Router", + "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/keenetic_ndms2", - "requirements": ["ndms2_client==0.0.11"], + "requirements": ["ndms2_client==0.1.1"], "codeowners": ["@foxel"] } diff --git a/homeassistant/components/keenetic_ndms2/router.py b/homeassistant/components/keenetic_ndms2/router.py new file mode 100644 index 00000000000..340b25ff725 --- /dev/null +++ b/homeassistant/components/keenetic_ndms2/router.py @@ -0,0 +1,187 @@ +"""The Keenetic Client class.""" +from datetime import timedelta +import logging +from typing import Callable, Dict, Optional + +from ndms2_client import Client, ConnectionException, Device, TelnetConnection +from ndms2_client.client import RouterInfo + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + CONF_HOST, + CONF_PASSWORD, + CONF_PORT, + CONF_SCAN_INTERVAL, + CONF_USERNAME, +) +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.helpers.event import async_call_later +import homeassistant.util.dt as dt_util + +from .const import ( + CONF_CONSIDER_HOME, + CONF_INCLUDE_ARP, + CONF_INCLUDE_ASSOCIATED, + CONF_INTERFACES, + CONF_TRY_HOTSPOT, + DOMAIN, +) + +_LOGGER = logging.getLogger(__name__) + + +class KeeneticRouter: + """Keenetic client Object.""" + + def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry): + """Initialize the Client.""" + self.hass = hass + self.config_entry = config_entry + self._last_devices: Dict[str, Device] = {} + self._router_info: Optional[RouterInfo] = None + self._connection: Optional[TelnetConnection] = None + self._client: Optional[Client] = None + self._cancel_periodic_update: Optional[Callable] = None + self._available = False + self._progress = None + + @property + def client(self): + """Read-only accessor for the client connection.""" + return self._client + + @property + def last_devices(self): + """Read-only accessor for last_devices.""" + return self._last_devices + + @property + def host(self): + """Return the host of this hub.""" + return self.config_entry.data[CONF_HOST] + + @property + def device_info(self): + """Return the host of this hub.""" + return { + "identifiers": {(DOMAIN, f"router-{self.config_entry.entry_id}")}, + "manufacturer": self.manufacturer, + "model": self.model, + "name": self.name, + "sw_version": self.firmware, + } + + @property + def name(self): + """Return the name of the hub.""" + return self._router_info.name if self._router_info else self.host + + @property + def model(self): + """Return the model of the hub.""" + return self._router_info.model if self._router_info else None + + @property + def firmware(self): + """Return the firmware of the hub.""" + return self._router_info.fw_version if self._router_info else None + + @property + def manufacturer(self): + """Return the firmware of the hub.""" + return self._router_info.manufacturer if self._router_info else None + + @property + def available(self): + """Return if the hub is connected.""" + return self._available + + @property + def consider_home_interval(self): + """Config entry option defining number of seconds from last seen to away.""" + return timedelta(seconds=self.config_entry.options[CONF_CONSIDER_HOME]) + + @property + def signal_update(self): + """Event specific per router entry to signal updates.""" + return f"keenetic-update-{self.config_entry.entry_id}" + + async def request_update(self): + """Request an update.""" + if self._progress is not None: + await self._progress + return + + self._progress = self.hass.async_create_task(self.async_update()) + await self._progress + + self._progress = None + + async def async_update(self): + """Update devices information.""" + await self.hass.async_add_executor_job(self._update_devices) + async_dispatcher_send(self.hass, self.signal_update) + + async def async_setup(self): + """Set up the connection.""" + self._connection = TelnetConnection( + self.config_entry.data[CONF_HOST], + self.config_entry.data[CONF_PORT], + self.config_entry.data[CONF_USERNAME], + self.config_entry.data[CONF_PASSWORD], + ) + self._client = Client(self._connection) + + try: + await self.hass.async_add_executor_job(self._update_router_info) + except ConnectionException as error: + raise ConfigEntryNotReady from error + + async def async_update_data(_now): + await self.request_update() + self._cancel_periodic_update = async_call_later( + self.hass, + self.config_entry.options[CONF_SCAN_INTERVAL], + async_update_data, + ) + + await async_update_data(dt_util.utcnow()) + + async def async_teardown(self): + """Teardown up the connection.""" + if self._cancel_periodic_update: + self._cancel_periodic_update() + self._connection.disconnect() + + def _update_router_info(self): + try: + self._router_info = self._client.get_router_info() + self._available = True + except Exception: + self._available = False + raise + + def _update_devices(self): + """Get ARP from keenetic router.""" + _LOGGER.debug("Fetching devices from router...") + + try: + _response = self._client.get_devices( + try_hotspot=self.config_entry.options[CONF_TRY_HOTSPOT], + include_arp=self.config_entry.options[CONF_INCLUDE_ARP], + include_associated=self.config_entry.options[CONF_INCLUDE_ASSOCIATED], + ) + self._last_devices = { + dev.mac: dev + for dev in _response + if dev.interface in self.config_entry.options[CONF_INTERFACES] + } + _LOGGER.debug("Successfully fetched data from router: %s", str(_response)) + self._router_info = self._client.get_router_info() + self._available = True + + except ConnectionException: + _LOGGER.error("Error fetching data from router") + self._available = False diff --git a/homeassistant/components/keenetic_ndms2/strings.json b/homeassistant/components/keenetic_ndms2/strings.json new file mode 100644 index 00000000000..15629ba0f2f --- /dev/null +++ b/homeassistant/components/keenetic_ndms2/strings.json @@ -0,0 +1,36 @@ +{ + "config": { + "step": { + "user": { + "title": "Set up Keenetic NDMS2 Router", + "data": { + "name": "Name", + "host": "[%key:common::config_flow::data::host%]", + "username": "[%key:common::config_flow::data::username%]", + "password": "[%key:common::config_flow::data::password%]", + "port": "[%key:common::config_flow::data::port%]" + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]" + } + }, + "options": { + "step": { + "user": { + "data": { + "scan_interval": "Scan interval", + "consider_home": "Consider home interval", + "interfaces": "Choose interfaces to scan", + "try_hotspot": "Use 'ip hotspot' data (most accurate)", + "include_arp": "Use ARP data (ignored if hotspot data used)", + "include_associated": "Use WiFi AP associations data (ignored if hotspot data used)" + } + } + } + } +} diff --git a/homeassistant/components/keenetic_ndms2/translations/en.json b/homeassistant/components/keenetic_ndms2/translations/en.json new file mode 100644 index 00000000000..1849d68c651 --- /dev/null +++ b/homeassistant/components/keenetic_ndms2/translations/en.json @@ -0,0 +1,36 @@ +{ + "config": { + "step": { + "user": { + "title": "Set up Keenetic NDMS2 Router", + "data": { + "name": "Name", + "host": "Host", + "username": "Username", + "password": "Password", + "port": "Port" + } + } + }, + "error": { + "cannot_connect": "Connection Unsuccessful" + }, + "abort": { + "already_configured": "This router is already configured" + } + }, + "options": { + "step": { + "user": { + "data": { + "scan_interval": "Scan interval", + "consider_home": "Consider home interval", + "interfaces": "Choose interfaces to scan", + "try_hotspot": "Use 'ip hotspot' data (most accurate)", + "include_arp": "Use ARP data (if hotspot disabled/unavailable)", + "include_associated": "Use WiFi AP associations data (if hotspot disabled/unavailable)" + } + } + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 41d85c4b20b..6ff72cf5572 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -113,6 +113,7 @@ FLOWS = [ "isy994", "izone", "juicenet", + "keenetic_ndms2", "kodi", "konnected", "kulersky", diff --git a/requirements_all.txt b/requirements_all.txt index d8ec67e1d30..de1a0b221c2 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -973,7 +973,7 @@ n26==0.2.7 nad_receiver==0.0.12 # homeassistant.components.keenetic_ndms2 -ndms2_client==0.0.11 +ndms2_client==0.1.1 # homeassistant.components.ness_alarm nessclient==0.9.15 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 3a0ce6dd428..93c66806ade 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -502,6 +502,9 @@ motionblinds==0.4.8 # homeassistant.components.tts mutagen==1.45.1 +# homeassistant.components.keenetic_ndms2 +ndms2_client==0.1.1 + # homeassistant.components.ness_alarm nessclient==0.9.15 diff --git a/tests/components/keenetic_ndms2/__init__.py b/tests/components/keenetic_ndms2/__init__.py new file mode 100644 index 00000000000..1fce0dbe2a6 --- /dev/null +++ b/tests/components/keenetic_ndms2/__init__.py @@ -0,0 +1,27 @@ +"""Tests for the Keenetic NDMS2 component.""" +from homeassistant.components.keenetic_ndms2 import const +from homeassistant.const import ( + CONF_HOST, + CONF_PASSWORD, + CONF_PORT, + CONF_SCAN_INTERVAL, + CONF_USERNAME, +) + +MOCK_NAME = "Keenetic Ultra 2030" + +MOCK_DATA = { + CONF_HOST: "0.0.0.0", + CONF_USERNAME: "user", + CONF_PASSWORD: "pass", + CONF_PORT: 23, +} + +MOCK_OPTIONS = { + CONF_SCAN_INTERVAL: 15, + const.CONF_CONSIDER_HOME: 150, + const.CONF_TRY_HOTSPOT: False, + const.CONF_INCLUDE_ARP: True, + const.CONF_INCLUDE_ASSOCIATED: True, + const.CONF_INTERFACES: ["Home", "VPS0"], +} diff --git a/tests/components/keenetic_ndms2/test_config_flow.py b/tests/components/keenetic_ndms2/test_config_flow.py new file mode 100644 index 00000000000..aa5369fdc0a --- /dev/null +++ b/tests/components/keenetic_ndms2/test_config_flow.py @@ -0,0 +1,169 @@ +"""Test Keenetic NDMS2 setup process.""" + +from unittest.mock import Mock, patch + +from ndms2_client import ConnectionException +from ndms2_client.client import InterfaceInfo, RouterInfo +import pytest + +from homeassistant import config_entries, data_entry_flow +from homeassistant.components import keenetic_ndms2 as keenetic +from homeassistant.components.keenetic_ndms2 import const +from homeassistant.helpers.typing import HomeAssistantType + +from . import MOCK_DATA, MOCK_NAME, MOCK_OPTIONS + +from tests.common import MockConfigEntry + + +@pytest.fixture(name="connect") +def mock_keenetic_connect(): + """Mock connection routine.""" + with patch("ndms2_client.client.Client.get_router_info") as mock_get_router_info: + mock_get_router_info.return_value = RouterInfo( + name=MOCK_NAME, + fw_version="3.0.4", + fw_channel="stable", + model="mock", + hw_version="0000", + manufacturer="pytest", + vendor="foxel", + region="RU", + ) + yield + + +@pytest.fixture(name="connect_error") +def mock_keenetic_connect_failed(): + """Mock connection routine.""" + with patch( + "ndms2_client.client.Client.get_router_info", + side_effect=ConnectionException("Mocked failure"), + ): + yield + + +async def test_flow_works(hass: HomeAssistantType, connect): + """Test config flow.""" + + result = await hass.config_entries.flow.async_init( + keenetic.DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "user" + + with patch( + "homeassistant.components.keenetic_ndms2.async_setup", return_value=True + ) as mock_setup, patch( + "homeassistant.components.keenetic_ndms2.async_setup_entry", return_value=True + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=MOCK_DATA, + ) + await hass.async_block_till_done() + + assert result2["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result2["title"] == MOCK_NAME + assert result2["data"] == MOCK_DATA + assert len(mock_setup.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_import_works(hass: HomeAssistantType, connect): + """Test config flow.""" + + with patch( + "homeassistant.components.keenetic_ndms2.async_setup", return_value=True + ) as mock_setup, patch( + "homeassistant.components.keenetic_ndms2.async_setup_entry", return_value=True + ) as mock_setup_entry: + result = await hass.config_entries.flow.async_init( + keenetic.DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data=MOCK_DATA, + ) + await hass.async_block_till_done() + + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["title"] == MOCK_NAME + assert result["data"] == MOCK_DATA + assert len(mock_setup.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_options(hass): + """Test updating options.""" + entry = MockConfigEntry(domain=keenetic.DOMAIN, data=MOCK_DATA) + entry.add_to_hass(hass) + with patch( + "homeassistant.components.keenetic_ndms2.async_setup", return_value=True + ) as mock_setup, patch( + "homeassistant.components.keenetic_ndms2.async_setup_entry", return_value=True + ) as mock_setup_entry: + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert len(mock_setup.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 1 + + # fake router + hass.data.setdefault(keenetic.DOMAIN, {}) + hass.data[keenetic.DOMAIN][entry.entry_id] = { + keenetic.ROUTER: Mock( + client=Mock( + get_interfaces=Mock( + return_value=[ + InterfaceInfo.from_dict({"id": name, "type": "bridge"}) + for name in MOCK_OPTIONS[const.CONF_INTERFACES] + ] + ) + ) + ) + } + + result = await hass.config_entries.options.async_init(entry.entry_id) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "user" + + result2 = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input=MOCK_OPTIONS, + ) + + assert result2["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result2["data"] == MOCK_OPTIONS + + +async def test_host_already_configured(hass, connect): + """Test host already configured.""" + + entry = MockConfigEntry( + domain=keenetic.DOMAIN, data=MOCK_DATA, options=MOCK_OPTIONS + ) + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + keenetic.DOMAIN, context={"source": "user"} + ) + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=MOCK_DATA + ) + + assert result2["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result2["reason"] == "already_configured" + + +async def test_connection_error(hass, connect_error): + """Test error when connection is unsuccessful.""" + + result = await hass.config_entries.flow.async_init( + keenetic.DOMAIN, context={"source": "user"} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=MOCK_DATA + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["errors"] == {"base": "cannot_connect"}