From ec55d6cce2f71a0b949f414930512bf4dc865f88 Mon Sep 17 00:00:00 2001 From: Lars Kruse Date: Thu, 29 Oct 2015 03:57:00 +0100 Subject: [PATCH 1/3] new network plugin added: ath9k Collect information related to ath9k wireless events and states. * rate control statistics ("rc_stats") * events (dropped, transmitted, beacon loss, ...) * traffic (packets, bytes) All data is collected for each separate station (in case of multiple connected peers). Combined graphs are provided as a summary. --- plugins/network/ath9k_ | 453 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 453 insertions(+) create mode 100755 plugins/network/ath9k_ diff --git a/plugins/network/ath9k_ b/plugins/network/ath9k_ new file mode 100755 index 00000000..81229123 --- /dev/null +++ b/plugins/network/ath9k_ @@ -0,0 +1,453 @@ +#!/bin/sh +# weird shebang? See below: "interpreter selection" +# +# Collect information related to ath9k wireless events and states. +# * rate control statistics ("rc_stats") +# * events (dropped, transmitted, beacon loss, ...) +# * traffic (packets, bytes) +# +# All data is collected for each separate station (in case of multiple +# connected peers). Combined graphs are provided as a summary. +# +# +# This plugin works with the following python interpreters: +# * Python 2 +# * Python 3 +# * micropython +# +# +# Copyright (C) 2015 Lars Kruse +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# +# Magic markers +#%# capabilities=autoconf suggest +#%# family=auto + +"""true" +# ****************** Interpreter Selection *************** +# This unbelievable dirty hack allows to find a suitable python interpreter. +# This is specifically useful for OpenWRT where typically only micropython is available. +# +# This "execution hack" works as follows: +# * the script is executed by busybox ash or another shell +# * the above line (three quotes before and one quote after 'true') evaluates differently for shell and python: +# * shell: run "true" (i.e. nothing happens) +# * python: ignore everything up to the next three consecutive quotes +# Thus we may place shell code here that will take care for selecting an interpreter. + +# prefer micropython if it is available - otherwise fall back to any python (2 or 3) +if which micropython >/dev/null; then + /usr/bin/micropython "$0" "$@" +else + python "$0" "$@" +fi +exit $? +""" + + +""" +The following graphs are generated for each physical ath9k interface: + phy0_wifi0_traffic + phy0_wifi0_traffic.station0 + ... + pyh0_wifi0_events + phy0_wifi0_events.station0 + ... + pyh0_wifi0_rc_stats + phy0_wifi0_rc_stats.station0 + ... +""" + + +plugin_version = "0.2" + +STATION_TRAFFIC_COUNTERS = ("rx_bytes", "tx_bytes", "rx_packets", "tx_packets") +STATION_EVENT_COUNTERS = ("tx_retry_count", "tx_retry_failed", "tx_filtered", "tx_fragments", + "rx_dropped", "rx_fragments", "rx_duplicates", "beacon_loss_count") +# 16 colors (see http://munin-monitoring.org/wiki/fieldname.colour) for visualizing +# rate control selection (see rc_stats) +QUALITY_GRAPH_COLORS_16 = ("FF1F00", "FF4500", "FF7000", "FF9700", + "FFBC00", "FAE600", "D1FF00", "7BFF00", + "1CFF00", "06E41B", "00C43B", "009D60", + "007986", "0058A8", "0033CC", "0018DE") +SYS_BASE_DIR = "/sys/kernel/debug/ieee80211" +GRAPH_BASE_NAME = "ath9k_stats" +PLUGIN_SCOPES = ("traffic", "events", "rcstats") + + +import os +import os.path +import subprocess +import sys + + +class Station: + + config_map = {"events": lambda station, **kwargs: station._get_events_config(**kwargs), + "traffic": lambda station, **kwargs: station._get_traffic_config(**kwargs), + "rcstats": lambda station, **kwargs: station._get_rc_stats_config(**kwargs)} + values_map = {"events": lambda station: station._events_stats, + "traffic": lambda station: station._traffic_stats, + "rcstats": lambda station: station._get_rc_stats_success()} + + def __init__(self, label, key, path): + self._path = path + self.label = label + self.key = key + self._events_stats = self._parse_file_based_stats(STATION_EVENT_COUNTERS) + self._traffic_stats = self._parse_file_based_stats(STATION_TRAFFIC_COUNTERS) + self._rc_stats = self._parse_rc_stats() + + def _parse_rc_stats(self): + """ example content + + type rate tpt eprob *prob ret *ok(*cum) ok( cum) + HT20/LGI MCS0 5.6 100.0 100.0 3 0( 0) 3( 3) + HT20/LGI MCS1 10.5 100.0 100.0 0 0( 0) 1( 1) + HT20/LGI MCS2 14.9 100.0 100.0 0 0( 0) 1( 1) + HT20/LGI MCS3 18.7 96.5 100.0 5 0( 0) 261( 328) + HT20/LGI MCS4 25.3 95.6 100.0 5 0( 0) 4267( 5460) + HT20/LGI MCS5 30.6 95.8 100.0 5 0( 0) 11735( 17482) + HT20/LGI MCS6 32.9 95.7 100.0 5 0( 0) 24295( 32592) + HT20/LGI DP MCS7 35.0 90.4 95.2 5 0( 0) 63356( 88600) + HT20/LGI MCS8 10.5 100.0 100.0 0 0( 0) 1( 1) + + beware: sometimes the last two pairs of columns are joined without withespace: "90959383(100188029)" + """ + stats = {} + with open(os.path.join(self._path, "rc_stats"), "r") as statsfile: + rate_column = None + skip_retry_column = False + for index, line in enumerate(statsfile.readlines()): + # remove trailing linebreak, replace braces (annoyingly present in the lasf four columns) + line = line.rstrip().replace("(", " ").replace(")", " ") + # ignore the trailing summary lines + if not line: + break + if index == 0: + # we need to remember the start of the "rate" column (in order to skip the flags) + rate_column = line.index("rate") + if rate_column == 0: + # the following weird format was found on a Barrier Breaker host (2014, Linux 3.10.49): + # rate throughput ewma prob this prob this succ/attempt success attempts + # ABCDP 6 5.4 89.9 100.0 0( 0) 171 183 + # Thus we just assume that there are five flag letters and two blanks. + # Let's hope for the best! + rate_column = 6 + # this format does not contain the "retry" column + skip_retry_column = True + # skip the header line + continue + cutoff_line = line[rate_column:] + tokens = cutoff_line.split() + entry = {} + entry["rate"] = tokens.pop(0) + entry["throughput"] = float(tokens.pop(0)) + entry["ewma_probability"] = float(tokens.pop(0)) + entry["this_probability"] = float(tokens.pop(0)) + if skip_retry_column: + entry["retry"] = 0 + else: + entry["retry"] = int(tokens.pop(0)) + entry["this_success"] = int(tokens.pop(0)) + entry["this_attempts"] = int(tokens.pop(0)) + entry["success"] = int(tokens.pop(0)) + entry["attempts"] = int(tokens.pop(0)) + # some "rate" values are given in MBit/s - some are MCS0..15 + try: + entry["rate_label"] = "{rate:d} MBit/s".format(rate=int(entry["rate"])) + except ValueError: + # keep the MCS string + entry["rate_label"] = entry["rate"] + stats[entry["rate"]] = entry + return stats + + def _get_rc_stats_success(self): + rc_values = {self._get_rate_fieldname(rate["rate"]): rate["success"] for rate in self._rc_stats.values()} + rc_values["sum"] = sum(rc_values.values()) + return rc_values + + def _parse_file_based_stats(self, counters): + stats = {} + for counter in counters: + # some events are not handled with older versions (e.g. "beacon_loss_count") + filename = os.path.join(self._path, counter) + if os.path.exists(filename): + content = open(filename, "r").read().strip() + stats[counter] = int(content) + return stats + + def get_values(self, scope, graph_base): + func = self.values_map[scope] + yield "multigraph {base}_{suffix}.{station}".format(base=graph_base, suffix=scope, station=self.key) + for key, value in func(self).items(): + yield "{key}.value {value}".format(key=key, value=value) + yield "" + + @classmethod + def get_summary_values(cls, scope, siblings, graph_base): + func = cls.values_map[scope] + yield "multigraph {base}_{suffix}".format(base=graph_base, suffix=scope) + stats = {} + for station in siblings: + for key, value in func(station).items(): + stats[key] = stats.get(key, 0) + value + for key, value in stats.items(): + yield "{key}.value {value}".format(key=key, value=value) + yield "" + + def get_config(self, scope, graph_base): + func = self.config_map[scope] + yield "multigraph {base}_{suffix}.{station}".format(base=graph_base, suffix=scope, station=self.key) + yield from func(self, label=self.label, siblings=[self]) + + @classmethod + def get_summary_config(cls, scope, siblings, graph_base): + func = cls.config_map[scope] + yield "multigraph {base}_{suffix}".format(base=graph_base, suffix=scope) + for station in siblings: + yield from func(station, siblings=[station]) + + @classmethod + def _get_traffic_config(cls, label=None, siblings=None): + if label: + yield "graph_title ath9k Station Traffic {label}".format(label=label) + else: + yield "graph_title ath9k Station Traffic" + yield "graph_args --base 1024" + yield "graph_vlabel received (-) / transmitted (+)" + yield "graph_category wireless" + # convert bytes/s into kbit/s (x * 8 / 1000 = x / 125) + yield from _get_up_down_pair("kBit/s", "tx_bytes", "rx_bytes", divider=125, use_negative=False) + yield from _get_up_down_pair("Packets/s", "tx_packets", "rx_packets", use_negative=False) + yield "" + + @classmethod + def _get_events_config(cls, label=None, siblings=None): + if label: + yield "graph_title ath9k Station Events {label}".format(label=label) + else: + yield "graph_title ath9k Station Events" + yield "graph_vlabel events per ${graph_period}" + yield "graph_category wireless" + events = set() + for station in siblings: + for event in STATION_EVENT_COUNTERS: + events.add(event) + for event in events: + yield "{event}.label {event}".format(event=event) + yield "{event}.type COUNTER".format(event=event) + yield "" + + @classmethod + def _get_rate_fieldname(cls, rate): + return "rate_{0}".format(rate.lower()).replace(".", "_") + + @classmethod + def _get_rc_stats_config(cls, label=None, siblings=None): + if label: + yield "graph_title ath9k Station Transmit Rates {label} Success".format(label=label) + else: + yield "graph_title ath9k Station Transmit Rates Success" + yield "graph_vlabel transmit rates %" + yield "graph_category wireless" + yield "graph_args --base 1000 -r --lower-limit 0 --upper-limit 100" + all_rates = {} + # collect alle unique rates + for station in siblings: + for rate, details in station._rc_stats.items(): + all_rates[rate] = details + # return all rates + is_first = True + num_extract = lambda text: int("".join([char for char in text if "0" <= char <= "9"])) + get_key = lambda rate_name: cls._get_rate_fieldname(all_rates[rate_name]["rate"]) + # add all rates for percent visualization ("MCS7,MCS6,MCS5,MCS4,MCS3,MCS2,MCS1,MCS0,+,+,+,+,+,+,+") + cdef = None + for sum_rate in all_rates: + if cdef is None: + cdef = get_key(sum_rate) + else: + cdef = "{key},{cdef},+".format(key=get_key(sum_rate), cdef=cdef) + yield "sum.label Sum of all counters" + yield "sum.type DERIVE" + yield "sum.graph no" + for index, rate in enumerate(sorted(all_rates, key=num_extract)): + details = all_rates[rate] + key = get_key(rate) + yield "{key}.label {rate_label}".format(key=key, rate_label=details["rate_label"]) + yield "{key}.type DERIVE".format(key=key) + yield "{key}.min 0".format(key=key) + if index < len(QUALITY_GRAPH_COLORS_16): + yield "{key}.colour {colour}".format(key=key, colour=QUALITY_GRAPH_COLORS_16[index]) + yield "{key}.draw {draw_type}".format(key=key, draw_type=("AREA" if is_first else "STACK")) + # divide the current value by the above sum of all counters and calculate percent + yield "{key}.cdef 100,{key},sum,/,*".format(key=key, cdef=cdef) + is_first = False + yield "" + + +class WifiInterface: + + def __init__(self, name, path, graph_base): + self._path = path + self._graph_base = graph_base + self.name = name + self.stations = tuple(self._parse_stations()) + + def _parse_arp_cache(self): + """ read IPs and MACs from /proc/net/arp and return a dictionary for MAC -> IP """ + arp_cache = {} + # example content: + # IP address HW type Flags HW address Mask Device + # 192.168.2.70 0x1 0x0 00:00:00:00:00:00 * eth0.10 + # 192.168.12.76 0x1 0x2 24:a4:3c:fd:76:98 * eth1.10 + for line in open("/proc/net/arp", "r").read().split("\n"): + # skip empty lines + if not line: continue + tokens = line.split() + ip, mac = tokens[0], tokens[3] + # the header line can be ignored - all other should have well-formed MACs + if not ":" in mac: continue + # ignore remote peers outside of the broadcast domain + if mac == "00:00:00:00:00:00": continue + arp_cache[mac] = ip + return arp_cache + + def _parse_stations(self): + stations_base = os.path.join(self._path, "stations") + arp_cache = self._parse_arp_cache() + for item in os.listdir(stations_base): + peer_mac = item + # use the IP or fall back to the MAC without separators (":") + if peer_mac in arp_cache: + label = arp_cache[peer_mac] + key = peer_mac.replace(":", "") + else: + label = peer_mac + key = "host_" + peer_mac.replace(":", "").replace(".", "") + yield Station(label, key, os.path.join(stations_base, item)) + + def get_config(self, scope): + yield from Station.get_summary_config(scope, self.stations, self._graph_base) + for station in self.stations: + yield from station.get_config(scope, self._graph_base) + yield "" + + def get_values(self, scope): + yield from Station.get_summary_values(scope, self.stations, self._graph_base) + for station in self.stations: + yield from station.get_values(scope, self._graph_base) + yield "" + + +class Ath9kDriver: + + def __init__(self, path, graph_base): + self._path = path + self._graph_base = graph_base + self.interfaces = tuple(self._parse_interfaces()) + + def _parse_interfaces(self): + for phy in os.listdir(self._path): + phy_path = os.path.join(self._path, phy) + for item in os.listdir(phy_path): + if item.startswith("netdev:"): + wifi = item.split(":", 1)[1] + label = "{phy}/{interface}".format(phy=phy, interface=wifi) + wifi_path = os.path.join(phy_path, item) + graph_base = "{base}_{phy}_{interface}".format(base=self._graph_base, phy=phy, interface=wifi) + yield WifiInterface(label, wifi_path, graph_base) + + def get_config(self, scope): + for interface in self.interfaces: + yield from interface.get_config(scope) + + def get_values(self, scope): + for interface in self.interfaces: + yield from interface.get_values(scope) + + + +def _get_up_down_pair(unit, key_up, key_down, factor=None, divider=None, use_negative=True): + """ return all required statements for a munin-specific up/down value pair + "factor" or "divider" can be given for unit conversions + """ + for key in (key_up, key_down): + if use_negative: + yield "{key}.label {unit}".format(key=key, unit=unit) + else: + yield "{key}.label {key} {unit}".format(key=key, unit=unit) + yield "{key}.type COUNTER".format(key=key) + if factor: + yield "{key}.cdef {key},{factor},*".format(key=key, factor=factor) + if divider: + yield "{key}.cdef {key},{divider},/".format(key=key, divider=divider) + if use_negative: + yield "{key_down}.graph no".format(key_down=key_down) + yield "{key_up}.negative {key_down}".format(key_up=key_up, key_down=key_down) + + +def get_scope(): + called_name = os.path.basename(sys.argv[0]) + name_prefix = "ath9k_" + if called_name.startswith(name_prefix): + scope = called_name[len(name_prefix):] + if not scope in PLUGIN_SCOPES: + print_error("Invalid scope requested: {0} (expected: {1})".format(scope, PLUGIN_SCOPES)) + sys.exit(2) + else: + print_error("Invalid filename - failed to discover plugin scope") + sys.exit(2) + return scope + + +def print_error(message): + # necessary fallback for micropython + linesep = getattr(os, "linesep", "\n") + sys.stderr.write(message + linesep) + + +if __name__ == "__main__": + ath9k = Ath9kDriver(SYS_BASE_DIR, GRAPH_BASE_NAME) + # parse arguments + if len(sys.argv) > 1: + if sys.argv[1]=="config": + for item in ath9k.get_config(get_scope()): + print(item) + sys.exit(0) + elif sys.argv[1] == "autoconf": + if os.path.exists(SYS_BASE_PATH): + print('yes') + else: + print('no') + sys.exit(0) + elif sys.argv[1] == "suggest": + for scope in PLUGIN_SCOPES: + print(scope) + sys.exit(0) + elif sys.argv[1] == "version": + print_error('olsrd Munin plugin, version %s' % plugin_version) + sys.exit(0) + elif sys.argv[1] == "": + # ignore + pass + else: + # unknown argument + print_error("Unknown argument") + sys.exit(1) + + # output values + for item in ath9k.get_values(get_scope()): + print(item) From 3509d2a6edf33dcb5004439b90ef6ba9730705d9 Mon Sep 17 00:00:00 2001 From: Lars Kruse Date: Fri, 30 Oct 2015 05:23:16 +0100 Subject: [PATCH 2/3] remove python 2 compatibility due to widespread use of "yield from" --- plugins/network/ath9k_ | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/plugins/network/ath9k_ b/plugins/network/ath9k_ index 81229123..7c20207c 100755 --- a/plugins/network/ath9k_ +++ b/plugins/network/ath9k_ @@ -11,7 +11,6 @@ # # # This plugin works with the following python interpreters: -# * Python 2 # * Python 3 # * micropython # @@ -51,7 +50,7 @@ if which micropython >/dev/null; then /usr/bin/micropython "$0" "$@" else - python "$0" "$@" + python3 "$0" "$@" fi exit $? """ From 14ff36a31c3bbbc001424d255b746e3e8664d82b Mon Sep 17 00:00:00 2001 From: Lars Kruse Date: Fri, 30 Oct 2015 05:23:46 +0100 Subject: [PATCH 3/3] hide the python code part from shell parsers This works around a syntax warning of the continuous integration system. The cause of this warning could have never triggered anyway. This commit encloses the whole python code safely in a "here" document. --- plugins/network/ath9k_ | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/plugins/network/ath9k_ b/plugins/network/ath9k_ index 7c20207c..405b93c2 100755 --- a/plugins/network/ath9k_ +++ b/plugins/network/ath9k_ @@ -53,6 +53,10 @@ else python3 "$0" "$@" fi exit $? + +# For shell: ignore everything starting from here until the last line of this file. +# This is necessary for syntax checkers that try to complain about invalid shell syntax below. +true <