diff --git a/README.md b/README.md index cce46f0..cee3953 100644 --- a/README.md +++ b/README.md @@ -47,7 +47,7 @@ Monitor and control your Mikrotik device from Home Assistant. * Enable/disable Filter switches * Monitor and control PPP users * Kid Control - * Mikrotik Accounting traffic sensors per hosts for RX/TX WAN/LAN + * Client Traffic RX/TX WAN/LAN monitoring though Accounting or Kid Control Devices (depending on RouterOS FW version) * Device tracker for hosts in network * System sensors (CPU, Memory, HDD, Temperature) * Check firmware update @@ -112,9 +112,10 @@ Monitor and control. ![Kid Control](https://raw.githubusercontent.com/tomaae/homeassistant-mikrotik_router/master/docs/assets/images/ui/kidcontrol_switch.png) +## Client Traffic -## Accounting -*RouterOS 7+ no longer support accounting* +#### RouterOS v6 +###### Accounting Monitor per-IP throughput tracking based on Mikrotik Accounting. @@ -124,6 +125,20 @@ More information about Accounting can be found on [Mikrotik support page](https: NOTE: Accounting does not count in FastTracked packets. + +#### RouterOS v7 +###### Kid Control Devices + +In RouterOS v7 Accounting feature is deprecated so alternative approach for is to use +Kid Control Devices feature (IP - Kid Control - Devices). + +This feature requires at least one 'kid' to be defined, +after that Mikrotik will dynamically start tracking bandwidth usage of all known devices. + +Simple dummy Kid entry can be defined with + +```/ip kid-control add name=Monitor mon=0s-1d tue=0s-1d wed=0s-1d thu=0s-1d fri=0s-1d sat=0s-1d sun=0s-1d``` + ![Accounting sensor](https://raw.githubusercontent.com/tomaae/homeassistant-mikrotik_router/master/docs/assets/images/ui/accounting_sensor.png) # Install integration @@ -132,14 +147,12 @@ This integration is distributed using [HACS](https://hacs.xyz/). You can find it under "Integrations", named "Mikrotik Router" Minimum requirements: -* RouterOS v6.43 +* RouterOS v6.43/v7.1 * Home Assistant 0.114.0 ## Using Mikrotik development branch If you are using development branch for mikrotik, some features may stop working due to major changes in RouterOS. Use integration master branch instead of latest release to keep up with RouterOS beta adjustments. -* beta 7.3 was fully tested. -* beta 7.4 have been reported to miss system health information. ## Setup integration 1. Create user for homeassistant on your mikrotik router with following permissions: diff --git a/custom_components/mikrotik_router/mikrotik_controller.py b/custom_components/mikrotik_router/mikrotik_controller.py index dc35455..9b74e1c 100644 --- a/custom_components/mikrotik_router/mikrotik_controller.py +++ b/custom_components/mikrotik_router/mikrotik_controller.py @@ -129,10 +129,12 @@ class MikrotikControllerData: "wireless_hosts": {}, "host": {}, "host_hass": {}, - "accounting": {}, + "client_traffic": {}, "environment": {}, } + self.notified_flags = [] + self.listeners = [] self.lock = asyncio.Lock() self.lock_ping = asyncio.Lock() @@ -612,12 +614,11 @@ class MikrotikControllerData: if self.api.connected(): await self.hass.async_add_executor_job(self.get_system_resource) - if ( - self.api.connected() - and self.option_sensor_client_traffic - and 0 < self.major_fw_version < 7 - ): - await self.hass.async_add_executor_job(self.process_accounting) + if self.api.connected() and self.option_sensor_client_traffic: + if 0 < self.major_fw_version < 7: + await self.hass.async_add_executor_job(self.process_accounting) + elif 0 < self.major_fw_version >= 7: + await self.hass.async_add_executor_job(self.process_kid_control_devices) if self.api.connected() and self.option_sensor_simple_queues: await self.hass.async_add_executor_job(self.get_queue) @@ -952,6 +953,8 @@ class MikrotikControllerData: {"name": "src-port", "default": "any"}, {"name": "dst-address", "default": "any"}, {"name": "dst-port", "default": "any"}, + {"name": "src-address-list", "default": "any"}, + {"name": "dst-address-list", "default": "any"}, { "name": "enabled", "source": "disabled", @@ -976,6 +979,10 @@ class MikrotikControllerData: {"key": "dst-address"}, {"text": ":"}, {"key": "dst-port"}, + {"text": ","}, + {"key": "src-address-list"}, + {"text": "-"}, + {"key": "dst-address-list"}, ], [ {"name": "name"}, @@ -1869,8 +1876,8 @@ class MikrotikControllerData: # Build missing hosts from main hosts dict for uid, vals in self.data["host"].items(): - if uid not in self.data["accounting"]: - self.data["accounting"][uid] = { + if uid not in self.data["client_traffic"]: + self.data["client_traffic"][uid] = { "address": vals["address"], "mac-address": vals["mac-address"], "host-name": vals["host-name"], @@ -1879,11 +1886,13 @@ class MikrotikControllerData: "local_accounting": False, } - _LOGGER.debug(f"Working with {len(self.data['accounting'])} accounting devices") + _LOGGER.debug( + f"Working with {len(self.data['client_traffic'])} accounting devices" + ) # Build temp accounting values dict with ip address as key tmp_accounting_values = {} - for uid, vals in self.data["accounting"].items(): + for uid, vals in self.data["client_traffic"].items(): tmp_accounting_values[vals["address"]] = { "wan-tx": 0, "wan-rx": 0, @@ -1891,7 +1900,7 @@ class MikrotikControllerData: "lan-rx": 0, } - time_diff = self.api.take_accounting_snapshot() + time_diff = self.api.take_client_traffic_snapshot(True) if time_diff: accounting_data = parse_api( data={}, @@ -1959,20 +1968,20 @@ class MikrotikControllerData: ) continue - self.data["accounting"][uid]["tx-rx-attr"] = uom_type - self.data["accounting"][uid]["available"] = accounting_enabled - self.data["accounting"][uid]["local_accounting"] = local_traffic_enabled + self.data["client_traffic"][uid]["tx-rx-attr"] = uom_type + self.data["client_traffic"][uid]["available"] = accounting_enabled + self.data["client_traffic"][uid]["local_accounting"] = local_traffic_enabled if not accounting_enabled: # Skip calculation for WAN and LAN if accounting is disabled continue - self.data["accounting"][uid]["wan-tx"] = ( + self.data["client_traffic"][uid]["wan-tx"] = ( round(vals["wan-tx"] / time_diff * uom_div, 2) if vals["wan-tx"] else 0.0 ) - self.data["accounting"][uid]["wan-rx"] = ( + self.data["client_traffic"][uid]["wan-rx"] = ( round(vals["wan-rx"] / time_diff * uom_div, 2) if vals["wan-rx"] else 0.0 @@ -1982,12 +1991,12 @@ class MikrotikControllerData: # Skip calculation for LAN if LAN accounting is disabled continue - self.data["accounting"][uid]["lan-tx"] = ( + self.data["client_traffic"][uid]["lan-tx"] = ( round(vals["lan-tx"] / time_diff * uom_div, 2) if vals["lan-tx"] else 0.0 ) - self.data["accounting"][uid]["lan-rx"] = ( + self.data["client_traffic"][uid]["lan-rx"] = ( round(vals["lan-rx"] / time_diff * uom_div, 2) if vals["lan-rx"] else 0.0 @@ -2027,7 +2036,7 @@ class MikrotikControllerData: # _get_accounting_uid_by_ip # --------------------------- def _get_accounting_uid_by_ip(self, requested_ip): - for mac, vals in self.data["accounting"].items(): + for mac, vals in self.data["client_traffic"].items(): if vals.get("address") is requested_ip: return mac return None @@ -2044,3 +2053,85 @@ class MikrotikControllerData: break return uid + + # --------------------------- + # process_kid_control + # --------------------------- + def process_kid_control_devices(self): + """Get Kid Control Device data from Mikrotik""" + + uom_type, uom_div = self._get_unit_of_measurement() + + # Build missing hosts from main hosts dict + for uid, vals in self.data["host"].items(): + if uid not in self.data["client_traffic"]: + self.data["client_traffic"][uid] = { + "address": vals["address"], + "mac-address": vals["mac-address"], + "host-name": vals["host-name"], + "previous-bytes-up": 0.0, + "previous-bytes-down": 0.0, + "wan-tx": 0.0, + "wan-rx": 0.0, + "tx-rx-attr": uom_type, + "available": False, + "local_accounting": False, + } + + _LOGGER.debug( + f"Working with {len(self.data['client_traffic'])} kid control devices" + ) + + kid_control_devices_data = parse_api( + data={}, + source=self.api.path("/ip/kid-control/device"), + key="mac-address", + vals=[ + {"name": "mac-address"}, + {"name": "bytes-down"}, + {"name": "bytes-up"}, + { + "name": "enabled", + "source": "disabled", + "type": "bool", + "reverse": True, + }, + ], + ) + + time_diff = self.api.take_client_traffic_snapshot(False) + + if not kid_control_devices_data: + if "kid-control-devices" not in self.notified_flags: + _LOGGER.error( + "No kid control devices found on your Mikrotik device, make sure kid-control feature is configured" + ) + self.notified_flags.append("kid-control-devices") + return + elif "kid-control-devices" in self.notified_flags: + self.notified_flags.remove("kid-control-devices") + + for uid, vals in kid_control_devices_data.items(): + if uid not in self.data["client_traffic"]: + _LOGGER.debug(f"Skipping unknown device {uid}") + continue + + self.data["client_traffic"][uid]["available"] = vals["enabled"] + + current_tx = vals["bytes-up"] + previous_tx = self.data["client_traffic"][uid]["previous-bytes-up"] + if time_diff: + delta_tx = max(0, current_tx - previous_tx) * 8 + self.data["client_traffic"][uid]["wan-tx"] = round( + delta_tx / time_diff * uom_div, 2 + ) + self.data["client_traffic"][uid]["previous-bytes-up"] = current_tx + + current_rx = vals["bytes-down"] + previous_rx = self.data["client_traffic"][uid]["previous-bytes-down"] + if time_diff: + delta_rx = max(0, current_rx - previous_rx) * 8 + self.data["client_traffic"][uid]["wan-rx"] = round( + delta_rx / time_diff * uom_div, 2 + ) + self.data["client_traffic"][uid]["previous-bytes-down"] = current_rx diff --git a/custom_components/mikrotik_router/mikrotikapi.py b/custom_components/mikrotik_router/mikrotikapi.py index 9da31f6..d6c8ec3 100644 --- a/custom_components/mikrotik_router/mikrotikapi.py +++ b/custom_components/mikrotik_router/mikrotikapi.py @@ -52,7 +52,7 @@ class MikrotikAPI: self._connection_retry_sec = 58 self.error = None self.connection_error_reported = False - self.accounting_last_run = None + self.client_traffic_last_run = None # Default ports if not self._port: @@ -644,64 +644,65 @@ class MikrotikAPI: return True, True # --------------------------- - # take_accounting_snapshot + # take_client_traffic_snapshot # Returns float -> period in seconds between last and current run # --------------------------- - def take_accounting_snapshot(self) -> float: - """Get accounting data""" + def take_client_traffic_snapshot(self, use_accounting) -> float: + """Tako accounting snapshot and return time diff""" if not self.connection_check(): return 0 - accounting = self.path("/ip/accounting", return_list=False) + if use_accounting: + accounting = self.path("/ip/accounting", return_list=False) - self.lock.acquire() - try: - # Prepare command - take = accounting("snapshot/take") - except librouteros.exceptions.ConnectionClosed: - self.disconnect() - self.lock.release() - return 0 - except ( - librouteros.exceptions.TrapError, - librouteros.exceptions.MultiTrapError, - librouteros.exceptions.ProtocolError, - librouteros.exceptions.FatalError, - socket_timeout, - socket_error, - ssl.SSLError, - BrokenPipeError, - OSError, - ValueError, - ) as api_error: - self.disconnect("accounting_snapshot", api_error) - self.lock.release() - return 0 - except: - self.disconnect("accounting_snapshot") - self.lock.release() - return 0 + self.lock.acquire() + try: + # Prepare command + take = accounting("snapshot/take") + except librouteros.exceptions.ConnectionClosed: + self.disconnect() + self.lock.release() + return 0 + except ( + librouteros.exceptions.TrapError, + librouteros.exceptions.MultiTrapError, + librouteros.exceptions.ProtocolError, + librouteros.exceptions.FatalError, + socket_timeout, + socket_error, + ssl.SSLError, + BrokenPipeError, + OSError, + ValueError, + ) as api_error: + self.disconnect("accounting_snapshot", api_error) + self.lock.release() + return 0 + except: + self.disconnect("accounting_snapshot") + self.lock.release() + return 0 - try: - list(take) - except librouteros.exceptions.ConnectionClosed as api_error: - self.disconnect("accounting_snapshot", api_error) - self.lock.release() - return 0 - except: - self.disconnect("accounting_snapshot") - self.lock.release() - return 0 + try: + list(take) + except librouteros.exceptions.ConnectionClosed as api_error: + self.disconnect("accounting_snapshot", api_error) + self.lock.release() + return 0 + except: + self.disconnect("accounting_snapshot") + self.lock.release() + return 0 - self.lock.release() + 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() + if not self.client_traffic_last_run: + self.client_traffic_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() + time_diff = self._current_milliseconds() - self.client_traffic_last_run + self.client_traffic_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 eea7db6..0a57caf 100644 --- a/custom_components/mikrotik_router/sensor.py +++ b/custom_components/mikrotik_router/sensor.py @@ -186,49 +186,49 @@ SENSOR_TYPES = { ATTR_ATTR: "rx-bits-per-second", ATTR_CTGR: None, }, - "accounting_lan_tx": { + "client_traffic_lan_tx": { ATTR_DEVICE_CLASS: None, ATTR_ICON: "mdi:upload-network", ATTR_LABEL: "LAN TX", ATTR_UNIT: "ps", ATTR_UNIT_ATTR: "tx-rx-attr", - ATTR_PATH: "accounting", + ATTR_PATH: "client_traffic", ATTR_ATTR: "lan-tx", ATTR_CTGR: None, }, - "accounting_lan_rx": { + "client_traffic_lan_rx": { ATTR_DEVICE_CLASS: None, ATTR_ICON: "mdi:download-network", ATTR_LABEL: "LAN RX", ATTR_UNIT: "ps", ATTR_UNIT_ATTR: "tx-rx-attr", - ATTR_PATH: "accounting", + ATTR_PATH: "client_traffic", ATTR_ATTR: "lan-rx", ATTR_CTGR: None, }, - "accounting_wan_tx": { + "client_traffic_wan_tx": { ATTR_DEVICE_CLASS: None, ATTR_ICON: "mdi:upload-network", ATTR_LABEL: "WAN TX", ATTR_UNIT: "ps", ATTR_UNIT_ATTR: "tx-rx-attr", - ATTR_PATH: "accounting", + ATTR_PATH: "client_traffic", ATTR_ATTR: "wan-tx", ATTR_CTGR: None, }, - "accounting_wan_rx": { + "client_traffic_wan_rx": { ATTR_DEVICE_CLASS: None, ATTR_ICON: "mdi:download-network", ATTR_LABEL: "WAN RX", ATTR_UNIT: "ps", ATTR_UNIT_ATTR: "tx-rx-attr", - ATTR_PATH: "accounting", + ATTR_PATH: "client_traffic", ATTR_ATTR: "wan-rx", ATTR_CTGR: None, }, } -DEVICE_ATTRIBUTES_ACCOUNTING = ["address", "mac-address", "host-name"] +DEVICE_ATTRIBUTES_CLIENT_TRAFFIC = ["address", "mac-address", "host-name"] # --------------------------- @@ -305,7 +305,7 @@ def update_items(inst, config_entry, mikrotik_controller, async_add_entities, se new_sensors.append(sensors[item_id]) for sensor in SENSOR_TYPES: - if "system_" in sensor: + if sensor.startswith("system_"): if ( SENSOR_TYPES[sensor][ATTR_ATTR] not in mikrotik_controller.data[SENSOR_TYPES[sensor][ATTR_PATH]] @@ -327,7 +327,7 @@ def update_items(inst, config_entry, mikrotik_controller, async_add_entities, se ) new_sensors.append(sensors[item_id]) - if "traffic_" in sensor: + if sensor.startswith("traffic_"): if not config_entry.options.get( CONF_SENSOR_PORT_TRAFFIC, DEFAULT_SENSOR_PORT_TRAFFIC ): @@ -350,9 +350,9 @@ def update_items(inst, config_entry, mikrotik_controller, async_add_entities, se ) 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]['mac-address']}" + if sensor.startswith("client_traffic_"): + for uid in mikrotik_controller.data["client_traffic"]: + item_id = f"{inst}-{sensor}-{mikrotik_controller.data['client_traffic'][uid]['mac-address']}" if item_id in sensors: if sensors[item_id].enabled: sensors[item_id].async_schedule_update_ha_state() @@ -360,9 +360,9 @@ def update_items(inst, config_entry, mikrotik_controller, async_add_entities, se if ( SENSOR_TYPES[sensor][ATTR_ATTR] - in mikrotik_controller.data["accounting"][uid].keys() + in mikrotik_controller.data["client_traffic"][uid].keys() ): - sensors[item_id] = MikrotikAccountingSensor( + sensors[item_id] = MikrotikClientTrafficSensor( mikrotik_controller=mikrotik_controller, inst=inst, sensor=sensor, @@ -534,10 +534,10 @@ class MikrotikControllerTrafficSensor(MikrotikControllerSensor): # --------------------------- -# MikrotikAccountingSensor +# MikrotikClientTrafficSensor # --------------------------- -class MikrotikAccountingSensor(MikrotikControllerSensor): - """Define an Mikrotik Accounting sensor.""" +class MikrotikClientTrafficSensor(MikrotikControllerSensor): + """Define an Mikrotik MikrotikClientTrafficSensor sensor.""" def __init__(self, mikrotik_controller, inst, sensor, uid): """Initialize.""" @@ -585,7 +585,7 @@ class MikrotikAccountingSensor(MikrotikControllerSensor): def extra_state_attributes(self) -> Dict[str, Any]: """Return the state attributes.""" attributes = self._attrs - for variable in DEVICE_ATTRIBUTES_ACCOUNTING: + for variable in DEVICE_ATTRIBUTES_CLIENT_TRAFFIC: if variable in self._data: attributes[format_attribute(variable)] = self._data[variable] diff --git a/custom_components/mikrotik_router/switch.py b/custom_components/mikrotik_router/switch.py index c7de95e..bef19cd 100644 --- a/custom_components/mikrotik_router/switch.py +++ b/custom_components/mikrotik_router/switch.py @@ -619,7 +619,8 @@ class MikrotikControllerMangleSwitch(MikrotikControllerSwitch): if self._ctrl.data["mangle"][uid]["uniq-id"] == ( f"{self._data['chain']},{self._data['action']},{self._data['protocol']}," f"{self._data['src-address']}:{self._data['src-port']}-" - f"{self._data['dst-address']}:{self._data['dst-port']}" + f"{self._data['dst-address']}:{self._data['dst-port']}," + f"{self._data['src-address-list']}-{self._data['dst-address-list']}" ): value = self._ctrl.data["mangle"][uid][".id"] @@ -637,7 +638,8 @@ class MikrotikControllerMangleSwitch(MikrotikControllerSwitch): if self._ctrl.data["mangle"][uid]["uniq-id"] == ( f"{self._data['chain']},{self._data['action']},{self._data['protocol']}," f"{self._data['src-address']}:{self._data['src-port']}-" - f"{self._data['dst-address']}:{self._data['dst-port']}" + f"{self._data['dst-address']}:{self._data['dst-port']}," + f"{self._data['src-address-list']}-{self._data['dst-address-list']}" ): value = self._ctrl.data["mangle"][uid][".id"]