Add keenetic_ndms2 config flow (#38353)
parent
2c3a2bd35e
commit
accba85e35
homeassistant
components/keenetic_ndms2
generated
tests/components/keenetic_ndms2
|
@ -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/*
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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,
|
||||
)
|
||||
)
|
|
@ -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)
|
|
@ -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"
|
|
@ -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
|
||||
|
|
|
@ -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"]
|
||||
}
|
||||
|
|
|
@ -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
|
|
@ -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)"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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)"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -113,6 +113,7 @@ FLOWS = [
|
|||
"isy994",
|
||||
"izone",
|
||||
"juicenet",
|
||||
"keenetic_ndms2",
|
||||
"kodi",
|
||||
"konnected",
|
||||
"kulersky",
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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"],
|
||||
}
|
|
@ -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"}
|
Loading…
Reference in New Issue