Merge "Add LICENSE so I can import isoping.c into google3/third_party."
diff --git a/Makefile b/Makefile
index 2f0e4a6..4dd7267 100644
--- a/Makefile
+++ b/Makefile
@@ -20,7 +20,7 @@
 
 # note: libgpio is not built here.  It's conditionally built
 # via buildroot/packages/google/google_platform/google_platform.mk
-DIRS=libstacktrace ginstall cmds \
+DIRS=libstacktrace libexperiments ginstall cmds \
 	antirollback tvstat gpio-mailbox spectralanalyzer wifi wifiblaster \
 	sysvar py_mtd devcert
 
@@ -62,6 +62,10 @@
 DIRS+=jsonpoll
 endif
 
+ifeq ($(BUILD_CRAFTUI),y)
+DIRS+=craftui
+endif
+
 ifeq ($(BR2_TARGET_GENERIC_PLATFORM_NAME),gfsc100)
 DIRS+=diags
 endif
@@ -93,10 +97,19 @@
 install:
 	set -e; for d in $(DIRS); do $(MAKE) -C $$d install; done
 	$(MAKE) install-optionspy
+	mkdir -p $(BINDIR)
+	rm -fv $(BINDIR)/hnvram
+ifeq ($(BR2_TARGET_GENERIC_PLATFORM_NAME), gfmn110)
+	ln -s /usr/bin/hnvram_wrapper $(BINDIR)/hnvram
+else ifeq ($(BR2_TARGET_GENERIC_PLATFORM_NAME), gflt110)
+	ln -s /usr/bin/hnvram_wrapper $(BINDIR)/hnvram
+else
+	ln -s /usr/bin/hnvram_binary $(BINDIR)/hnvram
+endif
 
-sysmgr/all: base/all libstacktrace/all
-cmds/all: libstacktrace/all
-gpio-mailbox/all: libstacktrace/all
+sysmgr/all: base/all libstacktrace/all libexperiments/all
+cmds/all: libstacktrace/all libexperiments/all
+gpio-mailbox/all: libstacktrace/all libexperiments/all
 
 %/all:
 	$(MAKE) -C $* all
diff --git a/base/time.cc b/base/time.cc
index d1427c3..2672023 100644
--- a/base/time.cc
+++ b/base/time.cc
@@ -65,7 +65,7 @@
 }
 
 // Make sure someone calls it so that it gets initialized
-static uint32 ignore = StartTime();
+static uint32 __attribute__((used)) ignore = StartTime();
 
 uint32 TimeAfter(int32 elapsed) {
   ASSERT(elapsed >= 0);
diff --git a/cmds/logos.c b/cmds/logos.c
index 2f453d0..6c186a5 100644
--- a/cmds/logos.c
+++ b/cmds/logos.c
@@ -25,7 +25,6 @@
  *  - cleans up control characters (ie. chars < 32).
  *  - makes sure output lines are in "facility: message" format.
  *  - doesn't rely on syslogd.
- *  - suppresses logging of MAC addresses.
  *  - suppresses logging of filenames of personal media.
  */
 #include <assert.h>
@@ -461,52 +460,6 @@
 }
 
 
-static int is_mac_address(const uint8_t *s, char sep) {
-  if ((s[2] == sep) && (s[5] == sep) && (s[8] == sep) &&
-      (s[11] == sep) && (s[14] == sep) &&
-      isxdigit(s[0]) && isxdigit(s[1]) &&
-      isxdigit(s[3]) && isxdigit(s[4]) &&
-      isxdigit(s[6]) && isxdigit(s[7]) &&
-      isxdigit(s[9]) && isxdigit(s[10]) &&
-      isxdigit(s[12]) && isxdigit(s[13]) &&
-      isxdigit(s[15]) && isxdigit(s[16])) {
-    return 1;
-  }
-
-  return 0;
-}
-
-
-static void blot_out_mac_address(uint8_t *s) {
-  s[12] = 'X';
-  s[13] = 'X';
-  s[15] = 'X';
-  s[16] = 'X';
-}
-
-
-/*
- * search for text patterns which look like MAC addresses,
- * and cross out the last two bytes with 'X' characters.
- * Ex: f8:8f:ca:00:00:01 and f8-8f-ca-00-00-01
- */
-#define MAC_ADDR_LEN 17
-static void suppress_mac_addresses(uint8_t *line, ssize_t len, char sep) {
-  uint8_t *s = line;
-
-  while (len >= MAC_ADDR_LEN) {
-    if (is_mac_address(s, sep)) {
-      blot_out_mac_address(s);
-      s += MAC_ADDR_LEN;
-      len -= MAC_ADDR_LEN;
-    } else {
-      s += 1;
-      len -= 1;
-    }
-  }
-}
-
-
 /*
  * Return true for a character which we expect to terminate a
  * media filename.
@@ -700,9 +653,6 @@
       uint8_t *start = buf, *next = buf + used, *end = buf + used + got, *p;
       while ((p = memchr(next, '\n', end - next)) != NULL) {
         ssize_t linelen = p - start;
-        suppress_mac_addresses(start, linelen, ':');
-        suppress_mac_addresses(start, linelen, '-');
-        suppress_mac_addresses(start, linelen, '_');
         suppress_media_filenames(start, linelen, "/var/media/pictures/");
         suppress_media_filenames(start, linelen, "/var/media/videos/");
         flush(header, headerlen, start, linelen);
diff --git a/cmds/test-logos.py b/cmds/test-logos.py
index c934c58..d930ccb 100755
--- a/cmds/test-logos.py
+++ b/cmds/test-logos.py
@@ -11,10 +11,6 @@
 from wvtest.wvtest import *
 
 
-def macAddressShapedString():
-  chars = '0123456789abcdef::::::'
-  return ''.join(random.choice(chars) for x in range(17))
-
 @wvtest
 def testLogos():
   # We use a SOCK_DGRAM here rather than a normal pipe, because datagram
@@ -90,25 +86,6 @@
   os.write(fd1, '\n')
   WVPASSEQ('<7>fac: booga!\n', _Read())
 
-  # MAC addresses
-  os.write(fd1, 'f8:8f:ca:00:00:01\n')
-  WVPASSEQ('<7>fac: f8:8f:ca:00:XX:XX\n', _Read())
-  os.write(fd1, '8:8f:ca:00:00:01\n')
-  WVPASSEQ('<7>fac: 8:8f:ca:00:00:01\n', _Read())
-  os.write(fd1, '8:8f:ca:00:00:01:\n')
-  WVPASSEQ('<7>fac: 8:8f:ca:00:00:01:\n', _Read())
-  os.write(fd1, ':::semicolons:f8:8f:ca:00:00:01:and:after\n')
-  WVPASSEQ('<7>fac: :::semicolons:f8:8f:ca:00:XX:XX:and:after\n', _Read())
-  os.write(fd1, 'f8-8f-ca-00-00-01\n')
-  WVPASSEQ('<7>fac: f8-8f-ca-00-XX-XX\n', _Read())
-
-  # Send in random strings to look for crashes.
-  for x in range(10):
-    mac = macAddressShapedString()
-    print 'Trying %s to check for crashes' % mac
-    os.write(fd1, mac + '\n')
-    print _Read()
-
   # Filenames
   os.write(fd1, 'Accessing /var/media/pictures/MyPicture.jpg for decode\n')
   WVPASSEQ('<7>fac: Accessing /var/media/pictures/XXXXXXXXXXXXX for decode\n',
diff --git a/cmds/test-mmap.sh b/cmds/test-mmap.sh
index 85b3004..32710d9 100755
--- a/cmds/test-mmap.sh
+++ b/cmds/test-mmap.sh
@@ -18,7 +18,14 @@
     $PREFIX ../host-mmap $ARGS < INPUT > GOT 2>&1
     status=$?
     if [ -n "$PREFIX" ]; then
-      sleep .5      # script mysteriously delays output
+      # /usr/bin/script mysteriously delays output (child writes cached?)
+      # sleep up to 4 seconds waiting for the output
+      for n in $(seq 1 40); do
+        if [ -s GOT ]; then
+          break
+        fi
+        sleep .1
+      done
     fi
     if [ "$status" != "$EXIT" ]; then
       echo "exit code: expected '$EXIT', got '$status'"
diff --git a/cmds/wifi_files.c b/cmds/wifi_files.c
index 31f9d4a..49e77c6 100644
--- a/cmds/wifi_files.c
+++ b/cmds/wifi_files.c
@@ -777,26 +777,6 @@
 }
 
 
-static void TouchUpdateFile()
-{
-  char filename[PATH_MAX];
-  int fd;
-
-  snprintf(filename, sizeof(filename), "%s/updated.new", STATIONS_DIR);
-  if ((fd = open(filename, O_CREAT | O_WRONLY, 0666)) < 0) {
-    perror("TouchUpdatedFile open");
-    exit(1);
-  }
-
-  if (write(fd, "updated", 7) < 7) {
-    perror("TouchUpdatedFile write");
-    exit(1);
-  }
-
-  close(fd);
-} /* TouchUpdateFile */
-
-
 static void ClientStateToLog(gpointer key, gpointer value, gpointer user_data)
 {
   const client_state_t *state = (const client_state_t *)value;
@@ -850,7 +830,6 @@
 void UpdateAssociatedDevices()
 {
   g_hash_table_foreach(clients, ClientStateToJson, NULL);
-  TouchUpdateFile();
 }
 
 
@@ -887,12 +866,21 @@
   int i;
 
   for (i = 0; i < len; i++) {
-    if (isprint(data[i]) && data[i] != ' ' && data[i] != '\\')
-      fprintf(f, "%c", data[i]);
-    else if (data[i] == ' ' && (i != 0 && i != len -1))
-      fprintf(f, " ");
-    else
-      fprintf(f, "\\x%.2x", data[i]);
+    switch(data[i]) {
+      case '\\': fprintf(f, "\\\\"); break;
+      case '"': fprintf(f, "\\\""); break;
+      case '\b': fprintf(f, "\\b"); break;
+      case '\f': fprintf(f, "\\f"); break;
+      case '\n': fprintf(f, "\\n"); break;
+      case '\r': fprintf(f, "\\r"); break;
+      case '\t': fprintf(f, "\\t"); break;
+      default:
+        if ((data[i] <= 0x1f) || !isprint(data[i])) {
+          fprintf(f, "\\u00%02x", data[i]);
+        } else {
+          fprintf(f, "%c", data[i]); break;
+        }
+    }
   }
 }
 
@@ -1027,6 +1015,26 @@
 }
 
 #ifndef UNIT_TESTS
+static void TouchUpdateFile()
+{
+  char filename[PATH_MAX];
+  int fd;
+
+  snprintf(filename, sizeof(filename), "%s/updated.new", STATIONS_DIR);
+  if ((fd = open(filename, O_CREAT | O_WRONLY, 0666)) < 0) {
+    perror("TouchUpdatedFile open");
+    exit(1);
+  }
+
+  if (write(fd, "updated", 7) < 7) {
+    perror("TouchUpdatedFile write");
+    exit(1);
+  }
+
+  close(fd);
+} /* TouchUpdateFile */
+
+
 int main(int argc, char **argv)
 {
   int done = 0;
diff --git a/cmds/wifi_files_test.c b/cmds/wifi_files_test.c
index bb0043c..9d48dd1 100644
--- a/cmds/wifi_files_test.c
+++ b/cmds/wifi_files_test.c
@@ -34,8 +34,8 @@
 {
   FILE *f = tmpfile();
   char buf[32];
-  const uint8_t ssid[] = {'a', 'b', 0x86, ' ', 'c'};  /* not NUL terminated. */
-  const uint8_t expected[] = {'a', 'b', '\\', 'x', '8', '6', ' ', 'c'};
+  const uint8_t ssid[] = {'b', 0x86, ' ', 'c'};  /* not NUL terminated. */
+  const uint8_t expected[] = {'b', '\\', 'u', '0', '0', '8', '6', ' ', 'c'};
 
   printf("Testing \"%s\" in %s:\n", __FUNCTION__, __FILE__);
   memset(buf, 0, sizeof(buf));
diff --git a/conman/connection_manager.py b/conman/connection_manager.py
index 708954f..a2de7d6 100755
--- a/conman/connection_manager.py
+++ b/conman/connection_manager.py
@@ -17,6 +17,7 @@
 import cycler
 import interface
 import iw
+import status
 
 GFIBER_OUIS = ['f4:f5:e8']
 VENDOR_IE_FEATURE_ID_AUTOPROVISIONING = '01'
@@ -53,7 +54,7 @@
   WIFI_SETCLIENT = ['wifi', 'setclient', '--persist']
   WIFI_STOPCLIENT = ['wifi', 'stopclient']
 
-  def __init__(self, band, wifi, command_lines):
+  def __init__(self, band, wifi, command_lines, _status):
     self.band = band
     self.wifi = wifi
     self.command = command_lines.splitlines()
@@ -63,6 +64,7 @@
     self.passphrase = None
     self.interface_suffix = None
     self.access_point = None
+    self._status = _status
 
     binwifi_option_attrs = {
         '-s': 'ssid',
@@ -136,8 +138,10 @@
     if self.passphrase:
       env['WIFI_CLIENT_PSK'] = self.passphrase
     try:
+      self._status.trying_wlan = True
       subprocess.check_output(command, stderr=subprocess.STDOUT, env=env)
       self.client_up = True
+      self._status.connected_to_wlan = True
       logging.info('Started wifi client on %s GHz', self.band)
     except subprocess.CalledProcessError as e:
       logging.error('Failed to start wifi client: %s', e.output)
@@ -153,6 +157,8 @@
       subprocess.check_output(self.WIFI_STOPCLIENT + ['-b', self.band],
                               stderr=subprocess.STDOUT)
       self.client_up = False
+      # TODO(rofrankel): Make this work for dual-radio devices.
+      self._status.connected_to_wlan = False
       logging.debug('Stopped wifi client on %s GHz', self.band)
     except subprocess.CalledProcessError as e:
       logging.error('Failed to stop wifi client: %s', e.output)
@@ -181,17 +187,18 @@
 
   def __init__(self,
                bridge_interface='br0',
-               status_dir='/tmp/conman',
+               tmp_dir='/tmp/conman',
                config_dir='/config/conman',
-               moca_status_dir='/tmp/cwmp/monitoring/moca2',
+               moca_tmp_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._status_dir = status_dir
+    self._tmp_dir = tmp_dir
     self._config_dir = config_dir
-    self._interface_status_dir = os.path.join(status_dir, 'interfaces')
-    self._moca_status_dir = moca_status_dir
+    self._interface_status_dir = os.path.join(tmp_dir, 'interfaces')
+    self._status_dir = os.path.join(tmp_dir, 'status')
+    self._moca_tmp_dir = moca_tmp_dir
     self._wpa_control_interface = wpa_control_interface
     self._run_duration_s = run_duration_s
     self._interface_update_period = interface_update_period
@@ -200,7 +207,14 @@
     self._acs_update_wait_s = acs_update_wait_s
     self._wlan_configuration = {}
 
-    acs_autoprov_filepath = os.path.join(self._status_dir,
+    # Make sure all necessary directories exist.
+    for directory in (self._tmp_dir, self._config_dir, self._moca_tmp_dir,
+                      self._interface_status_dir, self._moca_tmp_dir):
+      if not os.path.exists(directory):
+        os.makedirs(directory)
+        logging.info('Created monitored directory: %s', directory)
+
+    acs_autoprov_filepath = os.path.join(self._tmp_dir,
                                          'acs_autoprovisioning')
     self.bridge = self.Bridge(
         bridge_interface, '10',
@@ -224,22 +238,17 @@
     for wifi in self.wifi:
       wifi.last_wifi_scan_time = -self._wifi_scan_period_s
 
-    # Make sure all necessary directories exist.
-    for directory in (self._status_dir, self._config_dir,
-                      self._interface_status_dir, self._moca_status_dir):
-      if not os.path.exists(directory):
-        os.makedirs(directory)
-        logging.info('Created monitored directory: %s', directory)
+    self._status = status.Status(self._status_dir)
 
     wm = pyinotify.WatchManager()
     wm.add_watch(self._config_dir,
                  pyinotify.IN_CLOSE_WRITE | pyinotify.IN_MOVED_TO |
                  pyinotify.IN_DELETE | pyinotify.IN_MOVED_FROM)
-    wm.add_watch(self._status_dir,
+    wm.add_watch(self._tmp_dir,
                  pyinotify.IN_CLOSE_WRITE | pyinotify.IN_MOVED_TO)
     wm.add_watch(self._interface_status_dir,
                  pyinotify.IN_CLOSE_WRITE | pyinotify.IN_MOVED_TO)
-    wm.add_watch(self._moca_status_dir,
+    wm.add_watch(self._moca_tmp_dir,
                  pyinotify.IN_CLOSE_WRITE | pyinotify.IN_MOVED_TO)
     self.notifier = pyinotify.Notifier(wm, FileChangeHandler(self), timeout=0)
 
@@ -261,9 +270,9 @@
         if wifi_up:
           wifi.attach_wpa_control(self._wpa_control_interface)
 
-    for path, prefix in ((self._status_dir, self.GATEWAY_FILE_PREFIX),
+    for path, prefix in ((self._tmp_dir, self.GATEWAY_FILE_PREFIX),
                          (self._interface_status_dir, ''),
-                         (self._moca_status_dir, self.MOCA_NODE_FILE_PREFIX),
+                         (self._moca_tmp_dir, self.MOCA_NODE_FILE_PREFIX),
                          (self._config_dir, self.COMMAND_FILE_PREFIX)):
       for filepath in glob.glob(os.path.join(path, prefix + '*')):
         self._process_file(path, os.path.split(filepath)[-1])
@@ -373,12 +382,14 @@
       # If this interface is connected to the user's WLAN, there is nothing else
       # to do.
       if self._connected_to_wlan(wifi):
+        self._status.connected_to_wlan = True
         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
+          not getattr(wifi, 'last_successful_bss_info', None) 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)
@@ -395,10 +406,12 @@
           wlan_configuration.start_client()
           if self._connected_to_wlan(wifi):
             logging.debug('Joined WLAN on %s.', wifi.name)
+            self._status.connected_to_wlan = True
             self._try_wlan_after[band] = 0
             break
           else:
             logging.error('Failed to connect to WLAN on %s.', wifi.name)
+            self._status.connected_to_wlan = False
             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
@@ -408,8 +421,12 @@
         # 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)
+        self._status.connected_to_wlan = False
         if self.acs():
           logging.debug('Connected to ACS on %s', wifi.name)
+          wifi.last_successful_bss_info = getattr(wifi,
+                                                  'last_attempted_bss_info',
+                                                  None)
           now = time.time()
           if (self._wlan_configuration and
               hasattr(wifi, 'waiting_for_acs_since')):
@@ -437,15 +454,39 @@
     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)
+    result = self.bridge.acs() or any(wifi.acs() for wifi in self.wifi)
+    self._status.can_reach_acs = result
+    return result
 
   def internet(self):
-    return self.bridge.internet() or any(wifi.internet() for wifi in self.wifi)
+    result = self.bridge.internet() or any(wifi.internet()
+                                           for wifi in self.wifi)
+    self._status.can_reach_internet = result
+    return result
 
   def _update_interfaces_and_routes(self):
+    """Touch each interface via update_routes."""
+
     self.bridge.update_routes()
     for wifi in self.wifi:
       wifi.update_routes()
+      # If wifi is connected to something that's not the WLAN, it must be a
+      # provisioning attempt, and in particular that attempt must be via
+      # last_attempted_bss_info.  If that is the same as the
+      # last_successful_bss_info (i.e. the last attempt was successful) and we
+      # aren't connected to the ACS after calling update_routes (which expires
+      # the connection status cache), then this BSS is no longer successful.
+      if (wifi.wpa_supplicant and
+          not self._connected_to_wlan(wifi) and
+          (getattr(wifi, 'last_successful_bss_info', None) ==
+           getattr(wifi, 'last_attempted_bss_info', None)) and
+          not wifi.acs()):
+        wifi.last_successful_bss_info = None
+
+    # Make sure these get called semi-regularly so that exported status is up-
+    # to-date.
+    self.acs()
+    self.internet()
 
   def handle_event(self, path, filename, deleted):
     if deleted:
@@ -474,6 +515,8 @@
     config.stop_client()
     config.stop_access_point()
     del self._wlan_configuration[band]
+    if not self._wlan_configuration:
+      self._status.have_config = False
 
   def _process_file(self, path, filename):
     """Process or ignore an updated file in a watched directory."""
@@ -507,7 +550,7 @@
           wifi = self.wifi_for_band(band)
           if wifi:
             self._update_wlan_configuration(
-                self.WLANConfiguration(band, wifi, contents))
+                self.WLANConfiguration(band, wifi, contents, self._status))
       elif filename.startswith(self.ACCESS_POINT_FILE_PREFIX):
         match = re.match(self.ACCESS_POINT_FILE_REGEXP, filename)
         if match:
@@ -517,7 +560,7 @@
             self._wlan_configuration[band].access_point = True
           logging.debug('AP enabled for %s GHz', band)
 
-    elif path == self._status_dir:
+    elif path == self._tmp_dir:
       if filename.startswith(self.GATEWAY_FILE_PREFIX):
         interface_name = filename.split(self.GATEWAY_FILE_PREFIX)[-1]
         ifc = self.interface_by_name(interface_name)
@@ -526,7 +569,7 @@
           logging.debug('Received gateway %r for interface %s', contents,
                         ifc.name)
 
-    elif path == self._moca_status_dir:
+    elif path == self._moca_tmp_dir:
       match = re.match(r'^%s\d+$' % self.MOCA_NODE_FILE_PREFIX, filename)
       if match:
         try:
@@ -594,21 +637,40 @@
     if not hasattr(wifi, 'cycler'):
       return False
 
-    bss_info = wifi.cycler.next()
+    last_successful_bss_info = getattr(wifi, 'last_successful_bss_info', None)
+    bss_info = last_successful_bss_info or 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
+      logging.debug('Attempting to connect to SSID %s for provisioning',
+                    bss_info.ssid)
+      self._status.trying_open = True
+      connected = self._try_bssid(wifi, bss_info)
       if connected:
+        self._status.connected_to_open = True
         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)
+      # If we can no longer connect to this, it's no longer successful.
+      elif bss_info == last_successful_bss_info:
+        wifi.last_successful_bss_info = None
       return connected
+    else:
+      # TODO(rofrankel):  There are probably more cases in which this should be
+      # true, e.g. if we keep trying the same few unsuccessful BSSIDs.
+      # Relatedly, once we find ACS access on an open network we may want to
+      # save that SSID/BSSID and that first in future.  If we do that then we
+      # can declare that provisioning has failed much more aggressively.
+      self._status.provisioning_failed = True
 
     return False
 
+  def _try_bssid(self, wifi, bss_info):
+    wifi.last_attempted_bss_info = bss_info
+    return subprocess.call(self.WIFI_SETCLIENT +
+                           ['--ssid', bss_info.ssid,
+                            '--band', wifi.bands[0],
+                            '--bssid', bss_info.bssid]) == 0
+
   def _connected_to_wlan(self, wifi):
     return (wifi.wpa_supplicant and
             any(config.client_up for band, config
@@ -628,6 +690,7 @@
              if wlan_configuration.interface_suffix else '') + band)
         wlan_configuration.access_point = os.path.exists(ap_file)
       self._wlan_configuration[band] = wlan_configuration
+      self._status.have_config = True
       logging.debug('Updated WLAN configuration for %s GHz', band)
       self._update_access_point(wlan_configuration)
 
diff --git a/conman/connection_manager_test.py b/conman/connection_manager_test.py
index 335f7ee..6b1142d 100755
--- a/conman/connection_manager_test.py
+++ b/conman/connection_manager_test.py
@@ -10,6 +10,7 @@
 import connection_manager
 import interface_test
 import iw
+import status
 from wvtest import wvtest
 
 logging.basicConfig(level=logging.DEBUG)
@@ -160,7 +161,7 @@
     return os.path.join(self._wpa_control_interface, self.wifi.name)
 
   def write_gateway_file(self):
-    gateway_file = os.path.join(self.status_dir,
+    gateway_file = os.path.join(self.tmp_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.
@@ -220,6 +221,10 @@
       self.interface_by_name(wifi)._initially_connected = True
 
     self.scan_has_results = False
+    # Should we be able to connect to open network s2?
+    self.s2_connect = True
+    # Will s2 fail rather than providing ACS access?
+    self.s2_fail = False
 
   @property
   def IP_LINK(self):
@@ -234,13 +239,8 @@
         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)
+  def _try_bssid(self, wifi, bss_info):
+    super(ConnectionManager, self)._try_bssid(wifi, bss_info)
 
     socket = os.path.join(self._wpa_control_interface, wifi.name)
 
@@ -254,13 +254,21 @@
       return True
 
     if bss_info and bss_info.ssid == 's2':
-      if wifi.attached():
-        wifi.add_connected_event()
+      if self.s2_connect:
+        if wifi.attached():
+          wifi.add_connected_event()
+        else:
+          open(socket, 'w')
+        if self.s2_fail:
+          connection_check_result = 'fail'
+          logging.debug('s2 configured to have no ACS access')
+        else:
+          connection_check_result = 'restricted'
+        wifi.set_connection_check_result(connection_check_result)
+        self.ifplugd_action(wifi.name, True)
+        return True
       else:
-        open(socket, 'w')
-      wifi.set_connection_check_result('restricted')
-      self.ifplugd_action(wifi.name, True)
-      return True
+        logging.debug('s2 configured not to connect')
 
     return False
 
@@ -280,7 +288,7 @@
   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.tmp_dir = self._tmp_dir
     wlan_configuration.interface_status_dir = self._interface_status_dir
     wlan_configuration.gateway_file_prefix = self.GATEWAY_FILE_PREFIX
 
@@ -347,7 +355,7 @@
       os.unlink(ap_filename)
 
   def write_gateway_file(self, interface_name):
-    gateway_file = os.path.join(self._status_dir,
+    gateway_file = os.path.join(self._tmp_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.
@@ -363,7 +371,7 @@
     self.ifplugd_action('eth0', up)
 
   def set_moca(self, up):
-    moca_node1_file = os.path.join(self._moca_status_dir,
+    moca_node1_file = os.path.join(self._moca_tmp_dir,
                                    self.MOCA_NODE_FILE_PREFIX + '1')
     with open(moca_node1_file, 'w') as f:
       f.write(FAKE_MOCA_NODE1_FILE if up else
@@ -381,6 +389,9 @@
     while wifi_scan_counter == wifi.wifi_scan_counter:
       self.run_once()
 
+  def has_status_files(self, files):
+    return not set(files) - set(os.listdir(self._status_dir))
+
 
 def connection_manager_test(radio_config, **cm_kwargs):
   """Returns a decorator that does ConnectionManager test boilerplate."""
@@ -399,18 +410,18 @@
 
       try:
         # No initial state.
-        status_dir = tempfile.mkdtemp()
+        tmp_dir = tempfile.mkdtemp()
         config_dir = tempfile.mkdtemp()
-        os.mkdir(os.path.join(status_dir, 'interfaces'))
-        moca_status_dir = tempfile.mkdtemp()
+        os.mkdir(os.path.join(tmp_dir, 'interfaces'))
+        moca_tmp_dir = tempfile.mkdtemp()
         wpa_control_interface = tempfile.mkdtemp()
 
         # Test that missing directories are created by ConnectionManager.
-        shutil.rmtree(status_dir)
+        shutil.rmtree(tmp_dir)
 
-        c = ConnectionManager(status_dir=status_dir,
+        c = ConnectionManager(tmp_dir=tmp_dir,
                               config_dir=config_dir,
-                              moca_status_dir=moca_status_dir,
+                              moca_tmp_dir=moca_tmp_dir,
                               wpa_control_interface=wpa_control_interface,
                               run_duration_s=run_duration_s,
                               interface_update_period=interface_update_period,
@@ -421,11 +432,10 @@
         c.test_wifi_scan_period = wifi_scan_period
 
         f(c)
-
       finally:
-        shutil.rmtree(status_dir)
+        shutil.rmtree(tmp_dir)
         shutil.rmtree(config_dir)
-        shutil.rmtree(moca_status_dir)
+        shutil.rmtree(moca_tmp_dir)
         shutil.rmtree(wpa_control_interface)
         # pylint: disable=protected-access
         connection_manager._wifi_show = original_wifi_show
@@ -450,12 +460,14 @@
   # ConnectionManager cares that the file is created *where* expected, but it is
   # Bridge's responsbility to make sure its creation and deletion are generally
   # correct; more thorough tests are in bridge_test in interface_test.py.
-  acs_autoprov_filepath = os.path.join(c._status_dir, 'acs_autoprovisioning')
+  acs_autoprov_filepath = os.path.join(c._tmp_dir, 'acs_autoprovisioning')
 
   # Initially, there is ethernet access (via explicit check of ethernet status,
   # rather than the interface status file).
   wvtest.WVPASS(c.acs())
   wvtest.WVPASS(c.internet())
+  wvtest.WVPASS(c.has_status_files([status.P.CAN_REACH_ACS,
+                                    status.P.CAN_REACH_INTERNET]))
 
   c.run_once()
   wvtest.WVPASS(c.acs())
@@ -464,6 +476,8 @@
   wvtest.WVPASS(os.path.exists(acs_autoprov_filepath))
   wvtest.WVFAIL(c.wifi_for_band('2.4').current_route())
   wvtest.WVFAIL(c.wifi_for_band('5').current_route())
+  wvtest.WVFAIL(c.has_status_files([status.P.CONNECTED_TO_WLAN,
+                                    status.P.HAVE_CONFIG]))
 
   # Take down ethernet, no access.
   c.set_ethernet(False)
@@ -472,6 +486,8 @@
   wvtest.WVFAIL(c.internet())
   wvtest.WVFAIL(c.bridge.current_route())
   wvtest.WVFAIL(os.path.exists(acs_autoprov_filepath))
+  wvtest.WVFAIL(c.has_status_files([status.P.CAN_REACH_ACS,
+                                    status.P.CAN_REACH_INTERNET]))
 
   # Bring up moca, access.
   c.set_moca(True)
@@ -522,8 +538,12 @@
   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')
+    wvtest.WVPASS(c.has_status_files([status.P.CONNECTED_TO_OPEN]))
+
+  last_bss_info = c.wifi_for_band('2.4').last_attempted_bss_info
+  wvtest.WVPASSEQ(last_bss_info.ssid, 's2')
+  wvtest.WVPASSEQ(last_bss_info.bssid, '01:23:45:67:89:ab')
+
   # Wait for the connection to be processed.
   c.run_once()
   wvtest.WVPASS(c.acs())
@@ -540,6 +560,7 @@
   c.run_once()
   wvtest.WVPASS(c.client_up('2.4'))
   wvtest.WVPASS(c.wifi_for_band('2.4').current_route())
+  wvtest.WVPASS(c.has_status_files([status.P.CONNECTED_TO_WLAN]))
 
   # Now enable the AP.  Since we have no wired connection, this should have no
   # effect.
@@ -570,6 +591,7 @@
   wvtest.WVFAIL(c.client_up('2.4'))
   wvtest.WVFAIL(c.wifi_for_band('2.4').current_route())
   wvtest.WVPASS(c.bridge.current_route())
+  wvtest.WVFAIL(c.has_status_files([status.P.HAVE_CONFIG]))
 
   # Now move it back, and the AP should come back.
   os.rename(other_filename, filename)
@@ -579,6 +601,75 @@
   wvtest.WVFAIL(c.wifi_for_band('2.4').current_route())
   wvtest.WVPASS(c.bridge.current_route())
 
+  # Now delete the config and bring down the bridge and make sure we reprovision
+  # via the last working BSS.
+  c.delete_wlan_config('2.4')
+  c.bridge.set_connection_check_result('fail')
+  scan_count_2_4 = c.wifi_for_band('2.4').wifi_scan_counter
+  c.run_until_interface_update()
+  wvtest.WVFAIL(c.acs())
+  wvtest.WVFAIL(c.internet())
+  # s2 is not what the cycler would suggest trying next.
+  wvtest.WVPASSNE('s2', c.wifi_for_band('2.4').cycler.peek())
+  # Run only once, so that only one BSS can be tried.  It should be the s2 one,
+  # since that worked previously.
+  c.run_once()
+  wvtest.WVPASS(c.acs())
+  # Make sure we didn't scan on 2.4.
+  wvtest.WVPASSEQ(scan_count_2_4, c.wifi_for_band('2.4').wifi_scan_counter)
+
+  # Now re-create the WLAN config, connect to the WLAN, and make sure that s2 is
+  # unset as last_successful_bss_info if it is no longer available.
+  c.write_wlan_config('2.4', ssid, psk)
+  c.run_once()
+  wvtest.WVPASS(c.acs())
+  wvtest.WVPASS(c.internet())
+
+  c.s2_connect = False
+  c.delete_wlan_config('2.4')
+  c.run_once()
+  wvtest.WVPASSEQ(c.wifi_for_band('2.4').last_successful_bss_info, None)
+
+  # Now do the same, except this time s2 is connected to but doesn't provide ACS
+  # access.  This requires first re-establishing s2 as successful, so there are
+  # four steps:
+  #
+  # 1) Connect to WLAN.
+  # 2) Disconnect, reprovision via s2 (establishing it as successful).
+  # 3) Reconnect to WLAN so that we can trigger re-provisioning by
+  #    disconnecting.
+  # 4) Connect to s2 but get no ACS access; see that last_successful_bss_info is
+  #    unset.
+  c.write_wlan_config('2.4', ssid, psk)
+  c.run_once()
+  wvtest.WVPASS(c.acs())
+  wvtest.WVPASS(c.internet())
+
+  c.delete_wlan_config('2.4')
+  c.run_once()
+  wvtest.WVFAIL(c.wifi_for_band('2.4').acs())
+
+  c.s2_connect = True
+  # Give it time to try all BSSIDs.
+  for _ in range(3):
+    c.run_once()
+  s2_bss = iw.BssInfo('01:23:45:67:89:ab', 's2')
+  wvtest.WVPASSEQ(c.wifi_for_band('2.4').last_successful_bss_info, s2_bss)
+
+  c.s2_fail = True
+  c.write_wlan_config('2.4', ssid, psk)
+  c.run_once()
+  wvtest.WVPASS(c.acs())
+  wvtest.WVPASS(c.internet())
+
+  wvtest.WVPASSEQ(c.wifi_for_band('2.4').last_successful_bss_info, s2_bss)
+  c.delete_wlan_config('2.4')
+  # Run once so that c will reconnect to s2.
+  c.run_once()
+  # Now run until it sees the lack of ACS access.
+  c.run_until_interface_update()
+  wvtest.WVPASSEQ(c.wifi_for_band('2.4').last_successful_bss_info, None)
+
 
 @wvtest.wvtest
 @connection_manager_test(WIFI_SHOW_OUTPUT_ONE_RADIO)
@@ -754,7 +845,6 @@
   wvtest.WVPASS(c.wifi_for_band('5').current_route())
 
 
-
 @wvtest.wvtest
 @connection_manager_test(WIFI_SHOW_OUTPUT_ONE_RADIO_NO_5GHZ)
 def connection_manager_test_one_radio_no_5ghz(c):
diff --git a/conman/iw.py b/conman/iw.py
index f751302..973d653 100755
--- a/conman/iw.py
+++ b/conman/iw.py
@@ -34,7 +34,7 @@
 
   def __eq__(self, other):
     # pylint: disable=protected-access
-    return self.__attrs() == other.__attrs()
+    return isinstance(other, BssInfo) and self.__attrs() == other.__attrs()
 
   def __hash__(self):
     return hash(self.__attrs())
diff --git a/conman/main.py b/conman/main.py
index 3b81bfd..b6632a8 100755
--- a/conman/main.py
+++ b/conman/main.py
@@ -8,7 +8,7 @@
 
 import connection_manager
 
-STATUS_DIR = '/tmp/conman'
+TMP_DIR = '/tmp/conman'
 
 if __name__ == '__main__':
   loglevel = logging.INFO
@@ -20,8 +20,8 @@
   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)
+  if not os.path.exists(TMP_DIR):
+    os.makedirs(TMP_DIR)
 
-  c = connection_manager.ConnectionManager(status_dir=STATUS_DIR)
+  c = connection_manager.ConnectionManager(tmp_dir=TMP_DIR)
   c.run()
diff --git a/conman/status.py b/conman/status.py
new file mode 100644
index 0000000..118bafc
--- /dev/null
+++ b/conman/status.py
@@ -0,0 +1,179 @@
+#!/usr/bin/python
+
+"""Tracks and exports conman status information for e.g. ledmonitor."""
+
+# This may seem over-engineered, but conman has enough loosely-coupled moving
+# parts that it is worth being able to reason formally and separately about the
+# state of the system.  Otherwise it would be very easy for new conman code to
+# create subtle bugs in e.g. LED behavior.
+
+import inspect
+import logging
+import os
+
+
+class P(object):
+  """Enumerate propositions about conman status.
+
+  Using class attributes rather than just strings will help prevent typos.
+  """
+
+  TRYING_OPEN = 'TRYING_OPEN'
+  TRYING_WLAN = 'TRYING_WLAN'
+  CONNECTED_TO_OPEN = 'CONNECTED_TO_OPEN'
+  CONNECTED_TO_WLAN = 'CONNECTED_TO_WLAN'
+  HAVE_CONFIG = 'HAVE_CONFIG'
+  HAVE_WORKING_CONFIG = 'HAVE_WORKING_CONFIG'
+  CAN_REACH_ACS = 'CAN_REACH_ACS'
+  # Were we able to connect to the ACS last time we expected to be able to?
+  COULD_REACH_ACS = 'COULD_REACH_ACS'
+  CAN_REACH_INTERNET = 'CAN_REACH_INTERNET'
+  PROVISIONING_FAILED = 'PROVISIONING_FAILED'
+
+
+# Format:  { proposition: (implications, counter-implications), ... }
+# If you want to add a new proposition to the Status class, just edit this dict.
+IMPLICATIONS = {
+    P.TRYING_OPEN: (
+        (),
+        (P.CONNECTED_TO_OPEN, P.TRYING_WLAN, P.CONNECTED_TO_WLAN)
+    ),
+    P.TRYING_WLAN: (
+        (),
+        (P.TRYING_OPEN, P.CONNECTED_TO_OPEN, P.CONNECTED_TO_WLAN)
+    ),
+    P.CONNECTED_TO_OPEN: (
+        (),
+        (P.CONNECTED_TO_WLAN, P.TRYING_OPEN, P.TRYING_WLAN)
+    ),
+    P.CONNECTED_TO_WLAN: (
+        (P.HAVE_WORKING_CONFIG,),
+        (P.CONNECTED_TO_OPEN, P.TRYING_OPEN, P.TRYING_WLAN)
+    ),
+    P.CAN_REACH_ACS: (
+        (P.COULD_REACH_ACS,),
+        (P.TRYING_OPEN, P.TRYING_WLAN)
+    ),
+    P.COULD_REACH_ACS: (
+        (),
+        (P.PROVISIONING_FAILED,),
+    ),
+    P.PROVISIONING_FAILED: (
+        (),
+        (P.COULD_REACH_ACS,),
+    ),
+    P.HAVE_WORKING_CONFIG: (
+        (),
+        (P.HAVE_CONFIG,),
+    ),
+}
+
+
+class Proposition(object):
+  """Represents a proposition.
+
+  May imply truth or falsity of other propositions.
+  """
+
+  def __init__(self, name, export_path):
+    self._name = name
+    self._export_path = export_path
+    self._value = False
+    self._implications = set()
+    self._counter_implications = set()
+    self._impliers = set()
+    self._counter_impliers = set()
+
+  def implies(self, implication):
+    self._counter_implications.discard(implication)
+    self._implications.add(implication)
+    # pylint: disable=protected-access
+    implication._implied_by(self)
+
+  def implies_not(self, counter_implication):
+    self._implications.discard(counter_implication)
+    self._counter_implications.add(counter_implication)
+    # pylint: disable=protected-access
+    counter_implication._counter_implied_by(self)
+
+  def _implied_by(self, implier):
+    self._counter_impliers.discard(implier)
+    self._impliers.add(implier)
+
+  def _counter_implied_by(self, counter_implier):
+    self._impliers.discard(counter_implier)
+    self._counter_impliers.add(counter_implier)
+
+  def set(self, value):
+    if value == self._value:
+      return
+
+    self._value = value
+    self.export()
+    logging.debug('%s is now %s', self._name, self._value)
+
+    if value:
+      for implication in self._implications:
+        implication.set(True)
+      for counter_implication in self._counter_implications:
+        counter_implication.set(False)
+      # Contrapositive:  (A -> ~B) -> (B -> ~A)
+      for counter_implier in self._counter_impliers:
+        counter_implier.set(False)
+    # Contrapositive:  (A -> B) -> (~B -> ~A)
+    else:
+      for implier in self._impliers:
+        implier.set(False)
+
+  def export(self):
+    filepath = os.path.join(self._export_path, self._name)
+    if self._value:
+      if not os.path.exists(filepath):
+        open(filepath, 'w')
+    else:
+      if os.path.exists(filepath):
+        os.unlink(filepath)
+
+
+class Status(object):
+  """Provides a convenient API for conman to describe system status."""
+
+  def __init__(self, export_path):
+    if not os.path.isdir(export_path):
+      os.makedirs(export_path)
+
+    self._export_path = export_path
+
+    self._propositions = {
+        p: Proposition(p, self._export_path)
+        for p in dict(inspect.getmembers(P)) if not p.startswith('_')
+    }
+
+    for p, (implications, counter_implications) in IMPLICATIONS.iteritems():
+      for implication in implications:
+        self._propositions[p].implies(self._propositions[implication])
+      for counter_implication in counter_implications:
+        self._propositions[p].implies_not(
+            self._propositions[counter_implication])
+
+  def _proposition(self, p):
+    return self._propositions[p]
+
+  def __setattr__(self, attr, value):
+    """Allow setting of propositions with attributes.
+
+    If _propositions contains an attribute 'FOO', then `Status().foo = True`
+    will set that Proposition to True.  This means that this class doesn't have
+    to be changed when IMPLICATIONS is updated.
+
+    Args:
+      attr:  The attribute name.
+      value:  The attribute value.
+    """
+    if hasattr(self, '_propositions') and not hasattr(self, attr):
+      if attr.islower():
+        if attr.upper() in self._propositions:
+          self._propositions[attr.upper()].set(value)
+          return
+
+    super(Status, self).__setattr__(attr, value)
diff --git a/conman/status_test.py b/conman/status_test.py
new file mode 100755
index 0000000..03223f8
--- /dev/null
+++ b/conman/status_test.py
@@ -0,0 +1,122 @@
+#!/usr/bin/python
+
+"""Tests for connection_manager.py."""
+
+import logging
+import os
+import shutil
+import tempfile
+
+import status
+from wvtest import wvtest
+
+logging.basicConfig(level=logging.DEBUG)
+
+
+def file_in(path, filename):
+  return os.path.exists(os.path.join(path, filename))
+
+
+@wvtest.wvtest
+def test_proposition():
+  export_path = tempfile.mkdtemp()
+
+  try:
+    rain = status.Proposition('rain', export_path)
+    wet = status.Proposition('wet', export_path)
+    dry = status.Proposition('dry', export_path)
+
+    rain.implies(wet)
+    wet.implies_not(dry)
+
+    # Test basics.
+    rain.set(True)
+    wvtest.WVPASS(file_in(export_path, 'rain'))
+    wvtest.WVPASS(file_in(export_path, 'wet'))
+    wvtest.WVFAIL(file_in(export_path, 'dry'))
+
+    # It may be wet even if it is not raining, but even in that case it is still
+    # not dry.
+    rain.set(False)
+    wvtest.WVFAIL(file_in(export_path, 'rain'))
+    wvtest.WVPASS(file_in(export_path, 'wet'))
+    wvtest.WVFAIL(file_in(export_path, 'dry'))
+
+    # Test contrapositives.
+    dry.set(True)
+    wvtest.WVFAIL(file_in(export_path, 'rain'))
+    wvtest.WVFAIL(file_in(export_path, 'wet'))
+    wvtest.WVPASS(file_in(export_path, 'dry'))
+
+    # Make sure cycles are okay.
+    tautology = status.Proposition('tautology', export_path)
+    tautology.implies(tautology)
+    tautology.set(True)
+    wvtest.WVPASS(file_in(export_path, 'tautology'))
+
+    zig = status.Proposition('zig', export_path)
+    zag = status.Proposition('zag', export_path)
+    zig.implies(zag)
+    zag.implies(zig)
+    zig.set(True)
+    wvtest.WVPASS(file_in(export_path, 'zig'))
+    wvtest.WVPASS(file_in(export_path, 'zag'))
+    zag.set(False)
+    wvtest.WVFAIL(file_in(export_path, 'zig'))
+    wvtest.WVFAIL(file_in(export_path, 'zag'))
+
+  finally:
+    shutil.rmtree(export_path)
+
+
+@wvtest.wvtest
+def test_status():
+  export_path = tempfile.mkdtemp()
+
+  try:
+    s = status.Status(export_path)
+
+    # Sanity check that there are no contradictions.
+    for p, (want_true, want_false) in status.IMPLICATIONS.iteritems():
+      setattr(s, p.lower(), True)
+      wvtest.WVPASS(file_in(export_path, p))
+      for wt in want_true:
+        wvtest.WVPASS(file_in(export_path, wt))
+      for wf in want_false:
+        wvtest.WVFAIL(file_in(export_path, wf))
+
+    s.trying_wlan = True
+    wvtest.WVPASS(file_in(export_path, status.P.TRYING_WLAN))
+    wvtest.WVFAIL(file_in(export_path, status.P.CONNECTED_TO_WLAN))
+
+    s.connected_to_open = True
+    wvtest.WVPASS(file_in(export_path, status.P.CONNECTED_TO_OPEN))
+    wvtest.WVFAIL(file_in(export_path, status.P.CONNECTED_TO_WLAN))
+
+    s.connected_to_wlan = True
+    wvtest.WVPASS(file_in(export_path, status.P.CONNECTED_TO_WLAN))
+    wvtest.WVPASS(file_in(export_path, status.P.HAVE_WORKING_CONFIG))
+    wvtest.WVFAIL(file_in(export_path, status.P.CONNECTED_TO_OPEN))
+    wvtest.WVFAIL(file_in(export_path, status.P.TRYING_WLAN))
+    wvtest.WVFAIL(file_in(export_path, status.P.TRYING_OPEN))
+
+    s.can_reach_acs = True
+    s.can_reach_internet = True
+    wvtest.WVPASS(file_in(export_path, status.P.CAN_REACH_ACS))
+    wvtest.WVPASS(file_in(export_path, status.P.COULD_REACH_ACS))
+    wvtest.WVPASS(file_in(export_path, status.P.CAN_REACH_INTERNET))
+    wvtest.WVFAIL(file_in(export_path, status.P.PROVISIONING_FAILED))
+
+    # These should not have changed
+    wvtest.WVPASS(file_in(export_path, status.P.CONNECTED_TO_WLAN))
+    wvtest.WVPASS(file_in(export_path, status.P.HAVE_WORKING_CONFIG))
+    wvtest.WVFAIL(file_in(export_path, status.P.CONNECTED_TO_OPEN))
+    wvtest.WVFAIL(file_in(export_path, status.P.TRYING_WLAN))
+    wvtest.WVFAIL(file_in(export_path, status.P.TRYING_OPEN))
+
+  finally:
+    shutil.rmtree(export_path)
+
+
+if __name__ == '__main__':
+  wvtest.wvtest_main()
diff --git a/craftui/.gitignore b/craftui/.gitignore
new file mode 100644
index 0000000..f972e18
--- /dev/null
+++ b/craftui/.gitignore
@@ -0,0 +1,2 @@
+*.swp
+.started
diff --git a/craftui/HOW.restart_if_changed b/craftui/HOW.restart_if_changed
new file mode 100644
index 0000000..8c17154
--- /dev/null
+++ b/craftui/HOW.restart_if_changed
@@ -0,0 +1,36 @@
+#! /bin/sh
+
+# developer tool to restart server when file source changes
+
+pid=
+
+restart() {
+  [ -n "$pid" ] && kill $pid
+  echo "######################################################################"
+  echo "# starting craftui"
+  gpylint *.py
+  make test
+  ./craftui &
+  pid=$!
+  touch .started
+}
+
+onExit() {
+  [ -n "$pid" ] && kill $pid
+  exit 1
+}
+
+trap onExit 1 2 3
+restart
+
+while sleep 1; do
+  if ! kill -0 $pid; then
+    restart
+    continue
+  fi
+  f=$(find . -name '*.swp' -prune -o -type f -newer .started -print)
+  if [ -n "$f" ]; then
+    restart
+    continue
+  fi
+done
diff --git a/craftui/Makefile b/craftui/Makefile
new file mode 100644
index 0000000..9f41971
--- /dev/null
+++ b/craftui/Makefile
@@ -0,0 +1,30 @@
+default:
+
+PREFIX=/
+BINDIR=$(DESTDIR)$(PREFIX)/bin
+WWWDIR=$(DESTDIR)$(PREFIX)/usr/craftui
+PYTHON?=python
+
+all:
+
+install:
+	mkdir -p $(BINDIR) $(WWWDIR)
+	cp craftui craftui.py $(BINDIR)
+	cp -rp www $(WWWDIR)
+
+install-libs:
+	@echo "No libs to install."
+
+test: lint
+	set -e; \
+	for n in $(wildcard ./*_test.*); do \
+		echo; \
+		echo "Testing $$n"; \
+		$$n; \
+	done
+
+clean:
+	rm -rf *.pyc
+
+lint:
+	for n in *.py; do gpylint $$n || exit 1; done
diff --git a/craftui/craftui b/craftui/craftui
new file mode 100755
index 0000000..2f5e143
--- /dev/null
+++ b/craftui/craftui
@@ -0,0 +1,32 @@
+#! /bin/sh
+
+pycode=/bin/craftui.py
+cw=/usr/catawampus
+devcw=../../../../vendor/google/catawampus
+localwww=./www
+
+# in developer environment if vendor/google/catawapus is above us
+if [ -d "$devcw" ]; then
+  isdev=1
+fi
+
+# if running from developer desktop, use simulated data
+if [ "$isdev" = 1 ]; then
+  cw="$devcw"
+  args="$args --port=8888 --sim=./sim"
+  pycode=./craftui_fortesting.py
+  export PATH="$PWD/sim/bin:$PATH"
+fi
+
+# for debugging on the device, use the local (/tmp/www?) web tree
+if [ -d "$localwww" ]; then
+  args="$args --www=$localwww"
+fi
+
+# enable debugger
+if [ "$1" = -d ]; then
+  debug="-m pdb"
+fi
+
+export PYTHONPATH="$cw/tr/vendor/tornado:$PYTHONPATH"
+exec python -u $debug $pycode $args
diff --git a/craftui/craftui.py b/craftui/craftui.py
new file mode 100755
index 0000000..e39faf1
--- /dev/null
+++ b/craftui/craftui.py
@@ -0,0 +1,550 @@
+#! /usr/bin/python
+# Copyright 2016 Google Inc. All Rights Reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Chimera craft UI.  Code lifted from catawampus diag and tech UI."""
+
+__author__ = 'edjames@google.com (Ed James)'
+
+import getopt
+import json
+import os
+import re
+import subprocess
+import sys
+import urllib2
+import tornado.ioloop
+import tornado.web
+
+
+class ConfigError(Exception):
+  """Configuration errors to pass to browser."""
+
+  def __init__(self, message):
+    super(ConfigError, self).__init__(message)
+
+
+class Validator(object):
+  """Validate the user value and convert to safe config value."""
+  pattern = r'^(.*)$'
+  example = 'any string'
+
+  def __init__(self):
+    self.Reset()
+
+  def Reset(self):
+    self.fields = ()
+    self.config = ''
+
+  def Validate(self, value):
+    self.Reset()
+    self.value = value
+    m = re.search(self.pattern, value)
+    if not m:
+      raise ConfigError('value "%s" does not match pattern "%s", eg: "%s"' %
+                        (value, self.pattern, self.example))
+    self.fields = m.groups()
+    self.config = self.fields[0]
+
+
+class VInt(Validator):
+  """Validate as integer."""
+  pattern = r'^(\d+)$'
+  example = '123'
+
+
+class VRange(VInt):
+  """Validate as integer in a range."""
+
+  def __init__(self, low, high):
+    super(VRange, self).__init__()
+    self.low = low
+    self.high = high
+
+  def Validate(self, value):
+    super(VRange, self).Validate(value)
+    self.CheckInRange(int(self.config), self.low, self.high)
+
+  @staticmethod
+  def CheckInRange(num, low, high):
+    if num < low or num > high:
+      raise ConfigError('number %d is out of range %d-%d' % (num, low, high))
+
+
+class VSlash(Validator):
+  """Validate as slash notation (eg 192.168.1.1/24)."""
+  pattern = r'^((\d+).(\d+).(\d+).(\d+)/(\d+))$'
+  example = '192.168.1.1/24'
+
+  def __init__(self):
+    super(VSlash, self).__init__()
+
+  def Validate(self, value):
+    super(VSlash, self).Validate(value)
+    mask = int(self.fields[5])
+    VRange.CheckInRange(mask, 0, 32)
+    for dotted_quad_part in self.fields[1:4]:
+      num = int(dotted_quad_part)
+      VRange.CheckInRange(num, 0, 255)
+
+
+class VVlan(VRange):
+  """Validate as vlan."""
+
+  def __init__(self):
+    super(VVlan, self).__init__(0, 4095)
+
+
+class VFreqHi(VRange):
+  """Validate as Hi E-Band frequency."""
+
+  def __init__(self):
+    super(VFreqHi, self).__init__(82000000, 85000000)
+
+
+class VFreqLo(VRange):
+  """Validate as Low E-Band frequency."""
+
+  def __init__(self):
+    super(VFreqLo, self).__init__(72000000, 75000000)
+
+
+class VPower(VRange):
+  """Validate as PA power level."""
+
+  def __init__(self):
+    super(VPower, self).__init__(0, 2000000)       # TODO(edjames)
+
+
+class VDict(Validator):
+  """Validate as member of dict."""
+  dict = {}
+
+  def Validate(self, value):
+    super(VDict, self).Validate(value)
+    if value not in self.dict:
+      keys = self.dict.keys()
+      raise ConfigError('value "%s" must be one of "%s"' % (value, keys))
+    self.config = self.dict[value]
+
+
+class VTx(VDict):
+  """Validate: tx/rx."""
+  dict = {'tx': 'tx', 'rx': 'rx'}
+
+
+class VTrueFalse(VDict):
+  """Validate as true or false."""
+  dict = {'true': 'true', 'false': 'false'}
+
+
+class Config(object):
+  """Configure the device after validation."""
+
+  def __init__(self, validator):
+    self.validator = validator()
+
+  def Validate(self, value):
+    self.validator.Validate(value)
+
+  def Configure(self):
+    raise Exception('override Config.Configure')
+
+  @staticmethod
+  def Run(command):
+    """Run a command."""
+    print 'running: %s' % command
+    try:
+      subprocess.check_output(command)
+    except subprocess.CalledProcessError as e:
+      print 'Run: ', str(e)
+      raise ConfigError('command failed with %d' % e.returncode)
+
+
+class PtpConfig(Config):
+  """Configure using ptp-config."""
+
+  def __init__(self, validator, key):
+    super(PtpConfig, self).__init__(validator)
+    self.key = key
+
+  def Configure(self):
+    Config.Run(['ptp-config', '-s', self.key, self.validator.config])
+
+
+class PtpActivate(Config):
+  """Configure using ptp-config."""
+
+  def __init__(self, validator, key):
+    super(PtpActivate, self).__init__(validator)
+    self.key = key
+
+  def Configure(self):
+    Config.Run(['ptp-config', '-i', self.key])
+
+
+class Glaukus(Config):
+  """Configure using glaukus json api."""
+
+  def __init__(self, validator, api, fmt):
+    super(Glaukus, self).__init__(validator)
+    self.api = api
+    self.fmt = fmt
+
+  def Configure(self):
+    """Handle a JSON request to glaukusd."""
+    url = 'http://localhost:8080' + self.api
+    payload = self.fmt % self.validator.config
+    # TODO(edjames)
+    print 'Glaukus: ', url, payload
+    try:
+      fd = urllib2.urlopen(url, payload)
+    except urllib2.URLError as ex:
+      print 'Connection to %s failed: %s' % (url, ex.reason)
+      raise ConfigError('failed to contact glaukus')
+    response = fd.read()
+    j = json.loads(response)
+    print j
+    if j['code'] != 'SUCCESS':
+      if j['message']:
+        raise ConfigError(j.message)
+      raise ConfigError('failed to configure glaukus')
+
+
+class Reboot(Config):
+  """Reboot."""
+
+  def Configure(self):
+    if self.validator.config == 'true':
+      Config.Run(['reboot'])
+
+
+class CraftUI(object):
+  """A web server that configures and displays Chimera data."""
+
+  handlers = {
+      'craft_ipaddr': PtpConfig(VSlash, 'craft_ipaddr'),
+      'link_ipaddr': PtpConfig(VSlash, 'local_ipaddr'),
+      'peer_ipaddr': PtpConfig(VSlash, 'peer_ipaddr'),
+
+      'vlan_inband': PtpConfig(VVlan, 'vlan_inband'),
+      'vlan_peer': PtpConfig(VVlan, 'vlan_peer'),
+
+      'craft_ipaddr_activate': PtpActivate(VTrueFalse, 'craft_ipaddr'),
+      'link_ipaddr_activate': PtpActivate(VTrueFalse, 'local_ipaddr'),
+      'peer_ipaddr_activate': PtpActivate(VTrueFalse, 'peer_ipaddr'),
+      'vlan_inband_activate': PtpActivate(VTrueFalse, 'vlan_inband'),
+      'vlan_peer_activate': PtpActivate(VTrueFalse, 'vlan_peer'),
+
+      'freq_hi': Glaukus(VFreqHi, '/api/radio/frequency', '{"hiFrequency":%s}'),
+      'freq_lo': Glaukus(VFreqLo, '/api/radio/frequency', '{"loFrequency":%s}'),
+      'mode_hi': Glaukus(VTx, '/api/radio/hiTransceiver/mode', '%s'),
+      'tx_powerlevel': Glaukus(VPower, '/api/radio/tx/paPowerSet', '%s'),
+      'tx_on': Glaukus(VTrueFalse, '/api/radio/paLnaPowerEnabled', '%s'),
+
+      'reboot': Reboot(VTrueFalse)
+  }
+  ifmap = {
+      'craft0': 'craft',
+      'eth1.inband': 'inband',
+      'eth1.peer': 'link',
+      'br0': 'poe'
+  }
+  ifvlan = [
+      'eth1.inband',
+      'eth1.peer'
+  ]
+  stats = [
+      'multicast',
+      'collisions',
+      'rx_bytes',
+      'rx_packets',
+      'rx_errors',
+      'rx_dropped',
+      'tx_bytes',
+      'tx_packets',
+      'tx_errors',
+      'tx_dropped'
+  ]
+
+  def __init__(self, wwwroot, port, sim):
+    """initialize."""
+    self.wwwroot = wwwroot
+    self.port = port
+    self.sim = sim
+    self.data = {}
+    self.data['refreshCount'] = 0
+
+  def ApplyChanges(self, changes):
+    """Apply changes to system."""
+    if 'config' not in changes:
+      raise ConfigError('missing required config array')
+    conf = changes['config']
+    try:
+      # dry run to validate all
+      for c in conf:
+        for k, v in c.items():
+          if k not in self.handlers:
+            raise ConfigError('unknown key "%s"' % k)
+          h = self.handlers[k]
+          h.Validate(v)
+      # do it again for real
+      for c in conf:
+        for k, v in c.items():
+          h = self.handlers[k]
+          h.Validate(v)
+          h.Configure()
+    except ConfigError as e:
+      raise ConfigError('key "%s": %s' % (k, e))
+
+  def ReadFile(self, filepath):
+    """cat file."""
+    text = ''
+    try:
+      with open(filepath) as fd:
+        text = fd.read().rstrip()
+    except IOError as e:
+      text = 'ReadFile failed: %s: %s' % (filepath, e.strerror)
+
+    return text
+
+  def GetData(self):
+    """Get system data, return a json string."""
+    pj = self.GetPlatformData()
+    mj = self.GetModemData()
+    rj = self.GetRadioData()
+    js = '{"platform":' + pj + ',"modem":' + mj + ',"radio":' + rj + '}'
+    return js
+
+  def AddIpAddr(self, data):
+    """Run ip addr and parse results."""
+    ipaddr = ''
+    try:
+      ipaddr = subprocess.check_output(['ip', '-o', 'addr'])
+    except subprocess.CalledProcessError as e:
+      print 'warning: "ip -o addr" failed: ', e
+    v = {}
+    for line in ipaddr.splitlines():
+      f = line.split()
+      m = re.search(r'scope (global|link)', line)
+      scope = m.group(1) if m else 'noscope'
+      v[f[1] + ':' + f[2] + ':' + scope] = f[3]
+    for ifname, uiname in self.ifmap.items():
+      for inet in ('inet', 'inet6'):
+        kglobal = ifname + ':' + inet + ':' + 'global'
+        vdata = v.get(kglobal, 'unknown')
+        kdata = 'active_' + uiname + '_' + inet
+        data[kdata] = vdata
+
+  def AddInterfaceStats(self, data):
+    """Get if stats."""
+    for ifname, uiname in self.ifmap.items():
+      d = self.sim + '/sys/class/net/' + ifname + '/statistics/'
+      for stat in self.stats:
+        k = uiname + '_' + stat
+        data[k] = self.ReadFile(d + stat)
+
+  def AddVlans(self, data):
+    """Run ip -d link and parse results for vlans."""
+    iplink = ''
+    try:
+      iplink = subprocess.check_output(['ip', '-o', '-d', 'link'])
+    except subprocess.CalledProcessError as e:
+      print 'warning: "ip -o -d link" failed: ', e
+    v = {}
+    for line in iplink.splitlines():
+      m = re.search(r'^\d+: ([\w\.]+)@\w+: .* vlan id (\w+)', line)
+      if m:
+        v[m.group(1)] = m.group(2)
+    for ifname in self.ifvlan:
+      uiname = self.ifmap[ifname]
+      vdata = v.get(ifname, 'unknown')
+      kdata = 'active_' + uiname + '_vlan'
+      data[kdata] = vdata
+
+  def GetPlatformData(self):
+    """Get platform data, return a json string."""
+    data = self.data
+    sim = self.sim
+
+    if data['refreshCount'] == 0:
+      data['serialno'] = self.ReadFile(sim + '/etc/serial')
+      data['version'] = self.ReadFile(sim + '/etc/version')
+      data['platform'] = self.ReadFile(sim + '/etc/platform')
+      data['softwaredate'] = self.ReadFile(sim + '/etc/softwaredate')
+    data['refreshCount'] += 1
+    data['uptime'] = self.ReadFile(sim + '/proc/uptime')
+    data['ledstate'] = self.ReadFile(sim + '/tmp/gpio/ledstate')
+    cs = '/config/settings/'
+    data['craft_ipaddr'] = self.ReadFile(sim + cs + 'craft_ipaddr')
+    data['link_ipaddr'] = self.ReadFile(sim + cs + 'local_ipaddr')
+    data['peer_ipaddr'] = self.ReadFile(sim + cs + 'peer_ipaddr')
+    data['vlan_inband'] = self.ReadFile(sim + cs + 'vlan_inband')
+    data['vlan_link'] = self.ReadFile(sim + cs + 'vlan_peer')
+    self.AddIpAddr(data)
+    self.AddInterfaceStats(data)
+    self.AddVlans(data)
+    return json.dumps(data)
+
+  def GetModemData(self):
+    """Get modem data, return a json string."""
+    response = '{}'
+    if self.sim:
+      response = self.ReadFile(self.sim + '/tmp/glaukus/modem.json')
+    else:
+      try:
+        url = 'http://localhost:8080/api/modem'
+        handle = urllib2.urlopen(url, timeout=2)
+        response = handle.read()
+      except urllib2.URLError as ex:
+        print 'Connection to %s failed: %s' % (url, ex.reason)
+    return response
+
+  def GetRadioData(self):
+    """Get radio data, return a json string."""
+    response = '{}'
+    if self.sim:
+      response = self.ReadFile(self.sim + '/tmp/glaukus/radio.json')
+    else:
+      try:
+        url = 'http://localhost:8080/api/radio'
+        handle = urllib2.urlopen(url, timeout=2)
+        response = handle.read()
+      except urllib2.URLError as ex:
+        print 'Connection to %s failed: %s' % (url, ex.reason)
+    return response
+
+  class MainHandler(tornado.web.RequestHandler):
+    """Displays the Craft UI."""
+
+    def get(self):
+      ui = self.settings['ui']
+      print 'GET craft HTML page'
+      self.render(ui.wwwroot + '/index.thtml', peerurl='/?peer=1')
+
+  class ConfigHandler(tornado.web.RequestHandler):
+    """Displays the Config page."""
+
+    def get(self):
+      ui = self.settings['ui']
+      print 'GET config HTML page'
+      self.render(ui.wwwroot + '/config.thtml', peerurl='/config/?peer=1')
+
+  class RestartHandler(tornado.web.RequestHandler):
+    """Restart the box."""
+
+    def get(self):
+      print 'displaying restart interstitial screen'
+      self.render('restarting.html')
+
+    def post(self):
+      print 'user requested restart'
+      self.redirect('/restart')
+      os.system('(sleep 5; reboot) &')
+
+  class JsonHandler(tornado.web.RequestHandler):
+    """Provides JSON-formatted content to be displayed in the UI."""
+
+    @tornado.web.asynchronous
+    def get(self):
+      ui = self.settings['ui']
+      print 'GET JSON data for craft page'
+      jsonstring = ui.GetData()
+      self.set_header('Content-Type', 'application/json')
+      self.write(jsonstring)
+      self.finish()
+
+    def post(self):
+      print 'POST JSON data for craft page'
+      request = self.request.body
+      result = {}
+      result['error'] = 0
+      result['errorstring'] = ''
+      try:
+        try:
+          json_args = json.loads(request)
+          request = json.dumps(json_args)
+        except ValueError as e:
+          print e
+          raise ConfigError('json format error')
+        ui = self.settings['ui']
+        ui.ApplyChanges(json_args)
+      except ConfigError as e:
+        print e
+        result['error'] += 1
+        result['errorstring'] += str(e)
+
+      response = json.dumps(result)
+      print 'request: ', request
+      print 'response: ', response
+      self.set_header('Content-Type', 'application/json')
+      self.write(response)
+      self.finish()
+
+  def RunUI(self):
+    """Create the web server and run forever."""
+    handlers = [
+        (r'/', self.MainHandler),
+        (r'/config', self.ConfigHandler),
+        (r'/content.json', self.JsonHandler),
+        (r'/restart', self.RestartHandler),
+        (r'/static/([^/]*)$', tornado.web.StaticFileHandler,
+         {'path': self.wwwroot + '/static'}),
+    ]
+    app = tornado.web.Application(handlers)
+    app.settings['ui'] = self
+    app.listen(self.port)
+    ioloop = tornado.ioloop.IOLoop.instance()
+    ioloop.start()
+
+
+def Usage():
+  """Show usage."""
+  print 'Usage: % [-p)ort 80] [-d)ir web] [-s)im top]'
+  print '\tUse -s to provide an alternate rootfs'
+
+
+def main():
+  www = '/usr/craftui/www'
+  port = 80
+  sim = ''
+  try:
+    opts, args = getopt.getopt(sys.argv[1:], 's:p:w:',
+                               ['sim=', 'port=', 'www='])
+  except getopt.GetoptError as err:
+    # print help information and exit:
+    print str(err)
+    Usage()
+    sys.exit(1)
+  for o, a in opts:
+    if o in ('-s', '--sim'):
+      sim = a
+    elif o in ('-p', '--port'):
+      port = int(a)
+    elif o in ('-w', '--www'):
+      www = a
+    else:
+      assert False, 'unhandled option'
+      Usage()
+      sys.exit(1)
+  if args:
+    assert False, 'extra args'
+    Usage()
+    sys.exit(1)
+  craftui = CraftUI(www, port, sim)
+  craftui.RunUI()
+
+
+if __name__ == '__main__':
+  main()
diff --git a/craftui/craftui_fortesting.py b/craftui/craftui_fortesting.py
new file mode 100644
index 0000000..82bf147
--- /dev/null
+++ b/craftui/craftui_fortesting.py
@@ -0,0 +1,30 @@
+#!/usr/bin/python
+# Copyright 2016 Google Inc. All Rights Reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Tests for craftui."""
+
+__author__ = 'edjames@google.com (Ed James)'
+
+import traceback
+import craftui
+
+if __name__ == '__main__':
+  try:
+    craftui.main()
+  # pylint: disable=broad-except
+  except Exception as e:
+    traceback.print_exc()
+    # exit cleanly to close the socket so next listen doesn't fail with in-use
+    exit(1)
diff --git a/craftui/craftui_test.sh b/craftui/craftui_test.sh
new file mode 100755
index 0000000..9147945
--- /dev/null
+++ b/craftui/craftui_test.sh
@@ -0,0 +1,107 @@
+#! /bin/sh
+
+# some unit tests for the craft UI
+
+# save stdout to 3, dup stdout to a file
+log=.testlog.$$
+exec 3>&1
+exec >$log 2>&1
+
+failcount=0
+passcount=0
+
+fail() {
+	echo "FAIL: $*" >&3
+	echo "FAIL: $*"
+	((failcount++))
+}
+
+pass() {
+	echo "PASS: $*" >&3
+	echo "PASS: $*"
+	((passcount++))
+}
+
+testname() {
+	test="$*"
+	echo "---------------------------------------------------------"
+	echo "starting test $test"
+}
+
+check_success() {
+	status=$?
+	echo "check_success: last return code was $status, wanted 0"
+	if [ $status = 0 ]; then
+		pass $test
+	else
+		fail $test
+	fi
+}
+
+check_failure() {
+	status=$?
+	echo "check_failure: last return code was $status, wanted not-0"
+	if [ $status != 0 ]; then
+		pass $test
+	else
+		fail $test
+	fi
+}
+
+onexit() {
+	testname "process running at exit"
+	kill -0 $pid
+	check_success
+
+	# cleanup
+	kill -9 $pid
+
+	exec 1>&3
+	echo "SUMMARY: pass=$passcount fail=$failcount"
+	if [ $failcount -eq 0 ]; then
+		echo "SUCCESS: $passcount tests passed."
+	else
+		echo "FAILURE: $failcount tests failed."
+		echo "details follow:"
+		cat $log
+	fi
+	rm -f $log
+
+	exit $failcount
+}
+
+trap onexit 0 1 2 3
+
+testname "server not running"
+curl -s http://localhost:8888/
+check_failure
+
+./craftui > /tmp/LOG 2>&1 &
+pid=$!
+
+testname "process running"
+kill -0 $pid
+check_success
+
+sleep 1
+
+testname true
+true
+check_success
+
+testname false
+false
+check_failure
+
+testname "main web page"
+curl -s http://localhost:8888/ > /dev/null
+check_success
+
+testname "404 not found"
+curl -s http://localhost:8888/notexist | grep '404: Not Found'
+check_success
+
+testname "json"
+curl -s http://localhost:8888/content.json | grep '"platform": "GFCH100"'
+check_success
+
diff --git a/craftui/sim/bin/ip b/craftui/sim/bin/ip
new file mode 100755
index 0000000..0775940
--- /dev/null
+++ b/craftui/sim/bin/ip
@@ -0,0 +1,35 @@
+#! /bin/sh
+
+if [ "$3" = link ]; then
+  cat << EOF
+1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN \	 link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
+2: craft0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc mq state UP qlen 532\    link/ether f4:f5:e8:01:a1:01 brd ff:ff:ff:ff:ff:ff
+3: eth1: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc mq master br0 state UP qlen 532\    link/ether f4:f5:e8:01:a1:02 brd ff:ff:ff:ff:ff:ff
+4: eth1.inband@eth1: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state UP qlen 1000\    link/ether f4:f5:e8:01:a1:02 brd ff:ff:ff:ff:ff:ff\    vlan id 4090 <REORDER_HDR> 
+5: eth1.peer@eth1: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state UP qlen 1000\	   link/ether f4:f5:e8:01:a1:02 brd ff:ff:ff:ff:ff:ff\	  vlan id 2000 <REORDER_HDR> 
+6: br0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state UP qlen 1000\	link/ether f4:f5:e8:01:a1:02 brd ff:ff:ff:ff:ff:ff\    bridge 
+EOF
+
+else
+  cat << EOF
+1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN \    link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
+1: lo    inet 127.0.0.1/32 scope host lo\       valid_lft forever preferred_lft forever
+1: lo    inet 127.0.0.1/8 scope host lo\       valid_lft forever preferred_lft forever
+1: lo    inet6 ::1/128 scope host \       valid_lft forever preferred_lft forever
+2: craft0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc mq state UP qlen 532\    link/ether f4:f5:e8:01:a1:01 brd ff:ff:ff:ff:ff:ff
+2: craft0    inet 192.168.5.99/24 scope global craft0\       valid_lft forever preferred_lft forever
+2: craft0    inet6 fe80::f6f5:e8ff:fe01:a101/64 scope link \       valid_lft forever preferred_lft forever
+3: eth1: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc mq master br0 state UP qlen 532\    link/ether f4:f5:e8:01:a1:02 brd ff:ff:ff:ff:ff:ff
+3: eth1    inet6 fe80::f6f5:e8ff:fe01:a102/64 scope link \       valid_lft forever preferred_lft forever
+4: eth1.inband@eth1: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state UP qlen 1000\    link/ether f4:f5:e8:01:a1:02 brd ff:ff:ff:ff:ff:ff
+4: eth1.inband    inet6 fe80::f6f5:e8ff:fe01:a102/64 scope link \       valid_lft forever preferred_lft forever
+5: eth1.peer@eth1: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state UP qlen 1000\    link/ether f4:f5:e8:01:a1:02 brd ff:ff:ff:ff:ff:ff
+5: eth1.peer    inet 192.168.2.1/24 scope global eth1.peer\       valid_lft forever preferred_lft forever
+5: eth1.peer    inet6 fe80::f6f5:e8ff:fe01:a102/64 scope link \       valid_lft forever preferred_lft forever
+6: br0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state UP qlen 1000\    link/ether f4:f5:e8:01:a1:02 brd ff:ff:ff:ff:ff:ff
+6: br0    inet 192.168.1.147/24 brd 192.168.1.255 scope global br0\       valid_lft forever preferred_lft forever
+6: br0    inet6 2605:a601:b17:d600:f6f5:e8ff:fe01:a102/64 scope global dynamic \       valid_lft 3595sec preferred_lft 3595sec
+6: br0    inet6 fe80::f6f5:e8ff:fe01:a102/64 scope link \       valid_lft forever preferred_lft forever
+EOF
+
+fi
diff --git a/craftui/sim/bin/ptp-config b/craftui/sim/bin/ptp-config
new file mode 100755
index 0000000..4031019
--- /dev/null
+++ b/craftui/sim/bin/ptp-config
@@ -0,0 +1,3 @@
+#! /bin/sh
+
+echo "TODO: $0 $*"
diff --git a/craftui/sim/bin/reboot b/craftui/sim/bin/reboot
new file mode 100755
index 0000000..60ee746
--- /dev/null
+++ b/craftui/sim/bin/reboot
@@ -0,0 +1,3 @@
+#! /bin/sh
+
+echo running: $0 $*
diff --git a/craftui/sim/config/settings/craft_ipaddr b/craftui/sim/config/settings/craft_ipaddr
new file mode 100644
index 0000000..a1f2875
--- /dev/null
+++ b/craftui/sim/config/settings/craft_ipaddr
@@ -0,0 +1 @@
+192.168.5.99/24
diff --git a/craftui/sim/config/settings/local_ipaddr b/craftui/sim/config/settings/local_ipaddr
new file mode 100644
index 0000000..6f5d511
--- /dev/null
+++ b/craftui/sim/config/settings/local_ipaddr
@@ -0,0 +1 @@
+192.168.2.1/24
diff --git a/craftui/sim/config/settings/peer_ipaddr b/craftui/sim/config/settings/peer_ipaddr
new file mode 100644
index 0000000..85be23f
--- /dev/null
+++ b/craftui/sim/config/settings/peer_ipaddr
@@ -0,0 +1 @@
+192.168.2.2/24
diff --git a/craftui/sim/config/settings/vlan_inband b/craftui/sim/config/settings/vlan_inband
new file mode 100644
index 0000000..6b61c08
--- /dev/null
+++ b/craftui/sim/config/settings/vlan_inband
@@ -0,0 +1 @@
+4090
diff --git a/craftui/sim/config/settings/vlan_peer b/craftui/sim/config/settings/vlan_peer
new file mode 100644
index 0000000..8bd1af1
--- /dev/null
+++ b/craftui/sim/config/settings/vlan_peer
@@ -0,0 +1 @@
+2000
diff --git a/craftui/sim/etc/platform b/craftui/sim/etc/platform
new file mode 100644
index 0000000..91bb929
--- /dev/null
+++ b/craftui/sim/etc/platform
@@ -0,0 +1 @@
+GFCH100
diff --git a/craftui/sim/etc/serial b/craftui/sim/etc/serial
new file mode 100644
index 0000000..72a6d63
--- /dev/null
+++ b/craftui/sim/etc/serial
@@ -0,0 +1 @@
+NPAPID1611E0001
diff --git a/craftui/sim/etc/softwaredate b/craftui/sim/etc/softwaredate
new file mode 100644
index 0000000..8bdcf18
--- /dev/null
+++ b/craftui/sim/etc/softwaredate
@@ -0,0 +1,2 @@
+1457565485
+2016-03-09 15:18:05 -0800
diff --git a/craftui/sim/etc/version b/craftui/sim/etc/version
new file mode 100644
index 0000000..67debcc
--- /dev/null
+++ b/craftui/sim/etc/version
@@ -0,0 +1 @@
+gfch100-47-pre5-44951-g1e8917f-ed
diff --git a/craftui/sim/proc/uptime b/craftui/sim/proc/uptime
new file mode 100644
index 0000000..47bea91
--- /dev/null
+++ b/craftui/sim/proc/uptime
@@ -0,0 +1 @@
+86648.37 85251.17
diff --git a/craftui/sim/sys/class/net/br0/statistics/collisions b/craftui/sim/sys/class/net/br0/statistics/collisions
new file mode 100644
index 0000000..573541a
--- /dev/null
+++ b/craftui/sim/sys/class/net/br0/statistics/collisions
@@ -0,0 +1 @@
+0
diff --git a/craftui/sim/sys/class/net/br0/statistics/multicast b/craftui/sim/sys/class/net/br0/statistics/multicast
new file mode 100644
index 0000000..573541a
--- /dev/null
+++ b/craftui/sim/sys/class/net/br0/statistics/multicast
@@ -0,0 +1 @@
+0
diff --git a/craftui/sim/sys/class/net/br0/statistics/rx_bytes b/craftui/sim/sys/class/net/br0/statistics/rx_bytes
new file mode 100644
index 0000000..08e3da2
--- /dev/null
+++ b/craftui/sim/sys/class/net/br0/statistics/rx_bytes
@@ -0,0 +1 @@
+3519029
diff --git a/craftui/sim/sys/class/net/br0/statistics/rx_dropped b/craftui/sim/sys/class/net/br0/statistics/rx_dropped
new file mode 100644
index 0000000..573541a
--- /dev/null
+++ b/craftui/sim/sys/class/net/br0/statistics/rx_dropped
@@ -0,0 +1 @@
+0
diff --git a/craftui/sim/sys/class/net/br0/statistics/rx_errors b/craftui/sim/sys/class/net/br0/statistics/rx_errors
new file mode 100644
index 0000000..573541a
--- /dev/null
+++ b/craftui/sim/sys/class/net/br0/statistics/rx_errors
@@ -0,0 +1 @@
+0
diff --git a/craftui/sim/sys/class/net/br0/statistics/rx_packets b/craftui/sim/sys/class/net/br0/statistics/rx_packets
new file mode 100644
index 0000000..9c4044d
--- /dev/null
+++ b/craftui/sim/sys/class/net/br0/statistics/rx_packets
@@ -0,0 +1 @@
+13252
diff --git a/craftui/sim/sys/class/net/br0/statistics/tx_bytes b/craftui/sim/sys/class/net/br0/statistics/tx_bytes
new file mode 100644
index 0000000..3365165
--- /dev/null
+++ b/craftui/sim/sys/class/net/br0/statistics/tx_bytes
@@ -0,0 +1 @@
+2378483
diff --git a/craftui/sim/sys/class/net/br0/statistics/tx_dropped b/craftui/sim/sys/class/net/br0/statistics/tx_dropped
new file mode 100644
index 0000000..573541a
--- /dev/null
+++ b/craftui/sim/sys/class/net/br0/statistics/tx_dropped
@@ -0,0 +1 @@
+0
diff --git a/craftui/sim/sys/class/net/br0/statistics/tx_errors b/craftui/sim/sys/class/net/br0/statistics/tx_errors
new file mode 100644
index 0000000..573541a
--- /dev/null
+++ b/craftui/sim/sys/class/net/br0/statistics/tx_errors
@@ -0,0 +1 @@
+0
diff --git a/craftui/sim/sys/class/net/br0/statistics/tx_packets b/craftui/sim/sys/class/net/br0/statistics/tx_packets
new file mode 100644
index 0000000..bad2b41
--- /dev/null
+++ b/craftui/sim/sys/class/net/br0/statistics/tx_packets
@@ -0,0 +1 @@
+5530
diff --git a/craftui/sim/sys/class/net/craft0/statistics/collisions b/craftui/sim/sys/class/net/craft0/statistics/collisions
new file mode 100644
index 0000000..573541a
--- /dev/null
+++ b/craftui/sim/sys/class/net/craft0/statistics/collisions
@@ -0,0 +1 @@
+0
diff --git a/craftui/sim/sys/class/net/craft0/statistics/multicast b/craftui/sim/sys/class/net/craft0/statistics/multicast
new file mode 100644
index 0000000..573541a
--- /dev/null
+++ b/craftui/sim/sys/class/net/craft0/statistics/multicast
@@ -0,0 +1 @@
+0
diff --git a/craftui/sim/sys/class/net/craft0/statistics/rx_bytes b/craftui/sim/sys/class/net/craft0/statistics/rx_bytes
new file mode 100644
index 0000000..84f168f
--- /dev/null
+++ b/craftui/sim/sys/class/net/craft0/statistics/rx_bytes
@@ -0,0 +1 @@
+3008350
diff --git a/craftui/sim/sys/class/net/craft0/statistics/rx_dropped b/craftui/sim/sys/class/net/craft0/statistics/rx_dropped
new file mode 100644
index 0000000..573541a
--- /dev/null
+++ b/craftui/sim/sys/class/net/craft0/statistics/rx_dropped
@@ -0,0 +1 @@
+0
diff --git a/craftui/sim/sys/class/net/craft0/statistics/rx_errors b/craftui/sim/sys/class/net/craft0/statistics/rx_errors
new file mode 100644
index 0000000..573541a
--- /dev/null
+++ b/craftui/sim/sys/class/net/craft0/statistics/rx_errors
@@ -0,0 +1 @@
+0
diff --git a/craftui/sim/sys/class/net/craft0/statistics/rx_packets b/craftui/sim/sys/class/net/craft0/statistics/rx_packets
new file mode 100644
index 0000000..6fc6a64
--- /dev/null
+++ b/craftui/sim/sys/class/net/craft0/statistics/rx_packets
@@ -0,0 +1 @@
+18701
diff --git a/craftui/sim/sys/class/net/craft0/statistics/tx_bytes b/craftui/sim/sys/class/net/craft0/statistics/tx_bytes
new file mode 100644
index 0000000..924221a
--- /dev/null
+++ b/craftui/sim/sys/class/net/craft0/statistics/tx_bytes
@@ -0,0 +1 @@
+12081830
diff --git a/craftui/sim/sys/class/net/craft0/statistics/tx_dropped b/craftui/sim/sys/class/net/craft0/statistics/tx_dropped
new file mode 100644
index 0000000..573541a
--- /dev/null
+++ b/craftui/sim/sys/class/net/craft0/statistics/tx_dropped
@@ -0,0 +1 @@
+0
diff --git a/craftui/sim/sys/class/net/craft0/statistics/tx_errors b/craftui/sim/sys/class/net/craft0/statistics/tx_errors
new file mode 100644
index 0000000..573541a
--- /dev/null
+++ b/craftui/sim/sys/class/net/craft0/statistics/tx_errors
@@ -0,0 +1 @@
+0
diff --git a/craftui/sim/sys/class/net/craft0/statistics/tx_packets b/craftui/sim/sys/class/net/craft0/statistics/tx_packets
new file mode 100644
index 0000000..5c2b8bb
--- /dev/null
+++ b/craftui/sim/sys/class/net/craft0/statistics/tx_packets
@@ -0,0 +1 @@
+7473
diff --git a/craftui/sim/sys/class/net/eth1.inband/statistics/collisions b/craftui/sim/sys/class/net/eth1.inband/statistics/collisions
new file mode 100644
index 0000000..573541a
--- /dev/null
+++ b/craftui/sim/sys/class/net/eth1.inband/statistics/collisions
@@ -0,0 +1 @@
+0
diff --git a/craftui/sim/sys/class/net/eth1.inband/statistics/multicast b/craftui/sim/sys/class/net/eth1.inband/statistics/multicast
new file mode 100644
index 0000000..91b629b
--- /dev/null
+++ b/craftui/sim/sys/class/net/eth1.inband/statistics/multicast
@@ -0,0 +1 @@
+156
diff --git a/craftui/sim/sys/class/net/eth1.inband/statistics/rx_bytes b/craftui/sim/sys/class/net/eth1.inband/statistics/rx_bytes
new file mode 100644
index 0000000..81fa122
--- /dev/null
+++ b/craftui/sim/sys/class/net/eth1.inband/statistics/rx_bytes
@@ -0,0 +1 @@
+407824
diff --git a/craftui/sim/sys/class/net/eth1.inband/statistics/rx_dropped b/craftui/sim/sys/class/net/eth1.inband/statistics/rx_dropped
new file mode 100644
index 0000000..573541a
--- /dev/null
+++ b/craftui/sim/sys/class/net/eth1.inband/statistics/rx_dropped
@@ -0,0 +1 @@
+0
diff --git a/craftui/sim/sys/class/net/eth1.inband/statistics/rx_errors b/craftui/sim/sys/class/net/eth1.inband/statistics/rx_errors
new file mode 100644
index 0000000..573541a
--- /dev/null
+++ b/craftui/sim/sys/class/net/eth1.inband/statistics/rx_errors
@@ -0,0 +1 @@
+0
diff --git a/craftui/sim/sys/class/net/eth1.inband/statistics/rx_packets b/craftui/sim/sys/class/net/eth1.inband/statistics/rx_packets
new file mode 100644
index 0000000..79abba8
--- /dev/null
+++ b/craftui/sim/sys/class/net/eth1.inband/statistics/rx_packets
@@ -0,0 +1 @@
+1348
diff --git a/craftui/sim/sys/class/net/eth1.inband/statistics/tx_bytes b/craftui/sim/sys/class/net/eth1.inband/statistics/tx_bytes
new file mode 100644
index 0000000..cf34894
--- /dev/null
+++ b/craftui/sim/sys/class/net/eth1.inband/statistics/tx_bytes
@@ -0,0 +1 @@
+442098
diff --git a/craftui/sim/sys/class/net/eth1.inband/statistics/tx_dropped b/craftui/sim/sys/class/net/eth1.inband/statistics/tx_dropped
new file mode 100644
index 0000000..573541a
--- /dev/null
+++ b/craftui/sim/sys/class/net/eth1.inband/statistics/tx_dropped
@@ -0,0 +1 @@
+0
diff --git a/craftui/sim/sys/class/net/eth1.inband/statistics/tx_errors b/craftui/sim/sys/class/net/eth1.inband/statistics/tx_errors
new file mode 100644
index 0000000..573541a
--- /dev/null
+++ b/craftui/sim/sys/class/net/eth1.inband/statistics/tx_errors
@@ -0,0 +1 @@
+0
diff --git a/craftui/sim/sys/class/net/eth1.inband/statistics/tx_packets b/craftui/sim/sys/class/net/eth1.inband/statistics/tx_packets
new file mode 100644
index 0000000..ceb9a4e
--- /dev/null
+++ b/craftui/sim/sys/class/net/eth1.inband/statistics/tx_packets
@@ -0,0 +1 @@
+1403
diff --git a/craftui/sim/sys/class/net/eth1.peer/statistics/collisions b/craftui/sim/sys/class/net/eth1.peer/statistics/collisions
new file mode 100644
index 0000000..573541a
--- /dev/null
+++ b/craftui/sim/sys/class/net/eth1.peer/statistics/collisions
@@ -0,0 +1 @@
+0
diff --git a/craftui/sim/sys/class/net/eth1.peer/statistics/multicast b/craftui/sim/sys/class/net/eth1.peer/statistics/multicast
new file mode 100644
index 0000000..573541a
--- /dev/null
+++ b/craftui/sim/sys/class/net/eth1.peer/statistics/multicast
@@ -0,0 +1 @@
+0
diff --git a/craftui/sim/sys/class/net/eth1.peer/statistics/rx_bytes b/craftui/sim/sys/class/net/eth1.peer/statistics/rx_bytes
new file mode 100644
index 0000000..573541a
--- /dev/null
+++ b/craftui/sim/sys/class/net/eth1.peer/statistics/rx_bytes
@@ -0,0 +1 @@
+0
diff --git a/craftui/sim/sys/class/net/eth1.peer/statistics/rx_dropped b/craftui/sim/sys/class/net/eth1.peer/statistics/rx_dropped
new file mode 100644
index 0000000..573541a
--- /dev/null
+++ b/craftui/sim/sys/class/net/eth1.peer/statistics/rx_dropped
@@ -0,0 +1 @@
+0
diff --git a/craftui/sim/sys/class/net/eth1.peer/statistics/rx_errors b/craftui/sim/sys/class/net/eth1.peer/statistics/rx_errors
new file mode 100644
index 0000000..573541a
--- /dev/null
+++ b/craftui/sim/sys/class/net/eth1.peer/statistics/rx_errors
@@ -0,0 +1 @@
+0
diff --git a/craftui/sim/sys/class/net/eth1.peer/statistics/rx_packets b/craftui/sim/sys/class/net/eth1.peer/statistics/rx_packets
new file mode 100644
index 0000000..573541a
--- /dev/null
+++ b/craftui/sim/sys/class/net/eth1.peer/statistics/rx_packets
@@ -0,0 +1 @@
+0
diff --git a/craftui/sim/sys/class/net/eth1.peer/statistics/tx_bytes b/craftui/sim/sys/class/net/eth1.peer/statistics/tx_bytes
new file mode 100644
index 0000000..775971e
--- /dev/null
+++ b/craftui/sim/sys/class/net/eth1.peer/statistics/tx_bytes
@@ -0,0 +1 @@
+648
diff --git a/craftui/sim/sys/class/net/eth1.peer/statistics/tx_dropped b/craftui/sim/sys/class/net/eth1.peer/statistics/tx_dropped
new file mode 100644
index 0000000..573541a
--- /dev/null
+++ b/craftui/sim/sys/class/net/eth1.peer/statistics/tx_dropped
@@ -0,0 +1 @@
+0
diff --git a/craftui/sim/sys/class/net/eth1.peer/statistics/tx_errors b/craftui/sim/sys/class/net/eth1.peer/statistics/tx_errors
new file mode 100644
index 0000000..573541a
--- /dev/null
+++ b/craftui/sim/sys/class/net/eth1.peer/statistics/tx_errors
@@ -0,0 +1 @@
+0
diff --git a/craftui/sim/sys/class/net/eth1.peer/statistics/tx_packets b/craftui/sim/sys/class/net/eth1.peer/statistics/tx_packets
new file mode 100644
index 0000000..45a4fb7
--- /dev/null
+++ b/craftui/sim/sys/class/net/eth1.peer/statistics/tx_packets
@@ -0,0 +1 @@
+8
diff --git a/craftui/sim/tmp/glaukus/modem.json b/craftui/sim/tmp/glaukus/modem.json
new file mode 100644
index 0000000..9b51b70
--- /dev/null
+++ b/craftui/sim/tmp/glaukus/modem.json
@@ -0,0 +1 @@
+{"firmware":"\/etc\/glaukus\/firmware\/bcm85100mc_1007128.fw","network":{"rxCounters":{"broadcast":10859,"bytes":3978230,"crcErrors":0,"frames":12225,"frames1024_1518":0,"frames128_255":1366,"frames256_511":10859,"frames512_1023":0,"frames64":0,"frames65_127":0,"framesJumbo":0,"framesUndersized":0,"multicast":1366,"unicast":0},"status":0,"statusStr":"UP","txCounters":{"broadcast":10857,"bytes":3977010,"crcErrors":0,"frames":12219,"frames1024_1518":0,"frames128_255":1362,"frames256_511":10857,"frames512_1023":0,"frames64":0,"frames65_127":0,"framesJumbo":0,"framesUndersized":0,"multicast":1362,"unicast":0}},"profile":"\/etc\/glaukus\/profiles\/16qam_tx26bo_rx29bo_1500M_v128.bin","status":{"absoluteMse":-275,"acmEngineRxSensorsEnabled":false,"acmEngineTxSwitchEnabled":false,"acquireStatus":1,"acquireStatusStr":"Modem is locked","carrierOffset":35908,"debugIndications":12,"externalAgc":184,"internalAgc":182,"lastAcquireError":0,"lastAcquireErrorStr":"No Error","normalizedMse":-200,"radialMse":-204,"resPhNoiseVal":35,"rxAcmProfile":3,"rxSymbolRate":1499998768,"txAcmProfile":3,"txSymbolRate":1500000000},"temperature":67.972015380859375,"transmitter":{"dcLeakageI":0,"dcLeakageQ":0,"mode":0,"modeStr":"NORMAL","sweepTime":0,"toneFreq":0,"toneSecFreq":0},"version":{"build":128,"chipType":"BCM85100IFSBG","major":100,"minor":7}}
\ No newline at end of file
diff --git a/craftui/sim/tmp/glaukus/radio.json b/craftui/sim/tmp/glaukus/radio.json
new file mode 100644
index 0000000..eec8519
--- /dev/null
+++ b/craftui/sim/tmp/glaukus/radio.json
@@ -0,0 +1 @@
+{"heaterEnabled":false,"hiTransceiver":{"epot":{"control":"auto","driver":3,"lna":10,"pa":10},"icModel":"BGT80","mode":"rx","pll":{"frequency":81500000,"lockCounts":0,"locked":true},"temp":42.2089},"loTransceiver":{"epot":{"control":"auto","driver":126,"lna":10,"pa":126},"icModel":"BGT70","mode":"tx","pll":{"frequency":71500000,"lockCounts":0,"locked":true},"temp":48.507800000000006},"mcuTemp":-128,"paLnaPowerEnabled":true,"paLnaPowerStatus":"normal","rx":{"agcDigitalGain":36,"agcDigitalGainIndex":1,"lnaCurrentMeas":0.049,"lnaCurrentSet":0,"rssi":1578},"transceiversPowerEnabled":true,"tx":{"dcI":41,"dcQ":29,"driverCurrentMeas":0.2157,"driverCurrentSet":0,"paCurrentMeas":0.3201,"paCurrentSet":0,"paPowerMeas":12.6419,"paPowerSet":1146588,"paTemp":125,"txPowerControl":"auto","txPowerMeas":-4.2567000000000006,"txPowerSet":0,"vgaGain":18},"version":{"hardware":{"major":2,"minor":1,"type":"Chimera V2 000001"},"software":{"build":0,"major":0,"minor":2}}}
\ No newline at end of file
diff --git a/craftui/sim/tmp/gpio/ledstate b/craftui/sim/tmp/gpio/ledstate
new file mode 100644
index 0000000..80e3de0
--- /dev/null
+++ b/craftui/sim/tmp/gpio/ledstate
@@ -0,0 +1 @@
+IPV6ACQUIRED
diff --git a/craftui/www/config.thtml b/craftui/www/config.thtml
new file mode 100644
index 0000000..ca94b22
--- /dev/null
+++ b/craftui/www/config.thtml
@@ -0,0 +1,167 @@
+<html>
+<head>
+  <meta content="text/html;charset=utf-8" http-equiv="Content-Type">
+  <meta content="utf-8" http-equiv="encoding">
+  <script src="static/jquery-2.1.4.min.js"></script>
+  <link rel="stylesheet" type="text/css" href="static/craft.css">
+  <link rel=icon href=static/favicon.ico>
+  <link rel=stylesheet href="https://fonts.googleapis.com/css?family=Open+Sans:300,400,600,700&amp;lang=en">
+  <link rel=stylesheet href=static/default.css>
+</head>
+<body>
+  <header>
+    <section>
+      <h1><img src=static/logo.png alt="Google Fiber"></h1>
+      <nav>
+        <ul>
+          <li ><a href=/>GFCH100</a></li>
+          <li class=active><a href=/config>Configuration</a></li>
+          <li ><a href={{ peerurl }} target=_blank>Peer</a></li>
+        </ul>
+      </nav>
+    </section>
+  </header>
+  <br>
+  <div class="tabs">
+    <div class="tab">
+      <input type="radio" id="tab-1" name="tab-group-1" checked>
+      <label for="tab-1">Site Configuration</label>
+      <div class="content">
+	<b>Platfrom Parameters:</b>
+	<table>
+	  <tr>
+	    <td align=center><b>Parameter
+	    <td align=center><b>Active Value
+	    <td align=center><b>Last Configured
+	    <td align=center><b>Configure
+	    <td align=center><b>Status
+
+	  <tr>
+	    <td><b>Craft IP Address
+	    <td align=right><span id="platform/active_craft_inet">...</span>
+	    <td align=right>
+	      <span id="platform/craft_ipaddr">...</span>
+	      <input type=submit value="Apply Now" onclick="CraftUI.config('craft_ipaddr', 1)">
+	    <td>
+	      <input id=craft_ipaddr type=text value="">
+	      <input type=submit value=Configure onclick="CraftUI.config('craft_ipaddr')">
+	    <td>
+	      <span id=craft_ipaddr_result>...</span>
+
+	  <tr>
+	    <td><b>Link IP Address
+	    <td align=right><span id="platform/active_link_inet">...</span>
+	    <td align=right>
+	      <span id="platform/link_ipaddr">...</span>
+	      <input type=submit value="Apply Now" onclick="CraftUI.config('link_ipaddr', 1)">
+	    <td>
+	      <input id=link_ipaddr type=text value="">
+	      <input type=submit value=Configure onclick="CraftUI.config('link_ipaddr')">
+	    <td>
+	      <span id=link_ipaddr_result>...</span>
+
+	  <tr>
+	    <td><b>Peer IP Address
+	    <td align=right>N/A
+	    <td align=right>
+	      <span id="platform/peer_ipaddr">...</span>
+	      <input type=submit value="Apply Now" onclick="CraftUI.config('peer_ipaddr', 1)">
+	    <td>
+	      <input id=peer_ipaddr type=text value="">
+	      <input type=submit value=Configure onclick="CraftUI.config('peer_ipaddr')">
+	    <td>
+	      <span id=peer_ipaddr_result>...</span>
+
+	  <tr>
+	    <td><b>Link VLAN (to peer)
+	    <td align=right><span id="platform/active_link_vlan">...</span>
+	    <td align=right>
+	      <span id="platform/vlan_link">...</span>
+	      <input type=submit value="Apply Now" onclick="CraftUI.config('vlan_peer', 1)">
+	    <td>
+	      <input id=vlan_peer type=text value="">
+	      <input type=submit value=Configure onclick="CraftUI.config('vlan_peer')">
+	    <td>
+	      <span id=vlan_peer_result>...</span>
+
+	  <tr>
+	    <td><b>In-band Management VLAN
+	    <td align=right><span id="platform/active_inband_vlan">...</span>
+	    <td align=right>
+	      <span id="platform/vlan_inband">...</span>
+	      <input type=submit value="Apply Now" onclick="CraftUI.config('vlan_inband', 1)">
+	    <td>
+	      <input id=vlan_inband type=text value="">
+	      <input type=submit value=Configure onclick="CraftUI.config('vlan_inband')">
+	    <td>
+	      <span id=vlan_inband_result>...</span>
+
+	</table>
+	<b>Radio Parameters:</b>
+	<table>
+	  <tr>
+	    <td align=center><b>Parameter
+	    <td align=center><b>Active Value
+	    <td align=center><b>Configure and Apply
+	    <td align=center><b>Status
+
+	  <tr>
+	    <td><b>High Frequency
+	    <td align=right><span id="radio/hiTransceiver/pll/frequency">...</span>
+	    <td>
+	      <input id=freq_hi type=text value="">
+	      <input type=submit value=Configure onclick="CraftUI.config('freq_hi')">
+	    <td>
+	      <span id=freq_hi_result>...</span>
+
+	  <tr>
+	    <td><b>Low Frequency
+	    <td align=right><span id="radio/loTransceiver/pll/frequency">...</span>
+	    <td>
+	      <input id=freq_lo type=text value="">
+	      <input type=submit value=Configure onclick="CraftUI.config('freq_lo')">
+	    <td>
+	      <span id=freq_lo_result>...</span>
+
+	  <tr>
+	    <td><b>High Frequency Mode
+	    <td align=right><span id="radio/hiTransceiver/mode">...</span>
+	    <td>
+	      <input id=mode_hi type=text value="">
+	      <input type=submit value=Configure onclick="CraftUI.config('mode_hi')">
+	    <td>
+	      <span id=mode_hi_result>...</span>
+
+	  <tr>
+	    <td><b>Power Level
+	    <td align=right><span id="radio/tx/paPowerSet">...</span>
+	    <td>
+	      <input id=tx_powerlevel type=text value="">
+	      <input type=submit value=Configure onclick="CraftUI.config('tx_powerlevel')">
+	    <td>
+	      <span id=tx_powerlevel_result>...</span>
+
+	  <tr>
+	    <td><b>Power Enabled
+	    <td align=right><span id="radio/paLnaPowerEnabled">...</span>
+	    <td>
+	      <input id=tx_on type=text value="">
+	      <input type=submit value=Configure onclick="CraftUI.config('tx_on')">
+	    <td>
+	      <span id=tx_on_result>...</span>
+
+	</table>
+      </div>
+    </div>
+    <div class="tab">
+      <input type="radio" id="tab-2" name="tab-group-1">
+      <label for="tab-2">Debug</label>
+      <div class="content">
+        <b>refreshCount:</b><span class="values" id="platform/refreshCount">...</span><br>
+        <b>unhandled xml:</b><span class="values" id="unhandled"></span>
+      </div>
+    </div>
+  </div>
+  <script src="static/craft.js"></script>
+</body>
+</html>
diff --git a/craftui/www/index.thtml b/craftui/www/index.thtml
new file mode 100644
index 0000000..5d3e841
--- /dev/null
+++ b/craftui/www/index.thtml
@@ -0,0 +1,432 @@
+<html>
+<head>
+  <meta content="text/html;charset=utf-8" http-equiv="Content-Type">
+  <meta content="utf-8" http-equiv="encoding">
+  <script src="static/jquery-2.1.4.min.js"></script>
+  <link rel="stylesheet" type="text/css" href="static/craft.css">
+  <link rel=icon href=static/favicon.ico>
+  <link rel=stylesheet href="https://fonts.googleapis.com/css?family=Open+Sans:300,400,600,700&amp;lang=en">
+  <link rel=stylesheet href=static/default.css>
+</head>
+<body>
+  <header>
+    <section>
+      <h1><img src=static/logo.png alt="Google Fiber"></h1>
+      <nav>
+        <ul>
+          <li class=active><a href=/>GFCH100</a></li>
+          <li ><a href=/config>Configuration</a></li>
+          <li ><a href={{ peerurl }} target=_blank>Peer</a></li>
+        </ul>
+      </nav>
+    </section>
+  </header>
+  <br>
+  <div class="tabs">
+    <div class="tab">
+      <input type="radio" id="tab-1" name="tab-group-1" checked>
+      <label for="tab-1">Platform</label>
+      <div class="content">
+        <b>Serial Number:</b><span class="values" id="platform/serialno">...</span><br>
+        <b>Platform:</b><span class="values" id="platform/platform">...</span><br>
+        <b>Software Version:</b><span class="values" id="platform/version">...</span><br>
+        <b>Software Date:</b><span class="values" id="platform/softwaredate">...</span><br>
+        <b>Uptime:</b><span class="values" id="platform/uptime">...</span><br>
+        <b>Status:</b><span class="values" id="platform/ledstate">...</span><br>
+      </div>
+    </div>
+    <div class="tab">
+      <input type="radio" id="tab-2" name="tab-group-1">
+      <label for="tab-2">Network</label>
+      <div class="content">
+        <b>IP Addresses:</b>
+	<table>
+	  <tr>
+            <td align=center><b>Port</b></td>
+            <td align=center><b>IPv4</b></td>
+            <td align=center><b>IPv6</b></td></tr>
+	  <tr>
+            <td><b>Craft</b></td>
+	    <td align=right><span id="platform/active_craft_inet">...</span></td>
+	    <td align=right><span id="platform/active_craft_inet6">...</span></td></tr>
+	  <tr>
+            <td><b>Out-of-Band (PoE)</b></td>
+	    <td align=right><span id="platform/active_poe_inet">...</span></td>
+	    <td align=right><span id="platform/active_poe_inet6">...</span></td></tr>
+	  <tr>
+            <td><b>In-Band</b></td>
+	    <td align=right><span id="platform/active_inband_inet">...</span></td>
+	    <td align=right><span id="platform/active_inband_inet6">...</span></td></tr>
+	  <tr>
+            <td><b>Link (to peer)</b></td>
+	    <td align=right><span id="platform/active_link_inet">...</span></td>
+	    <td align=right><span id="platform/active_link_inet6">...</span></td></tr>
+	</table>
+        <b>Packet Counters:</b>
+        <table>
+          <tr>
+            <td><b></b></td>
+            <td colspan=3 align=center><b>received</b></td>
+            <td colspan=3 align=center><b>transmitted</b></td>
+            <td colspan=9 align=center><b>errors</b></td></tr>
+          <tr>
+            <td align=center><b>interface</b></td>
+
+            <td align=center><b>bytes</b></td>
+            <td align=center><b>frames</b></td>
+            <td align=center><b>multicast</b></td>
+
+            <td align=center><b>bytes</b></td>
+            <td align=center><b>frames</b></td>
+            <td align=center><b>multicast</b></td>
+
+            <td align=center><b>rx errors</b></td>
+            <td align=center><b>rx dropped</b></td>
+            <td align=center><b>rx CRC</b></td>
+            <td align=center><b>rx Undersize</b></td>
+            <td align=center><b>tx errors</b></td>
+            <td align=center><b>tx dropped</b></td>
+            <td align=center><b>tx CRC</b></td>
+            <td align=center><b>tx Undersize</b></td>
+            <td align=center><b>collisions</b></td>
+          <tr>
+            <td><b>Modem (from/to switch)<b></td>
+            <td align=right><span id="modem/network/rxCounters/bytes">...</span></td>
+            <td align=right><span id="modem/network/rxCounters/frames">...</span></td>
+	    <td align=right><span id="modem/network/rxCounters/multicast">...</span></td>
+
+            <td align=right><span id="modem/network/txCounters/bytes">...</span></td>
+            <td align=right><span id="modem/network/txCounters/frames">...</span></td>
+	    <td align=right><span id="modem/network/txCounters/multicast">...</span></td>
+
+            <td align=right>-</td>
+            <td align=right>-</td>
+            <td align=right><span id="modem/network/rxCounters/crcErrors">...</span></td>
+            <td align=right><span id="modem/network/rxCounters/framesUndersized">...</span></td>
+            <td align=right>-</td>
+            <td align=right>-</td>
+            <td align=right><span id="modem/network/txCounters/crcErrors">...</span></td>
+            <td align=right><span id="modem/network/txCounters/framesUndersized">...</span></td>
+            <td align=right>-</td></tr>
+
+	  <tr>
+            <td><b>Craft<b></td>
+            <td align=right><span id="platform/craft_rx_bytes">...</span></td>
+            <td align=right><span id="platform/craft_rx_packets">...</span></td>
+            <td align=right><span id="platform/craft_multicast">...</span></td>
+
+            <td align=right><span id="platform/craft_tx_bytes">...</span></td>
+            <td align=right><span id="platform/craft_tx_packets">...</span></td>
+            <td align=right>-</td>
+
+            <td align=right><span id="platform/craft_rx_errors">...</span></td>
+            <td align=right><span id="platform/craft_rx_dropped">...</span></td>
+            <td align=right>-</td>
+            <td align=right>-</td>
+            <td align=right><span id="platform/craft_tx_errors">...</span></td>
+            <td align=right><span id="platform/craft_tx_dropped">...</span></td>
+            <td align=right>-</td>
+            <td align=right>-</td>
+            <td align=right><span id="platform/craft_collisions">...</span></td>
+
+	  <tr>
+            <td><b>Out-of-Band (PoE)<b></td>
+            <td align=right><span id="platform/poe_rx_bytes">...</span></td>
+            <td align=right><span id="platform/poe_rx_packets">...</span></td>
+            <td align=right><span id="platform/poe_multicast">...</span></td>
+
+            <td align=right><span id="platform/poe_tx_bytes">...</span></td>
+            <td align=right><span id="platform/poe_tx_packets">...</span></td>
+            <td align=right>-</td>
+
+            <td align=right><span id="platform/poe_rx_errors">...</span></td>
+            <td align=right><span id="platform/poe_rx_dropped">...</span></td>
+            <td align=right>-</td>
+            <td align=right>-</td>
+            <td align=right><span id="platform/poe_tx_errors">...</span></td>
+            <td align=right><span id="platform/poe_tx_dropped">...</span></td>
+            <td align=right>-</td>
+            <td align=right>-</td>
+            <td align=right><span id="platform/poe_collisions">...</span></td>
+
+	  <tr>
+            <td><b>In-Band<b></td>
+            <td align=right><span id="platform/inband_rx_bytes">...</span></td>
+            <td align=right><span id="platform/inband_rx_packets">...</span></td>
+            <td align=right><span id="platform/inband_multicast">...</span></td>
+
+            <td align=right><span id="platform/inband_tx_bytes">...</span></td>
+            <td align=right><span id="platform/inband_tx_packets">...</span></td>
+            <td align=right>-</td>
+
+            <td align=right><span id="platform/inband_rx_errors">...</span></td>
+            <td align=right><span id="platform/inband_rx_dropped">...</span></td>
+            <td align=right>-</td>
+            <td align=right>-</td>
+            <td align=right><span id="platform/inband_tx_errors">...</span></td>
+            <td align=right><span id="platform/inband_tx_dropped">...</span></td>
+            <td align=right>-</td>
+            <td align=right>-</td>
+            <td align=right><span id="platform/inband_collisions">...</span></td>
+
+	  <tr>
+            <td><b>Link (to peer)<b></td>
+            <td align=right><span id="platform/link_rx_bytes">...</span></td>
+            <td align=right><span id="platform/link_rx_packets">...</span></td>
+            <td align=right><span id="platform/link_multicast">...</span></td>
+
+            <td align=right><span id="platform/link_tx_bytes">...</span></td>
+            <td align=right><span id="platform/link_tx_packets">...</span></td>
+            <td align=right>-</td>
+
+            <td align=right><span id="platform/link_rx_errors">...</span></td>
+            <td align=right><span id="platform/link_rx_dropped">...</span></td>
+            <td align=right>-</td>
+            <td align=right>-</td>
+            <td align=right><span id="platform/link_tx_errors">...</span></td>
+            <td align=right><span id="platform/link_tx_dropped">...</span></td>
+            <td align=right>-</td>
+            <td align=right>-</td>
+            <td align=right><span id="platform/link_collisions">...</span></td>
+
+          <tr>
+            <td><b>Switch Port 0/0 (PoE)</b></td>
+            <td align=right>-</td>
+            <td align=right>-</td>
+            <td align=right>-</td>
+            <td align=right>-</td>
+            <td align=right>-</td>
+            <td align=right>-</td>
+            <td align=right>-</td>
+            <td align=right>-</td>
+            <td align=right>-</td>
+            <td align=right>-</td>
+            <td align=right>-</td>
+            <td align=right>-</td>
+            <td align=right>-</td>
+            <td align=right>-</td>
+            <td align=right>-</td>
+
+          <tr>
+            <td><b>Switch Port 0/4 (SOC)</b></td>
+            <td align=right>-</td>
+            <td align=right>-</td>
+            <td align=right>-</td>
+            <td align=right>-</td>
+            <td align=right>-</td>
+            <td align=right>-</td>
+            <td align=right>-</td>
+            <td align=right>-</td>
+            <td align=right>-</td>
+            <td align=right>-</td>
+            <td align=right>-</td>
+            <td align=right>-</td>
+            <td align=right>-</td>
+            <td align=right>-</td>
+            <td align=right>-</td>
+
+          <tr>
+            <td><b>Switch Port 0/24 (modem)</b></td>
+            <td align=right>-</td>
+            <td align=right>-</td>
+            <td align=right>-</td>
+            <td align=right>-</td>
+            <td align=right>-</td>
+            <td align=right>-</td>
+            <td align=right>-</td>
+            <td align=right>-</td>
+            <td align=right>-</td>
+            <td align=right>-</td>
+            <td align=right>-</td>
+            <td align=right>-</td>
+            <td align=right>-</td>
+            <td align=right>-</td>
+            <td align=right>-</td>
+
+          <tr>
+            <td><b>Switch Port 0/25 (SFP+)</b></td>
+            <td align=right>-</td>
+            <td align=right>-</td>
+            <td align=right>-</td>
+            <td align=right>-</td>
+            <td align=right>-</td>
+            <td align=right>-</td>
+            <td align=right>-</td>
+            <td align=right>-</td>
+            <td align=right>-</td>
+            <td align=right>-</td>
+            <td align=right>-</td>
+            <td align=right>-</td>
+            <td align=right>-</td>
+            <td align=right>-</td>
+            <td align=right>-</td>
+        </table>
+      </div>
+    </div>
+    <div class="tab">
+      <input type="radio" id="tab-3" name="tab-group-1">
+      <label for="tab-3">Modem</label>
+      <div class="content">
+        <b>Chip:</b><span class="values" id="modem/version/chipType">...</span><br>
+        <b>Firmware:</b><span class="values" id="modem/firmware">...</span><br>
+        <b>Version:</b><span class="values">
+          <span id="modem/version/major">...</span>.<span id="modem/version/minor">?</span>.<span id="modem/version/build">?</span>
+        </span><br>
+        <b>Profile:</b><span class="values" id="modem/profile">...</span><br>
+        <b>Temperature:</b><span class="values" id="modem/temperature">...</span><br>
+        <b>Network Status:</b><span class="values">
+          <span id="modem/network/statusStr">...</span>
+          (Code: <span id="modem/network/status">...</span>)
+        </span><br>
+        <b>Acquire Status:</b><span class="values">
+          <span id="modem/status/acquireStatusStr">...</span>
+          (Code: <span id="modem/status/acquireStatus">...</span>)
+        </span><br>
+        <b>Transmitter Mode:</b><span class="values">
+          <span id="modem/transmitter/modeStr">...</span>
+          (Code: <span id="modem/transmitter/mode">...</span>)
+        </span><br>
+        <b>Last Acquire Error:</b><span class="values">
+          <span id="modem/status/lastAcquireErrorStr">...</span>
+          (Code: <span id="modem/status/lastAcquireError">...</span>)
+        </span><br>
+        <b>Carrier Offset:</b><span class="values" id="modem/status/carrierOffset">...</span><br>
+        <b>ResPhNoise:</b><span class="values" id="modem/status/resPhNoiseVal">...</span><br>
+        <b>DebugIndications:</b><span class="values" id="modem/status/debugIndications">...</span><br>
+        <b>MSE:</b><span class="values">
+          Normalized: <span id="modem/status/normalizedMse">...</span>&nbsp;&nbsp;
+          Absolute: <span id="modem/status/absoluteMse">...</span>&nbsp;&nbsp;
+          Radial: <span id="modem/status/radialMse">...</span>&nbsp;&nbsp;
+        </span><br>
+        <b>ACM Profile:</b><span class="values">
+          rx: <span id="modem/status/rxAcmProfile">...</span>&nbsp;&nbsp;
+          tx: <span id="modem/status/txAcmProfile">...</span>&nbsp;&nbsp;
+        </span><br>
+        <b>Symbol Rate:</b><span class="values">
+          rx: <span id="modem/status/rxSymbolRate">...</span>&nbsp;&nbsp;
+          tx: <span id="modem/status/txSymbolRate">...</span>&nbsp;&nbsp;
+        </span><br>
+        <b>AGC:</b><span class="values">
+          external: <span id="modem/status/externalAgc">...</span>&nbsp;&nbsp;
+          internal: <span id="modem/status/internalAgc">...</span>&nbsp;&nbsp;
+        </span><br>
+        <b>ACM Engine:</b><span class="values">
+          rx sensors enabled: <span id="modem/status/acmEngineRxSensorsEnabled">...</span>&nbsp;&nbsp;
+          tx switch enabled: <span id="modem/status/acmEngineTxSwitchEnabled">...</span>&nbsp;&nbsp;
+        </span><br>
+        <b>Transmitter DC Leakage:</b><span class="values">
+          I: <span id="modem/transmitter/dcLeakageI">...</span>&nbsp;&nbsp;
+          Q: <span id="modem/transmitter/dcLeakageQ">...</span>&nbsp;&nbsp;
+        </span><br>
+        <b>Transmitter:</b><span class="values">
+          sweep time: <span id="modem/transmitter/sweepTime">...</span>&nbsp;&nbsp;
+          tone freq: <span id="modem/transmitter/toneFreq">...</span>&nbsp;&nbsp;
+          tone sec freq: <span id="modem/transmitter/toneSecFreq">...</span>&nbsp;&nbsp;
+        </span><br>
+      </div>
+    </div>
+    <div class="tab">
+      <input type="radio" id="tab-4" name="tab-group-1">
+      <label for="tab-4">Radio</label>
+      <div class="content">
+        <b>Hardware Version:</b><span class="values">
+          <span id="radio/version/hardware/type">...</span>&nbsp;
+          (<span id="radio/version/hardware/major">?</span>.<span id="radio/version/hardware/minor">?</span>)
+        </span><br>
+        <b>Software Version:</b><span class="values">
+          <span id="radio/version/software/major">?</span>.<span id="radio/version/software/minor">?</span>.<span id="radio/version/software/build">?</span>
+        </span><br>
+        <b>RSSI:</b><span class="values" id="radio/rx/rssi">...</span><br>
+        <b>PA Temp:</b><span class="values" id="radio/tx/paTemp">...</span><br>
+        <b>MCU Temp:</b><span class="values" id="radio/mcuTemp">...</span><br>
+        <b>Heater Enabled:</b><span class="values" id="radio/heaterEnabled">...</span><br>
+        <b>PA LNA:</b><span class="values">
+          Power Enabled: <span id="radio/paLnaPowerEnabled">...</span>&nbsp;&nbsp;&nbsp;
+          Status: <span id="radio/paLnaPowerStatus">...</span>&nbsp;&nbsp;&nbsp;
+        </span><br>
+        <b>Transceivers Power Enabled:</b><span class="values" id="radio/transceiversPowerEnabled">...</span><br>
+        <table>
+        <tr>
+          <td><b>Transceiver</b></td>
+          <td><b>Model</b></td>
+          <td><b>Mode</b></td>
+          <td><b>Temp</b></td>
+          <td><b>PLL (freq, locked, lockCount)</b></td>
+          <td><b>EPOT (control, driver, lna, pa)</b></td></tr>
+        <tr>
+          <td><b>High</b>
+          <td><span id="radio/hiTransceiver/icModel">...</span></td>
+          <td><span id="radio/hiTransceiver/mode">...</span></td>
+          <td><span id="radio/hiTransceiver/temp">...</span></td>
+          <td>
+            <span id="radio/hiTransceiver/pll/frequency">...</span>
+            <span id="radio/hiTransceiver/pll/locked">...</span>
+            <span id="radio/hiTransceiver/pll/lockCounts">...</span></td>
+          <td>
+            <span id="radio/hiTransceiver/epot/control">...</span>
+            <span id="radio/hiTransceiver/epot/driver">...</span>
+            <span id="radio/hiTransceiver/epot/lna">...</span>
+            <span id="radio/hiTransceiver/epot/pa">...</span></td></tr>
+        <tr>
+          <td><b>Low</b>
+          <td><span id="radio/loTransceiver/icModel">...</span></td>
+          <td><span id="radio/loTransceiver/mode">...</span></td>
+          <td><span id="radio/loTransceiver/temp">...</span></td>
+          <td>
+            <span id="radio/loTransceiver/pll/frequency">...</span>
+            <span id="radio/loTransceiver/pll/locked">...</span>
+            <span id="radio/loTransceiver/pll/lockCounts">...</span></td>
+          <td>
+            <span id="radio/loTransceiver/epot/control">...</span>
+            <span id="radio/loTransceiver/epot/driver">...</span>
+            <span id="radio/loTransceiver/epot/lna">...</span>
+            <span id="radio/loTransceiver/epot/pa">...</span></td></tr>
+        </table>
+        <b>Digital AGC Gain:</b><span class="values">
+          <span id="radio/rx/agcDigitalGain">...</span>&nbsp;&nbsp;&nbsp;
+          (Index <span id="radio/rx/agcDigitalGainIndex">...</span>)
+        </span><br>
+        <b>LNA Current:</b><span class="values">
+          Meas: <span id="radio/rx/lnaCurrentMeas">...</span>&nbsp;&nbsp;&nbsp;
+          Set: <span id="radio/rx/lnaCurrentSet">...</span>
+        </span><br>
+        <b>Driver Current:</b><span class="values">
+          Meas: <span id="radio/tx/driverCurrentMeas">...</span>&nbsp;&nbsp;&nbsp;
+          Set: <span id="radio/tx/driverCurrentSet">...</span>
+        </span><br>
+        <b>PA Current:</b><span class="values">
+          Meas: <span id="radio/tx/paCurrentMeas">...</span>&nbsp;&nbsp;&nbsp;
+          Set: <span id="radio/tx/paCurrentSet">...</span>
+        </span><br>
+        <b>PA Power:</b><span class="values">
+          Meas: <span id="radio/tx/paPowerMeas">...</span>&nbsp;&nbsp;&nbsp;
+          Set: <span id="radio/tx/paPowerSet">...</span>
+        </span><br>
+        <b>TX Power:</b><span class="values">
+          Meas: <span id="radio/tx/txPowerMeas">...</span>&nbsp;&nbsp;&nbsp;
+          Set: <span id="radio/tx/txPowerSet">...</span>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;
+          Control: <span id="radio/tx/txPowerControl">...</span>
+        </span><br>
+        <b>DC:</b><span class="values">
+          I: <span id="radio/tx/dcI">...</span>&nbsp;&nbsp;&nbsp;
+          Q: <span id="radio/tx/dcQ">...</span>&nbsp;&nbsp;&nbsp;
+        </span><br>
+        <b>VGA Gain:</b><span class="values" id="radio/tx/vgaGain">...</span><br>
+      </div>
+    </div>
+    <div class="tab">
+      <input type="radio" id="tab-5" name="tab-group-1">
+      <label for="tab-5">Debug</label>
+      <div class="content">
+        <form action=/startisostream method=post>
+          {% module xsrf_form_html() %}
+          <button id="isostream_button">Run Test</button>
+        </form>
+        <b>refreshCount:</b><span class="values" id="platform/refreshCount">...</span><br>
+        <b>unhandled xml:</b><span class="values" id="unhandled"></span>
+      </div>
+    </div>
+  </div>
+  <script src="static/craft.js"></script>
+</body>
+</html>
diff --git a/craftui/www/static/OpenSansNormal400Latin.ttf b/craftui/www/static/OpenSansNormal400Latin.ttf
new file mode 100644
index 0000000..5597cf3
--- /dev/null
+++ b/craftui/www/static/OpenSansNormal400Latin.ttf
Binary files differ
diff --git a/craftui/www/static/craft.css b/craftui/www/static/craft.css
new file mode 100644
index 0000000..2fd9201
--- /dev/null
+++ b/craftui/www/static/craft.css
@@ -0,0 +1,73 @@
+table, th, td {
+  border: 1px solid #ccc;
+  font-size:12px;
+}
+
+td {
+  padding: 5px;
+}
+
+.bit {
+  outline-style:solid;
+  outline-width:thin;
+  display:block;
+  text-align:center;
+  float:left;
+  width:16px;
+  height:16px;
+  font-size:12px;
+}
+
+.tabs {
+  position: relative;
+  clear: both;
+  margin: 35px 0 25px;
+  background: #56616d;
+}
+
+.tab {
+  float: left;
+}
+
+.tab label {
+  background: #eee;
+  padding: 10px;
+  border: 1px solid #ccc;
+  margin-left: -1px;
+  position: relative;
+  left: 1px;
+  top: -29px;
+}
+
+.tab [type=radio] {
+  display: none;
+}
+
+.content {
+  position: absolute;
+  background: white;
+  border: 1px solid #ccc;
+  top: -1px;
+  left: 0;
+  right: 0;
+  bottom: 0;
+  height: 600px;
+  padding: 20px;
+  opacity: 0;
+}
+
+.values {
+  position: absolute;
+  left: 250px;
+}
+
+[type=radio]:checked ~ label {
+  background: white;
+  border-bottom: 1px solid white;
+  z-index: 2;
+}
+
+[type=radio]:checked ~ label ~ .content {
+  z-index: 1;
+  opacity: 1;
+}
diff --git a/craftui/www/static/craft.js b/craftui/www/static/craft.js
new file mode 100644
index 0000000..04e0f2c
--- /dev/null
+++ b/craftui/www/static/craft.js
@@ -0,0 +1,110 @@
+CraftUI = function() {
+  // Do some feature sniffing for dependencies and return if not supported.
+  if (!window.XMLHttpRequest ||
+      !document.querySelector ||
+      !Element.prototype.addEventListener ||
+      !('classList' in document.createElement('_'))) {
+    document.documentElement.classList.add('unsupported');
+    return;
+  }
+
+  // Initialize the info.
+  CraftUI.getInfo();
+
+  // Refresh data periodically.
+  window.setInterval(CraftUI.getInfo, 5000);
+};
+
+CraftUI.info = {checksum: 0};
+
+CraftUI.updateField = function(key, val) {
+  var el = document.getElementById(key);
+  if (el == null) {
+    self.unhandled += key + '=' + val + '; ';
+    return;
+  }
+  el.innerHTML = ''; // Clear the field.
+  // For objects, create an unordered list and append the values as list items.
+  if (val && typeof val === 'object') {
+    var ul = document.createElement('ul');
+    for (key in val) {
+      var li = document.createElement('li');
+      var primary = document.createTextNode(key + ' ');
+      li.appendChild(primary);
+      var secondary = document.createElement('span');
+      secondary.textContent = val[key];
+      li.appendChild(secondary);
+      ul.appendChild(li);
+    }
+    // If the unordered list has children, append it and return.
+    if (ul.hasChildNodes()) {
+      el.appendChild(ul);
+      return;
+    } else {
+      val = 'N/A';
+    }
+  }
+  el.appendChild(document.createTextNode(val));
+};
+
+CraftUI.flattenAndUpdateFields = function(jsonmap, prefix) {
+  for (var key in jsonmap) {
+    var val = jsonmap[key];
+    if (typeof val !== 'object') {
+      CraftUI.updateField(prefix + key, jsonmap[key]);
+    } else {
+      CraftUI.flattenAndUpdateFields(val, prefix + key + '/')
+    }
+  }
+};
+
+CraftUI.getInfo = function() {
+  // Request info, set the connected status, and update the fields.
+  var xhr = new XMLHttpRequest();
+  xhr.onreadystatechange = function() {
+    self.unhandled = '';
+    if (xhr.readyState == 4 && xhr.status == 200) {
+      var list = JSON.parse(xhr.responseText);
+      CraftUI.flattenAndUpdateFields(list, '');
+    }
+    CraftUI.updateField('unhandled', self.unhandled);
+  };
+  var payload = [];
+  payload.push('checksum=' + encodeURIComponent(CraftUI.info.checksum));
+  payload.push('_=' + encodeURIComponent((new Date()).getTime()));
+  xhr.open('get', 'content.json?' + payload.join('&'), true);
+  xhr.send();
+};
+
+CraftUI.config = function(key, activate) {
+  // POST as json
+  var el = document.getElementById(key);
+  var value = el.value;
+  var xhr = new XMLHttpRequest();
+  xhr.open('post', 'content.json');
+  xhr.setRequestHeader('Content-Type', 'application/json; charset=UTF-8');
+  var data;
+  if (activate) {
+    data = { config: [ { [key + "_activate"]: "true" } ] };
+  } else {
+    data = { config: [ { [key]: value } ] };
+  }
+  var txt = JSON.stringify(data);
+  var resultid = key + "_result"
+  var el = document.getElementById(resultid);
+  xhr.onload = function(e) {
+    var json = JSON.parse(xhr.responseText);
+    if (json.error == 0) {
+      el.innerHTML = "Success!";
+    } else {
+      el.innerHTML = "Error: " + json.errorstring;
+    }
+    CraftUI.getInfo();
+  }
+  xhr.onerror = function(e) {
+    el.innerHTML = xhr.statusText + xhr.responseText;
+  }
+  xhr.send(txt);
+};
+
+new CraftUI();
diff --git a/craftui/www/static/default.css b/craftui/www/static/default.css
new file mode 100644
index 0000000..1f51a21
--- /dev/null
+++ b/craftui/www/static/default.css
@@ -0,0 +1,549 @@
+@media screen, print {
+
+  *,
+  *:before,
+  *:after {
+    box-sizing: inherit;
+  }
+
+  html,
+  button {
+    box-sizing: border-box;
+    font: 14px/1.64 'open sans', arial, sans-serif;
+  }
+
+  table {
+    border-collapse: collapse;
+    border-spacing: 0;
+    margin: 20px 0;
+    table-layout: fixed;
+    word-wrap: break-word;
+  }
+
+  th {
+    font-weight: normal;
+    text-align: left;
+  }
+
+  ul {
+    list-style: none;
+    margin: 0;
+    padding: 0;
+  }
+
+  img {
+    border: 0;
+  }
+
+}
+
+@media screen {
+
+  html,
+  body,
+  header,
+  footer {
+    padding: 0 13px;
+  }
+
+  html {
+    background: #fff;
+    color: #222;
+    min-width: 320px;
+  }
+
+  html.dialog,
+  html.error {
+    overflow: hidden;
+  }
+
+  body,
+  header,
+  footer {
+    margin: 0 -13px;
+  }
+
+  body {
+    background: #f6f6f6;
+  }
+
+  h1,
+  h2,
+  h3 {
+    color: #444;
+    line-height: 1.25;
+  }
+
+  h1,
+  h2 {
+    font-weight: 300;
+  }
+
+  h1 {
+    font-size: 42px;
+  }
+
+  h2 {
+    font-size: 36px;
+  }
+
+  h3,
+  header {
+    color: #6a758a;
+  }
+
+  h3 {
+    font-size: 18px;
+    font-weight: 600;
+  }
+
+  small {
+    color: #777;
+    font-size: 11px;
+  }
+
+  b,
+  em,
+  strong {
+    font-weight: 600;
+  }
+
+  a {
+    color: inherit;
+    text-decoration: none;
+  }
+
+  a:focus,
+  a:hover,
+  a:active {
+    text-decoration: underline;
+  }
+
+  header,
+  footer {
+    min-height: 60px;
+  }
+
+  header {
+    background: #f1f1f1;
+    border-bottom: 1px solid #ebebeb;
+    top: 0;
+  }
+
+  header section,
+  footer ul {
+    margin: 0 auto;
+    max-width: 978px;
+  }
+
+  header section:after {
+   clear: both;
+   content: '';
+   display: block;
+  }
+
+
+  header h1,
+  header nav,
+  header nav li,
+  header #help,
+  footer li {
+    display: inline-block;
+    vertical-align: middle;
+  }
+
+  header h1,
+  header #help {
+    line-height: 60px;
+  }
+
+  header h1 {
+    margin: 0 20px 0 0;
+  }
+
+  .disconnected header h1 {
+    pointer-events: none;
+  }
+
+  header h1 img {
+    display: block;
+    height: 26px;
+    margin: 20px 0 14px;
+    width: 117px;
+  }
+
+  header nav ul {
+    margin: 0 -10px;
+  }
+
+  header nav li {
+    border-bottom: 4px solid transparent;
+    font-weight: 600;
+    line-height: 48px;
+    margin: 1px 5px -1px;
+    padding: 8px 10px 0;
+    text-transform: uppercase;
+  }
+
+  header nav li.active {
+    border-color: #3369e8;
+  }
+
+  footer {
+    background: #6a758a;
+    bottom: 0;
+    color: #ccc;
+    font-size: 12px;
+    text-align: right;
+  }
+
+  footer ul {
+    padding: 10px 0;
+  }
+
+  footer li {
+    line-height: 40px;
+  }
+
+  footer li:nth-last-child(n+1) {
+    margin-left: 40px;
+  }
+
+  .button {
+    background: #f2f2f2;
+    background-image: linear-gradient(#f2f2f2, #fff);
+    border: 1px solid #ccc;
+    border-radius: 4px;
+    box-shadow: .6px 1px .5px 0 rgba(0, 0, 0, .1);
+    color: #3369e8;
+    cursor: pointer;
+    display: inline-block;
+    font-weight: 600;
+    max-width: 100%;
+    padding: 5px 15px;
+    text-decoration: none !important;
+    -webkit-user-select: none;
+    word-wrap: break-word;
+  }
+
+  .button:focus {
+    border: 1px solid #3369e8;
+  }
+
+  .button:hover {
+    background: #eee;
+    background-image: linear-gradient(#fff, #f2f2f2);
+    border: 1px solid #adc3f6;
+    box-shadow: .6px 1px 1px 0 rgba(0, 0, 0, .2);
+  }
+
+  .button:active {
+    background: #ddd;
+    background-image: linear-gradient(#ddd, #eee);
+    border: 1px solid #7ba5ed;
+    box-shadow: .6px 1px 1px 0 rgba(0, 0, 0, .3);
+    color: #2e57df;
+  }
+
+  #dialog .button.restart,
+  .disconnected #restart .button.restart {
+    background: #4180ed;
+    border: 1px solid #3369e8;
+    box-shadow: .6px 1px .5px 0 rgba(0, 0, 0, .1), inset .6px 1px 2px 1px rgba(255, 255, 255, .15);
+    color: #fff !important;
+    text-shadow: .6px 1px 1px rgba(0, 0, 0, .2);
+    text-transform: uppercase;
+  }
+
+  #dialog .button.restart:focus,
+  .disconnected #restart .button.restart:focus {
+    box-shadow: inset 0 0 0 1px rgba(255, 255, 255, .5);
+  }
+
+  #dialog .button.restart:hover,
+  .disconnected #restart .button.restart:hover {
+    background: #3da2fd;
+    box-shadow: .6px 1px 2px 0 rgba(0, 0, 0, .3), inset .6px 1px 2px 0 rgba(255, 255, 255, .3);
+  }
+
+  #dialog .button.restart:active,
+  .disconnected #restart .button.restart:active {
+    background: #56616d;
+    border: 1px solid #46505c;
+    box-shadow: .6px 1px .5px 0 rgba(0, 0, 0, .4), inset .6px 1px 2px 0 rgba(142, 142, 142, .3);
+  }
+
+  #dialog .button.cancel {
+    background: transparent;
+    border: none;
+    box-shadow: none;
+    margin-left: 40px;
+  }
+
+  #help {
+    float: right;
+    text-decoration: none;
+  }
+
+  #help:before,
+  #advanced button.toggle {
+    display: inline-block;
+    height: 24px;
+    vertical-align: middle;
+    width: 24px;
+  }
+
+  #help:before {
+    background: url(data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0Ij4KICAgIDxwYXRoIGQ9Ik0wIDBoMjR2MjRoLTI0eiIgZmlsbD0ibm9uZSIvPgogICAgPHBhdGggZD0iTTE5IDJoLTE0Yy0xLjExIDAtMiAuOS0yIDJ2MTRjMCAxLjEuODkgMiAyIDJoNGwzIDMgMy0zaDRjMS4xIDAgMi0uOSAyLTJ2LTE0YzAtMS4xLS45LTItMi0yem0tNiAxNmgtMnYtMmgydjJ6bTIuMDctNy43NWwtLjkuOTJjLS43Mi43My0xLjE3IDEuMzMtMS4xNyAyLjgzaC0ydi0uNWMwLTEuMS40NS0yLjEgMS4xNy0yLjgzbDEuMjQtMS4yNmMuMzctLjM2LjU5LS44Ni41OS0xLjQxIDAtMS4xLS45LTItMi0ycy0yIC45LTIgMmgtMmMwLTIuMjEgMS43OS00IDQtNHM0IDEuNzkgNCA0YzAgLjg4LS4zNiAxLjY4LS45MyAyLjI1eiIgZmlsbD0iIzZhNzU4YSIvPgo8L3N2Zz4K);
+    content: '';
+    margin-right: 10px;
+  }
+
+  #main {
+    margin: 30px auto;
+    max-width: 768px;
+    text-align: center;
+  }
+
+  #main section {
+    background: #fff;
+    border: 1px solid #ebebeb;
+    border-radius: 4px;
+    margin: 20px auto;
+    padding: 5px 30px;
+    position: relative;
+  }
+
+  #main a {
+    color: #3369e8;
+  }
+
+  #main a:active {
+    color: #2954ba;
+  }
+
+  #ssid {
+    margin: 0 20px;
+    max-width: 768px;
+    overflow: hidden;
+    text-overflow: ellipsis;
+    white-space: nowrap;
+  }
+
+  .init #manage,
+  .init #advanced,
+  .unsupported #status,
+  .disconnected #manage,
+  #advanced.collapsed #save,
+  #advanced.collapsed table,
+  .overlay {
+    display: none;
+  }
+
+  #status:before,
+  #loading:after {
+    background: url(data:image/gif;base64,R0lGODlhGAAYAPf/AHJYi2Ob9YHDxVKnVOiaRYu1%2Bt9TP7a7SUh02LvB5e6oNf7STj6C9BVrOiaLlj2C%2BH2s%2BfC4MuS8NcBLTuuVNuVuO8Wjuh6ceJe1RuNmQOFMQG%2B%2Bs8W5O6lbeNRwc7/R5kmJ%2BOvSfSyrX3RnqqK3TyeDq%2BbSiW%2BxUPa5S9jRqkVyNdxZU9DRvWWd%2Bf3SUd9OQx2oYvzRVEi0i8qSo1V6NnaLO1WoZNJ6gVK3lSGpYaLM5l2X%2BfbRZCeUUslSV7FTZSqWo7nR89bRrjuA90h75apTad9RSMmWqdtnJFVtxSVuyyWPeM/RwCpftjl%2B8XG/tsrRy7/Q5TaE4fzLTJ02NZlcg6y3QSWLib08MoKyStaBD12nx9lzGtzRohqQVINhmc/Rv3yr%2BeBLP7fR%2BhiESS6Huc1GPjKFzNd9ETiB7S2tcO7Rcx2OY8RMNed3OczRxbxPV5C5%2BjqA9ECE94Cu%2BRd4QYxejfTSaCR1vrR6leZxQllZmDl35uKWab7Mu99bOTKJzp5ni8HL3dDHtdfJpyOieUuls0OqlCmihXB1vt1NPo5BUtxJPjl%2B9P/RTTR46tFEOSVq0RtgwKMxKaTM6SSrZ6jI%2Bx%2BNbjd98afH%2BqfH%2Bzl/9KPM6PfSX0GF%2BHuq%2BFuV9iGqY0WH9cOowabH%2BtRvctJ5f95QR0eJ9%2BBPRCWraHPAuIfFzKXG%2Br3R69pgXI%2B4%2Bb/R5LzA47vB5OBLQDWvePzSVcBDMz2aUGZruPfASk%2BfUJE%2BTPvQTTCRtvXDXNPFToqa2EB13%2BDRljd35%2BXMhkuC6dh4Fk1vy1N009fMhKa4T9d3PcBUYOCNQcy4UOOWUz1769%2Bta5K9p/GeSdNuO5%2B3Vj%2BSzb9AM%2BF0UoFJaYZVeqBSapVTcSCRi4yWPvPOTejRg8yUmtqcgGOBOMjR0aajQYlfkTCNwvDRcB6MUkSVuru9ZkiJ92G4jR%2BpXzmC8OBaQyySsKCLt6fL3s1mQRmQUfG9MjqA9%2BFLPzh%2B9DuB%2BOBKPv/STR2pYLbR%2ByH/C05FVFNDQVBFMi4wAwEAAAAh/wtYTVAgRGF0YVhNUDw/eHBhY2tldCBiZWdpbj0i77u/IiBpZD0iVzVNME1wQ2VoaUh6cmVTek5UY3prYzlkIj8%2BIDx4OnhtcG1ldGEgeG1sbnM6eD0iYWRvYmU6bnM6bWV0YS8iIHg6eG1wdGs9IkFkb2JlIFhNUCBDb3JlIDUuMy1jMDExIDY2LjE0NTY2MSwgMjAxMi8wMi8wNi0xNDo1NjoyNyAgICAgICAgIj4gPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4gPHJkZjpEZXNjcmlwdGlvbiByZGY6YWJvdXQ9IiIgeG1sbnM6eG1wTU09Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9tbS8iIHhtbG5zOnN0UmVmPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvc1R5cGUvUmVzb3VyY2VSZWYjIiB4bWxuczp4bXA9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC8iIHhtcE1NOk9yaWdpbmFsRG9jdW1lbnRJRD0ieG1wLmRpZDo1RkI0NUM4ODFFMjE2ODExODA4M0JBNDNFM0Q1NEYzOCIgeG1wTU06RG9jdW1lbnRJRD0ieG1wLmRpZDpDOUU0MzBGRTM2OEQxMUUyOTAxMkREREU2RUFERDE0RiIgeG1wTU06SW5zdGFuY2VJRD0ieG1wLmlpZDpDOUU0MzBGRDM2OEQxMUUyOTAxMkREREU2RUFERDE0RiIgeG1wOkNyZWF0b3JUb29sPSJBZG9iZSBQaG90b3Nob3AgQ1M1IE1hY2ludG9zaCI%2BIDx4bXBNTTpEZXJpdmVkRnJvbSBzdFJlZjppbnN0YW5jZUlEPSJ4bXAuaWlkOjA3ODAxMTc0MDcyMDY4MTE4OEM2RjVBOThGQUNEQkQ5IiBzdFJlZjpkb2N1bWVudElEPSJ4bXAuZGlkOjVGQjQ1Qzg4MUUyMTY4MTE4MDgzQkE0M0UzRDU0RjM4Ii8%2BIDwvcmRmOkRlc2NyaXB0aW9uPiA8L3JkZjpSREY%2BIDwveDp4bXBtZXRhPiA8P3hwYWNrZXQgZW5kPSJyIj8%2BAf/%2B/fz7%2Bvn49/b19PPy8fDv7u3s6%2Brp6Ofm5eTj4uHg397d3Nva2djX1tXU09LR0M/OzczLysnIx8bFxMPCwcC/vr28u7q5uLe2tbSzsrGwr66trKuqqainpqWko6KhoJ%2BenZybmpmYl5aVlJOSkZCPjo2Mi4qJiIeGhYSDgoGAf359fHt6eXh3dnV0c3JxcG9ubWxramloZ2ZlZGNiYWBfXl1cW1pZWFdWVVRTUlFQT05NTEtKSUhHRkVEQ0JBQD8%2BPTw7Ojk4NzY1NDMyMTAvLi0sKyopKCcmJSQjIiEgHx4dHBsaGRgXFhUUExIREA8ODQwLCgkIBwYFBAMCAQAAIfkEBQAA/wAsAAAAABgAGAAACJgA/wkc%2BE9TmB3tHngCsSMMKYIQB2pqsQ%2BfxYsW97XQFHEgrDkYQ16cU6AjnYoiU%2BKjA7GAypf4SgrUBBJmyjkc/7Ww%2BbJFQZQ8Re4zGPTlwaIqd6BCmhLVA6YiHzyFivHBUqoXUe3AerEhV4sOgULd93AnVp8FazLFOdAlVJkDTyKF0PEjT5IdZ1JUqTFn3pkHnVpt2KpjQAAh%2BQQFAAD/ACwAAAAAGAAYAAAIxgD/CRz4L8EMDytSGXnlYYYsghAHJrihgZaPDlU6/PBBS8ONBBEHjjLSbISwRyhTChvRzIiFkEd85EpJs2YuH0cgWuhwsqZPlMI6vBSYoAOxn0gfEesA8l8ePkmT8snzb1aSqFGTJJiHFeu8RF2jJkIQNikCqGV/8pGjr63bt3Dj6pMjSq5duaJA3d3rFtQnvnw//dsE2O4mgQEKyw0gMBMDxW8ZZCII2W1IwopDCnzMl4HmxgEwx90UYPLnxp9AiZJD16/mgAAh%2BQQFAAD/ACwAAAEAGAALAAAIaAD/CRwoMJ/BgwYJKlSIsOHBhQQ1OJyYTwNEiRQnWiRoJGNGIwNvIKQFh9s2ACgBbOMGhxZCUwIlvuC2p4mkmzhzNtnDTVHFWTNodbOZs6jRJt1ozfCwx6hTp3s8AHhKtSgAolWrNgkIACH5BAUAAP8ALAAABgAYAAoAAAhWAP8JHJivoMGDBQcqFIiwoUOFDiM%2B/CexosF/KyxaXJFKY8UXiyaJHEmypMlJi4wBoHKy5UgqAIwJxJQEgDZeLE9S4aUNQJJGCxdGQ4AAGTKi0YIKDAgAIfkEBQAA/wAsAAAMABgACgAACFgAzUAaSLCgwYOQzMBByBAhnEANIxIMFEyixGD/7FhkaOefxx8bDf7w%2BM8SkSIhBxYhQtJjoxECJZr54qQlSSdEzk1gOOEcS5tAGyUb8aWKnS8jkjUC%2Bi8gACH5BAUAAP8ALAAAAAAYABgAAAi3AP8JHDiw1L8XiuJlGyeIoEOCpv7xm8iPkUVGBvpEeeiQIsWLF/UM4vhPjMePIC1mIPTQ5MmJKUOCIXjqJcqYjKh9GGjzZkwDxQS67Inzok6JPWEWZaQnBdKkSzGGeEo06q8XSZUuRYE1a1RGKFZkrWi11NioBkLMOLu0qayhNqMeNeW1KFCBCWpWxXlUoAW4HovqmUnwCGCtIFdyHGXkZUyRJP8lMAUXZMaNkfHOKLUCoUKGHAMCACH5BAUAAP8ALAwAAAAMABgAAAiLANvUk5Yi1r%2BDCP9hWehsgYkPCRUuxEKgny0wCSdiuaWrXz8hCDViYeaxH5ODIrFN8WgLpciKHk1I1Fit5IKZE9uU7Idz4kqPPRd29NhJ5EIUJV0YxYLUo1KjQ/sVNfqTp1GdJYPW9HjTKMx%2BMlP%2BbNmTJFCXCzl6BImWokUWGRk6hJjw1sCCEQ8GBAAh%2BQQFAAD/ACwLAAAACQAYAAAIUAD/2LoT4p9Bg0j6Kex38F/ChQwNcoGoUCLFiMcuMkSj8R/Hi/%2B0dBSpkeRFkxRRQlS5kKVClx47fqT4LyPIiTT/4YSIMKdDngf/LOhUsGFAACH5BAUAAP8ALAMAAAAKABgAAAhfAP8JFCgOmoIIAwVWiHCv4cBTFRpKHJiBocR7//IpUnDR4T83HT1yDPnvT0iHIE/%2Bo3ASI0uVI0nG7LiyZU2VKUmaVPlv5sSPPMX4xPiPX0WaAyNeTPhv4c%2BEBQ%2B6CAgAIfkEBQAA/wAsAAAAAAwAGAAACGAA/wkc%2BO9DihCdXBAsaGJBv4f9CL6xBRHiQCEOKz4UyERjxYIUPW40IRLih4wlU5SEGGLlw04u%2B7mIOdMlTJctXap0edLlP5IrQQb9B2boP4wiJYb8SPBDQ6YLDSJ0ERAAIfkECQAA/wAsAAAAABgAGAAACJIA/wkcSLCgwYMIEypcyLChw4cQI0qcSLGixYsMT9zbyLGjx48bbYAcOVKGBJIoOW7QmJIkO045rLQEyYHePxw5DszsyMGdQEqV3mU5ORODCEoDBfjzJwID0ZFWBvgTUHDVu6WhBmSxwqGrFQwDcix9cpCVqqVo06JVRRUhJRxq1eJAupDSBhlqQoVSI2ODjoMBAQAh%2BQQFAAD/ACwAAAAAGAAYAAAIwQD/CRw4MBYUFizKfSDIsKGrLjwcSZzIo4urhgTHpIgxsePEGCnGYAwSzqPJiSGCMAyy5qRLR2tUDjTx8qWJgW9q1nzzb8wdnS/vjIECtCaUYUVfDkuX1GW6Z%2Ba%2B1ahBjoZVFVizWqVBbuo3c89C%2BRtLtqzZs/5yqEHLFq0aGW3jkpWxQa7cDZzsxuX0D4detDgEUqr0t2wlSgMFFCYrgOGqxU8wslJlt1JjjP8o%2BW2LAzHmgZQ2yFATKtTbDTowBgQAIfkEBQAA/wAsAAACABgACgAACF0A/wkcKLAXsAPLlh0A1ougQ4LgSOzqYa%2BixR67SIB7OJAERYsgQfYg8VDZrpAoQ%2B5SNtAPrpQwLeLyI3CXl5g4d/2bhrOnvWk2fOK08VFoyh7WSChdyrSpUxLWAgIAIfkEBQAA/wAsAAAAABgADQAACF4A/wkc%2BI/SBhlqQlWqJWODDoIQB1LCkcOfxYsXcVCKOJCVKowgL1YSwHFVyJMXn0AUgLKlP5ICKVVyibLSxn84aLbE8Y%2BTTpecNvxseXAoSoRGTyZMGjIUx6dPQwUEACH5BAUAAP8ALAAAAAAMABgAAAiTAP8JHPjP0qd1DhwQLNjiDBt79i4RjOOpjBeIEQfSeXDGy0WIEv8V2AfvIcaMljztK3ES5L8W%2Bx6YPHlJ04N9Z1qChLBvnwOdEXf0XAL0UrueM2ne3Pex5aWlTWke3ZcU4yWh%2By4V5bnvSlGb%2B8oUfbkvTVSXKbvqDDlSylqND37SnOjJq9WFlgLYzbiw4BailwICACH5BAUAAP8ALAMAAAAKABgAAAhVAP8JFBhmB4gH6gb%2Be7Cv4T4yAx06rCNQ4sR/Fh02wJhxX4OODT%2BCFNmRZEaTFlFKVKkRpEeXLEPChMkxI0WQFGtKhFjRIs9/LRg6tKfwX8F2D3IEBAAh%2BQQFAAD/ACwLAAAACAAYAAAIUwCdKClx6J/Bf5giRVJyAYbBhAojXXgYMRIehxAjekNY0SLHjhcyRnQgUmGJkhZRKkEZSaXLjilhnoRJEmbIjng%2BatRpEWNEhhQXTjQokODBfwEBACH5BAUAAP8ALAsAAAAMABgAAAhoAB%2BkkXJty7%2BDCP8NacSwkRRDCRU2bOgr4cKJDNEhvIixkbyDHDE6QSSxI0NAJU02KhSyI5CWGAHBnChlZkMnNhniVMmwJs9GMn%2B%2B/MmSJ0qbI1Ni/Ki0oUaQHStupAkxoUCCBiP%2BCwgAOw%3D%3D) no-repeat center center;
+  }
+
+  #status:before {
+    border-radius: 6px;
+    content: '';
+    display: inline-block;
+    margin-right: 10px;
+    height: 12px;
+    width: 12px;
+  }
+
+  .disconnected #status:before {
+    background: #969ead;
+  }
+
+  .connected #status:before {
+    background: #56b300;
+  }
+
+  #manage {
+    font-size: 24px;
+    line-height: 1.25;
+  }
+
+  #manage a,
+  #restart a {
+    white-space: nowrap;
+  }
+
+  .connected #restart {
+    text-align: left;
+  }
+
+  .connected #restart .button {
+    position: absolute;
+    right: 30px;
+    top: 20px;
+  }
+
+  #advanced {
+    text-align: left;
+  }
+
+  #advanced button.toggle {
+    background: url(data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0Ij4KICAgIDxwYXRoIGQ9Ik0wIDBoMjR2MjRoLTI0eiIgZmlsbD0ibm9uZSIvPgogICAgPHBhdGggZD0iTTcuNDEgMTguNTlsMS40MiAxLjQxIDMuMTctMy4xNyAzLjE3IDMuMTcgMS40MS0xLjQxLTQuNTgtNC41OS00LjU5IDQuNTl6bTkuMTgtMTMuMThsLTEuNDItMS40MS0zLjE3IDMuMTctMy4xNy0zLjE3LTEuNDIgMS40MSA0LjU5IDQuNTkgNC41OS00LjU5eiIgZmlsbD0iIzZhNzU4YSIvPgo8L3N2Zz4K);
+    border: none;
+    cursor: pointer;
+    float: right;
+    margin: 18px 0 0;
+  }
+
+  #advanced #save {
+    float: right;
+    max-width: 198px;
+  }
+
+  #advanced table {
+    width: 63.739376770538%; /* 450px/706px */
+  }
+
+  #advanced th,
+  #advanced td {
+    padding: 6px 0;
+    vertical-align: top;
+  }
+
+  #advanced th {
+    padding-right: 16px;
+    width: 44%;
+  }
+
+  #advanced td {
+    width: 56%;
+  }
+
+  #advanced td span {
+    color: #777;
+  }
+
+  #advanced tr.onu th {
+    padding-left: 20px;
+  }
+
+  #advanced.collapsed button.toggle {
+    background: url(data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0Ij4KICAgIDxwYXRoIGQ9Ik0wIDBoMjR2MjRoLTI0eiIgZmlsbD0ibm9uZSIvPgogICAgPHBhdGggZD0iTTEyIDUuODNsMy4xNyAzLjE3IDEuNDEtMS40MS00LjU4LTQuNTktNC41OSA0LjU5IDEuNDIgMS40MSAzLjE3LTMuMTd6bTAgMTIuMzRsLTMuMTctMy4xNy0xLjQxIDEuNDEgNC41OCA0LjU5IDQuNTktNC41OS0xLjQyLTEuNDEtMy4xNyAzLjE3eiIgZmlsbD0iIzZhNzU4YSIvPgo8L3N2Zz4K);
+  }
+
+  .overlay {
+    background: rgba(0, 0, 0, .5);
+    height: 100%;
+    left: 0;
+    padding: 10px;
+    position: fixed;
+    top: 0;
+    width: 100%;
+    z-index: 2;
+  }
+
+  .overlay > div {
+    background: #fff;
+    border-radius: 4px;
+    margin: 180px auto 0;
+    max-width: 345px;
+    padding: 20px;
+    position: relative;
+    text-align: left;
+  }
+
+  .overlay h2 {
+    font-size: 28px;
+    margin: 0;
+  }
+
+  #loading:after {
+    border-radius: 10px;
+    content: '';
+    height: 20px;
+    position: absolute;
+    top: 280px;
+    width: 20px;
+  }
+
+  html.dialog #dialog,
+  html.error #error,
+  html.loading #loading {
+    display: block;
+  }
+
+}
+
+@media screen and (max-width: 767px) {
+
+  html,
+  body,
+  header,
+  footer {
+    padding: 0 8px;
+  }
+
+  body,
+  header,
+  footer {
+    margin: 0 -8px;
+  }
+
+  header nav {
+    display: none;
+  }
+
+  footer ul {
+    padding: 10px 5px;
+  }
+
+  #main {
+    margin: 20px auto;
+  }
+
+  #main section {
+    padding: 5px 20px;
+  }
+
+  .connected #restart {
+    text-align: center;
+  }
+
+  .connected #restart .button {
+    position: static;
+    right: auto;
+    top: auto;
+  }
+
+  #advanced #save {
+    float: none;
+    max-width: 100%;
+  }
+
+  #advanced table {
+    width: 100%;
+  }
+
+  #advanced th {
+    width: 46.052631578947%;
+  }
+
+  #advanced td {
+    width: 53.947368421053%;
+  }
+
+}
+
+@media print {
+
+  header,
+  footer,
+  #status,
+  #manage,
+  #restart,
+  .overlay,
+  #advanced button.toggle,
+  #advanced #save {
+    display: none;
+  }
+
+}
diff --git a/craftui/www/static/dygraph-combined.js b/craftui/www/static/dygraph-combined.js
new file mode 100644
index 0000000..7d6121e
--- /dev/null
+++ b/craftui/www/static/dygraph-combined.js
@@ -0,0 +1,6 @@
+/*! @license Copyright 2014 Dan Vanderkam (danvdk@gmail.com) MIT-licensed (http://opensource.org/licenses/MIT) */
+!function(t){"use strict";for(var e,a,i={},r=function(){},n="memory".split(","),o="assert,clear,count,debug,dir,dirxml,error,exception,group,groupCollapsed,groupEnd,info,log,markTimeline,profile,profiles,profileEnd,show,table,time,timeEnd,timeline,timelineEnd,timeStamp,trace,warn".split(",");e=n.pop();)t[e]=t[e]||i;for(;a=o.pop();)t[a]=t[a]||r}(this.console=this.console||{}),function(){"use strict";CanvasRenderingContext2D.prototype.installPattern=function(t){if("undefined"!=typeof this.isPatternInstalled)throw"Must un-install old line pattern before installing a new one.";this.isPatternInstalled=!0;var e=[0,0],a=[],i=this.beginPath,r=this.lineTo,n=this.moveTo,o=this.stroke;this.uninstallPattern=function(){this.beginPath=i,this.lineTo=r,this.moveTo=n,this.stroke=o,this.uninstallPattern=void 0,this.isPatternInstalled=void 0},this.beginPath=function(){a=[],i.call(this)},this.moveTo=function(t,e){a.push([[t,e]]),n.call(this,t,e)},this.lineTo=function(t,e){var i=a[a.length-1];i.push([t,e])},this.stroke=function(){if(0===a.length)return void o.call(this);for(var i=0;i<a.length;i++)for(var s=a[i],l=s[0][0],h=s[0][1],p=1;p<s.length;p++){var g=s[p][0],d=s[p][1];this.save();var u=g-l,c=d-h,y=Math.sqrt(u*u+c*c),_=Math.atan2(c,u);this.translate(l,h),n.call(this,0,0),this.rotate(_);for(var v=e[0],f=0;y>f;){var x=t[v];f+=e[1]?e[1]:x,f>y?(e=[v,f-y],f=y):e=[(v+1)%t.length,0],v%2===0?r.call(this,f,0):n.call(this,f,0),v=(v+1)%t.length}this.restore(),l=g,h=d}o.call(this),a=[]}},CanvasRenderingContext2D.prototype.uninstallPattern=function(){throw"Must install a line pattern before uninstalling it."}}();var DygraphOptions=function(){return function(){"use strict";var t=function(t){this.dygraph_=t,this.yAxes_=[],this.xAxis_={},this.series_={},this.global_=this.dygraph_.attrs_,this.user_=this.dygraph_.user_attrs_||{},this.labels_=[],this.highlightSeries_=this.get("highlightSeriesOpts")||{},this.reparseSeries()};t.AXIS_STRING_MAPPINGS_={y:0,Y:0,y1:0,Y1:0,y2:1,Y2:1},t.axisToIndex_=function(e){if("string"==typeof e){if(t.AXIS_STRING_MAPPINGS_.hasOwnProperty(e))return t.AXIS_STRING_MAPPINGS_[e];throw"Unknown axis : "+e}if("number"==typeof e){if(0===e||1===e)return e;throw"Dygraphs only supports two y-axes, indexed from 0-1."}if(e)throw"Unknown axis : "+e;return 0},t.prototype.reparseSeries=function(){var e=this.get("labels");if(e){this.labels_=e.slice(1),this.yAxes_=[{series:[],options:{}}],this.xAxis_={options:{}},this.series_={};var a=!this.user_.series;if(a){for(var i=0,r=0;r<this.labels_.length;r++){var n=this.labels_[r],o=this.user_[n]||{},s=0,l=o.axis;"object"==typeof l&&(s=++i,this.yAxes_[s]={series:[n],options:l}),l||this.yAxes_[0].series.push(n),this.series_[n]={idx:r,yAxis:s,options:o}}for(var r=0;r<this.labels_.length;r++){var n=this.labels_[r],o=this.series_[n].options,l=o.axis;if("string"==typeof l){if(!this.series_.hasOwnProperty(l))return void console.error("Series "+n+" wants to share a y-axis with series "+l+", which does not define its own axis.");var s=this.series_[l].yAxis;this.series_[n].yAxis=s,this.yAxes_[s].series.push(n)}}}else for(var r=0;r<this.labels_.length;r++){var n=this.labels_[r],o=this.user_.series[n]||{},s=t.axisToIndex_(o.axis);this.series_[n]={idx:r,yAxis:s,options:o},this.yAxes_[s]?this.yAxes_[s].series.push(n):this.yAxes_[s]={series:[n],options:{}}}var h=this.user_.axes||{};Dygraph.update(this.yAxes_[0].options,h.y||{}),this.yAxes_.length>1&&Dygraph.update(this.yAxes_[1].options,h.y2||{}),Dygraph.update(this.xAxis_.options,h.x||{})}},t.prototype.get=function(t){var e=this.getGlobalUser_(t);return null!==e?e:this.getGlobalDefault_(t)},t.prototype.getGlobalUser_=function(t){return this.user_.hasOwnProperty(t)?this.user_[t]:null},t.prototype.getGlobalDefault_=function(t){return this.global_.hasOwnProperty(t)?this.global_[t]:Dygraph.DEFAULT_ATTRS.hasOwnProperty(t)?Dygraph.DEFAULT_ATTRS[t]:null},t.prototype.getForAxis=function(t,e){var a,i;if("number"==typeof e)a=e,i=0===a?"y":"y2";else{if("y1"==e&&(e="y"),"y"==e)a=0;else if("y2"==e)a=1;else{if("x"!=e)throw"Unknown axis "+e;a=-1}i=e}var r=-1==a?this.xAxis_:this.yAxes_[a];if(r){var n=r.options;if(n.hasOwnProperty(t))return n[t]}if("x"!==e||"logscale"!==t){var o=this.getGlobalUser_(t);if(null!==o)return o}var s=Dygraph.DEFAULT_ATTRS.axes[i];return s.hasOwnProperty(t)?s[t]:this.getGlobalDefault_(t)},t.prototype.getForSeries=function(t,e){if(e===this.dygraph_.getHighlightSeries()&&this.highlightSeries_.hasOwnProperty(t))return this.highlightSeries_[t];if(!this.series_.hasOwnProperty(e))throw"Unknown series: "+e;var a=this.series_[e],i=a.options;return i.hasOwnProperty(t)?i[t]:this.getForAxis(t,a.yAxis)},t.prototype.numAxes=function(){return this.yAxes_.length},t.prototype.axisForSeries=function(t){return this.series_[t].yAxis},t.prototype.axisOptions=function(t){return this.yAxes_[t].options},t.prototype.seriesForAxis=function(t){return this.yAxes_[t].series},t.prototype.seriesNames=function(){return this.labels_};return t}()}(),DygraphLayout=function(){"use strict";var t=function(t){this.dygraph_=t,this.points=[],this.setNames=[],this.annotations=[],this.yAxes_=null,this.xTicks_=null,this.yTicks_=null};return t.prototype.addDataset=function(t,e){this.points.push(e),this.setNames.push(t)},t.prototype.getPlotArea=function(){return this.area_},t.prototype.computePlotArea=function(){var t={x:0,y:0};t.w=this.dygraph_.width_-t.x-this.dygraph_.getOption("rightGap"),t.h=this.dygraph_.height_;var e={chart_div:this.dygraph_.graphDiv,reserveSpaceLeft:function(e){var a={x:t.x,y:t.y,w:e,h:t.h};return t.x+=e,t.w-=e,a},reserveSpaceRight:function(e){var a={x:t.x+t.w-e,y:t.y,w:e,h:t.h};return t.w-=e,a},reserveSpaceTop:function(e){var a={x:t.x,y:t.y,w:t.w,h:e};return t.y+=e,t.h-=e,a},reserveSpaceBottom:function(e){var a={x:t.x,y:t.y+t.h-e,w:t.w,h:e};return t.h-=e,a},chartRect:function(){return{x:t.x,y:t.y,w:t.w,h:t.h}}};this.dygraph_.cascadeEvents_("layout",e),this.area_=t},t.prototype.setAnnotations=function(t){this.annotations=[];for(var e=this.dygraph_.getOption("xValueParser")||function(t){return t},a=0;a<t.length;a++){var i={};if(!t[a].xval&&void 0===t[a].x)return void console.error("Annotations must have an 'x' property");if(t[a].icon&&(!t[a].hasOwnProperty("width")||!t[a].hasOwnProperty("height")))return void console.error("Must set width and height when setting annotation.icon property");Dygraph.update(i,t[a]),i.xval||(i.xval=e(i.x)),this.annotations.push(i)}},t.prototype.setXTicks=function(t){this.xTicks_=t},t.prototype.setYAxes=function(t){this.yAxes_=t},t.prototype.evaluate=function(){this._xAxis={},this._evaluateLimits(),this._evaluateLineCharts(),this._evaluateLineTicks(),this._evaluateAnnotations()},t.prototype._evaluateLimits=function(){var t=this.dygraph_.xAxisRange();this._xAxis.minval=t[0],this._xAxis.maxval=t[1];var e=t[1]-t[0];this._xAxis.scale=0!==e?1/e:1,this.dygraph_.getOptionForAxis("logscale","x")&&(this._xAxis.xlogrange=Dygraph.log10(this._xAxis.maxval)-Dygraph.log10(this._xAxis.minval),this._xAxis.xlogscale=0!==this._xAxis.xlogrange?1/this._xAxis.xlogrange:1);for(var a=0;a<this.yAxes_.length;a++){var i=this.yAxes_[a];i.minyval=i.computedValueRange[0],i.maxyval=i.computedValueRange[1],i.yrange=i.maxyval-i.minyval,i.yscale=0!==i.yrange?1/i.yrange:1,this.dygraph_.getOption("logscale")&&(i.ylogrange=Dygraph.log10(i.maxyval)-Dygraph.log10(i.minyval),i.ylogscale=0!==i.ylogrange?1/i.ylogrange:1,(!isFinite(i.ylogrange)||isNaN(i.ylogrange))&&console.error("axis "+a+" of graph at "+i.g+" can't be displayed in log scale for range ["+i.minyval+" - "+i.maxyval+"]"))}},t.calcXNormal_=function(t,e,a){return a?(Dygraph.log10(t)-Dygraph.log10(e.minval))*e.xlogscale:(t-e.minval)*e.scale},t.calcYNormal_=function(t,e,a){if(a){var i=1-(Dygraph.log10(e)-Dygraph.log10(t.minyval))*t.ylogscale;return isFinite(i)?i:0/0}return 1-(e-t.minyval)*t.yscale},t.prototype._evaluateLineCharts=function(){for(var e=this.dygraph_.getOption("stackedGraph"),a=this.dygraph_.getOptionForAxis("logscale","x"),i=0;i<this.points.length;i++){for(var r=this.points[i],n=this.setNames[i],o=this.dygraph_.getOption("connectSeparatedPoints",n),s=this.dygraph_.axisPropertiesForSeries(n),l=this.dygraph_.attributes_.getForSeries("logscale",n),h=0;h<r.length;h++){var p=r[h];p.x=t.calcXNormal_(p.xval,this._xAxis,a);var g=p.yval;e&&(p.y_stacked=t.calcYNormal_(s,p.yval_stacked,l),null===g||isNaN(g)||(g=p.yval_stacked)),null===g&&(g=0/0,o||(p.yval=0/0)),p.y=t.calcYNormal_(s,g,l)}this.dygraph_.dataHandler_.onLineEvaluated(r,s,l)}},t.prototype._evaluateLineTicks=function(){var t,e,a,i;for(this.xticks=[],t=0;t<this.xTicks_.length;t++)e=this.xTicks_[t],a=e.label,i=this.dygraph_.toPercentXCoord(e.v),i>=0&&1>i&&this.xticks.push([i,a]);for(this.yticks=[],t=0;t<this.yAxes_.length;t++)for(var r=this.yAxes_[t],n=0;n<r.ticks.length;n++)e=r.ticks[n],a=e.label,i=this.dygraph_.toPercentYCoord(e.v,t),i>0&&1>=i&&this.yticks.push([t,i,a])},t.prototype._evaluateAnnotations=function(){var t,e={};for(t=0;t<this.annotations.length;t++){var a=this.annotations[t];e[a.xval+","+a.series]=a}if(this.annotated_points=[],this.annotations&&this.annotations.length)for(var i=0;i<this.points.length;i++){var r=this.points[i];for(t=0;t<r.length;t++){var n=r[t],o=n.xval+","+n.name;o in e&&(n.annotation=e[o],this.annotated_points.push(n))}}},t.prototype.removeAllDatasets=function(){delete this.points,delete this.setNames,delete this.setPointsLengths,delete this.setPointsOffsets,this.points=[],this.setNames=[],this.setPointsLengths=[],this.setPointsOffsets=[]},t}(),DygraphCanvasRenderer=function(){"use strict";var t=function(t,e,a,i){if(this.dygraph_=t,this.layout=i,this.element=e,this.elementContext=a,this.height=t.height_,this.width=t.width_,!this.isIE&&!Dygraph.isCanvasSupported(this.element))throw"Canvas is not supported.";if(this.area=i.getPlotArea(),this.dygraph_.isUsingExcanvas_)this._createIEClipArea();else if(!Dygraph.isAndroid()){var r=this.dygraph_.canvas_ctx_;r.beginPath(),r.rect(this.area.x,this.area.y,this.area.w,this.area.h),r.clip(),r=this.dygraph_.hidden_ctx_,r.beginPath(),r.rect(this.area.x,this.area.y,this.area.w,this.area.h),r.clip()}};return t.prototype.clear=function(){var t;if(this.isIE)try{this.clearDelay&&(this.clearDelay.cancel(),this.clearDelay=null),t=this.elementContext}catch(e){return}t=this.elementContext,t.clearRect(0,0,this.width,this.height)},t.prototype.render=function(){this._updatePoints(),this._renderLineChart()},t.prototype._createIEClipArea=function(){function t(t){if(0!==t.w&&0!==t.h){var i=document.createElement("div");i.className=e,i.style.backgroundColor=r,i.style.position="absolute",i.style.left=t.x+"px",i.style.top=t.y+"px",i.style.width=t.w+"px",i.style.height=t.h+"px",a.appendChild(i)}}for(var e="dygraph-clip-div",a=this.dygraph_.graphDiv,i=a.childNodes.length-1;i>=0;i--)a.childNodes[i].className==e&&a.removeChild(a.childNodes[i]);for(var r=document.bgColor,n=this.dygraph_.graphDiv;n!=document;){var o=n.currentStyle.backgroundColor;if(o&&"transparent"!=o){r=o;break}n=n.parentNode}var s=this.area;t({x:0,y:0,w:s.x,h:this.height}),t({x:s.x,y:0,w:this.width-s.x,h:s.y}),t({x:s.x+s.w,y:0,w:this.width-s.x-s.w,h:this.height}),t({x:s.x,y:s.y+s.h,w:this.width-s.x,h:this.height-s.h-s.y})},t._getIteratorPredicate=function(e){return e?t._predicateThatSkipsEmptyPoints:null},t._predicateThatSkipsEmptyPoints=function(t,e){return null!==t[e].yval},t._drawStyledLine=function(e,a,i,r,n,o,s){var l=e.dygraph,h=l.getBooleanOption("stepPlot",e.setName);Dygraph.isArrayLike(r)||(r=null);var p=l.getBooleanOption("drawGapEdgePoints",e.setName),g=e.points,d=e.setName,u=Dygraph.createIterator(g,0,g.length,t._getIteratorPredicate(l.getBooleanOption("connectSeparatedPoints",d))),c=r&&r.length>=2,y=e.drawingContext;y.save(),c&&y.installPattern(r);var _=t._drawSeries(e,u,i,s,n,p,h,a);t._drawPointsOnLine(e,_,o,a,s),c&&y.uninstallPattern(),y.restore()},t._drawSeries=function(t,e,a,i,r,n,o,s){var l,h,p=null,g=null,d=null,u=[],c=!0,y=t.drawingContext;y.beginPath(),y.strokeStyle=s,y.lineWidth=a;for(var _=e.array_,v=e.end_,f=e.predicate_,x=e.start_;v>x;x++){if(h=_[x],f){for(;v>x&&!f(_,x);)x++;if(x==v)break;h=_[x]}if(null===h.canvasy||h.canvasy!=h.canvasy)o&&null!==p&&(y.moveTo(p,g),y.lineTo(h.canvasx,g)),p=g=null;else{if(l=!1,n||!p){e.nextIdx_=x,e.next(),d=e.hasNext?e.peek.canvasy:null;var m=null===d||d!=d;l=!p&&m,n&&(!c&&!p||e.hasNext&&m)&&(l=!0)}null!==p?a&&(o&&(y.moveTo(p,g),y.lineTo(h.canvasx,g)),y.lineTo(h.canvasx,h.canvasy)):y.moveTo(h.canvasx,h.canvasy),(r||l)&&u.push([h.canvasx,h.canvasy,h.idx]),p=h.canvasx,g=h.canvasy}c=!1}return y.stroke(),u},t._drawPointsOnLine=function(t,e,a,i,r){for(var n=t.drawingContext,o=0;o<e.length;o++){var s=e[o];n.save(),a.call(t.dygraph,t.dygraph,t.setName,n,s[0],s[1],i,r,s[2]),n.restore()}},t.prototype._updatePoints=function(){for(var t=this.layout.points,e=t.length;e--;)for(var a=t[e],i=a.length;i--;){var r=a[i];r.canvasx=this.area.w*r.x+this.area.x,r.canvasy=this.area.h*r.y+this.area.y}},t.prototype._renderLineChart=function(t,e){var a,i,r=e||this.elementContext,n=this.layout.points,o=this.layout.setNames;this.colors=this.dygraph_.colorsMap_;var s=this.dygraph_.getOption("plotter"),l=s;Dygraph.isArrayLike(l)||(l=[l]);var h={};for(a=0;a<o.length;a++){i=o[a];var p=this.dygraph_.getOption("plotter",i);p!=s&&(h[i]=p)}for(a=0;a<l.length;a++)for(var g=l[a],d=a==l.length-1,u=0;u<n.length;u++)if(i=o[u],!t||i==t){var c=n[u],y=g;if(i in h){if(!d)continue;y=h[i]}var _=this.colors[i],v=this.dygraph_.getOption("strokeWidth",i);r.save(),r.strokeStyle=_,r.lineWidth=v,y({points:c,setName:i,drawingContext:r,color:_,strokeWidth:v,dygraph:this.dygraph_,axis:this.dygraph_.axisPropertiesForSeries(i),plotArea:this.area,seriesIndex:u,seriesCount:n.length,singleSeriesName:t,allSeriesPoints:n}),r.restore()}},t._Plotters={linePlotter:function(e){t._linePlotter(e)},fillPlotter:function(e){t._fillPlotter(e)},errorPlotter:function(e){t._errorPlotter(e)}},t._linePlotter=function(e){var a=e.dygraph,i=e.setName,r=e.strokeWidth,n=a.getNumericOption("strokeBorderWidth",i),o=a.getOption("drawPointCallback",i)||Dygraph.Circles.DEFAULT,s=a.getOption("strokePattern",i),l=a.getBooleanOption("drawPoints",i),h=a.getNumericOption("pointSize",i);n&&r&&t._drawStyledLine(e,a.getOption("strokeBorderColor",i),r+2*n,s,l,o,h),t._drawStyledLine(e,e.color,r,s,l,o,h)},t._errorPlotter=function(e){var a=e.dygraph,i=e.setName,r=a.getBooleanOption("errorBars")||a.getBooleanOption("customBars");if(r){var n=a.getBooleanOption("fillGraph",i);n&&console.warn("Can't use fillGraph option with error bars");var o,s=e.drawingContext,l=e.color,h=a.getNumericOption("fillAlpha",i),p=a.getBooleanOption("stepPlot",i),g=e.points,d=Dygraph.createIterator(g,0,g.length,t._getIteratorPredicate(a.getBooleanOption("connectSeparatedPoints",i))),u=0/0,c=0/0,y=[-1,-1],_=Dygraph.toRGB_(l),v="rgba("+_.r+","+_.g+","+_.b+","+h+")";s.fillStyle=v,s.beginPath();for(var f=function(t){return null===t||void 0===t||isNaN(t)};d.hasNext;){var x=d.next();!p&&f(x.y)||p&&!isNaN(c)&&f(c)?u=0/0:(o=[x.y_bottom,x.y_top],p&&(c=x.y),isNaN(o[0])&&(o[0]=x.y),isNaN(o[1])&&(o[1]=x.y),o[0]=e.plotArea.h*o[0]+e.plotArea.y,o[1]=e.plotArea.h*o[1]+e.plotArea.y,isNaN(u)||(p?(s.moveTo(u,y[0]),s.lineTo(x.canvasx,y[0]),s.lineTo(x.canvasx,y[1])):(s.moveTo(u,y[0]),s.lineTo(x.canvasx,o[0]),s.lineTo(x.canvasx,o[1])),s.lineTo(u,y[1]),s.closePath()),y=o,u=x.canvasx)}s.fill()}},t._fastCanvasProxy=function(t){var e=[],a=null,i=null,r=1,n=2,o=0,s=function(t){if(!(e.length<=1)){for(var a=e.length-1;a>0;a--){var i=e[a];if(i[0]==n){var o=e[a-1];o[1]==i[1]&&o[2]==i[2]&&e.splice(a,1)}}for(var a=0;a<e.length-1;){var i=e[a];i[0]==n&&e[a+1][0]==n?e.splice(a,1):a++}if(e.length>2&&!t){var s=0;e[0][0]==n&&s++;for(var l=null,h=null,a=s;a<e.length;a++){var i=e[a];if(i[0]==r)if(null===l&&null===h)l=a,h=a;else{var p=i[2];p<e[l][2]?l=a:p>e[h][2]&&(h=a)}}var g=e[l],d=e[h];e.splice(s,e.length-s),h>l?(e.push(g),e.push(d)):l>h?(e.push(d),e.push(g)):e.push(g)}}},l=function(a){s(a);for(var l=0,h=e.length;h>l;l++){var p=e[l];p[0]==r?t.lineTo(p[1],p[2]):p[0]==n&&t.moveTo(p[1],p[2])}e.length&&(i=e[e.length-1][1]),o+=e.length,e=[]},h=function(t,r,n){var o=Math.round(r);if(null===a||o!=a){var s=a-i>1,h=o-a>1,p=s||h;l(p),a=o}e.push([t,r,n])};return{moveTo:function(t,e){h(n,t,e)},lineTo:function(t,e){h(r,t,e)},stroke:function(){l(!0),t.stroke()},fill:function(){l(!0),t.fill()},beginPath:function(){l(!0),t.beginPath()},closePath:function(){l(!0),t.closePath()},_count:function(){return o}}},t._fillPlotter=function(e){if(!e.singleSeriesName&&0===e.seriesIndex){for(var a=e.dygraph,i=a.getLabels().slice(1),r=i.length;r>=0;r--)a.visibility()[r]||i.splice(r,1);var n=function(){for(var t=0;t<i.length;t++)if(a.getBooleanOption("fillGraph",i[t]))return!0;return!1}();if(n)for(var o,s,l=e.plotArea,h=e.allSeriesPoints,p=h.length,g=a.getNumericOption("fillAlpha"),d=a.getBooleanOption("stackedGraph"),u=a.getColors(),c={},y=function(t,e,a,i){if(t.lineTo(e,a),d)for(var r=i.length-1;r>=0;r--){var n=i[r];t.lineTo(n[0],n[1])}},_=p-1;_>=0;_--){var v=e.drawingContext,f=i[_];if(a.getBooleanOption("fillGraph",f)){var x=a.getBooleanOption("stepPlot",f),m=u[_],D=a.axisPropertiesForSeries(f),w=1+D.minyval*D.yscale;0>w?w=0:w>1&&(w=1),w=l.h*w+l.y;var A,b=h[_],T=Dygraph.createIterator(b,0,b.length,t._getIteratorPredicate(a.getBooleanOption("connectSeparatedPoints",f))),E=0/0,C=[-1,-1],L=Dygraph.toRGB_(m),P="rgba("+L.r+","+L.g+","+L.b+","+g+")";v.fillStyle=P,v.beginPath();var S,O=!0;(b.length>2*a.width_||Dygraph.FORCE_FAST_PROXY)&&(v=t._fastCanvasProxy(v));for(var M,R=[];T.hasNext;)if(M=T.next(),Dygraph.isOK(M.y)||x){if(d){if(!O&&S==M.xval)continue;O=!1,S=M.xval,o=c[M.canvasx];var F;F=void 0===o?w:s?o[0]:o,A=[M.canvasy,F],x?-1===C[0]?c[M.canvasx]=[M.canvasy,w]:c[M.canvasx]=[M.canvasy,C[0]]:c[M.canvasx]=M.canvasy}else A=isNaN(M.canvasy)&&x?[l.y+l.h,w]:[M.canvasy,w];isNaN(E)?(v.moveTo(M.canvasx,A[1]),v.lineTo(M.canvasx,A[0])):(x?(v.lineTo(M.canvasx,C[0]),v.lineTo(M.canvasx,A[0])):v.lineTo(M.canvasx,A[0]),d&&(R.push([E,C[1]]),R.push(s&&o?[M.canvasx,o[1]]:[M.canvasx,A[1]]))),C=A,E=M.canvasx}else y(v,E,C[1],R),R=[],E=0/0,null===M.y_stacked||isNaN(M.y_stacked)||(c[M.canvasx]=l.h*M.y_stacked+l.y);s=x,A&&M&&(y(v,M.canvasx,A[1],R),R=[]),v.fill()}}}},t}(),Dygraph=function(){"use strict";var t=function(t,e,a,i){this.is_initial_draw_=!0,this.readyFns_=[],void 0!==i?(console.warn("Using deprecated four-argument dygraph constructor"),this.__old_init__(t,e,a,i)):this.__init__(t,e,a)};return t.NAME="Dygraph",t.VERSION="1.1.1",t.__repr__=function(){return"["+t.NAME+" "+t.VERSION+"]"},t.toString=function(){return t.__repr__()},t.DEFAULT_ROLL_PERIOD=1,t.DEFAULT_WIDTH=480,t.DEFAULT_HEIGHT=320,t.ANIMATION_STEPS=12,t.ANIMATION_DURATION=200,t.KMB_LABELS=["K","M","B","T","Q"],t.KMG2_BIG_LABELS=["k","M","G","T","P","E","Z","Y"],t.KMG2_SMALL_LABELS=["m","u","n","p","f","a","z","y"],t.numberValueFormatter=function(e,a){var i=a("sigFigs");if(null!==i)return t.floatFormat(e,i);var r,n=a("digitsAfterDecimal"),o=a("maxNumberWidth"),s=a("labelsKMB"),l=a("labelsKMG2");if(r=0!==e&&(Math.abs(e)>=Math.pow(10,o)||Math.abs(e)<Math.pow(10,-n))?e.toExponential(n):""+t.round_(e,n),s||l){var h,p=[],g=[];s&&(h=1e3,p=t.KMB_LABELS),l&&(s&&console.warn("Setting both labelsKMB and labelsKMG2. Pick one!"),h=1024,p=t.KMG2_BIG_LABELS,g=t.KMG2_SMALL_LABELS);for(var d=Math.abs(e),u=t.pow(h,p.length),c=p.length-1;c>=0;c--,u/=h)if(d>=u){r=t.round_(e/u,n)+p[c];break}if(l){var y=String(e.toExponential()).split("e-");2===y.length&&y[1]>=3&&y[1]<=24&&(r=y[1]%3>0?t.round_(y[0]/t.pow(10,y[1]%3),n):Number(y[0]).toFixed(2),r+=g[Math.floor(y[1]/3)-1])}}return r},t.numberAxisLabelFormatter=function(e,a,i){return t.numberValueFormatter.call(this,e,i)},t.SHORT_MONTH_NAMES_=["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"],t.dateAxisLabelFormatter=function(e,a,i){var r=i("labelsUTC"),n=r?t.DateAccessorsUTC:t.DateAccessorsLocal,o=n.getFullYear(e),s=n.getMonth(e),l=n.getDate(e),h=n.getHours(e),p=n.getMinutes(e),g=n.getSeconds(e),d=n.getSeconds(e);if(a>=t.DECADAL)return""+o;if(a>=t.MONTHLY)return t.SHORT_MONTH_NAMES_[s]+"&#160;"+o;var u=3600*h+60*p+g+.001*d;return 0===u||a>=t.DAILY?t.zeropad(l)+"&#160;"+t.SHORT_MONTH_NAMES_[s]:t.hmsString_(h,p,g)},t.dateAxisFormatter=t.dateAxisLabelFormatter,t.dateValueFormatter=function(e,a){return t.dateString_(e,a("labelsUTC"))},t.Plotters=DygraphCanvasRenderer._Plotters,t.DEFAULT_ATTRS={highlightCircleSize:3,highlightSeriesOpts:null,highlightSeriesBackgroundAlpha:.5,labelsDivWidth:250,labelsDivStyles:{},labelsSeparateLines:!1,labelsShowZeroValues:!0,labelsKMB:!1,labelsKMG2:!1,showLabelsOnHighlight:!0,digitsAfterDecimal:2,maxNumberWidth:6,sigFigs:null,strokeWidth:1,strokeBorderWidth:0,strokeBorderColor:"white",axisTickSize:3,axisLabelFontSize:14,rightGap:5,showRoller:!1,xValueParser:t.dateParser,delimiter:",",sigma:2,errorBars:!1,fractions:!1,wilsonInterval:!0,customBars:!1,fillGraph:!1,fillAlpha:.15,connectSeparatedPoints:!1,stackedGraph:!1,stackedGraphNaNFill:"all",hideOverlayOnMouseOut:!0,legend:"onmouseover",stepPlot:!1,avoidMinZero:!1,xRangePad:0,yRangePad:null,drawAxesAtZero:!1,titleHeight:28,xLabelHeight:18,yLabelWidth:18,drawXAxis:!0,drawYAxis:!0,axisLineColor:"black",axisLineWidth:.3,gridLineWidth:.3,axisLabelColor:"black",axisLabelWidth:50,drawYGrid:!0,drawXGrid:!0,gridLineColor:"rgb(128,128,128)",interactionModel:null,animatedZooms:!1,showRangeSelector:!1,rangeSelectorHeight:40,rangeSelectorPlotStrokeColor:"#808FAB",rangeSelectorPlotFillColor:"#A7B1C4",showInRangeSelector:null,plotter:[t.Plotters.fillPlotter,t.Plotters.errorPlotter,t.Plotters.linePlotter],plugins:[],axes:{x:{pixelsPerLabel:70,axisLabelWidth:60,axisLabelFormatter:t.dateAxisLabelFormatter,valueFormatter:t.dateValueFormatter,drawGrid:!0,drawAxis:!0,independentTicks:!0,ticker:null},y:{axisLabelWidth:50,pixelsPerLabel:30,valueFormatter:t.numberValueFormatter,axisLabelFormatter:t.numberAxisLabelFormatter,drawGrid:!0,drawAxis:!0,independentTicks:!0,ticker:null},y2:{axisLabelWidth:50,pixelsPerLabel:30,valueFormatter:t.numberValueFormatter,axisLabelFormatter:t.numberAxisLabelFormatter,drawAxis:!0,drawGrid:!1,independentTicks:!1,ticker:null}}},t.HORIZONTAL=1,t.VERTICAL=2,t.PLUGINS=[],t.addedAnnotationCSS=!1,t.prototype.__old_init__=function(e,a,i,r){if(null!==i){for(var n=["Date"],o=0;o<i.length;o++)n.push(i[o]);t.update(r,{labels:n})}this.__init__(e,a,r)},t.prototype.__init__=function(e,a,i){if(/MSIE/.test(navigator.userAgent)&&!window.opera&&"undefined"!=typeof G_vmlCanvasManager&&"complete"!=document.readyState){var r=this;return void setTimeout(function(){r.__init__(e,a,i)},100)}if((null===i||void 0===i)&&(i={}),i=t.mapLegacyOptions_(i),"string"==typeof e&&(e=document.getElementById(e)),!e)return void console.error("Constructing dygraph with a non-existent div!");this.isUsingExcanvas_="undefined"!=typeof G_vmlCanvasManager,this.maindiv_=e,this.file_=a,this.rollPeriod_=i.rollPeriod||t.DEFAULT_ROLL_PERIOD,this.previousVerticalX_=-1,this.fractions_=i.fractions||!1,this.dateWindow_=i.dateWindow||null,this.annotations_=[],this.zoomed_x_=!1,this.zoomed_y_=!1,e.innerHTML="",""===e.style.width&&i.width&&(e.style.width=i.width+"px"),""===e.style.height&&i.height&&(e.style.height=i.height+"px"),""===e.style.height&&0===e.clientHeight&&(e.style.height=t.DEFAULT_HEIGHT+"px",""===e.style.width&&(e.style.width=t.DEFAULT_WIDTH+"px")),this.width_=e.clientWidth||i.width||0,this.height_=e.clientHeight||i.height||0,i.stackedGraph&&(i.fillGraph=!0),this.user_attrs_={},t.update(this.user_attrs_,i),this.attrs_={},t.updateDeep(this.attrs_,t.DEFAULT_ATTRS),this.boundaryIds_=[],this.setIndexByName_={},this.datasetIndex_=[],this.registeredEvents_=[],this.eventListeners_={},this.attributes_=new DygraphOptions(this),this.createInterface_(),this.plugins_=[];for(var n=t.PLUGINS.concat(this.getOption("plugins")),o=0;o<n.length;o++){var s,l=n[o];s="undefined"!=typeof l.activate?l:new l;var h={plugin:s,events:{},options:{},pluginOptions:{}},p=s.activate(this);for(var g in p)p.hasOwnProperty(g)&&(h.events[g]=p[g]);this.plugins_.push(h)}for(var o=0;o<this.plugins_.length;o++){var d=this.plugins_[o];for(var g in d.events)if(d.events.hasOwnProperty(g)){var u=d.events[g],c=[d.plugin,u];g in this.eventListeners_?this.eventListeners_[g].push(c):this.eventListeners_[g]=[c]}}this.createDragInterface_(),this.start_()},t.prototype.cascadeEvents_=function(e,a){if(!(e in this.eventListeners_))return!1;var i={dygraph:this,cancelable:!1,defaultPrevented:!1,preventDefault:function(){if(!i.cancelable)throw"Cannot call preventDefault on non-cancelable event.";i.defaultPrevented=!0},propagationStopped:!1,stopPropagation:function(){i.propagationStopped=!0}};t.update(i,a);var r=this.eventListeners_[e];if(r)for(var n=r.length-1;n>=0;n--){var o=r[n][0],s=r[n][1];if(s.call(o,i),i.propagationStopped)break}return i.defaultPrevented},t.prototype.getPluginInstance_=function(t){for(var e=0;e<this.plugins_.length;e++){var a=this.plugins_[e];if(a.plugin instanceof t)return a.plugin}return null},t.prototype.isZoomed=function(t){if(null===t||void 0===t)return this.zoomed_x_||this.zoomed_y_;if("x"===t)return this.zoomed_x_;if("y"===t)return this.zoomed_y_;throw"axis parameter is ["+t+"] must be null, 'x' or 'y'."},t.prototype.toString=function(){var t=this.maindiv_,e=t&&t.id?t.id:t;return"[Dygraph "+e+"]"},t.prototype.attr_=function(t,e){return e?this.attributes_.getForSeries(t,e):this.attributes_.get(t)},t.prototype.getOption=function(t,e){return this.attr_(t,e)},t.prototype.getNumericOption=function(t,e){return this.getOption(t,e)},t.prototype.getStringOption=function(t,e){return this.getOption(t,e)},t.prototype.getBooleanOption=function(t,e){return this.getOption(t,e)},t.prototype.getFunctionOption=function(t,e){return this.getOption(t,e)},t.prototype.getOptionForAxis=function(t,e){return this.attributes_.getForAxis(t,e)},t.prototype.optionsViewForAxis_=function(t){var e=this;return function(a){var i=e.user_attrs_.axes;return i&&i[t]&&i[t].hasOwnProperty(a)?i[t][a]:"x"===t&&"logscale"===a?!1:"undefined"!=typeof e.user_attrs_[a]?e.user_attrs_[a]:(i=e.attrs_.axes,i&&i[t]&&i[t].hasOwnProperty(a)?i[t][a]:"y"==t&&e.axes_[0].hasOwnProperty(a)?e.axes_[0][a]:"y2"==t&&e.axes_[1].hasOwnProperty(a)?e.axes_[1][a]:e.attr_(a))}},t.prototype.rollPeriod=function(){return this.rollPeriod_},t.prototype.xAxisRange=function(){return this.dateWindow_?this.dateWindow_:this.xAxisExtremes()},t.prototype.xAxisExtremes=function(){var t=this.getNumericOption("xRangePad")/this.plotter_.area.w;if(0===this.numRows())return[0-t,1+t];var e=this.rawData_[0][0],a=this.rawData_[this.rawData_.length-1][0];if(t){var i=a-e;e-=i*t,a+=i*t}return[e,a]},t.prototype.yAxisRange=function(t){if("undefined"==typeof t&&(t=0),0>t||t>=this.axes_.length)return null;var e=this.axes_[t];return[e.computedValueRange[0],e.computedValueRange[1]]},t.prototype.yAxisRanges=function(){for(var t=[],e=0;e<this.axes_.length;e++)t.push(this.yAxisRange(e));return t},t.prototype.toDomCoords=function(t,e,a){return[this.toDomXCoord(t),this.toDomYCoord(e,a)]},t.prototype.toDomXCoord=function(t){if(null===t)return null;var e=this.plotter_.area,a=this.xAxisRange();return e.x+(t-a[0])/(a[1]-a[0])*e.w},t.prototype.toDomYCoord=function(t,e){var a=this.toPercentYCoord(t,e);if(null===a)return null;var i=this.plotter_.area;return i.y+a*i.h},t.prototype.toDataCoords=function(t,e,a){return[this.toDataXCoord(t),this.toDataYCoord(e,a)]},t.prototype.toDataXCoord=function(e){if(null===e)return null;var a=this.plotter_.area,i=this.xAxisRange();if(this.attributes_.getForAxis("logscale","x")){var r=(e-a.x)/a.w,n=t.log10(i[0]),o=t.log10(i[1]),s=n+r*(o-n),l=Math.pow(t.LOG_SCALE,s);return l}return i[0]+(e-a.x)/a.w*(i[1]-i[0])},t.prototype.toDataYCoord=function(e,a){if(null===e)return null;var i=this.plotter_.area,r=this.yAxisRange(a);if("undefined"==typeof a&&(a=0),this.attributes_.getForAxis("logscale",a)){var n=(e-i.y)/i.h,o=t.log10(r[0]),s=t.log10(r[1]),l=s-n*(s-o),h=Math.pow(t.LOG_SCALE,l);return h}return r[0]+(i.y+i.h-e)/i.h*(r[1]-r[0])},t.prototype.toPercentYCoord=function(e,a){if(null===e)return null;"undefined"==typeof a&&(a=0);var i,r=this.yAxisRange(a),n=this.attributes_.getForAxis("logscale",a);if(n){var o=t.log10(r[0]),s=t.log10(r[1]);i=(s-t.log10(e))/(s-o)}else i=(r[1]-e)/(r[1]-r[0]);return i},t.prototype.toPercentXCoord=function(e){if(null===e)return null;var a,i=this.xAxisRange(),r=this.attributes_.getForAxis("logscale","x");if(r===!0){var n=t.log10(i[0]),o=t.log10(i[1]);a=(t.log10(e)-n)/(o-n)}else a=(e-i[0])/(i[1]-i[0]);return a},t.prototype.numColumns=function(){return this.rawData_?this.rawData_[0]?this.rawData_[0].length:this.attr_("labels").length:0},t.prototype.numRows=function(){return this.rawData_?this.rawData_.length:0},t.prototype.getValue=function(t,e){return 0>t||t>this.rawData_.length?null:0>e||e>this.rawData_[t].length?null:this.rawData_[t][e]},t.prototype.createInterface_=function(){var e=this.maindiv_;this.graphDiv=document.createElement("div"),this.graphDiv.style.textAlign="left",this.graphDiv.style.position="relative",e.appendChild(this.graphDiv),this.canvas_=t.createCanvas(),this.canvas_.style.position="absolute",this.hidden_=this.createPlotKitCanvas_(this.canvas_),this.canvas_ctx_=t.getContext(this.canvas_),this.hidden_ctx_=t.getContext(this.hidden_),this.resizeElements_(),this.graphDiv.appendChild(this.hidden_),this.graphDiv.appendChild(this.canvas_),this.mouseEventElement_=this.createMouseEventElement_(),this.layout_=new DygraphLayout(this);var a=this;this.mouseMoveHandler_=function(t){a.mouseMove_(t)},this.mouseOutHandler_=function(e){var i=e.target||e.fromElement,r=e.relatedTarget||e.toElement;t.isNodeContainedBy(i,a.graphDiv)&&!t.isNodeContainedBy(r,a.graphDiv)&&a.mouseOut_(e)},this.addAndTrackEvent(window,"mouseout",this.mouseOutHandler_),this.addAndTrackEvent(this.mouseEventElement_,"mousemove",this.mouseMoveHandler_),this.resizeHandler_||(this.resizeHandler_=function(t){a.resize()},this.addAndTrackEvent(window,"resize",this.resizeHandler_))},t.prototype.resizeElements_=function(){this.graphDiv.style.width=this.width_+"px",this.graphDiv.style.height=this.height_+"px";var e=t.getContextPixelRatio(this.canvas_ctx_);this.canvas_.width=this.width_*e,this.canvas_.height=this.height_*e,this.canvas_.style.width=this.width_+"px",this.canvas_.style.height=this.height_+"px",1!==e&&this.canvas_ctx_.scale(e,e);var a=t.getContextPixelRatio(this.hidden_ctx_);this.hidden_.width=this.width_*a,this.hidden_.height=this.height_*a,this.hidden_.style.width=this.width_+"px",this.hidden_.style.height=this.height_+"px",1!==a&&this.hidden_ctx_.scale(a,a)},t.prototype.destroy=function(){this.canvas_ctx_.restore(),this.hidden_ctx_.restore();for(var e=this.plugins_.length-1;e>=0;e--){var a=this.plugins_.pop();a.plugin.destroy&&a.plugin.destroy()}var i=function(t){for(;t.hasChildNodes();)i(t.firstChild),t.removeChild(t.firstChild)};this.removeTrackedEvents_(),t.removeEvent(window,"mouseout",this.mouseOutHandler_),t.removeEvent(this.mouseEventElement_,"mousemove",this.mouseMoveHandler_),t.removeEvent(window,"resize",this.resizeHandler_),this.resizeHandler_=null,i(this.maindiv_);var r=function(t){for(var e in t)"object"==typeof t[e]&&(t[e]=null)};r(this.layout_),r(this.plotter_),r(this)},t.prototype.createPlotKitCanvas_=function(e){var a=t.createCanvas();return a.style.position="absolute",a.style.top=e.style.top,a.style.left=e.style.left,a.width=this.width_,a.height=this.height_,a.style.width=this.width_+"px",a.style.height=this.height_+"px",a},t.prototype.createMouseEventElement_=function(){if(this.isUsingExcanvas_){var t=document.createElement("div");return t.style.position="absolute",t.style.backgroundColor="white",t.style.filter="alpha(opacity=0)",t.style.width=this.width_+"px",t.style.height=this.height_+"px",this.graphDiv.appendChild(t),t}return this.canvas_},t.prototype.setColors_=function(){var e=this.getLabels(),a=e.length-1;this.colors_=[],this.colorsMap_={};for(var i=this.getNumericOption("colorSaturation")||1,r=this.getNumericOption("colorValue")||.5,n=Math.ceil(a/2),o=this.getOption("colors"),s=this.visibility(),l=0;a>l;l++)if(s[l]){
+var h=e[l+1],p=this.attributes_.getForSeries("color",h);if(!p)if(o)p=o[l%o.length];else{var g=l%2?n+(l+1)/2:Math.ceil((l+1)/2),d=1*g/(1+a);p=t.hsvToRGB(d,i,r)}this.colors_.push(p),this.colorsMap_[h]=p}},t.prototype.getColors=function(){return this.colors_},t.prototype.getPropertiesForSeries=function(t){for(var e=-1,a=this.getLabels(),i=1;i<a.length;i++)if(a[i]==t){e=i;break}return-1==e?null:{name:t,column:e,visible:this.visibility()[e-1],color:this.colorsMap_[t],axis:1+this.attributes_.axisForSeries(t)}},t.prototype.createRollInterface_=function(){this.roller_||(this.roller_=document.createElement("input"),this.roller_.type="text",this.roller_.style.display="none",this.graphDiv.appendChild(this.roller_));var t=this.getBooleanOption("showRoller")?"block":"none",e=this.plotter_.area,a={position:"absolute",zIndex:10,top:e.y+e.h-25+"px",left:e.x+1+"px",display:t};this.roller_.size="2",this.roller_.value=this.rollPeriod_;for(var i in a)a.hasOwnProperty(i)&&(this.roller_.style[i]=a[i]);var r=this;this.roller_.onchange=function(){r.adjustRoll(r.roller_.value)}},t.prototype.createDragInterface_=function(){var e={isZooming:!1,isPanning:!1,is2DPan:!1,dragStartX:null,dragStartY:null,dragEndX:null,dragEndY:null,dragDirection:null,prevEndX:null,prevEndY:null,prevDragDirection:null,cancelNextDblclick:!1,initialLeftmostDate:null,xUnitsPerPixel:null,dateRange:null,px:0,py:0,boundedDates:null,boundedValues:null,tarp:new t.IFrameTarp,initializeMouseDown:function(e,a,i){e.preventDefault?e.preventDefault():(e.returnValue=!1,e.cancelBubble=!0);var r=t.findPos(a.canvas_);i.px=r.x,i.py=r.y,i.dragStartX=t.dragGetX_(e,i),i.dragStartY=t.dragGetY_(e,i),i.cancelNextDblclick=!1,i.tarp.cover()},destroy:function(){var t=this;if((t.isZooming||t.isPanning)&&(t.isZooming=!1,t.dragStartX=null,t.dragStartY=null),t.isPanning){t.isPanning=!1,t.draggingDate=null,t.dateRange=null;for(var e=0;e<i.axes_.length;e++)delete i.axes_[e].draggingValue,delete i.axes_[e].dragValueRange}t.tarp.uncover()}},a=this.getOption("interactionModel"),i=this,r=function(t){return function(a){t(a,i,e)}};for(var n in a)a.hasOwnProperty(n)&&this.addAndTrackEvent(this.mouseEventElement_,n,r(a[n]));if(!a.willDestroyContextMyself){var o=function(t){e.destroy()};this.addAndTrackEvent(document,"mouseup",o)}},t.prototype.drawZoomRect_=function(e,a,i,r,n,o,s,l){var h=this.canvas_ctx_;o==t.HORIZONTAL?h.clearRect(Math.min(a,s),this.layout_.getPlotArea().y,Math.abs(a-s),this.layout_.getPlotArea().h):o==t.VERTICAL&&h.clearRect(this.layout_.getPlotArea().x,Math.min(r,l),this.layout_.getPlotArea().w,Math.abs(r-l)),e==t.HORIZONTAL?i&&a&&(h.fillStyle="rgba(128,128,128,0.33)",h.fillRect(Math.min(a,i),this.layout_.getPlotArea().y,Math.abs(i-a),this.layout_.getPlotArea().h)):e==t.VERTICAL&&n&&r&&(h.fillStyle="rgba(128,128,128,0.33)",h.fillRect(this.layout_.getPlotArea().x,Math.min(r,n),this.layout_.getPlotArea().w,Math.abs(n-r))),this.isUsingExcanvas_&&(this.currentZoomRectArgs_=[e,a,i,r,n,0,0,0])},t.prototype.clearZoomRect_=function(){this.currentZoomRectArgs_=null,this.canvas_ctx_.clearRect(0,0,this.width_,this.height_)},t.prototype.doZoomX_=function(t,e){this.currentZoomRectArgs_=null;var a=this.toDataXCoord(t),i=this.toDataXCoord(e);this.doZoomXDates_(a,i)},t.prototype.doZoomXDates_=function(t,e){var a=this.xAxisRange(),i=[t,e];this.zoomed_x_=!0;var r=this;this.doAnimatedZoom(a,i,null,null,function(){r.getFunctionOption("zoomCallback")&&r.getFunctionOption("zoomCallback").call(r,t,e,r.yAxisRanges())})},t.prototype.doZoomY_=function(t,e){this.currentZoomRectArgs_=null;for(var a=this.yAxisRanges(),i=[],r=0;r<this.axes_.length;r++){var n=this.toDataYCoord(t,r),o=this.toDataYCoord(e,r);i.push([o,n])}this.zoomed_y_=!0;var s=this;this.doAnimatedZoom(null,null,a,i,function(){if(s.getFunctionOption("zoomCallback")){var t=s.xAxisRange();s.getFunctionOption("zoomCallback").call(s,t[0],t[1],s.yAxisRanges())}})},t.zoomAnimationFunction=function(t,e){var a=1.5;return(1-Math.pow(a,-t))/(1-Math.pow(a,-e))},t.prototype.resetZoom=function(){var t=!1,e=!1,a=!1;null!==this.dateWindow_&&(t=!0,e=!0);for(var i=0;i<this.axes_.length;i++)"undefined"!=typeof this.axes_[i].valueWindow&&null!==this.axes_[i].valueWindow&&(t=!0,a=!0);if(this.clearSelection(),t){this.zoomed_x_=!1,this.zoomed_y_=!1;var r=this.rawData_[0][0],n=this.rawData_[this.rawData_.length-1][0];if(!this.getBooleanOption("animatedZooms")){for(this.dateWindow_=null,i=0;i<this.axes_.length;i++)null!==this.axes_[i].valueWindow&&delete this.axes_[i].valueWindow;return this.drawGraph_(),void(this.getFunctionOption("zoomCallback")&&this.getFunctionOption("zoomCallback").call(this,r,n,this.yAxisRanges()))}var o=null,s=null,l=null,h=null;if(e&&(o=this.xAxisRange(),s=[r,n]),a){l=this.yAxisRanges();var p=this.gatherDatasets_(this.rolledSeries_,null),g=p.extremes;for(this.computeYAxisRanges_(g),h=[],i=0;i<this.axes_.length;i++){var d=this.axes_[i];h.push(null!==d.valueRange&&void 0!==d.valueRange?d.valueRange:d.extremeRange)}}var u=this;this.doAnimatedZoom(o,s,l,h,function(){u.dateWindow_=null;for(var t=0;t<u.axes_.length;t++)null!==u.axes_[t].valueWindow&&delete u.axes_[t].valueWindow;u.getFunctionOption("zoomCallback")&&u.getFunctionOption("zoomCallback").call(u,r,n,u.yAxisRanges())})}},t.prototype.doAnimatedZoom=function(e,a,i,r,n){var o,s,l=this.getBooleanOption("animatedZooms")?t.ANIMATION_STEPS:1,h=[],p=[];if(null!==e&&null!==a)for(o=1;l>=o;o++)s=t.zoomAnimationFunction(o,l),h[o-1]=[e[0]*(1-s)+s*a[0],e[1]*(1-s)+s*a[1]];if(null!==i&&null!==r)for(o=1;l>=o;o++){s=t.zoomAnimationFunction(o,l);for(var g=[],d=0;d<this.axes_.length;d++)g.push([i[d][0]*(1-s)+s*r[d][0],i[d][1]*(1-s)+s*r[d][1]]);p[o-1]=g}var u=this;t.repeatAndCleanup(function(t){if(p.length)for(var e=0;e<u.axes_.length;e++){var a=p[t][e];u.axes_[e].valueWindow=[a[0],a[1]]}h.length&&(u.dateWindow_=h[t]),u.drawGraph_()},l,t.ANIMATION_DURATION/l,n)},t.prototype.getArea=function(){return this.plotter_.area},t.prototype.eventToDomCoords=function(e){if(e.offsetX&&e.offsetY)return[e.offsetX,e.offsetY];var a=t.findPos(this.mouseEventElement_),i=t.pageX(e)-a.x,r=t.pageY(e)-a.y;return[i,r]},t.prototype.findClosestRow=function(e){for(var a=1/0,i=-1,r=this.layout_.points,n=0;n<r.length;n++)for(var o=r[n],s=o.length,l=0;s>l;l++){var h=o[l];if(t.isValidPoint(h,!0)){var p=Math.abs(h.canvasx-e);a>p&&(a=p,i=h.idx)}}return i},t.prototype.findClosestPoint=function(e,a){for(var i,r,n,o,s,l,h,p=1/0,g=this.layout_.points.length-1;g>=0;--g)for(var d=this.layout_.points[g],u=0;u<d.length;++u)o=d[u],t.isValidPoint(o)&&(r=o.canvasx-e,n=o.canvasy-a,i=r*r+n*n,p>i&&(p=i,s=o,l=g,h=o.idx));var c=this.layout_.setNames[l];return{row:h,seriesName:c,point:s}},t.prototype.findStackedPoint=function(e,a){for(var i,r,n=this.findClosestRow(e),o=0;o<this.layout_.points.length;++o){var s=this.getLeftBoundary_(o),l=n-s,h=this.layout_.points[o];if(!(l>=h.length)){var p=h[l];if(t.isValidPoint(p)){var g=p.canvasy;if(e>p.canvasx&&l+1<h.length){var d=h[l+1];if(t.isValidPoint(d)){var u=d.canvasx-p.canvasx;if(u>0){var c=(e-p.canvasx)/u;g+=c*(d.canvasy-p.canvasy)}}}else if(e<p.canvasx&&l>0){var y=h[l-1];if(t.isValidPoint(y)){var u=p.canvasx-y.canvasx;if(u>0){var c=(p.canvasx-e)/u;g+=c*(y.canvasy-p.canvasy)}}}(0===o||a>g)&&(i=p,r=o)}}}var _=this.layout_.setNames[r];return{row:n,seriesName:_,point:i}},t.prototype.mouseMove_=function(t){var e=this.layout_.points;if(void 0!==e&&null!==e){var a=this.eventToDomCoords(t),i=a[0],r=a[1],n=this.getOption("highlightSeriesOpts"),o=!1;if(n&&!this.isSeriesLocked()){var s;s=this.getBooleanOption("stackedGraph")?this.findStackedPoint(i,r):this.findClosestPoint(i,r),o=this.setSelection(s.row,s.seriesName)}else{var l=this.findClosestRow(i);o=this.setSelection(l)}var h=this.getFunctionOption("highlightCallback");h&&o&&h.call(this,t,this.lastx_,this.selPoints_,this.lastRow_,this.highlightSet_)}},t.prototype.getLeftBoundary_=function(t){if(this.boundaryIds_[t])return this.boundaryIds_[t][0];for(var e=0;e<this.boundaryIds_.length;e++)if(void 0!==this.boundaryIds_[e])return this.boundaryIds_[e][0];return 0},t.prototype.animateSelection_=function(e){var a=10,i=30;void 0===this.fadeLevel&&(this.fadeLevel=0),void 0===this.animateId&&(this.animateId=0);var r=this.fadeLevel,n=0>e?r:a-r;if(0>=n)return void(this.fadeLevel&&this.updateSelection_(1));var o=++this.animateId,s=this;t.repeatAndCleanup(function(t){s.animateId==o&&(s.fadeLevel+=e,0===s.fadeLevel?s.clearSelection():s.updateSelection_(s.fadeLevel/a))},n,i,function(){})},t.prototype.updateSelection_=function(e){this.cascadeEvents_("select",{selectedRow:this.lastRow_,selectedX:this.lastx_,selectedPoints:this.selPoints_});var a,i=this.canvas_ctx_;if(this.getOption("highlightSeriesOpts")){i.clearRect(0,0,this.width_,this.height_);var r=1-this.getNumericOption("highlightSeriesBackgroundAlpha");if(r){var n=!0;if(n){if(void 0===e)return void this.animateSelection_(1);r*=e}i.fillStyle="rgba(255,255,255,"+r+")",i.fillRect(0,0,this.width_,this.height_)}this.plotter_._renderLineChart(this.highlightSet_,i)}else if(this.previousVerticalX_>=0){var o=0,s=this.attr_("labels");for(a=1;a<s.length;a++){var l=this.getNumericOption("highlightCircleSize",s[a]);l>o&&(o=l)}var h=this.previousVerticalX_;i.clearRect(h-o-1,0,2*o+2,this.height_)}if(this.isUsingExcanvas_&&this.currentZoomRectArgs_&&t.prototype.drawZoomRect_.apply(this,this.currentZoomRectArgs_),this.selPoints_.length>0){var p=this.selPoints_[0].canvasx;for(i.save(),a=0;a<this.selPoints_.length;a++){var g=this.selPoints_[a];if(t.isOK(g.canvasy)){var d=this.getNumericOption("highlightCircleSize",g.name),u=this.getFunctionOption("drawHighlightPointCallback",g.name),c=this.plotter_.colors[g.name];u||(u=t.Circles.DEFAULT),i.lineWidth=this.getNumericOption("strokeWidth",g.name),i.strokeStyle=c,i.fillStyle=c,u.call(this,this,g.name,i,p,g.canvasy,c,d,g.idx)}}i.restore(),this.previousVerticalX_=p}},t.prototype.setSelection=function(t,e,a){this.selPoints_=[];var i=!1;if(t!==!1&&t>=0){t!=this.lastRow_&&(i=!0),this.lastRow_=t;for(var r=0;r<this.layout_.points.length;++r){var n=this.layout_.points[r],o=t-this.getLeftBoundary_(r);if(o<n.length&&n[o].idx==t){var s=n[o];null!==s.yval&&this.selPoints_.push(s)}else for(var l=0;l<n.length;++l){var s=n[l];if(s.idx==t){null!==s.yval&&this.selPoints_.push(s);break}}}}else this.lastRow_>=0&&(i=!0),this.lastRow_=-1;return this.selPoints_.length?this.lastx_=this.selPoints_[0].xval:this.lastx_=-1,void 0!==e&&(this.highlightSet_!==e&&(i=!0),this.highlightSet_=e),void 0!==a&&(this.lockedSet_=a),i&&this.updateSelection_(void 0),i},t.prototype.mouseOut_=function(t){this.getFunctionOption("unhighlightCallback")&&this.getFunctionOption("unhighlightCallback").call(this,t),this.getBooleanOption("hideOverlayOnMouseOut")&&!this.lockedSet_&&this.clearSelection()},t.prototype.clearSelection=function(){return this.cascadeEvents_("deselect",{}),this.lockedSet_=!1,this.fadeLevel?void this.animateSelection_(-1):(this.canvas_ctx_.clearRect(0,0,this.width_,this.height_),this.fadeLevel=0,this.selPoints_=[],this.lastx_=-1,this.lastRow_=-1,void(this.highlightSet_=null))},t.prototype.getSelection=function(){if(!this.selPoints_||this.selPoints_.length<1)return-1;for(var t=0;t<this.layout_.points.length;t++)for(var e=this.layout_.points[t],a=0;a<e.length;a++)if(e[a].x==this.selPoints_[0].x)return e[a].idx;return-1},t.prototype.getHighlightSeries=function(){return this.highlightSet_},t.prototype.isSeriesLocked=function(){return this.lockedSet_},t.prototype.loadedEvent_=function(t){this.rawData_=this.parseCSV_(t),this.cascadeDataDidUpdateEvent_(),this.predraw_()},t.prototype.addXTicks_=function(){var t;t=this.dateWindow_?[this.dateWindow_[0],this.dateWindow_[1]]:this.xAxisExtremes();var e=this.optionsViewForAxis_("x"),a=e("ticker")(t[0],t[1],this.plotter_.area.w,e,this);this.layout_.setXTicks(a)},t.prototype.getHandlerClass_=function(){var e;return e=this.attr_("dataHandler")?this.attr_("dataHandler"):this.fractions_?this.getBooleanOption("errorBars")?t.DataHandlers.FractionsBarsHandler:t.DataHandlers.DefaultFractionHandler:this.getBooleanOption("customBars")?t.DataHandlers.CustomBarsHandler:this.getBooleanOption("errorBars")?t.DataHandlers.ErrorBarsHandler:t.DataHandlers.DefaultHandler},t.prototype.predraw_=function(){var t=new Date;this.dataHandler_=new(this.getHandlerClass_()),this.layout_.computePlotArea(),this.computeYAxes_(),this.is_initial_draw_||(this.canvas_ctx_.restore(),this.hidden_ctx_.restore()),this.canvas_ctx_.save(),this.hidden_ctx_.save(),this.plotter_=new DygraphCanvasRenderer(this,this.hidden_,this.hidden_ctx_,this.layout_),this.createRollInterface_(),this.cascadeEvents_("predraw"),this.rolledSeries_=[null];for(var e=1;e<this.numColumns();e++){var a=this.dataHandler_.extractSeries(this.rawData_,e,this.attributes_);this.rollPeriod_>1&&(a=this.dataHandler_.rollingAverage(a,this.rollPeriod_,this.attributes_)),this.rolledSeries_.push(a)}this.drawGraph_();var i=new Date;this.drawingTimeMs_=i-t},t.PointType=void 0,t.stackPoints_=function(t,e,a,i){for(var r=null,n=null,o=null,s=-1,l=function(e){if(!(s>=e))for(var a=e;a<t.length;++a)if(o=null,!isNaN(t[a].yval)&&null!==t[a].yval){s=a,o=t[a];break}},h=0;h<t.length;++h){var p=t[h],g=p.xval;void 0===e[g]&&(e[g]=0);var d=p.yval;isNaN(d)||null===d?"none"==i?d=0:(l(h),d=n&&o&&"none"!=i?n.yval+(o.yval-n.yval)*((g-n.xval)/(o.xval-n.xval)):n&&"all"==i?n.yval:o&&"all"==i?o.yval:0):n=p;var u=e[g];r!=g&&(u+=d,e[g]=u),r=g,p.yval_stacked=u,u>a[1]&&(a[1]=u),u<a[0]&&(a[0]=u)}},t.prototype.gatherDatasets_=function(e,a){var i,r,n,o,s,l,h=[],p=[],g=[],d={},u=e.length-1;for(i=u;i>=1;i--)if(this.visibility()[i-1]){if(a){l=e[i];var c=a[0],y=a[1];for(n=null,o=null,r=0;r<l.length;r++)l[r][0]>=c&&null===n&&(n=r),l[r][0]<=y&&(o=r);null===n&&(n=0);for(var _=n,v=!0;v&&_>0;)_--,v=null===l[_][1];null===o&&(o=l.length-1);var f=o;for(v=!0;v&&f<l.length-1;)f++,v=null===l[f][1];_!==n&&(n=_),f!==o&&(o=f),h[i-1]=[n,o],l=l.slice(n,o+1)}else l=e[i],h[i-1]=[0,l.length-1];var x=this.attr_("labels")[i],m=this.dataHandler_.getExtremeYValues(l,a,this.getBooleanOption("stepPlot",x)),D=this.dataHandler_.seriesToPoints(l,x,h[i-1][0]);this.getBooleanOption("stackedGraph")&&(s=this.attributes_.axisForSeries(x),void 0===g[s]&&(g[s]=[]),t.stackPoints_(D,g[s],m,this.getBooleanOption("stackedGraphNaNFill"))),d[x]=m,p[i]=D}return{points:p,extremes:d,boundaryIds:h}},t.prototype.drawGraph_=function(){var t=new Date,e=this.is_initial_draw_;this.is_initial_draw_=!1,this.layout_.removeAllDatasets(),this.setColors_(),this.attrs_.pointSize=.5*this.getNumericOption("highlightCircleSize");var a=this.gatherDatasets_(this.rolledSeries_,this.dateWindow_),i=a.points,r=a.extremes;this.boundaryIds_=a.boundaryIds,this.setIndexByName_={};var n=this.attr_("labels");n.length>0&&(this.setIndexByName_[n[0]]=0);for(var o=0,s=1;s<i.length;s++)this.setIndexByName_[n[s]]=s,this.visibility()[s-1]&&(this.layout_.addDataset(n[s],i[s]),this.datasetIndex_[s]=o++);this.computeYAxisRanges_(r),this.layout_.setYAxes(this.axes_),this.addXTicks_();var l=this.zoomed_x_;if(this.zoomed_x_=l,this.layout_.evaluate(),this.renderGraph_(e),this.getStringOption("timingName")){var h=new Date;console.log(this.getStringOption("timingName")+" - drawGraph: "+(h-t)+"ms")}},t.prototype.renderGraph_=function(t){this.cascadeEvents_("clearChart"),this.plotter_.clear(),this.getFunctionOption("underlayCallback")&&this.getFunctionOption("underlayCallback").call(this,this.hidden_ctx_,this.layout_.getPlotArea(),this,this);var e={canvas:this.hidden_,drawingContext:this.hidden_ctx_};if(this.cascadeEvents_("willDrawChart",e),this.plotter_.render(),this.cascadeEvents_("didDrawChart",e),this.lastRow_=-1,this.canvas_.getContext("2d").clearRect(0,0,this.width_,this.height_),null!==this.getFunctionOption("drawCallback")&&this.getFunctionOption("drawCallback").call(this,this,t),t)for(this.readyFired_=!0;this.readyFns_.length>0;){var a=this.readyFns_.pop();a(this)}},t.prototype.computeYAxes_=function(){var e,a,i,r,n;if(void 0!==this.axes_&&this.user_attrs_.hasOwnProperty("valueRange")===!1)for(e=[],i=0;i<this.axes_.length;i++)e.push(this.axes_[i].valueWindow);for(this.axes_=[],a=0;a<this.attributes_.numAxes();a++)r={g:this},t.update(r,this.attributes_.axisOptions(a)),this.axes_[a]=r;if(n=this.attr_("valueRange"),n&&(this.axes_[0].valueRange=n),void 0!==e){var o=Math.min(e.length,this.axes_.length);for(i=0;o>i;i++)this.axes_[i].valueWindow=e[i]}for(a=0;a<this.axes_.length;a++)if(0===a)r=this.optionsViewForAxis_("y"+(a?"2":"")),n=r("valueRange"),n&&(this.axes_[a].valueRange=n);else{var s=this.user_attrs_.axes;s&&s.y2&&(n=s.y2.valueRange,n&&(this.axes_[a].valueRange=n))}},t.prototype.numAxes=function(){return this.attributes_.numAxes()},t.prototype.axisPropertiesForSeries=function(t){return this.axes_[this.attributes_.axisForSeries(t)]},t.prototype.computeYAxisRanges_=function(t){for(var e,a,i,r,n,o=function(t){return isNaN(parseFloat(t))},s=this.attributes_.numAxes(),l=0;s>l;l++){var h=this.axes_[l],p=this.attributes_.getForAxis("logscale",l),g=this.attributes_.getForAxis("includeZero",l),d=this.attributes_.getForAxis("independentTicks",l);if(i=this.attributes_.seriesForAxis(l),e=!0,r=.1,null!==this.getNumericOption("yRangePad")&&(e=!1,r=this.getNumericOption("yRangePad")/this.plotter_.area.h),0===i.length)h.extremeRange=[0,1];else{for(var u,c,y=1/0,_=-(1/0),v=0;v<i.length;v++)t.hasOwnProperty(i[v])&&(u=t[i[v]][0],null!==u&&(y=Math.min(u,y)),c=t[i[v]][1],null!==c&&(_=Math.max(c,_)));g&&!p&&(y>0&&(y=0),0>_&&(_=0)),y==1/0&&(y=0),_==-(1/0)&&(_=1),a=_-y,0===a&&(0!==_?a=Math.abs(_):(_=1,a=1));var f,x;if(p)if(e)f=_+r*a,x=y;else{var m=Math.exp(Math.log(a)*r);f=_*m,x=y/m}else f=_+r*a,x=y-r*a,e&&!this.getBooleanOption("avoidMinZero")&&(0>x&&y>=0&&(x=0),f>0&&0>=_&&(f=0));h.extremeRange=[x,f]}if(h.valueWindow)h.computedValueRange=[h.valueWindow[0],h.valueWindow[1]];else if(h.valueRange){var D=o(h.valueRange[0])?h.extremeRange[0]:h.valueRange[0],w=o(h.valueRange[1])?h.extremeRange[1]:h.valueRange[1];if(!e)if(h.logscale){var m=Math.exp(Math.log(a)*r);D*=m,w/=m}else a=w-D,D-=a*r,w+=a*r;h.computedValueRange=[D,w]}else h.computedValueRange=h.extremeRange;if(d){h.independentTicks=d;var A=this.optionsViewForAxis_("y"+(l?"2":"")),b=A("ticker");h.ticks=b(h.computedValueRange[0],h.computedValueRange[1],this.plotter_.area.h,A,this),n||(n=h)}}if(void 0===n)throw'Configuration Error: At least one axis has to have the "independentTicks" option activated.';for(var l=0;s>l;l++){var h=this.axes_[l];if(!h.independentTicks){for(var A=this.optionsViewForAxis_("y"+(l?"2":"")),b=A("ticker"),T=n.ticks,E=n.computedValueRange[1]-n.computedValueRange[0],C=h.computedValueRange[1]-h.computedValueRange[0],L=[],P=0;P<T.length;P++){var S=(T[P].v-n.computedValueRange[0])/E,O=h.computedValueRange[0]+S*C;L.push(O)}h.ticks=b(h.computedValueRange[0],h.computedValueRange[1],this.plotter_.area.h,A,this,L)}}},t.prototype.detectTypeFromString_=function(t){var e=!1,a=t.indexOf("-");a>0&&"e"!=t[a-1]&&"E"!=t[a-1]||t.indexOf("/")>=0||isNaN(parseFloat(t))?e=!0:8==t.length&&t>"19700101"&&"20371231">t&&(e=!0),this.setXAxisOptions_(e)},t.prototype.setXAxisOptions_=function(e){e?(this.attrs_.xValueParser=t.dateParser,this.attrs_.axes.x.valueFormatter=t.dateValueFormatter,this.attrs_.axes.x.ticker=t.dateTicker,this.attrs_.axes.x.axisLabelFormatter=t.dateAxisLabelFormatter):(this.attrs_.xValueParser=function(t){return parseFloat(t)},this.attrs_.axes.x.valueFormatter=function(t){return t},this.attrs_.axes.x.ticker=t.numericTicks,this.attrs_.axes.x.axisLabelFormatter=this.attrs_.axes.x.valueFormatter)},t.prototype.parseCSV_=function(e){var a,i,r=[],n=t.detectLineDelimiter(e),o=e.split(n||"\n"),s=this.getStringOption("delimiter");-1==o[0].indexOf(s)&&o[0].indexOf("	")>=0&&(s="	");var l=0;"labels"in this.user_attrs_||(l=1,this.attrs_.labels=o[0].split(s),this.attributes_.reparseSeries());for(var h,p=0,g=!1,d=this.attr_("labels").length,u=!1,c=l;c<o.length;c++){var y=o[c];if(p=c,0!==y.length&&"#"!=y[0]){var _=y.split(s);if(!(_.length<2)){var v=[];if(g||(this.detectTypeFromString_(_[0]),h=this.getFunctionOption("xValueParser"),g=!0),v[0]=h(_[0],this),this.fractions_)for(i=1;i<_.length;i++)a=_[i].split("/"),2!=a.length?(console.error('Expected fractional "num/den" values in CSV data but found a value \''+_[i]+"' on line "+(1+c)+" ('"+y+"') which is not of this form."),v[i]=[0,0]):v[i]=[t.parseFloat_(a[0],c,y),t.parseFloat_(a[1],c,y)];else if(this.getBooleanOption("errorBars"))for(_.length%2!=1&&console.error("Expected alternating (value, stdev.) pairs in CSV data but line "+(1+c)+" has an odd number of values ("+(_.length-1)+"): '"+y+"'"),i=1;i<_.length;i+=2)v[(i+1)/2]=[t.parseFloat_(_[i],c,y),t.parseFloat_(_[i+1],c,y)];else if(this.getBooleanOption("customBars"))for(i=1;i<_.length;i++){var f=_[i];/^ *$/.test(f)?v[i]=[null,null,null]:(a=f.split(";"),3==a.length?v[i]=[t.parseFloat_(a[0],c,y),t.parseFloat_(a[1],c,y),t.parseFloat_(a[2],c,y)]:console.warn('When using customBars, values must be either blank or "low;center;high" tuples (got "'+f+'" on line '+(1+c)))}else for(i=1;i<_.length;i++)v[i]=t.parseFloat_(_[i],c,y);if(r.length>0&&v[0]<r[r.length-1][0]&&(u=!0),v.length!=d&&console.error("Number of columns in line "+c+" ("+v.length+") does not agree with number of labels ("+d+") "+y),0===c&&this.attr_("labels")){var x=!0;for(i=0;x&&i<v.length;i++)v[i]&&(x=!1);if(x){console.warn("The dygraphs 'labels' option is set, but the first row of CSV data ('"+y+"') appears to also contain labels. Will drop the CSV labels and use the option labels.");continue}}r.push(v)}}}return u&&(console.warn("CSV is out of order; order it correctly to speed loading."),r.sort(function(t,e){return t[0]-e[0]})),r},t.prototype.parseArray_=function(e){if(0===e.length)return console.error("Can't plot empty data set"),null;if(0===e[0].length)return console.error("Data set cannot contain an empty row"),null;var a;if(null===this.attr_("labels")){for(console.warn("Using default labels. Set labels explicitly via 'labels' in the options parameter"),this.attrs_.labels=["X"],a=1;a<e[0].length;a++)this.attrs_.labels.push("Y"+a);this.attributes_.reparseSeries()}else{var i=this.attr_("labels");if(i.length!=e[0].length)return console.error("Mismatch between number of labels ("+i+") and number of columns in array ("+e[0].length+")"),null}if(t.isDateLike(e[0][0])){this.attrs_.axes.x.valueFormatter=t.dateValueFormatter,this.attrs_.axes.x.ticker=t.dateTicker,this.attrs_.axes.x.axisLabelFormatter=t.dateAxisLabelFormatter;var r=t.clone(e);for(a=0;a<e.length;a++){if(0===r[a].length)return console.error("Row "+(1+a)+" of data is empty"),null;if(null===r[a][0]||"function"!=typeof r[a][0].getTime||isNaN(r[a][0].getTime()))return console.error("x value in row "+(1+a)+" is not a Date"),null;r[a][0]=r[a][0].getTime()}return r}return this.attrs_.axes.x.valueFormatter=function(t){return t},this.attrs_.axes.x.ticker=t.numericTicks,this.attrs_.axes.x.axisLabelFormatter=t.numberAxisLabelFormatter,e},t.prototype.parseDataTable_=function(e){var a=function(t){var e=String.fromCharCode(65+t%26);for(t=Math.floor(t/26);t>0;)e=String.fromCharCode(65+(t-1)%26)+e.toLowerCase(),t=Math.floor((t-1)/26);return e},i=e.getNumberOfColumns(),r=e.getNumberOfRows(),n=e.getColumnType(0);if("date"==n||"datetime"==n)this.attrs_.xValueParser=t.dateParser,this.attrs_.axes.x.valueFormatter=t.dateValueFormatter,this.attrs_.axes.x.ticker=t.dateTicker,this.attrs_.axes.x.axisLabelFormatter=t.dateAxisLabelFormatter;else{if("number"!=n)return console.error("only 'date', 'datetime' and 'number' types are supported for column 1 of DataTable input (Got '"+n+"')"),null;this.attrs_.xValueParser=function(t){return parseFloat(t)},this.attrs_.axes.x.valueFormatter=function(t){return t},this.attrs_.axes.x.ticker=t.numericTicks,this.attrs_.axes.x.axisLabelFormatter=this.attrs_.axes.x.valueFormatter}var o,s,l=[],h={},p=!1;for(o=1;i>o;o++){var g=e.getColumnType(o);if("number"==g)l.push(o);else if("string"==g&&this.getBooleanOption("displayAnnotations")){var d=l[l.length-1];h.hasOwnProperty(d)?h[d].push(o):h[d]=[o],p=!0}else console.error("Only 'number' is supported as a dependent type with Gviz. 'string' is only supported if displayAnnotations is true")}var u=[e.getColumnLabel(0)];for(o=0;o<l.length;o++)u.push(e.getColumnLabel(l[o])),this.getBooleanOption("errorBars")&&(o+=1);this.attrs_.labels=u,i=u.length;var c=[],y=!1,_=[];for(o=0;r>o;o++){var v=[];if("undefined"!=typeof e.getValue(o,0)&&null!==e.getValue(o,0)){if(v.push("date"==n||"datetime"==n?e.getValue(o,0).getTime():e.getValue(o,0)),this.getBooleanOption("errorBars"))for(s=0;i-1>s;s++)v.push([e.getValue(o,1+2*s),e.getValue(o,2+2*s)]);else{for(s=0;s<l.length;s++){var f=l[s];if(v.push(e.getValue(o,f)),p&&h.hasOwnProperty(f)&&null!==e.getValue(o,h[f][0])){var x={};x.series=e.getColumnLabel(f),x.xval=v[0],x.shortText=a(_.length),x.text="";for(var m=0;m<h[f].length;m++)m&&(x.text+="\n"),x.text+=e.getValue(o,h[f][m]);_.push(x)}}for(s=0;s<v.length;s++)isFinite(v[s])||(v[s]=null)}c.length>0&&v[0]<c[c.length-1][0]&&(y=!0),c.push(v)}else console.warn("Ignoring row "+o+" of DataTable because of undefined or null first column.")}y&&(console.warn("DataTable is out of order; order it correctly to speed loading."),c.sort(function(t,e){return t[0]-e[0]})),this.rawData_=c,_.length>0&&this.setAnnotations(_,!0),this.attributes_.reparseSeries()},t.prototype.cascadeDataDidUpdateEvent_=function(){this.cascadeEvents_("dataDidUpdate",{})},t.prototype.start_=function(){var e=this.file_;if("function"==typeof e&&(e=e()),t.isArrayLike(e))this.rawData_=this.parseArray_(e),this.cascadeDataDidUpdateEvent_(),this.predraw_();else if("object"==typeof e&&"function"==typeof e.getColumnRange)this.parseDataTable_(e),this.cascadeDataDidUpdateEvent_(),this.predraw_();else if("string"==typeof e){var a=t.detectLineDelimiter(e);if(a)this.loadedEvent_(e);else{var i;i=window.XMLHttpRequest?new XMLHttpRequest:new ActiveXObject("Microsoft.XMLHTTP");var r=this;i.onreadystatechange=function(){4==i.readyState&&(200===i.status||0===i.status)&&r.loadedEvent_(i.responseText)},i.open("GET",e,!0),i.send(null)}}else console.error("Unknown data format: "+typeof e)},t.prototype.updateOptions=function(e,a){"undefined"==typeof a&&(a=!1);var i=e.file,r=t.mapLegacyOptions_(e);"rollPeriod"in r&&(this.rollPeriod_=r.rollPeriod),"dateWindow"in r&&(this.dateWindow_=r.dateWindow,"isZoomedIgnoreProgrammaticZoom"in r||(this.zoomed_x_=null!==r.dateWindow)),"valueRange"in r&&!("isZoomedIgnoreProgrammaticZoom"in r)&&(this.zoomed_y_=null!==r.valueRange);var n=t.isPixelChangingOptionList(this.attr_("labels"),r);t.updateDeep(this.user_attrs_,r),this.attributes_.reparseSeries(),i?(this.cascadeEvents_("dataWillUpdate",{}),this.file_=i,a||this.start_()):a||(n?this.predraw_():this.renderGraph_(!1))},t.mapLegacyOptions_=function(t){var e={};for(var a in t)t.hasOwnProperty(a)&&"file"!=a&&t.hasOwnProperty(a)&&(e[a]=t[a]);var i=function(t,a,i){e.axes||(e.axes={}),e.axes[t]||(e.axes[t]={}),e.axes[t][a]=i},r=function(a,r,n){"undefined"!=typeof t[a]&&(console.warn("Option "+a+" is deprecated. Use the "+n+" option for the "+r+" axis instead. (e.g. { axes : { "+r+" : { "+n+" : ... } } } (see http://dygraphs.com/per-axis.html for more information."),i(r,n,t[a]),delete e[a])};return r("xValueFormatter","x","valueFormatter"),r("pixelsPerXLabel","x","pixelsPerLabel"),r("xAxisLabelFormatter","x","axisLabelFormatter"),r("xTicker","x","ticker"),r("yValueFormatter","y","valueFormatter"),r("pixelsPerYLabel","y","pixelsPerLabel"),r("yAxisLabelFormatter","y","axisLabelFormatter"),r("yTicker","y","ticker"),r("drawXGrid","x","drawGrid"),r("drawXAxis","x","drawAxis"),r("drawYGrid","y","drawGrid"),r("drawYAxis","y","drawAxis"),r("xAxisLabelWidth","x","axisLabelWidth"),r("yAxisLabelWidth","y","axisLabelWidth"),e},t.prototype.resize=function(t,e){if(!this.resize_lock){this.resize_lock=!0,null===t!=(null===e)&&(console.warn("Dygraph.resize() should be called with zero parameters or two non-NULL parameters. Pretending it was zero."),t=e=null);var a=this.width_,i=this.height_;t?(this.maindiv_.style.width=t+"px",this.maindiv_.style.height=e+"px",this.width_=t,this.height_=e):(this.width_=this.maindiv_.clientWidth,this.height_=this.maindiv_.clientHeight),(a!=this.width_||i!=this.height_)&&(this.resizeElements_(),this.predraw_()),this.resize_lock=!1}},t.prototype.adjustRoll=function(t){this.rollPeriod_=t,this.predraw_()},t.prototype.visibility=function(){for(this.getOption("visibility")||(this.attrs_.visibility=[]);this.getOption("visibility").length<this.numColumns()-1;)this.attrs_.visibility.push(!0);return this.getOption("visibility")},t.prototype.setVisibility=function(t,e){var a=this.visibility();0>t||t>=a.length?console.warn("invalid series number in setVisibility: "+t):(a[t]=e,this.predraw_())},t.prototype.size=function(){return{width:this.width_,height:this.height_}},t.prototype.setAnnotations=function(e,a){return t.addAnnotationRule(),this.annotations_=e,this.layout_?(this.layout_.setAnnotations(this.annotations_),void(a||this.predraw_())):void console.warn("Tried to setAnnotations before dygraph was ready. Try setting them in a ready() block. See dygraphs.com/tests/annotation.html")},t.prototype.annotations=function(){return this.annotations_},t.prototype.getLabels=function(){var t=this.attr_("labels");return t?t.slice():null},t.prototype.indexFromSetName=function(t){return this.setIndexByName_[t]},t.prototype.ready=function(t){this.is_initial_draw_?this.readyFns_.push(t):t.call(this,this)},t.addAnnotationRule=function(){if(!t.addedAnnotationCSS){var e="border: 1px solid black; background-color: white; text-align: center;",a=document.createElement("style");a.type="text/css",document.getElementsByTagName("head")[0].appendChild(a);for(var i=0;i<document.styleSheets.length;i++)if(!document.styleSheets[i].disabled){var r=document.styleSheets[i];try{if(r.insertRule){var n=r.cssRules?r.cssRules.length:0;r.insertRule(".dygraphDefaultAnnotation { "+e+" }",n)}else r.addRule&&r.addRule(".dygraphDefaultAnnotation",e);return void(t.addedAnnotationCSS=!0)}catch(o){}}console.warn("Unable to add default annotation CSS rule; display may be off.")}},"object"==typeof exports&&"undefined"!=typeof module&&(module.exports=t),t}();!function(){"use strict";function t(t){var e=a.exec(t);if(!e)return null;var i=parseInt(e[1],10),r=parseInt(e[2],10),n=parseInt(e[3],10);return e[4]?{r:i,g:r,b:n,a:parseFloat(e[4])}:{r:i,g:r,b:n}}Dygraph.LOG_SCALE=10,Dygraph.LN_TEN=Math.log(Dygraph.LOG_SCALE),Dygraph.log10=function(t){return Math.log(t)/Dygraph.LN_TEN},Dygraph.DOTTED_LINE=[2,2],Dygraph.DASHED_LINE=[7,3],Dygraph.DOT_DASH_LINE=[7,2,2,2],Dygraph.getContext=function(t){return t.getContext("2d")},Dygraph.addEvent=function(t,e,a){t.addEventListener?t.addEventListener(e,a,!1):(t[e+a]=function(){a(window.event)},t.attachEvent("on"+e,t[e+a]))},Dygraph.prototype.addAndTrackEvent=function(t,e,a){Dygraph.addEvent(t,e,a),this.registeredEvents_.push({elem:t,type:e,fn:a})},Dygraph.removeEvent=function(t,e,a){if(t.removeEventListener)t.removeEventListener(e,a,!1);else{try{t.detachEvent("on"+e,t[e+a])}catch(i){}t[e+a]=null}},Dygraph.prototype.removeTrackedEvents_=function(){if(this.registeredEvents_)for(var t=0;t<this.registeredEvents_.length;t++){var e=this.registeredEvents_[t];Dygraph.removeEvent(e.elem,e.type,e.fn)}this.registeredEvents_=[]},Dygraph.cancelEvent=function(t){return t=t?t:window.event,t.stopPropagation&&t.stopPropagation(),t.preventDefault&&t.preventDefault(),t.cancelBubble=!0,t.cancel=!0,t.returnValue=!1,!1},Dygraph.hsvToRGB=function(t,e,a){var i,r,n;if(0===e)i=a,r=a,n=a;else{var o=Math.floor(6*t),s=6*t-o,l=a*(1-e),h=a*(1-e*s),p=a*(1-e*(1-s));switch(o){case 1:i=h,r=a,n=l;break;case 2:i=l,r=a,n=p;break;case 3:i=l,r=h,n=a;break;case 4:i=p,r=l,n=a;break;case 5:i=a,r=l,n=h;break;case 6:case 0:i=a,r=p,n=l}}return i=Math.floor(255*i+.5),r=Math.floor(255*r+.5),n=Math.floor(255*n+.5),"rgb("+i+","+r+","+n+")"},Dygraph.findPos=function(t){var e=0,a=0;if(t.offsetParent)for(var i=t;;){var r="0",n="0";if(window.getComputedStyle){
+var o=window.getComputedStyle(i,null);r=o.borderLeft||"0",n=o.borderTop||"0"}if(e+=parseInt(r,10),a+=parseInt(n,10),e+=i.offsetLeft,a+=i.offsetTop,!i.offsetParent)break;i=i.offsetParent}else t.x&&(e+=t.x),t.y&&(a+=t.y);for(;t&&t!=document.body;)e-=t.scrollLeft,a-=t.scrollTop,t=t.parentNode;return{x:e,y:a}},Dygraph.pageX=function(t){if(t.pageX)return!t.pageX||t.pageX<0?0:t.pageX;var e=document.documentElement,a=document.body;return t.clientX+(e.scrollLeft||a.scrollLeft)-(e.clientLeft||0)},Dygraph.pageY=function(t){if(t.pageY)return!t.pageY||t.pageY<0?0:t.pageY;var e=document.documentElement,a=document.body;return t.clientY+(e.scrollTop||a.scrollTop)-(e.clientTop||0)},Dygraph.dragGetX_=function(t,e){return Dygraph.pageX(t)-e.px},Dygraph.dragGetY_=function(t,e){return Dygraph.pageY(t)-e.py},Dygraph.isOK=function(t){return!!t&&!isNaN(t)},Dygraph.isValidPoint=function(t,e){return t?null===t.yval?!1:null===t.x||void 0===t.x?!1:null===t.y||void 0===t.y?!1:isNaN(t.x)||!e&&isNaN(t.y)?!1:!0:!1},Dygraph.floatFormat=function(t,e){var a=Math.min(Math.max(1,e||2),21);return Math.abs(t)<.001&&0!==t?t.toExponential(a-1):t.toPrecision(a)},Dygraph.zeropad=function(t){return 10>t?"0"+t:""+t},Dygraph.DateAccessorsLocal={getFullYear:function(t){return t.getFullYear()},getMonth:function(t){return t.getMonth()},getDate:function(t){return t.getDate()},getHours:function(t){return t.getHours()},getMinutes:function(t){return t.getMinutes()},getSeconds:function(t){return t.getSeconds()},getMilliseconds:function(t){return t.getMilliseconds()},getDay:function(t){return t.getDay()},makeDate:function(t,e,a,i,r,n,o){return new Date(t,e,a,i,r,n,o)}},Dygraph.DateAccessorsUTC={getFullYear:function(t){return t.getUTCFullYear()},getMonth:function(t){return t.getUTCMonth()},getDate:function(t){return t.getUTCDate()},getHours:function(t){return t.getUTCHours()},getMinutes:function(t){return t.getUTCMinutes()},getSeconds:function(t){return t.getUTCSeconds()},getMilliseconds:function(t){return t.getUTCMilliseconds()},getDay:function(t){return t.getUTCDay()},makeDate:function(t,e,a,i,r,n,o){return new Date(Date.UTC(t,e,a,i,r,n,o))}},Dygraph.hmsString_=function(t,e,a){var i=Dygraph.zeropad,r=i(t)+":"+i(e);return a&&(r+=":"+i(a)),r},Dygraph.dateString_=function(t,e){var a=Dygraph.zeropad,i=e?Dygraph.DateAccessorsUTC:Dygraph.DateAccessorsLocal,r=new Date(t),n=i.getFullYear(r),o=i.getMonth(r),s=i.getDate(r),l=i.getHours(r),h=i.getMinutes(r),p=i.getSeconds(r),g=""+n,d=a(o+1),u=a(s),c=3600*l+60*h+p,y=g+"/"+d+"/"+u;return c&&(y+=" "+Dygraph.hmsString_(l,h,p)),y},Dygraph.round_=function(t,e){var a=Math.pow(10,e);return Math.round(t*a)/a},Dygraph.binarySearch=function(t,e,a,i,r){if((null===i||void 0===i||null===r||void 0===r)&&(i=0,r=e.length-1),i>r)return-1;(null===a||void 0===a)&&(a=0);var n,o=function(t){return t>=0&&t<e.length},s=parseInt((i+r)/2,10),l=e[s];return l==t?s:l>t?a>0&&(n=s-1,o(n)&&e[n]<t)?s:Dygraph.binarySearch(t,e,a,i,s-1):t>l?0>a&&(n=s+1,o(n)&&e[n]>t)?s:Dygraph.binarySearch(t,e,a,s+1,r):-1},Dygraph.dateParser=function(t){var e,a;if((-1==t.search("-")||-1!=t.search("T")||-1!=t.search("Z"))&&(a=Dygraph.dateStrToMillis(t),a&&!isNaN(a)))return a;if(-1!=t.search("-")){for(e=t.replace("-","/","g");-1!=e.search("-");)e=e.replace("-","/");a=Dygraph.dateStrToMillis(e)}else 8==t.length?(e=t.substr(0,4)+"/"+t.substr(4,2)+"/"+t.substr(6,2),a=Dygraph.dateStrToMillis(e)):a=Dygraph.dateStrToMillis(t);return(!a||isNaN(a))&&console.error("Couldn't parse "+t+" as a date"),a},Dygraph.dateStrToMillis=function(t){return new Date(t).getTime()},Dygraph.update=function(t,e){if("undefined"!=typeof e&&null!==e)for(var a in e)e.hasOwnProperty(a)&&(t[a]=e[a]);return t},Dygraph.updateDeep=function(t,e){function a(t){return"object"==typeof Node?t instanceof Node:"object"==typeof t&&"number"==typeof t.nodeType&&"string"==typeof t.nodeName}if("undefined"!=typeof e&&null!==e)for(var i in e)e.hasOwnProperty(i)&&(null===e[i]?t[i]=null:Dygraph.isArrayLike(e[i])?t[i]=e[i].slice():a(e[i])?t[i]=e[i]:"object"==typeof e[i]?(("object"!=typeof t[i]||null===t[i])&&(t[i]={}),Dygraph.updateDeep(t[i],e[i])):t[i]=e[i]);return t},Dygraph.isArrayLike=function(t){var e=typeof t;return"object"!=e&&("function"!=e||"function"!=typeof t.item)||null===t||"number"!=typeof t.length||3===t.nodeType?!1:!0},Dygraph.isDateLike=function(t){return"object"!=typeof t||null===t||"function"!=typeof t.getTime?!1:!0},Dygraph.clone=function(t){for(var e=[],a=0;a<t.length;a++)e.push(Dygraph.isArrayLike(t[a])?Dygraph.clone(t[a]):t[a]);return e},Dygraph.createCanvas=function(){var t=document.createElement("canvas"),e=/MSIE/.test(navigator.userAgent)&&!window.opera;return e&&"undefined"!=typeof G_vmlCanvasManager&&(t=G_vmlCanvasManager.initElement(t)),t},Dygraph.getContextPixelRatio=function(t){try{var e=window.devicePixelRatio,a=t.webkitBackingStorePixelRatio||t.mozBackingStorePixelRatio||t.msBackingStorePixelRatio||t.oBackingStorePixelRatio||t.backingStorePixelRatio||1;return void 0!==e?e/a:1}catch(i){return 1}},Dygraph.isAndroid=function(){return/Android/.test(navigator.userAgent)},Dygraph.Iterator=function(t,e,a,i){e=e||0,a=a||t.length,this.hasNext=!0,this.peek=null,this.start_=e,this.array_=t,this.predicate_=i,this.end_=Math.min(t.length,e+a),this.nextIdx_=e-1,this.next()},Dygraph.Iterator.prototype.next=function(){if(!this.hasNext)return null;for(var t=this.peek,e=this.nextIdx_+1,a=!1;e<this.end_;){if(!this.predicate_||this.predicate_(this.array_,e)){this.peek=this.array_[e],a=!0;break}e++}return this.nextIdx_=e,a||(this.hasNext=!1,this.peek=null),t},Dygraph.createIterator=function(t,e,a,i){return new Dygraph.Iterator(t,e,a,i)},Dygraph.requestAnimFrame=function(){return window.requestAnimationFrame||window.webkitRequestAnimationFrame||window.mozRequestAnimationFrame||window.oRequestAnimationFrame||window.msRequestAnimationFrame||function(t){window.setTimeout(t,1e3/60)}}(),Dygraph.repeatAndCleanup=function(t,e,a,i){var r,n=0,o=(new Date).getTime();if(t(n),1==e)return void i();var s=e-1;!function l(){n>=e||Dygraph.requestAnimFrame.call(window,function(){var e=(new Date).getTime(),h=e-o;r=n,n=Math.floor(h/a);var p=n-r,g=n+p>s;g||n>=s?(t(s),i()):(0!==p&&t(n),l())})}()};var e={annotationClickHandler:!0,annotationDblClickHandler:!0,annotationMouseOutHandler:!0,annotationMouseOverHandler:!0,axisLabelColor:!0,axisLineColor:!0,axisLineWidth:!0,clickCallback:!0,drawCallback:!0,drawHighlightPointCallback:!0,drawPoints:!0,drawPointCallback:!0,drawXGrid:!0,drawYGrid:!0,fillAlpha:!0,gridLineColor:!0,gridLineWidth:!0,hideOverlayOnMouseOut:!0,highlightCallback:!0,highlightCircleSize:!0,interactionModel:!0,isZoomedIgnoreProgrammaticZoom:!0,labelsDiv:!0,labelsDivStyles:!0,labelsDivWidth:!0,labelsKMB:!0,labelsKMG2:!0,labelsSeparateLines:!0,labelsShowZeroValues:!0,legend:!0,panEdgeFraction:!0,pixelsPerYLabel:!0,pointClickCallback:!0,pointSize:!0,rangeSelectorPlotFillColor:!0,rangeSelectorPlotStrokeColor:!0,showLabelsOnHighlight:!0,showRoller:!0,strokeWidth:!0,underlayCallback:!0,unhighlightCallback:!0,zoomCallback:!0};Dygraph.isPixelChangingOptionList=function(t,a){var i={};if(t)for(var r=1;r<t.length;r++)i[t[r]]=!0;var n=function(t){for(var a in t)if(t.hasOwnProperty(a)&&!e[a])return!0;return!1};for(var o in a)if(a.hasOwnProperty(o))if("highlightSeriesOpts"==o||i[o]&&!a.series){if(n(a[o]))return!0}else if("series"==o||"axes"==o){var s=a[o];for(var l in s)if(s.hasOwnProperty(l)&&n(s[l]))return!0}else if(!e[o])return!0;return!1},Dygraph.Circles={DEFAULT:function(t,e,a,i,r,n,o){a.beginPath(),a.fillStyle=n,a.arc(i,r,o,0,2*Math.PI,!1),a.fill()}},Dygraph.IFrameTarp=function(){this.tarps=[]},Dygraph.IFrameTarp.prototype.cover=function(){for(var t=document.getElementsByTagName("iframe"),e=0;e<t.length;e++){var a=t[e],i=Dygraph.findPos(a),r=i.x,n=i.y,o=a.offsetWidth,s=a.offsetHeight,l=document.createElement("div");l.style.position="absolute",l.style.left=r+"px",l.style.top=n+"px",l.style.width=o+"px",l.style.height=s+"px",l.style.zIndex=999,document.body.appendChild(l),this.tarps.push(l)}},Dygraph.IFrameTarp.prototype.uncover=function(){for(var t=0;t<this.tarps.length;t++)this.tarps[t].parentNode.removeChild(this.tarps[t]);this.tarps=[]},Dygraph.detectLineDelimiter=function(t){for(var e=0;e<t.length;e++){var a=t.charAt(e);if("\r"===a)return e+1<t.length&&"\n"===t.charAt(e+1)?"\r\n":a;if("\n"===a)return e+1<t.length&&"\r"===t.charAt(e+1)?"\n\r":a}return null},Dygraph.isNodeContainedBy=function(t,e){if(null===e||null===t)return!1;for(var a=t;a&&a!==e;)a=a.parentNode;return a===e},Dygraph.pow=function(t,e){return 0>e?1/Math.pow(t,-e):Math.pow(t,e)};var a=/^rgba?\((\d{1,3}),\s*(\d{1,3}),\s*(\d{1,3})(?:,\s*([01](?:\.\d+)?))?\)$/;Dygraph.toRGB_=function(e){var a=t(e);if(a)return a;var i=document.createElement("div");i.style.backgroundColor=e,i.style.visibility="hidden",document.body.appendChild(i);var r;return r=window.getComputedStyle?window.getComputedStyle(i,null).backgroundColor:i.currentStyle.backgroundColor,document.body.removeChild(i),t(r)},Dygraph.isCanvasSupported=function(t){var e;try{e=t||document.createElement("canvas"),e.getContext("2d")}catch(a){var i=navigator.appVersion.match(/MSIE (\d\.\d)/),r=-1!=navigator.userAgent.toLowerCase().indexOf("opera");return!i||i[1]<6||r?!1:!0}return!0},Dygraph.parseFloat_=function(t,e,a){var i=parseFloat(t);if(!isNaN(i))return i;if(/^ *$/.test(t))return null;if(/^ *nan *$/i.test(t))return 0/0;var r="Unable to parse '"+t+"' as a number";return void 0!==a&&void 0!==e&&(r+=" on line "+(1+(e||0))+" ('"+a+"') of CSV."),console.error(r),null}}(),function(){"use strict";Dygraph.GVizChart=function(t){this.container=t},Dygraph.GVizChart.prototype.draw=function(t,e){this.container.innerHTML="","undefined"!=typeof this.date_graph&&this.date_graph.destroy(),this.date_graph=new Dygraph(this.container,t,e)},Dygraph.GVizChart.prototype.setSelection=function(t){var e=!1;t.length&&(e=t[0].row),this.date_graph.setSelection(e)},Dygraph.GVizChart.prototype.getSelection=function(){var t=[],e=this.date_graph.getSelection();if(0>e)return t;for(var a=this.date_graph.layout_.points,i=0;i<a.length;++i)t.push({row:e,column:i+1});return t}}(),function(){"use strict";var t=100;Dygraph.Interaction={},Dygraph.Interaction.maybeTreatMouseOpAsClick=function(t,e,a){a.dragEndX=Dygraph.dragGetX_(t,a),a.dragEndY=Dygraph.dragGetY_(t,a);var i=Math.abs(a.dragEndX-a.dragStartX),r=Math.abs(a.dragEndY-a.dragStartY);2>i&&2>r&&void 0!==e.lastx_&&-1!=e.lastx_&&Dygraph.Interaction.treatMouseOpAsClick(e,t,a),a.regionWidth=i,a.regionHeight=r},Dygraph.Interaction.startPan=function(t,e,a){var i,r;a.isPanning=!0;var n=e.xAxisRange();if(e.getOptionForAxis("logscale","x")?(a.initialLeftmostDate=Dygraph.log10(n[0]),a.dateRange=Dygraph.log10(n[1])-Dygraph.log10(n[0])):(a.initialLeftmostDate=n[0],a.dateRange=n[1]-n[0]),a.xUnitsPerPixel=a.dateRange/(e.plotter_.area.w-1),e.getNumericOption("panEdgeFraction")){var o=e.width_*e.getNumericOption("panEdgeFraction"),s=e.xAxisExtremes(),l=e.toDomXCoord(s[0])-o,h=e.toDomXCoord(s[1])+o,p=e.toDataXCoord(l),g=e.toDataXCoord(h);a.boundedDates=[p,g];var d=[],u=e.height_*e.getNumericOption("panEdgeFraction");for(i=0;i<e.axes_.length;i++){r=e.axes_[i];var c=r.extremeRange,y=e.toDomYCoord(c[0],i)+u,_=e.toDomYCoord(c[1],i)-u,v=e.toDataYCoord(y,i),f=e.toDataYCoord(_,i);d[i]=[v,f]}a.boundedValues=d}for(a.is2DPan=!1,a.axes=[],i=0;i<e.axes_.length;i++){r=e.axes_[i];var x={},m=e.yAxisRange(i),D=e.attributes_.getForAxis("logscale",i);D?(x.initialTopValue=Dygraph.log10(m[1]),x.dragValueRange=Dygraph.log10(m[1])-Dygraph.log10(m[0])):(x.initialTopValue=m[1],x.dragValueRange=m[1]-m[0]),x.unitsPerPixel=x.dragValueRange/(e.plotter_.area.h-1),a.axes.push(x),(r.valueWindow||r.valueRange)&&(a.is2DPan=!0)}},Dygraph.Interaction.movePan=function(t,e,a){a.dragEndX=Dygraph.dragGetX_(t,a),a.dragEndY=Dygraph.dragGetY_(t,a);var i=a.initialLeftmostDate-(a.dragEndX-a.dragStartX)*a.xUnitsPerPixel;a.boundedDates&&(i=Math.max(i,a.boundedDates[0]));var r=i+a.dateRange;if(a.boundedDates&&r>a.boundedDates[1]&&(i-=r-a.boundedDates[1],r=i+a.dateRange),e.getOptionForAxis("logscale","x")?e.dateWindow_=[Math.pow(Dygraph.LOG_SCALE,i),Math.pow(Dygraph.LOG_SCALE,r)]:e.dateWindow_=[i,r],a.is2DPan)for(var n=a.dragEndY-a.dragStartY,o=0;o<e.axes_.length;o++){var s=e.axes_[o],l=a.axes[o],h=n*l.unitsPerPixel,p=a.boundedValues?a.boundedValues[o]:null,g=l.initialTopValue+h;p&&(g=Math.min(g,p[1]));var d=g-l.dragValueRange;p&&d<p[0]&&(g-=d-p[0],d=g-l.dragValueRange),e.attributes_.getForAxis("logscale",o)?s.valueWindow=[Math.pow(Dygraph.LOG_SCALE,d),Math.pow(Dygraph.LOG_SCALE,g)]:s.valueWindow=[d,g]}e.drawGraph_(!1)},Dygraph.Interaction.endPan=Dygraph.Interaction.maybeTreatMouseOpAsClick,Dygraph.Interaction.startZoom=function(t,e,a){a.isZooming=!0,a.zoomMoved=!1},Dygraph.Interaction.moveZoom=function(t,e,a){a.zoomMoved=!0,a.dragEndX=Dygraph.dragGetX_(t,a),a.dragEndY=Dygraph.dragGetY_(t,a);var i=Math.abs(a.dragStartX-a.dragEndX),r=Math.abs(a.dragStartY-a.dragEndY);a.dragDirection=r/2>i?Dygraph.VERTICAL:Dygraph.HORIZONTAL,e.drawZoomRect_(a.dragDirection,a.dragStartX,a.dragEndX,a.dragStartY,a.dragEndY,a.prevDragDirection,a.prevEndX,a.prevEndY),a.prevEndX=a.dragEndX,a.prevEndY=a.dragEndY,a.prevDragDirection=a.dragDirection},Dygraph.Interaction.treatMouseOpAsClick=function(t,e,a){for(var i=t.getFunctionOption("clickCallback"),r=t.getFunctionOption("pointClickCallback"),n=null,o=-1,s=Number.MAX_VALUE,l=0;l<t.selPoints_.length;l++){var h=t.selPoints_[l],p=Math.pow(h.canvasx-a.dragEndX,2)+Math.pow(h.canvasy-a.dragEndY,2);!isNaN(p)&&(-1==o||s>p)&&(s=p,o=l)}var g=t.getNumericOption("highlightCircleSize")+2;if(g*g>=s&&(n=t.selPoints_[o]),n){var d={cancelable:!0,point:n,canvasx:a.dragEndX,canvasy:a.dragEndY},u=t.cascadeEvents_("pointClick",d);if(u)return;r&&r.call(t,e,n)}var d={cancelable:!0,xval:t.lastx_,pts:t.selPoints_,canvasx:a.dragEndX,canvasy:a.dragEndY};t.cascadeEvents_("click",d)||i&&i.call(t,e,t.lastx_,t.selPoints_)},Dygraph.Interaction.endZoom=function(t,e,a){e.clearZoomRect_(),a.isZooming=!1,Dygraph.Interaction.maybeTreatMouseOpAsClick(t,e,a);var i=e.getArea();if(a.regionWidth>=10&&a.dragDirection==Dygraph.HORIZONTAL){var r=Math.min(a.dragStartX,a.dragEndX),n=Math.max(a.dragStartX,a.dragEndX);r=Math.max(r,i.x),n=Math.min(n,i.x+i.w),n>r&&e.doZoomX_(r,n),a.cancelNextDblclick=!0}else if(a.regionHeight>=10&&a.dragDirection==Dygraph.VERTICAL){var o=Math.min(a.dragStartY,a.dragEndY),s=Math.max(a.dragStartY,a.dragEndY);o=Math.max(o,i.y),s=Math.min(s,i.y+i.h),s>o&&e.doZoomY_(o,s),a.cancelNextDblclick=!0}a.dragStartX=null,a.dragStartY=null},Dygraph.Interaction.startTouch=function(t,e,a){t.preventDefault(),t.touches.length>1&&(a.startTimeForDoubleTapMs=null);for(var i=[],r=0;r<t.touches.length;r++){var n=t.touches[r];i.push({pageX:n.pageX,pageY:n.pageY,dataX:e.toDataXCoord(n.pageX),dataY:e.toDataYCoord(n.pageY)})}if(a.initialTouches=i,1==i.length)a.initialPinchCenter=i[0],a.touchDirections={x:!0,y:!0};else if(i.length>=2){a.initialPinchCenter={pageX:.5*(i[0].pageX+i[1].pageX),pageY:.5*(i[0].pageY+i[1].pageY),dataX:.5*(i[0].dataX+i[1].dataX),dataY:.5*(i[0].dataY+i[1].dataY)};var o=180/Math.PI*Math.atan2(a.initialPinchCenter.pageY-i[0].pageY,i[0].pageX-a.initialPinchCenter.pageX);o=Math.abs(o),o>90&&(o=90-o),a.touchDirections={x:67.5>o,y:o>22.5}}a.initialRange={x:e.xAxisRange(),y:e.yAxisRange()}},Dygraph.Interaction.moveTouch=function(t,e,a){a.startTimeForDoubleTapMs=null;var i,r=[];for(i=0;i<t.touches.length;i++){var n=t.touches[i];r.push({pageX:n.pageX,pageY:n.pageY})}var o,s=a.initialTouches,l=a.initialPinchCenter;o=1==r.length?r[0]:{pageX:.5*(r[0].pageX+r[1].pageX),pageY:.5*(r[0].pageY+r[1].pageY)};var h={pageX:o.pageX-l.pageX,pageY:o.pageY-l.pageY},p=a.initialRange.x[1]-a.initialRange.x[0],g=a.initialRange.y[0]-a.initialRange.y[1];h.dataX=h.pageX/e.plotter_.area.w*p,h.dataY=h.pageY/e.plotter_.area.h*g;var d,u;if(1==r.length)d=1,u=1;else if(r.length>=2){var c=s[1].pageX-l.pageX;d=(r[1].pageX-o.pageX)/c;var y=s[1].pageY-l.pageY;u=(r[1].pageY-o.pageY)/y}d=Math.min(8,Math.max(.125,d)),u=Math.min(8,Math.max(.125,u));var _=!1;if(a.touchDirections.x&&(e.dateWindow_=[l.dataX-h.dataX+(a.initialRange.x[0]-l.dataX)/d,l.dataX-h.dataX+(a.initialRange.x[1]-l.dataX)/d],_=!0),a.touchDirections.y)for(i=0;1>i;i++){var v=e.axes_[i],f=e.attributes_.getForAxis("logscale",i);f||(v.valueWindow=[l.dataY-h.dataY+(a.initialRange.y[0]-l.dataY)/u,l.dataY-h.dataY+(a.initialRange.y[1]-l.dataY)/u],_=!0)}if(e.drawGraph_(!1),_&&r.length>1&&e.getFunctionOption("zoomCallback")){var x=e.xAxisRange();e.getFunctionOption("zoomCallback").call(e,x[0],x[1],e.yAxisRanges())}},Dygraph.Interaction.endTouch=function(t,e,a){if(0!==t.touches.length)Dygraph.Interaction.startTouch(t,e,a);else if(1==t.changedTouches.length){var i=(new Date).getTime(),r=t.changedTouches[0];a.startTimeForDoubleTapMs&&i-a.startTimeForDoubleTapMs<500&&a.doubleTapX&&Math.abs(a.doubleTapX-r.screenX)<50&&a.doubleTapY&&Math.abs(a.doubleTapY-r.screenY)<50?e.resetZoom():(a.startTimeForDoubleTapMs=i,a.doubleTapX=r.screenX,a.doubleTapY=r.screenY)}};var e=function(t,e,a){return e>t?e-t:t>a?t-a:0},a=function(t,a){var i=Dygraph.findPos(a.canvas_),r={left:i.x,right:i.x+a.canvas_.offsetWidth,top:i.y,bottom:i.y+a.canvas_.offsetHeight},n={x:Dygraph.pageX(t),y:Dygraph.pageY(t)},o=e(n.x,r.left,r.right),s=e(n.y,r.top,r.bottom);return Math.max(o,s)};Dygraph.Interaction.defaultModel={mousedown:function(e,i,r){if(!e.button||2!=e.button){r.initializeMouseDown(e,i,r),e.altKey||e.shiftKey?Dygraph.startPan(e,i,r):Dygraph.startZoom(e,i,r);var n=function(e){if(r.isZooming){var n=a(e,i);t>n?Dygraph.moveZoom(e,i,r):null!==r.dragEndX&&(r.dragEndX=null,r.dragEndY=null,i.clearZoomRect_())}else r.isPanning&&Dygraph.movePan(e,i,r)},o=function(t){r.isZooming?null!==r.dragEndX?Dygraph.endZoom(t,i,r):Dygraph.Interaction.maybeTreatMouseOpAsClick(t,i,r):r.isPanning&&Dygraph.endPan(t,i,r),Dygraph.removeEvent(document,"mousemove",n),Dygraph.removeEvent(document,"mouseup",o),r.destroy()};i.addAndTrackEvent(document,"mousemove",n),i.addAndTrackEvent(document,"mouseup",o)}},willDestroyContextMyself:!0,touchstart:function(t,e,a){Dygraph.Interaction.startTouch(t,e,a)},touchmove:function(t,e,a){Dygraph.Interaction.moveTouch(t,e,a)},touchend:function(t,e,a){Dygraph.Interaction.endTouch(t,e,a)},dblclick:function(t,e,a){if(a.cancelNextDblclick)return void(a.cancelNextDblclick=!1);var i={canvasx:a.dragEndX,canvasy:a.dragEndY};e.cascadeEvents_("dblclick",i)||t.altKey||t.shiftKey||e.resetZoom()}},Dygraph.DEFAULT_ATTRS.interactionModel=Dygraph.Interaction.defaultModel,Dygraph.defaultInteractionModel=Dygraph.Interaction.defaultModel,Dygraph.endZoom=Dygraph.Interaction.endZoom,Dygraph.moveZoom=Dygraph.Interaction.moveZoom,Dygraph.startZoom=Dygraph.Interaction.startZoom,Dygraph.endPan=Dygraph.Interaction.endPan,Dygraph.movePan=Dygraph.Interaction.movePan,Dygraph.startPan=Dygraph.Interaction.startPan,Dygraph.Interaction.nonInteractiveModel_={mousedown:function(t,e,a){a.initializeMouseDown(t,e,a)},mouseup:Dygraph.Interaction.maybeTreatMouseOpAsClick},Dygraph.Interaction.dragIsPanInteractionModel={mousedown:function(t,e,a){a.initializeMouseDown(t,e,a),Dygraph.startPan(t,e,a)},mousemove:function(t,e,a){a.isPanning&&Dygraph.movePan(t,e,a)},mouseup:function(t,e,a){a.isPanning&&Dygraph.endPan(t,e,a)}}}(),function(){"use strict";Dygraph.TickList=void 0,Dygraph.Ticker=void 0,Dygraph.numericLinearTicks=function(t,e,a,i,r,n){var o=function(t){return"logscale"===t?!1:i(t)};return Dygraph.numericTicks(t,e,a,o,r,n)},Dygraph.numericTicks=function(t,e,a,i,r,n){var o,s,l,h,p=i("pixelsPerLabel"),g=[];if(n)for(o=0;o<n.length;o++)g.push({v:n[o]});else{if(i("logscale")){h=Math.floor(a/p);var d=Dygraph.binarySearch(t,Dygraph.PREFERRED_LOG_TICK_VALUES,1),u=Dygraph.binarySearch(e,Dygraph.PREFERRED_LOG_TICK_VALUES,-1);-1==d&&(d=0),-1==u&&(u=Dygraph.PREFERRED_LOG_TICK_VALUES.length-1);var c=null;if(u-d>=h/4){for(var y=u;y>=d;y--){var _=Dygraph.PREFERRED_LOG_TICK_VALUES[y],v=Math.log(_/t)/Math.log(e/t)*a,f={v:_};null===c?c={tickValue:_,pixel_coord:v}:Math.abs(v-c.pixel_coord)>=p?c={tickValue:_,pixel_coord:v}:f.label="",g.push(f)}g.reverse()}}if(0===g.length){var x,m,D=i("labelsKMG2");D?(x=[1,2,4,8,16,32,64,128,256],m=16):(x=[1,2,5,10,20,50,100],m=10);var w,A,b,T,E=Math.ceil(a/p),C=Math.abs(e-t)/E,L=Math.floor(Math.log(C)/Math.log(m)),P=Math.pow(m,L);for(s=0;s<x.length&&(w=P*x[s],A=Math.floor(t/w)*w,b=Math.ceil(e/w)*w,h=Math.abs(b-A)/w,T=a/h,!(T>p));s++);for(A>b&&(w*=-1),o=0;h>=o;o++)l=A+o*w,g.push({v:l})}}var S=i("axisLabelFormatter");for(o=0;o<g.length;o++)void 0===g[o].label&&(g[o].label=S.call(r,g[o].v,0,i,r));return g},Dygraph.dateTicker=function(t,e,a,i,r,n){var o=Dygraph.pickDateTickGranularity(t,e,a,i);return o>=0?Dygraph.getDateAxis(t,e,o,i,r):[]},Dygraph.SECONDLY=0,Dygraph.TWO_SECONDLY=1,Dygraph.FIVE_SECONDLY=2,Dygraph.TEN_SECONDLY=3,Dygraph.THIRTY_SECONDLY=4,Dygraph.MINUTELY=5,Dygraph.TWO_MINUTELY=6,Dygraph.FIVE_MINUTELY=7,Dygraph.TEN_MINUTELY=8,Dygraph.THIRTY_MINUTELY=9,Dygraph.HOURLY=10,Dygraph.TWO_HOURLY=11,Dygraph.SIX_HOURLY=12,Dygraph.DAILY=13,Dygraph.TWO_DAILY=14,Dygraph.WEEKLY=15,Dygraph.MONTHLY=16,Dygraph.QUARTERLY=17,Dygraph.BIANNUAL=18,Dygraph.ANNUAL=19,Dygraph.DECADAL=20,Dygraph.CENTENNIAL=21,Dygraph.NUM_GRANULARITIES=22,Dygraph.DATEFIELD_Y=0,Dygraph.DATEFIELD_M=1,Dygraph.DATEFIELD_D=2,Dygraph.DATEFIELD_HH=3,Dygraph.DATEFIELD_MM=4,Dygraph.DATEFIELD_SS=5,Dygraph.DATEFIELD_MS=6,Dygraph.NUM_DATEFIELDS=7,Dygraph.TICK_PLACEMENT=[],Dygraph.TICK_PLACEMENT[Dygraph.SECONDLY]={datefield:Dygraph.DATEFIELD_SS,step:1,spacing:1e3},Dygraph.TICK_PLACEMENT[Dygraph.TWO_SECONDLY]={datefield:Dygraph.DATEFIELD_SS,step:2,spacing:2e3},Dygraph.TICK_PLACEMENT[Dygraph.FIVE_SECONDLY]={datefield:Dygraph.DATEFIELD_SS,step:5,spacing:5e3},Dygraph.TICK_PLACEMENT[Dygraph.TEN_SECONDLY]={datefield:Dygraph.DATEFIELD_SS,step:10,spacing:1e4},Dygraph.TICK_PLACEMENT[Dygraph.THIRTY_SECONDLY]={datefield:Dygraph.DATEFIELD_SS,step:30,spacing:3e4},Dygraph.TICK_PLACEMENT[Dygraph.MINUTELY]={datefield:Dygraph.DATEFIELD_MM,step:1,spacing:6e4},Dygraph.TICK_PLACEMENT[Dygraph.TWO_MINUTELY]={datefield:Dygraph.DATEFIELD_MM,step:2,spacing:12e4},Dygraph.TICK_PLACEMENT[Dygraph.FIVE_MINUTELY]={datefield:Dygraph.DATEFIELD_MM,step:5,spacing:3e5},Dygraph.TICK_PLACEMENT[Dygraph.TEN_MINUTELY]={datefield:Dygraph.DATEFIELD_MM,step:10,spacing:6e5},Dygraph.TICK_PLACEMENT[Dygraph.THIRTY_MINUTELY]={datefield:Dygraph.DATEFIELD_MM,step:30,spacing:18e5},Dygraph.TICK_PLACEMENT[Dygraph.HOURLY]={datefield:Dygraph.DATEFIELD_HH,step:1,spacing:36e5},Dygraph.TICK_PLACEMENT[Dygraph.TWO_HOURLY]={datefield:Dygraph.DATEFIELD_HH,step:2,spacing:72e5},Dygraph.TICK_PLACEMENT[Dygraph.SIX_HOURLY]={datefield:Dygraph.DATEFIELD_HH,step:6,spacing:216e5},Dygraph.TICK_PLACEMENT[Dygraph.DAILY]={datefield:Dygraph.DATEFIELD_D,step:1,spacing:864e5},Dygraph.TICK_PLACEMENT[Dygraph.TWO_DAILY]={datefield:Dygraph.DATEFIELD_D,step:2,spacing:1728e5},Dygraph.TICK_PLACEMENT[Dygraph.WEEKLY]={datefield:Dygraph.DATEFIELD_D,step:7,spacing:6048e5},Dygraph.TICK_PLACEMENT[Dygraph.MONTHLY]={datefield:Dygraph.DATEFIELD_M,step:1,spacing:2629817280},Dygraph.TICK_PLACEMENT[Dygraph.QUARTERLY]={datefield:Dygraph.DATEFIELD_M,step:3,spacing:216e5*365.2524},Dygraph.TICK_PLACEMENT[Dygraph.BIANNUAL]={datefield:Dygraph.DATEFIELD_M,step:6,spacing:432e5*365.2524},Dygraph.TICK_PLACEMENT[Dygraph.ANNUAL]={datefield:Dygraph.DATEFIELD_Y,step:1,spacing:864e5*365.2524},Dygraph.TICK_PLACEMENT[Dygraph.DECADAL]={datefield:Dygraph.DATEFIELD_Y,step:10,spacing:315578073600},Dygraph.TICK_PLACEMENT[Dygraph.CENTENNIAL]={datefield:Dygraph.DATEFIELD_Y,step:100,spacing:3155780736e3},Dygraph.PREFERRED_LOG_TICK_VALUES=function(){for(var t=[],e=-39;39>=e;e++)for(var a=Math.pow(10,e),i=1;9>=i;i++){var r=a*i;t.push(r)}return t}(),Dygraph.pickDateTickGranularity=function(t,e,a,i){for(var r=i("pixelsPerLabel"),n=0;n<Dygraph.NUM_GRANULARITIES;n++){var o=Dygraph.numDateTicks(t,e,n);if(a/o>=r)return n}return-1},Dygraph.numDateTicks=function(t,e,a){var i=Dygraph.TICK_PLACEMENT[a].spacing;return Math.round(1*(e-t)/i)},Dygraph.getDateAxis=function(t,e,a,i,r){var n=i("axisLabelFormatter"),o=i("labelsUTC"),s=o?Dygraph.DateAccessorsUTC:Dygraph.DateAccessorsLocal,l=Dygraph.TICK_PLACEMENT[a].datefield,h=Dygraph.TICK_PLACEMENT[a].step,p=Dygraph.TICK_PLACEMENT[a].spacing,g=new Date(t),d=[];d[Dygraph.DATEFIELD_Y]=s.getFullYear(g),d[Dygraph.DATEFIELD_M]=s.getMonth(g),d[Dygraph.DATEFIELD_D]=s.getDate(g),d[Dygraph.DATEFIELD_HH]=s.getHours(g),d[Dygraph.DATEFIELD_MM]=s.getMinutes(g),d[Dygraph.DATEFIELD_SS]=s.getSeconds(g),d[Dygraph.DATEFIELD_MS]=s.getMilliseconds(g);var u=d[l]%h;a==Dygraph.WEEKLY&&(u=s.getDay(g)),d[l]-=u;for(var c=l+1;c<Dygraph.NUM_DATEFIELDS;c++)d[c]=c===Dygraph.DATEFIELD_D?1:0;var y=[],_=s.makeDate.apply(null,d),v=_.getTime();if(a<=Dygraph.HOURLY)for(t>v&&(v+=p,_=new Date(v));e>=v;)y.push({v:v,label:n.call(r,_,a,i,r)}),v+=p,_=new Date(v);else for(t>v&&(d[l]+=h,_=s.makeDate.apply(null,d),v=_.getTime());e>=v;)(a>=Dygraph.DAILY||s.getHours(_)%h===0)&&y.push({v:v,label:n.call(r,_,a,i,r)}),d[l]+=h,_=s.makeDate.apply(null,d),v=_.getTime();return y},Dygraph&&Dygraph.DEFAULT_ATTRS&&Dygraph.DEFAULT_ATTRS.axes&&Dygraph.DEFAULT_ATTRS.axes.x&&Dygraph.DEFAULT_ATTRS.axes.y&&Dygraph.DEFAULT_ATTRS.axes.y2&&(Dygraph.DEFAULT_ATTRS.axes.x.ticker=Dygraph.dateTicker,Dygraph.DEFAULT_ATTRS.axes.y.ticker=Dygraph.numericTicks,Dygraph.DEFAULT_ATTRS.axes.y2.ticker=Dygraph.numericTicks)}(),Dygraph.Plugins={},Dygraph.Plugins.Annotations=function(){"use strict";var t=function(){this.annotations_=[]};return t.prototype.toString=function(){return"Annotations Plugin"},t.prototype.activate=function(t){return{clearChart:this.clearChart,didDrawChart:this.didDrawChart}},t.prototype.detachLabels=function(){for(var t=0;t<this.annotations_.length;t++){var e=this.annotations_[t];e.parentNode&&e.parentNode.removeChild(e),this.annotations_[t]=null}this.annotations_=[]},t.prototype.clearChart=function(t){this.detachLabels()},t.prototype.didDrawChart=function(t){var e=t.dygraph,a=e.layout_.annotated_points;if(a&&0!==a.length)for(var i=t.canvas.parentNode,r={position:"absolute",fontSize:e.getOption("axisLabelFontSize")+"px",zIndex:10,overflow:"hidden"},n=function(t,a,i){return function(r){var n=i.annotation;n.hasOwnProperty(t)?n[t](n,i,e,r):e.getOption(a)&&e.getOption(a)(n,i,e,r)}},o=t.dygraph.plotter_.area,s={},l=0;l<a.length;l++){var h=a[l];if(!(h.canvasx<o.x||h.canvasx>o.x+o.w||h.canvasy<o.y||h.canvasy>o.y+o.h)){var p=h.annotation,g=6;p.hasOwnProperty("tickHeight")&&(g=p.tickHeight);var d=document.createElement("div");for(var u in r)r.hasOwnProperty(u)&&(d.style[u]=r[u]);p.hasOwnProperty("icon")||(d.className="dygraphDefaultAnnotation"),p.hasOwnProperty("cssClass")&&(d.className+=" "+p.cssClass);var c=p.hasOwnProperty("width")?p.width:16,y=p.hasOwnProperty("height")?p.height:16;if(p.hasOwnProperty("icon")){var _=document.createElement("img");_.src=p.icon,_.width=c,_.height=y,d.appendChild(_)}else h.annotation.hasOwnProperty("shortText")&&d.appendChild(document.createTextNode(h.annotation.shortText));var v=h.canvasx-c/2;d.style.left=v+"px";var f=0;if(p.attachAtBottom){var x=o.y+o.h-y-g;s[v]?x-=s[v]:s[v]=0,s[v]+=g+y,f=x}else f=h.canvasy-y-g;d.style.top=f+"px",d.style.width=c+"px",d.style.height=y+"px",d.title=h.annotation.text,d.style.color=e.colorsMap_[h.name],d.style.borderColor=e.colorsMap_[h.name],p.div=d,e.addAndTrackEvent(d,"click",n("clickHandler","annotationClickHandler",h,this)),e.addAndTrackEvent(d,"mouseover",n("mouseOverHandler","annotationMouseOverHandler",h,this)),e.addAndTrackEvent(d,"mouseout",n("mouseOutHandler","annotationMouseOutHandler",h,this)),e.addAndTrackEvent(d,"dblclick",n("dblClickHandler","annotationDblClickHandler",h,this)),i.appendChild(d),this.annotations_.push(d);var m=t.drawingContext;if(m.save(),m.strokeStyle=e.colorsMap_[h.name],m.beginPath(),p.attachAtBottom){var x=f+y;m.moveTo(h.canvasx,x),m.lineTo(h.canvasx,x+g)}else m.moveTo(h.canvasx,h.canvasy),m.lineTo(h.canvasx,h.canvasy-2-g);m.closePath(),m.stroke(),m.restore()}}},t.prototype.destroy=function(){this.detachLabels()},t}(),Dygraph.Plugins.Axes=function(){"use strict";var t=function(){this.xlabels_=[],this.ylabels_=[]};return t.prototype.toString=function(){return"Axes Plugin"},t.prototype.activate=function(t){return{layout:this.layout,clearChart:this.clearChart,willDrawChart:this.willDrawChart}},t.prototype.layout=function(t){var e=t.dygraph;if(e.getOptionForAxis("drawAxis","y")){var a=e.getOptionForAxis("axisLabelWidth","y")+2*e.getOptionForAxis("axisTickSize","y");t.reserveSpaceLeft(a)}if(e.getOptionForAxis("drawAxis","x")){var i;i=e.getOption("xAxisHeight")?e.getOption("xAxisHeight"):e.getOptionForAxis("axisLabelFontSize","x")+2*e.getOptionForAxis("axisTickSize","x"),t.reserveSpaceBottom(i)}if(2==e.numAxes()){if(e.getOptionForAxis("drawAxis","y2")){var a=e.getOptionForAxis("axisLabelWidth","y2")+2*e.getOptionForAxis("axisTickSize","y2");t.reserveSpaceRight(a)}}else e.numAxes()>2&&e.error("Only two y-axes are supported at this time. (Trying to use "+e.numAxes()+")")},t.prototype.detachLabels=function(){function t(t){for(var e=0;e<t.length;e++){var a=t[e];a.parentNode&&a.parentNode.removeChild(a)}}t(this.xlabels_),t(this.ylabels_),this.xlabels_=[],this.ylabels_=[]},t.prototype.clearChart=function(t){this.detachLabels()},t.prototype.willDrawChart=function(t){function e(t){return Math.round(t)+.5}function a(t){return Math.round(t)-.5}var i=t.dygraph;if(i.getOptionForAxis("drawAxis","x")||i.getOptionForAxis("drawAxis","y")||i.getOptionForAxis("drawAxis","y2")){var r,n,o,s,l,h=t.drawingContext,p=t.canvas.parentNode,g=i.width_,d=i.height_,u=function(t){return{position:"absolute",fontSize:i.getOptionForAxis("axisLabelFontSize",t)+"px",zIndex:10,color:i.getOptionForAxis("axisLabelColor",t),width:i.getOptionForAxis("axisLabelWidth",t)+"px",lineHeight:"normal",overflow:"hidden"}},c={x:u("x"),y:u("y"),y2:u("y2")},y=function(t,e,a){var i=document.createElement("div"),r=c["y2"==a?"y2":e];for(var n in r)r.hasOwnProperty(n)&&(i.style[n]=r[n]);var o=document.createElement("div");return o.className="dygraph-axis-label dygraph-axis-label-"+e+(a?" dygraph-axis-label-"+a:""),o.innerHTML=t,i.appendChild(o),i};h.save();var _=i.layout_,v=t.dygraph.plotter_.area,f=function(t){return function(e){return i.getOptionForAxis(e,t)}};if(i.getOptionForAxis("drawAxis","y")){if(_.yticks&&_.yticks.length>0){var x=i.numAxes(),m=[f("y"),f("y2")];for(l=0;l<_.yticks.length;l++){if(s=_.yticks[l],"function"==typeof s)return;n=v.x;var D=1,w="y1",A=m[0];1==s[0]&&(n=v.x+v.w,D=-1,w="y2",A=m[1]);var b=A("axisLabelFontSize");o=v.y+s[1]*v.h,r=y(s[2],"y",2==x?w:null);var T=o-b/2;0>T&&(T=0),T+b+3>d?r.style.bottom="0":r.style.top=T+"px",0===s[0]?(r.style.left=v.x-A("axisLabelWidth")-A("axisTickSize")+"px",r.style.textAlign="right"):1==s[0]&&(r.style.left=v.x+v.w+A("axisTickSize")+"px",r.style.textAlign="left"),r.style.width=A("axisLabelWidth")+"px",p.appendChild(r),this.ylabels_.push(r)}var E=this.ylabels_[0],b=i.getOptionForAxis("axisLabelFontSize","y"),C=parseInt(E.style.top,10)+b;C>d-b&&(E.style.top=parseInt(E.style.top,10)-b/2+"px")}var L;if(i.getOption("drawAxesAtZero")){var P=i.toPercentXCoord(0);(P>1||0>P||isNaN(P))&&(P=0),L=e(v.x+P*v.w)}else L=e(v.x);h.strokeStyle=i.getOptionForAxis("axisLineColor","y"),h.lineWidth=i.getOptionForAxis("axisLineWidth","y"),h.beginPath(),h.moveTo(L,a(v.y)),h.lineTo(L,a(v.y+v.h)),h.closePath(),h.stroke(),2==i.numAxes()&&(h.strokeStyle=i.getOptionForAxis("axisLineColor","y2"),h.lineWidth=i.getOptionForAxis("axisLineWidth","y2"),h.beginPath(),h.moveTo(a(v.x+v.w),a(v.y)),h.lineTo(a(v.x+v.w),a(v.y+v.h)),h.closePath(),h.stroke())}if(i.getOptionForAxis("drawAxis","x")){if(_.xticks){var A=f("x");for(l=0;l<_.xticks.length;l++){s=_.xticks[l],n=v.x+s[0]*v.w,o=v.y+v.h,r=y(s[1],"x"),r.style.textAlign="center",r.style.top=o+A("axisTickSize")+"px";var S=n-A("axisLabelWidth")/2;S+A("axisLabelWidth")>g&&(S=g-A("axisLabelWidth"),r.style.textAlign="right"),0>S&&(S=0,r.style.textAlign="left"),r.style.left=S+"px",r.style.width=A("axisLabelWidth")+"px",
+p.appendChild(r),this.xlabels_.push(r)}}h.strokeStyle=i.getOptionForAxis("axisLineColor","x"),h.lineWidth=i.getOptionForAxis("axisLineWidth","x"),h.beginPath();var O;if(i.getOption("drawAxesAtZero")){var P=i.toPercentYCoord(0,0);(P>1||0>P)&&(P=1),O=a(v.y+P*v.h)}else O=a(v.y+v.h);h.moveTo(e(v.x),O),h.lineTo(e(v.x+v.w),O),h.closePath(),h.stroke()}h.restore()}},t}(),Dygraph.Plugins.ChartLabels=function(){"use strict";var t=function(){this.title_div_=null,this.xlabel_div_=null,this.ylabel_div_=null,this.y2label_div_=null};t.prototype.toString=function(){return"ChartLabels Plugin"},t.prototype.activate=function(t){return{layout:this.layout,didDrawChart:this.didDrawChart}};var e=function(t){var e=document.createElement("div");return e.style.position="absolute",e.style.left=t.x+"px",e.style.top=t.y+"px",e.style.width=t.w+"px",e.style.height=t.h+"px",e};t.prototype.detachLabels_=function(){for(var t=[this.title_div_,this.xlabel_div_,this.ylabel_div_,this.y2label_div_],e=0;e<t.length;e++){var a=t[e];a&&a.parentNode&&a.parentNode.removeChild(a)}this.title_div_=null,this.xlabel_div_=null,this.ylabel_div_=null,this.y2label_div_=null};var a=function(t,e,a,i,r){var n=document.createElement("div");n.style.position="absolute",1==a?n.style.left="0px":n.style.left=e.x+"px",n.style.top=e.y+"px",n.style.width=e.w+"px",n.style.height=e.h+"px",n.style.fontSize=t.getOption("yLabelWidth")-2+"px";var o=document.createElement("div");o.style.position="absolute",o.style.width=e.h+"px",o.style.height=e.w+"px",o.style.top=e.h/2-e.w/2+"px",o.style.left=e.w/2-e.h/2+"px",o.style.textAlign="center";var s="rotate("+(1==a?"-":"")+"90deg)";o.style.transform=s,o.style.WebkitTransform=s,o.style.MozTransform=s,o.style.OTransform=s,o.style.msTransform=s,"undefined"!=typeof document.documentMode&&document.documentMode<9&&(o.style.filter="progid:DXImageTransform.Microsoft.BasicImage(rotation="+(1==a?"3":"1")+")",o.style.left="0px",o.style.top="0px");var l=document.createElement("div");return l.className=i,l.innerHTML=r,o.appendChild(l),n.appendChild(o),n};return t.prototype.layout=function(t){this.detachLabels_();var i=t.dygraph,r=t.chart_div;if(i.getOption("title")){var n=t.reserveSpaceTop(i.getOption("titleHeight"));this.title_div_=e(n),this.title_div_.style.textAlign="center",this.title_div_.style.fontSize=i.getOption("titleHeight")-8+"px",this.title_div_.style.fontWeight="bold",this.title_div_.style.zIndex=10;var o=document.createElement("div");o.className="dygraph-label dygraph-title",o.innerHTML=i.getOption("title"),this.title_div_.appendChild(o),r.appendChild(this.title_div_)}if(i.getOption("xlabel")){var s=t.reserveSpaceBottom(i.getOption("xLabelHeight"));this.xlabel_div_=e(s),this.xlabel_div_.style.textAlign="center",this.xlabel_div_.style.fontSize=i.getOption("xLabelHeight")-2+"px";var o=document.createElement("div");o.className="dygraph-label dygraph-xlabel",o.innerHTML=i.getOption("xlabel"),this.xlabel_div_.appendChild(o),r.appendChild(this.xlabel_div_)}if(i.getOption("ylabel")){var l=t.reserveSpaceLeft(0);this.ylabel_div_=a(i,l,1,"dygraph-label dygraph-ylabel",i.getOption("ylabel")),r.appendChild(this.ylabel_div_)}if(i.getOption("y2label")&&2==i.numAxes()){var h=t.reserveSpaceRight(0);this.y2label_div_=a(i,h,2,"dygraph-label dygraph-y2label",i.getOption("y2label")),r.appendChild(this.y2label_div_)}},t.prototype.didDrawChart=function(t){var e=t.dygraph;this.title_div_&&(this.title_div_.children[0].innerHTML=e.getOption("title")),this.xlabel_div_&&(this.xlabel_div_.children[0].innerHTML=e.getOption("xlabel")),this.ylabel_div_&&(this.ylabel_div_.children[0].children[0].innerHTML=e.getOption("ylabel")),this.y2label_div_&&(this.y2label_div_.children[0].children[0].innerHTML=e.getOption("y2label"))},t.prototype.clearChart=function(){},t.prototype.destroy=function(){this.detachLabels_()},t}(),Dygraph.Plugins.Grid=function(){"use strict";var t=function(){};return t.prototype.toString=function(){return"Gridline Plugin"},t.prototype.activate=function(t){return{willDrawChart:this.willDrawChart}},t.prototype.willDrawChart=function(t){function e(t){return Math.round(t)+.5}function a(t){return Math.round(t)-.5}var i,r,n,o,s=t.dygraph,l=t.drawingContext,h=s.layout_,p=t.dygraph.plotter_.area;if(s.getOptionForAxis("drawGrid","y")){for(var g=["y","y2"],d=[],u=[],c=[],y=[],_=[],n=0;n<g.length;n++)c[n]=s.getOptionForAxis("drawGrid",g[n]),c[n]&&(d[n]=s.getOptionForAxis("gridLineColor",g[n]),u[n]=s.getOptionForAxis("gridLineWidth",g[n]),_[n]=s.getOptionForAxis("gridLinePattern",g[n]),y[n]=_[n]&&_[n].length>=2);for(o=h.yticks,l.save(),n=0;n<o.length;n++){var v=o[n][0];c[v]&&(y[v]&&l.installPattern(_[v]),l.strokeStyle=d[v],l.lineWidth=u[v],i=e(p.x),r=a(p.y+o[n][1]*p.h),l.beginPath(),l.moveTo(i,r),l.lineTo(i+p.w,r),l.closePath(),l.stroke(),y[v]&&l.uninstallPattern())}l.restore()}if(s.getOptionForAxis("drawGrid","x")){o=h.xticks,l.save();var _=s.getOptionForAxis("gridLinePattern","x"),y=_&&_.length>=2;for(y&&l.installPattern(_),l.strokeStyle=s.getOptionForAxis("gridLineColor","x"),l.lineWidth=s.getOptionForAxis("gridLineWidth","x"),n=0;n<o.length;n++)i=e(p.x+o[n][0]*p.w),r=a(p.y+p.h),l.beginPath(),l.moveTo(i,r),l.lineTo(i,p.y),l.closePath(),l.stroke();y&&l.uninstallPattern(),l.restore()}},t.prototype.destroy=function(){},t}(),Dygraph.Plugins.Legend=function(){"use strict";var t=function(){this.legend_div_=null,this.is_generated_div_=!1};t.prototype.toString=function(){return"Legend Plugin"};var e;t.prototype.activate=function(t){var e,a=t.getOption("labelsDivWidth"),i=t.getOption("labelsDiv");if(i&&null!==i)e="string"==typeof i||i instanceof String?document.getElementById(i):i;else{var r={position:"absolute",fontSize:"14px",zIndex:10,width:a+"px",top:"0px",left:t.size().width-a-2+"px",background:"white",lineHeight:"normal",textAlign:"left",overflow:"hidden"};Dygraph.update(r,t.getOption("labelsDivStyles")),e=document.createElement("div"),e.className="dygraph-legend";for(var n in r)if(r.hasOwnProperty(n))try{e.style[n]=r[n]}catch(o){console.warn("You are using unsupported css properties for your browser in labelsDivStyles")}t.graphDiv.appendChild(e),this.is_generated_div_=!0}return this.legend_div_=e,this.one_em_width_=10,{select:this.select,deselect:this.deselect,predraw:this.predraw,didDrawChart:this.didDrawChart}};var a=function(t){var e=document.createElement("span");e.setAttribute("style","margin: 0; padding: 0 0 0 1em; border: 0;"),t.appendChild(e);var a=e.offsetWidth;return t.removeChild(e),a},i=function(t){return t.replace(/&/g,"&amp;").replace(/"/g,"&quot;").replace(/</g,"&lt;").replace(/>/g,"&gt;")};return t.prototype.select=function(e){var a=e.selectedX,i=e.selectedPoints,r=e.selectedRow,n=e.dygraph.getOption("legend");if("never"===n)return void(this.legend_div_.style.display="none");if("follow"===n){var o=e.dygraph.plotter_.area,s=e.dygraph.getOption("labelsDivWidth"),l=e.dygraph.getOptionForAxis("axisLabelWidth","y"),h=i[0].x*o.w+20,p=i[0].y*o.h-20;h+s+1>window.scrollX+window.innerWidth&&(h=h-40-s-(l-o.x)),e.dygraph.graphDiv.appendChild(this.legend_div_),this.legend_div_.style.left=l+h+"px",this.legend_div_.style.top=p+"px"}var g=t.generateLegendHTML(e.dygraph,a,i,this.one_em_width_,r);this.legend_div_.innerHTML=g,this.legend_div_.style.display=""},t.prototype.deselect=function(e){var i=e.dygraph.getOption("legend");"always"!==i&&(this.legend_div_.style.display="none");var r=a(this.legend_div_);this.one_em_width_=r;var n=t.generateLegendHTML(e.dygraph,void 0,void 0,r,null);this.legend_div_.innerHTML=n},t.prototype.didDrawChart=function(t){this.deselect(t)},t.prototype.predraw=function(t){if(this.is_generated_div_){t.dygraph.graphDiv.appendChild(this.legend_div_);var e=t.dygraph.plotter_.area,a=t.dygraph.getOption("labelsDivWidth");this.legend_div_.style.left=e.x+e.w-a-1+"px",this.legend_div_.style.top=e.y+"px",this.legend_div_.style.width=a+"px"}},t.prototype.destroy=function(){this.legend_div_=null},t.generateLegendHTML=function(t,a,r,n,o){if(t.getOption("showLabelsOnHighlight")!==!0)return"";var s,l,h,p,g,d=t.getLabels();if("undefined"==typeof a){if("always"!=t.getOption("legend"))return"";for(l=t.getOption("labelsSeparateLines"),s="",h=1;h<d.length;h++){var u=t.getPropertiesForSeries(d[h]);u.visible&&(""!==s&&(s+=l?"<br/>":" "),g=t.getOption("strokePattern",d[h]),p=e(g,u.color,n),s+="<span style='font-weight: bold; color: "+u.color+";'>"+p+" "+i(d[h])+"</span>")}return s}var c=t.optionsViewForAxis_("x"),y=c("valueFormatter");s=y.call(t,a,c,d[0],t,o,0),""!==s&&(s+=":");var _=[],v=t.numAxes();for(h=0;v>h;h++)_[h]=t.optionsViewForAxis_("y"+(h?1+h:""));var f=t.getOption("labelsShowZeroValues");l=t.getOption("labelsSeparateLines");var x=t.getHighlightSeries();for(h=0;h<r.length;h++){var m=r[h];if((0!==m.yval||f)&&Dygraph.isOK(m.canvasy)){l&&(s+="<br/>");var u=t.getPropertiesForSeries(m.name),D=_[u.axis-1],w=D("valueFormatter"),A=w.call(t,m.yval,D,m.name,t,o,d.indexOf(m.name)),b=m.name==x?" class='highlight'":"";s+="<span"+b+"> <b><span style='color: "+u.color+";'>"+i(m.name)+"</span></b>:&#160;"+A+"</span>"}}return s},e=function(t,e,a){var i=/MSIE/.test(navigator.userAgent)&&!window.opera;if(i)return"&mdash;";if(!t||t.length<=1)return'<div style="display: inline-block; position: relative; bottom: .5ex; padding-left: 1em; height: 1px; border-bottom: 2px solid '+e+';"></div>';var r,n,o,s,l,h=0,p=0,g=[];for(r=0;r<=t.length;r++)h+=t[r%t.length];if(l=Math.floor(a/(h-t[0])),l>1){for(r=0;r<t.length;r++)g[r]=t[r]/a;p=g.length}else{for(l=1,r=0;r<t.length;r++)g[r]=t[r]/h;p=g.length+1}var d="";for(n=0;l>n;n++)for(r=0;p>r;r+=2)o=g[r%g.length],s=r<t.length?g[(r+1)%g.length]:0,d+='<div style="display: inline-block; position: relative; bottom: .5ex; margin-right: '+s+"em; padding-left: "+o+"em; height: 1px; border-bottom: 2px solid "+e+';"></div>';return d},t}(),Dygraph.Plugins.RangeSelector=function(){"use strict";var t=function(){this.isIE_=/MSIE/.test(navigator.userAgent)&&!window.opera,this.hasTouchInterface_="undefined"!=typeof TouchEvent,this.isMobileDevice_=/mobile|android/gi.test(navigator.appVersion),this.interfaceCreated_=!1};return t.prototype.toString=function(){return"RangeSelector Plugin"},t.prototype.activate=function(t){return this.dygraph_=t,this.isUsingExcanvas_=t.isUsingExcanvas_,this.getOption_("showRangeSelector")&&this.createInterface_(),{layout:this.reserveSpace_,predraw:this.renderStaticLayer_,didDrawChart:this.renderInteractiveLayer_}},t.prototype.destroy=function(){this.bgcanvas_=null,this.fgcanvas_=null,this.leftZoomHandle_=null,this.rightZoomHandle_=null,this.iePanOverlay_=null},t.prototype.getOption_=function(t,e){return this.dygraph_.getOption(t,e)},t.prototype.setDefaultOption_=function(t,e){this.dygraph_.attrs_[t]=e},t.prototype.createInterface_=function(){this.createCanvases_(),this.isUsingExcanvas_&&this.createIEPanOverlay_(),this.createZoomHandles_(),this.initInteraction_(),this.getOption_("animatedZooms")&&(console.warn("Animated zooms and range selector are not compatible; disabling animatedZooms."),this.dygraph_.updateOptions({animatedZooms:!1},!0)),this.interfaceCreated_=!0,this.addToGraph_()},t.prototype.addToGraph_=function(){var t=this.graphDiv_=this.dygraph_.graphDiv;t.appendChild(this.bgcanvas_),t.appendChild(this.fgcanvas_),t.appendChild(this.leftZoomHandle_),t.appendChild(this.rightZoomHandle_)},t.prototype.removeFromGraph_=function(){var t=this.graphDiv_;t.removeChild(this.bgcanvas_),t.removeChild(this.fgcanvas_),t.removeChild(this.leftZoomHandle_),t.removeChild(this.rightZoomHandle_),this.graphDiv_=null},t.prototype.reserveSpace_=function(t){this.getOption_("showRangeSelector")&&t.reserveSpaceBottom(this.getOption_("rangeSelectorHeight")+4)},t.prototype.renderStaticLayer_=function(){this.updateVisibility_()&&(this.resize_(),this.drawStaticLayer_())},t.prototype.renderInteractiveLayer_=function(){this.updateVisibility_()&&!this.isChangingRange_&&(this.placeZoomHandles_(),this.drawInteractiveLayer_())},t.prototype.updateVisibility_=function(){var t=this.getOption_("showRangeSelector");if(t)this.interfaceCreated_?this.graphDiv_&&this.graphDiv_.parentNode||this.addToGraph_():this.createInterface_();else if(this.graphDiv_){this.removeFromGraph_();var e=this.dygraph_;setTimeout(function(){e.width_=0,e.resize()},1)}return t},t.prototype.resize_=function(){function t(t,e,a){var i=Dygraph.getContextPixelRatio(e);t.style.top=a.y+"px",t.style.left=a.x+"px",t.width=a.w*i,t.height=a.h*i,t.style.width=a.w+"px",t.style.height=a.h+"px",1!=i&&e.scale(i,i)}var e=this.dygraph_.layout_.getPlotArea(),a=0;this.dygraph_.getOptionForAxis("drawAxis","x")&&(a=this.getOption_("xAxisHeight")||this.getOption_("axisLabelFontSize")+2*this.getOption_("axisTickSize")),this.canvasRect_={x:e.x,y:e.y+e.h+a+4,w:e.w,h:this.getOption_("rangeSelectorHeight")},t(this.bgcanvas_,this.bgcanvas_ctx_,this.canvasRect_),t(this.fgcanvas_,this.fgcanvas_ctx_,this.canvasRect_)},t.prototype.createCanvases_=function(){this.bgcanvas_=Dygraph.createCanvas(),this.bgcanvas_.className="dygraph-rangesel-bgcanvas",this.bgcanvas_.style.position="absolute",this.bgcanvas_.style.zIndex=9,this.bgcanvas_ctx_=Dygraph.getContext(this.bgcanvas_),this.fgcanvas_=Dygraph.createCanvas(),this.fgcanvas_.className="dygraph-rangesel-fgcanvas",this.fgcanvas_.style.position="absolute",this.fgcanvas_.style.zIndex=9,this.fgcanvas_.style.cursor="default",this.fgcanvas_ctx_=Dygraph.getContext(this.fgcanvas_)},t.prototype.createIEPanOverlay_=function(){this.iePanOverlay_=document.createElement("div"),this.iePanOverlay_.style.position="absolute",this.iePanOverlay_.style.backgroundColor="white",this.iePanOverlay_.style.filter="alpha(opacity=0)",this.iePanOverlay_.style.display="none",this.iePanOverlay_.style.cursor="move",this.fgcanvas_.appendChild(this.iePanOverlay_)},t.prototype.createZoomHandles_=function(){var t=new Image;t.className="dygraph-rangesel-zoomhandle",t.style.position="absolute",t.style.zIndex=10,t.style.visibility="hidden",t.style.cursor="col-resize",/MSIE 7/.test(navigator.userAgent)?(t.width=7,t.height=14,t.style.backgroundColor="white",t.style.border="1px solid #333333"):(t.width=9,t.height=16,t.src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAkAAAAQCAYAAADESFVDAAAAAXNSR0IArs4c6QAAAAZiS0dEANAAzwDP4Z7KegAAAAlwSFlzAAAOxAAADsQBlSsOGwAAAAd0SU1FB9sHGw0cMqdt1UwAAAAZdEVYdENvbW1lbnQAQ3JlYXRlZCB3aXRoIEdJTVBXgQ4XAAAAaElEQVQoz+3SsRFAQBCF4Z9WJM8KCDVwownl6YXsTmCUsyKGkZzcl7zkz3YLkypgAnreFmDEpHkIwVOMfpdi9CEEN2nGpFdwD03yEqDtOgCaun7sqSTDH32I1pQA2Pb9sZecAxc5r3IAb21d6878xsAAAAAASUVORK5CYII="),this.isMobileDevice_&&(t.width*=2,t.height*=2),this.leftZoomHandle_=t,this.rightZoomHandle_=t.cloneNode(!1)},t.prototype.initInteraction_=function(){var t,e,a,i,r,n,o,s,l,h,p,g,d,u,c=this,y=document,_=0,v=null,f=!1,x=!1,m=!this.isMobileDevice_&&!this.isUsingExcanvas_,D=new Dygraph.IFrameTarp;t=function(t){var e=c.dygraph_.xAxisExtremes(),a=(e[1]-e[0])/c.canvasRect_.w,i=e[0]+(t.leftHandlePos-c.canvasRect_.x)*a,r=e[0]+(t.rightHandlePos-c.canvasRect_.x)*a;return[i,r]},e=function(t){return Dygraph.cancelEvent(t),f=!0,_=t.clientX,v=t.target?t.target:t.srcElement,("mousedown"===t.type||"dragstart"===t.type)&&(Dygraph.addEvent(y,"mousemove",a),Dygraph.addEvent(y,"mouseup",i)),c.fgcanvas_.style.cursor="col-resize",D.cover(),!0},a=function(t){if(!f)return!1;Dygraph.cancelEvent(t);var e=t.clientX-_;if(Math.abs(e)<4)return!0;_=t.clientX;var a,i=c.getZoomHandleStatus_();v==c.leftZoomHandle_?(a=i.leftHandlePos+e,a=Math.min(a,i.rightHandlePos-v.width-3),a=Math.max(a,c.canvasRect_.x)):(a=i.rightHandlePos+e,a=Math.min(a,c.canvasRect_.x+c.canvasRect_.w),a=Math.max(a,i.leftHandlePos+v.width+3));var n=v.width/2;return v.style.left=a-n+"px",c.drawInteractiveLayer_(),m&&r(),!0},i=function(t){return f?(f=!1,D.uncover(),Dygraph.removeEvent(y,"mousemove",a),Dygraph.removeEvent(y,"mouseup",i),c.fgcanvas_.style.cursor="default",m||r(),!0):!1},r=function(){try{var e=c.getZoomHandleStatus_();if(c.isChangingRange_=!0,e.isZoomed){var a=t(e);c.dygraph_.doZoomXDates_(a[0],a[1])}else c.dygraph_.resetZoom()}finally{c.isChangingRange_=!1}},n=function(t){if(c.isUsingExcanvas_)return t.srcElement==c.iePanOverlay_;var e=c.leftZoomHandle_.getBoundingClientRect(),a=e.left+e.width/2;e=c.rightZoomHandle_.getBoundingClientRect();var i=e.left+e.width/2;return t.clientX>a&&t.clientX<i},o=function(t){return!x&&n(t)&&c.getZoomHandleStatus_().isZoomed?(Dygraph.cancelEvent(t),x=!0,_=t.clientX,"mousedown"===t.type&&(Dygraph.addEvent(y,"mousemove",s),Dygraph.addEvent(y,"mouseup",l)),!0):!1},s=function(t){if(!x)return!1;Dygraph.cancelEvent(t);var e=t.clientX-_;if(Math.abs(e)<4)return!0;_=t.clientX;var a=c.getZoomHandleStatus_(),i=a.leftHandlePos,r=a.rightHandlePos,n=r-i;i+e<=c.canvasRect_.x?(i=c.canvasRect_.x,r=i+n):r+e>=c.canvasRect_.x+c.canvasRect_.w?(r=c.canvasRect_.x+c.canvasRect_.w,i=r-n):(i+=e,r+=e);var o=c.leftZoomHandle_.width/2;return c.leftZoomHandle_.style.left=i-o+"px",c.rightZoomHandle_.style.left=r-o+"px",c.drawInteractiveLayer_(),m&&h(),!0},l=function(t){return x?(x=!1,Dygraph.removeEvent(y,"mousemove",s),Dygraph.removeEvent(y,"mouseup",l),m||h(),!0):!1},h=function(){try{c.isChangingRange_=!0,c.dygraph_.dateWindow_=t(c.getZoomHandleStatus_()),c.dygraph_.drawGraph_(!1)}finally{c.isChangingRange_=!1}},p=function(t){if(!f&&!x){var e=n(t)?"move":"default";e!=c.fgcanvas_.style.cursor&&(c.fgcanvas_.style.cursor=e)}},g=function(t){"touchstart"==t.type&&1==t.targetTouches.length?e(t.targetTouches[0])&&Dygraph.cancelEvent(t):"touchmove"==t.type&&1==t.targetTouches.length?a(t.targetTouches[0])&&Dygraph.cancelEvent(t):i(t)},d=function(t){"touchstart"==t.type&&1==t.targetTouches.length?o(t.targetTouches[0])&&Dygraph.cancelEvent(t):"touchmove"==t.type&&1==t.targetTouches.length?s(t.targetTouches[0])&&Dygraph.cancelEvent(t):l(t)},u=function(t,e){for(var a=["touchstart","touchend","touchmove","touchcancel"],i=0;i<a.length;i++)c.dygraph_.addAndTrackEvent(t,a[i],e)},this.setDefaultOption_("interactionModel",Dygraph.Interaction.dragIsPanInteractionModel),this.setDefaultOption_("panEdgeFraction",1e-4);var w=window.opera?"mousedown":"dragstart";this.dygraph_.addAndTrackEvent(this.leftZoomHandle_,w,e),this.dygraph_.addAndTrackEvent(this.rightZoomHandle_,w,e),this.isUsingExcanvas_?this.dygraph_.addAndTrackEvent(this.iePanOverlay_,"mousedown",o):(this.dygraph_.addAndTrackEvent(this.fgcanvas_,"mousedown",o),this.dygraph_.addAndTrackEvent(this.fgcanvas_,"mousemove",p)),this.hasTouchInterface_&&(u(this.leftZoomHandle_,g),u(this.rightZoomHandle_,g),u(this.fgcanvas_,d))},t.prototype.drawStaticLayer_=function(){var t=this.bgcanvas_ctx_;t.clearRect(0,0,this.canvasRect_.w,this.canvasRect_.h);try{this.drawMiniPlot_()}catch(e){console.warn(e)}var a=.5;this.bgcanvas_ctx_.lineWidth=1,t.strokeStyle="gray",t.beginPath(),t.moveTo(a,a),t.lineTo(a,this.canvasRect_.h-a),t.lineTo(this.canvasRect_.w-a,this.canvasRect_.h-a),t.lineTo(this.canvasRect_.w-a,a),t.stroke()},t.prototype.drawMiniPlot_=function(){var t=this.getOption_("rangeSelectorPlotFillColor"),e=this.getOption_("rangeSelectorPlotStrokeColor");if(t||e){var a=this.getOption_("stepPlot"),i=this.computeCombinedSeriesAndLimits_(),r=i.yMax-i.yMin,n=this.bgcanvas_ctx_,o=.5,s=this.dygraph_.xAxisExtremes(),l=Math.max(s[1]-s[0],1e-30),h=(this.canvasRect_.w-o)/l,p=(this.canvasRect_.h-o)/r,g=this.canvasRect_.w-o,d=this.canvasRect_.h-o,u=null,c=null;n.beginPath(),n.moveTo(o,d);for(var y=0;y<i.data.length;y++){var _=i.data[y],v=null!==_[0]?(_[0]-s[0])*h:0/0,f=null!==_[1]?d-(_[1]-i.yMin)*p:0/0;(a||null===u||Math.round(v)!=Math.round(u))&&(isFinite(v)&&isFinite(f)?(null===u?n.lineTo(v,d):a&&n.lineTo(v,c),n.lineTo(v,f),u=v,c=f):(null!==u&&(a?(n.lineTo(v,c),n.lineTo(v,d)):n.lineTo(u,d)),u=c=null))}if(n.lineTo(g,d),n.closePath(),t){var x=this.bgcanvas_ctx_.createLinearGradient(0,0,0,d);x.addColorStop(0,"white"),x.addColorStop(1,t),this.bgcanvas_ctx_.fillStyle=x,n.fill()}e&&(this.bgcanvas_ctx_.strokeStyle=e,this.bgcanvas_ctx_.lineWidth=1.5,n.stroke())}},t.prototype.computeCombinedSeriesAndLimits_=function(){var t,e=this.dygraph_,a=this.getOption_("logscale"),i=e.numColumns(),r=e.getLabels(),n=new Array(i),o=!1;for(t=1;i>t;t++){var s=this.getOption_("showInRangeSelector",r[t]);n[t]=s,null!==s&&(o=!0)}if(!o)for(t=0;t<n.length;t++)n[t]=!0;var l=[],h=e.dataHandler_,p=e.attributes_;for(t=1;t<e.numColumns();t++)if(n[t]){var g=h.extractSeries(e.rawData_,t,p);e.rollPeriod()>1&&(g=h.rollingAverage(g,e.rollPeriod(),p)),l.push(g)}var d=[];for(t=0;t<l[0].length;t++){for(var u=0,c=0,y=0;y<l.length;y++){var _=l[y][t][1];null===_||isNaN(_)||(c++,u+=_)}d.push([l[0][t][0],u/c])}var v=Number.MAX_VALUE,f=-Number.MAX_VALUE;for(t=0;t<d.length;t++){var x=d[t][1];null!==x&&isFinite(x)&&(!a||x>0)&&(v=Math.min(v,x),f=Math.max(f,x))}var m=.25;if(a)for(f=Dygraph.log10(f),f+=f*m,v=Dygraph.log10(v),t=0;t<d.length;t++)d[t][1]=Dygraph.log10(d[t][1]);else{var D,w=f-v;D=w<=Number.MIN_VALUE?f*m:w*m,f+=D,v-=D}return{data:d,yMin:v,yMax:f}},t.prototype.placeZoomHandles_=function(){var t=this.dygraph_.xAxisExtremes(),e=this.dygraph_.xAxisRange(),a=t[1]-t[0],i=Math.max(0,(e[0]-t[0])/a),r=Math.max(0,(t[1]-e[1])/a),n=this.canvasRect_.x+this.canvasRect_.w*i,o=this.canvasRect_.x+this.canvasRect_.w*(1-r),s=Math.max(this.canvasRect_.y,this.canvasRect_.y+(this.canvasRect_.h-this.leftZoomHandle_.height)/2),l=this.leftZoomHandle_.width/2;this.leftZoomHandle_.style.left=n-l+"px",this.leftZoomHandle_.style.top=s+"px",this.rightZoomHandle_.style.left=o-l+"px",this.rightZoomHandle_.style.top=this.leftZoomHandle_.style.top,this.leftZoomHandle_.style.visibility="visible",this.rightZoomHandle_.style.visibility="visible"},t.prototype.drawInteractiveLayer_=function(){var t=this.fgcanvas_ctx_;t.clearRect(0,0,this.canvasRect_.w,this.canvasRect_.h);var e=1,a=this.canvasRect_.w-e,i=this.canvasRect_.h-e,r=this.getZoomHandleStatus_();if(t.strokeStyle="black",r.isZoomed){var n=Math.max(e,r.leftHandlePos-this.canvasRect_.x),o=Math.min(a,r.rightHandlePos-this.canvasRect_.x);t.fillStyle="rgba(240, 240, 240, 0.6)",t.fillRect(0,0,n,this.canvasRect_.h),t.fillRect(o,0,this.canvasRect_.w-o,this.canvasRect_.h),t.beginPath(),t.moveTo(e,e),t.lineTo(n,e),t.lineTo(n,i),t.lineTo(o,i),t.lineTo(o,e),t.lineTo(a,e),t.stroke(),this.isUsingExcanvas_&&(this.iePanOverlay_.style.width=o-n+"px",this.iePanOverlay_.style.left=n+"px",this.iePanOverlay_.style.height=i+"px",this.iePanOverlay_.style.display="inline")}else t.beginPath(),t.moveTo(e,e),t.lineTo(e,i),t.lineTo(a,i),t.lineTo(a,e),t.stroke(),this.iePanOverlay_&&(this.iePanOverlay_.style.display="none")},t.prototype.getZoomHandleStatus_=function(){var t=this.leftZoomHandle_.width/2,e=parseFloat(this.leftZoomHandle_.style.left)+t,a=parseFloat(this.rightZoomHandle_.style.left)+t;return{leftHandlePos:e,rightHandlePos:a,isZoomed:e-1>this.canvasRect_.x||a+1<this.canvasRect_.x+this.canvasRect_.w}},t}(),Dygraph.PLUGINS.push(Dygraph.Plugins.Legend,Dygraph.Plugins.Axes,Dygraph.Plugins.RangeSelector,Dygraph.Plugins.ChartLabels,Dygraph.Plugins.Annotations,Dygraph.Plugins.Grid),Dygraph.DataHandler=function(){},Dygraph.DataHandlers={},function(){"use strict";var t=Dygraph.DataHandler;t.X=0,t.Y=1,t.EXTRAS=2,t.prototype.extractSeries=function(t,e,a){},t.prototype.seriesToPoints=function(e,a,i){for(var r=[],n=0;n<e.length;++n){var o=e[n],s=o[1],l=null===s?null:t.parseFloat(s),h={x:0/0,y:0/0,xval:t.parseFloat(o[0]),yval:l,name:a,idx:n+i};r.push(h)}return this.onPointsCreated_(e,r),r},t.prototype.onPointsCreated_=function(t,e){},t.prototype.rollingAverage=function(t,e,a){},t.prototype.getExtremeYValues=function(t,e,a){},t.prototype.onLineEvaluated=function(t,e,a){},t.prototype.computeYInterpolation_=function(t,e,a){var i=e[1]-t[1],r=e[0]-t[0],n=i/r,o=(a-t[0])*n;return t[1]+o},t.prototype.getIndexesInWindow_=function(t,e){var a=0,i=t.length-1;if(e){for(var r=0,n=e[0],o=e[1];r<t.length-1&&t[r][0]<n;)a++,r++;for(r=t.length-1;r>0&&t[r][0]>o;)i--,r--}return i>=a?[a,i]:[0,t.length-1]},t.parseFloat=function(t){return null===t?0/0:t}}(),function(){"use strict";Dygraph.DataHandlers.DefaultHandler=function(){};var t=Dygraph.DataHandlers.DefaultHandler;t.prototype=new Dygraph.DataHandler,t.prototype.extractSeries=function(t,e,a){for(var i=[],r=a.get("logscale"),n=0;n<t.length;n++){var o=t[n][0],s=t[n][e];r&&0>=s&&(s=null),i.push([o,s])}return i},t.prototype.rollingAverage=function(t,e,a){e=Math.min(e,t.length);var i,r,n,o,s,l=[];if(1==e)return t;for(i=0;i<t.length;i++){for(o=0,s=0,r=Math.max(0,i-e+1);i+1>r;r++)n=t[r][1],null===n||isNaN(n)||(s++,o+=t[r][1]);s?l[i]=[t[i][0],o/s]:l[i]=[t[i][0],null]}return l},t.prototype.getExtremeYValues=function(t,e,a){for(var i,r=null,n=null,o=0,s=t.length-1,l=o;s>=l;l++)i=t[l][1],null===i||isNaN(i)||((null===n||i>n)&&(n=i),(null===r||r>i)&&(r=i));return[r,n]}}(),function(){"use strict";Dygraph.DataHandlers.DefaultFractionHandler=function(){};var t=Dygraph.DataHandlers.DefaultFractionHandler;t.prototype=new Dygraph.DataHandlers.DefaultHandler,t.prototype.extractSeries=function(t,e,a){for(var i,r,n,o,s,l,h=[],p=100,g=a.get("logscale"),d=0;d<t.length;d++)i=t[d][0],n=t[d][e],g&&null!==n&&(n[0]<=0||n[1]<=0)&&(n=null),null!==n?(o=n[0],s=n[1],null===o||isNaN(o)?h.push([i,o,[o,s]]):(l=s?o/s:0,r=p*l,h.push([i,r,[o,s]]))):h.push([i,null,[null,null]]);return h},t.prototype.rollingAverage=function(t,e,a){e=Math.min(e,t.length);var i,r=[],n=0,o=0,s=100;for(i=0;i<t.length;i++){n+=t[i][2][0],o+=t[i][2][1],i-e>=0&&(n-=t[i-e][2][0],o-=t[i-e][2][1]);var l=t[i][0],h=o?n/o:0;r[i]=[l,s*h]}return r}}(),function(){"use strict";Dygraph.DataHandlers.BarsHandler=function(){Dygraph.DataHandler.call(this)},Dygraph.DataHandlers.BarsHandler.prototype=new Dygraph.DataHandler;var t=Dygraph.DataHandlers.BarsHandler;t.prototype.extractSeries=function(t,e,a){},t.prototype.rollingAverage=function(t,e,a){},t.prototype.onPointsCreated_=function(t,e){for(var a=0;a<t.length;++a){var i=t[a],r=e[a];r.y_top=0/0,r.y_bottom=0/0,r.yval_minus=Dygraph.DataHandler.parseFloat(i[2][0]),r.yval_plus=Dygraph.DataHandler.parseFloat(i[2][1])}},t.prototype.getExtremeYValues=function(t,e,a){for(var i,r=null,n=null,o=0,s=t.length-1,l=o;s>=l;l++)if(i=t[l][1],null!==i&&!isNaN(i)){var h=t[l][2][0],p=t[l][2][1];h>i&&(h=i),i>p&&(p=i),(null===n||p>n)&&(n=p),(null===r||r>h)&&(r=h)}return[r,n]},t.prototype.onLineEvaluated=function(t,e,a){for(var i,r=0;r<t.length;r++)i=t[r],i.y_top=DygraphLayout.calcYNormal_(e,i.yval_minus,a),i.y_bottom=DygraphLayout.calcYNormal_(e,i.yval_plus,a)}}(),function(){"use strict";Dygraph.DataHandlers.CustomBarsHandler=function(){};var t=Dygraph.DataHandlers.CustomBarsHandler;t.prototype=new Dygraph.DataHandlers.BarsHandler,t.prototype.extractSeries=function(t,e,a){for(var i,r,n,o=[],s=a.get("logscale"),l=0;l<t.length;l++)i=t[l][0],n=t[l][e],s&&null!==n&&(n[0]<=0||n[1]<=0||n[2]<=0)&&(n=null),null!==n?(r=n[1],o.push(null===r||isNaN(r)?[i,r,[r,r]]:[i,r,[n[0],n[2]]])):o.push([i,null,[null,null]]);return o},t.prototype.rollingAverage=function(t,e,a){e=Math.min(e,t.length);var i,r,n,o,s,l,h,p=[];for(r=0,o=0,n=0,s=0,l=0;l<t.length;l++){if(i=t[l][1],h=t[l][2],p[l]=t[l],null===i||isNaN(i)||(r+=h[0],o+=i,n+=h[1],s+=1),l-e>=0){var g=t[l-e];null===g[1]||isNaN(g[1])||(r-=g[2][0],o-=g[1],n-=g[2][1],s-=1)}s?p[l]=[t[l][0],1*o/s,[1*r/s,1*n/s]]:p[l]=[t[l][0],null,[null,null]]}return p}}(),function(){"use strict";Dygraph.DataHandlers.ErrorBarsHandler=function(){};var t=Dygraph.DataHandlers.ErrorBarsHandler;t.prototype=new Dygraph.DataHandlers.BarsHandler,t.prototype.extractSeries=function(t,e,a){for(var i,r,n,o,s=[],l=a.get("sigma"),h=a.get("logscale"),p=0;p<t.length;p++)i=t[p][0],o=t[p][e],h&&null!==o&&(o[0]<=0||o[0]-l*o[1]<=0)&&(o=null),null!==o?(r=o[0],null===r||isNaN(r)?s.push([i,r,[r,r,r]]):(n=l*o[1],s.push([i,r,[r-n,r+n,o[1]]]))):s.push([i,null,[null,null,null]]);return s},t.prototype.rollingAverage=function(t,e,a){e=Math.min(e,t.length);var i,r,n,o,s,l,h,p,g,d=[],u=a.get("sigma");for(i=0;i<t.length;i++){for(s=0,p=0,l=0,r=Math.max(0,i-e+1);i+1>r;r++)n=t[r][1],null===n||isNaN(n)||(l++,s+=n,p+=Math.pow(t[r][2][2],2));l?(h=Math.sqrt(p)/l,g=s/l,d[i]=[t[i][0],g,[g-u*h,g+u*h]]):(o=1==e?t[i][1]:null,d[i]=[t[i][0],o,[o,o]])}return d}}(),function(){"use strict";Dygraph.DataHandlers.FractionsBarsHandler=function(){};var t=Dygraph.DataHandlers.FractionsBarsHandler;t.prototype=new Dygraph.DataHandlers.BarsHandler,t.prototype.extractSeries=function(t,e,a){for(var i,r,n,o,s,l,h,p,g=[],d=100,u=a.get("sigma"),c=a.get("logscale"),y=0;y<t.length;y++)i=t[y][0],n=t[y][e],c&&null!==n&&(n[0]<=0||n[1]<=0)&&(n=null),null!==n?(o=n[0],s=n[1],null===o||isNaN(o)?g.push([i,o,[o,o,o,s]]):(l=s?o/s:0,h=s?u*Math.sqrt(l*(1-l)/s):1,p=d*h,r=d*l,g.push([i,r,[r-p,r+p,o,s]]))):g.push([i,null,[null,null,null,null]]);return g},t.prototype.rollingAverage=function(t,e,a){e=Math.min(e,t.length);var i,r,n,o,s=[],l=a.get("sigma"),h=a.get("wilsonInterval"),p=0,g=0,d=100;for(n=0;n<t.length;n++){p+=t[n][2][2],g+=t[n][2][3],n-e>=0&&(p-=t[n-e][2][2],g-=t[n-e][2][3]);var u=t[n][0],c=g?p/g:0;if(h)if(g){var y=0>c?0:c,_=g,v=l*Math.sqrt(y*(1-y)/_+l*l/(4*_*_)),f=1+l*l/g;i=(y+l*l/(2*g)-v)/f,r=(y+l*l/(2*g)+v)/f,s[n]=[u,y*d,[i*d,r*d]]}else s[n]=[u,0,[0,0]];else o=g?l*Math.sqrt(c*(1-c)/g):1,s[n]=[u,d*c,[d*(c-o),d*(c+o)]]}return s}}();
+//# sourceMappingURL=dygraph-combined.js.map
\ No newline at end of file
diff --git a/craftui/www/static/favicon.ico b/craftui/www/static/favicon.ico
new file mode 100644
index 0000000..27ca313
--- /dev/null
+++ b/craftui/www/static/favicon.ico
Binary files differ
diff --git a/craftui/www/static/jquery-2.1.4.min.js b/craftui/www/static/jquery-2.1.4.min.js
new file mode 100644
index 0000000..49990d6
--- /dev/null
+++ b/craftui/www/static/jquery-2.1.4.min.js
@@ -0,0 +1,4 @@
+/*! jQuery v2.1.4 | (c) 2005, 2015 jQuery Foundation, Inc. | jquery.org/license */
+!function(a,b){"object"==typeof module&&"object"==typeof module.exports?module.exports=a.document?b(a,!0):function(a){if(!a.document)throw new Error("jQuery requires a window with a document");return b(a)}:b(a)}("undefined"!=typeof window?window:this,function(a,b){var c=[],d=c.slice,e=c.concat,f=c.push,g=c.indexOf,h={},i=h.toString,j=h.hasOwnProperty,k={},l=a.document,m="2.1.4",n=function(a,b){return new n.fn.init(a,b)},o=/^[\s\uFEFF\xA0]+|[\s\uFEFF\xA0]+$/g,p=/^-ms-/,q=/-([\da-z])/gi,r=function(a,b){return b.toUpperCase()};n.fn=n.prototype={jquery:m,constructor:n,selector:"",length:0,toArray:function(){return d.call(this)},get:function(a){return null!=a?0>a?this[a+this.length]:this[a]:d.call(this)},pushStack:function(a){var b=n.merge(this.constructor(),a);return b.prevObject=this,b.context=this.context,b},each:function(a,b){return n.each(this,a,b)},map:function(a){return this.pushStack(n.map(this,function(b,c){return a.call(b,c,b)}))},slice:function(){return this.pushStack(d.apply(this,arguments))},first:function(){return this.eq(0)},last:function(){return this.eq(-1)},eq:function(a){var b=this.length,c=+a+(0>a?b:0);return this.pushStack(c>=0&&b>c?[this[c]]:[])},end:function(){return this.prevObject||this.constructor(null)},push:f,sort:c.sort,splice:c.splice},n.extend=n.fn.extend=function(){var a,b,c,d,e,f,g=arguments[0]||{},h=1,i=arguments.length,j=!1;for("boolean"==typeof g&&(j=g,g=arguments[h]||{},h++),"object"==typeof g||n.isFunction(g)||(g={}),h===i&&(g=this,h--);i>h;h++)if(null!=(a=arguments[h]))for(b in a)c=g[b],d=a[b],g!==d&&(j&&d&&(n.isPlainObject(d)||(e=n.isArray(d)))?(e?(e=!1,f=c&&n.isArray(c)?c:[]):f=c&&n.isPlainObject(c)?c:{},g[b]=n.extend(j,f,d)):void 0!==d&&(g[b]=d));return g},n.extend({expando:"jQuery"+(m+Math.random()).replace(/\D/g,""),isReady:!0,error:function(a){throw new Error(a)},noop:function(){},isFunction:function(a){return"function"===n.type(a)},isArray:Array.isArray,isWindow:function(a){return null!=a&&a===a.window},isNumeric:function(a){return!n.isArray(a)&&a-parseFloat(a)+1>=0},isPlainObject:function(a){return"object"!==n.type(a)||a.nodeType||n.isWindow(a)?!1:a.constructor&&!j.call(a.constructor.prototype,"isPrototypeOf")?!1:!0},isEmptyObject:function(a){var b;for(b in a)return!1;return!0},type:function(a){return null==a?a+"":"object"==typeof a||"function"==typeof a?h[i.call(a)]||"object":typeof a},globalEval:function(a){var b,c=eval;a=n.trim(a),a&&(1===a.indexOf("use strict")?(b=l.createElement("script"),b.text=a,l.head.appendChild(b).parentNode.removeChild(b)):c(a))},camelCase:function(a){return a.replace(p,"ms-").replace(q,r)},nodeName:function(a,b){return a.nodeName&&a.nodeName.toLowerCase()===b.toLowerCase()},each:function(a,b,c){var d,e=0,f=a.length,g=s(a);if(c){if(g){for(;f>e;e++)if(d=b.apply(a[e],c),d===!1)break}else for(e in a)if(d=b.apply(a[e],c),d===!1)break}else if(g){for(;f>e;e++)if(d=b.call(a[e],e,a[e]),d===!1)break}else for(e in a)if(d=b.call(a[e],e,a[e]),d===!1)break;return a},trim:function(a){return null==a?"":(a+"").replace(o,"")},makeArray:function(a,b){var c=b||[];return null!=a&&(s(Object(a))?n.merge(c,"string"==typeof a?[a]:a):f.call(c,a)),c},inArray:function(a,b,c){return null==b?-1:g.call(b,a,c)},merge:function(a,b){for(var c=+b.length,d=0,e=a.length;c>d;d++)a[e++]=b[d];return a.length=e,a},grep:function(a,b,c){for(var d,e=[],f=0,g=a.length,h=!c;g>f;f++)d=!b(a[f],f),d!==h&&e.push(a[f]);return e},map:function(a,b,c){var d,f=0,g=a.length,h=s(a),i=[];if(h)for(;g>f;f++)d=b(a[f],f,c),null!=d&&i.push(d);else for(f in a)d=b(a[f],f,c),null!=d&&i.push(d);return e.apply([],i)},guid:1,proxy:function(a,b){var c,e,f;return"string"==typeof b&&(c=a[b],b=a,a=c),n.isFunction(a)?(e=d.call(arguments,2),f=function(){return a.apply(b||this,e.concat(d.call(arguments)))},f.guid=a.guid=a.guid||n.guid++,f):void 0},now:Date.now,support:k}),n.each("Boolean Number String Function Array Date RegExp Object Error".split(" "),function(a,b){h["[object "+b+"]"]=b.toLowerCase()});function s(a){var b="length"in a&&a.length,c=n.type(a);return"function"===c||n.isWindow(a)?!1:1===a.nodeType&&b?!0:"array"===c||0===b||"number"==typeof b&&b>0&&b-1 in a}var t=function(a){var b,c,d,e,f,g,h,i,j,k,l,m,n,o,p,q,r,s,t,u="sizzle"+1*new Date,v=a.document,w=0,x=0,y=ha(),z=ha(),A=ha(),B=function(a,b){return a===b&&(l=!0),0},C=1<<31,D={}.hasOwnProperty,E=[],F=E.pop,G=E.push,H=E.push,I=E.slice,J=function(a,b){for(var c=0,d=a.length;d>c;c++)if(a[c]===b)return c;return-1},K="checked|selected|async|autofocus|autoplay|controls|defer|disabled|hidden|ismap|loop|multiple|open|readonly|required|scoped",L="[\\x20\\t\\r\\n\\f]",M="(?:\\\\.|[\\w-]|[^\\x00-\\xa0])+",N=M.replace("w","w#"),O="\\["+L+"*("+M+")(?:"+L+"*([*^$|!~]?=)"+L+"*(?:'((?:\\\\.|[^\\\\'])*)'|\"((?:\\\\.|[^\\\\\"])*)\"|("+N+"))|)"+L+"*\\]",P=":("+M+")(?:\\((('((?:\\\\.|[^\\\\'])*)'|\"((?:\\\\.|[^\\\\\"])*)\")|((?:\\\\.|[^\\\\()[\\]]|"+O+")*)|.*)\\)|)",Q=new RegExp(L+"+","g"),R=new RegExp("^"+L+"+|((?:^|[^\\\\])(?:\\\\.)*)"+L+"+$","g"),S=new RegExp("^"+L+"*,"+L+"*"),T=new RegExp("^"+L+"*([>+~]|"+L+")"+L+"*"),U=new RegExp("="+L+"*([^\\]'\"]*?)"+L+"*\\]","g"),V=new RegExp(P),W=new RegExp("^"+N+"$"),X={ID:new RegExp("^#("+M+")"),CLASS:new RegExp("^\\.("+M+")"),TAG:new RegExp("^("+M.replace("w","w*")+")"),ATTR:new RegExp("^"+O),PSEUDO:new RegExp("^"+P),CHILD:new RegExp("^:(only|first|last|nth|nth-last)-(child|of-type)(?:\\("+L+"*(even|odd|(([+-]|)(\\d*)n|)"+L+"*(?:([+-]|)"+L+"*(\\d+)|))"+L+"*\\)|)","i"),bool:new RegExp("^(?:"+K+")$","i"),needsContext:new RegExp("^"+L+"*[>+~]|:(even|odd|eq|gt|lt|nth|first|last)(?:\\("+L+"*((?:-\\d)?\\d*)"+L+"*\\)|)(?=[^-]|$)","i")},Y=/^(?:input|select|textarea|button)$/i,Z=/^h\d$/i,$=/^[^{]+\{\s*\[native \w/,_=/^(?:#([\w-]+)|(\w+)|\.([\w-]+))$/,aa=/[+~]/,ba=/'|\\/g,ca=new RegExp("\\\\([\\da-f]{1,6}"+L+"?|("+L+")|.)","ig"),da=function(a,b,c){var d="0x"+b-65536;return d!==d||c?b:0>d?String.fromCharCode(d+65536):String.fromCharCode(d>>10|55296,1023&d|56320)},ea=function(){m()};try{H.apply(E=I.call(v.childNodes),v.childNodes),E[v.childNodes.length].nodeType}catch(fa){H={apply:E.length?function(a,b){G.apply(a,I.call(b))}:function(a,b){var c=a.length,d=0;while(a[c++]=b[d++]);a.length=c-1}}}function ga(a,b,d,e){var f,h,j,k,l,o,r,s,w,x;if((b?b.ownerDocument||b:v)!==n&&m(b),b=b||n,d=d||[],k=b.nodeType,"string"!=typeof a||!a||1!==k&&9!==k&&11!==k)return d;if(!e&&p){if(11!==k&&(f=_.exec(a)))if(j=f[1]){if(9===k){if(h=b.getElementById(j),!h||!h.parentNode)return d;if(h.id===j)return d.push(h),d}else if(b.ownerDocument&&(h=b.ownerDocument.getElementById(j))&&t(b,h)&&h.id===j)return d.push(h),d}else{if(f[2])return H.apply(d,b.getElementsByTagName(a)),d;if((j=f[3])&&c.getElementsByClassName)return H.apply(d,b.getElementsByClassName(j)),d}if(c.qsa&&(!q||!q.test(a))){if(s=r=u,w=b,x=1!==k&&a,1===k&&"object"!==b.nodeName.toLowerCase()){o=g(a),(r=b.getAttribute("id"))?s=r.replace(ba,"\\$&"):b.setAttribute("id",s),s="[id='"+s+"'] ",l=o.length;while(l--)o[l]=s+ra(o[l]);w=aa.test(a)&&pa(b.parentNode)||b,x=o.join(",")}if(x)try{return H.apply(d,w.querySelectorAll(x)),d}catch(y){}finally{r||b.removeAttribute("id")}}}return i(a.replace(R,"$1"),b,d,e)}function ha(){var a=[];function b(c,e){return a.push(c+" ")>d.cacheLength&&delete b[a.shift()],b[c+" "]=e}return b}function ia(a){return a[u]=!0,a}function ja(a){var b=n.createElement("div");try{return!!a(b)}catch(c){return!1}finally{b.parentNode&&b.parentNode.removeChild(b),b=null}}function ka(a,b){var c=a.split("|"),e=a.length;while(e--)d.attrHandle[c[e]]=b}function la(a,b){var c=b&&a,d=c&&1===a.nodeType&&1===b.nodeType&&(~b.sourceIndex||C)-(~a.sourceIndex||C);if(d)return d;if(c)while(c=c.nextSibling)if(c===b)return-1;return a?1:-1}function ma(a){return function(b){var c=b.nodeName.toLowerCase();return"input"===c&&b.type===a}}function na(a){return function(b){var c=b.nodeName.toLowerCase();return("input"===c||"button"===c)&&b.type===a}}function oa(a){return ia(function(b){return b=+b,ia(function(c,d){var e,f=a([],c.length,b),g=f.length;while(g--)c[e=f[g]]&&(c[e]=!(d[e]=c[e]))})})}function pa(a){return a&&"undefined"!=typeof a.getElementsByTagName&&a}c=ga.support={},f=ga.isXML=function(a){var b=a&&(a.ownerDocument||a).documentElement;return b?"HTML"!==b.nodeName:!1},m=ga.setDocument=function(a){var b,e,g=a?a.ownerDocument||a:v;return g!==n&&9===g.nodeType&&g.documentElement?(n=g,o=g.documentElement,e=g.defaultView,e&&e!==e.top&&(e.addEventListener?e.addEventListener("unload",ea,!1):e.attachEvent&&e.attachEvent("onunload",ea)),p=!f(g),c.attributes=ja(function(a){return a.className="i",!a.getAttribute("className")}),c.getElementsByTagName=ja(function(a){return a.appendChild(g.createComment("")),!a.getElementsByTagName("*").length}),c.getElementsByClassName=$.test(g.getElementsByClassName),c.getById=ja(function(a){return o.appendChild(a).id=u,!g.getElementsByName||!g.getElementsByName(u).length}),c.getById?(d.find.ID=function(a,b){if("undefined"!=typeof b.getElementById&&p){var c=b.getElementById(a);return c&&c.parentNode?[c]:[]}},d.filter.ID=function(a){var b=a.replace(ca,da);return function(a){return a.getAttribute("id")===b}}):(delete d.find.ID,d.filter.ID=function(a){var b=a.replace(ca,da);return function(a){var c="undefined"!=typeof a.getAttributeNode&&a.getAttributeNode("id");return c&&c.value===b}}),d.find.TAG=c.getElementsByTagName?function(a,b){return"undefined"!=typeof b.getElementsByTagName?b.getElementsByTagName(a):c.qsa?b.querySelectorAll(a):void 0}:function(a,b){var c,d=[],e=0,f=b.getElementsByTagName(a);if("*"===a){while(c=f[e++])1===c.nodeType&&d.push(c);return d}return f},d.find.CLASS=c.getElementsByClassName&&function(a,b){return p?b.getElementsByClassName(a):void 0},r=[],q=[],(c.qsa=$.test(g.querySelectorAll))&&(ja(function(a){o.appendChild(a).innerHTML="<a id='"+u+"'></a><select id='"+u+"-\f]' msallowcapture=''><option selected=''></option></select>",a.querySelectorAll("[msallowcapture^='']").length&&q.push("[*^$]="+L+"*(?:''|\"\")"),a.querySelectorAll("[selected]").length||q.push("\\["+L+"*(?:value|"+K+")"),a.querySelectorAll("[id~="+u+"-]").length||q.push("~="),a.querySelectorAll(":checked").length||q.push(":checked"),a.querySelectorAll("a#"+u+"+*").length||q.push(".#.+[+~]")}),ja(function(a){var b=g.createElement("input");b.setAttribute("type","hidden"),a.appendChild(b).setAttribute("name","D"),a.querySelectorAll("[name=d]").length&&q.push("name"+L+"*[*^$|!~]?="),a.querySelectorAll(":enabled").length||q.push(":enabled",":disabled"),a.querySelectorAll("*,:x"),q.push(",.*:")})),(c.matchesSelector=$.test(s=o.matches||o.webkitMatchesSelector||o.mozMatchesSelector||o.oMatchesSelector||o.msMatchesSelector))&&ja(function(a){c.disconnectedMatch=s.call(a,"div"),s.call(a,"[s!='']:x"),r.push("!=",P)}),q=q.length&&new RegExp(q.join("|")),r=r.length&&new RegExp(r.join("|")),b=$.test(o.compareDocumentPosition),t=b||$.test(o.contains)?function(a,b){var c=9===a.nodeType?a.documentElement:a,d=b&&b.parentNode;return a===d||!(!d||1!==d.nodeType||!(c.contains?c.contains(d):a.compareDocumentPosition&&16&a.compareDocumentPosition(d)))}:function(a,b){if(b)while(b=b.parentNode)if(b===a)return!0;return!1},B=b?function(a,b){if(a===b)return l=!0,0;var d=!a.compareDocumentPosition-!b.compareDocumentPosition;return d?d:(d=(a.ownerDocument||a)===(b.ownerDocument||b)?a.compareDocumentPosition(b):1,1&d||!c.sortDetached&&b.compareDocumentPosition(a)===d?a===g||a.ownerDocument===v&&t(v,a)?-1:b===g||b.ownerDocument===v&&t(v,b)?1:k?J(k,a)-J(k,b):0:4&d?-1:1)}:function(a,b){if(a===b)return l=!0,0;var c,d=0,e=a.parentNode,f=b.parentNode,h=[a],i=[b];if(!e||!f)return a===g?-1:b===g?1:e?-1:f?1:k?J(k,a)-J(k,b):0;if(e===f)return la(a,b);c=a;while(c=c.parentNode)h.unshift(c);c=b;while(c=c.parentNode)i.unshift(c);while(h[d]===i[d])d++;return d?la(h[d],i[d]):h[d]===v?-1:i[d]===v?1:0},g):n},ga.matches=function(a,b){return ga(a,null,null,b)},ga.matchesSelector=function(a,b){if((a.ownerDocument||a)!==n&&m(a),b=b.replace(U,"='$1']"),!(!c.matchesSelector||!p||r&&r.test(b)||q&&q.test(b)))try{var d=s.call(a,b);if(d||c.disconnectedMatch||a.document&&11!==a.document.nodeType)return d}catch(e){}return ga(b,n,null,[a]).length>0},ga.contains=function(a,b){return(a.ownerDocument||a)!==n&&m(a),t(a,b)},ga.attr=function(a,b){(a.ownerDocument||a)!==n&&m(a);var e=d.attrHandle[b.toLowerCase()],f=e&&D.call(d.attrHandle,b.toLowerCase())?e(a,b,!p):void 0;return void 0!==f?f:c.attributes||!p?a.getAttribute(b):(f=a.getAttributeNode(b))&&f.specified?f.value:null},ga.error=function(a){throw new Error("Syntax error, unrecognized expression: "+a)},ga.uniqueSort=function(a){var b,d=[],e=0,f=0;if(l=!c.detectDuplicates,k=!c.sortStable&&a.slice(0),a.sort(B),l){while(b=a[f++])b===a[f]&&(e=d.push(f));while(e--)a.splice(d[e],1)}return k=null,a},e=ga.getText=function(a){var b,c="",d=0,f=a.nodeType;if(f){if(1===f||9===f||11===f){if("string"==typeof a.textContent)return a.textContent;for(a=a.firstChild;a;a=a.nextSibling)c+=e(a)}else if(3===f||4===f)return a.nodeValue}else while(b=a[d++])c+=e(b);return c},d=ga.selectors={cacheLength:50,createPseudo:ia,match:X,attrHandle:{},find:{},relative:{">":{dir:"parentNode",first:!0}," ":{dir:"parentNode"},"+":{dir:"previousSibling",first:!0},"~":{dir:"previousSibling"}},preFilter:{ATTR:function(a){return a[1]=a[1].replace(ca,da),a[3]=(a[3]||a[4]||a[5]||"").replace(ca,da),"~="===a[2]&&(a[3]=" "+a[3]+" "),a.slice(0,4)},CHILD:function(a){return a[1]=a[1].toLowerCase(),"nth"===a[1].slice(0,3)?(a[3]||ga.error(a[0]),a[4]=+(a[4]?a[5]+(a[6]||1):2*("even"===a[3]||"odd"===a[3])),a[5]=+(a[7]+a[8]||"odd"===a[3])):a[3]&&ga.error(a[0]),a},PSEUDO:function(a){var b,c=!a[6]&&a[2];return X.CHILD.test(a[0])?null:(a[3]?a[2]=a[4]||a[5]||"":c&&V.test(c)&&(b=g(c,!0))&&(b=c.indexOf(")",c.length-b)-c.length)&&(a[0]=a[0].slice(0,b),a[2]=c.slice(0,b)),a.slice(0,3))}},filter:{TAG:function(a){var b=a.replace(ca,da).toLowerCase();return"*"===a?function(){return!0}:function(a){return a.nodeName&&a.nodeName.toLowerCase()===b}},CLASS:function(a){var b=y[a+" "];return b||(b=new RegExp("(^|"+L+")"+a+"("+L+"|$)"))&&y(a,function(a){return b.test("string"==typeof a.className&&a.className||"undefined"!=typeof a.getAttribute&&a.getAttribute("class")||"")})},ATTR:function(a,b,c){return function(d){var e=ga.attr(d,a);return null==e?"!="===b:b?(e+="","="===b?e===c:"!="===b?e!==c:"^="===b?c&&0===e.indexOf(c):"*="===b?c&&e.indexOf(c)>-1:"$="===b?c&&e.slice(-c.length)===c:"~="===b?(" "+e.replace(Q," ")+" ").indexOf(c)>-1:"|="===b?e===c||e.slice(0,c.length+1)===c+"-":!1):!0}},CHILD:function(a,b,c,d,e){var f="nth"!==a.slice(0,3),g="last"!==a.slice(-4),h="of-type"===b;return 1===d&&0===e?function(a){return!!a.parentNode}:function(b,c,i){var j,k,l,m,n,o,p=f!==g?"nextSibling":"previousSibling",q=b.parentNode,r=h&&b.nodeName.toLowerCase(),s=!i&&!h;if(q){if(f){while(p){l=b;while(l=l[p])if(h?l.nodeName.toLowerCase()===r:1===l.nodeType)return!1;o=p="only"===a&&!o&&"nextSibling"}return!0}if(o=[g?q.firstChild:q.lastChild],g&&s){k=q[u]||(q[u]={}),j=k[a]||[],n=j[0]===w&&j[1],m=j[0]===w&&j[2],l=n&&q.childNodes[n];while(l=++n&&l&&l[p]||(m=n=0)||o.pop())if(1===l.nodeType&&++m&&l===b){k[a]=[w,n,m];break}}else if(s&&(j=(b[u]||(b[u]={}))[a])&&j[0]===w)m=j[1];else while(l=++n&&l&&l[p]||(m=n=0)||o.pop())if((h?l.nodeName.toLowerCase()===r:1===l.nodeType)&&++m&&(s&&((l[u]||(l[u]={}))[a]=[w,m]),l===b))break;return m-=e,m===d||m%d===0&&m/d>=0}}},PSEUDO:function(a,b){var c,e=d.pseudos[a]||d.setFilters[a.toLowerCase()]||ga.error("unsupported pseudo: "+a);return e[u]?e(b):e.length>1?(c=[a,a,"",b],d.setFilters.hasOwnProperty(a.toLowerCase())?ia(function(a,c){var d,f=e(a,b),g=f.length;while(g--)d=J(a,f[g]),a[d]=!(c[d]=f[g])}):function(a){return e(a,0,c)}):e}},pseudos:{not:ia(function(a){var b=[],c=[],d=h(a.replace(R,"$1"));return d[u]?ia(function(a,b,c,e){var f,g=d(a,null,e,[]),h=a.length;while(h--)(f=g[h])&&(a[h]=!(b[h]=f))}):function(a,e,f){return b[0]=a,d(b,null,f,c),b[0]=null,!c.pop()}}),has:ia(function(a){return function(b){return ga(a,b).length>0}}),contains:ia(function(a){return a=a.replace(ca,da),function(b){return(b.textContent||b.innerText||e(b)).indexOf(a)>-1}}),lang:ia(function(a){return W.test(a||"")||ga.error("unsupported lang: "+a),a=a.replace(ca,da).toLowerCase(),function(b){var c;do if(c=p?b.lang:b.getAttribute("xml:lang")||b.getAttribute("lang"))return c=c.toLowerCase(),c===a||0===c.indexOf(a+"-");while((b=b.parentNode)&&1===b.nodeType);return!1}}),target:function(b){var c=a.location&&a.location.hash;return c&&c.slice(1)===b.id},root:function(a){return a===o},focus:function(a){return a===n.activeElement&&(!n.hasFocus||n.hasFocus())&&!!(a.type||a.href||~a.tabIndex)},enabled:function(a){return a.disabled===!1},disabled:function(a){return a.disabled===!0},checked:function(a){var b=a.nodeName.toLowerCase();return"input"===b&&!!a.checked||"option"===b&&!!a.selected},selected:function(a){return a.parentNode&&a.parentNode.selectedIndex,a.selected===!0},empty:function(a){for(a=a.firstChild;a;a=a.nextSibling)if(a.nodeType<6)return!1;return!0},parent:function(a){return!d.pseudos.empty(a)},header:function(a){return Z.test(a.nodeName)},input:function(a){return Y.test(a.nodeName)},button:function(a){var b=a.nodeName.toLowerCase();return"input"===b&&"button"===a.type||"button"===b},text:function(a){var b;return"input"===a.nodeName.toLowerCase()&&"text"===a.type&&(null==(b=a.getAttribute("type"))||"text"===b.toLowerCase())},first:oa(function(){return[0]}),last:oa(function(a,b){return[b-1]}),eq:oa(function(a,b,c){return[0>c?c+b:c]}),even:oa(function(a,b){for(var c=0;b>c;c+=2)a.push(c);return a}),odd:oa(function(a,b){for(var c=1;b>c;c+=2)a.push(c);return a}),lt:oa(function(a,b,c){for(var d=0>c?c+b:c;--d>=0;)a.push(d);return a}),gt:oa(function(a,b,c){for(var d=0>c?c+b:c;++d<b;)a.push(d);return a})}},d.pseudos.nth=d.pseudos.eq;for(b in{radio:!0,checkbox:!0,file:!0,password:!0,image:!0})d.pseudos[b]=ma(b);for(b in{submit:!0,reset:!0})d.pseudos[b]=na(b);function qa(){}qa.prototype=d.filters=d.pseudos,d.setFilters=new qa,g=ga.tokenize=function(a,b){var c,e,f,g,h,i,j,k=z[a+" "];if(k)return b?0:k.slice(0);h=a,i=[],j=d.preFilter;while(h){(!c||(e=S.exec(h)))&&(e&&(h=h.slice(e[0].length)||h),i.push(f=[])),c=!1,(e=T.exec(h))&&(c=e.shift(),f.push({value:c,type:e[0].replace(R," ")}),h=h.slice(c.length));for(g in d.filter)!(e=X[g].exec(h))||j[g]&&!(e=j[g](e))||(c=e.shift(),f.push({value:c,type:g,matches:e}),h=h.slice(c.length));if(!c)break}return b?h.length:h?ga.error(a):z(a,i).slice(0)};function ra(a){for(var b=0,c=a.length,d="";c>b;b++)d+=a[b].value;return d}function sa(a,b,c){var d=b.dir,e=c&&"parentNode"===d,f=x++;return b.first?function(b,c,f){while(b=b[d])if(1===b.nodeType||e)return a(b,c,f)}:function(b,c,g){var h,i,j=[w,f];if(g){while(b=b[d])if((1===b.nodeType||e)&&a(b,c,g))return!0}else while(b=b[d])if(1===b.nodeType||e){if(i=b[u]||(b[u]={}),(h=i[d])&&h[0]===w&&h[1]===f)return j[2]=h[2];if(i[d]=j,j[2]=a(b,c,g))return!0}}}function ta(a){return a.length>1?function(b,c,d){var e=a.length;while(e--)if(!a[e](b,c,d))return!1;return!0}:a[0]}function ua(a,b,c){for(var d=0,e=b.length;e>d;d++)ga(a,b[d],c);return c}function va(a,b,c,d,e){for(var f,g=[],h=0,i=a.length,j=null!=b;i>h;h++)(f=a[h])&&(!c||c(f,d,e))&&(g.push(f),j&&b.push(h));return g}function wa(a,b,c,d,e,f){return d&&!d[u]&&(d=wa(d)),e&&!e[u]&&(e=wa(e,f)),ia(function(f,g,h,i){var j,k,l,m=[],n=[],o=g.length,p=f||ua(b||"*",h.nodeType?[h]:h,[]),q=!a||!f&&b?p:va(p,m,a,h,i),r=c?e||(f?a:o||d)?[]:g:q;if(c&&c(q,r,h,i),d){j=va(r,n),d(j,[],h,i),k=j.length;while(k--)(l=j[k])&&(r[n[k]]=!(q[n[k]]=l))}if(f){if(e||a){if(e){j=[],k=r.length;while(k--)(l=r[k])&&j.push(q[k]=l);e(null,r=[],j,i)}k=r.length;while(k--)(l=r[k])&&(j=e?J(f,l):m[k])>-1&&(f[j]=!(g[j]=l))}}else r=va(r===g?r.splice(o,r.length):r),e?e(null,g,r,i):H.apply(g,r)})}function xa(a){for(var b,c,e,f=a.length,g=d.relative[a[0].type],h=g||d.relative[" "],i=g?1:0,k=sa(function(a){return a===b},h,!0),l=sa(function(a){return J(b,a)>-1},h,!0),m=[function(a,c,d){var e=!g&&(d||c!==j)||((b=c).nodeType?k(a,c,d):l(a,c,d));return b=null,e}];f>i;i++)if(c=d.relative[a[i].type])m=[sa(ta(m),c)];else{if(c=d.filter[a[i].type].apply(null,a[i].matches),c[u]){for(e=++i;f>e;e++)if(d.relative[a[e].type])break;return wa(i>1&&ta(m),i>1&&ra(a.slice(0,i-1).concat({value:" "===a[i-2].type?"*":""})).replace(R,"$1"),c,e>i&&xa(a.slice(i,e)),f>e&&xa(a=a.slice(e)),f>e&&ra(a))}m.push(c)}return ta(m)}function ya(a,b){var c=b.length>0,e=a.length>0,f=function(f,g,h,i,k){var l,m,o,p=0,q="0",r=f&&[],s=[],t=j,u=f||e&&d.find.TAG("*",k),v=w+=null==t?1:Math.random()||.1,x=u.length;for(k&&(j=g!==n&&g);q!==x&&null!=(l=u[q]);q++){if(e&&l){m=0;while(o=a[m++])if(o(l,g,h)){i.push(l);break}k&&(w=v)}c&&((l=!o&&l)&&p--,f&&r.push(l))}if(p+=q,c&&q!==p){m=0;while(o=b[m++])o(r,s,g,h);if(f){if(p>0)while(q--)r[q]||s[q]||(s[q]=F.call(i));s=va(s)}H.apply(i,s),k&&!f&&s.length>0&&p+b.length>1&&ga.uniqueSort(i)}return k&&(w=v,j=t),r};return c?ia(f):f}return h=ga.compile=function(a,b){var c,d=[],e=[],f=A[a+" "];if(!f){b||(b=g(a)),c=b.length;while(c--)f=xa(b[c]),f[u]?d.push(f):e.push(f);f=A(a,ya(e,d)),f.selector=a}return f},i=ga.select=function(a,b,e,f){var i,j,k,l,m,n="function"==typeof a&&a,o=!f&&g(a=n.selector||a);if(e=e||[],1===o.length){if(j=o[0]=o[0].slice(0),j.length>2&&"ID"===(k=j[0]).type&&c.getById&&9===b.nodeType&&p&&d.relative[j[1].type]){if(b=(d.find.ID(k.matches[0].replace(ca,da),b)||[])[0],!b)return e;n&&(b=b.parentNode),a=a.slice(j.shift().value.length)}i=X.needsContext.test(a)?0:j.length;while(i--){if(k=j[i],d.relative[l=k.type])break;if((m=d.find[l])&&(f=m(k.matches[0].replace(ca,da),aa.test(j[0].type)&&pa(b.parentNode)||b))){if(j.splice(i,1),a=f.length&&ra(j),!a)return H.apply(e,f),e;break}}}return(n||h(a,o))(f,b,!p,e,aa.test(a)&&pa(b.parentNode)||b),e},c.sortStable=u.split("").sort(B).join("")===u,c.detectDuplicates=!!l,m(),c.sortDetached=ja(function(a){return 1&a.compareDocumentPosition(n.createElement("div"))}),ja(function(a){return a.innerHTML="<a href='#'></a>","#"===a.firstChild.getAttribute("href")})||ka("type|href|height|width",function(a,b,c){return c?void 0:a.getAttribute(b,"type"===b.toLowerCase()?1:2)}),c.attributes&&ja(function(a){return a.innerHTML="<input/>",a.firstChild.setAttribute("value",""),""===a.firstChild.getAttribute("value")})||ka("value",function(a,b,c){return c||"input"!==a.nodeName.toLowerCase()?void 0:a.defaultValue}),ja(function(a){return null==a.getAttribute("disabled")})||ka(K,function(a,b,c){var d;return c?void 0:a[b]===!0?b.toLowerCase():(d=a.getAttributeNode(b))&&d.specified?d.value:null}),ga}(a);n.find=t,n.expr=t.selectors,n.expr[":"]=n.expr.pseudos,n.unique=t.uniqueSort,n.text=t.getText,n.isXMLDoc=t.isXML,n.contains=t.contains;var u=n.expr.match.needsContext,v=/^<(\w+)\s*\/?>(?:<\/\1>|)$/,w=/^.[^:#\[\.,]*$/;function x(a,b,c){if(n.isFunction(b))return n.grep(a,function(a,d){return!!b.call(a,d,a)!==c});if(b.nodeType)return n.grep(a,function(a){return a===b!==c});if("string"==typeof b){if(w.test(b))return n.filter(b,a,c);b=n.filter(b,a)}return n.grep(a,function(a){return g.call(b,a)>=0!==c})}n.filter=function(a,b,c){var d=b[0];return c&&(a=":not("+a+")"),1===b.length&&1===d.nodeType?n.find.matchesSelector(d,a)?[d]:[]:n.find.matches(a,n.grep(b,function(a){return 1===a.nodeType}))},n.fn.extend({find:function(a){var b,c=this.length,d=[],e=this;if("string"!=typeof a)return this.pushStack(n(a).filter(function(){for(b=0;c>b;b++)if(n.contains(e[b],this))return!0}));for(b=0;c>b;b++)n.find(a,e[b],d);return d=this.pushStack(c>1?n.unique(d):d),d.selector=this.selector?this.selector+" "+a:a,d},filter:function(a){return this.pushStack(x(this,a||[],!1))},not:function(a){return this.pushStack(x(this,a||[],!0))},is:function(a){return!!x(this,"string"==typeof a&&u.test(a)?n(a):a||[],!1).length}});var y,z=/^(?:\s*(<[\w\W]+>)[^>]*|#([\w-]*))$/,A=n.fn.init=function(a,b){var c,d;if(!a)return this;if("string"==typeof a){if(c="<"===a[0]&&">"===a[a.length-1]&&a.length>=3?[null,a,null]:z.exec(a),!c||!c[1]&&b)return!b||b.jquery?(b||y).find(a):this.constructor(b).find(a);if(c[1]){if(b=b instanceof n?b[0]:b,n.merge(this,n.parseHTML(c[1],b&&b.nodeType?b.ownerDocument||b:l,!0)),v.test(c[1])&&n.isPlainObject(b))for(c in b)n.isFunction(this[c])?this[c](b[c]):this.attr(c,b[c]);return this}return d=l.getElementById(c[2]),d&&d.parentNode&&(this.length=1,this[0]=d),this.context=l,this.selector=a,this}return a.nodeType?(this.context=this[0]=a,this.length=1,this):n.isFunction(a)?"undefined"!=typeof y.ready?y.ready(a):a(n):(void 0!==a.selector&&(this.selector=a.selector,this.context=a.context),n.makeArray(a,this))};A.prototype=n.fn,y=n(l);var B=/^(?:parents|prev(?:Until|All))/,C={children:!0,contents:!0,next:!0,prev:!0};n.extend({dir:function(a,b,c){var d=[],e=void 0!==c;while((a=a[b])&&9!==a.nodeType)if(1===a.nodeType){if(e&&n(a).is(c))break;d.push(a)}return d},sibling:function(a,b){for(var c=[];a;a=a.nextSibling)1===a.nodeType&&a!==b&&c.push(a);return c}}),n.fn.extend({has:function(a){var b=n(a,this),c=b.length;return this.filter(function(){for(var a=0;c>a;a++)if(n.contains(this,b[a]))return!0})},closest:function(a,b){for(var c,d=0,e=this.length,f=[],g=u.test(a)||"string"!=typeof a?n(a,b||this.context):0;e>d;d++)for(c=this[d];c&&c!==b;c=c.parentNode)if(c.nodeType<11&&(g?g.index(c)>-1:1===c.nodeType&&n.find.matchesSelector(c,a))){f.push(c);break}return this.pushStack(f.length>1?n.unique(f):f)},index:function(a){return a?"string"==typeof a?g.call(n(a),this[0]):g.call(this,a.jquery?a[0]:a):this[0]&&this[0].parentNode?this.first().prevAll().length:-1},add:function(a,b){return this.pushStack(n.unique(n.merge(this.get(),n(a,b))))},addBack:function(a){return this.add(null==a?this.prevObject:this.prevObject.filter(a))}});function D(a,b){while((a=a[b])&&1!==a.nodeType);return a}n.each({parent:function(a){var b=a.parentNode;return b&&11!==b.nodeType?b:null},parents:function(a){return n.dir(a,"parentNode")},parentsUntil:function(a,b,c){return n.dir(a,"parentNode",c)},next:function(a){return D(a,"nextSibling")},prev:function(a){return D(a,"previousSibling")},nextAll:function(a){return n.dir(a,"nextSibling")},prevAll:function(a){return n.dir(a,"previousSibling")},nextUntil:function(a,b,c){return n.dir(a,"nextSibling",c)},prevUntil:function(a,b,c){return n.dir(a,"previousSibling",c)},siblings:function(a){return n.sibling((a.parentNode||{}).firstChild,a)},children:function(a){return n.sibling(a.firstChild)},contents:function(a){return a.contentDocument||n.merge([],a.childNodes)}},function(a,b){n.fn[a]=function(c,d){var e=n.map(this,b,c);return"Until"!==a.slice(-5)&&(d=c),d&&"string"==typeof d&&(e=n.filter(d,e)),this.length>1&&(C[a]||n.unique(e),B.test(a)&&e.reverse()),this.pushStack(e)}});var E=/\S+/g,F={};function G(a){var b=F[a]={};return n.each(a.match(E)||[],function(a,c){b[c]=!0}),b}n.Callbacks=function(a){a="string"==typeof a?F[a]||G(a):n.extend({},a);var b,c,d,e,f,g,h=[],i=!a.once&&[],j=function(l){for(b=a.memory&&l,c=!0,g=e||0,e=0,f=h.length,d=!0;h&&f>g;g++)if(h[g].apply(l[0],l[1])===!1&&a.stopOnFalse){b=!1;break}d=!1,h&&(i?i.length&&j(i.shift()):b?h=[]:k.disable())},k={add:function(){if(h){var c=h.length;!function g(b){n.each(b,function(b,c){var d=n.type(c);"function"===d?a.unique&&k.has(c)||h.push(c):c&&c.length&&"string"!==d&&g(c)})}(arguments),d?f=h.length:b&&(e=c,j(b))}return this},remove:function(){return h&&n.each(arguments,function(a,b){var c;while((c=n.inArray(b,h,c))>-1)h.splice(c,1),d&&(f>=c&&f--,g>=c&&g--)}),this},has:function(a){return a?n.inArray(a,h)>-1:!(!h||!h.length)},empty:function(){return h=[],f=0,this},disable:function(){return h=i=b=void 0,this},disabled:function(){return!h},lock:function(){return i=void 0,b||k.disable(),this},locked:function(){return!i},fireWith:function(a,b){return!h||c&&!i||(b=b||[],b=[a,b.slice?b.slice():b],d?i.push(b):j(b)),this},fire:function(){return k.fireWith(this,arguments),this},fired:function(){return!!c}};return k},n.extend({Deferred:function(a){var b=[["resolve","done",n.Callbacks("once memory"),"resolved"],["reject","fail",n.Callbacks("once memory"),"rejected"],["notify","progress",n.Callbacks("memory")]],c="pending",d={state:function(){return c},always:function(){return e.done(arguments).fail(arguments),this},then:function(){var a=arguments;return n.Deferred(function(c){n.each(b,function(b,f){var g=n.isFunction(a[b])&&a[b];e[f[1]](function(){var a=g&&g.apply(this,arguments);a&&n.isFunction(a.promise)?a.promise().done(c.resolve).fail(c.reject).progress(c.notify):c[f[0]+"With"](this===d?c.promise():this,g?[a]:arguments)})}),a=null}).promise()},promise:function(a){return null!=a?n.extend(a,d):d}},e={};return d.pipe=d.then,n.each(b,function(a,f){var g=f[2],h=f[3];d[f[1]]=g.add,h&&g.add(function(){c=h},b[1^a][2].disable,b[2][2].lock),e[f[0]]=function(){return e[f[0]+"With"](this===e?d:this,arguments),this},e[f[0]+"With"]=g.fireWith}),d.promise(e),a&&a.call(e,e),e},when:function(a){var b=0,c=d.call(arguments),e=c.length,f=1!==e||a&&n.isFunction(a.promise)?e:0,g=1===f?a:n.Deferred(),h=function(a,b,c){return function(e){b[a]=this,c[a]=arguments.length>1?d.call(arguments):e,c===i?g.notifyWith(b,c):--f||g.resolveWith(b,c)}},i,j,k;if(e>1)for(i=new Array(e),j=new Array(e),k=new Array(e);e>b;b++)c[b]&&n.isFunction(c[b].promise)?c[b].promise().done(h(b,k,c)).fail(g.reject).progress(h(b,j,i)):--f;return f||g.resolveWith(k,c),g.promise()}});var H;n.fn.ready=function(a){return n.ready.promise().done(a),this},n.extend({isReady:!1,readyWait:1,holdReady:function(a){a?n.readyWait++:n.ready(!0)},ready:function(a){(a===!0?--n.readyWait:n.isReady)||(n.isReady=!0,a!==!0&&--n.readyWait>0||(H.resolveWith(l,[n]),n.fn.triggerHandler&&(n(l).triggerHandler("ready"),n(l).off("ready"))))}});function I(){l.removeEventListener("DOMContentLoaded",I,!1),a.removeEventListener("load",I,!1),n.ready()}n.ready.promise=function(b){return H||(H=n.Deferred(),"complete"===l.readyState?setTimeout(n.ready):(l.addEventListener("DOMContentLoaded",I,!1),a.addEventListener("load",I,!1))),H.promise(b)},n.ready.promise();var J=n.access=function(a,b,c,d,e,f,g){var h=0,i=a.length,j=null==c;if("object"===n.type(c)){e=!0;for(h in c)n.access(a,b,h,c[h],!0,f,g)}else if(void 0!==d&&(e=!0,n.isFunction(d)||(g=!0),j&&(g?(b.call(a,d),b=null):(j=b,b=function(a,b,c){return j.call(n(a),c)})),b))for(;i>h;h++)b(a[h],c,g?d:d.call(a[h],h,b(a[h],c)));return e?a:j?b.call(a):i?b(a[0],c):f};n.acceptData=function(a){return 1===a.nodeType||9===a.nodeType||!+a.nodeType};function K(){Object.defineProperty(this.cache={},0,{get:function(){return{}}}),this.expando=n.expando+K.uid++}K.uid=1,K.accepts=n.acceptData,K.prototype={key:function(a){if(!K.accepts(a))return 0;var b={},c=a[this.expando];if(!c){c=K.uid++;try{b[this.expando]={value:c},Object.defineProperties(a,b)}catch(d){b[this.expando]=c,n.extend(a,b)}}return this.cache[c]||(this.cache[c]={}),c},set:function(a,b,c){var d,e=this.key(a),f=this.cache[e];if("string"==typeof b)f[b]=c;else if(n.isEmptyObject(f))n.extend(this.cache[e],b);else for(d in b)f[d]=b[d];return f},get:function(a,b){var c=this.cache[this.key(a)];return void 0===b?c:c[b]},access:function(a,b,c){var d;return void 0===b||b&&"string"==typeof b&&void 0===c?(d=this.get(a,b),void 0!==d?d:this.get(a,n.camelCase(b))):(this.set(a,b,c),void 0!==c?c:b)},remove:function(a,b){var c,d,e,f=this.key(a),g=this.cache[f];if(void 0===b)this.cache[f]={};else{n.isArray(b)?d=b.concat(b.map(n.camelCase)):(e=n.camelCase(b),b in g?d=[b,e]:(d=e,d=d in g?[d]:d.match(E)||[])),c=d.length;while(c--)delete g[d[c]]}},hasData:function(a){return!n.isEmptyObject(this.cache[a[this.expando]]||{})},discard:function(a){a[this.expando]&&delete this.cache[a[this.expando]]}};var L=new K,M=new K,N=/^(?:\{[\w\W]*\}|\[[\w\W]*\])$/,O=/([A-Z])/g;function P(a,b,c){var d;if(void 0===c&&1===a.nodeType)if(d="data-"+b.replace(O,"-$1").toLowerCase(),c=a.getAttribute(d),"string"==typeof c){try{c="true"===c?!0:"false"===c?!1:"null"===c?null:+c+""===c?+c:N.test(c)?n.parseJSON(c):c}catch(e){}M.set(a,b,c)}else c=void 0;return c}n.extend({hasData:function(a){return M.hasData(a)||L.hasData(a)},data:function(a,b,c){
+return M.access(a,b,c)},removeData:function(a,b){M.remove(a,b)},_data:function(a,b,c){return L.access(a,b,c)},_removeData:function(a,b){L.remove(a,b)}}),n.fn.extend({data:function(a,b){var c,d,e,f=this[0],g=f&&f.attributes;if(void 0===a){if(this.length&&(e=M.get(f),1===f.nodeType&&!L.get(f,"hasDataAttrs"))){c=g.length;while(c--)g[c]&&(d=g[c].name,0===d.indexOf("data-")&&(d=n.camelCase(d.slice(5)),P(f,d,e[d])));L.set(f,"hasDataAttrs",!0)}return e}return"object"==typeof a?this.each(function(){M.set(this,a)}):J(this,function(b){var c,d=n.camelCase(a);if(f&&void 0===b){if(c=M.get(f,a),void 0!==c)return c;if(c=M.get(f,d),void 0!==c)return c;if(c=P(f,d,void 0),void 0!==c)return c}else this.each(function(){var c=M.get(this,d);M.set(this,d,b),-1!==a.indexOf("-")&&void 0!==c&&M.set(this,a,b)})},null,b,arguments.length>1,null,!0)},removeData:function(a){return this.each(function(){M.remove(this,a)})}}),n.extend({queue:function(a,b,c){var d;return a?(b=(b||"fx")+"queue",d=L.get(a,b),c&&(!d||n.isArray(c)?d=L.access(a,b,n.makeArray(c)):d.push(c)),d||[]):void 0},dequeue:function(a,b){b=b||"fx";var c=n.queue(a,b),d=c.length,e=c.shift(),f=n._queueHooks(a,b),g=function(){n.dequeue(a,b)};"inprogress"===e&&(e=c.shift(),d--),e&&("fx"===b&&c.unshift("inprogress"),delete f.stop,e.call(a,g,f)),!d&&f&&f.empty.fire()},_queueHooks:function(a,b){var c=b+"queueHooks";return L.get(a,c)||L.access(a,c,{empty:n.Callbacks("once memory").add(function(){L.remove(a,[b+"queue",c])})})}}),n.fn.extend({queue:function(a,b){var c=2;return"string"!=typeof a&&(b=a,a="fx",c--),arguments.length<c?n.queue(this[0],a):void 0===b?this:this.each(function(){var c=n.queue(this,a,b);n._queueHooks(this,a),"fx"===a&&"inprogress"!==c[0]&&n.dequeue(this,a)})},dequeue:function(a){return this.each(function(){n.dequeue(this,a)})},clearQueue:function(a){return this.queue(a||"fx",[])},promise:function(a,b){var c,d=1,e=n.Deferred(),f=this,g=this.length,h=function(){--d||e.resolveWith(f,[f])};"string"!=typeof a&&(b=a,a=void 0),a=a||"fx";while(g--)c=L.get(f[g],a+"queueHooks"),c&&c.empty&&(d++,c.empty.add(h));return h(),e.promise(b)}});var Q=/[+-]?(?:\d*\.|)\d+(?:[eE][+-]?\d+|)/.source,R=["Top","Right","Bottom","Left"],S=function(a,b){return a=b||a,"none"===n.css(a,"display")||!n.contains(a.ownerDocument,a)},T=/^(?:checkbox|radio)$/i;!function(){var a=l.createDocumentFragment(),b=a.appendChild(l.createElement("div")),c=l.createElement("input");c.setAttribute("type","radio"),c.setAttribute("checked","checked"),c.setAttribute("name","t"),b.appendChild(c),k.checkClone=b.cloneNode(!0).cloneNode(!0).lastChild.checked,b.innerHTML="<textarea>x</textarea>",k.noCloneChecked=!!b.cloneNode(!0).lastChild.defaultValue}();var U="undefined";k.focusinBubbles="onfocusin"in a;var V=/^key/,W=/^(?:mouse|pointer|contextmenu)|click/,X=/^(?:focusinfocus|focusoutblur)$/,Y=/^([^.]*)(?:\.(.+)|)$/;function Z(){return!0}function $(){return!1}function _(){try{return l.activeElement}catch(a){}}n.event={global:{},add:function(a,b,c,d,e){var f,g,h,i,j,k,l,m,o,p,q,r=L.get(a);if(r){c.handler&&(f=c,c=f.handler,e=f.selector),c.guid||(c.guid=n.guid++),(i=r.events)||(i=r.events={}),(g=r.handle)||(g=r.handle=function(b){return typeof n!==U&&n.event.triggered!==b.type?n.event.dispatch.apply(a,arguments):void 0}),b=(b||"").match(E)||[""],j=b.length;while(j--)h=Y.exec(b[j])||[],o=q=h[1],p=(h[2]||"").split(".").sort(),o&&(l=n.event.special[o]||{},o=(e?l.delegateType:l.bindType)||o,l=n.event.special[o]||{},k=n.extend({type:o,origType:q,data:d,handler:c,guid:c.guid,selector:e,needsContext:e&&n.expr.match.needsContext.test(e),namespace:p.join(".")},f),(m=i[o])||(m=i[o]=[],m.delegateCount=0,l.setup&&l.setup.call(a,d,p,g)!==!1||a.addEventListener&&a.addEventListener(o,g,!1)),l.add&&(l.add.call(a,k),k.handler.guid||(k.handler.guid=c.guid)),e?m.splice(m.delegateCount++,0,k):m.push(k),n.event.global[o]=!0)}},remove:function(a,b,c,d,e){var f,g,h,i,j,k,l,m,o,p,q,r=L.hasData(a)&&L.get(a);if(r&&(i=r.events)){b=(b||"").match(E)||[""],j=b.length;while(j--)if(h=Y.exec(b[j])||[],o=q=h[1],p=(h[2]||"").split(".").sort(),o){l=n.event.special[o]||{},o=(d?l.delegateType:l.bindType)||o,m=i[o]||[],h=h[2]&&new RegExp("(^|\\.)"+p.join("\\.(?:.*\\.|)")+"(\\.|$)"),g=f=m.length;while(f--)k=m[f],!e&&q!==k.origType||c&&c.guid!==k.guid||h&&!h.test(k.namespace)||d&&d!==k.selector&&("**"!==d||!k.selector)||(m.splice(f,1),k.selector&&m.delegateCount--,l.remove&&l.remove.call(a,k));g&&!m.length&&(l.teardown&&l.teardown.call(a,p,r.handle)!==!1||n.removeEvent(a,o,r.handle),delete i[o])}else for(o in i)n.event.remove(a,o+b[j],c,d,!0);n.isEmptyObject(i)&&(delete r.handle,L.remove(a,"events"))}},trigger:function(b,c,d,e){var f,g,h,i,k,m,o,p=[d||l],q=j.call(b,"type")?b.type:b,r=j.call(b,"namespace")?b.namespace.split("."):[];if(g=h=d=d||l,3!==d.nodeType&&8!==d.nodeType&&!X.test(q+n.event.triggered)&&(q.indexOf(".")>=0&&(r=q.split("."),q=r.shift(),r.sort()),k=q.indexOf(":")<0&&"on"+q,b=b[n.expando]?b:new n.Event(q,"object"==typeof b&&b),b.isTrigger=e?2:3,b.namespace=r.join("."),b.namespace_re=b.namespace?new RegExp("(^|\\.)"+r.join("\\.(?:.*\\.|)")+"(\\.|$)"):null,b.result=void 0,b.target||(b.target=d),c=null==c?[b]:n.makeArray(c,[b]),o=n.event.special[q]||{},e||!o.trigger||o.trigger.apply(d,c)!==!1)){if(!e&&!o.noBubble&&!n.isWindow(d)){for(i=o.delegateType||q,X.test(i+q)||(g=g.parentNode);g;g=g.parentNode)p.push(g),h=g;h===(d.ownerDocument||l)&&p.push(h.defaultView||h.parentWindow||a)}f=0;while((g=p[f++])&&!b.isPropagationStopped())b.type=f>1?i:o.bindType||q,m=(L.get(g,"events")||{})[b.type]&&L.get(g,"handle"),m&&m.apply(g,c),m=k&&g[k],m&&m.apply&&n.acceptData(g)&&(b.result=m.apply(g,c),b.result===!1&&b.preventDefault());return b.type=q,e||b.isDefaultPrevented()||o._default&&o._default.apply(p.pop(),c)!==!1||!n.acceptData(d)||k&&n.isFunction(d[q])&&!n.isWindow(d)&&(h=d[k],h&&(d[k]=null),n.event.triggered=q,d[q](),n.event.triggered=void 0,h&&(d[k]=h)),b.result}},dispatch:function(a){a=n.event.fix(a);var b,c,e,f,g,h=[],i=d.call(arguments),j=(L.get(this,"events")||{})[a.type]||[],k=n.event.special[a.type]||{};if(i[0]=a,a.delegateTarget=this,!k.preDispatch||k.preDispatch.call(this,a)!==!1){h=n.event.handlers.call(this,a,j),b=0;while((f=h[b++])&&!a.isPropagationStopped()){a.currentTarget=f.elem,c=0;while((g=f.handlers[c++])&&!a.isImmediatePropagationStopped())(!a.namespace_re||a.namespace_re.test(g.namespace))&&(a.handleObj=g,a.data=g.data,e=((n.event.special[g.origType]||{}).handle||g.handler).apply(f.elem,i),void 0!==e&&(a.result=e)===!1&&(a.preventDefault(),a.stopPropagation()))}return k.postDispatch&&k.postDispatch.call(this,a),a.result}},handlers:function(a,b){var c,d,e,f,g=[],h=b.delegateCount,i=a.target;if(h&&i.nodeType&&(!a.button||"click"!==a.type))for(;i!==this;i=i.parentNode||this)if(i.disabled!==!0||"click"!==a.type){for(d=[],c=0;h>c;c++)f=b[c],e=f.selector+" ",void 0===d[e]&&(d[e]=f.needsContext?n(e,this).index(i)>=0:n.find(e,this,null,[i]).length),d[e]&&d.push(f);d.length&&g.push({elem:i,handlers:d})}return h<b.length&&g.push({elem:this,handlers:b.slice(h)}),g},props:"altKey bubbles cancelable ctrlKey currentTarget eventPhase metaKey relatedTarget shiftKey target timeStamp view which".split(" "),fixHooks:{},keyHooks:{props:"char charCode key keyCode".split(" "),filter:function(a,b){return null==a.which&&(a.which=null!=b.charCode?b.charCode:b.keyCode),a}},mouseHooks:{props:"button buttons clientX clientY offsetX offsetY pageX pageY screenX screenY toElement".split(" "),filter:function(a,b){var c,d,e,f=b.button;return null==a.pageX&&null!=b.clientX&&(c=a.target.ownerDocument||l,d=c.documentElement,e=c.body,a.pageX=b.clientX+(d&&d.scrollLeft||e&&e.scrollLeft||0)-(d&&d.clientLeft||e&&e.clientLeft||0),a.pageY=b.clientY+(d&&d.scrollTop||e&&e.scrollTop||0)-(d&&d.clientTop||e&&e.clientTop||0)),a.which||void 0===f||(a.which=1&f?1:2&f?3:4&f?2:0),a}},fix:function(a){if(a[n.expando])return a;var b,c,d,e=a.type,f=a,g=this.fixHooks[e];g||(this.fixHooks[e]=g=W.test(e)?this.mouseHooks:V.test(e)?this.keyHooks:{}),d=g.props?this.props.concat(g.props):this.props,a=new n.Event(f),b=d.length;while(b--)c=d[b],a[c]=f[c];return a.target||(a.target=l),3===a.target.nodeType&&(a.target=a.target.parentNode),g.filter?g.filter(a,f):a},special:{load:{noBubble:!0},focus:{trigger:function(){return this!==_()&&this.focus?(this.focus(),!1):void 0},delegateType:"focusin"},blur:{trigger:function(){return this===_()&&this.blur?(this.blur(),!1):void 0},delegateType:"focusout"},click:{trigger:function(){return"checkbox"===this.type&&this.click&&n.nodeName(this,"input")?(this.click(),!1):void 0},_default:function(a){return n.nodeName(a.target,"a")}},beforeunload:{postDispatch:function(a){void 0!==a.result&&a.originalEvent&&(a.originalEvent.returnValue=a.result)}}},simulate:function(a,b,c,d){var e=n.extend(new n.Event,c,{type:a,isSimulated:!0,originalEvent:{}});d?n.event.trigger(e,null,b):n.event.dispatch.call(b,e),e.isDefaultPrevented()&&c.preventDefault()}},n.removeEvent=function(a,b,c){a.removeEventListener&&a.removeEventListener(b,c,!1)},n.Event=function(a,b){return this instanceof n.Event?(a&&a.type?(this.originalEvent=a,this.type=a.type,this.isDefaultPrevented=a.defaultPrevented||void 0===a.defaultPrevented&&a.returnValue===!1?Z:$):this.type=a,b&&n.extend(this,b),this.timeStamp=a&&a.timeStamp||n.now(),void(this[n.expando]=!0)):new n.Event(a,b)},n.Event.prototype={isDefaultPrevented:$,isPropagationStopped:$,isImmediatePropagationStopped:$,preventDefault:function(){var a=this.originalEvent;this.isDefaultPrevented=Z,a&&a.preventDefault&&a.preventDefault()},stopPropagation:function(){var a=this.originalEvent;this.isPropagationStopped=Z,a&&a.stopPropagation&&a.stopPropagation()},stopImmediatePropagation:function(){var a=this.originalEvent;this.isImmediatePropagationStopped=Z,a&&a.stopImmediatePropagation&&a.stopImmediatePropagation(),this.stopPropagation()}},n.each({mouseenter:"mouseover",mouseleave:"mouseout",pointerenter:"pointerover",pointerleave:"pointerout"},function(a,b){n.event.special[a]={delegateType:b,bindType:b,handle:function(a){var c,d=this,e=a.relatedTarget,f=a.handleObj;return(!e||e!==d&&!n.contains(d,e))&&(a.type=f.origType,c=f.handler.apply(this,arguments),a.type=b),c}}}),k.focusinBubbles||n.each({focus:"focusin",blur:"focusout"},function(a,b){var c=function(a){n.event.simulate(b,a.target,n.event.fix(a),!0)};n.event.special[b]={setup:function(){var d=this.ownerDocument||this,e=L.access(d,b);e||d.addEventListener(a,c,!0),L.access(d,b,(e||0)+1)},teardown:function(){var d=this.ownerDocument||this,e=L.access(d,b)-1;e?L.access(d,b,e):(d.removeEventListener(a,c,!0),L.remove(d,b))}}}),n.fn.extend({on:function(a,b,c,d,e){var f,g;if("object"==typeof a){"string"!=typeof b&&(c=c||b,b=void 0);for(g in a)this.on(g,b,c,a[g],e);return this}if(null==c&&null==d?(d=b,c=b=void 0):null==d&&("string"==typeof b?(d=c,c=void 0):(d=c,c=b,b=void 0)),d===!1)d=$;else if(!d)return this;return 1===e&&(f=d,d=function(a){return n().off(a),f.apply(this,arguments)},d.guid=f.guid||(f.guid=n.guid++)),this.each(function(){n.event.add(this,a,d,c,b)})},one:function(a,b,c,d){return this.on(a,b,c,d,1)},off:function(a,b,c){var d,e;if(a&&a.preventDefault&&a.handleObj)return d=a.handleObj,n(a.delegateTarget).off(d.namespace?d.origType+"."+d.namespace:d.origType,d.selector,d.handler),this;if("object"==typeof a){for(e in a)this.off(e,b,a[e]);return this}return(b===!1||"function"==typeof b)&&(c=b,b=void 0),c===!1&&(c=$),this.each(function(){n.event.remove(this,a,c,b)})},trigger:function(a,b){return this.each(function(){n.event.trigger(a,b,this)})},triggerHandler:function(a,b){var c=this[0];return c?n.event.trigger(a,b,c,!0):void 0}});var aa=/<(?!area|br|col|embed|hr|img|input|link|meta|param)(([\w:]+)[^>]*)\/>/gi,ba=/<([\w:]+)/,ca=/<|&#?\w+;/,da=/<(?:script|style|link)/i,ea=/checked\s*(?:[^=]|=\s*.checked.)/i,fa=/^$|\/(?:java|ecma)script/i,ga=/^true\/(.*)/,ha=/^\s*<!(?:\[CDATA\[|--)|(?:\]\]|--)>\s*$/g,ia={option:[1,"<select multiple='multiple'>","</select>"],thead:[1,"<table>","</table>"],col:[2,"<table><colgroup>","</colgroup></table>"],tr:[2,"<table><tbody>","</tbody></table>"],td:[3,"<table><tbody><tr>","</tr></tbody></table>"],_default:[0,"",""]};ia.optgroup=ia.option,ia.tbody=ia.tfoot=ia.colgroup=ia.caption=ia.thead,ia.th=ia.td;function ja(a,b){return n.nodeName(a,"table")&&n.nodeName(11!==b.nodeType?b:b.firstChild,"tr")?a.getElementsByTagName("tbody")[0]||a.appendChild(a.ownerDocument.createElement("tbody")):a}function ka(a){return a.type=(null!==a.getAttribute("type"))+"/"+a.type,a}function la(a){var b=ga.exec(a.type);return b?a.type=b[1]:a.removeAttribute("type"),a}function ma(a,b){for(var c=0,d=a.length;d>c;c++)L.set(a[c],"globalEval",!b||L.get(b[c],"globalEval"))}function na(a,b){var c,d,e,f,g,h,i,j;if(1===b.nodeType){if(L.hasData(a)&&(f=L.access(a),g=L.set(b,f),j=f.events)){delete g.handle,g.events={};for(e in j)for(c=0,d=j[e].length;d>c;c++)n.event.add(b,e,j[e][c])}M.hasData(a)&&(h=M.access(a),i=n.extend({},h),M.set(b,i))}}function oa(a,b){var c=a.getElementsByTagName?a.getElementsByTagName(b||"*"):a.querySelectorAll?a.querySelectorAll(b||"*"):[];return void 0===b||b&&n.nodeName(a,b)?n.merge([a],c):c}function pa(a,b){var c=b.nodeName.toLowerCase();"input"===c&&T.test(a.type)?b.checked=a.checked:("input"===c||"textarea"===c)&&(b.defaultValue=a.defaultValue)}n.extend({clone:function(a,b,c){var d,e,f,g,h=a.cloneNode(!0),i=n.contains(a.ownerDocument,a);if(!(k.noCloneChecked||1!==a.nodeType&&11!==a.nodeType||n.isXMLDoc(a)))for(g=oa(h),f=oa(a),d=0,e=f.length;e>d;d++)pa(f[d],g[d]);if(b)if(c)for(f=f||oa(a),g=g||oa(h),d=0,e=f.length;e>d;d++)na(f[d],g[d]);else na(a,h);return g=oa(h,"script"),g.length>0&&ma(g,!i&&oa(a,"script")),h},buildFragment:function(a,b,c,d){for(var e,f,g,h,i,j,k=b.createDocumentFragment(),l=[],m=0,o=a.length;o>m;m++)if(e=a[m],e||0===e)if("object"===n.type(e))n.merge(l,e.nodeType?[e]:e);else if(ca.test(e)){f=f||k.appendChild(b.createElement("div")),g=(ba.exec(e)||["",""])[1].toLowerCase(),h=ia[g]||ia._default,f.innerHTML=h[1]+e.replace(aa,"<$1></$2>")+h[2],j=h[0];while(j--)f=f.lastChild;n.merge(l,f.childNodes),f=k.firstChild,f.textContent=""}else l.push(b.createTextNode(e));k.textContent="",m=0;while(e=l[m++])if((!d||-1===n.inArray(e,d))&&(i=n.contains(e.ownerDocument,e),f=oa(k.appendChild(e),"script"),i&&ma(f),c)){j=0;while(e=f[j++])fa.test(e.type||"")&&c.push(e)}return k},cleanData:function(a){for(var b,c,d,e,f=n.event.special,g=0;void 0!==(c=a[g]);g++){if(n.acceptData(c)&&(e=c[L.expando],e&&(b=L.cache[e]))){if(b.events)for(d in b.events)f[d]?n.event.remove(c,d):n.removeEvent(c,d,b.handle);L.cache[e]&&delete L.cache[e]}delete M.cache[c[M.expando]]}}}),n.fn.extend({text:function(a){return J(this,function(a){return void 0===a?n.text(this):this.empty().each(function(){(1===this.nodeType||11===this.nodeType||9===this.nodeType)&&(this.textContent=a)})},null,a,arguments.length)},append:function(){return this.domManip(arguments,function(a){if(1===this.nodeType||11===this.nodeType||9===this.nodeType){var b=ja(this,a);b.appendChild(a)}})},prepend:function(){return this.domManip(arguments,function(a){if(1===this.nodeType||11===this.nodeType||9===this.nodeType){var b=ja(this,a);b.insertBefore(a,b.firstChild)}})},before:function(){return this.domManip(arguments,function(a){this.parentNode&&this.parentNode.insertBefore(a,this)})},after:function(){return this.domManip(arguments,function(a){this.parentNode&&this.parentNode.insertBefore(a,this.nextSibling)})},remove:function(a,b){for(var c,d=a?n.filter(a,this):this,e=0;null!=(c=d[e]);e++)b||1!==c.nodeType||n.cleanData(oa(c)),c.parentNode&&(b&&n.contains(c.ownerDocument,c)&&ma(oa(c,"script")),c.parentNode.removeChild(c));return this},empty:function(){for(var a,b=0;null!=(a=this[b]);b++)1===a.nodeType&&(n.cleanData(oa(a,!1)),a.textContent="");return this},clone:function(a,b){return a=null==a?!1:a,b=null==b?a:b,this.map(function(){return n.clone(this,a,b)})},html:function(a){return J(this,function(a){var b=this[0]||{},c=0,d=this.length;if(void 0===a&&1===b.nodeType)return b.innerHTML;if("string"==typeof a&&!da.test(a)&&!ia[(ba.exec(a)||["",""])[1].toLowerCase()]){a=a.replace(aa,"<$1></$2>");try{for(;d>c;c++)b=this[c]||{},1===b.nodeType&&(n.cleanData(oa(b,!1)),b.innerHTML=a);b=0}catch(e){}}b&&this.empty().append(a)},null,a,arguments.length)},replaceWith:function(){var a=arguments[0];return this.domManip(arguments,function(b){a=this.parentNode,n.cleanData(oa(this)),a&&a.replaceChild(b,this)}),a&&(a.length||a.nodeType)?this:this.remove()},detach:function(a){return this.remove(a,!0)},domManip:function(a,b){a=e.apply([],a);var c,d,f,g,h,i,j=0,l=this.length,m=this,o=l-1,p=a[0],q=n.isFunction(p);if(q||l>1&&"string"==typeof p&&!k.checkClone&&ea.test(p))return this.each(function(c){var d=m.eq(c);q&&(a[0]=p.call(this,c,d.html())),d.domManip(a,b)});if(l&&(c=n.buildFragment(a,this[0].ownerDocument,!1,this),d=c.firstChild,1===c.childNodes.length&&(c=d),d)){for(f=n.map(oa(c,"script"),ka),g=f.length;l>j;j++)h=c,j!==o&&(h=n.clone(h,!0,!0),g&&n.merge(f,oa(h,"script"))),b.call(this[j],h,j);if(g)for(i=f[f.length-1].ownerDocument,n.map(f,la),j=0;g>j;j++)h=f[j],fa.test(h.type||"")&&!L.access(h,"globalEval")&&n.contains(i,h)&&(h.src?n._evalUrl&&n._evalUrl(h.src):n.globalEval(h.textContent.replace(ha,"")))}return this}}),n.each({appendTo:"append",prependTo:"prepend",insertBefore:"before",insertAfter:"after",replaceAll:"replaceWith"},function(a,b){n.fn[a]=function(a){for(var c,d=[],e=n(a),g=e.length-1,h=0;g>=h;h++)c=h===g?this:this.clone(!0),n(e[h])[b](c),f.apply(d,c.get());return this.pushStack(d)}});var qa,ra={};function sa(b,c){var d,e=n(c.createElement(b)).appendTo(c.body),f=a.getDefaultComputedStyle&&(d=a.getDefaultComputedStyle(e[0]))?d.display:n.css(e[0],"display");return e.detach(),f}function ta(a){var b=l,c=ra[a];return c||(c=sa(a,b),"none"!==c&&c||(qa=(qa||n("<iframe frameborder='0' width='0' height='0'/>")).appendTo(b.documentElement),b=qa[0].contentDocument,b.write(),b.close(),c=sa(a,b),qa.detach()),ra[a]=c),c}var ua=/^margin/,va=new RegExp("^("+Q+")(?!px)[a-z%]+$","i"),wa=function(b){return b.ownerDocument.defaultView.opener?b.ownerDocument.defaultView.getComputedStyle(b,null):a.getComputedStyle(b,null)};function xa(a,b,c){var d,e,f,g,h=a.style;return c=c||wa(a),c&&(g=c.getPropertyValue(b)||c[b]),c&&(""!==g||n.contains(a.ownerDocument,a)||(g=n.style(a,b)),va.test(g)&&ua.test(b)&&(d=h.width,e=h.minWidth,f=h.maxWidth,h.minWidth=h.maxWidth=h.width=g,g=c.width,h.width=d,h.minWidth=e,h.maxWidth=f)),void 0!==g?g+"":g}function ya(a,b){return{get:function(){return a()?void delete this.get:(this.get=b).apply(this,arguments)}}}!function(){var b,c,d=l.documentElement,e=l.createElement("div"),f=l.createElement("div");if(f.style){f.style.backgroundClip="content-box",f.cloneNode(!0).style.backgroundClip="",k.clearCloneStyle="content-box"===f.style.backgroundClip,e.style.cssText="border:0;width:0;height:0;top:0;left:-9999px;margin-top:1px;position:absolute",e.appendChild(f);function g(){f.style.cssText="-webkit-box-sizing:border-box;-moz-box-sizing:border-box;box-sizing:border-box;display:block;margin-top:1%;top:1%;border:1px;padding:1px;width:4px;position:absolute",f.innerHTML="",d.appendChild(e);var g=a.getComputedStyle(f,null);b="1%"!==g.top,c="4px"===g.width,d.removeChild(e)}a.getComputedStyle&&n.extend(k,{pixelPosition:function(){return g(),b},boxSizingReliable:function(){return null==c&&g(),c},reliableMarginRight:function(){var b,c=f.appendChild(l.createElement("div"));return c.style.cssText=f.style.cssText="-webkit-box-sizing:content-box;-moz-box-sizing:content-box;box-sizing:content-box;display:block;margin:0;border:0;padding:0",c.style.marginRight=c.style.width="0",f.style.width="1px",d.appendChild(e),b=!parseFloat(a.getComputedStyle(c,null).marginRight),d.removeChild(e),f.removeChild(c),b}})}}(),n.swap=function(a,b,c,d){var e,f,g={};for(f in b)g[f]=a.style[f],a.style[f]=b[f];e=c.apply(a,d||[]);for(f in b)a.style[f]=g[f];return e};var za=/^(none|table(?!-c[ea]).+)/,Aa=new RegExp("^("+Q+")(.*)$","i"),Ba=new RegExp("^([+-])=("+Q+")","i"),Ca={position:"absolute",visibility:"hidden",display:"block"},Da={letterSpacing:"0",fontWeight:"400"},Ea=["Webkit","O","Moz","ms"];function Fa(a,b){if(b in a)return b;var c=b[0].toUpperCase()+b.slice(1),d=b,e=Ea.length;while(e--)if(b=Ea[e]+c,b in a)return b;return d}function Ga(a,b,c){var d=Aa.exec(b);return d?Math.max(0,d[1]-(c||0))+(d[2]||"px"):b}function Ha(a,b,c,d,e){for(var f=c===(d?"border":"content")?4:"width"===b?1:0,g=0;4>f;f+=2)"margin"===c&&(g+=n.css(a,c+R[f],!0,e)),d?("content"===c&&(g-=n.css(a,"padding"+R[f],!0,e)),"margin"!==c&&(g-=n.css(a,"border"+R[f]+"Width",!0,e))):(g+=n.css(a,"padding"+R[f],!0,e),"padding"!==c&&(g+=n.css(a,"border"+R[f]+"Width",!0,e)));return g}function Ia(a,b,c){var d=!0,e="width"===b?a.offsetWidth:a.offsetHeight,f=wa(a),g="border-box"===n.css(a,"boxSizing",!1,f);if(0>=e||null==e){if(e=xa(a,b,f),(0>e||null==e)&&(e=a.style[b]),va.test(e))return e;d=g&&(k.boxSizingReliable()||e===a.style[b]),e=parseFloat(e)||0}return e+Ha(a,b,c||(g?"border":"content"),d,f)+"px"}function Ja(a,b){for(var c,d,e,f=[],g=0,h=a.length;h>g;g++)d=a[g],d.style&&(f[g]=L.get(d,"olddisplay"),c=d.style.display,b?(f[g]||"none"!==c||(d.style.display=""),""===d.style.display&&S(d)&&(f[g]=L.access(d,"olddisplay",ta(d.nodeName)))):(e=S(d),"none"===c&&e||L.set(d,"olddisplay",e?c:n.css(d,"display"))));for(g=0;h>g;g++)d=a[g],d.style&&(b&&"none"!==d.style.display&&""!==d.style.display||(d.style.display=b?f[g]||"":"none"));return a}n.extend({cssHooks:{opacity:{get:function(a,b){if(b){var c=xa(a,"opacity");return""===c?"1":c}}}},cssNumber:{columnCount:!0,fillOpacity:!0,flexGrow:!0,flexShrink:!0,fontWeight:!0,lineHeight:!0,opacity:!0,order:!0,orphans:!0,widows:!0,zIndex:!0,zoom:!0},cssProps:{"float":"cssFloat"},style:function(a,b,c,d){if(a&&3!==a.nodeType&&8!==a.nodeType&&a.style){var e,f,g,h=n.camelCase(b),i=a.style;return b=n.cssProps[h]||(n.cssProps[h]=Fa(i,h)),g=n.cssHooks[b]||n.cssHooks[h],void 0===c?g&&"get"in g&&void 0!==(e=g.get(a,!1,d))?e:i[b]:(f=typeof c,"string"===f&&(e=Ba.exec(c))&&(c=(e[1]+1)*e[2]+parseFloat(n.css(a,b)),f="number"),null!=c&&c===c&&("number"!==f||n.cssNumber[h]||(c+="px"),k.clearCloneStyle||""!==c||0!==b.indexOf("background")||(i[b]="inherit"),g&&"set"in g&&void 0===(c=g.set(a,c,d))||(i[b]=c)),void 0)}},css:function(a,b,c,d){var e,f,g,h=n.camelCase(b);return b=n.cssProps[h]||(n.cssProps[h]=Fa(a.style,h)),g=n.cssHooks[b]||n.cssHooks[h],g&&"get"in g&&(e=g.get(a,!0,c)),void 0===e&&(e=xa(a,b,d)),"normal"===e&&b in Da&&(e=Da[b]),""===c||c?(f=parseFloat(e),c===!0||n.isNumeric(f)?f||0:e):e}}),n.each(["height","width"],function(a,b){n.cssHooks[b]={get:function(a,c,d){return c?za.test(n.css(a,"display"))&&0===a.offsetWidth?n.swap(a,Ca,function(){return Ia(a,b,d)}):Ia(a,b,d):void 0},set:function(a,c,d){var e=d&&wa(a);return Ga(a,c,d?Ha(a,b,d,"border-box"===n.css(a,"boxSizing",!1,e),e):0)}}}),n.cssHooks.marginRight=ya(k.reliableMarginRight,function(a,b){return b?n.swap(a,{display:"inline-block"},xa,[a,"marginRight"]):void 0}),n.each({margin:"",padding:"",border:"Width"},function(a,b){n.cssHooks[a+b]={expand:function(c){for(var d=0,e={},f="string"==typeof c?c.split(" "):[c];4>d;d++)e[a+R[d]+b]=f[d]||f[d-2]||f[0];return e}},ua.test(a)||(n.cssHooks[a+b].set=Ga)}),n.fn.extend({css:function(a,b){return J(this,function(a,b,c){var d,e,f={},g=0;if(n.isArray(b)){for(d=wa(a),e=b.length;e>g;g++)f[b[g]]=n.css(a,b[g],!1,d);return f}return void 0!==c?n.style(a,b,c):n.css(a,b)},a,b,arguments.length>1)},show:function(){return Ja(this,!0)},hide:function(){return Ja(this)},toggle:function(a){return"boolean"==typeof a?a?this.show():this.hide():this.each(function(){S(this)?n(this).show():n(this).hide()})}});function Ka(a,b,c,d,e){return new Ka.prototype.init(a,b,c,d,e)}n.Tween=Ka,Ka.prototype={constructor:Ka,init:function(a,b,c,d,e,f){this.elem=a,this.prop=c,this.easing=e||"swing",this.options=b,this.start=this.now=this.cur(),this.end=d,this.unit=f||(n.cssNumber[c]?"":"px")},cur:function(){var a=Ka.propHooks[this.prop];return a&&a.get?a.get(this):Ka.propHooks._default.get(this)},run:function(a){var b,c=Ka.propHooks[this.prop];return this.options.duration?this.pos=b=n.easing[this.easing](a,this.options.duration*a,0,1,this.options.duration):this.pos=b=a,this.now=(this.end-this.start)*b+this.start,this.options.step&&this.options.step.call(this.elem,this.now,this),c&&c.set?c.set(this):Ka.propHooks._default.set(this),this}},Ka.prototype.init.prototype=Ka.prototype,Ka.propHooks={_default:{get:function(a){var b;return null==a.elem[a.prop]||a.elem.style&&null!=a.elem.style[a.prop]?(b=n.css(a.elem,a.prop,""),b&&"auto"!==b?b:0):a.elem[a.prop]},set:function(a){n.fx.step[a.prop]?n.fx.step[a.prop](a):a.elem.style&&(null!=a.elem.style[n.cssProps[a.prop]]||n.cssHooks[a.prop])?n.style(a.elem,a.prop,a.now+a.unit):a.elem[a.prop]=a.now}}},Ka.propHooks.scrollTop=Ka.propHooks.scrollLeft={set:function(a){a.elem.nodeType&&a.elem.parentNode&&(a.elem[a.prop]=a.now)}},n.easing={linear:function(a){return a},swing:function(a){return.5-Math.cos(a*Math.PI)/2}},n.fx=Ka.prototype.init,n.fx.step={};var La,Ma,Na=/^(?:toggle|show|hide)$/,Oa=new RegExp("^(?:([+-])=|)("+Q+")([a-z%]*)$","i"),Pa=/queueHooks$/,Qa=[Va],Ra={"*":[function(a,b){var c=this.createTween(a,b),d=c.cur(),e=Oa.exec(b),f=e&&e[3]||(n.cssNumber[a]?"":"px"),g=(n.cssNumber[a]||"px"!==f&&+d)&&Oa.exec(n.css(c.elem,a)),h=1,i=20;if(g&&g[3]!==f){f=f||g[3],e=e||[],g=+d||1;do h=h||".5",g/=h,n.style(c.elem,a,g+f);while(h!==(h=c.cur()/d)&&1!==h&&--i)}return e&&(g=c.start=+g||+d||0,c.unit=f,c.end=e[1]?g+(e[1]+1)*e[2]:+e[2]),c}]};function Sa(){return setTimeout(function(){La=void 0}),La=n.now()}function Ta(a,b){var c,d=0,e={height:a};for(b=b?1:0;4>d;d+=2-b)c=R[d],e["margin"+c]=e["padding"+c]=a;return b&&(e.opacity=e.width=a),e}function Ua(a,b,c){for(var d,e=(Ra[b]||[]).concat(Ra["*"]),f=0,g=e.length;g>f;f++)if(d=e[f].call(c,b,a))return d}function Va(a,b,c){var d,e,f,g,h,i,j,k,l=this,m={},o=a.style,p=a.nodeType&&S(a),q=L.get(a,"fxshow");c.queue||(h=n._queueHooks(a,"fx"),null==h.unqueued&&(h.unqueued=0,i=h.empty.fire,h.empty.fire=function(){h.unqueued||i()}),h.unqueued++,l.always(function(){l.always(function(){h.unqueued--,n.queue(a,"fx").length||h.empty.fire()})})),1===a.nodeType&&("height"in b||"width"in b)&&(c.overflow=[o.overflow,o.overflowX,o.overflowY],j=n.css(a,"display"),k="none"===j?L.get(a,"olddisplay")||ta(a.nodeName):j,"inline"===k&&"none"===n.css(a,"float")&&(o.display="inline-block")),c.overflow&&(o.overflow="hidden",l.always(function(){o.overflow=c.overflow[0],o.overflowX=c.overflow[1],o.overflowY=c.overflow[2]}));for(d in b)if(e=b[d],Na.exec(e)){if(delete b[d],f=f||"toggle"===e,e===(p?"hide":"show")){if("show"!==e||!q||void 0===q[d])continue;p=!0}m[d]=q&&q[d]||n.style(a,d)}else j=void 0;if(n.isEmptyObject(m))"inline"===("none"===j?ta(a.nodeName):j)&&(o.display=j);else{q?"hidden"in q&&(p=q.hidden):q=L.access(a,"fxshow",{}),f&&(q.hidden=!p),p?n(a).show():l.done(function(){n(a).hide()}),l.done(function(){var b;L.remove(a,"fxshow");for(b in m)n.style(a,b,m[b])});for(d in m)g=Ua(p?q[d]:0,d,l),d in q||(q[d]=g.start,p&&(g.end=g.start,g.start="width"===d||"height"===d?1:0))}}function Wa(a,b){var c,d,e,f,g;for(c in a)if(d=n.camelCase(c),e=b[d],f=a[c],n.isArray(f)&&(e=f[1],f=a[c]=f[0]),c!==d&&(a[d]=f,delete a[c]),g=n.cssHooks[d],g&&"expand"in g){f=g.expand(f),delete a[d];for(c in f)c in a||(a[c]=f[c],b[c]=e)}else b[d]=e}function Xa(a,b,c){var d,e,f=0,g=Qa.length,h=n.Deferred().always(function(){delete i.elem}),i=function(){if(e)return!1;for(var b=La||Sa(),c=Math.max(0,j.startTime+j.duration-b),d=c/j.duration||0,f=1-d,g=0,i=j.tweens.length;i>g;g++)j.tweens[g].run(f);return h.notifyWith(a,[j,f,c]),1>f&&i?c:(h.resolveWith(a,[j]),!1)},j=h.promise({elem:a,props:n.extend({},b),opts:n.extend(!0,{specialEasing:{}},c),originalProperties:b,originalOptions:c,startTime:La||Sa(),duration:c.duration,tweens:[],createTween:function(b,c){var d=n.Tween(a,j.opts,b,c,j.opts.specialEasing[b]||j.opts.easing);return j.tweens.push(d),d},stop:function(b){var c=0,d=b?j.tweens.length:0;if(e)return this;for(e=!0;d>c;c++)j.tweens[c].run(1);return b?h.resolveWith(a,[j,b]):h.rejectWith(a,[j,b]),this}}),k=j.props;for(Wa(k,j.opts.specialEasing);g>f;f++)if(d=Qa[f].call(j,a,k,j.opts))return d;return n.map(k,Ua,j),n.isFunction(j.opts.start)&&j.opts.start.call(a,j),n.fx.timer(n.extend(i,{elem:a,anim:j,queue:j.opts.queue})),j.progress(j.opts.progress).done(j.opts.done,j.opts.complete).fail(j.opts.fail).always(j.opts.always)}n.Animation=n.extend(Xa,{tweener:function(a,b){n.isFunction(a)?(b=a,a=["*"]):a=a.split(" ");for(var c,d=0,e=a.length;e>d;d++)c=a[d],Ra[c]=Ra[c]||[],Ra[c].unshift(b)},prefilter:function(a,b){b?Qa.unshift(a):Qa.push(a)}}),n.speed=function(a,b,c){var d=a&&"object"==typeof a?n.extend({},a):{complete:c||!c&&b||n.isFunction(a)&&a,duration:a,easing:c&&b||b&&!n.isFunction(b)&&b};return d.duration=n.fx.off?0:"number"==typeof d.duration?d.duration:d.duration in n.fx.speeds?n.fx.speeds[d.duration]:n.fx.speeds._default,(null==d.queue||d.queue===!0)&&(d.queue="fx"),d.old=d.complete,d.complete=function(){n.isFunction(d.old)&&d.old.call(this),d.queue&&n.dequeue(this,d.queue)},d},n.fn.extend({fadeTo:function(a,b,c,d){return this.filter(S).css("opacity",0).show().end().animate({opacity:b},a,c,d)},animate:function(a,b,c,d){var e=n.isEmptyObject(a),f=n.speed(b,c,d),g=function(){var b=Xa(this,n.extend({},a),f);(e||L.get(this,"finish"))&&b.stop(!0)};return g.finish=g,e||f.queue===!1?this.each(g):this.queue(f.queue,g)},stop:function(a,b,c){var d=function(a){var b=a.stop;delete a.stop,b(c)};return"string"!=typeof a&&(c=b,b=a,a=void 0),b&&a!==!1&&this.queue(a||"fx",[]),this.each(function(){var b=!0,e=null!=a&&a+"queueHooks",f=n.timers,g=L.get(this);if(e)g[e]&&g[e].stop&&d(g[e]);else for(e in g)g[e]&&g[e].stop&&Pa.test(e)&&d(g[e]);for(e=f.length;e--;)f[e].elem!==this||null!=a&&f[e].queue!==a||(f[e].anim.stop(c),b=!1,f.splice(e,1));(b||!c)&&n.dequeue(this,a)})},finish:function(a){return a!==!1&&(a=a||"fx"),this.each(function(){var b,c=L.get(this),d=c[a+"queue"],e=c[a+"queueHooks"],f=n.timers,g=d?d.length:0;for(c.finish=!0,n.queue(this,a,[]),e&&e.stop&&e.stop.call(this,!0),b=f.length;b--;)f[b].elem===this&&f[b].queue===a&&(f[b].anim.stop(!0),f.splice(b,1));for(b=0;g>b;b++)d[b]&&d[b].finish&&d[b].finish.call(this);delete c.finish})}}),n.each(["toggle","show","hide"],function(a,b){var c=n.fn[b];n.fn[b]=function(a,d,e){return null==a||"boolean"==typeof a?c.apply(this,arguments):this.animate(Ta(b,!0),a,d,e)}}),n.each({slideDown:Ta("show"),slideUp:Ta("hide"),slideToggle:Ta("toggle"),fadeIn:{opacity:"show"},fadeOut:{opacity:"hide"},fadeToggle:{opacity:"toggle"}},function(a,b){n.fn[a]=function(a,c,d){return this.animate(b,a,c,d)}}),n.timers=[],n.fx.tick=function(){var a,b=0,c=n.timers;for(La=n.now();b<c.length;b++)a=c[b],a()||c[b]!==a||c.splice(b--,1);c.length||n.fx.stop(),La=void 0},n.fx.timer=function(a){n.timers.push(a),a()?n.fx.start():n.timers.pop()},n.fx.interval=13,n.fx.start=function(){Ma||(Ma=setInterval(n.fx.tick,n.fx.interval))},n.fx.stop=function(){clearInterval(Ma),Ma=null},n.fx.speeds={slow:600,fast:200,_default:400},n.fn.delay=function(a,b){return a=n.fx?n.fx.speeds[a]||a:a,b=b||"fx",this.queue(b,function(b,c){var d=setTimeout(b,a);c.stop=function(){clearTimeout(d)}})},function(){var a=l.createElement("input"),b=l.createElement("select"),c=b.appendChild(l.createElement("option"));a.type="checkbox",k.checkOn=""!==a.value,k.optSelected=c.selected,b.disabled=!0,k.optDisabled=!c.disabled,a=l.createElement("input"),a.value="t",a.type="radio",k.radioValue="t"===a.value}();var Ya,Za,$a=n.expr.attrHandle;n.fn.extend({attr:function(a,b){return J(this,n.attr,a,b,arguments.length>1)},removeAttr:function(a){return this.each(function(){n.removeAttr(this,a)})}}),n.extend({attr:function(a,b,c){var d,e,f=a.nodeType;if(a&&3!==f&&8!==f&&2!==f)return typeof a.getAttribute===U?n.prop(a,b,c):(1===f&&n.isXMLDoc(a)||(b=b.toLowerCase(),d=n.attrHooks[b]||(n.expr.match.bool.test(b)?Za:Ya)),
+void 0===c?d&&"get"in d&&null!==(e=d.get(a,b))?e:(e=n.find.attr(a,b),null==e?void 0:e):null!==c?d&&"set"in d&&void 0!==(e=d.set(a,c,b))?e:(a.setAttribute(b,c+""),c):void n.removeAttr(a,b))},removeAttr:function(a,b){var c,d,e=0,f=b&&b.match(E);if(f&&1===a.nodeType)while(c=f[e++])d=n.propFix[c]||c,n.expr.match.bool.test(c)&&(a[d]=!1),a.removeAttribute(c)},attrHooks:{type:{set:function(a,b){if(!k.radioValue&&"radio"===b&&n.nodeName(a,"input")){var c=a.value;return a.setAttribute("type",b),c&&(a.value=c),b}}}}}),Za={set:function(a,b,c){return b===!1?n.removeAttr(a,c):a.setAttribute(c,c),c}},n.each(n.expr.match.bool.source.match(/\w+/g),function(a,b){var c=$a[b]||n.find.attr;$a[b]=function(a,b,d){var e,f;return d||(f=$a[b],$a[b]=e,e=null!=c(a,b,d)?b.toLowerCase():null,$a[b]=f),e}});var _a=/^(?:input|select|textarea|button)$/i;n.fn.extend({prop:function(a,b){return J(this,n.prop,a,b,arguments.length>1)},removeProp:function(a){return this.each(function(){delete this[n.propFix[a]||a]})}}),n.extend({propFix:{"for":"htmlFor","class":"className"},prop:function(a,b,c){var d,e,f,g=a.nodeType;if(a&&3!==g&&8!==g&&2!==g)return f=1!==g||!n.isXMLDoc(a),f&&(b=n.propFix[b]||b,e=n.propHooks[b]),void 0!==c?e&&"set"in e&&void 0!==(d=e.set(a,c,b))?d:a[b]=c:e&&"get"in e&&null!==(d=e.get(a,b))?d:a[b]},propHooks:{tabIndex:{get:function(a){return a.hasAttribute("tabindex")||_a.test(a.nodeName)||a.href?a.tabIndex:-1}}}}),k.optSelected||(n.propHooks.selected={get:function(a){var b=a.parentNode;return b&&b.parentNode&&b.parentNode.selectedIndex,null}}),n.each(["tabIndex","readOnly","maxLength","cellSpacing","cellPadding","rowSpan","colSpan","useMap","frameBorder","contentEditable"],function(){n.propFix[this.toLowerCase()]=this});var ab=/[\t\r\n\f]/g;n.fn.extend({addClass:function(a){var b,c,d,e,f,g,h="string"==typeof a&&a,i=0,j=this.length;if(n.isFunction(a))return this.each(function(b){n(this).addClass(a.call(this,b,this.className))});if(h)for(b=(a||"").match(E)||[];j>i;i++)if(c=this[i],d=1===c.nodeType&&(c.className?(" "+c.className+" ").replace(ab," "):" ")){f=0;while(e=b[f++])d.indexOf(" "+e+" ")<0&&(d+=e+" ");g=n.trim(d),c.className!==g&&(c.className=g)}return this},removeClass:function(a){var b,c,d,e,f,g,h=0===arguments.length||"string"==typeof a&&a,i=0,j=this.length;if(n.isFunction(a))return this.each(function(b){n(this).removeClass(a.call(this,b,this.className))});if(h)for(b=(a||"").match(E)||[];j>i;i++)if(c=this[i],d=1===c.nodeType&&(c.className?(" "+c.className+" ").replace(ab," "):"")){f=0;while(e=b[f++])while(d.indexOf(" "+e+" ")>=0)d=d.replace(" "+e+" "," ");g=a?n.trim(d):"",c.className!==g&&(c.className=g)}return this},toggleClass:function(a,b){var c=typeof a;return"boolean"==typeof b&&"string"===c?b?this.addClass(a):this.removeClass(a):this.each(n.isFunction(a)?function(c){n(this).toggleClass(a.call(this,c,this.className,b),b)}:function(){if("string"===c){var b,d=0,e=n(this),f=a.match(E)||[];while(b=f[d++])e.hasClass(b)?e.removeClass(b):e.addClass(b)}else(c===U||"boolean"===c)&&(this.className&&L.set(this,"__className__",this.className),this.className=this.className||a===!1?"":L.get(this,"__className__")||"")})},hasClass:function(a){for(var b=" "+a+" ",c=0,d=this.length;d>c;c++)if(1===this[c].nodeType&&(" "+this[c].className+" ").replace(ab," ").indexOf(b)>=0)return!0;return!1}});var bb=/\r/g;n.fn.extend({val:function(a){var b,c,d,e=this[0];{if(arguments.length)return d=n.isFunction(a),this.each(function(c){var e;1===this.nodeType&&(e=d?a.call(this,c,n(this).val()):a,null==e?e="":"number"==typeof e?e+="":n.isArray(e)&&(e=n.map(e,function(a){return null==a?"":a+""})),b=n.valHooks[this.type]||n.valHooks[this.nodeName.toLowerCase()],b&&"set"in b&&void 0!==b.set(this,e,"value")||(this.value=e))});if(e)return b=n.valHooks[e.type]||n.valHooks[e.nodeName.toLowerCase()],b&&"get"in b&&void 0!==(c=b.get(e,"value"))?c:(c=e.value,"string"==typeof c?c.replace(bb,""):null==c?"":c)}}}),n.extend({valHooks:{option:{get:function(a){var b=n.find.attr(a,"value");return null!=b?b:n.trim(n.text(a))}},select:{get:function(a){for(var b,c,d=a.options,e=a.selectedIndex,f="select-one"===a.type||0>e,g=f?null:[],h=f?e+1:d.length,i=0>e?h:f?e:0;h>i;i++)if(c=d[i],!(!c.selected&&i!==e||(k.optDisabled?c.disabled:null!==c.getAttribute("disabled"))||c.parentNode.disabled&&n.nodeName(c.parentNode,"optgroup"))){if(b=n(c).val(),f)return b;g.push(b)}return g},set:function(a,b){var c,d,e=a.options,f=n.makeArray(b),g=e.length;while(g--)d=e[g],(d.selected=n.inArray(d.value,f)>=0)&&(c=!0);return c||(a.selectedIndex=-1),f}}}}),n.each(["radio","checkbox"],function(){n.valHooks[this]={set:function(a,b){return n.isArray(b)?a.checked=n.inArray(n(a).val(),b)>=0:void 0}},k.checkOn||(n.valHooks[this].get=function(a){return null===a.getAttribute("value")?"on":a.value})}),n.each("blur focus focusin focusout load resize scroll unload click dblclick mousedown mouseup mousemove mouseover mouseout mouseenter mouseleave change select submit keydown keypress keyup error contextmenu".split(" "),function(a,b){n.fn[b]=function(a,c){return arguments.length>0?this.on(b,null,a,c):this.trigger(b)}}),n.fn.extend({hover:function(a,b){return this.mouseenter(a).mouseleave(b||a)},bind:function(a,b,c){return this.on(a,null,b,c)},unbind:function(a,b){return this.off(a,null,b)},delegate:function(a,b,c,d){return this.on(b,a,c,d)},undelegate:function(a,b,c){return 1===arguments.length?this.off(a,"**"):this.off(b,a||"**",c)}});var cb=n.now(),db=/\?/;n.parseJSON=function(a){return JSON.parse(a+"")},n.parseXML=function(a){var b,c;if(!a||"string"!=typeof a)return null;try{c=new DOMParser,b=c.parseFromString(a,"text/xml")}catch(d){b=void 0}return(!b||b.getElementsByTagName("parsererror").length)&&n.error("Invalid XML: "+a),b};var eb=/#.*$/,fb=/([?&])_=[^&]*/,gb=/^(.*?):[ \t]*([^\r\n]*)$/gm,hb=/^(?:about|app|app-storage|.+-extension|file|res|widget):$/,ib=/^(?:GET|HEAD)$/,jb=/^\/\//,kb=/^([\w.+-]+:)(?:\/\/(?:[^\/?#]*@|)([^\/?#:]*)(?::(\d+)|)|)/,lb={},mb={},nb="*/".concat("*"),ob=a.location.href,pb=kb.exec(ob.toLowerCase())||[];function qb(a){return function(b,c){"string"!=typeof b&&(c=b,b="*");var d,e=0,f=b.toLowerCase().match(E)||[];if(n.isFunction(c))while(d=f[e++])"+"===d[0]?(d=d.slice(1)||"*",(a[d]=a[d]||[]).unshift(c)):(a[d]=a[d]||[]).push(c)}}function rb(a,b,c,d){var e={},f=a===mb;function g(h){var i;return e[h]=!0,n.each(a[h]||[],function(a,h){var j=h(b,c,d);return"string"!=typeof j||f||e[j]?f?!(i=j):void 0:(b.dataTypes.unshift(j),g(j),!1)}),i}return g(b.dataTypes[0])||!e["*"]&&g("*")}function sb(a,b){var c,d,e=n.ajaxSettings.flatOptions||{};for(c in b)void 0!==b[c]&&((e[c]?a:d||(d={}))[c]=b[c]);return d&&n.extend(!0,a,d),a}function tb(a,b,c){var d,e,f,g,h=a.contents,i=a.dataTypes;while("*"===i[0])i.shift(),void 0===d&&(d=a.mimeType||b.getResponseHeader("Content-Type"));if(d)for(e in h)if(h[e]&&h[e].test(d)){i.unshift(e);break}if(i[0]in c)f=i[0];else{for(e in c){if(!i[0]||a.converters[e+" "+i[0]]){f=e;break}g||(g=e)}f=f||g}return f?(f!==i[0]&&i.unshift(f),c[f]):void 0}function ub(a,b,c,d){var e,f,g,h,i,j={},k=a.dataTypes.slice();if(k[1])for(g in a.converters)j[g.toLowerCase()]=a.converters[g];f=k.shift();while(f)if(a.responseFields[f]&&(c[a.responseFields[f]]=b),!i&&d&&a.dataFilter&&(b=a.dataFilter(b,a.dataType)),i=f,f=k.shift())if("*"===f)f=i;else if("*"!==i&&i!==f){if(g=j[i+" "+f]||j["* "+f],!g)for(e in j)if(h=e.split(" "),h[1]===f&&(g=j[i+" "+h[0]]||j["* "+h[0]])){g===!0?g=j[e]:j[e]!==!0&&(f=h[0],k.unshift(h[1]));break}if(g!==!0)if(g&&a["throws"])b=g(b);else try{b=g(b)}catch(l){return{state:"parsererror",error:g?l:"No conversion from "+i+" to "+f}}}return{state:"success",data:b}}n.extend({active:0,lastModified:{},etag:{},ajaxSettings:{url:ob,type:"GET",isLocal:hb.test(pb[1]),global:!0,processData:!0,async:!0,contentType:"application/x-www-form-urlencoded; charset=UTF-8",accepts:{"*":nb,text:"text/plain",html:"text/html",xml:"application/xml, text/xml",json:"application/json, text/javascript"},contents:{xml:/xml/,html:/html/,json:/json/},responseFields:{xml:"responseXML",text:"responseText",json:"responseJSON"},converters:{"* text":String,"text html":!0,"text json":n.parseJSON,"text xml":n.parseXML},flatOptions:{url:!0,context:!0}},ajaxSetup:function(a,b){return b?sb(sb(a,n.ajaxSettings),b):sb(n.ajaxSettings,a)},ajaxPrefilter:qb(lb),ajaxTransport:qb(mb),ajax:function(a,b){"object"==typeof a&&(b=a,a=void 0),b=b||{};var c,d,e,f,g,h,i,j,k=n.ajaxSetup({},b),l=k.context||k,m=k.context&&(l.nodeType||l.jquery)?n(l):n.event,o=n.Deferred(),p=n.Callbacks("once memory"),q=k.statusCode||{},r={},s={},t=0,u="canceled",v={readyState:0,getResponseHeader:function(a){var b;if(2===t){if(!f){f={};while(b=gb.exec(e))f[b[1].toLowerCase()]=b[2]}b=f[a.toLowerCase()]}return null==b?null:b},getAllResponseHeaders:function(){return 2===t?e:null},setRequestHeader:function(a,b){var c=a.toLowerCase();return t||(a=s[c]=s[c]||a,r[a]=b),this},overrideMimeType:function(a){return t||(k.mimeType=a),this},statusCode:function(a){var b;if(a)if(2>t)for(b in a)q[b]=[q[b],a[b]];else v.always(a[v.status]);return this},abort:function(a){var b=a||u;return c&&c.abort(b),x(0,b),this}};if(o.promise(v).complete=p.add,v.success=v.done,v.error=v.fail,k.url=((a||k.url||ob)+"").replace(eb,"").replace(jb,pb[1]+"//"),k.type=b.method||b.type||k.method||k.type,k.dataTypes=n.trim(k.dataType||"*").toLowerCase().match(E)||[""],null==k.crossDomain&&(h=kb.exec(k.url.toLowerCase()),k.crossDomain=!(!h||h[1]===pb[1]&&h[2]===pb[2]&&(h[3]||("http:"===h[1]?"80":"443"))===(pb[3]||("http:"===pb[1]?"80":"443")))),k.data&&k.processData&&"string"!=typeof k.data&&(k.data=n.param(k.data,k.traditional)),rb(lb,k,b,v),2===t)return v;i=n.event&&k.global,i&&0===n.active++&&n.event.trigger("ajaxStart"),k.type=k.type.toUpperCase(),k.hasContent=!ib.test(k.type),d=k.url,k.hasContent||(k.data&&(d=k.url+=(db.test(d)?"&":"?")+k.data,delete k.data),k.cache===!1&&(k.url=fb.test(d)?d.replace(fb,"$1_="+cb++):d+(db.test(d)?"&":"?")+"_="+cb++)),k.ifModified&&(n.lastModified[d]&&v.setRequestHeader("If-Modified-Since",n.lastModified[d]),n.etag[d]&&v.setRequestHeader("If-None-Match",n.etag[d])),(k.data&&k.hasContent&&k.contentType!==!1||b.contentType)&&v.setRequestHeader("Content-Type",k.contentType),v.setRequestHeader("Accept",k.dataTypes[0]&&k.accepts[k.dataTypes[0]]?k.accepts[k.dataTypes[0]]+("*"!==k.dataTypes[0]?", "+nb+"; q=0.01":""):k.accepts["*"]);for(j in k.headers)v.setRequestHeader(j,k.headers[j]);if(k.beforeSend&&(k.beforeSend.call(l,v,k)===!1||2===t))return v.abort();u="abort";for(j in{success:1,error:1,complete:1})v[j](k[j]);if(c=rb(mb,k,b,v)){v.readyState=1,i&&m.trigger("ajaxSend",[v,k]),k.async&&k.timeout>0&&(g=setTimeout(function(){v.abort("timeout")},k.timeout));try{t=1,c.send(r,x)}catch(w){if(!(2>t))throw w;x(-1,w)}}else x(-1,"No Transport");function x(a,b,f,h){var j,r,s,u,w,x=b;2!==t&&(t=2,g&&clearTimeout(g),c=void 0,e=h||"",v.readyState=a>0?4:0,j=a>=200&&300>a||304===a,f&&(u=tb(k,v,f)),u=ub(k,u,v,j),j?(k.ifModified&&(w=v.getResponseHeader("Last-Modified"),w&&(n.lastModified[d]=w),w=v.getResponseHeader("etag"),w&&(n.etag[d]=w)),204===a||"HEAD"===k.type?x="nocontent":304===a?x="notmodified":(x=u.state,r=u.data,s=u.error,j=!s)):(s=x,(a||!x)&&(x="error",0>a&&(a=0))),v.status=a,v.statusText=(b||x)+"",j?o.resolveWith(l,[r,x,v]):o.rejectWith(l,[v,x,s]),v.statusCode(q),q=void 0,i&&m.trigger(j?"ajaxSuccess":"ajaxError",[v,k,j?r:s]),p.fireWith(l,[v,x]),i&&(m.trigger("ajaxComplete",[v,k]),--n.active||n.event.trigger("ajaxStop")))}return v},getJSON:function(a,b,c){return n.get(a,b,c,"json")},getScript:function(a,b){return n.get(a,void 0,b,"script")}}),n.each(["get","post"],function(a,b){n[b]=function(a,c,d,e){return n.isFunction(c)&&(e=e||d,d=c,c=void 0),n.ajax({url:a,type:b,dataType:e,data:c,success:d})}}),n._evalUrl=function(a){return n.ajax({url:a,type:"GET",dataType:"script",async:!1,global:!1,"throws":!0})},n.fn.extend({wrapAll:function(a){var b;return n.isFunction(a)?this.each(function(b){n(this).wrapAll(a.call(this,b))}):(this[0]&&(b=n(a,this[0].ownerDocument).eq(0).clone(!0),this[0].parentNode&&b.insertBefore(this[0]),b.map(function(){var a=this;while(a.firstElementChild)a=a.firstElementChild;return a}).append(this)),this)},wrapInner:function(a){return this.each(n.isFunction(a)?function(b){n(this).wrapInner(a.call(this,b))}:function(){var b=n(this),c=b.contents();c.length?c.wrapAll(a):b.append(a)})},wrap:function(a){var b=n.isFunction(a);return this.each(function(c){n(this).wrapAll(b?a.call(this,c):a)})},unwrap:function(){return this.parent().each(function(){n.nodeName(this,"body")||n(this).replaceWith(this.childNodes)}).end()}}),n.expr.filters.hidden=function(a){return a.offsetWidth<=0&&a.offsetHeight<=0},n.expr.filters.visible=function(a){return!n.expr.filters.hidden(a)};var vb=/%20/g,wb=/\[\]$/,xb=/\r?\n/g,yb=/^(?:submit|button|image|reset|file)$/i,zb=/^(?:input|select|textarea|keygen)/i;function Ab(a,b,c,d){var e;if(n.isArray(b))n.each(b,function(b,e){c||wb.test(a)?d(a,e):Ab(a+"["+("object"==typeof e?b:"")+"]",e,c,d)});else if(c||"object"!==n.type(b))d(a,b);else for(e in b)Ab(a+"["+e+"]",b[e],c,d)}n.param=function(a,b){var c,d=[],e=function(a,b){b=n.isFunction(b)?b():null==b?"":b,d[d.length]=encodeURIComponent(a)+"="+encodeURIComponent(b)};if(void 0===b&&(b=n.ajaxSettings&&n.ajaxSettings.traditional),n.isArray(a)||a.jquery&&!n.isPlainObject(a))n.each(a,function(){e(this.name,this.value)});else for(c in a)Ab(c,a[c],b,e);return d.join("&").replace(vb,"+")},n.fn.extend({serialize:function(){return n.param(this.serializeArray())},serializeArray:function(){return this.map(function(){var a=n.prop(this,"elements");return a?n.makeArray(a):this}).filter(function(){var a=this.type;return this.name&&!n(this).is(":disabled")&&zb.test(this.nodeName)&&!yb.test(a)&&(this.checked||!T.test(a))}).map(function(a,b){var c=n(this).val();return null==c?null:n.isArray(c)?n.map(c,function(a){return{name:b.name,value:a.replace(xb,"\r\n")}}):{name:b.name,value:c.replace(xb,"\r\n")}}).get()}}),n.ajaxSettings.xhr=function(){try{return new XMLHttpRequest}catch(a){}};var Bb=0,Cb={},Db={0:200,1223:204},Eb=n.ajaxSettings.xhr();a.attachEvent&&a.attachEvent("onunload",function(){for(var a in Cb)Cb[a]()}),k.cors=!!Eb&&"withCredentials"in Eb,k.ajax=Eb=!!Eb,n.ajaxTransport(function(a){var b;return k.cors||Eb&&!a.crossDomain?{send:function(c,d){var e,f=a.xhr(),g=++Bb;if(f.open(a.type,a.url,a.async,a.username,a.password),a.xhrFields)for(e in a.xhrFields)f[e]=a.xhrFields[e];a.mimeType&&f.overrideMimeType&&f.overrideMimeType(a.mimeType),a.crossDomain||c["X-Requested-With"]||(c["X-Requested-With"]="XMLHttpRequest");for(e in c)f.setRequestHeader(e,c[e]);b=function(a){return function(){b&&(delete Cb[g],b=f.onload=f.onerror=null,"abort"===a?f.abort():"error"===a?d(f.status,f.statusText):d(Db[f.status]||f.status,f.statusText,"string"==typeof f.responseText?{text:f.responseText}:void 0,f.getAllResponseHeaders()))}},f.onload=b(),f.onerror=b("error"),b=Cb[g]=b("abort");try{f.send(a.hasContent&&a.data||null)}catch(h){if(b)throw h}},abort:function(){b&&b()}}:void 0}),n.ajaxSetup({accepts:{script:"text/javascript, application/javascript, application/ecmascript, application/x-ecmascript"},contents:{script:/(?:java|ecma)script/},converters:{"text script":function(a){return n.globalEval(a),a}}}),n.ajaxPrefilter("script",function(a){void 0===a.cache&&(a.cache=!1),a.crossDomain&&(a.type="GET")}),n.ajaxTransport("script",function(a){if(a.crossDomain){var b,c;return{send:function(d,e){b=n("<script>").prop({async:!0,charset:a.scriptCharset,src:a.url}).on("load error",c=function(a){b.remove(),c=null,a&&e("error"===a.type?404:200,a.type)}),l.head.appendChild(b[0])},abort:function(){c&&c()}}}});var Fb=[],Gb=/(=)\?(?=&|$)|\?\?/;n.ajaxSetup({jsonp:"callback",jsonpCallback:function(){var a=Fb.pop()||n.expando+"_"+cb++;return this[a]=!0,a}}),n.ajaxPrefilter("json jsonp",function(b,c,d){var e,f,g,h=b.jsonp!==!1&&(Gb.test(b.url)?"url":"string"==typeof b.data&&!(b.contentType||"").indexOf("application/x-www-form-urlencoded")&&Gb.test(b.data)&&"data");return h||"jsonp"===b.dataTypes[0]?(e=b.jsonpCallback=n.isFunction(b.jsonpCallback)?b.jsonpCallback():b.jsonpCallback,h?b[h]=b[h].replace(Gb,"$1"+e):b.jsonp!==!1&&(b.url+=(db.test(b.url)?"&":"?")+b.jsonp+"="+e),b.converters["script json"]=function(){return g||n.error(e+" was not called"),g[0]},b.dataTypes[0]="json",f=a[e],a[e]=function(){g=arguments},d.always(function(){a[e]=f,b[e]&&(b.jsonpCallback=c.jsonpCallback,Fb.push(e)),g&&n.isFunction(f)&&f(g[0]),g=f=void 0}),"script"):void 0}),n.parseHTML=function(a,b,c){if(!a||"string"!=typeof a)return null;"boolean"==typeof b&&(c=b,b=!1),b=b||l;var d=v.exec(a),e=!c&&[];return d?[b.createElement(d[1])]:(d=n.buildFragment([a],b,e),e&&e.length&&n(e).remove(),n.merge([],d.childNodes))};var Hb=n.fn.load;n.fn.load=function(a,b,c){if("string"!=typeof a&&Hb)return Hb.apply(this,arguments);var d,e,f,g=this,h=a.indexOf(" ");return h>=0&&(d=n.trim(a.slice(h)),a=a.slice(0,h)),n.isFunction(b)?(c=b,b=void 0):b&&"object"==typeof b&&(e="POST"),g.length>0&&n.ajax({url:a,type:e,dataType:"html",data:b}).done(function(a){f=arguments,g.html(d?n("<div>").append(n.parseHTML(a)).find(d):a)}).complete(c&&function(a,b){g.each(c,f||[a.responseText,b,a])}),this},n.each(["ajaxStart","ajaxStop","ajaxComplete","ajaxError","ajaxSuccess","ajaxSend"],function(a,b){n.fn[b]=function(a){return this.on(b,a)}}),n.expr.filters.animated=function(a){return n.grep(n.timers,function(b){return a===b.elem}).length};var Ib=a.document.documentElement;function Jb(a){return n.isWindow(a)?a:9===a.nodeType&&a.defaultView}n.offset={setOffset:function(a,b,c){var d,e,f,g,h,i,j,k=n.css(a,"position"),l=n(a),m={};"static"===k&&(a.style.position="relative"),h=l.offset(),f=n.css(a,"top"),i=n.css(a,"left"),j=("absolute"===k||"fixed"===k)&&(f+i).indexOf("auto")>-1,j?(d=l.position(),g=d.top,e=d.left):(g=parseFloat(f)||0,e=parseFloat(i)||0),n.isFunction(b)&&(b=b.call(a,c,h)),null!=b.top&&(m.top=b.top-h.top+g),null!=b.left&&(m.left=b.left-h.left+e),"using"in b?b.using.call(a,m):l.css(m)}},n.fn.extend({offset:function(a){if(arguments.length)return void 0===a?this:this.each(function(b){n.offset.setOffset(this,a,b)});var b,c,d=this[0],e={top:0,left:0},f=d&&d.ownerDocument;if(f)return b=f.documentElement,n.contains(b,d)?(typeof d.getBoundingClientRect!==U&&(e=d.getBoundingClientRect()),c=Jb(f),{top:e.top+c.pageYOffset-b.clientTop,left:e.left+c.pageXOffset-b.clientLeft}):e},position:function(){if(this[0]){var a,b,c=this[0],d={top:0,left:0};return"fixed"===n.css(c,"position")?b=c.getBoundingClientRect():(a=this.offsetParent(),b=this.offset(),n.nodeName(a[0],"html")||(d=a.offset()),d.top+=n.css(a[0],"borderTopWidth",!0),d.left+=n.css(a[0],"borderLeftWidth",!0)),{top:b.top-d.top-n.css(c,"marginTop",!0),left:b.left-d.left-n.css(c,"marginLeft",!0)}}},offsetParent:function(){return this.map(function(){var a=this.offsetParent||Ib;while(a&&!n.nodeName(a,"html")&&"static"===n.css(a,"position"))a=a.offsetParent;return a||Ib})}}),n.each({scrollLeft:"pageXOffset",scrollTop:"pageYOffset"},function(b,c){var d="pageYOffset"===c;n.fn[b]=function(e){return J(this,function(b,e,f){var g=Jb(b);return void 0===f?g?g[c]:b[e]:void(g?g.scrollTo(d?a.pageXOffset:f,d?f:a.pageYOffset):b[e]=f)},b,e,arguments.length,null)}}),n.each(["top","left"],function(a,b){n.cssHooks[b]=ya(k.pixelPosition,function(a,c){return c?(c=xa(a,b),va.test(c)?n(a).position()[b]+"px":c):void 0})}),n.each({Height:"height",Width:"width"},function(a,b){n.each({padding:"inner"+a,content:b,"":"outer"+a},function(c,d){n.fn[d]=function(d,e){var f=arguments.length&&(c||"boolean"!=typeof d),g=c||(d===!0||e===!0?"margin":"border");return J(this,function(b,c,d){var e;return n.isWindow(b)?b.document.documentElement["client"+a]:9===b.nodeType?(e=b.documentElement,Math.max(b.body["scroll"+a],e["scroll"+a],b.body["offset"+a],e["offset"+a],e["client"+a])):void 0===d?n.css(b,c,g):n.style(b,c,d,g)},b,f?d:void 0,f,null)}})}),n.fn.size=function(){return this.length},n.fn.andSelf=n.fn.addBack,"function"==typeof define&&define.amd&&define("jquery",[],function(){return n});var Kb=a.jQuery,Lb=a.$;return n.noConflict=function(b){return a.$===n&&(a.$=Lb),b&&a.jQuery===n&&(a.jQuery=Kb),n},typeof b===U&&(a.jQuery=a.$=n),n});
diff --git a/craftui/www/static/logo.png b/craftui/www/static/logo.png
new file mode 100644
index 0000000..4a3ace0
--- /dev/null
+++ b/craftui/www/static/logo.png
Binary files differ
diff --git a/hnvram/Makefile b/hnvram/Makefile
index c3368cb..2790f4d 100644
--- a/hnvram/Makefile
+++ b/hnvram/Makefile
@@ -7,7 +7,8 @@
 AR:=$(CROSS_COMPILE)ar
 RANLIB:=$(CROSS_COMPILE)ranlib
 STRIP:=$(CROSS_COMPILE)strip
-BINDIR=$(DESTDIR)/bin
+PREFIX=/usr
+BINDIR=$(DESTDIR)$(PREFIX)/bin
 
 HUMAX_UPGRADE_DIR ?= ../../../humax/misc/libupgrade
 CFLAGS += -g -Os -I$(HUMAX_UPGRADE_DIR) $(EXTRACFLAGS)
@@ -29,7 +30,7 @@
 
 install:
 	mkdir -p $(BINDIR)
-	cp hnvram $(BINDIR)
+	cp hnvram $(BINDIR)/hnvram_binary
 
 install-libs:
 	@echo "No libs to install."
diff --git a/hnvram/hnvram_main.c b/hnvram/hnvram_main.c
index 6e86f26..b850048 100644
--- a/hnvram/hnvram_main.c
+++ b/hnvram/hnvram_main.c
@@ -79,7 +79,10 @@
   {"PAIRED_DISK",          NVRAM_FIELD_PAIRED_DISK,       HNVRAM_STRING},
   {"PARTITION_VER",        NVRAM_FIELD_PARTITION_VER,     HNVRAM_STRING},
   {"HW_VER",               NVRAM_FIELD_HW_VER,            HNVRAM_UINT8},
-  {"UITYPE",               NVRAM_FIELD_UITYPE,            HNVRAM_STRING}
+  {"UITYPE",               NVRAM_FIELD_UITYPE,            HNVRAM_STRING},
+  {"LASER_CHANNEL",        NVRAM_FIELD_LASER_CHANNEL,     HNVRAM_STRING},
+  {"MAC_ADDR_PON",         NVRAM_FIELD_MAC_ADDR_PON,      HNVRAM_MAC},
+  {"PRODUCTION_UNIT",      NVRAM_FIELD_PRODUCTION_UNIT,   HNVRAM_STRING},
 };
 
 const hnvram_field_t* get_nvram_field(const char* name) {
diff --git a/libexperiments/.gitignore b/libexperiments/.gitignore
new file mode 100644
index 0000000..1d2719e
--- /dev/null
+++ b/libexperiments/.gitignore
@@ -0,0 +1 @@
+experiments_test
diff --git a/libexperiments/Makefile b/libexperiments/Makefile
new file mode 100644
index 0000000..08ba166
--- /dev/null
+++ b/libexperiments/Makefile
@@ -0,0 +1,47 @@
+CC=$(CROSS_COMPILE)gcc
+CXX=$(CROSS_COMPILE)g++
+INSTALL=install
+PREFIX=/usr
+LIBDIR=$(DESTDIR)$(PREFIX)/lib
+INCLUDEDIR=$(DESTDIR)$(PREFIX)/include
+
+all: libexperiments.so
+
+CPPFLAGS=$(EXTRACFLAGS)
+CFLAGS=-Wall -Werror -g -fPIC -Wswitch-enum -Wextra -fno-omit-frame-pointer \
+    -Wno-sign-compare -Wno-unused-parameter $(EXTRACFLAGS)
+CXXFLAGS=-Wall -Werror -g -fPIC -Wswitch-enum -Wextra -fno-omit-frame-pointer \
+    -Wno-sign-compare -Wno-unused-parameter -std=c++0x $(EXTRACXXFLAGS)
+LDFLAGS+=$(EXTRALDFLAGS)
+
+libexperiments.so: experiments.o utils.o
+	$(CC) -shared -Wl,-soname,libexperiments.so -Wl,-export-dynamic -o $@ $^
+
+experiments_test: experiments.o experiments_test.o experiments_c_api_test.o utils.o
+	$(CXX) -o $@ $^ $(LDFLAGS) $(CPPFLAGS) -lgtest -lpthread
+
+%.o: %.c
+	$(CC) -c $(CFLAGS) $(CPPFLAGS) $< -o $@
+%.o: %.cc
+	$(CXX) -c $(CXXFLAGS) $(CPPFLAGS) $< -o $@
+
+# all *.o depend on all the header files
+$(patsubst %.cc,%.o,$(wildcard *.cc)) $(patsubst %.c,%.o,$(wildcard *.c)): \
+  $(wildcard *.h)
+
+install: all
+	echo 'target-install=$(INSTALL)'
+	mkdir -p $(LIBDIR)
+	$(INSTALL) -m 0755 libexperiments.so $(LIBDIR)/
+
+install-libs: all
+	echo 'staging-install=$(INSTALL)'
+	mkdir -p $(INCLUDEDIR) $(LIBDIR) $(LIBDIR)/pkgconfig
+	$(INSTALL) -m 0644 experiments.h $(INCLUDEDIR)/
+	$(INSTALL) -m 0755 libexperiments.so $(LIBDIR)/
+
+test: experiments_test
+	./experiments_test
+
+clean:
+	rm -rf *.[oa] *.so *~
diff --git a/libexperiments/experiments.cc b/libexperiments/experiments.cc
new file mode 100644
index 0000000..d3dcb2b
--- /dev/null
+++ b/libexperiments/experiments.cc
@@ -0,0 +1,176 @@
+#ifndef _GNU_SOURCE
+#define _GNU_SOURCE
+#endif
+
+#ifdef __cplusplus
+#define __STDC_FORMAT_MACROS
+#endif
+
+#include "experiments.h"
+
+#include <inttypes.h>
+
+#include <sstream>
+#include <string>
+
+#include "utils.h"
+
+using namespace libexperiments_utils;  // NOLINT
+
+Experiments *experiments = NULL;
+
+int DefaultExperimentsRegisterFunc(const char *name) {
+  std::vector<std::string> cmd({"register_experiment", name});
+  std::ostringstream out, err;
+  int64_t timeout_usec = secs_to_usecs(5);
+  int status;
+  int ret = run_cmd(cmd, "", &status, &out, &err, timeout_usec);
+  if (ret < 0 || status != 0) {
+    log("experiments:Error-Cannot register '%s', ret:%d status:%d stdout:%s "
+        "stderr:%s", name, ret, status, out.str().c_str(),
+        err.str().c_str());
+    return 0;  // boolean false
+  }
+  return 1;  // boolean true
+}
+
+int DummyExperimentsRegisterFunc(const char *name) {
+  return 1;  // boolean true
+}
+
+bool Experiments::Initialize(
+    const std::string &config_dir, int64_t min_time_between_refresh_usec,
+    experiments_register_func_t register_func,
+    const std::vector<std::string> &names_to_register) {
+  log("experiments:initializing - config_dir:%s min_time_between_refresh:%"
+      PRId64 " us", config_dir.c_str(), min_time_between_refresh_usec);
+
+  std::lock_guard<std::mutex> lock_guard(lock_);
+
+  if (register_func == NULL) {
+    log("experiments:Error-register_func is NULL");
+    return false;
+  }
+
+  if (!directory_exists(config_dir.c_str())) {
+    log("experiments:Error-config_dir '%s' does not exist", config_dir.c_str());
+    return false;
+  }
+
+  if (min_time_between_refresh_usec < 0)
+    min_time_between_refresh_usec = 0;
+
+  config_dir_ = config_dir;
+  register_func_ = register_func;
+  min_time_between_refresh_usec_ = min_time_between_refresh_usec;
+
+  if (!Register_Locked(names_to_register))
+    return false;
+
+  // initial read of registered experiments states
+  Refresh();
+
+  initialized_ = true;
+  return true;
+}
+
+bool Experiments::Register(const std::vector<std::string> &names) {
+  if (!IsInitialized()) {
+    log("experiments:Cannot register, not initialized!");
+    return false;
+  }
+  return Register_Unlocked(names);
+}
+
+bool Experiments::Register_Unlocked(const std::vector<std::string> &names) {
+  std::lock_guard<std::mutex> lock_guard(lock_);
+  return Register_Locked(names);
+}
+
+bool Experiments::Register_Locked(const std::vector<std::string> &names) {
+  for (const auto &name : names) {
+    if (IsInRegisteredList(name)) {
+      log("experiments:'%s' already registered", name.c_str());
+      continue;
+    }
+
+    // call external register function
+    if (!register_func_(name.c_str()))
+      return false;  // no reason to continue
+
+    registered_experiments_.insert(name);
+    log("experiments:Registered '%s'", name.c_str());
+  }
+  return true;
+}
+
+bool Experiments::IsRegistered(const std::string &name) {
+  std::lock_guard<std::mutex> lock_guard(lock_);
+  return IsInRegisteredList(name);
+}
+
+bool Experiments::IsEnabled(const std::string &name) {
+  if (!IsInitialized())
+    return false;  // silent return to avoid log flooding
+
+  std::lock_guard<std::mutex> lock_guard(lock_);
+
+  if (us_elapse(last_time_refreshed_usec_) >= min_time_between_refresh_usec_) {
+    Refresh();
+  }
+
+  return IsInEnabledList(name);
+}
+
+void Experiments::Refresh() {
+  for (const auto &name : registered_experiments_)
+    UpdateState(name);
+  last_time_refreshed_usec_ = us_elapse(0);
+}
+
+void Experiments::UpdateState(const std::string &name) {
+  if (!IsInRegisteredList(name)) {
+    log("experiments:'%s' not registered", name.c_str());
+    return;
+  }
+
+  std::string file_path = config_dir_ + "/" + name + ".active";
+  bool was_enabled = IsInEnabledList(name);
+  bool is_enabled = file_exists(file_path.c_str());
+  if (is_enabled && !was_enabled) {
+    log("experiments:'%s' is now enabled", name.c_str());
+    enabled_experiments_.insert(name);
+  } else if (!is_enabled && was_enabled) {
+    log("experiments:'%s' is now disabled", name.c_str());
+    enabled_experiments_.erase(name);
+  }
+}
+
+
+// API for C programs
+int experiments_initialize(const char *config_dir,
+                           int64_t min_time_between_refresh_usec,
+                           experiments_register_func_t register_func) {
+  if (register_func == NULL)
+    register_func = DefaultExperimentsRegisterFunc;
+
+  experiments = new Experiments();
+  return experiments->Initialize(config_dir, min_time_between_refresh_usec,
+                                 register_func, {""});
+}
+
+int experiments_is_initialized() {
+  return experiments ? experiments->IsInitialized() : false;
+}
+
+int experiments_register(const char *name) {
+  return experiments ? experiments->Register(name) : false;
+}
+
+int experiments_is_registered(const char *name) {
+  return experiments ? experiments->IsRegistered(name) : false;
+}
+
+int experiments_is_enabled(const char *name) {
+  return experiments ? experiments->IsEnabled(name) : false;
+}
diff --git a/libexperiments/experiments.h b/libexperiments/experiments.h
new file mode 100644
index 0000000..54c6389
--- /dev/null
+++ b/libexperiments/experiments.h
@@ -0,0 +1,217 @@
+#ifndef _LIBEXPERIMENTS_EXPERIMENTS_H
+#define _LIBEXPERIMENTS_EXPERIMENTS_H
+
+#include <inttypes.h>
+
+
+// Implements a library that supports the Gfiber Experiments framework, as
+// explained in the following doc: go/gfiber-experiments-framework.
+//
+// Both C and C++ (class) implementations are available.
+//
+// C++ example:
+// ====================================
+//   const char* kConfigFolderPath[] = "/config/experiments";
+//   int64_t kMinTimeBetweenRefreshUs = 60 * 1000 * 1000;  // 60 secs
+//   e = new Experiments();
+//   if (!e->Initialize(kConfigFolderPath, kMinTimeBetweenRefreshUs,
+//                      {"exp1", "exp2"})) {
+//     // handle error case
+//   }
+//
+//   // later in the code
+//   if (e->IsEnabled("exp1")) {
+//     // exp1 is enabled
+//     [..]
+//   }
+//
+// C example:
+// ===================================
+//   const char* kConfigFolderPath[] = "/config/experiments";
+//   int64_t kMinTimeBetweenRefreshUs = 60 * 1000 * 1000;  // 60 secs
+//   if (!experiments_initialize(kConfigFolderPath, kMinTimeBetweenRefreshUs,
+//                               NULL);  // use default register function
+//     // handle error case
+//   }
+//
+//   experiments_register("exp1");
+//   experiments_register("exp2");
+//
+//   // later in the code
+//   if (experiments_is_enabled("exp1")) {
+//     // exp1 is enabled
+//     [..]
+//   }
+
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+// Function called when registering a new experiment.
+// Returns non-zero (boolean true) for success, else 0 (boolean false).
+typedef int (*experiments_register_func_t) (const char *name);
+
+// Default experiment register function. Calls the shell script
+// "register_experiment <name>".
+int DefaultExperimentsRegisterFunc(const char *name);
+
+// Dummy experiment register function. Just returns true.
+int DummyExperimentsRegisterFunc(const char *name);
+
+#ifdef __cplusplus
+}
+#endif
+
+
+//
+// C++ implementation
+//
+#ifdef __cplusplus
+
+#include <atomic>
+#include <mutex>  // NOLINT
+#include <set>
+#include <string>
+#include <vector>
+
+
+class Experiments {
+ public:
+  Experiments()
+      : initialized_(false),
+        min_time_between_refresh_usec_(0),
+        last_time_refreshed_usec_(0) {}
+  virtual ~Experiments() {}
+
+  // Initializes the instance:
+  // * Sets the provided experiments config directory and register function.
+  // * Calls the register function for the provided experiment names.
+  // * Scans the config folder to determine initial state of all registered
+  //   experiments.
+  // The min_time_between_refresh_usec values sets a lower boundary on how
+  // often the config folder is scanned for updated experiment states.
+  // Returns true if successful.
+  bool Initialize(const std::string &config_dir,
+                  int64_t min_time_between_refresh_usec,
+                  experiments_register_func_t register_func,
+                  const std::vector<std::string> &names_to_register);
+  // Convenience version, using default experiments register function.
+  bool Initialize(const std::string &config_dir,
+                  int64_t min_time_between_refresh_usec,
+                  const std::vector<std::string> &names_to_register) {
+    return Initialize(config_dir, min_time_between_refresh_usec,
+                      &DefaultExperimentsRegisterFunc, names_to_register);
+  }
+
+  bool IsInitialized() const { return initialized_; }
+
+  // Registers the provided experiment(s).
+  bool Register(const std::vector<std::string> &names);
+  bool Register(const std::string &name) {
+    std::vector<std::string> names{name};
+    return Register(names);
+  }
+
+  // Returns true if the given experiment is registered.
+  bool IsRegistered(const std::string &name);
+
+  // Returns true if the given experiment is active, else false. If the minimum
+  // time between refreshes has passed, re-scans the config folder for updates
+  // first.
+  bool IsEnabled(const std::string &name);
+
+ private:
+  // Registers the given experiments. Unlocked version takes lock_ first.
+  // Returns true if successful, else false.
+  bool Register_Unlocked(const std::vector<std::string> &names);
+  bool Register_Locked(const std::vector<std::string> &names);
+
+  // Returns true if the given experiment is in the list of registered
+  // experiments.
+  bool IsInRegisteredList(const std::string &name) const {
+    return registered_experiments_.find(name) != registered_experiments_.end();
+  }
+
+  // Refreshes all registered experiment states by scanning the config folder.
+  void Refresh();
+
+  // Updates the state of the given experiment by checking its file in the
+  // config folder.
+  void UpdateState(const std::string &name);
+
+  // Returns true if the given experiment is in the list of enabled
+  // experiments.
+  bool IsInEnabledList(const std::string &name) {
+    return enabled_experiments_.find(name) != enabled_experiments_.end();
+  }
+
+  std::atomic<bool> initialized_;
+  std::mutex lock_;
+
+  // Experiments config folder, containing the system-wide list of experiments.
+  // An experiment is marked active if the folder contains the file named
+  // "<experiment_name>.active".
+  std::string config_dir_;
+
+  // External function called to register an experiment.
+  experiments_register_func_t register_func_;
+
+  std::set<std::string> registered_experiments_;
+  std::set<std::string> enabled_experiments_;
+
+  // Minimum time between accessing the config folder to refresh the experiment
+  // states. When set to 0 it refreshes on every call to IsEnabled().
+  uint64_t min_time_between_refresh_usec_;
+  uint64_t last_time_refreshed_usec_;
+};
+
+extern Experiments *experiments;
+
+#endif  // __cplusplus
+
+
+//
+// C-based API
+//
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+// Creates and initializes the experiments object:
+// * Sets the provided experiments config directory and register function.
+// * Calls the register function for the provided experiment names.
+// * Scans the config folder to determine initial state of all registered
+//   experiments.
+// The min_time_between_refresh_usec values sets a lower boundary on how often
+// the config folder is scanned for updated experiment states. Set
+// register_func to NULL to use the default register function
+// (DefaultExperimentsRegisterFunc()).
+// Returns non-zero (boolean true) if successful, 0 (boolean false) for error.
+int experiments_initialize(const char *config_dir,
+                            int64_t min_time_between_refresh_usec,
+                            experiments_register_func_t register_func);
+
+// Returns non-zero (boolean true) if the experiments object is initialized,
+// else 0 (boolean false).
+int experiments_is_initialized();
+
+// Registers the provided experiment.
+// Returns non-zero (boolean true) if successful, 0 (boolean false) for error.
+int experiments_register(const char *name);
+
+// Returns non-zero (boolean true) if the given experiment name is registered,
+// else 0 (boolean false).
+int experiments_is_registered(const char *name);
+
+// Returns non-zero (boolean true) if the given experiment is active, else 0
+// (boolean false). If the minimum time between refreshes has passed, re-scans
+// the config folder for updates first.
+int experiments_is_enabled(const char *name);
+
+#ifdef __cplusplus
+}
+#endif
+
+#endif  // _LIBEXPERIMENTS_EXPERIMENTS_H
diff --git a/libexperiments/experiments_c_api_test.c b/libexperiments/experiments_c_api_test.c
new file mode 100644
index 0000000..fc90849
--- /dev/null
+++ b/libexperiments/experiments_c_api_test.c
@@ -0,0 +1,21 @@
+#include "experiments_c_api_test.h"
+
+int test_experiments_initialize(const char *config_dir) {
+  return experiments_initialize(config_dir, 0, DummyExperimentsRegisterFunc);
+}
+
+int test_experiments_is_initialized() {
+  return experiments_is_initialized();
+}
+
+int test_experiments_register(const char *name) {
+  return experiments_register(name);
+}
+
+int test_experiments_is_registered(const char *name) {
+  return experiments_is_registered(name);
+}
+
+int test_experiments_is_enabled(const char *name) {
+  return experiments_is_enabled(name);
+}
diff --git a/libexperiments/experiments_c_api_test.h b/libexperiments/experiments_c_api_test.h
new file mode 100644
index 0000000..bed21b6
--- /dev/null
+++ b/libexperiments/experiments_c_api_test.h
@@ -0,0 +1,24 @@
+#ifndef _LIBEXPERIMENTS_EXPERIMENTS_C_API_TEST_H
+#define _LIBEXPERIMENTS_EXPERIMENTS_C_API_TEST_H
+
+// Provides C-compiled functions to test the C-API functionality. The main
+// purpose of this is to verify that one can use libexperiments from a purely C
+// environment.
+
+#include "experiments.h"
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+int test_experiments_initialize(const char *config_dir);
+int test_experiments_is_initialized();
+int test_experiments_register(const char *name);
+int test_experiments_is_registered(const char *name);
+int test_experiments_is_enabled(const char *name);
+
+#ifdef __cplusplus
+}
+#endif
+
+#endif  // _LIBEXPERIMENTS_EXPERIMENTS_C_API_TEST_H
diff --git a/libexperiments/experiments_test.cc b/libexperiments/experiments_test.cc
new file mode 100644
index 0000000..e0370cc
--- /dev/null
+++ b/libexperiments/experiments_test.cc
@@ -0,0 +1,280 @@
+#include <gtest/gtest.h>
+
+#include "experiments.h"
+#include "experiments_c_api_test.h"
+
+#include <fcntl.h>
+#include <linux/limits.h>
+#include <sys/types.h>
+#include <sys/stat.h>
+
+#include "utils.h"
+
+using namespace libexperiments_utils;  // NOLINT
+
+int FailingExperimentsRegisterFunc(const char *name) {
+  return false;
+}
+
+class ExperimentsTest : public ::testing::Test {
+ protected:
+  static void SetUpTestCase() {
+    ASSERT_TRUE(realpath(".", root_path_));
+    snprintf(test_folder_path_, sizeof(test_folder_path_), "%s/exps-XXXXXX",
+             root_path_);
+    char strerrbuf[1024] = {'\0'};
+    ASSERT_TRUE(mkdtemp(test_folder_path_)) <<
+        strerror_r(errno, strerrbuf, sizeof(strerrbuf)) << "(" << errno << ")";
+    ASSERT_EQ(chdir(test_folder_path_), 0);
+  }
+
+  static void TearDownTestCase() {
+    // change out of the test directory and remove it
+    ASSERT_EQ(chdir(root_path_), 0);
+    std::string cmd = StringPrintf("rm -r %s", test_folder_path_);
+    ASSERT_EQ(0, system(cmd.c_str()));
+  }
+
+  bool CreateFile(const std::string &name) {
+    int fd = open(name.c_str(), O_CREAT | O_TRUNC,
+                  S_IRUSR | S_IWUSR | S_IRGRP | S_IROTH);
+    if (fd < 0) {
+      log_perror(errno, "Cannot create file '%s':", name.c_str());
+      return false;
+    } else {
+      close(fd);
+    }
+    return true;
+  }
+
+  bool RenameFile(const std::string &from_name, const std::string &to_name) {
+    if (rename(from_name.c_str(), to_name.c_str()) < 0) {
+      log_perror(errno, "Cannot rename file '%s' to '%s':", from_name.c_str(),
+                 to_name.c_str());
+      return false;
+    }
+    return true;
+  }
+
+  bool DeleteFile(const std::string &name) {
+    if (remove(name.c_str()) < 0) {
+      log_perror(errno, "Cannot delete file '%s':", name.c_str());
+      return false;
+    }
+    return true;
+  }
+
+  bool SwitchFromTo(Experiments *e, const std::string &name,
+                    const std::string &from_ext, const std::string &to_ext) {
+    std::string from_file = name + from_ext;
+    std::string to_file = name + to_ext;
+    if (file_exists(from_file.c_str())) {
+      return RenameFile(from_file, to_file);
+    } else {
+      return CreateFile(to_file);
+    }
+  }
+
+  bool SetActive(Experiments *e, const std::string &name) {
+    return SwitchFromTo(e, name, ".inactive", ".active");
+  }
+
+  bool SetInactive(Experiments *e, const std::string &name) {
+    return SwitchFromTo(e, name, ".active", ".inactive");
+  }
+
+  bool Remove(Experiments *e, const std::string &name) {
+    std::string active_file = name + ".active";
+    if (file_exists(active_file.c_str())) {
+      if (!DeleteFile(active_file)) {
+        return false;
+      }
+    }
+    std::string inactive_file = name + ".inactive";
+    if (file_exists(inactive_file.c_str())) {
+      if (!DeleteFile(inactive_file)) {
+        return false;
+      }
+    }
+    return true;
+  }
+
+  static char root_path_[PATH_MAX];
+  static char test_folder_path_[PATH_MAX];
+};
+
+char ExperimentsTest::test_folder_path_[PATH_MAX] = {0};
+char ExperimentsTest::root_path_[PATH_MAX] = {0};
+
+
+TEST_F(ExperimentsTest, InvalidConfigPath) {
+  Experiments e;
+  char invalid_path[1024];
+  snprintf(invalid_path, sizeof(invalid_path), "%s/nope", test_folder_path_);
+  ASSERT_FALSE(e.Initialize(invalid_path, 0, &DummyExperimentsRegisterFunc,
+                            {"exp1"}));
+}
+
+TEST_F(ExperimentsTest, InvalidRegisterFunc) {
+  Experiments e;
+  ASSERT_FALSE(e.Initialize(test_folder_path_, 0, NULL, {"exp1"}));
+}
+
+TEST_F(ExperimentsTest, RegisterFuncFails) {
+  Experiments e;
+  ASSERT_FALSE(e.Initialize(test_folder_path_, 0,
+                            &FailingExperimentsRegisterFunc, {"exp1"}));
+}
+
+TEST_F(ExperimentsTest, Register) {
+  Experiments e;
+  ASSERT_TRUE(e.Initialize(test_folder_path_, 0, &DummyExperimentsRegisterFunc,
+                           {"exp1"}));
+  EXPECT_TRUE(e.IsRegistered("exp1"));
+
+  // add one more
+  EXPECT_FALSE(e.IsRegistered("exp2"));
+  EXPECT_TRUE(e.Register("exp2"));
+  EXPECT_TRUE(e.IsRegistered("exp1"));
+  EXPECT_TRUE(e.IsRegistered("exp2"));
+
+  // repeated registration is ignored
+  EXPECT_TRUE(e.Register("exp2"));
+  EXPECT_TRUE(e.IsRegistered("exp1"));
+  EXPECT_TRUE(e.IsRegistered("exp2"));
+
+  // register vector
+  EXPECT_FALSE(e.IsRegistered("exp3"));
+  EXPECT_FALSE(e.IsRegistered("exp4"));
+  EXPECT_FALSE(e.IsRegistered("exp5"));
+  EXPECT_TRUE(e.Register({"exp3", "exp4", "exp5"}));
+  EXPECT_TRUE(e.IsRegistered("exp1"));
+  EXPECT_TRUE(e.IsRegistered("exp2"));
+  EXPECT_TRUE(e.IsRegistered("exp3"));
+  EXPECT_TRUE(e.IsRegistered("exp4"));
+  EXPECT_TRUE(e.IsRegistered("exp5"));
+}
+
+TEST_F(ExperimentsTest, Single) {
+  Experiments e;
+  ASSERT_TRUE(e.Initialize(test_folder_path_, 0, &DummyExperimentsRegisterFunc,
+                           {"exp1"}));
+  EXPECT_FALSE(e.IsEnabled("exp1"));
+
+  EXPECT_TRUE(SetActive(&e, "exp1"));
+  EXPECT_TRUE(e.IsEnabled("exp1"));
+
+  EXPECT_TRUE(SetInactive(&e, "exp1"));
+  EXPECT_FALSE(e.IsEnabled("exp1"));
+
+  EXPECT_TRUE(SetActive(&e, "exp1"));
+  EXPECT_TRUE(e.IsEnabled("exp1"));
+
+  EXPECT_TRUE(Remove(&e, "exp1"));
+  EXPECT_FALSE(e.IsEnabled("exp1"));
+}
+
+TEST_F(ExperimentsTest, Multiple) {
+  Experiments e;
+  ASSERT_TRUE(e.Initialize(test_folder_path_, 0, &DummyExperimentsRegisterFunc,
+                           {"exp1", "exp2", "exp3"}));
+  EXPECT_FALSE(e.IsEnabled("exp1"));
+  EXPECT_FALSE(e.IsEnabled("exp2"));
+  EXPECT_FALSE(e.IsEnabled("exp3"));
+
+  // activate exp1 - AII
+  EXPECT_TRUE(SetActive(&e, "exp1"));
+  EXPECT_TRUE(e.IsEnabled("exp1"));
+  EXPECT_FALSE(e.IsEnabled("exp2"));
+  EXPECT_FALSE(e.IsEnabled("exp3"));
+  // activate exp2 - AAI
+  EXPECT_TRUE(SetActive(&e, "exp2"));
+  EXPECT_TRUE(e.IsEnabled("exp1"));
+  EXPECT_TRUE(e.IsEnabled("exp2"));
+  EXPECT_FALSE(e.IsEnabled("exp3"));
+  // active exp3 - AAA
+  EXPECT_TRUE(SetActive(&e, "exp3"));
+  EXPECT_TRUE(e.IsEnabled("exp1"));
+  EXPECT_TRUE(e.IsEnabled("exp2"));
+  EXPECT_TRUE(e.IsEnabled("exp3"));
+  // inactivate exp2 - AIA
+  EXPECT_TRUE(SetInactive(&e, "exp2"));
+  EXPECT_TRUE(e.IsEnabled("exp1"));
+  EXPECT_FALSE(e.IsEnabled("exp2"));
+  EXPECT_TRUE(e.IsEnabled("exp3"));
+  // remove exp1 file - IIA
+  EXPECT_TRUE(Remove(&e, "exp1"));
+  EXPECT_FALSE(e.IsEnabled("exp1"));
+  EXPECT_FALSE(e.IsEnabled("exp2"));
+  EXPECT_TRUE(e.IsEnabled("exp3"));
+  // re-activate exp2 - IAA
+  EXPECT_TRUE(SetActive(&e, "exp2"));
+  EXPECT_FALSE(e.IsEnabled("exp1"));
+  EXPECT_TRUE(e.IsEnabled("exp2"));
+  EXPECT_TRUE(e.IsEnabled("exp3"));
+  // inactivate exp1 (re-create file) - IAA
+  EXPECT_TRUE(SetInactive(&e, "exp1"));
+  EXPECT_FALSE(e.IsEnabled("exp1"));
+  EXPECT_TRUE(e.IsEnabled("exp2"));
+  EXPECT_TRUE(e.IsEnabled("exp3"));
+  // remove all - III
+  EXPECT_TRUE(Remove(&e, "exp1"));
+  EXPECT_TRUE(Remove(&e, "exp2"));
+  EXPECT_TRUE(Remove(&e, "exp3"));
+  EXPECT_FALSE(e.IsEnabled("exp1"));
+  EXPECT_FALSE(e.IsEnabled("exp2"));
+  EXPECT_FALSE(e.IsEnabled("exp3"));
+}
+
+TEST_F(ExperimentsTest, TimeBetweenRefresh) {
+  int64_t kMinTimeBetweenRefresh = secs_to_usecs(3);
+  int64_t kTimeout =  secs_to_usecs(5);
+  uint64_t start_time = us_elapse(0);
+  Experiments e;
+  ASSERT_TRUE(e.Initialize(test_folder_path_, kMinTimeBetweenRefresh,
+                           &DummyExperimentsRegisterFunc, {"exp1"}));
+  EXPECT_FALSE(e.IsEnabled("exp1"));
+  EXPECT_TRUE(SetActive(&e, "exp1"));
+
+  // measure time until we see "exp1" active
+  uint64_t duration = us_elapse(start_time);
+  while (!e.IsEnabled("exp1") && duration < kTimeout) {
+    us_sleep(100);
+    duration = us_elapse(start_time);
+  }
+
+  EXPECT_GE(duration, kMinTimeBetweenRefresh) << "time:" << duration;
+  EXPECT_LT(duration, kTimeout) << "time:" << duration;
+
+  // clean up
+  EXPECT_TRUE(Remove(&e, "exp1"));
+}
+
+TEST_F(ExperimentsTest, C_API_Test) {
+  // returns false on all API functions until initialized is called
+  EXPECT_FALSE(test_experiments_is_initialized());
+  EXPECT_FALSE(test_experiments_register("exp1"));
+  EXPECT_FALSE(test_experiments_is_registered("exp1"));
+  EXPECT_FALSE(test_experiments_is_enabled("exp1"));
+  EXPECT_TRUE(SetActive(experiments, "exp1"));
+  EXPECT_FALSE(test_experiments_is_enabled("exp1"));
+  EXPECT_TRUE(Remove(experiments, "exp1"));
+
+  // initialize
+  EXPECT_TRUE(test_experiments_initialize(test_folder_path_));
+  EXPECT_TRUE(test_experiments_is_initialized());
+
+  EXPECT_TRUE(test_experiments_register("exp1"));
+  EXPECT_TRUE(test_experiments_is_registered("exp1"));
+
+  EXPECT_FALSE(test_experiments_is_enabled("exp1"));
+  EXPECT_TRUE(SetActive(experiments, "exp1"));
+  EXPECT_TRUE(test_experiments_is_enabled("exp1"));
+  EXPECT_FALSE(test_experiments_is_enabled("exp2"));
+
+  EXPECT_TRUE(SetInactive(experiments, "exp1"));
+  EXPECT_FALSE(test_experiments_is_enabled("exp1"));
+
+  // clean up
+  EXPECT_TRUE(Remove(experiments, "exp1"));
+}
diff --git a/libexperiments/utils.cc b/libexperiments/utils.cc
new file mode 100644
index 0000000..b82d34b
--- /dev/null
+++ b/libexperiments/utils.cc
@@ -0,0 +1,300 @@
+#include "utils.h"
+
+#include <errno.h>
+#include <signal.h>
+#include <stdarg.h>
+#include <stdint.h>
+#include <stdio.h>
+#include <string.h>
+#include <sys/time.h>
+#include <sys/wait.h>
+#include <unistd.h>
+
+namespace libexperiments_utils {
+
+void log(const char* cstr, ...) {
+  va_list va;
+  va_start(va, cstr);
+  vprintf(cstr, va);
+  va_end(va);
+  printf("\n");
+  fflush(stdout);
+}
+
+void log_perror(int err, const char* cstr, ...) {
+  va_list va;
+  va_start(va, cstr);
+  vprintf(cstr, va);
+  va_end(va);
+  char strerrbuf[1024] = {'\0'};
+  printf("'%s'[%d]\n", strerror_r(err, strerrbuf, sizeof(strerrbuf)), err);
+  fflush(stdout);
+}
+
+uint64_t us_elapse(uint64_t start_time_us) {
+  struct timespec tv;
+  clock_gettime(CLOCK_MONOTONIC, &tv);
+  return tv.tv_sec * kUsecsPerSec + tv.tv_nsec / kNsecsPerUsec - start_time_us;
+}
+
+void us_sleep(uint64_t usecs) {
+  uint64_t nsecs = kNsecsPerUsec * usecs;
+  struct timespec tv;
+  // tv_nsec field must be [0..kNsecsPerSec-1]
+  tv.tv_sec = nsecs / kNsecsPerSec;
+  tv.tv_nsec = nsecs % kNsecsPerSec;
+  nanosleep(&tv, NULL);
+}
+
+// Maximum output (stdout+stderr) accepted by run_cmd()
+const int kMaxRunCmdOutput = 4 * 1024 * 1024;
+
+static int nice_snprintf(char *str, size_t size, const char *format, ...) {
+  va_list ap;
+  int bi;
+  va_start(ap, format);
+  // http://stackoverflow.com/a/100991
+  bi = vsnprintf(str, size, format, ap);
+  va_end(ap);
+  if (bi > size) {
+    // From printf(3):
+    // "snprintf() [returns] the number of characters (not including the
+    // trailing '\0') which would have been written to the final string
+    // if enough space had been available" [printf(3)]
+    bi = size;
+  }
+  return bi;
+}
+
+/* snprintf's a run_cmd command */
+int snprintf_cmd(char *buf, int bufsize, const std::vector<std::string> &cmd) {
+  int bi = 0;
+
+  // ensure we always return something valid
+  buf[0] = '\0';
+  for (const auto &item : cmd) {
+    bool blanks = (item.find_first_of(" \n\r\t") != std::string::npos);
+    if (blanks)
+      bi += nice_snprintf(buf+bi, bufsize-bi, "\"");
+    for (int i = 0; i < item.length() && bi < bufsize; ++i) {
+      if (isprint(item.at(i)))
+        bi += nice_snprintf(buf+bi, bufsize-bi, "%c", item.at(i));
+      else if (item.at(i) == '\n')
+        bi += nice_snprintf(buf+bi, bufsize-bi, "\\n");
+      else
+        bi += nice_snprintf(buf+bi, bufsize-bi, "\\x%02x", item.at(i));
+    }
+    if (blanks)
+      bi += nice_snprintf(buf+bi, bufsize-bi, "\"");
+    bi += nice_snprintf(buf+bi, bufsize-bi, " ");
+  }
+  return bi;
+}
+
+int run_cmd(const std::vector<std::string> &cmd, const std::string &in,
+            int *status,
+            std::ostream *out,
+            std::ostream *err,
+            int64_t timeout_usec) {
+  if (cmd.empty() || cmd[0].empty()) {
+    *status = -1;
+    return -1;
+  }
+
+  int pipe_in[2];
+  int pipe_out[2];
+  int pipe_err[2];
+  int pid;
+
+  // init the 3 pipes
+  int ret;
+  for (auto the_pipe : { pipe_in, pipe_out, pipe_err }) {
+    if ((ret = pipe(the_pipe)) < 0) {
+      log_perror(errno, "run_cmd:Error-pipe failed-");
+      return -1;
+    }
+  }
+
+  char cmd_buf[1024];
+  snprintf_cmd(cmd_buf, sizeof(cmd_buf), cmd);
+  log("run_cmd:running command: %s", cmd_buf);
+
+  pid = fork();
+  if (pid == 0) {
+    // child: set stdin/stdout/stderr
+    dup2(pipe_in[0], STDIN_FILENO);
+    dup2(pipe_out[1], STDOUT_FILENO);
+    dup2(pipe_err[1], STDERR_FILENO);
+
+    // close unused pipe ends
+    close(pipe_in[1]);
+    close(pipe_out[0]);
+    close(pipe_err[0]);
+    close(pipe_in[0]);
+    close(pipe_out[1]);
+    close(pipe_err[1]);
+
+    // convert strings to "const char *" and "char * []"
+    const char *file = cmd[0].c_str();
+    char *argv[cmd.size() + 1];
+    for (int i = 0; i < cmd.size(); ++i)
+      argv[i] = const_cast<char *>(cmd[i].c_str());
+    argv[cmd.size()] = NULL;
+    // run command
+    execvp(file, argv);
+    // exec() functions return only if an error has occurred
+    _exit(errno);
+  }
+
+  // parent: close unused pipe ends
+  close(pipe_in[0]);
+  close(pipe_out[1]);
+  close(pipe_err[1]);
+  // process stdin
+  if (!in.empty()) {
+    if ((ret = write(pipe_in[1], in.c_str(), in.length())) < in.length()) {
+      log_perror(errno, "run_cmd:Error-write() failed-");
+      // kill the child
+      kill(pid, SIGKILL);
+      wait(NULL);
+      return -4;
+    }
+  }
+  close(pipe_in[1]);
+
+  // start reading stdout/stderr
+  struct FancyPipe {
+      int fd;
+      std::ostream *stream_ptr;
+  } fancypipes[] = {
+      { pipe_out[0], out },
+      { pipe_err[0], err },
+  };
+  fd_set fdread;
+  char buf[1024];
+
+  int total_output = 0;
+  int retcode = 0;
+  while (fancypipes[0].fd >= 0 || fancypipes[1].fd >= 0) {
+    if (total_output > kMaxRunCmdOutput) {
+      log("run_cmd:Error-command output is too large (%i bytes > %i)",
+          total_output, kMaxRunCmdOutput);
+      // kill the child
+      kill(pid, SIGKILL);
+      retcode = -3;
+      break;
+    }
+    FD_ZERO(&fdread);
+    int max_fd = -1;
+    struct timeval tv, *timeout = NULL;
+    if (timeout_usec >= 0) {
+      tv.tv_sec = timeout_usec / 1000000;
+      tv.tv_usec = (timeout_usec % 1000000) * 1000000;
+      timeout = &tv;
+    }
+    for (const auto &fancypipe : fancypipes) {
+      if (fancypipe.fd >= 0) {
+        FD_SET(fancypipe.fd, &fdread);
+        max_fd = MAX(max_fd, fancypipe.fd);
+      }
+    }
+    int select_ret = select(max_fd + 1, &fdread, NULL, NULL, timeout);
+    if (select_ret == 0) {
+      // timeout
+      log("run_cmd:Error-command timed out");
+      // kill the child
+      kill(pid, SIGKILL);
+      retcode = -2;
+      break;
+    } else if (select_ret == -1) {
+      if (errno == EINTR) {
+        // interrupted by signal
+        retcode = -1;
+        break;
+      }
+      log_perror(errno, "run_cmd:Error-pipe select failed-");
+      retcode = -1;
+      break;
+    }
+    for (auto &fancypipe : fancypipes) {
+      if (fancypipe.fd >= 0 && FD_ISSET(fancypipe.fd, &fdread)) {
+        ssize_t len;
+        len = read(fancypipe.fd, buf, sizeof(buf));
+        if (len <= 0) {
+          close(fancypipe.fd);
+          fancypipe.fd = -1;
+          if (len < 0)
+            retcode = -1;
+          continue;
+        }
+        total_output += len;
+        if (fancypipe.stream_ptr)
+          fancypipe.stream_ptr->write(buf, len);
+      }
+    }
+  }
+
+  *status = 0;
+  wait(status);
+  // interpret child exit status
+  if (WIFEXITED(*status))
+    *status = WEXITSTATUS(*status);
+  return retcode;
+}
+
+//
+// String printf functions, ported from stringprintf.cc/h
+//
+
+void StringAppendV(std::string* dst, const char* format, va_list ap) {
+  // First try with a small fixed size buffer
+  static const int kSpaceLength = 1024;
+  char space[kSpaceLength];
+
+  // It's possible for methods that use a va_list to invalidate
+  // the data in it upon use.  The fix is to make a copy
+  // of the structure before using it and use that copy instead.
+  va_list backup_ap;
+  va_copy(backup_ap, ap);
+  int result = vsnprintf(space, kSpaceLength, format, backup_ap);
+  va_end(backup_ap);
+
+  if (result < kSpaceLength) {
+    if (result >= 0) {
+      // Normal case -- everything fit.
+      dst->append(space, result);
+      return;
+    }
+    if (result < 0) {
+      // Just an error.
+      return;
+    }
+  }
+
+  // Increase the buffer size to the size requested by vsnprintf,
+  // plus one for the closing \0.
+  int length = result+1;
+  char* buf = new char[length];
+
+  // Restore the va_list before we use it again
+  va_copy(backup_ap, ap);
+  result = vsnprintf(buf, length, format, backup_ap);
+  va_end(backup_ap);
+
+  if (result >= 0 && result < length) {
+    // It fit
+    dst->append(buf, result);
+  }
+  delete[] buf;
+}
+
+std::string StringPrintf(const char* format, ...) {
+  va_list ap;
+  va_start(ap, format);
+  std::string result;
+  StringAppendV(&result, format, ap);
+  va_end(ap);
+  return result;
+}
+
+}  // namespace libexperiments_utils
diff --git a/libexperiments/utils.h b/libexperiments/utils.h
new file mode 100644
index 0000000..3c1adb4
--- /dev/null
+++ b/libexperiments/utils.h
@@ -0,0 +1,83 @@
+#ifndef _LIBEXPERIMENTS_UTILS_H_
+#define _LIBEXPERIMENTS_UTILS_H_
+
+#include <stdint.h>
+#include <sys/time.h>
+#include <sys/types.h>
+#include <sys/stat.h>
+#include <unistd.h>
+
+#include <string>
+#include <ostream>
+#include <vector>
+
+//
+// Subset of utils functions copied from vendor/google/mcastcapture/utils/.
+//
+
+// Namespace is needed to avoid conflicts when other modules with identical
+// function names are linking against libexperiments.so.
+namespace libexperiments_utils {
+
+#define MAX(x, y) ((x) > (y) ? (x) : (y))
+#define MIN(x, y) ((x) < (y) ? (x) : (y))
+
+const int64_t kUsecsPerSec = 1000000LL;
+const int64_t kNsecsPerSec = 1000000000LL;
+const int64_t kNsecsPerUsec = 1000LL;
+
+static inline int64_t secs_to_usecs(int64_t secs) {
+  return secs * kUsecsPerSec;
+}
+
+void log(const char* cstr, ...)
+    __attribute__((format(printf, 1, 2)));
+
+void log_perror(int err, const char* cstr, ...)
+    __attribute__((format(printf, 2, 3)));
+
+// Measures elapsed time in usecs.
+uint64_t us_elapse(uint64_t start_time_us);
+
+void us_sleep(uint64_t usecs);
+
+static inline bool file_exists(const char *name) {
+  return access(name, F_OK) == 0;
+}
+
+static inline bool directory_exists(const char *path) {
+  struct stat dir_stat;
+  if (stat(path, &dir_stat) != 0) {
+    return false;
+  }
+  return S_ISDIR(dir_stat.st_mode);
+}
+
+// This function runs the command cmd (but not in a shell), providing the
+// return code, stdout, and stderr. It also allows to specify the stdin,
+// and a command timeout value (timeout_usec, use <0 to block indefinitely).
+// Note that the timeout is fired only if the command stops producing
+// either stdout or stderr. A process that periodically produces output
+// will never be killed.
+// Returns an extended error code:
+// - 0 if everything is successful,
+//  -1 if any step fails,
+//  -2 if timeout, and
+//  -3 if the command output (stdout+stderr) is too large (kMaxRunCmdOutput).
+int run_cmd(const std::vector<std::string> &cmd, const std::string &in,
+            int *status,
+            std::ostream *out,
+            std::ostream *err,
+            int64_t timeout_usec);
+
+//
+// String printf functions, ported from stringprintf.cc/h
+//
+
+// Return a C++ string
+std::string StringPrintf(const char* format, ...)
+    __attribute__((format(printf, 1, 2)));
+
+}  // namespace libexperiments_utils
+
+#endif  // _LIBEXPERIMENTS_UTILS_H_
diff --git a/logupload/client/Makefile b/logupload/client/Makefile
index f7a56d3..5e72977 100644
--- a/logupload/client/Makefile
+++ b/logupload/client/Makefile
@@ -10,7 +10,7 @@
 
 CFLAGS+=-Wall -Werror $(EXTRACFLAGS)
 LDFLAGS+=$(EXTRALDFLAGS)
-LIBS=-lrt -lcurl -lz -lm
+LIBS=-lrt -lcurl -lz -lm -lcrypto
 
 # Test Flags
 TEST_LDFLAGS=$(LDFLAGS)
diff --git a/logupload/client/log_uploader.c b/logupload/client/log_uploader.c
index 5b63174..8aa0990 100644
--- a/logupload/client/log_uploader.c
+++ b/logupload/client/log_uploader.c
@@ -1,3 +1,4 @@
+#include <ctype.h>
 #include <stdlib.h>
 #include <errno.h>
 #include <fcntl.h>
@@ -6,6 +7,10 @@
 #include <time.h>
 #include <unistd.h>
 #include <inttypes.h>
+#include <openssl/md5.h>
+#include <openssl/hmac.h>
+#include <sys/types.h>
+#include <sys/stat.h>
 
 #include "log_uploader.h"
 #include "utils.h"
@@ -108,6 +113,8 @@
       }
     }
 
+    num_read = suppress_mac_addresses(params->line_buffer, num_read);
+
     // Parse the data on the line to get the extra information
     if (parse_line_data(params->line_buffer, &parsed_line)) {
       // We don't want to be fatal if we fail to parse a line for some
@@ -177,3 +184,158 @@
   }
   return params->log_buffer;
 }
+
+const char SOFT[] = "AEIOUY" "V";
+const char HARD[] = "BCDFGHJKLMNPQRSTVWXYZ" "AEIOU";
+const char *consensus_key_file = "/tmp/waveguide/consensus_key";
+#define CONSENSUS_KEY_LEN 16
+uint8_t consensus_key[CONSENSUS_KEY_LEN];
+#define MAC_ADDR_LEN 17
+
+void default_consensus_key()
+{
+  int fd;
+
+  if ((fd = open("/dev/urandom", O_RDONLY)) >= 0) {
+    ssize_t siz = sizeof(consensus_key);
+    if (read(fd, consensus_key, siz) != siz) {
+      /* https://xkcd.com/221/ */
+      memset(consensus_key, time(NULL), siz);
+    }
+    close(fd);
+  }
+}
+
+/* Read the waveguide consensus_key, if any */
+static void get_consensus_key()
+{
+  static ino_t ino = 0;
+  static time_t mtime = 0;
+  struct stat statbuf;
+  int fd;
+
+  if (stat(consensus_key_file, &statbuf)) {
+    if ((statbuf.st_ino == ino) && (statbuf.st_mtime == mtime)) {
+      return;
+    }
+  }
+
+  fd = open(consensus_key_file, O_RDONLY);
+  if (fd >= 0) {
+    uint8_t new_key[sizeof(consensus_key)];
+    if (read(fd, new_key, sizeof(new_key)) == sizeof(new_key)) {
+      memcpy(consensus_key, new_key, sizeof(consensus_key));
+      ino = statbuf.st_ino;
+      mtime = statbuf.st_mtime;
+    }
+    close(fd);
+  }
+}
+
+/* Given a value from 0..4095, encode it as a cons+vowel+cons sequence. */
+static void trigraph(int num, char *out)
+{
+  int ns = sizeof(SOFT) - 1;
+  int nh = sizeof(HARD) - 1;
+  int c1, c2, c3;
+
+  c3 = num % nh;
+  c2 = (num / nh) % ns;
+  c1 = num / nh / ns;
+  out[0] = HARD[c1];
+  out[1] = SOFT[c2];
+  out[2] = HARD[c3];
+}
+
+static int hex_chr_to_int(char hex) {
+  switch(hex) {
+    case '0' ... '9':
+      return hex - '0';
+    case 'a' ... 'f':
+      return hex - 'a' + 10;
+    case 'A' ... 'F':
+      return hex - 'A' + 10;
+  }
+
+  return 0;
+}
+
+/*
+ * Convert a string of the form "00:11:22:33:44:55" to
+ * a binary array 001122334455.
+ */
+static void get_binary_mac(const char *mac, uint8_t *out) {
+  int i;
+  for (i = 0; i < MAC_ADDR_LEN; i += 3) {
+    *out = (hex_chr_to_int(mac[i]) << 4) | hex_chr_to_int(mac[i+1]);
+    out++;
+  }
+}
+
+static const char *get_anonid_for_mac(const char *mac, char *out) {
+  unsigned char digest[EVP_MAX_MD_SIZE];
+  unsigned int digest_len = sizeof(digest);
+  uint8_t macbin[6];
+  uint32_t num;
+
+  get_consensus_key();
+  get_binary_mac(mac, macbin);
+  HMAC(EVP_md5(), consensus_key, sizeof(consensus_key), macbin, sizeof(macbin),
+      digest, &digest_len);
+  num = (digest[0] << 16) | (digest[1] << 8) | digest[2];
+  trigraph((num >> 12) & 0x0fff, out);
+  trigraph((num      ) & 0x0fff, out + 3);
+
+  return out;
+}
+
+static ssize_t anonymize_mac_address(char *s, ssize_t len) {
+  char anonid[6];
+  ssize_t offset = MAC_ADDR_LEN - sizeof(anonid);
+
+  get_anonid_for_mac(s, anonid);
+  memcpy(s, anonid, sizeof(anonid));
+  s += sizeof(anonid);
+  len -= offset;
+  memmove(s, s + offset, len);
+  return offset;
+}
+
+static int is_mac_addr(const char *s, char sep) {
+  if ((s[2] == sep) && (s[5] == sep) && (s[8] == sep) &&
+      (s[11] == sep) && (s[14] == sep) &&
+      isxdigit(s[0]) && isxdigit(s[1]) &&
+      isxdigit(s[3]) && isxdigit(s[4]) &&
+      isxdigit(s[6]) && isxdigit(s[7]) &&
+      isxdigit(s[9]) && isxdigit(s[10]) &&
+      isxdigit(s[12]) && isxdigit(s[13]) &&
+      isxdigit(s[15]) && isxdigit(s[16])) {
+    return 1;
+  }
+
+  return 0;
+}
+
+/*
+ * search for text patterns which look like MAC addresses,
+ * and anonymize them.
+ * Ex: f8:8f:ca:00:00:01 to PEEVJB
+ */
+unsigned long suppress_mac_addresses(char *line, ssize_t len) {
+  char *s = line;
+  unsigned long new_len = len;
+  ssize_t reduce;
+
+  while (len >= MAC_ADDR_LEN) {
+    if (is_mac_addr(s, ':') || is_mac_addr(s, '-') || is_mac_addr(s, '_')) {
+      reduce = anonymize_mac_address(s, len);
+      len -= reduce;
+      new_len -= reduce;
+    } else {
+      s += 1;
+      len -= 1;
+    }
+  }
+
+  return new_len;
+}
diff --git a/logupload/client/log_uploader.h b/logupload/client/log_uploader.h
index bb6011c..4a3672f 100644
--- a/logupload/client/log_uploader.h
+++ b/logupload/client/log_uploader.h
@@ -49,6 +49,13 @@
 int logmark_once(const char* output_path, const char* version_path,
     const char* ntp_sync_path);
 
+// Rewrite any MAC addresses of the form 00:11:22:33:44:55 (or similar)
+// as anonids like ABCDEF.
+unsigned long suppress_mac_addresses(char *line, ssize_t len);
+
+// initialize a random key for anonymization.
+void default_consensus_key();
+
 #ifdef __cplusplus
 }
 #endif
diff --git a/logupload/client/log_uploader_main.c b/logupload/client/log_uploader_main.c
index 8d3afc4..7a4e0fb 100644
--- a/logupload/client/log_uploader_main.c
+++ b/logupload/client/log_uploader_main.c
@@ -170,6 +170,8 @@
   snprintf(config.upload_target, sizeof(config.upload_target), "%s",
         DEFAULT_UPLOAD_TARGET);
 
+  default_consensus_key();
+
   if (argc > 1) {
     if (parse_args(&config, argc, argv) < 0) {
       usage(argv[0]);
diff --git a/logupload/client/log_uploader_test.cc b/logupload/client/log_uploader_test.cc
index af10f41..ad3d61f 100644
--- a/logupload/client/log_uploader_test.cc
+++ b/logupload/client/log_uploader_test.cc
@@ -315,3 +315,30 @@
   remove(test_dev_kmsg_path);
   rmdir(tdir);
 }
+
+
+struct log_data test_MAC_data[] = {
+  { 1, 1000LL, 100LL, "-", "f8:8f:ca:00:00:01\n", NULL },
+  { 4, 1001LL, 101LL, "-", "8:8f:ca:00:00:01\n", NULL },
+  { 2, 1010LL, 102LL, "-", "8:8f:ca:00:00:01:\n", NULL },
+  { 5, 2030000LL, 104LL, "-", ":::semicolons:f8:8f:ca:00:00:01:and:after\n",
+      NULL },
+  { 3, 3030000LL, 105LL, "-", "f8-8f-ca-00-00-01\n", NULL },
+  { 3, 3030000LL, 105LL, "-", "f8_8f_ca_00_00_01\n", NULL },
+};
+int test_MAC_data_size = sizeof(test_MAC_data) / sizeof(struct log_data);
+
+
+TEST(LogUploader, anonymize_mac_addresses) {
+  struct log_parse_params* params = create_log_parse_params(test_MAC_data,
+      test_MAC_data_size);
+  char* res_buffer = parse_and_consume_log_data(params);
+
+  /* Verify that the MAC address has been anonymized. */
+  printf("%s\n", res_buffer);
+  EXPECT_TRUE(strstr(res_buffer, "f8:8f:ca:00:00:01") == NULL);
+  EXPECT_TRUE(strstr(res_buffer, "f8-8f-ca-00-00-01") == NULL);
+  EXPECT_TRUE(strstr(res_buffer, "f8_8f_ca_00_00_01") == NULL);
+
+  free_log_parse_params(params);
+}
diff --git a/speedtest/Makefile b/speedtest/Makefile
index 56dc3e1..af5ea27 100644
--- a/speedtest/Makefile
+++ b/speedtest/Makefile
@@ -4,27 +4,65 @@
 BINDIR=$(PREFIX)/bin
 DEBUG?=-g
 WARNINGS=-Wall -Werror -Wno-unused-result -Wno-unused-but-set-variable
-CXXFLAGS=$(DEBUG) $(WARNINGS) -O3 -DNDEBUG -std=c++11 $(EXTRACFLAGS)
+CXXFLAGS=$(DEBUG) $(WARNINGS) -DNDEBUG -std=c++11 $(EXTRACFLAGS)
+#CXXFLAGS=$(DEBUG) $(WARNINGS) -O3 -DNDEBUG -std=c++11 $(EXTRACFLAGS)
 LDFLAGS=$(DEBUG) $(EXTRALDFLAGS)
 
 GTEST_DIR=googletest
 GMOCK_DIR=googlemock
 TFLAGS=$(DEBUG) -isystem ${GTEST_DIR}/include -isystem $(GMOCK_DIR)/include -pthread -std=c++11
 
-LIBS=-lcurl -lpthread
+LIBS=-lcurl -lpthread -ljsoncpp
 TOBJS=curl_env.o url.o errors.o request.o utils.o
-OBJS=errors.o curl_env.o options.o request.o utils.o speedtest.o url.o
+OBJS=config.o \
+     curl_env.o \
+     download_task.o \
+     errors.o \
+     http_task.o \
+     options.o \
+     ping_task.o \
+     request.o \
+     speedtest.o \
+     task.o \
+     timed_runner.o \
+     transfer_runner.o \
+     transfer_task.o \
+     upload_task.o \
+     url.o \
+     utils.o
 
 all: speedtest
 
+config.o: config.cc config.h url.h
 errors.o: errors.cc errors.h
 curl_env.o: curl_env.cc curl_env.h errors.h request.h
+download_task.o: download_task.cc download_task.h transfer_task.h utils.h
+http_task.o: http_task.cc http_task.h
 options.o: options.cc options.h url.h
-utils.o: utils.cc options.h
-request.o: request.cc request.h curl_env.h url.h
-url.o: url.cc url.h
-speedtest.o: speedtest.cc speedtest.h curl_env.h options.h request.h url.h
+ping_task.o: ping_task.cc ping_task.h http_task.h request.h url.h utils.h
+request.o: request.cc request.h url.h
+speedtest.o: speedtest.cc \
+             speedtest.h \
+             config.h \
+             curl_env.h \
+             download_task.h \
+             options.h \
+             ping_task.h \
+             request.h \
+             task.h \
+             timed_runner.h \
+             transfer_runner.h \
+             upload_task.h \
+             url.h
 speedtest_main.o: speedtest_main.cc options.h speedtest.h
+task.o: task.cc task.h utils.h
+timed_runner.o: timed_runner.cc timed_runner.h task.h
+transfer_runner.o: transfer_runner.cc transfer_runner.h transfer_task.h utils.h
+transfer_task.o: transfer_task.cc transfer_task.h http_task.h
+upload_task.o: upload_task.cc upload_task.h transfer_task.h utils.h
+utils.o: utils.cc options.h
+url.o: url.cc url.h utils.h
+
 speedtest: speedtest_main.o $(OBJS)
 	$(CXX) -o $@ $< $(OBJS) $(LDFLAGS) $(LIBS)
 
@@ -45,14 +83,14 @@
 libspeedtesttest.a: $(TOBJS)
 	ar -rv libspeedtesttest.a $(TOBJS)
 
-%_test.o: %_test.cc %.h
+%_test.o: %_test.cc %.h %.cc
 	$(CXX) -c $< $(TFLAGS) $(CXXFLAGS)
 
 %_test: %_test.o %.o libgmock.a libspeedtesttest.a
 	$(CXX) -o $@ $(TFLAGS) googlemock/src/gmock_main.cc $< $*.o $(LDFLAGS) $(LIBS) libgmock.a libspeedtesttest.a
 	./$@
 
-test: options_test request_test url_test
+test: config_test options_test request_test url_test
 
 install: speedtest
 	$(INSTALL) -m 0755 speedtest $(BINDIR)/
diff --git a/speedtest/config.cc b/speedtest/config.cc
new file mode 100644
index 0000000..aede959
--- /dev/null
+++ b/speedtest/config.cc
@@ -0,0 +1,97 @@
+/*
+ * Copyright 2016 Google Inc. All rights reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+#include "config.h"
+
+// For some reason, the libjsoncpp package installs to /usr/include/jsoncpp/json
+// instead of /usr{,/local}/include/json
+#include <jsoncpp/json/json.h>
+
+namespace speedtest {
+
+bool ParseConfig(const std::string &json, Config *config) {
+  if (!config) {
+    return false;
+  }
+
+  Json::Reader reader;
+  Json::Value root;
+  if (!reader.parse(json, root, false)) {
+    return false;
+  }
+
+  config->download_size = root["downloadSize"].asInt();
+  config->upload_size = root["uploadSize"].asInt();
+  config->interval_millis = root["intervalSize"].asInt();
+  config->location_name = root["locationName"].asString();
+  config->min_transfer_intervals = root["minTransferIntervals"].asInt();
+  config->max_transfer_intervals = root["maxTransferIntervals"].asInt();
+  config->min_transfer_runtime = root["minTransferRunTime"].asInt();
+  config->max_transfer_runtime = root["maxTransferRunTime"].asInt();
+  config->max_transfer_variance = root["maxTransferVariance"].asDouble();
+  config->num_uploads = root["numConcurrentUploads"].asInt();
+  config->num_downloads = root["numConcurrentDownloads"].asInt();
+  config->ping_runtime = root["pingRunTime"].asInt();
+  config->ping_timeout = root["pingTimeout"].asInt();
+  config->transfer_port_start = root["transferPortStart"].asInt();
+  config->transfer_port_end = root["transferPortEnd"].asInt();
+  return true;
+}
+
+bool ParseServers(const std::string &json, std::vector<http::Url> *servers) {
+  if (!servers) {
+    return false;
+  }
+
+  Json::Reader reader;
+  Json::Value root;
+  if (!reader.parse(json, root, false)) {
+    return false;
+  }
+
+  for (const auto &it : root["regionalServers"]) {
+    http::Url url(it.asString());
+    if (!url.ok()) {
+      return false;
+    }
+    servers->emplace_back(url);
+  }
+  return true;
+}
+
+void PrintConfig(const Config &config) {
+  PrintConfig(std::cout, config);
+}
+
+void PrintConfig(std::ostream &out, const Config &config) {
+  out << "Download size: " << config.download_size << " bytes\n"
+      << "Upload size: " << config.upload_size << " bytes\n"
+      << "Interval size: " << config.interval_millis << " ms\n"
+      << "Location name: " << config.location_name << "\n"
+      << "Min transfer intervals: " << config.min_transfer_intervals << "\n"
+      << "Max transfer intervals: " << config.max_transfer_intervals << "\n"
+      << "Min transfer runtime: " << config.min_transfer_runtime << " ms\n"
+      << "Max transfer runtime: " << config.max_transfer_runtime << " ms\n"
+      << "Max transfer variance: " << config.max_transfer_variance << "\n"
+      << "Number of downloads: " << config.num_downloads << "\n"
+      << "Number of uploads: " << config.num_uploads << "\n"
+      << "Ping runtime: " << config.ping_runtime << " ms\n"
+      << "Ping timeout: " << config.ping_timeout << " ms\n"
+      << "Transfer port start: " << config.transfer_port_start << "\n"
+      << "Transfer port end: " << config.transfer_port_end << "\n";
+}
+
+}  // namespace
diff --git a/speedtest/config.h b/speedtest/config.h
new file mode 100644
index 0000000..2988484
--- /dev/null
+++ b/speedtest/config.h
@@ -0,0 +1,60 @@
+/*
+ * Copyright 2016 Google Inc. All rights reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+#ifndef SPEEDTEST_CONFIG_H
+#define SPEEDTEST_CONFIG_H
+
+#include <iostream>
+#include <string>
+#include <vector>
+#include "url.h"
+
+namespace speedtest {
+
+struct Config {
+  int download_size = 0;
+  int upload_size = 0;
+  int interval_millis = 0;
+  std::string location_name;
+  int min_transfer_intervals = 0;
+  int max_transfer_intervals = 0;
+  int min_transfer_runtime = 0;
+  int max_transfer_runtime = 0;
+  double max_transfer_variance = 0;
+  int num_downloads = 0;
+  int num_uploads = 0;
+  int ping_runtime = 0;
+  int ping_timeout = 0;
+  int transfer_port_start = 0;
+  int transfer_port_end = 0;
+};
+
+// Parses a JSON document into a config struct.
+// Returns true with the config struct populated on success.
+// Returns false if the JSON is invalid or config is null.
+bool ParseConfig(const std::string &json, Config *config);
+
+// Parses a JSON document into a list of server URLs
+// Returns true with the servers populated in the vector on success.
+// Returns false if the JSON is invalid or servers is null.
+bool ParseServers(const std::string &json, std::vector<http::Url> *servers);
+
+void PrintConfig(const Config &config);
+void PrintConfig(std::ostream &out, const Config &config);
+
+}  // namespace speedtest
+
+#endif //SPEEDTEST_CONFIG_H
diff --git a/speedtest/config_test.cc b/speedtest/config_test.cc
new file mode 100644
index 0000000..5924921
--- /dev/null
+++ b/speedtest/config_test.cc
@@ -0,0 +1,132 @@
+/*
+ * Copyright 2016 Google Inc. All rights reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+#include "config.h"
+
+#include <gtest/gtest.h>
+#include <gmock/gmock.h>
+
+namespace speedtest {
+namespace {
+
+const char *kValidConfig = R"CONFIG(
+{
+    "downloadSize": 10000000,
+    "intervalSize": 200,
+    "locationName": "Kansas City",
+    "maxTransferIntervals": 25,
+    "maxTransferRunTime": 20000,
+    "maxTransferVariance": 0.08,
+    "minTransferIntervals": 10,
+    "minTransferRunTime": 5000,
+    "numConcurrentDownloads": 20,
+    "numConcurrentUploads": 15,
+    "pingRunTime": 3000,
+    "pingTimeout": 300,
+    "transferPortEnd": 3023,
+    "transferPortStart": 3004,
+    "uploadSize": 20000000
+}
+)CONFIG";
+
+const char *kValidServers = R"SERVERS(
+{
+    "locationName": "Kansas City",
+    "regionalServers": [
+        "http://austin.speed.googlefiber.net/",
+        "http://kansas.speed.googlefiber.net/",
+        "http://provo.speed.googlefiber.net/",
+        "http://stanford.speed.googlefiber.net/"
+    ]
+}
+)SERVERS";
+
+const char *kInvalidServers = R"SERVERS(
+{
+    "locationName": "Kansas City",
+    "regionalServers": [
+        "example.com..",
+    ]
+}
+)SERVERS";
+
+const char *kInvalidJson = "{{}{";
+
+TEST(ParseConfigTest, NullConfig_Invalid) {
+  EXPECT_FALSE(ParseConfig(kValidConfig, nullptr));
+}
+
+TEST(ParseConfigTest, EmptyJson_Invalid) {
+  Config config;
+  EXPECT_FALSE(ParseConfig("", &config));
+}
+
+TEST(ParseConfigTest, InvalidJson_Invalid) {
+  Config config;
+  EXPECT_FALSE(ParseConfig(kInvalidJson, &config));
+}
+
+TEST(ParseConfigTest, FullConfig_Valid) {
+  Config config;
+  EXPECT_TRUE(ParseConfig(kValidConfig, &config));
+  EXPECT_EQ(10000000, config.download_size);
+  EXPECT_EQ(20000000, config.upload_size);
+  EXPECT_EQ(20, config.num_downloads);
+  EXPECT_EQ(15, config.num_uploads);
+  EXPECT_EQ(200, config.interval_millis);
+  EXPECT_EQ("Kansas City", config.location_name);
+  EXPECT_EQ(10, config.min_transfer_intervals);
+  EXPECT_EQ(25, config.max_transfer_intervals);
+  EXPECT_EQ(5000, config.min_transfer_runtime);
+  EXPECT_EQ(20000, config.max_transfer_runtime);
+  EXPECT_EQ(0.08, config.max_transfer_variance);
+  EXPECT_EQ(3000, config.ping_runtime);
+  EXPECT_EQ(300, config.ping_timeout);
+  EXPECT_EQ(3004, config.transfer_port_start);
+  EXPECT_EQ(3023, config.transfer_port_end);
+}
+
+TEST(ParseServersTest, NullServers_Invalid) {
+  EXPECT_FALSE(ParseServers(kValidServers, nullptr));
+}
+
+TEST(ParseServersTest, EmptyServers_Invalid) {
+  std::vector<http::Url> servers;
+  EXPECT_FALSE(ParseServers("", &servers));
+}
+
+TEST(ParseServersTest, InvalidJson_Invalid) {
+  std::vector<http::Url> servers;
+  EXPECT_FALSE(ParseServers(kInvalidJson, &servers));
+}
+
+TEST(ParseServersTest, FullServers_Valid) {
+  std::vector<http::Url> servers;
+  EXPECT_TRUE(ParseServers(kValidServers, &servers));
+  EXPECT_THAT(servers, testing::UnorderedElementsAre(
+      http::Url("http://austin.speed.googlefiber.net/"),
+      http::Url("http://kansas.speed.googlefiber.net/"),
+      http::Url("http://provo.speed.googlefiber.net/"),
+      http::Url("http://stanford.speed.googlefiber.net/")));
+}
+
+TEST(ParseServersTest, InvalidServers_Invalid) {
+  std::vector<http::Url> servers;
+  EXPECT_FALSE(ParseServers(kInvalidServers, &servers));
+}
+
+}  // namespace
+}  // namespace speedtest
diff --git a/speedtest/curl_env.cc b/speedtest/curl_env.cc
index c02835c..eed370f 100644
--- a/speedtest/curl_env.cc
+++ b/speedtest/curl_env.cc
@@ -1,5 +1,5 @@
 /*
- * Copyright 2015 Google Inc. All rights reserved.
+ * Copyright 2016 Google Inc. All rights reserved.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -18,34 +18,106 @@
 
 #include <cstdlib>
 #include <iostream>
-#include <curl/curl.h>
 #include "errors.h"
 #include "request.h"
 
 namespace http {
+namespace {
 
-CurlEnv::CurlEnv() {
-  init(CURL_GLOBAL_NOTHING);
+void LockFn(CURL *handle,
+            curl_lock_data data,
+            curl_lock_access access,
+            void *userp) {
+  CurlEnv *env = static_cast<CurlEnv *>(userp);
+  env->Lock(data);
 }
 
-CurlEnv::CurlEnv(int init_options) {
-  init(init_options);
+void UnlockFn(CURL *handle, curl_lock_data data, void *userp) {
+  CurlEnv *env = static_cast<CurlEnv *>(userp);
+  env->Unlock(data);
 }
 
-CurlEnv::~CurlEnv() {
-  curl_global_cleanup();
+}  // namespace
+
+std::shared_ptr<CurlEnv> CurlEnv::NewCurlEnv(const Options &options) {
+  return std::shared_ptr<CurlEnv>(new CurlEnv(options));
 }
 
-std::unique_ptr<Request> CurlEnv::NewRequest() {
-  return std::unique_ptr<Request>(new Request(shared_from_this()));
-}
-
-void CurlEnv::init(int init_flags) {
-  CURLcode status = curl_global_init(init_flags);
+CurlEnv::CurlEnv(const Options &options)
+    : options_(options),
+      set_max_connections_(false),
+      share_(nullptr) {
+  CURLcode status;
+  {
+    std::lock_guard <std::mutex> lock(curl_mutex_);
+    status = curl_global_init(options_.curl_options);
+  }
   if (status != 0) {
     std::cerr << "Curl initialization failed: " << ErrorString(status);
     std::exit(1);
   }
+  if (!options_.disable_dns_cache) {
+    share_ = curl_share_init();
+    curl_share_setopt(share_, CURLSHOPT_SHARE, CURL_LOCK_DATA_DNS);
+    curl_share_setopt(share_, CURLSHOPT_USERDATA, this);
+    curl_share_setopt(share_, CURLSHOPT_LOCKFUNC, &LockFn);
+    curl_share_setopt(share_, CURLSHOPT_UNLOCKFUNC, &UnlockFn);
+  }
+}
+
+CurlEnv::~CurlEnv() {
+  curl_share_cleanup(share_);
+  share_ = nullptr;
+  curl_global_cleanup();
+}
+
+std::unique_ptr<Request> CurlEnv::NewRequest(const Url &url) {
+  // curl_global_init is not threadsafe and calling curl_easy_init may
+  // implicitly call it so we need to mutex lock on creating all requests
+  // to ensure the global initialization is done in a threadsafe manner.
+  std::lock_guard <std::mutex> lock(curl_mutex_);
+
+  // We use an aliasing constructor on a shared_ptr to keep a reference to
+  // CurlEnv as when the refcount drops to 0 we want to do global cleanup.
+  // So the CURL handle for this shared_ptr is _unmanaged_ and the Request
+  // object is responsible for cleaning it up, which involves calling
+  // curl_easy_cleanup().
+  //
+  // This way Request doesn't need to know about CurlEnv at all, while
+  // all Request instances will still keep an implicit reference to
+  // CurlEnv.
+  std::shared_ptr<CURL> handle(shared_from_this(), curl_easy_init());
+
+  // For some reason libcurl sets the max connections on a handle.
+  // According to the docs, doing so when there are open connections may
+  // close them so we maintain this boolean so as to set the maximum
+  // number of connections on the connection pool associated with this
+  // handle before any connections are opened.
+  if (!set_max_connections_ && options_.max_connections > 0) {
+    curl_easy_setopt(handle.get(),
+                     CURLOPT_MAXCONNECTS,
+                     options_.max_connections);
+    set_max_connections_ = true;
+  }
+
+  curl_easy_setopt(handle.get(), CURLOPT_SHARE, share_);
+  return std::unique_ptr<Request>(new Request(handle, url));
+}
+
+void CurlEnv::Lock(curl_lock_data lock_type) {
+  if (lock_type == CURL_LOCK_DATA_DNS) {
+    // It is ill-advised to call lock directly but libcurl uses
+    // separate lock/unlock functions.
+    dns_mutex_.lock();
+  }
+}
+
+void CurlEnv::Unlock(curl_lock_data lock_type) {
+  if (lock_type == CURL_LOCK_DATA_DNS) {
+    // It is ill-advised to call lock directly but libcurl uses
+    // separate lock/unlock functiounknns.
+    dns_mutex_.unlock();
+  }
 }
 
 }  // namespace http
diff --git a/speedtest/curl_env.h b/speedtest/curl_env.h
index 09511fa..6a70f28 100644
--- a/speedtest/curl_env.h
+++ b/speedtest/curl_env.h
@@ -1,5 +1,5 @@
 /*
- * Copyright 2015 Google Inc. All rights reserved.
+ * Copyright 2016 Google Inc. All rights reserved.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -17,27 +17,45 @@
 #ifndef HTTP_CURL_ENV_H
 #define HTTP_CURL_ENV_H
 
+#include <curl/curl.h>
 #include <memory>
+#include <mutex>
+#include "url.h"
 
 namespace http {
 
 class Request;
 
-// Curl initialization to cleanup automatically
 class CurlEnv : public std::enable_shared_from_this<CurlEnv> {
  public:
-  CurlEnv();
-  explicit CurlEnv(int init_options);
+  struct Options {
+    int curl_options = CURL_GLOBAL_NOTHING;
+    bool disable_dns_cache = false;
+    int max_connections = 0;
+  };
+
+  static std::shared_ptr<CurlEnv> NewCurlEnv(const Options &options);
   virtual ~CurlEnv();
 
-  std::unique_ptr<Request> NewRequest();
+  std::unique_ptr<Request> NewRequest(const Url &url);
+
+  void Lock(curl_lock_data lock_type);
+  void Unlock(curl_lock_data lock_type);
 
  private:
-  void init(int flags);
+  explicit CurlEnv(const Options &options);
+
+  Options options_;
+
+  // used to lock on curl global state
+  std::mutex curl_mutex_;
+  bool set_max_connections_;
+
+  std::mutex dns_mutex_;
+  CURLSH *share_;  // owned
 
   // disable
   CurlEnv(const CurlEnv &other) = delete;
-
   void operator=(const CurlEnv &other) = delete;
 };
 
diff --git a/speedtest/download_task.cc b/speedtest/download_task.cc
new file mode 100644
index 0000000..a643725
--- /dev/null
+++ b/speedtest/download_task.cc
@@ -0,0 +1,78 @@
+/*
+ * Copyright 2016 Google Inc. All rights reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+#include "download_task.h"
+
+#include <algorithm>
+#include <cassert>
+#include <iostream>
+#include <thread>
+#include "utils.h"
+
+namespace speedtest {
+
+DownloadTask::DownloadTask(const Options &options)
+    : TransferTask(options_),
+      options_(options) {
+  assert(options_.num_transfers > 0);
+  assert(options_.download_size > 0);
+}
+
+void DownloadTask::RunInternal() {
+  ResetCounters();
+  threads_.clear();
+  if (options_.verbose) {
+    std::cout << "Downloading " << options_.num_transfers
+              << " threads with " << options_.download_size << " bytes\n";
+  }
+  for (int i = 0; i < options_.num_transfers; ++i) {
+    threads_.emplace_back([=]{
+      RunDownload(i);
+    });
+  }
+}
+
+void DownloadTask::StopInternal() {
+  std::for_each(threads_.begin(), threads_.end(), [](std::thread &t) {
+    t.join();
+  });
+}
+
+void DownloadTask::RunDownload(int id) {
+  http::Request::Ptr download = options_.request_factory(id);
+  while (GetStatus() == TaskStatus::RUNNING) {
+    long downloaded = 0;
+    download->set_param("i", to_string(id));
+    download->set_param("size", to_string(options_.download_size));
+    download->set_param("time", to_string(SystemTimeMicros()));
+    download->set_progress_fn([&](curl_off_t,
+                                  curl_off_t dlnow,
+                                  curl_off_t,
+                                  curl_off_t) -> bool {
+      if (dlnow > downloaded) {
+        TransferBytes(dlnow - downloaded);
+        downloaded = dlnow;
+      }
+      return GetStatus() != TaskStatus::RUNNING;
+    });
+    StartRequest();
+    download->Get();
+    EndRequest();
+    download->Reset();
+  }
+}
+
+}  // namespace speedtest
diff --git a/speedtest/download_task.h b/speedtest/download_task.h
new file mode 100644
index 0000000..2b65478
--- /dev/null
+++ b/speedtest/download_task.h
@@ -0,0 +1,51 @@
+/*
+ * Copyright 2016 Google Inc. All rights reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+#ifndef SPEEDTEST_DOWNLOAD_TASK_H
+#define SPEEDTEST_DOWNLOAD_TASK_H
+
+#include <thread>
+#include <vector>
+#include "transfer_task.h"
+
+namespace speedtest {
+
+class DownloadTask : public TransferTask {
+ public:
+  struct Options : TransferTask::Options {
+    int download_size = 0;
+  };
+
+  explicit DownloadTask(const Options &options);
+
+ protected:
+  void RunInternal() override;
+  void StopInternal() override;
+
+ private:
+  void RunDownload(int id);
+
+  Options options_;
+  std::vector<std::thread> threads_;
+
+  // disallowed
+  DownloadTask(const DownloadTask &) = delete;
+  void operator=(const DownloadTask &) = delete;
+};
+
+}  // namespace speedtest
+
+#endif  // SPEEDTEST_DOWNLOAD_TASK_H
diff --git a/speedtest/errors.cc b/speedtest/errors.cc
index 05f382b..43c94f8 100644
--- a/speedtest/errors.cc
+++ b/speedtest/errors.cc
@@ -1,5 +1,5 @@
 /*
- * Copyright 2015 Google Inc. All rights reserved.
+ * Copyright 2016 Google Inc. All rights reserved.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
diff --git a/speedtest/errors.h b/speedtest/errors.h
index 334689d..4c70003 100644
--- a/speedtest/errors.h
+++ b/speedtest/errors.h
@@ -1,5 +1,5 @@
 /*
- * Copyright 2015 Google Inc. All rights reserved.
+ * Copyright 2016 Google Inc. All rights reserved.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
diff --git a/speedtest/http_task.cc b/speedtest/http_task.cc
new file mode 100644
index 0000000..1275aa4
--- /dev/null
+++ b/speedtest/http_task.cc
@@ -0,0 +1,24 @@
+/*
+ * Copyright 2016 Google Inc. All rights reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+#include "http_task.h"
+
+namespace speedtest {
+
+HttpTask::HttpTask(const Options &options): Task(options) {
+}
+
+}  // namespace speedtest
diff --git a/speedtest/http_task.h b/speedtest/http_task.h
new file mode 100644
index 0000000..a54e4ba
--- /dev/null
+++ b/speedtest/http_task.h
@@ -0,0 +1,43 @@
+/*
+ * Copyright 2016 Google Inc. All rights reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+#ifndef SPEEDTEST_HTTP_TASK_H
+#define SPEEDTEST_HTTP_TASK_H
+
+#include "task.h"
+
+#include "request.h"
+
+namespace speedtest {
+
+class HttpTask : public Task {
+ public:
+  struct Options : Task::Options {
+    bool verbose = false;
+    std::function<http::Request::Ptr(int)> request_factory;
+  };
+
+  explicit HttpTask(const Options &options);
+
+ private:
+  // disallowed
+  HttpTask(const Task &) = delete;
+  void operator=(const HttpTask &) = delete;
+};
+
+}  // namespace speedtest
+
+#endif  // SPEEDTEST_HTTP_TASK_H
diff --git a/speedtest/options.cc b/speedtest/options.cc
index 4d07099..133b857 100644
--- a/speedtest/options.cc
+++ b/speedtest/options.cc
@@ -1,5 +1,5 @@
 /*
- * Copyright 2015 Google Inc. All rights reserved.
+ * Copyright 2016 Google Inc. All rights reserved.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -25,14 +25,23 @@
 
 namespace speedtest {
 
-const char* kDefaultHost = "speedtest.googlefiber.net";
+const char* kDefaultHost = "any.speed.gfsvc.com";
 
 namespace {
 
-bool ParseLong(const char *s, char **endptr, long *size) {
-  assert(s != nullptr);
-  assert(size != nullptr);
-  *size = strtol(s, endptr, 10);
+bool ParseLong(const char *s, char **endptr, long *number) {
+  assert(s);
+  assert(endptr);
+  assert(number != nullptr);
+  *number = strtol(s, endptr, 10);
+  return !**endptr;
+}
+
+bool ParseDouble(const char *s, char **endptr, double *number) {
+  assert(s);
+  assert(endptr);
+  assert(number != nullptr);
+  *number = strtod(s, endptr);
   return !**endptr;
 }
 
@@ -58,67 +67,129 @@
   return true;
 }
 
-const char *kShortOpts = "d:u:t:n:p:s:vh";
+const int kOptDisableDnsCache = 1000;
+const int kOptMaxConnections = 1001;
+const int kOptExponentialMovingAverage = 1002;
+
+const int kOptMinTransferTime = 1100;
+const int kOptMaxTransferTime = 1101;
+const int kOptMinTransferIntervals = 1102;
+const int kOptMaxTransferIntervals = 1103;
+const int kOptMaxTransferVariance = 1104;
+const int kOptIntervalMillis = 1105;
+const int kOptPingRuntime = 1106;
+const int kOptPingTimeout = 1107;
+
+const char *kShortOpts = "hvg:a:d:s:t:u:p:";
+
 struct option kLongOpts[] = {
     {"help", no_argument, nullptr, 'h'},
-    {"number", required_argument, nullptr, 'n'},
-    {"download_size", required_argument, nullptr, 'd'},
-    {"upload_size", required_argument, nullptr, 'u'},
-    {"progress", required_argument, nullptr, 'p'},
-    {"serverid", required_argument, nullptr, 's'},  // ignored
-    {"time", required_argument, nullptr, 't'},
     {"verbose", no_argument, nullptr, 'v'},
+    {"global_host", required_argument, nullptr, 'g'},
+    {"user_agent", required_argument, nullptr, 'a'},
+    {"disable_dns_cache", no_argument, nullptr, kOptDisableDnsCache},
+    {"max_connections", required_argument, nullptr, kOptMaxConnections},
+    {"progress_millis", required_argument, nullptr, 'p'},
+    {"exponential_moving_average", no_argument, nullptr,
+        kOptExponentialMovingAverage},
+
+    {"num_downloads", required_argument, nullptr, 'd'},
+    {"download_size", required_argument, nullptr, 's'},
+    {"num_uploads", required_argument, nullptr, 'u'},
+    {"upload_size", required_argument, nullptr, 't'},
+    {"min_transfer_runtime", required_argument, nullptr, kOptMinTransferTime},
+    {"max_transfer_runtime", required_argument, nullptr, kOptMaxTransferTime},
+    {"min_transfer_intervals", required_argument, nullptr,
+        kOptMinTransferIntervals},
+    {"max_transfer_intervals", required_argument, nullptr,
+        kOptMaxTransferIntervals},
+    {"max_transfer_variance", required_argument, nullptr,
+        kOptMaxTransferVariance},
+    {"interval_millis", required_argument, nullptr, kOptIntervalMillis},
+    {"ping_runtime", required_argument, nullptr, kOptPingRuntime},
+    {"ping_timeout", required_argument, nullptr, kOptPingTimeout},
     {nullptr, 0, nullptr, 0},
 };
-const int kDefaultNumber = 10;
-const int kDefaultDownloadSize = 10000000;
-const int kDefaultUploadSize = 10000000;
-const int kDefaultTimeMillis = 5000;
 const int kMaxNumber = 1000;
 const int kMaxProgress = 1000000;
 
+const char *kSpeedtestHelp = R"USAGE(: run an HTTP speedtest.
+
+If no hosts are specified, the global host is queried for a list
+of servers to use, otherwise the list of supplied hosts will be
+used. Each will be pinged several times and the one with the
+lowest ping time will be used. If only one host is supplied, it
+will be used without pinging.
+
+Usage: speedtest [options] [host ...]
+ -h, --help                    This help text
+ -v, --verbose                 Verbose output
+ -g, --global_host URL         Global host URL
+ -a, --user_agent AGENT        User agent string for HTTP requests
+ -p, --progress_millis NUM     Delay in milliseconds between updates
+ --disable_dns_cache           Disable global DNS cache
+ --max_connections NUM         Maximum number of parallel connections
+ --exponential_moving_average  Use exponential instead of simple moving average
+
+These options override the speedtest config parameters:
+ -d, --num_downloads NUM       Number of simultaneous downloads
+ -s, --download_size SIZE      Download size in bytes
+ -t, --upload_size SIZE        Upload size in bytes
+ -u, --num_uploads NUM         Number of simultaneous uploads
+ --min_transfer_runtime TIME   Minimum transfer time in milliseconds
+ --max_transfer_runtime TIME   Maximum transfer time in milliseconds
+ --min_transfer_intervals NUM  Short moving average intervals
+ --max_transfer_intervals NUM  Long moving average intervals
+ --max_transfer_variance NUM   Max difference between moving averages
+ --interval_millis TIME        Interval size in milliseconds
+ --ping_runtime TIME           Ping runtime in milliseconds
+ --ping_timeout TIME           Ping timeout in milliseconds
+)USAGE";
+
 }  // namespace
 
 bool ParseOptions(int argc, char *argv[], Options *options) {
   assert(options != nullptr);
-  options->number = kDefaultNumber;
-  options->download_size = kDefaultDownloadSize;
-  options->upload_size = kDefaultUploadSize;
-  options->time_millis = kDefaultTimeMillis;
-  options->progress_millis = 0;
-  options->verbose = false;
   options->usage = false;
+  options->verbose = false;
+  options->global_host = http::Url(kDefaultHost);
+  options->global = false;
+  options->user_agent = "";
+  options->progress_millis = 0;
+  options->disable_dns_cache = false;
+  options->max_connections = 0;
+  options->exponential_moving_average = false;
+
+  options->num_downloads = 0;
+  options->download_size = 0;
+  options->num_uploads = 0;
+  options->upload_size = 0;
+  options->min_transfer_runtime = 0;
+  options->max_transfer_runtime = 0;
+  options->min_transfer_intervals = 0;
+  options->max_transfer_intervals = 0;
+  options->max_transfer_variance = 0.0;
+  options->interval_millis = 0;
+  options->ping_runtime = 0;
+  options->ping_timeout = 0;
+
   options->hosts.clear();
 
+  if (!options->global_host.ok()) {
+    std::cerr << "Invalid global host " << kDefaultHost << "\n";
+    return false;
+  }
+
   // Manually set this to 0 to allow repeated calls
   optind = 0;
-
   int opt = 0, long_index = 0;
   while ((opt = getopt_long(argc, argv,
                             kShortOpts, kLongOpts, &long_index)) != -1) {
     switch (opt) {
-      case 'd':
-        if (!ParseSize(optarg, &options->download_size)) {
-          std::cerr << "Invalid download size '" << optarg << "'\n";
-          return false;
-        }
+      case 'a':
+        options->user_agent = optarg;
         break;
-      case 'u':
-        if (!ParseSize(optarg, &options->upload_size)) {
-          std::cerr << "Invalid upload size '" << optarg << "'\n";
-          return false;
-        }
-        break;
-      case 't':
-        options->time_millis = atoi(optarg);
-        break;
-      case 'v':
-        options->verbose = true;
-        break;
-      case 'h':
-        options->usage = true;
-        return true;
-      case 'n': {
+      case 'd': {
         long number;
         char *endptr;
         if (!ParseLong(optarg, &endptr, &number)) {
@@ -127,12 +198,24 @@
         }
         if (number < 1 || number > kMaxNumber) {
           std::cerr << "Number must be between 1 and " << kMaxNumber
-          << ", got '" << optarg << "'\n";
+                    << ", got '" << optarg << "'\n";
           return false;
         }
-        options->number = static_cast<int>(number);
+        options->num_downloads = static_cast<int>(number);
         break;
       }
+      case 'g': {
+        http::Url url(optarg);
+        if (!url.ok()) {
+          std::cerr << "Invalid global host " << optarg << "\n";
+          return false;
+        }
+        options->global_host = url;
+        break;
+      }
+      case 'h':
+        options->usage = true;
+        return true;
       case 'p': {
         long progress;
         char *endptr;
@@ -142,16 +225,186 @@
         }
         if (progress < 0 || progress > kMaxProgress) {
           std::cerr << "Number must be between 0 and " << kMaxProgress
-          << ", got '" << optarg << "'\n";
+                    << ", got '" << optarg << "'\n";
           return false;
         }
         options->progress_millis = static_cast<int>(progress);
         break;
       }
       case 's':
-        // serverid is an argument supported by the older speedtest
-        // implementation. It is ignored here to ease the transition.
+        if (!ParseSize(optarg, &options->download_size)) {
+          std::cerr << "Invalid download size '" << optarg << "'\n";
+          return false;
+        }
         break;
+      case 't':
+        if (!ParseSize(optarg, &options->upload_size)) {
+          std::cerr << "Invalid upload size '" << optarg << "'\n";
+          return false;
+        }
+        break;
+      case 'u': {
+        long number;
+        char *endptr;
+        if (!ParseLong(optarg, &endptr, &number)) {
+          std::cerr << "Could not parse number '" << optarg << "'\n";
+          return false;
+        }
+        if (number < 1 || number > kMaxNumber) {
+          std::cerr << "Number must be between 1 and " << kMaxNumber
+                    << ", got '" << optarg << "'\n";
+          return false;
+        }
+        options->num_uploads = static_cast<int>(number);
+        break;
+      }
+      case 'v':
+        options->verbose = true;
+        break;
+      case kOptDisableDnsCache:
+        options->disable_dns_cache = true;
+        break;
+      case kOptMaxConnections: {
+        long max_connections;
+        char *endptr;
+        if (!ParseLong(optarg, &endptr, &max_connections)) {
+          std::cerr << "Could not parse max connections '" << optarg << "'\n";
+          return false;
+        }
+        if (max_connections < 0) {
+          std::cerr << "Max connections must be nonnegative, got "
+          << optarg << "'\n";
+          return false;
+        }
+        options->max_connections = static_cast<int>(max_connections);
+        break;
+      }
+      case kOptExponentialMovingAverage:
+        options->exponential_moving_average = true;
+        break;
+      case kOptMinTransferTime: {
+        long transfer_time;
+        char *endptr;
+        if (!ParseLong(optarg, &endptr, &transfer_time)) {
+          std::cerr << "Could not parse minimum transfer time '"
+                    << optarg << "'\n";
+          return false;
+        }
+        if (transfer_time < 0) {
+          std::cerr << "Minimum transfer runtime must be nonnegative, got "
+                    << optarg << "'\n";
+          return false;
+        }
+        options->min_transfer_runtime = static_cast<int>(transfer_time);
+        break;
+      }
+      case kOptMaxTransferTime: {
+        long transfer_time;
+        char *endptr;
+        if (!ParseLong(optarg, &endptr, &transfer_time)) {
+          std::cerr << "Could not parse maximum transfer time '"
+                    << optarg << "'\n";
+          return false;
+        }
+        if (transfer_time < 0) {
+          std::cerr << "Maximum transfer runtime must be nonnegative, got "
+                    << optarg << "'\n";
+          return false;
+        }
+        options->max_transfer_runtime = static_cast<int>(transfer_time);
+        break;
+      }
+      case kOptMinTransferIntervals: {
+        long intervals;
+        char *endptr;
+        if (!ParseLong(optarg, &endptr, &intervals)) {
+          std::cerr << "Could not parse minimum transfer intervals '"
+                    << optarg << "'\n";
+          return false;
+        }
+        if (intervals < 0) {
+          std::cerr << "Minimum transfer intervals must be nonnegative, got "
+                    << optarg << "'\n";
+          return false;
+        }
+        options->min_transfer_intervals = static_cast<int>(intervals);
+        break;
+      }
+      case kOptMaxTransferIntervals: {
+        long intervals;
+        char *endptr;
+        if (!ParseLong(optarg, &endptr, &intervals)) {
+          std::cerr << "Could not parse maximum transfer intervals '"
+                    << optarg << "'\n";
+          return false;
+        }
+        if (intervals < 0) {
+          std::cerr << "Maximum transfer intervals must be nonnegative, got "
+                    << optarg << "'\n";
+          return false;
+        }
+        options->max_transfer_intervals = static_cast<int>(intervals);
+        break;
+      }
+      case kOptMaxTransferVariance: {
+        double variance;
+        char *endptr;
+        if (!ParseDouble(optarg, &endptr, &variance)) {
+          std::cerr << "Could not parse variance '" << optarg << "'\n";
+          return false;
+        }
+        if (variance < 0) {
+          std::cerr << "Variances must be nonnegative, got " << optarg << "'\n";
+          return false;
+        }
+        options->max_transfer_variance = variance;
+        break;
+      }
+      case kOptIntervalMillis: {
+        long interval_millis;
+        char *endptr;
+        if (!ParseLong(optarg, &endptr, &interval_millis)) {
+          std::cerr << "Could not parse interval time '" << optarg << "'\n";
+          return false;
+        }
+        if (interval_millis < 0) {
+          std::cerr << "Interval time must be nonnegative, got "
+                    << optarg << "'\n";
+          return false;
+        }
+        options->interval_millis = static_cast<int>(interval_millis);
+        break;
+      }
+      case kOptPingRuntime: {
+        long ping_runtime;
+        char *endptr;
+        if (!ParseLong(optarg, &endptr, &ping_runtime)) {
+          std::cerr << "Could not parse ping time '" << optarg << "'\n";
+          return false;
+        }
+        if (ping_runtime < 0) {
+          std::cerr << "Ping time must be nonnegative, got "
+                    << optarg << "'\n";
+          return false;
+        }
+        options->ping_runtime = static_cast<int>(ping_runtime);
+        break;
+      }
+      case kOptPingTimeout: {
+        long ping_timeout;
+        char *endptr;
+        if (!ParseLong(optarg, &endptr, &ping_timeout)) {
+          std::cerr << "Could not parse ping timeout '" << optarg << "'\n";
+          return false;
+        }
+        if (ping_timeout < 0) {
+          std::cerr << "Ping timeout must be nonnegative, got "
+                    << optarg << "'\n";
+          return false;
+        }
+        options->ping_timeout = static_cast<int>(ping_timeout);
+        break;
+      }
       default:
         return false;
     }
@@ -174,7 +427,7 @@
     }
   }
   if (options->hosts.empty()) {
-    options->hosts.emplace_back(http::Url(kDefaultHost));
+    options->global = true;
   }
   return true;
 }
@@ -184,13 +437,29 @@
 }
 
 void PrintOptions(std::ostream &out, const Options &options) {
-  out << "Number: " << options.number << "\n"
-      << "Upload size: " << options.upload_size << "\n"
-      << "Download size: " << options.download_size << "\n"
-      << "Time: " << options.time_millis << " ms\n"
-      << "Progress interval: " << options.progress_millis << " ms\n"
+  out << "Usage: " << (options.usage ? "true" : "false") << "\n"
       << "Verbose: " << (options.verbose ? "true" : "false") << "\n"
-      << "Usage: " << (options.usage ? "true" : "false") << "\n"
+      << "Global host: " << options.global_host.url() << "\n"
+      << "Global: " << (options.global ? "true" : "false") << "\n"
+      << "User agent: " << options.user_agent << "\n"
+      << "Progress interval: " << options.progress_millis << " ms\n"
+      << "Disable DNS cache: "
+      << (options.disable_dns_cache ? "true" : "false") << "\n"
+      << "Max connections: " << options.max_connections << "\n"
+      << "Exponential moving average: "
+      << (options.exponential_moving_average ? "true" : "false") << "\n"
+      << "Number of downloads: " << options.num_downloads << "\n"
+      << "Download size: " << options.download_size << " bytes\n"
+      << "Number of uploads: " << options.num_uploads << "\n"
+      << "Upload size: " << options.upload_size << " bytes\n"
+      << "Min transfer runtime: " << options.min_transfer_runtime << " ms\n"
+      << "Max transfer runtime: " << options.max_transfer_runtime << " ms\n"
+      << "Min transfer intervals: " << options.min_transfer_intervals << "\n"
+      << "Max transfer intervals: " << options.max_transfer_intervals << "\n"
+      << "Max transfer variance: " << options.max_transfer_variance << "\n"
+      << "Interval size: " << options.interval_millis << " ms\n"
+      << "Ping runtime: " << options.ping_runtime << " ms\n"
+      << "Ping timeout: " << options.ping_timeout << " ms\n"
       << "Hosts:\n";
   for (const http::Url &host : options.hosts) {
     out << "  " << host.url() << "\n";
@@ -205,15 +474,7 @@
   assert(app_path != nullptr);
   const char *last_slash = strrchr(app_path, '/');
   const char *app_name = last_slash == nullptr ? app_path : last_slash + 1;
-  out << basename(app_name) << ": run an HTTP speedtest\n\n"
-      << "Usage: speedtest [options] <host> ...\n"
-      << " -h, --help                This help text\n"
-      << " -n, --number NUM          Number of simultaneous transfers\n"
-      << " -d, --download_size SIZE  Download size in bytes\n"
-      << " -u, --upload_size SIZE    Upload size in bytes\n"
-      << " -t, --time TIME           Time per test in milliseconds\n"
-      << " -p, --progress TIME       Progress intervals in milliseconds\n"
-      << " -v, --verbose             Verbose output\n";
+  out << basename(app_name) << kSpeedtestHelp;
 }
 
 }  // namespace speedtest
diff --git a/speedtest/options.h b/speedtest/options.h
index 0996798..9028f70 100644
--- a/speedtest/options.h
+++ b/speedtest/options.h
@@ -1,5 +1,5 @@
 /*
- * Copyright 2015 Google Inc. All rights reserved.
+ * Copyright 2016 Google Inc. All rights reserved.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -27,14 +27,31 @@
 extern const char* kDefaultHost;
 
 struct Options {
+  bool usage = false;
+  bool verbose = false;
+  http::Url global_host;
+  bool global = false;
+  std::string user_agent;
+  bool disable_dns_cache = false;
+  int max_connections = 0;
+  int progress_millis = 0;
+  bool exponential_moving_average = false;
+
+  // A value of 0 means use the speedtest config parameters
+  int num_downloads = 0;
+  long download_size = 0;
+  int num_uploads = 0;
+  long upload_size = 0;
+  int min_transfer_runtime = 0;
+  int max_transfer_runtime = 0;
+  int min_transfer_intervals = 0;
+  int max_transfer_intervals = 0;
+  double max_transfer_variance = 0.0;
+  int interval_millis = 0;
+  int ping_runtime = 0;
+  int ping_timeout = 0;
+
   std::vector<http::Url> hosts;
-  int number;
-  long download_size;
-  long upload_size;
-  int time_millis;
-  int progress_millis;
-  bool verbose;
-  bool usage;
 };
 
 // Parse command line options putting results into 'options'
diff --git a/speedtest/options_test.cc b/speedtest/options_test.cc
index 2754552..601984a 100644
--- a/speedtest/options_test.cc
+++ b/speedtest/options_test.cc
@@ -1,5 +1,5 @@
 /*
- * Copyright 2015 Google Inc. All rights reserved.
+ * Copyright 2016 Google Inc. All rights reserved.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -14,12 +14,14 @@
  * limitations under the License.
  */
 
+#include "options.h"
+
 #include <gtest/gtest.h>
 #include <gmock/gmock.h>
 #include <mutex>
 #include <string.h>
 #include <vector>
-#include "options.h"
+#include "url.h"
 
 namespace speedtest {
 namespace {
@@ -81,8 +83,28 @@
 TEST(OptionsTest, Empty_ValidDefault) {
   Options options;
   TestValidOptions({}, &options);
-  EXPECT_FALSE(options.verbose);
   EXPECT_FALSE(options.usage);
+  EXPECT_FALSE(options.verbose);
+  EXPECT_TRUE(options.global);
+  EXPECT_EQ(http::Url("any.speed.gfsvc.com"), options.global_host);
+  EXPECT_FALSE(options.disable_dns_cache);
+  EXPECT_EQ(0, options.max_connections);
+  EXPECT_EQ(0, options.progress_millis);
+  EXPECT_FALSE(options.exponential_moving_average);
+
+  EXPECT_EQ(0, options.num_downloads);
+  EXPECT_EQ(0, options.download_size);
+  EXPECT_EQ(0, options.num_uploads);
+  EXPECT_EQ(0, options.upload_size);
+  EXPECT_EQ(0, options.min_transfer_runtime);
+  EXPECT_EQ(0, options.max_transfer_runtime);
+  EXPECT_EQ(0, options.min_transfer_intervals);
+  EXPECT_EQ(0, options.max_transfer_intervals);
+  EXPECT_EQ(0, options.max_transfer_variance);
+  EXPECT_EQ(0, options.interval_millis);
+  EXPECT_EQ(0, options.ping_runtime);
+  EXPECT_EQ(0, options.ping_timeout);
+  EXPECT_THAT(options.hosts, testing::IsEmpty());
 }
 
 TEST(OptionsTest, Usage_Valid) {
@@ -104,35 +126,94 @@
   EXPECT_THAT(options.hosts, testing::ElementsAre(http::Url("efgh")));
 }
 
-TEST(OptionsTest, FullShort_Valid) {
+TEST(OptionsTest, ShortOptions_Valid) {
   Options options;
-  TestValidOptions({"-d", "5122",
-                    "-u", "7653",
-                    "-t", "123",
-                    "-n", "15",
-                    "-p", "500"},
+  TestValidOptions({"-v",
+                    "-s", "5122",
+                    "-t", "7653",
+                    "-d", "20",
+                    "-u", "15",
+                    "-p", "500",
+                    "-g", "speed.gfsvc.com",
+                    "-a", "CrOS",
+                    "foo.speed.googlefiber.net",
+                    "bar.speed.googlefiber.net"},
                     &options);
+  EXPECT_TRUE(options.verbose);
+  EXPECT_EQ(20, options.num_downloads);
   EXPECT_EQ(5122, options.download_size);
+  EXPECT_EQ(15, options.num_uploads);
   EXPECT_EQ(7653, options.upload_size);
-  EXPECT_EQ(123, options.time_millis);
-  EXPECT_EQ(15, options.number);
   EXPECT_EQ(500, options.progress_millis);
+  EXPECT_FALSE(options.global);
+  EXPECT_EQ(http::Url("speed.gfsvc.com"), options.global_host);
+  EXPECT_EQ("CrOS", options.user_agent);
+
+  EXPECT_EQ(0, options.max_connections);
+  EXPECT_FALSE(options.disable_dns_cache);
+  EXPECT_FALSE(options.exponential_moving_average);
+  EXPECT_EQ(0, options.min_transfer_runtime);
+  EXPECT_EQ(0, options.max_transfer_runtime);
+  EXPECT_EQ(0, options.min_transfer_intervals);
+  EXPECT_EQ(0, options.max_transfer_intervals);
+  EXPECT_EQ(0, options.max_transfer_variance);
+  EXPECT_EQ(0, options.interval_millis);
+  EXPECT_EQ(0, options.ping_runtime);
+  EXPECT_EQ(0, options.ping_timeout);
+
+  EXPECT_THAT(options.hosts, testing::UnorderedElementsAre(
+      http::Url("foo.speed.googlefiber.net"),
+      http::Url("bar.speed.googlefiber.net")));
 }
 
-TEST(OptionsTest, FullLong_Valid) {
+TEST(OptionsTest, LongOptions_Valid) {
   Options options;
-  TestValidOptions({"--download_size", "5122",
+  TestValidOptions({"--verbose",
+                    "--global_host", "speed.gfsvc.com",
+                    "--user_agent", "CrOS",
+                    "--progress_millis", "1000",
+                    "--disable_dns_cache",
+                    "--max_connections", "23",
+                    "--exponential_moving_average",
+                    "--num_downloads", "16",
+                    "--download_size", "5122",
+                    "--num_uploads", "12",
                     "--upload_size", "7653",
-                    "--time", "123",
-                    "--progress", "1000",
-                    "--number", "12"},
+                    "--min_transfer_runtime", "7500",
+                    "--max_transfer_runtime", "13500",
+                    "--min_transfer_intervals", "13",
+                    "--max_transfer_intervals", "22",
+                    "--max_transfer_variance", "0.12",
+                    "--interval_millis", "250",
+                    "--ping_runtime", "2500",
+                    "--ping_timeout", "300",
+                    "foo.speed.googlefiber.net",
+                    "bar.speed.googlefiber.net"},
                     &options);
-  EXPECT_EQ(5122, options.download_size);
-  EXPECT_EQ(7653, options.upload_size);
-  EXPECT_EQ(123, options.time_millis);
-  EXPECT_EQ(12, options.number);
+  EXPECT_TRUE(options.verbose);
+  EXPECT_FALSE(options.global);
+  EXPECT_EQ(http::Url("speed.gfsvc.com"), options.global_host);
+  EXPECT_EQ("CrOS", options.user_agent);
   EXPECT_EQ(1000, options.progress_millis);
-  EXPECT_THAT(options.hosts, testing::ElementsAre(http::Url(kDefaultHost)));
+  EXPECT_TRUE(options.disable_dns_cache);
+  EXPECT_EQ(23, options.max_connections);
+  EXPECT_TRUE(options.exponential_moving_average);
+  EXPECT_EQ(16, options.num_downloads);
+  EXPECT_EQ(5122, options.download_size);
+  EXPECT_EQ(12, options.num_uploads);
+  EXPECT_EQ(7653, options.upload_size);
+  EXPECT_EQ("CrOS", options.user_agent);
+  EXPECT_EQ(7500, options.min_transfer_runtime);
+  EXPECT_EQ(13500, options.max_transfer_runtime);
+  EXPECT_EQ(13, options.min_transfer_intervals);
+  EXPECT_EQ(22, options.max_transfer_intervals);
+  EXPECT_EQ(0.12, options.max_transfer_variance);
+  EXPECT_EQ(250, options.interval_millis);
+  EXPECT_EQ(2500, options.ping_runtime);
+  EXPECT_EQ(300, options.ping_timeout);
+  EXPECT_THAT(options.hosts, testing::UnorderedElementsAre(
+      http::Url("foo.speed.googlefiber.net"),
+      http::Url("bar.speed.googlefiber.net")));
 }
 
 }  // namespace
diff --git a/speedtest/ping_task.cc b/speedtest/ping_task.cc
new file mode 100644
index 0000000..7a1c7be
--- /dev/null
+++ b/speedtest/ping_task.cc
@@ -0,0 +1,115 @@
+/*
+ * Copyright 2016 Google Inc. All rights reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+#include "ping_task.h"
+
+#include <algorithm>
+#include <cassert>
+#include <iomanip>
+#include <iostream>
+#include "utils.h"
+
+namespace speedtest {
+
+PingTask::PingTask(const Options &options)
+    : HttpTask(options),
+      options_(options) {
+  assert(options_.num_pings > 0);
+}
+
+void PingTask::RunInternal() {
+  ResetCounters();
+  success_ = false;
+  threads_.clear();
+  for (int i = 0; i < options_.num_pings; ++i) {
+    threads_.emplace_back([=]() {
+      RunPing(i);
+    });
+  }
+}
+
+void PingTask::StopInternal() {
+  std::for_each(threads_.begin(), threads_.end(), [](std::thread &t) {
+    t.join();
+  });
+  threads_.clear();
+  if (options_.verbose) {
+    std::cout << "Pinged " << options_.num_pings << " "
+              << (options_.num_pings == 1 ? "host" : "hosts") << ":\n";
+  }
+  const PingStats *min_stats = nullptr;
+  for (const auto &stat : stats_) {
+    if (options_.verbose) {
+      std::cout << "  " << stat.url.url() << ": ";
+      if (stat.pings_received == 0) {
+        std::cout << "no packets received";
+      } else {
+        double mean_micros = ((double) stat.total_micros) / stat.pings_received;
+        std::cout << "min " << round(stat.min_micros / 1000.0d, 2) << " ms"
+                  << " from " << stat.pings_received << " pings"
+                  << " (mean " << round(mean_micros / 1000.0d, 2) << " ms)";
+      }
+      std::cout << "\n";
+    }
+    if (stat.pings_received > 0) {
+      if (!min_stats || stat.min_micros < min_stats->min_micros) {
+        min_stats = &stat;
+      }
+    }
+  }
+
+  std::lock_guard<std::mutex> lock(mutex_);
+  if (!min_stats) {
+    // no servers respondeded
+    success_ = false;
+  } else {
+    fastest_ = *min_stats;
+    success_ = true;
+  }
+}
+
+void PingTask::RunPing(size_t index) {
+  http::Request::Ptr ping = options_.request_factory(index);
+  stats_[index].url = ping->url();
+  while (GetStatus() == TaskStatus::RUNNING) {
+    long req_start = SystemTimeMicros();
+    if (ping->Get() == CURLE_OK) {
+      long req_end = SystemTimeMicros();
+      long ping_time = req_end - req_start;
+      stats_[index].total_micros += ping_time;
+      stats_[index].pings_received++;
+      stats_[index].min_micros = std::min(stats_[index].min_micros, ping_time);
+    }
+    ping->Reset();
+    std::this_thread::sleep_for(std::chrono::milliseconds(100));
+  }
+}
+
+bool PingTask::IsSucceeded() const {
+  return success_;
+}
+
+PingStats PingTask::GetFastest() const {
+  std::lock_guard<std::mutex> lock(mutex_);
+  return fastest_;
+}
+
+void PingTask::ResetCounters() {
+  stats_.clear();
+  stats_.resize(options_.num_pings);
+}
+
+}  // namespace speedtest
diff --git a/speedtest/ping_task.h b/speedtest/ping_task.h
new file mode 100644
index 0000000..b2923a8
--- /dev/null
+++ b/speedtest/ping_task.h
@@ -0,0 +1,77 @@
+/*
+ * Copyright 2016 Google Inc. All rights reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+#ifndef SPEEDTEST_PING_TASK_H
+#define SPEEDTEST_PING_TASK_H
+
+#include <atomic>
+#include <functional>
+#include <limits>
+#include <memory>
+#include <mutex>
+#include <thread>
+#include <vector>
+#include "http_task.h"
+#include "request.h"
+#include "url.h"
+
+namespace speedtest {
+
+struct PingStats {
+  long total_micros = 0;
+  int pings_received = 0;
+  long min_micros = std::numeric_limits<long>::max();
+  http::Url url;
+};
+
+class PingTask : public HttpTask {
+ public:
+  struct Options : HttpTask::Options {
+    int timeout = 0;
+    int num_pings = 0;
+  };
+
+  explicit PingTask(const Options &options);
+
+  bool IsSucceeded() const;
+
+  PingStats GetFastest() const;
+
+ protected:
+  void RunInternal() override;
+  void StopInternal() override;
+
+ private:
+  void RunPing(size_t index);
+
+  void ResetCounters();
+
+  Options options_;
+  std::vector<PingStats> stats_;
+  std::vector<std::thread> threads_;
+  std::atomic_bool success_;
+
+  mutable std::mutex mutex_;
+  PingStats fastest_;
+
+  // disallowed
+  PingTask(const PingTask &) = delete;
+  void operator=(const PingTask &) = delete;
+};
+
+}  // namespace speedtest
+
+#endif  // SPEEDTEST_PING_TASK_H
diff --git a/speedtest/request.cc b/speedtest/request.cc
index b0a2b89..ef46d2d 100644
--- a/speedtest/request.cc
+++ b/speedtest/request.cc
@@ -1,5 +1,5 @@
 /*
- * Copyright 2015 Google Inc. All rights reserved.
+ * Copyright 2016 Google Inc. All rights reserved.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -64,17 +64,14 @@
 
 const int kDefaultQueryStringSize = 200;
 
+const Request::DownloadFn noop = [](void *, size_t) { };
+
 }  // namespace
 
-Request::Request(std::shared_ptr<CurlEnv> env)
-    : curl_headers_(nullptr),
-      env_(env) {
-  assert(env_);
-  handle_ = curl_easy_init();
-  if (!handle_) {
-    std::cerr << "Failed to create handle\n";
-    std::exit(1);
-  }
+Request::Request(std::shared_ptr<CURL> handle, const Url &url)
+    : handle_(handle),
+      curl_headers_(nullptr),
+      url_(url) {
 }
 
 Request::~Request() {
@@ -82,35 +79,40 @@
     curl_slist_free_all(curl_headers_);
     curl_headers_ = nullptr;
   }
-  curl_easy_cleanup(handle_);
+
+  curl_easy_cleanup(handle_.get());
+}
+
+CURLcode Request::Get() {
+  return Get(noop);
 }
 
 CURLcode Request::Get(DownloadFn download_fn) {
   CommonSetup();
   if (download_fn) {
-    curl_easy_setopt(handle_, CURLOPT_WRITEFUNCTION, &WriteCallback);
-    curl_easy_setopt(handle_, CURLOPT_WRITEDATA, &download_fn);
+    curl_easy_setopt(handle_.get(), CURLOPT_WRITEFUNCTION, &WriteCallback);
+    curl_easy_setopt(handle_.get(), CURLOPT_WRITEDATA, &download_fn);
   }
   return Execute();
 }
 
 CURLcode Request::Post(UploadFn upload_fn) {
   CommonSetup();
-  curl_easy_setopt(handle_, CURLOPT_UPLOAD, 1);
-  curl_easy_setopt(handle_, CURLOPT_READFUNCTION, &ReadCallback);
-  curl_easy_setopt(handle_, CURLOPT_READDATA, &upload_fn);
+  curl_easy_setopt(handle_.get(), CURLOPT_UPLOAD, 1);
+  curl_easy_setopt(handle_.get(), CURLOPT_READFUNCTION, &ReadCallback);
+  curl_easy_setopt(handle_.get(), CURLOPT_READDATA, &upload_fn);
   return Execute();
 }
 
 CURLcode Request::Post(const char *data, curl_off_t data_len) {
   CommonSetup();
-  curl_easy_setopt(handle_, CURLOPT_POSTFIELDSIZE_LARGE, data_len);
-  curl_easy_setopt(handle_, CURLOPT_POSTFIELDS, data);
+  curl_easy_setopt(handle_.get(), CURLOPT_POSTFIELDSIZE_LARGE, data_len);
+  curl_easy_setopt(handle_.get(), CURLOPT_POSTFIELDS, data);
   return Execute();
 }
 
 void Request::Reset() {
-  curl_easy_reset(handle_);
+  curl_easy_reset(handle_.get());
   clear_progress_fn();
   clear_headers();
   clear_params();
@@ -156,6 +158,10 @@
   params_.clear();
 }
 
+void Request::set_timeout_millis(long millis) {
+  curl_easy_setopt(handle_.get(), CURLOPT_TIMEOUT_MS, millis);
+}
+
 void Request::UpdateUrl() {
   std::string query_string;
   query_string.reserve(kDefaultQueryStringSize);
@@ -165,10 +171,10 @@
     if (!query_string.empty()) {
       query_string.append("&");
     }
-    char *name = curl_easy_escape(handle_,
+    char *name = curl_easy_escape(handle_.get(),
                                   iter->first.data(),
                                   iter->first.length());
-    char *value = curl_easy_escape(handle_,
+    char *value = curl_easy_escape(handle_.get(),
                                    iter->second.data(),
                                    iter->second.length());
     query_string.append(name);
@@ -183,12 +189,14 @@
 void Request::CommonSetup() {
   UpdateUrl();
   std::string request_url = url_.url();
-  curl_easy_setopt(handle_, CURLOPT_URL, request_url.c_str());
-  curl_easy_setopt(handle_, CURLOPT_USERAGENT, user_agent_.c_str());
+  curl_easy_setopt(handle_.get(), CURLOPT_URL, request_url.c_str());
+  curl_easy_setopt(handle_.get(), CURLOPT_USERAGENT, user_agent_.c_str());
   if (progress_fn_) {
-    curl_easy_setopt(handle_, CURLOPT_NOPROGRESS, 0);
-    curl_easy_setopt(handle_, CURLOPT_XFERINFOFUNCTION, &ProgressCallback);
-    curl_easy_setopt(handle_, CURLOPT_XFERINFODATA, &progress_fn_);
+    curl_easy_setopt(handle_.get(), CURLOPT_NOPROGRESS, 0);
+    curl_easy_setopt(handle_.get(),
+                     CURLOPT_XFERINFOFUNCTION,
+                     &ProgressCallback);
+    curl_easy_setopt(handle_.get(), CURLOPT_XFERINFODATA, &progress_fn_);
   }
   if (!headers_.empty()) {
     struct curl_slist *headers = nullptr;
@@ -200,12 +208,12 @@
       header.append(iter->second);
       headers = curl_slist_append(headers, header.c_str());
     }
-    curl_easy_setopt(handle_, CURLOPT_HTTPHEADER, headers);
+    curl_easy_setopt(handle_.get(), CURLOPT_HTTPHEADER, headers);
   }
 }
 
 CURLcode Request::Execute() {
-  return curl_easy_perform(handle_);
+  return curl_easy_perform(handle_.get());
 }
 
 }  // namespace http
diff --git a/speedtest/request.h b/speedtest/request.h
index 837eecc..8588e29 100644
--- a/speedtest/request.h
+++ b/speedtest/request.h
@@ -1,5 +1,5 @@
 /*
- * Copyright 2015 Google Inc. All rights reserved.
+ * Copyright 2016 Google Inc. All rights reserved.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -22,8 +22,6 @@
 #include <map>
 #include <memory>
 #include <string>
-
-#include "curl_env.h"
 #include "url.h"
 
 namespace http {
@@ -44,10 +42,12 @@
                                         curl_off_t,
                                         curl_off_t,
                                         curl_off_t)>;
+  using Ptr = std::unique_ptr<Request>;
 
-  explicit Request(std::shared_ptr<CurlEnv> env);
+  Request(std::shared_ptr<CURL> handle, const Url &url);
   virtual ~Request();
 
+  CURLcode Get();
   CURLcode Get(DownloadFn download_fn);
   CURLcode Post(UploadFn upload_fn);
   CURLcode Post(const char *data, curl_off_t data_len);
@@ -80,24 +80,25 @@
   void set_progress_fn(ProgressFn progress_fn) { progress_fn_ = progress_fn; }
   void clear_progress_fn() { progress_fn_ = nullptr; }
 
+  // Request timeout
+  void set_timeout_millis(long millis);
+
   void UpdateUrl();
 
  private:
   void CommonSetup();
+
   CURLcode Execute();
 
   // owned
-  CURL *handle_;
+  std::shared_ptr<CURL> handle_;
   struct curl_slist *curl_headers_;
-
-  // ref-count CURL global config
-  std::shared_ptr<CurlEnv> env_;
   Url url_;
 
   std::string user_agent_;
   Headers headers_;
   QueryStringParams params_;
-  ProgressFn progress_fn_;  // unowned
+  ProgressFn progress_fn_;
 
   // disable
   Request(const Request &) = delete;
diff --git a/speedtest/request_test.cc b/speedtest/request_test.cc
index d8b11e9..ae7b74f 100644
--- a/speedtest/request_test.cc
+++ b/speedtest/request_test.cc
@@ -1,5 +1,5 @@
 /*
- * Copyright 2015 Google Inc. All rights reserved.
+ * Copyright 2016 Google Inc. All rights reserved.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -14,10 +14,9 @@
  * limitations under the License.
  */
 
-#include <gtest/gtest.h>
-
 #include "request.h"
 
+#include <gtest/gtest.h>
 #include <memory>
 #include "curl_env.h"
 
@@ -30,8 +29,8 @@
   std::unique_ptr<Request> request;
 
   void SetUp() override {
-    env = std::make_shared<CurlEnv>();
-    request = env->NewRequest();
+    env = CurlEnv::NewCurlEnv({});
+    request = env->NewRequest(http::Url("http://example.com/foo"));
   }
 
   void VerifyQueryString(const char *expected,
@@ -92,9 +91,9 @@
 }
 
 TEST_F(RequestTest, Url_OneParamTwoValues_Ok) {
-  VerifyUrl("http://example.com/?abc=def&abc=ghi",
+  VerifyUrl("http://example.com/?abc=def&abc=def",
             "http://example.com",
-            {{"abc", "def"}, {"abc", "ghi"}});
+            {{"abc", "def"}, {"abc", "def"}});
 }
 
 TEST_F(RequestTest, Url_EscapeParam_Ok) {
diff --git a/speedtest/speedtest.cc b/speedtest/speedtest.cc
index bf8fe3c..ed1263d 100644
--- a/speedtest/speedtest.cc
+++ b/speedtest/speedtest.cc
@@ -1,5 +1,5 @@
 /*
- * Copyright 2015 Google Inc. All rights reserved.
+ * Copyright 2016 Google Inc. All rights reserved.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -16,241 +16,416 @@
 
 #include "speedtest.h"
 
-#include <assert.h>
 #include <chrono>
 #include <cstring>
 #include <limits>
 #include <random>
 #include <thread>
 #include <iomanip>
+#include <fstream>
+#include <streambuf>
 
 #include "errors.h"
+#include "timed_runner.h"
+#include "transfer_runner.h"
 #include "utils.h"
 
 namespace speedtest {
+namespace {
 
-Speedtest::Speedtest(const Options &options)
-    : options_(options),
-      bytes_downloaded_(0),
-      bytes_uploaded_(0) {
-  assert(!options_.hosts.empty());
-  env_ = std::make_shared<http::CurlEnv>();
+std::shared_ptr<std::string> MakeRandomData(size_t size) {
   std::random_device rd;
   std::default_random_engine random_engine(rd());
   std::uniform_int_distribution<char> uniform_dist(1, 255);
-  char *data = new char[options_.upload_size];
-  for (int i = 0; i < options_.upload_size; ++i) {
-    data[i] = uniform_dist(random_engine);
+  auto random_data = std::make_shared<std::string>();
+  random_data->resize(size);
+  for (size_t i = 0; i < size; ++i) {
+    (*random_data)[i] = uniform_dist(random_engine);
   }
-  send_data_ = data;
+  return std::move(random_data);
+}
+
+const char *kFileSerial = "/etc/serial";
+const char *kFileVersion = "/etc/version";
+
+std::string LoadFile(const std::string &file_name) {
+  std::ifstream in(file_name);
+  return std::string(std::istreambuf_iterator<char>(in),
+                     std::istreambuf_iterator<char>());
+}
+
+}  // namespace
+
+Speedtest::Speedtest(const Options &options)
+    : options_(options) {
+  http::CurlEnv::Options curl_options;
+  curl_options.disable_dns_cache = options_.disable_dns_cache;
+  curl_options.max_connections = options_.max_connections;
+  env_ = http::CurlEnv::NewCurlEnv(curl_options);
 }
 
 Speedtest::~Speedtest() {
-  delete[] send_data_;
 }
 
 void Speedtest::Run() {
-  if (!RunPingTest()) {
+  InitUserAgent();
+  LoadServerList();
+  if (servers_.empty()) {
+    std::cerr << "No servers found in global server list\n";
+    std::exit(1);
+  }
+  FindNearestServer();
+  if (!server_url_) {
     std::cout << "No servers responded. Exiting\n";
     return;
   }
+  std::string json = LoadConfig(*server_url_);
+  if (!ParseConfig(json, &config_)) {
+    std::cout << "Could not parse config\n";
+    return;
+  }
+  if (options_.verbose) {
+    std::cout << "Server config:\n";
+    PrintConfig(config_);
+  }
+  std::cout << "Location: " << config_.location_name << "\n";
+  std::cout << "URL: " << server_url_->url() << "\n";
   RunDownloadTest();
   RunUploadTest();
+  RunPingTest();
+}
+
+void Speedtest::InitUserAgent() {
+  if (options_.user_agent.empty()) {
+    std::string serial = LoadFile(kFileSerial);
+    std::string version = LoadFile(kFileVersion);
+    Trim(&serial);
+    Trim(&version);
+    user_agent_ = "CPE";
+    if (!version.empty()) {
+      user_agent_ += "/" + version;
+      if (!serial.empty()) {
+        user_agent_ += "/" + serial;
+      }
+    }
+  } else {
+    user_agent_ = options_.user_agent;
+    return;
+  }
+  if (options_.verbose) {
+    std::cout << "Setting user agent to " << user_agent_ << "\n";
+  }
+}
+
+void Speedtest::LoadServerList() {
+  servers_.clear();
+  if (!options_.global) {
+    if (options_.verbose) {
+      std::cout << "Explicit server list:\n";
+      for (const auto &url : options_.hosts) {
+        std::cout << "  " << url.url() << "\n";
+      }
+    }
+    servers_ = options_.hosts;
+    return;
+  }
+
+  std::string json = LoadConfig(options_.global_host);
+  if (json.empty()) {
+    std::cerr << "Failed to load config JSON\n";
+    std::exit(1);
+  }
+  if (options_.verbose) {
+    std::cout << "Loaded config JSON: " << json << "\n";
+  }
+  if (!ParseServers(json, &servers_)) {
+    std::cerr << "Failed to parse server list: " << json << "\n";
+    std::exit(1);
+  }
+  if (options_.verbose) {
+    std::cout << "Loaded servers:\n";
+    for (const auto &url : servers_) {
+      std::cout << "  " << url.url() << "\n";
+    }
+  }
+}
+
+void Speedtest::FindNearestServer() {
+  server_url_.reset();
+  if (servers_.size() == 1) {
+    server_url_.reset(new http::Url(servers_[0]));
+    if (options_.verbose) {
+      std::cout << "Only 1 server so using " << server_url_->url() << "\n";
+    }
+    return;
+  }
+
+  PingTask::Options options;
+  options.verbose = options_.verbose;
+  options.timeout = PingTimeout();
+  std::vector<http::Url> hosts;
+  for (const auto &server : servers_) {
+    http::Url url(server);
+    url.set_path("/ping");
+    hosts.emplace_back(url);
+  }
+  options.num_pings = hosts.size();
+  if (options_.verbose) {
+    std::cout << "There are " << hosts.size() << " ping URLs:\n";
+    for (const auto &host : hosts) {
+      std::cout << "  " << host.url() << "\n";
+    }
+  }
+  options.request_factory = [&](int id) -> http::Request::Ptr{
+    return MakeRequest(hosts[id]);
+  };
+  PingTask find_nearest(options);
+  if (options_.verbose) {
+    std::cout << "Starting to find nearest server\n";
+  }
+  RunTimed(&find_nearest, 1500);
+  find_nearest.WaitForEnd();
+  if (find_nearest.IsSucceeded()) {
+    PingStats fastest = find_nearest.GetFastest();
+    server_url_.reset(new http::Url(fastest.url));
+    server_url_->clear_path();
+    if (options_.verbose) {
+      double ping_millis = fastest.min_micros / 1000.0d;
+      std::cout << "Found nearest server: " << fastest.url.url()
+                   << " (" << round(ping_millis, 2) << " ms)\n";
+    }
+  }
+}
+
+std::string Speedtest::LoadConfig(const http::Url &url) {
+  http::Url config_url(url);
+  config_url.set_path("/config");
+  if (options_.verbose) {
+    std::cout << "Loading config from " << config_url.url() << "\n";
+  }
+  http::Request::Ptr request = MakeRequest(config_url);
+  request->set_url(config_url);
+  std::string json;
+  request->Get([&](void *data, size_t size){
+    json.assign(static_cast<const char *>(data), size);
+  });
+  return json;
+}
+
+void Speedtest::RunPingTest() {
+  PingTask::Options options;
+  options.verbose = options_.verbose;
+  options.timeout = PingTimeout();
+  options.num_pings = 1;
+  http::Url ping_url(*server_url_);
+  ping_url.set_path("/ping");
+  options.request_factory = [&](int id) -> http::Request::Ptr{
+    return MakeRequest(ping_url);
+  };
+  std::unique_ptr<PingTask> ping(new PingTask(options));
+  RunTimed(ping.get(), PingRunTime());
+  ping->WaitForEnd();
+  PingStats fastest = ping->GetFastest();
+  if (ping->IsSucceeded()) {
+    long micros = fastest.min_micros;
+    std::cout << "Ping time: " << round(micros / 1000.0d, 3) << " ms\n";
+  } else {
+    std::cout << "Failed to get ping response from "
+              << config_.location_name << " (" << fastest.url << ")\n";
+  }
 }
 
 void Speedtest::RunDownloadTest() {
-  end_download_ = false;
-  long start_time = SystemTimeMicros();
-  bytes_downloaded_ = 0;
-  std::thread threads[options_.number];
-  for (int i = 0; i < options_.number; ++i) {
-    threads[i] = std::thread([=]() {
-      RunDownload(i);
-    });
+  if (options_.verbose) {
+    std::cout << "Starting download test to " << config_.location_name
+              << " (" << server_url_->url() << ")\n";
   }
-  std::thread timer([&]{
-    std::this_thread::sleep_for(
-        std::chrono::milliseconds(options_.time_millis));
-    end_download_ = true;
-  });
-  timer.join();
-  for (auto &thread : threads) {
-    thread.join();
+  DownloadTask::Options download_options;
+  download_options.verbose = options_.verbose;
+  download_options.num_transfers = NumDownloads();
+  download_options.download_size = DownloadSize();
+  download_options.request_factory = [this](int id) -> http::Request::Ptr{
+    return MakeTransferRequest(id, "/download");
+  };
+  std::unique_ptr<DownloadTask> download(new DownloadTask(download_options));
+  TransferRunner::Options runner_options;
+  runner_options.verbose = options_.verbose;
+  runner_options.task = download.get();
+  runner_options.min_runtime = MinTransferRuntime();
+  runner_options.max_runtime = MaxTransferRuntime();
+  runner_options.min_intervals = MinTransferIntervals();
+  runner_options.max_intervals = MaxTransferIntervals();
+  runner_options.max_variance = MaxTransferVariance();
+  runner_options.interval_millis = IntervalMillis();
+  if (options_.progress_millis > 0) {
+    runner_options.progress_millis = options_.progress_millis;
+    runner_options.progress_fn = [](Interval interval) {
+      double speed_variance = variance(interval.short_megabits,
+                                       interval.long_megabits);
+      std::cout << "[+" << round(interval.running_time / 1000.0, 0) << " ms] "
+                << "Download speed: " << round(interval.short_megabits, 2)
+                << " - " << round(interval.long_megabits, 2)
+                << " Mbps (" << interval.bytes << " bytes, variance "
+                << round(speed_variance, 4) << ")\n";
+    };
   }
-  long end_time = speedtest::SystemTimeMicros();
-
-  double running_time = (end_time - start_time) / 1000000.0;
-  double megabits = bytes_downloaded_ * 8 / 1000000.0 / running_time;
-  std::cout << "Downloaded " << bytes_downloaded_
-            << " bytes in " << running_time * 1000 << " ms ("
-            << megabits << " Mbps)\n";
-}
-
-void Speedtest::RunDownload(int id) {
-  auto download = MakeRequest(id, "/download");
-  http::Request::DownloadFn noop = [](void *, size_t) {};
-  while (!end_download_) {
-    long downloaded = 0;
-    download->set_param("i", speedtest::to_string(id));
-    download->set_param("size", speedtest::to_string(options_.download_size));
-    download->set_param("time", speedtest::to_string(SystemTimeMicros()));
-    download->set_progress_fn([&](curl_off_t,
-                                  curl_off_t dlnow,
-                                  curl_off_t,
-                                  curl_off_t) -> bool {
-      if (dlnow > downloaded) {
-        bytes_downloaded_ += dlnow - downloaded;
-        downloaded = dlnow;
-      }
-      return end_download_;
-    });
-    download->Get(noop);
-    download->Reset();
+  TransferRunner runner(runner_options);
+  runner.Run();
+  runner.WaitForEnd();
+  if (options_.verbose) {
+    long running_time = download->GetRunningTimeMicros();
+    std::cout << "Downloaded " << download->bytes_transferred()
+              << " bytes in " << round(running_time / 1000.0, 0) << " ms\n";
   }
+  std::cout << "Download speed: "
+            << round(runner.GetSpeedInMegabits(), 2) << " Mbps\n";
 }
 
 void Speedtest::RunUploadTest() {
-  end_upload_ = false;
-  long start_time = SystemTimeMicros();
-  bytes_uploaded_ = 0;
-  std::thread threads[options_.number];
-  for (int i = 0; i < options_.number; ++i) {
-    threads[i] = std::thread([=]() {
-      RunUpload(i);
-    });
-  }
-  std::thread timer([&]{
-    std::this_thread::sleep_for(
-        std::chrono::milliseconds(options_.time_millis));
-    end_upload_ = true;
-  });
-  timer.join();
-  for (auto &thread : threads) {
-    thread.join();
-  }
-  long end_time = speedtest::SystemTimeMicros();
-
-  double running_time = (end_time - start_time) / 1000000.0;
-  double megabits = bytes_uploaded_ * 8 / 1000000.0 / running_time;
-  std::cout << "Uploaded " << bytes_uploaded_
-            << " bytes in " << running_time * 1000 << " ms ("
-            << megabits << " Mbps)\n";
-}
-
-void Speedtest::RunUpload(int id) {
-  auto upload = MakeRequest(id, "/upload");
-  while (!end_upload_) {
-    long uploaded = 0;
-    upload->set_progress_fn([&](curl_off_t,
-                                curl_off_t,
-                                curl_off_t,
-                                curl_off_t ulnow) -> bool {
-      if (ulnow > uploaded) {
-        bytes_uploaded_ += ulnow - uploaded;
-        uploaded = ulnow;
-      }
-      return end_upload_;
-    });
-
-    // disable the Expect header as the server isn't expecting it (perhaps
-    // it should?). If the server isn't then libcurl waits for 1 second
-    // before sending the data anyway. So sending this header eliminated
-    // the 1 second delay.
-    upload->set_header("Expect", "");
-    upload->Post(send_data_, options_.upload_size);
-    upload->Reset();
-  }
-}
-
-bool Speedtest::RunPingTest() {
-  end_ping_ = false;
-  size_t num_hosts = options_.hosts.size();
-  std::thread threads[num_hosts];
-  min_ping_micros_.clear();
-  min_ping_micros_.resize(num_hosts);
-  for (size_t i = 0; i < num_hosts; ++i) {
-    threads[i] = std::thread([=]() {
-      RunPing(i);
-    });
-  }
-  std::thread timer([&]{
-    std::this_thread::sleep_for(
-        std::chrono::milliseconds(options_.time_millis));
-    end_ping_ = true;
-  });
-  timer.join();
-  for (auto &thread : threads) {
-    thread.join();
-  }
   if (options_.verbose) {
-    std::cout << "Pinged " << num_hosts << " "
-              << (num_hosts == 1 ? "host" : "hosts:") << "\n";
+    std::cout << "Starting upload test to " << config_.location_name
+              << " (" << server_url_->url() << ")\n";
   }
-  size_t min_index = 0;
-  for (size_t i = 0; i < num_hosts; ++i) {
-    if (options_.verbose) {
-      std::cout << "  " << options_.hosts[i].url() << ": ";
-      if (min_ping_micros_[i] == std::numeric_limits<long>::max()) {
-        std::cout << "no packets received";
-      } else {
-        double ping_ms = min_ping_micros_[i] / 1000.0;
-        if (ping_ms < 10) {
-          std::cout << std::fixed << std::setprecision(1);
-        } else {
-          std::cout << std::fixed << std::setprecision(0);
-        }
-        std::cout << ping_ms << " ms";
-      }
-      std::cout << "\n";
-    }
-    if (min_ping_micros_[i] < min_ping_micros_[min_index]) {
-      min_index = i;
-    }
+  UploadTask::Options upload_options;
+  upload_options.verbose = options_.verbose;
+  upload_options.num_transfers = NumUploads();
+  upload_options.payload = MakeRandomData(UploadSize());
+  upload_options.request_factory = [this](int id) -> http::Request::Ptr{
+    return MakeTransferRequest(id, "/upload");
+  };
+
+  std::unique_ptr<UploadTask> upload(new UploadTask(upload_options));
+  TransferRunner::Options runner_options;
+  runner_options.verbose = options_.verbose;
+  runner_options.task = upload.get();
+  runner_options.min_runtime = MinTransferRuntime();
+  runner_options.max_runtime = MaxTransferRuntime();
+  runner_options.min_intervals = MinTransferIntervals();
+  runner_options.max_intervals = MaxTransferIntervals();
+  runner_options.max_variance = MaxTransferVariance();
+  runner_options.interval_millis = IntervalMillis();
+  if (options_.progress_millis > 0) {
+    runner_options.progress_millis = options_.progress_millis;
+    runner_options.progress_fn = [](Interval interval) {
+      double speed_variance = variance(interval.short_megabits,
+                                       interval.long_megabits);
+      std::cout << "[+" << round(interval.running_time / 1000.0, 0) << " ms] "
+                << "Upload speed: " << round(interval.short_megabits, 2)
+                << " - " << round(interval.long_megabits, 2)
+                << " Mbps (" << interval.bytes << " bytes, variance "
+                << round(speed_variance, 4) << ")\n";
+    };
   }
-  if (min_ping_micros_[min_index] == std::numeric_limits<long>::max()) {
-    // no servers respondeded
-    return false;
+  TransferRunner runner(runner_options);
+  runner.Run();
+  runner.WaitForEnd();
+  if (options_.verbose) {
+    long running_time = upload->GetRunningTimeMicros();
+    std::cout << "Uploaded " << upload->bytes_transferred()
+              << " bytes in " << round(running_time / 1000.0, 0) << " ms\n";
   }
-  url_ = options_.hosts[min_index];
-  std::cout << "Host for Speedtest: " << url_.url() << " (";
-  double ping_ms = min_ping_micros_[min_index] / 1000.0;
-  if (ping_ms < 10) {
-    std::cout << std::fixed << std::setprecision(1);
-  } else {
-    std::cout << std::fixed << std::setprecision(0);
-  }
-  std::cout << ping_ms << " ms)\n";
-  return true;
+  std::cout << "Upload speed: "
+            << round(runner.GetSpeedInMegabits(), 2) << " Mbps\n";
 }
 
-void Speedtest::RunPing(size_t index) {
-  http::Request::DownloadFn noop = [](void *, size_t) {};
-  min_ping_micros_[index] = std::numeric_limits<long>::max();
-  http::Url url(options_.hosts[index]);
-  url.set_path("/ping");
-  auto ping = env_->NewRequest();
-  ping->set_url(url);
-  while (!end_ping_) {
-    long req_start = SystemTimeMicros();
-    if (ping->Get(noop) == CURLE_OK) {
-      long req_end = SystemTimeMicros();
-      long ping_time = req_end - req_start;
-      min_ping_micros_[index] = std::min(min_ping_micros_[index], ping_time);
-    }
-    ping->Reset();
-    std::this_thread::sleep_for(std::chrono::milliseconds(100));
-  }
+int Speedtest::NumDownloads() const {
+  return options_.num_downloads
+         ? options_.num_downloads
+         : config_.num_downloads;
 }
 
-std::unique_ptr<http::Request> Speedtest::MakeRequest(int id,
-                                                      const std::string &path) {
-  auto request = env_->NewRequest();
-  http::Url url(url_);
-  int port = (id % 20) + url.port() + 1;
-  url.set_port(port);
-  url.set_path(path);
-  request->set_url(url);
+int Speedtest::DownloadSize() const {
+  return options_.download_size
+         ? options_.download_size
+         : config_.download_size;
+}
+
+int Speedtest::NumUploads() const {
+  return options_.num_uploads
+         ? options_.num_uploads
+         : config_.num_uploads;
+}
+
+int Speedtest::UploadSize() const {
+  return options_.upload_size
+         ? options_.upload_size
+         : config_.upload_size;
+}
+
+int Speedtest::PingRunTime() const {
+  return options_.ping_runtime
+         ? options_.ping_runtime
+         : config_.ping_runtime;
+}
+
+int Speedtest::PingTimeout() const {
+  return options_.ping_timeout
+         ? options_.ping_timeout
+         : config_.ping_timeout;
+}
+
+int Speedtest::MinTransferRuntime() const {
+  return options_.min_transfer_runtime
+         ? options_.min_transfer_runtime
+         : config_.min_transfer_runtime;
+}
+
+int Speedtest::MaxTransferRuntime() const {
+  return options_.max_transfer_runtime
+         ? options_.max_transfer_runtime
+         : config_.max_transfer_runtime;
+}
+
+int Speedtest::MinTransferIntervals() const {
+  return options_.min_transfer_intervals
+         ? options_.min_transfer_intervals
+         : config_.min_transfer_intervals;
+}
+
+int Speedtest::MaxTransferIntervals() const {
+  return options_.max_transfer_intervals
+         ? options_.max_transfer_intervals
+         : config_.max_transfer_intervals;
+}
+
+double Speedtest::MaxTransferVariance() const {
+  return options_.max_transfer_variance
+         ? options_.max_transfer_variance
+         : config_.max_transfer_variance;
+}
+
+int Speedtest::IntervalMillis() const {
+  return options_.interval_millis
+         ? options_.interval_millis
+         : config_.interval_millis;
+}
+
+http::Request::Ptr Speedtest::MakeRequest(const http::Url &url) {
+  http::Request::Ptr request = env_->NewRequest(url);
+  if (!user_agent_.empty()) {
+    request->set_user_agent(user_agent_);
+  }
   return std::move(request);
 }
 
+http::Request::Ptr Speedtest::MakeBaseRequest(
+    int id, const std::string &path) {
+  http::Url url(*server_url_);
+  url.set_path(path);
+  return MakeRequest(url);
+}
+
+http::Request::Ptr Speedtest::MakeTransferRequest(
+    int id, const std::string &path) {
+  http::Url url(*server_url_);
+  int port_start = config_.transfer_port_start;
+  int port_end = config_.transfer_port_end;
+  int num_ports = port_end - port_start + 1;
+  if (num_ports > 0) {
+    url.set_port(port_start + (id % num_ports));
+  }
+  url.set_path(path);
+  return MakeRequest(url);
+}
+
 }  // namespace speedtest
diff --git a/speedtest/speedtest.h b/speedtest/speedtest.h
index 01e6f75..fb32355 100644
--- a/speedtest/speedtest.h
+++ b/speedtest/speedtest.h
@@ -1,5 +1,5 @@
 /*
- * Copyright 2015 Google Inc. All rights reserved.
+ * Copyright 2016 Google Inc. All rights reserved.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -21,8 +21,12 @@
 #include <memory>
 #include <string>
 
+#include "config.h"
 #include "curl_env.h"
+#include "download_task.h"
 #include "options.h"
+#include "ping_task.h"
+#include "upload_task.h"
 #include "url.h"
 #include "request.h"
 
@@ -34,27 +38,40 @@
   virtual ~Speedtest();
 
   void Run();
-  void RunDownloadTest();
-  void RunUploadTest();
-  bool RunPingTest();
 
  private:
-  void RunDownload(int id);
-  void RunUpload(int id);
-  void RunPing(size_t host_index);
+  void InitUserAgent();
+  void LoadServerList();
+  void FindNearestServer();
+  std::string LoadConfig(const http::Url &url);
+  void RunPingTest();
+  void RunDownloadTest();
+  void RunUploadTest();
 
-  std::unique_ptr<http::Request> MakeRequest(int id, const std::string &path);
+  int NumDownloads() const;
+  int DownloadSize() const;
+  int NumUploads() const;
+  int UploadSize() const;
+  int PingTimeout() const;
+  int PingRunTime() const;
+  int MinTransferRuntime() const;
+  int MaxTransferRuntime() const;
+  int MinTransferIntervals() const;
+  int MaxTransferIntervals() const;
+  double MaxTransferVariance() const;
+  int IntervalMillis() const;
 
-  std::shared_ptr<http::CurlEnv> env_;
+  http::Request::Ptr MakeRequest(const http::Url &url);
+  http::Request::Ptr MakeBaseRequest(int id, const std::string &path);
+  http::Request::Ptr MakeTransferRequest(int id, const std::string &path);
+
+  std::shared_ptr <http::CurlEnv> env_;
   Options options_;
-  http::Url url_;
-  std::atomic_bool end_ping_;
-  std::atomic_bool end_download_;
-  std::atomic_bool end_upload_;
-  std::atomic_long bytes_downloaded_;
-  std::atomic_long bytes_uploaded_;
-  std::vector<long> min_ping_micros_;
-  const char *send_data_;
+  Config config_;
+  std::string user_agent_;
+  std::vector<http::Url> servers_;
+  std::unique_ptr<http::Url> server_url_;
+  std::unique_ptr<std::string> send_data_;
 
   // disable
   Speedtest(const Speedtest &) = delete;
diff --git a/speedtest/speedtest_main.cc b/speedtest/speedtest_main.cc
index 8a9c2c9..d756c4b 100644
--- a/speedtest/speedtest_main.cc
+++ b/speedtest/speedtest_main.cc
@@ -1,5 +1,5 @@
 /*
- * Copyright 2015 Google Inc. All rights reserved.
+ * Copyright 2016 Google Inc. All rights reserved.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
diff --git a/speedtest/task.cc b/speedtest/task.cc
new file mode 100644
index 0000000..84d12c9
--- /dev/null
+++ b/speedtest/task.cc
@@ -0,0 +1,127 @@
+/*
+ * Copyright 2016 Google Inc. All rights reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+#include "task.h"
+
+#include <algorithm>
+#include <cassert>
+#include <iostream>
+#include <thread>
+#include "utils.h"
+
+namespace speedtest {
+
+const char *AsString(TaskStatus status) {
+  switch (status) {
+    case TaskStatus::NOT_STARTED: return "NOT_STARTED";
+    case TaskStatus::RUNNING: return "RUNNING";
+    case TaskStatus::STOPPING: return "STOPPING";
+    case TaskStatus::STOPPED: return "STOPPED";
+  }
+  std::exit(1);
+}
+
+Task::Task(const Options &options)
+    : status_(TaskStatus::NOT_STARTED) {
+  assert(options.request_factory);
+}
+
+Task::~Task() {
+  Stop();
+  if (runner_.joinable()) {
+    runner_.join();
+  }
+  if (stopper_.joinable()) {
+    stopper_.join();
+  }
+}
+
+void Task::Run() {
+  runner_ = std::thread([=]{
+    {
+      std::lock_guard <std::mutex> lock(mutex_);
+      if (status_ != TaskStatus::NOT_STARTED &&
+          status_ != TaskStatus::STOPPED) {
+        return;
+      }
+      UpdateStatusLocked(TaskStatus::RUNNING);
+      start_time_ = SystemTimeMicros();
+    }
+    RunInternal();
+  });
+  stopper_ = std::thread([=]{
+    WaitFor(TaskStatus::STOPPING);
+    StopInternal();
+    std::lock_guard <std::mutex> lock(mutex_);
+    UpdateStatusLocked(TaskStatus::STOPPED);
+    end_time_ = SystemTimeMicros();
+  });
+}
+
+void Task::Stop() {
+  std::lock_guard <std::mutex> lock(mutex_);
+  if (status_ != TaskStatus::RUNNING) {
+    return;
+  }
+  UpdateStatusLocked(TaskStatus::STOPPING);
+}
+
+TaskStatus Task::GetStatus() const {
+  std::lock_guard <std::mutex> lock(mutex_);
+  return status_;
+}
+
+long Task::GetStartTime() const {
+  std::lock_guard <std::mutex> lock(mutex_);
+  return start_time_;
+}
+
+long Task::GetEndTime() const {
+  std::lock_guard <std::mutex> lock(mutex_);
+  return end_time_;
+}
+
+long Task::GetRunningTimeMicros() const {
+  std::lock_guard <std::mutex> lock(mutex_);
+  switch (status_) {
+    case TaskStatus::NOT_STARTED:
+      break;
+    case TaskStatus::RUNNING:
+    case TaskStatus::STOPPING:
+      return SystemTimeMicros() - start_time_;
+    case TaskStatus::STOPPED:
+      return end_time_ - start_time_;
+  }
+  return 0;
+}
+
+void Task::WaitForEnd() {
+  WaitFor(TaskStatus::STOPPED);
+}
+
+void Task::UpdateStatusLocked(TaskStatus status) {
+  status_ = status;
+  status_cond_.notify_all();
+}
+
+void Task::WaitFor(TaskStatus status) {
+  std::unique_lock<std::mutex> lock(mutex_);
+  status_cond_.wait(lock, [=]{
+    return status_ == status;
+  });
+}
+
+}  // namespace speedtest
diff --git a/speedtest/task.h b/speedtest/task.h
new file mode 100644
index 0000000..429b078
--- /dev/null
+++ b/speedtest/task.h
@@ -0,0 +1,80 @@
+/*
+ * Copyright 2016 Google Inc. All rights reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+#ifndef SPEEDTEST_TASK_H
+#define SPEEDTEST_TASK_H
+
+#include <condition_variable>
+#include <functional>
+#include <memory>
+#include <mutex>
+#include <thread>
+
+namespace speedtest {
+
+enum class TaskStatus {
+  NOT_STARTED,
+  RUNNING,
+  STOPPING,
+  STOPPED
+};
+
+const char *AsString(TaskStatus status);
+
+class Task {
+ public:
+  struct Options {
+    bool verbose = false;
+  };
+
+  explicit Task(const Options &options);
+  virtual ~Task();
+
+  void Run();
+  void Stop();
+
+  TaskStatus GetStatus() const;
+  long GetStartTime() const;
+  long GetEndTime() const;
+  long GetRunningTimeMicros() const;
+  void WaitForEnd();
+
+ protected:
+  virtual void RunInternal() = 0;
+  virtual void StopInternal() {}
+
+ private:
+  // Only call with mutex_
+  void UpdateStatusLocked(TaskStatus status);
+
+  void WaitFor(TaskStatus status);
+
+  mutable std::mutex mutex_;
+  std::thread runner_;
+  std::thread stopper_;
+  std::condition_variable status_cond_;
+  TaskStatus status_;
+  long start_time_;
+  long end_time_;
+
+  // disallowed
+  Task(const Task &) = delete;
+  void operator=(const Task &) = delete;
+};
+
+}  // namespace speedtest
+
+#endif  //SPEEDTEST_TASK_H
diff --git a/speedtest/timed_runner.cc b/speedtest/timed_runner.cc
new file mode 100644
index 0000000..bf7c4cc
--- /dev/null
+++ b/speedtest/timed_runner.cc
@@ -0,0 +1,35 @@
+/*
+ * Copyright 2016 Google Inc. All rights reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+#include "timed_runner.h"
+
+#include <cassert>
+#include <thread>
+
+namespace speedtest {
+
+void RunTimed(Task *task, long millis) {
+  assert(task);
+  task->Run();
+  std::thread timer([=] {
+    std::this_thread::sleep_for(
+        std::chrono::milliseconds(millis));
+    task->Stop();
+  });
+  timer.join();
+}
+
+}  // namespace speedtest
diff --git a/speedtest/timed_runner.h b/speedtest/timed_runner.h
new file mode 100644
index 0000000..02e673f
--- /dev/null
+++ b/speedtest/timed_runner.h
@@ -0,0 +1,29 @@
+/*
+ * Copyright 2016 Google Inc. All rights reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+#ifndef SPEEDTEST_RUNNER_H
+#define SPEEDTEST_RUNNER_H
+
+#include "task.h"
+
+namespace speedtest {
+
+// Run a task for a set duration
+void RunTimed(Task *task, long millis);
+
+}  // namespace speedtest
+
+#endif  // SPEEDTEST_RUNNER_H
diff --git a/speedtest/transfer_runner.cc b/speedtest/transfer_runner.cc
new file mode 100644
index 0000000..d37f087
--- /dev/null
+++ b/speedtest/transfer_runner.cc
@@ -0,0 +1,171 @@
+/*
+ * Copyright 2016 Google Inc. All rights reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+#include "transfer_runner.h"
+
+#include <algorithm>
+#include <cassert>
+#include <chrono>
+#include <iostream>
+#include <thread>
+#include "transfer_task.h"
+#include "utils.h"
+
+namespace speedtest {
+namespace {
+
+const int kDefaultIntervalMillis = 200;
+
+}  // namespace
+
+TransferRunner::TransferRunner(const Options &options)
+    : Task(options),
+      options_(options) {
+  if (options_.interval_millis <= 0) {
+    options_.interval_millis = kDefaultIntervalMillis;
+  }
+}
+
+void TransferRunner::RunInternal() {
+  threads_.clear();
+  intervals_.clear();
+
+  // sentinel value of all zeroes
+  intervals_.emplace_back();
+
+  // If progress updates are created add a thread to send updates
+  if (options_.progress_fn && options_.progress_millis > 0) {
+    if (options_.verbose) {
+      std::cout << "Progress updates every "
+                << options_.progress_millis << " ms\n";
+    }
+    threads_.emplace_back([&] {
+      std::this_thread::sleep_for(
+          std::chrono::milliseconds(options_.progress_millis));
+      while (GetStatus() == TaskStatus::RUNNING) {
+        Interval progress = GetLastInterval();
+        options_.progress_fn(progress);
+        std::this_thread::sleep_for(
+            std::chrono::milliseconds(options_.progress_millis));
+      }
+      Interval progress = GetLastInterval();
+      options_.progress_fn(progress);
+    });
+  } else if (options_.verbose) {
+    std::cout << "No progress updates\n";
+  }
+
+  // Updating thread
+  if (options_.verbose) {
+    std::cout << "Transfer runner updates every "
+              << options_.interval_millis << " ms\n";
+  }
+  threads_.emplace_back([&] {
+    std::this_thread::sleep_for(
+        std::chrono::milliseconds(options_.interval_millis));
+    while (GetStatus() == TaskStatus::RUNNING) {
+      const Interval &interval = AddInterval();
+      if (interval.running_time > options_.max_runtime * 1000) {
+        Stop();
+        return;
+      }
+      if (interval.running_time >= options_.min_runtime * 1000 &&
+          interval.long_megabits > 0 &&
+          interval.short_megabits > 0) {
+        double speed_variance = variance(interval.short_megabits,
+                                         interval.long_megabits);
+        if (speed_variance <= options_.max_variance) {
+          Stop();
+          return;
+        }
+      }
+      std::this_thread::sleep_for(
+          std::chrono::milliseconds(options_.interval_millis));
+    }
+  });
+
+  options_.task->Run();
+}
+
+void TransferRunner::StopInternal() {
+  options_.task->Stop();
+  options_.task->WaitForEnd();
+  std::for_each(threads_.begin(), threads_.end(), [](std::thread &t) {
+    t.join();
+  });
+  threads_.clear();
+}
+
+const Interval &TransferRunner::AddInterval() {
+  std::lock_guard <std::mutex> lock(mutex_);
+  intervals_.emplace_back();
+  Interval &interval = intervals_[intervals_.size() - 1];
+  interval.running_time = options_.task->GetRunningTimeMicros();
+  interval.bytes = options_.task->bytes_transferred();
+  if (options_.exponential_moving_average) {
+    interval.short_megabits = GetShortEma(options_.min_intervals);
+    interval.long_megabits = GetLongEma(options_.max_intervals);
+  } else {
+    interval.short_megabits = GetSimpleAverage(options_.min_intervals);
+    interval.long_megabits = GetSimpleAverage(options_.max_intervals);
+  }
+  speed_ = interval.long_megabits;
+  return intervals_.back();
+}
+
+Interval TransferRunner::GetLastInterval() const {
+  std::lock_guard <std::mutex> lock(mutex_);
+  return intervals_.back();
+}
+
+double TransferRunner::GetSpeedInMegabits() const {
+  std::lock_guard <std::mutex> lock(mutex_);
+  return speed_;
+}
+
+double TransferRunner::GetShortEma(int num_intervals) {
+  if (intervals_.empty() || num_intervals <= 0) {
+    return 0.0;
+  }
+  Interval last_interval = GetLastInterval();
+  double percent = 2.0d / (num_intervals + 1);
+  return GetSimpleAverage(1) * percent +
+      last_interval.short_megabits * (1 - percent);
+}
+
+double TransferRunner::GetLongEma(int num_intervals) {
+  if (intervals_.empty() || num_intervals <= 0) {
+    return 0.0;
+  }
+  Interval last_interval = GetLastInterval();
+  double percent = 2.0d / (num_intervals + 1);
+  return GetSimpleAverage(1) * percent +
+      last_interval.long_megabits * (1 - percent);
+}
+
+double TransferRunner::GetSimpleAverage(int num_intervals) {
+  if (intervals_.empty() || num_intervals <= 0) {
+    return 0.0;
+  }
+  int end_index = intervals_.size() - 1;
+  int start_index = std::max(0, end_index - num_intervals);
+  const Interval &end = intervals_[end_index];
+  const Interval &start = intervals_[start_index];
+  return ToMegabits(end.bytes - start.bytes,
+                    end.running_time - start.running_time);
+}
+
+}  // namespace
diff --git a/speedtest/transfer_runner.h b/speedtest/transfer_runner.h
new file mode 100644
index 0000000..793c8ec
--- /dev/null
+++ b/speedtest/transfer_runner.h
@@ -0,0 +1,84 @@
+/*
+ * Copyright 2016 Google Inc. All rights reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+#ifndef SPEEDTEST_TRANSFER_RUNNER_H
+#define SPEEDTEST_TRANSFER_RUNNER_H
+
+#include <functional>
+#include <mutex>
+#include <thread>
+#include <vector>
+#include "task.h"
+#include "transfer_task.h"
+
+namespace speedtest {
+
+struct Interval {
+  long bytes = 0;
+  long running_time = 0;
+  double short_megabits = 0.0;
+  double long_megabits = 0.0;
+};
+
+// Run a variable length transfer test using two moving averages.
+// The test runs between min_runtime and max_runtime and otherwise
+// ends when the speed is "stable" meaning the two moving averages
+// are relatively close to one another.
+class TransferRunner : public Task {
+ public:
+  struct Options : public Task::Options {
+    TransferTask *task = nullptr;
+    int min_runtime = 0;
+    int max_runtime = 0;
+    int interval_millis = 0;
+    int progress_millis = 0;
+    int min_intervals = 0;
+    int max_intervals = 0;
+    double max_variance = 0.0;
+    bool exponential_moving_average = false;
+    std::function<void(Interval)> progress_fn;
+  };
+
+  explicit TransferRunner(const Options &options);
+
+  double GetSpeedInMegabits() const;
+  Interval GetLastInterval() const;
+
+ protected:
+  void RunInternal() override;
+  void StopInternal() override;
+
+ private:
+  const Interval &AddInterval();
+  double GetSimpleAverage(int num_intervals);
+  double GetShortEma(int num_intervals);
+  double GetLongEma(int num_intervals);
+
+  Options options_;
+
+  mutable std::mutex mutex_;
+  std::vector<Interval> intervals_;
+  std::vector<std::thread> threads_;
+  double speed_;
+
+  // disallowed
+  TransferRunner(const TransferRunner &) = delete;
+  void operator=(const TransferRunner &) = delete;
+};
+
+}  // namespace
+
+#endif //SPEEDTEST_TRANSFER_RUNNER_H
diff --git a/speedtest/transfer_task.cc b/speedtest/transfer_task.cc
new file mode 100644
index 0000000..d742d87
--- /dev/null
+++ b/speedtest/transfer_task.cc
@@ -0,0 +1,51 @@
+/*
+ * Copyright 2016 Google Inc. All rights reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+#include "transfer_task.h"
+
+#include <cassert>
+#include <thread>
+#include <vector>
+
+namespace speedtest {
+
+TransferTask::TransferTask(const Options &options)
+    : HttpTask(options),
+      bytes_transferred_(0),
+      requests_started_(0),
+      requests_ended_(0) {
+  assert(options.num_transfers > 0);
+}
+
+void TransferTask::ResetCounters() {
+  bytes_transferred_ = 0;
+  requests_started_ = 0;
+  requests_ended_ = 0;
+}
+
+void TransferTask::StartRequest() {
+  requests_started_++;
+}
+
+void TransferTask::EndRequest() {
+  requests_ended_++;
+}
+
+void TransferTask::TransferBytes(long bytes) {
+  bytes_transferred_ += bytes;
+}
+
+}  // namespace speedtest
diff --git a/speedtest/transfer_task.h b/speedtest/transfer_task.h
new file mode 100644
index 0000000..83cff9e
--- /dev/null
+++ b/speedtest/transfer_task.h
@@ -0,0 +1,55 @@
+/*
+ * Copyright 2016 Google Inc. All rights reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+#ifndef SPEEDTEST_TRANSFER_TEST_H
+#define SPEEDTEST_TRANSFER_TEST_H
+
+#include <atomic>
+#include "http_task.h"
+
+namespace speedtest {
+
+class TransferTask : public HttpTask {
+ public:
+  struct Options : HttpTask::Options {
+    int num_transfers = 0;
+  };
+
+  explicit TransferTask(const Options &options);
+
+  long bytes_transferred() const { return bytes_transferred_; }
+  long requests_started() const { return requests_started_; }
+  long requests_ended() const { return requests_ended_; }
+
+ protected:
+  void ResetCounters();
+  void StartRequest();
+  void EndRequest();
+  void TransferBytes(long bytes);
+
+ private:
+  std::atomic_long bytes_transferred_;
+  std::atomic_int requests_started_;
+  std::atomic_int requests_ended_;
+
+  // disallowed
+  TransferTask(const TransferTask &) = delete;
+  void operator=(const TransferTask &) = delete;
+};
+
+}  // namespace speedtest
+
+#endif  // SPEEDTEST_TRANSFER_TEST_H
diff --git a/speedtest/upload_task.cc b/speedtest/upload_task.cc
new file mode 100644
index 0000000..251fc41
--- /dev/null
+++ b/speedtest/upload_task.cc
@@ -0,0 +1,83 @@
+/*
+ * Copyright 2016 Google Inc. All rights reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+#include "upload_task.h"
+
+#include <algorithm>
+#include <cassert>
+#include <iostream>
+#include "utils.h"
+
+namespace speedtest {
+
+UploadTask::UploadTask(const Options &options)
+    : TransferTask(options),
+      options_(options) {
+  assert(options_.payload);
+  assert(options_.payload->size() > 0);
+}
+
+void UploadTask::RunInternal() {
+  ResetCounters();
+  threads_.clear();
+  if (options_.verbose) {
+    std::cout << "Uploading " << options_.num_transfers
+              << " threads with " << options_.payload->size() << " bytes\n";
+  }
+  for (int i = 0; i < options_.num_transfers; ++i) {
+    threads_.emplace_back([=]{
+      RunUpload(i);
+    });
+  }
+}
+
+void UploadTask::StopInternal() {
+  std::for_each(threads_.begin(), threads_.end(), [](std::thread &t) {
+    t.join();
+  });
+}
+
+void UploadTask::RunUpload(int id) {
+  http::Request::Ptr upload = options_.request_factory(id);
+  while (GetStatus() == TaskStatus::RUNNING) {
+    long uploaded = 0;
+    upload->set_param("i", to_string(id));
+    upload->set_param("time", to_string(SystemTimeMicros()));
+    upload->set_progress_fn([&](curl_off_t,
+                                  curl_off_t,
+                                  curl_off_t,
+                                  curl_off_t ulnow) -> bool {
+      if (ulnow > uploaded) {
+        TransferBytes(ulnow - uploaded);
+        uploaded = ulnow;
+      }
+      return GetStatus() != TaskStatus::RUNNING;
+    });
+
+    // disable the Expect header as the server isn't expecting it (perhaps
+    // it should?). If the server isn't then libcurl waits for 1 second
+    // before sending the data anyway. So sending this header eliminated
+    // the 1 second delay.
+    upload->set_header("Expect", "");
+
+    StartRequest();
+    upload->Post(options_.payload->c_str(), options_.payload->size());
+    EndRequest();
+    upload->Reset();
+  }
+}
+
+}  // namespace speedtest
diff --git a/speedtest/upload_task.h b/speedtest/upload_task.h
new file mode 100644
index 0000000..323f904
--- /dev/null
+++ b/speedtest/upload_task.h
@@ -0,0 +1,53 @@
+/*
+ * Copyright 2016 Google Inc. All rights reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+#ifndef SPEEDTEST_UPLOAD_TASK_H
+#define SPEEDTEST_UPLOAD_TASK_H
+
+#include <memory>
+#include <string>
+#include <thread>
+#include <vector>
+#include "transfer_task.h"
+
+namespace speedtest {
+
+class UploadTask : public TransferTask {
+ public:
+  struct Options : TransferTask::Options {
+    std::shared_ptr<std::string> payload;
+  };
+
+  explicit UploadTask(const Options &options);
+
+ protected:
+  void RunInternal() override;
+  void StopInternal() override;
+
+ private:
+  void RunUpload(int id);
+
+  Options options_;
+  std::vector<std::thread> threads_;
+
+  // disallowed
+  UploadTask(const UploadTask &) = delete;
+  void operator=(const UploadTask &) = delete;
+};
+
+}  // namespace speedtest
+
+#endif  // SPEEDTEST_UPLOAD_TASK_H
diff --git a/speedtest/url.cc b/speedtest/url.cc
index c0934a0..61588a0 100644
--- a/speedtest/url.cc
+++ b/speedtest/url.cc
@@ -1,5 +1,5 @@
 /*
- * Copyright 2015 Google Inc. All rights reserved.
+ * Copyright 2016 Google Inc. All rights reserved.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -132,6 +132,10 @@
   return url1.url() == url2.url();
 }
 
+std::ostream &operator<<(std::ostream &os, const Url &url) {
+  return os << (url.ok() ? url.url() : "{invalid URL}");
+}
+
 Url::Url(): parsed_(false), absolute_(false), port_(0) {
 }
 
@@ -150,7 +154,8 @@
   fragment_ = other.fragment_;
 }
 
-Url::Url(const char *url): parsed_(false), absolute_(false), port_(0) {
+Url::Url(const std::string &url)
+    : parsed_(false), absolute_(false), port_(0) {
   Parse(url);
 }
 
@@ -411,8 +416,9 @@
     return false;
   }
   std::string port(start, iter);
-  int portnum = speedtest::stoi(port);
-  if (portnum < 1 || portnum > 65535) {
+  int portnum;
+  if (!speedtest::ParseInt(port, &portnum) ||
+      portnum < 1 || portnum > 65535) {
     return false;
   }
   current_ = iter;
diff --git a/speedtest/url.h b/speedtest/url.h
index 6aeb68a..4844916 100644
--- a/speedtest/url.h
+++ b/speedtest/url.h
@@ -1,5 +1,5 @@
 /*
- * Copyright 2015 Google Inc. All rights reserved.
+ * Copyright 2016 Google Inc. All rights reserved.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -24,6 +24,7 @@
 class Url;
 
 bool operator==(const Url &url1, const Url &url2);
+std::ostream &operator<<(std::ostream &os, const Url &url);
 
 // Partial implementation of a URL parser. This is needed because URLs need
 // to be manipulated for creating URLs for Speedtest, which is otherwise
@@ -43,7 +44,7 @@
  public:
   Url();
   Url(const Url &other);
-  explicit Url(const char *url);
+  explicit Url(const std::string &url);
   Url &operator=(const Url &other);
 
   bool Parse(const std::string &url);
@@ -75,6 +76,7 @@
   std::string url() const;
 
   friend bool operator==(const Url &url1, const Url &url2);
+  friend std::ostream &operator<<(std::ostream &os, const Url &url);
 
  private:
   using Iter = std::string::const_iterator;
diff --git a/speedtest/url_test.cc b/speedtest/url_test.cc
index 06c2d72..f2945a5 100644
--- a/speedtest/url_test.cc
+++ b/speedtest/url_test.cc
@@ -1,5 +1,5 @@
 /*
- * Copyright 2015 Google Inc. All rights reserved.
+ * Copyright 2016 Google Inc. All rights reserved.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -14,10 +14,10 @@
  * limitations under the License.
  */
 
-#include <gtest/gtest.h>
-
 #include "url.h"
 
+#include <gtest/gtest.h>
+
 namespace http {
 namespace {
 
diff --git a/speedtest/utils.cc b/speedtest/utils.cc
index 012e1eb..8144174 100644
--- a/speedtest/utils.cc
+++ b/speedtest/utils.cc
@@ -1,5 +1,5 @@
 /*
- * Copyright 2015 Google Inc. All rights reserved.
+ * Copyright 2016 Google Inc. All rights reserved.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -16,9 +16,13 @@
 
 #include "utils.h"
 
+#include <algorithm>
+#include <cctype>
 #include <cstdlib>
+#include <functional>
 #include <iostream>
 #include <stdexcept>
+#include <stdio.h>
 #include <string>
 #include <sstream>
 
@@ -33,24 +37,62 @@
   return ts.tv_sec * 1000000 + ts.tv_nsec / 1000;
 }
 
-std::string to_string(long n)
-{
+std::string to_string(long n) {
   std::ostringstream s;
   s << n;
   return s.str();
 }
 
-int stoi(const std::string& str)
-{
-  int rc;
-  std::istringstream n(str);
+std::string round(double d, int digits) {
+  char buf[20];
+  sprintf(buf, "%.*f", digits, d);
+  return buf;
+}
 
-  if (!(n >> rc)) {
-    std::cerr << "Not a number: " << str;
-    std::exit(1);
+double variance(double d1, double d2) {
+  if (d2 == 0) {
+    return 0.0;
   }
+  double smaller = std::min(d1, d2);
+  double larger = std::max(d1, d2);
+  return 1.0 - smaller / larger;
+}
 
-  return rc;
+double ToMegabits(long bytes, long micros) {
+  return (8.0d * bytes) / micros;
+}
+
+bool ParseInt(const std::string &str, int *result) {
+  if (!result) {
+    return false;
+  }
+  std::istringstream n(str);
+  return !(n >> *result).fail();
+}
+
+// Trim from start in place
+// Caller retains ownership
+void LeftTrim(std::string *s) {
+  s->erase(s->begin(),
+           std::find_if(s->begin(),
+                        s->end(),
+                        std::not1(std::ptr_fun<int, int>(std::isspace))));
+}
+
+// Trim from end in place
+// Caller retains ownership
+void RightTrim(std::string *s) {
+  s->erase(std::find_if(s->rbegin(),
+                        s->rend(),
+                        std::not1(std::ptr_fun<int, int>(std::isspace))).base(),
+           s->end());
+}
+
+// Trim from both ends in place
+// Caller retains ownership
+void Trim(std::string *s) {
+  LeftTrim(s);
+  RightTrim(s);
 }
 
 }  // namespace speedtest
diff --git a/speedtest/utils.h b/speedtest/utils.h
index 7ad4289..7e8d251 100644
--- a/speedtest/utils.h
+++ b/speedtest/utils.h
@@ -1,5 +1,5 @@
 /*
- * Copyright 2015 Google Inc. All rights reserved.
+ * Copyright 2016 Google Inc. All rights reserved.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -28,8 +28,31 @@
 // Return a string representation of n
 std::string to_string(long n);
 
-// return an integer value from the string str.
-int stoi(const std::string& str);
+// Round a double to a minimum number of significant digits
+std::string round(double d, int digits);
+
+// Return 1 - (shorter / larger)
+double variance(double d1, double d2);
+
+// Convert bytes and time in micros to speed in megabits
+double ToMegabits(long bytes, long micros);
+
+// Parse an int.
+// If successful, write result to result and return true.
+// If result is null or the int can't be parsed, return false.
+bool ParseInt(const std::string &str, int *result);
+
+// Trim from start in place
+// Caller retains ownership
+void LeftTrim(std::string *s);
+
+// Trim from end in place
+// Caller retains ownership
+void RightTrim(std::string *s);
+
+// Trim from both ends in place
+// Caller retains ownership
+void Trim(std::string *s);
 
 }  // namespace speedtst
 
diff --git a/sysmgr/peripheral/fancontrol.cc b/sysmgr/peripheral/fancontrol.cc
index a19afa9..8e81fe1 100644
--- a/sysmgr/peripheral/fancontrol.cc
+++ b/sysmgr/peripheral/fancontrol.cc
@@ -226,6 +226,16 @@
                           temp_overheat : 97,
                         };
 
+const FanControlParams FanControl::kGFLT300FanCtrlSocDefaults = {
+                          temp_setpt    : 0,  /* No fan */
+                          temp_max      : 0,
+                          temp_step     : 0,
+                          duty_cycle_min: 0,
+                          duty_cycle_max: 0,
+                          pwm_step      : 0,
+                          temp_overheat : 97,
+                        };
+
 FanControl::~FanControl() {
   Terminate();
 }
@@ -310,6 +320,9 @@
     case BRUNO_GFLT110:
       pfan_ctrl_params_[BRUNO_SOC] = kGFLT110FanCtrlSocDefaults;
       break;
+    case BRUNO_GFLT300:
+      pfan_ctrl_params_[BRUNO_SOC] = kGFLT300FanCtrlSocDefaults;
+      break;
     case BRUNO_UNKNOWN:
       LOG(LS_ERROR) << "Invalid platform type, ignore ... " << platform_;
       break;
diff --git a/sysmgr/peripheral/fancontrol.h b/sysmgr/peripheral/fancontrol.h
index 2e552e3..06e8499 100644
--- a/sysmgr/peripheral/fancontrol.h
+++ b/sysmgr/peripheral/fancontrol.h
@@ -81,6 +81,7 @@
   static const FanControlParams kGFHD254FanCtrlAux1Defaults;
 
   static const FanControlParams kGFLT110FanCtrlSocDefaults;
+  static const FanControlParams kGFLT300FanCtrlSocDefaults;
 
   explicit FanControl(Platform *platform)
       : state_(OFF),
diff --git a/sysmgr/peripheral/platform.cc b/sysmgr/peripheral/platform.cc
index 23572a0..ba239fd 100644
--- a/sysmgr/peripheral/platform.cc
+++ b/sysmgr/peripheral/platform.cc
@@ -20,6 +20,7 @@
   Platform("GFLT110", BRUNO_GFLT110, false, false, false),
   Platform("GFLT120", BRUNO_GFLT110, false, false, false),
   Platform("GFHD254", BRUNO_GFHD254, false, true, true),
+  Platform("GFLT300", BRUNO_GFLT300, false, false, false),
   Platform("UNKNOWN PLATFORM", BRUNO_UNKNOWN, false, false,  false),
 };
 
diff --git a/sysmgr/peripheral/platform.h b/sysmgr/peripheral/platform.h
index 80e0a74..91a5925 100644
--- a/sysmgr/peripheral/platform.h
+++ b/sysmgr/peripheral/platform.h
@@ -26,6 +26,7 @@
   BRUNO_GFHD200,          /* Camaro */
   BRUNO_GFLT110,          /* Fiber Jack */
   BRUNO_GFHD254,          /* Lockdown */
+  BRUNO_GFLT300,          /* Go-Long FiberJack */
   BRUNO_UNKNOWN
 };
 
diff --git a/taxonomy/dhcp.py b/taxonomy/dhcp.py
index bfd1c91..7a503bc 100644
--- a/taxonomy/dhcp.py
+++ b/taxonomy/dhcp.py
@@ -43,6 +43,7 @@
 
     '1,3,6,12,15,17,28,40,41,42': ['epsonprinter'],
 
+    '6,3,1,15,66,67,13,44,12': ['hpprinter'],
     '6,3,1,15,66,67,13,44,12,81': ['hpprinter'],
     '6,3,1,15,66,67,13,44,119,12,81,252': ['hpprinter'],
 
@@ -64,7 +65,7 @@
     '1,3,6,12,15,28,40,41,42': ['visiotv', 'kindle'],
 
     '1,3,6,15,28,33': ['wii'],
-    '1,3,6,15': ['wii'],
+    '1,3,6,15': ['wii', 'xbox'],
 
     '1,15,3,6,44,46,47,31,33,121,249,252,43': ['windows-phone'],
 }
diff --git a/taxonomy/ethernet.py b/taxonomy/ethernet.py
index d0aaf45..0635073 100644
--- a/taxonomy/ethernet.py
+++ b/taxonomy/ethernet.py
@@ -24,6 +24,7 @@
 # Galaxy S4.
 database = {
     '00:bb:3a': ['amazon'],
+    '0c:47:c9': ['amazon'],
     '10:ae:60': ['amazon'],
     '28:ef:01': ['amazon'],
     '74:75:48': ['amazon'],
@@ -35,8 +36,11 @@
 
     '30:85:a9': ['asus'],
     '5c:ff:35': ['asus'],
+    '60:a4:4c': ['asus'],
     '74:d0:2b': ['asus'],
     'ac:22:0b': ['asus'],
+    'bc:ee:7b': ['asus'],
+    'd8:50:e6': ['asus'],
 
     '30:8c:fb': ['dropcam'],
 
@@ -49,6 +53,7 @@
 
     # These are registered to AzureWave, but used for Chromecast v1.
     '6c:ad:f8': ['azurewave', 'google'],
+    'b0:ee:45': ['azurewave', 'google'],
     'd0:e7:82': ['azurewave', 'google'],
 
     '00:23:76': ['htc'],
@@ -69,14 +74,17 @@
     '0c:48:85': ['lg'],
     '10:68:3f': ['lg'],
     '2c:54:cf': ['lg'],
+    '34:fc:ef': ['lg'],
     '40:b0:fa': ['lg'],
     '58:3f:54': ['lg'],
     '64:89:9a': ['lg'],
     '64:bc:0c': ['lg'],
     '78:f8:82': ['lg'],
+    '88:c9:d0': ['lg'],
     '8c:3a:e3': ['lg'],
     'a0:39:f7': ['lg'],
     'a0:91:69': ['lg'],
+    'bc:f5:ac': ['lg'],
     'c4:43:8f': ['lg'],
     'c4:9a:02': ['lg'],
     'f8:95:c7': ['lg'],
@@ -91,6 +99,7 @@
     '1c:56:fe': ['motorola'],
     '24:da:9b': ['motorola'],
     '3c:43:8e': ['motorola'],
+    '40:78:6a': ['motorola'],
     '44:80:eb': ['motorola'],
     '5c:51:88': ['motorola'],
     '60:be:b5': ['motorola'],
@@ -100,26 +109,30 @@
     '98:4b:4a': ['motorola'],
     '9c:d9:17': ['motorola'],
     'cc:c3:ea': ['motorola'],
+    'ec:88:92': ['motorola'],
     'e8:91:20': ['motorola'],
     'f8:7b:7a': ['motorola'],
     'f8:cf:c5': ['motorola'],
     'f8:e0:79': ['motorola'],
     'f8:f1:b6': ['motorola'],
 
-    '00:26:e8': ['murata'],
-    '10:a5:d0': ['murata'],
-    '14:7d:c5': ['murata'],
-    '1c:99:4c': ['murata'],
-    '20:02:af': ['murata'],
-    '40:f3:08': ['murata'],
-    '44:a7:cf': ['murata'],
-    '5c:da:d4': ['murata'],
-    '78:4b:87': ['murata'],
-    '90:b6:86': ['murata'],
-    '98:f1:70': ['murata'],
-    'f0:27:65': ['murata'],
-    'fc:c2:de': ['murata'],
-    'fc:db:b3': ['murata'],
+    '00:26:e8': ['murata', 'samsung'],
+    '00:ae:fa': ['murata', 'samsung'],
+    '10:a5:d0': ['murata', 'samsung'],
+    '14:7d:c5': ['murata', 'samsung'],
+    '1c:99:4c': ['murata', 'samsung'],
+    '20:02:af': ['murata', 'samsung'],
+    '40:f3:08': ['murata', 'samsung'],
+    '44:a7:cf': ['murata', 'samsung'],
+    '5c:da:d4': ['murata', 'samsung'],
+    '5c:f8:a1': ['murata', 'samsung'],
+    '60:f1:89': ['murata', 'samsung'],
+    '78:4b:87': ['murata', 'samsung'],
+    '90:b6:86': ['murata', 'samsung'],
+    '98:f1:70': ['murata', 'samsung'],
+    'f0:27:65': ['murata', 'samsung'],
+    'fc:c2:de': ['murata', 'samsung'],
+    'fc:db:b3': ['murata', 'samsung'],
 
     '18:b4:30': ['nest'],
 
@@ -137,12 +150,14 @@
     '3c:8b:fe': ['samsung'],
     '40:0e:85': ['samsung'],
     '48:5a:3f': ['samsung', 'wisol'],
+    '54:88:0e': ['samsung'],
     '5c:0a:5b': ['samsung'],
     '5c:f6:dc': ['samsung'],
     '6c:2f:2c': ['samsung'],
     '6c:83:36': ['samsung'],
     '78:d6:f0': ['samsung'],
     '80:65:6d': ['samsung'],
+    '84:11:9e': ['samsung'],
     '84:25:db': ['samsung'],
     '84:38:38': ['samsung'],
     '88:32:9b': ['samsung'],
@@ -155,6 +170,7 @@
     'b0:df:3a': ['samsung'],
     'b0:ec:71': ['samsung'],
     'b4:07:f9': ['samsung'],
+    'bc:20:a4': ['samsung'],
     'c0:bd:d1': ['samsung'],
     'c4:42:02': ['samsung'],
     'cc:07:ab': ['samsung'],
diff --git a/taxonomy/ssdp.py b/taxonomy/ssdp.py
index f16b3ce..f359b1b 100644
--- a/taxonomy/ssdp.py
+++ b/taxonomy/ssdp.py
@@ -21,8 +21,11 @@
 
 
 database = {
+    'Canon IJ-UPnP/1.0 UPnP/1.0 UPnP-Device-Host/1.0': 'Canon Printer',
     'OpenRG/6.0.7.1.4 UPnP/1.0': 'Google Fiber GFRG1x0',
     'HDHomeRun/1.0 UPnP/1.0': 'HDHomeRun',
+    'Linux UPnP/1.0 Sonos/31.9-26010 (ZPS1)': 'Sonos ZPS1',
+    'Linux UPnP/1.0 Sonos/31.9-26010 (ZPS5)': 'Somos ZPS5',
     'Linux UPnP/1.0 Sonos/28.1-83040 (ZP90)': 'Sonos ZP90',
     'Linux UPnP/1.0 Sonos/28.1-83040 (ZP120)': 'Sonos ZP120',
     'WNDR3700v2 UPnP/1.0 miniupnpd/1.0': 'Netgear WNDR3700',
diff --git "a/taxonomy/testdata/pcaps/MacBook Pro early 2014 \050A1502\051 2.4GHz.pcap" "b/taxonomy/testdata/pcaps/MacBook Pro early 2014 \050A1502\051 2.4GHz.pcap"
index 51073f9..94e3ea7 100644
--- "a/taxonomy/testdata/pcaps/MacBook Pro early 2014 \050A1502\051 2.4GHz.pcap"
+++ "b/taxonomy/testdata/pcaps/MacBook Pro early 2014 \050A1502\051 2.4GHz.pcap"
Binary files differ
diff --git "a/taxonomy/testdata/pcaps/MacBook Pro early 2014 \050A1502\051 5GHz.pcap" "b/taxonomy/testdata/pcaps/MacBook Pro early 2014 \050A1502\051 5GHz.pcap"
new file mode 100644
index 0000000..51073f9
--- /dev/null
+++ "b/taxonomy/testdata/pcaps/MacBook Pro early 2014 \050A1502\051 5GHz.pcap"
Binary files differ
diff --git a/taxonomy/testdata/pcaps/Nexus 4 2.4GHz 802.11k Broadcast Probe.pcap b/taxonomy/testdata/pcaps/Nexus 4 2.4GHz 802.11k Broadcast Probe.pcap
new file mode 100644
index 0000000..12f97d4
--- /dev/null
+++ b/taxonomy/testdata/pcaps/Nexus 4 2.4GHz 802.11k Broadcast Probe.pcap
Binary files differ
diff --git a/taxonomy/testdata/pcaps/Nexus 4 2.4GHz 802.11k Specific Probe.pcap b/taxonomy/testdata/pcaps/Nexus 4 2.4GHz 802.11k Specific Probe.pcap
new file mode 100644
index 0000000..5ee2741
--- /dev/null
+++ b/taxonomy/testdata/pcaps/Nexus 4 2.4GHz 802.11k Specific Probe.pcap
Binary files differ
diff --git a/taxonomy/testdata/pcaps/Nexus 4 2.4GHz 802.11k.pcap b/taxonomy/testdata/pcaps/Nexus 4 2.4GHz 802.11k.pcap
new file mode 100644
index 0000000..f709aa7
--- /dev/null
+++ b/taxonomy/testdata/pcaps/Nexus 4 2.4GHz 802.11k.pcap
Binary files differ
diff --git a/taxonomy/testdata/pcaps/Nexus 4 5GHz 802.11k Broadcast Probe.pcap b/taxonomy/testdata/pcaps/Nexus 4 5GHz 802.11k Broadcast Probe.pcap
new file mode 100644
index 0000000..5829c0f
--- /dev/null
+++ b/taxonomy/testdata/pcaps/Nexus 4 5GHz 802.11k Broadcast Probe.pcap
Binary files differ
diff --git a/taxonomy/testdata/pcaps/Nexus 4 5GHz 802.11k Specific Probe.pcap b/taxonomy/testdata/pcaps/Nexus 4 5GHz 802.11k Specific Probe.pcap
new file mode 100644
index 0000000..fe597d5
--- /dev/null
+++ b/taxonomy/testdata/pcaps/Nexus 4 5GHz 802.11k Specific Probe.pcap
Binary files differ
diff --git a/taxonomy/testdata/pcaps/Nexus 4 5GHz 802.11k.pcap b/taxonomy/testdata/pcaps/Nexus 4 5GHz 802.11k.pcap
new file mode 100644
index 0000000..f0eb731
--- /dev/null
+++ b/taxonomy/testdata/pcaps/Nexus 4 5GHz 802.11k.pcap
Binary files differ
diff --git a/taxonomy/testdata/pcaps/Nexus 5X 2.4GHz 802.11k Broadcast Probe.pcap b/taxonomy/testdata/pcaps/Nexus 5X 2.4GHz 802.11k Broadcast Probe.pcap
new file mode 100644
index 0000000..3a25dbf
--- /dev/null
+++ b/taxonomy/testdata/pcaps/Nexus 5X 2.4GHz 802.11k Broadcast Probe.pcap
Binary files differ
diff --git a/taxonomy/testdata/pcaps/Nexus 5X 2.4GHz 802.11k Specific Probe.pcap b/taxonomy/testdata/pcaps/Nexus 5X 2.4GHz 802.11k Specific Probe.pcap
new file mode 100644
index 0000000..7e2a57a
--- /dev/null
+++ b/taxonomy/testdata/pcaps/Nexus 5X 2.4GHz 802.11k Specific Probe.pcap
Binary files differ
diff --git a/taxonomy/testdata/pcaps/Nexus 5X 2.4GHz 802.11k.pcap b/taxonomy/testdata/pcaps/Nexus 5X 2.4GHz 802.11k.pcap
new file mode 100644
index 0000000..b0961fa
--- /dev/null
+++ b/taxonomy/testdata/pcaps/Nexus 5X 2.4GHz 802.11k.pcap
Binary files differ
diff --git a/taxonomy/testdata/pcaps/Nexus 5X 5GHz 802.11k.pcap b/taxonomy/testdata/pcaps/Nexus 5X 5GHz 802.11k.pcap
new file mode 100644
index 0000000..8f5c6b5
--- /dev/null
+++ b/taxonomy/testdata/pcaps/Nexus 5X 5GHz 802.11k.pcap
Binary files differ
diff --git "a/taxonomy/testdata/pcaps/Nexus 7 \0502013\051 2.4GHz Broadcast Probe.pcap" "b/taxonomy/testdata/pcaps/Nexus 7 \0502013\051 2.4GHz Broadcast Probe.pcap"
new file mode 100644
index 0000000..b1eec97
--- /dev/null
+++ "b/taxonomy/testdata/pcaps/Nexus 7 \0502013\051 2.4GHz Broadcast Probe.pcap"
Binary files differ
diff --git "a/taxonomy/testdata/pcaps/Nexus 7 \0502013\051 2.4GHz Specific Probe.pcap" "b/taxonomy/testdata/pcaps/Nexus 7 \0502013\051 2.4GHz Specific Probe.pcap"
new file mode 100644
index 0000000..5ae5486
--- /dev/null
+++ "b/taxonomy/testdata/pcaps/Nexus 7 \0502013\051 2.4GHz Specific Probe.pcap"
Binary files differ
diff --git "a/taxonomy/testdata/pcaps/Nexus 7 \0502013\051 2.4GHz.pcap" "b/taxonomy/testdata/pcaps/Nexus 7 \0502013\051 2.4GHz.pcap"
new file mode 100644
index 0000000..7b0ccd3
--- /dev/null
+++ "b/taxonomy/testdata/pcaps/Nexus 7 \0502013\051 2.4GHz.pcap"
Binary files differ
diff --git "a/taxonomy/testdata/pcaps/Nexus 7 \0502013\051 5GHz Broadcast Probe.pcap" "b/taxonomy/testdata/pcaps/Nexus 7 \0502013\051 5GHz Broadcast Probe.pcap"
new file mode 100644
index 0000000..6f146e8
--- /dev/null
+++ "b/taxonomy/testdata/pcaps/Nexus 7 \0502013\051 5GHz Broadcast Probe.pcap"
Binary files differ
diff --git "a/taxonomy/testdata/pcaps/Nexus 7 \0502013\051 5GHz Specific Probe.pcap" "b/taxonomy/testdata/pcaps/Nexus 7 \0502013\051 5GHz Specific Probe.pcap"
new file mode 100644
index 0000000..9f3cca4
--- /dev/null
+++ "b/taxonomy/testdata/pcaps/Nexus 7 \0502013\051 5GHz Specific Probe.pcap"
Binary files differ
diff --git "a/taxonomy/testdata/pcaps/Nexus 7 \0502013\051 5GHz.pcap" "b/taxonomy/testdata/pcaps/Nexus 7 \0502013\051 5GHz.pcap"
new file mode 100644
index 0000000..e1dba3f
--- /dev/null
+++ "b/taxonomy/testdata/pcaps/Nexus 7 \0502013\051 5GHz.pcap"
Binary files differ
diff --git a/taxonomy/tests/wifi_test.py b/taxonomy/tests/wifi_test.py
index 8fae201..5ccb745 100755
--- a/taxonomy/tests/wifi_test.py
+++ b/taxonomy/tests/wifi_test.py
@@ -48,16 +48,18 @@
     self.assertEqual('802.11n n:2,w:40', taxonomy[2])
 
   def testNameLookup(self):
-    signature = ('wifi|probe:0,1,50,221(001018,2)|assoc:0,1,48,50,'
-                 '221(001018,2),221(0050f2,2)')
+    signature = ('wifi4|probe:0,1,45,3,221(001018,2),221(00904c,51),htcap:0100,'
+                 'htagg:19,htmcs:000000ff|assoc:0,1,33,36,48,45,221(001018,2),'
+                 '221(00904c,51),221(0050f2,2),htcap:0100,htagg:19,'
+                 'htmcs:000000ff,txpow:180f')
     taxonomy = wifi.identify_wifi_device(signature, '00:00:01:00:00:01')
     self.assertEqual(3, len(taxonomy))
     self.assertEqual('Unknown', taxonomy[1])
     taxonomy = wifi.identify_wifi_device(signature, '00:00:01:00:00:01')
     self.assertEqual(3, len(taxonomy))
     self.assertEqual('Unknown', taxonomy[1])
-    taxonomy = wifi.identify_wifi_device(signature, '2c:1f:23:ff:ff:01')
-    self.assertEqual('iPod Touch 3rd gen', taxonomy[1])
+    taxonomy = wifi.identify_wifi_device(signature, 'c8:69:cd:5e:b5:43')
+    self.assertEqual('Apple TV (3rd gen)', taxonomy[1])
 
   def testChecksumWhenNoIdentification(self):
     taxonomy = wifi.identify_wifi_device('wifi|probe:1,2,3,4,htcap:0|assoc:1',
@@ -86,7 +88,7 @@
     self.assertEqual('Chromecast', taxonomy[1])
 
   def testOS(self):
-    signature = 'wifi|probe:0,1,50|assoc:0,1,50,48,221(0050f2,2)'
+    signature = 'wifi4|probe:0,1,50|assoc:0,1,50,48,221(0050f2,2)'
     taxonomy = wifi.identify_wifi_device(signature, '00:00:01:00:00:01')
     self.assertIn('Unknown', taxonomy[1])
     taxonomy = wifi.identify_wifi_device(signature, '28:ef:01:00:00:01')
diff --git a/taxonomy/wifi.py b/taxonomy/wifi.py
index 554e128..3a12713 100644
--- a/taxonomy/wifi.py
+++ b/taxonomy/wifi.py
@@ -26,7 +26,7 @@
     'wifi4|probe:0,1,50,45,3,221(001018,2),221(00904c,51),htcap:110c,htagg:19,htmcs:000000ff|assoc:0,1,48,50,45,221(001018,2),221(00904c,51),221(0050f2,2),htcap:110c,htagg:19,htmcs:000000ff|os:dashbutton':
         ('BCM43362', 'Amazon Dash Button', '2.4GHz'),
 
-    'wifi|probe:0,1,50|assoc:0,1,50,48,221(0050f2,2)|os:kindle':
+    'wifi4|probe:0,1,50|assoc:0,1,50,48,221(0050f2,2)|os:kindle':
         ('', 'Amazon Kindle', '2.4GHz'),
     'wifi|probe:0,1,50,45,htcap:01ac,htagg:02,htmcs:0000ffff|assoc:0,1,50,48,221(0050f2,2),45,127,htcap:01ac,htagg:02,htmcs:0000ffff|os:kindle':
         ('', 'Amazon Kindle', '2.4GHz'),
@@ -59,7 +59,9 @@
     'wifi4|probe:0,1,50,45,221(001018,2),221(00904c,51),htcap:180c,htagg:1b,htmcs:000000ff|assoc:0,1,33,36,48,50,45,221(001018,2),221(00904c,51),221(0050f2,2),htcap:180c,htagg:1b,htmcs:000000ff,txpow:1308|os:ios':
         ('BCM4329', 'Apple TV (2nd gen)', '2.4GHz'),
 
-    'wifi|probe:0,1,45,3,221(001018,2),221(00904c,51),htcap:0100|assoc:0,1,33,36,48,45,221(001018,2),221(00904c,51),221(0050f2,2),htcap:0100|name:Apple-TV':
+    'wifi4|probe:0,1,45,3,221(001018,2),221(00904c,51),htcap:0100,htagg:19,htmcs:000000ff|assoc:0,1,33,36,48,45,221(001018,2),221(00904c,51),221(0050f2,2),htcap:0100,htagg:19,htmcs:000000ff,txpow:180f|name:appletv':
+        ('BCM4330', 'Apple TV (3rd gen)', '2.4GHz'),
+    'wifi4|probe:0,1,45,3,221(001018,2),221(00904c,51),htcap:0100,htagg:19,htmcs:000000ff|assoc:0,1,33,36,48,45,70,221(001018,2),221(00904c,51),221(0050f2,2),htcap:0100,htagg:19,htmcs:000000ff,txpow:180f|name:appletv':
         ('BCM4330', 'Apple TV (3rd gen)', '2.4GHz'),
 
     'wifi4|probe:0,1,45,127,107,221(001018,2),221(00904c,51),221(0050f2,8),htcap:0062,htagg:1a,htmcs:000000ff,extcap:00000004|assoc:0,1,33,36,48,45,221(001018,2),221(00904c,51),221(0050f2,2),htcap:0062,htagg:1a,htmcs:000000ff,txpow:1907|os:ios':
@@ -76,7 +78,7 @@
     'wifi4|probe:0,1,50,3,45,127,107,221(0050f2,8),221(001018,2),htcap:002d,htagg:17,htmcs:0000ffff,extcap:0400088400000040|assoc:0,1,50,33,36,48,70,45,127,221(001018,2),221(0050f2,2),htcap:002d,htagg:17,htmcs:0000ffff,txpow:1502,extcap:0000000000000040|name:appletv':
         ('', 'Apple TV (4th gen)', '2.4GHz'),
 
-    'wifi|probe:0,1,50,45,3,221(001018,2),221(00904c,51),htcap:112c,htagg:19,htmcs:000000ff|assoc:0,1,48,50,45,221(001018,2),221(00904c,51),221(0050f2,2),htcap:112c,htagg:19,htmcs:000000ff|os:brotherprinter':
+    'wifi4|probe:0,1,50,45,3,221(001018,2),221(00904c,51),htcap:112c,htagg:19,htmcs:000000ff|assoc:0,1,48,50,45,221(001018,2),221(00904c,51),221(0050f2,2),htcap:112c,htagg:19,htmcs:000000ff|os:brotherprinter':
         ('', 'Brother Printer', '2.4GHz'),
 
     'wifi4|probe:0,1,45,191,htcap:11e2,htagg:17,htmcs:0000ffff,vhtcap:038071a0,vhtrxmcs:0000fffa,vhttxmcs:0000fffa|assoc:0,1,48,45,127,191,221(0050f2,2),htcap:11e6,htagg:17,htmcs:0000ffff,vhtcap:038001a0,vhtrxmcs:0000fffa,vhttxmcs:0000fffa,extcap:0000000000000040|os:chromeos':
@@ -98,9 +100,9 @@
     'wifi|probe:0,1,3,45,50,htcap:01ff|assoc:0,1,50,127,221(0050f2,1),221(0050f2,2),45,htcap:01ff|os:chromeos':
         ('Marvell_88W8897', 'Chromebook 14" HP (Tegra)', '2.4GHz'),
 
-    'wifi|probe:0,1,45,50,htcap:016e|assoc:0,1,48,127,221(0050f2,2),45,htcap:016e|os:chromeos':
+    'wifi4|probe:0,1,45,50,htcap:016e,htagg:03,htmcs:0000ffff|assoc:0,1,48,127,221(0050f2,2),45,htcap:016e,htagg:03,htmcs:0000ffff,extcap:00':
         ('AR9382', 'Chromebook 11" Samsung', '5GHz'),
-    'wifi|probe:0,1,3,45,50,htcap:016e|assoc:0,1,48,50,127,221(0050f2,2),45,htcap:016e|os:chromeos':
+    'wifi4|probe:0,1,3,45,50,htcap:016e,htagg:03,htmcs:0000ffff|assoc:0,1,48,50,127,221(0050f2,2),45,htcap:016e,htagg:03,htmcs:0000ffff,extcap:00':
         ('AR9382', 'Chromebook 11" Samsung', '2.4GHz'),
 
     'wifi4|probe:0,1,3,45,50,htcap:0120,htagg:03,htmcs:00000000|assoc:0,1,48,50,127,221(0050f2,2),45,htcap:012c,htagg:03,htmcs:000000ff,extcap:0000000000000140|oui:google':
@@ -114,9 +116,6 @@
     'wifi4|probe:0,1,3,45,50,127,191,htcap:0062,htagg:03,htmcs:00000000,vhtcap:33c07030,vhtrxmcs:0124fffc,vhttxmcs:0124fffc,extcap:0000000000000040|assoc:0,1,48,50,127,221(0050f2,2),45,htcap:002c,htagg:03,htmcs:000000ff,extcap:0000000000000140|oui:google':
         ('Marvell_88W8887', 'Chromecast v2', '2.4GHz'),
 
-    'wifi|probe:0,1,45,221(001018,2),221(00904c,51),htcap:007c|assoc:0,1,33,36,48,45,221(001018,2),221(00904c,51),221(0050f2,2),htcap:007c':
-        ('', 'DirecTV HR-44', ''),
-
     'wifi|probe:0,1,50,45,htcap:002c,htagg:01,htmcs:000000ff|assoc:0,1,50,45,48,221(0050f2,2),htcap:002c,htagg:01,htmcs:000000ff|oui:dropcam':
         ('', 'Dropcam', '2.4GHz'),
 
@@ -131,6 +130,8 @@
         ('', 'HP Printer', '2.4GHz'),
     'wifi|probe:0,1,3,45,50,htcap:0060,htagg:03,htmcs:000000ff|assoc:0,1,48,50,127,221(0050f2,2),45,htcap:006c,htagg:03,htmcs:000000ff|os:hpprinter':
         ('', 'HP Printer', '2.4GHz'),
+    'wifi4|probe:0,1,3,45,50,127,htcap:010c,htagg:1b,htmcs:0000ffff,extcap:00|assoc:0,1,45,48,127,50,221(0050f2,2),htcap:016c,htagg:1b,htmcs:000000ff,extcap:00|os:hpprinter':
+        ('', 'HP Printer', '2.4GHz'),
 
     'wifi4|probe:0,1,45,127,191,221(001018,2),221(00904c,51),htcap:006f,htagg:17,htmcs:000000ff,vhtcap:03800032,vhtrxmcs:0000fffe,vhttxmcs:0000fffe,extcap:0000000000000040|assoc:0,1,33,36,48,45,127,191,221(001018,2),221(0050f2,2),htcap:006f,htagg:17,htmcs:000000ff,vhtcap:03800032,vhtrxmcs:0000fffe,vhttxmcs:0000fffe,txpow:e008,extcap:0000000000000040|oui:htc':
         ('BCM4335', 'HTC One', '5GHz'),
@@ -175,7 +176,9 @@
     'wifi4|probe:0,1,50,45,221(001018,2),221(00904c,51),htcap:1800,htagg:1b,htmcs:000000ff|assoc:0,1,33,36,48,50,45,221(001018,2),221(00904c,51),221(0050f2,2),htcap:1800,htagg:1b,htmcs:000000ff,txpow:1108|os:ios':
         ('BCM4329', 'iPad (2nd gen)', '2.4GHz'),
 
-    'wifi|probe:0,1,45,3,221(001018,2),221(00904c,51),htcap:0100|assoc:0,1,33,36,48,45,70,221(001018,2),221(00904c,51),221(0050f2,2),htcap:0100|os:ios':
+    'wifi4|probe:0,1,45,3,221(001018,2),221(00904c,51),htcap:0100,htagg:19,htmcs:000000ff|assoc:0,1,33,36,48,45,70,221(001018,2),221(00904c,51),221(0050f2,2),htcap:0100,htagg:19,htmcs:000000ff,txpow:180f|os:ios':
+        ('BCM4330', 'iPad (3rd gen)', '5GHz'),
+    'wifi4|probe:0,1,45,3,221(001018,2),221(00904c,51),htcap:0100,htagg:19,htmcs:000000ff|assoc:0,1,33,36,48,45,221(001018,2),221(00904c,51),221(0050f2,2),htcap:0100,htagg:19,htmcs:000000ff,txpow:180f|os:ios':
         ('BCM4330', 'iPad (3rd gen)', '5GHz'),
     'wifi|probe:0,1,50,45,3,221(001018,2),221(00904c,51),htcap:0100|assoc:0,1,33,36,48,50,45,70,221(001018,2),221(00904c,51),221(0050f2,2),htcap:0100|os:ios':
         ('BCM4330', 'iPad (3rd gen)', '2.4GHz'),
@@ -280,6 +283,8 @@
         ('BCM4350', 'iPhone 6s/6s+', '5GHz'),
     'wifi4|probe:0,1,45,127,107,191,221(0050f2,8),221(001018,2),htcap:006f,htagg:17,htmcs:0000ffff,vhtcap:0f815832,vhtrxmcs:0000fffe,vhttxmcs:0000fffe,extcap:0400088400000040|assoc:0,1,33,36,48,70,45,127,191,221(001018,2),221(0050f2,2),htcap:006f,htagg:17,htmcs:0000ffff,vhtcap:0f815832,vhtrxmcs:0000fffa,vhttxmcs:0000fffa,txpow:e002,extcap:0400000000000040|os:ios':
         ('BCM4350', 'iPhone 6s/6s+', '5GHz'),
+    'wifi4|probe:0,1,45,127,107,191,221(0050f2,8),221(001018,2),htcap:006f,htagg:17,htmcs:000000ff,vhtcap:0f815832,vhtrxmcs:0000fffe,vhttxmcs:0000fffe,extcap:0400088400000040|assoc:0,1,33,36,48,70,45,127,191,221(001018,2),221(0050f2,2),htcap:006f,htagg:17,htmcs:0000ffff,vhtcap:0f815832,vhtrxmcs:0000fffa,vhttxmcs:0000fffa,txpow:e002,extcap:0400000000000040|os:ios':
+        ('BCM4350', 'iPhone 6s/6s+', '5GHz'),
     'wifi4|probe:0,1,45,127,107,191,221(0050f2,8),221(001018,2),htcap:006f,htagg:17,htmcs:0000ffff,vhtcap:0f815832,vhtrxmcs:0000fffa,vhttxmcs:0000fffa,extcap:0400088400000040|assoc:0,1,33,36,48,45,127,191,221(001018,2),221(0050f2,2),htcap:006f,htagg:17,htmcs:0000ffff,vhtcap:0f815832,vhtrxmcs:0000fffa,vhttxmcs:0000fffa,txpow:e002,extcap:0400000000000040|os:ios':
         ('BCM4350', 'iPhone 6s/6s+', '5GHz'),
     'wifi4|probe:0,1,45,127,107,191,221(0050f2,8),221(001018,2),htcap:006f,htagg:17,htmcs:0000ffff,vhtcap:0f815832,vhtrxmcs:0000fffa,vhttxmcs:0000fffe,extcap:0400088400000040|assoc:0,1,33,36,48,45,127,191,221(001018,2),221(0050f2,2),htcap:006f,htagg:17,htmcs:0000ffff,vhtcap:0f815832,vhtrxmcs:0000fffa,vhttxmcs:0000fffa,txpow:e002,extcap:0400000000000040|os:ios':
@@ -298,7 +303,7 @@
     'wifi4|probe:0,1,3,50|assoc:0,1,48,50|os:ipodtouch1':
         ('Marvell_W8686B22', 'iPod Touch 1st/2nd gen', '2.4GHz'),
 
-    'wifi|probe:0,1,50,221(001018,2)|assoc:0,1,48,50,221(001018,2),221(0050f2,2)|name:ipod':
+    'wifi4|probe:0,1,50,221(001018,2)|assoc:0,1,48,50,221(001018,2),221(0050f2,2)|name:ipod':
         ('BCM4329', 'iPod Touch 3rd gen', '2.4GHz'),
 
     'wifi4|probe:0,1,50,45,221(001018,2),221(00904c,51),htcap:180c,htagg:1b,htmcs:000000ff|assoc:0,1,48,50,45,221(001018,2),221(00904c,51),221(0050f2,2),htcap:180c,htagg:1b,htmcs:000000ff|os:ios':
@@ -306,6 +311,8 @@
 
     'wifi4|probe:0,1,50,3,45,127,107,221(001018,2),221(00904c,51),221(0050f2,8),htcap:0020,htagg:1a,htmcs:000000ff,extcap:00000004|assoc:0,1,33,36,48,50,45,221(001018,2),221(00904c,51),221(0050f2,2),htcap:0020,htagg:1a,htmcs:000000ff,txpow:1504|os:ios':
         ('BCM4334', 'iPod Touch 5th gen', '5GHz'),
+    'wifi4|probe:0,1,45,127,107,221(001018,2),221(00904c,51),221(0050f2,8),htcap:0062,htagg:1a,htmcs:000000ff,extcap:00000004|assoc:0,1,33,36,48,45,70,221(001018,2),221(00904c,51),221(0050f2,2),htcap:0062,htagg:1a,htmcs:000000ff,txpow:1706|os:ios':
+        ('BCM4334', 'iPod Touch 5th gen', '2.4GHz'),
     'wifi4|probe:0,1,45,127,107,221(001018,2),221(00904c,51),221(0050f2,8),htcap:0062,htagg:1a,htmcs:000000ff,extcap:00000004|assoc:0,1,33,36,48,45,221(001018,2),221(00904c,51),221(0050f2,2),htcap:0062,htagg:1a,htmcs:000000ff,txpow:1706|os:ios':
         ('BCM4334', 'iPod Touch 5th gen', '2.4GHz'),
 
@@ -332,17 +339,12 @@
     'wifi|probe:0,1,50,3,45,221(0050f2,8),221(0050f2,4),221(506f9a,9),htcap:012c,htagg:03,htmcs:000000ff,wps:LGLS660|assoc:0,1,50,48,45,221(0050f2,2),htcap:012c,htagg:03,htmcs:000000ff':
         ('', 'LG Tribute', '2.4GHz'),
 
-    'wifi|probe:0,1,50,45,221(00904c,51),htcap:182c|assoc:0,1,33,36,48,50,45,221(00904c,51),221(0050f2,2),htcap:182c|os:macos':
-        ('BCM4322', 'MacBook late 2008 (A1278)', '5GHz'),
-    'wifi|probe:0,1,50,3,45,221(00904c,51),htcap:182c|assoc:0,1,33,36,48,50,45,221(00904c,51),221(0050f2,2),htcap:182c|os:macos':
-        ('BCM4322', 'MacBook late 2008 (A1278)', '2.4GHz'),
-
     'wifi4|probe:0,1,45,221(001018,2),221(00904c,51),htcap:087e,htagg:1b,htmcs:0000ffff|assoc:0,1,33,36,48,45,221(001018,2),221(00904c,51),221(0050f2,2),htcap:087e,htagg:1b,htmcs:0000ffff,txpow:0f07|os:macos':
         ('BCM43224', 'MacBook Air late 2010 (A1369)', '5GHz'),
     'wifi4|probe:0,1,50,45,221(001018,2),221(00904c,51),htcap:187c,htagg:1b,htmcs:0000ffff|assoc:0,1,33,36,48,50,45,221(001018,2),221(00904c,51),221(0050f2,2),htcap:187c,htagg:1b,htmcs:0000ffff,txpow:1207|os:macos':
         ('BCM43224', 'MacBook Air late 2010 (A1369)', '2.4GHz'),
 
-    'wifi|probe:0,1,45,221(00904c,51),htcap:086e,htagg:1b,htmcs:0000ffff|assoc:0,1,33,36,48,45,221(00904c,51),221(0050f2,2),htcap:086e,htagg:1b,htmcs:0000ffff|os:macos':
+    'wifi4|probe:0,1,45,221(00904c,51),htcap:086e,htagg:1b,htmcs:0000ffff|assoc:0,1,33,36,48,45,221(00904c,51),221(0050f2,2),htcap:086e,htagg:1b,htmcs:0000ffff,txpow:0f07|os:macos':
         ('BCM4322', 'MacBook Air late 2011', '5GHz'),
 
     'wifi4|probe:0,1,45,221(00904c,51),htcap:09ef,htagg:1b,htmcs:0000ffff|assoc:0,1,33,36,48,45,221(00904c,51),221(0050f2,2),htcap:09ef,htagg:1b,htmcs:0000ffff,txpow:0005|os:macos':
@@ -364,6 +366,8 @@
 
     'wifi4|probe:0,1,45,127,191,221(00904c,51),htcap:09ef,htagg:17,htmcs:0000ffff,vhtcap:0f8259b2,vhtrxmcs:0000ffea,vhttxmcs:0000ffea,extcap:0000000000000040|assoc:0,1,33,36,48,45,127,191,221(00904c,51),221(0050f2,2),htcap:09ef,htagg:17,htmcs:0000ffff,vhtcap:0f8259b2,vhtrxmcs:0000ffea,vhttxmcs:0000ffea,txpow:e808,extcap:0000000000000040|os:macos':
         ('BCM4360', 'MacBook Pro early 2014 (A1502)', '5GHz'),
+    'wifi4|probe:0,1,50,45,127,221(00904c,51),htcap:59ad,htagg:17,htmcs:0000ffff,extcap:0000000000000040|assoc:0,1,33,36,48,50,45,127,221(00904c,51),221(0050f2,2),htcap:59ad,htagg:17,htmcs:0000ffff,txpow:1906,extcap:0000000000000040|os:macos':
+        ('BCM4360', 'MacBook Pro early 2014 (A1502)', '2.4GHz'),
 
     'wifi4|probe:0,1,45,50,htcap:0102,htagg:03,htmcs:0000ffff|assoc:0,1,48,221(0050f2,2),45,htcap:010e,htagg:03,htmcs:0000ffff|oui:microsoft':
         ('Marvell_88W8797', 'Microsoft Surface RT', '5GHz'),
@@ -408,21 +412,35 @@
         ('QCA_WCN3360', 'Nexus 4', '5GHz'),
     'wifi4|probe:0,1,45,221(0050f2,8),191,221(0050f2,4),221(506f9a,9),htcap:012c,htagg:03,htmcs:000000ff,vhtcap:31811120,vhtrxmcs:01b2fffc,vhttxmcs:01b2fffc,wps:Nexus_4|assoc:0,1,48,45,221(0050f2,2),htcap:012c,htagg:03,htmcs:000000ff':
         ('QCA_WCN3360', 'Nexus 4', '5GHz'),
+    'wifi4|probe:0,1,45,221(0050f2,8),191,221(0050f2,4),221(506f9a,9),htcap:012c,htagg:03,htmcs:000000ff,vhtcap:31811120,vhtrxmcs:01b2fffc,vhttxmcs:01b2fffc,wps:Nexus_4|assoc:0,1,33,36,48,45,221(0050f2,2),htcap:012c,htagg:03,htmcs:000000ff,txpow:130d':
+        ('QCA_WCN3360', 'Nexus 4', '5GHz'),
     'wifi4|probe:0,1,45,221(0050f2,8),221(0050f2,4),221(506f9a,9),htcap:012c,htagg:03,htmcs:000000ff,wps:Nexus_4|assoc:0,1,48,45,221(0050f2,2),127,htcap:012c,htagg:03,htmcs:000000ff,extcap:00000a02':
         ('QCA_WCN3360', 'Nexus 4', '5GHz'),
     'wifi4|probe:0,1,45,221(0050f2,8),191,221(0050f2,4),221(506f9a,9),htcap:012c,htagg:03,htmcs:000000ff,vhtcap:31811120,vhtrxmcs:01b2fffc,vhttxmcs:01b2fffc,wps:Nexus_4|assoc:0,1,50,48,45,221(0050f2,2),htcap:012c,htagg:03,htmcs:000000ff':
         ('QCA_WCN3360', 'Nexus 4', '5GHz'),
+    'wifi4|probe:0,1,45,221(0050f2,8),221(0050f2,4),221(506f9a,9),htcap:012c,htagg:03,htmcs:000000ff,wps:Nexus_4|assoc:0,1,33,36,48,45,221(0050f2,2),127,htcap:012c,htagg:03,htmcs:000000ff,txpow:170d,extcap:00000a02':
+        ('QCA_WCN3360', 'Nexus 4', '5GHz'),
+    'wifi4|probe:0,1,45,221(0050f2,8),htcap:012c,htagg:03,htmcs:000000ff|assoc:0,1,33,36,48,45,221(0050f2,2),127,htcap:012c,htagg:03,htmcs:000000ff,txpow:170d,extcap:00000a02|oui:lg':
+        ('QCA_WCN3360', 'Nexus 4', '5GHz'),
     'wifi4|probe:0,1,50,45,221(0050f2,8),191,221(0050f2,4),221(506f9a,9),htcap:012c,htagg:03,htmcs:000000ff,vhtcap:31811120,vhtrxmcs:01b2fffc,vhttxmcs:01b2fffc,wps:Nexus_4|assoc:0,1,50,48,45,221(0050f2,2),htcap:012c,htagg:03,htmcs:000000ff':
         ('QCA_WCN3360', 'Nexus 4', '2.4GHz'),
     'wifi4|probe:0,1,50,3,45,221(0050f2,8),221(0050f2,4),221(506f9a,9),htcap:012c,htagg:03,htmcs:000000ff,wps:Nexus_4|assoc:0,1,50,48,45,221(0050f2,2),htcap:012c,htagg:03,htmcs:000000ff':
         ('QCA_WCN3360', 'Nexus 4', '2.4GHz'),
     'wifi4|probe:0,1,50,45,221(0050f2,8),htcap:012c,htagg:03,htmcs:000000ff|assoc:0,1,50,48,45,221(0050f2,2),127,htcap:012c,htagg:03,htmcs:000000ff,extcap:00000a02|oui:lg':
         ('QCA_WCN3360', 'Nexus 4', '2.4GHz'),
+    'wifi4|probe:0,1,50,3,45,221(0050f2,8),htcap:012c,htagg:03,htmcs:000000ff|assoc:0,1,50,48,45,221(0050f2,2),127,htcap:012c,htagg:03,htmcs:000000ff,extcap:00000a02|oui:lg':
+        ('QCA_WCN3360', 'Nexus 4', '2.4GHz'),
+    'wifi4|probe:0,1,50,3,45,221(0050f2,8),221(0050f2,4),221(506f9a,9),htcap:012c,htagg:03,htmcs:000000ff,wps:Nexus_4|assoc:0,1,50,48,45,221(0050f2,2),127,htcap:012c,htagg:03,htmcs:000000ff,extcap:00000a02':
+        ('QCA_WCN3360', 'Nexus 4', '2.4GHz'),
+    'wifi4|probe:0,1,50,45,221(0050f2,8),221(0050f2,4),221(506f9a,9),htcap:012c,htagg:03,htmcs:000000ff,wps:Nexus_4|assoc:0,1,50,48,45,221(0050f2,2),127,htcap:012c,htagg:03,htmcs:000000ff,extcap:00000a02|oui:lg':
+        ('QCA_WCN3360', 'Nexus 4', '2.4GHz'),
 
     'wifi4|probe:0,1,45,127,191,221(001018,2),221(00904c,51),htcap:016f,htagg:17,htmcs:000000ff,vhtcap:0f805932,vhtrxmcs:0000fffe,vhttxmcs:0000fffe,extcap:0000000000000040|assoc:0,1,33,36,48,45,127,191,221(001018,2),221(0050f2,2),htcap:016f,htagg:17,htmcs:000000ff,vhtcap:0f805932,vhtrxmcs:0000fffe,vhttxmcs:0000fffe,txpow:e003,extcap:0000000000000040|oui:lg':
         ('BCM4339', 'Nexus 5', '5GHz'),
     'wifi4|probe:0,1,3,45,127,191,221(001018,2),221(00904c,51),htcap:016f,htagg:17,htmcs:000000ff,vhtcap:0f805932,vhtrxmcs:0000fffe,vhttxmcs:0000fffe,extcap:0000000000000040|assoc:0,1,33,36,48,45,127,191,221(001018,2),221(0050f2,2),htcap:016f,htagg:17,htmcs:000000ff,vhtcap:0f805932,vhtrxmcs:0000fffe,vhttxmcs:0000fffe,txpow:e003,extcap:0000000000000040|oui:lg':
         ('BCM4339', 'Nexus 5', '5GHz'),
+    'wifi4|probe:0,1,45,127,107,191,221(506f9a,16),221(001018,2),221(00904c,51),221(00904c,4),221(0050f2,8),htcap:016f,htagg:17,htmcs:000000ff,vhtcap:0f805932,vhtrxmcs:0000fffe,vhttxmcs:0000fffe,extcap:0000088001400040|assoc:0,1,33,36,48,45,127,70,191,221(001018,2),221(00904c,4),221(0050f2,2),htcap:016f,htagg:17,htmcs:000000ff,vhtcap:0f805932,vhtrxmcs:0000fffe,vhttxmcs:0000fffe,txpow:e003,extcap:0000008001400040|oui:lg':
+        ('BCM4339', 'Nexus 5', '5GHz'),
     'wifi4|probe:0,1,50,3,45,127,221(001018,2),221(00904c,51),htcap:112d,htagg:17,htmcs:000000ff,extcap:0000000000000040|assoc:0,1,33,36,48,50,45,221(001018,2),221(0050f2,2),htcap:112d,htagg:17,htmcs:000000ff,txpow:1303|oui:lg':
         ('BCM4339', 'Nexus 5', '2.4GHz'),
     'wifi4|probe:0,1,50,45,127,221(001018,2),221(00904c,51),htcap:112d,htagg:17,htmcs:000000ff,extcap:0000000000000040|assoc:0,1,33,36,48,50,45,221(001018,2),221(0050f2,2),htcap:112d,htagg:17,htmcs:000000ff,txpow:1303|oui:lg':
@@ -436,21 +454,43 @@
         ('QCA6174', 'Nexus 5X', '5GHz'),
     'wifi4|probe:0,1,127,45,191,htcap:01ef,htagg:03,htmcs:0000ffff,vhtcap:338061b2,vhtrxmcs:030cfffa,vhttxmcs:030cfffa,extcap:00000a020100004080|assoc:0,1,48,45,221(0050f2,2),191,127,htcap:01ef,htagg:03,htmcs:0000ffff,vhtcap:339071b2,vhtrxmcs:030cfffa,vhttxmcs:030cfffa,extcap:000000000000004080|oui:lg':
         ('QCA6174', 'Nexus 5X', '5GHz'),
+    'wifi4|probe:0,1,127,45,191,htcap:01ef,htagg:03,htmcs:0000ffff,vhtcap:338001b2,vhtrxmcs:030cfffa,vhttxmcs:030cfffa,extcap:00000a020100004080|assoc:0,1,48,45,221(0050f2,2),191,127,htcap:01ef,htagg:03,htmcs:0000ffff,vhtcap:339071b2,vhtrxmcs:030cfffa,vhttxmcs:030cfffa,extcap:000000000000004080|oui:lg':
+        ('QCA6174', 'Nexus 5X', '5GHz'),
+    'wifi4|probe:0,1,127,45,191,htcap:01ef,htagg:03,htmcs:0000ffff,vhtcap:338061b2,vhtrxmcs:030cfffa,vhttxmcs:030cfffa,extcap:00000a020100004080|assoc:0,1,33,36,48,70,45,221(0050f2,2),191,127,htcap:01ef,htagg:03,htmcs:0000ffff,vhtcap:339071b2,vhtrxmcs:030cfffa,vhttxmcs:030cfffa,txpow:1e08,extcap:000000000000004080|oui:lg':
+        ('QCA6174', 'Nexus 5X', '5GHz'),
+    'wifi4|probe:0,1,127,extcap:00000a020100004080|assoc:0,1,33,36,48,70,45,221(0050f2,2),191,127,htcap:01ef,htagg:03,htmcs:0000ffff,vhtcap:339071b2,vhtrxmcs:030cfffa,vhttxmcs:030cfffa,txpow:1e08,extcap:000000000000004080|oui:lg':
+        ('QCA6174', 'Nexus 5X', '5GHz'),
+    'wifi4|probe:0,1,45,221(0050f2,8),191,127,htcap:01ef,htagg:03,htmcs:0000ffff,vhtcap:339071b2,vhtrxmcs:030cfffa,vhttxmcs:030cfffa,extcap:000000000000004080|assoc:0,1,33,36,48,70,45,221(0050f2,2),191,127,htcap:01ef,htagg:03,htmcs:0000ffff,vhtcap:339071b2,vhtrxmcs:030cfffa,vhttxmcs:030cfffa,txpow:1e08,extcap:000000000000004080|oui:lg':
+        ('QCA6174', 'Nexus 5X', '5GHz'),
     'wifi4|probe:0,1,50,127,45,191,htcap:01ef,htagg:03,htmcs:0000ffff,vhtcap:338061b2,vhtrxmcs:030cfffa,vhttxmcs:030cfffa,extcap:00000a0201000040|assoc:0,1,50,48,45,221(0050f2,2),127,htcap:01ad,htagg:03,htmcs:0000ffff,extcap:0000000000000000|oui:lg':
         ('QCA6174', 'Nexus 5X', '2.4GHz'),
     'wifi4|probe:0,1,50,3,45,221(0050f2,8),127,htcap:01ad,htagg:03,htmcs:0000ffff,extcap:000000000000000080|assoc:0,1,50,48,45,221(0050f2,2),127,htcap:01ad,htagg:03,htmcs:0000ffff,extcap:000000000000000080|oui:lg':
         ('QCA6174', 'Nexus 5X', '2.4GHz'),
     'wifi4|probe:0,1,50,127,45,191,htcap:01ef,htagg:03,htmcs:0000ffff,vhtcap:338061b2,vhtrxmcs:030cfffa,vhttxmcs:030cfffa,extcap:00000a020100004080|assoc:0,1,50,48,45,221(0050f2,2),127,htcap:01ad,htagg:03,htmcs:0000ffff,extcap:000000000000000080|oui:lg':
         ('QCA6174', 'Nexus 5X', '2.4GHz'),
+    'wifi4|probe:0,1,127,45,191,htcap:01ef,htagg:03,htmcs:0000ffff,vhtcap:338061b2,vhtrxmcs:030cfffa,vhttxmcs:030cfffa,extcap:00000a020100004080|assoc:0,1,33,36,48,70,45,221(0050f2,2),191,127,htcap:01ef,htagg:03,htmcs:0000ffff,vhtcap:339071b2,vhtrxmcs:030cfffa,vhttxmcs:030cfffa,txpow:1e08,extcap:000000000000004080|oui:lg':
+        ('QCA6174', 'Nexus 5X', '2.4GHz'),
+    'wifi4|probe:0,1,50,127,45,191,htcap:01ef,htagg:03,htmcs:0000ffff,vhtcap:338061b2,vhtrxmcs:030cfffa,vhttxmcs:030cfffa,extcap:00000a020100004080|assoc:0,1,50,33,48,70,45,221(0050f2,2),127,htcap:01ad,htagg:03,htmcs:0000ffff,txpow:1e08,extcap:000000000000000080|oui:lg':
+        ('QCA6174', 'Nexus 5X', '2.4GHz'),
+    'wifi4|probe:0,1,50,127,extcap:00000a020100004080|assoc:0,1,50,33,48,70,45,221(0050f2,2),127,htcap:01ad,htagg:03,htmcs:0000ffff,txpow:1e08,extcap:000000000000000080|oui:lg':
+        ('QCA6174', 'Nexus 5X', '2.4GHz'),
+    'wifi4|probe:0,1,50,3,45,221(0050f2,8),127,htcap:01ad,htagg:03,htmcs:0000ffff,extcap:000000000000000080|assoc:0,1,50,33,48,70,45,221(0050f2,2),127,htcap:01ad,htagg:03,htmcs:0000ffff,txpow:1e08,extcap:000000000000000080|oui:lg':
+        ('QCA6174', 'Nexus 5X', '2.4GHz'),
 
     'wifi4|probe:0,1,45,191,221(0050f2,4),221(506f9a,9),221(001018,2),htcap:006f,htagg:17,htmcs:0000ffff,vhtcap:0f815832,vhtrxmcs:0000fffa,vhttxmcs:0000fffa,extcap:000008800140,wps:Nexus_6|assoc:0,1,33,36,48,45,127,191,221(001018,2),221(0050f2,2),htcap:006f,htagg:17,htmcs:0000ffff,vhtcap:0f815832,vhtrxmcs:0000fffa,vhttxmcs:0000fffa,txpow:e009,extcap:000008800140':
         ('BCM4356', 'Nexus 6', '5GHz'),
     'wifi4|probe:0,1,45,127,191,221(0050f2,4),221(506f9a,9),221(001018,2),htcap:006f,htagg:17,htmcs:0000ffff,vhtcap:0f815832,vhtrxmcs:0000fffa,vhttxmcs:0000fffa,extcap:000008800140,wps:Nexus_6|assoc:0,1,33,36,48,45,127,191,221(001018,2),221(0050f2,2),htcap:006f,htagg:17,htmcs:0000ffff,vhtcap:0f815832,vhtrxmcs:0000fffa,vhttxmcs:0000fffa,txpow:e009,extcap:000008800140':
         ('BCM4356', 'Nexus 6', '5GHz'),
+    'wifi4|probe:0,1,45,191,221(0050f2,4),221(506f9a,9),221(001018,2),htcap:006f,htagg:17,htmcs:0000ffff,vhtcap:0f815832,vhtrxmcs:0000fffa,vhttxmcs:0000fffa,wps:Nexus_6|assoc:0,1,33,36,48,45,191,221(001018,2),221(0050f2,2),htcap:006f,htagg:17,htmcs:0000ffff,vhtcap:0f815832,vhtrxmcs:0000fffa,vhttxmcs:0000fffa,txpow:e009':
+        ('BCM4356', 'Nexus 6', '5GHz'),
     'wifi4|probe:0,1,50,3,45,127,221(0050f2,4),221(506f9a,9),221(001018,2),htcap:002d,htagg:17,htmcs:0000ffff,extcap:0000088001400040,wps:Nexus_6|assoc:0,1,50,33,36,48,45,127,221(001018,2),221(0050f2,2),htcap:002d,htagg:17,htmcs:0000ffff,txpow:1209,extcap:000008800140':
         ('BCM4356', 'Nexus 6', '2.4GHz'),
     'wifi4|probe:0,1,50,3,45,221(0050f2,4),221(506f9a,9),221(001018,2),htcap:002d,htagg:17,htmcs:0000ffff,extcap:000008800140,wps:Nexus_6|assoc:0,1,50,33,36,48,45,127,221(001018,2),221(0050f2,2),htcap:002d,htagg:17,htmcs:0000ffff,txpow:1209,extcap:000008800140':
         ('BCM4356', 'Nexus 6', '2.4GHz'),
+    'wifi4|probe:0,1,50,3,45,127,221(0050f2,4),221(506f9a,9),221(001018,2),htcap:002d,htagg:17,htmcs:0000ffff,extcap:000008800140,wps:Nexus_6|assoc:0,1,50,33,36,48,45,127,221(001018,2),221(0050f2,2),htcap:002d,htagg:17,htmcs:0000ffff,txpow:1209,extcap:000008800140':
+        ('BCM4356', 'Nexus 6', '2.4GHz'),
+    'wifi4|probe:0,1,50,3,45,221(0050f2,4),221(506f9a,9),221(001018,2),htcap:002d,htagg:17,htmcs:0000ffff,wps:Nexus_6|assoc:0,1,50,33,36,48,45,221(001018,2),221(0050f2,2),htcap:002d,htagg:17,htmcs:0000ffff,txpow:1209':
+        ('BCM4356', 'Nexus 6', '2.4GHz'),
 
     'wifi4|probe:0,1,45,191,221(0050f2,4),221(506f9a,9),221(001018,2),htcap:006f,htagg:17,htmcs:0000ffff,vhtcap:0f815832,vhtrxmcs:0000fffa,vhttxmcs:0000fffa,wps:Nexus_6P|assoc:0,1,33,36,48,45,191,221(001018,2),221(0050f2,2),htcap:006f,htagg:17,htmcs:0000ffff,vhtcap:0f815832,vhtrxmcs:0000fffa,vhttxmcs:0000fffa,txpow:e002':
         ('BCM4358', 'Nexus 6P', '5GHz'),
@@ -474,26 +514,42 @@
         ('QCA_WCN3660', 'Nexus 7 (2013)', '5GHz'),
     'wifi4|probe:0,1,45,221(0050f2,8),127,221(0050f2,4),221(506f9a,9),htcap:016e,htagg:03,htmcs:000000ff,extcap:00000a02,wps:Nexus_7|assoc:0,1,48,45,221(0050f2,2),127,htcap:016e,htagg:03,htmcs:000000ff,extcap:00000a02':
         ('QCA_WCN3660', 'Nexus 7 (2013)', '5GHz'),
+    'wifi4|probe:0,1,45,221(0050f2,8),127,221(0050f2,4),221(506f9a,9),htcap:016e,htagg:03,htmcs:000000ff,extcap:00000a02,wps:Nexus_7|assoc:0,1,33,36,48,45,221(0050f2,2),127,htcap:016e,htagg:03,htmcs:000000ff,txpow:1e0d,extcap:00000a02':
+        ('QCA_WCN3660', 'Nexus 7 (2013)', '5GHz'),
+    'wifi4|probe:0,1,45,221(0050f2,8),127,221(0050f2,4),221(506f9a,9),htcap:016e,htagg:03,htmcs:000000ff,extcap:00000a02,wps:Nexus_7|assoc:0,1,48,45,221(0050f2,2),127,htcap:012c,htagg:03,htmcs:000000ff,extcap:00000a02':
+        ('QCA_WCN3660', 'Nexus 7 (2013)', '5GHz'),
+    'wifi4|probe:0,1,45,221(0050f2,8),127,221(0050f2,4),221(506f9a,10),221(506f9a,9),htcap:016e,htagg:03,htmcs:000000ff,extcap:00000a02,wps:Nexus_7|assoc:0,1,33,36,48,45,221(0050f2,2),127,htcap:016e,htagg:03,htmcs:000000ff,txpow:1e0d,extcap:00000a02':
+        ('QCA_WCN3660', 'Nexus 7 (2013)', '5GHz'),
+    'wifi4|probe:0,1,45,221(0050f2,8),221(0050f2,4),221(506f9a,9),htcap:016e,htagg:03,htmcs:000000ff,wps:Nexus_7|assoc:0,1,33,36,48,45,221(0050f2,2),127,htcap:016e,htagg:03,htmcs:000000ff,txpow:1e0d,extcap:00000a02':
+        ('QCA_WCN3660', 'Nexus 7 (2013)', '5GHz'),
+    'wifi4|probe:0,1,45,221(0050f2,8),127,221(0050f2,4),221(506f9a,9),htcap:016e,htagg:03,htmcs:000000ff,extcap:00000a02,wps:Nexus_7|assoc:0,1,33,36,48,45,221(0050f2,2),127,htcap:012c,htagg:03,htmcs:000000ff,txpow:1e0d,extcap:00000a02':
+        ('QCA_WCN3660', 'Nexus 7 (2013)', '5GHz'),
+    'wifi4|probe:0,1,45,221(0050f2,8),htcap:016e,htagg:03,htmcs:000000ff|assoc:0,1,33,36,48,45,221(0050f2,2),127,htcap:016e,htagg:03,htmcs:000000ff,txpow:1e0d,extcap:00000a02|oui:asus':
+        ('QCA_WCN3660', 'Nexus 7 (2013)', '5GHz'),
+    'wifi4|probe:0,1,45,htcap:016e,htagg:03,htmcs:000000ff|assoc:0,1,33,36,48,45,221(0050f2,2),127,htcap:016e,htagg:03,htmcs:000000ff,txpow:1e0d,extcap:00000a02|oui:asus':
+        ('QCA_WCN3660', 'Nexus 7 (2013)', '5GHz'),
     'wifi4|probe:0,1,50,45,221(0050f2,8),127,221(0050f2,4),221(506f9a,9),htcap:012c,htagg:03,htmcs:000000ff,extcap:00000a02,wps:Nexus_7|assoc:0,1,50,48,45,221(0050f2,2),127,htcap:012c,htagg:03,htmcs:000000ff,extcap:00000a02':
         ('QCA_WCN3660', 'Nexus 7 (2013)', '2.4GHz'),
     'wifi4|probe:0,1,50,45,221(0050f2,8),221(0050f2,4),221(506f9a,9),htcap:012c,htagg:03,htmcs:000000ff,wps:Nexus_7|assoc:0,1,50,48,45,221(0050f2,2),127,htcap:012c,htagg:03,htmcs:000000ff,extcap:00000a02':
         ('QCA_WCN3660', 'Nexus 7 (2013)', '2.4GHz'),
     'wifi4|probe:0,1,50,45,221(0050f2,8),htcap:012c,htagg:03,htmcs:000000ff|assoc:0,1,50,48,45,221(0050f2,2),127,htcap:012c,htagg:03,htmcs:000000ff,extcap:00000a02|oui:asus':
         ('QCA_WCN3660', 'Nexus 7 (2013)', '2.4GHz'),
+    'wifi4|probe:0,1,50,3,45,221(0050f2,8),htcap:012c,htagg:03,htmcs:000000ff|assoc:0,1,50,48,45,221(0050f2,2),127,htcap:012c,htagg:03,htmcs:000000ff,extcap:00000a02|oui:asus':
+        ('QCA_WCN3660', 'Nexus 7 (2013)', '2.4GHz'),
+    'wifi4|probe:0,1,50,45,htcap:016e,htagg:03,htmcs:000000ff|assoc:0,1,50,48,45,221(0050f2,2),127,htcap:012c,htagg:03,htmcs:000000ff,extcap:00000a02|oui:asus':
+        ('QCA_WCN3660', 'Nexus 7 (2013)', '2.4GHz'),
 
     'wifi4|probe:0,1,45,127,191,221(0050f2,4),221(506f9a,9),221(001018,2),htcap:006f,htagg:17,htmcs:0000ffff,vhtcap:0f815832,vhtrxmcs:0000fffa,vhttxmcs:0000fffa,extcap:000008800140,wps:Nexus_9|assoc:0,1,33,36,48,45,127,191,221(001018,2),221(0050f2,2),htcap:006f,htagg:17,htmcs:0000ffff,vhtcap:0f815832,vhtrxmcs:0000fffa,vhttxmcs:0000fffa,txpow:e009,extcap:000008800140':
         ('BCM4354', 'Nexus 9', '5GHz'),
     'wifi4|probe:0,1,50,3,45,127,221(0050f2,4),221(506f9a,9),221(001018,2),htcap:002d,htagg:17,htmcs:0000ffff,extcap:0000088001400040,wps:Nexus_9|assoc:0,1,50,33,36,48,45,127,221(001018,2),221(0050f2,2),htcap:002d,htagg:17,htmcs:0000ffff,txpow:1309,extcap:000008800140':
         ('BCM4354', 'Nexus 9', '2.4GHz'),
+    'wifi4|probe:0,1,50,3,45,127,221(0050f2,4),221(506f9a,9),221(001018,2),htcap:002d,htagg:17,htmcs:0000ffff,extcap:000008800140,wps:Nexus_9|assoc:0,1,50,33,36,48,45,127,221(001018,2),221(0050f2,2),htcap:002d,htagg:17,htmcs:0000ffff,txpow:150b,extcap:000008800140':
+        ('BCM4354', 'Nexus 9', '2.4GHz'),
 
     'wifi4|probe:0,1,45,221(001018,2),221(00904c,51),htcap:01fe,htagg:1b,htmcs:0000ffff|assoc:0,1,48,45,221(001018,2),221(00904c,51),221(0050f2,2),htcap:01fe,htagg:1b,htmcs:0000ffff|oui:samsung':
         ('', 'Nexus 10', '5GHz'),
-    'wifi4|probe:0,1,45,221(001018,2),221(00904c,51),htcap:01fe,htagg:1b,htmcs:0000ffff|assoc:0,1,48,45,221(001018,2),221(00904c,51),221(0050f2,2),htcap:01fe,htagg:1b,htmcs:0000ffff|oui:murata':
-        ('', 'Nexus 10', '5GHz'),
     'wifi4|probe:0,1,50,3,45,221(001018,2),221(00904c,51),htcap:01bc,htagg:1b,htmcs:0000ffff|assoc:0,1,48,50,45,221(001018,2),221(00904c,51),221(0050f2,2),htcap:01bc,htagg:1b,htmcs:0000ffff|oui:samsung':
         ('', 'Nexus 10', '2.4GHz'),
-    'wifi4|probe:0,1,50,3,45,221(001018,2),221(00904c,51),htcap:01bc,htagg:1b,htmcs:0000ffff|assoc:0,1,48,50,45,221(001018,2),221(00904c,51),221(0050f2,2),htcap:01bc,htagg:1b,htmcs:0000ffff|oui:murata':
-        ('', 'Nexus 10', '2.4GHz'),
 
     'wifi4|probe:0,1,45,127,191,221(0050f2,4),221(506f9a,9),221(001018,2),htcap:006f,htagg:17,htmcs:0000ffff,vhtcap:0f815832,vhtrxmcs:0000fffa,vhttxmcs:0000fffa,extcap:0000088001400040,wps:Nexus_Player|assoc:0,1,33,36,48,45,127,191,221(001018,2),221(0050f2,2),htcap:006f,htagg:17,htmcs:0000ffff,vhtcap:0f815832,vhtrxmcs:0000fffa,vhttxmcs:0000fffa,txpow:e009,extcap:0000088001400040':
         ('BCM4356', 'Nexus Player', '5GHz'),
@@ -505,12 +561,12 @@
     'wifi4|probe:0,1,50,45,htcap:012c,htagg:1b,htmcs:000000ff|assoc:0,1,48,50,221(0050f2,2),45,51,127,htcap:012c,htagg:1b,htmcs:000000ff,extcap:0100000000000040|os:windows-phone':
         ('', 'Nokia Lumia 635', '2.4GHz'),
 
-    'wifi|probe:0,1,50|assoc:0,1,50,48,221(005043,1)|os:playstation':
-        ('', 'Playstation 3', '2.4GHz'),
+    'wifi4|probe:0,1,50|assoc:0,1,50,48,221(005043,1)|os:playstation':
+        ('', 'Playstation 3 or 4', '2.4GHz'),
 
     'wifi|probe:0,1,3,50|assoc:0,1,48,50,221(0050f2,2),45,htcap:112c,htagg:03,htmcs:0000ffff|os:playstation':
         ('Marvell_88W8797', 'Playstation 4', '2.4GHz'),
-    'wifi|probe:0,1,3,50|assoc:0,1,33,48,50,221(0050f2,2),45,htcap:112c,htagg:03,htmcs:0000ffff|os:playstation':
+    'wifi4|probe:0,1,3,50|assoc:0,1,33,48,50,221(0050f2,2),45,htcap:112c,htagg:03,htmcs:0000ffff,txpow:0f06|os:playstation':
         ('Marvell_88W8797', 'Playstation 4', '2.4GHz'),
 
     'wifi|probe:0,1,50,45,3,221(001018,2),221(00904c,51),htcap:110c,htagg:19,htmcs:000000ff|assoc:0,1,48,50,45,221(001018,2),221(00904c,51),221(0050f2,2),htcap:110c,htagg:19,htmcs:000000ff|os:roku':
@@ -519,7 +575,9 @@
     'wifi|probe:0,1,50,45,3,221(001018,2),221(00904c,51),htcap:100c,htagg:19,htmcs:000000ff|assoc:0,1,48,50,45,221(001018,2),221(00904c,51),221(0050f2,2),htcap:100c,htagg:19,htmcs:000000ff|os:roku':
         ('BCM4336', 'Roku 2 XD', '2.4GHz'),
 
-    'wifi|probe:0,1,50,3,45,127,221(001018,2),221(00904c,51),htcap:19bc,htagg:16,htmcs:0000ffff,extcap:00000000|assoc:0,1,33,36,48,50,45,127,221(001018,2),221(0050f2,2),htcap:19bc,htagg:16,htmcs:0000ffff,extcap:00000000|os:roku':
+    'wifi4|probe:0,1,50,3,45,127,221(001018,2),221(00904c,51),htcap:19bc,htagg:16,htmcs:0000ffff,extcap:0000000000000040|assoc:0,1,33,36,48,50,45,127,221(001018,2),221(0050f2,2),htcap:19bc,htagg:16,htmcs:0000ffff,txpow:140a,extcap:0000000000000040|os:roku':
+        ('BCM43236', 'Roku 3', '2.4GHz'),
+    'wifi4|probe:0,1,50,3,45,127,221(001018,2),221(00904c,51),htcap:193c,htagg:16,htmcs:0000ffff,extcap:0000000000000040|assoc:0,1,33,36,48,50,45,127,221(001018,2),221(0050f2,2),htcap:193c,htagg:16,htmcs:0000ffff,txpow:140a,extcap:0000000000000040|os:roku':
         ('BCM43236', 'Roku 3', '2.4GHz'),
 
     'wifi4|probe:0,1,45,191,221(001018,2),htcap:01ad,htagg:17,htmcs:0000ffff,vhtcap:0f8159b2,vhtrxmcs:0000fffa,vhttxmcs:0000fffa|assoc:0,1,33,36,48,45,191,199,221(001018,2),221(0050f2,2),htcap:01ad,htagg:17,htmcs:0000ffff,vhtcap:0f8159b2,vhtrxmcs:0000fffa,vhttxmcs:0000fffa,txpow:1109|os:roku':
@@ -529,8 +587,6 @@
 
     'wifi4|probe:0,1,50,3,45,htcap:0020,htagg:01,htmcs:000000ff|assoc:0,1,50,45,61,48,221(0050f2,2),htcap:0020,htagg:01,htmcs:000000ff|oui:samsung':
         ('', 'Samsung Galaxy Mini', '2.4GHz'),
-    'wifi4|probe:0,1,50,3,45,htcap:0020,htagg:01,htmcs:000000ff|assoc:0,1,50,45,61,48,221(0050f2,2),htcap:0020,htagg:01,htmcs:000000ff|oui:murata':
-        ('', 'Samsung Galaxy Mini', '2.4GHz'),
 
     'wifi4|probe:0,1,45,3,221(0050f2,4),221(001018,2),221(00904c,51),htcap:010c,htagg:19,htmcs:000000ff,wps:Galaxy_Nexus|assoc:0,1,33,36,48,45,221(001018,2),221(00904c,51),221(0050f2,2),htcap:010c,htagg:19,htmcs:000000ff,txpow:0f09':
         ('BCM4330', 'Samsung Galaxy Nexus', '5GHz'),
@@ -538,236 +594,134 @@
         ('BCM4330', 'Samsung Galaxy Nexus', '5GHz'),
     'wifi4|probe:0,1,45,3,221(0050f2,4),221(001018,2),221(00904c,51),htcap:000c,htagg:19,htmcs:000000ff,wps:_|assoc:0,1,33,36,48,45,221(001018,2),221(00904c,51),221(0050f2,2),htcap:000c,htagg:19,htmcs:000000ff,txpow:0f0a|oui:samsung':
         ('BCM4330', 'Samsung Galaxy Nexus', '5GHz'),
-    'wifi4|probe:0,1,45,3,221(0050f2,4),221(001018,2),221(00904c,51),htcap:000c,htagg:19,htmcs:000000ff,wps:_|assoc:0,1,33,36,48,45,221(001018,2),221(00904c,51),221(0050f2,2),htcap:000c,htagg:19,htmcs:000000ff,txpow:0f0a|oui:murata':
-        ('BCM4330', 'Samsung Galaxy Nexus', '5GHz'),
     'wifi4|probe:0,1,50,45,221(0050f2,4),221(001018,2),221(00904c,51),htcap:110c,htagg:19,htmcs:000000ff,wps:Galaxy_Nexus|assoc:0,1,33,36,48,50,45,221(001018,2),221(00904c,51),221(0050f2,2),htcap:110c,htagg:19,htmcs:000000ff,txpow:1209':
         ('BCM4330', 'Samsung Galaxy Nexus', '2.4GHz'),
     'wifi4|probe:0,1,50,45,3,221(0050f2,4),221(001018,2),221(00904c,51),htcap:110c,htagg:19,htmcs:000000ff,wps:Galaxy_Nexus|assoc:0,1,33,36,48,50,45,221(001018,2),221(00904c,51),221(0050f2,2),htcap:110c,htagg:19,htmcs:000000ff,txpow:1209':
         ('BCM4330', 'Samsung Galaxy Nexus', '2.4GHz'),
     'wifi4|probe:0,1,50,45,221(0050f2,4),221(001018,2),221(00904c,51),htcap:100c,htagg:19,htmcs:000000ff,wps:_|assoc:0,1,33,36,48,50,45,221(001018,2),221(00904c,51),221(0050f2,2),htcap:100c,htagg:19,htmcs:000000ff,txpow:120a|oui:samsung':
         ('BCM4330', 'Samsung Galaxy Nexus', '2.4GHz'),
-    'wifi4|probe:0,1,50,45,221(0050f2,4),221(001018,2),221(00904c,51),htcap:100c,htagg:19,htmcs:000000ff,wps:_|assoc:0,1,33,36,48,50,45,221(001018,2),221(00904c,51),221(0050f2,2),htcap:100c,htagg:19,htmcs:000000ff,txpow:120a|oui:murata':
-        ('BCM4330', 'Samsung Galaxy Nexus', '2.4GHz'),
 
     'wifi4|probe:0,1,45,3,221(001018,2),221(00904c,51),htcap:000c,htagg:19,htmcs:000000ff|assoc:0,1,33,36,48,45,221(001018,2),221(00904c,51),221(0050f2,2),htcap:000c,htagg:19,htmcs:000000ff,txpow:0f09|oui:samsung':
         ('', 'Samsung Galaxy Note or S2+', '5GHz'),
-    'wifi4|probe:0,1,45,3,221(001018,2),221(00904c,51),htcap:000c,htagg:19,htmcs:000000ff|assoc:0,1,33,36,48,45,221(001018,2),221(00904c,51),221(0050f2,2),htcap:000c,htagg:19,htmcs:000000ff,txpow:0f09|oui:murata':
-        ('', 'Samsung Galaxy Note or S2+', '5GHz'),
 
     'wifi4|probe:0,1,50,45,3,221(001018,2),221(00904c,51),htcap:100c,htagg:19,htmcs:000000ff|assoc:0,1,33,36,48,50,45,221(001018,2),221(00904c,51),221(0050f2,2),htcap:100c,htagg:19,htmcs:000000ff,txpow:1409|oui:samsung':
         ('', 'Samsung Galaxy Note', '2.4GHz'),
-    'wifi4|probe:0,1,50,45,3,221(001018,2),221(00904c,51),htcap:100c,htagg:19,htmcs:000000ff|assoc:0,1,33,36,48,50,45,221(001018,2),221(00904c,51),221(0050f2,2),htcap:100c,htagg:19,htmcs:000000ff,txpow:1409|oui:murata':
-        ('', 'Samsung Galaxy Note', '2.4GHz'),
 
     'wifi4|probe:0,1,45,221(001018,2),221(00904c,51),htcap:0062,htagg:1a,htmcs:000000ff|assoc:0,1,33,36,45,221(001018,2),221(00904c,51),221(0050f2,2),htcap:0062,htagg:1a,htmcs:000000ff,txpow:0e09|oui:samsung':
         ('BCM4330', 'Samsung Galaxy Note 2', '5GHz'),
-    'wifi4|probe:0,1,45,221(001018,2),221(00904c,51),htcap:0062,htagg:1a,htmcs:000000ff|assoc:0,1,33,36,45,221(001018,2),221(00904c,51),221(0050f2,2),htcap:0062,htagg:1a,htmcs:000000ff,txpow:0e09|oui:murata':
-        ('BCM4330', 'Samsung Galaxy Note 2', '5GHz'),
     'wifi4|probe:0,1,45,221(001018,2),221(00904c,51),htcap:0062,htagg:1a,htmcs:000000ff|assoc:0,1,33,36,48,45,221(001018,2),221(00904c,51),221(0050f2,2),htcap:0062,htagg:1a,htmcs:000000ff,txpow:0e09|oui:samsung':
         ('BCM4330', 'Samsung Galaxy Note 2', '5GHz'),
-    'wifi4|probe:0,1,45,221(001018,2),221(00904c,51),htcap:0062,htagg:1a,htmcs:000000ff|assoc:0,1,33,36,48,45,221(001018,2),221(00904c,51),221(0050f2,2),htcap:0062,htagg:1a,htmcs:000000ff,txpow:0e09|oui:murata':
-        ('BCM4330', 'Samsung Galaxy Note 2', '5GHz'),
     'wifi4|probe:0,1,45,3,221(001018,2),221(00904c,51),htcap:0062,htagg:1a,htmcs:000000ff|assoc:0,1,33,36,45,221(001018,2),221(00904c,51),221(0050f2,2),htcap:0062,htagg:1a,htmcs:000000ff,txpow:0e09|oui:samsung':
         ('BCM4330', 'Samsung Galaxy Note 2', '5GHz'),
-    'wifi4|probe:0,1,45,3,221(001018,2),221(00904c,51),htcap:0062,htagg:1a,htmcs:000000ff|assoc:0,1,33,36,45,221(001018,2),221(00904c,51),221(0050f2,2),htcap:0062,htagg:1a,htmcs:000000ff,txpow:0e09|oui:murata':
-        ('BCM4330', 'Samsung Galaxy Note 2', '5GHz'),
     'wifi4|probe:0,1,45,3,221(001018,2),221(00904c,51),htcap:0062,htagg:1a,htmcs:000000ff|assoc:0,1,33,36,48,45,221(001018,2),221(00904c,51),221(0050f2,2),htcap:0062,htagg:1a,htmcs:000000ff,txpow:0e09|oui:samsung':
         ('BCM4330', 'Samsung Galaxy Note 2', '5GHz'),
-    'wifi4|probe:0,1,45,3,221(001018,2),221(00904c,51),htcap:0062,htagg:1a,htmcs:000000ff|assoc:0,1,33,36,48,45,221(001018,2),221(00904c,51),221(0050f2,2),htcap:0062,htagg:1a,htmcs:000000ff,txpow:0e09|oui:murata':
-        ('BCM4330', 'Samsung Galaxy Note 2', '5GHz'),
     'wifi4|probe:0,1,50,45,3,221(001018,2),221(00904c,51),htcap:1020,htagg:1a,htmcs:000000ff|assoc:0,1,33,36,50,45,221(001018,2),221(00904c,51),221(0050f2,2),htcap:1020,htagg:1a,htmcs:000000ff,txpow:1209|oui:samsung':
         ('BCM4330', 'Samsung Galaxy Note 2', '2.4GHz'),
-    'wifi4|probe:0,1,50,45,3,221(001018,2),221(00904c,51),htcap:1020,htagg:1a,htmcs:000000ff|assoc:0,1,33,36,50,45,221(001018,2),221(00904c,51),221(0050f2,2),htcap:1020,htagg:1a,htmcs:000000ff,txpow:1209|oui:murata':
-        ('BCM4330', 'Samsung Galaxy Note 2', '2.4GHz'),
     'wifi4|probe:0,1,50,45,3,221(001018,2),221(00904c,51),htcap:1020,htagg:1a,htmcs:000000ff|assoc:0,1,33,36,48,50,45,221(001018,2),221(00904c,51),221(0050f2,2),htcap:1020,htagg:1a,htmcs:000000ff,txpow:1209|oui:samsung':
         ('BCM4330', 'Samsung Galaxy Note 2', '2.4GHz'),
-    'wifi4|probe:0,1,50,45,3,221(001018,2),221(00904c,51),htcap:1020,htagg:1a,htmcs:000000ff|assoc:0,1,33,36,48,50,45,221(001018,2),221(00904c,51),221(0050f2,2),htcap:1020,htagg:1a,htmcs:000000ff,txpow:1209|oui:murata':
-        ('BCM4330', 'Samsung Galaxy Note 2', '2.4GHz'),
 
     'wifi4|probe:0,1,3,45,127,191,221(001018,2),221(00904c,51),221(00904c,4),221(0050f2,8),htcap:016f,htagg:17,htmcs:000000ff,vhtcap:0f805932,vhtrxmcs:0000fffe,vhttxmcs:0000fffe,extcap:0000080000000040|assoc:0,1,33,36,48,45,127,191,221(001018,2),221(00904c,4),221(0050f2,2),htcap:016f,htagg:17,htmcs:000000ff,vhtcap:0f805932,vhtrxmcs:0000fffe,vhttxmcs:0000fffe,txpow:e008,extcap:0000000000000040|oui:samsung':
         ('BCM4335', 'Samsung Galaxy Note 3', '5GHz'),
-    'wifi4|probe:0,1,3,45,127,191,221(001018,2),221(00904c,51),221(00904c,4),221(0050f2,8),htcap:016f,htagg:17,htmcs:000000ff,vhtcap:0f805932,vhtrxmcs:0000fffe,vhttxmcs:0000fffe,extcap:0000080000000040|assoc:0,1,33,36,48,45,127,191,221(001018,2),221(00904c,4),221(0050f2,2),htcap:016f,htagg:17,htmcs:000000ff,vhtcap:0f805932,vhtrxmcs:0000fffe,vhttxmcs:0000fffe,txpow:e008,extcap:0000000000000040|oui:murata':
-        ('BCM4335', 'Samsung Galaxy Note 3', '5GHz'),
     'wifi4|probe:0,1,45,127,191,221(001018,2),221(00904c,51),221(00904c,4),221(0050f2,8),htcap:016f,htagg:17,htmcs:000000ff,vhtcap:0f805932,vhtrxmcs:0000fffe,vhttxmcs:0000fffe,extcap:0000080000000040|assoc:0,1,33,36,48,45,127,191,221(001018,2),221(00904c,4),221(0050f2,2),htcap:016f,htagg:17,htmcs:000000ff,vhtcap:0f805932,vhtrxmcs:0000fffe,vhttxmcs:0000fffe,txpow:e008,extcap:0000000000000040|oui:samsung':
         ('BCM4335', 'Samsung Galaxy Note 3', '5GHz'),
-    'wifi4|probe:0,1,45,127,191,221(001018,2),221(00904c,51),221(00904c,4),221(0050f2,8),htcap:016f,htagg:17,htmcs:000000ff,vhtcap:0f805932,vhtrxmcs:0000fffe,vhttxmcs:0000fffe,extcap:0000080000000040|assoc:0,1,33,36,48,45,127,191,221(001018,2),221(00904c,4),221(0050f2,2),htcap:016f,htagg:17,htmcs:000000ff,vhtcap:0f805932,vhtrxmcs:0000fffe,vhttxmcs:0000fffe,txpow:e008,extcap:0000000000000040|oui:murata':
-        ('BCM4335', 'Samsung Galaxy Note 3', '5GHz'),
     'wifi4|probe:0,1,50,3,45,127,221(001018,2),221(00904c,51),221(00904c,4),221(0050f2,8),htcap:112d,htagg:17,htmcs:000000ff,extcap:0000080000000040|assoc:0,1,33,36,48,50,45,221(001018,2),221(0050f2,2),htcap:112d,htagg:17,htmcs:000000ff,txpow:1208|oui:samsung':
         ('BCM4335', 'Samsung Galaxy Note 3', '2.4GHz'),
-    'wifi4|probe:0,1,50,3,45,127,221(001018,2),221(00904c,51),221(00904c,4),221(0050f2,8),htcap:112d,htagg:17,htmcs:000000ff,extcap:0000080000000040|assoc:0,1,33,36,48,50,45,221(001018,2),221(0050f2,2),htcap:112d,htagg:17,htmcs:000000ff,txpow:1208|oui:murata':
-        ('BCM4335', 'Samsung Galaxy Note 3', '2.4GHz'),
     'wifi4|probe:0,1,50,3,45,127,221(001018,2),221(00904c,51),221(00904c,4),221(0050f2,8),htcap:112d,htagg:17,htmcs:000000ff,extcap:0000080000000040|assoc:0,1,33,36,48,50,45,127,221(001018,2),221(0050f2,2),htcap:112d,htagg:17,htmcs:000000ff,txpow:1208|oui:samsung':
         ('BCM4335', 'Samsung Galaxy Note 3', '2.4GHz'),
-    'wifi4|probe:0,1,50,3,45,127,221(001018,2),221(00904c,51),221(00904c,4),221(0050f2,8),htcap:112d,htagg:17,htmcs:000000ff,extcap:0000080000000040|assoc:0,1,33,36,48,50,45,127,221(001018,2),221(0050f2,2),htcap:112d,htagg:17,htmcs:000000ff,txpow:1208|oui:murata':
-        ('BCM4335', 'Samsung Galaxy Note 3', '2.4GHz'),
 
     'wifi4|probe:0,1,45,127,107,191,221(506f9a,16),221(00904c,4),221(0050f2,8),221(001018,2),htcap:006f,htagg:17,htmcs:0000ffff,vhtcap:0f815832,vhtrxmcs:0000fffa,vhttxmcs:0000fffa,extcap:0000088001400040|assoc:0,1,33,36,48,45,127,107,191,221(00904c,4),221(001018,2),221(0050f2,2),htcap:006f,htagg:17,htmcs:0000ffff,vhtcap:0f815832,vhtrxmcs:0000fffa,vhttxmcs:0000fffa,txpow:e009,extcap:0000088001400040|oui:samsung':
         ('BCM4358', 'Samsung Galaxy Note 4', '5GHz'),
-    'wifi4|probe:0,1,45,127,107,191,221(506f9a,16),221(00904c,4),221(0050f2,8),221(001018,2),htcap:006f,htagg:17,htmcs:0000ffff,vhtcap:0f815832,vhtrxmcs:0000fffa,vhttxmcs:0000fffa,extcap:0000088001400040|assoc:0,1,33,36,48,45,127,107,191,221(00904c,4),221(001018,2),221(0050f2,2),htcap:006f,htagg:17,htmcs:0000ffff,vhtcap:0f815832,vhtrxmcs:0000fffa,vhttxmcs:0000fffa,txpow:e009,extcap:0000088001400040|oui:murata':
-        ('BCM4358', 'Samsung Galaxy Note 4', '5GHz'),
     'wifi4|probe:0,1,45,127,191,221(506f9a,16),221(00904c,4),221(0050f2,8),221(001018,2),htcap:006f,htagg:17,htmcs:0000ffff,vhtcap:0f815832,vhtrxmcs:0000fffa,vhttxmcs:0000fffa,extcap:0000088001400040|assoc:0,1,33,36,48,70,45,127,191,221(00904c,4),221(001018,2),221(0050f2,2),htcap:006f,htagg:17,htmcs:0000ffff,vhtcap:0f815832,vhtrxmcs:0000fffa,vhttxmcs:0000fffa,txpow:e009,extcap:0000088001400040|oui:samsung':
         ('BCM4358', 'Samsung Galaxy Note 4', '5GHz'),
-    'wifi4|probe:0,1,45,127,191,221(506f9a,16),221(00904c,4),221(0050f2,8),221(001018,2),htcap:006f,htagg:17,htmcs:0000ffff,vhtcap:0f815832,vhtrxmcs:0000fffa,vhttxmcs:0000fffa,extcap:0000088001400040|assoc:0,1,33,36,48,70,45,127,191,221(00904c,4),221(001018,2),221(0050f2,2),htcap:006f,htagg:17,htmcs:0000ffff,vhtcap:0f815832,vhtrxmcs:0000fffa,vhttxmcs:0000fffa,txpow:e009,extcap:0000088001400040|oui:murata':
-        ('BCM4358', 'Samsung Galaxy Note 4', '5GHz'),
     'wifi4|probe:0,1,50,3,45,127,107,221(506f9a,16),221(00904c,4),221(0050f2,8),221(001018,2),htcap:002d,htagg:17,htmcs:0000ffff,extcap:0000088001400040|assoc:0,1,50,33,36,48,45,127,107,221(001018,2),221(0050f2,2),htcap:002d,htagg:17,htmcs:0000ffff,txpow:1509,extcap:0000088001400040|oui:samsung':
         ('BCM4358', 'Samsung Galaxy Note 4', '2.4GHz'),
-    'wifi4|probe:0,1,50,3,45,127,107,221(506f9a,16),221(00904c,4),221(0050f2,8),221(001018,2),htcap:002d,htagg:17,htmcs:0000ffff,extcap:0000088001400040|assoc:0,1,50,33,36,48,45,127,107,221(001018,2),221(0050f2,2),htcap:002d,htagg:17,htmcs:0000ffff,txpow:1509,extcap:0000088001400040|oui:murata':
-        ('BCM4358', 'Samsung Galaxy Note 4', '2.4GHz'),
     'wifi4|probe:0,1,50,3,45,127,221(506f9a,16),221(00904c,4),221(0050f2,8),221(001018,2),htcap:002d,htagg:17,htmcs:0000ffff,extcap:0000088001400040|assoc:0,1,50,33,36,48,70,45,127,221(001018,2),221(0050f2,2),htcap:002d,htagg:17,htmcs:0000ffff,txpow:1509,extcap:0000088001400040|oui:samsung':
         ('BCM4358', 'Samsung Galaxy Note 4', '2.4GHz'),
-    'wifi4|probe:0,1,50,3,45,127,221(506f9a,16),221(00904c,4),221(0050f2,8),221(001018,2),htcap:002d,htagg:17,htmcs:0000ffff,extcap:0000088001400040|assoc:0,1,50,33,36,48,70,45,127,221(001018,2),221(0050f2,2),htcap:002d,htagg:17,htmcs:0000ffff,txpow:1509,extcap:0000088001400040|oui:murata':
-        ('BCM4358', 'Samsung Galaxy Note 4', '2.4GHz'),
 
     'wifi4|probe:0,1,45,127,107,191,221(506f9a,16),221(00904c,4),221(0050f2,8),221(001018,2),htcap:01ef,htagg:17,htmcs:0000ffff,vhtcap:0f9118b2,vhtrxmcs:0000fffa,vhttxmcs:0000fffa,extcap:00080f8401400040|assoc:0,1,33,36,48,45,127,191,199,221(00904c,4),221(001018,2),221(0050f2,2),htcap:01ef,htagg:17,htmcs:0000ffff,vhtcap:0f9118b2,vhtrxmcs:0000fffa,vhttxmcs:0000fffa,txpow:1102,extcap:0000000000000040|oui:samsung':
         ('BCM4359', 'Samsung Galaxy Note 5', '5GHz'),
-    'wifi4|probe:0,1,45,127,107,191,221(506f9a,16),221(00904c,4),221(0050f2,8),221(001018,2),htcap:01ef,htagg:17,htmcs:0000ffff,vhtcap:0f9118b2,vhtrxmcs:0000fffa,vhttxmcs:0000fffa,extcap:00080f8401400040|assoc:0,1,33,36,48,45,127,191,199,221(00904c,4),221(001018,2),221(0050f2,2),htcap:01ef,htagg:17,htmcs:0000ffff,vhtcap:0f9118b2,vhtrxmcs:0000fffa,vhttxmcs:0000fffa,txpow:1102,extcap:0000000000000040|oui:murata':
-        ('BCM4359', 'Samsung Galaxy Note 5', '5GHz'),
     'wifi4|probe:0,1,50,3,45,127,107,221(506f9a,16),221(00904c,4),221(0050f2,8),221(001018,2),htcap:01ad,htagg:17,htmcs:0000ffff,extcap:00080f8401400040|assoc:0,1,50,33,36,48,45,221(001018,2),221(0050f2,2),htcap:01ad,htagg:17,htmcs:0000ffff,txpow:1202|oui:samsung':
         ('BCM4359', 'Samsung Galaxy Note 5', '2.4GHz'),
-    'wifi4|probe:0,1,50,3,45,127,107,221(506f9a,16),221(00904c,4),221(0050f2,8),221(001018,2),htcap:01ad,htagg:17,htmcs:0000ffff,extcap:00080f8401400040|assoc:0,1,50,33,36,48,45,221(001018,2),221(0050f2,2),htcap:01ad,htagg:17,htmcs:0000ffff,txpow:1202|oui:murata':
-        ('BCM4359', 'Samsung Galaxy Note 5', '2.4GHz'),
 
     'wifi4|probe:0,1,45,221(001018,2),221(00904c,51),htcap:000c,htagg:19,htmcs:000000ff|assoc:0,1,33,36,48,45,221(001018,2),221(00904c,51),221(0050f2,2),htcap:000c,htagg:19,htmcs:000000ff,txpow:0f0a|oui:samsung':
         ('', 'Samsung Galaxy S2', '5GHz'),
-    'wifi4|probe:0,1,45,221(001018,2),221(00904c,51),htcap:000c,htagg:19,htmcs:000000ff|assoc:0,1,33,36,48,45,221(001018,2),221(00904c,51),221(0050f2,2),htcap:000c,htagg:19,htmcs:000000ff,txpow:0f0a|oui:murata':
-        ('', 'Samsung Galaxy S2', '5GHz'),
     'wifi4|probe:0,1,45,3,221(001018,2),221(00904c,51),htcap:000c,htagg:19,htmcs:000000ff|assoc:0,1,33,36,48,45,221(001018,2),221(00904c,51),221(0050f2,2),htcap:000c,htagg:19,htmcs:000000ff,txpow:0f0a|oui:samsung':
         ('', 'Samsung Galaxy S2', '5GHz'),
-    'wifi4|probe:0,1,45,3,221(001018,2),221(00904c,51),htcap:000c,htagg:19,htmcs:000000ff|assoc:0,1,33,36,48,45,221(001018,2),221(00904c,51),221(0050f2,2),htcap:000c,htagg:19,htmcs:000000ff,txpow:0f0a|oui:murata':
-        ('', 'Samsung Galaxy S2', '5GHz'),
     'wifi4|probe:0,1,50,45,221(001018,2),221(00904c,51),htcap:100c,htagg:19,htmcs:000000ff|assoc:0,1,33,36,48,50,45,221(001018,2),221(00904c,51),221(0050f2,2),htcap:100c,htagg:19,htmcs:000000ff,txpow:120a|oui:samsung':
         ('', 'Samsung Galaxy S2 or Infuse','2.4GHz'),
-    'wifi4|probe:0,1,50,45,221(001018,2),221(00904c,51),htcap:100c,htagg:19,htmcs:000000ff|assoc:0,1,33,36,48,50,45,221(001018,2),221(00904c,51),221(0050f2,2),htcap:100c,htagg:19,htmcs:000000ff,txpow:120a|oui:murata':
-        ('', 'Samsung Galaxy S2 or Infuse','2.4GHz'),
     'wifi4|probe:0,1,50,45,3,221(001018,2),221(00904c,51),htcap:100c,htagg:19,htmcs:000000ff|assoc:0,1,33,36,48,50,45,221(001018,2),221(00904c,51),221(0050f2,2),htcap:100c,htagg:19,htmcs:000000ff,txpow:120a|oui:samsung':
         ('', 'Samsung Galaxy S2 or Infuse','2.4GHz'),
-    'wifi4|probe:0,1,50,45,3,221(001018,2),221(00904c,51),htcap:100c,htagg:19,htmcs:000000ff|assoc:0,1,33,36,48,50,45,221(001018,2),221(00904c,51),221(0050f2,2),htcap:100c,htagg:19,htmcs:000000ff,txpow:120a|oui:murata':
-        ('', 'Samsung Galaxy S2 or Infuse','2.4GHz'),
 
     'wifi4|probe:0,1,50,45,3,221(001018,2),221(00904c,51),htcap:100c,htagg:19,htmcs:000000ff|assoc:0,1,33,36,48,50,45,221(001018,2),221(00904c,51),221(0050f2,2),htcap:100c,htagg:19,htmcs:000000ff,txpow:1209|oui:samsung':
         ('', 'Samsung Galaxy S2+', '2.4GHz'),
-    'wifi4|probe:0,1,50,45,3,221(001018,2),221(00904c,51),htcap:100c,htagg:19,htmcs:000000ff|assoc:0,1,33,36,48,50,45,221(001018,2),221(00904c,51),221(0050f2,2),htcap:100c,htagg:19,htmcs:000000ff,txpow:1209|oui:murata':
-        ('', 'Samsung Galaxy S2+', '2.4GHz'),
 
     'wifi4|probe:0,1,45,221(001018,2),221(00904c,51),htcap:0062,htagg:1a,htmcs:000000ff|assoc:0,1,33,36,48,45,221(001018,2),221(00904c,51),221(0050f2,2),htcap:0062,htagg:1a,htmcs:000000ff,txpow:1409|oui:samsung':
         ('BCM4334', 'Samsung Galaxy S3', '5GHz'),
-    'wifi4|probe:0,1,45,221(001018,2),221(00904c,51),htcap:0062,htagg:1a,htmcs:000000ff|assoc:0,1,33,36,48,45,221(001018,2),221(00904c,51),221(0050f2,2),htcap:0062,htagg:1a,htmcs:000000ff,txpow:1409|oui:murata':
-        ('BCM4334', 'Samsung Galaxy S3', '5GHz'),
     'wifi4|probe:0,1,45,3,221(001018,2),221(00904c,51),htcap:0062,htagg:1a,htmcs:000000ff|assoc:0,1,33,36,48,45,221(001018,2),221(00904c,51),221(0050f2,2),htcap:0062,htagg:1a,htmcs:000000ff,txpow:1409|oui:samsung':
         ('BCM4334', 'Samsung Galaxy S3', '5GHz'),
-    'wifi4|probe:0,1,45,3,221(001018,2),221(00904c,51),htcap:0062,htagg:1a,htmcs:000000ff|assoc:0,1,33,36,48,45,221(001018,2),221(00904c,51),221(0050f2,2),htcap:0062,htagg:1a,htmcs:000000ff,txpow:1409|oui:murata':
-        ('BCM4334', 'Samsung Galaxy S3', '5GHz'),
     'wifi4|probe:0,1,50,45,3,221(001018,2),221(00904c,51),htcap:1020,htagg:1a,htmcs:000000ff|assoc:0,1,33,36,48,50,45,221(001018,2),221(00904c,51),221(0050f2,2),htcap:1020,htagg:1a,htmcs:000000ff,txpow:1409|oui:samsung':
         ('BCM4334', 'Samsung Galaxy S3', '2.4GHz'),
-    'wifi4|probe:0,1,50,45,3,221(001018,2),221(00904c,51),htcap:1020,htagg:1a,htmcs:000000ff|assoc:0,1,33,36,48,50,45,221(001018,2),221(00904c,51),221(0050f2,2),htcap:1020,htagg:1a,htmcs:000000ff,txpow:1409|oui:murata':
-        ('BCM4334', 'Samsung Galaxy S3', '2.4GHz'),
 
     'wifi4|probe:0,1,45,127,191,221(001018,2),221(00904c,51),221(00904c,4),221(0050f2,8),htcap:006f,htagg:17,htmcs:000000ff,vhtcap:0f805832,vhtrxmcs:0000fffe,vhttxmcs:0000fffe,extcap:0000080000000040|assoc:0,1,33,36,48,45,127,191,221(001018,2),221(00904c,4),221(0050f2,2),htcap:006f,htagg:17,htmcs:000000ff,vhtcap:0f805832,vhtrxmcs:0000fffe,vhttxmcs:0000fffe,txpow:e001,extcap:0000000000000040|oui:samsung':
         ('BCM4335', 'Samsung Galaxy S4', '5GHz'),
-    'wifi4|probe:0,1,45,127,191,221(001018,2),221(00904c,51),221(00904c,4),221(0050f2,8),htcap:006f,htagg:17,htmcs:000000ff,vhtcap:0f805832,vhtrxmcs:0000fffe,vhttxmcs:0000fffe,extcap:0000080000000040|assoc:0,1,33,36,48,45,127,191,221(001018,2),221(00904c,4),221(0050f2,2),htcap:006f,htagg:17,htmcs:000000ff,vhtcap:0f805832,vhtrxmcs:0000fffe,vhttxmcs:0000fffe,txpow:e001,extcap:0000000000000040|oui:murata':
-        ('BCM4335', 'Samsung Galaxy S4', '5GHz'),
     'wifi4|probe:0,1,3,45,127,191,221(001018,2),221(00904c,51),221(00904c,4),221(0050f2,8),htcap:006f,htagg:17,htmcs:000000ff,vhtcap:0f805832,vhtrxmcs:0000fffe,vhttxmcs:0000fffe,extcap:0000080000000040|assoc:0,1,33,36,48,45,127,191,221(001018,2),221(00904c,4),221(0050f2,2),htcap:006f,htagg:17,htmcs:000000ff,vhtcap:0f805832,vhtrxmcs:0000fffe,vhttxmcs:0000fffe,txpow:e001,extcap:0000000000000040|oui:samsung':
         ('BCM4335', 'Samsung Galaxy S4', '5GHz'),
-    'wifi4|probe:0,1,3,45,127,191,221(001018,2),221(00904c,51),221(00904c,4),221(0050f2,8),htcap:006f,htagg:17,htmcs:000000ff,vhtcap:0f805832,vhtrxmcs:0000fffe,vhttxmcs:0000fffe,extcap:0000080000000040|assoc:0,1,33,36,48,45,127,191,221(001018,2),221(00904c,4),221(0050f2,2),htcap:006f,htagg:17,htmcs:000000ff,vhtcap:0f805832,vhtrxmcs:0000fffe,vhttxmcs:0000fffe,txpow:e001,extcap:0000000000000040|oui:murata':
-        ('BCM4335', 'Samsung Galaxy S4', '5GHz'),
     'wifi4|probe:0,1,45,127,107,191,221(506f9a,16),221(001018,2),221(00904c,51),221(00904c,4),221(0050f2,8),htcap:006f,htagg:17,htmcs:000000ff,vhtcap:0f805832,vhtrxmcs:0000fffe,vhttxmcs:0000fffe,extcap:0000088000400040|assoc:0,1,33,36,48,45,127,107,191,221(001018,2),221(00904c,4),221(0050f2,2),htcap:006f,htagg:17,htmcs:000000ff,vhtcap:0f805832,vhtrxmcs:0000fffe,vhttxmcs:0000fffe,txpow:e001,extcap:0000008000400040|oui:samsung':
         ('BCM4335', 'Samsung Galaxy S4', '5GHz'),
-    'wifi4|probe:0,1,45,127,107,191,221(506f9a,16),221(001018,2),221(00904c,51),221(00904c,4),221(0050f2,8),htcap:006f,htagg:17,htmcs:000000ff,vhtcap:0f805832,vhtrxmcs:0000fffe,vhttxmcs:0000fffe,extcap:0000088000400040|assoc:0,1,33,36,48,45,127,107,191,221(001018,2),221(00904c,4),221(0050f2,2),htcap:006f,htagg:17,htmcs:000000ff,vhtcap:0f805832,vhtrxmcs:0000fffe,vhttxmcs:0000fffe,txpow:e001,extcap:0000008000400040|oui:murata':
-        ('BCM4335', 'Samsung Galaxy S4', '5GHz'),
     'wifi4|probe:0,1,3,45,127,107,191,221(506f9a,16),221(001018,2),221(00904c,51),221(00904c,4),221(0050f2,8),htcap:006f,htagg:17,htmcs:000000ff,vhtcap:0f805832,vhtrxmcs:0000fffe,vhttxmcs:0000fffe,extcap:0000088000400040|assoc:0,1,33,36,48,45,127,107,191,221(001018,2),221(00904c,4),221(0050f2,2),htcap:006f,htagg:17,htmcs:000000ff,vhtcap:0f805832,vhtrxmcs:0000fffe,vhttxmcs:0000fffe,txpow:e001,extcap:0000008000400040|oui:samsung':
         ('BCM4335', 'Samsung Galaxy S4', '5GHz'),
-    'wifi4|probe:0,1,3,45,127,107,191,221(506f9a,16),221(001018,2),221(00904c,51),221(00904c,4),221(0050f2,8),htcap:006f,htagg:17,htmcs:000000ff,vhtcap:0f805832,vhtrxmcs:0000fffe,vhttxmcs:0000fffe,extcap:0000088000400040|assoc:0,1,33,36,48,45,127,107,191,221(001018,2),221(00904c,4),221(0050f2,2),htcap:006f,htagg:17,htmcs:000000ff,vhtcap:0f805832,vhtrxmcs:0000fffe,vhttxmcs:0000fffe,txpow:e001,extcap:0000008000400040|oui:murata':
-        ('BCM4335', 'Samsung Galaxy S4', '5GHz'),
     'wifi4|probe:0,1,45,127,191,221(001018,2),221(00904c,51),221(00904c,4),221(0050f2,8),htcap:006f,htagg:17,htmcs:000000ff,vhtcap:0f805832,vhtrxmcs:0000fffe,vhttxmcs:0000fffe,extcap:0000080000400040|assoc:0,1,33,36,48,45,127,191,221(001018,2),221(00904c,4),221(0050f2,2),htcap:006f,htagg:17,htmcs:000000ff,vhtcap:0f805832,vhtrxmcs:0000fffe,vhttxmcs:0000fffe,txpow:e001,extcap:0000000000400040|oui:samsung':
         ('BCM4335', 'Samsung Galaxy S4', '5GHz'),
-    'wifi4|probe:0,1,45,127,191,221(001018,2),221(00904c,51),221(00904c,4),221(0050f2,8),htcap:006f,htagg:17,htmcs:000000ff,vhtcap:0f805832,vhtrxmcs:0000fffe,vhttxmcs:0000fffe,extcap:0000080000400040|assoc:0,1,33,36,48,45,127,191,221(001018,2),221(00904c,4),221(0050f2,2),htcap:006f,htagg:17,htmcs:000000ff,vhtcap:0f805832,vhtrxmcs:0000fffe,vhttxmcs:0000fffe,txpow:e001,extcap:0000000000400040|oui:murata':
-        ('BCM4335', 'Samsung Galaxy S4', '5GHz'),
     'wifi4|probe:0,1,50,3,45,127,107,221(506f9a,16),221(001018,2),221(00904c,51),221(00904c,4),221(0050f2,8),htcap:102d,htagg:17,htmcs:000000ff,extcap:0000088000400040|assoc:0,1,33,36,48,50,45,127,107,221(001018,2),221(0050f2,2),htcap:102d,htagg:17,htmcs:000000ff,txpow:1201,extcap:000000800040|oui:samsung':
         ('BCM4335', 'Samsung Galaxy S4', '2.4GHz'),
-    'wifi4|probe:0,1,50,3,45,127,107,221(506f9a,16),221(001018,2),221(00904c,51),221(00904c,4),221(0050f2,8),htcap:102d,htagg:17,htmcs:000000ff,extcap:0000088000400040|assoc:0,1,33,36,48,50,45,127,107,221(001018,2),221(0050f2,2),htcap:102d,htagg:17,htmcs:000000ff,txpow:1201,extcap:000000800040|oui:murata':
-        ('BCM4335', 'Samsung Galaxy S4', '2.4GHz'),
     'wifi4|probe:0,1,50,45,127,221(001018,2),221(00904c,51),221(00904c,4),221(0050f2,8),htcap:102d,htagg:17,htmcs:000000ff,extcap:0000080000000040|assoc:0,1,33,36,48,50,45,221(001018,2),221(0050f2,2),htcap:102d,htagg:17,htmcs:000000ff,txpow:1201|oui:samsung':
         ('BCM4335', 'Samsung Galaxy S4', '2.4GHz'),
-    'wifi4|probe:0,1,50,45,127,221(001018,2),221(00904c,51),221(00904c,4),221(0050f2,8),htcap:102d,htagg:17,htmcs:000000ff,extcap:0000080000000040|assoc:0,1,33,36,48,50,45,221(001018,2),221(0050f2,2),htcap:102d,htagg:17,htmcs:000000ff,txpow:1201|oui:murata':
-        ('BCM4335', 'Samsung Galaxy S4', '2.4GHz'),
     'wifi4|probe:0,1,50,45,127,107,221(506f9a,16),221(001018,2),221(00904c,51),221(00904c,4),221(0050f2,8),htcap:102d,htagg:17,htmcs:000000ff,extcap:0000088000400040|assoc:0,1,33,36,48,50,45,127,107,221(001018,2),221(0050f2,2),htcap:102d,htagg:17,htmcs:000000ff,txpow:1201,extcap:000000800040|oui:samsung':
         ('BCM4335', 'Samsung Galaxy S4', '2.4GHz'),
-    'wifi4|probe:0,1,50,45,127,107,221(506f9a,16),221(001018,2),221(00904c,51),221(00904c,4),221(0050f2,8),htcap:102d,htagg:17,htmcs:000000ff,extcap:0000088000400040|assoc:0,1,33,36,48,50,45,127,107,221(001018,2),221(0050f2,2),htcap:102d,htagg:17,htmcs:000000ff,txpow:1201,extcap:000000800040|oui:murata':
-        ('BCM4335', 'Samsung Galaxy S4', '2.4GHz'),
 
     'wifi4|probe:0,1,45,127,107,191,221(506f9a,16),221(00904c,4),221(0050f2,8),221(001018,2),htcap:006f,htagg:17,htmcs:0000ffff,vhtcap:0f815832,vhtrxmcs:0000fffa,vhttxmcs:0000fffa,extcap:0000088001400040|assoc:0,1,33,36,48,45,127,107,191,221(00904c,4),221(001018,2),221(0050f2,2),htcap:006f,htagg:17,htmcs:0000ffff,vhtcap:0f815832,vhtrxmcs:0000fffa,vhttxmcs:0000fffa,txpow:e20b,extcap:0000088001400040|oui:samsung':
         ('BCM4354', 'Samsung Galaxy S5', '5GHz'),
-    'wifi4|probe:0,1,45,127,107,191,221(506f9a,16),221(00904c,4),221(0050f2,8),221(001018,2),htcap:006f,htagg:17,htmcs:0000ffff,vhtcap:0f815832,vhtrxmcs:0000fffa,vhttxmcs:0000fffa,extcap:0000088001400040|assoc:0,1,33,36,48,45,127,107,191,221(00904c,4),221(001018,2),221(0050f2,2),htcap:006f,htagg:17,htmcs:0000ffff,vhtcap:0f815832,vhtrxmcs:0000fffa,vhttxmcs:0000fffa,txpow:e20b,extcap:0000088001400040|oui:murata':
+    'wifi4|probe:0,1,45,191,221(00904c,4),221(0050f2,8),221(001018,2),htcap:006f,htagg:17,htmcs:0000ffff,vhtcap:0f815832,vhtrxmcs:0000fffa,vhttxmcs:0000fffa|assoc:0,1,33,36,48,45,191,221(00904c,4),221(001018,2),221(0050f2,2),htcap:006f,htagg:17,htmcs:0000ffff,vhtcap:0f815832,vhtrxmcs:0000fffa,vhttxmcs:0000fffa,txpow:e20b|oui:samsung':
         ('BCM4354', 'Samsung Galaxy S5', '5GHz'),
     'wifi4|probe:0,1,50,3,45,127,107,221(506f9a,16),221(00904c,4),221(0050f2,8),221(001018,2),htcap:002d,htagg:17,htmcs:0000ffff,extcap:000008800140|assoc:0,1,50,33,36,48,45,127,107,221(001018,2),221(0050f2,2),htcap:002d,htagg:17,htmcs:0000ffff,txpow:1209,extcap:000008800140|oui:samsung':
         ('BCM4354', 'Samsung Galaxy S5', '2.4GHz'),
-    'wifi4|probe:0,1,50,3,45,127,107,221(506f9a,16),221(00904c,4),221(0050f2,8),221(001018,2),htcap:002d,htagg:17,htmcs:0000ffff,extcap:000008800140|assoc:0,1,50,33,36,48,45,127,107,221(001018,2),221(0050f2,2),htcap:002d,htagg:17,htmcs:0000ffff,txpow:1209,extcap:000008800140|oui:murata':
-        ('BCM4354', 'Samsung Galaxy S5', '2.4GHz'),
     'wifi4|probe:0,1,50,3,45,127,107,221(506f9a,16),221(00904c,4),221(0050f2,8),221(001018,2),htcap:002d,htagg:17,htmcs:0000ffff,extcap:0000088001400040|assoc:0,1,50,33,36,48,45,127,107,221(001018,2),221(0050f2,2),htcap:002d,htagg:17,htmcs:0000ffff,txpow:1209,extcap:000008800140|oui:samsung':
         ('BCM4354', 'Samsung Galaxy S5', '2.4GHz'),
-    'wifi4|probe:0,1,50,3,45,127,107,221(506f9a,16),221(00904c,4),221(0050f2,8),221(001018,2),htcap:002d,htagg:17,htmcs:0000ffff,extcap:0000088001400040|assoc:0,1,50,33,36,48,45,127,107,221(001018,2),221(0050f2,2),htcap:002d,htagg:17,htmcs:0000ffff,txpow:1209,extcap:000008800140|oui:murata':
-        ('BCM4354', 'Samsung Galaxy S5', '2.4GHz'),
 
     'wifi4|probe:0,1,45,127,191,221(00904c,4),221(0050f2,8),221(001018,2),htcap:006f,htagg:17,htmcs:0000ffff,vhtcap:0f815832,vhtrxmcs:0000fffa,vhttxmcs:0000fffa,extcap:0000088001400040|assoc:0,1,33,36,48,45,127,191,221(00904c,4),221(001018,2),221(0050f2,2),htcap:006f,htagg:17,htmcs:0000ffff,vhtcap:0f815832,vhtrxmcs:0000fffa,vhttxmcs:0000fffa,txpow:e002,extcap:0000088001400040|oui:samsung':
         ('BCM4358', 'Samsung Galaxy S6', '5GHz'),
-    'wifi4|probe:0,1,45,127,191,221(00904c,4),221(0050f2,8),221(001018,2),htcap:006f,htagg:17,htmcs:0000ffff,vhtcap:0f815832,vhtrxmcs:0000fffa,vhttxmcs:0000fffa,extcap:0000088001400040|assoc:0,1,33,36,48,45,127,191,221(00904c,4),221(001018,2),221(0050f2,2),htcap:006f,htagg:17,htmcs:0000ffff,vhtcap:0f815832,vhtrxmcs:0000fffa,vhttxmcs:0000fffa,txpow:e002,extcap:0000088001400040|oui:murata':
-        ('BCM4358', 'Samsung Galaxy S6', '5GHz'),
     'wifi4|probe:0,1,45,127,191,221(00904c,4),221(0050f2,8),221(001018,2),htcap:006f,htagg:17,htmcs:0000ffff,vhtcap:0f815832,vhtrxmcs:0000fffa,vhttxmcs:0000fffa,extcap:0000088001400040|assoc:0,1,33,36,45,127,191,221(00904c,4),221(001018,2),221(0050f2,2),htcap:006f,htagg:17,htmcs:0000ffff,vhtcap:0f815832,vhtrxmcs:0000fffa,vhttxmcs:0000fffa,txpow:e002,extcap:0000088001400040|oui:samsung':
         ('BCM4358', 'Samsung Galaxy S6', '5GHz'),
-    'wifi4|probe:0,1,45,127,191,221(00904c,4),221(0050f2,8),221(001018,2),htcap:006f,htagg:17,htmcs:0000ffff,vhtcap:0f815832,vhtrxmcs:0000fffa,vhttxmcs:0000fffa,extcap:0000088001400040|assoc:0,1,33,36,45,127,191,221(00904c,4),221(001018,2),221(0050f2,2),htcap:006f,htagg:17,htmcs:0000ffff,vhtcap:0f815832,vhtrxmcs:0000fffa,vhttxmcs:0000fffa,txpow:e002,extcap:0000088001400040|oui:murata':
+    'wifi4|probe:0,1,45,191,221(00904c,4),221(0050f2,8),221(001018,2),htcap:006f,htagg:17,htmcs:0000ffff,vhtcap:0f815832,vhtrxmcs:0000fffa,vhttxmcs:0000fffa|assoc:0,1,33,36,48,45,191,221(00904c,4),221(001018,2),221(0050f2,2),htcap:006f,htagg:17,htmcs:0000ffff,vhtcap:0f815832,vhtrxmcs:0000fffa,vhttxmcs:0000fffa,txpow:e002|oui:samsung':
         ('BCM4358', 'Samsung Galaxy S6', '5GHz'),
     'wifi4|probe:0,1,50,3,45,127,221(00904c,4),221(0050f2,8),221(001018,2),htcap:002d,htagg:17,htmcs:0000ffff,extcap:0000088001400040|assoc:0,1,50,33,36,45,127,221(001018,2),221(0050f2,2),htcap:002d,htagg:17,htmcs:0000ffff,txpow:1402,extcap:0000088001400040|oui:samsung':
         ('BCM4358', 'Samsung Galaxy S6', '2.4GHz'),
-    'wifi4|probe:0,1,50,3,45,127,221(00904c,4),221(0050f2,8),221(001018,2),htcap:002d,htagg:17,htmcs:0000ffff,extcap:0000088001400040|assoc:0,1,50,33,36,45,127,221(001018,2),221(0050f2,2),htcap:002d,htagg:17,htmcs:0000ffff,txpow:1402,extcap:0000088001400040|oui:murata':
-        ('BCM4358', 'Samsung Galaxy S6', '2.4GHz'),
     'wifi4|probe:0,1,50,3,45,127,221(00904c,4),221(0050f2,8),221(001018,2),htcap:002d,htagg:17,htmcs:0000ffff,extcap:0000088001400040|assoc:0,1,50,33,36,48,45,127,221(001018,2),221(0050f2,2),htcap:002d,htagg:17,htmcs:0000ffff,txpow:1402,extcap:0000088001400040|oui:samsung':
         ('BCM4358', 'Samsung Galaxy S6', '2.4GHz'),
-    'wifi4|probe:0,1,50,3,45,127,221(00904c,4),221(0050f2,8),221(001018,2),htcap:002d,htagg:17,htmcs:0000ffff,extcap:0000088001400040|assoc:0,1,50,33,36,48,45,127,221(001018,2),221(0050f2,2),htcap:002d,htagg:17,htmcs:0000ffff,txpow:1402,extcap:0000088001400040|oui:murata':
-        ('BCM4358', 'Samsung Galaxy S6', '2.4GHz'),
 
     'wifi4|probe:0,1,45,221(001018,2),221(00904c,51),htcap:082c,htagg:1b,htmcs:000000ff|assoc:0,1,33,36,48,45,221(001018,2),221(00904c,51),221(0050f2,2),htcap:082c,htagg:1b,htmcs:000000ff,txpow:0f08|oui:samsung':
         ('BCM4329', 'Samsung Galaxy Tab', '5GHz'),
-    'wifi4|probe:0,1,45,221(001018,2),221(00904c,51),htcap:082c,htagg:1b,htmcs:000000ff|assoc:0,1,33,36,48,45,221(001018,2),221(00904c,51),221(0050f2,2),htcap:082c,htagg:1b,htmcs:000000ff,txpow:0f08|oui:murata':
-        ('BCM4329', 'Samsung Galaxy Tab', '5GHz'),
     'wifi4|probe:0,1,50,45,221(001018,2),221(00904c,51),htcap:182c,htagg:1b,htmcs:000000ff|assoc:0,1,33,36,48,50,45,221(001018,2),221(00904c,51),221(0050f2,2),htcap:182c,htagg:1b,htmcs:000000ff,txpow:1208|oui:samsung':
         ('BCM4329', 'Samsung Galaxy Tab', '2.4GHz'),
-    'wifi4|probe:0,1,50,45,221(001018,2),221(00904c,51),htcap:182c,htagg:1b,htmcs:000000ff|assoc:0,1,33,36,48,50,45,221(001018,2),221(00904c,51),221(0050f2,2),htcap:182c,htagg:1b,htmcs:000000ff,txpow:1208|oui:murata':
-        ('BCM4329', 'Samsung Galaxy Tab', '2.4GHz'),
 
     'wifi4|probe:0,1,45,50,htcap:0162,htagg:03,htmcs:00000000|assoc:0,1,48,127,221(0050f2,2),45,htcap:016e,htagg:03,htmcs:000000ff,extcap:0400000000000140|oui:samsung':
         ('Marvell_88W8787', 'Samsung Galaxy Tab 3', '5GHz'),
-    'wifi4|probe:0,1,45,50,htcap:0162,htagg:03,htmcs:00000000|assoc:0,1,48,127,221(0050f2,2),45,htcap:016e,htagg:03,htmcs:000000ff,extcap:0400000000000140|oui:murata':
-        ('Marvell_88W8787', 'Samsung Galaxy Tab 3', '5GHz'),
     'wifi4|probe:0,1,45,50,htcap:0162,htagg:03,htmcs:00000000|assoc:0,1,33,36,48,127,221(0050f2,2),45,htcap:016e,htagg:03,htmcs:000000ff,txpow:1208,extcap:0400000000000140|oui:samsung':
         ('Marvell_88W8787', 'Samsung Galaxy Tab 3', '5GHz'),
-    'wifi4|probe:0,1,45,50,htcap:0162,htagg:03,htmcs:00000000|assoc:0,1,33,36,48,127,221(0050f2,2),45,htcap:016e,htagg:03,htmcs:000000ff,txpow:1208,extcap:0400000000000140|oui:murata':
-        ('Marvell_88W8787', 'Samsung Galaxy Tab 3', '5GHz'),
     'wifi4|probe:0,1,3,45,50,htcap:0162,htagg:03,htmcs:00000000|assoc:0,1,48,50,127,221(0050f2,2),45,htcap:012c,htagg:03,htmcs:000000ff,extcap:0000000000000140|oui:samsung':
         ('Marvell_88W8787', 'Samsung Galaxy Tab 3', '2.4GHz'),
-    'wifi4|probe:0,1,3,45,50,htcap:0162,htagg:03,htmcs:00000000|assoc:0,1,48,50,127,221(0050f2,2),45,htcap:012c,htagg:03,htmcs:000000ff,extcap:0000000000000140|oui:murata':
-        ('Marvell_88W8787', 'Samsung Galaxy Tab 3', '2.4GHz'),
 
     'wifi|probe:0,1,45,221(0050f2,8),htcap:016e|assoc:0,1,33,36,48,45,221(0050f2,2),221(004096,3),htcap:016e|oui:samsung':
         ('APQ8026', 'Samsung Galaxy Tab 4', '5GHz'),
-    'wifi|probe:0,1,45,221(0050f2,8),htcap:016e|assoc:0,1,33,36,48,45,221(0050f2,2),221(004096,3),htcap:016e|oui:murata':
-        ('APQ8026', 'Samsung Galaxy Tab 4', '5GHz'),
     'wifi|probe:0,1,50,3,45,221(0050f2,8),htcap:012c|assoc:0,1,50,48,45,221(0050f2,2),221(004096,3),htcap:012c|oui:samsung':
         ('APQ8026', 'Samsung Galaxy Tab 4', '2.4GHz'),
-    'wifi|probe:0,1,50,3,45,221(0050f2,8),htcap:012c|assoc:0,1,50,48,45,221(0050f2,2),221(004096,3),htcap:012c|oui:murata':
-        ('APQ8026', 'Samsung Galaxy Tab 4', '2.4GHz'),
 
     'wifi4|probe:0,1,45,221(0050f2,4),221(001018,2),221(00904c,51),htcap:000c,htagg:19,htmcs:000000ff,wps:_|assoc:0,1,33,36,48,45,221(001018,2),221(00904c,51),221(0050f2,2),htcap:000c,htagg:19,htmcs:000000ff,txpow:0c0a|oui:samsung':
         ('BCM4330', 'Samsung Galaxy Tab 10.1', '5GHz'),
-    'wifi4|probe:0,1,45,221(0050f2,4),221(001018,2),221(00904c,51),htcap:000c,htagg:19,htmcs:000000ff,wps:_|assoc:0,1,33,36,48,45,221(001018,2),221(00904c,51),221(0050f2,2),htcap:000c,htagg:19,htmcs:000000ff,txpow:0c0a|oui:murata':
-        ('BCM4330', 'Samsung Galaxy Tab 10.1', '5GHz'),
     'wifi4|probe:0,1,45,3,221(0050f2,4),221(001018,2),221(00904c,51),htcap:000c,htagg:19,htmcs:000000ff,wps:_|assoc:0,1,33,36,48,45,221(001018,2),221(00904c,51),221(0050f2,2),htcap:000c,htagg:19,htmcs:000000ff,txpow:0c0a|oui:samsung':
         ('BCM4330', 'Samsung Galaxy Tab 10.1', '5GHz'),
-    'wifi4|probe:0,1,45,3,221(0050f2,4),221(001018,2),221(00904c,51),htcap:000c,htagg:19,htmcs:000000ff,wps:_|assoc:0,1,33,36,48,45,221(001018,2),221(00904c,51),221(0050f2,2),htcap:000c,htagg:19,htmcs:000000ff,txpow:0c0a|oui:murata':
-        ('BCM4330', 'Samsung Galaxy Tab 10.1', '5GHz'),
     'wifi4|probe:0,1,50,45,3,221(0050f2,4),221(001018,2),221(00904c,51),htcap:100c,htagg:19,htmcs:000000ff,wps:_|assoc:0,1,33,36,48,50,45,221(001018,2),221(00904c,51),221(0050f2,2),htcap:100c,htagg:19,htmcs:000000ff,txpow:0f0a|oui:samsung':
         ('BCM4330', 'Samsung Galaxy Tab 10.1', '2.4GHz'),
-    'wifi4|probe:0,1,50,45,3,221(0050f2,4),221(001018,2),221(00904c,51),htcap:100c,htagg:19,htmcs:000000ff,wps:_|assoc:0,1,33,36,48,50,45,221(001018,2),221(00904c,51),221(0050f2,2),htcap:100c,htagg:19,htmcs:000000ff,txpow:0f0a|oui:murata':
-        ('BCM4330', 'Samsung Galaxy Tab 10.1', '2.4GHz'),
 
     'wifi4|probe:0,1,45,htcap:11ee,htagg:02,htmcs:0000ffff|assoc:0,1,45,127,33,36,48,221(0050f2,2),htcap:11ee,htagg:02,htmcs:0000ffff,txpow:1100,extcap:01|os:samsungtv':
         ('', 'Samsung Smart TV', '5GHz'),
@@ -788,7 +742,7 @@
     'wifi4|probe:0,1,50,3,45,127,107,221(506f9a,16),221(0050f2,8),221(001018,2),htcap:002d,htagg:17,htmcs:0000ffff,extcap:0000088001400040|assoc:0,1,50,33,36,48,70,45,127,107,221(001018,2),221(0050f2,2),htcap:002d,htagg:17,htmcs:0000ffff,txpow:1307,extcap:0000088001400040|oui:sony':
         ('', 'Sony Xperia Z4 Tablet', '2.4GHz'),
 
-    'wifi|probe:0,1,50,221(0050f2,4),wps:Ralink_Wireless_Linux_Client|assoc:0,1,50,45,127,221(000c43,6),221(0050f2,2),48,htcap:000c,htagg:12,htmcs:000000ff,extcap:00000001|os:visiotv':
+    'wifi4|probe:0,1,50,221(0050f2,4),wps:Ralink_Wireless_Linux_Client|assoc:0,1,50,45,127,221(000c43,6),221(0050f2,2),48,htcap:000c,htagg:12,htmcs:000000ff,extcap:01000000|os:visiotv':
         ('', 'Vizio Smart TV', '2.4GHz'),
     'wifi|probe:0,1,50,45,127,221(0050f2,4),htcap:106e,htagg:12,htmcs:000000ff,wps:Ralink_Wireless_Linux_Client|assoc:0,1,50,45,127,221(000c43,6),221(0050f2,2),48,htcap:000c,htagg:12,htmcs:000000ff,extcap:00000001|os:visiotv':
         ('', 'Vizio Smart TV', '2.4GHz'),
@@ -809,9 +763,9 @@
     'wifi|probe:0,1,3,45,50,htcap:016e,htagg:03,htmcs:0000ffff|assoc:0,1,33,48,50,127,221(0050f2,2),45,htcap:012c,htagg:03,htmcs:0000ffff,extcap:00000000|oui:microsoft':
         ('', 'Xbox', '5GHz'),
 
-    'wifi|probe:0,1,3,45,50,htcap:058f,htagg:03,htmcs:0000ffff|assoc:0,1,48,50,221(0050f2,2),45,htcap:058d,htagg:03,htmcs:0000ffff|oui:microsoft':
-        ('Marvell_88W8897', 'Xbox One', '5GHz'),
-    'wifi|probe:0,1,45,50,htcap:058f|assoc:0,1,48,221(0050f2,2),45,htcap:058f|oui:microsoft':
+    'wifi4|probe:0,1,3,45,50,htcap:058f,htagg:03,htmcs:0000ffff|assoc:0,1,48,50,221(0050f2,2),45,htcap:058d,htagg:03,htmcs:0000ffff|oui:microsoft':
+        ('Marvell_88W8897', 'Xbox One', '2.4GHz'),
+    'wifi4|probe:0,1,45,50,htcap:058f,htagg:03,htmcs:0000ffff|assoc:0,1,48,50,221(0050f2,2),45,htcap:058d,htagg:03,htmcs:0000ffff|oui:microsoft':
         ('Marvell_88W8897', 'Xbox One', '2.4GHz'),
 }
 
diff --git a/waveguide/clientinfo_test.py b/waveguide/clientinfo_test.py
index 282ec62..e7471f3 100755
--- a/waveguide/clientinfo_test.py
+++ b/waveguide/clientinfo_test.py
@@ -13,6 +13,11 @@
 # See the License for the specific language governing permissions and
 # limitations under the License.
 
+# One version of gpylint wants to see clientinfo first and taxonomy last.
+# Another wants to see the reverse. Cannot satisfy both, so tell both of them
+# to shove the error so far up their stdin that it should never trouble us
+# again.
+# pylint:disable=g-bad-import-order
 import taxonomy
 import clientinfo
 from wvtest import wvtest
diff --git a/waveguide/fake/wifi b/waveguide/fake/wifi
new file mode 100755
index 0000000..9270bc7
--- /dev/null
+++ b/waveguide/fake/wifi
@@ -0,0 +1,28 @@
+#!/bin/sh
+echo "fake/wifi:" "$@" >&2
+cd "$(dirname "$0")"
+
+if [ "$1" = "scan" ]; then
+  # TODO(willangley): pass the dev in an env var for tests, since looking it up
+  #   from the platform cannot be expected to work.
+  iw_scan="./iw dev wlan-22:22 scan"
+  while [ -n "$2" ]; do
+    shift
+    case "$1" in
+      -b)
+        shift
+        ;;
+      --scan-freq)
+        shift
+        iw_scan="$iw_scan freq $1"
+        ;;
+      --scan-*)
+        iw_scan="$iw_scan ${1#--scan-}"
+        ;;
+    esac
+  done
+  exec $iw_scan
+else
+  echo "fake/wifi: first arg ('$1') must be 'scan'" >&2
+  exit 99
+fi
diff --git a/waveguide/waveguide.py b/waveguide/waveguide.py
index 982bf23..cbc9fe8 100755
--- a/waveguide/waveguide.py
+++ b/waveguide/waveguide.py
@@ -93,6 +93,15 @@
 consensus_start = None
 
 
+def BandForFreq(freq):
+  if freq / 100 == 24:
+    return '2.4'
+  elif freq / 1000 == 5:
+    return '5'
+  else:
+    raise ValueError('Frequency %d is not in any known band', freq)
+
+
 def UpdateConsensus(new_uptime_ms, new_consensus_key):
   """Update the consensus key based on received multicast packets."""
   global consensus_key, consensus_start
@@ -198,7 +207,8 @@
 class WlanManager(object):
   """A class representing one wifi interface on the local host."""
 
-  def __init__(self, phyname, vdevname, high_power, tv_box):
+  def __init__(self, phyname, vdevname, high_power, tv_box,
+               wifiblaster_controller):
     self.phyname = phyname
     self.vdevname = vdevname
     self.mac = '\0\0\0\0\0\0'
@@ -224,6 +234,7 @@
     self.ap_signals = {}
     self.auto_disabled = None
     self.autochan_2g = self.autochan_5g = self.autochan_free = 0
+    self.wifiblaster_controller = wifiblaster_controller
     helpers.Unlink(self.Filename('disabled'))
 
   def Filename(self, suffix):
@@ -322,9 +333,15 @@
               args=['iw', 'dev', self.vdevname, 'info'])
       # channel scan more than once in case we miss hearing a beacon
       for _ in range(opt.initial_scans):
+        if self.flags & wgdata.ApFlags.Can2G:
+          band = '2.4'
+        elif self.flags & wgdata.ApFlags.Can5G:
+          band = '5'
+
         RunProc(
             callback=self._ScanResults,
-            args=['iw', 'dev', self.vdevname, 'scan', 'ap-force', 'passive'])
+            args=['wifi', 'scan', '-b', band, '--scan-ap-force',
+                  '--scan-passive'])
         self.UpdateStationInfo()
       self.next_scan_time = now
       self.did_initial_scan = True
@@ -336,8 +353,9 @@
       self.Log('scanning %d MHz (%d/%d)', scan_freq, self.scan_idx + 1,
                len(self.allowed_freqs))
       RunProc(callback=self._ScanResults,
-              args=['iw', 'dev', self.vdevname, 'scan', 'freq', str(scan_freq),
-                    'ap-force', 'passive'])
+              args=['wifi', 'scan', '-b', BandForFreq(scan_freq),
+                    '--scan-freq', str(scan_freq), '--scan-ap-force',
+                    '--scan-passive'])
       chan_interval = opt.scan_interval / len(self.allowed_freqs)
       # Randomly fiddle with the timing to avoid permanent alignment with
       # other nodes also doing scans.  If we're perfectly aligned with
@@ -584,9 +602,10 @@
         disabled = (g.group(3) == 'disabled')
         self.Debug('phy freq=%d chan=%d disabled=%d', freq, chan, disabled)
         if not disabled:
-          if freq / 100 == 24:
+          band = BandForFreq(freq)
+          if band == '2.4':
             self.flags |= wgdata.ApFlags.Can2G
-          if freq / 1000 == 5:
+          elif band == '5':
             self.flags |= wgdata.ApFlags.Can5G
           self.allowed_freqs.add(freq)
           freq_to_chan[freq] = chan
@@ -753,7 +772,7 @@
     self.Debug('assoc err:%r stdout:%r stderr:%r', errcode, stdout[:70], stderr)
     if errcode: return
     now = time.time()
-    self.assoc_list = {}
+    assoc_list = {}
     mac = None
     rssi = 0
     last_seen = now
@@ -764,7 +783,8 @@
         a = wgdata.Assoc(mac=mac, rssi=rssi, last_seen=last_seen, can5G=can5G)
         if mac not in self.assoc_list:
           self.Debug('Added: %r', a)
-        self.assoc_list[mac] = a
+          self.wifiblaster_controller.MeasureOnAssociation(self.vdevname, mac)
+        assoc_list[mac] = a
 
     for line in stdout.split('\n'):
       line = line.strip()
@@ -783,6 +803,7 @@
       if g:
         rssi = float(g.group(1))
     AddEntry()
+    self.assoc_list = assoc_list
 
   def _AssocCan5G(self, mac):
     """Check whether a station supports 5GHz.
@@ -811,7 +832,7 @@
     return False
 
 
-def CreateManagers(managers, high_power, tv_box):
+def CreateManagers(managers, high_power, tv_box, wifiblaster_controller):
   """Create WlanManager() objects, one per wifi interface."""
 
   def ParseDevList(errcode, stdout, stderr):
@@ -858,31 +879,43 @@
     for phy, dev in phy_devs.iteritems():
       if dev not in existing_devs:
         log.Debug('Creating wlan manager for (%r, %r)', phy, dev)
-        managers.append(WlanManager(phy, dev, high_power=high_power,
-                                    tv_box=tv_box))
+        managers.append(
+            WlanManager(phy, dev, high_power=high_power, tv_box=tv_box,
+                        wifiblaster_controller=wifiblaster_controller))
 
   RunProc(callback=ParseDevList, args=['iw', 'dev'])
 
 
 class WifiblasterController(object):
-  """State machine and scheduler for packet blast testing.
+  """WiFi performance measurement using wifiblaster.
+
+  There are two modes: automated and triggered.
+
+  In automated mode, WifiblasterController measures random clients at random
+  times as governed by a Poisson process with rate = 1 / interval. Thus,
+  measurements are distributed uniformly over time, and every point in time is
+  equally likely to be measured. The average number of measurements in any given
+  window of W seconds is W / interval.
+
+  In triggered mode, WifiblasterController immediately measures the requested
+  client.
 
   WifiblasterController reads parameters from files:
 
-    wifiblaster.duration  Packet blast duration in seconds.
-    wifiblaster.enable    Enable packet blast testing.
-    wifiblaster.fraction  Number of samples per duration.
-    wifiblaster.interval  Average time between packet blasts.
-    wifiblaster.size      Packet size in bytes.
+  - Scheduling parameters
 
-  When enabled, WifiblasterController runs packet blasts at random times as
-  governed by a Poisson process with rate = 1 / interval. Thus, packet blasts
-  are distributed uniformly over time, and every point in time is equally likely
-  to be measured by a packet blast. The average number of packet blasts in any
-  given window of W seconds is W / interval.
+    wifiblaster.enable         Enable WiFi performance measurement.
+    wifiblaster.interval       Average time between automated measurements in
+                               seconds, or 0 to disable automated measurements.
+    wifiblaster.measureall     Unix time at which to measure all clients.
+    wifiblaster.onassociation  Enable WiFi performance measurement after clients
+                               associate.
 
-  Each packet blast tests a random associated client. The results output by
-  wifiblaster are anonymized and written directly to the log.
+  - Measurement parameters
+
+    wifiblaster.duration       Measurement duration in seconds.
+    wifiblaster.fraction       Number of samples per measurement.
+    wifiblaster.size           Packet size in bytes.
   """
 
   def __init__(self, managers, basedir):
@@ -890,7 +923,8 @@
     self._managers = managers
     self._basedir = basedir
     self._interval = 0  # Disabled.
-    self._next_packet_blast_time = float('inf')
+    self._next_measurement_time = float('inf')
+    self._last_measureall_time = 0
     self._next_timeout = 0
 
   def _ReadParameter(self, name, typecast):
@@ -928,64 +962,77 @@
     """Returns True if a string expresses a true value."""
     return s.rstrip().lower() in ('true', '1')
 
+  def _GetAllClients(self):
+    """Returns all associated clients."""
+    return [(manager.vdevname, assoc.mac)
+            for manager in self._managers for assoc in manager.GetState().assoc]
+
   def NextTimeout(self):
     """Returns the time of the next event."""
     return self._next_timeout
 
-  def NextBlast(self):
-    """Return the time of the next packet blast event."""
-    return self._next_packet_blast_time
+  def NextMeasurement(self):
+    """Return the time of the next measurement event."""
+    return self._next_measurement_time
+
+  def Measure(self, interface, client):
+    """Measures the performance of a client."""
+    enable = self._ReadParameter('enable', self._StrToBool)
+    duration = self._ReadParameter('duration', float)
+    fraction = self._ReadParameter('fraction', int)
+    size = self._ReadParameter('size', int)
+    if enable and duration > 0 and fraction > 0 and size > 0:
+      RunProc(callback=self._HandleResults,
+              args=[WIFIBLASTER_BIN, '-i', interface, '-d', str(duration),
+                    '-f', str(fraction), '-s', str(size),
+                    helpers.DecodeMAC(client)])
+
+  def MeasureOnAssociation(self, interface, client):
+    """Measures the performance of a client after association."""
+    onassociation = self._ReadParameter('onassociation', self._StrToBool)
+    if onassociation:
+      self.Measure(interface, client)
 
   def Poll(self, now):
     """Polls the state machine."""
 
     def Disable():
       self._interval = 0
-      self._next_packet_blast_time = float('inf')
+      self._next_measurement_time = float('inf')
 
-    def StartPacketBlastTimer(interval):
+    def StartMeasurementTimer(interval):
       self._interval = interval
       # Inter-arrival times in a Poisson process are exponentially distributed.
-      # The timebase slip prevents a burst of packet blasts in case we fall
+      # The timebase slip prevents a burst of measurements in case we fall
       # behind.
-      self._next_packet_blast_time = now + random.expovariate(1 / interval)
+      self._next_measurement_time = now + random.expovariate(1 / interval)
 
-    # Read parameters.
-    duration = self._ReadParameter('duration', float)
-    enable = self._ReadParameter('enable', self._StrToBool)
-    fraction = self._ReadParameter('fraction', int)
     interval = self._ReadParameter('interval', float)
-    rapidpolling = self._ReadParameter('rapidpolling', int)
-    size = self._ReadParameter('size', int)
-
-    if rapidpolling > time.time():
-      interval = 10.0
-
-    if (not enable or duration <= 0 or fraction <= 0 or interval <= 0 or
-        size <= 0):
+    if interval <= 0:
       Disable()
     elif self._interval != interval:
       # Enable or change interval.
-      StartPacketBlastTimer(interval)
-    elif now >= self._next_packet_blast_time:
-      # Packet blast.
-      StartPacketBlastTimer(interval)
-      clients = [
-          (manager.vdevname, assoc.mac)
-          for manager in self._managers for assoc in manager.GetState().assoc
-      ]
-      if clients:
-        (interface, client) = random.choice(clients)
-        RunProc(
-            callback=self._HandleResults,
-            args=[WIFIBLASTER_BIN, '-i', interface, '-d', str(duration),
-                  '-f', str(fraction), '-s', str(size),
-                  helpers.DecodeMAC(client)])
+      StartMeasurementTimer(interval)
+    elif now >= self._next_measurement_time:
+      # Measure a random client.
+      StartMeasurementTimer(interval)
+      try:
+        (interface, client) = random.choice(self._GetAllClients())
+      except IndexError:
+        pass
+      else:
+        self.Measure(interface, client)
+
+    measureall = self._ReadParameter('measureall', float)
+    if time.time() >= measureall and measureall > self._last_measureall_time:
+      self._last_measureall_time = measureall
+      for (interface, client) in self._GetAllClients():
+        self.Measure(interface, client)
 
     # Poll again in at most one second. This allows parameter changes (e.g. a
-    # long interval to a short interval) to take effect sooner than the next
-    # scheduled packet blast.
-    self._next_timeout = min(now + 1, self._next_packet_blast_time)
+    # measureall request or a long interval to a short interval) to take effect
+    # sooner than the next scheduled measurement.
+    self._next_timeout = min(now + 1, self._next_measurement_time)
 
 
 def do_ssids_match(managers):
@@ -1050,6 +1097,7 @@
   # Seed the consensus key with random data.
   UpdateConsensus(0, os.urandom(16))
   managers = []
+  wifiblaster_controller = WifiblasterController(managers, opt.status_dir)
   if opt.fake:
     for k, fakemac in flags:
       if k == '--fake':
@@ -1060,17 +1108,17 @@
         wlm = WlanManager(phyname='phy-%s' % fakemac[12:],
                           vdevname='wlan-%s' % fakemac[12:],
                           high_power=opt.high_power,
-                          tv_box=opt.tv_box)
+                          tv_box=opt.tv_box,
+                          wifiblaster_controller=wifiblaster_controller)
         wlm.mac = helpers.EncodeMAC(fakemac)
         managers.append(wlm)
   else:
     # The list of managers is also refreshed occasionally in the main loop
-    CreateManagers(managers, high_power=opt.high_power, tv_box=opt.tv_box)
+    CreateManagers(managers, high_power=opt.high_power, tv_box=opt.tv_box,
+                   wifiblaster_controller=wifiblaster_controller)
   if not managers:
     raise Exception('no wifi AP-mode devices found.  Try --fake.')
 
-  wifiblaster_controller = WifiblasterController(managers, opt.status_dir)
-
   last_sent = last_autochan = last_print = 0
   while 1:
     TouchAliveFile()
@@ -1123,7 +1171,8 @@
     if ((opt.tx_interval and now - last_sent > opt.tx_interval) or (
         opt.autochan_interval and now - last_autochan > opt.autochan_interval)):
       if not opt.fake:
-        CreateManagers(managers, high_power=opt.high_power, tv_box=opt.tv_box)
+        CreateManagers(managers, high_power=opt.high_power, tv_box=opt.tv_box,
+                       wifiblaster_controller=wifiblaster_controller)
       for m in managers:
         m.UpdateStationInfo()
     if opt.tx_interval and now - last_sent > opt.tx_interval:
diff --git a/waveguide/wifiblaster_controller_test.py b/waveguide/wifiblaster_controller_test.py
index 18b6564..6b562b1 100755
--- a/waveguide/wifiblaster_controller_test.py
+++ b/waveguide/wifiblaster_controller_test.py
@@ -125,9 +125,11 @@
       for f in glob.glob(os.path.join(d, '*')):
         os.remove(f)
 
-      manager = waveguide.WlanManager(**flags)
+      managers = []
+      wc = waveguide.WifiblasterController(managers, d)
+      manager = waveguide.WlanManager(wifiblaster_controller=wc, **flags)
+      managers.append(manager)
       manager.UpdateStationInfo()
-      wc = waveguide.WifiblasterController([manager], d)
 
       def WriteConfig(k, v):
         open(os.path.join(d, 'wifiblaster.%s' % k), 'w').write(v)
@@ -136,10 +138,10 @@
       WriteConfig('enable', 'False')
       WriteConfig('fraction', '10')
       WriteConfig('interval', '10')
-      WriteConfig('rapidpolling', '10')
+      WriteConfig('measureall', '0')
       WriteConfig('size', '1470')
 
-      # Disabled. No packet blasts should be run.
+      # Disabled. No measurements should be run.
       print manager.GetState()
       for t in xrange(0, 100):
         wc.Poll(t)
@@ -156,19 +158,19 @@
       CountRuns()  # get rid of any leftovers
       wvtest.WVPASSEQ(CountRuns(), 0)
 
-      # The first packet blast should be one
-      # cycle later than the start time. This is not an implementation detail:
-      # it prevents multiple APs from running simultaneous packet blasts if
-      # packet blasts are enabled at the same time.
+      # The first measurement should be one cycle later than the start time.
+      # This is not an implementation detail: it prevents multiple APs from
+      # running simultaneous measurements if measurements are enabled at the
+      # same time.
       WriteConfig('enable', 'True')
       wc.Poll(100)
-      wvtest.WVPASSGE(wc.NextBlast(), 100)
+      wvtest.WVPASSGE(wc.NextMeasurement(), 100)
       for t in xrange(101, 200):
         wc.Poll(t)
       wvtest.WVPASSGE(CountRuns(), 1)
 
       # Invalid parameter.
-      # Disabled. No packet blasts should be run.
+      # Disabled. No measurements should be run.
       WriteConfig('duration', '-1')
       for t in xrange(200, 300):
         wc.Poll(t)
@@ -181,7 +183,7 @@
         wc.Poll(t)
       wvtest.WVPASSGE(CountRuns(), 1)
 
-      # Run the packet blast at t=400 to restart the timer.
+      # Run the measurement at t=400 to restart the timer.
       wc.Poll(400)
       wvtest.WVPASSGE(CountRuns(), 0)
 
@@ -191,38 +193,30 @@
       # Enabled with longer average interval.  The change in interval should
       # trigger a change in next poll timeout.
       WriteConfig('interval', '0.5')
-      old_to = wc.NextBlast()
+      old_to = wc.NextMeasurement()
       wc.Poll(401)
-      wvtest.WVPASSNE(old_to, wc.NextBlast())
-      for t in xrange(402, 410):
+      wvtest.WVPASSNE(old_to, wc.NextMeasurement())
+      for t in xrange(402, 500):
         wc.Poll(t)
       wvtest.WVPASSGE(CountRuns(), 1)
 
-      # Switch back to a longer poll interval.
-      WriteConfig('interval', '36000')
-      ok = False
-      for t in xrange(410, 600):
-        wc.Poll(t)
-        if wc.NextBlast() > t + 200:
-          ok = True
-      wvtest.WVPASS(ok)
+      # Request all clients to be measured and make sure it only happens once.
+      # Disable automated measurement so they are not counted.
+      WriteConfig('interval', '0')
+      WriteConfig('measureall', str(faketime[0]))
+      wc.Poll(500)
+      wvtest.WVPASSEQ(CountRuns(), 1)
+      wc.Poll(501)
+      wvtest.WVPASSEQ(CountRuns(), 0)
 
-      # And then try rapid polling for a limited time
-      WriteConfig('rapidpolling', '800')
-      ok = False
-      for t in xrange(600, 700):
-        wc.Poll(t)
-        if wc.NextBlast() < t + 20:
-          ok = True
-      wvtest.WVPASS(ok)
-
-      # Make sure rapid polling auto-disables
-      ok = False
-      for t in xrange(700, 900):
-        wc.Poll(t)
-        if wc.NextBlast() > t + 200:
-          ok = True
-      wvtest.WVPASS(ok)
+      # Measure on association only if enabled.
+      wc.MeasureOnAssociation(manager.vdevname,
+                              manager.GetState().assoc[0].mac)
+      wvtest.WVPASSEQ(CountRuns(), 0)
+      WriteConfig('onassociation', 'True')
+      wc.MeasureOnAssociation(manager.vdevname,
+                              manager.GetState().assoc[0].mac)
+      wvtest.WVPASSEQ(CountRuns(), 1)
   finally:
     time.time = oldtime
     shutil.rmtree(d)
diff --git a/wifi/configs.py b/wifi/configs.py
index 7518872..11867b2 100644
--- a/wifi/configs.py
+++ b/wifi/configs.py
@@ -77,6 +77,7 @@
 {require_ht}
 {require_vht}
 {hidden}
+{ap_isolate}
 
 ht_capab={ht20}{ht40}{guard_interval}{ht_rxstbc}
 {vht_settings}
@@ -275,6 +276,7 @@
   enable_wmm = 'wmm_enabled=1' if opt.enable_wmm else ''
   hidden = 'ignore_broadcast_ssid=1' if opt.hidden_mode else ''
   bridge = 'bridge=%s' % opt.bridge if opt.bridge else ''
+  ap_isolate = 'ap_isolate=1' if opt.client_isolation else ''
   hostapd_conf_parts = [_HOSTCONF_TPL.format(
       interface=interface, band=band, channel=channel, width=width,
       protocols=protocols, hostapd_band=hostapd_band,
@@ -282,7 +284,8 @@
       require_ht=require_ht, require_vht=require_vht, ht20=ht20, ht40=ht40,
       ht_rxstbc=ht_rxstbc, vht_settings=vht_settings,
       guard_interval=guard_interval, enable_wmm=enable_wmm, hidden=hidden,
-      auth_algs=auth_algs, bridge=bridge, ssid=utils.sanitize_ssid(opt.ssid))]
+      ap_isolate=ap_isolate, auth_algs=auth_algs, bridge=bridge,
+      ssid=utils.sanitize_ssid(opt.ssid))]
 
   if opt.encryption != 'NONE':
     hostapd_conf_parts.append(_HOSTCONF_WPA_TPL.format(
diff --git a/wifi/configs_test.py b/wifi/configs_test.py
index 3956642..77773ce 100755
--- a/wifi/configs_test.py
+++ b/wifi/configs_test.py
@@ -309,6 +309,7 @@
 
 
 
+
 ht_capab=[HT20][RX-STBC1]
 
 """
@@ -331,6 +332,7 @@
 
 
 
+
 ht_capab=[HT20][RX-STBC1]
 
 """
@@ -363,6 +365,7 @@
     self.yottasecond_timeouts = False
     self.persist = False
     self.interface_suffix = ''
+    self.client_isolation = False
 
 
 # pylint: disable=protected-access
diff --git a/wifi/iw.py b/wifi/iw.py
index b1d49b5..647e56c 100644
--- a/wifi/iw.py
+++ b/wifi/iw.py
@@ -46,11 +46,16 @@
 
 
 def _info(interface, **kwargs):
-  return subprocess.check_output(('iw', interface, 'info'), **kwargs)
+  return subprocess.check_output(('iw', 'dev', interface, 'info'), **kwargs)
 
 
 def _link(interface, **kwargs):
-  return subprocess.check_output(('iw', interface, 'link'), **kwargs)
+  return subprocess.check_output(('iw', 'dev', interface, 'link'), **kwargs)
+
+
+def _scan(interface, scan_args, **kwargs):
+  return subprocess.check_output(['iw', 'dev', interface, 'scan'] + scan_args,
+                                 **kwargs)
 
 
 _WIPHY_RE = re.compile(r'Wiphy (?P<phy>\S+)')
@@ -356,3 +361,8 @@
       result.add(band)
 
   return result
+
+
+def scan(interface, scan_args):
+  """Return 'iw scan' output for printing."""
+  return _scan(interface, scan_args)
diff --git a/wifi/utils.py b/wifi/utils.py
index e665654..c3b237e 100644
--- a/wifi/utils.py
+++ b/wifi/utils.py
@@ -5,8 +5,10 @@
 from __future__ import print_function
 
 import collections
+import math
 import os
 import re
+import signal
 import subprocess
 import sys
 import time
@@ -300,3 +302,60 @@
     raise BinWifiException('PSK is not of a valid length: %d', len(psk))
 
   return psk
+
+
+def _lockfile_create_retries(timeout_sec):
+  """Invert the lockfile-create --retry option.
+
+  The --retry option specifies how many times to retry.  Each retry takes an
+  additional five seconds, starting at 0, so --retry 1 takes 5 seconds,
+  --retry 2 takes 15 (5 + 10), and so on.  So:
+
+    timeout_sec = 5 * (retries * (retries + 1)) / 2 =>
+    2.5retries^2 + 2.5retries + -timeout_sec = 0 =>
+    retries = (-2.5 +/- sqrt(2.5^2 - 4*2.5*-timeout_sec)) / (2*2.5)
+    retries = (-2.5 +/- sqrt(6.25 + 10*timeout_sec)) / 5
+
+  We want to ceil this to make sure we have more than enough time, and we can
+  even also add 1 to timeout_sec in case we'd otherwise get a whole number and
+  don't want it to be close.  We can also reduce the +/- to a + because we
+  don't care about negative solutions.
+
+  (Alternatively, we could remove the signal.alarm and
+  expose /bin/wifi callers to this logic by letting them specify the retry
+  count directly, but that would be even worse than this.)
+
+  Args:
+    timeout_sec: The number of seconds the timeout must exceed.
+
+  Returns:
+    A value for lockfile-create --retry.
+  """
+  return math.ceil((-2.5 + math.sqrt(6.25 + 10.0 * (timeout_sec + 1))) / 5.0)
+
+
+def lock(lockfile, timeout_sec):
+  """Attempt to lock lockfile for up to timeout_sec.
+
+  Args:
+    lockfile:  The file to lock.
+    timeout_sec:  How long to try before giving up.
+
+  Raises:
+    BinWifiException:  If the timeout is exceeded.
+  """
+  def time_out(*_):
+    raise BinWifiException('Failed to obtain lock %s after %d seconds',
+                           lockfile, timeout_sec)
+
+  retries = _lockfile_create_retries(timeout_sec)
+
+  signal.signal(signal.SIGALRM, time_out)
+  signal.alarm(timeout_sec)
+  subprocess.call(['lockfile-create', '--use-pid', '--retry', str(retries),
+                   lockfile])
+  signal.alarm(0)
+
+
+def unlock(lockfile):
+  subprocess.call(['lockfile-remove', lockfile])
diff --git a/wifi/utils_test.py b/wifi/utils_test.py
index be91859..0224605 100755
--- a/wifi/utils_test.py
+++ b/wifi/utils_test.py
@@ -3,7 +3,11 @@
 """Tests for utils.py."""
 
 import collections
+import multiprocessing
 import os
+import shutil
+import sys
+import tempfile
 
 import utils
 from wvtest import wvtest
@@ -123,5 +127,84 @@
   wvtest.WVPASSEQ('g' * 63, utils.validate_and_sanitize_psk('g' * 63))
 
 
+@wvtest.wvtest
+def lock_test():
+  """Test utils.lock and utils._lockfile_create_retries."""
+  wvtest.WVPASSEQ(utils._lockfile_create_retries(0), 1)
+  wvtest.WVPASSEQ(utils._lockfile_create_retries(4), 1)
+  wvtest.WVPASSEQ(utils._lockfile_create_retries(5), 2)
+  wvtest.WVPASSEQ(utils._lockfile_create_retries(14), 2)
+  wvtest.WVPASSEQ(utils._lockfile_create_retries(15), 3)
+  wvtest.WVPASSEQ(utils._lockfile_create_retries(60), 5)
+
+  try:
+    temp_dir = tempfile.mkdtemp()
+    lockfile = os.path.join(temp_dir, 'lock_and_unlock_test')
+
+    def lock_until_qi_nonempty(qi, qo, timeout_sec):
+      try:
+        utils.lock(lockfile, timeout_sec)
+      except utils.BinWifiException:
+        qo.put('timed out')
+        return
+      qo.put('acquired')
+      wvtest.WVPASSEQ(qi.get(), 'release')
+      qo.put('released')
+      sys.exit(0)
+
+    # Use multiprocessing because we're using lockfile-create with --use-pid, so
+    # we need separate PIDs.
+    q1i = multiprocessing.Queue()
+    q1o = multiprocessing.Queue()
+    # The timeout here is 5 because occasionally it takes more than one second
+    # to acquire the lock, causing the test to hang.  Five seconds is enough to
+    # prevent this.
+    p1 = multiprocessing.Process(target=lock_until_qi_nonempty,
+                                 args=(q1i, q1o, 1))
+
+    q2i = multiprocessing.Queue()
+    q2o = multiprocessing.Queue()
+    p2 = multiprocessing.Process(target=lock_until_qi_nonempty,
+                                 args=(q2i, q2o, 10))
+
+    p1.start()
+    wvtest.WVPASSEQ(q1o.get(), 'acquired')
+
+    p2.start()
+    wvtest.WVPASS(q2o.empty())
+
+    q1i.put('release')
+    wvtest.WVPASSEQ(q1o.get(), 'released')
+    p1.join()
+    wvtest.WVPASSEQ(q2o.get(), 'acquired')
+
+    q2i.put('release')
+    wvtest.WVPASSEQ(q2o.get(), 'released')
+    p2.join()
+
+    # Now test that the timeout works.
+    q3i = multiprocessing.Queue()
+    q3o = multiprocessing.Queue()
+    p3 = multiprocessing.Process(target=lock_until_qi_nonempty,
+                                 args=(q3i, q3o, 1))
+
+    q4i = multiprocessing.Queue()
+    q4o = multiprocessing.Queue()
+    p4 = multiprocessing.Process(target=lock_until_qi_nonempty,
+                                 args=(q4i, q4o, 1))
+
+    p3.start()
+    wvtest.WVPASSEQ(q3o.get(), 'acquired')
+    p4.start()
+    wvtest.WVPASSEQ(q4o.get(), 'timed out')
+    p4.join()
+
+    q3i.put('release')
+    p3.join()
+
+  finally:
+    shutil.rmtree(temp_dir)
+
+
 if __name__ == '__main__':
   wvtest.wvtest_main()
diff --git a/wifi/wifi.py b/wifi/wifi.py
index f57e09e..9ba8b31 100755
--- a/wifi/wifi.py
+++ b/wifi/wifi.py
@@ -4,6 +4,7 @@
 
 from __future__ import print_function
 
+import atexit
 import glob
 import os
 import re
@@ -30,14 +31,16 @@
 {bin} stopclient    Disable wifi clients.  Takes -b, -P, -S.
 {bin} restore       Restore saved client and access point options.  Takes -b, -S.
 {bin} show          Print all known parameters.  Takes -b, -S.
+{bin} scan          Print 'iw scan' results for a single band.  Takes -b, -S.
 --
-b,band=                           Wifi band(s) to use (5 GHz and/or 2.4 GHz).  set commands have a default of 2.4 and cannot take multiple-band values.  [2.4 5]
+b,band=                           Wifi band(s) to use (5 GHz and/or 2.4 GHz).  set, setclient, and scan have a default of 2.4 and cannot take multiple-band values.  [2.4 5]
 c,channel=                        Channel to use [auto]
 a,autotype=                       Autochannel method to use (LOW, HIGH, DFS, NONDFS, ANY,OVERLAP) [NONDFS]
 s,ssid=                           SSID to use [{ssid}]
 bssid=                            BSSID to use []
 e,encryption=                     Encryption type to use (WPA_PSK_AES, WPA2_PSK_AES, WPA12_PSK_AES, WPA_PSK_TKIP, WPA2_PSK_TKIP, WPA12_PSK_TKIP, WEP, or NONE) [WPA2_PSK_AES]
 f,force-restart                   Force restart even if already running with these options
+C,client-isolation                Enable client isolation, preventing bridging of frames between associated stations.
 H,hidden-mode                     Enable hidden mode (disable SSID advertisements)
 M,enable-wmm                      Enable wmm extensions (needed for block acks)
 G,short-guard-interval            Enable short guard interval
@@ -48,9 +51,15 @@
 Y,yottasecond-timeouts            Don't rotate any keys: PTK, GTK, or GMK
 P,persist                         For set commands, persist options so we can restore them with 'wifi restore'.  For stop commands, remove persisted options.
 S,interface-suffix=               Interface suffix []
+lock-timeout=                     How long, in seconds, to wait for another /bin/wifi process to finish before giving up. [60]
+scan-ap-force                     (Scan only) scan when in AP mode
+scan-passive                      (Scan only) do not probe, scan passively
+scan-freq=                        (Scan only) limit scan to specific frequencies.
 """
 
 _FINGERPRINTS_DIRECTORY = '/tmp/wifi/fingerprints'
+_LOCKFILE = '/tmp/wifi/wifi'
+lockfile_taken = False
 
 
 # pylint: disable=protected-access
@@ -491,6 +500,38 @@
   return True
 
 
+@iw.requires_iw
+def scan_wifi(opt):
+  """Prints 'iw scan' results.
+
+  Args:
+    opt: The OptDict parsed from command line options.
+
+  Returns:
+    True.
+
+  Raises:
+    BinWifiException: If an expected interface is not found.
+  """
+  band = opt.band.split()[0]
+  interface = iw.find_interface_from_band(
+      band, iw.INTERFACE_TYPE.ap, opt.interface_suffix)
+  if interface is None:
+    raise utils.BinWifiException('No client interface for band %s', band)
+
+  scan_args = []
+  if opt.scan_ap_force:
+    scan_args += ['ap-force']
+  if opt.scan_passive:
+    scan_args += ['passive']
+  if opt.scan_freq:
+    scan_args += ['freq', opt.scan_freq]
+
+  print(iw.scan(interface, scan_args))
+
+  return True
+
+
 def _is_hostapd_running(interface):
   return utils.subprocess_quiet(
       ('hostapd_cli', '-i', interface, 'status'), no_stdout=True) == 0
@@ -949,6 +990,8 @@
   Raises:
     BinWifiException: On file write failures.
   """
+  global lockfile_taken
+
   optspec = _OPTSPEC_FORMAT.format(
       bin=__file__.split('/')[-1],
       ssid='%s_TestWifi' % subprocess.check_output(('serial')).strip())
@@ -974,9 +1017,16 @@
         'setclient': set_client_wifi,
         'stopclient': stop_client_wifi,
         'stopap': stop_ap_wifi,
+        'scan': scan_wifi,
     }[extra[0]]
   except KeyError:
     parser.fatal('Unrecognized command %s' % extra[0])
+
+  if not lockfile_taken:
+    utils.lock(_LOCKFILE, int(opt.lock_timeout))
+    atexit.register(utils.unlock, _LOCKFILE)
+    lockfile_taken = True
+
   success = function(opt)
 
   if success: