Get ready for last round of tests in 9th floor.

* Extract RSSI from beacon and non-beacon frames
* Fix handling of 802.11ac channels reported by `airport`
* Suppress lint errors from outside files
* Continue report if iperf3 files contain invalid JSON.

Change-Id: Ie50585252a5a2e83f736c76b94c972a7ce3cc73d
diff --git a/wifitables/Makefile b/wifitables/Makefile
index 57ffad6..e322fee 100644
--- a/wifitables/Makefile
+++ b/wifitables/Makefile
@@ -23,7 +23,7 @@
 runtests: \
 	$(patsubst %_test.py,%.test,$(wildcard *_test.py))
 
-lint: $(filter-out options.py,$(wildcard *.py))
+lint: $(filter-out __init__.py options.py wifipacket.py,$(wildcard *.py))
 	$(GPYLINT) $^
 
 test_only: all
diff --git a/wifitables/channels.tsv b/wifitables/channels.tsv
index 06462f8..eb3e40f 100644
--- a/wifitables/channels.tsv
+++ b/wifitables/channels.tsv
@@ -29,6 +29,7 @@
 132	5660
 136	5680
 140	5700
+144	5720
 149	5745
 153	5765
 157	5785
diff --git a/wifitables/iperf.py b/wifitables/iperf.py
index 265c520..430e226 100644
--- a/wifitables/iperf.py
+++ b/wifitables/iperf.py
@@ -41,9 +41,22 @@
   def IperfUDP(self, host='127.0.0.1', bandwidth=20):
     return self._Iperf(host, udp=True, bandwidth=bandwidth)
 
-  def Iperf3(self, host, udp=False, reverse=False, bandwidth=1e6):
-    """Run iperf against host and return string containing stdout from run."""
-    args = ['iperf3', '-c', host, '-J']
+  def Iperf3(self, host, udp=False, reverse=False, bandwidth=1e6, time=10):
+    """Run iperf against host and return string containing stdout from run.
+
+    Args:
+      host: host to run test against
+      udp:  False (default) for TCP iperf, True for UDP iperf3
+      reverse: if True, run test in reverse direction
+      bandwidth: bandwidth in bits/s for test
+      time: time, in seconds, for test to run
+
+    Returns:
+      output from iperf3 as a string containing json data.
+    """
+    # iperf may hang if a server goes away during a test
+    args = ['./timeout', '{:d}'.format(time+10),
+            'iperf3', '-c', host, '-J', '-t', '{:d}'.format(time)]
     if udp:
       args += ['-u', '-b', str(bandwidth)]
     if reverse:
