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
+