waveguide: log stations on secondary APs.

This is important now that we have a guest network with devices
remaining on the secondary _portal interface for extended periods of
time.

Prefix log lines for connected stations with the interface they are
connected to, since this is meaningful now from a business standpoint.
The log lines look like:

wlan0_portal(aa:15:62:43:20:0c): Connected station 80:7a:bf:6f:79:bd taxonomy: SHA:d00571e4b38df159b363b2ce05d89f398aceca18944f50b0ed1ce3804f9ded8b;Unknown;802.11n n:2,w:20

BUG=31601144

Change-Id: I25aed0ad24fe0d1906481e79fc6dbd9fa79ca9a9
diff --git a/waveguide/fake/devlist b/waveguide/fake/devlist
new file mode 100644
index 0000000..e46a05f
--- /dev/null
+++ b/waveguide/fake/devlist
@@ -0,0 +1,30 @@
+phy#1
+        Interface wlan1_portal
+                ifindex 13
+                wdev 0x100000002
+                addr aa:32:ed:07:7f:a8
+                ssid GFiberSetupAutomation
+                type AP
+                channel 153 (5765 MHz), width: 80 MHz, center1: 5775 MHz
+        Interface wlan1
+                ifindex 10
+                wdev 0x100000001
+                addr 88:dc:96:21:13:a1
+                ssid Tangent
+                type AP
+                channel 153 (5765 MHz), width: 80 MHz, center1: 5775 MHz
+phy#0
+        Interface wlan0_portal
+                ifindex 12
+                wdev 0x2
+                addr aa:3b:1c:64:41:93
+                ssid GFiberSetupAutomation
+                type AP
+                channel 1 (2412 MHz), width: 20 MHz, center1: 2412 MHz
+        Interface wlan0
+                ifindex 6
+                wdev 0x1
+                addr f4:f5:e8:81:54:77
+                ssid Tangent
+                type AP
+                channel 1 (2412 MHz), width: 20 MHz, center1: 2412 MHz
diff --git a/waveguide/fake/iw b/waveguide/fake/iw
index ba954f6..b642a29 100755
--- a/waveguide/fake/iw
+++ b/waveguide/fake/iw
@@ -52,6 +52,10 @@
     [ -r "stationdump.$dev" ] && cat "stationdump.$dev"
     exit 0
     ;;
+  dev)
+    [ -r "devlist" ] && cat "devlist"
+    exit 0
+    ;;
   *)
     exit 1
     ;;
diff --git a/waveguide/waveguide.py b/waveguide/waveguide.py
index 594f83f..8f0c3f2 100755
--- a/waveguide/waveguide.py
+++ b/waveguide/waveguide.py
@@ -16,6 +16,7 @@
 # pylint:disable=invalid-name
 """Wifi channel selection and roaming daemon."""
 
+import collections
 import errno
 import gc
 import json
@@ -204,10 +205,22 @@
 
 
 class WlanManager(object):
-  """A class representing one wifi interface on the local host."""
+  """A class representing one wifi interface on the local host.
+
+  Args:
+    phyname (str): name of the phy, like phy0
+    vdevname (str): name of the vdev, like wlan0 or wlan1_portal
+    high_power (bool): advertise the AP as high power
+    tv_box (bool): advertise the AP as a TV box
+    wifiblaster_controller(:obj:`WifiblasterController`): a shared
+      WifiblasterController to probe associating STAs
+    primary (bool): True if the primary AP on a radio, False otherwise.
+      If False, defers most functionality to the WlanManager for the primary AP
+      and logs associated stations only.
+  """
 
   def __init__(self, phyname, vdevname, high_power, tv_box,
-               wifiblaster_controller):
+               wifiblaster_controller, primary=True):
     self.phyname = phyname
     self.vdevname = vdevname
     self.mac = '\0\0\0\0\0\0'
@@ -234,6 +247,7 @@
     self.auto_disabled = None
     self.autochan_2g = self.autochan_5g = self.autochan_free = 0
     self.wifiblaster_controller = wifiblaster_controller
+    self.primary = primary
     helpers.Unlink(self.Filename('disabled'))
 
   def Filename(self, suffix):
@@ -253,7 +267,10 @@
 
   # TODO(apenwarr): when we have async subprocs, add those here
   def GetReadFds(self):
-    return [self.mcast.rsock]
+    if self.primary:
+      return [self.mcast.rsock]
+    else:
+      return []
 
   def NextTimeout(self):
     return self.next_scan_time
@@ -365,8 +382,10 @@
 
   def UpdateStationInfo(self):
     # These change in the background, not as the result of a scan
-    RunProc(callback=self._SurveyResults,
-            args=['iw', 'dev', self.vdevname, 'survey', 'dump'])
+    if self.primary:
+      RunProc(callback=self._SurveyResults,
+              args=['iw', 'dev', self.vdevname, 'survey', 'dump'])
+
     RunProc(callback=self._AssocResults,
             args=['iw', 'dev', self.vdevname, 'station', 'dump'])
 
@@ -837,15 +856,12 @@
       raise Exception('failed (%d) getting wifi dev list: %r' %
                       (errcode, stderr))
     phy = dev = devtype = None
-    phy_devs = {}
+    phy_devs = collections.defaultdict(list)
 
     def AddEntry():
       if phy and dev:
         if devtype == 'AP':
