diff --git a/wireless_monitor/CMakeLists.txt b/wireless_monitor/CMakeLists.txt new file mode 100644 index 0000000..01252a6 --- /dev/null +++ b/wireless_monitor/CMakeLists.txt @@ -0,0 +1,29 @@ +cmake_minimum_required(VERSION 2.8.3) +project(wireless_monitor) + +############# +## Build ## +############# + +find_package(catkin REQUIRED) + +catkin_python_setup() + +catkin_package() + +############# +## Test ## +############# + +if (CATKIN_ENABLE_TESTING) + catkin_add_nosetests(test) +endif() + +############# +## Install ## +############# + +install(PROGRAMS + scripts/wireless_monitor + DESTINATION ${CATKIN_PACKAGE_BIN_DESTINATION} +) diff --git a/wireless_monitor/README.md b/wireless_monitor/README.md new file mode 100644 index 0000000..e2a022b --- /dev/null +++ b/wireless_monitor/README.md @@ -0,0 +1,13 @@ +# Wireless monitor + +## Nodes + +### wireless_monitor + +Monitors a given wireless interface using `psutil` and `iwconfig`. + +#### Parameters + +- `~interface`: Wireless interface to monitor (default=`'wlo1'`) +- `~link_quality_warning_percentage`: On what link quality a warning level will be given (default=`0.5`) +- `~rate`: Publish rate (default=`1`) diff --git a/wireless_monitor/package.xml b/wireless_monitor/package.xml new file mode 100644 index 0000000..73ed55c --- /dev/null +++ b/wireless_monitor/package.xml @@ -0,0 +1,13 @@ + + wireless_monitor + 0.0.0 + Monitor a wireless network interface + Rein Appeldoorn + + BSD + + catkin + + python-psutil + wireless-tools + diff --git a/wireless_monitor/scripts/wireless_monitor b/wireless_monitor/scripts/wireless_monitor new file mode 100755 index 0000000..6289b16 --- /dev/null +++ b/wireless_monitor/scripts/wireless_monitor @@ -0,0 +1,76 @@ +#!/usr/bin/env python +# +# Copyright (c) 2019, Eurotec, Netherlands +# All rights reserved. +# +# \author Rein Appeldoorn + +import rospy +from diagnostic_msgs.msg import DiagnosticStatus +from diagnostic_updater import DiagnosticTask, Updater + +from wireless_monitor.wireless import get_iwconfig_dict_and_link_quality, get_wifi_ifname, NetCount + + +class WirelessTask(DiagnosticTask): + def __init__(self, interface, link_quality_warning_percentage): + DiagnosticTask.__init__(self, "Wireless Information") + self._interface = interface + self._link_quality_warning_percentage = float(link_quality_warning_percentage) + self._net_count = NetCount(interface) + + def _update_with_iwconfig_status(self, stat): + iwconfig_dict, link_quality = get_iwconfig_dict_and_link_quality(self._interface) + + if link_quality is None: + stat.summary(DiagnosticStatus.OK, "Link quality unknown") + elif link_quality > self._link_quality_warning_percentage: + stat.summary(DiagnosticStatus.OK, "Link quality OK") + else: + stat.summary(DiagnosticStatus.WARN, "Link quality low") + + for k, v in iwconfig_dict.iteritems(): + stat.add(k, v) + + def _update_with_psutil_net_io_counter(self, stat): + for k, v in self._net_count.get_net_count_dict().items(): + stat.add(k, v) + + def run(self, stat): + if self._interface is None: + stat.summary(DiagnosticStatus.OK, "No wireless device") + return stat + + try: + self._update_with_iwconfig_status(stat) + self._update_with_psutil_net_io_counter(stat) + except RuntimeError as e: + stat.summary(DiagnosticStatus.ERROR, "Failed to update wireless information: {}".format(e)) + + return stat + + +def main(): + rospy.init_node('wireless_monitor') + + interface = None + try: + interface = get_wifi_ifname(rospy.get_param('~interface', None)) + except Exception as e: + rospy.logerr(e) + + updater = Updater() + updater.setHardwareID(str(interface)) + updater.add(WirelessTask( + interface, + rospy.get_param("~link_quality_warning_percentage", 0.0) + )) + + rate = rospy.Rate(rospy.get_param("~rate", 1)) + while not rospy.is_shutdown(): + updater.force_update() # We want do determine the rate ourselves (not the internal rate of the updater) + rate.sleep() + + +if __name__ == '__main__': + main() diff --git a/wireless_monitor/setup.py b/wireless_monitor/setup.py new file mode 100644 index 0000000..3c0f785 --- /dev/null +++ b/wireless_monitor/setup.py @@ -0,0 +1,16 @@ +#!/usr/bin/env python +# +# Copyright (c) 2019, Eurotec, Netherlands +# All rights reserved. +# +# \author Rein Appeldoorn + +from distutils.core import setup +from catkin_pkg.python_setup import generate_distutils_setup + +d = generate_distutils_setup( + packages=['wireless_monitor'], + package_dir={'': 'src'} +) + +setup(**d) diff --git a/wireless_monitor/src/wireless_monitor/util.py b/wireless_monitor/src/wireless_monitor/util.py new file mode 100644 index 0000000..c2fcfba --- /dev/null +++ b/wireless_monitor/src/wireless_monitor/util.py @@ -0,0 +1,28 @@ +# +# Copyright (c) 2019, Eurotec, Netherlands +# All rights reserved. +# +# \author Rein Appeldoorn + +import subprocess + + +def execute_command(cmd, shell=False, cwd=None): + """ + Execute a command and return the stripped output + :param cmd: The command (list) to run + :param shell: Whether to use the shell executor + :param cwd: Current working directory + :return: Stripped output + :raises RuntimeError + """ + try: + p = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, shell=shell, cwd=cwd) + except OSError as e: + raise RuntimeError("Could not execute command {}: {}".format(cmd, e)) + + stdout, stderr = p.communicate() + + if p.returncode != 0: + raise RuntimeError("Could not execute command {}".format(cmd)) + return stdout.strip() diff --git a/wireless_monitor/src/wireless_monitor/wireless.py b/wireless_monitor/src/wireless_monitor/wireless.py new file mode 100644 index 0000000..54b9c08 --- /dev/null +++ b/wireless_monitor/src/wireless_monitor/wireless.py @@ -0,0 +1,108 @@ +# +# Copyright (c) 2019, Eurotec, Netherlands +# All rights reserved. +# +# \author Rein Appeldoorn + +import os +import psutil +import re +import time + +from .util import execute_command + + +def get_wifi_ifnames(): + """ + Get a list of wifi interface names + :return: List of interface names + """ + return [ifname for ifname in os.listdir('/sys/class/net/') if ifname[:2] == "wl"] + + +def get_wifi_ifname(wifi_ifname_preference=None): + """ + Get wifi interface name with an optional preference + + If the preference is specified, the preference will be returned if it exists + If the preference is NOT specified, the first wireless adapter will be returned + + :param wifi_ifname_preference: Optiona wifi ifname prefence e.g. wlan0 + :return: The wifi interface name + :raises RuntimeError if there are no wireless devices available + :raises ValueError if there are wireless devices available but the preference does not exist + """ + wifi_ifnames = get_wifi_ifnames() + if not wifi_ifnames: + raise RuntimeError("No wifi ifnames found") + if wifi_ifname_preference: + if wifi_ifname_preference in wifi_ifnames: + return wifi_ifname_preference + raise ValueError( + "Wifi ifname preference {} not in wifi ifnames {}".format(wifi_ifname_preference, wifi_ifnames)) + return wifi_ifnames[0] + + +def _parse_iwconfig_output(output): + result = {} + items = [e.strip() for e in output.replace('\n', ' ').split(' ') if ":" in e or "=" in e] + for item in items: + key, value = re.search('(.+?)[:=](.+)', item).groups() + result[key] = value.strip().strip("\"") + return result + + +def get_iwconfig_dict_and_link_quality(ifname): + """ + Get the iwconfig status dictionary + link quality of an interface + :param ifname: Wireless interface name + :return: iwconfig status dictionary + link quality + :raises: RuntimeError if the iwconfig information cannot be obtained + """ + output = execute_command('iwconfig {}'.format(ifname), shell=True) + iwconfig_dict = _parse_iwconfig_output(output) + + link_quality = None + if 'Link Quality' in iwconfig_dict: + numerator, denominator = iwconfig_dict['Link Quality'].split("/") + link_quality = float(numerator) / float(denominator) + + return iwconfig_dict, link_quality + + +class NetCount(object): + def __init__(self, ifname): + """ + Net counter that monitors traffic + :param ifname: Interface name + """ + self._ifname = ifname + self._last_count = None + + def get_net_count_dict(self): + """ + Get the net count + rate for the specified interface + :return: Net count dictionary + :raises RuntimeError if getting counters fails + """ + def _rate(current, last, dt): + return ((current - last) / 1e3) / dt + + now = time.time() + counts = psutil.net_io_counters(pernic=True) + if self._ifname not in counts: + raise RuntimeError("Could not get net IO counter for interface {}".format(self._ifname)) + + count = counts[self._ifname] + result = dict(count.__dict__) + + if self._last_count: + last_stamp, last_count = self._last_count + dt = now - last_stamp + + if dt > 0: + result["Download rate [KB/s]"] = "{:.3f}".format(_rate(count.bytes_recv, last_count.bytes_recv, dt)) + result["Upload rate [KB/s]"] = "{:.3f}".format(_rate(count.bytes_sent, last_count.bytes_sent, dt)) + + self._last_count = now, count + return result diff --git a/wireless_monitor/test/test_util.py b/wireless_monitor/test/test_util.py new file mode 100644 index 0000000..96e7d77 --- /dev/null +++ b/wireless_monitor/test/test_util.py @@ -0,0 +1,18 @@ +# +# Copyright (c) 2019, Eurotec, Netherlands +# All rights reserved. +# +# \author Rein Appeldoorn + +from nose.tools import raises + +from wireless_monitor.util import execute_command + + +def test_execute_command(): + assert "hi" == execute_command(["echo", "hi"]) + + +@raises(RuntimeError) +def test_execute_invalid_command(): + execute_command(["--&*(", "hi"]) diff --git a/wireless_monitor/test/test_wireless.py b/wireless_monitor/test/test_wireless.py new file mode 100644 index 0000000..367d6ba --- /dev/null +++ b/wireless_monitor/test/test_wireless.py @@ -0,0 +1,55 @@ +# +# Copyright (c) 2019, Eurotec, Netherlands +# All rights reserved. +# +# \author Rein Appeldoorn + +import random +from nose.tools import raises + +from wireless_monitor.wireless import get_wifi_ifname, get_iwconfig_dict_and_link_quality, get_wifi_ifnames, NetCount + +wifi_ifnames = get_wifi_ifnames() + + +def test_wifi_ifnames(): + if wifi_ifnames: + assert get_wifi_ifname() in wifi_ifnames + + wifi_ifname = random.choice(wifi_ifnames) + assert get_wifi_ifname(wifi_ifname) == wifi_ifname + + try: + get_wifi_ifname("lan0") + except Exception as e: + assert isinstance(e, ValueError) + else: + try: + get_wifi_ifname() + except Exception as e: + assert isinstance(e, RuntimeError) + + +def test_iwconfig(): + if wifi_ifnames: + iwconfig_dict, quality = get_iwconfig_dict_and_link_quality(get_wifi_ifname()) + assert isinstance(iwconfig_dict, dict) + assert quality is None or isinstance(quality, float) + + +@raises(RuntimeError) +def test_invalid_iwconfig(): + get_iwconfig_dict_and_link_quality("lan0") + + +def test_net_count(): + if wifi_ifnames: + nc = NetCount(get_wifi_ifname()) + assert "Download rate [KB/s]" not in nc.get_net_count_dict() + assert "Download rate [KB/s]" in nc.get_net_count_dict() + + +@raises(RuntimeError) +def test_invalid_net_count(): + nc = NetCount("invalid_interface") + nc.get_net_count_dict()