Support running measurements remotely.
Extend the existing test scripts to allow a test laptop to control a TV
Box over SSH, and gather measurements directly from it. This replaces
ad-hoc testing with something more systematic.
Scripts now depend on Python's Fabric library. To install, run
$ sudo easy_install pip
$ sudo pip install fabric
Change-Id: Iec029dee26a9c729528e4f12b89f865537febadc
diff --git a/wifitables/ifstats.py b/wifitables/ifstats.py
new file mode 100644
index 0000000..ae277a9
--- /dev/null
+++ b/wifitables/ifstats.py
@@ -0,0 +1,188 @@
+"""ifstats: get information about a wireless interface."""
+
+import os
+import pipes
+import re
+
+
+class InterfaceStats(object):
+ """InterfaceStats collects statistics from a wireless interface."""
+
+ def __init__(self, system=None, runner=None, interface=None):
+ self.system = system
+ self.run = runner
+ self.interface = interface
+
+ def IwLink(self, devname):
+ """Invoke `iw dev <devname> link` and return its output as a string."""
+ return self.run('iw dev {} link'.format(pipes.quote(devname)))
+
+ def IwScan(self, devname):
+ """Invoke `iw dev <devname> scan` and return its output as a string."""
+ devname_quoted = pipes.quote(devname)
+ text = self.run('iw dev {} scan dump'.format(devname_quoted))
+ if not text:
+ text = self.run('iw dev {} scan'.format(devname_quoted))
+ return text
+
+ def IpAddr(self):
+ """Invoke `ip addr` in one-line mode and return output as a string."""
+ return self.run('ip -o -f inet addr')
+
+ def AirportI(self):
+ """Gather information about the current wireless network from `airport`."""
+ return self.run('airport -I')
+
+ def AirportScan(self):
+ """Gather information about other observable networks from `airport`."""
+ return self.run('airport -s')
+
+ def GatherDarwin(self):
+ """Gather wireless network information on Darwin (Mac OS X)."""
+ return {
+ 'airport': self.AirportI(),
+ 'airportscan': self.AirportScan(),
+ }
+
+ def GatherLinux(self):
+ """Gather wireless network information on Linux."""
+ outputs = {'ipaddr': self.IpAddr()}
+ if self.interface is not None:
+ outputs.update({
+ 'iwlink': self.IwLink(self.interface),
+ 'iwscan': self.IwScan(self.interface)
+ })
+ return outputs
+
+ # If no interface was supplied, use the first connected one (like `airport`
+ # does for you automatically.)
+ iw_dev_raw = self.run('iw dev')
+ for line in iw_dev_raw.splitlines():
+ tokens = line.split()
+ if len(tokens) >= 2 and tokens[0] == 'Interface':
+ interface = tokens[1]
+ try:
+ outputs.update({
+ 'iwlink': self.IwLink(interface),
+ 'iwscan': self.IwScan(interface)
+ })
+ return outputs
+ except ValueError:
+ pass
+
+ raise ValueError('No connected interfaces found.')
+
+ def Gather(self):
+ """Gather all the wireless network information we know how to."""
+ if self.system == 'Darwin':
+ return self.GatherDarwin()
+ elif self.system == 'Linux':
+ return self.GatherLinux()
+ else:
+ raise OSError('System {} unsupported for InterfaceStats'.format(
+ self.system))
+
+def ParseIwLink(text):
+ ol = text.splitlines()
+
+ # BSSID is in the first line, in an idiosyncratic format.
+ # sample: Connected to d8:c7:c8:d7:72:30 (on wlan0)
+ m = re.search(r'(\w{2}:){5}\w{2}', ol[0])
+ if m:
+ result = {'BSSID': m.group(0)}
+ else:
+ raise ValueError('dev was not connected.')
+
+ for line in ol[1:]:
+ try:
+ key, value = line.split(':', 1)
+ result[key.strip()] = value.strip()
+ except ValueError:
+ continue
+
+ return result
+
+def ParseIpAddr(text):
+ ol = text.splitlines()
+ result = {}
+ for line in ol:
+ _, interface, _, addr, _ = line.split(None, 4)
+ result[interface] = addr
+
+ return result
+
+
+def ParseAirportI(text):
+ result = {}
+ for line in text.splitlines():
+ try:
+ key, value = [cell.strip() for cell in line.split(':', 1)]
+ if key in ['agrCtlRSSI', 'agrCtlNoise']:
+ result[key] = int(value)
+ else:
+ result[key] = value
+ except ValueError:
+ continue
+
+ return result
+
+def ParseAirportScan(text):
+ # This is a simple fixed-width format.
+ header = ['SSID', 'BSSID', 'RSSI', 'CHANNEL', 'HT', 'CC',
+ 'SECURITY (auth/unicast/group)']
+ result = []
+
+ chre = re.compile(r'\d+(?:,\+|-\d+)?')
+ for line in text.splitlines():
+ ssid, bssid, rssi, channel, ht, cc, security = (
+ [cell.strip() for cell in (line[:32], line[33:50], line[51:55],
+ line[56:63], line[64:66], line[67:69],
+ line[70:])])
+
+ # the scan sometimes includes comment lines. assume that anything that has
+ # a valid channel isn't a comment line.
+ if chre.match(channel):
+ result += [[ssid, bssid, int(rssi), channel, ht, cc, security]]
+
+ return [header] + result
+
+def Parse(system, cache):
+ if system == 'Darwin':
+ return {
+ 'link': ParseAirportI(cache.get('airport')),
+ 'scan': ParseAirportScan(cache.get('airportscan'))
+ }
+ elif system == 'Linux':
+ return {
+ 'link': ParseIwLink(cache.get('iwlink')),
+ 'addr': ParseIpAddr(cache.get('ipaddr'))
+ }
+ else:
+ raise OSError('Attempt to parse cache from '
+ 'unrecognized system {}'.format(system))
+
+def Restore(report_dir):
+ """Restore an InterfaceStats cache from data on the filesystem."""
+ cache = {}
+
+ apath = os.path.join(report_dir, 'airport')
+ if os.path.exists(apath):
+ system = 'Darwin'
+ names = ['airport', 'airportscan']
+ else:
+ iwpath = os.path.join(report_dir, 'iwlink')
+ if os.path.exists(iwpath):
+ system = 'Linux'
+ names = ['ipaddr', 'iwlink', 'iwscan']
+ else:
+ raise IOError('Could not open report in {}'.format(report_dir))
+
+ for name in names:
+ try:
+ with open(os.path.join(report_dir, name)) as infile:
+ cache[name] = infile.read()
+ except IOError:
+ cache[name] = ''
+
+ return system, cache
+
diff --git a/wifitables/iperf.py b/wifitables/iperf.py
new file mode 100644
index 0000000..efe6781
--- /dev/null
+++ b/wifitables/iperf.py
@@ -0,0 +1,116 @@
+"""iperf: run a series of iperf tests over a wireless network."""
+
+import os
+import pipes
+import re
+import sys
+
+DEVNULL = open(os.devnull, 'wb')
+
+
+class Iperf(object):
+ """Iperf collects `iperf` measurements across a wireless network."""
+
+ def __init__(self, runner=None, bind=None):
+ self.run = runner
+ self.bind = bind
+ self.cache = {}
+
+ def Ping(self, host):
+ line = 'ping -c 1 {}'.format(pipes.quote(host))
+ return self.run(line).return_code
+
+ def _Iperf(self, host, udp=False, bandwidth=20):
+ """Run iperf against host and return string containing stdout from run."""
+ line = 'iperf -c {}'
+ args = [host]
+ if udp:
+ line += ' -u -b {}'
+ args += [str(bandwidth * 1000000)]
+ if self.bind:
+ line += ' -B {}'
+ args += [self.bind]
+
+ return self.run(line.format(*[pipes.quote(arg) for arg in args]))
+
+ def IperfTCP(self, host='127.0.0.1'):
+ return self._Iperf(host)
+
+ def IperfUDP(self, host='127.0.0.1', bandwidth=20):
+ return self._Iperf(host, udp=True, bandwidth=bandwidth)
+
+ def RunTestSeries(self, host):
+ """RunTestSeries runs iperf tests and returns their results.
+
+ Args:
+ host: string containing the hostname to run tests against
+ Returns:
+ a list of files; each file contains output for one test
+ """
+ outputs = {}
+ status = self.Ping(host)
+ if not status:
+ it = self.IperfTCP(host)
+ outputs['iperf'] = it
+
+ # Empirically about 1.25x more packets make it through in UDP than TCP.
+ # Try to saturate the channel by sending a bit more than that over UDP.
+ bandwidth = ParseIperfTCP(it).get('bandwidth', 0.01)
+ outputs['iperfu'] = self.IperfUDP(host, bandwidth=bandwidth * 1.5)
+ else:
+ print >> sys.stderr, ('Could not ping destination host {0}; '
+ 'skipping performance tests').format(host)
+
+ return outputs
+
+def _ParseIperf(text, udp=False):
+ """Parse summary line written by an `iperf` run into a Python dict."""
+ pattern = (r'\[(.{3})\]\s+(?P<interval>.*?sec)\s+'
+ r'(?P<transfer>.*?Bytes|bits)'
+ r'\s+(?P<bandwidth>.*?/sec)')
+ if udp:
+ pattern += r'\s+(?P<jitter>.*?s)\s+(?P<datagrams>.*)'
+
+ iperf_re = re.compile(pattern)
+
+ for line in text.splitlines():
+ match = iperf_re.match(line)
+ if match:
+ iperf = match.groupdict()
+ bval, bunit = iperf['bandwidth'].split()
+ iperf['bandwidth'] = float(bval)
+ iperf['bandwidth_unit'] = bunit
+
+ tval, tunit = iperf['transfer'].split()
+ iperf['transfer'] = float(tval)
+ iperf['transfer_unit'] = tunit
+ return iperf
+
+ return {}
+
+
+def ParseIperfTCP(text):
+ # sample line: [ 4] 0.0-10.0 sec 245 MBytes 206 Mbits/sec
+ return _ParseIperf(text)
+
+
+def ParseIperfUDP(text):
+ # pylint: disable=line-too-long
+ # sample line: [ 5] 0.0-10.0 sec 1.25 MBytes 1.05 Mbits/sec 0.593 ms 0/ 893 (0%)
+ return _ParseIperf(text, udp=True)
+
+
+def Restore(report_dir):
+ """Restores an `Iperf` cache from data on the filesystem."""
+ cache = {}
+ for name in ['iperf', 'iperfu']:
+ ipath = os.path.join(report_dir, name)
+ if os.path.exists(ipath):
+ with open(ipath) as infile:
+ cache[name] = infile.read()
+ else:
+ cache[name] = ''
+
+ return cache
+
+
diff --git a/wifitables/report.py b/wifitables/report.py
index 8ee0fac..67719b2 100755
--- a/wifitables/report.py
+++ b/wifitables/report.py
@@ -7,6 +7,9 @@
import os
import re
import sys
+
+import ifstats
+import iperf
import options
optspec = """
@@ -80,44 +83,6 @@
return counter.most_common()[0][0], phy / alltimes
-def ParseIperf(out, udp=False):
- """Parse output written by an `iperf` run into structured data."""
- pattern = (r'\[(.{3})\]\s+(?P<interval>.*?sec)\s+(?P<transfer>.*?Bytes|bits)'
- r'\s+(?P<bandwidth>.*?/sec)')
- if udp:
- pattern += r'\s+(?P<jitter>.*?s)\s+(?P<datagrams>.*)'
-
- iperf_re = re.compile(pattern)
-
- for line in out.splitlines():
- match = iperf_re.match(line)
- if match:
- iperf = match.groupdict()
- bval, bunit = iperf['bandwidth'].split()
- iperf['bandwidth'] = float(bval)
- iperf['bandwidth_unit'] = bunit
-
- tval, tunit = iperf['transfer'].split()
- iperf['transfer'] = float(tval)
- iperf['transfer_unit'] = tunit
- return iperf
-
- return {}
-
-
-def ParseIperfTCP(out):
- """ParseIperfTCP parses the output of TCP `iperf` runs."""
- # sample line: [ 4] 0.0-10.0 sec 245 MBytes 206 Mbits/sec
- return ParseIperf(out)
-
-
-def ParseIperfUDP(out):
- """ParseIperfUDP parses the output of UDP `iperf` runs."""
- # pylint: disable=line-too-long
- # sample line: [ 5] 0.0-10.0 sec 1.25 MBytes 1.05 Mbits/sec 0.593 ms 0/ 893 (0%)
- return ParseIperf(out, udp=True)
-
-
def Channel(text_channel):
"""Given a text channel spec like 149,+1 return the central freq and width."""
LoadChannels()
@@ -131,77 +96,6 @@
return channels[int(text_channel)], 20
-def ParseAirportI(output):
- """Parse output of `airport -I` and return it as a dictionary."""
- result = {}
- for line in output.splitlines():
- try:
- key, value = [cell.strip() for cell in line.split(':', 1)]
- if key in ['agrCtlRSSI', 'agrCtlNoise']:
- result[key] = int(value)
- else:
- result[key] = value
- except ValueError:
- continue
-
- return result
-
-
-def ParseAirportScan(output):
- """Parse output of `airport -s` and return it as a dictionary."""
- # This is a simple fixed-width format.
- header = ['SSID', 'BSSID', 'RSSI', 'CHANNEL', 'HT', 'CC',
- 'SECURITY (auth/unicast/group)']
- result = []
-
- chre = re.compile(r'\d+(?:,\+|-\d+)?')
- for line in output.splitlines():
- ssid, bssid, rssi, channel, ht, cc, security = (
- [cell.strip() for cell in (line[:32], line[33:50], line[51:55],
- line[56:63], line[64:66], line[67:69],
- line[70:])])
-
- # the scan sometimes includes comment lines. assume that anything that has
- # a valid channel isn't a comment line.
- if chre.match(channel):
- result += [[ssid, bssid, int(rssi), channel, ht, cc, security]]
-
- return [header] + result
-
-
-def ParseIwLink(output):
- """Parse output of `iw dev <devname> link` and return it as a dictionary."""
- ol = output.splitlines()
-
- # BSSID is in the first line, in an idiosyncratic format.
- # sample: Connected to d8:c7:c8:d7:72:30 (on wlan0)
- m = re.search(r'(\w{2}:){5}\w{2}', ol[0])
- if m:
- result = {'BSSID': m.group(0)}
- else:
- raise ValueError('dev was not connected.')
-
- for line in ol[1:]:
- try:
- key, value = line.split(':', 1)
- result[key.strip()] = value.strip()
- except ValueError:
- continue
-
- return result
-
-
-def ParseIpAddr(output):
- """Parse output of one-line `ip addr` and return it as a dictionary."""
- ol = output.splitlines()
- result = {}
- for line in ol:
- _, interface, _, addr, _ = line.split(None, 4)
- result[interface] = addr
-
- return result
-
-
def Overlap(c1, w1, c2, w2):
"""Return True if two WiFi channels overlap, or False otherwise."""
# TODO(willangley): replace with code from Waveguide
@@ -219,35 +113,28 @@
_, _, steps = os.path.basename(report_dir).split('-')
line = [int(steps)]
- # Reports generated on Mac have 'airport'
- apath = os.path.join(report_dir, 'airport')
- if os.path.isfile(apath):
- with open(apath) as ai:
- airport = ParseAirportI(ai.read())
-
+ system, cache = ifstats.Restore(report_dir)
+ result = ifstats.Parse(system, cache)
+ if system == 'Darwin':
+ airport = result.get('link')
channel, width = Channel(airport['channel'])
shared = 0
overlap = 0
- cpath = os.path.join(report_dir, 'airportscan')
- if os.path.exists(cpath):
- with open(cpath) as ac:
- for row in ParseAirportScan(ac.read())[1:]:
- oc, ow = Channel(row[3])
- if channel == oc and width == ow:
- shared += 1
- if Overlap(channel, width, oc, ow):
- overlap += 1
+ scan = result.get('scan')
+ if len(scan) > 1:
+ for row in scan[1:]:
+ oc, ow = Channel(row[3])
+ if channel == oc and width == ow:
+ shared += 1
+ if Overlap(channel, width, oc, ow):
+ overlap += 1
rssi = airport['agrCtlRSSI']
noise = airport['agrCtlNoise']
line += [channel, width, rssi, noise, shared, overlap - shared]
-
- else:
- # assume the report was generated on Linux.
- with open(os.path.join(report_dir, 'iwlink')) as il:
- iwlink = ParseIwLink(il.read())
-
+ elif system == 'Linux':
+ iwlink = result.get('link')
signal = int(iwlink.get('signal', '0 dBm').split()[0])
channel = int(iwlink.get('freq'))
width = 20
@@ -259,24 +146,18 @@
line += [channel, width, signal, None, None, None]
mpath = os.path.join(report_dir, 'mcs')
- if os.path.isfile(mpath):
- with open(os.path.join(report_dir, 'mcs')) as mf:
+ if os.path.exists(mpath):
+ with open(mpath) as mf:
line += ParseMCSFile(mf, width)
else:
line += [None, None]
# If the initial ping test fails, we won't collect performance information.
# deal with this gracefully.
- for fn, infile in [(ParseIperfTCP, 'iperf'),
- (ParseIperfUDP, 'iperfu')]:
- ipath = os.path.join(report_dir, infile)
- if not os.path.isfile(ipath):
- line += [None, None]
- continue
-
- with open(ipath) as ip:
- perf = fn(ip.read())
- line += [perf.get('bandwidth'), perf.get('bandwidth_unit')]
+ ips = iperf.Restore(report_dir)
+ for perf in [iperf.ParseIperfTCP(ips.get('iperf', '')),
+ iperf.ParseIperfUDP(ips.get('iperfu', ''))]:
+ line += [perf.get('bandwidth'), perf.get('bandwidth_unit')]
return line
diff --git a/wifitables/report_test.py b/wifitables/report_test.py
index b324fe4..6ccb832 100644
--- a/wifitables/report_test.py
+++ b/wifitables/report_test.py
@@ -2,6 +2,7 @@
import os
+import ifstats
import report
from wvtest import * # pylint: disable=wildcard-import
@@ -14,11 +15,11 @@
idx = (0, 20, 800)
print 'Testing MCS rate in file', idx
- WVPASSEQ(report.nrates[idx], 6.5 )
+ WVPASSEQ(report.nrates[idx], 6.5)
idx = (25, 40, 400)
print 'Testing computed MCS rate', idx
- WVPASSEQ(report.nrates[idx], 120 )
+ WVPASSEQ(report.nrates[idx], 120)
print
report.LoadChannels()
@@ -37,16 +38,15 @@
def TVBoxReport():
rpt = 'testdata/wifi-1424739295.41-0010'
print 'Checking IP address'
- with open(os.path.join(rpt, 'ipaddr')) as ip:
- addrmap = report.ParseIpAddr(ip.read())
- WVPASSEQ(addrmap.get('lo'), '127.0.0.1/32')
- WVPASSEQ(addrmap.get('wcli0'), '192.168.1.222/24')
+ system, cache = ifstats.Restore(rpt)
+ addrmap = ifstats.ParseIpAddr(cache['ipaddr'])
+ WVPASSEQ(addrmap.get('lo'), '127.0.0.1/32')
+ WVPASSEQ(addrmap.get('wcli0'), '192.168.1.222/24')
print 'Checking for link information'
- with open(os.path.join(rpt, 'iwlink')) as iw:
- data = report.ParseIwLink(iw.read())
- WVPASSEQ(data.get('SSID'), 'GSAFNS1441P0208_TestWifi')
- WVPASSEQ(data.get('BSSID'), 'f4:f5:e8:80:f3:d0')
+ data = ifstats.ParseIwLink(cache['iwlink'])
+ WVPASSEQ(data.get('SSID'), 'GSAFNS1441P0208_TestWifi')
+ WVPASSEQ(data.get('BSSID'), 'f4:f5:e8:80:f3:d0')
line = report.ReportLine(rpt)
print ('Checking report. Implemented measures: steps, channel, width, rssi, '
diff --git a/wifitables/sample.py b/wifitables/sample.py
index 6ff88cf..89cfe2f 100755
--- a/wifitables/sample.py
+++ b/wifitables/sample.py
@@ -2,221 +2,62 @@
"""sample: measure wireless performance and write a report to the filesystem."""
-import multiprocessing
+import atexit
+import functools
import os
import platform
-import re
-import shutil
import subprocess
import sys
-import tempfile
import time
+from fabric.api import env, execute, local, run
+from fabric.network import disconnect_all
+
+import ifstats
+import iperf
import options
-import report
+import tcpdump
optspec = """
sample [options...]
--
+B,bind= interface IP to bind during iperf runs
d,destination= host to run tests against [192.168.1.143]
s,steps= number of steps test was run from [10]
j,journal= append to journal tracking a series of test runs
-i,interface= wireless interface to use for outgoing connections [{0}]
-m,monitor= wireless monitor interface to use [{1}]
+i,interface= wireless interface to use for outgoing connections
+m,monitor= wireless monitor interface to use
+r,remote= remote host to run tests on
"""
-DEVNULL = open(os.devnull, 'wb')
-
-
-def Ping(host):
- code = subprocess.call(['ping', '-c', '1', host], stdout=DEVNULL,
- stderr=DEVNULL)
- return code
-
-
-def Iperf(host, path, udp=False, bandwidth=20, bind=None):
- """Run iperf against host and report results."""
- line = ['iperf', '-c', host]
- name = 'iperf'
-
- if udp:
- line += ['-u', '-b', str(bandwidth * 1000000)]
- name += 'u'
- if bind:
- line += ['-B', bind]
-
- out = open(os.path.join(path, name), 'w+')
- subprocess.check_call(line, stdout=out)
- return out
-
-
-def MCSBackground(tcpdump, out):
- """Continually extract wireless MCS from a running `tcpdump` process.
-
- This function will not return as long as tcpdump is running. You probably want
- to run it in a background activity using multiprocessing.
-
- Args:
- tcpdump: a tcpdump process that's monitoring a wireless interface
- and writing textual output to its stdout stream.
- out: Python file-like object to write MCS information to.
- """
- mcs = re.compile(r'MCS (\d+)')
- x = 0
-
- for row in iter(tcpdump.stdout.readline, b''):
- x += 1
- match = mcs.search(row)
- if match:
- print >> out, '%2d ' % int(match.group(1)),
- else:
- print >> out, ' . ',
-
- if x % 25 == 0:
- print >> out
-
-
-def MCS(bssid, interface, path):
- """Runs tcpdump in the background to gather wireless MCS."""
- print 'Please enter password for `sudo` if prompted.'
- subprocess.call(['sudo', '-v'])
-
- out = open(os.path.join(path, 'mcs'), 'w+')
- err = open(os.path.join(path, 'mcserr'), 'w+')
-
- filt = ('(not subtype beacon and not subtype ack) and '
- '(wlan addr1 {0} or wlan addr2 {0} or wlan addr3 {0})'.format(
- bssid, bssid, bssid))
-
- sudo_tcpdump = subprocess.Popen(['sudo', 'tcpdump', '-Z', os.getlogin(),
- '-Ilnei', interface, filt],
- stdout=subprocess.PIPE, stderr=err)
- proc = multiprocessing.Process(target=MCSBackground, args=(sudo_tcpdump, out))
- proc.start()
-
- return sudo_tcpdump, out, err
-
-
-def IwLink(devname, path):
- out = open(os.path.join(path, 'iwlink'), 'w+')
- subprocess.check_call(['iw', 'dev', devname, 'link'], stdout=out)
- return out
-
-
-def IwScan(devname, path):
- out = open(os.path.join(path, 'iwscan'), 'w+')
- subprocess.check_call(['iw', 'dev', devname, 'scan', 'dump'], stdout=out)
- if os.fstat(out.file.fileno()).st_size: return out
-
- subprocess.check_call(['iw', 'dev', devname, 'scan'], stdout=out)
- return out
-
-
-def IpAddr(path):
- out = open(os.path.join(path, 'ipaddr'), 'w+')
- subprocess.check_call(['ip', '-o', '-f', 'inet', 'addr'], stdout=out)
- return out
-
-
-def AirportI(path):
- """Gather information about the current wireless network from `airport`."""
- out = open(os.path.join(path, 'airport'), 'w+')
- subprocess.check_call(['airport', '-I'], stdout=out)
- return out
-
-
-def AirportScan(path):
- """Gather information about other observable networks from `airport`."""
- out = open(os.path.join(path, 'airportscan'), 'w+')
- subprocess.check_call(['airport', '-s'], stdout=out)
- return out
-
def main():
system = platform.system()
defaults = {
# on a modern MacBook, en0 is the AirPort and can monitor and send at once
- 'Darwin': ['en0', 'en0'],
- # on Linux, separate client and monitor interfaces are needed.
- # these defaults are for a TV box, other platforms will be different.
- 'Linux': ['wcli0', ''],
+ 'Darwin': {
+ 'monitor': 'en0'
+ },
+ # on Linux, separate client and monitor interfaces are needed; and the
+ # monitor interface must be manually created
+ 'Linux': {
+ 'monitor': 'moni0' # from the `iw` documentation
+ },
}
+ o = options.Options(optspec)
+ (opt, _, extra) = o.parse(sys.argv[1:])
+ if extra:
+ o.fatal('did not understand supplied extra arguments.')
+
if not defaults.get(system):
raise OSError('Running on unsupported system {0}; '
'supported systems are {1}'.format(system,
' '.join(defaults.keys())))
- o = options.Options(optspec.format(*defaults[system]))
- (opt, _, extra) = o.parse(sys.argv[1:])
- if extra:
- o.fatal('did not understand supplied extra arguments.')
-
# Pick the report name before we run it, so it can be consistent across
# multiple systems.
report_name = 'wifi-{}-{:04}'.format(time.time(), opt.steps)
- report_dir = tempfile.mkdtemp(prefix='wifi')
-
- # we run diagnostics, write their output to files, and gather the files into
- # a report that we present at the end of the run.
- outputs = []
- addr = ''
-
- if system == 'Darwin':
- ai = AirportI(report_dir)
- ai.seek(0)
- bssid = report.ParseAirportI(ai.read())['BSSID']
- outputs += [ai, AirportScan(report_dir)]
- elif system == 'Linux':
- # It's really likely we're running on a device with more than one interface.
- # Be sure we're using the one that we're trying to test.
- ip = IpAddr(report_dir)
- ip.seek(0)
- addrmap = report.ParseIpAddr(ip.read())
- addr = addrmap.get(opt.interface)
- if not addr:
- raise ValueError('Interface {0} does not have an IPv4 address.'.format(
- opt.interface))
-
- # because ip addr usually includes a subnet mask, which will prevent iperf
- # from binding to the address
- mask = addr.find('/')
- if mask > -1:
- addr = addr[:mask]
-
- il = IwLink(opt.interface, report_dir)
- il.seek(0)
- bssid = report.ParseIwLink(il.read())['BSSID']
-
- ic = IwScan(opt.interface, report_dir)
- outputs += [ip, il, ic]
- else:
- raise OSError('This script requires Mac OS X or Linux.')
-
- if opt.monitor:
- sudo_tcpdump, mcs_out, mcs_err = MCS(bssid, opt.monitor, report_dir)
- print 'Gathering tcpdump in background as', sudo_tcpdump.pid
- outputs += [mcs_out, mcs_err]
-
- status = Ping(opt.destination)
- if not status:
- it = Iperf(opt.destination, report_dir, bind=addr)
-
- # Empirically about 1.25x more packets make it through in UDP than TCP.
- # Try to saturate the channel by sending a bit more than that over UDP.
- it.seek(0)
- it_iperf = report.ParseIperfTCP(it.read())
-
- bandwidth = it_iperf.get('bandwidth', 0.01)
- outputs += [it, Iperf(opt.destination, report_dir, udp=True,
- bandwidth=bandwidth * 1.5,
- bind=addr)]
- else:
- print >> sys.stderr, ('Could not ping destination host {0}; '
- 'skipping performance tests').format(opt.destination)
-
- if opt.monitor:
- subprocess.check_call(['sudo', 'kill', str(sudo_tcpdump.pid)])
if opt.journal:
with open(opt.journal, 'a') as journal:
@@ -225,10 +66,75 @@
else:
dest_dir = report_name
- shutil.move(report_dir, dest_dir)
- print 'Report written to', dest_dir
- for name in (o.name for o in outputs):
- print '*', os.path.basename(name)
+ print 'Report being written to', dest_dir
+ if not os.path.exists(dest_dir):
+ os.makedirs(dest_dir)
+
+ # we run diagnostics, write their output to files, and gather the files into
+ # a report that we present at the end of the run.
+ #
+ # entries collects Entry objects that have not been written to disk yet.
+ execute_args = {}
+ if opt.remote:
+ # update Fabric env for embedded systems
+ env.update({
+ 'key_filename': os.path.expanduser('~/.ssh/bruno-sshkey'),
+ 'user': 'root',
+ 'shell': 'sh -l -c',
+ })
+ execute_args['host'] = opt.remote
+
+ ifsystem = 'Linux'
+ ifrun = run
+
+ atexit.register(disconnect_all)
+ else:
+ ifsystem = system
+ ifrun = functools.partial(local, capture=True)
+
+ ifs = ifstats.InterfaceStats(system=ifsystem,
+ runner=ifrun,
+ interface=opt.interface)
+
+ # since we're only executing over one host, ignore the return from `execute`
+ # that says which host it was for now.
+ cache = execute(ifs.Gather, **execute_args).values()[0]
+ results = ifstats.Parse(ifsystem, cache)
+
+ bssid = results['link']['BSSID']
+ addr = results.get('addr', {}).get(opt.interface, '')
+
+ # because ip addr usually includes a subnet mask, which will prevent iperf
+ # from binding to the address
+ mask = addr.find('/')
+ if mask > -1:
+ addr = addr[:mask]
+
+ if opt.monitor:
+ sudo_tcpdump, mcs_out, mcs_err = tcpdump.MCS(bssid, opt.monitor, dest_dir)
+ print 'Gathering tcpdump in background as', sudo_tcpdump.pid
+
+ ips = iperf.Iperf(runner=ifrun, bind=addr)
+ env.warn_only = True # `iperf` returns 56 if it can't reach the server,
+ # or 57 if it doesn't receive a final report from it
+ # on Linux; don't abort in these cases
+ cache.update(
+ execute(ips.RunTestSeries, opt.destination, **execute_args).values()[0]
+ )
+
+ if opt.monitor:
+ subprocess.check_call(['sudo', 'kill', str(sudo_tcpdump.pid)])
+ for stream in [mcs_out, mcs_err]:
+ stream.flush()
+ stream.close()
+
+ if opt.journal:
+ with open(opt.journal, 'a') as journal:
+ print >> journal, report_name
+
+ for name, value in cache.items():
+ with open(os.path.join(dest_dir, name), 'w+b') as outfile:
+ outfile.write(value)
if __name__ == '__main__':
diff --git a/wifitables/tcpdump.py b/wifitables/tcpdump.py
new file mode 100644
index 0000000..b1e9b3d
--- /dev/null
+++ b/wifitables/tcpdump.py
@@ -0,0 +1,58 @@
+"""tcpdump: helper module that gathers wireless network MCS using tcpdump."""
+
+import multiprocessing
+import os
+import re
+import subprocess
+
+
+def MCSBackground(tcpdump, out):
+ """Continually extract wireless MCS from `tcpdump` output.
+
+ If tcpdump is actively capturing packets, this function will not return as
+ long as tcpdump is running. You probably want to run it in a background
+ activity using multiprocessing.
+
+ Args:
+ tcpdump: a tcpdump process that's writing text output with Radiotap headers
+ to its stdout stream.
+ out: Python file-like object to write MCS information to.
+ """
+ mcsre = re.compile(r'MCS (\d+)')
+ x = 0
+
+ for row in iter(tcpdump.stdout.readline, b''):
+ x += 1
+ match = mcsre.search(row)
+ if match:
+ mcs = int(match.group(1))
+ out.write('{:02} '.format(mcs))
+ else:
+ out.write(' . ')
+
+ if x % 25 == 0:
+ out.write('\n')
+
+
+def MCS(bssid, interface, report_dir=''):
+ """Runs tcpdump in the background to gather wireless MCS."""
+ print 'Please enter password for `sudo` if prompted.'
+ subprocess.call(['sudo', '-v'])
+
+ out = open(os.path.join(report_dir, 'mcs'), 'w+b')
+ err = open(os.path.join(report_dir, 'mcserr'), 'w+b')
+
+ filt = ('(not subtype beacon and not subtype ack) and '
+ '(wlan addr1 {0} or wlan addr2 {0} or wlan addr3 {0})'.format(
+ bssid, bssid, bssid))
+
+ sudo_tcpdump = subprocess.Popen(['sudo', 'tcpdump', '-Z', os.getlogin(),
+ '-Ilnei', interface, filt],
+ stdout=subprocess.PIPE,
+ stderr=err)
+ proc = multiprocessing.Process(target=MCSBackground,
+ args=(sudo_tcpdump, out))
+ proc.start()
+
+ return sudo_tcpdump, out, err
+