Add keenetic_ndms2 config flow ()

pull/46530/head
Andrey Kupreychik 2021-02-14 19:09:19 +07:00 committed by GitHub
parent 2c3a2bd35e
commit accba85e35
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 1036 additions and 78 deletions

View File

@ -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/*

View File

@ -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)

View File

@ -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,
)
)

View File

@ -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)

View File

@ -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"

View File

@ -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

View File

@ -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"]
}

View File

@ -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

View File

@ -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)"
}
}
}
}
}

View File

@ -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)"
}
}
}
}
}

View File

@ -113,6 +113,7 @@ FLOWS = [
"isy994",
"izone",
"juicenet",
"keenetic_ndms2",
"kodi",
"konnected",
"kulersky",

View File

@ -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

View File

@ -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

View File

@ -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"],
}

View File

@ -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"}