Add type hints and code cleanup for mikrotik (#74296)

* Add type hints and code cleanup for mikrotik

* update test and increase coverage

* move setup_mikrotik_entry to __init__.py
This commit is contained in:
Rami Mosleh 2022-07-05 10:16:38 +03:00 committed by GitHub
parent 110d9232cd
commit b09aaba421
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
10 changed files with 302 additions and 333 deletions

View file

@ -717,7 +717,6 @@ omit =
homeassistant/components/microsoft/tts.py homeassistant/components/microsoft/tts.py
homeassistant/components/miflora/sensor.py homeassistant/components/miflora/sensor.py
homeassistant/components/mikrotik/hub.py homeassistant/components/mikrotik/hub.py
homeassistant/components/mikrotik/device_tracker.py
homeassistant/components/mill/climate.py homeassistant/components/mill/climate.py
homeassistant/components/mill/const.py homeassistant/components/mill/const.py
homeassistant/components/mill/sensor.py homeassistant/components/mill/sensor.py

View file

@ -1,35 +1,41 @@
"""The Mikrotik component.""" """The Mikrotik component."""
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers import config_validation as cv, device_registry as dr from homeassistant.helpers import config_validation as cv, device_registry as dr
from .const import ATTR_MANUFACTURER, DOMAIN, PLATFORMS from .const import ATTR_MANUFACTURER, DOMAIN, PLATFORMS
from .hub import MikrotikDataUpdateCoordinator from .errors import CannotConnect, LoginError
from .hub import MikrotikDataUpdateCoordinator, get_api
CONFIG_SCHEMA = cv.removed(DOMAIN, raise_if_present=False) CONFIG_SCHEMA = cv.removed(DOMAIN, raise_if_present=False)
async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool:
"""Set up the Mikrotik component.""" """Set up the Mikrotik component."""
try:
hub = MikrotikDataUpdateCoordinator(hass, config_entry) api = await hass.async_add_executor_job(get_api, dict(config_entry.data))
if not await hub.async_setup(): except CannotConnect as api_error:
raise ConfigEntryNotReady from api_error
except LoginError:
return False return False
await hub.async_config_entry_first_refresh() coordinator = MikrotikDataUpdateCoordinator(hass, config_entry, api)
await hass.async_add_executor_job(coordinator.api.get_hub_details)
await coordinator.async_config_entry_first_refresh()
hass.data.setdefault(DOMAIN, {})[config_entry.entry_id] = hub hass.data.setdefault(DOMAIN, {})[config_entry.entry_id] = coordinator
hass.config_entries.async_setup_platforms(config_entry, PLATFORMS) hass.config_entries.async_setup_platforms(config_entry, PLATFORMS)
device_registry = dr.async_get(hass) device_registry = dr.async_get(hass)
device_registry.async_get_or_create( device_registry.async_get_or_create(
config_entry_id=config_entry.entry_id, config_entry_id=config_entry.entry_id,
connections={(DOMAIN, hub.serial_num)}, connections={(DOMAIN, coordinator.serial_num)},
manufacturer=ATTR_MANUFACTURER, manufacturer=ATTR_MANUFACTURER,
model=hub.model, model=coordinator.model,
name=hub.hostname, name=coordinator.hostname,
sw_version=hub.firmware, sw_version=coordinator.firmware,
) )
return True return True
@ -37,10 +43,9 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b
async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool:
"""Unload a config entry.""" """Unload a config entry."""
unload_ok = await hass.config_entries.async_unload_platforms( if unload_ok := await hass.config_entries.async_unload_platforms(
config_entry, PLATFORMS config_entry, PLATFORMS
) ):
hass.data[DOMAIN].pop(config_entry.entry_id) hass.data[DOMAIN].pop(config_entry.entry_id)
return unload_ok return unload_ok

View file

@ -1,6 +1,8 @@
"""Config flow for Mikrotik.""" """Config flow for Mikrotik."""
from __future__ import annotations from __future__ import annotations
from typing import Any
import voluptuous as vol import voluptuous as vol
from homeassistant import config_entries from homeassistant import config_entries
@ -13,6 +15,7 @@ from homeassistant.const import (
CONF_VERIFY_SSL, CONF_VERIFY_SSL,
) )
from homeassistant.core import callback from homeassistant.core import callback
from homeassistant.data_entry_flow import FlowResult
from .const import ( from .const import (
CONF_ARP_PING, CONF_ARP_PING,
@ -40,7 +43,9 @@ class MikrotikFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
"""Get the options flow for this handler.""" """Get the options flow for this handler."""
return MikrotikOptionsFlowHandler(config_entry) return MikrotikOptionsFlowHandler(config_entry)
async def async_step_user(self, user_input=None): async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Handle a flow initialized by the user.""" """Handle a flow initialized by the user."""
errors = {} errors = {}
if user_input is not None: if user_input is not None:
@ -52,7 +57,7 @@ class MikrotikFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
break break
try: try:
await self.hass.async_add_executor_job(get_api, self.hass, user_input) await self.hass.async_add_executor_job(get_api, user_input)
except CannotConnect: except CannotConnect:
errors["base"] = "cannot_connect" errors["base"] = "cannot_connect"
except LoginError: except LoginError:
@ -86,11 +91,15 @@ class MikrotikOptionsFlowHandler(config_entries.OptionsFlow):
"""Initialize Mikrotik options flow.""" """Initialize Mikrotik options flow."""
self.config_entry = config_entry self.config_entry = config_entry
async def async_step_init(self, user_input=None): async def async_step_init(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Manage the Mikrotik options.""" """Manage the Mikrotik options."""
return await self.async_step_device_tracker() return await self.async_step_device_tracker()
async def async_step_device_tracker(self, user_input=None): async def async_step_device_tracker(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Manage the device tracker options.""" """Manage the device tracker options."""
if user_input is not None: if user_input is not None:
return self.async_create_entry(title="", data=user_input) return self.async_create_entry(title="", data=user_input)

View file

@ -1,33 +1,35 @@
"""Constants used in the Mikrotik components.""" """Constants used in the Mikrotik components."""
from typing import Final
from homeassistant.const import Platform from homeassistant.const import Platform
DOMAIN = "mikrotik" DOMAIN: Final = "mikrotik"
DEFAULT_NAME = "Mikrotik" DEFAULT_NAME: Final = "Mikrotik"
DEFAULT_API_PORT = 8728 DEFAULT_API_PORT: Final = 8728
DEFAULT_DETECTION_TIME = 300 DEFAULT_DETECTION_TIME: Final = 300
ATTR_MANUFACTURER = "Mikrotik" ATTR_MANUFACTURER: Final = "Mikrotik"
ATTR_SERIAL_NUMBER = "serial-number" ATTR_SERIAL_NUMBER: Final = "serial-number"
ATTR_FIRMWARE = "current-firmware" ATTR_FIRMWARE: Final = "current-firmware"
ATTR_MODEL = "model" ATTR_MODEL: Final = "model"
CONF_ARP_PING = "arp_ping" CONF_ARP_PING: Final = "arp_ping"
CONF_FORCE_DHCP = "force_dhcp" CONF_FORCE_DHCP: Final = "force_dhcp"
CONF_DETECTION_TIME = "detection_time" CONF_DETECTION_TIME: Final = "detection_time"
NAME = "name" NAME: Final = "name"
INFO = "info" INFO: Final = "info"
IDENTITY = "identity" IDENTITY: Final = "identity"
ARP = "arp" ARP: Final = "arp"
CAPSMAN = "capsman" CAPSMAN: Final = "capsman"
DHCP = "dhcp" DHCP: Final = "dhcp"
WIRELESS = "wireless" WIRELESS: Final = "wireless"
IS_WIRELESS = "is_wireless" IS_WIRELESS: Final = "is_wireless"
IS_CAPSMAN = "is_capsman" IS_CAPSMAN: Final = "is_capsman"
MIKROTIK_SERVICES = { MIKROTIK_SERVICES: Final = {
ARP: "/ip/arp/getall", ARP: "/ip/arp/getall",
CAPSMAN: "/caps-man/registration-table/getall", CAPSMAN: "/caps-man/registration-table/getall",
DHCP: "/ip/dhcp-server/lease/getall", DHCP: "/ip/dhcp-server/lease/getall",
@ -38,9 +40,9 @@ MIKROTIK_SERVICES = {
IS_CAPSMAN: "/caps-man/interface/print", IS_CAPSMAN: "/caps-man/interface/print",
} }
PLATFORMS = [Platform.DEVICE_TRACKER] PLATFORMS: Final = [Platform.DEVICE_TRACKER]
ATTR_DEVICE_TRACKER = [ ATTR_DEVICE_TRACKER: Final = [
"comment", "comment",
"mac-address", "mac-address",
"ssid", "ssid",

View file

@ -1,6 +1,8 @@
"""Support for Mikrotik routers as device tracker.""" """Support for Mikrotik routers as device tracker."""
from __future__ import annotations from __future__ import annotations
from typing import Any
from homeassistant.components.device_tracker.config_entry import ScannerEntity from homeassistant.components.device_tracker.config_entry import ScannerEntity
from homeassistant.components.device_tracker.const import ( from homeassistant.components.device_tracker.const import (
DOMAIN as DEVICE_TRACKER, DOMAIN as DEVICE_TRACKER,
@ -14,7 +16,7 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity
import homeassistant.util.dt as dt_util import homeassistant.util.dt as dt_util
from .const import DOMAIN from .const import DOMAIN
from .hub import MikrotikDataUpdateCoordinator from .hub import Device, MikrotikDataUpdateCoordinator
# These are normalized to ATTR_IP and ATTR_MAC to conform # These are normalized to ATTR_IP and ATTR_MAC to conform
# to device_tracker # to device_tracker
@ -27,7 +29,9 @@ async def async_setup_entry(
async_add_entities: AddEntitiesCallback, async_add_entities: AddEntitiesCallback,
) -> None: ) -> None:
"""Set up device tracker for Mikrotik component.""" """Set up device tracker for Mikrotik component."""
hub = hass.data[DOMAIN][config_entry.entry_id] coordinator: MikrotikDataUpdateCoordinator = hass.data[DOMAIN][
config_entry.entry_id
]
tracked: dict[str, MikrotikDataUpdateCoordinatorTracker] = {} tracked: dict[str, MikrotikDataUpdateCoordinatorTracker] = {}
@ -42,47 +46,53 @@ async def async_setup_entry(
): ):
if ( if (
entity.unique_id in hub.api.devices entity.unique_id in coordinator.api.devices
or entity.unique_id not in hub.api.all_devices or entity.unique_id not in coordinator.api.all_devices
): ):
continue continue
hub.api.restore_device(entity.unique_id) coordinator.api.restore_device(entity.unique_id)
@callback @callback
def update_hub(): def update_hub() -> None:
"""Update the status of the device.""" """Update the status of the device."""
update_items(hub, async_add_entities, tracked) update_items(coordinator, async_add_entities, tracked)
config_entry.async_on_unload(hub.async_add_listener(update_hub)) config_entry.async_on_unload(coordinator.async_add_listener(update_hub))
update_hub() update_hub()
@callback @callback
def update_items(hub, async_add_entities, tracked): def update_items(
coordinator: MikrotikDataUpdateCoordinator,
async_add_entities: AddEntitiesCallback,
tracked: dict[str, MikrotikDataUpdateCoordinatorTracker],
):
"""Update tracked device state from the hub.""" """Update tracked device state from the hub."""
new_tracked = [] new_tracked: list[MikrotikDataUpdateCoordinatorTracker] = []
for mac, device in hub.api.devices.items(): for mac, device in coordinator.api.devices.items():
if mac not in tracked: if mac not in tracked:
tracked[mac] = MikrotikDataUpdateCoordinatorTracker(device, hub) tracked[mac] = MikrotikDataUpdateCoordinatorTracker(device, coordinator)
new_tracked.append(tracked[mac]) new_tracked.append(tracked[mac])
if new_tracked: if new_tracked:
async_add_entities(new_tracked) async_add_entities(new_tracked)
class MikrotikDataUpdateCoordinatorTracker(CoordinatorEntity, ScannerEntity): class MikrotikDataUpdateCoordinatorTracker(
CoordinatorEntity[MikrotikDataUpdateCoordinator], ScannerEntity
):
"""Representation of network device.""" """Representation of network device."""
coordinator: MikrotikDataUpdateCoordinator def __init__(
self, device: Device, coordinator: MikrotikDataUpdateCoordinator
def __init__(self, device, hub): ) -> None:
"""Initialize the tracked device.""" """Initialize the tracked device."""
super().__init__(hub) super().__init__(coordinator)
self.device = device self.device = device
@property @property
def is_connected(self): def is_connected(self) -> bool:
"""Return true if the client is connected to the network.""" """Return true if the client is connected to the network."""
if ( if (
self.device.last_seen self.device.last_seen
@ -93,7 +103,7 @@ class MikrotikDataUpdateCoordinatorTracker(CoordinatorEntity, ScannerEntity):
return False return False
@property @property
def source_type(self): def source_type(self) -> str:
"""Return the source type of the client.""" """Return the source type of the client."""
return SOURCE_TYPE_ROUTER return SOURCE_TYPE_ROUTER
@ -124,7 +134,7 @@ class MikrotikDataUpdateCoordinatorTracker(CoordinatorEntity, ScannerEntity):
return self.device.mac return self.device.mac
@property @property
def extra_state_attributes(self): def extra_state_attributes(self) -> dict[str, Any] | None:
"""Return the device state attributes.""" """Return the device state attributes."""
if self.is_connected: if self.is_connected:
return {k: v for k, v in self.device.attrs.items() if k not in FILTER_ATTRS} return {k: v for k, v in self.device.attrs.items() if k not in FILTER_ATTRS}

View file

@ -1,14 +1,18 @@
"""The Mikrotik router class.""" """The Mikrotik router class."""
from datetime import timedelta from __future__ import annotations
from datetime import datetime, timedelta
import logging import logging
import socket import socket
import ssl import ssl
from typing import Any
import librouteros import librouteros
from librouteros.login import plain as login_plain, token as login_token from librouteros.login import plain as login_plain, token as login_token
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME, CONF_VERIFY_SSL from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME, CONF_VERIFY_SSL
from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from homeassistant.util import slugify from homeassistant.util import slugify
import homeassistant.util.dt as dt_util import homeassistant.util.dt as dt_util
@ -42,36 +46,36 @@ _LOGGER = logging.getLogger(__name__)
class Device: class Device:
"""Represents a network device.""" """Represents a network device."""
def __init__(self, mac, params): def __init__(self, mac: str, params: dict[str, Any]) -> None:
"""Initialize the network device.""" """Initialize the network device."""
self._mac = mac self._mac = mac
self._params = params self._params = params
self._last_seen = None self._last_seen: datetime | None = None
self._attrs = {} self._attrs: dict[str, Any] = {}
self._wireless_params = None self._wireless_params: dict[str, Any] = {}
@property @property
def name(self): def name(self) -> str:
"""Return device name.""" """Return device name."""
return self._params.get("host-name", self.mac) return self._params.get("host-name", self.mac)
@property @property
def ip_address(self): def ip_address(self) -> str:
"""Return device primary ip address.""" """Return device primary ip address."""
return self._params.get("address") return self._params["address"]
@property @property
def mac(self): def mac(self) -> str:
"""Return device mac.""" """Return device mac."""
return self._mac return self._mac
@property @property
def last_seen(self): def last_seen(self) -> datetime | None:
"""Return device last seen.""" """Return device last seen."""
return self._last_seen return self._last_seen
@property @property
def attrs(self): def attrs(self) -> dict[str, Any]:
"""Return device attributes.""" """Return device attributes."""
attr_data = self._wireless_params if self._wireless_params else self._params attr_data = self._wireless_params if self._wireless_params else self._params
for attr in ATTR_DEVICE_TRACKER: for attr in ATTR_DEVICE_TRACKER:
@ -80,7 +84,12 @@ class Device:
self._attrs["ip_address"] = self._params.get("active-address") self._attrs["ip_address"] = self._params.get("active-address")
return self._attrs return self._attrs
def update(self, wireless_params=None, params=None, active=False): def update(
self,
wireless_params: dict[str, Any] | None = None,
params: dict[str, Any] | None = None,
active: bool = False,
) -> None:
"""Update Device params.""" """Update Device params."""
if wireless_params: if wireless_params:
self._wireless_params = wireless_params self._wireless_params = wireless_params
@ -93,27 +102,26 @@ class Device:
class MikrotikData: class MikrotikData:
"""Handle all communication with the Mikrotik API.""" """Handle all communication with the Mikrotik API."""
def __init__(self, hass, config_entry, api): def __init__(
self, hass: HomeAssistant, config_entry: ConfigEntry, api: librouteros.Api
) -> None:
"""Initialize the Mikrotik Client.""" """Initialize the Mikrotik Client."""
self.hass = hass self.hass = hass
self.config_entry = config_entry self.config_entry = config_entry
self.api = api self.api = api
self._host = self.config_entry.data[CONF_HOST] self._host: str = self.config_entry.data[CONF_HOST]
self.all_devices = {} self.all_devices: dict[str, dict[str, Any]] = {}
self.devices = {} self.devices: dict[str, Device] = {}
self.available = True self.support_capsman: bool = False
self.support_capsman = False self.support_wireless: bool = False
self.support_wireless = False self.hostname: str = ""
self.hostname = None self.model: str = ""
self.model = None self.firmware: str = ""
self.firmware = None self.serial_number: str = ""
self.serial_number = None
@staticmethod @staticmethod
def load_mac(devices=None): def load_mac(devices: list[dict[str, Any]]) -> dict[str, dict[str, Any]]:
"""Load dictionary using MAC address as key.""" """Load dictionary using MAC address as key."""
if not devices:
return None
mac_devices = {} mac_devices = {}
for device in devices: for device in devices:
if "mac-address" in device: if "mac-address" in device:
@ -122,26 +130,23 @@ class MikrotikData:
return mac_devices return mac_devices
@property @property
def arp_enabled(self): def arp_enabled(self) -> bool:
"""Return arp_ping option setting.""" """Return arp_ping option setting."""
return self.config_entry.options[CONF_ARP_PING] return self.config_entry.options.get(CONF_ARP_PING, False)
@property @property
def force_dhcp(self): def force_dhcp(self) -> bool:
"""Return force_dhcp option setting.""" """Return force_dhcp option setting."""
return self.config_entry.options[CONF_FORCE_DHCP] return self.config_entry.options.get(CONF_FORCE_DHCP, False)
def get_info(self, param): def get_info(self, param: str) -> str:
"""Return device model name.""" """Return device model name."""
cmd = IDENTITY if param == NAME else INFO cmd = IDENTITY if param == NAME else INFO
data = self.command(MIKROTIK_SERVICES[cmd]) if data := self.command(MIKROTIK_SERVICES[cmd]):
return ( return str(data[0].get(param))
data[0].get(param) # pylint: disable=unsubscriptable-object return ""
if data
else None
)
def get_hub_details(self): def get_hub_details(self) -> None:
"""Get Hub info.""" """Get Hub info."""
self.hostname = self.get_info(NAME) self.hostname = self.get_info(NAME)
self.model = self.get_info(ATTR_MODEL) self.model = self.get_info(ATTR_MODEL)
@ -150,24 +155,17 @@ class MikrotikData:
self.support_capsman = bool(self.command(MIKROTIK_SERVICES[IS_CAPSMAN])) self.support_capsman = bool(self.command(MIKROTIK_SERVICES[IS_CAPSMAN]))
self.support_wireless = bool(self.command(MIKROTIK_SERVICES[IS_WIRELESS])) self.support_wireless = bool(self.command(MIKROTIK_SERVICES[IS_WIRELESS]))
def connect_to_hub(self): def get_list_from_interface(self, interface: str) -> dict[str, dict[str, Any]]:
"""Connect to hub."""
try:
self.api = get_api(self.hass, self.config_entry.data)
return True
except (LoginError, CannotConnect):
return False
def get_list_from_interface(self, interface):
"""Get devices from interface.""" """Get devices from interface."""
result = self.command(MIKROTIK_SERVICES[interface]) if result := self.command(MIKROTIK_SERVICES[interface]):
return self.load_mac(result) if result else {} return self.load_mac(result)
return {}
def restore_device(self, mac): def restore_device(self, mac: str) -> None:
"""Restore a missing device after restart.""" """Restore a missing device after restart."""
self.devices[mac] = Device(mac, self.all_devices[mac]) self.devices[mac] = Device(mac, self.all_devices[mac])
def update_devices(self): def update_devices(self) -> None:
"""Get list of devices with latest status.""" """Get list of devices with latest status."""
arp_devices = {} arp_devices = {}
device_list = {} device_list = {}
@ -192,7 +190,7 @@ class MikrotikData:
# get new hub firmware version if updated # get new hub firmware version if updated
self.firmware = self.get_info(ATTR_FIRMWARE) self.firmware = self.get_info(ATTR_FIRMWARE)
except (CannotConnect, socket.timeout, OSError) as err: except (CannotConnect, LoginError) as err:
raise UpdateFailed from err raise UpdateFailed from err
if not device_list: if not device_list:
@ -218,11 +216,12 @@ class MikrotikData:
active = True active = True
if self.arp_enabled and mac in arp_devices: if self.arp_enabled and mac in arp_devices:
active = self.do_arp_ping( active = self.do_arp_ping(
params.get("active-address"), arp_devices[mac].get("interface") str(params.get("active-address")),
str(arp_devices[mac].get("interface")),
) )
self.devices[mac].update(active=active) self.devices[mac].update(active=active)
def do_arp_ping(self, ip_address, interface): def do_arp_ping(self, ip_address: str, interface: str) -> bool:
"""Attempt to arp ping MAC address via interface.""" """Attempt to arp ping MAC address via interface."""
_LOGGER.debug("pinging - %s", ip_address) _LOGGER.debug("pinging - %s", ip_address)
params = { params = {
@ -234,9 +233,9 @@ class MikrotikData:
} }
cmd = "/ping" cmd = "/ping"
data = self.command(cmd, params) data = self.command(cmd, params)
if data is not None: if data:
status = 0 status = 0
for result in data: # pylint: disable=not-an-iterable for result in data:
if "status" in result: if "status" in result:
status += 1 status += 1
if status == len(data): if status == len(data):
@ -246,21 +245,24 @@ class MikrotikData:
return False return False
return True return True
def command(self, cmd, params=None): def command(
self, cmd: str, params: dict[str, Any] | None = None
) -> list[dict[str, Any]]:
"""Retrieve data from Mikrotik API.""" """Retrieve data from Mikrotik API."""
try: try:
_LOGGER.info("Running command %s", cmd) _LOGGER.info("Running command %s", cmd)
if params: if params:
response = list(self.api(cmd=cmd, **params)) return list(self.api(cmd=cmd, **params))
else: return list(self.api(cmd=cmd))
response = list(self.api(cmd=cmd))
except ( except (
librouteros.exceptions.ConnectionClosed, librouteros.exceptions.ConnectionClosed,
OSError, OSError,
socket.timeout, socket.timeout,
) as api_error: ) as api_error:
_LOGGER.error("Mikrotik %s connection error %s", self._host, api_error) _LOGGER.error("Mikrotik %s connection error %s", self._host, api_error)
if not self.connect_to_hub(): # try to reconnect
self.api = get_api(dict(self.config_entry.data))
# we still have to raise CannotConnect to fail the update.
raise CannotConnect from api_error raise CannotConnect from api_error
except librouteros.exceptions.ProtocolError as api_error: except librouteros.exceptions.ProtocolError as api_error:
_LOGGER.warning( _LOGGER.warning(
@ -269,106 +271,71 @@ class MikrotikData:
cmd, cmd,
api_error, api_error,
) )
return None return []
return response if response else None
class MikrotikDataUpdateCoordinator(DataUpdateCoordinator): class MikrotikDataUpdateCoordinator(DataUpdateCoordinator):
"""Mikrotik Hub Object.""" """Mikrotik Hub Object."""
def __init__(self, hass, config_entry): def __init__(
self, hass: HomeAssistant, config_entry: ConfigEntry, api: librouteros.Api
) -> None:
"""Initialize the Mikrotik Client.""" """Initialize the Mikrotik Client."""
self.hass = hass self.hass = hass
self.config_entry = config_entry self.config_entry: ConfigEntry = config_entry
self._mk_data = None self._mk_data = MikrotikData(self.hass, self.config_entry, api)
super().__init__( super().__init__(
self.hass, self.hass,
_LOGGER, _LOGGER,
name=f"{DOMAIN} - {self.host}", name=f"{DOMAIN} - {self.host}",
update_method=self.async_update,
update_interval=timedelta(seconds=10), update_interval=timedelta(seconds=10),
) )
@property @property
def host(self): def host(self) -> str:
"""Return the host of this hub.""" """Return the host of this hub."""
return self.config_entry.data[CONF_HOST] return self.config_entry.data[CONF_HOST]
@property @property
def hostname(self): def hostname(self) -> str:
"""Return the hostname of the hub.""" """Return the hostname of the hub."""
return self._mk_data.hostname return self._mk_data.hostname
@property @property
def model(self): def model(self) -> str:
"""Return the model of the hub.""" """Return the model of the hub."""
return self._mk_data.model return self._mk_data.model
@property @property
def firmware(self): def firmware(self) -> str:
"""Return the firmware of the hub.""" """Return the firmware of the hub."""
return self._mk_data.firmware return self._mk_data.firmware
@property @property
def serial_num(self): def serial_num(self) -> str:
"""Return the serial number of the hub.""" """Return the serial number of the hub."""
return self._mk_data.serial_number return self._mk_data.serial_number
@property @property
def available(self): def option_detection_time(self) -> timedelta:
"""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.""" """Config entry option defining number of seconds from last seen to away."""
return timedelta(seconds=self.config_entry.options[CONF_DETECTION_TIME]) return timedelta(
seconds=self.config_entry.options.get(
CONF_DETECTION_TIME, DEFAULT_DETECTION_TIME
)
)
@property @property
def api(self): def api(self) -> MikrotikData:
"""Represent Mikrotik data object.""" """Represent Mikrotik data object."""
return self._mk_data return self._mk_data
async def async_add_options(self): async def _async_update_data(self) -> None:
"""Populate default options for Mikrotik."""
if not self.config_entry.options:
data = dict(self.config_entry.data)
options = {
CONF_ARP_PING: data.pop(CONF_ARP_PING, False),
CONF_FORCE_DHCP: data.pop(CONF_FORCE_DHCP, False),
CONF_DETECTION_TIME: data.pop(
CONF_DETECTION_TIME, DEFAULT_DETECTION_TIME
),
}
self.hass.config_entries.async_update_entry(
self.config_entry, data=data, options=options
)
async def async_update(self):
"""Update Mikrotik devices information.""" """Update Mikrotik devices information."""
await self.hass.async_add_executor_job(self._mk_data.update_devices) await self.hass.async_add_executor_job(self._mk_data.update_devices)
async def async_setup(self):
"""Set up the Mikrotik hub."""
try:
api = await self.hass.async_add_executor_job(
get_api, self.hass, self.config_entry.data
)
except CannotConnect as api_error:
raise ConfigEntryNotReady from api_error
except LoginError:
return False
self._mk_data = MikrotikData(self.hass, self.config_entry, api) def get_api(entry: dict[str, Any]) -> librouteros.Api:
await self.async_add_options()
await self.hass.async_add_executor_job(self._mk_data.get_hub_details)
return True
def get_api(hass, entry):
"""Connect to Mikrotik hub.""" """Connect to Mikrotik hub."""
_LOGGER.debug("Connecting to Mikrotik hub [%s]", entry[CONF_HOST]) _LOGGER.debug("Connecting to Mikrotik hub [%s]", entry[CONF_HOST])

View file

@ -1,4 +1,7 @@
"""Tests for the Mikrotik component.""" """Tests for the Mikrotik component."""
from unittest.mock import patch
from homeassistant.components import mikrotik
from homeassistant.components.mikrotik.const import ( from homeassistant.components.mikrotik.const import (
CONF_ARP_PING, CONF_ARP_PING,
CONF_DETECTION_TIME, CONF_DETECTION_TIME,
@ -14,6 +17,8 @@ from homeassistant.const import (
CONF_VERIFY_SSL, CONF_VERIFY_SSL,
) )
from tests.common import MockConfigEntry
MOCK_DATA = { MOCK_DATA = {
CONF_NAME: "Mikrotik", CONF_NAME: "Mikrotik",
CONF_HOST: "0.0.0.0", CONF_HOST: "0.0.0.0",
@ -130,3 +135,38 @@ ARP_DATA = [
"disabled": False, "disabled": False,
}, },
] ]
async def setup_mikrotik_entry(hass, **kwargs):
"""Set up Mikrotik integration 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
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
)
config_entry.add_to_hass(hass)
if "force_dhcp" in kwargs:
config_entry.options = {**config_entry.options, "force_dhcp": True}
if "arp_ping" in kwargs:
config_entry.options = {**config_entry.options, "arp_ping": True}
with patch("librouteros.connect"), patch.object(
mikrotik.hub.MikrotikData, "command", new=mock_command
):
await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()

View file

@ -1,13 +1,14 @@
"""The tests for the Mikrotik device tracker platform.""" """The tests for the Mikrotik device tracker platform."""
from datetime import timedelta from datetime import timedelta
from freezegun import freeze_time
import pytest import pytest
from homeassistant.components import mikrotik from homeassistant.components import mikrotik
import homeassistant.components.device_tracker as device_tracker import homeassistant.components.device_tracker as device_tracker
from homeassistant.const import STATE_UNAVAILABLE
from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers import device_registry as dr, entity_registry as er
from homeassistant.setup import async_setup_component from homeassistant.util.dt import utcnow
import homeassistant.util.dt as dt_util
from . import ( from . import (
DEVICE_2_WIRELESS, DEVICE_2_WIRELESS,
@ -17,12 +18,10 @@ from . import (
MOCK_DATA, MOCK_DATA,
MOCK_OPTIONS, MOCK_OPTIONS,
WIRELESS_DATA, WIRELESS_DATA,
setup_mikrotik_entry,
) )
from .test_hub import setup_mikrotik_entry
from tests.common import MockConfigEntry, patch from tests.common import MockConfigEntry, async_fire_time_changed, patch
DEFAULT_DETECTION_TIME = timedelta(seconds=300)
@pytest.fixture @pytest.fixture
@ -56,24 +55,11 @@ def mock_command(self, cmd, params=None):
return {} return {}
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, mock_device_registry_devices): async def test_device_trackers(hass, mock_device_registry_devices):
"""Test device_trackers created by mikrotik.""" """Test device_trackers created by mikrotik."""
# test devices are added from wireless list only # test devices are added from wireless list only
hub = await setup_mikrotik_entry(hass) await setup_mikrotik_entry(hass)
device_1 = hass.states.get("device_tracker.device_1") device_1 = hass.states.get("device_tracker.device_1")
assert device_1 is not None assert device_1 is not None
@ -90,7 +76,7 @@ async def test_device_trackers(hass, mock_device_registry_devices):
# test device_2 is added after connecting to wireless network # test device_2 is added after connecting to wireless network
WIRELESS_DATA.append(DEVICE_2_WIRELESS) WIRELESS_DATA.append(DEVICE_2_WIRELESS)
await hub.async_refresh() async_fire_time_changed(hass, utcnow() + timedelta(seconds=10))
await hass.async_block_till_done() await hass.async_block_till_done()
device_2 = hass.states.get("device_tracker.device_2") device_2 = hass.states.get("device_tracker.device_2")
@ -104,26 +90,72 @@ async def test_device_trackers(hass, mock_device_registry_devices):
# test state remains home if last_seen consider_home_interval # test state remains home if last_seen consider_home_interval
del WIRELESS_DATA[1] # device 2 is removed from wireless list 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( with freeze_time(utcnow() + timedelta(minutes=4)):
minutes=4 async_fire_time_changed(hass, utcnow() + timedelta(minutes=4))
)
await hub.async_update()
await hass.async_block_till_done() await hass.async_block_till_done()
device_2 = hass.states.get("device_tracker.device_2") device_2 = hass.states.get("device_tracker.device_2")
assert device_2.state != "not_home" assert device_2.state == "home"
# test state changes to away if last_seen > consider_home_interval # 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( with freeze_time(utcnow() + timedelta(minutes=6)):
minutes=5 async_fire_time_changed(hass, utcnow() + timedelta(minutes=6))
)
await hub.async_refresh()
await hass.async_block_till_done() await hass.async_block_till_done()
device_2 = hass.states.get("device_tracker.device_2") device_2 = hass.states.get("device_tracker.device_2")
assert device_2.state == "not_home" assert device_2.state == "not_home"
async def test_force_dhcp(hass, mock_device_registry_devices):
"""Test updating hub that supports wireless with forced dhcp method."""
# hub supports wireless by default, force_dhcp is enabled to override
await setup_mikrotik_entry(hass, force_dhcp=False)
device_1 = hass.states.get("device_tracker.device_1")
assert device_1
assert device_1.state == "home"
# device_2 is not on the wireless list but it is still added from DHCP
device_2 = hass.states.get("device_tracker.device_2")
assert device_2
assert device_2.state == "home"
async def test_hub_not_support_wireless(hass, mock_device_registry_devices):
"""Test device_trackers created when hub doesn't support wireless."""
await setup_mikrotik_entry(hass, support_wireless=False)
device_1 = hass.states.get("device_tracker.device_1")
assert device_1
assert device_1.state == "home"
# device_2 is added from DHCP
device_2 = hass.states.get("device_tracker.device_2")
assert device_2
assert device_2.state == "home"
async def test_arp_ping_success(hass, mock_device_registry_devices):
"""Test arp ping devices to confirm they are connected."""
with patch.object(mikrotik.hub.MikrotikData, "do_arp_ping", return_value=True):
await setup_mikrotik_entry(hass, arp_ping=True, force_dhcp=True)
# test wired device_2 show as home if arp ping returns True
device_2 = hass.states.get("device_tracker.device_2")
assert device_2
assert device_2.state == "home"
async def test_arp_ping_timeout(hass, mock_device_registry_devices):
"""Test arp ping timeout so devices are shown away."""
with patch.object(mikrotik.hub.MikrotikData, "do_arp_ping", return_value=False):
await setup_mikrotik_entry(hass, arp_ping=True, force_dhcp=True)
# test wired device_2 show as not_home if arp ping times out
device_2 = hass.states.get("device_tracker.device_2")
assert device_2
assert device_2.state == "not_home"
async def test_device_trackers_numerical_name(hass, mock_device_registry_devices): async def test_device_trackers_numerical_name(hass, mock_device_registry_devices):
"""Test device_trackers created by mikrotik with numerical device name.""" """Test device_trackers created by mikrotik with numerical device name."""
@ -164,6 +196,13 @@ async def test_restoring_devices(hass):
suggested_object_id="device_2", suggested_object_id="device_2",
config_entry=config_entry, config_entry=config_entry,
) )
registry.async_get_or_create(
device_tracker.DOMAIN,
mikrotik.DOMAIN,
"00:00:00:00:00:03",
suggested_object_id="device_3",
config_entry=config_entry,
)
await setup_mikrotik_entry(hass) await setup_mikrotik_entry(hass)
@ -174,3 +213,22 @@ async def test_restoring_devices(hass):
device_2 = hass.states.get("device_tracker.device_2") device_2 = hass.states.get("device_tracker.device_2")
assert device_2 is not None assert device_2 is not None
assert device_2.state == "not_home" assert device_2.state == "not_home"
# device_3 is not on the DHCP list or wireless list
# so it won't be restored.
device_3 = hass.states.get("device_tracker.device_3")
assert device_3 is None
async def test_update_failed(hass, mock_device_registry_devices):
"""Test failing to connect during update."""
await setup_mikrotik_entry(hass)
with patch.object(
mikrotik.hub.MikrotikData, "command", side_effect=mikrotik.errors.CannotConnect
):
async_fire_time_changed(hass, utcnow() + timedelta(seconds=10))
await hass.async_block_till_done()
device_1 = hass.states.get("device_tracker.device_1")
assert device_1.state == STATE_UNAVAILABLE

