From 30c11db741c03b6983cf092221acdc1c7e4fad02 Mon Sep 17 00:00:00 2001 From: Ivan Pavlina Date: Sat, 4 Apr 2020 19:42:05 +0200 Subject: [PATCH] Add ability to track accounting data from Mikrotik. --- .../mikrotik_router/.translations/en.json | 6 +- .../mikrotik_router/.translations/ru.json | 6 +- custom_components/mikrotik_router/__init__.py | 8 +- .../mikrotik_router/config_flow.py | 11 +- custom_components/mikrotik_router/const.py | 2 + .../mikrotik_router/mikrotik_controller.py | 200 ++++++++++++++++++ .../mikrotik_router/mikrotikapi.py | 93 +++++++- custom_components/mikrotik_router/sensor.py | 130 +++++++++++- .../mikrotik_router/strings.json | 6 +- 9 files changed, 449 insertions(+), 13 deletions(-) diff --git a/custom_components/mikrotik_router/.translations/en.json b/custom_components/mikrotik_router/.translations/en.json index ec26cf2..913af8a 100644 --- a/custom_components/mikrotik_router/.translations/en.json +++ b/custom_components/mikrotik_router/.translations/en.json @@ -12,7 +12,8 @@ "username": "Username", "password": "Password", "ssl": "Use SSL", - "unit_of_measurement": "Unit of measurement" + "unit_of_measurement": "Unit of measurement", + "track_accounting": "Track accounting" } } }, @@ -21,7 +22,8 @@ "cannot_connect": "Cannot connect to Mikrotik.", "ssl_handshake_failure": "SSL handshake failure", "connection_timeout": "Mikrotik connection timeout.", - "wrong_login": "Invalid user name or password." + "wrong_login": "Invalid user name or password.", + "accounting_disabled": "Accounting disabled in Mikrotik, cannot track." } }, "options": { diff --git a/custom_components/mikrotik_router/.translations/ru.json b/custom_components/mikrotik_router/.translations/ru.json index 2cda4b9..b8216d0 100644 --- a/custom_components/mikrotik_router/.translations/ru.json +++ b/custom_components/mikrotik_router/.translations/ru.json @@ -12,7 +12,8 @@ "username": "Имя пользователя", "password": "Пароль", "ssl": "Использовать SSL", - "unit_of_measurement": "Единицы измерения" + "unit_of_measurement": "Единицы измерения", + "track_accounting": "Отслеживание учета" } } }, @@ -21,7 +22,8 @@ "cannot_connect": "Нет связи с Mikrotik.", "ssl_handshake_failure": "Ошибка SSL-соединения", "connection_timeout": "Таймаут подключения к Mikrotik.", - "wrong_login": "Неверные имя пользователя или пароль." + "wrong_login": "Неверные имя пользователя или пароль.", + "accounting_disabled": "Учетная запись отключена в Mikrotik, не может отслеживать." } }, "options": { diff --git a/custom_components/mikrotik_router/__init__.py b/custom_components/mikrotik_router/__init__.py index 61d051b..b9ae7f3 100644 --- a/custom_components/mikrotik_router/__init__.py +++ b/custom_components/mikrotik_router/__init__.py @@ -17,6 +17,7 @@ from .const import ( DOMAIN, DATA_CLIENT, DEFAULT_TRAFFIC_TYPE, + CONF_TRACK_ACCOUNTING, ) from .mikrotik_controller import MikrotikControllerData @@ -48,12 +49,17 @@ async def async_setup_entry(hass, config_entry): traffic_type = config_entry.data[CONF_UNIT_OF_MEASUREMENT] else: traffic_type = DEFAULT_TRAFFIC_TYPE + track_accounting = config_entry.data[CONF_TRACK_ACCOUNTING] mikrotik_controller = MikrotikControllerData( hass, config_entry, name, host, port, username, password, use_ssl, - traffic_type + traffic_type, track_accounting ) await mikrotik_controller.hwinfo_update() + + if track_accounting: + await mikrotik_controller.async_accounting_hosts_update() + await mikrotik_controller.async_update() if not mikrotik_controller.data: diff --git a/custom_components/mikrotik_router/config_flow.py b/custom_components/mikrotik_router/config_flow.py index 3e9b3be..fdc6285 100644 --- a/custom_components/mikrotik_router/config_flow.py +++ b/custom_components/mikrotik_router/config_flow.py @@ -27,6 +27,7 @@ from .const import ( DEFAULT_SCAN_INTERVAL, DEFAULT_TRAFFIC_TYPE, TRAFFIC_TYPES, + CONF_TRACK_ACCOUNTING, ) from .mikrotikapi import MikrotikAPI @@ -51,7 +52,7 @@ def configured_instances(hass): class MikrotikControllerConfigFlow(ConfigFlow, domain=DOMAIN): """MikrotikControllerConfigFlow class""" - VERSION = 1 + VERSION = 2 CONNECTION_CLASS = CONN_CLASS_LOCAL_POLL def __init__(self): @@ -81,10 +82,13 @@ class MikrotikControllerConfigFlow(ConfigFlow, domain=DOMAIN): username=user_input["username"], password=user_input["password"], port=user_input["port"], - use_ssl=user_input["ssl"], + use_ssl=user_input["ssl"] ) if not api.connect(): errors[CONF_HOST] = api.error + else: + if user_input[CONF_TRACK_ACCOUNTING] and not api.is_accounting_enabled(): + errors[CONF_HOST] = "accounting_disabled" # Save instance if not errors: @@ -99,6 +103,7 @@ class MikrotikControllerConfigFlow(ConfigFlow, domain=DOMAIN): port=user_input["port"], name=user_input["name"], use_ssl=user_input["ssl"], + track_accounting=user_input["track_accounting"], errors=errors, ) @@ -115,6 +120,7 @@ class MikrotikControllerConfigFlow(ConfigFlow, domain=DOMAIN): port=0, name="Mikrotik", use_ssl=False, + track_accounting=False, errors=None, ): """Show the configuration form to edit data.""" @@ -131,6 +137,7 @@ class MikrotikControllerConfigFlow(ConfigFlow, domain=DOMAIN): vol.Optional(CONF_PORT, default=port): int, vol.Optional(CONF_NAME, default=name): str, vol.Optional(CONF_SSL, default=use_ssl): bool, + vol.Optional(CONF_TRACK_ACCOUNTING, default=track_accounting): bool, } ), errors=errors, diff --git a/custom_components/mikrotik_router/const.py b/custom_components/mikrotik_router/const.py index 0472cca..c9d4a11 100644 --- a/custom_components/mikrotik_router/const.py +++ b/custom_components/mikrotik_router/const.py @@ -16,3 +16,5 @@ DEFAULT_LOGIN_METHOD = "plain" DEFAULT_TRAFFIC_TYPE = "Kbps" TRAFFIC_TYPES = ["bps", "Kbps", "Mbps", "B/s", "KB/s", "MB/s"] + +CONF_TRACK_ACCOUNTING = "track_accounting" diff --git a/custom_components/mikrotik_router/mikrotik_controller.py b/custom_components/mikrotik_router/mikrotik_controller.py index 42a5326..3377253 100644 --- a/custom_components/mikrotik_router/mikrotik_controller.py +++ b/custom_components/mikrotik_router/mikrotik_controller.py @@ -3,6 +3,7 @@ import asyncio import logging from datetime import timedelta +import ipaddress from homeassistant.core import callback from homeassistant.helpers.dispatcher import async_dispatcher_send @@ -41,12 +42,14 @@ class MikrotikControllerData: password, use_ssl, traffic_type, + track_accounting, ): """Initialize MikrotikController.""" self.name = name self.hass = hass self.config_entry = config_entry self.traffic_type = traffic_type + self.track_accounting = track_accounting self.data = { "routerboard": {}, @@ -57,8 +60,11 @@ class MikrotikControllerData: "fw-update": {}, "script": {}, "queue": {}, + "accounting": {} } + self.local_dhcp_networks = [] + self.listeners = [] self.lock = asyncio.Lock() @@ -70,6 +76,10 @@ class MikrotikControllerData: async_track_time_interval( self.hass, self.force_fwupdate_check, timedelta(hours=1) ) + if self.track_accounting: + async_track_time_interval( + self.hass, self.force_accounting_hosts_update, timedelta(minutes=15) + ) def _get_traffic_type_and_div(self): traffic_type = self.option_traffic_type @@ -96,6 +106,14 @@ class MikrotikControllerData: """Trigger update by timer""" await self.async_update() + # --------------------------- + # force_accounting_hosts_update + # --------------------------- + @callback + async def force_accounting_hosts_update(self, _now=None): + """Trigger update by timer""" + await self.async_accounting_hosts_update() + # --------------------------- # force_fwupdate_check # --------------------------- @@ -162,6 +180,19 @@ class MikrotikControllerData: await self.hass.async_add_executor_job(self.get_system_resource) self.lock.release() + # --------------------------- + # async_accounting_hosts_update + # --------------------------- + async def async_accounting_hosts_update(self): + """Update Mikrotik accounting hosts""" + try: + await asyncio.wait_for(self.lock.acquire(), timeout=10) + except: + return + + await self.hass.async_add_executor_job(self.build_accounting_hosts) + self.lock.release() + # --------------------------- # async_fwupdate_check # --------------------------- @@ -190,6 +221,8 @@ class MikrotikControllerData: await self.hass.async_add_executor_job(self.get_system_resource) await self.hass.async_add_executor_job(self.get_script) await self.hass.async_add_executor_job(self.get_queue) + if self.track_accounting: + await self.hass.async_add_executor_job(self.get_accounting) async_dispatcher_send(self.hass, self.signal_update) self.lock.release() @@ -629,3 +662,170 @@ class MikrotikControllerData: upload_burst_time, download_burst_time = self.data["queue"][uid]["burst-time"].split('/') self.data["queue"][uid]["upload-burst-time"] = upload_burst_time self.data["queue"][uid]["download-burst-time"] = download_burst_time + + def build_accounting_hosts(self): + # Build hosts from DHCP Server leases and ARP list + + self.data["accounting"] = parse_api( + data=self.data["accounting"], + source=self.api.path("/ip/dhcp-server/lease", return_list=True), + key="address", + vals=[ + {"name": "address"}, + {"name": "mac-address"}, + {"name": "host-name"}, + {"name": "comment"}, + {"name": "disabled", "default": True}, + ], + only=[ + {"key": "disabled", "value": False}, + ], + ensure_vals=[ + {"name": "address"}, + {"name": "mac-address"}, + ] + ) + + # Also retrieve static DNS entries + dns_data = parse_api( + data={}, + source=self.api.path("/ip/dns/static", return_list=True), + key="address", + vals=[ + {"name": "address"}, + {"name": "name"}, + ], + ) + + # Also retrieve all entries in ARP table. If some hosts are missing, build it here + arp_hosts = parse_api( + data={}, + source=self.api.path("/ip/arp", return_list=True), + key="address", + vals=[ + {"name": "address"}, + {"name": "mac-address"}, + {"name": "disabled", "default": True}, + {"name": "invalid", "default": True}, + ], + only=[ + {"key": "disabled", "value": False}, + {"key": "invalid", "value": False} + ], + ensure_vals=[ + {"name": "address"}, + {"name": "mac-address"}, + ] + ) + + for addr in arp_hosts: + if addr not in self.data["accounting"]: + self.data["accounting"][addr] = { + "address": arp_hosts[addr]['address'], + "mac-address": arp_hosts[addr]['address'] + } + + # Build name for host. First try getting DHCP lease comment, then entry in DNS and then device's host-name. + # If everything fails use hosts IP address as name + for addr in self.data["accounting"]: + if str(self.data["accounting"][addr].get('comment', '').strip()): + self.data["accounting"][addr]['name'] = self.data["accounting"][addr]['comment'] + elif addr in dns_data and str(dns_data[addr].get('name', '').strip()): + self.data["accounting"][addr]['name'] = dns_data[addr]['name'] + elif str(self.data["accounting"][addr].get('host-name', '').strip()): + self.data["accounting"][addr]['name'] = self.data["accounting"][addr]['host-name'] + else: + self.data["accounting"][addr]['name'] = self.data["accounting"][addr]['address'] + + _LOGGER.debug(f"Generated {len(self.data['accounting'])} accounting hosts") + + # Build local networks + dhcp_networks = parse_api( + data={}, + source=self.api.path("/ip/dhcp-server/network", return_list=True), + key="address", + vals=[ + {"name": "address"}, + ], + ensure_vals=[ + {"name": "address"}, + ] + ) + + self.local_dhcp_networks = [ipaddress.IPv4Network(network) for network in dhcp_networks] + + def _address_part_of_local_network(self, address): + address = ipaddress.ip_address(address) + for network in self.local_dhcp_networks: + if address in network: + return True + return False + + def get_accounting(self): + """Get Accounting data from Mikrotik""" + traffic_type, traffic_div = self._get_traffic_type_and_div() + + # Build temp accounting values dict with all known addresses + # Also set traffic type for each item + accounting_values = {} + for addr in self.data['accounting']: + accounting_values[addr] = { + "wan-tx": 0, + "wan-rx": 0, + "lan-tx": 0, + "lan-rx": 0 + } + self.data['accounting'][addr]["lan-wan-tx-rx-attr"] = traffic_type + + time_diff = self.api.take_accounting_snapshot() + if time_diff: + accounting_data = parse_api( + data={}, + source=self.api.path("/ip/accounting/snapshot", return_list=True), + key=".id", + vals=[ + {"name": ".id"}, + {"name": "src-address"}, + {"name": "dst-address"}, + {"name": "bytes", "default": 0}, + ], + ) + + for item in accounting_data.values(): + source_ip = str(item.get('src-address')).strip() + destination_ip = str(item.get('dst-address')).strip() + bits_count = int(str(item.get('bytes')).strip()) * 8 + + if self._address_part_of_local_network(source_ip) and self._address_part_of_local_network(destination_ip): + # LAN TX/RX + if source_ip in accounting_values: + accounting_values[source_ip]['lan-tx'] += bits_count + if destination_ip in accounting_values: + accounting_values[destination_ip]['lan-rx'] += bits_count + elif self._address_part_of_local_network(source_ip) and \ + not self._address_part_of_local_network(destination_ip): + # WAN TX + if source_ip in accounting_values: + accounting_values[source_ip]['wan-tx'] += bits_count + elif not self._address_part_of_local_network(source_ip) and \ + self._address_part_of_local_network(destination_ip): + # WAN RX + if destination_ip in accounting_values: + accounting_values[destination_ip]['wan-rx'] += bits_count + else: + _LOGGER.debug(f"Skipping packet from {source_ip} to {destination_ip}") + continue + + # Now that we have sum of all traffic in bytes for given period + # calculate real throughput and transform it to appropriate unit + for addr in accounting_values: + self.data['accounting'][addr]['lan-tx'] = round( + accounting_values[addr]['lan-tx'] / time_diff * traffic_div, 2) + self.data['accounting'][addr]['lan-rx'] = round( + accounting_values[addr]['lan-rx'] / time_diff * traffic_div, 2) + + self.data['accounting'][addr]['wan-tx'] = round( + accounting_values[addr]['wan-tx'] / time_diff * traffic_div, 2) + + self.data['accounting'][addr]['wan-rx'] = round( + accounting_values[addr]['wan-rx'] / time_diff * traffic_div, 2) diff --git a/custom_components/mikrotik_router/mikrotikapi.py b/custom_components/mikrotik_router/mikrotikapi.py index 883e865..b8497fb 100644 --- a/custom_components/mikrotik_router/mikrotikapi.py +++ b/custom_components/mikrotik_router/mikrotikapi.py @@ -60,6 +60,7 @@ class MikrotikAPI: self._connection_retry_sec = 58 self.error = None self.connection_error_reported = False + self.accounting_last_run = None # Default ports if not self._port: @@ -396,7 +397,6 @@ class MikrotikAPI: # --------------------------- def get_traffic(self, interfaces) -> Optional(list): """Get traffic stats""" - traffic = None if not self._connected or not self._connection: if self._connection_epoch > time.time() - self._connection_retry_sec: return None @@ -482,3 +482,94 @@ class MikrotikAPI: self.lock.release() return traffic if traffic else None + + @staticmethod + def _current_milliseconds(): + from time import time + return int(round(time() * 1000)) + + def is_accounting_enabled(self): + accounting = self.path("/ip/accounting", return_list=True) + if accounting is None: + return False + + for item in accounting: + if 'enabled' not in item: + continue + if item['enabled']: + return True + return False + + # --------------------------- + # take_accounting_snapshot + # Returns float -> seconds period between last run and current run + # --------------------------- + def take_accounting_snapshot(self) -> float: + """Get accounting data""" + if not self._connected or not self._connection: + if self._connection_epoch > time.time() - self._connection_retry_sec: + return 0 + + if not self.connect(): + return 0 + + accounting = self.path("/ip/accounting") + + self.lock.acquire() + try: + # Prepare command + take = accounting('snapshot/take') + # Run command on Mikrotik + tuple(take) + except librouteros_custom.exceptions.ConnectionClosed: + if not self.connection_error_reported: + _LOGGER.error("Mikrotik %s connection closed", self._host) + self.connection_error_reported = True + + self.disconnect() + self.lock.release() + return 0 + except ( + librouteros_custom.exceptions.TrapError, + librouteros_custom.exceptions.MultiTrapError, + librouteros_custom.exceptions.ProtocolError, + librouteros_custom.exceptions.FatalError, + ssl.SSLError, + BrokenPipeError, + OSError, + ValueError, + ) as api_error: + if not self.connection_error_reported: + _LOGGER.error( + "Mikrotik %s error while take_accounting_snapshot %s -> %s - %s", self._host, + type(api_error), api_error.args + ) + self.connection_error_reported = True + + self.disconnect() + self.lock.release() + return 0 + except Exception as e: + if not self.connection_error_reported: + _LOGGER.error( + "% -> %s error on %s host while take_accounting_snapshot", + type(e), e.args, self._host, + ) + self.connection_error_reported = True + + self.disconnect() + self.lock.release() + return 0 + + self.lock.release() + + # First request will be discarded because we cannot know when the last data was retrieved + # prevents spikes in data + if not self.accounting_last_run: + self.accounting_last_run = self._current_milliseconds() + return 0 + + # Calculate time difference in seconds and return + time_diff = self._current_milliseconds() - self.accounting_last_run + self.accounting_last_run = self._current_milliseconds() + return time_diff / 1000 diff --git a/custom_components/mikrotik_router/sensor.py b/custom_components/mikrotik_router/sensor.py index 5a883a2..9746f14 100644 --- a/custom_components/mikrotik_router/sensor.py +++ b/custom_components/mikrotik_router/sensor.py @@ -12,6 +12,19 @@ from .const import (DOMAIN, DATA_CLIENT, ATTRIBUTION) _LOGGER = logging.getLogger(__name__) + +# --------------------------- +# format_attribute +# --------------------------- +def format_attribute(attr): + res = attr.replace("-", " ") + res = res.capitalize() + res = res.replace(" ip ", " IP ") + res = res.replace(" mac ", " MAC ") + res = res.replace(" mtu", " MTU") + return res + + ATTR_ICON = "icon" ATTR_LABEL = "label" ATTR_UNIT = "unit" @@ -66,8 +79,49 @@ SENSOR_TYPES = { ATTR_PATH: "interface", ATTR_ATTR: "rx-bits-per-second", }, + "accounting_lan_tx": { + ATTR_DEVICE_CLASS: None, + ATTR_ICON: "mdi:download-network", + ATTR_LABEL: "LAN TX", + ATTR_UNIT: "ps", + ATTR_UNIT_ATTR: "lan-wan-tx-rx-attr", + ATTR_PATH: "accounting", + ATTR_ATTR: "lan-tx", + }, + "accounting_lan_rx": { + ATTR_DEVICE_CLASS: None, + ATTR_ICON: "mdi:upload-network", + ATTR_LABEL: "LAN RX", + ATTR_UNIT: "ps", + ATTR_UNIT_ATTR: "lan-wan-tx-rx-attr", + ATTR_PATH: "accounting", + ATTR_ATTR: "lan-rx", + }, + "accounting_wan_tx": { + ATTR_DEVICE_CLASS: None, + ATTR_ICON: "mdi:download-network", + ATTR_LABEL: "WAN TX", + ATTR_UNIT: "ps", + ATTR_UNIT_ATTR: "lan-wan-tx-rx-attr", + ATTR_PATH: "accounting", + ATTR_ATTR: "wan-tx", + }, + "accounting_wan_rx": { + ATTR_DEVICE_CLASS: None, + ATTR_ICON: "mdi:upload-network", + ATTR_LABEL: "WAN RX", + ATTR_UNIT: "ps", + ATTR_UNIT_ATTR: "lan-wan-tx-rx-attr", + ATTR_PATH: "accounting", + ATTR_ATTR: "wan-rx", + }, } +DEVICE_ATTRIBUTES_ACCOUNTING = [ + "address", + "mac-address", +] + # --------------------------- # async_setup_entry @@ -101,7 +155,7 @@ def update_items(inst, mikrotik_controller, async_add_entities, sensors): new_sensors = [] for sensor in SENSOR_TYPES: - if "traffic_" not in sensor: + if "system_" in sensor: item_id = f"{inst}-{sensor}" if item_id in sensors: if sensors[item_id].enabled: @@ -116,8 +170,7 @@ def update_items(inst, mikrotik_controller, async_add_entities, sensors): if "traffic_" in sensor: for uid in mikrotik_controller.data["interface"]: - if mikrotik_controller.data["interface"][uid][ - "type"] == "ether": + if mikrotik_controller.data["interface"][uid]["type"] == "ether": item_id = f"{inst}-{sensor}-{mikrotik_controller.data['interface'][uid]['default-name']}" if item_id in sensors: if sensors[item_id].enabled: @@ -132,6 +185,23 @@ def update_items(inst, mikrotik_controller, async_add_entities, sensors): ) new_sensors.append(sensors[item_id]) + if "accounting_" in sensor: + for uid in mikrotik_controller.data["accounting"]: + + item_id = f"{inst}-{sensor}-{mikrotik_controller.data['accounting'][uid]['name']}" + if item_id in sensors: + if sensors[item_id].enabled: + sensors[item_id].async_schedule_update_ha_state() + continue + + sensors[item_id] = MikrotikAccountingSensor( + mikrotik_controller=mikrotik_controller, + inst=inst, + sensor=sensor, + uid=uid, + ) + new_sensors.append(sensors[item_id]) + if new_sensors: async_add_entities(new_sensors, True) @@ -276,3 +346,57 @@ class MikrotikControllerTrafficSensor(MikrotikControllerSensor): self._data["default-name"], self._sensor, ) + + +# --------------------------- +# MikrotikAccountingSensor +# --------------------------- +class MikrotikAccountingSensor(MikrotikControllerSensor): + """Define an Mikrotik Accounting sensor.""" + + def __init__(self, mikrotik_controller, inst, sensor, uid): + """Initialize.""" + super().__init__(mikrotik_controller, inst, sensor) + self._uid = uid + self._data = mikrotik_controller.data[SENSOR_TYPES[sensor][ATTR_PATH]][uid] + + @property + def name(self): + """Return the name.""" + return f"{self._inst} {self._data['name']} {self._type[ATTR_LABEL]}" + + @property + def unique_id(self): + """Return a unique_id for this entity.""" + return f"{self._inst.lower()}-{self._sensor.lower()}-{self._data['address'].lower()}" + + @property + def device_info(self): + """Return a port description for device registry.""" + info = { + "connections": { + (CONNECTION_NETWORK_MAC, self._data["mac-address"])}, + "manufacturer": self._ctrl.data["resource"]["platform"], + "model": self._ctrl.data["resource"]["board-name"], + "name": self._data["name"], + } + return info + + @property + def device_state_attributes(self): + """Return the state attributes.""" + attributes = self._attrs + for variable in DEVICE_ATTRIBUTES_ACCOUNTING: + if variable in self._data: + attributes[format_attribute(variable)] = self._data[variable] + + return attributes + + async def async_added_to_hass(self): + """Port entity created.""" + _LOGGER.debug( + "New sensor %s (%s %s)", + self._inst, + self._data["name"], + self._sensor, + ) diff --git a/custom_components/mikrotik_router/strings.json b/custom_components/mikrotik_router/strings.json index ec26cf2..913af8a 100644 --- a/custom_components/mikrotik_router/strings.json +++ b/custom_components/mikrotik_router/strings.json @@ -12,7 +12,8 @@ "username": "Username", "password": "Password", "ssl": "Use SSL", - "unit_of_measurement": "Unit of measurement" + "unit_of_measurement": "Unit of measurement", + "track_accounting": "Track accounting" } } }, @@ -21,7 +22,8 @@ "cannot_connect": "Cannot connect to Mikrotik.", "ssl_handshake_failure": "SSL handshake failure", "connection_timeout": "Mikrotik connection timeout.", - "wrong_login": "Invalid user name or password." + "wrong_login": "Invalid user name or password.", + "accounting_disabled": "Accounting disabled in Mikrotik, cannot track." } }, "options": {