Merge "taxonomy: restore pcap test cases."
diff --git a/Makefile b/Makefile
index 7c3f167..2f0e4a6 100644
--- a/Makefile
+++ b/Makefile
@@ -70,6 +70,10 @@
 DIRS+=diags
 endif
 
+ifeq ($(BUILD_CONMAN),y)
+DIRS+=conman
+endif
+
 PREFIX=/usr
 BINDIR=$(DESTDIR)$(PREFIX)/bin
 LIBDIR=$(DESTDIR)$(PREFIX)/lib
diff --git a/conman/Makefile b/conman/Makefile
new file mode 100644
index 0000000..8ff1719
--- /dev/null
+++ b/conman/Makefile
@@ -0,0 +1,47 @@
+INSTALL?=install
+BINDIR=$(DESTDIR)/bin
+LIBDIR=$(DESTDIR)/usr/conman
+PYTHON?=python
+GPYLINT=$(shell \
+    if which gpylint >/dev/null; then \
+      echo gpylint; \
+    else \
+      echo 'echo "(gpylint-missing)" >&2'; \
+    fi \
+)
+
+all:
+
+%.test: %_test.py
+	echo ./$<
+	PYTHONPATH=..:./test/fake_wpactrl:$(PYTHONPATH) ./$<
+
+runtests: \
+	$(patsubst %_test.py,%.test,$(wildcard *_test.py))
+
+lint: $(filter-out options.py,$(wildcard *.py))
+	$(GPYLINT) $^
+
+test_only: all
+	./wvtest/wvtestrun $(MAKE) runtests
+
+# Use a submake here, only because otherwise GNU make (3.81) will not print
+# an error about 'test' itself failing if one of the two sub-targets fails.
+# Without such output, 'lint' could fail long before test_only fails, and
+# the test_only output could scroll off the top of the screen, leaving the
+# misleading impression that everything tested successfully.
+test: all
+	$(MAKE) test_only lint
+
+install:
+	mkdir -p $(LIBDIR) $(BINDIR)
+	$(INSTALL) -m 0644 $(filter-out %_test.py, $(wildcard *.py)) $(LIBDIR)/
+	$(INSTALL) -m 0755 main.py $(LIBDIR)/
+	rm -f $(BINDIR)/conman
+	ln -s /usr/conman/main.py $(BINDIR)/conman
+
+install-libs:
+	@echo "No libs to install."
+
+clean:
+	rm -rf *~ .*~ *.pyc *.tmp */*~
diff --git a/conman/connection_manager.py b/conman/connection_manager.py
new file mode 100755
index 0000000..9bbfb78
--- /dev/null
+++ b/conman/connection_manager.py
@@ -0,0 +1,608 @@
+#!/usr/bin/python
+
+"""Manages a device's wired and wireless connections to the LAN."""
+
+import collections
+import errno
+import glob
+import json
+import logging
+import os
+import re
+import subprocess
+import time
+
+import pyinotify
+
+import cycler
+import interface
+import iw
+
+GFIBER_OUIS = ['f4:f5:e8']
+VENDOR_IE_FEATURE_ID_AUTOPROVISIONING = '01'
+
+
+class FileChangeHandler(pyinotify.ProcessEvent):
+  """Connects pyinotify events to ConnectionManager."""
+
+  def __init__(self, connection_manager, *args, **kwargs):
+    self._connection_manager = connection_manager
+    super(FileChangeHandler, self).__init__(*args, **kwargs)
+
+  def process_IN_DELETE(self, event):
+    self._connection_manager.handle_event(event.path, event.name, True)
+
+  def process_IN_CLOSE_WRITE(self, event):
+    self._connection_manager.handle_event(event.path, event.name, False)
+
+  def process_IN_MOVED_TO(self, event):
+    self.process_IN_CLOSE_WRITE(event)
+
+  def process_IN_MOVED_FROM(self, event):
+    self.process_IN_DELETE(event)
+
+  def check_events(self, *args, **kwargs):
+    result = super(FileChangeHandler, self).check_events(*args, **kwargs)
+    return result
+
+
+class WLANConfiguration(object):
+  """Represents a WLAN configuration from cwmpd."""
+
+  WIFI_STOPAP = ['wifi', 'stopap']
+  WIFI_SETCLIENT = ['wifi', 'setclient']
+  WIFI_STOPCLIENT = ['wifi', 'stopclient']
+
+  def __init__(self, band, wifi, command_lines):
+    self.band = band
+    self.wifi = wifi
+    self.command = command_lines.splitlines()
+    self.access_point_up = False
+    self.client_up = False
+    self.ssid = None
+    self.passphrase = None
+    self.interface_suffix = None
+    self.access_point = None
+
+    binwifi_option_attrs = {
+        '-s': 'ssid',
+        '--ssid': 'ssid',
+        '-S': 'interface_suffix',
+        '--interface_suffix': 'interface_suffix',
+    }
+    attr = None
+    for line in self.command:
+      if attr is not None:
+        setattr(self, attr, line)
+        attr = None
+        continue
+
+      attr = binwifi_option_attrs.get(line, None)
+
+      if line.startswith('WIFI_PSK='):
+        self.passphrase = line.split('WIFI_PSK=')[-1]
+
+    if self.ssid is None:
+      raise ValueError('Command file does not specify SSID')
+
+  def start_access_point(self):
+    """Start an access point."""
+
+    if not self.access_point or self.access_point_up:
+      return
+
+    # Since we only run an access point if we have a wired connection anyway, we
+    # don't want to constrain the AP's channel to the channel of the AP we are
+    # connected to.  This line should go away when we have wireless repeaters.
+    self.stop_client()
+
+    try:
+      subprocess.check_call(self.command, stderr=subprocess.STDOUT)
+      self.access_point_up = True
+    except subprocess.CalledProcessError as e:
+      logging.error('Failed to start access point: %s', e.output)
+
+  def stop_access_point(self):
+    if not self.access_point_up:
+      return
+
+    command = self.WIFI_STOPAP + ['--band', self.band]
+    if self.interface_suffix:
+      command += ['--interface_suffix', self.interface_suffix]
+
+    try:
+      subprocess.check_call(command, stderr=subprocess.STDOUT)
+      self.access_point_up = False
+    except subprocess.CalledProcessError as e:
+      logging.error('Failed to stop access point: %s', e.output)
+      return
+
+  def start_client(self):
+    """Join the WLAN as a client."""
+    if self.client_up:
+      return
+
+    self.wifi.detach_wpa_control()
+
+    command = self.WIFI_SETCLIENT + ['--ssid', self.ssid, '--band', self.band]
+    env = dict(os.environ)
+    if self.passphrase:
+      env['WIFI_CLIENT_PSK'] = self.passphrase
+    try:
+      subprocess.check_call(command, stderr=subprocess.STDOUT, env=env)
+      self.client_up = True
+      logging.info('Successfully joined WLAN')
+    except subprocess.CalledProcessError as e:
+      logging.error('Failed to start wifi client: %s', e.output)
+
+  def stop_client(self):
+    if not self.client_up:
+      return
+
+    self.wifi.detach_wpa_control()
+
+    try:
+      subprocess.check_call(self.WIFI_STOPCLIENT + ['-b', self.band],
+                            stderr=subprocess.STDOUT)
+      self.client_up = False
+    except subprocess.CalledProcessError as e:
+      logging.error('Failed to stop wifi client: %s', e.output)
+
+
+class ConnectionManager(object):
+  """Manages wired and wireless connections to the LAN."""
+
+  # pylint: disable=invalid-name
+  Bridge = interface.Bridge
+  Wifi = interface.Wifi
+  WLANConfiguration = WLANConfiguration
+
+  ETHERNET_STATUS_FILE = 'eth0'
+  WLAN_FILE_REGEXP_FMT = r'^%s((?P<interface_suffix>.*)\.)?(?P<band>2\.4|5)$'
+  COMMAND_FILE_PREFIX = 'command.'
+  ACCESS_POINT_FILE_PREFIX = 'access_point.'
+  COMMAND_FILE_REGEXP = WLAN_FILE_REGEXP_FMT % COMMAND_FILE_PREFIX
+  ACCESS_POINT_FILE_REGEXP = WLAN_FILE_REGEXP_FMT % ACCESS_POINT_FILE_PREFIX
+  GATEWAY_FILE_PREFIX = 'gateway.'
+  MOCA_NODE_FILE_PREFIX = 'node'
+  WIFI_SETCLIENT = ['wifi', 'setclient']
+  IFUP = ['ifup']
+  IP_LINK = ['ip', 'link']
+  IFPLUGD_ACTION = ['/etc/ifplugd/ifplugd.action']
+
+  def __init__(self,
+               bridge_interface='br0',
+               status_dir='/tmp/conman',
+               moca_status_dir='/tmp/cwmp/monitoring/moca2',
+               wpa_control_interface='/var/run/wpa_supplicant',
+               run_duration_s=1, interface_update_period=5,
+               wifi_scan_period_s=120, wlan_retry_s=15, acs_update_wait_s=10):
+
+    self.bridge = self.Bridge(bridge_interface, '10')
+
+    # If we have multiple wcli interfaces, 5 GHz-only < both < 2.4 GHz-only.
+    def metric_for_bands(bands):
+      if '5' in bands:
+        if '2.4' in bands:
+          return interface.METRIC_24GHZ_5GHZ
+        return interface.METRIC_5GHZ
+      return interface.METRIC_24GHZ
+
+    self.wifi = sorted([self.Wifi(interface_name, metric_for_bands(bands),
+                                  # Prioritize 5 GHz over 2.4.
+                                  bands=sorted(bands, reverse=True))
+                        for interface_name, bands
+                        in get_client_interfaces().iteritems()],
+                       key=lambda w: w.metric)
+
+    self._status_dir = status_dir
+    self._interface_status_dir = os.path.join(status_dir, 'interfaces')
+    self._moca_status_dir = moca_status_dir
+    self._wpa_control_interface = wpa_control_interface
+    self._run_duration_s = run_duration_s
+    self._interface_update_period = interface_update_period
+    self._wifi_scan_period_s = wifi_scan_period_s
+    for wifi in self.wifi:
+      wifi.last_wifi_scan_time = -self._wifi_scan_period_s
+    self._wlan_retry_s = wlan_retry_s
+    self._acs_update_wait_s = acs_update_wait_s
+
+    self._wlan_configuration = {}
+
+    # It is very important that we know whether ethernet is up.  So if the
+    # ethernet file doesn't exist for any reason when conman starts, check
+    # explicitly.
+    if os.path.exists(os.path.join(self._interface_status_dir,
+                                   self.ETHERNET_STATUS_FILE)):
+      self._ethernet_file_exists = True
+      self._process_file(self._interface_status_dir, self.ETHERNET_STATUS_FILE)
+    else:
+      self._ethernet_file_exists = False
+      self.bridge.ethernet = self.is_ethernet_up()
+
+    for path, prefix in ((self._moca_status_dir, self.MOCA_NODE_FILE_PREFIX),
+                         (self._status_dir, self.COMMAND_FILE_PREFIX),
+                         (self._status_dir, self.GATEWAY_FILE_PREFIX)):
+      for filepath in glob.glob(os.path.join(path, prefix + '*')):
+        self._process_file(path, os.path.split(filepath)[-1])
+
+    wm = pyinotify.WatchManager()
+    wm.add_watch(self._status_dir,
+                 pyinotify.IN_CLOSE_WRITE | pyinotify.IN_MOVED_TO |
+                 pyinotify.IN_DELETE | pyinotify.IN_MOVED_FROM)
+    wm.add_watch(self._interface_status_dir,
+                 pyinotify.IN_CLOSE_WRITE | pyinotify.IN_MOVED_TO)
+    wm.add_watch(self._moca_status_dir,
+                 pyinotify.IN_CLOSE_WRITE | pyinotify.IN_MOVED_TO)
+    self.notifier = pyinotify.Notifier(wm, FileChangeHandler(self), timeout=0)
+
+    self._interface_update_counter = 0
+    self._try_wlan_after = {'5': 0, '2.4': 0}
+
+  def is_ethernet_up(self):
+    """Explicitly check whether ethernet is up.
+
+    Only used on startup, and only if ifplugd file is missing.
+
+    Returns:
+      Whether the ethernet link is up.
+    """
+    try:
+      lines = subprocess.check_output(self.IP_LINK).splitlines()
+    except subprocess.CalledProcessError as e:
+      raise EnvironmentError('Failed to call "ip link": %r', e.message)
+
+    for line in lines:
+      if 'eth0' in line and 'LOWER_UP' in line:
+        return True
+
+    return False
+
+  def run(self):
+    """Run the main loop, and clean up when done."""
+    try:
+      while True:
+        self.run_once()
+    finally:
+      for wifi in self.wifi:
+        wifi.detach_wpa_control()
+      self.notifier.stop()
+
+  def run_once(self):
+    """Run one iteration of the main loop.
+
+    This includes the following:
+
+    1. Process any changes in watched files.
+    2. Check interfaces for changed connectivity, if
+       update_interfaces_and_routes is true.
+    3. Start, stop, or restart access points as appropriate.  If running an
+       access point, skip all remaining wifi steps for that band.
+    3. Handle any wpa_supplicant events.
+    4. Periodically, perform a wifi scan.
+    5. If not connected to the WLAN or to the ACS, try to connect to something.
+    6. If connected to the ACS but not the WLAN, and enough time has passed
+       since connecting that we should expect a current WLAN configuration, try
+       to join the WLAN again.
+    7. Sleep for the rest of the duration of _run_duration_s.
+    """
+    start_time = time.time()
+    self.notifier.process_events()
+    while self.notifier.check_events():
+      self.notifier.read_events()
+      self.notifier.process_events()
+
+    self._interface_update_counter += 1
+    if self._interface_update_counter == self._interface_update_period:
+      self._interface_update_counter = 0
+      self._update_interfaces_and_routes()
+
+    for wifi in self.wifi:
+      continue_wifi = False
+      if not wifi.attached():
+        logging.debug('Attempting to attach to wpa control interface for %s',
+                      wifi.name)
+        wifi.attach_wpa_control(self._wpa_control_interface)
+      wifi.handle_wpa_events()
+
+      # Only one wlan_configuration per interface will have access_point ==
+      # True.  Try 5 GHz first, then 2.4 GHz.  If both bands are supported by
+      # the same radio, and the enabled band is changed, then hostapd will be
+      # updated by the wifi command, but we should tell any other
+      # wlan_configurations that their APs have been disabled.
+      for band in wifi.bands:
+        wlan_configuration = self._wlan_configuration.get(band, None)
+        if continue_wifi:
+          if wlan_configuration:
+            wlan_configuration.access_point_up = False
+          continue
+
+        # Make sure access point is running iff it should be.
+        if wlan_configuration:
+          self._update_access_point(wlan_configuration)
+          # If we are running an AP on this interface, we don't want to make any
+          # client connections on it.
+          if wlan_configuration.access_point_up:
+            continue_wifi = True
+
+      if continue_wifi:
+        logging.debug('Running AP on %s, nothing else to do.', wifi.name)
+        continue
+
+      # If this interface is connected to the user's WLAN, there is nothing else
+      # to do.
+      if self._connected_to_wlan(wifi):
+        logging.debug('Connected to WLAN on %s, nothing else to do.', wifi.name)
+        return
+
+      # This interface is not connected to the WLAN, so scan for potential
+      # routes to the ACS for provisioning.
+      if (not self.acs() and
+          time.time() > wifi.last_wifi_scan_time + self._wifi_scan_period_s):
+        logging.debug('Performing scan on %s.', wifi.name)
+        self._wifi_scan(wifi)
+
+      # Periodically retry rejoining the WLAN.  If the WLAN configuration is
+      # updated then _update_wlan_configuration will do this immediately, but
+      # maybe the credentials didn't change and we were just waiting for the AP
+      # to restart or something.  Try both bands, so we can fall back to 2.4 in
+      # case 5 is unavailable for some reason.
+      for band in wifi.bands:
+        wlan_configuration = self._wlan_configuration.get(band, None)
+        if wlan_configuration and time.time() > self._try_wlan_after[band]:
+          logging.debug('Trying to join WLAN on %s.', wifi.name)
+          wlan_configuration.start_client()
+          if self._connected_to_wlan(wifi):
+            logging.debug('Joined WLAN on %s.', wifi.name)
+            self._try_wlan_after[band] = 0
+            break
+          else:
+            logging.error('Failed to connect to WLAN on %s.', wifi.name)
+            self._try_wlan_after[band] = time.time() + self._wlan_retry_s
+      else:
+        # If we are aren't on the WLAN, can ping the ACS, and haven't gotten a
+        # new WLAN configuration yet, there are two possibilities:
+        #
+        # 1) The configuration didn't change, and we should retry connecting.
+        # 2) cwmpd isn't writing a configuration, possibly because the device
+        #    isn't registered to any accounts.
+        logging.debug('Unable to join WLAN on %s', wifi.name)
+        if self.acs():
+          logging.debug('Connected to ACS on %s', wifi.name)
+          now = time.time()
+          if (self._wlan_configuration and
+              hasattr(wifi, 'waiting_for_acs_since')):
+            if now - wifi.waiting_for_acs_since > self._acs_update_wait_s:
+              logging.info('ACS has not updated WLAN configuration; will retry '
+                           ' with old config.')
+              for w in self.wifi:
+                for b in w.bands:
+                  self._try_wlan_after[b] = now
+              continue
+          # We don't want to want to log this spammily, so do exponential
+          # backoff.
+          elif (hasattr(wifi, 'complain_about_acs_at')
+                and now >= wifi.complain_about_acs_at):
+            wait = wifi.complain_about_acs_at - wifi.waiting_for_acs_since
+            logging.info('Can ping ACS, but no WLAN configuration for %ds.',
+                         wait)
+            wifi.complain_about_acs_at += wait
+        # If we didn't manage to join the WLAN and we don't have an ACS
+        # connection, we should try to establish one.
+        else:
+          logging.debug('Not connected to ACS on %s', wifi.name)
+          self._try_next_bssid(wifi)
+
+    time.sleep(max(0, self._run_duration_s - (time.time() - start_time)))
+
+  def acs(self):
+    return self.bridge.acs() or any(wifi.acs() for wifi in self.wifi)
+
+  def internet(self):
+    return self.bridge.internet() or any(wifi.internet() for wifi in self.wifi)
+
+  def _update_interfaces_and_routes(self):
+    # If ifplugd has never written an ethernet status file, poll manually before
+    # updating routes.
+    if not self._ethernet_file_exists:
+      self.bridge.ethernet = self.is_ethernet_up()
+
+    self.bridge.update_routes()
+    for wifi in self.wifi:
+      wifi.update_routes()
+
+  def handle_event(self, path, filename, deleted):
+    if deleted:
+      self._handle_deleted_file(path, filename)
+    else:
+      self._process_file(path, filename)
+
+  def _handle_deleted_file(self, path, filename):
+    if path == self._status_dir:
+      match = re.match(self.COMMAND_FILE_REGEXP, filename)
+      if match:
+        band = match.group('band')
+        if band in self._wlan_configuration:
+          self._remove_wlan_configuration(band)
+          return
+
+      match = re.match(self.ACCESS_POINT_FILE_REGEXP, filename)
+      if match:
+        band = match.group('band')
+        if band in self._wlan_configuration:
+          self._wlan_configuration[band].access_point = False
+          return
+
+  def _remove_wlan_configuration(self, band):
+    config = self._wlan_configuration[band]
+    config.stop_client()
+    config.stop_access_point()
+    del self._wlan_configuration[band]
+
+  def _process_file(self, path, filename):
+    """Process or ignore an updated file in a watched directory."""
+    filepath = os.path.join(path, filename)
+    try:
+      contents = open(filepath).read().strip()
+    except IOError as e:
+      if e.errno == errno.ENOENT:
+        # Logging about failing to open .tmp files results in spammy logs.
+        if not filename.endswith('.tmp'):
+          logging.error('Not a file: %s', filepath)
+        return
+      else:
+        raise
+
+    if path == self._interface_status_dir:
+      if filename == self.ETHERNET_STATUS_FILE:
+        try:
+          self.bridge.ethernet = bool(int(contents))
+          self._ethernet_file_exists = True
+        except ValueError:
+          logging.error('Status file contents should be 0 or 1, not %s',
+                        contents)
+          return
+
+    if path == self._status_dir:
+      if filename.startswith(self.COMMAND_FILE_PREFIX):
+        match = re.match(self.COMMAND_FILE_REGEXP, filename)
+        if match:
+          band = match.group('band')
+          wifi = next(w for w in self.wifi if band in w.bands)
+          self._update_wlan_configuration(
+              self.WLANConfiguration(band, wifi, contents))
+      elif filename.startswith(self.ACCESS_POINT_FILE_PREFIX):
+        match = re.match(self.ACCESS_POINT_FILE_REGEXP, filename)
+        if match:
+          band = match.group('band')
+          wifi = next(w for w in self.wifi if band in w.bands)
+          if band in self._wlan_configuration:
+            self._wlan_configuration[band].access_point = True
+      elif filename.startswith(self.GATEWAY_FILE_PREFIX):
+        interface_name = filename.split(self.GATEWAY_FILE_PREFIX)[-1]
+        ifc = self.interface_by_name(interface_name)
+        if ifc:
+          ifc.set_gateway_ip(contents)
+          logging.info('Received gateway %r for interface %s', contents,
+                       ifc.name)
+
+    elif path == self._moca_status_dir:
+      match = re.match(r'^%s\d+$' % self.MOCA_NODE_FILE_PREFIX, filename)
+      if match:
+        try:
+          json_contents = json.loads(contents)
+        except ValueError:
+          logging.error('Cannot parse %s as JSON.', filepath)
+          return
+        node = json_contents['NodeId']
+        had_moca = self.bridge.moca
+        if json_contents.get('RxNBAS', 0) == 0:
+          self.bridge.remove_moca_station(node)
+        else:
+          self.bridge.add_moca_station(node)
+        has_moca = self.bridge.moca
+        if had_moca != has_moca:
+          self.tell_ifplugd_about_moca(has_moca)
+
+  def interface_by_name(self, interface_name):
+    for ifc in [self.bridge] + self.wifi:
+      if ifc.name == interface_name:
+        return ifc
+
+  def tell_ifplugd_about_moca(self, up):
+    subprocess.call(self.IFPLUGD_ACTION + ['moca0', 'up' if up else 'down'])
+
+  def _wifi_scan(self, wifi):
+    """Perform a wifi scan and update wifi.cycler."""
+    logging.info('Scanning on %s...', wifi.name)
+    wifi.last_wifi_scan_time = time.time()
+    subprocess.call(self.IFUP + [wifi.name])
+    with_ie, without_ie = self._find_bssids(wifi.name)
+    logging.info('Done scanning on %s', wifi.name)
+    items = [(bss_info, 3) for bss_info in with_ie]
+    items += [(bss_info, 1) for bss_info in without_ie]
+    wifi.cycler = cycler.AgingPriorityCycler(cycle_length_s=30, items=items)
+
+  def _find_bssids(self, wcli):
+    def supports_autoprovisioning(oui, vendor_ie):
+      if oui not in GFIBER_OUIS:
+        return False
+
+      return vendor_ie.startswith(VENDOR_IE_FEATURE_ID_AUTOPROVISIONING)
+
+    return iw.find_bssids(wcli, supports_autoprovisioning, False)
+
+  def _try_next_bssid(self, wifi):
+    """Attempt to connect to the next BSSID in wifi's BSSID cycler.
+
+    Args:
+      wifi: The wifi interface to use.
+
+    Returns:
+      Whether connecting to the network succeeded.
+    """
+    if not hasattr(wifi, 'cycler'):
+      return False
+
+    bss_info = wifi.cycler.next()
+    if bss_info is not None:
+      connected = subprocess.call(self.WIFI_SETCLIENT +
+                                  ['--ssid', bss_info.ssid,
+                                   '--band', wifi.bands[0],
+                                   '--bssid', bss_info.bssid]) == 0
+      if connected:
+        now = time.time()
+        wifi.waiting_for_acs_since = now
+        wifi.complain_about_acs_at = now + 5
+        logging.info('Attempting to provision via SSID %s', bss_info.ssid)
+      return connected
+
+    return False
+
+  def _connected_to_wlan(self, wifi):
+    return (wifi.wpa_supplicant and
+            any(config.client_up for band, config
+                in self._wlan_configuration.iteritems()
+                if band in wifi.bands))
+
+  def _update_wlan_configuration(self, wlan_configuration):
+    band = wlan_configuration.band
+    current = self._wlan_configuration.get(band, None)
+    if current is None or wlan_configuration.command != current.command:
+      if current is not None:
+        wlan_configuration.access_point = current.access_point
+      else:
+        ap_file = os.path.join(
+            self._status_dir, self.ACCESS_POINT_FILE_PREFIX +
+            (('%s.' % wlan_configuration.interface_suffix)
+             if wlan_configuration.interface_suffix else '') + band)
+        wlan_configuration.access_point = os.path.exists(ap_file)
+      self._wlan_configuration[band] = wlan_configuration
+      self._update_access_point(wlan_configuration)
+
+  def _update_access_point(self, wlan_configuration):
+    if self.bridge.internet() and wlan_configuration.access_point:
+      wlan_configuration.start_access_point()
+    else:
+      wlan_configuration.stop_access_point()
+      wlan_configuration.start_client()
+
+
+def _wifi_show():
+  try:
+    return subprocess.check_output(['wifi', 'show'])
+  except subprocess.CalledProcessError as e:
+    logging.error('Failed to call "wifi show": %s', e)
+
+
+def get_client_interfaces():
+  current_band = None
+  result = collections.defaultdict(set)
+  for line in _wifi_show().splitlines():
+    if line.startswith('Band:'):
+      current_band = line.split()[1]
+    elif line.startswith('Client Interface:'):
+      result[line.split()[2]].add(current_band)
+
+  return result
+
diff --git a/conman/connection_manager_test.py b/conman/connection_manager_test.py
new file mode 100755
index 0000000..5388b22
--- /dev/null
+++ b/conman/connection_manager_test.py
@@ -0,0 +1,714 @@
+#!/usr/bin/python
+
+"""Tests for connection_manager.py."""
+
+import logging
+import os
+import shutil
+import tempfile
+
+import connection_manager
+import interface_test
+import iw
+from wvtest import wvtest
+
+logging.basicConfig(level=logging.DEBUG)
+
+FAKE_MOCA_NODE1_FILE = """{
+  "NodeId": 1,
+  "RxNBAS": 25
+}
+"""
+
+FAKE_MOCA_NODE1_FILE_DISCONNECTED = """{
+  "NodeId": 1,
+  "RxNBAS": 0
+}
+"""
+
+WIFI_SHOW_OUTPUT_ONE_RADIO = """Band: 2.4
+RegDomain: US
+Interface: wlan0  # 2.4 GHz ap
+Channel: 149
+BSSID: f4:f5:e8:81:1b:a0
+AutoChannel: True
+AutoType: NONDFS
+Station List for band: 2.4
+
+Client Interface: wcli0  # 2.4 GHz client
+Client BSSID: f4:f5:e8:81:1b:a1
+
+Band: 5
+RegDomain: US
+Interface: wlan0  # 5 GHz ap
+Channel: 149
+BSSID: f4:f5:e8:81:1b:a0
+AutoChannel: True
+AutoType: NONDFS
+Station List for band: 5
+
+Client Interface: wcli0  # 5 GHz client
+Client BSSID: f4:f5:e8:81:1b:a1
+"""
+
+WIFI_SHOW_OUTPUT_TWO_RADIOS = """Band: 2.4
+RegDomain: US
+Interface: wlan0  # 2.4 GHz ap
+Channel: 149
+BSSID: f4:f5:e8:81:1b:a0
+AutoChannel: True
+AutoType: NONDFS
+Station List for band: 2.4
+
+Client Interface: wcli0  # 2.4 GHz client
+Client BSSID: f4:f5:e8:81:1b:a1
+
+Band: 5
+RegDomain: US
+Interface: wlan1  # 5 GHz ap
+Channel: 149
+BSSID: f4:f5:e8:81:1b:a0
+AutoChannel: True
+AutoType: NONDFS
+Station List for band: 5
+
+Client Interface: wcli1  # 5 GHz client
+Client BSSID: f4:f5:e8:81:1b:a1
+"""
+
+IW_SCAN_OUTPUT = """BSS 00:11:22:33:44:55(on wcli0)
+  SSID: s1
+  Vendor specific: OUI f4:f5:e8, data: 01
+BSS 66:77:88:99:aa:bb(on wcli0)
+  SSID: s1
+  Vendor specific: OUI f4:f5:e8, data: 01
+BSS 01:23:45:67:89:ab(on wcli0)
+  SSID: s2
+"""
+
+
+@wvtest.wvtest
+def get_client_interfaces_test():
+  """Test get_client_interfaces."""
+  # pylint: disable=protected-access
+  original_wifi_show = connection_manager._wifi_show
+  connection_manager._wifi_show = lambda: WIFI_SHOW_OUTPUT_ONE_RADIO
+  wvtest.WVPASSEQ(connection_manager.get_client_interfaces(),
+                  {'wcli0': set(['2.4', '5'])})
+  connection_manager._wifi_show = lambda: WIFI_SHOW_OUTPUT_TWO_RADIOS
+  wvtest.WVPASSEQ(connection_manager.get_client_interfaces(),
+                  {'wcli0': set(['2.4']), 'wcli1': set(['5'])})
+  connection_manager._wifi_show = original_wifi_show
+
+
+class WLANConfiguration(connection_manager.WLANConfiguration):
+  """WLANConfiguration subclass for testing."""
+
+  WIFI_STOPAP = ['echo', 'stopap']
+  WIFI_SETCLIENT = ['echo', 'setclient']
+  WIFI_STOPCLIENT = ['echo', 'stopclient']
+
+  def start_client(self):
+    if not self.client_up:
+      self.wifi.set_connection_check_result('succeed')
+
+      if self.wifi.attached():
+        self.wifi.add_connected_event()
+      else:
+        open(self._socket(), 'w')
+
+      # Normally, wpa_supplicant would bring up wcli*, which would trigger
+      # ifplugd, which would run ifplugd.action, which would do two things:
+      #
+      # 1)  Write an interface status file.
+      # 2)  Call run-dhclient, which would call dhclient-script, which would
+      #     write a gateway file.
+      #
+      # Fake both of these things instead.
+      self.write_interface_status_file('1')
+      self.write_gateway_file()
+
+    super(WLANConfiguration, self).start_client()
+
+  def stop_client(self):
+    if self.client_up:
+      self.wifi.add_terminating_event()
+      os.unlink(self._socket())
+      self.wifi.set_connection_check_result('fail')
+
+    # See comments in start_client.
+    self.write_interface_status_file('0')
+
+    super(WLANConfiguration, self).stop_client()
+
+  def _socket(self):
+    return os.path.join(self._wpa_control_interface, self.wifi.name)
+
+  def write_gateway_file(self):
+    gateway_file = os.path.join(self.status_dir,
+                                self.gateway_file_prefix + self.wifi.name)
+    with open(gateway_file, 'w') as f:
+      # This value doesn't matter to conman, so it's fine to hard code it here.
+      f.write('192.168.1.1')
+
+  def write_interface_status_file(self, value):
+    status_file = os.path.join(self.interface_status_dir, self.wifi.name)
+    with open(status_file, 'w') as f:
+      # This value doesn't matter to conman, so it's fine to hard code it here.
+      f.write(value)
+
+
+class Wifi(interface_test.Wifi):
+
+  def __init__(self, *args, **kwargs):
+    super(Wifi, self).__init__(*args, **kwargs)
+    # Whether wpa_supplicant is connected to a network.
+    self._initially_connected = True
+    self.wifi_scan_counter = 0
+
+
+class ConnectionManager(connection_manager.ConnectionManager):
+  """ConnectionManager subclass for testing."""
+
+  # pylint: disable=invalid-name
+  Bridge = interface_test.Bridge
+  Wifi = Wifi
+  WLANConfiguration = WLANConfiguration
+
+  WIFI_SETCLIENT = ['echo', 'setclient']
+  IFUP = ['echo', 'ifup']
+  IFPLUGD_ACTION = ['echo', 'ifplugd.action']
+
+  def __init__(self, *args, **kwargs):
+    super(ConnectionManager, self).__init__(*args, **kwargs)
+    self.scan_has_results = False
+
+  def _update_access_point(self, wlan_configuration):
+    client_was_up = wlan_configuration.client_up
+    super(ConnectionManager, self)._update_access_point(wlan_configuration)
+    if wlan_configuration.access_point_up:
+      if client_was_up:
+        wifi = self.wifi_for_band(wlan_configuration.band)
+        wifi.add_terminating_event()
+
+  def _try_next_bssid(self, wifi):
+    if hasattr(wifi, 'cycler'):
+      bss_info = wifi.cycler.peek()
+      if bss_info:
+        self.last_provisioning_attempt = bss_info
+
+    super(ConnectionManager, self)._try_next_bssid(wifi)
+
+    socket = os.path.join(self._wpa_control_interface, wifi.name)
+
+    if bss_info and bss_info.ssid == 's1':
+      if wifi.attached():
+        wifi.add_connected_event()
+      else:
+        open(socket, 'w')
+      wifi.set_connection_check_result('fail')
+      self.write_interface_status_file(wifi.name, '1')
+      return True
+
+    if bss_info and bss_info.ssid == 's2':
+      if wifi.attached():
+        wifi.add_connected_event()
+      else:
+        open(socket, 'w')
+      wifi.set_connection_check_result('restricted')
+      self.write_interface_status_file(wifi.name, '1')
+      self.write_gateway_file(wifi.name)
+      return True
+
+    return False
+
+  def _wifi_stopclient(self, band):
+    super(ConnectionManager, self)._wifi_stopclient(band)
+    self.wifi_for_band(band).add_terminating_event()
+
+  # pylint: disable=unused-argument,protected-access
+  def _find_bssids(self, wcli):
+    # Only the 5 GHz scan finds anything.
+    if wcli == 'wcli0' and self.scan_has_results:
+      iw._scan = lambda interface: IW_SCAN_OUTPUT
+    else:
+      iw._scan = lambda interface: ''
+    return super(ConnectionManager, self)._find_bssids(wcli)
+
+  def _update_wlan_configuration(self, wlan_configuration):
+    wlan_configuration.command.insert(0, 'echo')
+    wlan_configuration._wpa_control_interface = self._wpa_control_interface
+    wlan_configuration.status_dir = self._status_dir
+    wlan_configuration.interface_status_dir = self._interface_status_dir
+    wlan_configuration.gateway_file_prefix = self.GATEWAY_FILE_PREFIX
+
+    super(ConnectionManager, self)._update_wlan_configuration(
+        wlan_configuration)
+
+  # Just looking for last_wifi_scan_time to change doesn't work because the
+  # tests run too fast.
+  def _wifi_scan(self, wifi):
+    super(ConnectionManager, self)._wifi_scan(wifi)
+    wifi.wifi_scan_counter += 1
+
+  def tell_ifplugd_about_moca(self, up):
+    # Typically, when moca comes up, conman calls ifplugd.action, which writes
+    # this file.
+    self.write_interface_status_file('moca0', '1' if up else '0')
+
+  # Non-overrides
+
+  def wifi_for_band(self, band):
+    for wifi in self.wifi:
+      if band in wifi.bands:
+        return wifi
+
+  def access_point_up(self, band):
+    if band not in self._wlan_configuration:
+      return False
+
+    return self._wlan_configuration[band].access_point_up
+
+  def client_up(self, band):
+    if band not in self._wlan_configuration:
+      return False
+
+    return self._wlan_configuration[band].client_up
+
+  # Test methods
+
+  def wlan_config_filename(self, band):
+    return os.path.join(self._status_dir, 'command.%s' % band)
+
+  def access_point_filename(self, band):
+    return os.path.join(self._status_dir, 'access_point.%s' % band)
+
+  def delete_wlan_config(self, band):
+    os.unlink(self.wlan_config_filename(band))
+
+  def write_wlan_config(self, band, ssid, psk, atomic=False):
+    final_filename = self.wlan_config_filename(band)
+    filename = final_filename + ('.tmp' if atomic else '')
+    with open(filename, 'w') as f:
+      f.write('\n'.join(['env', 'WIFI_PSK=%s' % psk,
+                         'wifi', 'set', '-b', band, '--ssid', ssid]))
+    if atomic:
+      os.rename(filename, final_filename)
+
+  def enable_access_point(self, band):
+    open(self.access_point_filename(band), 'w')
+
+  def disable_access_point(self, band):
+    ap_filename = self.access_point_filename(band)
+    if os.path.isfile(ap_filename):
+      os.unlink(ap_filename)
+
+  def write_gateway_file(self, interface_name):
+    gateway_file = os.path.join(self._status_dir,
+                                self.GATEWAY_FILE_PREFIX + interface_name)
+    with open(gateway_file, 'w') as f:
+      # This value doesn't matter to conman, so it's fine to hard code it here.
+      f.write('192.168.1.1')
+
+  def write_interface_status_file(self, interface_name, value):
+    status_file = os.path.join(self._interface_status_dir, interface_name)
+    with open(status_file, 'w') as f:
+      # This value doesn't matter to conman, so it's fine to hard code it here.
+      f.write(value)
+
+  def set_ethernet(self, up):
+    self.write_interface_status_file('eth0', '1' if up else '0')
+
+    if up:
+      # On the real system, ifplugd would run dhclient, which would write a
+      # gateway file.
+      self.write_gateway_file('br0')
+
+  def set_moca(self, up):
+    moca_node1_file = os.path.join(self._moca_status_dir,
+                                   self.MOCA_NODE_FILE_PREFIX + '1')
+    with open(moca_node1_file, 'w') as f:
+      f.write(FAKE_MOCA_NODE1_FILE if up else
+              FAKE_MOCA_NODE1_FILE_DISCONNECTED)
+
+    if up:
+      # On the real system, ifplugd would run dhclient, which would write a
+      # gateway file.
+      self.write_gateway_file('br0')
+
+  def run_until_interface_update(self):
+    while self._interface_update_counter == 0:
+      self.run_once()
+    while self._interface_update_counter != 0:
+      self.run_once()
+
+  def run_until_scan(self, band):
+    wifi = self.wifi_for_band(band)
+    wifi_scan_counter = wifi.wifi_scan_counter
+    while wifi_scan_counter == wifi.wifi_scan_counter:
+      self.run_once()
+
+
+def connection_manager_test(radio_config):
+  """Returns a decorator that does ConnectionManager test boilerplate."""
+  def inner(f):
+    """The actual decorator."""
+    def actual_test():
+      """The actual test function."""
+      run_duration_s = .01
+      interface_update_period = 5
+      wifi_scan_period = 5
+      wifi_scan_period_s = run_duration_s * wifi_scan_period
+
+      # pylint: disable=protected-access
+      original_wifi_show = connection_manager._wifi_show
+      connection_manager._wifi_show = lambda: radio_config
+
+      try:
+        # No initial state.
+        status_dir = tempfile.mkdtemp()
+        os.mkdir(os.path.join(status_dir, 'interfaces'))
+        moca_status_dir = tempfile.mkdtemp()
+        wpa_control_interface = tempfile.mkdtemp()
+
+        c = ConnectionManager(status_dir=status_dir,
+                              moca_status_dir=moca_status_dir,
+                              wpa_control_interface=wpa_control_interface,
+                              run_duration_s=run_duration_s,
+                              interface_update_period=interface_update_period,
+                              wifi_scan_period_s=wifi_scan_period_s)
+
+        c.test_interface_update_period = interface_update_period
+        c.test_wifi_scan_period = wifi_scan_period
+
+        f(c)
+
+      finally:
+        shutil.rmtree(status_dir)
+        shutil.rmtree(moca_status_dir)
+        shutil.rmtree(wpa_control_interface)
+        # pylint: disable=protected-access
+        connection_manager._wifi_show = original_wifi_show
+
+    actual_test.func_name = f.func_name
+    return actual_test
+
+  return inner
+
+
+def connection_manager_test_radio_independent(c):
+  """Test ConnectionManager for things independent of radio configuration.
+
+  To verify that these things are both independent, this function is called
+  twice below, once with each radio configuration.  Those wrappers have the
+  relevant test decorators.
+
+  Args:
+    c:  A ConnectionManager set up by @connection_manager_test.
+  """
+
+  # Initially, no access.
+  wvtest.WVFAIL(c.acs())
+  wvtest.WVFAIL(c.internet())
+
+  c.run_once()
+
+  # Bring up ethernet, access.
+  c.set_ethernet(True)
+  c.run_once()
+  wvtest.WVPASS(c.acs())
+  wvtest.WVPASS(c.internet())
+  wvtest.WVPASS(c.bridge.current_route())
+  wvtest.WVFAIL(c.wifi_for_band('2.4').current_route())
+  wvtest.WVFAIL(c.wifi_for_band('5').current_route())
+
+  # Take down ethernet, no access.
+  c.set_ethernet(False)
+  c.run_once()
+  wvtest.WVFAIL(c.acs())
+  wvtest.WVFAIL(c.internet())
+  wvtest.WVFAIL(c.bridge.current_route())
+
+  # Bring up moca, access.
+  c.set_moca(True)
+  c.run_once()
+  wvtest.WVPASS(c.acs())
+  wvtest.WVPASS(c.internet())
+  wvtest.WVPASS(c.bridge.current_route())
+
+  # Bring up ethernet, access via both moca and ethernet.
+  c.set_ethernet(True)
+  c.run_once()
+  wvtest.WVPASS(c.acs())
+  wvtest.WVPASS(c.internet())
+  wvtest.WVPASS(c.bridge.current_route())
+
+  # Bring down moca, still have access via ethernet.
+  c.set_moca(False)
+  c.run_once()
+  wvtest.WVPASS(c.acs())
+  wvtest.WVPASS(c.internet())
+  wvtest.WVPASS(c.bridge.current_route())
+
+  # The bridge interfaces are up, but they can't reach anything.
+  c.bridge.set_connection_check_result('fail')
+  c.run_until_interface_update()
+  wvtest.WVFAIL(c.acs())
+  wvtest.WVFAIL(c.internet())
+  wvtest.WVFAIL(c.bridge.current_route())
+
+  # Now c connects to a restricted network.
+  c.bridge.set_connection_check_result('restricted')
+  c.run_until_interface_update()
+  wvtest.WVPASS(c.acs())
+  wvtest.WVFAIL(c.internet())
+  wvtest.WVPASS(c.bridge.current_route())
+
+  # Now the wired connection goes away.
+  c.set_ethernet(False)
+  c.set_moca(False)
+  c.run_once()
+  wvtest.WVFAIL(c.acs())
+  wvtest.WVFAIL(c.internet())
+  wvtest.WVFAIL(c.bridge.current_route())
+
+  # Now there are some scan results.
+  c.scan_has_results = True
+  # Wait for a scan, plus 3 cycles, so that s2 will have been tried.
+  c.run_until_scan('2.4')
+  for _ in range(3):
+    c.run_once()
+  wvtest.WVPASSEQ(c.last_provisioning_attempt.ssid, 's2')
+  wvtest.WVPASSEQ(c.last_provisioning_attempt.bssid, '01:23:45:67:89:ab')
+  # Wait for the connection to be processed.
+  c.run_once()
+  wvtest.WVPASS(c.acs())
+  wvtest.WVFAIL(c.internet())
+  wvtest.WVFAIL(c.client_up('2.4'))
+  wvtest.WVPASS(c.wifi_for_band('2.4').current_route())
+
+  # Now, create a WLAN configuration which should be connected to.  Also, test
+  # that atomic writes/renames work.
+  ssid = 'wlan'
+  psk = 'password'
+  c.write_wlan_config('2.4', ssid, psk, atomic=True)
+  c.disable_access_point('2.4')
+  c.run_once()
+  wvtest.WVPASS(c.client_up('2.4'))
+  wvtest.WVPASS(c.wifi_for_band('2.4').current_route())
+
+  # Now enable the AP.  Since we have no wired connection, this should have no
+  # effect.
+  c.enable_access_point('2.4')
+  c.run_once()
+  wvtest.WVPASS(c.client_up('2.4'))
+  wvtest.WVPASS(c.wifi_for_band('2.4').current_route())
+  wvtest.WVFAIL(c.bridge.current_route())
+
+  # Now bring up the bridge.  We should remove the wifi connection and start
+  # an AP.
+  c.set_ethernet(True)
+  c.bridge.set_connection_check_result('succeed')
+  c.run_until_interface_update()
+  wvtest.WVPASS(c.access_point_up('2.4'))
+  wvtest.WVFAIL(c.client_up('2.4'))
+  wvtest.WVFAIL(c.wifi_for_band('2.4').current_route())
+  wvtest.WVPASS(c.bridge.current_route())
+
+  # Now move (rather than delete) the configuration file.  The AP should go
+  # away, and we should not be able to join the WLAN.  Routes should not be
+  # affected.
+  filename = c.wlan_config_filename('2.4')
+  other_filename = filename + '.bak'
+  os.rename(filename, other_filename)
+  c.run_once()
+  wvtest.WVFAIL(c.access_point_up('2.4'))
+  wvtest.WVFAIL(c.client_up('2.4'))
+  wvtest.WVFAIL(c.wifi_for_band('2.4').current_route())
+  wvtest.WVPASS(c.bridge.current_route())
+
+  # Now move it back, and the AP should come back.
+  os.rename(other_filename, filename)
+  c.run_once()
+  wvtest.WVPASS(c.access_point_up('2.4'))
+  wvtest.WVFAIL(c.client_up('2.4'))
+  wvtest.WVFAIL(c.wifi_for_band('2.4').current_route())
+  wvtest.WVPASS(c.bridge.current_route())
+
+
+@wvtest.wvtest
+@connection_manager_test(WIFI_SHOW_OUTPUT_ONE_RADIO)
+def connection_manager_test_radio_independent_one_radio(c):
+  connection_manager_test_radio_independent(c)
+
+
+@wvtest.wvtest
+@connection_manager_test(WIFI_SHOW_OUTPUT_TWO_RADIOS)
+def connection_manager_test_radio_independent_two_radios(c):
+  connection_manager_test_radio_independent(c)
+
+
+@wvtest.wvtest
+@connection_manager_test(WIFI_SHOW_OUTPUT_TWO_RADIOS)
+def connection_manager_test_two_radios(c):
+  """Test ConnectionManager for devices with two radios.
+
+  This test should be kept roughly parallel to the one-radio test.
+
+  Args:
+    c:  The ConnectionManager set up by @connection_manager_test.
+  """
+  # Bring up ethernet, access.
+  c.set_ethernet(True)
+  c.run_once()
+  wvtest.WVPASS(c.acs())
+  wvtest.WVPASS(c.internet())
+
+  ssid = 'my ssid'
+  psk = 'passphrase'
+
+  # Bring up both access points.
+  c.write_wlan_config('2.4', ssid, psk)
+  c.enable_access_point('2.4')
+  c.write_wlan_config('5', ssid, psk)
+  c.enable_access_point('5')
+  c.run_once()
+  wvtest.WVPASS(c.access_point_up('2.4'))
+  wvtest.WVPASS(c.access_point_up('5'))
+  wvtest.WVPASS(c.bridge.current_route())
+  wvtest.WVFAIL(c.wifi_for_band('2.4').current_route())
+  wvtest.WVFAIL(c.wifi_for_band('5').current_route())
+
+  # Disable the 2.4 GHz AP, make sure the 5 GHz AP stays up.  2.4 GHz should
+  # join the WLAN.
+  c.disable_access_point('2.4')
+  c.run_until_interface_update()
+  wvtest.WVFAIL(c.access_point_up('2.4'))
+  wvtest.WVPASS(c.access_point_up('5'))
+  wvtest.WVPASS(c.client_up('2.4'))
+  wvtest.WVPASS(c.bridge.current_route())
+  wvtest.WVPASS(c.wifi_for_band('2.4').current_route())
+  wvtest.WVFAIL(c.wifi_for_band('5').current_route())
+
+  # Delete the 2.4 GHz WLAN configuration; it should leave the WLAN but nothing
+  # else should change.
+  c.delete_wlan_config('2.4')
+  c.run_until_interface_update()
+  wvtest.WVFAIL(c.access_point_up('2.4'))
+  wvtest.WVPASS(c.access_point_up('5'))
+  wvtest.WVFAIL(c.client_up('2.4'))
+  wvtest.WVPASS(c.bridge.current_route())
+  wvtest.WVFAIL(c.wifi_for_band('2.4').current_route())
+  wvtest.WVFAIL(c.wifi_for_band('5').current_route())
+
+  # Disable the wired connection and remove the WLAN configurations.  Both
+  # radios should scan.  Wait for 5 GHz to scan, then enable scan results for
+  # 2.4. This should lead to ACS access.
+  c.delete_wlan_config('5')
+  c.set_ethernet(False)
+  c.run_once()
+  wvtest.WVFAIL(c.acs())
+  wvtest.WVFAIL(c.bridge.current_route())
+  wvtest.WVFAIL(c.wifi_for_band('2.4').current_route())
+  wvtest.WVFAIL(c.wifi_for_band('5').current_route())
+
+  # The 5 GHz scan has no results.
+  c.run_until_scan('5')
+  c.run_once()
+  c.run_until_interface_update()
+  wvtest.WVFAIL(c.acs())
+  wvtest.WVFAIL(c.bridge.current_route())
+  wvtest.WVFAIL(c.wifi_for_band('2.4').current_route())
+  wvtest.WVFAIL(c.wifi_for_band('5').current_route())
+
+  # The next 2.4 GHz scan will have results.
+  c.scan_has_results = True
+  c.run_until_scan('2.4')
+  # Now run 3 cycles, so that s2 will have been tried.
+  for _ in range(3):
+    c.run_once()
+  c.run_until_interface_update()
+  wvtest.WVPASS(c.acs())
+  wvtest.WVFAIL(c.bridge.current_route())
+  wvtest.WVPASS(c.wifi_for_band('2.4').current_route())
+  wvtest.WVFAIL(c.wifi_for_band('5').current_route())
+
+
+@wvtest.wvtest
+@connection_manager_test(WIFI_SHOW_OUTPUT_ONE_RADIO)
+def connection_manager_test_one_radio(c):
+  """Test ConnectionManager for devices with one radio.
+
+  This test should be kept roughly parallel to the two-radio test.
+
+  Args:
+    c:  The ConnectionManager set up by @connection_manager_test.
+  """
+  # Bring up ethernet, access.
+  c.set_ethernet(True)
+  c.run_once()
+  wvtest.WVPASS(c.acs())
+  wvtest.WVPASS(c.internet())
+
+  ssid = 'my ssid'
+  psk = 'passphrase'
+
+  # Enable both access points.  Only 5 should be up.
+  c.write_wlan_config('2.4', ssid, psk)
+  c.enable_access_point('2.4')
+  c.write_wlan_config('5', ssid, psk)
+  c.enable_access_point('5')
+  c.run_once()
+  wvtest.WVFAIL(c.access_point_up('2.4'))
+  wvtest.WVPASS(c.access_point_up('5'))
+  wvtest.WVPASS(c.bridge.current_route())
+  wvtest.WVFAIL(c.wifi_for_band('2.4').current_route())
+  wvtest.WVFAIL(c.wifi_for_band('5').current_route())
+
+  # Disable the 2.4 GHz AP; nothing should change.  The 2.4 GHz client should
+  # not be up because the same radio is being used to run a 5 GHz AP.
+  c.disable_access_point('2.4')
+  c.run_until_interface_update()
+  wvtest.WVFAIL(c.access_point_up('2.4'))
+  wvtest.WVPASS(c.access_point_up('5'))
+  wvtest.WVFAIL(c.client_up('2.4'))
+  wvtest.WVPASS(c.bridge.current_route())
+  wvtest.WVFAIL(c.wifi_for_band('2.4').current_route())
+  wvtest.WVFAIL(c.wifi_for_band('5').current_route())
+
+  # Delete the 2.4 GHz WLAN configuration; nothing should change.
+  c.delete_wlan_config('2.4')
+  c.run_once()
+  wvtest.WVFAIL(c.access_point_up('2.4'))
+  wvtest.WVPASS(c.access_point_up('5'))
+  wvtest.WVFAIL(c.client_up('2.4'))
+  wvtest.WVPASS(c.bridge.current_route())
+  wvtest.WVFAIL(c.wifi_for_band('2.4').current_route())
+  wvtest.WVFAIL(c.wifi_for_band('5').current_route())
+
+  # Disable the wired connection and remove the WLAN configurations.  There
+  # should be a single scan that leads to ACS access.  (It doesn't matter which
+  # band we specify in run_until_scan, since both bands point to the same
+  # interface.)
+  c.delete_wlan_config('5')
+  c.set_ethernet(False)
+  c.run_once()
+  wvtest.WVFAIL(c.acs())
+  wvtest.WVFAIL(c.bridge.current_route())
+  wvtest.WVFAIL(c.wifi_for_band('2.4').current_route())
+  wvtest.WVFAIL(c.wifi_for_band('5').current_route())
+
+  # The wcli0 scan will have results that will lead to ACS access.
+  c.scan_has_results = True
+  c.run_until_scan('5')
+  for _ in range(3):
+    c.run_once()
+  c.run_until_interface_update()
+  wvtest.WVPASS(c.acs())
+  wvtest.WVFAIL(c.bridge.current_route())
+  wvtest.WVPASS(c.wifi_for_band('2.4').current_route())
+  wvtest.WVPASS(c.wifi_for_band('5').current_route())
+
+
+if __name__ == '__main__':
+  wvtest.wvtest_main()
diff --git a/conman/cycler.py b/conman/cycler.py
new file mode 100755
index 0000000..ff65bfc
--- /dev/null
+++ b/conman/cycler.py
@@ -0,0 +1,81 @@
+#!/usr/bin/python -S
+
+"""Utility for attempting to join a list of BSSIDs."""
+
+import time
+
+
+class AgingPriorityCycler(object):
+  """A modified priority queue.
+
+  1)  Items are not removed from the queue, but automatically reinserted (thus
+      "cycler" rather than "queue").
+  2)  Baseline priority is multiplied by time in queue.
+
+  This data structure is not efficient and may not scale well.  Don't use it for
+  anything big.
+
+  As a minor optimization, items must be hashable.  This restriction could be
+  removed, but we don't have a use case for non-hashable values yet.
+  """
+
+  def __init__(self, cycle_length_s=0, items=()):
+    """Initializes the queue.
+
+    Args:
+      cycle_length_s: The minimum amount of time an item will spend in the
+      queue after being automatically reinserted.
+      items: Initial items for the queue, as tuples of (item, priority).
+    """
+    t = time.time()
+    self._items = {item: [priority, t] for item, priority in items}
+    self._min_time_in_queue_s = cycle_length_s
+
+  def empty(self):
+    return not self._items
+
+  def insert(self, item, priority):
+    """Insert a new item, or update the priority of an existing item."""
+    try:
+      self._items[item][0] = priority
+    except KeyError:
+      self._items[item] = [priority, time.time()]
+
+  def remove(self, item):
+    if item in self._items:
+      self._items.pop(item)
+
+  def peek(self):
+    """Return the next item in the queue, but do not cycle it."""
+    return self._find_next(cycle=False)
+
+  def next(self):
+    """Return the next item in the queue.
+
+    Also resets that item's age to now + cycle_length_s.
+
+    Returns:
+      The next item in the queue.
+    """
+    return self._find_next(True)
+
+  def _find_next(self, cycle=False):
+    """Implementation of peek and next."""
+    if self.empty():
+      return
+
+    now = time.time()
+
+    def aged_priority(key_value):
+      _, (priority, birth) = key_value
+      return priority * (now - birth)
+
+    result, value = max(self._items.iteritems(), key=aged_priority)
+    if value[1] > now:
+      return
+
+    if cycle:
+      value[1] = now + self._min_time_in_queue_s
+
+    return result
+
diff --git a/conman/cycler_test.py b/conman/cycler_test.py
new file mode 100755
index 0000000..c4e498b
--- /dev/null
+++ b/conman/cycler_test.py
@@ -0,0 +1,45 @@
+#!/usr/bin/python
+
+"""Tests for cycler.py."""
+
+import time
+
+import cycler
+from wvtest import wvtest
+
+
+@wvtest.wvtest
+def cycler_test():
+  c = cycler.AgingPriorityCycler()
+  wvtest.WVPASS(c.next() is None)
+
+  cycle_length_s = .01
+  c = cycler.AgingPriorityCycler(cycle_length_s=cycle_length_s,
+                                 items=(('A', 10), ('B', 5), ('C', 1)))
+
+  # We should get all three in order, since they all have the same insertion
+  # time.  They will all get slightly different insertion times, but next()
+  # should be fast enough that the differences don't make much difference.
+  wvtest.WVPASS(c.peek() == 'A')
+  wvtest.WVPASS(c.next() == 'A')
+  wvtest.WVPASS(c.next() == 'B')
+  wvtest.WVPASS(c.next() == 'C')
+  wvtest.WVPASS(c.peek() is None)
+  wvtest.WVPASS(c.next() is None)
+  wvtest.WVPASS(c.next() is None)
+
+  # Now, wait for items to be ready again and just cycle one of them.
+  time.sleep(cycle_length_s)
+  wvtest.WVPASS(c.next() == 'A')
+
+  # Now, if we wait 1.9 cycles, the aged priorities will be as follows:
+  # A: 0.9 * 10 = 9
+  # B: 1.9 * 5 = 9.5
+  # C: 1.9 * 1 = 1.9
+  time.sleep(cycle_length_s * 1.9)
+  wvtest.WVPASS(c.next() == 'B')
+  wvtest.WVPASS(c.next() == 'A')
+  wvtest.WVPASS(c.next() == 'C')
+
+if __name__ == '__main__':
+  wvtest.wvtest_main()
diff --git a/conman/interface.py b/conman/interface.py
new file mode 100755
index 0000000..c2de318
--- /dev/null
+++ b/conman/interface.py
@@ -0,0 +1,330 @@
+#!/usr/bin/python
+
+"""Models wired and wireless interfaces."""
+
+import logging
+import os
+import re
+import subprocess
+
+import wpactrl
+
+METRIC_5GHZ = 20
+METRIC_24GHZ_5GHZ = 21
+METRIC_24GHZ = 22
+METRIC_TEMPORARY_CONNECTION_CHECK = 99
+
+
+class Interface(object):
+  """Represents an interface.
+
+  Base class for more specific interface types.
+  """
+
+  CONNECTION_CHECK = 'connection_check'
+  IP_ROUTE = ['ip', 'route']
+
+  def __init__(self, name, metric):
+    self.name = name
+
+    # Currently connected links for this interface, e.g. ethernet.
+    self.links = set()
+
+    # Whether [ACS, internet] access is currently available via this interface.
+    self._has_acs = None
+    self._has_internet = None
+
+    # The gateway IP for this interface.
+    self._gateway_ip = None
+    self.metric = metric
+
+  def _connection_check(self, check_acs):
+    """Check this interface's connection status.
+
+    Args:
+      check_acs:  If true, check for ACS access rather than internet access.
+
+    Returns:
+      Whether the connection is working.
+    """
+    if not self.links:
+      return False
+
+    logging.debug('gateway ip for %s is %s', self.name, self._gateway_ip)
+    if self._gateway_ip is None:
+      return False
+
+    # Temporarily add a route to make sure the connection check can be run.
+    # Give it a high metric so that it won't interfere with normal default
+    # routes.
+    added_temporary_route = False
+    if not self.current_route():
+      logging.debug('Adding temporary connection check route for dev %s',
+                    self.name)
+      self._ip_route('add', 'default',
+                     'via', self._gateway_ip,
+                     'dev', self.name,
+                     'metric', str(METRIC_TEMPORARY_CONNECTION_CHECK))
+
+    cmd = [self.CONNECTION_CHECK, '-I', self.name]
+    if check_acs:
+      cmd.append('-a')
+
+    with open(os.devnull, 'w') as devnull:
+      result = subprocess.call(cmd, stdout=devnull, stderr=devnull) == 0
+
+    # Delete the temporary route.
+    if added_temporary_route:
+      logging.debug('Deleting temporary connection check route for dev %s',
+                    self.name)
+      self._ip_route('del', 'default',
+                     'dev', self.name,
+                     'metric', str(METRIC_TEMPORARY_CONNECTION_CHECK))
+
+    return result
+
+  def acs(self):
+    if self._has_acs is None:
+      self._has_acs = self._connection_check(check_acs=True)
+
+    return self._has_acs
+
+  def internet(self):
+    if self._has_internet is None:
+      self._has_internet = self._connection_check(check_acs=False)
+
+    return self._has_internet
+
+  def add_route(self):
+    """Adds a default route for this interface.
+
+    First, checks whether an equivalent route already exists, and if so,
+    returns.
+    """
+    if self.metric is None:
+      logging.info('Cannot add route for %s without a metric.', self.name)
+      return
+
+    if self._gateway_ip is None:
+      logging.info('Cannot add route for %s without a gateway IP.', self.name)
+      return
+
+    # If the current default route is the same, there is nothing to do.  If it
+    # exists but is different, delete it before adding an updated one.
+    current = self.current_route()
+    if current:
+      if (current.get('via', None) == self._gateway_ip and
+          current.get('metric', None) == str(self.metric)):
+        return
+      else:
+        self.delete_route()
+
+    logging.debug('Adding default route for dev %s', self.name)
+    self._ip_route('add', 'default',
+                   'via', self._gateway_ip,
+                   'dev', self.name,
+                   'metric', str(self.metric))
+
+  def delete_route(self):
+    while self.current_route():
+      logging.debug('Deleting default route for dev %s', self.name)
+      self._ip_route('del', 'default',
+                     'dev', self.name)
+
+  def current_route(self):
+    """Read the current default route for this interface.
+
+    Returns:
+      A dict containing the gateway [and metric] of the route, or an empty dict
+      if there is currently no default route for this interface.
+    """
+    result = {}
+    for line in self._ip_route().splitlines():
+      if line.startswith('default') and 'dev %s' % self.name in line:
+        key = None
+        for token in line.split():
+          if token in ['via', 'metric']:
+            key = token
+          elif key:
+            result[key] = token
+            key = None
+
+    return result
+
+  def _ip_route(self, *args):
+    try:
+      logging.debug('%s calling ip route %s', self.name, ' '.join(args))
+      return subprocess.check_output(self.IP_ROUTE + list(args))
+    except subprocess.CalledProcessError as e:
+      logging.error('Failed to call "ip route" with args %r: %s', args,
+                    e.message)
+      return ''
+
+  def set_gateway_ip(self, gateway_ip):
+    logging.debug('New gateway IP %s for %s', gateway_ip, self.name)
+    self._gateway_ip = gateway_ip
+    self.update_routes()
+
+  def _set_link_status(self, link, is_up):
+    """Set whether a link is up or not."""
+    if is_up == (link in self.links):
+      return
+
+    had_links = bool(self.links)
+
+    if is_up:
+      logging.debug('%s gained link %s', self.name, link)
+      self.links.add(link)
+    else:
+      logging.debug('%s lost link %s', self.name, link)
+      self.links.remove(link)
+
+    # If a link goes away, we may have lost access to something but not gained
+    # it, and vice versa.
+    if is_up != self._has_acs:
+      self._has_acs = None
+
+    if is_up != self._has_internet:
+      self._has_internet = None
+
+    if had_links != bool(self.links):
+      self.update_routes(expire_cache=False)
+
+  def expire_connection_status_cache(self):
+    logging.debug('Expiring connection status cache for %s', self.name)
+    self._has_internet = self._has_acs = None
+
+  def update_routes(self, expire_cache=True):
+    """Update this interface's routes.
+
+    If the interface has gained ACS or internet access, add a route.  If it had
+    either and now has neither, delete the route.
+
+    Args:
+      expire_cache:  If true, force a recheck of connection status before
+      deciding whether to add or remove routes.
+    """
+    logging.debug('Updating routes for %s', self.name)
+    # We care about the distinction between None (unknown) and False (known
+    # inaccessible) here.
+    # pylint: disable=g-explicit-bool-comparison
+    maybe_had_acs = self._has_acs != False
+    maybe_had_internet = self._has_internet != False
+
+    if expire_cache:
+      self.expire_connection_status_cache()
+
+    has_acs = self.acs()
+    has_internet = self.internet()
+    if ((not maybe_had_acs and has_acs) or
+        (not maybe_had_internet and has_internet)):
+      self.add_route()
+    elif ((maybe_had_acs or maybe_had_internet) and not
+          (has_acs or has_internet)):
+      self.delete_route()
+
+
+class Bridge(Interface):
+  """Represents the wired bridge."""
+
+  def __init__(self, *args, **kwargs):
+    super(Bridge, self).__init__(*args, **kwargs)
+    self._moca_stations = set()
+
+  @property
+  def moca(self):
+    return bool(self._moca_stations)
+
+  @moca.setter
+  def moca(self, is_up):
+    self._set_link_status('moca', is_up)
+
+  @property
+  def ethernet(self):
+    return 'ethernet' in self.links
+
+  @ethernet.setter
+  def ethernet(self, is_up):
+    self._set_link_status('ethernet', is_up)
+
+  def add_moca_station(self, node_id):
+    if node_id not in self._moca_stations:
+      self._moca_stations.add(node_id)
+      self.moca = True
+
+  def remove_moca_station(self, node_id):
+    if node_id in self._moca_stations:
+      self._moca_stations.remove(node_id)
+      self.moca = bool(self._moca_stations)
+
+
+class Wifi(Interface):
+  """Represents the wireless interface."""
+
+  WPA_EVENT_RE = re.compile(r'<\d+>CTRL-EVENT-(?P<event>[A-Z\-]+).*')
+
+  def __init__(self, *args, **kwargs):
+    self.bands = kwargs.pop('bands', [])
+    super(Wifi, self).__init__(*args, **kwargs)
+    self._wpa_control = None
+
+  @property
+  def wpa_supplicant(self):
+    return 'wpa_supplicant' in self.links
+
+  @wpa_supplicant.setter
+  def wpa_supplicant(self, is_up):
+    self._set_link_status('wpa_supplicant', is_up)
+
+  def attached(self):
+    return self._wpa_control and self._wpa_control.attached
+
+  def attach_wpa_control(self, path):
+    if self.attached():
+      return
+
+    socket = os.path.join(path, self.name)
+    if os.path.exists(socket):
+      try:
+        self._wpa_control = self.get_wpa_control(socket)
+        self._wpa_control.attach()
+      except wpactrl.error as e:
+        logging.error('Error attaching to wpa_supplicant: %s', e)
+        return
+
+      self.wpa_supplicant = ('wpa_state=COMPLETED' in
+                             self._wpa_control.request('STATUS'))
+
+  def get_wpa_control(self, socket):
+    return wpactrl.WPACtrl(socket)
+
+  def detach_wpa_control(self):
+    if self.attached():
+      try:
+        self._wpa_control.detach()
+      except wpactrl.error:
+        logging.error('Failed to detach from wpa_supplicant interface. This '
+                      'may mean something else killed wpa_supplicant.')
+        self._wpa_control = None
+
+      self.wpa_supplicant = False
+
+  def handle_wpa_events(self):
+    if not self.attached():
+      self.wpa_supplicant = False
+      return
+
+    while self._wpa_control.pending():
+      match = self.WPA_EVENT_RE.match(self._wpa_control.recv())
+      if match:
+        event = match.group('event')
+        if event == 'CONNECTED':
+          self.wpa_supplicant = True
+        elif event in ('DISCONNECTED', 'TERMINATING', 'ASSOC-REJECT',
+                       'AUTH-REJECT'):
+          self.wpa_supplicant = False
+          if event == 'TERMINATING':
+            self.detach_wpa_control()
+            break
+
+        self.update_routes()
diff --git a/conman/interface_test.py b/conman/interface_test.py
new file mode 100755
index 0000000..edc3541
--- /dev/null
+++ b/conman/interface_test.py
@@ -0,0 +1,233 @@
+#!/usr/bin/python
+
+"""Tests for connection_manager.py."""
+
+import logging
+import os
+import shutil
+import tempfile
+
+import wpactrl
+
+import interface
+from wvtest import wvtest
+
+logging.basicConfig(level=logging.DEBUG)
+
+
+class FakeInterfaceMixin(object):
+  """Replace Interface methods which interact with the system."""
+
+  def __init__(self, *args, **kwargs):
+    super(FakeInterfaceMixin, self).__init__(*args, **kwargs)
+    self.set_connection_check_result('succeed')
+    self.routing_table = {}
+
+  def set_connection_check_result(self, result):
+    if result in ['succeed', 'fail', 'restricted']:
+      # pylint: disable=invalid-name
+      self.CONNECTION_CHECK = './test/' + result
+    else:
+      raise ValueError('Invalid fake connection_check script.')
+
+  def _ip_route(self, *args):
+    if not args:
+      return '\n'.join(self.routing_table.values() +
+                       ['1.2.3.4/24 dev %s proto kernel scope link' % self.name,
+                        'default via 1.2.3.4 dev fake0',
+                        'random junk'])
+
+    metric = None
+    if 'metric' in args:
+      metric = args[args.index('metric') + 1]
+    key = (self.name, metric)
+    if args[0] == 'add' and key not in self.routing_table:
+      logging.debug('Adding route for %r', key)
+      self.routing_table[key] = ' '.join(args[1:])
+    elif args[0] == 'del':
+      if key in self.routing_table:
+        logging.debug('Deleting route for %r', key)
+        del self.routing_table[key]
+      elif key[1] is None:
+        # pylint: disable=g-builtin-op
+        for k in self.routing_table.keys():
+          if k[0] == key[0]:
+            logging.debug('Deleting route for %r (generalized from %s)', k, key)
+            del self.routing_table[k]
+
+
+class Bridge(FakeInterfaceMixin, interface.Bridge):
+  pass
+
+
+class FakeWPACtrl(object):
+  """Fake wpactrl.WPACtrl."""
+
+  # pylint: disable=unused-argument
+  def __init__(self, socket):
+    self._socket = socket
+    self.events = []
+    self.attached = False
+    self.connected = False
+
+  def pending(self):
+    return bool(self.events)
+
+  def recv(self):
+    return self.events.pop(0)
+
+  def attach(self):
+    if not os.path.exists(self._socket):
+      raise wpactrl.error('wpactrl_attach failed')
+    self.attached = True
+
+  def detach(self):
+    if not os.path.exists(self._socket):
+      raise wpactrl.error('wpactrl_detach failed')
+    self.attached = False
+
+  def request(self, request_type):
+    if request_type == 'STATUS':
+      return 'foo\nwpa_state=COMPLETED\nbar' if self.connected else 'foo'
+    else:
+      raise ValueError('Invalid request_type %s' % request_type)
+
+  # Below methods are not part of WPACtrl.
+
+  def add_event(self, event):
+    self.events.append(event)
+
+
+class Wifi(FakeInterfaceMixin, interface.Wifi):
+  """Fake Wifi for testing."""
+
+  CONNECTED_EVENT = '<2>CTRL-EVENT-CONNECTED'
+  DISCONNECTED_EVENT = '<2>CTRL-EVENT-DISCONNECTED'
+  TERMINATING_EVENT = '<2>CTRL-EVENT-TERMINATING'
+
+  def __init__(self, *args, **kwargs):
+    super(Wifi, self).__init__(*args, **kwargs)
+    self._initially_connected = False
+
+  def attach_wpa_control(self, *args, **kwargs):
+    if self._initially_connected and self._wpa_control:
+      self._wpa_control.connected = True
+    super(Wifi, self).attach_wpa_control(*args, **kwargs)
+
+  def get_wpa_control(self, socket):
+    result = FakeWPACtrl(socket)
+    result.connected = self._initially_connected
+    return result
+
+  def add_connected_event(self):
+    if self.attached():
+      self._wpa_control.add_event(self.CONNECTED_EVENT)
+
+  def add_disconnected_event(self):
+    if self.attached():
+      self._wpa_control.add_event(self.DISCONNECTED_EVENT)
+
+  def add_terminating_event(self):
+    if self.attached():
+      self._wpa_control.add_event(self.TERMINATING_EVENT)
+
+
+@wvtest.wvtest
+def bridge_test():
+  """Test Interface and Bridge."""
+  b = Bridge('br0', '10')
+  b.set_connection_check_result('succeed')
+
+  wvtest.WVFAIL(b.acs())
+  wvtest.WVFAIL(b.internet())
+  wvtest.WVFAIL(b.current_route())
+
+  b.add_moca_station(0)
+  b.set_gateway_ip('192.168.1.1')
+  wvtest.WVPASS(b.acs())
+  wvtest.WVPASS(b.internet())
+  wvtest.WVPASS(b.current_route())
+
+  b.add_moca_station(1)
+  wvtest.WVPASS(b.acs())
+  wvtest.WVPASS(b.internet())
+  wvtest.WVPASS(b.current_route())
+
+  b.remove_moca_station(0)
+  b.remove_moca_station(1)
+  wvtest.WVFAIL(b.acs())
+  wvtest.WVFAIL(b.internet())
+  wvtest.WVFAIL(b.current_route())
+
+  b.add_moca_station(2)
+  wvtest.WVPASS(b.acs())
+  wvtest.WVPASS(b.internet())
+  wvtest.WVPASS(b.current_route())
+
+  b.set_connection_check_result('fail')
+  b.update_routes()
+  wvtest.WVFAIL(b.acs())
+  wvtest.WVFAIL(b.internet())
+  wvtest.WVFAIL(b.current_route())
+
+  b.set_connection_check_result('restricted')
+  b.update_routes()
+  wvtest.WVPASS(b.acs())
+  wvtest.WVFAIL(b.internet())
+  wvtest.WVPASS(b.current_route())
+
+
+@wvtest.wvtest
+def wifi_test():
+  """Test Wifi."""
+  w = Wifi('wcli0', '21')
+  w.set_connection_check_result('succeed')
+
+  try:
+    wpa_path = tempfile.mkdtemp()
+    socket = os.path.join(wpa_path, w.name)
+    open(socket, 'w')
+
+    # Not currently connected.
+    w.attach_wpa_control(wpa_path)
+    wvtest.WVFAIL(w.wpa_supplicant)
+
+    # pylint: disable=protected-access
+    wpa_control = w._wpa_control
+
+    # wpa_supplicant connects.
+    wpa_control.add_event(Wifi.CONNECTED_EVENT)
+    wvtest.WVFAIL(w.wpa_supplicant)
+    w.handle_wpa_events()
+    wvtest.WVPASS(w.wpa_supplicant)
+    w.set_gateway_ip('192.168.1.1')
+
+    # wpa_supplicant disconnects.
+    wpa_control.add_event(Wifi.DISCONNECTED_EVENT)
+    w.handle_wpa_events()
+    wvtest.WVFAIL(w.wpa_supplicant)
+
+    # Now, start over so we can test what happens when wpa_supplicant is already
+    # connected when we attach.
+    w.detach_wpa_control()
+    # pylint: disable=protected-access
+    w._initially_connected = True
+    w.attach_wpa_control(wpa_path)
+    wpa_control = w._wpa_control
+
+    # wpa_supplicant was already connected when we attached.
+    wvtest.WVPASS(w.wpa_supplicant)
+
+    # The wpa_supplicant process disconnects and terminates.
+    wpa_control.add_event(Wifi.DISCONNECTED_EVENT)
+    wpa_control.add_event(Wifi.TERMINATING_EVENT)
+    os.unlink(socket)
+    w.handle_wpa_events()
+    wvtest.WVFAIL(w.wpa_supplicant)
+
+  finally:
+    shutil.rmtree(wpa_path)
+
+
+if __name__ == '__main__':
+  wvtest.wvtest_main()
diff --git a/conman/iw.py b/conman/iw.py
new file mode 100755
index 0000000..f751302
--- /dev/null
+++ b/conman/iw.py
@@ -0,0 +1,111 @@
+#!/usr/bin/python
+
+"""Utility functions for calling iw and parsing its output."""
+
+import re
+import subprocess
+
+
+def _scan(interface, **kwargs):
+  try:
+    return subprocess.check_output(('iw', 'dev', interface, 'scan'), **kwargs)
+  except subprocess.CalledProcessError:
+    return ''
+
+
+_BSSID_RE = r'BSS (?P<BSSID>([0-9a-f]{2}:?){6})\(on .*\)'
+_SSID_RE = r'SSID: (?P<SSID>.*)'
+_VENDOR_IE_RE = (r'Vendor specific: OUI (?P<OUI>([0-9a-f]{2}:?){3}), '
+                 'data:(?P<data>( [0-9a-f]{2})+)')
+
+
+class BssInfo(object):
+  """Contains info about a BSS, parsed from 'iw scan'."""
+
+  def __init__(self, bssid='', ssid='', security=None, vendor_ies=None):
+    self.bssid = bssid
+    self.ssid = ssid
+    self.vendor_ies = vendor_ies or []
+    self.security = security or []
+
+  def __attrs(self):
+    return (self.bssid, self.ssid, tuple(sorted(self.vendor_ies)),
+            tuple(sorted(self.security)))
+
+  def __eq__(self, other):
+    # pylint: disable=protected-access
+    return self.__attrs() == other.__attrs()
+
+  def __hash__(self):
+    return hash(self.__attrs())
+
+  def __repr__(self):
+    return '<BssInfo: SSID=%s BSSID=%s Security=%s Vendor IEs=%s>' % (
+        self.ssid, self.bssid, ','.join(self.security),
+        ','.join('|'.join(ie) for ie in self.vendor_ies))
+
+
+# TODO(rofrankel): waveguide also scans. Can we find a way to avoid two programs
+# scanning in parallel?
+def scan_parsed(interface, **kwargs):
+  """Return the parsed results of 'iw scan'."""
+  result = []
+  bss_info = None
+  for line in _scan(interface, **kwargs).splitlines():
+    line = line.strip()
+    match = re.match(_BSSID_RE, line)
+    if match:
+      if bss_info:
+        result.append(bss_info)
+      bss_info = BssInfo(bssid=match.group('BSSID'))
+      continue
+    match = re.match(_SSID_RE, line)
+    if match:
+      bss_info.ssid = match.group('SSID')
+      continue
+    match = re.match(_VENDOR_IE_RE, line)
+    if match:
+      bss_info.vendor_ies.append((match.group('OUI'),
+                                  match.group('data').strip()))
+      continue
+    if line.startswith('RSN:'):
+      bss_info.security.append('WPA2')
+    elif line.startswith('WPA:'):
+      bss_info.security.append('WPA')
+    elif line.startswith('Privacy:'):
+      bss_info.security.append('WEP')
+
+  if bss_info:
+    result.append(bss_info)
+
+  return result
+
+
+def find_bssids(interface, vendor_ie_function, include_secure):
+  """Return information about interesting access points.
+
+  Args:
+    interface:  The wireless interface with which to scan.
+    vendor_ie_function:  A function that takes a vendor IE and returns a bool.
+    include_secure:  Whether to exclude secure networks.
+
+  Returns:
+    Two lists of tuples of the form (SSID, BSSID info dict).  The first list has
+    BSSIDs which have a vendor IE accepted by vendor_ie_function, and the second
+    list has those which don't.
+  """
+  parsed = scan_parsed(interface)
+  result_with_ie = set()
+  result_without_ie = set()
+
+  for bss_info in parsed:
+    if bss_info.security and not include_secure:
+      continue
+    for oui, data in bss_info.vendor_ies:
+      if vendor_ie_function(oui, data):
+        result_with_ie.add(bss_info)
+        break
+    else:
+      result_without_ie.add(bss_info)
+
+  return result_with_ie, result_without_ie
diff --git a/conman/iw_test.py b/conman/iw_test.py
new file mode 100755
index 0000000..c069c91
--- /dev/null
+++ b/conman/iw_test.py
@@ -0,0 +1,535 @@
+#!/usr/bin/python
+
+"""Tests for iw.py."""
+
+import iw
+from wvtest import wvtest
+
+
+SCAN_OUTPUT = """BSS 00:23:97:57:f4:d8(on wcli0)
+  TSF: 1269828266773 usec (14d, 16:43:48)
+  freq: 2437
+  beacon interval: 100 TUs
+  capability: ESS Privacy ShortSlotTime (0x0411)
+  signal: -60.00 dBm
+  last seen: 2190 ms ago
+  Information elements from Probe Response frame:
+  Vendor specific: OUI 00:11:22, data: 01 23 45 67
+  SSID: short scan result
+  Supported rates: 1.0* 2.0* 5.5* 11.0* 18.0 24.0 36.0 54.0
+  DS Parameter set: channel 6
+  ERP: <no flags>
+  ERP D4.0: <no flags>
+  Privacy:  WEP
+  Extended supported rates: 6.0 9.0 12.0 48.0
+BSS 94:b4:0f:f1:02:a0(on wcli0)
+  TSF: 16233722683 usec (0d, 04:30:33)
+  freq: 2412
+  beacon interval: 100 TUs
+  capability: ESS Privacy ShortPreamble SpectrumMgmt ShortSlotTime RadioMeasure (0x1531)
+  signal: -54.00 dBm
+  last seen: 2490 ms ago
+  Information elements from Probe Response frame:
+  SSID: Google
+  Supported rates: 36.0* 48.0 54.0
+  DS Parameter set: channel 1
+  Country: US Environment: Indoor/Outdoor
+    Channels [1 - 11] @ 36 dBm
+  Power constraint: 0 dB
+  TPC report: TX power: 3 dBm
+  ERP: <no flags>
+  RSN:   * Version: 1
+     * Group cipher: CCMP
+     * Pairwise ciphers: CCMP
+     * Authentication suites: IEEE 802.1X
+     * Capabilities: 4-PTKSA-RC 4-GTKSA-RC (0x0028)
+  BSS Load:
+     * station count: 0
+     * channel utilisation: 33/255
+     * available admission capacity: 25625 [*32us]
+  HT capabilities:
+    Capabilities: 0x19ad
+      RX LDPC
+      HT20
+      SM Power Save disabled
+      RX HT20 SGI
+      TX STBC
+      RX STBC 1-stream
+      Max AMSDU length: 7935 bytes
+      DSSS/CCK HT40
+    Maximum RX AMPDU length 65535 bytes (exponent: 0x003)
+    Minimum RX AMPDU time spacing: 4 usec (0x05)
+    HT RX MCS rate indexes supported: 0-23
+    HT TX MCS rate indexes are undefined
+  HT operation:
+     * primary channel: 1
+     * secondary channel offset: no secondary
+     * STA channel width: 20 MHz
+     * RIFS: 1
+     * HT protection: nonmember
+     * non-GF present: 0
+     * OBSS non-GF present: 1
+     * dual beacon: 0
+     * dual CTS protection: 0
+     * STBC beacon: 0
+     * L-SIG TXOP Prot: 0
+     * PCO active: 0
+     * PCO phase: 0
+  Overlapping BSS scan params:
+     * passive dwell: 20 TUs
+     * active dwell: 10 TUs
+     * channel width trigger scan interval: 300 s
+     * scan passive total per channel: 200 TUs
+     * scan active total per channel: 20 TUs
+     * BSS width channel transition delay factor: 5
+     * OBSS Scan Activity Threshold: 0.25 %
+  Extended capabilities: HT Information Exchange Supported, Extended Channel Switching, BSS Transition, 6
+  WMM:   * Parameter version 1
+     * u-APSD
+     * BE: CW 15-1023, AIFSN 3
+     * BK: CW 15-1023, AIFSN 7
+     * VI: CW 7-15, AIFSN 2, TXOP 3008 usec
+     * VO: CW 3-7, AIFSN 2, TXOP 1504 usec
+BSS 94:b4:0f:f1:35:60(on wcli0)
+  TSF: 1739987968 usec (0d, 00:28:59)
+  freq: 2462
+  beacon interval: 100 TUs
+  capability: ESS Privacy ShortPreamble SpectrumMgmt ShortSlotTime RadioMeasure (0x1531)
+  signal: -39.00 dBm
+  last seen: 1910 ms ago
+  Information elements from Probe Response frame:
+  SSID: Google
+  Supported rates: 36.0* 48.0 54.0
+  DS Parameter set: channel 11
+  Country: US Environment: Indoor/Outdoor
+    Channels [1 - 11] @ 36 dBm
+  Power constraint: 0 dB
+  TPC report: TX power: 3 dBm
+  ERP: <no flags>
+  RSN:   * Version: 1
+     * Group cipher: CCMP
+     * Pairwise ciphers: CCMP
+     * Authentication suites: IEEE 802.1X
+     * Capabilities: 4-PTKSA-RC 4-GTKSA-RC (0x0028)
+  BSS Load:
+     * station count: 0
+     * channel utilisation: 49/255
+     * available admission capacity: 26875 [*32us]
+  HT capabilities:
+    Capabilities: 0x19ad
+      RX LDPC
+      HT20
+      SM Power Save disabled
+      RX HT20 SGI
+      TX STBC
+      RX STBC 1-stream
+      Max AMSDU length: 7935 bytes
+      DSSS/CCK HT40
+    Maximum RX AMPDU length 65535 bytes (exponent: 0x003)
+    Minimum RX AMPDU time spacing: 4 usec (0x05)
+    HT RX MCS rate indexes supported: 0-23
+    HT TX MCS rate indexes are undefined
+  HT operation:
+     * primary channel: 11
+     * secondary channel offset: no secondary
+     * STA channel width: 20 MHz
+     * RIFS: 1
+     * HT protection: nonmember
+     * non-GF present: 1
+     * OBSS non-GF present: 1
+     * dual beacon: 0
+     * dual CTS protection: 0
+     * STBC beacon: 0
+     * L-SIG TXOP Prot: 0
+     * PCO active: 0
+     * PCO phase: 0
+  Overlapping BSS scan params:
+     * passive dwell: 20 TUs
+     * active dwell: 10 TUs
+     * channel width trigger scan interval: 300 s
+     * scan passive total per channel: 200 TUs
+     * scan active total per channel: 20 TUs
+     * BSS width channel transition delay factor: 5
+     * OBSS Scan Activity Threshold: 0.25 %
+  Extended capabilities: HT Information Exchange Supported, Extended Channel Switching, BSS Transition, 6
+  WMM:   * Parameter version 1
+     * u-APSD
+     * BE: CW 15-1023, AIFSN 3
+     * BK: CW 15-1023, AIFSN 7
+     * VI: CW 7-15, AIFSN 2, TXOP 3008 usec
+     * VO: CW 3-7, AIFSN 2, TXOP 1504 usec
+BSS 94:b4:0f:f1:35:61(on wcli0)
+  TSF: 1739988134 usec (0d, 00:28:59)
+  freq: 2462
+  beacon interval: 100 TUs
+  capability: ESS ShortPreamble SpectrumMgmt ShortSlotTime RadioMeasure (0x1521)
+  signal: -38.00 dBm
+  last seen: 1910 ms ago
+  Information elements from Probe Response frame:
+  SSID: GoogleGuest
+  Supported rates: 36.0* 48.0 54.0
+  DS Parameter set: channel 11
+  Country: US Environment: Indoor/Outdoor
+    Channels [1 - 11] @ 36 dBm
+  Power constraint: 0 dB
+  TPC report: TX power: 3 dBm
+  ERP: <no flags>
+  BSS Load:
+     * station count: 1
+     * channel utilisation: 49/255
+     * available admission capacity: 26875 [*32us]
+  HT capabilities:
+    Capabilities: 0x19ad
+      RX LDPC
+      HT20
+      SM Power Save disabled
+      RX HT20 SGI
+      TX STBC
+      RX STBC 1-stream
+      Max AMSDU length: 7935 bytes
+      DSSS/CCK HT40
+    Maximum RX AMPDU length 65535 bytes (exponent: 0x003)
+    Minimum RX AMPDU time spacing: 4 usec (0x05)
+    HT RX MCS rate indexes supported: 0-23
+    HT TX MCS rate indexes are undefined
+  HT operation:
+     * primary channel: 11
+     * secondary channel offset: no secondary
+     * STA channel width: 20 MHz
+     * RIFS: 1
+     * HT protection: nonmember
+     * non-GF present: 1
+     * OBSS non-GF present: 1
+     * dual beacon: 0
+     * dual CTS protection: 0
+     * STBC beacon: 0
+     * L-SIG TXOP Prot: 0
+     * PCO active: 0
+     * PCO phase: 0
+  Overlapping BSS scan params:
+     * passive dwell: 20 TUs
+     * active dwell: 10 TUs
+     * channel width trigger scan interval: 300 s
+     * scan passive total per channel: 200 TUs
+     * scan active total per channel: 20 TUs
+     * BSS width channel transition delay factor: 5
+     * OBSS Scan Activity Threshold: 0.25 %
+  Extended capabilities: HT Information Exchange Supported, Extended Channel Switching, BSS Transition, 6
+  WMM:   * Parameter version 1
+     * u-APSD
+     * BE: CW 15-1023, AIFSN 3
+     * BK: CW 15-1023, AIFSN 7
+     * VI: CW 7-15, AIFSN 2, TXOP 3008 usec
+     * VO: CW 3-7, AIFSN 2, TXOP 1504 usec
+BSS 94:b4:0f:f1:3a:e0(on wcli0)
+  TSF: 24578560051 usec (0d, 06:49:38)
+  freq: 2437
+  beacon interval: 100 TUs
+  capability: ESS Privacy ShortPreamble SpectrumMgmt ShortSlotTime RadioMeasure (0x1531)
+  signal: -55.00 dBm
+  last seen: 2310 ms ago
+  Information elements from Probe Response frame:
+  SSID: Google
+  Supported rates: 36.0* 48.0 54.0
+  DS Parameter set: channel 6
+  TIM: DTIM Count 0 DTIM Period 1 Bitmap Control 0x0 Bitmap[0] 0x0
+  Country: US Environment: Indoor/Outdoor
+    Channels [1 - 11] @ 36 dBm
+  Power constraint: 0 dB
+  TPC report: TX power: 3 dBm
+  ERP: <no flags>
+  RSN:   * Version: 1
+     * Group cipher: CCMP
+     * Pairwise ciphers: CCMP
+     * Authentication suites: IEEE 802.1X
+     * Capabilities: 4-PTKSA-RC 4-GTKSA-RC (0x0028)
+  BSS Load:
+     * station count: 1
+     * channel utilisation: 21/255
+     * available admission capacity: 28125 [*32us]
+  HT capabilities:
+    Capabilities: 0x19ad
+      RX LDPC
+      HT20
+      SM Power Save disabled
+      RX HT20 SGI
+      TX STBC
+      RX STBC 1-stream
+      Max AMSDU length: 7935 bytes
+      DSSS/CCK HT40
+    Maximum RX AMPDU length 65535 bytes (exponent: 0x003)
+    Minimum RX AMPDU time spacing: 4 usec (0x05)
+    HT RX MCS rate indexes supported: 0-23
+    HT TX MCS rate indexes are undefined
+  HT operation:
+     * primary channel: 6
+     * secondary channel offset: no secondary
+     * STA channel width: 20 MHz
+     * RIFS: 1
+     * HT protection: nonmember
+     * non-GF present: 1
+     * OBSS non-GF present: 1
+     * dual beacon: 0
+     * dual CTS protection: 0
+     * STBC beacon: 0
+     * L-SIG TXOP Prot: 0
+     * PCO active: 0
+     * PCO phase: 0
+  Overlapping BSS scan params:
+     * passive dwell: 20 TUs
+     * active dwell: 10 TUs
+     * channel width trigger scan interval: 300 s
+     * scan passive total per channel: 200 TUs
+     * scan active total per channel: 20 TUs
+     * BSS width channel transition delay factor: 5
+     * OBSS Scan Activity Threshold: 0.25 %
+  Extended capabilities: HT Information Exchange Supported, Extended Channel Switching, BSS Transition, 6
+  WMM:   * Parameter version 1
+     * u-APSD
+     * BE: CW 15-1023, AIFSN 3
+     * BK: CW 15-1023, AIFSN 7
+     * VI: CW 7-15, AIFSN 2, TXOP 3008 usec
+     * VO: CW 3-7, AIFSN 2, TXOP 1504 usec
+BSS 94:b4:0f:f1:3a:e1(on wcli0)
+  TSF: 24578576547 usec (0d, 06:49:38)
+  freq: 2437
+  beacon interval: 100 TUs
+  capability: ESS ShortPreamble SpectrumMgmt ShortSlotTime RadioMeasure (0x1521)
+  signal: -65.00 dBm
+  last seen: 80 ms ago
+  Information elements from Probe Response frame:
+  SSID: GoogleGuest
+  Supported rates: 36.0* 48.0 54.0
+  DS Parameter set: channel 6
+  Country: US Environment: Indoor/Outdoor
+    Channels [1 - 11] @ 36 dBm
+  Power constraint: 0 dB
+  TPC report: TX power: 3 dBm
+  ERP: <no flags>
+  BSS Load:
+     * station count: 2
+     * channel utilisation: 21/255
+     * available admission capacity: 28125 [*32us]
+  HT capabilities:
+    Capabilities: 0x19ad
+      RX LDPC
+      HT20
+      SM Power Save disabled
+      RX HT20 SGI
+      TX STBC
+      RX STBC 1-stream
+      Max AMSDU length: 7935 bytes
+      DSSS/CCK HT40
+    Maximum RX AMPDU length 65535 bytes (exponent: 0x003)
+    Minimum RX AMPDU time spacing: 4 usec (0x05)
+    HT RX MCS rate indexes supported: 0-23
+    HT TX MCS rate indexes are undefined
+  HT operation:
+     * primary channel: 6
+     * secondary channel offset: no secondary
+     * STA channel width: 20 MHz
+     * RIFS: 1
+     * HT protection: nonmember
+     * non-GF present: 1
+     * OBSS non-GF present: 1
+     * dual beacon: 0
+     * dual CTS protection: 0
+     * STBC beacon: 0
+     * L-SIG TXOP Prot: 0
+     * PCO active: 0
+     * PCO phase: 0
+  Overlapping BSS scan params:
+     * passive dwell: 20 TUs
+     * active dwell: 10 TUs
+     * channel width trigger scan interval: 300 s
+     * scan passive total per channel: 200 TUs
+     * scan active total per channel: 20 TUs
+     * BSS width channel transition delay factor: 5
+     * OBSS Scan Activity Threshold: 0.25 %
+  Extended capabilities: HT Information Exchange Supported, Extended Channel Switching, BSS Transition, 6
+  WMM:   * Parameter version 1
+     * u-APSD
+     * BE: CW 15-1023, AIFSN 3
+     * BK: CW 15-1023, AIFSN 7
+     * VI: CW 7-15, AIFSN 2, TXOP 3008 usec
+     * VO: CW 3-7, AIFSN 2, TXOP 1504 usec
+
+BSS 94:b4:0f:f1:36:41(on wcli0)
+  TSF: 12499149351 usec (0d, 03:28:19)
+  freq: 2437
+  beacon interval: 100 TUs
+  capability: ESS ShortPreamble SpectrumMgmt ShortSlotTime RadioMeasure (0x1521)
+  signal: -67.00 dBm
+  last seen: 80 ms ago
+  Information elements from Probe Response frame:
+  SSID: GoogleGuest
+  Supported rates: 36.0* 48.0 54.0
+  DS Parameter set: channel 6
+  Country: US Environment: Indoor/Outdoor
+    Channels [1 - 11] @ 36 dBm
+  Power constraint: 0 dB
+  TPC report: TX power: 3 dBm
+  ERP: <no flags>
+  BSS Load:
+     * station count: 1
+     * channel utilisation: 28/255
+     * available admission capacity: 27500 [*32us]
+  HT capabilities:
+    Capabilities: 0x19ad
+      RX LDPC
+      HT20
+      SM Power Save disabled
+      RX HT20 SGI
+      TX STBC
+      RX STBC 1-stream
+      Max AMSDU length: 7935 bytes
+      DSSS/CCK HT40
+    Maximum RX AMPDU length 65535 bytes (exponent: 0x003)
+    Minimum RX AMPDU time spacing: 4 usec (0x05)
+    HT RX MCS rate indexes supported: 0-23
+    HT TX MCS rate indexes are undefined
+  HT operation:
+     * primary channel: 6
+     * secondary channel offset: no secondary
+     * STA channel width: 20 MHz
+     * RIFS: 1
+     * HT protection: nonmember
+     * non-GF present: 1
+     * OBSS non-GF present: 1
+     * dual beacon: 0
+     * dual CTS protection: 0
+     * STBC beacon: 0
+     * L-SIG TXOP Prot: 0
+     * PCO active: 0
+     * PCO phase: 0
+  Overlapping BSS scan params:
+     * passive dwell: 20 TUs
+     * active dwell: 10 TUs
+     * channel width trigger scan interval: 300 s
+     * scan passive total per channel: 200 TUs
+     * scan active total per channel: 20 TUs
+     * BSS width channel transition delay factor: 5
+     * OBSS Scan Activity Threshold: 0.25 %
+  Extended capabilities: HT Information Exchange Supported, Extended Channel Switching, BSS Transition, 6
+  WMM:   * Parameter version 1
+     * u-APSD
+     * BE: CW 15-1023, AIFSN 3
+     * BK: CW 15-1023, AIFSN 7
+     * VI: CW 7-15, AIFSN 2, TXOP 3008 usec
+     * VO: CW 3-7, AIFSN 2, TXOP 1504 usec
+BSS 94:b4:0f:f1:36:40(on wcli0)
+  TSF: 12499150000 usec (0d, 03:28:19)
+  freq: 2437
+  beacon interval: 100 TUs
+  capability: ESS Privacy ShortPreamble SpectrumMgmt ShortSlotTime RadioMeasure (0x1531)
+  signal: -66.00 dBm
+  last seen: 2350 ms ago
+  Information elements from Probe Response frame:
+  SSID: Google
+  Supported rates: 36.0* 48.0 54.0
+  DS Parameter set: channel 6
+  TIM: DTIM Count 0 DTIM Period 1 Bitmap Control 0x0 Bitmap[0] 0x0
+  Country: US Environment: Indoor/Outdoor
+    Channels [1 - 11] @ 36 dBm
+  Power constraint: 0 dB
+  TPC report: TX power: 3 dBm
+  ERP: <no flags>
+  RSN:   * Version: 1
+     * Group cipher: CCMP
+     * Pairwise ciphers: CCMP
+     * Authentication suites: IEEE 802.1X
+     * Capabilities: 4-PTKSA-RC 4-GTKSA-RC (0x0028)
+  BSS Load:
+     * station count: 0
+     * channel utilisation: 28/255
+     * available admission capacity: 27500 [*32us]
+  HT capabilities:
+    Capabilities: 0x19ad
+      RX LDPC
+      HT20
+      SM Power Save disabled
+      RX HT20 SGI
+      TX STBC
+      RX STBC 1-stream
+      Max AMSDU length: 7935 bytes
+      DSSS/CCK HT40
+    Maximum RX AMPDU length 65535 bytes (exponent: 0x003)
+    Minimum RX AMPDU time spacing: 4 usec (0x05)
+    HT RX MCS rate indexes supported: 0-23
+    HT TX MCS rate indexes are undefined
+  HT operation:
+     * primary channel: 6
+     * secondary channel offset: no secondary
+     * STA channel width: 20 MHz
+     * RIFS: 1
+     * HT protection: nonmember
+     * non-GF present: 1
+     * OBSS non-GF present: 1
+     * dual beacon: 0
+     * dual CTS protection: 0
+     * STBC beacon: 0
+     * L-SIG TXOP Prot: 0
+     * PCO active: 0
+     * PCO phase: 0
+  Overlapping BSS scan params:
+     * passive dwell: 20 TUs
+     * active dwell: 10 TUs
+     * channel width trigger scan interval: 300 s
+     * scan passive total per channel: 200 TUs
+     * scan active total per channel: 20 TUs
+     * BSS width channel transition delay factor: 5
+     * OBSS Scan Activity Threshold: 0.25 %
+  Extended capabilities: HT Information Exchange Supported, Extended Channel Switching, BSS Transition, 6
+  WMM:   * Parameter version 1
+     * u-APSD
+     * BE: CW 15-1023, AIFSN 3
+     * BK: CW 15-1023, AIFSN 7
+     * VI: CW 7-15, AIFSN 2, TXOP 3008 usec
+     * VO: CW 3-7, AIFSN 2, TXOP 1504 usec
+"""
+
+
+# pylint: disable=unused-argument,protected-access
+def fake_scan(*args, **kwargs):
+  return SCAN_OUTPUT
+iw._scan = fake_scan
+
+
+@wvtest.wvtest
+def find_bssids_test():
+  """Test iw.find_bssids."""
+  short_scan_result = iw.BssInfo(ssid='short scan result',
+                                 bssid='00:23:97:57:f4:d8',
+                                 security=['WEP'],
+                                 vendor_ies=[('00:11:22', '01 23 45 67')])
+
+  with_ie, without_ie = iw.find_bssids('wcli0', lambda o, d: o == '00:11:22',
+                                       True)
+
+  wvtest.WVPASSEQ(with_ie, set([short_scan_result]))
+
+  wvtest.WVPASSEQ(
+      without_ie,
+      set([iw.BssInfo(ssid='GoogleGuest', bssid='94:b4:0f:f1:36:41'),
+           iw.BssInfo(ssid='GoogleGuest', bssid='94:b4:0f:f1:3a:e1'),
+           iw.BssInfo(ssid='GoogleGuest', bssid='94:b4:0f:f1:35:61'),
+           iw.BssInfo(ssid='Google', bssid='94:b4:0f:f1:36:40',
+                      security=['WPA2']),
+           iw.BssInfo(ssid='Google', bssid='94:b4:0f:f1:3a:e0',
+                      security=['WPA2']),
+           iw.BssInfo(ssid='Google', bssid='94:b4:0f:f1:35:60',
+                      security=['WPA2']),
+           iw.BssInfo(ssid='Google', bssid='94:b4:0f:f1:02:a0',
+                      security=['WPA2'])]))
+
+  with_ie, without_ie = iw.find_bssids('wcli0', lambda o, d: o == '00:11:22',
+                                       False)
+  wvtest.WVPASSEQ(with_ie, set())
+  wvtest.WVPASSEQ(
+      without_ie,
+      set([iw.BssInfo(ssid='GoogleGuest', bssid='94:b4:0f:f1:36:41'),
+           iw.BssInfo(ssid='GoogleGuest', bssid='94:b4:0f:f1:3a:e1'),
+           iw.BssInfo(ssid='GoogleGuest', bssid='94:b4:0f:f1:35:61')]))
+
+if __name__ == '__main__':
+  wvtest.wvtest_main()
diff --git a/conman/main.py b/conman/main.py
new file mode 100755
index 0000000..3b81bfd
--- /dev/null
+++ b/conman/main.py
@@ -0,0 +1,27 @@
+#!/usr/bin/python
+
+"""Runs a ConnectionManager."""
+
+import logging
+import os
+import sys
+
+import connection_manager
+
+STATUS_DIR = '/tmp/conman'
+
+if __name__ == '__main__':
+  loglevel = logging.INFO
+  if '--debug' in sys.argv:
+    loglevel = logging.DEBUG
+  logging.basicConfig(level=loglevel)
+  logging.debug('Debug logging enabled.')
+
+  sys.stdout = os.fdopen(1, 'w', 1)  # force line buffering even if redirected
+  sys.stderr = os.fdopen(2, 'w', 1)  # force line buffering even if redirected
+
+  if not os.path.exists(STATUS_DIR):
+    os.makedirs(STATUS_DIR)
+
+  c = connection_manager.ConnectionManager(status_dir=STATUS_DIR)
+  c.run()
diff --git a/conman/test/fail b/conman/test/fail
new file mode 100755
index 0000000..2bb8d86
--- /dev/null
+++ b/conman/test/fail
@@ -0,0 +1,3 @@
+#!/bin/sh
+
+exit 1
diff --git a/conman/test/fake_wpactrl/wpactrl/__init__.py b/conman/test/fake_wpactrl/wpactrl/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/conman/test/fake_wpactrl/wpactrl/__init__.py
diff --git a/conman/test/restricted b/conman/test/restricted
new file mode 100755
index 0000000..9bd8fc3
--- /dev/null
+++ b/conman/test/restricted
@@ -0,0 +1,4 @@
+#!/bin/sh
+
+echo "$@" | grep -q -- "-a"
+
diff --git a/conman/test/succeed b/conman/test/succeed
new file mode 100755
index 0000000..c52d3c2
--- /dev/null
+++ b/conman/test/succeed
@@ -0,0 +1,3 @@
+#!/bin/sh
+
+exit 0
diff --git a/conman/wvtest b/conman/wvtest
new file mode 120000
index 0000000..75927a5
--- /dev/null
+++ b/conman/wvtest
@@ -0,0 +1 @@
+../cmds/wvtest
\ No newline at end of file