View file

@ -1,120 +0,0 @@
"""Test Mikrotik hub."""
from unittest.mock import patch
from homeassistant.components import mikrotik
from . import ARP_DATA, DHCP_DATA, MOCK_DATA, MOCK_OPTIONS, WIRELESS_DATA
from tests.common import MockConfigEntry
async def setup_mikrotik_entry(hass, **kwargs):
"""Set up Mikrotik integration 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
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
)
config_entry.add_to_hass(hass)
if "force_dhcp" in kwargs:
config_entry.options = {**config_entry.options, "force_dhcp": True}
if "arp_ping" in kwargs:
config_entry.options = {**config_entry.options, "arp_ping": True}
with patch("librouteros.connect"), patch.object(
mikrotik.hub.MikrotikData, "command", new=mock_command
):
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]
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_refresh()
assert not hub.last_update_success
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 dhcp 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]
# devices not in wireless list will not be added
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]
# 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

View file

@ -39,7 +39,6 @@ async def test_successful_config_entry(hass):
await hass.config_entries.async_setup(entry.entry_id) await hass.config_entries.async_setup(entry.entry_id)
assert entry.state == ConfigEntryState.LOADED assert entry.state == ConfigEntryState.LOADED
assert hass.data[DOMAIN][entry.entry_id]
async def test_hub_conn_error(hass, mock_api): async def test_hub_conn_error(hass, mock_api):