From a1c1f13022e4e77be6109fab20a9da09977140ea Mon Sep 17 00:00:00 2001 From: Rami Mosleh Date: Fri, 11 Oct 2019 19:54:58 +0300 Subject: [PATCH 01/21] rework device scanning, add tests --- .../components/mikrotik/.translations/en.json | 37 ++ homeassistant/components/mikrotik/__init__.py | 223 +++------- .../components/mikrotik/config_flow.py | 144 +++++++ homeassistant/components/mikrotik/const.py | 39 +- .../components/mikrotik/device_tracker.py | 306 ++++++------- homeassistant/components/mikrotik/errors.py | 10 + homeassistant/components/mikrotik/hub.py | 403 ++++++++++++++++++ .../components/mikrotik/manifest.json | 9 +- .../components/mikrotik/strings.json | 37 ++ tests/components/mikrotik/__init__.py | 102 +++++ tests/components/mikrotik/test_config_flow.py | 177 ++++++++ .../mikrotik/test_device_tracker.py | 125 ++++++ tests/components/mikrotik/test_hub.py | 153 +++++++ tests/components/mikrotik/test_init.py | 109 +++++ 14 files changed, 1506 insertions(+), 368 deletions(-) create mode 100644 homeassistant/components/mikrotik/.translations/en.json create mode 100644 homeassistant/components/mikrotik/config_flow.py create mode 100644 homeassistant/components/mikrotik/errors.py create mode 100644 homeassistant/components/mikrotik/hub.py create mode 100644 homeassistant/components/mikrotik/strings.json create mode 100644 tests/components/mikrotik/__init__.py create mode 100644 tests/components/mikrotik/test_config_flow.py create mode 100644 tests/components/mikrotik/test_device_tracker.py create mode 100644 tests/components/mikrotik/test_hub.py create mode 100644 tests/components/mikrotik/test_init.py diff --git a/homeassistant/components/mikrotik/.translations/en.json b/homeassistant/components/mikrotik/.translations/en.json new file mode 100644 index 00000000000000..38352ef4f90121 --- /dev/null +++ b/homeassistant/components/mikrotik/.translations/en.json @@ -0,0 +1,37 @@ +{ + "config": { + "title": "Mikrotik", + "step": { + "user": { + "title": "Set up Mikrotik Router", + "data": { + "name": "Name", + "host": "Host", + "username": "Username", + "password": "Password", + "port": "Port", + "verify_ssl": "Use ssl", + "track_devices": "Enable newly added entities" + } + } + }, + "error": { + "cannot_connect": "Connection Unsuccessful", + "wrong_credentials": "Wrong Credentials" + }, + "abort": { + "already_configured": "Mikrotik is already configured" + } + }, + "options": { + "step": { + "device_tracker": { + "data": { + "arp_ping": "Enable ARP ping", + "force_dhcp": "Force scanning using DHCP", + "detection_time": "Consider home interval" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/mikrotik/__init__.py b/homeassistant/components/mikrotik/__init__.py index aacd3c65b3eb64..f1d82f3c43825d 100644 --- a/homeassistant/components/mikrotik/__init__.py +++ b/homeassistant/components/mikrotik/__init__.py @@ -1,41 +1,28 @@ -"""The mikrotik component.""" -import logging -import ssl - +"""The Mikrotik component.""" import voluptuous as vol -import librouteros -from librouteros.login import login_plain, login_token +from homeassistant.config_entries import SOURCE_IMPORT from homeassistant.const import ( CONF_HOST, + CONF_NAME, CONF_PASSWORD, - CONF_USERNAME, CONF_PORT, - CONF_SSL, - CONF_METHOD, + CONF_USERNAME, + CONF_VERIFY_SSL, ) from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.discovery import load_platform -from homeassistant.components.device_tracker import DOMAIN as DEVICE_TRACKER + from .const import ( - NAME, - DOMAIN, - HOSTS, - MTK_LOGIN_PLAIN, - MTK_LOGIN_TOKEN, - DEFAULT_ENCODING, - IDENTITY, - CONF_TRACK_DEVICES, - CONF_ENCODING, + ATTR_MANUFACTURER, CONF_ARP_PING, - CONF_LOGIN_METHOD, - MIKROTIK_SERVICES, + CONF_DETECTION_TIME, + CONF_TRACK_DEVICES, + DEFAULT_API_PORT, + DEFAULT_DETECTION_TIME, + DEFAULT_NAME, + DOMAIN, ) - -_LOGGER = logging.getLogger(__name__) - -MTK_DEFAULT_API_PORT = "8728" -MTK_DEFAULT_API_SSL_PORT = "8729" +from .hub import MikrotikHub MIKROTIK_SCHEMA = vol.All( vol.Schema( @@ -43,13 +30,14 @@ vol.Required(CONF_HOST): cv.string, vol.Required(CONF_USERNAME): cv.string, vol.Required(CONF_PASSWORD): cv.string, - vol.Optional(CONF_METHOD): cv.string, - vol.Optional(CONF_LOGIN_METHOD): vol.Any(MTK_LOGIN_PLAIN, MTK_LOGIN_TOKEN), - vol.Optional(CONF_PORT): cv.port, - vol.Optional(CONF_SSL, default=False): cv.boolean, - vol.Optional(CONF_ENCODING, default=DEFAULT_ENCODING): cv.string, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_PORT, default=DEFAULT_API_PORT): cv.port, + vol.Optional(CONF_VERIFY_SSL, default=False): cv.boolean, vol.Optional(CONF_TRACK_DEVICES, default=True): cv.boolean, vol.Optional(CONF_ARP_PING, default=False): cv.boolean, + vol.Optional( + CONF_DETECTION_TIME, default=DEFAULT_DETECTION_TIME + ): cv.time_period, } ) ) @@ -59,143 +47,46 @@ ) -def setup(hass, config): - """Set up the Mikrotik component.""" - hass.data[DOMAIN] = {HOSTS: {}} - - for device in config[DOMAIN]: - host = device[CONF_HOST] - use_ssl = device.get(CONF_SSL) - user = device.get(CONF_USERNAME) - password = device.get(CONF_PASSWORD, "") - login = device.get(CONF_LOGIN_METHOD) - encoding = device.get(CONF_ENCODING) - track_devices = device.get(CONF_TRACK_DEVICES) - - if CONF_PORT in device: - port = device.get(CONF_PORT) - else: - if use_ssl: - port = MTK_DEFAULT_API_SSL_PORT - else: - port = MTK_DEFAULT_API_PORT - - if login == MTK_LOGIN_PLAIN: - login_method = (login_plain,) - elif login == MTK_LOGIN_TOKEN: - login_method = (login_token,) - else: - login_method = (login_plain, login_token) - - try: - api = MikrotikClient( - host, use_ssl, port, user, password, login_method, encoding +async def async_setup(hass, config): + """Import the Transmission Component from config.""" + + if DOMAIN in config: + for entry in config[DOMAIN]: + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data=entry + ) ) - api.connect_to_device() - hass.data[DOMAIN][HOSTS][host] = {"config": device, "api": api} - except ( - librouteros.exceptions.TrapError, - librouteros.exceptions.MultiTrapError, - librouteros.exceptions.ConnectionError, - ) as api_error: - _LOGGER.error("Mikrotik %s error %s", host, api_error) - continue - - if track_devices: - hass.data[DOMAIN][HOSTS][host][DEVICE_TRACKER] = True - load_platform(hass, DEVICE_TRACKER, DOMAIN, None, config) - - if not hass.data[DOMAIN][HOSTS]: + + return True + + +async def async_setup_entry(hass, config_entry): + """Set up the Mikrotik component.""" + + hub = MikrotikHub(hass, config_entry) + hass.data.setdefault(DOMAIN, {})[config_entry.data[CONF_HOST]] = hub + + if not await hub.async_setup(): return False + + device_registry = await hass.helpers.device_registry.async_get_registry() + device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(DOMAIN, hub.serial_num)}, + manufacturer=ATTR_MANUFACTURER, + model=hub.model, + name=hub.hostname, + sw_version=hub.firmware, + ) + return True -class MikrotikClient: - """Handle all communication with the Mikrotik API.""" - - def __init__(self, host, use_ssl, port, user, password, login_method, encoding): - """Initialize the Mikrotik Client.""" - self._host = host - self._use_ssl = use_ssl - self._port = port - self._user = user - self._password = password - self._login_method = login_method - self._encoding = encoding - self._ssl_wrapper = None - self.hostname = None - self._client = None - self._connected = False - - def connect_to_device(self): - """Connect to Mikrotik device.""" - self._connected = False - _LOGGER.debug("[%s] Connecting to Mikrotik device", self._host) - - kwargs = { - "encoding": self._encoding, - "login_methods": self._login_method, - "port": self._port, - } +async def async_unload_entry(hass, config_entry): + """Unload a config entry.""" + await hass.config_entries.async_forward_entry_unload(config_entry, "device_tracker") - if self._use_ssl: - if self._ssl_wrapper is None: - ssl_context = ssl.create_default_context() - ssl_context.check_hostname = False - ssl_context.verify_mode = ssl.CERT_NONE - self._ssl_wrapper = ssl_context.wrap_socket - kwargs["ssl_wrapper"] = self._ssl_wrapper - - try: - self._client = librouteros.connect( - self._host, self._user, self._password, **kwargs - ) - self._connected = True - except ( - librouteros.exceptions.TrapError, - librouteros.exceptions.MultiTrapError, - librouteros.exceptions.ConnectionError, - ) as api_error: - _LOGGER.error("Mikrotik %s: %s", self._host, api_error) - self._client = None - return False - - self.hostname = self.get_hostname() - _LOGGER.info("Mikrotik Connected to %s (%s)", self.hostname, self._host) - return self._connected - - def get_hostname(self): - """Return device host name.""" - data = self.command(MIKROTIK_SERVICES[IDENTITY]) - return data[0][NAME] if data else None - - def connected(self): - """Return connected boolean.""" - return self._connected - - def command(self, cmd, params=None): - """Retrieve data from Mikrotik API.""" - if not self._connected or not self._client: - if not self.connect_to_device(): - return None - try: - if params: - response = self._client(cmd=cmd, **params) - else: - response = self._client(cmd=cmd) - except (librouteros.exceptions.ConnectionError,) as api_error: - _LOGGER.error("Mikrotik %s connection error %s", self._host, api_error) - self.connect_to_device() - return None - except ( - librouteros.exceptions.TrapError, - librouteros.exceptions.MultiTrapError, - ) as api_error: - _LOGGER.error( - "Mikrotik %s failed to retrieve data. cmd=[%s] Error: %s", - self._host, - cmd, - api_error, - ) - return None - return response if response else None + hass.data[DOMAIN].pop(config_entry.data[CONF_HOST]) + + return True diff --git a/homeassistant/components/mikrotik/config_flow.py b/homeassistant/components/mikrotik/config_flow.py new file mode 100644 index 00000000000000..680640b9d6ed81 --- /dev/null +++ b/homeassistant/components/mikrotik/config_flow.py @@ -0,0 +1,144 @@ +"""Config flow for Mikrotik.""" +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import ( + CONF_HOST, + CONF_NAME, + CONF_PASSWORD, + CONF_PORT, + CONF_USERNAME, + CONF_VERIFY_SSL, +) +from homeassistant.core import callback + +from .const import ( + CONF_ARP_PING, + CONF_FORCE_DHCP, + CONF_DETECTION_TIME, + CONF_TRACK_DEVICES, + DEFAULT_API_PORT, + DEFAULT_NAME, + DEFAULT_DETECTION_TIME, + DOMAIN, +) +from .errors import CannotConnect, LoginError +from .hub import get_hub + + +class MikrotikFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a Mikrotik 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 MikrotikOptionsFlowHandler(config_entry) + + def __init__(self): + """Initialize the UniFi flow.""" + self.config = {} + + 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") + + errors = self.validate_user_input(user_input) + if not errors: + return self.async_create_entry( + title=self.config[CONF_NAME], data=self.config + ) + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Required(CONF_NAME, default=DEFAULT_NAME): str, + vol.Required(CONF_HOST): str, + vol.Required(CONF_USERNAME): str, + vol.Required(CONF_PASSWORD): str, + vol.Optional(CONF_PORT, default=DEFAULT_API_PORT): int, + vol.Optional(CONF_VERIFY_SSL, default=False): bool, + vol.Optional(CONF_TRACK_DEVICES, default=False): bool, + } + ), + errors=errors, + ) + + def validate_user_input(self, user_input): + """Validate user input.""" + errors = {} + if CONF_TRACK_DEVICES in user_input: + self.config["options"] = {} + self.config["options"][CONF_TRACK_DEVICES] = user_input.pop( + CONF_TRACK_DEVICES + ) + try: + get_hub(self.hass, user_input) + self.config.update(user_input) + except CannotConnect: + errors["base"] = "cannot_connect" + except LoginError: + errors[CONF_USERNAME] = "wrong_credentials" + errors[CONF_PASSWORD] = "wrong_credentials" + + return errors + + async def async_step_import(self, import_config): + """Import Miktortik from config.""" + + self.config["options"] = {} + self.config["options"][CONF_ARP_PING] = import_config.pop(CONF_ARP_PING) + self.config["options"][CONF_FORCE_DHCP] = import_config.pop(CONF_FORCE_DHCP) + self.config["options"][CONF_DETECTION_TIME] = import_config.pop( + CONF_DETECTION_TIME + ) + self.config["options"][CONF_TRACK_DEVICES] = import_config.pop( + CONF_TRACK_DEVICES + ) + + return await self.async_step_user(user_input=import_config) + + +class MikrotikOptionsFlowHandler(config_entries.OptionsFlow): + """Handle Mikrotik options.""" + + def __init__(self, config_entry): + """Initialize UniFi options flow.""" + self.config_entry = config_entry + + async def async_step_init(self, user_input=None): + """Manage the Mikrotik options.""" + return await self.async_step_device_tracker() + + async def async_step_device_tracker(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.Optional( + CONF_FORCE_DHCP, + default=self.config_entry.options.get(CONF_FORCE_DHCP, False), + ): bool, + vol.Optional( + CONF_ARP_PING, + default=self.config_entry.options.get(CONF_ARP_PING, False), + ): bool, + vol.Optional( + CONF_DETECTION_TIME, + default=self.config_entry.options.get( + CONF_DETECTION_TIME, DEFAULT_DETECTION_TIME + ), + ): int, + } + + return self.async_show_form( + step_id="device_tracker", data_schema=vol.Schema(options) + ) diff --git a/homeassistant/components/mikrotik/const.py b/homeassistant/components/mikrotik/const.py index bd26b02fe1b924..2c2df7740104ce 100644 --- a/homeassistant/components/mikrotik/const.py +++ b/homeassistant/components/mikrotik/const.py @@ -1,32 +1,41 @@ """Constants used in the Mikrotik components.""" DOMAIN = "mikrotik" -MIKROTIK = DOMAIN -HOSTS = "hosts" -MTK_LOGIN_PLAIN = "plain" -MTK_LOGIN_TOKEN = "token" +# MK_CONFIG = "mikrotik_config" +DEFAULT_NAME = "Mikrotik" +DEFAULT_API_PORT = 8728 +# DEFAULT_API_SSL_PORT = 8729 +DEFAULT_DETECTION_TIME = 300 + +ATTR_MANUFACTURER = "Mikrotik" +ATTR_SERIAL_NUMBER = "serial-number" +ATTR_FIRMWARE = "current-firmware" +ATTR_MODEL = "model" CONF_ARP_PING = "arp_ping" +CONF_FORCE_DHCP = "force_dhcp" CONF_TRACK_DEVICES = "track_devices" -CONF_LOGIN_METHOD = "login_method" -CONF_ENCODING = "encoding" -DEFAULT_ENCODING = "utf-8" +CONF_DETECTION_TIME = "detection_time" + NAME = "name" INFO = "info" IDENTITY = "identity" ARP = "arp" + +CAPSMAN = "capsman" DHCP = "dhcp" WIRELESS = "wireless" -CAPSMAN = "capsman" +IS_WIRELESS = "is_wireless" MIKROTIK_SERVICES = { - INFO: "/system/routerboard/getall", - IDENTITY: "/system/identity/getall", ARP: "/ip/arp/getall", + CAPSMAN: "/caps-man/registration-table/getall", DHCP: "/ip/dhcp-server/lease/getall", + IDENTITY: "/system/identity/getall", + INFO: "/system/routerboard/getall", WIRELESS: "/interface/wireless/registration-table/getall", - CAPSMAN: "/caps-man/registration-table/getall", + IS_WIRELESS: "/interface/wireless/print", } ATTR_DEVICE_TRACKER = [ @@ -34,16 +43,8 @@ "mac-address", "ssid", "interface", - "host-name", - "last-seen", - "rx-signal", "signal-strength", - "tx-ccq", "signal-to-noise", - "wmm-enabled", - "authentication-type", - "encryption", - "tx-rate-set", "rx-rate", "tx-rate", "uptime", diff --git a/homeassistant/components/mikrotik/device_tracker.py b/homeassistant/components/mikrotik/device_tracker.py index 6c3fb559cba750..3ac105c32b1a42 100644 --- a/homeassistant/components/mikrotik/device_tracker.py +++ b/homeassistant/components/mikrotik/device_tracker.py @@ -1,190 +1,136 @@ """Support for Mikrotik routers as device tracker.""" import logging -from homeassistant.components.device_tracker import ( +from homeassistant.components.device_tracker.config_entry import ScannerEntity +from homeassistant.components.device_tracker.const import ( DOMAIN as DEVICE_TRACKER, - DeviceScanner, -) -from homeassistant.util import slugify -from homeassistant.const import CONF_METHOD -from .const import ( - HOSTS, - MIKROTIK, - CONF_ARP_PING, - MIKROTIK_SERVICES, - CAPSMAN, - WIRELESS, - DHCP, - ARP, - ATTR_DEVICE_TRACKER, + SOURCE_TYPE_ROUTER, ) +from homeassistant.const import CONF_HOST +from homeassistant.core import callback +from homeassistant.helpers import entity_registry +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 DOMAIN _LOGGER = logging.getLogger(__name__) -def get_scanner(hass, config): - """Validate the configuration and return MikrotikScanner.""" - for host in hass.data[MIKROTIK][HOSTS]: - if DEVICE_TRACKER not in hass.data[MIKROTIK][HOSTS][host]: - continue - hass.data[MIKROTIK][HOSTS][host].pop(DEVICE_TRACKER, None) - api = hass.data[MIKROTIK][HOSTS][host]["api"] - config = hass.data[MIKROTIK][HOSTS][host]["config"] - hostname = api.get_hostname() - scanner = MikrotikScanner(api, host, hostname, config) - return scanner if scanner.success_init else None - - -class MikrotikScanner(DeviceScanner): - """This class queries a Mikrotik device.""" - - def __init__(self, api, host, hostname, config): - """Initialize the scanner.""" - self.api = api - self.config = config - self.host = host - self.hostname = hostname - self.method = config.get(CONF_METHOD) - self.arp_ping = config.get(CONF_ARP_PING) - self.dhcp = None - self.devices_arp = {} - self.devices_dhcp = {} - self.device_tracker = None - self.success_init = self.api.connected() - - def get_extra_attributes(self, device): - """ - Get extra attributes of a device. - - Some known extra attributes that may be returned in the device tuple - include MAC address (mac), network device (dev), IP address - (ip), reachable status (reachable), associated router - (host), hostname if known (hostname) among others. - """ - return self.device_tracker.get(device) or {} - - def get_device_name(self, device): - """Get name for a device.""" - host = self.device_tracker.get(device, {}) - return host.get("host_name") - - def scan_devices(self): - """Scan for new devices and return a list with found device MACs.""" - self.update_device_tracker() - return list(self.device_tracker) - - def get_method(self): - """Determine the device tracker polling method.""" - if self.method: - _LOGGER.debug( - "Mikrotik %s: Manually selected polling method %s", - self.host, - self.method, - ) - return self.method - - capsman = self.api.command(MIKROTIK_SERVICES[CAPSMAN]) - if not capsman: - _LOGGER.debug( - "Mikrotik %s: Not a CAPsMAN controller. " - "Trying local wireless interfaces", - (self.host), - ) - else: - return CAPSMAN - - wireless = self.api.command(MIKROTIK_SERVICES[WIRELESS]) - if not wireless: - _LOGGER.info( - "Mikrotik %s: Wireless adapters not found. Try to " - "use DHCP lease table as presence tracker source. " - "Please decrease lease time as much as possible", - self.host, - ) - return DHCP - - return WIRELESS - - def update_device_tracker(self): - """Update device_tracker from Mikrotik API.""" - self.device_tracker = {} - if not self.method: - self.method = self.get_method() - - data = self.api.command(MIKROTIK_SERVICES[self.method]) - if data is None: - return - - if self.method != DHCP: - dhcp = self.api.command(MIKROTIK_SERVICES[DHCP]) - if dhcp is not None: - self.devices_dhcp = load_mac(dhcp) - - arp = self.api.command(MIKROTIK_SERVICES[ARP]) - self.devices_arp = load_mac(arp) - - for device in data: - mac = device.get("mac-address") - if self.method == DHCP: - if "active-address" not in device: - continue - - if self.arp_ping and self.devices_arp: - if mac not in self.devices_arp: - continue - ip_address = self.devices_arp[mac]["address"] - interface = self.devices_arp[mac]["interface"] - if not self.do_arp_ping(ip_address, interface): - continue - - attrs = {} - if mac in self.devices_dhcp and "host-name" in self.devices_dhcp[mac]: - hostname = self.devices_dhcp[mac].get("host-name") - if hostname: - attrs["host_name"] = hostname - - if self.devices_arp and mac in self.devices_arp: - attrs["ip_address"] = self.devices_arp[mac].get("address") - - for attr in ATTR_DEVICE_TRACKER: - if attr in device and device[attr] is not None: - attrs[slugify(attr)] = device[attr] - attrs["scanner_type"] = self.method - attrs["scanner_host"] = self.host - attrs["scanner_hostname"] = self.hostname - self.device_tracker[mac] = attrs - - def do_arp_ping(self, ip_address, interface): - """Attempt to arp ping MAC address via interface.""" - params = { - "arp-ping": "yes", - "interval": "100ms", - "count": 3, - "interface": interface, - "address": ip_address, - } - cmd = "/ping" - data = self.api.command(cmd, params) - if data is not None: - status = 0 - for result in data: - if "status" in result: - _LOGGER.debug( - "Mikrotik %s arp_ping error: %s", self.host, result["status"] - ) - status += 1 - if status == len(data): - return None - return data - - -def load_mac(devices=None): - """Load dictionary using MAC address as key.""" - if not devices: +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up device tracker for Mikrotik component.""" + hub = hass.data[DOMAIN][config_entry.data[CONF_HOST]] + + tracked = {} + + registry = await entity_registry.async_get_registry(hass) + + # Restore clients that is not a part of active clients list. + for entity in registry.entities.values(): + + if ( + entity.config_entry_id == config_entry.entry_id + and entity.domain == DEVICE_TRACKER + ): + + if ( + entity.unique_id in hub.api.devices + or entity.unique_id not in hub.api.all_devices + ): + continue + hub.api.restore_device(entity.unique_id) + + @callback + def update_hub(): + """Update the status of the device.""" + update_items(hub, async_add_entities, tracked) + + async_dispatcher_connect(hass, hub.signal_update, update_hub) + + update_hub() + + +@callback +def update_items(hub, async_add_entities, tracked): + """Update tracked device state from the controller.""" + new_tracked = [] + for device in hub.api.devices: + if device not in tracked: + tracked[device] = MikrotikHubTracker(hub.api.devices[device], hub) + new_tracked.append(tracked[device]) + + if new_tracked: + async_add_entities(new_tracked) + + +class MikrotikHubTracker(ScannerEntity): + """Representation of network device.""" + + def __init__(self, device, hub): + """Initialize the tracked device.""" + self.device = device + self.hub = hub + + @property + def is_connected(self): + """Return true if the client is connected to the network.""" + if ( + self.device.last_seen + and (dt_util.utcnow() - self.device.last_seen) + < self.hub.option_detection_time + ): + return True + return False + + @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 client.""" + return self.device.name + + @property + def unique_id(self) -> str: + """Return a unique identifier for this device.""" + return self.device.mac + + @property + def available(self) -> bool: + """Return if controller is available.""" + return self.hub.available + + @property + def device_state_attributes(self): + """Return the device state attributes.""" + if self.is_connected: + return self.device.attrs return None - mac_devices = {} - for device in devices: - if "mac-address" in device: - mac = device.pop("mac-address") - mac_devices[mac] = device - return mac_devices + + @property + def device_info(self): + """Return a client description for device registry.""" + info = { + "connections": {(CONNECTION_NETWORK_MAC, self.device.mac)}, + "identifiers": {(DOMAIN, self.unique_id)}, + "name": self.name, + "via_device": (DOMAIN, self.hub.serial_num), + } + 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) + async_dispatcher_connect( + self.hass, self.hub.signal_update, self.async_write_ha_state + ) + + async def async_update(self): + """Synchronize state with hub.""" + _LOGGER.debug( + "Updating Mikrotik tracked client %s (%s)", self.entity_id, self.unique_id + ) + await self.hub.request_update() diff --git a/homeassistant/components/mikrotik/errors.py b/homeassistant/components/mikrotik/errors.py new file mode 100644 index 00000000000000..22cd63d74689ae --- /dev/null +++ b/homeassistant/components/mikrotik/errors.py @@ -0,0 +1,10 @@ +"""Errors for the Mikrotik component.""" +from homeassistant.exceptions import HomeAssistantError + + +class CannotConnect(HomeAssistantError): + """Unable to connect to the hub.""" + + +class LoginError(HomeAssistantError): + """Component got logged out.""" diff --git a/homeassistant/components/mikrotik/hub.py b/homeassistant/components/mikrotik/hub.py new file mode 100644 index 00000000000000..32a886dd4cebb9 --- /dev/null +++ b/homeassistant/components/mikrotik/hub.py @@ -0,0 +1,403 @@ +"""The mikrotik router class.""" +from datetime import timedelta +import logging +import ssl + +import librouteros +from librouteros.login import login_plain, login_token + +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME, CONF_VERIFY_SSL +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.util import slugify +import homeassistant.util.dt as dt_util + +from .const import ( + ARP, + ATTR_DEVICE_TRACKER, + ATTR_FIRMWARE, + ATTR_MODEL, + ATTR_SERIAL_NUMBER, + CAPSMAN, + CONF_ARP_PING, + CONF_DETECTION_TIME, + CONF_FORCE_DHCP, + CONF_TRACK_DEVICES, + DEFAULT_DETECTION_TIME, + DHCP, + IDENTITY, + INFO, + IS_WIRELESS, + MIKROTIK_SERVICES, + NAME, + WIRELESS, +) +from .errors import CannotConnect, LoginError + +_LOGGER = logging.getLogger(__name__) + + +class Device: + """Represents a network device.""" + + def __init__(self, mac, params): + """Initialize the network device.""" + self._mac = mac + self._params = params + self._last_seen = None + self._attrs = {} + self._wireless_params = None + + @property + def name(self): + """Return device name.""" + return self._params.get("host-name", self.mac) + + @property + def mac(self): + """Return device mac.""" + return self._mac + + @property + def last_seen(self): + """Return device last seen.""" + return self._last_seen + + @property + def attrs(self): + """Return device attributes.""" + attr_data = self._wireless_params if self._wireless_params else self._params + for attr in ATTR_DEVICE_TRACKER: + if attr in attr_data: + self._attrs[slugify(attr)] = attr_data[attr] + self._attrs["ip_address"] = self._params.get("active-address") + return self._attrs + + def update(self, wireless_params=None, params=None, active=False): + """Update Device params.""" + if wireless_params: + self._wireless_params = wireless_params + if params: + self._params = params + if active: + self._last_seen = dt_util.utcnow() + + +class MikrotikData: + """Handle all communication with the Mikrotik API.""" + + def __init__(self, hass, config_entry, api): + """Initialize the Mikrotik Client.""" + self.hass = hass + self.config_entry = config_entry + self.api = api + self._host = self.config_entry.data[CONF_HOST] + self.all_devices = {} + self.devices = {} + self.available = True + self.support_wireless = bool(self.command(MIKROTIK_SERVICES[IS_WIRELESS])) + + @staticmethod + def load_mac(devices=None): + """Load dictionary using MAC address as key.""" + if not devices: + return None + mac_devices = {} + for device in devices: + if "mac-address" in device: + mac = device["mac-address"] + mac_devices[mac] = device + return mac_devices + + @property + def arp_enabled(self): + """Return arp_ping option setting.""" + return self.config_entry.options[CONF_ARP_PING] + + @property + def force_dhcp(self): + """Return force_dhcp option setting.""" + return self.config_entry.options[CONF_FORCE_DHCP] + + def get_info(self, param): + """Return device model name.""" + cmd = IDENTITY if param == NAME else INFO + data = self.command(MIKROTIK_SERVICES[cmd]) + return data[0].get(param) if data else None + + def connect_to_hub(self): + """Connect to hub.""" + try: + self.api = get_hub(self.hass, self.config_entry.data) + self.available = True + return True + except (LoginError, CannotConnect): + self.available = False + return False + + def get_list_from_interface(self, interface): + """Get devices from interface.""" + result = self.command(MIKROTIK_SERVICES[interface]) + return self.load_mac(result) if result else None + + def restore_device(self, mac): + """Restore a missing device after restart.""" + self.devices[mac] = Device(mac, self.all_devices[mac]) + + def update_devices(self): + """Get list of devices with latest status.""" + arp_devices = {} + wireless_devices = {} + device_list = {} + try: + self.all_devices = self.get_list_from_interface(DHCP) + if self.support_wireless: + _LOGGER.debug("wireless is supported") + for interface in [CAPSMAN, WIRELESS]: + wireless_devices = self.get_list_from_interface(interface) + if wireless_devices: + _LOGGER.debug("Scanning wireless devices using %s", interface) + break + + if self.support_wireless and not self.force_dhcp: + device_list = wireless_devices + else: + device_list = self.all_devices + _LOGGER.debug("Falling back to DHCP for scanning devices") + + if self.arp_enabled: + arp_devices = self.get_list_from_interface(ARP) + + except CannotConnect: + self.available = False + return + + if not device_list: + return + + for mac, params in device_list.items(): + if mac not in self.devices: + self.devices[mac] = Device(mac, self.all_devices.get(mac)) + else: + self.devices[mac].update(params=self.all_devices.get(mac)) + + if mac in wireless_devices: + # if wireless is supported then wireless_params are params + self.devices[mac].update( + wireless_params=wireless_devices[mac], active=True + ) + continue + # for wired devices or when forcing dhcp check for active-address + if not params.get("active-address"): + self.devices[mac].update(active=False) + continue + # ping check the rest of active devices if arp ping is enabled + active = True + if self.arp_enabled and mac in arp_devices: + active = self.do_arp_ping( + params.get("active-address"), arp_devices[mac].get("interface") + ) + self.devices[mac].update(active=active) + + def do_arp_ping(self, ip_address, interface): + """Attempt to arp ping MAC address via interface.""" + _LOGGER.debug("pinging - %s", ip_address) + params = { + "arp-ping": "yes", + "interval": "100ms", + "count": 3, + "interface": interface, + "address": ip_address, + } + cmd = "/ping" + data = self.command(cmd, params) + if data is not None: + status = 0 + for result in data: + if "status" in result: + status += 1 + if status == len(data): + _LOGGER.debug( + "Mikrotik %s - %s arp_ping timed out", ip_address, interface + ) + return False + return True + + def command(self, cmd, params=None): + """Retrieve data from Mikrotik API.""" + try: + if params: + response = self.api(cmd=cmd, **params) + else: + response = self.api(cmd=cmd) + except (librouteros.exceptions.ConnectionError,) as api_error: + _LOGGER.error("Mikrotik %s connection error %s", self._host, api_error) + raise CannotConnect + except ( + librouteros.exceptions.TrapError, + librouteros.exceptions.MultiTrapError, + ) as api_error: + _LOGGER.error( + "Mikrotik %s failed to retrieve data. cmd=[%s] Error: %s", + self._host, + cmd, + api_error, + ) + return None + + return response if response else None + + def update(self): + """Update device_tracker from Mikrotik API.""" + if not self.available or not self.api: + if not self.connect_to_hub(): + return + _LOGGER.debug("updating network devices for host: %s", self._host) + self.update_devices() + + +class MikrotikHub: + """Mikrotik Hub Object.""" + + def __init__(self, hass, config_entry): + """Initialize the Mikrotik Client.""" + self.hass = hass + self.config_entry = config_entry + self._mk_data = None + self.progress = None + + @property + def host(self): + """Return the host of this hub.""" + return self.config_entry.data[CONF_HOST] + + @property + def hostname(self): + """Return the hostname of the hub.""" + return self._mk_data.get_info(NAME) + + @property + def model(self): + """Return the model of the hub.""" + return self._mk_data.get_info(ATTR_MODEL) + + @property + def firmware(self): + """Return the firware of the hub.""" + return self._mk_data.get_info(ATTR_FIRMWARE) + + @property + def serial_num(self): + """Return the serial number of the hub.""" + return self._mk_data.get_info(ATTR_SERIAL_NUMBER) + + @property + def available(self): + """Return if the hub is connected.""" + return self._mk_data.available + + @property + def option_detection_time(self): + """Config entry option defining number of seconds from last seen to away.""" + return timedelta(seconds=self.config_entry.options[CONF_DETECTION_TIME]) + + @property + def signal_update(self): + """Event specific per Mikrotik entry to signal new options.""" + return f"mikrotik-update-{self.host}" + + @property + def api(self): + """Represent Mikrotik data object.""" + return self._mk_data + + async def add_options(self): + """Populate default options for Mikrotik.""" + if not self.config_entry.options: + hub_options = self.config_entry.data.pop("options", {}) + system_options = { + "disable_new_entities": not hub_options.get(CONF_TRACK_DEVICES, False) + } + if CONF_DETECTION_TIME in hub_options: + detection_time = hub_options[CONF_DETECTION_TIME].seconds + else: + detection_time = DEFAULT_DETECTION_TIME + options = { + CONF_ARP_PING: hub_options.get(CONF_ARP_PING, False), + CONF_FORCE_DHCP: hub_options.get(CONF_FORCE_DHCP, False), + CONF_DETECTION_TIME: detection_time, + } + + self.hass.config_entries.async_update_entry( + self.config_entry, options=options, system_options=system_options + ) + + async def request_update(self): + """Request an update.""" + if self.progress is not None: + return await self.progress + + self.progress = self.hass.async_create_task(self.async_update()) + await self.progress + + self.progress = None + + async def async_update(self): + """Update Mikrotik devices information.""" + await self.hass.async_add_executor_job(self._mk_data.update) + async_dispatcher_send(self.hass, self.signal_update) + + async def async_setup(self): + """Set up the Mikrotik hub.""" + try: + api = await self.hass.async_add_executor_job( + get_hub, self.hass, self.config_entry.data + ) + except CannotConnect: + raise ConfigEntryNotReady + except LoginError: + return False + + self._mk_data = MikrotikData(self.hass, self.config_entry, api) + await self.add_options() + await self.hass.async_add_executor_job(self._mk_data.update_devices) + self.hass.async_create_task( + self.hass.config_entries.async_forward_entry_setup( + self.config_entry, "device_tracker" + ) + ) + return True + + +def get_hub(hass, config_entry): + """Connect to Mikrotik hub.""" + _LOGGER.debug("Connecting to Mikrotik hub [%s]", config_entry[CONF_HOST]) + + _login_method = (login_plain, login_token) + kwargs = {"login_methods": _login_method, "port": config_entry["port"]} + + if config_entry[CONF_VERIFY_SSL]: + ssl_context = ssl.create_default_context() + ssl_context.check_hostname = False + ssl_context.verify_mode = ssl.CERT_NONE + _ssl_wrapper = ssl_context.wrap_socket + kwargs["ssl_wrapper"] = _ssl_wrapper + + try: + api = librouteros.connect( + config_entry[CONF_HOST], + config_entry[CONF_USERNAME], + config_entry[CONF_PASSWORD], + **kwargs, + ) + _LOGGER.debug("Connected to %s successfully", config_entry[CONF_HOST]) + return api + except ( + librouteros.exceptions.TrapError, + librouteros.exceptions.MultiTrapError, + librouteros.exceptions.ConnectionError, + ) as api_error: + _LOGGER.error("Mikrotik %s error: %s", config_entry[CONF_HOST], api_error) + if "invalid user name or password" in str(api_error): + raise LoginError + raise CannotConnect diff --git a/homeassistant/components/mikrotik/manifest.json b/homeassistant/components/mikrotik/manifest.json index 9a05f5a9f870e0..2cda7c45e6ebc2 100644 --- a/homeassistant/components/mikrotik/manifest.json +++ b/homeassistant/components/mikrotik/manifest.json @@ -1,10 +1,13 @@ { "domain": "mikrotik", "name": "Mikrotik", + "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/mikrotik", "requirements": [ - "librouteros==2.3.0" + "librouteros==2.3.1" ], "dependencies": [], - "codeowners": [] -} + "codeowners": [ + "@engrbm87" + ] +} \ No newline at end of file diff --git a/homeassistant/components/mikrotik/strings.json b/homeassistant/components/mikrotik/strings.json new file mode 100644 index 00000000000000..38352ef4f90121 --- /dev/null +++ b/homeassistant/components/mikrotik/strings.json @@ -0,0 +1,37 @@ +{ + "config": { + "title": "Mikrotik", + "step": { + "user": { + "title": "Set up Mikrotik Router", + "data": { + "name": "Name", + "host": "Host", + "username": "Username", + "password": "Password", + "port": "Port", + "verify_ssl": "Use ssl", + "track_devices": "Enable newly added entities" + } + } + }, + "error": { + "cannot_connect": "Connection Unsuccessful", + "wrong_credentials": "Wrong Credentials" + }, + "abort": { + "already_configured": "Mikrotik is already configured" + } + }, + "options": { + "step": { + "device_tracker": { + "data": { + "arp_ping": "Enable ARP ping", + "force_dhcp": "Force scanning using DHCP", + "detection_time": "Consider home interval" + } + } + } + } +} \ No newline at end of file diff --git a/tests/components/mikrotik/__init__.py b/tests/components/mikrotik/__init__.py new file mode 100644 index 00000000000000..dec29e26a5b795 --- /dev/null +++ b/tests/components/mikrotik/__init__.py @@ -0,0 +1,102 @@ +"""Tests for the Mikrotik component.""" +from homeassistant.components import mikrotik + +MOCK_DATA = { + mikrotik.CONF_NAME: "Mikrotik", + mikrotik.CONF_HOST: "0.0.0.0", + mikrotik.CONF_USERNAME: "user", + mikrotik.CONF_PASSWORD: "pass", + mikrotik.CONF_PORT: 8278, + mikrotik.CONF_VERIFY_SSL: False, + "options": {mikrotik.CONF_TRACK_DEVICES: True}, +} + + +DEVICE_1_DHCP = { + ".id": "*1A", + "address": "0.0.0.1", + "mac-address": "00:00:00:00:00:01", + "active-address": "0.0.0.1", + "host-name": "Device_1", + "comment": "Mobile", +} +DEVICE_2_DHCP = { + ".id": "*1B", + "address": "0.0.0.2", + "mac-address": "00:00:00:00:00:02", + "active-address": "0.0.0.2", + "host-name": "Device_2", + "comment": "PC", +} +DEVICE_1_WIRELESS = { + ".id": "*264", + "interface": "wlan1", + "mac-address": "00:00:00:00:00:01", + "ap": False, + "wds": False, + "bridge": False, + "rx-rate": "72.2Mbps-20MHz/1S/SGI", + "tx-rate": "72.2Mbps-20MHz/1S/SGI", + "packets": "59542,17464", + "bytes": "17536671,2966351", + "frames": "59542,17472", + "frame-bytes": "17655785,2862445", + "hw-frames": "78935,38395", + "hw-frame-bytes": "25636019,4063445", + "tx-frames-timed-out": 0, + "uptime": "5h49m36s", + "last-activity": "170ms", + "signal-strength": "-62@1Mbps", + "signal-to-noise": 52, + "signal-strength-ch0": -63, + "signal-strength-ch1": -69, + "strength-at-rates": "-62@1Mbps 16s330ms,-64@6Mbps 13s560ms,-65@HT20-3 52m6s30ms,-66@HT20-4 52m4s350ms,-66@HT20-5 51m58s580ms,-65@HT20-6 51m24s780ms,-65@HT20-7 5s680ms", + "tx-ccq": 93, + "p-throughput": 54928, + "last-ip": "0.0.0.1", + "802.1x-port-enabled": True, + "authentication-type": "wpa2-psk", + "encryption": "aes-ccm", + "group-encryption": "aes-ccm", + "management-protection": False, + "wmm-enabled": True, + "tx-rate-set": "OFDM:6-54 BW:1x SGI:1x HT:0-7", +} + +DEVICE_2_WIRELESS = { + ".id": "*265", + "interface": "wlan1", + "mac-address": "00:00:00:00:00:02", + "ap": False, + "wds": False, + "bridge": False, + "rx-rate": "72.2Mbps-20MHz/1S/SGI", + "tx-rate": "72.2Mbps-20MHz/1S/SGI", + "packets": "59542,17464", + "bytes": "17536671,2966351", + "frames": "59542,17472", + "frame-bytes": "17655785,2862445", + "hw-frames": "78935,38395", + "hw-frame-bytes": "25636019,4063445", + "tx-frames-timed-out": 0, + "uptime": "5h49m36s", + "last-activity": "170ms", + "signal-strength": "-62@1Mbps", + "signal-to-noise": 52, + "signal-strength-ch0": -63, + "signal-strength-ch1": -69, + "strength-at-rates": "-62@1Mbps 16s330ms,-64@6Mbps 13s560ms,-65@HT20-3 52m6s30ms,-66@HT20-4 52m4s350ms,-66@HT20-5 51m58s580ms,-65@HT20-6 51m24s780ms,-65@HT20-7 5s680ms", + "tx-ccq": 93, + "p-throughput": 54928, + "last-ip": "0.0.0.2", + "802.1x-port-enabled": True, + "authentication-type": "wpa2-psk", + "encryption": "aes-ccm", + "group-encryption": "aes-ccm", + "management-protection": False, + "wmm-enabled": True, + "tx-rate-set": "OFDM:6-54 BW:1x SGI:1x HT:0-7", +} +DHCP_DATA = [DEVICE_1_DHCP, DEVICE_2_DHCP] + +WIRELESS_DATA = [DEVICE_1_WIRELESS] diff --git a/tests/components/mikrotik/test_config_flow.py b/tests/components/mikrotik/test_config_flow.py new file mode 100644 index 00000000000000..22decb1e62688b --- /dev/null +++ b/tests/components/mikrotik/test_config_flow.py @@ -0,0 +1,177 @@ +"""Test Mikrotik setup process.""" +from unittest.mock import patch + +import librouteros +import pytest + +from homeassistant import data_entry_flow +from homeassistant.components import mikrotik +from homeassistant.components.mikrotik import config_flow +from homeassistant.const import ( + CONF_HOST, + CONF_NAME, + CONF_PASSWORD, + CONF_PORT, + CONF_USERNAME, + CONF_VERIFY_SSL, +) +from tests.common import MockConfigEntry + +DEMO_USER_INPUT = { + CONF_NAME: "Home router", + CONF_HOST: "0.0.0.0", + CONF_USERNAME: "username", + CONF_PASSWORD: "password", + CONF_PORT: 8278, + CONF_VERIFY_SSL: False, + mikrotik.CONF_TRACK_DEVICES: True, +} + +DEMO_CONFIG = { + CONF_NAME: "Home router", + CONF_HOST: "0.0.0.0", + CONF_USERNAME: "username", + CONF_PASSWORD: "password", + CONF_PORT: 8278, + CONF_VERIFY_SSL: False, + mikrotik.const.CONF_FORCE_DHCP: False, + mikrotik.CONF_TRACK_DEVICES: True, + mikrotik.CONF_ARP_PING: False, + mikrotik.CONF_DETECTION_TIME: 30, +} + + +MOCK_ENTRY = MockConfigEntry(domain=mikrotik.DOMAIN, data=DEMO_CONFIG) + + +@pytest.fixture(name="api") +def mock_mikrotik_api(): + """Mock an api.""" + with patch("librouteros.connect"): + yield + + +@pytest.fixture(name="auth_error") +def mock_api_authentication_error(): + """Mock an api.""" + with patch( + "librouteros.connect", + side_effect=librouteros.exceptions.TrapError("invalid user name or password"), + ): + yield + + +@pytest.fixture(name="conn_error") +def mock_api_connection_error(): + """Mock an api.""" + with patch("transmissionrpc.Client", side_effect=librouteros.exceptions.TrapError): + yield + + +def init_config_flow(hass): + """Init a configuration flow.""" + flow = config_flow.MikrotikFlowHandler() + flow.hass = hass + return flow + + +async def test_import(hass, api): + """Test import step.""" + flow = init_config_flow(hass) + + result = await flow.async_step_import(DEMO_CONFIG) + + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["title"] == "Home router" + assert result["data"][CONF_NAME] == "Home router" + assert result["data"][CONF_HOST] == "0.0.0.0" + assert result["data"][CONF_USERNAME] == "username" + assert result["data"][CONF_PASSWORD] == "password" + assert result["data"][CONF_PORT] == 8278 + assert result["data"][CONF_VERIFY_SSL] is False + assert result["data"]["options"][mikrotik.CONF_DETECTION_TIME] == 30 + assert result["data"]["options"][mikrotik.CONF_ARP_PING] is False + assert result["data"]["options"][mikrotik.const.CONF_FORCE_DHCP] is False + + +async def test_flow_works(hass, api): + """Test config flow.""" + flow = init_config_flow(hass) + + result = await flow.async_step_user() + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "user" + + result = await flow.async_step_user(DEMO_USER_INPUT) + + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["title"] == "Home router" + assert result["data"][CONF_NAME] == "Home router" + assert result["data"][CONF_HOST] == "0.0.0.0" + assert result["data"][CONF_USERNAME] == "username" + assert result["data"][CONF_PASSWORD] == "password" + assert result["data"][CONF_PORT] == 8278 + assert result["data"]["options"][mikrotik.CONF_TRACK_DEVICES] is True + + +async def test_options(hass): + """Test updating options.""" + entry = MOCK_ENTRY + flow = init_config_flow(hass) + options_flow = flow.async_get_options_flow(entry) + + result = await options_flow.async_step_init() + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "device_tracker" + + result = await options_flow.async_step_device_tracker( + { + mikrotik.CONF_DETECTION_TIME: 30, + mikrotik.CONF_ARP_PING: True, + mikrotik.const.CONF_FORCE_DHCP: False, + } + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["data"] == { + mikrotik.CONF_DETECTION_TIME: 30, + mikrotik.CONF_ARP_PING: True, + mikrotik.const.CONF_FORCE_DHCP: False, + } + + +async def test_host_already_configured(hass, auth_error): + """Test host already configured.""" + + entry = MOCK_ENTRY + entry.add_to_hass(hass) + flow = init_config_flow(hass) + + result = await flow.async_step_user(DEMO_USER_INPUT) + + assert result["type"] == "abort" + assert result["reason"] == "already_configured" + + +async def test_connection_error(hass, conn_error): + """Test error when connection is unsuccesful.""" + + flow = init_config_flow(hass) + + result = await flow.async_step_user(DEMO_USER_INPUT) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["errors"] == {"base": "cannot_connect"} + + +async def test_wrong_credentials(hass, auth_error): + """Test error when credentials are wrong.""" + + flow = init_config_flow(hass) + + result = await flow.async_step_user(DEMO_USER_INPUT) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["errors"] == { + CONF_USERNAME: "wrong_credentials", + CONF_PASSWORD: "wrong_credentials", + } diff --git a/tests/components/mikrotik/test_device_tracker.py b/tests/components/mikrotik/test_device_tracker.py new file mode 100644 index 00000000000000..a1ea90bf249c44 --- /dev/null +++ b/tests/components/mikrotik/test_device_tracker.py @@ -0,0 +1,125 @@ +"""The tests for the Mikrotik device tracker platform.""" +from datetime import timedelta + +from homeassistant import config_entries +from homeassistant.components import mikrotik +import homeassistant.components.device_tracker as device_tracker +from homeassistant.helpers import entity_registry +from homeassistant.setup import async_setup_component +import homeassistant.util.dt as dt_util + +from . import DEVICE_2_WIRELESS, DHCP_DATA, MOCK_DATA, WIRELESS_DATA +from .test_hub import setup_mikrotik_entry + +from tests.common import patch + +DEFAULT_DETECTION_TIME = timedelta(seconds=300) + + +def mock_command(self, cmd, params=None): + """Mock the Mikrotik command method.""" + if cmd == mikrotik.const.MIKROTIK_SERVICES[mikrotik.const.IS_WIRELESS]: + return True + if cmd == mikrotik.const.MIKROTIK_SERVICES[mikrotik.const.DHCP]: + return DHCP_DATA + if cmd == mikrotik.const.MIKROTIK_SERVICES[mikrotik.const.WIRELESS]: + return WIRELESS_DATA + + +async def test_platform_manually_configured(hass): + """Test that nothing happens when configuring mikrotik through device tracker platform.""" + assert ( + await async_setup_component( + hass, + device_tracker.DOMAIN, + {device_tracker.DOMAIN: {"platform": "mikrotik"}}, + ) + is False + ) + assert mikrotik.DOMAIN not in hass.data + + +async def test_device_trackers(hass): + """Test device_trackers created by mikrotik.""" + + # test devices are added from wireless list only + hub = await setup_mikrotik_entry(hass) + + device_1 = hass.states.get("device_tracker.device_1") + assert device_1 is not None + assert device_1.state == "home" + device_2 = hass.states.get("device_tracker.device_2") + assert device_2 is None + + with patch.object(mikrotik.hub.MikrotikData, "command", new=mock_command): + # test device_2 is added after connecting to wireless network + WIRELESS_DATA.append(DEVICE_2_WIRELESS) + + await hub.async_update() + await hass.async_block_till_done() + + device_2 = hass.states.get("device_tracker.device_2") + assert device_2 is not None + assert device_1.state == "home" + + # test state changes to away if last_seen > consider_home_interval + + del WIRELESS_DATA[1] + hub.api.devices["00:00:00:00:00:02"]._last_seen = dt_util.utcnow() - timedelta( + minutes=4 + ) + await hub.async_update() + await hass.async_block_till_done() + + device_2 = hass.states.get("device_tracker.device_2") + assert device_2.state != "not_home" + + hub.api.devices["00:00:00:00:00:02"]._last_seen = dt_util.utcnow() - timedelta( + minutes=5 + ) + await hub.async_update() + await hass.async_block_till_done() + + device_2 = hass.states.get("device_tracker.device_2") + assert device_2.state == "not_home" + + +async def test_restoring_devices(hass): + """Test restoring existing device_tracker entities if not detected on startup.""" + config_entry = config_entries.ConfigEntry( + version=1, + domain=mikrotik.DOMAIN, + title="Mikrotik", + data=MOCK_DATA, + source="test", + connection_class=config_entries.CONN_CLASS_LOCAL_POLL, + system_options={}, + options={}, + entry_id=1, + ) + + registry = await entity_registry.async_get_registry(hass) + registry.async_get_or_create( + device_tracker.DOMAIN, + mikrotik.DOMAIN, + "00:00:00:00:00:01", + suggested_object_id="device_1", + config_entry=config_entry, + ) + registry.async_get_or_create( + device_tracker.DOMAIN, + mikrotik.DOMAIN, + "00:00:00:00:00:02", + suggested_object_id="device_2", + config_entry=config_entry, + ) + + await setup_mikrotik_entry(hass) + + # test device_2 which is not in wireless list is restored + device_1 = hass.states.get("device_tracker.device_1") + assert device_1 is not None + assert device_1.state == "home" + device_2 = hass.states.get("device_tracker.device_2") + assert device_2 is not None + assert device_2.state == "not_home" diff --git a/tests/components/mikrotik/test_hub.py b/tests/components/mikrotik/test_hub.py new file mode 100644 index 00000000000000..cd90570b4c9343 --- /dev/null +++ b/tests/components/mikrotik/test_hub.py @@ -0,0 +1,153 @@ +"""Test Mikrotik hub.""" +from asynctest import patch +import librouteros +import pytest + +from homeassistant import config_entries +from homeassistant.components import mikrotik +from homeassistant.exceptions import ConfigEntryNotReady + +from . import DHCP_DATA, MOCK_DATA, WIRELESS_DATA + +CONFIG_ENTRY = config_entries.ConfigEntry( + version=1, + domain=mikrotik.DOMAIN, + title="Mikrotik", + data=MOCK_DATA, + source="test", + connection_class=config_entries.CONN_CLASS_LOCAL_POLL, + system_options={}, + options={}, + entry_id=1, +) + + +async def setup_mikrotik_entry(hass, **kwargs): + """Set up Mikrotik intergation successfully.""" + support_wireless = kwargs.get("support_wireless", True) + dhcp_data = kwargs.get("dhcp_data", DHCP_DATA) + wireless_data = kwargs.get("wireless_data", WIRELESS_DATA) + + def mock_command(self, cmd, params=None): + if cmd == mikrotik.const.MIKROTIK_SERVICES[mikrotik.const.IS_WIRELESS]: + return support_wireless + if cmd == mikrotik.const.MIKROTIK_SERVICES[mikrotik.const.DHCP]: + return dhcp_data + if cmd == mikrotik.const.MIKROTIK_SERVICES[mikrotik.const.WIRELESS]: + return wireless_data + + config_entry = CONFIG_ENTRY + if "force_dhcp" in kwargs: + config_entry.options["force_dhcp"] = True + + with patch("librouteros.connect"), patch.object( + mikrotik.hub.MikrotikData, "command", new=mock_command + ): + await mikrotik.async_setup_entry(hass, config_entry) + await hass.async_block_till_done() + return hass.data[mikrotik.DOMAIN][CONFIG_ENTRY.data[mikrotik.CONF_HOST]] + + +async def test_hub_setup_successful(hass): + """Successful setup of Mikrotik hub.""" + with patch( + "homeassistant.config_entries.ConfigEntries.async_forward_entry_setup", + return_value=True, + ) as forward_entry_setup: + hub = await setup_mikrotik_entry(hass) + + assert hub.config_entry.data == { + mikrotik.CONF_NAME: "Mikrotik", + mikrotik.CONF_HOST: "0.0.0.0", + mikrotik.CONF_USERNAME: "user", + mikrotik.CONF_PASSWORD: "pass", + mikrotik.CONF_PORT: 8278, + mikrotik.CONF_VERIFY_SSL: False, + } + assert hub.config_entry.options == { + mikrotik.hub.CONF_FORCE_DHCP: False, + mikrotik.CONF_ARP_PING: False, + mikrotik.CONF_DETECTION_TIME: 300, + } + assert hub.config_entry.system_options == config_entries.SystemOptions( + disable_new_entities=False + ) + assert hub.api.available is True + assert hub.signal_update == "mikrotik-update-0.0.0.0" + assert forward_entry_setup.mock_calls[0][1] == (hub.config_entry, "device_tracker") + + +async def test_hub_setup_failed(hass): + """Failed setup of Mikrotik hub.""" + + # error when connection fails + with patch( + "librouteros.connect", side_effect=librouteros.exceptions.ConnectionError + ): + with pytest.raises(ConfigEntryNotReady): + await mikrotik.async_setup_entry(hass, CONFIG_ENTRY) + + # error when username or password is invalid + with patch( + "homeassistant.config_entries.ConfigEntries.async_forward_entry_setup" + ) as forward_entry_setup, patch( + "librouteros.connect", + side_effect=librouteros.exceptions.TrapError("invalid user name or password"), + ): + result = await mikrotik.async_setup_entry(hass, CONFIG_ENTRY) + + assert result is False + assert len(forward_entry_setup.mock_calls) == 0 + + +async def test_update_failed(hass): + """Test failing to connect during update.""" + + hub = await setup_mikrotik_entry(hass) + + with patch.object( + mikrotik.hub.MikrotikData, "command", side_effect=mikrotik.errors.CannotConnect + ): + await hub.async_update() + + assert hub.api.available is False + + +async def test_hub_not_support_wireless(hass): + """Test updating hub devices when hub doesn't support wireless interfaces.""" + + # test that the devices are constructed from wireless data + + hub = await setup_mikrotik_entry(hass, support_wireless=False) + + assert hub.api.devices["00:00:00:00:00:01"]._params == DHCP_DATA[0] + assert hub.api.devices["00:00:00:00:00:01"]._wireless_params is None + assert hub.api.devices["00:00:00:00:00:02"]._params == DHCP_DATA[1] + assert hub.api.devices["00:00:00:00:00:02"]._wireless_params is None + + +async def test_hub_support_wireless(hass): + """Test updating hub devices when hub support wireless interfaces.""" + + # test that the device list is from wireless data list + + hub = await setup_mikrotik_entry(hass) + + assert hub.api.support_wireless is True + assert hub.api.devices["00:00:00:00:00:01"]._params == DHCP_DATA[0] + assert hub.api.devices["00:00:00:00:00:01"]._wireless_params == WIRELESS_DATA[0] + assert "00:00:00:00:00:02" not in hub.api.devices + + +async def test_force_dhcp(hass): + """Test updating hub devices with forced dhcp method.""" + + # test that the devices are constructed from dhcp data + + hub = await setup_mikrotik_entry(hass, force_dhcp=True) + + assert hub.api.support_wireless is True + assert hub.api.devices["00:00:00:00:00:01"]._params == DHCP_DATA[0] + assert hub.api.devices["00:00:00:00:00:01"]._wireless_params == WIRELESS_DATA[0] + assert hub.api.devices["00:00:00:00:00:02"]._params == DHCP_DATA[1] + assert hub.api.devices["00:00:00:00:00:02"]._wireless_params is None diff --git a/tests/components/mikrotik/test_init.py b/tests/components/mikrotik/test_init.py new file mode 100644 index 00000000000000..61a2ec9e7b7d47 --- /dev/null +++ b/tests/components/mikrotik/test_init.py @@ -0,0 +1,109 @@ +"""Test UniFi setup process.""" +from unittest.mock import Mock, patch + +from homeassistant.components import mikrotik +from homeassistant.setup import async_setup_component + +from tests.common import MockConfigEntry, mock_coro + +MOCK_ENTRY = MockConfigEntry( + domain=mikrotik.DOMAIN, + data={ + mikrotik.CONF_NAME: "Mikrotik", + mikrotik.CONF_HOST: "0.0.0.0", + mikrotik.CONF_USERNAME: "user", + mikrotik.CONF_PASSWORD: "pass", + mikrotik.CONF_PORT: 8278, + mikrotik.CONF_VERIFY_SSL: False, + }, +) + + +async def test_setup_with_no_config(hass): + """Test that we do not discover anything or try to set up a bridge.""" + assert await async_setup_component(hass, mikrotik.DOMAIN, {}) is True + assert mikrotik.DOMAIN not in hass.data + + +async def test_setup_with_config(hass): + """Test that we do not discover anything or try to set up a bridge.""" + config = { + mikrotik.DOMAIN: { + mikrotik.CONF_HOST: "0.0.0.0", + mikrotik.CONF_USERNAME: "user", + mikrotik.CONF_PASSWORD: "pass", + mikrotik.CONF_ARP_PING: True, + mikrotik.CONF_TRACK_DEVICES: True, + mikrotik.CONF_DETECTION_TIME: 30, + } + } + assert await async_setup_component(hass, mikrotik.DOMAIN, config) is True + + +async def test_successful_config_entry(hass): + """Test that configured options for a host are loaded via config entry.""" + entry = MOCK_ENTRY + entry.add_to_hass(hass) + mock_registry = Mock() + + with patch.object(mikrotik, "MikrotikHub") as mock_hub, patch( + "homeassistant.helpers.device_registry.async_get_registry", + return_value=mock_coro(mock_registry), + ): + mock_hub.return_value.async_setup.return_value = mock_coro(True) + mock_hub.return_value.serial_num = "12345678" + mock_hub.return_value.model = "RB750" + mock_hub.return_value.hostname = "mikrotik" + mock_hub.return_value.firmware = "3.65" + assert await mikrotik.async_setup_entry(hass, entry) is True + + assert len(mock_hub.mock_calls) == 2 + p_hass, p_entry = mock_hub.mock_calls[0][1] + + assert p_hass is hass + assert p_entry is entry + + assert len(mock_registry.mock_calls) == 1 + assert mock_registry.mock_calls[0][2] == { + "config_entry_id": entry.entry_id, + "connections": {("mikrotik", "12345678")}, + "manufacturer": mikrotik.ATTR_MANUFACTURER, + "model": "RB750", + "name": "mikrotik", + "sw_version": "3.65", + } + + +async def test_hub_fail_setup(hass): + """Test that a failed setup still stores controller.""" + entry = MOCK_ENTRY + entry.add_to_hass(hass) + + with patch.object(mikrotik, "MikrotikHub") as mock_hub: + mock_hub.return_value.async_setup.return_value = mock_coro(False) + assert await mikrotik.async_setup_entry(hass, entry) is False + + assert entry.data[mikrotik.CONF_HOST] in hass.data[mikrotik.DOMAIN] + + +async def test_unload_entry(hass): + """Test being able to unload an entry.""" + entry = MOCK_ENTRY + entry.add_to_hass(hass) + + with patch.object(mikrotik, "MikrotikHub") as mock_hub, patch( + "homeassistant.helpers.device_registry.async_get_registry", + return_value=mock_coro(Mock()), + ): + mock_hub.return_value.async_setup.return_value = mock_coro(True) + mock_hub.return_value.serial_num = "12345678" + mock_hub.return_value.model = "RB750" + mock_hub.return_value.hostname = "mikrotik" + mock_hub.return_value.firmware = "3.65" + assert await mikrotik.async_setup_entry(hass, entry) is True + + assert len(mock_hub.return_value.mock_calls) == 1 + + mock_hub.return_value.async_reset.return_value = mock_coro(True) + assert await mikrotik.async_unload_entry(hass, entry) + assert hass.data[mikrotik.DOMAIN] == {} From 9f13bf8a70c7abf51ee9fceb581a826e2da4e93f Mon Sep 17 00:00:00 2001 From: Rami Mosleh Date: Fri, 11 Oct 2019 19:59:47 +0300 Subject: [PATCH 02/21] update requirements and coverage --- .coveragerc | 1 - CODEOWNERS | 1 + homeassistant/generated/config_flows.py | 1 + requirements_all.txt | 2 +- requirements_test_all.txt | 3 +++ 5 files changed, 6 insertions(+), 2 deletions(-) diff --git a/.coveragerc b/.coveragerc index d241260fdf0821..7de7e612d7e399 100644 --- a/.coveragerc +++ b/.coveragerc @@ -395,7 +395,6 @@ omit = homeassistant/components/metoffice/weather.py homeassistant/components/microsoft/tts.py homeassistant/components/miflora/sensor.py - homeassistant/components/mikrotik/* homeassistant/components/mill/climate.py homeassistant/components/minio/* homeassistant/components/mitemp_bt/sensor.py diff --git a/CODEOWNERS b/CODEOWNERS index 070151d01e076b..58dd005370361a 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -179,6 +179,7 @@ homeassistant/components/met/* @danielhiversen homeassistant/components/meteo_france/* @victorcerutti @oncleben31 homeassistant/components/meteoalarm/* @rolfberkenbosch homeassistant/components/miflora/* @danielhiversen @ChristianKuehnel +homeassistant/components/mikrotik/* @engrbm87 homeassistant/components/mill/* @danielhiversen homeassistant/components/min_max/* @fabaff homeassistant/components/minio/* @tkislan diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 4a4effc36ce9a2..1a7652002ef31a 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -41,6 +41,7 @@ "luftdaten", "mailgun", "met", + "mikrotik", "mobile_app", "mqtt", "neato", diff --git a/requirements_all.txt b/requirements_all.txt index bff608964a4482..9519978d62888b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -739,7 +739,7 @@ libpyfoscam==1.0 libpyvivotek==0.2.2 # homeassistant.components.mikrotik -librouteros==2.3.0 +librouteros==2.3.1 # homeassistant.components.soundtouch libsoundtouch==0.7.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 0f37ef15992ec9..ce1744ab4e57b7 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -269,6 +269,9 @@ keyrings.alt==3.1.1 # homeassistant.components.dyson libpurecool==0.5.0 +# homeassistant.components.mikrotik +librouteros==2.3.1 + # homeassistant.components.soundtouch libsoundtouch==0.7.2 From 710b3b5ff7d24a8cd7665f097a5e4f26752a96db Mon Sep 17 00:00:00 2001 From: Rami Mosleh Date: Wed, 23 Oct 2019 11:10:49 +0300 Subject: [PATCH 03/21] fix description comments --- tests/components/mikrotik/test_init.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/components/mikrotik/test_init.py b/tests/components/mikrotik/test_init.py index 61a2ec9e7b7d47..4843dd600c28ea 100644 --- a/tests/components/mikrotik/test_init.py +++ b/tests/components/mikrotik/test_init.py @@ -1,4 +1,4 @@ -"""Test UniFi setup process.""" +"""Test Mikrotik setup process.""" from unittest.mock import Mock, patch from homeassistant.components import mikrotik @@ -20,13 +20,13 @@ async def test_setup_with_no_config(hass): - """Test that we do not discover anything or try to set up a bridge.""" + """Test that we do not discover anything or try to set up a hub.""" assert await async_setup_component(hass, mikrotik.DOMAIN, {}) is True assert mikrotik.DOMAIN not in hass.data async def test_setup_with_config(hass): - """Test that we do not discover anything or try to set up a bridge.""" + """Test that we do not discover anything or try to set up a hub.""" config = { mikrotik.DOMAIN: { mikrotik.CONF_HOST: "0.0.0.0", @@ -75,7 +75,7 @@ async def test_successful_config_entry(hass): async def test_hub_fail_setup(hass): - """Test that a failed setup still stores controller.""" + """Test that a failed setup still stores hub.""" entry = MOCK_ENTRY entry.add_to_hass(hass) From eebe07956cfd41a2853702fa22a7bb29eb6e4bb1 Mon Sep 17 00:00:00 2001 From: Rami Date: Thu, 14 Nov 2019 13:54:10 +0200 Subject: [PATCH 04/21] update tests, fix disabled entity updates --- .../components/mikrotik/.translations/en.json | 1 + homeassistant/components/mikrotik/__init__.py | 6 +-- .../components/mikrotik/config_flow.py | 4 +- .../components/mikrotik/device_tracker.py | 16 +++++--- homeassistant/components/mikrotik/hub.py | 4 +- .../components/mikrotik/strings.json | 1 + tests/components/mikrotik/__init__.py | 27 +++++++++++++ tests/components/mikrotik/test_config_flow.py | 14 +++++++ tests/components/mikrotik/test_hub.py | 38 ++++++++++++++++--- tests/components/mikrotik/test_init.py | 22 ++--------- 10 files changed, 98 insertions(+), 35 deletions(-) diff --git a/homeassistant/components/mikrotik/.translations/en.json b/homeassistant/components/mikrotik/.translations/en.json index 38352ef4f90121..2d3569945ab41b 100644 --- a/homeassistant/components/mikrotik/.translations/en.json +++ b/homeassistant/components/mikrotik/.translations/en.json @@ -16,6 +16,7 @@ } }, "error": { + "name_exists": "Name exists", "cannot_connect": "Connection Unsuccessful", "wrong_credentials": "Wrong Credentials" }, diff --git a/homeassistant/components/mikrotik/__init__.py b/homeassistant/components/mikrotik/__init__.py index f1d82f3c43825d..84bb7128fae8cf 100644 --- a/homeassistant/components/mikrotik/__init__.py +++ b/homeassistant/components/mikrotik/__init__.py @@ -48,7 +48,7 @@ async def async_setup(hass, config): - """Import the Transmission Component from config.""" + """Import the Mikrotik component from config.""" if DOMAIN in config: for entry in config[DOMAIN]: @@ -65,7 +65,7 @@ async def async_setup_entry(hass, config_entry): """Set up the Mikrotik component.""" hub = MikrotikHub(hass, config_entry) - hass.data.setdefault(DOMAIN, {})[config_entry.data[CONF_HOST]] = hub + hass.data.setdefault(DOMAIN, {})[config_entry.entry_id] = hub if not await hub.async_setup(): return False @@ -87,6 +87,6 @@ async def async_unload_entry(hass, config_entry): """Unload a config entry.""" await hass.config_entries.async_forward_entry_unload(config_entry, "device_tracker") - hass.data[DOMAIN].pop(config_entry.data[CONF_HOST]) + hass.data[DOMAIN].pop(config_entry.entry_id) return True diff --git a/homeassistant/components/mikrotik/config_flow.py b/homeassistant/components/mikrotik/config_flow.py index 680640b9d6ed81..19bc4185acdba4 100644 --- a/homeassistant/components/mikrotik/config_flow.py +++ b/homeassistant/components/mikrotik/config_flow.py @@ -49,8 +49,10 @@ async def async_step_user(self, user_input=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") + if entry.data[CONF_NAME] == user_input[CONF_NAME]: + errors[CONF_NAME] = "name_exists" - errors = self.validate_user_input(user_input) + errors.update(self.validate_user_input(user_input)) if not errors: return self.async_create_entry( title=self.config[CONF_NAME], data=self.config diff --git a/homeassistant/components/mikrotik/device_tracker.py b/homeassistant/components/mikrotik/device_tracker.py index 3ac105c32b1a42..844c5852dfbe52 100644 --- a/homeassistant/components/mikrotik/device_tracker.py +++ b/homeassistant/components/mikrotik/device_tracker.py @@ -6,21 +6,20 @@ DOMAIN as DEVICE_TRACKER, SOURCE_TYPE_ROUTER, ) -from homeassistant.const import CONF_HOST from homeassistant.core import callback from homeassistant.helpers import entity_registry 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 DOMAIN +from .const import DOMAIN, ATTR_MANUFACTURER _LOGGER = logging.getLogger(__name__) async def async_setup_entry(hass, config_entry, async_add_entities): """Set up device tracker for Mikrotik component.""" - hub = hass.data[DOMAIN][config_entry.data[CONF_HOST]] + hub = hass.data[DOMAIN][config_entry.entry_id] tracked = {} @@ -53,7 +52,7 @@ def update_hub(): @callback def update_items(hub, async_add_entities, tracked): - """Update tracked device state from the controller.""" + """Update tracked device state from the hub.""" new_tracked = [] for device in hub.api.devices: if device not in tracked: @@ -71,6 +70,7 @@ def __init__(self, device, hub): """Initialize the tracked device.""" self.device = device self.hub = hub + self.unsub_dispatcher = None @property def is_connected(self): @@ -115,6 +115,7 @@ def device_info(self): """Return a client description for device registry.""" info = { "connections": {(CONNECTION_NETWORK_MAC, self.device.mac)}, + "manufacturer": ATTR_MANUFACTURER, "identifiers": {(DOMAIN, self.unique_id)}, "name": self.name, "via_device": (DOMAIN, self.hub.serial_num), @@ -124,7 +125,7 @@ def device_info(self): async def async_added_to_hass(self): """Client entity created.""" _LOGGER.debug("New network device tracker %s (%s)", self.name, self.unique_id) - async_dispatcher_connect( + self.unsub_dispatcher = async_dispatcher_connect( self.hass, self.hub.signal_update, self.async_write_ha_state ) @@ -134,3 +135,8 @@ async def async_update(self): "Updating Mikrotik tracked client %s (%s)", self.entity_id, self.unique_id ) await self.hub.request_update() + + async def will_remove_from_hass(self): + """Disconnect from dispatcher.""" + if self.unsub_dispatcher: + self.unsub_dispatcher() diff --git a/homeassistant/components/mikrotik/hub.py b/homeassistant/components/mikrotik/hub.py index 32a886dd4cebb9..f2c538e7c948c8 100644 --- a/homeassistant/components/mikrotik/hub.py +++ b/homeassistant/components/mikrotik/hub.py @@ -237,7 +237,7 @@ def command(self, cmd, params=None): librouteros.exceptions.TrapError, librouteros.exceptions.MultiTrapError, ) as api_error: - _LOGGER.error( + _LOGGER.warning( "Mikrotik %s failed to retrieve data. cmd=[%s] Error: %s", self._host, cmd, @@ -303,7 +303,7 @@ def option_detection_time(self): @property def signal_update(self): - """Event specific per Mikrotik entry to signal new options.""" + """Event specific per Mikrotik entry to signal updates.""" return f"mikrotik-update-{self.host}" @property diff --git a/homeassistant/components/mikrotik/strings.json b/homeassistant/components/mikrotik/strings.json index 38352ef4f90121..2d3569945ab41b 100644 --- a/homeassistant/components/mikrotik/strings.json +++ b/homeassistant/components/mikrotik/strings.json @@ -16,6 +16,7 @@ } }, "error": { + "name_exists": "Name exists", "cannot_connect": "Connection Unsuccessful", "wrong_credentials": "Wrong Credentials" }, diff --git a/tests/components/mikrotik/__init__.py b/tests/components/mikrotik/__init__.py index dec29e26a5b795..982fb73a79914f 100644 --- a/tests/components/mikrotik/__init__.py +++ b/tests/components/mikrotik/__init__.py @@ -100,3 +100,30 @@ DHCP_DATA = [DEVICE_1_DHCP, DEVICE_2_DHCP] WIRELESS_DATA = [DEVICE_1_WIRELESS] + +ARP_DATA = [ + { + ".id": "*1", + "address": "0.0.0.1", + "mac-address": "00:00:00:00:00:01", + "interface": "bridge", + "published": False, + "invalid": False, + "DHCP": True, + "dynamic": True, + "complete": True, + "disabled": False, + }, + { + ".id": "*2", + "address": "0.0.0.2", + "mac-address": "00:00:00:00:00:02", + "interface": "bridge", + "published": False, + "invalid": False, + "DHCP": True, + "dynamic": True, + "complete": True, + "disabled": False, + }, +] diff --git a/tests/components/mikrotik/test_config_flow.py b/tests/components/mikrotik/test_config_flow.py index 22decb1e62688b..9d7a2580ad72e9 100644 --- a/tests/components/mikrotik/test_config_flow.py +++ b/tests/components/mikrotik/test_config_flow.py @@ -152,6 +152,20 @@ async def test_host_already_configured(hass, auth_error): assert result["reason"] == "already_configured" +async def test_name_exists(hass, api): + """Test name already configured.""" + + entry = MOCK_ENTRY + entry.add_to_hass(hass) + flow = init_config_flow(hass) + user_input = DEMO_USER_INPUT.copy() + user_input[CONF_HOST] = "0.0.0.1" + result = await flow.async_step_user(user_input) + + assert result["type"] == "form" + assert result["errors"] == {CONF_NAME: "name_exists"} + + async def test_connection_error(hass, conn_error): """Test error when connection is unsuccesful.""" diff --git a/tests/components/mikrotik/test_hub.py b/tests/components/mikrotik/test_hub.py index cd90570b4c9343..adb31981445335 100644 --- a/tests/components/mikrotik/test_hub.py +++ b/tests/components/mikrotik/test_hub.py @@ -7,7 +7,7 @@ from homeassistant.components import mikrotik from homeassistant.exceptions import ConfigEntryNotReady -from . import DHCP_DATA, MOCK_DATA, WIRELESS_DATA +from . import DHCP_DATA, MOCK_DATA, WIRELESS_DATA, ARP_DATA CONFIG_ENTRY = config_entries.ConfigEntry( version=1, @@ -35,17 +35,22 @@ def mock_command(self, cmd, params=None): return dhcp_data if cmd == mikrotik.const.MIKROTIK_SERVICES[mikrotik.const.WIRELESS]: return wireless_data + if cmd == mikrotik.const.MIKROTIK_SERVICES[mikrotik.const.ARP]: + return ARP_DATA config_entry = CONFIG_ENTRY if "force_dhcp" in kwargs: config_entry.options["force_dhcp"] = True + if "arp_ping" in kwargs: + config_entry.options["arp_ping"] = True + with patch("librouteros.connect"), patch.object( mikrotik.hub.MikrotikData, "command", new=mock_command ): await mikrotik.async_setup_entry(hass, config_entry) await hass.async_block_till_done() - return hass.data[mikrotik.DOMAIN][CONFIG_ENTRY.data[mikrotik.CONF_HOST]] + return hass.data[mikrotik.DOMAIN][config_entry.entry_id] async def test_hub_setup_successful(hass): @@ -96,8 +101,8 @@ async def test_hub_setup_failed(hass): ): result = await mikrotik.async_setup_entry(hass, CONFIG_ENTRY) - assert result is False - assert len(forward_entry_setup.mock_calls) == 0 + assert result is False + assert len(forward_entry_setup.mock_calls) == 0 async def test_update_failed(hass): @@ -116,7 +121,7 @@ async def test_update_failed(hass): async def test_hub_not_support_wireless(hass): """Test updating hub devices when hub doesn't support wireless interfaces.""" - # test that the devices are constructed from wireless data + # test that the devices are constructed from dhcp data hub = await setup_mikrotik_entry(hass, support_wireless=False) @@ -136,6 +141,8 @@ async def test_hub_support_wireless(hass): assert hub.api.support_wireless is True assert hub.api.devices["00:00:00:00:00:01"]._params == DHCP_DATA[0] assert hub.api.devices["00:00:00:00:00:01"]._wireless_params == WIRELESS_DATA[0] + + # devices not in wireless list will not be added assert "00:00:00:00:00:02" not in hub.api.devices @@ -149,5 +156,26 @@ async def test_force_dhcp(hass): assert hub.api.support_wireless is True assert hub.api.devices["00:00:00:00:00:01"]._params == DHCP_DATA[0] assert hub.api.devices["00:00:00:00:00:01"]._wireless_params == WIRELESS_DATA[0] + + # devices not in wireless list are added from dhcp assert hub.api.devices["00:00:00:00:00:02"]._params == DHCP_DATA[1] assert hub.api.devices["00:00:00:00:00:02"]._wireless_params is None + + +async def test_arp_ping(hass): + """Test arp ping devices to confirm they are connected.""" + + # test device show as home if arp ping returns value + with patch.object(mikrotik.hub.MikrotikData, "do_arp_ping", return_value=True): + hub = await setup_mikrotik_entry(hass, arp_ping=True, force_dhcp=True) + + assert hub.api.devices["00:00:00:00:00:01"].last_seen is not None + assert hub.api.devices["00:00:00:00:00:02"].last_seen is not None + + # test device show as away if arp ping times out + with patch.object(mikrotik.hub.MikrotikData, "do_arp_ping", return_value=False): + hub = await setup_mikrotik_entry(hass, arp_ping=True, force_dhcp=True) + + assert hub.api.devices["00:00:00:00:00:01"].last_seen is not None + # this device is not wireless so it will show as away + assert hub.api.devices["00:00:00:00:00:02"].last_seen is None diff --git a/tests/components/mikrotik/test_init.py b/tests/components/mikrotik/test_init.py index 4843dd600c28ea..13cbdeb7f2ad38 100644 --- a/tests/components/mikrotik/test_init.py +++ b/tests/components/mikrotik/test_init.py @@ -25,23 +25,8 @@ async def test_setup_with_no_config(hass): assert mikrotik.DOMAIN not in hass.data -async def test_setup_with_config(hass): - """Test that we do not discover anything or try to set up a hub.""" - config = { - mikrotik.DOMAIN: { - mikrotik.CONF_HOST: "0.0.0.0", - mikrotik.CONF_USERNAME: "user", - mikrotik.CONF_PASSWORD: "pass", - mikrotik.CONF_ARP_PING: True, - mikrotik.CONF_TRACK_DEVICES: True, - mikrotik.CONF_DETECTION_TIME: 30, - } - } - assert await async_setup_component(hass, mikrotik.DOMAIN, config) is True - - async def test_successful_config_entry(hass): - """Test that configured options for a host are loaded via config entry.""" + """Test config entry successfull setup.""" entry = MOCK_ENTRY entry.add_to_hass(hass) mock_registry = Mock() @@ -83,7 +68,7 @@ async def test_hub_fail_setup(hass): mock_hub.return_value.async_setup.return_value = mock_coro(False) assert await mikrotik.async_setup_entry(hass, entry) is False - assert entry.data[mikrotik.CONF_HOST] in hass.data[mikrotik.DOMAIN] + assert entry.entry_id in hass.data[mikrotik.DOMAIN] async def test_unload_entry(hass): @@ -104,6 +89,5 @@ async def test_unload_entry(hass): assert len(mock_hub.return_value.mock_calls) == 1 - mock_hub.return_value.async_reset.return_value = mock_coro(True) assert await mikrotik.async_unload_entry(hass, entry) - assert hass.data[mikrotik.DOMAIN] == {} + assert entry.entry_id not in hass.data[mikrotik.DOMAIN] From bf8d7670bf9832d44603bdfc728f03d65130db50 Mon Sep 17 00:00:00 2001 From: Rami Mosleh Date: Fri, 11 Oct 2019 19:54:58 +0300 Subject: [PATCH 05/21] rework device scanning, add tests --- .../components/mikrotik/.translations/en.json | 37 ++ homeassistant/components/mikrotik/__init__.py | 216 +++------- .../components/mikrotik/config_flow.py | 144 +++++++ homeassistant/components/mikrotik/const.py | 39 +- .../components/mikrotik/device_tracker.py | 307 ++++++------- homeassistant/components/mikrotik/errors.py | 10 + homeassistant/components/mikrotik/hub.py | 403 ++++++++++++++++++ .../components/mikrotik/manifest.json | 9 +- .../components/mikrotik/strings.json | 37 ++ tests/components/mikrotik/__init__.py | 102 +++++ tests/components/mikrotik/test_config_flow.py | 177 ++++++++ .../mikrotik/test_device_tracker.py | 125 ++++++ tests/components/mikrotik/test_hub.py | 153 +++++++ tests/components/mikrotik/test_init.py | 109 +++++ 14 files changed, 1502 insertions(+), 366 deletions(-) create mode 100644 homeassistant/components/mikrotik/.translations/en.json create mode 100644 homeassistant/components/mikrotik/config_flow.py create mode 100644 homeassistant/components/mikrotik/errors.py create mode 100644 homeassistant/components/mikrotik/hub.py create mode 100644 homeassistant/components/mikrotik/strings.json create mode 100644 tests/components/mikrotik/__init__.py create mode 100644 tests/components/mikrotik/test_config_flow.py create mode 100644 tests/components/mikrotik/test_device_tracker.py create mode 100644 tests/components/mikrotik/test_hub.py create mode 100644 tests/components/mikrotik/test_init.py diff --git a/homeassistant/components/mikrotik/.translations/en.json b/homeassistant/components/mikrotik/.translations/en.json new file mode 100644 index 00000000000000..38352ef4f90121 --- /dev/null +++ b/homeassistant/components/mikrotik/.translations/en.json @@ -0,0 +1,37 @@ +{ + "config": { + "title": "Mikrotik", + "step": { + "user": { + "title": "Set up Mikrotik Router", + "data": { + "name": "Name", + "host": "Host", + "username": "Username", + "password": "Password", + "port": "Port", + "verify_ssl": "Use ssl", + "track_devices": "Enable newly added entities" + } + } + }, + "error": { + "cannot_connect": "Connection Unsuccessful", + "wrong_credentials": "Wrong Credentials" + }, + "abort": { + "already_configured": "Mikrotik is already configured" + } + }, + "options": { + "step": { + "device_tracker": { + "data": { + "arp_ping": "Enable ARP ping", + "force_dhcp": "Force scanning using DHCP", + "detection_time": "Consider home interval" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/mikrotik/__init__.py b/homeassistant/components/mikrotik/__init__.py index 9b533288d86295..f1d82f3c43825d 100644 --- a/homeassistant/components/mikrotik/__init__.py +++ b/homeassistant/components/mikrotik/__init__.py @@ -1,42 +1,28 @@ -"""The mikrotik component.""" -import logging -import ssl - -import librouteros -from librouteros.login import login_plain, login_token +"""The Mikrotik component.""" import voluptuous as vol -from homeassistant.components.device_tracker import DOMAIN as DEVICE_TRACKER +from homeassistant.config_entries import SOURCE_IMPORT from homeassistant.const import ( CONF_HOST, - CONF_METHOD, + CONF_NAME, CONF_PASSWORD, CONF_PORT, - CONF_SSL, CONF_USERNAME, + CONF_VERIFY_SSL, ) from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.discovery import load_platform from .const import ( + ATTR_MANUFACTURER, CONF_ARP_PING, - CONF_ENCODING, - CONF_LOGIN_METHOD, + CONF_DETECTION_TIME, CONF_TRACK_DEVICES, - DEFAULT_ENCODING, + DEFAULT_API_PORT, + DEFAULT_DETECTION_TIME, + DEFAULT_NAME, DOMAIN, - HOSTS, - IDENTITY, - MIKROTIK_SERVICES, - MTK_LOGIN_PLAIN, - MTK_LOGIN_TOKEN, - NAME, ) - -_LOGGER = logging.getLogger(__name__) - -MTK_DEFAULT_API_PORT = "8728" -MTK_DEFAULT_API_SSL_PORT = "8729" +from .hub import MikrotikHub MIKROTIK_SCHEMA = vol.All( vol.Schema( @@ -44,13 +30,14 @@ vol.Required(CONF_HOST): cv.string, vol.Required(CONF_USERNAME): cv.string, vol.Required(CONF_PASSWORD): cv.string, - vol.Optional(CONF_METHOD): cv.string, - vol.Optional(CONF_LOGIN_METHOD): vol.Any(MTK_LOGIN_PLAIN, MTK_LOGIN_TOKEN), - vol.Optional(CONF_PORT): cv.port, - vol.Optional(CONF_SSL, default=False): cv.boolean, - vol.Optional(CONF_ENCODING, default=DEFAULT_ENCODING): cv.string, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_PORT, default=DEFAULT_API_PORT): cv.port, + vol.Optional(CONF_VERIFY_SSL, default=False): cv.boolean, vol.Optional(CONF_TRACK_DEVICES, default=True): cv.boolean, vol.Optional(CONF_ARP_PING, default=False): cv.boolean, + vol.Optional( + CONF_DETECTION_TIME, default=DEFAULT_DETECTION_TIME + ): cv.time_period, } ) ) @@ -60,143 +47,46 @@ ) -def setup(hass, config): - """Set up the Mikrotik component.""" - hass.data[DOMAIN] = {HOSTS: {}} - - for device in config[DOMAIN]: - host = device[CONF_HOST] - use_ssl = device.get(CONF_SSL) - user = device.get(CONF_USERNAME) - password = device.get(CONF_PASSWORD, "") - login = device.get(CONF_LOGIN_METHOD) - encoding = device.get(CONF_ENCODING) - track_devices = device.get(CONF_TRACK_DEVICES) - - if CONF_PORT in device: - port = device.get(CONF_PORT) - else: - if use_ssl: - port = MTK_DEFAULT_API_SSL_PORT - else: - port = MTK_DEFAULT_API_PORT - - if login == MTK_LOGIN_PLAIN: - login_method = (login_plain,) - elif login == MTK_LOGIN_TOKEN: - login_method = (login_token,) - else: - login_method = (login_plain, login_token) - - try: - api = MikrotikClient( - host, use_ssl, port, user, password, login_method, encoding +async def async_setup(hass, config): + """Import the Transmission Component from config.""" + + if DOMAIN in config: + for entry in config[DOMAIN]: + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data=entry + ) ) - api.connect_to_device() - hass.data[DOMAIN][HOSTS][host] = {"config": device, "api": api} - except ( - librouteros.exceptions.TrapError, - librouteros.exceptions.MultiTrapError, - librouteros.exceptions.ConnectionError, - ) as api_error: - _LOGGER.error("Mikrotik %s error %s", host, api_error) - continue - - if track_devices: - hass.data[DOMAIN][HOSTS][host][DEVICE_TRACKER] = True - load_platform(hass, DEVICE_TRACKER, DOMAIN, None, config) - - if not hass.data[DOMAIN][HOSTS]: + + return True + + +async def async_setup_entry(hass, config_entry): + """Set up the Mikrotik component.""" + + hub = MikrotikHub(hass, config_entry) + hass.data.setdefault(DOMAIN, {})[config_entry.data[CONF_HOST]] = hub + + if not await hub.async_setup(): return False + + device_registry = await hass.helpers.device_registry.async_get_registry() + device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(DOMAIN, hub.serial_num)}, + manufacturer=ATTR_MANUFACTURER, + model=hub.model, + name=hub.hostname, + sw_version=hub.firmware, + ) + return True -class MikrotikClient: - """Handle all communication with the Mikrotik API.""" - - def __init__(self, host, use_ssl, port, user, password, login_method, encoding): - """Initialize the Mikrotik Client.""" - self._host = host - self._use_ssl = use_ssl - self._port = port - self._user = user - self._password = password - self._login_method = login_method - self._encoding = encoding - self._ssl_wrapper = None - self.hostname = None - self._client = None - self._connected = False - - def connect_to_device(self): - """Connect to Mikrotik device.""" - self._connected = False - _LOGGER.debug("[%s] Connecting to Mikrotik device", self._host) - - kwargs = { - "encoding": self._encoding, - "login_methods": self._login_method, - "port": self._port, - } +async def async_unload_entry(hass, config_entry): + """Unload a config entry.""" + await hass.config_entries.async_forward_entry_unload(config_entry, "device_tracker") - if self._use_ssl: - if self._ssl_wrapper is None: - ssl_context = ssl.create_default_context() - ssl_context.check_hostname = False - ssl_context.verify_mode = ssl.CERT_NONE - self._ssl_wrapper = ssl_context.wrap_socket - kwargs["ssl_wrapper"] = self._ssl_wrapper - - try: - self._client = librouteros.connect( - self._host, self._user, self._password, **kwargs - ) - self._connected = True - except ( - librouteros.exceptions.TrapError, - librouteros.exceptions.MultiTrapError, - librouteros.exceptions.ConnectionError, - ) as api_error: - _LOGGER.error("Mikrotik %s: %s", self._host, api_error) - self._client = None - return False - - self.hostname = self.get_hostname() - _LOGGER.info("Mikrotik Connected to %s (%s)", self.hostname, self._host) - return self._connected - - def get_hostname(self): - """Return device host name.""" - data = self.command(MIKROTIK_SERVICES[IDENTITY]) - return data[0][NAME] if data else None - - def connected(self): - """Return connected boolean.""" - return self._connected - - def command(self, cmd, params=None): - """Retrieve data from Mikrotik API.""" - if not self._connected or not self._client: - if not self.connect_to_device(): - return None - try: - if params: - response = self._client(cmd=cmd, **params) - else: - response = self._client(cmd=cmd) - except (librouteros.exceptions.ConnectionError,) as api_error: - _LOGGER.error("Mikrotik %s connection error %s", self._host, api_error) - self.connect_to_device() - return None - except ( - librouteros.exceptions.TrapError, - librouteros.exceptions.MultiTrapError, - ) as api_error: - _LOGGER.error( - "Mikrotik %s failed to retrieve data. cmd=[%s] Error: %s", - self._host, - cmd, - api_error, - ) - return None - return response if response else None + hass.data[DOMAIN].pop(config_entry.data[CONF_HOST]) + + return True diff --git a/homeassistant/components/mikrotik/config_flow.py b/homeassistant/components/mikrotik/config_flow.py new file mode 100644 index 00000000000000..680640b9d6ed81 --- /dev/null +++ b/homeassistant/components/mikrotik/config_flow.py @@ -0,0 +1,144 @@ +"""Config flow for Mikrotik.""" +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import ( + CONF_HOST, + CONF_NAME, + CONF_PASSWORD, + CONF_PORT, + CONF_USERNAME, + CONF_VERIFY_SSL, +) +from homeassistant.core import callback + +from .const import ( + CONF_ARP_PING, + CONF_FORCE_DHCP, + CONF_DETECTION_TIME, + CONF_TRACK_DEVICES, + DEFAULT_API_PORT, + DEFAULT_NAME, + DEFAULT_DETECTION_TIME, + DOMAIN, +) +from .errors import CannotConnect, LoginError +from .hub import get_hub + + +class MikrotikFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a Mikrotik 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 MikrotikOptionsFlowHandler(config_entry) + + def __init__(self): + """Initialize the UniFi flow.""" + self.config = {} + + 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") + + errors = self.validate_user_input(user_input) + if not errors: + return self.async_create_entry( + title=self.config[CONF_NAME], data=self.config + ) + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Required(CONF_NAME, default=DEFAULT_NAME): str, + vol.Required(CONF_HOST): str, + vol.Required(CONF_USERNAME): str, + vol.Required(CONF_PASSWORD): str, + vol.Optional(CONF_PORT, default=DEFAULT_API_PORT): int, + vol.Optional(CONF_VERIFY_SSL, default=False): bool, + vol.Optional(CONF_TRACK_DEVICES, default=False): bool, + } + ), + errors=errors, + ) + + def validate_user_input(self, user_input): + """Validate user input.""" + errors = {} + if CONF_TRACK_DEVICES in user_input: + self.config["options"] = {} + self.config["options"][CONF_TRACK_DEVICES] = user_input.pop( + CONF_TRACK_DEVICES + ) + try: + get_hub(self.hass, user_input) + self.config.update(user_input) + except CannotConnect: + errors["base"] = "cannot_connect" + except LoginError: + errors[CONF_USERNAME] = "wrong_credentials" + errors[CONF_PASSWORD] = "wrong_credentials" + + return errors + + async def async_step_import(self, import_config): + """Import Miktortik from config.""" + + self.config["options"] = {} + self.config["options"][CONF_ARP_PING] = import_config.pop(CONF_ARP_PING) + self.config["options"][CONF_FORCE_DHCP] = import_config.pop(CONF_FORCE_DHCP) + self.config["options"][CONF_DETECTION_TIME] = import_config.pop( + CONF_DETECTION_TIME + ) + self.config["options"][CONF_TRACK_DEVICES] = import_config.pop( + CONF_TRACK_DEVICES + ) + + return await self.async_step_user(user_input=import_config) + + +class MikrotikOptionsFlowHandler(config_entries.OptionsFlow): + """Handle Mikrotik options.""" + + def __init__(self, config_entry): + """Initialize UniFi options flow.""" + self.config_entry = config_entry + + async def async_step_init(self, user_input=None): + """Manage the Mikrotik options.""" + return await self.async_step_device_tracker() + + async def async_step_device_tracker(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.Optional( + CONF_FORCE_DHCP, + default=self.config_entry.options.get(CONF_FORCE_DHCP, False), + ): bool, + vol.Optional( + CONF_ARP_PING, + default=self.config_entry.options.get(CONF_ARP_PING, False), + ): bool, + vol.Optional( + CONF_DETECTION_TIME, + default=self.config_entry.options.get( + CONF_DETECTION_TIME, DEFAULT_DETECTION_TIME + ), + ): int, + } + + return self.async_show_form( + step_id="device_tracker", data_schema=vol.Schema(options) + ) diff --git a/homeassistant/components/mikrotik/const.py b/homeassistant/components/mikrotik/const.py index bd26b02fe1b924..2c2df7740104ce 100644 --- a/homeassistant/components/mikrotik/const.py +++ b/homeassistant/components/mikrotik/const.py @@ -1,32 +1,41 @@ """Constants used in the Mikrotik components.""" DOMAIN = "mikrotik" -MIKROTIK = DOMAIN -HOSTS = "hosts" -MTK_LOGIN_PLAIN = "plain" -MTK_LOGIN_TOKEN = "token" +# MK_CONFIG = "mikrotik_config" +DEFAULT_NAME = "Mikrotik" +DEFAULT_API_PORT = 8728 +# DEFAULT_API_SSL_PORT = 8729 +DEFAULT_DETECTION_TIME = 300 + +ATTR_MANUFACTURER = "Mikrotik" +ATTR_SERIAL_NUMBER = "serial-number" +ATTR_FIRMWARE = "current-firmware" +ATTR_MODEL = "model" CONF_ARP_PING = "arp_ping" +CONF_FORCE_DHCP = "force_dhcp" CONF_TRACK_DEVICES = "track_devices" -CONF_LOGIN_METHOD = "login_method" -CONF_ENCODING = "encoding" -DEFAULT_ENCODING = "utf-8" +CONF_DETECTION_TIME = "detection_time" + NAME = "name" INFO = "info" IDENTITY = "identity" ARP = "arp" + +CAPSMAN = "capsman" DHCP = "dhcp" WIRELESS = "wireless" -CAPSMAN = "capsman" +IS_WIRELESS = "is_wireless" MIKROTIK_SERVICES = { - INFO: "/system/routerboard/getall", - IDENTITY: "/system/identity/getall", ARP: "/ip/arp/getall", + CAPSMAN: "/caps-man/registration-table/getall", DHCP: "/ip/dhcp-server/lease/getall", + IDENTITY: "/system/identity/getall", + INFO: "/system/routerboard/getall", WIRELESS: "/interface/wireless/registration-table/getall", - CAPSMAN: "/caps-man/registration-table/getall", + IS_WIRELESS: "/interface/wireless/print", } ATTR_DEVICE_TRACKER = [ @@ -34,16 +43,8 @@ "mac-address", "ssid", "interface", - "host-name", - "last-seen", - "rx-signal", "signal-strength", - "tx-ccq", "signal-to-noise", - "wmm-enabled", - "authentication-type", - "encryption", - "tx-rate-set", "rx-rate", "tx-rate", "uptime", diff --git a/homeassistant/components/mikrotik/device_tracker.py b/homeassistant/components/mikrotik/device_tracker.py index 92fcfac4ae4d90..3ac105c32b1a42 100644 --- a/homeassistant/components/mikrotik/device_tracker.py +++ b/homeassistant/components/mikrotik/device_tracker.py @@ -1,191 +1,136 @@ """Support for Mikrotik routers as device tracker.""" import logging -from homeassistant.components.device_tracker import ( +from homeassistant.components.device_tracker.config_entry import ScannerEntity +from homeassistant.components.device_tracker.const import ( DOMAIN as DEVICE_TRACKER, - DeviceScanner, -) -from homeassistant.const import CONF_METHOD -from homeassistant.util import slugify - -from .const import ( - ARP, - ATTR_DEVICE_TRACKER, - CAPSMAN, - CONF_ARP_PING, - DHCP, - HOSTS, - MIKROTIK, - MIKROTIK_SERVICES, - WIRELESS, + SOURCE_TYPE_ROUTER, ) +from homeassistant.const import CONF_HOST +from homeassistant.core import callback +from homeassistant.helpers import entity_registry +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 DOMAIN _LOGGER = logging.getLogger(__name__) -def get_scanner(hass, config): - """Validate the configuration and return MikrotikScanner.""" - for host in hass.data[MIKROTIK][HOSTS]: - if DEVICE_TRACKER not in hass.data[MIKROTIK][HOSTS][host]: - continue - hass.data[MIKROTIK][HOSTS][host].pop(DEVICE_TRACKER, None) - api = hass.data[MIKROTIK][HOSTS][host]["api"] - config = hass.data[MIKROTIK][HOSTS][host]["config"] - hostname = api.get_hostname() - scanner = MikrotikScanner(api, host, hostname, config) - return scanner if scanner.success_init else None - - -class MikrotikScanner(DeviceScanner): - """This class queries a Mikrotik device.""" - - def __init__(self, api, host, hostname, config): - """Initialize the scanner.""" - self.api = api - self.config = config - self.host = host - self.hostname = hostname - self.method = config.get(CONF_METHOD) - self.arp_ping = config.get(CONF_ARP_PING) - self.dhcp = None - self.devices_arp = {} - self.devices_dhcp = {} - self.device_tracker = None - self.success_init = self.api.connected() - - def get_extra_attributes(self, device): - """ - Get extra attributes of a device. - - Some known extra attributes that may be returned in the device tuple - include MAC address (mac), network device (dev), IP address - (ip), reachable status (reachable), associated router - (host), hostname if known (hostname) among others. - """ - return self.device_tracker.get(device) or {} - - def get_device_name(self, device): - """Get name for a device.""" - host = self.device_tracker.get(device, {}) - return host.get("host_name") - - def scan_devices(self): - """Scan for new devices and return a list with found device MACs.""" - self.update_device_tracker() - return list(self.device_tracker) - - def get_method(self): - """Determine the device tracker polling method.""" - if self.method: - _LOGGER.debug( - "Mikrotik %s: Manually selected polling method %s", - self.host, - self.method, - ) - return self.method - - capsman = self.api.command(MIKROTIK_SERVICES[CAPSMAN]) - if not capsman: - _LOGGER.debug( - "Mikrotik %s: Not a CAPsMAN controller. " - "Trying local wireless interfaces", - (self.host), - ) - else: - return CAPSMAN - - wireless = self.api.command(MIKROTIK_SERVICES[WIRELESS]) - if not wireless: - _LOGGER.info( - "Mikrotik %s: Wireless adapters not found. Try to " - "use DHCP lease table as presence tracker source. " - "Please decrease lease time as much as possible", - self.host, - ) - return DHCP - - return WIRELESS - - def update_device_tracker(self): - """Update device_tracker from Mikrotik API.""" - self.device_tracker = {} - if not self.method: - self.method = self.get_method() - - data = self.api.command(MIKROTIK_SERVICES[self.method]) - if data is None: - return - - if self.method != DHCP: - dhcp = self.api.command(MIKROTIK_SERVICES[DHCP]) - if dhcp is not None: - self.devices_dhcp = load_mac(dhcp) - - arp = self.api.command(MIKROTIK_SERVICES[ARP]) - self.devices_arp = load_mac(arp) - - for device in data: - mac = device.get("mac-address") - if self.method == DHCP: - if "active-address" not in device: - continue - - if self.arp_ping and self.devices_arp: - if mac not in self.devices_arp: - continue - ip_address = self.devices_arp[mac]["address"] - interface = self.devices_arp[mac]["interface"] - if not self.do_arp_ping(ip_address, interface): - continue - - attrs = {} - if mac in self.devices_dhcp and "host-name" in self.devices_dhcp[mac]: - hostname = self.devices_dhcp[mac].get("host-name") - if hostname: - attrs["host_name"] = hostname - - if self.devices_arp and mac in self.devices_arp: - attrs["ip_address"] = self.devices_arp[mac].get("address") - - for attr in ATTR_DEVICE_TRACKER: - if attr in device and device[attr] is not None: - attrs[slugify(attr)] = device[attr] - attrs["scanner_type"] = self.method - attrs["scanner_host"] = self.host - attrs["scanner_hostname"] = self.hostname - self.device_tracker[mac] = attrs - - def do_arp_ping(self, ip_address, interface): - """Attempt to arp ping MAC address via interface.""" - params = { - "arp-ping": "yes", - "interval": "100ms", - "count": 3, - "interface": interface, - "address": ip_address, - } - cmd = "/ping" - data = self.api.command(cmd, params) - if data is not None: - status = 0 - for result in data: - if "status" in result: - _LOGGER.debug( - "Mikrotik %s arp_ping error: %s", self.host, result["status"] - ) - status += 1 - if status == len(data): - return None - return data - - -def load_mac(devices=None): - """Load dictionary using MAC address as key.""" - if not devices: +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up device tracker for Mikrotik component.""" + hub = hass.data[DOMAIN][config_entry.data[CONF_HOST]] + + tracked = {} + + registry = await entity_registry.async_get_registry(hass) + + # Restore clients that is not a part of active clients list. + for entity in registry.entities.values(): + + if ( + entity.config_entry_id == config_entry.entry_id + and entity.domain == DEVICE_TRACKER + ): + + if ( + entity.unique_id in hub.api.devices + or entity.unique_id not in hub.api.all_devices + ): + continue + hub.api.restore_device(entity.unique_id) + + @callback + def update_hub(): + """Update the status of the device.""" + update_items(hub, async_add_entities, tracked) + + async_dispatcher_connect(hass, hub.signal_update, update_hub) + + update_hub() + + +@callback +def update_items(hub, async_add_entities, tracked): + """Update tracked device state from the controller.""" + new_tracked = [] + for device in hub.api.devices: + if device not in tracked: + tracked[device] = MikrotikHubTracker(hub.api.devices[device], hub) + new_tracked.append(tracked[device]) + + if new_tracked: + async_add_entities(new_tracked) + + +class MikrotikHubTracker(ScannerEntity): + """Representation of network device.""" + + def __init__(self, device, hub): + """Initialize the tracked device.""" + self.device = device + self.hub = hub + + @property + def is_connected(self): + """Return true if the client is connected to the network.""" + if ( + self.device.last_seen + and (dt_util.utcnow() - self.device.last_seen) + < self.hub.option_detection_time + ): + return True + return False + + @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 client.""" + return self.device.name + + @property + def unique_id(self) -> str: + """Return a unique identifier for this device.""" + return self.device.mac + + @property + def available(self) -> bool: + """Return if controller is available.""" + return self.hub.available + + @property + def device_state_attributes(self): + """Return the device state attributes.""" + if self.is_connected: + return self.device.attrs return None - mac_devices = {} - for device in devices: - if "mac-address" in device: - mac = device.pop("mac-address") - mac_devices[mac] = device - return mac_devices + + @property + def device_info(self): + """Return a client description for device registry.""" + info = { + "connections": {(CONNECTION_NETWORK_MAC, self.device.mac)}, + "identifiers": {(DOMAIN, self.unique_id)}, + "name": self.name, + "via_device": (DOMAIN, self.hub.serial_num), + } + 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) + async_dispatcher_connect( + self.hass, self.hub.signal_update, self.async_write_ha_state + ) + + async def async_update(self): + """Synchronize state with hub.""" + _LOGGER.debug( + "Updating Mikrotik tracked client %s (%s)", self.entity_id, self.unique_id + ) + await self.hub.request_update() diff --git a/homeassistant/components/mikrotik/errors.py b/homeassistant/components/mikrotik/errors.py new file mode 100644 index 00000000000000..22cd63d74689ae --- /dev/null +++ b/homeassistant/components/mikrotik/errors.py @@ -0,0 +1,10 @@ +"""Errors for the Mikrotik component.""" +from homeassistant.exceptions import HomeAssistantError + + +class CannotConnect(HomeAssistantError): + """Unable to connect to the hub.""" + + +class LoginError(HomeAssistantError): + """Component got logged out.""" diff --git a/homeassistant/components/mikrotik/hub.py b/homeassistant/components/mikrotik/hub.py new file mode 100644 index 00000000000000..32a886dd4cebb9 --- /dev/null +++ b/homeassistant/components/mikrotik/hub.py @@ -0,0 +1,403 @@ +"""The mikrotik router class.""" +from datetime import timedelta +import logging +import ssl + +import librouteros +from librouteros.login import login_plain, login_token + +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME, CONF_VERIFY_SSL +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.util import slugify +import homeassistant.util.dt as dt_util + +from .const import ( + ARP, + ATTR_DEVICE_TRACKER, + ATTR_FIRMWARE, + ATTR_MODEL, + ATTR_SERIAL_NUMBER, + CAPSMAN, + CONF_ARP_PING, + CONF_DETECTION_TIME, + CONF_FORCE_DHCP, + CONF_TRACK_DEVICES, + DEFAULT_DETECTION_TIME, + DHCP, + IDENTITY, + INFO, + IS_WIRELESS, + MIKROTIK_SERVICES, + NAME, + WIRELESS, +) +from .errors import CannotConnect, LoginError + +_LOGGER = logging.getLogger(__name__) + + +class Device: + """Represents a network device.""" + + def __init__(self, mac, params): + """Initialize the network device.""" + self._mac = mac + self._params = params + self._last_seen = None + self._attrs = {} + self._wireless_params = None + + @property + def name(self): + """Return device name.""" + return self._params.get("host-name", self.mac) + + @property + def mac(self): + """Return device mac.""" + return self._mac + + @property + def last_seen(self): + """Return device last seen.""" + return self._last_seen + + @property + def attrs(self): + """Return device attributes.""" + attr_data = self._wireless_params if self._wireless_params else self._params + for attr in ATTR_DEVICE_TRACKER: + if attr in attr_data: + self._attrs[slugify(attr)] = attr_data[attr] + self._attrs["ip_address"] = self._params.get("active-address") + return self._attrs + + def update(self, wireless_params=None, params=None, active=False): + """Update Device params.""" + if wireless_params: + self._wireless_params = wireless_params + if params: + self._params = params + if active: + self._last_seen = dt_util.utcnow() + + +class MikrotikData: + """Handle all communication with the Mikrotik API.""" + + def __init__(self, hass, config_entry, api): + """Initialize the Mikrotik Client.""" + self.hass = hass + self.config_entry = config_entry + self.api = api + self._host = self.config_entry.data[CONF_HOST] + self.all_devices = {} + self.devices = {} + self.available = True + self.support_wireless = bool(self.command(MIKROTIK_SERVICES[IS_WIRELESS])) + + @staticmethod + def load_mac(devices=None): + """Load dictionary using MAC address as key.""" + if not devices: + return None + mac_devices = {} + for device in devices: + if "mac-address" in device: + mac = device["mac-address"] + mac_devices[mac] = device + return mac_devices + + @property + def arp_enabled(self): + """Return arp_ping option setting.""" + return self.config_entry.options[CONF_ARP_PING] + + @property + def force_dhcp(self): + """Return force_dhcp option setting.""" + return self.config_entry.options[CONF_FORCE_DHCP] + + def get_info(self, param): + """Return device model name.""" + cmd = IDENTITY if param == NAME else INFO + data = self.command(MIKROTIK_SERVICES[cmd]) + return data[0].get(param) if data else None + + def connect_to_hub(self): + """Connect to hub.""" + try: + self.api = get_hub(self.hass, self.config_entry.data) + self.available = True + return True + except (LoginError, CannotConnect): + self.available = False + return False + + def get_list_from_interface(self, interface): + """Get devices from interface.""" + result = self.command(MIKROTIK_SERVICES[interface]) + return self.load_mac(result) if result else None + + def restore_device(self, mac): + """Restore a missing device after restart.""" + self.devices[mac] = Device(mac, self.all_devices[mac]) + + def update_devices(self): + """Get list of devices with latest status.""" + arp_devices = {} + wireless_devices = {} + device_list = {} + try: + self.all_devices = self.get_list_from_interface(DHCP) + if self.support_wireless: + _LOGGER.debug("wireless is supported") + for interface in [CAPSMAN, WIRELESS]: + wireless_devices = self.get_list_from_interface(interface) + if wireless_devices: + _LOGGER.debug("Scanning wireless devices using %s", interface) + break + + if self.support_wireless and not self.force_dhcp: + device_list = wireless_devices + else: + device_list = self.all_devices + _LOGGER.debug("Falling back to DHCP for scanning devices") + + if self.arp_enabled: + arp_devices = self.get_list_from_interface(ARP) + + except CannotConnect: + self.available = False + return + + if not device_list: + return + + for mac, params in device_list.items(): + if mac not in self.devices: + self.devices[mac] = Device(mac, self.all_devices.get(mac)) + else: + self.devices[mac].update(params=self.all_devices.get(mac)) + + if mac in wireless_devices: + # if wireless is supported then wireless_params are params + self.devices[mac].update( + wireless_params=wireless_devices[mac], active=True + ) + continue + # for wired devices or when forcing dhcp check for active-address + if not params.get("active-address"): + self.devices[mac].update(active=False) + continue + # ping check the rest of active devices if arp ping is enabled + active = True + if self.arp_enabled and mac in arp_devices: + active = self.do_arp_ping( + params.get("active-address"), arp_devices[mac].get("interface") + ) + self.devices[mac].update(active=active) + + def do_arp_ping(self, ip_address, interface): + """Attempt to arp ping MAC address via interface.""" + _LOGGER.debug("pinging - %s", ip_address) + params = { + "arp-ping": "yes", + "interval": "100ms", + "count": 3, + "interface": interface, + "address": ip_address, + } + cmd = "/ping" + data = self.command(cmd, params) + if data is not None: + status = 0 + for result in data: + if "status" in result: + status += 1 + if status == len(data): + _LOGGER.debug( + "Mikrotik %s - %s arp_ping timed out", ip_address, interface + ) + return False + return True + + def command(self, cmd, params=None): + """Retrieve data from Mikrotik API.""" + try: + if params: + response = self.api(cmd=cmd, **params) + else: + response = self.api(cmd=cmd) + except (librouteros.exceptions.ConnectionError,) as api_error: + _LOGGER.error("Mikrotik %s connection error %s", self._host, api_error) + raise CannotConnect + except ( + librouteros.exceptions.TrapError, + librouteros.exceptions.MultiTrapError, + ) as api_error: + _LOGGER.error( + "Mikrotik %s failed to retrieve data. cmd=[%s] Error: %s", + self._host, + cmd, + api_error, + ) + return None + + return response if response else None + + def update(self): + """Update device_tracker from Mikrotik API.""" + if not self.available or not self.api: + if not self.connect_to_hub(): + return + _LOGGER.debug("updating network devices for host: %s", self._host) + self.update_devices() + + +class MikrotikHub: + """Mikrotik Hub Object.""" + + def __init__(self, hass, config_entry): + """Initialize the Mikrotik Client.""" + self.hass = hass + self.config_entry = config_entry + self._mk_data = None + self.progress = None + + @property + def host(self): + """Return the host of this hub.""" + return self.config_entry.data[CONF_HOST] + + @property + def hostname(self): + """Return the hostname of the hub.""" + return self._mk_data.get_info(NAME) + + @property + def model(self): + """Return the model of the hub.""" + return self._mk_data.get_info(ATTR_MODEL) + + @property + def firmware(self): + """Return the firware of the hub.""" + return self._mk_data.get_info(ATTR_FIRMWARE) + + @property + def serial_num(self): + """Return the serial number of the hub.""" + return self._mk_data.get_info(ATTR_SERIAL_NUMBER) + + @property + def available(self): + """Return if the hub is connected.""" + return self._mk_data.available + + @property + def option_detection_time(self): + """Config entry option defining number of seconds from last seen to away.""" + return timedelta(seconds=self.config_entry.options[CONF_DETECTION_TIME]) + + @property + def signal_update(self): + """Event specific per Mikrotik entry to signal new options.""" + return f"mikrotik-update-{self.host}" + + @property + def api(self): + """Represent Mikrotik data object.""" + return self._mk_data + + async def add_options(self): + """Populate default options for Mikrotik.""" + if not self.config_entry.options: + hub_options = self.config_entry.data.pop("options", {}) + system_options = { + "disable_new_entities": not hub_options.get(CONF_TRACK_DEVICES, False) + } + if CONF_DETECTION_TIME in hub_options: + detection_time = hub_options[CONF_DETECTION_TIME].seconds + else: + detection_time = DEFAULT_DETECTION_TIME + options = { + CONF_ARP_PING: hub_options.get(CONF_ARP_PING, False), + CONF_FORCE_DHCP: hub_options.get(CONF_FORCE_DHCP, False), + CONF_DETECTION_TIME: detection_time, + } + + self.hass.config_entries.async_update_entry( + self.config_entry, options=options, system_options=system_options + ) + + async def request_update(self): + """Request an update.""" + if self.progress is not None: + return await self.progress + + self.progress = self.hass.async_create_task(self.async_update()) + await self.progress + + self.progress = None + + async def async_update(self): + """Update Mikrotik devices information.""" + await self.hass.async_add_executor_job(self._mk_data.update) + async_dispatcher_send(self.hass, self.signal_update) + + async def async_setup(self): + """Set up the Mikrotik hub.""" + try: + api = await self.hass.async_add_executor_job( + get_hub, self.hass, self.config_entry.data + ) + except CannotConnect: + raise ConfigEntryNotReady + except LoginError: + return False + + self._mk_data = MikrotikData(self.hass, self.config_entry, api) + await self.add_options() + await self.hass.async_add_executor_job(self._mk_data.update_devices) + self.hass.async_create_task( + self.hass.config_entries.async_forward_entry_setup( + self.config_entry, "device_tracker" + ) + ) + return True + + +def get_hub(hass, config_entry): + """Connect to Mikrotik hub.""" + _LOGGER.debug("Connecting to Mikrotik hub [%s]", config_entry[CONF_HOST]) + + _login_method = (login_plain, login_token) + kwargs = {"login_methods": _login_method, "port": config_entry["port"]} + + if config_entry[CONF_VERIFY_SSL]: + ssl_context = ssl.create_default_context() + ssl_context.check_hostname = False + ssl_context.verify_mode = ssl.CERT_NONE + _ssl_wrapper = ssl_context.wrap_socket + kwargs["ssl_wrapper"] = _ssl_wrapper + + try: + api = librouteros.connect( + config_entry[CONF_HOST], + config_entry[CONF_USERNAME], + config_entry[CONF_PASSWORD], + **kwargs, + ) + _LOGGER.debug("Connected to %s successfully", config_entry[CONF_HOST]) + return api + except ( + librouteros.exceptions.TrapError, + librouteros.exceptions.MultiTrapError, + librouteros.exceptions.ConnectionError, + ) as api_error: + _LOGGER.error("Mikrotik %s error: %s", config_entry[CONF_HOST], api_error) + if "invalid user name or password" in str(api_error): + raise LoginError + raise CannotConnect diff --git a/homeassistant/components/mikrotik/manifest.json b/homeassistant/components/mikrotik/manifest.json index 9a05f5a9f870e0..2cda7c45e6ebc2 100644 --- a/homeassistant/components/mikrotik/manifest.json +++ b/homeassistant/components/mikrotik/manifest.json @@ -1,10 +1,13 @@ { "domain": "mikrotik", "name": "Mikrotik", + "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/mikrotik", "requirements": [ - "librouteros==2.3.0" + "librouteros==2.3.1" ], "dependencies": [], - "codeowners": [] -} + "codeowners": [ + "@engrbm87" + ] +} \ No newline at end of file diff --git a/homeassistant/components/mikrotik/strings.json b/homeassistant/components/mikrotik/strings.json new file mode 100644 index 00000000000000..38352ef4f90121 --- /dev/null +++ b/homeassistant/components/mikrotik/strings.json @@ -0,0 +1,37 @@ +{ + "config": { + "title": "Mikrotik", + "step": { + "user": { + "title": "Set up Mikrotik Router", + "data": { + "name": "Name", + "host": "Host", + "username": "Username", + "password": "Password", + "port": "Port", + "verify_ssl": "Use ssl", + "track_devices": "Enable newly added entities" + } + } + }, + "error": { + "cannot_connect": "Connection Unsuccessful", + "wrong_credentials": "Wrong Credentials" + }, + "abort": { + "already_configured": "Mikrotik is already configured" + } + }, + "options": { + "step": { + "device_tracker": { + "data": { + "arp_ping": "Enable ARP ping", + "force_dhcp": "Force scanning using DHCP", + "detection_time": "Consider home interval" + } + } + } + } +} \ No newline at end of file diff --git a/tests/components/mikrotik/__init__.py b/tests/components/mikrotik/__init__.py new file mode 100644 index 00000000000000..dec29e26a5b795 --- /dev/null +++ b/tests/components/mikrotik/__init__.py @@ -0,0 +1,102 @@ +"""Tests for the Mikrotik component.""" +from homeassistant.components import mikrotik + +MOCK_DATA = { + mikrotik.CONF_NAME: "Mikrotik", + mikrotik.CONF_HOST: "0.0.0.0", + mikrotik.CONF_USERNAME: "user", + mikrotik.CONF_PASSWORD: "pass", + mikrotik.CONF_PORT: 8278, + mikrotik.CONF_VERIFY_SSL: False, + "options": {mikrotik.CONF_TRACK_DEVICES: True}, +} + + +DEVICE_1_DHCP = { + ".id": "*1A", + "address": "0.0.0.1", + "mac-address": "00:00:00:00:00:01", + "active-address": "0.0.0.1", + "host-name": "Device_1", + "comment": "Mobile", +} +DEVICE_2_DHCP = { + ".id": "*1B", + "address": "0.0.0.2", + "mac-address": "00:00:00:00:00:02", + "active-address": "0.0.0.2", + "host-name": "Device_2", + "comment": "PC", +} +DEVICE_1_WIRELESS = { + ".id": "*264", + "interface": "wlan1", + "mac-address": "00:00:00:00:00:01", + "ap": False, + "wds": False, + "bridge": False, + "rx-rate": "72.2Mbps-20MHz/1S/SGI", + "tx-rate": "72.2Mbps-20MHz/1S/SGI", + "packets": "59542,17464", + "bytes": "17536671,2966351", + "frames": "59542,17472", + "frame-bytes": "17655785,2862445", + "hw-frames": "78935,38395", + "hw-frame-bytes": "25636019,4063445", + "tx-frames-timed-out": 0, + "uptime": "5h49m36s", + "last-activity": "170ms", + "signal-strength": "-62@1Mbps", + "signal-to-noise": 52, + "signal-strength-ch0": -63, + "signal-strength-ch1": -69, + "strength-at-rates": "-62@1Mbps 16s330ms,-64@6Mbps 13s560ms,-65@HT20-3 52m6s30ms,-66@HT20-4 52m4s350ms,-66@HT20-5 51m58s580ms,-65@HT20-6 51m24s780ms,-65@HT20-7 5s680ms", + "tx-ccq": 93, + "p-throughput": 54928, + "last-ip": "0.0.0.1", + "802.1x-port-enabled": True, + "authentication-type": "wpa2-psk", + "encryption": "aes-ccm", + "group-encryption": "aes-ccm", + "management-protection": False, + "wmm-enabled": True, + "tx-rate-set": "OFDM:6-54 BW:1x SGI:1x HT:0-7", +} + +DEVICE_2_WIRELESS = { + ".id": "*265", + "interface": "wlan1", + "mac-address": "00:00:00:00:00:02", + "ap": False, + "wds": False, + "bridge": False, + "rx-rate": "72.2Mbps-20MHz/1S/SGI", + "tx-rate": "72.2Mbps-20MHz/1S/SGI", + "packets": "59542,17464", + "bytes": "17536671,2966351", + "frames": "59542,17472", + "frame-bytes": "17655785,2862445", + "hw-frames": "78935,38395", + "hw-frame-bytes": "25636019,4063445", + "tx-frames-timed-out": 0, + "uptime": "5h49m36s", + "last-activity": "170ms", + "signal-strength": "-62@1Mbps", + "signal-to-noise": 52, + "signal-strength-ch0": -63, + "signal-strength-ch1": -69, + "strength-at-rates": "-62@1Mbps 16s330ms,-64@6Mbps 13s560ms,-65@HT20-3 52m6s30ms,-66@HT20-4 52m4s350ms,-66@HT20-5 51m58s580ms,-65@HT20-6 51m24s780ms,-65@HT20-7 5s680ms", + "tx-ccq": 93, + "p-throughput": 54928, + "last-ip": "0.0.0.2", + "802.1x-port-enabled": True, + "authentication-type": "wpa2-psk", + "encryption": "aes-ccm", + "group-encryption": "aes-ccm", + "management-protection": False, + "wmm-enabled": True, + "tx-rate-set": "OFDM:6-54 BW:1x SGI:1x HT:0-7", +} +DHCP_DATA = [DEVICE_1_DHCP, DEVICE_2_DHCP] + +WIRELESS_DATA = [DEVICE_1_WIRELESS] diff --git a/tests/components/mikrotik/test_config_flow.py b/tests/components/mikrotik/test_config_flow.py new file mode 100644 index 00000000000000..22decb1e62688b --- /dev/null +++ b/tests/components/mikrotik/test_config_flow.py @@ -0,0 +1,177 @@ +"""Test Mikrotik setup process.""" +from unittest.mock import patch + +import librouteros +import pytest + +from homeassistant import data_entry_flow +from homeassistant.components import mikrotik +from homeassistant.components.mikrotik import config_flow +from homeassistant.const import ( + CONF_HOST, + CONF_NAME, + CONF_PASSWORD, + CONF_PORT, + CONF_USERNAME, + CONF_VERIFY_SSL, +) +from tests.common import MockConfigEntry + +DEMO_USER_INPUT = { + CONF_NAME: "Home router", + CONF_HOST: "0.0.0.0", + CONF_USERNAME: "username", + CONF_PASSWORD: "password", + CONF_PORT: 8278, + CONF_VERIFY_SSL: False, + mikrotik.CONF_TRACK_DEVICES: True, +} + +DEMO_CONFIG = { + CONF_NAME: "Home router", + CONF_HOST: "0.0.0.0", + CONF_USERNAME: "username", + CONF_PASSWORD: "password", + CONF_PORT: 8278, + CONF_VERIFY_SSL: False, + mikrotik.const.CONF_FORCE_DHCP: False, + mikrotik.CONF_TRACK_DEVICES: True, + mikrotik.CONF_ARP_PING: False, + mikrotik.CONF_DETECTION_TIME: 30, +} + + +MOCK_ENTRY = MockConfigEntry(domain=mikrotik.DOMAIN, data=DEMO_CONFIG) + + +@pytest.fixture(name="api") +def mock_mikrotik_api(): + """Mock an api.""" + with patch("librouteros.connect"): + yield + + +@pytest.fixture(name="auth_error") +def mock_api_authentication_error(): + """Mock an api.""" + with patch( + "librouteros.connect", + side_effect=librouteros.exceptions.TrapError("invalid user name or password"), + ): + yield + + +@pytest.fixture(name="conn_error") +def mock_api_connection_error(): + """Mock an api.""" + with patch("transmissionrpc.Client", side_effect=librouteros.exceptions.TrapError): + yield + + +def init_config_flow(hass): + """Init a configuration flow.""" + flow = config_flow.MikrotikFlowHandler() + flow.hass = hass + return flow + + +async def test_import(hass, api): + """Test import step.""" + flow = init_config_flow(hass) + + result = await flow.async_step_import(DEMO_CONFIG) + + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["title"] == "Home router" + assert result["data"][CONF_NAME] == "Home router" + assert result["data"][CONF_HOST] == "0.0.0.0" + assert result["data"][CONF_USERNAME] == "username" + assert result["data"][CONF_PASSWORD] == "password" + assert result["data"][CONF_PORT] == 8278 + assert result["data"][CONF_VERIFY_SSL] is False + assert result["data"]["options"][mikrotik.CONF_DETECTION_TIME] == 30 + assert result["data"]["options"][mikrotik.CONF_ARP_PING] is False + assert result["data"]["options"][mikrotik.const.CONF_FORCE_DHCP] is False + + +async def test_flow_works(hass, api): + """Test config flow.""" + flow = init_config_flow(hass) + + result = await flow.async_step_user() + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "user" + + result = await flow.async_step_user(DEMO_USER_INPUT) + + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["title"] == "Home router" + assert result["data"][CONF_NAME] == "Home router" + assert result["data"][CONF_HOST] == "0.0.0.0" + assert result["data"][CONF_USERNAME] == "username" + assert result["data"][CONF_PASSWORD] == "password" + assert result["data"][CONF_PORT] == 8278 + assert result["data"]["options"][mikrotik.CONF_TRACK_DEVICES] is True + + +async def test_options(hass): + """Test updating options.""" + entry = MOCK_ENTRY + flow = init_config_flow(hass) + options_flow = flow.async_get_options_flow(entry) + + result = await options_flow.async_step_init() + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "device_tracker" + + result = await options_flow.async_step_device_tracker( + { + mikrotik.CONF_DETECTION_TIME: 30, + mikrotik.CONF_ARP_PING: True, + mikrotik.const.CONF_FORCE_DHCP: False, + } + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["data"] == { + mikrotik.CONF_DETECTION_TIME: 30, + mikrotik.CONF_ARP_PING: True, + mikrotik.const.CONF_FORCE_DHCP: False, + } + + +async def test_host_already_configured(hass, auth_error): + """Test host already configured.""" + + entry = MOCK_ENTRY + entry.add_to_hass(hass) + flow = init_config_flow(hass) + + result = await flow.async_step_user(DEMO_USER_INPUT) + + assert result["type"] == "abort" + assert result["reason"] == "already_configured" + + +async def test_connection_error(hass, conn_error): + """Test error when connection is unsuccesful.""" + + flow = init_config_flow(hass) + + result = await flow.async_step_user(DEMO_USER_INPUT) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["errors"] == {"base": "cannot_connect"} + + +async def test_wrong_credentials(hass, auth_error): + """Test error when credentials are wrong.""" + + flow = init_config_flow(hass) + + result = await flow.async_step_user(DEMO_USER_INPUT) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["errors"] == { + CONF_USERNAME: "wrong_credentials", + CONF_PASSWORD: "wrong_credentials", + } diff --git a/tests/components/mikrotik/test_device_tracker.py b/tests/components/mikrotik/test_device_tracker.py new file mode 100644 index 00000000000000..a1ea90bf249c44 --- /dev/null +++ b/tests/components/mikrotik/test_device_tracker.py @@ -0,0 +1,125 @@ +"""The tests for the Mikrotik device tracker platform.""" +from datetime import timedelta + +from homeassistant import config_entries +from homeassistant.components import mikrotik +import homeassistant.components.device_tracker as device_tracker +from homeassistant.helpers import entity_registry +from homeassistant.setup import async_setup_component +import homeassistant.util.dt as dt_util + +from . import DEVICE_2_WIRELESS, DHCP_DATA, MOCK_DATA, WIRELESS_DATA +from .test_hub import setup_mikrotik_entry + +from tests.common import patch + +DEFAULT_DETECTION_TIME = timedelta(seconds=300) + + +def mock_command(self, cmd, params=None): + """Mock the Mikrotik command method.""" + if cmd == mikrotik.const.MIKROTIK_SERVICES[mikrotik.const.IS_WIRELESS]: + return True + if cmd == mikrotik.const.MIKROTIK_SERVICES[mikrotik.const.DHCP]: + return DHCP_DATA + if cmd == mikrotik.const.MIKROTIK_SERVICES[mikrotik.const.WIRELESS]: + return WIRELESS_DATA + + +async def test_platform_manually_configured(hass): + """Test that nothing happens when configuring mikrotik through device tracker platform.""" + assert ( + await async_setup_component( + hass, + device_tracker.DOMAIN, + {device_tracker.DOMAIN: {"platform": "mikrotik"}}, + ) + is False + ) + assert mikrotik.DOMAIN not in hass.data + + +async def test_device_trackers(hass): + """Test device_trackers created by mikrotik.""" + + # test devices are added from wireless list only + hub = await setup_mikrotik_entry(hass) + + device_1 = hass.states.get("device_tracker.device_1") + assert device_1 is not None + assert device_1.state == "home" + device_2 = hass.states.get("device_tracker.device_2") + assert device_2 is None + + with patch.object(mikrotik.hub.MikrotikData, "command", new=mock_command): + # test device_2 is added after connecting to wireless network + WIRELESS_DATA.append(DEVICE_2_WIRELESS) + + await hub.async_update() + await hass.async_block_till_done() + + device_2 = hass.states.get("device_tracker.device_2") + assert device_2 is not None + assert device_1.state == "home" + + # test state changes to away if last_seen > consider_home_interval + + del WIRELESS_DATA[1] + hub.api.devices["00:00:00:00:00:02"]._last_seen = dt_util.utcnow() - timedelta( + minutes=4 + ) + await hub.async_update() + await hass.async_block_till_done() + + device_2 = hass.states.get("device_tracker.device_2") + assert device_2.state != "not_home" + + hub.api.devices["00:00:00:00:00:02"]._last_seen = dt_util.utcnow() - timedelta( + minutes=5 + ) + await hub.async_update() + await hass.async_block_till_done() + + device_2 = hass.states.get("device_tracker.device_2") + assert device_2.state == "not_home" + + +async def test_restoring_devices(hass): + """Test restoring existing device_tracker entities if not detected on startup.""" + config_entry = config_entries.ConfigEntry( + version=1, + domain=mikrotik.DOMAIN, + title="Mikrotik", + data=MOCK_DATA, + source="test", + connection_class=config_entries.CONN_CLASS_LOCAL_POLL, + system_options={}, + options={}, + entry_id=1, + ) + + registry = await entity_registry.async_get_registry(hass) + registry.async_get_or_create( + device_tracker.DOMAIN, + mikrotik.DOMAIN, + "00:00:00:00:00:01", + suggested_object_id="device_1", + config_entry=config_entry, + ) + registry.async_get_or_create( + device_tracker.DOMAIN, + mikrotik.DOMAIN, + "00:00:00:00:00:02", + suggested_object_id="device_2", + config_entry=config_entry, + ) + + await setup_mikrotik_entry(hass) + + # test device_2 which is not in wireless list is restored + device_1 = hass.states.get("device_tracker.device_1") + assert device_1 is not None + assert device_1.state == "home" + device_2 = hass.states.get("device_tracker.device_2") + assert device_2 is not None + assert device_2.state == "not_home" diff --git a/tests/components/mikrotik/test_hub.py b/tests/components/mikrotik/test_hub.py new file mode 100644 index 00000000000000..cd90570b4c9343 --- /dev/null +++ b/tests/components/mikrotik/test_hub.py @@ -0,0 +1,153 @@ +"""Test Mikrotik hub.""" +from asynctest import patch +import librouteros +import pytest + +from homeassistant import config_entries +from homeassistant.components import mikrotik +from homeassistant.exceptions import ConfigEntryNotReady + +from . import DHCP_DATA, MOCK_DATA, WIRELESS_DATA + +CONFIG_ENTRY = config_entries.ConfigEntry( + version=1, + domain=mikrotik.DOMAIN, + title="Mikrotik", + data=MOCK_DATA, + source="test", + connection_class=config_entries.CONN_CLASS_LOCAL_POLL, + system_options={}, + options={}, + entry_id=1, +) + + +async def setup_mikrotik_entry(hass, **kwargs): + """Set up Mikrotik intergation successfully.""" + support_wireless = kwargs.get("support_wireless", True) + dhcp_data = kwargs.get("dhcp_data", DHCP_DATA) + wireless_data = kwargs.get("wireless_data", WIRELESS_DATA) + + def mock_command(self, cmd, params=None): + if cmd == mikrotik.const.MIKROTIK_SERVICES[mikrotik.const.IS_WIRELESS]: + return support_wireless + if cmd == mikrotik.const.MIKROTIK_SERVICES[mikrotik.const.DHCP]: + return dhcp_data + if cmd == mikrotik.const.MIKROTIK_SERVICES[mikrotik.const.WIRELESS]: + return wireless_data + + config_entry = CONFIG_ENTRY + if "force_dhcp" in kwargs: + config_entry.options["force_dhcp"] = True + + with patch("librouteros.connect"), patch.object( + mikrotik.hub.MikrotikData, "command", new=mock_command + ): + await mikrotik.async_setup_entry(hass, config_entry) + await hass.async_block_till_done() + return hass.data[mikrotik.DOMAIN][CONFIG_ENTRY.data[mikrotik.CONF_HOST]] + + +async def test_hub_setup_successful(hass): + """Successful setup of Mikrotik hub.""" + with patch( + "homeassistant.config_entries.ConfigEntries.async_forward_entry_setup", + return_value=True, + ) as forward_entry_setup: + hub = await setup_mikrotik_entry(hass) + + assert hub.config_entry.data == { + mikrotik.CONF_NAME: "Mikrotik", + mikrotik.CONF_HOST: "0.0.0.0", + mikrotik.CONF_USERNAME: "user", + mikrotik.CONF_PASSWORD: "pass", + mikrotik.CONF_PORT: 8278, + mikrotik.CONF_VERIFY_SSL: False, + } + assert hub.config_entry.options == { + mikrotik.hub.CONF_FORCE_DHCP: False, + mikrotik.CONF_ARP_PING: False, + mikrotik.CONF_DETECTION_TIME: 300, + } + assert hub.config_entry.system_options == config_entries.SystemOptions( + disable_new_entities=False + ) + assert hub.api.available is True + assert hub.signal_update == "mikrotik-update-0.0.0.0" + assert forward_entry_setup.mock_calls[0][1] == (hub.config_entry, "device_tracker") + + +async def test_hub_setup_failed(hass): + """Failed setup of Mikrotik hub.""" + + # error when connection fails + with patch( + "librouteros.connect", side_effect=librouteros.exceptions.ConnectionError + ): + with pytest.raises(ConfigEntryNotReady): + await mikrotik.async_setup_entry(hass, CONFIG_ENTRY) + + # error when username or password is invalid + with patch( + "homeassistant.config_entries.ConfigEntries.async_forward_entry_setup" + ) as forward_entry_setup, patch( + "librouteros.connect", + side_effect=librouteros.exceptions.TrapError("invalid user name or password"), + ): + result = await mikrotik.async_setup_entry(hass, CONFIG_ENTRY) + + assert result is False + assert len(forward_entry_setup.mock_calls) == 0 + + +async def test_update_failed(hass): + """Test failing to connect during update.""" + + hub = await setup_mikrotik_entry(hass) + + with patch.object( + mikrotik.hub.MikrotikData, "command", side_effect=mikrotik.errors.CannotConnect + ): + await hub.async_update() + + assert hub.api.available is False + + +async def test_hub_not_support_wireless(hass): + """Test updating hub devices when hub doesn't support wireless interfaces.""" + + # test that the devices are constructed from wireless data + + hub = await setup_mikrotik_entry(hass, support_wireless=False) + + assert hub.api.devices["00:00:00:00:00:01"]._params == DHCP_DATA[0] + assert hub.api.devices["00:00:00:00:00:01"]._wireless_params is None + assert hub.api.devices["00:00:00:00:00:02"]._params == DHCP_DATA[1] + assert hub.api.devices["00:00:00:00:00:02"]._wireless_params is None + + +async def test_hub_support_wireless(hass): + """Test updating hub devices when hub support wireless interfaces.""" + + # test that the device list is from wireless data list + + hub = await setup_mikrotik_entry(hass) + + assert hub.api.support_wireless is True + assert hub.api.devices["00:00:00:00:00:01"]._params == DHCP_DATA[0] + assert hub.api.devices["00:00:00:00:00:01"]._wireless_params == WIRELESS_DATA[0] + assert "00:00:00:00:00:02" not in hub.api.devices + + +async def test_force_dhcp(hass): + """Test updating hub devices with forced dhcp method.""" + + # test that the devices are constructed from dhcp data + + hub = await setup_mikrotik_entry(hass, force_dhcp=True) + + assert hub.api.support_wireless is True + assert hub.api.devices["00:00:00:00:00:01"]._params == DHCP_DATA[0] + assert hub.api.devices["00:00:00:00:00:01"]._wireless_params == WIRELESS_DATA[0] + assert hub.api.devices["00:00:00:00:00:02"]._params == DHCP_DATA[1] + assert hub.api.devices["00:00:00:00:00:02"]._wireless_params is None diff --git a/tests/components/mikrotik/test_init.py b/tests/components/mikrotik/test_init.py new file mode 100644 index 00000000000000..61a2ec9e7b7d47 --- /dev/null +++ b/tests/components/mikrotik/test_init.py @@ -0,0 +1,109 @@ +"""Test UniFi setup process.""" +from unittest.mock import Mock, patch + +from homeassistant.components import mikrotik +from homeassistant.setup import async_setup_component + +from tests.common import MockConfigEntry, mock_coro + +MOCK_ENTRY = MockConfigEntry( + domain=mikrotik.DOMAIN, + data={ + mikrotik.CONF_NAME: "Mikrotik", + mikrotik.CONF_HOST: "0.0.0.0", + mikrotik.CONF_USERNAME: "user", + mikrotik.CONF_PASSWORD: "pass", + mikrotik.CONF_PORT: 8278, + mikrotik.CONF_VERIFY_SSL: False, + }, +) + + +async def test_setup_with_no_config(hass): + """Test that we do not discover anything or try to set up a bridge.""" + assert await async_setup_component(hass, mikrotik.DOMAIN, {}) is True + assert mikrotik.DOMAIN not in hass.data + + +async def test_setup_with_config(hass): + """Test that we do not discover anything or try to set up a bridge.""" + config = { + mikrotik.DOMAIN: { + mikrotik.CONF_HOST: "0.0.0.0", + mikrotik.CONF_USERNAME: "user", + mikrotik.CONF_PASSWORD: "pass", + mikrotik.CONF_ARP_PING: True, + mikrotik.CONF_TRACK_DEVICES: True, + mikrotik.CONF_DETECTION_TIME: 30, + } + } + assert await async_setup_component(hass, mikrotik.DOMAIN, config) is True + + +async def test_successful_config_entry(hass): + """Test that configured options for a host are loaded via config entry.""" + entry = MOCK_ENTRY + entry.add_to_hass(hass) + mock_registry = Mock() + + with patch.object(mikrotik, "MikrotikHub") as mock_hub, patch( + "homeassistant.helpers.device_registry.async_get_registry", + return_value=mock_coro(mock_registry), + ): + mock_hub.return_value.async_setup.return_value = mock_coro(True) + mock_hub.return_value.serial_num = "12345678" + mock_hub.return_value.model = "RB750" + mock_hub.return_value.hostname = "mikrotik" + mock_hub.return_value.firmware = "3.65" + assert await mikrotik.async_setup_entry(hass, entry) is True + + assert len(mock_hub.mock_calls) == 2 + p_hass, p_entry = mock_hub.mock_calls[0][1] + + assert p_hass is hass + assert p_entry is entry + + assert len(mock_registry.mock_calls) == 1 + assert mock_registry.mock_calls[0][2] == { + "config_entry_id": entry.entry_id, + "connections": {("mikrotik", "12345678")}, + "manufacturer": mikrotik.ATTR_MANUFACTURER, + "model": "RB750", + "name": "mikrotik", + "sw_version": "3.65", + } + + +async def test_hub_fail_setup(hass): + """Test that a failed setup still stores controller.""" + entry = MOCK_ENTRY + entry.add_to_hass(hass) + + with patch.object(mikrotik, "MikrotikHub") as mock_hub: + mock_hub.return_value.async_setup.return_value = mock_coro(False) + assert await mikrotik.async_setup_entry(hass, entry) is False + + assert entry.data[mikrotik.CONF_HOST] in hass.data[mikrotik.DOMAIN] + + +async def test_unload_entry(hass): + """Test being able to unload an entry.""" + entry = MOCK_ENTRY + entry.add_to_hass(hass) + + with patch.object(mikrotik, "MikrotikHub") as mock_hub, patch( + "homeassistant.helpers.device_registry.async_get_registry", + return_value=mock_coro(Mock()), + ): + mock_hub.return_value.async_setup.return_value = mock_coro(True) + mock_hub.return_value.serial_num = "12345678" + mock_hub.return_value.model = "RB750" + mock_hub.return_value.hostname = "mikrotik" + mock_hub.return_value.firmware = "3.65" + assert await mikrotik.async_setup_entry(hass, entry) is True + + assert len(mock_hub.return_value.mock_calls) == 1 + + mock_hub.return_value.async_reset.return_value = mock_coro(True) + assert await mikrotik.async_unload_entry(hass, entry) + assert hass.data[mikrotik.DOMAIN] == {} From 8e1f5e7a186881f2319401d31e8bced57d73bf21 Mon Sep 17 00:00:00 2001 From: Rami Mosleh Date: Fri, 11 Oct 2019 19:59:47 +0300 Subject: [PATCH 06/21] update requirements and coverage --- .coveragerc | 1 - CODEOWNERS | 1 + homeassistant/generated/config_flows.py | 1 + requirements_all.txt | 2 +- requirements_test_all.txt | 3 +++ 5 files changed, 6 insertions(+), 2 deletions(-) diff --git a/.coveragerc b/.coveragerc index e96895429a6cb1..5daf864c0d5bdd 100644 --- a/.coveragerc +++ b/.coveragerc @@ -418,7 +418,6 @@ omit = homeassistant/components/metoffice/weather.py homeassistant/components/microsoft/tts.py homeassistant/components/miflora/sensor.py - homeassistant/components/mikrotik/* homeassistant/components/mill/climate.py homeassistant/components/mill/const.py homeassistant/components/minio/* diff --git a/CODEOWNERS b/CODEOWNERS index 04918e979ee624..ea2c294919daba 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -204,6 +204,7 @@ homeassistant/components/met/* @danielhiversen homeassistant/components/meteo_france/* @victorcerutti @oncleben31 homeassistant/components/meteoalarm/* @rolfberkenbosch homeassistant/components/miflora/* @danielhiversen @ChristianKuehnel +homeassistant/components/mikrotik/* @engrbm87 homeassistant/components/mill/* @danielhiversen homeassistant/components/min_max/* @fabaff homeassistant/components/minio/* @tkislan diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 135fab2b746b3e..71a0a1c3463ff5 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -52,6 +52,7 @@ "luftdaten", "mailgun", "met", + "mikrotik", "mobile_app", "mqtt", "neato", diff --git a/requirements_all.txt b/requirements_all.txt index a6cba26235b8be..aea25eb3093ba7 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -771,7 +771,7 @@ libpyfoscam==1.0 libpyvivotek==0.4.0 # homeassistant.components.mikrotik -librouteros==2.3.0 +librouteros==2.3.1 # homeassistant.components.soundtouch libsoundtouch==0.7.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 93eedd2ccffc69..326ee25279f010 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -276,6 +276,9 @@ keyrings.alt==3.4.0 # homeassistant.components.dyson libpurecool==0.6.0 +# homeassistant.components.mikrotik +librouteros==2.3.1 + # homeassistant.components.soundtouch libsoundtouch==0.7.2 From e67a7b01884c7d3d31b9261900f4580c59ab1c9c Mon Sep 17 00:00:00 2001 From: Rami Mosleh Date: Wed, 23 Oct 2019 11:10:49 +0300 Subject: [PATCH 07/21] fix description comments --- tests/components/mikrotik/test_init.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/components/mikrotik/test_init.py b/tests/components/mikrotik/test_init.py index 61a2ec9e7b7d47..4843dd600c28ea 100644 --- a/tests/components/mikrotik/test_init.py +++ b/tests/components/mikrotik/test_init.py @@ -1,4 +1,4 @@ -"""Test UniFi setup process.""" +"""Test Mikrotik setup process.""" from unittest.mock import Mock, patch from homeassistant.components import mikrotik @@ -20,13 +20,13 @@ async def test_setup_with_no_config(hass): - """Test that we do not discover anything or try to set up a bridge.""" + """Test that we do not discover anything or try to set up a hub.""" assert await async_setup_component(hass, mikrotik.DOMAIN, {}) is True assert mikrotik.DOMAIN not in hass.data async def test_setup_with_config(hass): - """Test that we do not discover anything or try to set up a bridge.""" + """Test that we do not discover anything or try to set up a hub.""" config = { mikrotik.DOMAIN: { mikrotik.CONF_HOST: "0.0.0.0", @@ -75,7 +75,7 @@ async def test_successful_config_entry(hass): async def test_hub_fail_setup(hass): - """Test that a failed setup still stores controller.""" + """Test that a failed setup still stores hub.""" entry = MOCK_ENTRY entry.add_to_hass(hass) From 231c611ab297be8ea24d352da9fb1b780e0357a2 Mon Sep 17 00:00:00 2001 From: Rami Date: Thu, 14 Nov 2019 13:54:10 +0200 Subject: [PATCH 08/21] update tests, fix disabled entity updates --- .../components/mikrotik/.translations/en.json | 1 + homeassistant/components/mikrotik/__init__.py | 6 +-- .../components/mikrotik/config_flow.py | 4 +- .../components/mikrotik/device_tracker.py | 16 +++++--- homeassistant/components/mikrotik/hub.py | 4 +- .../components/mikrotik/strings.json | 1 + tests/components/mikrotik/__init__.py | 27 +++++++++++++ tests/components/mikrotik/test_config_flow.py | 14 +++++++ tests/components/mikrotik/test_hub.py | 38 ++++++++++++++++--- tests/components/mikrotik/test_init.py | 22 ++--------- 10 files changed, 98 insertions(+), 35 deletions(-) diff --git a/homeassistant/components/mikrotik/.translations/en.json b/homeassistant/components/mikrotik/.translations/en.json index 38352ef4f90121..2d3569945ab41b 100644 --- a/homeassistant/components/mikrotik/.translations/en.json +++ b/homeassistant/components/mikrotik/.translations/en.json @@ -16,6 +16,7 @@ } }, "error": { + "name_exists": "Name exists", "cannot_connect": "Connection Unsuccessful", "wrong_credentials": "Wrong Credentials" }, diff --git a/homeassistant/components/mikrotik/__init__.py b/homeassistant/components/mikrotik/__init__.py index f1d82f3c43825d..84bb7128fae8cf 100644 --- a/homeassistant/components/mikrotik/__init__.py +++ b/homeassistant/components/mikrotik/__init__.py @@ -48,7 +48,7 @@ async def async_setup(hass, config): - """Import the Transmission Component from config.""" + """Import the Mikrotik component from config.""" if DOMAIN in config: for entry in config[DOMAIN]: @@ -65,7 +65,7 @@ async def async_setup_entry(hass, config_entry): """Set up the Mikrotik component.""" hub = MikrotikHub(hass, config_entry) - hass.data.setdefault(DOMAIN, {})[config_entry.data[CONF_HOST]] = hub + hass.data.setdefault(DOMAIN, {})[config_entry.entry_id] = hub if not await hub.async_setup(): return False @@ -87,6 +87,6 @@ async def async_unload_entry(hass, config_entry): """Unload a config entry.""" await hass.config_entries.async_forward_entry_unload(config_entry, "device_tracker") - hass.data[DOMAIN].pop(config_entry.data[CONF_HOST]) + hass.data[DOMAIN].pop(config_entry.entry_id) return True diff --git a/homeassistant/components/mikrotik/config_flow.py b/homeassistant/components/mikrotik/config_flow.py index 680640b9d6ed81..19bc4185acdba4 100644 --- a/homeassistant/components/mikrotik/config_flow.py +++ b/homeassistant/components/mikrotik/config_flow.py @@ -49,8 +49,10 @@ async def async_step_user(self, user_input=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") + if entry.data[CONF_NAME] == user_input[CONF_NAME]: + errors[CONF_NAME] = "name_exists" - errors = self.validate_user_input(user_input) + errors.update(self.validate_user_input(user_input)) if not errors: return self.async_create_entry( title=self.config[CONF_NAME], data=self.config diff --git a/homeassistant/components/mikrotik/device_tracker.py b/homeassistant/components/mikrotik/device_tracker.py index 3ac105c32b1a42..844c5852dfbe52 100644 --- a/homeassistant/components/mikrotik/device_tracker.py +++ b/homeassistant/components/mikrotik/device_tracker.py @@ -6,21 +6,20 @@ DOMAIN as DEVICE_TRACKER, SOURCE_TYPE_ROUTER, ) -from homeassistant.const import CONF_HOST from homeassistant.core import callback from homeassistant.helpers import entity_registry 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 DOMAIN +from .const import DOMAIN, ATTR_MANUFACTURER _LOGGER = logging.getLogger(__name__) async def async_setup_entry(hass, config_entry, async_add_entities): """Set up device tracker for Mikrotik component.""" - hub = hass.data[DOMAIN][config_entry.data[CONF_HOST]] + hub = hass.data[DOMAIN][config_entry.entry_id] tracked = {} @@ -53,7 +52,7 @@ def update_hub(): @callback def update_items(hub, async_add_entities, tracked): - """Update tracked device state from the controller.""" + """Update tracked device state from the hub.""" new_tracked = [] for device in hub.api.devices: if device not in tracked: @@ -71,6 +70,7 @@ def __init__(self, device, hub): """Initialize the tracked device.""" self.device = device self.hub = hub + self.unsub_dispatcher = None @property def is_connected(self): @@ -115,6 +115,7 @@ def device_info(self): """Return a client description for device registry.""" info = { "connections": {(CONNECTION_NETWORK_MAC, self.device.mac)}, + "manufacturer": ATTR_MANUFACTURER, "identifiers": {(DOMAIN, self.unique_id)}, "name": self.name, "via_device": (DOMAIN, self.hub.serial_num), @@ -124,7 +125,7 @@ def device_info(self): async def async_added_to_hass(self): """Client entity created.""" _LOGGER.debug("New network device tracker %s (%s)", self.name, self.unique_id) - async_dispatcher_connect( + self.unsub_dispatcher = async_dispatcher_connect( self.hass, self.hub.signal_update, self.async_write_ha_state ) @@ -134,3 +135,8 @@ async def async_update(self): "Updating Mikrotik tracked client %s (%s)", self.entity_id, self.unique_id ) await self.hub.request_update() + + async def will_remove_from_hass(self): + """Disconnect from dispatcher.""" + if self.unsub_dispatcher: + self.unsub_dispatcher() diff --git a/homeassistant/components/mikrotik/hub.py b/homeassistant/components/mikrotik/hub.py index 32a886dd4cebb9..f2c538e7c948c8 100644 --- a/homeassistant/components/mikrotik/hub.py +++ b/homeassistant/components/mikrotik/hub.py @@ -237,7 +237,7 @@ def command(self, cmd, params=None): librouteros.exceptions.TrapError, librouteros.exceptions.MultiTrapError, ) as api_error: - _LOGGER.error( + _LOGGER.warning( "Mikrotik %s failed to retrieve data. cmd=[%s] Error: %s", self._host, cmd, @@ -303,7 +303,7 @@ def option_detection_time(self): @property def signal_update(self): - """Event specific per Mikrotik entry to signal new options.""" + """Event specific per Mikrotik entry to signal updates.""" return f"mikrotik-update-{self.host}" @property diff --git a/homeassistant/components/mikrotik/strings.json b/homeassistant/components/mikrotik/strings.json index 38352ef4f90121..2d3569945ab41b 100644 --- a/homeassistant/components/mikrotik/strings.json +++ b/homeassistant/components/mikrotik/strings.json @@ -16,6 +16,7 @@ } }, "error": { + "name_exists": "Name exists", "cannot_connect": "Connection Unsuccessful", "wrong_credentials": "Wrong Credentials" }, diff --git a/tests/components/mikrotik/__init__.py b/tests/components/mikrotik/__init__.py index dec29e26a5b795..982fb73a79914f 100644 --- a/tests/components/mikrotik/__init__.py +++ b/tests/components/mikrotik/__init__.py @@ -100,3 +100,30 @@ DHCP_DATA = [DEVICE_1_DHCP, DEVICE_2_DHCP] WIRELESS_DATA = [DEVICE_1_WIRELESS] + +ARP_DATA = [ + { + ".id": "*1", + "address": "0.0.0.1", + "mac-address": "00:00:00:00:00:01", + "interface": "bridge", + "published": False, + "invalid": False, + "DHCP": True, + "dynamic": True, + "complete": True, + "disabled": False, + }, + { + ".id": "*2", + "address": "0.0.0.2", + "mac-address": "00:00:00:00:00:02", + "interface": "bridge", + "published": False, + "invalid": False, + "DHCP": True, + "dynamic": True, + "complete": True, + "disabled": False, + }, +] diff --git a/tests/components/mikrotik/test_config_flow.py b/tests/components/mikrotik/test_config_flow.py index 22decb1e62688b..9d7a2580ad72e9 100644 --- a/tests/components/mikrotik/test_config_flow.py +++ b/tests/components/mikrotik/test_config_flow.py @@ -152,6 +152,20 @@ async def test_host_already_configured(hass, auth_error): assert result["reason"] == "already_configured" +async def test_name_exists(hass, api): + """Test name already configured.""" + + entry = MOCK_ENTRY + entry.add_to_hass(hass) + flow = init_config_flow(hass) + user_input = DEMO_USER_INPUT.copy() + user_input[CONF_HOST] = "0.0.0.1" + result = await flow.async_step_user(user_input) + + assert result["type"] == "form" + assert result["errors"] == {CONF_NAME: "name_exists"} + + async def test_connection_error(hass, conn_error): """Test error when connection is unsuccesful.""" diff --git a/tests/components/mikrotik/test_hub.py b/tests/components/mikrotik/test_hub.py index cd90570b4c9343..adb31981445335 100644 --- a/tests/components/mikrotik/test_hub.py +++ b/tests/components/mikrotik/test_hub.py @@ -7,7 +7,7 @@ from homeassistant.components import mikrotik from homeassistant.exceptions import ConfigEntryNotReady -from . import DHCP_DATA, MOCK_DATA, WIRELESS_DATA +from . import DHCP_DATA, MOCK_DATA, WIRELESS_DATA, ARP_DATA CONFIG_ENTRY = config_entries.ConfigEntry( version=1, @@ -35,17 +35,22 @@ def mock_command(self, cmd, params=None): return dhcp_data if cmd == mikrotik.const.MIKROTIK_SERVICES[mikrotik.const.WIRELESS]: return wireless_data + if cmd == mikrotik.const.MIKROTIK_SERVICES[mikrotik.const.ARP]: + return ARP_DATA config_entry = CONFIG_ENTRY if "force_dhcp" in kwargs: config_entry.options["force_dhcp"] = True + if "arp_ping" in kwargs: + config_entry.options["arp_ping"] = True + with patch("librouteros.connect"), patch.object( mikrotik.hub.MikrotikData, "command", new=mock_command ): await mikrotik.async_setup_entry(hass, config_entry) await hass.async_block_till_done() - return hass.data[mikrotik.DOMAIN][CONFIG_ENTRY.data[mikrotik.CONF_HOST]] + return hass.data[mikrotik.DOMAIN][config_entry.entry_id] async def test_hub_setup_successful(hass): @@ -96,8 +101,8 @@ async def test_hub_setup_failed(hass): ): result = await mikrotik.async_setup_entry(hass, CONFIG_ENTRY) - assert result is False - assert len(forward_entry_setup.mock_calls) == 0 + assert result is False + assert len(forward_entry_setup.mock_calls) == 0 async def test_update_failed(hass): @@ -116,7 +121,7 @@ async def test_update_failed(hass): async def test_hub_not_support_wireless(hass): """Test updating hub devices when hub doesn't support wireless interfaces.""" - # test that the devices are constructed from wireless data + # test that the devices are constructed from dhcp data hub = await setup_mikrotik_entry(hass, support_wireless=False) @@ -136,6 +141,8 @@ async def test_hub_support_wireless(hass): assert hub.api.support_wireless is True assert hub.api.devices["00:00:00:00:00:01"]._params == DHCP_DATA[0] assert hub.api.devices["00:00:00:00:00:01"]._wireless_params == WIRELESS_DATA[0] + + # devices not in wireless list will not be added assert "00:00:00:00:00:02" not in hub.api.devices @@ -149,5 +156,26 @@ async def test_force_dhcp(hass): assert hub.api.support_wireless is True assert hub.api.devices["00:00:00:00:00:01"]._params == DHCP_DATA[0] assert hub.api.devices["00:00:00:00:00:01"]._wireless_params == WIRELESS_DATA[0] + + # devices not in wireless list are added from dhcp assert hub.api.devices["00:00:00:00:00:02"]._params == DHCP_DATA[1] assert hub.api.devices["00:00:00:00:00:02"]._wireless_params is None + + +async def test_arp_ping(hass): + """Test arp ping devices to confirm they are connected.""" + + # test device show as home if arp ping returns value + with patch.object(mikrotik.hub.MikrotikData, "do_arp_ping", return_value=True): + hub = await setup_mikrotik_entry(hass, arp_ping=True, force_dhcp=True) + + assert hub.api.devices["00:00:00:00:00:01"].last_seen is not None + assert hub.api.devices["00:00:00:00:00:02"].last_seen is not None + + # test device show as away if arp ping times out + with patch.object(mikrotik.hub.MikrotikData, "do_arp_ping", return_value=False): + hub = await setup_mikrotik_entry(hass, arp_ping=True, force_dhcp=True) + + assert hub.api.devices["00:00:00:00:00:01"].last_seen is not None + # this device is not wireless so it will show as away + assert hub.api.devices["00:00:00:00:00:02"].last_seen is None diff --git a/tests/components/mikrotik/test_init.py b/tests/components/mikrotik/test_init.py index 4843dd600c28ea..13cbdeb7f2ad38 100644 --- a/tests/components/mikrotik/test_init.py +++ b/tests/components/mikrotik/test_init.py @@ -25,23 +25,8 @@ async def test_setup_with_no_config(hass): assert mikrotik.DOMAIN not in hass.data -async def test_setup_with_config(hass): - """Test that we do not discover anything or try to set up a hub.""" - config = { - mikrotik.DOMAIN: { - mikrotik.CONF_HOST: "0.0.0.0", - mikrotik.CONF_USERNAME: "user", - mikrotik.CONF_PASSWORD: "pass", - mikrotik.CONF_ARP_PING: True, - mikrotik.CONF_TRACK_DEVICES: True, - mikrotik.CONF_DETECTION_TIME: 30, - } - } - assert await async_setup_component(hass, mikrotik.DOMAIN, config) is True - - async def test_successful_config_entry(hass): - """Test that configured options for a host are loaded via config entry.""" + """Test config entry successfull setup.""" entry = MOCK_ENTRY entry.add_to_hass(hass) mock_registry = Mock() @@ -83,7 +68,7 @@ async def test_hub_fail_setup(hass): mock_hub.return_value.async_setup.return_value = mock_coro(False) assert await mikrotik.async_setup_entry(hass, entry) is False - assert entry.data[mikrotik.CONF_HOST] in hass.data[mikrotik.DOMAIN] + assert entry.entry_id in hass.data[mikrotik.DOMAIN] async def test_unload_entry(hass): @@ -104,6 +89,5 @@ async def test_unload_entry(hass): assert len(mock_hub.return_value.mock_calls) == 1 - mock_hub.return_value.async_reset.return_value = mock_coro(True) assert await mikrotik.async_unload_entry(hass, entry) - assert hass.data[mikrotik.DOMAIN] == {} + assert entry.entry_id not in hass.data[mikrotik.DOMAIN] From a13005152adb9eb72121b6cca89f9e0f629e3404 Mon Sep 17 00:00:00 2001 From: Rami Date: Fri, 3 Jan 2020 14:19:20 +0200 Subject: [PATCH 09/21] update librouteros to 3.0.0 --- homeassistant/components/mikrotik/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 5 +---- 3 files changed, 3 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/mikrotik/manifest.json b/homeassistant/components/mikrotik/manifest.json index 2cda7c45e6ebc2..72f98a11709cf5 100644 --- a/homeassistant/components/mikrotik/manifest.json +++ b/homeassistant/components/mikrotik/manifest.json @@ -4,7 +4,7 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/mikrotik", "requirements": [ - "librouteros==2.3.1" + "librouteros==3.0.0" ], "dependencies": [], "codeowners": [ diff --git a/requirements_all.txt b/requirements_all.txt index aea25eb3093ba7..d8ace6b3bff4e2 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -771,7 +771,7 @@ libpyfoscam==1.0 libpyvivotek==0.4.0 # homeassistant.components.mikrotik -librouteros==2.3.1 +librouteros==3.0.0 # homeassistant.components.soundtouch libsoundtouch==0.7.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e0f7c69b64f434..5216b5c304b250 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -277,10 +277,7 @@ keyrings.alt==3.4.0 libpurecool==0.6.0 # homeassistant.components.mikrotik -librouteros==2.3.1 - -# homeassistant.components.mikrotik -librouteros==2.3.1 +librouteros==3.0.0 # homeassistant.components.soundtouch libsoundtouch==0.7.2 From 44c5348dd22c2c44dbb364d5c31e4b3d4cbd3669 Mon Sep 17 00:00:00 2001 From: Rami Date: Fri, 3 Jan 2020 14:45:02 +0200 Subject: [PATCH 10/21] fix sorting --- tests/components/mikrotik/test_config_flow.py | 1 + tests/components/mikrotik/test_hub.py | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/components/mikrotik/test_config_flow.py b/tests/components/mikrotik/test_config_flow.py index 9d7a2580ad72e9..770da57c8a5222 100644 --- a/tests/components/mikrotik/test_config_flow.py +++ b/tests/components/mikrotik/test_config_flow.py @@ -15,6 +15,7 @@ CONF_USERNAME, CONF_VERIFY_SSL, ) + from tests.common import MockConfigEntry DEMO_USER_INPUT = { diff --git a/tests/components/mikrotik/test_hub.py b/tests/components/mikrotik/test_hub.py index adb31981445335..db31f2cb39a6f8 100644 --- a/tests/components/mikrotik/test_hub.py +++ b/tests/components/mikrotik/test_hub.py @@ -7,7 +7,7 @@ from homeassistant.components import mikrotik from homeassistant.exceptions import ConfigEntryNotReady -from . import DHCP_DATA, MOCK_DATA, WIRELESS_DATA, ARP_DATA +from . import ARP_DATA, DHCP_DATA, MOCK_DATA, WIRELESS_DATA CONFIG_ENTRY = config_entries.ConfigEntry( version=1, From 394bfc63e44b703c6719cbfd746d1822779a38d1 Mon Sep 17 00:00:00 2001 From: Rami Date: Fri, 3 Jan 2020 15:04:03 +0200 Subject: [PATCH 11/21] fix sorting 2 --- homeassistant/components/mikrotik/config_flow.py | 4 ++-- homeassistant/components/mikrotik/device_tracker.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/mikrotik/config_flow.py b/homeassistant/components/mikrotik/config_flow.py index 19bc4185acdba4..54e65879c93b1d 100644 --- a/homeassistant/components/mikrotik/config_flow.py +++ b/homeassistant/components/mikrotik/config_flow.py @@ -14,12 +14,12 @@ from .const import ( CONF_ARP_PING, - CONF_FORCE_DHCP, CONF_DETECTION_TIME, + CONF_FORCE_DHCP, CONF_TRACK_DEVICES, DEFAULT_API_PORT, - DEFAULT_NAME, DEFAULT_DETECTION_TIME, + DEFAULT_NAME, DOMAIN, ) from .errors import CannotConnect, LoginError diff --git a/homeassistant/components/mikrotik/device_tracker.py b/homeassistant/components/mikrotik/device_tracker.py index 844c5852dfbe52..be22a72a4f42b5 100644 --- a/homeassistant/components/mikrotik/device_tracker.py +++ b/homeassistant/components/mikrotik/device_tracker.py @@ -12,7 +12,7 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect import homeassistant.util.dt as dt_util -from .const import DOMAIN, ATTR_MANUFACTURER +from .const import ATTR_MANUFACTURER, DOMAIN _LOGGER = logging.getLogger(__name__) From acfac878802be4f561afe355fa0961b82da6947d Mon Sep 17 00:00:00 2001 From: Rami Date: Mon, 6 Jan 2020 11:03:22 +0200 Subject: [PATCH 12/21] revert to 2.3.0 as 3.0.0 requires code update --- homeassistant/components/mikrotik/hub.py | 4 ++-- homeassistant/components/mikrotik/manifest.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/mikrotik/hub.py b/homeassistant/components/mikrotik/hub.py index f2c538e7c948c8..5526c306409658 100644 --- a/homeassistant/components/mikrotik/hub.py +++ b/homeassistant/components/mikrotik/hub.py @@ -177,9 +177,9 @@ def update_devices(self): for mac, params in device_list.items(): if mac not in self.devices: - self.devices[mac] = Device(mac, self.all_devices.get(mac)) + self.devices[mac] = Device(mac, self.all_devices.get(mac, {})) else: - self.devices[mac].update(params=self.all_devices.get(mac)) + self.devices[mac].update(params=self.all_devices.get(mac, {})) if mac in wireless_devices: # if wireless is supported then wireless_params are params diff --git a/homeassistant/components/mikrotik/manifest.json b/homeassistant/components/mikrotik/manifest.json index 72f98a11709cf5..010e23c4279cd1 100644 --- a/homeassistant/components/mikrotik/manifest.json +++ b/homeassistant/components/mikrotik/manifest.json @@ -4,7 +4,7 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/mikrotik", "requirements": [ - "librouteros==3.0.0" + "librouteros==2.3.0" ], "dependencies": [], "codeowners": [ From 404b500c9b8f763385515737e5860671073cd21c Mon Sep 17 00:00:00 2001 From: Rami Date: Mon, 6 Jan 2020 11:34:28 +0200 Subject: [PATCH 13/21] fix requirements --- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/requirements_all.txt b/requirements_all.txt index d8ace6b3bff4e2..a6cba26235b8be 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -771,7 +771,7 @@ libpyfoscam==1.0 libpyvivotek==0.4.0 # homeassistant.components.mikrotik -librouteros==3.0.0 +librouteros==2.3.0 # homeassistant.components.soundtouch libsoundtouch==0.7.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 5216b5c304b250..c794728e7ba3fa 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -277,7 +277,7 @@ keyrings.alt==3.4.0 libpurecool==0.6.0 # homeassistant.components.mikrotik -librouteros==3.0.0 +librouteros==2.3.0 # homeassistant.components.soundtouch libsoundtouch==0.7.2 From 3af5c98a5a6c1ef23c879af04b0816c9ac900690 Mon Sep 17 00:00:00 2001 From: Rami Date: Thu, 9 Jan 2020 11:08:32 +0200 Subject: [PATCH 14/21] apply fixes --- .../components/mikrotik/.translations/en.json | 3 +- homeassistant/components/mikrotik/__init__.py | 7 +- .../components/mikrotik/config_flow.py | 52 ++++---------- homeassistant/components/mikrotik/const.py | 3 - .../components/mikrotik/device_tracker.py | 10 +-- homeassistant/components/mikrotik/hub.py | 71 ++++++++++--------- .../components/mikrotik/manifest.json | 2 +- .../components/mikrotik/strings.json | 3 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/mikrotik/__init__.py | 6 +- tests/components/mikrotik/test_config_flow.py | 34 +++++---- .../mikrotik/test_device_tracker.py | 26 +++---- tests/components/mikrotik/test_hub.py | 39 +++++----- tests/components/mikrotik/test_init.py | 4 +- 15 files changed, 116 insertions(+), 148 deletions(-) diff --git a/homeassistant/components/mikrotik/.translations/en.json b/homeassistant/components/mikrotik/.translations/en.json index 2d3569945ab41b..590563993d6a5d 100644 --- a/homeassistant/components/mikrotik/.translations/en.json +++ b/homeassistant/components/mikrotik/.translations/en.json @@ -10,8 +10,7 @@ "username": "Username", "password": "Password", "port": "Port", - "verify_ssl": "Use ssl", - "track_devices": "Enable newly added entities" + "verify_ssl": "Use ssl" } } }, diff --git a/homeassistant/components/mikrotik/__init__.py b/homeassistant/components/mikrotik/__init__.py index 84bb7128fae8cf..9a8ee7bdb45c26 100644 --- a/homeassistant/components/mikrotik/__init__.py +++ b/homeassistant/components/mikrotik/__init__.py @@ -16,7 +16,7 @@ ATTR_MANUFACTURER, CONF_ARP_PING, CONF_DETECTION_TIME, - CONF_TRACK_DEVICES, + CONF_FORCE_DHCP, DEFAULT_API_PORT, DEFAULT_DETECTION_TIME, DEFAULT_NAME, @@ -33,8 +33,8 @@ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, vol.Optional(CONF_PORT, default=DEFAULT_API_PORT): cv.port, vol.Optional(CONF_VERIFY_SSL, default=False): cv.boolean, - vol.Optional(CONF_TRACK_DEVICES, default=True): cv.boolean, vol.Optional(CONF_ARP_PING, default=False): cv.boolean, + vol.Optional(CONF_FORCE_DHCP, default=False): cv.boolean, vol.Optional( CONF_DETECTION_TIME, default=DEFAULT_DETECTION_TIME ): cv.time_period, @@ -65,11 +65,10 @@ async def async_setup_entry(hass, config_entry): """Set up the Mikrotik component.""" hub = MikrotikHub(hass, config_entry) - hass.data.setdefault(DOMAIN, {})[config_entry.entry_id] = hub - if not await hub.async_setup(): return False + hass.data.setdefault(DOMAIN, {})[config_entry.entry_id] = hub device_registry = await hass.helpers.device_registry.async_get_registry() device_registry.async_get_or_create( config_entry_id=config_entry.entry_id, diff --git a/homeassistant/components/mikrotik/config_flow.py b/homeassistant/components/mikrotik/config_flow.py index 54e65879c93b1d..c1a41abf0d07b8 100644 --- a/homeassistant/components/mikrotik/config_flow.py +++ b/homeassistant/components/mikrotik/config_flow.py @@ -16,14 +16,13 @@ CONF_ARP_PING, CONF_DETECTION_TIME, CONF_FORCE_DHCP, - CONF_TRACK_DEVICES, DEFAULT_API_PORT, DEFAULT_DETECTION_TIME, DEFAULT_NAME, DOMAIN, ) from .errors import CannotConnect, LoginError -from .hub import get_hub +from .hub import get_api class MikrotikFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): @@ -38,10 +37,6 @@ def async_get_options_flow(config_entry): """Get the options flow for this handler.""" return MikrotikOptionsFlowHandler(config_entry) - def __init__(self): - """Initialize the UniFi flow.""" - self.config = {} - async def async_step_user(self, user_input=None): """Handle a flow initialized by the user.""" errors = {} @@ -51,11 +46,19 @@ async def async_step_user(self, user_input=None): return self.async_abort(reason="already_configured") if entry.data[CONF_NAME] == user_input[CONF_NAME]: errors[CONF_NAME] = "name_exists" + break + + try: + await self.hass.async_add_executor_job(get_api, self.hass, user_input) + except CannotConnect: + errors["base"] = "cannot_connect" + except LoginError: + errors[CONF_USERNAME] = "wrong_credentials" + errors[CONF_PASSWORD] = "wrong_credentials" - errors.update(self.validate_user_input(user_input)) if not errors: return self.async_create_entry( - title=self.config[CONF_NAME], data=self.config + title=user_input[CONF_NAME], data=user_input ) return self.async_show_form( step_id="user", @@ -67,44 +70,15 @@ async def async_step_user(self, user_input=None): vol.Required(CONF_PASSWORD): str, vol.Optional(CONF_PORT, default=DEFAULT_API_PORT): int, vol.Optional(CONF_VERIFY_SSL, default=False): bool, - vol.Optional(CONF_TRACK_DEVICES, default=False): bool, } ), errors=errors, ) - def validate_user_input(self, user_input): - """Validate user input.""" - errors = {} - if CONF_TRACK_DEVICES in user_input: - self.config["options"] = {} - self.config["options"][CONF_TRACK_DEVICES] = user_input.pop( - CONF_TRACK_DEVICES - ) - try: - get_hub(self.hass, user_input) - self.config.update(user_input) - except CannotConnect: - errors["base"] = "cannot_connect" - except LoginError: - errors[CONF_USERNAME] = "wrong_credentials" - errors[CONF_PASSWORD] = "wrong_credentials" - - return errors - async def async_step_import(self, import_config): """Import Miktortik from config.""" - self.config["options"] = {} - self.config["options"][CONF_ARP_PING] = import_config.pop(CONF_ARP_PING) - self.config["options"][CONF_FORCE_DHCP] = import_config.pop(CONF_FORCE_DHCP) - self.config["options"][CONF_DETECTION_TIME] = import_config.pop( - CONF_DETECTION_TIME - ) - self.config["options"][CONF_TRACK_DEVICES] = import_config.pop( - CONF_TRACK_DEVICES - ) - + import_config[CONF_DETECTION_TIME] = import_config[CONF_DETECTION_TIME].seconds return await self.async_step_user(user_input=import_config) @@ -112,7 +86,7 @@ class MikrotikOptionsFlowHandler(config_entries.OptionsFlow): """Handle Mikrotik options.""" def __init__(self, config_entry): - """Initialize UniFi options flow.""" + """Initialize Mikrotik options flow.""" self.config_entry = config_entry async def async_step_init(self, user_input=None): diff --git a/homeassistant/components/mikrotik/const.py b/homeassistant/components/mikrotik/const.py index 2c2df7740104ce..d66a441aaf757e 100644 --- a/homeassistant/components/mikrotik/const.py +++ b/homeassistant/components/mikrotik/const.py @@ -1,10 +1,8 @@ """Constants used in the Mikrotik components.""" DOMAIN = "mikrotik" -# MK_CONFIG = "mikrotik_config" DEFAULT_NAME = "Mikrotik" DEFAULT_API_PORT = 8728 -# DEFAULT_API_SSL_PORT = 8729 DEFAULT_DETECTION_TIME = 300 ATTR_MANUFACTURER = "Mikrotik" @@ -14,7 +12,6 @@ CONF_ARP_PING = "arp_ping" CONF_FORCE_DHCP = "force_dhcp" -CONF_TRACK_DEVICES = "track_devices" CONF_DETECTION_TIME = "detection_time" diff --git a/homeassistant/components/mikrotik/device_tracker.py b/homeassistant/components/mikrotik/device_tracker.py index be22a72a4f42b5..e7c5e5655a0ba5 100644 --- a/homeassistant/components/mikrotik/device_tracker.py +++ b/homeassistant/components/mikrotik/device_tracker.py @@ -54,10 +54,10 @@ def update_hub(): def update_items(hub, async_add_entities, tracked): """Update tracked device state from the hub.""" new_tracked = [] - for device in hub.api.devices: - if device not in tracked: - tracked[device] = MikrotikHubTracker(hub.api.devices[device], hub) - new_tracked.append(tracked[device]) + for mac, device in hub.api.devices.items(): + if mac not in tracked: + tracked[mac] = MikrotikHubTracker(device, hub) + new_tracked.append(tracked[mac]) if new_tracked: async_add_entities(new_tracked) @@ -116,7 +116,7 @@ def device_info(self): info = { "connections": {(CONNECTION_NETWORK_MAC, self.device.mac)}, "manufacturer": ATTR_MANUFACTURER, - "identifiers": {(DOMAIN, self.unique_id)}, + "identifiers": {(DOMAIN, self.device.mac)}, "name": self.name, "via_device": (DOMAIN, self.hub.serial_num), } diff --git a/homeassistant/components/mikrotik/hub.py b/homeassistant/components/mikrotik/hub.py index 5526c306409658..532e2cde2b9414 100644 --- a/homeassistant/components/mikrotik/hub.py +++ b/homeassistant/components/mikrotik/hub.py @@ -22,7 +22,6 @@ CONF_ARP_PING, CONF_DETECTION_TIME, CONF_FORCE_DHCP, - CONF_TRACK_DEVICES, DEFAULT_DETECTION_TIME, DHCP, IDENTITY, @@ -96,6 +95,10 @@ def __init__(self, hass, config_entry, api): self.devices = {} self.available = True self.support_wireless = bool(self.command(MIKROTIK_SERVICES[IS_WIRELESS])) + self.hostname = None + self.model = None + self.firmware = None + self.serial_number = None @staticmethod def load_mac(devices=None): @@ -125,10 +128,17 @@ def get_info(self, param): data = self.command(MIKROTIK_SERVICES[cmd]) return data[0].get(param) if data else None + def get_details(self): + """Get Hub info.""" + self.hostname = self.get_info(NAME) + self.model = self.get_info(ATTR_MODEL) + self.firmware = self.get_info(ATTR_FIRMWARE) + self.serial_number = self.get_info(ATTR_SERIAL_NUMBER) + def connect_to_hub(self): """Connect to hub.""" try: - self.api = get_hub(self.hass, self.config_entry.data) + self.api = get_api(self.hass, self.config_entry.data) self.available = True return True except (LoginError, CannotConnect): @@ -138,7 +148,7 @@ def connect_to_hub(self): def get_list_from_interface(self, interface): """Get devices from interface.""" result = self.command(MIKROTIK_SERVICES[interface]) - return self.load_mac(result) if result else None + return self.load_mac(result) if result else {} def restore_device(self, mac): """Restore a missing device after restart.""" @@ -274,22 +284,22 @@ def host(self): @property def hostname(self): """Return the hostname of the hub.""" - return self._mk_data.get_info(NAME) + return self._mk_data.hostname @property def model(self): """Return the model of the hub.""" - return self._mk_data.get_info(ATTR_MODEL) + return self._mk_data.model @property def firmware(self): """Return the firware of the hub.""" - return self._mk_data.get_info(ATTR_FIRMWARE) + return self._mk_data.firmware @property def serial_num(self): """Return the serial number of the hub.""" - return self._mk_data.get_info(ATTR_SERIAL_NUMBER) + return self._mk_data.serial_number @property def available(self): @@ -311,31 +321,26 @@ def api(self): """Represent Mikrotik data object.""" return self._mk_data - async def add_options(self): + async def async_add_options(self): """Populate default options for Mikrotik.""" if not self.config_entry.options: - hub_options = self.config_entry.data.pop("options", {}) - system_options = { - "disable_new_entities": not hub_options.get(CONF_TRACK_DEVICES, False) - } - if CONF_DETECTION_TIME in hub_options: - detection_time = hub_options[CONF_DETECTION_TIME].seconds - else: - detection_time = DEFAULT_DETECTION_TIME options = { - CONF_ARP_PING: hub_options.get(CONF_ARP_PING, False), - CONF_FORCE_DHCP: hub_options.get(CONF_FORCE_DHCP, False), - CONF_DETECTION_TIME: detection_time, + CONF_ARP_PING: self.config_entry.data.pop(CONF_ARP_PING, False), + CONF_FORCE_DHCP: self.config_entry.data.pop(CONF_FORCE_DHCP, False), + CONF_DETECTION_TIME: self.config_entry.data.pop( + CONF_DETECTION_TIME, DEFAULT_DETECTION_TIME + ), } self.hass.config_entries.async_update_entry( - self.config_entry, options=options, system_options=system_options + self.config_entry, options=options ) async def request_update(self): """Request an update.""" if self.progress is not None: - return await self.progress + await self.progress + return self.progress = self.hass.async_create_task(self.async_update()) await self.progress @@ -345,13 +350,14 @@ async def request_update(self): async def async_update(self): """Update Mikrotik devices information.""" await self.hass.async_add_executor_job(self._mk_data.update) + await self.hass.async_add_executor_job(self._mk_data.get_details) async_dispatcher_send(self.hass, self.signal_update) async def async_setup(self): """Set up the Mikrotik hub.""" try: api = await self.hass.async_add_executor_job( - get_hub, self.hass, self.config_entry.data + get_api, self.hass, self.config_entry.data ) except CannotConnect: raise ConfigEntryNotReady @@ -359,8 +365,10 @@ async def async_setup(self): return False self._mk_data = MikrotikData(self.hass, self.config_entry, api) - await self.add_options() + await self.async_add_options() + await self.hass.async_add_executor_job(self._mk_data.get_details) await self.hass.async_add_executor_job(self._mk_data.update_devices) + self.hass.async_create_task( self.hass.config_entries.async_forward_entry_setup( self.config_entry, "device_tracker" @@ -369,14 +377,14 @@ async def async_setup(self): return True -def get_hub(hass, config_entry): +def get_api(hass, entry): """Connect to Mikrotik hub.""" - _LOGGER.debug("Connecting to Mikrotik hub [%s]", config_entry[CONF_HOST]) + _LOGGER.debug("Connecting to Mikrotik hub [%s]", entry[CONF_HOST]) _login_method = (login_plain, login_token) - kwargs = {"login_methods": _login_method, "port": config_entry["port"]} + kwargs = {"login_methods": _login_method, "port": entry["port"]} - if config_entry[CONF_VERIFY_SSL]: + if entry[CONF_VERIFY_SSL]: ssl_context = ssl.create_default_context() ssl_context.check_hostname = False ssl_context.verify_mode = ssl.CERT_NONE @@ -385,19 +393,16 @@ def get_hub(hass, config_entry): try: api = librouteros.connect( - config_entry[CONF_HOST], - config_entry[CONF_USERNAME], - config_entry[CONF_PASSWORD], - **kwargs, + entry[CONF_HOST], entry[CONF_USERNAME], entry[CONF_PASSWORD], **kwargs, ) - _LOGGER.debug("Connected to %s successfully", config_entry[CONF_HOST]) + _LOGGER.debug("Connected to %s successfully", entry[CONF_HOST]) return api except ( librouteros.exceptions.TrapError, librouteros.exceptions.MultiTrapError, librouteros.exceptions.ConnectionError, ) as api_error: - _LOGGER.error("Mikrotik %s error: %s", config_entry[CONF_HOST], api_error) + _LOGGER.error("Mikrotik %s error: %s", entry[CONF_HOST], api_error) if "invalid user name or password" in str(api_error): raise LoginError raise CannotConnect diff --git a/homeassistant/components/mikrotik/manifest.json b/homeassistant/components/mikrotik/manifest.json index 010e23c4279cd1..fd65aebc145b58 100644 --- a/homeassistant/components/mikrotik/manifest.json +++ b/homeassistant/components/mikrotik/manifest.json @@ -4,7 +4,7 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/mikrotik", "requirements": [ - "librouteros==2.3.0" + "librouteros==2.4.0" ], "dependencies": [], "codeowners": [ diff --git a/homeassistant/components/mikrotik/strings.json b/homeassistant/components/mikrotik/strings.json index 2d3569945ab41b..590563993d6a5d 100644 --- a/homeassistant/components/mikrotik/strings.json +++ b/homeassistant/components/mikrotik/strings.json @@ -10,8 +10,7 @@ "username": "Username", "password": "Password", "port": "Port", - "verify_ssl": "Use ssl", - "track_devices": "Enable newly added entities" + "verify_ssl": "Use ssl" } } }, diff --git a/requirements_all.txt b/requirements_all.txt index a6cba26235b8be..e1f16e47f5331e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -771,7 +771,7 @@ libpyfoscam==1.0 libpyvivotek==0.4.0 # homeassistant.components.mikrotik -librouteros==2.3.0 +librouteros==2.4.0 # homeassistant.components.soundtouch libsoundtouch==0.7.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c794728e7ba3fa..f398099ff90f19 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -277,7 +277,7 @@ keyrings.alt==3.4.0 libpurecool==0.6.0 # homeassistant.components.mikrotik -librouteros==2.3.0 +librouteros==2.4.0 # homeassistant.components.soundtouch libsoundtouch==0.7.2 diff --git a/tests/components/mikrotik/__init__.py b/tests/components/mikrotik/__init__.py index 982fb73a79914f..ae8013eff4b58d 100644 --- a/tests/components/mikrotik/__init__.py +++ b/tests/components/mikrotik/__init__.py @@ -8,9 +8,13 @@ mikrotik.CONF_PASSWORD: "pass", mikrotik.CONF_PORT: 8278, mikrotik.CONF_VERIFY_SSL: False, - "options": {mikrotik.CONF_TRACK_DEVICES: True}, } +MOCK_OPTIONS = { + mikrotik.CONF_ARP_PING: False, + mikrotik.const.CONF_FORCE_DHCP: False, + mikrotik.CONF_DETECTION_TIME: mikrotik.DEFAULT_DETECTION_TIME, +} DEVICE_1_DHCP = { ".id": "*1A", diff --git a/tests/components/mikrotik/test_config_flow.py b/tests/components/mikrotik/test_config_flow.py index 770da57c8a5222..49937bc6590c48 100644 --- a/tests/components/mikrotik/test_config_flow.py +++ b/tests/components/mikrotik/test_config_flow.py @@ -1,4 +1,5 @@ """Test Mikrotik setup process.""" +from datetime import timedelta from unittest.mock import patch import librouteros @@ -25,7 +26,6 @@ CONF_PASSWORD: "password", CONF_PORT: 8278, CONF_VERIFY_SSL: False, - mikrotik.CONF_TRACK_DEVICES: True, } DEMO_CONFIG = { @@ -36,13 +36,21 @@ CONF_PORT: 8278, CONF_VERIFY_SSL: False, mikrotik.const.CONF_FORCE_DHCP: False, - mikrotik.CONF_TRACK_DEVICES: True, mikrotik.CONF_ARP_PING: False, - mikrotik.CONF_DETECTION_TIME: 30, + mikrotik.CONF_DETECTION_TIME: timedelta(seconds=30), } - -MOCK_ENTRY = MockConfigEntry(domain=mikrotik.DOMAIN, data=DEMO_CONFIG) +DEMO_CONFIG_ENTRY = { + CONF_NAME: "Home router", + CONF_HOST: "0.0.0.0", + CONF_USERNAME: "username", + CONF_PASSWORD: "password", + CONF_PORT: 8278, + CONF_VERIFY_SSL: False, + mikrotik.const.CONF_FORCE_DHCP: False, + mikrotik.CONF_ARP_PING: False, + mikrotik.CONF_DETECTION_TIME: 30, +} @pytest.fixture(name="api") @@ -78,9 +86,11 @@ def init_config_flow(hass): async def test_import(hass, api): """Test import step.""" - flow = init_config_flow(hass) + # flow = init_config_flow(hass) - result = await flow.async_step_import(DEMO_CONFIG) + result = await hass.config_entries.flow.async_init( + mikrotik.DOMAIN, context={"source": "import"}, data=DEMO_CONFIG + ) assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY assert result["title"] == "Home router" @@ -90,9 +100,6 @@ async def test_import(hass, api): assert result["data"][CONF_PASSWORD] == "password" assert result["data"][CONF_PORT] == 8278 assert result["data"][CONF_VERIFY_SSL] is False - assert result["data"]["options"][mikrotik.CONF_DETECTION_TIME] == 30 - assert result["data"]["options"][mikrotik.CONF_ARP_PING] is False - assert result["data"]["options"][mikrotik.const.CONF_FORCE_DHCP] is False async def test_flow_works(hass, api): @@ -112,12 +119,11 @@ async def test_flow_works(hass, api): assert result["data"][CONF_USERNAME] == "username" assert result["data"][CONF_PASSWORD] == "password" assert result["data"][CONF_PORT] == 8278 - assert result["data"]["options"][mikrotik.CONF_TRACK_DEVICES] is True async def test_options(hass): """Test updating options.""" - entry = MOCK_ENTRY + entry = MockConfigEntry(domain=mikrotik.DOMAIN, data=DEMO_CONFIG_ENTRY) flow = init_config_flow(hass) options_flow = flow.async_get_options_flow(entry) @@ -143,7 +149,7 @@ async def test_options(hass): async def test_host_already_configured(hass, auth_error): """Test host already configured.""" - entry = MOCK_ENTRY + entry = MockConfigEntry(domain=mikrotik.DOMAIN, data=DEMO_CONFIG_ENTRY) entry.add_to_hass(hass) flow = init_config_flow(hass) @@ -156,7 +162,7 @@ async def test_host_already_configured(hass, auth_error): async def test_name_exists(hass, api): """Test name already configured.""" - entry = MOCK_ENTRY + entry = MockConfigEntry(domain=mikrotik.DOMAIN, data=DEMO_CONFIG_ENTRY) entry.add_to_hass(hass) flow = init_config_flow(hass) user_input = DEMO_USER_INPUT.copy() diff --git a/tests/components/mikrotik/test_device_tracker.py b/tests/components/mikrotik/test_device_tracker.py index a1ea90bf249c44..77b9bda62c8160 100644 --- a/tests/components/mikrotik/test_device_tracker.py +++ b/tests/components/mikrotik/test_device_tracker.py @@ -1,17 +1,16 @@ """The tests for the Mikrotik device tracker platform.""" from datetime import timedelta -from homeassistant import config_entries from homeassistant.components import mikrotik import homeassistant.components.device_tracker as device_tracker from homeassistant.helpers import entity_registry from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util -from . import DEVICE_2_WIRELESS, DHCP_DATA, MOCK_DATA, WIRELESS_DATA +from . import DEVICE_2_WIRELESS, DHCP_DATA, MOCK_DATA, MOCK_OPTIONS, WIRELESS_DATA from .test_hub import setup_mikrotik_entry -from tests.common import patch +from tests.common import MockConfigEntry, patch DEFAULT_DETECTION_TIME = timedelta(seconds=300) @@ -60,11 +59,10 @@ async def test_device_trackers(hass): device_2 = hass.states.get("device_tracker.device_2") assert device_2 is not None - assert device_1.state == "home" + assert device_2.state == "home" - # test state changes to away if last_seen > consider_home_interval - - del WIRELESS_DATA[1] + # test state remains home if last_seen consider_home_interval + del WIRELESS_DATA[1] # device 2 is removed from wireless list hub.api.devices["00:00:00:00:00:02"]._last_seen = dt_util.utcnow() - timedelta( minutes=4 ) @@ -74,6 +72,7 @@ async def test_device_trackers(hass): device_2 = hass.states.get("device_tracker.device_2") assert device_2.state != "not_home" + # test state changes to away if last_seen > consider_home_interval hub.api.devices["00:00:00:00:00:02"]._last_seen = dt_util.utcnow() - timedelta( minutes=5 ) @@ -86,17 +85,10 @@ async def test_device_trackers(hass): async def test_restoring_devices(hass): """Test restoring existing device_tracker entities if not detected on startup.""" - config_entry = config_entries.ConfigEntry( - version=1, - domain=mikrotik.DOMAIN, - title="Mikrotik", - data=MOCK_DATA, - source="test", - connection_class=config_entries.CONN_CLASS_LOCAL_POLL, - system_options={}, - options={}, - entry_id=1, + config_entry = MockConfigEntry( + domain=mikrotik.DOMAIN, data=MOCK_DATA, options=MOCK_OPTIONS ) + config_entry.add_to_hass(hass) registry = await entity_registry.async_get_registry(hass) registry.async_get_or_create( diff --git a/tests/components/mikrotik/test_hub.py b/tests/components/mikrotik/test_hub.py index db31f2cb39a6f8..c0bf4b169b1049 100644 --- a/tests/components/mikrotik/test_hub.py +++ b/tests/components/mikrotik/test_hub.py @@ -3,23 +3,12 @@ import librouteros import pytest -from homeassistant import config_entries from homeassistant.components import mikrotik from homeassistant.exceptions import ConfigEntryNotReady -from . import ARP_DATA, DHCP_DATA, MOCK_DATA, WIRELESS_DATA +from . import ARP_DATA, DHCP_DATA, MOCK_DATA, MOCK_OPTIONS, WIRELESS_DATA -CONFIG_ENTRY = config_entries.ConfigEntry( - version=1, - domain=mikrotik.DOMAIN, - title="Mikrotik", - data=MOCK_DATA, - source="test", - connection_class=config_entries.CONN_CLASS_LOCAL_POLL, - system_options={}, - options={}, - entry_id=1, -) +from tests.common import MockConfigEntry async def setup_mikrotik_entry(hass, **kwargs): @@ -38,7 +27,11 @@ def mock_command(self, cmd, params=None): if cmd == mikrotik.const.MIKROTIK_SERVICES[mikrotik.const.ARP]: return ARP_DATA - config_entry = CONFIG_ENTRY + config_entry = MockConfigEntry( + domain=mikrotik.DOMAIN, data=MOCK_DATA, options=MOCK_OPTIONS + ) + config_entry.add_to_hass(hass) + if "force_dhcp" in kwargs: config_entry.options["force_dhcp"] = True @@ -48,7 +41,8 @@ def mock_command(self, cmd, params=None): with patch("librouteros.connect"), patch.object( mikrotik.hub.MikrotikData, "command", new=mock_command ): - await mikrotik.async_setup_entry(hass, config_entry) + # await mikrotik.async_setup_entry(hass, config_entry) + await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() return hass.data[mikrotik.DOMAIN][config_entry.entry_id] @@ -74,9 +68,7 @@ async def test_hub_setup_successful(hass): mikrotik.CONF_ARP_PING: False, mikrotik.CONF_DETECTION_TIME: 300, } - assert hub.config_entry.system_options == config_entries.SystemOptions( - disable_new_entities=False - ) + assert hub.api.available is True assert hub.signal_update == "mikrotik-update-0.0.0.0" assert forward_entry_setup.mock_calls[0][1] == (hub.config_entry, "device_tracker") @@ -88,9 +80,9 @@ async def test_hub_setup_failed(hass): # error when connection fails with patch( "librouteros.connect", side_effect=librouteros.exceptions.ConnectionError - ): - with pytest.raises(ConfigEntryNotReady): - await mikrotik.async_setup_entry(hass, CONFIG_ENTRY) + ), pytest.raises(ConfigEntryNotReady): + config_entry = MockConfigEntry(domain=mikrotik.DOMAIN, data=MOCK_DATA) + await mikrotik.async_setup_entry(hass, config_entry) # error when username or password is invalid with patch( @@ -99,7 +91,8 @@ async def test_hub_setup_failed(hass): "librouteros.connect", side_effect=librouteros.exceptions.TrapError("invalid user name or password"), ): - result = await mikrotik.async_setup_entry(hass, CONFIG_ENTRY) + config_entry = MockConfigEntry(domain=mikrotik.DOMAIN, data=MOCK_DATA) + result = await mikrotik.async_setup_entry(hass, config_entry) assert result is False assert len(forward_entry_setup.mock_calls) == 0 @@ -112,7 +105,7 @@ async def test_update_failed(hass): with patch.object( mikrotik.hub.MikrotikData, "command", side_effect=mikrotik.errors.CannotConnect - ): + ), pytest.raises(mikrotik.errors.CannotConnect): await hub.async_update() assert hub.api.available is False diff --git a/tests/components/mikrotik/test_init.py b/tests/components/mikrotik/test_init.py index 13cbdeb7f2ad38..c2dd4cbd417465 100644 --- a/tests/components/mikrotik/test_init.py +++ b/tests/components/mikrotik/test_init.py @@ -60,7 +60,7 @@ async def test_successful_config_entry(hass): async def test_hub_fail_setup(hass): - """Test that a failed setup still stores hub.""" + """Test that a failed setup will not store the hub.""" entry = MOCK_ENTRY entry.add_to_hass(hass) @@ -68,7 +68,7 @@ async def test_hub_fail_setup(hass): mock_hub.return_value.async_setup.return_value = mock_coro(False) assert await mikrotik.async_setup_entry(hass, entry) is False - assert entry.entry_id in hass.data[mikrotik.DOMAIN] + assert mikrotik.DOMAIN not in hass.data async def test_unload_entry(hass): From ebeb1c6bb9124066bc6e35a2731e774cd3f79964 Mon Sep 17 00:00:00 2001 From: Rami Date: Thu, 9 Jan 2020 15:08:47 +0200 Subject: [PATCH 15/21] fix tests --- tests/components/mikrotik/test_config_flow.py | 4 +--- tests/components/mikrotik/test_hub.py | 19 ++++++++++++------- 2 files changed, 13 insertions(+), 10 deletions(-) diff --git a/tests/components/mikrotik/test_config_flow.py b/tests/components/mikrotik/test_config_flow.py index 49937bc6590c48..972ce4d7159aa0 100644 --- a/tests/components/mikrotik/test_config_flow.py +++ b/tests/components/mikrotik/test_config_flow.py @@ -73,7 +73,7 @@ def mock_api_authentication_error(): @pytest.fixture(name="conn_error") def mock_api_connection_error(): """Mock an api.""" - with patch("transmissionrpc.Client", side_effect=librouteros.exceptions.TrapError): + with patch("librouteros.connect", side_effect=librouteros.exceptions.TrapError("")): yield @@ -86,8 +86,6 @@ def init_config_flow(hass): async def test_import(hass, api): """Test import step.""" - # flow = init_config_flow(hass) - result = await hass.config_entries.flow.async_init( mikrotik.DOMAIN, context={"source": "import"}, data=DEMO_CONFIG ) diff --git a/tests/components/mikrotik/test_hub.py b/tests/components/mikrotik/test_hub.py index c0bf4b169b1049..10ded2e2ca2161 100644 --- a/tests/components/mikrotik/test_hub.py +++ b/tests/components/mikrotik/test_hub.py @@ -3,8 +3,8 @@ import librouteros import pytest +from homeassistant import config_entries from homeassistant.components import mikrotik -from homeassistant.exceptions import ConfigEntryNotReady from . import ARP_DATA, DHCP_DATA, MOCK_DATA, MOCK_OPTIONS, WIRELESS_DATA @@ -41,7 +41,6 @@ def mock_command(self, cmd, params=None): with patch("librouteros.connect"), patch.object( mikrotik.hub.MikrotikData, "command", new=mock_command ): - # await mikrotik.async_setup_entry(hass, config_entry) await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() return hass.data[mikrotik.DOMAIN][config_entry.entry_id] @@ -77,12 +76,16 @@ async def test_hub_setup_successful(hass): async def test_hub_setup_failed(hass): """Failed setup of Mikrotik hub.""" + config_entry = MockConfigEntry(domain=mikrotik.DOMAIN, data=MOCK_DATA) + config_entry.add_to_hass(hass) # error when connection fails with patch( "librouteros.connect", side_effect=librouteros.exceptions.ConnectionError - ), pytest.raises(ConfigEntryNotReady): - config_entry = MockConfigEntry(domain=mikrotik.DOMAIN, data=MOCK_DATA) - await mikrotik.async_setup_entry(hass, config_entry) + ): + + await hass.config_entries.async_setup(config_entry.entry_id) + + assert config_entry.state == config_entries.ENTRY_STATE_SETUP_RETRY # error when username or password is invalid with patch( @@ -90,9 +93,11 @@ async def test_hub_setup_failed(hass): ) as forward_entry_setup, patch( "librouteros.connect", side_effect=librouteros.exceptions.TrapError("invalid user name or password"), + ), pytest.raises( + config_entries.OperationNotAllowed ): - config_entry = MockConfigEntry(domain=mikrotik.DOMAIN, data=MOCK_DATA) - result = await mikrotik.async_setup_entry(hass, config_entry) + + result = await hass.config_entries.async_setup(config_entry.entry_id) assert result is False assert len(forward_entry_setup.mock_calls) == 0 From 6c5c38450c80ce7f51a41ffc76390795d3069779 Mon Sep 17 00:00:00 2001 From: Rami Date: Fri, 10 Jan 2020 14:15:11 +0200 Subject: [PATCH 16/21] update hub.py and fix tests --- homeassistant/components/mikrotik/hub.py | 10 ++++++---- tests/components/mikrotik/test_config_flow.py | 4 +++- tests/components/mikrotik/test_hub.py | 2 +- 3 files changed, 10 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/mikrotik/hub.py b/homeassistant/components/mikrotik/hub.py index 532e2cde2b9414..ac839368523773 100644 --- a/homeassistant/components/mikrotik/hub.py +++ b/homeassistant/components/mikrotik/hub.py @@ -128,7 +128,7 @@ def get_info(self, param): data = self.command(MIKROTIK_SERVICES[cmd]) return data[0].get(param) if data else None - def get_details(self): + def get_hub_details(self): """Get Hub info.""" self.hostname = self.get_info(NAME) self.model = self.get_info(ATTR_MODEL) @@ -178,6 +178,9 @@ def update_devices(self): if self.arp_enabled: arp_devices = self.get_list_from_interface(ARP) + # get new hub firmware version if updated + self.firmware = self.get_info(ATTR_FIRMWARE) + except CannotConnect: self.available = False return @@ -350,7 +353,6 @@ async def request_update(self): async def async_update(self): """Update Mikrotik devices information.""" await self.hass.async_add_executor_job(self._mk_data.update) - await self.hass.async_add_executor_job(self._mk_data.get_details) async_dispatcher_send(self.hass, self.signal_update) async def async_setup(self): @@ -366,8 +368,8 @@ async def async_setup(self): self._mk_data = MikrotikData(self.hass, self.config_entry, api) await self.async_add_options() - await self.hass.async_add_executor_job(self._mk_data.get_details) - await self.hass.async_add_executor_job(self._mk_data.update_devices) + await self.hass.async_add_executor_job(self._mk_data.get_hub_details) + await self.hass.async_add_executor_job(self._mk_data.update) self.hass.async_create_task( self.hass.config_entries.async_forward_entry_setup( diff --git a/tests/components/mikrotik/test_config_flow.py b/tests/components/mikrotik/test_config_flow.py index 972ce4d7159aa0..170a0ee33f79cc 100644 --- a/tests/components/mikrotik/test_config_flow.py +++ b/tests/components/mikrotik/test_config_flow.py @@ -73,7 +73,9 @@ def mock_api_authentication_error(): @pytest.fixture(name="conn_error") def mock_api_connection_error(): """Mock an api.""" - with patch("librouteros.connect", side_effect=librouteros.exceptions.TrapError("")): + with patch( + "librouteros.connect", side_effect=librouteros.exceptions.ConnectionError + ): yield diff --git a/tests/components/mikrotik/test_hub.py b/tests/components/mikrotik/test_hub.py index 10ded2e2ca2161..aa2d4563028e71 100644 --- a/tests/components/mikrotik/test_hub.py +++ b/tests/components/mikrotik/test_hub.py @@ -110,7 +110,7 @@ async def test_update_failed(hass): with patch.object( mikrotik.hub.MikrotikData, "command", side_effect=mikrotik.errors.CannotConnect - ), pytest.raises(mikrotik.errors.CannotConnect): + ): await hub.async_update() assert hub.api.available is False From c07f1e944b9d54fe57565293f0b63462fbd488fd Mon Sep 17 00:00:00 2001 From: Rami Date: Fri, 10 Jan 2020 15:15:56 +0200 Subject: [PATCH 17/21] fix test_hub_setup_failed --- tests/components/mikrotik/test_hub.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/tests/components/mikrotik/test_hub.py b/tests/components/mikrotik/test_hub.py index aa2d4563028e71..320d650e5426b5 100644 --- a/tests/components/mikrotik/test_hub.py +++ b/tests/components/mikrotik/test_hub.py @@ -1,7 +1,6 @@ """Test Mikrotik hub.""" from asynctest import patch import librouteros -import pytest from homeassistant import config_entries from homeassistant.components import mikrotik @@ -88,13 +87,13 @@ async def test_hub_setup_failed(hass): assert config_entry.state == config_entries.ENTRY_STATE_SETUP_RETRY # error when username or password is invalid + config_entry = MockConfigEntry(domain=mikrotik.DOMAIN, data=MOCK_DATA) + config_entry.add_to_hass(hass) with patch( "homeassistant.config_entries.ConfigEntries.async_forward_entry_setup" ) as forward_entry_setup, patch( "librouteros.connect", side_effect=librouteros.exceptions.TrapError("invalid user name or password"), - ), pytest.raises( - config_entries.OperationNotAllowed ): result = await hass.config_entries.async_setup(config_entry.entry_id) From e9dcfebf2f8c9d21f9317e3ef38246e243498441 Mon Sep 17 00:00:00 2001 From: engrbm87 Date: Thu, 30 Jan 2020 10:27:46 +0200 Subject: [PATCH 18/21] rebased on dev and update librouteros to 3.0.0 --- .coveragerc | 2 ++ homeassistant/components/mikrotik/hub.py | 31 ++++++++++--------- .../components/mikrotik/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/mikrotik/test_config_flow.py | 2 +- 6 files changed, 23 insertions(+), 18 deletions(-) diff --git a/.coveragerc b/.coveragerc index f2a662ad248a73..eb10564a989739 100644 --- a/.coveragerc +++ b/.coveragerc @@ -420,6 +420,8 @@ omit = homeassistant/components/metoffice/weather.py homeassistant/components/microsoft/tts.py homeassistant/components/miflora/sensor.py + homeassistant/components/mikrotik/hub.py + homeassistant/components/mikrotik/device_tracker.py homeassistant/components/mill/climate.py homeassistant/components/mill/const.py homeassistant/components/minio/* diff --git a/homeassistant/components/mikrotik/hub.py b/homeassistant/components/mikrotik/hub.py index ac839368523773..2243b6cc5ce377 100644 --- a/homeassistant/components/mikrotik/hub.py +++ b/homeassistant/components/mikrotik/hub.py @@ -1,10 +1,11 @@ -"""The mikrotik router class.""" +"""The Mikrotik router class.""" from datetime import timedelta import logging +import socket import ssl import librouteros -from librouteros.login import login_plain, login_token +from librouteros.login import plain as login_plain, token as login_token from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME, CONF_VERIFY_SSL from homeassistant.exceptions import ConfigEntryNotReady @@ -125,7 +126,7 @@ def force_dhcp(self): def get_info(self, param): """Return device model name.""" cmd = IDENTITY if param == NAME else INFO - data = self.command(MIKROTIK_SERVICES[cmd]) + data = list(self.command(MIKROTIK_SERVICES[cmd])) return data[0].get(param) if data else None def get_hub_details(self): @@ -147,7 +148,7 @@ def connect_to_hub(self): def get_list_from_interface(self, interface): """Get devices from interface.""" - result = self.command(MIKROTIK_SERVICES[interface]) + result = list(self.command(MIKROTIK_SERVICES[interface])) return self.load_mac(result) if result else {} def restore_device(self, mac): @@ -181,7 +182,7 @@ def update_devices(self): # get new hub firmware version if updated self.firmware = self.get_info(ATTR_FIRMWARE) - except CannotConnect: + except (CannotConnect, socket.timeout, socket.error): self.available = False return @@ -223,7 +224,7 @@ def do_arp_ping(self, ip_address, interface): "address": ip_address, } cmd = "/ping" - data = self.command(cmd, params) + data = list(self.command(cmd, params)) if data is not None: status = 0 for result in data: @@ -239,17 +240,19 @@ def do_arp_ping(self, ip_address, interface): def command(self, cmd, params=None): """Retrieve data from Mikrotik API.""" try: + _LOGGER.info("Running command %s", cmd) if params: response = self.api(cmd=cmd, **params) else: response = self.api(cmd=cmd) - except (librouteros.exceptions.ConnectionError,) as api_error: - _LOGGER.error("Mikrotik %s connection error %s", self._host, api_error) - raise CannotConnect except ( - librouteros.exceptions.TrapError, - librouteros.exceptions.MultiTrapError, + librouteros.exceptions.ConnectionClosed, + socket.error, + socket.timeout, ) as api_error: + _LOGGER.error("Mikrotik %s connection error %s", self._host, api_error) + raise CannotConnect + except librouteros.exceptions.ProtocolError as api_error: _LOGGER.warning( "Mikrotik %s failed to retrieve data. cmd=[%s] Error: %s", self._host, @@ -400,9 +403,9 @@ def get_api(hass, entry): _LOGGER.debug("Connected to %s successfully", entry[CONF_HOST]) return api except ( - librouteros.exceptions.TrapError, - librouteros.exceptions.MultiTrapError, - librouteros.exceptions.ConnectionError, + librouteros.exceptions.LibRouterosError, + socket.error, + socket.timeout, ) as api_error: _LOGGER.error("Mikrotik %s error: %s", entry[CONF_HOST], api_error) if "invalid user name or password" in str(api_error): diff --git a/homeassistant/components/mikrotik/manifest.json b/homeassistant/components/mikrotik/manifest.json index fd65aebc145b58..72f98a11709cf5 100644 --- a/homeassistant/components/mikrotik/manifest.json +++ b/homeassistant/components/mikrotik/manifest.json @@ -4,7 +4,7 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/mikrotik", "requirements": [ - "librouteros==2.4.0" + "librouteros==3.0.0" ], "dependencies": [], "codeowners": [ diff --git a/requirements_all.txt b/requirements_all.txt index 45737d38577915..3933d684778d44 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -778,7 +778,7 @@ libpyfoscam==1.0 libpyvivotek==0.4.0 # homeassistant.components.mikrotik -librouteros==2.4.0 +librouteros==3.0.0 # homeassistant.components.soundtouch libsoundtouch==0.7.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 67ca5f5368becd..846f35c83d2de0 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -284,7 +284,7 @@ keyrings.alt==3.4.0 libpurecool==0.6.0 # homeassistant.components.mikrotik -librouteros==2.4.0 +librouteros==3.0.0 # homeassistant.components.soundtouch libsoundtouch==0.7.2 diff --git a/tests/components/mikrotik/test_config_flow.py b/tests/components/mikrotik/test_config_flow.py index 170a0ee33f79cc..7106fd18fbc0f5 100644 --- a/tests/components/mikrotik/test_config_flow.py +++ b/tests/components/mikrotik/test_config_flow.py @@ -74,7 +74,7 @@ def mock_api_authentication_error(): def mock_api_connection_error(): """Mock an api.""" with patch( - "librouteros.connect", side_effect=librouteros.exceptions.ConnectionError + "librouteros.connect", side_effect=librouteros.exceptions.ConnectionClosed ): yield From 33b47a147f231d56a186ca4ac06495b8b59c6a52 Mon Sep 17 00:00:00 2001 From: engrbm87 Date: Thu, 30 Jan 2020 14:10:30 +0200 Subject: [PATCH 19/21] fixed test_config_flow --- tests/components/mikrotik/test_config_flow.py | 65 +++++++++++-------- 1 file changed, 38 insertions(+), 27 deletions(-) diff --git a/tests/components/mikrotik/test_config_flow.py b/tests/components/mikrotik/test_config_flow.py index 7106fd18fbc0f5..d4df22eb622632 100644 --- a/tests/components/mikrotik/test_config_flow.py +++ b/tests/components/mikrotik/test_config_flow.py @@ -7,7 +7,6 @@ from homeassistant import data_entry_flow from homeassistant.components import mikrotik -from homeassistant.components.mikrotik import config_flow from homeassistant.const import ( CONF_HOST, CONF_NAME, @@ -79,13 +78,6 @@ def mock_api_connection_error(): yield -def init_config_flow(hass): - """Init a configuration flow.""" - flow = config_flow.MikrotikFlowHandler() - flow.hass = hass - return flow - - async def test_import(hass, api): """Test import step.""" result = await hass.config_entries.flow.async_init( @@ -104,13 +96,16 @@ async def test_import(hass, api): async def test_flow_works(hass, api): """Test config flow.""" - flow = init_config_flow(hass) - result = await flow.async_step_user() + result = await hass.config_entries.flow.async_init( + mikrotik.DOMAIN, context={"source": "user"} + ) assert result["type"] == data_entry_flow.RESULT_TYPE_FORM assert result["step_id"] == "user" - result = await flow.async_step_user(DEMO_USER_INPUT) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=DEMO_USER_INPUT + ) assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY assert result["title"] == "Home router" @@ -124,14 +119,17 @@ async def test_flow_works(hass, api): async def test_options(hass): """Test updating options.""" entry = MockConfigEntry(domain=mikrotik.DOMAIN, data=DEMO_CONFIG_ENTRY) - flow = init_config_flow(hass) - options_flow = flow.async_get_options_flow(entry) + entry.add_to_hass(hass) - result = await options_flow.async_step_init() + flow = await hass.config_entries.options.async_create_flow( + entry.entry_id, context={"source": "test"}, data=None + ) + + result = await flow.async_step_init() assert result["type"] == data_entry_flow.RESULT_TYPE_FORM assert result["step_id"] == "device_tracker" - result = await options_flow.async_step_device_tracker( + result = await flow.async_step_device_tracker( { mikrotik.CONF_DETECTION_TIME: 30, mikrotik.CONF_ARP_PING: True, @@ -151,10 +149,13 @@ async def test_host_already_configured(hass, auth_error): entry = MockConfigEntry(domain=mikrotik.DOMAIN, data=DEMO_CONFIG_ENTRY) entry.add_to_hass(hass) - flow = init_config_flow(hass) - - result = await flow.async_step_user(DEMO_USER_INPUT) + result = await hass.config_entries.flow.async_init( + mikrotik.DOMAIN, context={"source": "user"} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=DEMO_USER_INPUT + ) assert result["type"] == "abort" assert result["reason"] == "already_configured" @@ -164,10 +165,15 @@ async def test_name_exists(hass, api): entry = MockConfigEntry(domain=mikrotik.DOMAIN, data=DEMO_CONFIG_ENTRY) entry.add_to_hass(hass) - flow = init_config_flow(hass) user_input = DEMO_USER_INPUT.copy() user_input[CONF_HOST] = "0.0.0.1" - result = await flow.async_step_user(user_input) + + result = await hass.config_entries.flow.async_init( + mikrotik.DOMAIN, context={"source": "user"} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=user_input + ) assert result["type"] == "form" assert result["errors"] == {CONF_NAME: "name_exists"} @@ -176,10 +182,12 @@ async def test_name_exists(hass, api): async def test_connection_error(hass, conn_error): """Test error when connection is unsuccesful.""" - flow = init_config_flow(hass) - - result = await flow.async_step_user(DEMO_USER_INPUT) - + result = await hass.config_entries.flow.async_init( + mikrotik.DOMAIN, context={"source": "user"} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=DEMO_USER_INPUT + ) assert result["type"] == data_entry_flow.RESULT_TYPE_FORM assert result["errors"] == {"base": "cannot_connect"} @@ -187,9 +195,12 @@ async def test_connection_error(hass, conn_error): async def test_wrong_credentials(hass, auth_error): """Test error when credentials are wrong.""" - flow = init_config_flow(hass) - - result = await flow.async_step_user(DEMO_USER_INPUT) + result = await hass.config_entries.flow.async_init( + mikrotik.DOMAIN, context={"source": "user"} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=DEMO_USER_INPUT + ) assert result["type"] == data_entry_flow.RESULT_TYPE_FORM assert result["errors"] == { From a7d141bd4d396f4b5848ffbb6a599c018756886e Mon Sep 17 00:00:00 2001 From: engrbm87 Date: Thu, 30 Jan 2020 15:17:42 +0200 Subject: [PATCH 20/21] fixed tests --- .../mikrotik/test_device_tracker.py | 1 + tests/components/mikrotik/test_hub.py | 3 ++- tests/components/mikrotik/test_init.py | 20 +++++-------------- 3 files changed, 8 insertions(+), 16 deletions(-) diff --git a/tests/components/mikrotik/test_device_tracker.py b/tests/components/mikrotik/test_device_tracker.py index 77b9bda62c8160..643f94a5ad5d93 100644 --- a/tests/components/mikrotik/test_device_tracker.py +++ b/tests/components/mikrotik/test_device_tracker.py @@ -23,6 +23,7 @@ def mock_command(self, cmd, params=None): return DHCP_DATA if cmd == mikrotik.const.MIKROTIK_SERVICES[mikrotik.const.WIRELESS]: return WIRELESS_DATA + return {} async def test_platform_manually_configured(hass): diff --git a/tests/components/mikrotik/test_hub.py b/tests/components/mikrotik/test_hub.py index 320d650e5426b5..fc37c9113aee16 100644 --- a/tests/components/mikrotik/test_hub.py +++ b/tests/components/mikrotik/test_hub.py @@ -25,6 +25,7 @@ def mock_command(self, cmd, params=None): return wireless_data if cmd == mikrotik.const.MIKROTIK_SERVICES[mikrotik.const.ARP]: return ARP_DATA + return {} config_entry = MockConfigEntry( domain=mikrotik.DOMAIN, data=MOCK_DATA, options=MOCK_OPTIONS @@ -79,7 +80,7 @@ async def test_hub_setup_failed(hass): config_entry.add_to_hass(hass) # error when connection fails with patch( - "librouteros.connect", side_effect=librouteros.exceptions.ConnectionError + "librouteros.connect", side_effect=librouteros.exceptions.ConnectionClosed ): await hass.config_entries.async_setup(config_entry.entry_id) diff --git a/tests/components/mikrotik/test_init.py b/tests/components/mikrotik/test_init.py index c2dd4cbd417465..bf2b19c735c67c 100644 --- a/tests/components/mikrotik/test_init.py +++ b/tests/components/mikrotik/test_init.py @@ -4,19 +4,9 @@ from homeassistant.components import mikrotik from homeassistant.setup import async_setup_component -from tests.common import MockConfigEntry, mock_coro +from . import MOCK_DATA -MOCK_ENTRY = MockConfigEntry( - domain=mikrotik.DOMAIN, - data={ - mikrotik.CONF_NAME: "Mikrotik", - mikrotik.CONF_HOST: "0.0.0.0", - mikrotik.CONF_USERNAME: "user", - mikrotik.CONF_PASSWORD: "pass", - mikrotik.CONF_PORT: 8278, - mikrotik.CONF_VERIFY_SSL: False, - }, -) +from tests.common import MockConfigEntry, mock_coro async def test_setup_with_no_config(hass): @@ -27,7 +17,7 @@ async def test_setup_with_no_config(hass): async def test_successful_config_entry(hass): """Test config entry successfull setup.""" - entry = MOCK_ENTRY + entry = MockConfigEntry(domain=mikrotik.DOMAIN, data=MOCK_DATA,) entry.add_to_hass(hass) mock_registry = Mock() @@ -61,7 +51,7 @@ async def test_successful_config_entry(hass): async def test_hub_fail_setup(hass): """Test that a failed setup will not store the hub.""" - entry = MOCK_ENTRY + entry = MockConfigEntry(domain=mikrotik.DOMAIN, data=MOCK_DATA,) entry.add_to_hass(hass) with patch.object(mikrotik, "MikrotikHub") as mock_hub: @@ -73,7 +63,7 @@ async def test_hub_fail_setup(hass): async def test_unload_entry(hass): """Test being able to unload an entry.""" - entry = MOCK_ENTRY + entry = MockConfigEntry(domain=mikrotik.DOMAIN, data=MOCK_DATA,) entry.add_to_hass(hass) with patch.object(mikrotik, "MikrotikHub") as mock_hub, patch( From 789190934158a75df2f1b4de0616d022227fea8d Mon Sep 17 00:00:00 2001 From: engrbm87 Date: Thu, 30 Jan 2020 16:19:31 +0200 Subject: [PATCH 21/21] fix test_config_flow --- tests/components/mikrotik/test_config_flow.py | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/tests/components/mikrotik/test_config_flow.py b/tests/components/mikrotik/test_config_flow.py index d4df22eb622632..25f541e92876eb 100644 --- a/tests/components/mikrotik/test_config_flow.py +++ b/tests/components/mikrotik/test_config_flow.py @@ -121,21 +121,20 @@ async def test_options(hass): entry = MockConfigEntry(domain=mikrotik.DOMAIN, data=DEMO_CONFIG_ENTRY) entry.add_to_hass(hass) - flow = await hass.config_entries.options.async_create_flow( - entry.entry_id, context={"source": "test"}, data=None - ) + result = await hass.config_entries.options.async_init(entry.entry_id) - result = await flow.async_step_init() assert result["type"] == data_entry_flow.RESULT_TYPE_FORM assert result["step_id"] == "device_tracker" - result = await flow.async_step_device_tracker( - { + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ mikrotik.CONF_DETECTION_TIME: 30, mikrotik.CONF_ARP_PING: True, mikrotik.const.CONF_FORCE_DHCP: False, - } + }, ) + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY assert result["data"] == { mikrotik.CONF_DETECTION_TIME: 30,