diff --git a/wifitables/report.py b/wifitables/report.py
index 2d4a1a5..2a56519 100755
--- a/wifitables/report.py
+++ b/wifitables/report.py
@@ -99,6 +99,7 @@
   """
 
   rates = collections.defaultdict(list)
+  beacon_powers = collections.defaultdict(list)
   times_seen = collections.Counter()
   start_secs = None
 
@@ -115,10 +116,15 @@
       bssid = opt.get(sta)
       ssid = known_ssids.get(bssid)
       if ssid:
-        rates[ssid].append((opt.pcap_secs - start_secs,
-                            direction,
-                            opt.rate,
-                            len(frame)))
+        if opt.type == 0x08:  # Beacon
+          rssi = opt.get('dbm_antsignal')
+          if rssi is not None: beacon_powers[ssid].append(rssi)
+        else:
+          rates[ssid].append((opt.pcap_secs - start_secs,
+                              direction,
+                              opt.get('dbm_antsignal'),
+                              opt.rate,
+                              len(frame)))
         times_seen[ssid] += 1
         break
 
@@ -127,20 +133,33 @@
 
   modal_ssid, _ = times_seen.most_common(1)[0]
   summary = {}
-  for _, direction, rate, size in rates[modal_ssid]:
+  for _, direction, rssi, rate, size in rates[modal_ssid]:
     size_weighted_rate = rate * float(size)
     if direction not in summary:
-      summary[direction] = [size_weighted_rate, size]
+      summary[direction] = [size_weighted_rate, size, 0, 0]
     else:
       summary[direction][0] += size_weighted_rate
       summary[direction][1] += size
+    if rssi is not None:
+      summary[direction][2] += rssi
+      summary[direction][3] += 1
 
-  line = {'PHY ssid': modal_ssid}
+  line = {
+      'PHY ssid': modal_ssid,
+      'Beacon RSSI': (sum(beacon_powers[modal_ssid]) /
+                      float(len(beacon_powers[modal_ssid]))
+                      if beacon_powers.get(modal_ssid) else 0.0)
+  }
+
   for direction, accum in summary.items():
-    size_weighted_rate, size = accum
+    size_weighted_rate, size = accum[:2]
     line['PHY {}'.format(direction)] = ((size_weighted_rate / size) if size
                                         else 0.0)
 
+    rssi, count = accum[2:]
+    line['PHY RSSI {}'.format(direction)] = ((rssi / float(count)) if count
+                                             else 0.0)
+
   return line
 
 
@@ -149,12 +168,25 @@
   LoadChannels()
 
   if ',' in text_channel:
-    base, offset = text_channel.split(',')
-    freq = channels[int(base)]
-    offset = int(offset)
-    return (2 * freq + offset * 20) / 2, 40
+    control, second = text_channel.split(',')
+    control = int(control)
+    second = int(second)
+
+    if second == 80:
+      five_ghz_channels = sorted(ch for ch in channels.keys() if ch >= 36)
+      i = five_ghz_channels.index(control)
+      base = five_ghz_channels[i - i % 4]
+      return control, channels[base] + 30, 80
+    elif second in [-1, 1]:
+      freq = channels[control]
+      offset = second
+      return control, freq + offset * 10, 40
+    else:
+      raise AssertionError('text channel "{}" does not match any known format'
+                           .format(text_channel))
   else:
-    return channels[int(text_channel)], 20
+    control = int(text_channel)
+    return control, channels[control], 20
 
 
 def Overlap(c1, w1, c2, w2):
@@ -187,28 +219,28 @@
   if 'Darwin' in system:
     result = ifstats.Parse('Darwin', cache)
     airport = result.get('link')
-    channel, width = Channel(airport['channel'])
+    control, freq, width = Channel(airport['channel'])
     shared = 0
     overlap = 0
 
     scan = result.get('scan')
     if len(scan) > 1:
       for row in scan:
-        oc, ow = Channel(row['CHANNEL'])
-        if channel == oc and width == ow:
+        oc, of, ow = Channel(row['CHANNEL'])
+        if control == oc:
           shared += 1
-        if Overlap(channel, width, oc, ow):
+        elif Overlap(freq, width, of, ow):
           overlap += 1
 
         known_ssids[row['BSSID']] = row['SSID']
 
     line.update({
-        'Channel': channel,
+        'Channel': freq,
         'Width': width,
         'RSSI': airport['agrCtlRSSI'],
         'Noise': airport['agrCtlNoise'],
         'Shared': shared,
-        'Interfering': overlap - shared
+        'Interfering': overlap
     })
 
   if 'Linux' in system:
@@ -281,7 +313,8 @@
       if protocol == 'TCP':
         line[key] = perf['end']['sum_received']['bits_per_second']
       elif protocol == 'UDP':
-        line[key] = perf['end']['sum']['bits_per_second']
+        line[key] = (perf['end']['sum']['bits_per_second'] *
+                     (100 - float(perf['end']['sum']['lost_percent'])) / 100.0)
       else:
         continue
 
@@ -325,7 +358,7 @@
     for folder in folders:
       try:
         report += [ReportLine(folder, series=series)]
-      except (TypeError, IOError) as e:
+      except (TypeError, IOError, ValueError) as e:
         bad += [collections.OrderedDict(folder=folder, error=repr(e))]
 
   return report, bad
@@ -334,9 +367,10 @@
 def WriteReport(lines):
   """Write a network testing report in .tsv format to stdout."""
   # include every field we can write in the header row
-  header = ['Series', 'Time', 'Steps', 'Channel', 'Width', 'RSSI', 'Noise',
-            'Shared', 'Interfering',
+  header = ['Series', 'Time', 'Steps', 'Channel', 'Width', 'RSSI',
+            'Beacon RSSI', 'Noise', 'Shared', 'Interfering',
             'MCS', 'PHY', 'PHY ssid', 'PHY up', 'PHY down', 'PHY across',
+            'PHY RSSI up', 'PHY RSSI down', 'PHY RSSI across',
             'TCP BW up', 'TCP BW up units', 'UDP BW up', 'UDP BW up units',
             'TCP BW down', 'TCP BW down units', 'UDP BW down',
             'UDP BW down units', 'isostream']
diff --git a/wifitables/sample.py b/wifitables/sample.py
index ddcef21..0e49283 100755
--- a/wifitables/sample.py
+++ b/wifitables/sample.py
@@ -226,6 +226,9 @@
       with open(os.path.join(dest_dir, name), 'w+b') as outfile:
         outfile.write(value)
 
+  # On a Mac, announce the end of tests.
+  subprocess.check_call(['which say && say "Done"'], shell=True)
+
 
 if __name__ == '__main__':
   main()
diff --git a/wifitables/tcpdump.py b/wifitables/tcpdump.py
index f29157f..b57006a 100644
--- a/wifitables/tcpdump.py
+++ b/wifitables/tcpdump.py
@@ -74,13 +74,11 @@
     tcpdump_args = ['sudo'] + tcpdump_args + ['-Z', login]
 
   if filtered:
-    filt = '(not subtype beacon and not subtype ack)'
+    filt = '(wlan addr1 {0} or wlan addr2 {0} or wlan addr3 {0})'.format(bssid)
     if bssid:
-      # pylint: disable=line-too-long
-      filt += ' and (wlan addr1 {0} or wlan addr2 {0} or wlan addr3 {0})'.format(bssid)
+      tcpdump_args += [filt]
     else:
       logger.warning('Requested filtered tcpdump, but network BSSID not known.')
-    tcpdump_args += [filt]
 
   err = open(os.path.join(report_dir, 'mcserr'), 'w+b')
   tcpdump_proc = subprocess.Popen(tcpdump_args, stderr=err)