-          # We only want one vdev per PHY.  Special-purpose vdevs are
-          # probably the same name with an extension, so use the shortest one.
-          if phy not in phy_devs or len(phy_devs[phy]) > len(dev):
-            phy_devs[phy] = dev
+          phy_devs[phy].append(dev)
         else:
           log.Debug('Skipping dev %r because type %r != AP', dev, devtype)
 
@@ -867,17 +883,30 @@
       if g:
         devtype = g.group(1)
     AddEntry()
+
     existing_devs = dict((m.vdevname, m) for m in managers)
+    new_devs = set()
+
+    for phy, devs in phy_devs.items():
+      new_devs.update(devs)
+
+      # We only want one full-fledged vdev per PHY.  Special-purpose vdevs are
+      # probably the same name with an extension, so treat the vdev with the
+      # shortest name as the full-fledged one.
+      devs.sort(key=lambda dev: (len(dev), dev))
+      for i, dev in enumerate(devs):
+        primary = i == 0
+        if dev not in existing_devs:
+          log.Debug('Creating wlan manager for (%r, %r)', phy, dev)
+          managers.append(
+              WlanManager(phy, dev, high_power=high_power, tv_box=tv_box,
+                          wifiblaster_controller=wifiblaster_controller,
+                          primary=primary))
+
     for dev, m in existing_devs.iteritems():
-      if dev not in phy_devs.values():
+      if dev not in new_devs:
         log.Log('Forgetting interface %r.', dev)
         managers.remove(m)
-    for phy, dev in phy_devs.iteritems():
-      if dev not in existing_devs:
-        log.Debug('Creating wlan manager for (%r, %r)', phy, dev)
-        managers.append(
-            WlanManager(phy, dev, high_power=high_power, tv_box=tv_box,
-                        wifiblaster_controller=wifiblaster_controller))
 
   RunProc(callback=ParseDevList, args=['iw', 'dev'])
 
@@ -1171,7 +1200,9 @@
     #   node joins, so it can learn about the other nodes as quickly as
     #   possible.  But if we do that, we need to rate limit it somehow.
     for m in managers:
-      m.DoScans()
+      if m.primary:
+        m.DoScans()
+
     if ((opt.tx_interval and now - last_sent > opt.tx_interval) or (
         opt.autochan_interval and now - last_autochan > opt.autochan_interval)):
       if not opt.fake:
@@ -1182,12 +1213,15 @@
     if opt.tx_interval and now - last_sent > opt.tx_interval:
       last_sent = now
       for m in managers:
-        m.SendUpdate()
-        log.WriteEventFile('sentpacket')
+        if m.primary:
+          m.SendUpdate()
+          log.WriteEventFile('sentpacket')
     if opt.autochan_interval and now - last_autochan > opt.autochan_interval:
       last_autochan = now
       for m in managers:
-        m.ChooseChannel()
+        if m.primary:
+          m.ChooseChannel()
+
     if opt.print_interval and now - last_print > opt.print_interval:
       last_print = now
       selfmacs = set()
@@ -1249,10 +1283,10 @@
             else:
               can2G_count += 1
               capability = '2.4'
-            log.Log('Connected station %s supports %s GHz', station, capability)
+            m.Log('Connected station %s supports %s GHz', station, capability)
           species = clientinfo.taxonomize(station)
           if species:
-            log.Log('Connected station %s taxonomy: %s', station, species)
+            m.Log('Connected station %s taxonomy: %s', station, species)
       if log_sta_band_capabilities:
         log.Log('Connected stations: total %d, 5 GHz %d, 2.4 GHz %d',
                 can5G_count + can2G_count, can5G_count, can2G_count)
diff --git a/waveguide/waveguide_test.py b/waveguide/waveguide_test.py
index bd7a65d..9a67bbe 100644
--- a/waveguide/waveguide_test.py
+++ b/waveguide/waveguide_test.py
@@ -19,6 +19,13 @@
 from wvtest import wvtest
 
 
+class FakeOptDict(object):
+  """A fake options.OptDict containing default values."""
+
+  def __init__(self):
+    self.status_dir = '/tmp/waveguide'
+
+
 @wvtest.wvtest
 def IwTimeoutTest():
   old_timeout = waveguide.IW_TIMEOUT_SECS
@@ -30,5 +37,28 @@
   os.environ['PATH'] = old_path
   waveguide.IW_TIMEOUT_SECS = old_timeout
 
+
+@wvtest.wvtest
+def ParseDevListTest():
+  waveguide.opt = FakeOptDict()
+
+  old_path = os.environ['PATH']
+  os.environ['PATH'] = 'fake:' + os.environ['PATH']
+  managers = []
+  waveguide.CreateManagers(managers, False, False, None)
+
+  got_manager_summary = set((m.phyname, m.vdevname, m.primary)
+                            for m in managers)
+  want_manager_summary = set((
+      ('phy1', 'wlan1', True),
+      ('phy1', 'wlan1_portal', False),
+      ('phy0', 'wlan0', True),
+      ('phy0', 'wlan0_portal', False)))
+
+  wvtest.WVPASSEQ(got_manager_summary, want_manager_summary)
+
+  os.environ['PATH'] = old_path
+
+
 if __name__ == '__main__':
   wvtest.wvtest_main()