diff --git a/custom_components/mikrotik_router/.translations/en.json b/custom_components/mikrotik_router/.translations/en.json index 1366ad6..5aca891 100644 --- a/custom_components/mikrotik_router/.translations/en.json +++ b/custom_components/mikrotik_router/.translations/en.json @@ -1,38 +1,38 @@ { - "config": { - "title": "Mikrotik Router", - "step": { - "user": { - "title": "Mikrotik Router", - "description": "Set up Mikrotik Router integration.", - "data": { - "name": "Name of the integration", - "host": "Host", - "port": "Port", - "username": "Username", - "password": "Password", - "ssl": "Use SSL" - } - } - }, - "error": { - "name_exists": "Name already exists.", - "cannot_connect": "Cannot connect to Mikrotik.", - "wrong_login": "Invalid user name or password.", - "routeros_api_missing": "Python module routeros_api not installed." - } - }, - "options": { - "step": { - "init": { - "data": {} - }, - "device_tracker": { - "data": { - "scan_interval": "Scan interval", - "track_arp": "Show client MAC and IP on interfaces" - } - } - } - } + "config": { + "title": "Mikrotik Router", + "step": { + "user": { + "title": "Mikrotik Router", + "description": "Set up Mikrotik Router integration.", + "data": { + "name": "Name of the integration", + "host": "Host", + "port": "Port", + "username": "Username", + "password": "Password", + "ssl": "Use SSL" + } + } + }, + "error": { + "name_exists": "Name already exists.", + "cannot_connect": "Cannot connect to Mikrotik.", + "wrong_login": "Invalid user name or password.", + "routeros_api_missing": "Python module routeros_api not installed." + } + }, + "options": { + "step": { + "init": { + "data": {} + }, + "device_tracker": { + "data": { + "scan_interval": "Scan interval", + "track_arp": "Show client MAC and IP on interfaces" + } + } + } + } } \ No newline at end of file diff --git a/custom_components/mikrotik_router/__init__.py b/custom_components/mikrotik_router/__init__.py index ab103e1..63b7abe 100644 --- a/custom_components/mikrotik_router/__init__.py +++ b/custom_components/mikrotik_router/__init__.py @@ -3,19 +3,19 @@ import logging from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.const import ( - CONF_NAME, - CONF_HOST, - CONF_PORT, - CONF_USERNAME, - CONF_PASSWORD, - CONF_SSL, + CONF_NAME, + CONF_HOST, + CONF_PORT, + CONF_USERNAME, + CONF_PASSWORD, + CONF_SSL, ) from .mikrotik_controller import MikrotikControllerData from .const import ( - DOMAIN, - DATA_CLIENT, + DOMAIN, + DATA_CLIENT, ) _LOGGER = logging.getLogger(__name__) @@ -25,61 +25,61 @@ _LOGGER = logging.getLogger(__name__) # async_setup # --------------------------- async def async_setup(hass, config): - """Set up configured Mikrotik Controller.""" - hass.data[DOMAIN] = {} - hass.data[DOMAIN][DATA_CLIENT] = {} - return True + """Set up configured Mikrotik Controller.""" + hass.data[DOMAIN] = {} + hass.data[DOMAIN][DATA_CLIENT] = {} + return True # --------------------------- # async_setup_entry # --------------------------- async def async_setup_entry(hass, config_entry): - """Set up Mikrotik Router as config entry.""" - name = config_entry.data[CONF_NAME] - host = config_entry.data[CONF_HOST] - port = config_entry.data[CONF_PORT] - username = config_entry.data[CONF_USERNAME] - password = config_entry.data[CONF_PASSWORD] - use_ssl = config_entry.data[CONF_SSL] - - mikrotik_controller = MikrotikControllerData(hass, config_entry, name, host, port, username, password, use_ssl) - await mikrotik_controller.hwinfo_update() - await mikrotik_controller.async_update() - - if not mikrotik_controller.data: - raise ConfigEntryNotReady() - - hass.data[DOMAIN][DATA_CLIENT][config_entry.entry_id] = mikrotik_controller - - # hass.async_create_task( - # hass.config_entries.async_forward_entry_setup(config_entry, "sensor") - # ) - - hass.async_create_task( - hass.config_entries.async_forward_entry_setup(config_entry, "device_tracker") - ) - - device_registry = await hass.helpers.device_registry.async_get_registry() - device_registry.async_get_or_create( - config_entry_id=config_entry.entry_id, - manufacturer=mikrotik_controller.data['resource']['platform'], - model=mikrotik_controller.data['routerboard']['model'], - name=mikrotik_controller.data['routerboard']['model'], - sw_version=mikrotik_controller.data['resource']['version'], - ) - - return True + """Set up Mikrotik Router as config entry.""" + name = config_entry.data[CONF_NAME] + host = config_entry.data[CONF_HOST] + port = config_entry.data[CONF_PORT] + username = config_entry.data[CONF_USERNAME] + password = config_entry.data[CONF_PASSWORD] + use_ssl = config_entry.data[CONF_SSL] + + mikrotik_controller = MikrotikControllerData(hass, config_entry, name, host, port, username, password, use_ssl) + await mikrotik_controller.hwinfo_update() + await mikrotik_controller.async_update() + + if not mikrotik_controller.data: + raise ConfigEntryNotReady() + + hass.data[DOMAIN][DATA_CLIENT][config_entry.entry_id] = mikrotik_controller + + # hass.async_create_task( + # hass.config_entries.async_forward_entry_setup(config_entry, "sensor") + # ) + + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(config_entry, "device_tracker") + ) + + device_registry = await hass.helpers.device_registry.async_get_registry() + device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + manufacturer=mikrotik_controller.data['resource']['platform'], + model=mikrotik_controller.data['routerboard']['model'], + name=mikrotik_controller.data['routerboard']['model'], + sw_version=mikrotik_controller.data['resource']['version'], + ) + + return True # --------------------------- # async_unload_entry # --------------------------- async def async_unload_entry(hass, config_entry): - """Unload a config entry.""" - mikrotik_controller = hass.data[DOMAIN][DATA_CLIENT][config_entry.entry_id] - await hass.config_entries.async_forward_entry_unload(config_entry, "sensor") - await hass.config_entries.async_forward_entry_unload(config_entry, "device_tracker") - await mikrotik_controller.async_reset() - hass.data[DOMAIN][DATA_CLIENT].pop(config_entry.entry_id) - return True + """Unload a config entry.""" + mikrotik_controller = hass.data[DOMAIN][DATA_CLIENT][config_entry.entry_id] + await hass.config_entries.async_forward_entry_unload(config_entry, "sensor") + await hass.config_entries.async_forward_entry_unload(config_entry, "device_tracker") + await mikrotik_controller.async_reset() + hass.data[DOMAIN][DATA_CLIENT].pop(config_entry.entry_id) + return True diff --git a/custom_components/mikrotik_router/config_flow.py b/custom_components/mikrotik_router/config_flow.py index d2cdac9..2b8eea8 100644 --- a/custom_components/mikrotik_router/config_flow.py +++ b/custom_components/mikrotik_router/config_flow.py @@ -5,20 +5,20 @@ import voluptuous as vol from homeassistant import config_entries from homeassistant.core import callback from homeassistant.const import ( - CONF_NAME, - CONF_HOST, - CONF_PORT, - CONF_USERNAME, - CONF_PASSWORD, - CONF_SSL, + CONF_NAME, + CONF_HOST, + CONF_PORT, + CONF_USERNAME, + CONF_PASSWORD, + CONF_SSL, ) from .const import ( - DOMAIN, - CONF_TRACK_ARP, - DEFAULT_TRACK_ARP, - CONF_SCAN_INTERVAL, - DEFAULT_SCAN_INTERVAL, + DOMAIN, + CONF_TRACK_ARP, + DEFAULT_TRACK_ARP, + CONF_SCAN_INTERVAL, + DEFAULT_SCAN_INTERVAL, ) from .mikrotikapi import MikrotikAPI @@ -30,110 +30,110 @@ _LOGGER = logging.getLogger(__name__) # --------------------------- @callback def configured_instances(hass): - """Return a set of configured instances.""" - return set( - entry.data[CONF_NAME] for entry in hass.config_entries.async_entries(DOMAIN) - ) + """Return a set of configured instances.""" + return set( + entry.data[CONF_NAME] for entry in hass.config_entries.async_entries(DOMAIN) + ) # --------------------------- # MikrotikControllerConfigFlow # --------------------------- class MikrotikControllerConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): - def __init__(self): - """Initialize.""" - return - - @staticmethod - @callback - def async_get_options_flow(config_entry): - """Get the options flow for this handler.""" - return MikrotikControllerOptionsFlowHandler(config_entry) - - async def async_step_import(self, user_input=None): - """Occurs when a previously entry setup fails and is re-initiated.""" - return await self.async_step_user(user_input) - - async def async_step_user(self, user_input=None): - """Handle a flow initialized by the user.""" - errors = {} - if user_input is not None: - # Check if instance with this name already exists - if user_input[CONF_NAME] in configured_instances(self.hass): - errors["base"] = "name_exists" - - # Test connection - api = MikrotikAPI(host=user_input["host"], username=user_input["username"], password=user_input["password"], port=user_input["port"], use_ssl=user_input["ssl"]) - if not api.connect(): - errors[CONF_HOST] = api.error - - # Save instance - if not errors: - return self.async_create_entry( - title=user_input[CONF_NAME], - data=user_input - ) - - return self._show_config_form(host=user_input["host"], username=user_input["username"], password=user_input["password"], port=user_input["port"], name=user_input["name"], use_ssl=user_input["ssl"], errors=errors) - - return self._show_config_form(errors=errors) - - # --------------------------- - # _show_config_form - # --------------------------- - def _show_config_form(self, host='10.0.0.1', username='admin', password='admin', port=0, name='Mikrotik', use_ssl=False, errors=None): - """Show the configuration form to edit data.""" - return self.async_show_form( - step_id='user', - data_schema=vol.Schema({ - vol.Required(CONF_HOST, default=host): str, - vol.Required(CONF_USERNAME, default=username): str, - vol.Required(CONF_PASSWORD, default=password): str, - vol.Optional(CONF_PORT, default=port): int, - vol.Optional(CONF_NAME, default=name): str, - vol.Optional(CONF_SSL, default=use_ssl): bool, - }), - errors=errors, - ) + def __init__(self): + """Initialize.""" + return + + @staticmethod + @callback + def async_get_options_flow(config_entry): + """Get the options flow for this handler.""" + return MikrotikControllerOptionsFlowHandler(config_entry) + + async def async_step_import(self, user_input=None): + """Occurs when a previously entry setup fails and is re-initiated.""" + return await self.async_step_user(user_input) + + async def async_step_user(self, user_input=None): + """Handle a flow initialized by the user.""" + errors = {} + if user_input is not None: + # Check if instance with this name already exists + if user_input[CONF_NAME] in configured_instances(self.hass): + errors["base"] = "name_exists" + + # Test connection + api = MikrotikAPI(host=user_input["host"], username=user_input["username"], password=user_input["password"], port=user_input["port"], use_ssl=user_input["ssl"]) + if not api.connect(): + errors[CONF_HOST] = api.error + + # Save instance + if not errors: + return self.async_create_entry( + title=user_input[CONF_NAME], + data=user_input + ) + + return self._show_config_form(host=user_input["host"], username=user_input["username"], password=user_input["password"], port=user_input["port"], name=user_input["name"], use_ssl=user_input["ssl"], errors=errors) + + return self._show_config_form(errors=errors) + + # --------------------------- + # _show_config_form + # --------------------------- + def _show_config_form(self, host='10.0.0.1', username='admin', password='admin', port=0, name='Mikrotik', use_ssl=False, errors=None): + """Show the configuration form to edit data.""" + return self.async_show_form( + step_id='user', + data_schema=vol.Schema({ + vol.Required(CONF_HOST, default=host): str, + vol.Required(CONF_USERNAME, default=username): str, + vol.Required(CONF_PASSWORD, default=password): str, + vol.Optional(CONF_PORT, default=port): int, + vol.Optional(CONF_NAME, default=name): str, + vol.Optional(CONF_SSL, default=use_ssl): bool, + }), + errors=errors, + ) # --------------------------- # MikrotikControllerOptionsFlowHandler # --------------------------- class MikrotikControllerOptionsFlowHandler(config_entries.OptionsFlow): - """Handle options.""" - - def __init__(self, config_entry): - """Initialize options flow.""" - self.config_entry = config_entry - self.options = dict(config_entry.options) - - async def async_step_init(self, user_input=None): - """Manage the options.""" - return await self.async_step_device_tracker(user_input) - - async def async_step_device_tracker(self, user_input=None): - """Manage the device tracker options.""" - if user_input is not None: - self.options.update(user_input) - return self.async_create_entry(title="", data=self.options) - - return self.async_show_form( - step_id="device_tracker", - data_schema=vol.Schema( - { - vol.Optional( - CONF_TRACK_ARP, - default=self.config_entry.options.get( - CONF_TRACK_ARP, DEFAULT_TRACK_ARP - ), - ): bool, - vol.Optional( - CONF_SCAN_INTERVAL, - default=self.config_entry.options.get( - CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL - ), - ): int, - } - ), - ) + """Handle options.""" + + def __init__(self, config_entry): + """Initialize options flow.""" + self.config_entry = config_entry + self.options = dict(config_entry.options) + + async def async_step_init(self, user_input=None): + """Manage the options.""" + return await self.async_step_device_tracker(user_input) + + async def async_step_device_tracker(self, user_input=None): + """Manage the device tracker options.""" + if user_input is not None: + self.options.update(user_input) + return self.async_create_entry(title="", data=self.options) + + return self.async_show_form( + step_id="device_tracker", + data_schema=vol.Schema( + { + vol.Optional( + CONF_TRACK_ARP, + default=self.config_entry.options.get( + CONF_TRACK_ARP, DEFAULT_TRACK_ARP + ), + ): bool, + vol.Optional( + CONF_SCAN_INTERVAL, + default=self.config_entry.options.get( + CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL + ), + ): int, + } + ), + ) diff --git a/custom_components/mikrotik_router/device_tracker.py b/custom_components/mikrotik_router/device_tracker.py index f53b0fb..6ff2215 100644 --- a/custom_components/mikrotik_router/device_tracker.py +++ b/custom_components/mikrotik_router/device_tracker.py @@ -7,31 +7,31 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.components.device_tracker.config_entry import ScannerEntity from homeassistant.components.device_tracker.const import SOURCE_TYPE_ROUTER from homeassistant.const import ( - CONF_NAME, - ATTR_ATTRIBUTION, + CONF_NAME, + ATTR_ATTRIBUTION, ) from .const import ( - DOMAIN, - DATA_CLIENT, - ATTRIBUTION, + DOMAIN, + DATA_CLIENT, + ATTRIBUTION, ) _LOGGER = logging.getLogger(__name__) DEVICE_ATTRIBUTES = [ - "running", - "enabled", - "comment", - "client-ip-address", - "client-mac-address", - "port-mac-address", - "last-link-down-time", - "last-link-up-time", - "link-downs", - "actual-mtu", - "type", - "name", - "default-name", + "running", + "enabled", + "comment", + "client-ip-address", + "client-mac-address", + "port-mac-address", + "last-link-down-time", + "last-link-up-time", + "link-downs", + "actual-mtu", + "type", + "name", + "default-name", ] @@ -39,22 +39,22 @@ DEVICE_ATTRIBUTES = [ # async_setup_entry # --------------------------- async def async_setup_entry(hass, config_entry, async_add_entities): - """Set up device tracker for Mikrotik Router component.""" - name = config_entry.data[CONF_NAME] - mikrotik_controller = hass.data[DOMAIN][DATA_CLIENT][config_entry.entry_id] - tracked = {} - - @callback - def update_controller(): - """Update the values of the controller.""" - update_items(name, mikrotik_controller, async_add_entities, tracked) - - mikrotik_controller.listeners.append( - async_dispatcher_connect(hass, mikrotik_controller.signal_update, update_controller) - ) - - update_controller() - return + """Set up device tracker for Mikrotik Router component.""" + name = config_entry.data[CONF_NAME] + mikrotik_controller = hass.data[DOMAIN][DATA_CLIENT][config_entry.entry_id] + tracked = {} + + @callback + def update_controller(): + """Update the values of the controller.""" + update_items(name, mikrotik_controller, async_add_entities, tracked) + + mikrotik_controller.listeners.append( + async_dispatcher_connect(hass, mikrotik_controller.signal_update, update_controller) + ) + + update_controller() + return # --------------------------- @@ -62,111 +62,111 @@ async def async_setup_entry(hass, config_entry, async_add_entities): # --------------------------- @callback def update_items(name, mikrotik_controller, async_add_entities, tracked): - """Update tracked device state from the controller.""" - new_tracked = [] - - for uid in mikrotik_controller.data['interface']: - if mikrotik_controller.data['interface'][uid]['type'] == "ether": - item_id = name + "-" + mikrotik_controller.data['interface'][uid]['default-name'] - if item_id in tracked: - if tracked[item_id].enabled: - tracked[item_id].async_schedule_update_ha_state() - continue - - tracked[item_id] = MikrotikControllerPortDeviceTracker(name, uid, mikrotik_controller) - new_tracked.append(tracked[item_id]) - - if new_tracked: - async_add_entities(new_tracked) - - return + """Update tracked device state from the controller.""" + new_tracked = [] + + for uid in mikrotik_controller.data['interface']: + if mikrotik_controller.data['interface'][uid]['type'] == "ether": + item_id = name + "-" + mikrotik_controller.data['interface'][uid]['default-name'] + if item_id in tracked: + if tracked[item_id].enabled: + tracked[item_id].async_schedule_update_ha_state() + continue + + tracked[item_id] = MikrotikControllerPortDeviceTracker(name, uid, mikrotik_controller) + new_tracked.append(tracked[item_id]) + + if new_tracked: + async_add_entities(new_tracked) + + return # --------------------------- # MikrotikControllerPortDeviceTracker # --------------------------- class MikrotikControllerPortDeviceTracker(ScannerEntity): - """Representation of a network port.""" - - def __init__(self, name, uid, mikrotik_controller): - """Set up tracked port.""" - self._name = name - self._uid = uid - self.mikrotik_controller = mikrotik_controller - - self._attrs = { - ATTR_ATTRIBUTION: ATTRIBUTION, - } - - @property - def entity_registry_enabled_default(self): - """Return if the entity should be enabled when first added to the entity registry.""" - return True - - async def async_added_to_hass(self): - """Port entity created.""" - _LOGGER.debug("New port tracker %s (%s)", self._name, self.mikrotik_controller.data['interface'][self._uid]['port-mac-address']) - return - - async def async_update(self): - """Synchronize state with controller.""" - # await self.mikrotik_controller.async_update() - return - - @property - def is_connected(self): - """Return true if the port is connected to the network.""" - return self.mikrotik_controller.data['interface'][self._uid]['running'] - - @property - def source_type(self): - """Return the source type of the port.""" - return SOURCE_TYPE_ROUTER - - @property - def name(self) -> str: - """Return the name of the port.""" - return self.mikrotik_controller.data['interface'][self._uid]['default-name'] - - @property - def unique_id(self) -> str: - """Return a unique identifier for this port.""" - return f"{self._name.lower()}-{self.mikrotik_controller.data['interface'][self._uid]['port-mac-address']}" - - @property - def available(self) -> bool: - """Return if controller is available.""" - return self.mikrotik_controller.connected() - - @property - def icon(self): - """Return the icon.""" - if not self.mikrotik_controller.data['interface'][self._uid]['enabled']: - return 'mdi:lan-disconnect' - - if self.mikrotik_controller.data['interface'][self._uid]['running']: - return 'mdi:lan-connect' - else: - return 'mdi:lan-pending' - - @property - def device_info(self): - """Return a port description for device registry.""" - info = { - "connections": {(CONNECTION_NETWORK_MAC, self.mikrotik_controller.data['interface'][self._uid]['port-mac-address'])}, - "manufacturer": self.mikrotik_controller.data['resource']['platform'], - "model": "Port", - "name": self.mikrotik_controller.data['interface'][self._uid]['default-name'], - } - return info - - @property - def device_state_attributes(self): - """Return the port state attributes.""" - attributes = self._attrs - - for variable in DEVICE_ATTRIBUTES: - if variable in self.mikrotik_controller.data['interface'][self._uid]: - attributes[variable] = self.mikrotik_controller.data['interface'][self._uid][variable] - - return attributes + """Representation of a network port.""" + + def __init__(self, name, uid, mikrotik_controller): + """Set up tracked port.""" + self._name = name + self._uid = uid + self.mikrotik_controller = mikrotik_controller + + self._attrs = { + ATTR_ATTRIBUTION: ATTRIBUTION, + } + + @property + def entity_registry_enabled_default(self): + """Return if the entity should be enabled when first added to the entity registry.""" + return True + + async def async_added_to_hass(self): + """Port entity created.""" + _LOGGER.debug("New port tracker %s (%s)", self._name, self.mikrotik_controller.data['interface'][self._uid]['port-mac-address']) + return + + async def async_update(self): + """Synchronize state with controller.""" + # await self.mikrotik_controller.async_update() + return + + @property + def is_connected(self): + """Return true if the port is connected to the network.""" + return self.mikrotik_controller.data['interface'][self._uid]['running'] + + @property + def source_type(self): + """Return the source type of the port.""" + return SOURCE_TYPE_ROUTER + + @property + def name(self) -> str: + """Return the name of the port.""" + return self.mikrotik_controller.data['interface'][self._uid]['default-name'] + + @property + def unique_id(self) -> str: + """Return a unique identifier for this port.""" + return f"{self._name.lower()}-{self.mikrotik_controller.data['interface'][self._uid]['port-mac-address']}" + + @property + def available(self) -> bool: + """Return if controller is available.""" + return self.mikrotik_controller.connected() + + @property + def icon(self): + """Return the icon.""" + if not self.mikrotik_controller.data['interface'][self._uid]['enabled']: + return 'mdi:lan-disconnect' + + if self.mikrotik_controller.data['interface'][self._uid]['running']: + return 'mdi:lan-connect' + else: + return 'mdi:lan-pending' + + @property + def device_info(self): + """Return a port description for device registry.""" + info = { + "connections": {(CONNECTION_NETWORK_MAC, self.mikrotik_controller.data['interface'][self._uid]['port-mac-address'])}, + "manufacturer": self.mikrotik_controller.data['resource']['platform'], + "model": "Port", + "name": self.mikrotik_controller.data['interface'][self._uid]['default-name'], + } + return info + + @property + def device_state_attributes(self): + """Return the port state attributes.""" + attributes = self._attrs + + for variable in DEVICE_ATTRIBUTES: + if variable in self.mikrotik_controller.data['interface'][self._uid]: + attributes[variable] = self.mikrotik_controller.data['interface'][self._uid][variable] + + return attributes diff --git a/custom_components/mikrotik_router/manifest.json b/custom_components/mikrotik_router/manifest.json index ec640a4..e9bfac1 100644 --- a/custom_components/mikrotik_router/manifest.json +++ b/custom_components/mikrotik_router/manifest.json @@ -1,13 +1,13 @@ { - "domain": "mikrotik_router", - "name": "Mikrotik Router", - "config_flow": true, - "documentation": "https://github.com/tomaae/homeassistant-mikrotik_router", - "dependencies": [], - "requirements": [ - "librouteros==3.0.0" - ], - "codeowners": [ - "@tomaae" - ] + "domain": "mikrotik_router", + "name": "Mikrotik Router", + "config_flow": true, + "documentation": "https://github.com/tomaae/homeassistant-mikrotik_router", + "dependencies": [], + "requirements": [ + "librouteros==3.0.0" + ], + "codeowners": [ + "@tomaae" + ] } \ No newline at end of file diff --git a/custom_components/mikrotik_router/mikrotik_controller.py b/custom_components/mikrotik_router/mikrotik_controller.py index f2c3484..da5cd89 100644 --- a/custom_components/mikrotik_router/mikrotik_controller.py +++ b/custom_components/mikrotik_router/mikrotik_controller.py @@ -7,11 +7,11 @@ from homeassistant.helpers.event import async_track_time_interval # from homeassistant.util import Throttle from .const import ( - DOMAIN, - CONF_TRACK_ARP, - DEFAULT_TRACK_ARP, - CONF_SCAN_INTERVAL, - DEFAULT_SCAN_INTERVAL, + DOMAIN, + CONF_TRACK_ARP, + DEFAULT_TRACK_ARP, + CONF_SCAN_INTERVAL, + DEFAULT_SCAN_INTERVAL, ) from .mikrotikapi import MikrotikAPI @@ -24,257 +24,257 @@ _LOGGER = logging.getLogger(__name__) # MikrotikControllerData # --------------------------- class MikrotikControllerData(): - def __init__(self, hass, config_entry, name, host, port, username, password, use_ssl): - """Initialize.""" - self.name = name - self.hass = hass - self.config_entry = config_entry - - self.data = {} - self.data['routerboard'] = {} - self.data['resource'] = {} - self.data['interface'] = {} - self.data['arp'] = {} - - self.listeners = [] - - self.api = MikrotikAPI(host, username, password, port, use_ssl) - if not self.api.connect(): - self.api = None - - async_track_time_interval(self.hass, self.force_update, self.option_scan_interval) - - return - - async def force_update(self, now=None): - """Periodic update.""" - await self.async_update() - return - - # --------------------------- - # option_track_arp - # --------------------------- - @property - def option_track_arp(self): - """Config entry option to not track ARP.""" - return self.config_entry.options.get(CONF_TRACK_ARP, DEFAULT_TRACK_ARP) - - # --------------------------- - # option_scan_interval - # --------------------------- - @property - def option_scan_interval(self): - """Config entry option scan interval.""" - scan_interval = self.config_entry.options.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL) - return timedelta(seconds=scan_interval) - - # --------------------------- - # signal_update - # --------------------------- - @property - def signal_update(self): - """Event specific per UniFi entry to signal new data.""" - return f"{DOMAIN}-update-{self.name}" - - # --------------------------- - # connected - # --------------------------- - def connected(self): - """Return connected boolean.""" - return self.api.connected() - - # --------------------------- - # hwinfo_update - # --------------------------- - async def hwinfo_update(self): - """Update Mikrotik hardware info.""" - self.get_system_routerboard() - self.get_system_resource() - return - - # --------------------------- - # async_update - # --------------------------- - # @Throttle(DEFAULT_SCAN_INTERVAL) - async def async_update(self): - """Update Mikrotik Controller data.""" - - self.get_interfaces() - self.get_arp() - - async_dispatcher_send(self.hass, self.signal_update) - return - - # --------------------------- - # async_reset - # --------------------------- - async def async_reset(self): - """Reset this controller to default state.""" - for unsub_dispatcher in self.listeners: - unsub_dispatcher() - - self.listeners = [] - return True - - # --------------------------- - # get_interfaces - # --------------------------- - def get_interfaces(self): - ifaces = self.api.path("/interface") - for iface in ifaces: - if 'default-name' not in iface: - continue - - uid = iface['default-name'] - if uid not in self.data['interface']: - self.data['interface'][uid] = {} - - self.data['interface'][uid]['default-name'] = iface['default-name'] - self.data['interface'][uid]['name'] = iface['name'] if 'name' in iface else iface['default-name'] - self.data['interface'][uid]['type'] = iface['type'] if 'type' in iface else "unknown" - self.data['interface'][uid]['running'] = True if iface['running'] else False - self.data['interface'][uid]['enabled'] = True if not iface['disabled'] else False - self.data['interface'][uid]['port-mac-address'] = iface['mac-address'] if 'mac-address' in iface else "" - self.data['interface'][uid]['comment'] = iface['comment'] if 'comment' in iface else "" - self.data['interface'][uid]['last-link-down-time'] = iface['last-link-down-time'] if 'last-link-down-time' in iface else "" - self.data['interface'][uid]['last-link-up-time'] = iface['last-link-up-time'] if 'last-link-up-time' in iface else "" - self.data['interface'][uid]['link-downs'] = iface['link-downs'] if 'link-downs' in iface else "" - self.data['interface'][uid]['rx-byte'] = iface['rx-byte'] if 'rx-byte' in iface else "" - self.data['interface'][uid]['tx-byte'] = iface['tx-byte'] if 'tx-byte' in iface else "" - self.data['interface'][uid]['tx-queue-drop'] = iface['tx-queue-drop'] if 'tx-queue-drop' in iface else "" - self.data['interface'][uid]['actual-mtu'] = iface['actual-mtu'] if 'actual-mtu' in iface else "" - - if 'client-ip-address' not in self.data['interface'][uid]: - self.data['interface'][uid]['client-ip-address'] = "" - - if 'client-mac-address' not in self.data['interface'][uid]: - self.data['interface'][uid]['client-mac-address'] = "" - - return - - # --------------------------- - # get_arp - # --------------------------- - def get_arp(self): - self.data['arp'] = {} - if not self.option_track_arp: - for uid in self.data['interface']: - self.data['interface'][uid]['client-ip-address'] = "disabled" - self.data['interface'][uid]['client-mac-address'] = "disabled" - return False - - mac2ip = {} - bridge_used = False - data = self.api.path("/ip/arp") - for entry in data: - # Ignore invalid entries - if entry['invalid']: - continue - - # Do not add ARP detected on bridge - if entry['interface'] == "bridge": - bridge_used = True - # Build address table on bridge - if 'mac-address' in entry and 'address' in entry: - mac2ip[entry['mac-address']] = entry['address'] - - continue - - # Get iface default-name from custom name - uid = None - for ifacename in self.data['interface']: - if self.data['interface'][ifacename]['name'] == entry['interface']: - uid = self.data['interface'][ifacename]['default-name'] - break - - if not uid: - continue - - # Create uid arp dict - if uid not in self.data['arp']: - self.data['arp'][uid] = {} - - # Add data - self.data['arp'][uid]['interface'] = uid - if 'mac-address' in self.data['arp'][uid]: - self.data['arp'][uid]['mac-address'] = "multiple" - else: - self.data['arp'][uid]['mac-address'] = entry['mac-address'] if 'mac-address' in entry else "" - - if 'address' in self.data['arp'][uid]: - self.data['arp'][uid]['address'] = "multiple" - else: - self.data['arp'][uid]['address'] = entry['address'] if 'address' in entry else "" - - if bridge_used: - self.update_bridge_hosts(mac2ip) - - # Map ARP to ifaces - for uid in self.data['interface']: - self.data['interface'][uid]['client-ip-address'] = self.data['arp'][uid]['address'] if uid in self.data['arp'] and 'address' in self.data['arp'][uid] else "" - self.data['interface'][uid]['client-mac-address'] = self.data['arp'][uid]['mac-address'] if uid in self.data['arp'] and 'mac-address' in self.data['arp'][uid] else "" - - return True - - # --------------------------- - # update_bridge_hosts - # --------------------------- - def update_bridge_hosts(self, mac2ip): - data = self.api.path("/interface/bridge/host") - for entry in data: - # Ignore port MAC - if entry['local']: - continue - - # Get iface default-name from custom name - uid = None - for ifacename in self.data['interface']: - if self.data['interface'][ifacename]['name'] == entry['interface']: - uid = self.data['interface'][ifacename]['default-name'] - break - - if not uid: - continue - - # Create uid arp dict - if uid not in self.data['arp']: - self.data['arp'][uid] = {} - - # Add data - self.data['arp'][uid]['interface'] = uid - if 'mac-address' in self.data['arp'][uid]: - self.data['arp'][uid]['mac-address'] = "multiple" - self.data['arp'][uid]['address'] = "multiple" - else: - self.data['arp'][uid]['mac-address'] = entry['mac-address'] if 'mac-address' in entry else "" - self.data['arp'][uid]['address'] = "" - - if self.data['arp'][uid]['address'] == "" and self.data['arp'][uid]['mac-address'] in mac2ip: - self.data['arp'][uid]['address'] = mac2ip[self.data['arp'][uid]['mac-address']] - - return - - # --------------------------- - # get_system_routerboard - # --------------------------- - def get_system_routerboard(self): - data = self.api.path("/system/routerboard") - for entry in data: - self.data['routerboard']['routerboard'] = True if entry['routerboard'] else False - self.data['routerboard']['model'] = entry['model'] if 'model' in entry else "unknown" - self.data['routerboard']['serial-number'] = entry['serial-number'] if 'serial-number' in entry else "unknown" - self.data['routerboard']['firmware'] = entry['current-firmware'] if 'current-firmware' in entry else "unknown" - - return - - # --------------------------- - # get_system_resource - # --------------------------- - def get_system_resource(self): - data = self.api.path("/system/resource") - for entry in data: - self.data['resource']['platform'] = entry['platform'] if 'platform' in entry else "unknown" - self.data['resource']['board-name'] = entry['board-name'] if 'board-name' in entry else "unknown" - self.data['resource']['version'] = entry['version'] if 'version' in entry else "unknown" - - return + def __init__(self, hass, config_entry, name, host, port, username, password, use_ssl): + """Initialize.""" + self.name = name + self.hass = hass + self.config_entry = config_entry + + self.data = {} + self.data['routerboard'] = {} + self.data['resource'] = {} + self.data['interface'] = {} + self.data['arp'] = {} + + self.listeners = [] + + self.api = MikrotikAPI(host, username, password, port, use_ssl) + if not self.api.connect(): + self.api = None + + async_track_time_interval(self.hass, self.force_update, self.option_scan_interval) + + return + + async def force_update(self, now=None): + """Periodic update.""" + await self.async_update() + return + + # --------------------------- + # option_track_arp + # --------------------------- + @property + def option_track_arp(self): + """Config entry option to not track ARP.""" + return self.config_entry.options.get(CONF_TRACK_ARP, DEFAULT_TRACK_ARP) + + # --------------------------- + # option_scan_interval + # --------------------------- + @property + def option_scan_interval(self): + """Config entry option scan interval.""" + scan_interval = self.config_entry.options.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL) + return timedelta(seconds=scan_interval) + + # --------------------------- + # signal_update + # --------------------------- + @property + def signal_update(self): + """Event specific per UniFi entry to signal new data.""" + return f"{DOMAIN}-update-{self.name}" + + # --------------------------- + # connected + # --------------------------- + def connected(self): + """Return connected boolean.""" + return self.api.connected() + + # --------------------------- + # hwinfo_update + # --------------------------- + async def hwinfo_update(self): + """Update Mikrotik hardware info.""" + self.get_system_routerboard() + self.get_system_resource() + return + + # --------------------------- + # async_update + # --------------------------- + # @Throttle(DEFAULT_SCAN_INTERVAL) + async def async_update(self): + """Update Mikrotik Controller data.""" + + self.get_interfaces() + self.get_arp() + + async_dispatcher_send(self.hass, self.signal_update) + return + + # --------------------------- + # async_reset + # --------------------------- + async def async_reset(self): + """Reset this controller to default state.""" + for unsub_dispatcher in self.listeners: + unsub_dispatcher() + + self.listeners = [] + return True + + # --------------------------- + # get_interfaces + # --------------------------- + def get_interfaces(self): + ifaces = self.api.path("/interface") + for iface in ifaces: + if 'default-name' not in iface: + continue + + uid = iface['default-name'] + if uid not in self.data['interface']: + self.data['interface'][uid] = {} + + self.data['interface'][uid]['default-name'] = iface['default-name'] + self.data['interface'][uid]['name'] = iface['name'] if 'name' in iface else iface['default-name'] + self.data['interface'][uid]['type'] = iface['type'] if 'type' in iface else "unknown" + self.data['interface'][uid]['running'] = True if iface['running'] else False + self.data['interface'][uid]['enabled'] = True if not iface['disabled'] else False + self.data['interface'][uid]['port-mac-address'] = iface['mac-address'] if 'mac-address' in iface else "" + self.data['interface'][uid]['comment'] = iface['comment'] if 'comment' in iface else "" + self.data['interface'][uid]['last-link-down-time'] = iface['last-link-down-time'] if 'last-link-down-time' in iface else "" + self.data['interface'][uid]['last-link-up-time'] = iface['last-link-up-time'] if 'last-link-up-time' in iface else "" + self.data['interface'][uid]['link-downs'] = iface['link-downs'] if 'link-downs' in iface else "" + self.data['interface'][uid]['rx-byte'] = iface['rx-byte'] if 'rx-byte' in iface else "" + self.data['interface'][uid]['tx-byte'] = iface['tx-byte'] if 'tx-byte' in iface else "" + self.data['interface'][uid]['tx-queue-drop'] = iface['tx-queue-drop'] if 'tx-queue-drop' in iface else "" + self.data['interface'][uid]['actual-mtu'] = iface['actual-mtu'] if 'actual-mtu' in iface else "" + + if 'client-ip-address' not in self.data['interface'][uid]: + self.data['interface'][uid]['client-ip-address'] = "" + + if 'client-mac-address' not in self.data['interface'][uid]: + self.data['interface'][uid]['client-mac-address'] = "" + + return + + # --------------------------- + # get_arp + # --------------------------- + def get_arp(self): + self.data['arp'] = {} + if not self.option_track_arp: + for uid in self.data['interface']: + self.data['interface'][uid]['client-ip-address'] = "disabled" + self.data['interface'][uid]['client-mac-address'] = "disabled" + return False + + mac2ip = {} + bridge_used = False + data = self.api.path("/ip/arp") + for entry in data: + # Ignore invalid entries + if entry['invalid']: + continue + + # Do not add ARP detected on bridge + if entry['interface'] == "bridge": + bridge_used = True + # Build address table on bridge + if 'mac-address' in entry and 'address' in entry: + mac2ip[entry['mac-address']] = entry['address'] + + continue + + # Get iface default-name from custom name + uid = None + for ifacename in self.data['interface']: + if self.data['interface'][ifacename]['name'] == entry['interface']: + uid = self.data['interface'][ifacename]['default-name'] + break + + if not uid: + continue + + # Create uid arp dict + if uid not in self.data['arp']: + self.data['arp'][uid] = {} + + # Add data + self.data['arp'][uid]['interface'] = uid + if 'mac-address' in self.data['arp'][uid]: + self.data['arp'][uid]['mac-address'] = "multiple" + else: + self.data['arp'][uid]['mac-address'] = entry['mac-address'] if 'mac-address' in entry else "" + + if 'address' in self.data['arp'][uid]: + self.data['arp'][uid]['address'] = "multiple" + else: + self.data['arp'][uid]['address'] = entry['address'] if 'address' in entry else "" + + if bridge_used: + self.update_bridge_hosts(mac2ip) + + # Map ARP to ifaces + for uid in self.data['interface']: + self.data['interface'][uid]['client-ip-address'] = self.data['arp'][uid]['address'] if uid in self.data['arp'] and 'address' in self.data['arp'][uid] else "" + self.data['interface'][uid]['client-mac-address'] = self.data['arp'][uid]['mac-address'] if uid in self.data['arp'] and 'mac-address' in self.data['arp'][uid] else "" + + return True + + # --------------------------- + # update_bridge_hosts + # --------------------------- + def update_bridge_hosts(self, mac2ip): + data = self.api.path("/interface/bridge/host") + for entry in data: + # Ignore port MAC + if entry['local']: + continue + + # Get iface default-name from custom name + uid = None + for ifacename in self.data['interface']: + if self.data['interface'][ifacename]['name'] == entry['interface']: + uid = self.data['interface'][ifacename]['default-name'] + break + + if not uid: + continue + + # Create uid arp dict + if uid not in self.data['arp']: + self.data['arp'][uid] = {} + + # Add data + self.data['arp'][uid]['interface'] = uid + if 'mac-address' in self.data['arp'][uid]: + self.data['arp'][uid]['mac-address'] = "multiple" + self.data['arp'][uid]['address'] = "multiple" + else: + self.data['arp'][uid]['mac-address'] = entry['mac-address'] if 'mac-address' in entry else "" + self.data['arp'][uid]['address'] = "" + + if self.data['arp'][uid]['address'] == "" and self.data['arp'][uid]['mac-address'] in mac2ip: + self.data['arp'][uid]['address'] = mac2ip[self.data['arp'][uid]['mac-address']] + + return + + # --------------------------- + # get_system_routerboard + # --------------------------- + def get_system_routerboard(self): + data = self.api.path("/system/routerboard") + for entry in data: + self.data['routerboard']['routerboard'] = True if entry['routerboard'] else False + self.data['routerboard']['model'] = entry['model'] if 'model' in entry else "unknown" + self.data['routerboard']['serial-number'] = entry['serial-number'] if 'serial-number' in entry else "unknown" + self.data['routerboard']['firmware'] = entry['current-firmware'] if 'current-firmware' in entry else "unknown" + + return + + # --------------------------- + # get_system_resource + # --------------------------- + def get_system_resource(self): + data = self.api.path("/system/resource") + for entry in data: + self.data['resource']['platform'] = entry['platform'] if 'platform' in entry else "unknown" + self.data['resource']['board-name'] = entry['board-name'] if 'board-name' in entry else "unknown" + self.data['resource']['version'] = entry['version'] if 'version' in entry else "unknown" + + return diff --git a/custom_components/mikrotik_router/mikrotikapi.py b/custom_components/mikrotik_router/mikrotikapi.py index 88063a1..cb07d5a 100644 --- a/custom_components/mikrotik_router/mikrotikapi.py +++ b/custom_components/mikrotik_router/mikrotikapi.py @@ -10,109 +10,109 @@ _LOGGER = logging.getLogger(__name__) # MikrotikAPI # --------------------------- class MikrotikAPI: - """Handle all communication with the Mikrotik API.""" - - def __init__(self, host, username, password, port=0, use_ssl=True, login_method="plain", encoding="utf-8"): - """Initialize the Mikrotik Client.""" - self._host = host - self._use_ssl = use_ssl - self._port = port - self._username = username - self._password = password - self._login_method = login_method - self._encoding = encoding - self._ssl_wrapper = None - - self._connection = None - self._connected = False - self.error = "" - - # Default ports - if not self._port: - self._port = 8729 if self._use_ssl else 8728 - - # --------------------------- - # connect - # --------------------------- - def connect(self): - """Connect to Mikrotik device.""" - self.error = "" - self._connected = False - - kwargs = { - "encoding": self._encoding, - "login_methods": self._login_method, - "port": self._port, - } - - 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._connection = librouteros.connect(self._host, self._username, self._password, **kwargs) - except ( - librouteros.exceptions.TrapError, - librouteros.exceptions.MultiTrapError, - librouteros.exceptions.ConnectionClosed, - librouteros.exceptions.ProtocolError, - librouteros.exceptions.FatalError - ) as api_error: - _LOGGER.error("Mikrotik %s: %s", self._host, api_error) - self.error_to_strings("%s" % api_error) - self._connection = None - return False - else: - _LOGGER.info("Mikrotik Connected to %s", self._host) - self._connected = True - - return self._connected - - # --------------------------- - # error_to_strings - # --------------------------- - def error_to_strings(self, error): - self.error = "cannot_connect" - if error == "invalid user name or password (6)": - self.error = "wrong_login" - - return - - # --------------------------- - # connected - # --------------------------- - def connected(self): - """Return connected boolean.""" - return self._connected - - # --------------------------- - # path - # --------------------------- - def path(self, path): - """Retrieve data from Mikrotik API.""" - if not self._connected or not self._connection: - if not self.connect(): - return None - - try: - response = self._connection.path(path) - tuple(response) - except librouteros.exceptions.ConnectionClosed: - _LOGGER.error("Mikrotik %s connection closed", self._host) - self._connected = False - self._connection = None - return None - except ( - librouteros.exceptions.TrapError, - librouteros.exceptions.MultiTrapError, - librouteros.exceptions.ProtocolError, - librouteros.exceptions.FatalError - ) as api_error: - _LOGGER.error("Mikrotik %s connection error %s", self._host, api_error) - return None - - return response if response else None + """Handle all communication with the Mikrotik API.""" + + def __init__(self, host, username, password, port=0, use_ssl=True, login_method="plain", encoding="utf-8"): + """Initialize the Mikrotik Client.""" + self._host = host + self._use_ssl = use_ssl + self._port = port + self._username = username + self._password = password + self._login_method = login_method + self._encoding = encoding + self._ssl_wrapper = None + + self._connection = None + self._connected = False + self.error = "" + + # Default ports + if not self._port: + self._port = 8729 if self._use_ssl else 8728 + + # --------------------------- + # connect + # --------------------------- + def connect(self): + """Connect to Mikrotik device.""" + self.error = "" + self._connected = False + + kwargs = { + "encoding": self._encoding, + "login_methods": self._login_method, + "port": self._port, + } + + 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._connection = librouteros.connect(self._host, self._username, self._password, **kwargs) + except ( + librouteros.exceptions.TrapError, + librouteros.exceptions.MultiTrapError, + librouteros.exceptions.ConnectionClosed, + librouteros.exceptions.ProtocolError, + librouteros.exceptions.FatalError + ) as api_error: + _LOGGER.error("Mikrotik %s: %s", self._host, api_error) + self.error_to_strings("%s" % api_error) + self._connection = None + return False + else: + _LOGGER.info("Mikrotik Connected to %s", self._host) + self._connected = True + + return self._connected + + # --------------------------- + # error_to_strings + # --------------------------- + def error_to_strings(self, error): + self.error = "cannot_connect" + if error == "invalid user name or password (6)": + self.error = "wrong_login" + + return + + # --------------------------- + # connected + # --------------------------- + def connected(self): + """Return connected boolean.""" + return self._connected + + # --------------------------- + # path + # --------------------------- + def path(self, path): + """Retrieve data from Mikrotik API.""" + if not self._connected or not self._connection: + if not self.connect(): + return None + + try: + response = self._connection.path(path) + tuple(response) + except librouteros.exceptions.ConnectionClosed: + _LOGGER.error("Mikrotik %s connection closed", self._host) + self._connected = False + self._connection = None + return None + except ( + librouteros.exceptions.TrapError, + librouteros.exceptions.MultiTrapError, + librouteros.exceptions.ProtocolError, + librouteros.exceptions.FatalError + ) as api_error: + _LOGGER.error("Mikrotik %s connection error %s", self._host, api_error) + return None + + return response if response else None diff --git a/custom_components/mikrotik_router/strings.json b/custom_components/mikrotik_router/strings.json index 969ff0e..5a74126 100644 --- a/custom_components/mikrotik_router/strings.json +++ b/custom_components/mikrotik_router/strings.json @@ -1,38 +1,38 @@ { - "config": { - "title": "Mikrotik Router", - "step": { - "user": { - "title": "Mikrotik Router", - "description": "Set up Mikrotik Router integration.", - "data": { - "name": "Name of the integration", - "host": "Host", - "port": "Port", - "username": "Username", - "password": "Password", - "ssl": "Use SSL" - } - } - }, - "error": { - "name_exists": "Name already exists.", - "cannot_connect": "Cannot connect to Mikrotik.", - "wrong_login": "Invalid user name or password.", - "routeros_api_missing": "Python module routeros_api not installed." - } - }, - "options": { - "step": { - "init": { - "data": {} - }, - "device_tracker": { - "data": { - "scan_interval": "Scan interval (requires HA restart)", - "track_arp": "Show client MAC and IP on interfaces" - } - } - } - } + "config": { + "title": "Mikrotik Router", + "step": { + "user": { + "title": "Mikrotik Router", + "description": "Set up Mikrotik Router integration.", + "data": { + "name": "Name of the integration", + "host": "Host", + "port": "Port", + "username": "Username", + "password": "Password", + "ssl": "Use SSL" + } + } + }, + "error": { + "name_exists": "Name already exists.", + "cannot_connect": "Cannot connect to Mikrotik.", + "wrong_login": "Invalid user name or password.", + "routeros_api_missing": "Python module routeros_api not installed." + } + }, + "options": { + "step": { + "init": { + "data": {} + }, + "device_tracker": { + "data": { + "scan_interval": "Scan interval (requires HA restart)", + "track_arp": "Show client MAC and IP on interfaces" + } + } + } + } } \ No newline at end of file diff --git a/hacs.json b/hacs.json index 4a62447..8276ff6 100644 --- a/hacs.json +++ b/hacs.json @@ -1,7 +1,7 @@ { - "name": "Mikrotik Router", - "homeassistant": "0.102.0", - "iot_class": "local_poll", - "domains": ["device_tracker"], - "render_readme": true + "name": "Mikrotik Router", + "homeassistant": "0.102.0", + "iot_class": "local_poll", + "domains": ["device_tracker"], + "render_readme": true }