Merge "log_uploader: make SIGUSR1 wake us from sleep."
diff --git a/cmds/Makefile b/cmds/Makefile
index 134ca85..069e6ff 100644
--- a/cmds/Makefile
+++ b/cmds/Makefile
@@ -24,6 +24,7 @@
 	buttonmon \
 	chg_mod_own \
 	cpulog \
+	dhcpnametax \
 	dhcpvendortax \
 	dhcp-rogue \
 	dir-monitor \
@@ -55,9 +56,10 @@
 	is-secure-boot
 ARCH_TARGETS=\
 
-ifeq ($(BUILD_ASUS),y)
+ifeq ($(BUILD_LIBNL_UTILS),y)
+ARCH_TARGETS += wifi_files dnssdmon
 TARGETS += asustax
-HOST_TEST_TARGETS += host-asustax_test
+HOST_TEST_TARGETS += host-wifi_files_test host-asustax_test
 endif
 
 ifeq ($(BUILD_SSDP),y)
@@ -78,11 +80,6 @@
 TARGETS += statpitcher statcatcher
 endif
 
-ifeq ($(BUILD_WIFIUTILS),y)
-ARCH_TARGETS += wifi_files
-HOST_TEST_TARGETS += host-wifi_files_test
-endif
-
 AS=$(CROSS_COMPILE)as
 CC=$(CROSS_COMPILE)gcc
 CXX=$(CROSS_COMPILE)g++
@@ -235,6 +232,21 @@
 dhcpvendorlookup.o: CFLAGS += -Wno-missing-field-initializers
 host-dhcpvendorlookup.o: CFLAGS += -Wno-missing-field-initializers
 host-dhcpvendortax: host-dhcpvendortax.o host-dhcpvendorlookup.o
+dnssdmon: dnssdmon.o l2utils.o modellookup.o
+dnssdmon: LIBS += -lnl-3 -lstdc++ -lm -lresolv
+modellookup.c: modellookup.gperf
+	$(GPERF) -G -C -t -T -L ANSI-C -n -N model_lookup -K model --delimiters="|" \
+		--includes --output-file=modellookup.c modellookup.gperf
+modellookup.o: CFLAGS += -Wno-missing-field-initializers
+host-modellookup.o: CFLAGS += -Wno-missing-field-initializers
+dhcpnametax: dhcpnametax.o hostnamelookup.o
+host-dhcpnametax: host-dhcpnametax.o host-hostnamelookup.o
+hostnamelookup.c: hostnamelookup.gperf
+	$(GPERF) -G -C -t -T -L ANSI-C -n -c -N hostname_lookup -K name --delimiters="|" \
+		--includes --output-file=hostnamelookup.c hostnamelookup.gperf
+hostnamelookup.o: CFLAGS += -Wno-missing-field-initializers
+host-hostnamelookup.o: CFLAGS += -Wno-missing-field-initializers
+
 
 TESTS = $(wildcard test-*.sh) $(wildcard test-*.py) $(wildcard *_test.py) $(TEST_TARGETS)
 ifeq ($(RUN_HOST_TESTS),y)
diff --git a/cmds/asustax.cc b/cmds/asustax.cc
index d4c665f..a472f05 100644
--- a/cmds/asustax.cc
+++ b/cmds/asustax.cc
@@ -118,19 +118,47 @@
   }
 }
 
-static const char *replace_newlines(const uint8_t *src, int srclen,
-                                    char *dst, int dstlen)
+
+static void strncpy_limited(char *dst, size_t dstsiz,
+    const char *src, size_t srclen)
 {
-  int i, j;
+  size_t i;
+  size_t lim = (srclen >= (dstsiz - 1)) ? (dstsiz - 2) : srclen;
 
-  for (i = 0, j = 0; i < srclen && j < (dstlen - 1); i++) {
-    dst[j++] = (src[i] == '\n') ? '.' : src[i];
+  for (i = 0; i < lim; ++i) {
+    unsigned char s = src[i];
+    if (s == ' ' || s == '\t') {
+      dst[i] = ' ';
+    } else if (isspace(s) || s == ';') {
+      dst[i] = '.';  // deliberately convert newline to dot
+    } else if (isprint(s)) {
+      dst[i] = s;
+    } else {
+      dst[i] = '_';
+    }
   }
-  dst[j] = '\0';
-
-  return dst;
+  dst[lim] = '\0';
 }
 
+
+static void extract_modelname(const char *src, int srclen,
+    char *genus, int genuslen, char *species, int specieslen)
+{
+  /* ASUS devices often (though not always) send just their
+   * model number like "RT-AC68U". In the string to be displayed
+   * to the user we want it to at least include "ASUS", so prepend
+   * it if necessary. */
+  if (strcasestr(src, "asus") == NULL && genuslen > 5) {
+    snprintf(genus, genuslen, "ASUS ");
+    genus += 5;
+    genuslen -= 5;
+  }
+
+  strncpy_limited(genus, genuslen, src, srclen);
+  strncpy_limited(species, specieslen, src, srclen);
+}
+
+
 int receive_response(int s, L2Map *l2map, char *response, int responselen)
 {
   struct timeval tv;
@@ -154,7 +182,8 @@
   }
   if (FD_ISSET(s, &rfds)) {
     uint8_t buf[PACKET_LENGTH + 64];
-    char addrbuf[INET_ADDRSTRLEN], namebuf[80];
+    char addrbuf[INET_ADDRSTRLEN];
+    char genus[80], species[80];
     const char *mac;
     struct sockaddr_in from;
     socklen_t fromlen = sizeof(from);
@@ -182,14 +211,15 @@
 
     id_len = strnlen((char *)discovery->product_id,
                      sizeof(discovery->product_id));
-    replace_newlines(discovery->product_id, id_len, namebuf, sizeof(namebuf));
+    extract_modelname((const char *)discovery->product_id, id_len,
+        genus, sizeof(genus), species, sizeof(species));
     L2Map::iterator ii = l2map->find(std::string(addrbuf));
     if (ii != l2map->end()) {
       mac = ii->second.c_str();
     } else {
       mac = "00:00:00:00:00:00";
     }
-    snprintf(response, responselen, "asus %s %s", mac, namebuf);
+    snprintf(response, responselen, "asus %s %s;%s", mac, genus, species);
 
     return 0;
   } else {
diff --git a/cmds/dhcpnametax.c b/cmds/dhcpnametax.c
new file mode 100644
index 0000000..f4af92a
--- /dev/null
+++ b/cmds/dhcpnametax.c
@@ -0,0 +1,129 @@
+/*
+ * 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 <getopt.h>
+#include <inttypes.h>
+#include <regex.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <unistd.h>
+
+#include "hostnamelookup.h"
+
+void usage(const char *progname)
+{
+  fprintf(stderr, "usage: %s -d dhcpsig -h hostname -l label\n", progname);
+  fprintf(stderr, "\t-d: DHCP options signature\n");
+  fprintf(stderr, "\t-h: hostname of the station\n");
+  fprintf(stderr, "\t-l: label for this station (typically the MAC addr)\n");
+  exit(1);
+}
+
+
+struct hostname_strings *check_directv(const char *hostname)
+{
+  regex_t r_directv;
+  regmatch_t match[2];
+
+  if (regcomp(&r_directv, "DIRECTV-([^-]+)-", REG_EXTENDED | REG_ICASE)) {
+    fprintf(stderr, "%s: regcomp failed!\n", __FUNCTION__);
+    exit(1);
+  }
+
+  if (regexec(&r_directv, hostname, 2, match, 0) == 0) {
+    struct hostname_strings *h = (struct hostname_strings *)malloc(sizeof *h);
+    int len = match[1].rm_eo - match[1].rm_so;
+
+    h->genus = "DirecTV";
+    h->species = strndup(hostname + match[1].rm_so, len);
+    return h;
+  }
+
+  return NULL;
+}
+
+
+int main(int argc, char **argv)
+{
+  struct option long_options[] = {
+    {"dhcpsig",  required_argument, 0, 'd'},
+    {"hostname", required_argument, 0, 'h'},
+    {"label",    required_argument, 0, 'l'},
+    {0,          0,                 0, 0},
+  };
+  int c;
+  const char *dhcpsig = NULL;
+  const char *hostname = NULL;
+  const char *label = NULL;
+  char concatenated[256];
+  const struct hostname_strings *sn = NULL;
+
+  setlinebuf(stdout);
+  alarm(30);
+  while ((c = getopt_long(argc, argv, "d:h:l:", long_options, NULL)) != -1) {
+    switch (c) {
+    case 'd':
+      dhcpsig = optarg;
+      break;
+    case 'l':
+      label = optarg;
+      break;
+    case 'h':
+      hostname = optarg;
+      break;
+    default:
+      usage(argv[0]);
+      break;
+    }
+  }
+
+  if (optind < argc ||
+      dhcpsig == NULL || hostname == NULL || label == NULL) {
+    usage(argv[0]);
+  }
+
+  snprintf(concatenated, sizeof(concatenated), "%s%%%s", hostname, dhcpsig);
+  if ((sn = hostname_lookup(concatenated, strlen(concatenated))) == NULL) {
+    if (strcmp(dhcpsig, "1,3,6,12,15,28,40,41,42") == 0) {
+      // DIRECTV-HR24-XXXXXXXX
+      sn = check_directv(hostname);
+    } else if (strcmp(dhcpsig, "1,3,6,12,15,28,42") == 0) {
+      // DIRECTV-HR24-XXXXXXXX
+      if ((sn = check_directv(hostname)) == NULL) {
+        // Trane thermostat XL824-XXXXXXXX
+        sn = hostname_lookup(hostname, 6);
+      }
+    } else if (strcmp(dhcpsig, "1,28,2,3,15,6,12") == 0) {
+      // TIVO-###
+      sn = hostname_lookup(hostname, 8);
+    } else if (strcmp(dhcpsig, "1,3,6,15,12") == 0) {
+      // Roku NP-##
+      sn = hostname_lookup(hostname, 5);
+    } else if (strcmp(dhcpsig, "3,1,252,42,15,6,12") == 0) {
+      // Nest 0#A
+      sn = hostname_lookup(hostname, 3);
+    } else if (strcmp(dhcpsig, "1,28,2,3,15,6,119,12,44,47,26,121,42") == 0) {
+      // SleepIQ
+      sn = hostname_lookup(hostname, 11);
+    }
+  }
+
+  if (sn != NULL) {
+    printf("name %s %s;%s\n", label, sn->genus, sn->species);
+  }
+  exit(0);
+}
diff --git a/cmds/dnssdmon.cc b/cmds/dnssdmon.cc
new file mode 100644
index 0000000..f598d9a
--- /dev/null
+++ b/cmds/dnssdmon.cc
@@ -0,0 +1,427 @@
+/*
+ * 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.
+ */
+
+/*
+ * dnssdmon
+ * Listen for DNS-SD packets containing TXT fields which help to identify
+ * the device. For example, some iOS devices send a model string:
+ *    My iPad._device-info._tcp.local: type TXT, class IN, cache flush
+ *          Name: My iPad._device-info._tcp.local
+ *          Type: TXT (Text strings) (16)
+ *          .000 0000 0000 0001 = Class: IN (0x0001)
+ *          1... .... .... .... = Cache flush: True
+ *          Time to live: 4500
+ *          Data length: 12
+ *          TXT Length: 11
+ *          TXT: model=J81AP
+ */
+
+#include <arpa/inet.h>
+#include <arpa/nameser.h>
+#include <assert.h>
+#include <ctype.h>
+#include <net/if.h>
+#include <resolv.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <sys/ioctl.h>
+#include <sys/select.h>
+#include <time.h>
+#include <unistd.h>
+
+#include <iostream>
+#include <string>
+#include <tr1/unordered_map>
+
+#include "l2utils.h"
+#include "modellookup.h"
+
+
+#define MDNS_PORT 5353
+#define MDNS_IPV4 "224.0.0.251"
+#define MDNS_IPV6 "ff02::fb"
+#define MIN(a,b) (((a)<(b))?(a):(b))
+
+
+typedef std::tr1::unordered_map<std::string, std::string> HostsMapType;
+HostsMapType hosts;
+
+
+/* Return monotonically increasing time in seconds. */
+static time_t monotime(void) {
+  struct timespec ts;
+  clock_gettime(CLOCK_MONOTONIC, &ts);
+  return ts.tv_sec;
+}
+
+
+static void strncpy_limited(char *dst, size_t dstlen,
+    const char *src, size_t srclen)
+{
+  size_t i;
+  size_t lim = (srclen >= (dstlen - 1)) ? (dstlen - 2) : srclen;
+
+  for (i = 0; i < lim; ++i) {
+    unsigned char s = src[i];
+    if (isspace(s) || s == ';') {
+      dst[i] = ' ';  // deliberately convert newline to space
+    } else if (isprint(s)) {
+      dst[i] = s;
+    } else {
+      dst[i] = '_';
+    }
+  }
+  dst[lim] = '\0';
+}
+
+
+void add_hostmap_entry(std::string macaddr, std::string model)
+{
+  HostsMapType::iterator found = hosts.find(macaddr);
+  if (found != hosts.end()) {
+    return;
+  }
+
+  hosts[macaddr] = model;
+}
+
+
+int get_ifindex(const char *ifname)
+{
+  int fd;
+  struct ifreq ifr;
+  size_t nlen = strlen(ifname);
+
+  if ((fd = socket(AF_PACKET, SOCK_DGRAM, 0)) < 0) {
+    perror("ERR: socket");
+    exit(1);
+  }
+
+  if (nlen >= sizeof(ifr.ifr_name)) {
+    fprintf(stderr, "ERR: interface name %s is too long\n", ifname);
+    exit(1);
+  }
+
+  memset(&ifr, 0, sizeof(ifr));
+  strncpy(ifr.ifr_name, ifname, nlen);
+  ifr.ifr_name[nlen] = '\0';
+
+  if (ioctl(fd, SIOCGIFINDEX, &ifr) < 0) {
+    perror("ERR: SIOCGIFINDEX");
+    exit(1);
+  }
+
+  close(fd);
+  return ifr.ifr_ifindex;
+}  /* get_ifindex */
+
+
+void init_mdns_socket_common(int s, const char *ifname)
+{
+  struct ifreq ifr;
+  unsigned int enable = 1;
+
+  memset(&ifr, 0, sizeof(ifr));
+  snprintf(ifr.ifr_name, sizeof(ifr.ifr_name), "%s", ifname);
+  if (setsockopt(s, SOL_SOCKET, SO_BINDTODEVICE, &ifr, sizeof(ifr))) {
+    perror("ERR: SO_BINDTODEVICE");
+    exit(1);
+  }
+
+  if (setsockopt(s, SOL_SOCKET, SO_REUSEADDR, &enable, sizeof(enable)) < 0) {
+    perror("ERR: SO_REUSEADDR");
+    exit(1);
+  }
+}
+
+
+int init_mdns_socket_ipv4(const char *ifname)
+{
+  int s;
+  struct sockaddr_in sin;
+  struct ip_mreq mreq;
+
+  if ((s = socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP)) < 0) {
+    perror("ERR: socket");
+    exit(1);
+  }
+  init_mdns_socket_common(s, ifname);
+
+  memset(&sin, 0, sizeof(sin));
+  sin.sin_family = AF_INET;
+  sin.sin_port = htons(MDNS_PORT);
+  sin.sin_addr.s_addr = htonl(INADDR_ANY);
+  if (bind(s, (struct sockaddr *)&sin, sizeof(sin))) {
+    perror("ERR: bind");
+    exit(1);
+  }
+
+  memset(&mreq, 0, sizeof(mreq));
+  mreq.imr_interface.s_addr = htonl(INADDR_ANY);
+  if (inet_pton(AF_INET, MDNS_IPV4, &mreq.imr_multiaddr) != 1) {
+    fprintf(stderr, "ERR: inet_pton(%s)", MDNS_IPV4);
+    exit(1);
+  }
+  if (setsockopt(s, IPPROTO_IP, IP_ADD_MEMBERSHIP, &mreq, sizeof(mreq)) < 0) {
+    perror("ERR: setsockopt(IP_ADD_MEMBERSHIP)");
+    exit(1);
+  }
+
+  return s;
+}
+
+
+int init_mdns_socket_ipv6(const char *ifname, int ifindex)
+{
+  int s;
+  struct sockaddr_in6 sin6;
+  struct ipv6_mreq mreq;
+  int off = 0;
+
+  if ((s = socket(AF_INET6, SOCK_DGRAM, IPPROTO_UDP)) < 0) {
+    perror("ERR: socket(AF_INET6)");
+    exit(1);
+  }
+  init_mdns_socket_common(s, ifname);
+
+  if (setsockopt(s, IPPROTO_IPV6, IPV6_V6ONLY, (void *)&off, sizeof(off))) {
+    perror("ERR: setsockopt(IPV6_V6ONLY)");
+    exit(1);
+  }
+
+  memset(&sin6, 0, sizeof(sin6));
+  sin6.sin6_family = AF_INET6;
+  sin6.sin6_port = htons(MDNS_PORT);
+  sin6.sin6_addr = in6addr_any;
+  if (bind(s, (struct sockaddr *)&sin6, sizeof(sin6))) {
+    perror("ERR: bind");
+    exit(1);
+  }
+
+  memset(&mreq, 0, sizeof(mreq));
+  mreq.ipv6mr_interface = ifindex;
+  if (inet_pton(AF_INET6, MDNS_IPV6, &mreq.ipv6mr_multiaddr) != 1) {
+    fprintf(stderr, "ERR: inet_pton(%s) failed", MDNS_IPV6);
+    exit(1);
+  }
+  if (setsockopt(s, IPPROTO_IPV6, IPV6_JOIN_GROUP, &mreq, sizeof(mreq)) < 0) {
+    perror("ERR: setsockopt(IPV6_JOIN_GROUP)");
+    exit(1);
+  }
+
+  return s;
+}
+
+
+/*
+ * Search a DNS TXT record for fields which look like a model description.
+ * MacOS and iOS devices send "model=...", various peripherals send
+ * "ty=..."  (presumably for 'type').
+ */
+int parse_txt_for_model(const unsigned char *rdata, unsigned int rdlen,
+    char *model, unsigned int modellen)
+{
+  const unsigned char *p = rdata;
+
+  while (rdlen > 0) {
+    unsigned int txtlen = p[0];
+    /*
+     * TXT record format is:
+     * Length1 (1 byte)
+     * String1 (variable length)
+     * Length2 (1 byte)
+     * String2 (variable length)
+     * etc.
+     */
+    p++;
+    rdlen--;
+    if (txtlen > rdlen) {
+      fprintf(stderr, "ERR: Malformed TXT record\n");
+      return -1;
+    }
+
+    if (txtlen > 6 && strncmp((const char *)p, "model=", 6) == 0) {
+      strncpy_limited(model, modellen, (const char *)(p + 6), txtlen - 6);
+      return 0;
+    }
+    if (txtlen > 3 && strncmp((const char *)p, "ty=", 3) == 0) {
+      strncpy_limited(model, modellen, (const char *)(p + 3), txtlen - 3);
+      return 0;
+    }
+    rdlen -= txtlen;
+    p += txtlen;
+  }
+
+  return 1;
+}
+
+void process_mdns(int s)
+{
+  ssize_t len;
+  ns_msg msg;
+  unsigned int i, n, rr_count;
+  ns_sect sections[] = {ns_s_an, ns_s_ar};  // Answers and Additional Records
+  struct sockaddr_storage from;
+  socklen_t fromlen = sizeof(from);
+  char ipstr[INET6_ADDRSTRLEN];
+  uint8_t buf[4096];
+
+  if ((len = recvfrom(s, buf, sizeof(buf), 0,
+      (struct sockaddr *)&from, &fromlen)) < 0) {
+    return;
+  }
+
+  if (from.ss_family == AF_INET) {
+    inet_ntop(AF_INET, &(((struct sockaddr_in *)&from)->sin_addr),
+        ipstr, sizeof(ipstr));
+  } else if (from.ss_family == AF_INET6) {
+    inet_ntop(AF_INET6, &(((struct sockaddr_in6 *)&from)->sin6_addr),
+        ipstr, sizeof(ipstr));
+  }
+
+  if (ns_initparse(buf, len, &msg) < 0) {
+    fprintf(stderr, "ERR: ns_initparse\n");
+    return;
+  }
+
+  for (i = 0; i < (sizeof(sections) / sizeof(sections[0])); ++i) {
+    ns_sect sect = sections[i];
+    rr_count = ns_msg_count(msg, sect);
+    for (n = 0; n < rr_count; ++n) {
+      ns_rr rr;
+      if (ns_parserr(&msg, sect, n, &rr)) {
+        fprintf(stderr, "ERR: unable to parse RR type=%s n=%d\n",
+            (sect == ns_s_an) ? "ns_s_an" : "ns_s_ar", n);
+        continue;
+      }
+
+      if (ns_rr_type(rr) == ns_t_txt) {
+        const unsigned char *rdata = ns_rr_rdata(rr);
+        unsigned int rdlen = ns_rr_rdlen(rr);
+        char model[64];
+        if (parse_txt_for_model(rdata, rdlen, model, sizeof(model)) == 0) {
+          std::string mac = get_l2addr_for_ip(std::string(ipstr));
+          add_hostmap_entry(mac, model);
+        }
+      }
+    }
+  }
+}
+
+
+void listen_for_mdns(const char *ifname, int ifindex, time_t seconds)
+{
+  int s4, s6;
+  time_t start, now;
+  struct timeval tv;
+  fd_set readfds;
+  int maxfd;
+
+  now = start = monotime();
+  s4 = init_mdns_socket_ipv4(ifname);
+  s6 = init_mdns_socket_ipv6(ifname, ifindex);
+  maxfd = ((s4 > s6) ? s4 : s6) + 1;
+
+  do {
+    FD_ZERO(&readfds);
+    FD_SET(s4, &readfds);
+    FD_SET(s6, &readfds);
+    memset(&tv, 0, sizeof(tv));
+
+    tv.tv_sec = (seconds - (now - start)) + 1;
+    if (select(maxfd, &readfds, NULL, NULL, &tv) < 0) {
+      perror("ERR: select");
+      return;
+    }
+
+    if (FD_ISSET(s4, &readfds)) {
+      process_mdns(s4);
+    }
+    if (FD_ISSET(s6, &readfds)) {
+      process_mdns(s6);
+    }
+
+    now = monotime();
+  } while ((now - start) < seconds);
+}
+
+
+void usage(char *progname)
+{
+  fprintf(stderr, "usage: %s [-i ifname] [-t seconds]\n", progname);
+  fprintf(stderr, "\t-i ifname - interface to use (default: br0)\n");
+  fprintf(stderr, "\t-t seconds - number of seconds to run before exiting.\n");
+  exit(1);
+}
+
+
+int main(int argc, char *argv[])
+{
+  int opt;
+  const char *ifname = "br0";
+  time_t seconds = 30 * 60;
+  int ifindex;
+  L2Map l2map;
+
+  setlinebuf(stdout);
+
+  while ((opt = getopt(argc, argv, "i:t:")) != -1) {
+    switch (opt) {
+      case 'i':
+        ifname = optarg;
+        break;
+      case 't':
+        seconds = atoi(optarg);
+        if (seconds < 0) usage(argv[0]);
+        break;
+      default:
+        usage(argv[0]);
+        break;
+    }
+  }
+
+  alarm(seconds * 2);
+
+  if ((ifindex = get_ifindex(ifname)) < 0) {
+    fprintf(stderr, "ERR: get_ifindex(%s).\n", ifname);
+    exit(1);
+  }
+
+  /* block for an extended period, listening for DNS-SD */
+  listen_for_mdns(ifname, ifindex, seconds);
+
+  for (HostsMapType::const_iterator ii = hosts.begin();
+      ii != hosts.end(); ++ii) {
+    std::string macaddr = ii->first;
+    std::string model = ii->second;
+
+    if (model.size() > 0) {
+      const struct model_strings *l;
+      std::string genus, species;
+
+      genus = species = model;
+      if ((l = model_lookup(model.c_str(), model.length())) != NULL) {
+        genus = l->genus;
+        species = l->species;
+      }
+      std::cout << "dnssd " << macaddr << " "
+        << genus << ";" << species << std::endl;
+    }
+  }
+
+  exit(0);
+}
diff --git a/cmds/host-test-ssdptax.sh b/cmds/host-test-ssdptax.sh
index d6796dd..3b12fdf 100755
--- a/cmds/host-test-ssdptax.sh
+++ b/cmds/host-test-ssdptax.sh
@@ -6,5 +6,11 @@
 
 SSDP=./host-ssdptax
 
+FIFO="/tmp/ssdptax.test.$$"
+python ./ssdptax-test-server.py "$FIFO" &
+sleep 0.5
+
 WVSTART "ssdptax test"
-WVPASSEQ "$($SSDP -t)" "ssdp 00:01:02:03:04:05 Test Device"
+WVPASSEQ "$($SSDP -t $FIFO)" "ssdp 00:00:00:00:00:00 Test Device;Google Fiber ssdptax"
+
+rm "$FIFO"
diff --git a/cmds/hostnamelookup.gperf b/cmds/hostnamelookup.gperf
new file mode 100644
index 0000000..bfae6fe
--- /dev/null
+++ b/cmds/hostnamelookup.gperf
@@ -0,0 +1,153 @@
+%{
+/*
+ * 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 "hostnamelookup.h"
+
+%}
+struct hostname_strings {
+  char *name;
+  char *genus;
+  char *species;
+};
+%%
+01A| "Nest Thermostat", "Nest Thermostat v1"
+02A| "Nest Thermostat", "Nest Thermostat v2"
+09A| "Nest Thermostat", "Nest Thermostat v3"
+500-cc04b40| "Select Comfort SleepIQ", "SleepIQ"
+EX2700%1,121,249,3,6,12,15,28,33,43| "NetGEAR Wifi Range Extender", "EX2700"
+EX3700%1,121,249,3,6,12,15,28,33,43| "NetGEAR Wifi Range Extender", "EX3700"
+EX3800%1,121,249,3,6,12,15,28,33,43| "NetGEAR Wifi Range Extender", "EX3800"
+EX3920%1,121,249,3,6,12,15,28,33,43| "NetGEAR Wifi Range Extender", "EX3920"
+EX6100%1,121,249,3,6,12,15,28,33,43| "NetGEAR Wifi Range Extender", "EX6100"
+EX6120%1,121,249,3,6,12,15,28,33,43| "NetGEAR Wifi Range Extender", "EX6120"
+EX6150%1,121,249,3,6,12,15,28,33,43| "NetGEAR Wifi Range Extender", "EX6150"
+EX6200%1,121,249,3,6,12,15,28,33,43| "NetGEAR Wifi Range Extender", "EX6200"
+EX6920%1,121,249,3,6,12,15,28,33,43| "NetGEAR Wifi Range Extender", "EX6920"
+EX7000%1,121,249,3,6,12,15,28,33,43| "NetGEAR Wifi Range Extender", "EX7000"
+EX7300%1,121,249,3,6,12,15,28,33,43| "NetGEAR Wifi Range Extender", "EX7300"
+GFPB100%1,3,6,12,15,28,42,66,125| "Google Fiber Phone Box", "GFPB100"
+HarmonyHub%1,3,6,12,15,28,42| "Logitech Harmony Hub", "Harmony Hub"
+Hopper_br%1,3,6,12,15,28,42| "DISH Networks Hopper", "Hopper"
+Hopper_br0%1,3,6,12,15,28,42| "DISH Networks Hopper", "Hopper"
+Hopper_ETH0%1,3,6,12,15,28,42| "DISH Networks Hopper", "Hopper"
+Hopper_ETH1%1,3,6,12,15,28,42| "DISH Networks Hopper", "Hopper"
+Hopper_MoCA%1,3,6,12,15,28,42| "DISH Networks Hopper", "Hopper"
+Hopper_WiFi%1,3,6,12,15,28,42| "DISH Networks Hopper", "Hopper"
+HT701%1,3,6,12,15,28,43,125| "Grandstream Voice Adaptor", "HandyTone 701"
+HT802%1,3,6,12,15,28,43,125| "Grandstream Voice Adaptor", "HandyTone 802"
+Joey_AP%1,3,6,12,15,28,42| "DISH Networks Joey", "Joey"
+Joey_ETH0%1,3,6,12,15,28,42| "DISH Networks Joey", "Joey"
+Joey_MoCA%1,3,6,12,15,28,42| "DISH Networks Joey", "Joey"
+Joey_WiFi%1,3,6,12,15,28,42| "DISH Networks Joey", "Joey"
+LGSmartTV%252,3,42,15,6,1,12| "LG Smart TV", "LG Smart TV"
+LGwebOSTV%252,3,42,15,6,1,12| "LG Smart TV", "LG Smart TV"
+MyBookLive%1,28,2,3,15,6,119,12,44,47,26,121| "WD My Book Live", "My Book Live"
+NP-20| "Roku", "Netflix Player"
+NP-C0| "Roku", "Roku HD 1100"
+NP-D0| "Roku", "Roku HD-XR 1101"
+NP-G0| "Roku", "Roku SD 1050"
+NP-H0| "Roku", "Roku 1 HD 2000"
+NP-J0| "Roku", "Roku 1 XD 2050"
+NP-K0| "Roku", "Roku 1 XD|S 2100"
+NP-N0| "Roku", "Roku 1 XD"
+NP-11| "Roku", "Roku 2 HD 3000"
+NP-12| "Roku", "Roku 2 XD 3050"
+NP-13| "Roku", "Roku 2 XS 3100"
+NP-16| "Roku", "Roku LT 2400"
+NP-18| "Roku", "Roku HD 2500"
+NP-19| "Roku", "Roku LT 2450"
+NP-1E| "Roku", "Roku Streaming stick MHL 3400X"
+NP-1G| "Roku", "Roku 3 4200X"
+NP-1P| "Roku", "Roku LT 2700"
+NP-1X| "Roku", "Roku 1 2710"
+NP-2L| "Roku", "Roku Streaming Stick 3500"
+NP-41| "Roku", "Roku 3 4200"
+NP-4E| "Roku", "Roku 3 4230RW"
+NP-5Y| "Roku", "Roku 2 4210"
+NP-63| "Roku", "Roku 3 4230"
+NP-YY| "Roku", "Roku 4 4400"
+OBi200%1,3,6,12,15,28,42,66| "Obihai VoIP Adaptor", "OBi200"
+OBi202%1,3,6,12,15,28,42,66| "Obihai VoIP Adaptor", "OBi202"
+OBi300%1,3,6,12,15,28,42,66| "Obihai VoIP Adaptor", "OBi300"
+OBi302%1,3,6,12,15,28,42,66| "Obihai VoIP Adaptor", "OBi302"
+OBi1022%1,3,6,12,15,28,42,66| "Obihai VoIP Adaptor", "OBi1022"
+OBi1032%1,3,6,12,15,28,42,66| "Obihai VoIP Adaptor", "OBi1032"
+OBi1062%1,3,6,12,15,28,42,66| "Obihai VoIP Adaptor", "OBi1062"
+Philips-hue%1,3,28,6| "Philips Hue", "Hue Home Automation Bridge"
+R7000%1,3,6,12,15,28,33,44,121,249| "NetGEAR Wifi AP", "Nighthawk R7000"
+R7000%1,3,6,12,15,28,33,44,121,249,212| "NetGEAR Wifi AP", "Nighthawk R7000"
+R8000%1,3,6,12,15,28,33,44,121,249| "NetGEAR Wifi AP", "Nighthawk R8000"
+R8000%1,3,6,12,15,28,33,44,121,249,212| "NetGEAR Wifi AP", "Nighthawk R8000"
+Slingbox-350%1,3,6,12,15,28,40,41,42,119| "Slingbox", "Slingbox 350"
+Slingbox350%1,3,6,12,15,28,40,41,42,119| "Slingbox", "Slingbox 350"
+Slingbox-500%1,3,6,12,15,28,42| "Slingbox", "Slingbox 500"
+Slingbox500%1,3,6,12,15,28,42| "Slingbox", "Slingbox 500"
+Slingbox-M1%1,3,6,12,15,28,40,41,42,119| "Slingbox", "Slingbox M1"
+SPA112%1,3,6,7,12,15,28,42,43,44,66,67,125,128,150,159,160| "Cisco VoIP Adaptor", "SPA112"
+SPA-112%1,3,6,7,12,15,28,42,43,44,66,67,125,128,150,159,160| "Cisco VoIP Adaptor", "SPA112"
+SPA122%1,3,6,7,12,15,28,42,43,44,66,67,125,128,150,159,160| "Cisco VoIP Adaptor", "SPA122"
+SPA232D%1,3,6,7,12,15,28,42,43,44,66,67,125,128,150,159,160| "Cisco VoIP Adaptor", "SPA232D"
+SPA504G%1,3,6,7,12,15,28,42,43,44,66,67,125,128,150,159,160| "Cisco VoIP Adaptor", "SPA504G"
+SPA525G2%1,3,6,7,12,15,28,42,43,44,66,67,125,128,150,159,160| "Cisco VoIP Adaptor", "SPA525G2"
+steamlink%3,1,252,42,15,6,12| "Steam Link", "Steam Link"
+TIVO-000| "TiVo", "Philips HDRx12"
+TIVO-001| "DirecTV TiVo", "Philips DSR600x"
+TIVO-002| "TiVo", "Philips HDRx12"
+TIVO-010| "TiVo", "Sony SVR-2000"
+TIVO-011| "DirecTV TiVo", "Sony SAT T-60"
+TIVO-031| "DirecTV TiVo", "Hughes GXCEBOTx"
+TIVO-101| "DirecTV TiVo", "Philips DSR7000x"
+TIVO-110| "TiVo", "Sony SVR-3000"
+TIVO-121| "DirecTV TiVo", "RCA DVR39"
+TIVO-130| "AT&T TiVo", "AT&T Broadband TiVo Series 2"
+TIVO-140| "TiVo", "TiVo Series 2"
+TIVO-151| "DirecTV TiVo", "Hughes HDVR2"
+TIVO-230| "AT&T TiVo", "AT&T Broadband TiVo Series 2"
+TIVO-240| "TiVo", "TiVo Series 2"
+TIVO-264| "TiVo DVD", "Toshiba SD-H400"
+TIVO-275| "TiVo DVD", "Pioneer 810H/57H"
+TIVO-301| "DirecTV TiVo", "Philips DSR70x"
+TIVO-321| "DirecTV TiVo", "RCA DVR40/80"
+TIVO-351| "DirecTV TiVo", "Hughes SD-DVRxx"
+TIVO-357| "DirecTV TiVo", "Hughes HR10-250"
+TIVO-381| "DirecTV TiVo", "Samsung SIR-S4xxx"
+TIVO-521| "DirecTV TiVo", "DirecTV R10"
+TIVO-540| "TiVo", "TiVo Series 2 NightLight"
+TIVO-565| "TiVo DVD", "Toshiba RS-TX20/60"
+TIVO-590| "TiVo DVD", "Humax T800/2500"
+TIVO-595| "TiVo DVD", "Humax DRT400/800"
+TIVO-627| "DirecTV TiVo", "DirecTV THR22"
+TIVO-648| "TiVo", "TiVo Series 3"
+TIVO-649| "TiVo", "TiVo Series 2 Dual Tuner"
+TIVO-652| "TiVo", "TiVo Series 3 HD"
+TIVO-658| "TiVo", "TiVo Series 3 HD XL"
+TIVO-746| "TiVo", "TiVo Premiere Series 4"
+TIVO-748| "TiVo", "TiVo Premiere XL Series 4"
+TIVO-750| "TiVo", "TiVo Premiere 4 Series 4"
+TIVO-758| "TiVo", "TiVo Premiere Elite Series 4"
+TIVO-840| "TiVo", "TiVo Roamio Pro"
+TIVO-846| "TiVo", "TiVo Roamio"
+TIVO-848| "TiVo", "TiVo Roamio Plus"
+TIVO-849| "TiVo", "TiVo BOLT"
+TIVO-A92| "TiVo", "TiVo Mini"
+TIVO-A93| "TiVo", "TiVo Mini"
+TIVO-A94| "TiVo", "TiVo Stream"
+VMB3010%1,3,6,12,15,28,33,44,121,249| "NetGEAR Arlo Camera", "VMB3010"
+VMB4000%1,3,6,12,15,28,33,44,121,249| "NetGEAR Arlo Camera", "VMB4000"
+XL824-| "Trane Thermostat", "XL824"
+XL850-| "Trane Thermostat", "XL850"
+%%
diff --git a/cmds/hostnamelookup.h b/cmds/hostnamelookup.h
new file mode 100644
index 0000000..73de8e0
--- /dev/null
+++ b/cmds/hostnamelookup.h
@@ -0,0 +1,40 @@
+/*
+ * 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 HOSTNAMELOOKUP_H_
+#define HOSTNAMELOOKUP_H_
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+/* Structure returned by the hostname_lookup function. */
+struct hostname_strings {
+  const char *name;
+  const char *genus;
+  const char *species;
+};
+
+/* Function generated by gperf for the hostname_lookup table. */
+extern const struct hostname_strings *hostname_lookup(const char *str,
+    unsigned int len);
+
+#ifdef __cplusplus
+}
+#endif
+
+#endif  // HOSTNAMELOOKUP_H_
diff --git a/cmds/l2utils.cc b/cmds/l2utils.cc
index d0549cb..65ce68f 100644
--- a/cmds/l2utils.cc
+++ b/cmds/l2utils.cc
@@ -119,3 +119,24 @@
     close(s);
   }
 }
+
+std::string get_l2addr_for_ip(std::string ipaddr)
+{
+  static L2Map l2map;
+  std::string mac;
+
+  L2Map::const_iterator l2i = l2map.find(ipaddr);
+  if (l2i == l2map.end()) {
+    /* If we only just saw this IP address, it may not be present in
+     * the cached data we have. Refresh the cache and try again. */
+    get_l2_map(&l2map);
+    l2i = l2map.find(ipaddr);
+  }
+  if (l2i == l2map.end()) {
+    mac = std::string("00:00:00:00:00:00");
+  } else {
+    mac = l2i->second;
+  }
+
+  return mac;
+}
diff --git a/cmds/l2utils.h b/cmds/l2utils.h
index 6b2385a..cc48579 100644
--- a/cmds/l2utils.h
+++ b/cmds/l2utils.h
@@ -6,5 +6,6 @@
 
 typedef std::tr1::unordered_map<std::string, std::string> L2Map;
 extern void get_l2_map(L2Map *l2map);
+extern std::string get_l2addr_for_ip(std::string ipaddr);
 
 #endif  // L2UTILS_H
diff --git a/cmds/logos.c b/cmds/logos.c
index 6c186a5..93577ce 100644
--- a/cmds/logos.c
+++ b/cmds/logos.c
@@ -642,7 +642,11 @@
     }
     got = read(0, buf + used, sizeof(buf) - used);
     if (got == 0) {
-      flush(header, headerlen, buf, used);
+      if (used > 0) {
+        /* Only output if there is text in the buffer, avoid
+         * printing a blank line when a process exits. */
+        flush(header, headerlen, buf, used);
+      }
       goto done;
     } else if (got < 0) {
       if (errno != EINTR && errno != EAGAIN) {
diff --git a/cmds/modellookup.gperf b/cmds/modellookup.gperf
new file mode 100644
index 0000000..774373a
--- /dev/null
+++ b/cmds/modellookup.gperf
@@ -0,0 +1,306 @@
+%{
+/*
+ * 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 "modellookup.h"
+
+%}
+struct model_strings {
+  char *model;
+  char *genus;
+  char *species;
+};
+%%
+AppleTV1,1| "Apple TV", "Apple TV (1st gen)"
+AppleTV2,1| "Apple TV", "Apple TV (2nd gen)"
+AppleTV3,1| "Apple TV", "Apple TV (3rd gen)"
+AppleTV3,2| "Apple TV", "Apple TV (3rd gen rev A)"
+AppleTV5,3| "Apple TV", "Apple TV (4th gen)"
+iMac,1| "iMac", "iMac G3"
+iMac4,1| "iMac", "iMac Early 2006"
+iMac4,2| "iMac", "iMac Mid 2006"
+iMac5,1| "iMac", "iMac Late 2006"
+iMac5,2| "iMac", "iMac Late 2006"
+iMac6,1| "iMac", "iMac Late 2006"
+iMac7,1| "iMac", "iMac Mid 2007"
+iMac8,1| "iMac", "iMac Early 2008"
+iMac9,1| "iMac", "iMac Early 2009"
+iMac10,1| "iMac", "iMac Late 2009"
+iMac11,1| "iMac", "iMac Late 2009"
+iMac11,2| "iMac", "iMac Mid 2010"
+iMac11,3| "iMac", "iMac Mid 2010"
+iMac12,1| "iMac", "iMac Mid 2011"
+iMac12,2| "iMac", "iMac Mid 2011"
+iMac13,1| "iMac", "iMac Late 2012"
+iMac13,2| "iMac", "iMac Late 2012"
+iMac14,1| "iMac", "iMac Late 2013"
+iMac14,2| "iMac", "iMac Late 2013"
+iMac14,3| "iMac", "iMac Late 2013"
+iMac14,4| "iMac", "iMac Mid 2014"
+iMac15,1| "iMac", "iMac Late 2014"
+iMac16,1| "iMac", "iMac Late 2015"
+iMac16,2| "iMac", "iMac Late 2015"
+iMac17,1| "iMac", "iMac Late 2015"
+iPad1,1| "iPad", "iPad (1st gen)"
+iPad2,1| "iPad", "iPad (2nd gen)"
+iPad2,2| "iPad", "iPad (2nd gen)"
+iPad2,3| "iPad", "iPad (2nd gen)"
+iPad2,4| "iPad", "iPad (2nd gen)"
+iPad2,5| "iPad mini", "iPad mini (1st gen)"
+iPad2,6| "iPad mini", "iPad mini (1st gen)"
+iPad2,7| "iPad mini", "iPad mini (1st gen)"
+iPad3,1| "iPad", "iPad (3rd gen)"
+iPad3,2| "iPad", "iPad (3rd gen)"
+iPad3,3| "iPad", "iPad (3rd gen)"
+iPad3,4| "iPad", "iPad (4th gen)"
+iPad3,5| "iPad", "iPad (4th gen)"
+iPad3,6| "iPad", "iPad (4th gen)"
+iPad4,1| "iPad", "iPad Air (1st gen)"
+iPad4,2| "iPad", "iPad Air (1st gen)"
+iPad4,3| "iPad", "iPad Air (1st gen)"
+iPad4,4| "iPad mini", "iPad mini (2nd gen)"
+iPad4,5| "iPad mini", "iPad mini (2nd gen)"
+iPad4,6| "iPad mini", "iPad mini (2nd gen)"
+iPad4,7| "iPad mini", "iPad mini (3rd gen)"
+iPad4,8| "iPad mini", "iPad mini (3rd gen)"
+iPad4,9| "iPad mini", "iPad mini (3rd gen)"
+iPad5,1| "iPad mini", "iPad mini (4th gen)"
+iPad5,2| "iPad mini", "iPad mini (4th gen)"
+iPad5,3| "iPad", "iPad Air (2nd gen)"
+iPad5,4| "iPad", "iPad Air (2nd gen)"
+iPad6,3| "iPad Pro", "iPad Pro 9.7 inch"
+iPad6,4| "iPad Pro", "iPad Pro 9.7 inch"
+iPad6,7| "iPad Pro", "iPad Pro 12.9 inch"
+iPad6,8| "iPad Pro", "iPad Pro 12.9 inch"
+iPhone1,1| "iPhone", "iPhone 1"
+iPhone1,2| "iPhone 3G", "iPhone 3G"
+iPhone2,1| "iPhone 3GS", "iPhone 3GS"
+iPhone3,1| "iPhone 4", "iPhone 4"
+iPhone3,2| "iPhone 4", "iPhone 4"
+iPhone3,3| "iPhone 4", "iPhone 4"
+iPhone4,1| "iPhone 4S", "iPhone 4S"
+iPhone5,1| "iPhone 5", "iPhone 5"
+iPhone5,2| "iPhone 5", "iPhone 5"
+iPhone5,3| "iPhone 5c", "iPhone 5c"
+iPhone5,4| "iPhone 5c", "iPhone 5c"
+iPhone6,1| "iPhone 5s", "iPhone 5s"
+iPhone6,2| "iPhone 5s", "iPhone 5s"
+iPhone7,1| "iPhone 6+", "iPhone 6+"
+iPhone7,2| "iPhone 6", "iPhone 6"
+iPhone8,1| "iPhone 6s", "iPhone 6s"
+iPhone8,2| "iPhone 6s+", "iPhone 6s+"
+iPhone8,4| "iPhone SE", "iPhone SE"
+iPod1,1| "iPod Touch", "iPod Touch (1st gen)"
+iPod2,1| "iPod Touch", "iPod Touch (2nd gen)"
+iPod3,1| "iPod Touch", "iPod Touch (3rd gen)"
+iPod4,1| "iPod Touch", "iPod Touch (4th gen)"
+iPod5,1| "iPod Touch", "iPod Touch (5th gen)"
+iPod7,1| "iPod Touch", "iPod Touch (6th gen)"
+MacBook1,1| "MacBook", "MacBook Early 2006"
+MacBook2,1| "MacBook", "MacBook Late 2006"
+MacBook3,1| "MacBook", "MacBook Late 2007"
+MacBook4,1| "MacBook", "MacBook Early 2008"
+MacBook4,2| "MacBook", "MacBook Late 2008"
+MacBook5,1| "MacBook", "MacBook Late 2008"
+MacBook5,2| "MacBook", "MacBook Mid 2009"
+MacBook6,1| "MacBook", "MacBook Late 2009"
+MacBook7,1| "MacBook", "MacBook Mid 2010"
+MacBook8,1| "MacBook", "MacBook Early 2015"
+MacBook9,1| "MacBook", "MacBook Early 2016"
+MacBookAir1,1| "MacBook Air", "MacBook Air Early 2008"
+MacBookAir2,1| "MacBook Air", "MacBook Air Mid 2009"
+MacBookAir3,1| "MacBook Air", "MacBook Air Late 2010"
+MacBookAir3,2| "MacBook Air", "MacBook Air Late 2010"
+MacBookAir4,1| "MacBook Air", "MacBook Air Mid 2011"
+MacBookAir4,2| "MacBook Air", "MacBook Air Mid 2011"
+MacBookAir5,1| "MacBook Air", "MacBook Air Mid 2012"
+MacBookAir5,2| "MacBook Air", "MacBook Air Mid 2012"
+MacBookAir6,1| "MacBook Air", "MacBook Air Mid 2013"
+MacBookAir6,2| "MacBook Air", "MacBook Air Mid 2013"
+MacBookAir7,1| "MacBook Air", "MacBook Air Early 2015"
+MacBookAir7,2| "MacBook Air", "MacBook Air Early 2015"
+MacBookPro1,1| "MacBook Pro", "MacBook Pro Early 2006"
+MacBookPro1,2| "MacBook Pro", "MacBook Pro Early 2006"
+MacBookPro2,1| "MacBook Pro", "MacBook Pro Late 2006"
+MacBookPro2,2| "MacBook Pro", "MacBook Pro Late 2006"
+MacBookPro3,1| "MacBook Pro", "MacBook Pro Mid 2007"
+MacBookPro4,1| "MacBook Pro", "MacBook Pro Early 2008"
+MacBookPro5,1| "MacBook Pro", "MacBook Pro Late 2008"
+MacBookPro5,2| "MacBook Pro", "MacBook Pro Early 2009"
+MacBookPro5,3| "MacBook Pro", "MacBook Pro Mid 2009"
+MacBookPro5,4| "MacBook Pro", "MacBook Pro Mid 2009"
+MacBookPro5,5| "MacBook Pro", "MacBook Pro Mid 2009"
+MacBookPro6,1| "MacBook Pro", "MacBook Pro Mid 2010"
+MacBookPro6,2| "MacBook Pro", "MacBook Pro Mid 2010"
+MacBookPro7,1| "MacBook Pro", "MacBook Pro Mid 2010"
+MacBookPro8,1| "MacBook Pro", "MacBook Pro Early 2011"
+MacBookPro8,2| "MacBook Pro", "MacBook Pro Early 2011"
+MacBookPro8,3| "MacBook Pro", "MacBook Pro Early 2011"
+MacBookPro9,1| "MacBook Pro", "MacBook Pro Mid 2012"
+MacBookPro9,2| "MacBook Pro", "MacBook Pro Mid 2012"
+MacBookPro10,1| "MacBook Pro", "MacBook Pro Mid 2012"
+MacBookPro10,2| "MacBook Pro", "MacBook Pro Late 2012"
+MacBookPro11,1| "MacBook Pro", "MacBook Pro Late 2013"
+MacBookPro11,2| "MacBook Pro", "MacBook Pro Late 2013"
+MacBookPro11,3| "MacBook Pro", "MacBook Pro Late 2013"
+MacBookPro11,4| "MacBook Pro", "MacBook Pro Mid 2015"
+MacBookPro11,5| "MacBook Pro", "MacBook Pro Mid 2015"
+MacBookPro12,1| "MacBook Pro", "MacBook Pro Early 2015"
+Macmini1,1| "Mac Mini", "Mac Mini Early 2006"
+Macmini2,1| "Mac Mini", "Mac Mini Mid 2007"
+Macmini3,1| "Mac Mini", "Mac Mini Early 2009"
+Macmini4,1| "Mac Mini", "Mac Mini Mid 2010"
+Macmini5,1| "Mac Mini", "Mac Mini Mid 2011"
+Macmini5,2| "Mac Mini", "Mac Mini Mid 2011"
+Macmini5,3| "Mac Mini", "Mac Mini Mid 2011"
+Macmini6,1| "Mac Mini", "Mac Mini Late 2012"
+Macmini6,2| "Mac Mini", "Mac Mini Late 2012"
+Macmini7,1| "Mac Mini", "Mac Mini Late 2014"
+Macmini7,2| "Mac Mini", "Mac Mini Late 2014"
+Macmini7,3| "Mac Mini", "Mac Mini Late 2014"
+MacPro1,1| "Mac Pro", "Mac Pro Mid 2006"
+MacPro2,1| "Mac Pro", "Mac Pro Mid 2006"
+MacPro3,1| "Mac Pro", "Mac Pro Early 2008"
+MacPro4,1| "Mac Pro", "Mac Pro Early 2009"
+MacPro5,1| "Mac Pro", "Mac Pro Mid 2010"
+MacPro6,1| "Mac Pro", "Mac Pro Late 2013"
+PowerBook1,1| "PowerBook", "PowerBook G3 Mid 1999"
+PowerBook2,1| "iBook", "iBook G3 Mid 1999"
+PowerBook2,2| "iBook", "iBook G3 Late 2000"
+PowerBook3,1| "PowerBook", "PowerBook G3 Early 2000"
+PowerBook3,2| "PowerBook", "PowerBook G4 Early 2001"
+PowerBook3,3| "PowerBook", "PowerBook G4 Late 2001"
+PowerBook3,4| "PowerBook", "PowerBook G4 Early 2002"
+PowerBook3,5| "PowerBook", "PowerBook G4 Late 2002"
+PowerBook4,1| "iBook", "iBook G3 Late 2001"
+PowerBook4,2| "iBook", "iBook G3 Early 2002"
+PowerBook4,3| "iBook", "iBook G3 Mid 2002"
+PowerBook5,1| "PowerBook", "PowerBook G4 Early 2003"
+PowerBook5,2| "PowerBook", "PowerBook G4 Late 2003"
+PowerBook5,3| "PowerBook", "PowerBook G4 Late 2003"
+PowerBook5,4| "PowerBook", "PowerBook G4 Early 2004"
+PowerBook5,5| "PowerBook", "PowerBook G4 Early 2004"
+PowerBook5,6| "PowerBook", "PowerBook G4 Early 2005"
+PowerBook5,7| "PowerBook", "PowerBook G4 Early 2005"
+PowerBook5,8| "PowerBook", "PowerBook G4 Late 2005"
+PowerBook5,9| "PowerBook", "PowerBook G4 Late 2005"
+PowerBook6,1| "PowerBook", "PowerBook G4 Early 2003"
+PowerBook6,2| "PowerBook", "PowerBook G4 Late 2003"
+PowerBook6,3| "iBook", "iBook G4 Late 2003"
+PowerBook6,4| "PowerBook", "PowerBook G4 Early 2004"
+PowerBook6,5| "iBook", "iBook G4 Early 2004"
+PowerBook6,7| "iBook", "iBook G4 Mid 2005"
+PowerBook6,8| "PowerBook", "PowerBook G4 Early 2005"
+PowerMac1,1| "Power Mac", "Power Mac G3 Early 1999"
+PowerMac1,2| "Power Mac", "Power Mac G4 Late 1999"
+PowerMac2,1| "iMac", "iMac G3 Early 2000"
+PowerMac2,2| "iMac", "iMac G3 Summer 2000"
+PowerMac3,1| "Power Mac", "Power Mac G4 Late 1999"
+PowerMac3,3| "Power Mac", "Power Mac G4 Mid 2000"
+PowerMac3,4| "Power Mac", "Power Mac G4 Early 2001"
+PowerMac3,5| "Power Mac", "Power Mac G4 Mid 2001"
+PowerMac3,6| "Power Mac", "Power Mac G4 Mid 2002"
+PowerMac4,1| "iMac", "iMac G3 Early 2001"
+PowerMac4,2| "iMac", "iMac G4 Early 2002"
+PowerMac4,4| "eMac", "eMac G4 Mid 2002"
+PowerMac4,5| "iMac", "iMac G4 Mid 2002"
+PowerMac5,1| "Power Mac", "Power Mac G4 Cube Mid 2000"
+PowerMac6,1| "iMac", "iMac G4 Early 2003"
+PowerMac6,3| "iMac", "iMac G4 Late 2003"
+PowerMac6,4| "eMac", "eMac G4 Early 2004"
+PowerMac7,2| "Power Mac", "Power Mac G5 Mid 2003"
+PowerMac7,3| "Power Mac", "Power Mac G5 Mid 2004"
+PowerMac8,1| "iMac", "iMac G5 Mid 2004"
+PowerMac8,2| "iMac", "iMac G5 Mid 2005"
+PowerMac9,1| "Power Mac", "Power Mac G5 Late 2004"
+PowerMac10,1| "Mac Mini", "Mac Mini Early 2005"
+PowerMac10,2| "Mac Mini", "Mac Mini Late 2005"
+PowerMac11,2| "Power Mac", "Power Mac G5 Late 2005"
+PowerMac12,1| "iMac", "iMac G5 Late 2005"
+RackMac1,1| "Xserve", "Xserve G4 Mid 2002"
+RackMac1,2| "Xserve", "Xserve G4 Early 2003"
+RackMac3,1| "Xserve", "Xserve G5 Early 2004"
+Watch1,1| "Apple Watch", "Apple Watch"
+Watch1,2| "Apple Watch", "Apple Watch"
+Xserve1,1| "Xserve", "Xserve Xeon Late 2006"
+Xserve2,1| "Xserve", "Xserve Xeon Early 2008"
+Xserve3,1| "Xserve", "Xserve Xeon Early 2009"
+J1AP| "iPad", "iPad (3rd gen)"
+J2AP| "iPad", "iPad (3rd gen)"
+J2AAP| "iPad", "iPad (3rd gen)"
+J127AP| "iPad Pro", "iPad Pro"
+J128AP| "iPad Pro", "iPad Pro"
+J33AP| "Apple TV", "Apple TV (3rd gen)"
+J33iAP| "Apple TV", "Apple TV (3rd gen rev A)"
+J42dAP| "Apple TV", "Apple TV (4th gen)"
+J71AP| "iPad", "iPad Air"
+J72AP| "iPad", "iPad Air"
+J73AP| "iPad", "iPad Air"
+J81AP| "iPad", "iPad Air (2nd gen)"
+J82AP| "iPad", "iPad Air (2nd gen)"
+J85AP| "iPad mini", "iPad mini (2nd gen)"
+J85mAP| "iPad mini", "iPad mini (3rd gen)"
+J86AP| "iPad mini", "iPad mini (2nd gen)"
+J86mAP| "iPad mini", "iPad mini (3rd gen)"
+J87AP| "iPad mini", "iPad mini (2nd gen)"
+J87mAP| "iPad mini", "iPad mini (3rd gen)"
+J96AP| "iPad mini", "iPad mini (4th gen)"
+J97AP| "iPad mini", "iPad mini (4th gen)"
+J98aAP| "iPad Pro", "iPad Pro"
+J99aAP| "iPad Pro", "iPad Pro"
+K48AP| "iPad", "iPad (1st gen)"
+K66AP| "Apple TV", "Apple TV (2nd gen)"
+K93AAP| "iPad", "iPad (2nd gen)"
+K93AP| "iPad", "iPad (2nd gen)"
+K94AP| "iPad", "iPad (2nd gen)"
+K95AP| "iPad", "iPad (2nd gen)"
+M68AP| "iPhone", "iPhone 1"
+N27aAP| "Apple Watch", "Apple Watch"
+N28aAP| "Apple Watch", "Apple Watch"
+N102AP| "iPod Touch", "iPod Touch (6th gen)"
+N18AP| "iPod Touch", "iPod Touch (3rd gen)"
+N41AP| "iPhone 5", "iPhone 5"
+N42AP| "iPhone 5", "iPhone 5"
+N45AP| "iPod Touch", "iPod Touch (1st gen)"
+N48AP| "iPhone 5c", "iPhone 5c"
+N49AP| "iPhone 5c", "iPhone 5c"
+N51AP| "iPhone 5s", "iPhone 5s"
+N53AP| "iPhone 5s", "iPhone 5s"
+N56AP| "iPhone 6+", "iPhone 6+"
+N61AP| "iPhone 6", "iPhone 6"
+N66AP| "iPhone 6s+", "iPhone 6s+"
+N66mAP| "iPhone 6s+", "iPhone 6s+"
+N69AP| "iPhone SE", "iPhone SE"
+N69uAP| "iPhone SE", "iPhone SE"
+N71AP| "iPhone 6s", "iPhone 6s"
+N71mAP| "iPhone 6s", "iPhone 6s"
+N72AP| "iPod Touch", "iPod Touch (2nd gen)"
+N78aAP| "iPod Touch", "iPod Touch (5th gen)"
+N78AP| "iPod Touch", "iPod Touch (5th gen)"
+N81AP| "iPod Touch", "iPod Touch (4th gen)"
+N82AP| "iPhone 3G", "iPhone 3G"
+N88AP| "iPhone 3GS", "iPhone 3GS"
+N90AP| "iPhone 4", "iPhone 4"
+N90BAP| "iPhone 4", "iPhone 4"
+N92AP| "iPhone 4", "iPhone 4"
+N94AP| "iPhone 4S", "iPhone 4S"
+P101AP| "iPad", "iPad (4th gen)"
+P102AP| "iPad", "iPad (4th gen)"
+P103AP| "iPad", "iPad (4th gen)"
+P105AP| "iPad mini", "iPad mini (1st gen)"
+P106AP| "iPad mini", "iPad mini (1st gen)"
+P107AP| "iPad mini", "iPad mini (1st gen)"
+%%
diff --git a/cmds/modellookup.h b/cmds/modellookup.h
new file mode 100644
index 0000000..cd6e7f3
--- /dev/null
+++ b/cmds/modellookup.h
@@ -0,0 +1,40 @@
+/*
+ * 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 MODELLOOKUP_H_
+#define MODELLOOKUP_H_
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+/* Structure returned by the model_lookup function. */
+struct model_strings {
+  const char *model;
+  const char *genus;
+  const char *species;
+};
+
+/* Function generated by gperf for the model_lookup table. */
+extern const struct model_strings *model_lookup(const char *str,
+    unsigned int len);
+
+#ifdef __cplusplus
+}
+#endif
+
+#endif  // MODELLOOKUP_H_
diff --git a/cmds/ssdptax-test-server.py b/cmds/ssdptax-test-server.py
new file mode 100644
index 0000000..c0346cb
--- /dev/null
+++ b/cmds/ssdptax-test-server.py
@@ -0,0 +1,47 @@
+#!/usr/bin/python
+"""Fake minissdpd for unit tests.
+
+"""
+
+import BaseHTTPServer
+import socket
+import sys
+
+
+class XmlHandler(BaseHTTPServer.BaseHTTPRequestHandler):
+  def do_GET(self):
+    self.send_response(200)
+    self.send_header('Content-type','text/xml')
+    self.end_headers()
+    self.wfile.write("""<root>
+        <specVersion><major>1</major><minor>0</minor></specVersion>
+        <device><friendlyName>Test Device</friendlyName>
+        <manufacturer>Google Fiber</manufacturer>
+        <modelDescription>Unit Test</modelDescription>
+        <modelName>ssdptax</modelName>
+    </device></root>""")
+
+def main():
+  un = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
+  un.bind(sys.argv[1])
+  un.listen(1)
+  conn, _ = un.accept()
+
+  s = BaseHTTPServer.HTTPServer(("", 0), XmlHandler)
+  sn = s.socket.getsockname()
+  port = sn[1]
+  url = 'http://127.0.0.1:%d/foo.xml' % port
+  st = 'server type'
+  uuid = 'uuid goes here'
+  data = [1]
+  data.extend([len(url)] + list(url))
+  data.extend([len(st)] + list(st))
+  data.extend([len(uuid)] + list(uuid))
+
+  _ = conn.recv(8192)
+  conn.sendall(bytearray(data))
+  s.handle_request()
+
+
+if __name__ == '__main__':
+  main()
diff --git a/cmds/ssdptax.cc b/cmds/ssdptax.cc
index 1c99553..2a991cb 100644
--- a/cmds/ssdptax.cc
+++ b/cmds/ssdptax.cc
@@ -40,6 +40,9 @@
 #include <sys/un.h>
 #include <unistd.h>
 
+#include <iostream>
+#include <set>
+
 #include "l2utils.h"
 
 /* Encode length by using 7bit per Byte :
@@ -62,24 +65,33 @@
 #define SOCK_PATH "/var/run/minissdpd.sock"
 
 
-typedef struct {
-  char server[512];
-  char url[512];
-  char friendlyName[64];
+typedef struct ssdp_info {
+  ssdp_info(): srv_type(), url(), friendlyName(), ipaddr(),
+    manufacturer(), model(), failed(0) {}
+  ssdp_info(const ssdp_info& s): srv_type(s.srv_type), url(s.url),
+    friendlyName(s.friendlyName), ipaddr(s.ipaddr),
+    manufacturer(s.manufacturer), model(s.model), failed(s.failed) {}
+  std::string srv_type;
+  std::string url;
+  std::string friendlyName;
+  std::string ipaddr;
+  std::string manufacturer;
+  std::string model;
+
+  std::string buffer;
   int failed;
 } ssdp_info_t;
 
 
-/* Unit test support */
-char *get_test_ssdp_data();
-void get_test_l2_map(L2Map *l2map);
-
-static void memcpy_printable(char *dst, const char *src, size_t n)
+static void strncpy_limited(char *dst, size_t dstlen,
+    const char *src, size_t srclen)
 {
   size_t i;
-  for (i = 0; i < n; ++i) {
+  size_t lim = (srclen >= (dstlen - 1)) ? (dstlen - 2) : srclen;
+
+  for (i = 0; i < lim; ++i) {
     unsigned char s = src[i];
-    if (isspace(s)) {
+    if (isspace(s) || s == ';') {
       dst[i] = ' ';  // deliberately convert newline to space
     } else if (isprint(s)) {
       dst[i] = s;
@@ -87,23 +99,27 @@
       dst[i] = '_';
     }
   }
+  dst[lim] = '\0';
 }
 
 
 /*
- * Send a request to minissdpd. Returns a pointer to a buffer
- * allocated using malloc(). Caller must free() the buffer when done.
+ * Send a request to minissdpd. Returns a std::string containing
+ * minissdpd's response.
  */
-char *request_from_ssdpd(int reqtype, const char *device)
+std::string request_from_ssdpd(const char *sock_path,
+    int reqtype, const char *device)
 {
   int s = socket(AF_UNIX, SOCK_STREAM, 0);
   struct sockaddr_un addr;
-  size_t siz = 256 * 1024;
-  char *buffer, *p;
+  char *buffer;
   ssize_t len;
+  size_t siz = 256 * 1024;
+  char *p;
   int device_len = (int)strlen(device);
   fd_set readfds;
   struct timeval tv;
+  std::string rc;
 
   if (s < 0) {
     perror("socket AF_UNIX failed");
@@ -111,7 +127,7 @@
   }
   memset(&addr, 0, sizeof(addr));
   addr.sun_family = AF_UNIX;
-  strncpy(addr.sun_path, SOCK_PATH, sizeof(addr.sun_path));
+  strncpy(addr.sun_path, sock_path, sizeof(addr.sun_path));
   if(connect(s, (struct sockaddr *)&addr, sizeof(struct sockaddr_un)) < 0) {
     perror("connect to minisspd failed");
     exit(1);
@@ -149,14 +165,32 @@
   }
 
   close(s);
-  return(buffer);
+  rc = std::string(buffer, len);
+  free(buffer);
+  return(rc);
 }
 
 
-static void print_responses(const std::string &ipaddr,
-    const ssdp_info_t *info, L2Map *l2map)
+/*
+ * Combine the manufacturer and model. If the manufacturer name
+ * is already present in the model string, don't duplicate it.
+ */
+const std::string unfriendly_name(const std::string &manufacturer,
+    const std::string &model)
 {
-  const char *mac;
+  if (strcasestr(model.c_str(), manufacturer.c_str()) != NULL) {
+    return model;
+  }
+
+  return manufacturer + " " + model;
+}
+
+
+std::string format_response(const ssdp_info_t *info, L2Map *l2map)
+{
+  std::string mac;
+  std::string ipaddr;
+  std::string result;
 
   if (info->failed) {
     /*
@@ -168,80 +202,62 @@
      * and that is misleading. We only report information about devices which
      * are active right now.
      */
+    return result;
+  }
+
+  mac = get_l2addr_for_ip(info->ipaddr);
+  if (info->friendlyName.length() > 0) {
+    result = "ssdp " + mac + " " + info->friendlyName + ";" +
+      unfriendly_name(info->manufacturer, info->model);
+  } else {
+    result = "ssdp " + mac + " Unknown;" + info->srv_type;
+  }
+
+  return result;
+}
+
+
+void parse_minissdpd_response(std::string &response,
+    std::string &url, std::string &srv_type)
+{
+  size_t slen;
+  const char *p;
+  const char *end = response.c_str() + response.length();
+  char urlbuf[256];
+  char srv_type_buf[256];
+
+  memset(urlbuf, 0, sizeof(urlbuf));
+  memset(srv_type_buf, 0, sizeof(srv_type_buf));
+
+  p = response.c_str();
+  DECODELENGTH(slen, p);
+  if ((p + slen) > end) {
+    fprintf(stderr, "Unable to parse SSDP response\n");
     return;
   }
-
-  L2Map::const_iterator ii = l2map->find(ipaddr);
-  if (ii != l2map->end()) {
-    mac = ii->second.c_str();
-  } else {
-    mac = "00:00:00:00:00:00";
-  }
-
-  /* taxonomy information to stdout */
-  if (strlen(info->friendlyName)) {
-    printf("ssdp %s %s\n", mac, info->friendlyName);
-  } else {
-    printf("ssdp %s Unknown;%s\n", mac, info->server);
-  }
-}
-
-
-const char *parse_minissdpd_response(const char *response,
-    char *key, size_t key_len,
-    char *url, size_t url_len,
-    char *value, size_t value_len)
-{
-  const char *p = response;
-  size_t copylen, slen;
-  int prefix = 0;
-  struct in6_addr in6;
-  struct in_addr in;
-  char ip[INET6_ADDRSTRLEN];
-
-  key[0] = url[0] = value[0] = '\0';
-
-  DECODELENGTH(slen, p);
-  copylen = (slen >= url_len) ? url_len - 1 : slen;
-  memcpy_printable(url, p, copylen);
-  url[copylen] = '\0';
+  strncpy_limited(urlbuf, sizeof(urlbuf), p, slen);
   p += slen;
 
   DECODELENGTH(slen, p);
-  copylen = (slen >= value_len) ? value_len - 1 : slen;
-  memcpy_printable(value, p, copylen);
-  value[copylen] = '\0';
+  if ((p + slen) > end) {
+    fprintf(stderr, "Unable to parse SSDP response\n");
+    return;
+  }
+  strncpy_limited(srv_type_buf, sizeof(srv_type_buf), p, slen);
   p += slen;
 
-  if (strncasecmp(url, "https://[", 9) == 0) prefix = 9;
-  if (strncasecmp(url, "http://[", 8) == 0) prefix = 8;
-  if (strncasecmp(url, "https://", 8) == 0) prefix = 8;
-  if (strncasecmp(url, "http://", 7) == 0) prefix = 7;
-  strncpy(ip, url + prefix, sizeof(ip));
-  strtok(ip, ":/@");
-
-  if (inet_pton(AF_INET6, ip, &in6)) {
-    inet_ntop(AF_INET6, &in6, key, key_len);
+  DECODELENGTH(slen, p);
+  if ((p + slen) > end) {
+    fprintf(stderr, "Unable to parse SSDP response\n");
+    return;
   }
-  if (inet_pton(AF_INET, ip, &in)) {
-    inet_ntop(AF_INET, &in, key, key_len);
-  }
+  /* Skip over the UUID without processing it. */
+  p += slen;
 
-  return p;
-}
+  url = urlbuf;
+  srv_type = srv_type_buf;
 
-
-ssdp_info_t *dupinfo(ssdp_info_t *info)
-{
-  ssdp_info_t *i = (ssdp_info_t *)malloc(sizeof(*info));
-
-  if (i == NULL) {
-    perror("malloc");
-    exit(1);
-  }
-
-  memcpy(i, info, sizeof(*i));
-  return i;
+  response.erase(0, (p - response.c_str()));
 }
 
 
@@ -256,7 +272,7 @@
   start = strcasestr(ptr, openlabel) + strlen(openlabel);
   end = strcasestr(ptr, closelabel);
 
-  if ((end - start) > 0) {
+  if (start < end) {
     *len = end - start;
     return start;
   }
@@ -266,9 +282,7 @@
 
 
 /*
- * libcurl calls this function back with the result of the HTTP GET.
- *
- * Expected value is an XML blob of
+ * Expected value in buffer is an XML blob of
  * http://upnp.org/specs/basic/UPnP-basic-Basic-v1-Device.pdf
  *
  * Like this (a Samsung TV):
@@ -285,22 +299,35 @@
  *    <modelURL>http://www.samsung.com/sec</modelURL>
  * ... etc, etc ...
  */
-size_t callback(const char *ptr, size_t size, size_t nmemb, void *userdata)
+void extract_fields_from_buffer(ssdp_info_t *info)
 {
-  ssdp_info_t *info = (ssdp_info_t *)userdata;
+  const char *ptr = info->buffer.c_str();
   const char *p;
   ssize_t len;
-  ssize_t max = (ssize_t)sizeof(info->friendlyName);
 
   if ((p = findXmlField(ptr, "friendlyName", &len)) == NULL) {
     p = findXmlField(ptr, "modelDescription", &len);
   }
-
-  if (p && (len > 0) && (len < max)) {
-    /* the len < max check ensures there will be a NUL byte at the end */
-    memcpy(info->friendlyName, p, len);
+  if (p && len > 0) {
+    info->friendlyName = std::string(p, len);
   }
 
+  p = findXmlField(ptr, "manufacturer", &len);
+  if (p && len > 0) {
+    info->manufacturer = std::string(p, len);
+  }
+
+  p = findXmlField(ptr, "modelName", &len);
+  if (p && len > 0) {
+    info->model = std::string(p, len);
+  }
+}
+
+
+size_t callback(const char *ptr, size_t size, size_t nmemb, void *userdata)
+{
+  ssdp_info_t *info = (ssdp_info_t *)userdata;
+  info->buffer.append(ptr, size * nmemb);
   return size * nmemb;
 }
 
@@ -308,44 +335,51 @@
 /*
  * SSDP returned an endpoint URL, use curl to GET its contents.
  */
-void fetch_device_info(const char *url, ssdp_info_t *ssdp)
+void fetch_device_info(const std::string &url, ssdp_info_t *info)
 {
   CURL *curl = curl_easy_init();
-  int rc;
+  char *ip;
 
   if (!curl) {
     fprintf(stderr, "curl_easy_init failed\n");
     return;
   }
-  curl_easy_setopt(curl, CURLOPT_URL, url);
+  curl_easy_setopt(curl, CURLOPT_URL, url.c_str());
   curl_easy_setopt(curl, CURLOPT_PATH_AS_IS, 1L);
   curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, &callback);
-  curl_easy_setopt(curl, CURLOPT_WRITEDATA, ssdp);
-  curl_easy_setopt(curl, CURLOPT_USERAGENT, "ssdptax/1.0");
+  curl_easy_setopt(curl, CURLOPT_WRITEDATA, info);
+  curl_easy_setopt(curl, CURLOPT_USERAGENT, "ssdptaxonomy/1.0");
   curl_easy_setopt(curl, CURLOPT_TIMEOUT, 1L);
-  if ((rc = curl_easy_perform(curl)) != CURLE_OK) {
-    ssdp->failed = 1;
+  curl_easy_setopt(curl, CURLOPT_FAILONERROR, true);
+  if (curl_easy_perform(curl) == CURLE_OK) {
+    extract_fields_from_buffer(info);
+  } else {
+    info->failed = 1;
   }
+  if (curl_easy_getinfo(curl, CURLINFO_PRIMARY_IP, &ip) == CURLE_OK) {
+    info->ipaddr = ip;
+  }
+
+  info->buffer.clear();
   curl_easy_cleanup(curl);
 }
 
 
 void usage(char *progname) {
-  printf("usage: %s [-t]\n", progname);
-  printf("\t-t\ttest mode, run a test with fake SSDP data.\n");
+  printf("usage: %s [-t /path/to/fifo]\n", progname);
+  printf("\t-t\ttest mode, use a fake path instead of minissdpd.\n");
   exit(1);
 }
 
 
 int main(int argc, char **argv)
 {
-  char *buffer;
-  const char *p;
+  std::string buffer;
   typedef std::tr1::unordered_map<std::string, ssdp_info_t*> ResponsesMap;
   ResponsesMap responses;
   L2Map l2map;
   int c, num;
-  int testmode = 0;
+  const char *sock_path = SOCK_PATH;
 
   setlinebuf(stdout);
   alarm(30);
@@ -355,241 +389,48 @@
     exit(1);
   }
 
-  while ((c = getopt(argc, argv, "t")) != -1) {
+  while ((c = getopt(argc, argv, "t:")) != -1) {
     switch(c) {
-      case 't': testmode = 1; break;
+      case 't': sock_path = optarg; break;
       default: usage(argv[0]); break;
     }
   }
 
-  if (!testmode) {
-    /* 5 == request all device server IDs */
-    buffer = request_from_ssdpd(5, "ssdp:all");
-  } else {
-    buffer = get_test_ssdp_data();
-  }
+  buffer = request_from_ssdpd(sock_path, 3, "ssdp:all");
+  num = buffer.c_str()[0];
+  buffer.erase(0, 1);
+  while ((num-- > 0) && buffer.length() > 0) {
+    ssdp_info_t *info = new ssdp_info_t;
 
-  num = buffer[0];
-  p = buffer + 1;
-  while ((num-- > 0) && (p < (buffer + sizeof(buffer)))) {
-    char key[INET6_ADDRSTRLEN];
-    ssdp_info_t info;
-
-    memset(&info, 0, sizeof(info));
-    p = parse_minissdpd_response(p, key, sizeof(key),
-        info.url, sizeof(info.url), info.server, sizeof(info.server));
-    if (strlen(key) && responses.find(std::string(key)) == responses.end()) {
-      if (!testmode) {
-        fetch_device_info(info.url, &info);
-      } else {
-        snprintf(info.friendlyName, sizeof(info.friendlyName), "Test Device");
-      }
-      responses.insert(std::make_pair<std::string,
-          ssdp_info_t*>(std::string(key), dupinfo(&info)));
+    parse_minissdpd_response(buffer, info->url, info->srv_type);
+    if (info->url.length() && responses.find(info->url) == responses.end()) {
+      fetch_device_info(info->url, info);
+      responses[info->url] = info;
+    } else {
+      delete info;
     }
   }
-  free(buffer);
 
-  if (!testmode) {
-    get_l2_map(&l2map);
-  } else {
-    get_test_l2_map(&l2map);
+  get_l2_map(&l2map);
+
+  typedef std::set<std::string> ResultsSet;
+  ResultsSet results;
+  for (ResponsesMap::const_iterator ii = responses.begin();
+      ii != responses.end(); ++ii) {
+    std::string r = format_response(ii->second, &l2map);
+    if (r.length() > 0) {
+      results.insert(r);
+    }
   }
 
-  for(ResponsesMap::const_iterator ii = responses.begin();
-      ii != responses.end(); ++ii) {
-    print_responses(ii->first, ii->second, &l2map);
+  /* Many devices advertise multiple URLs with the same
+   * model information in all of them. Suppress duplicate
+   * output using the set. */
+  for (ResultsSet::const_iterator ii = results.begin();
+      ii != results.end(); ++ii) {
+    std::cout << *ii << std::endl;
   }
 
   curl_global_cleanup();
   exit(0);
 }
-
-
-/*
- * data for a unit test, response from a single SSDP
- * client.
- */
-uint8_t test_ssdp_data[] = {
-  0x12, 0x22, 0x68, 0x74, 0x74, 0x70, 0x3a, 0x2f,
-  0x2f, 0x31, 0x39, 0x32, 0x2e, 0x31, 0x36, 0x38,
-  0x2e, 0x34, 0x32, 0x2e, 0x31, 0x32, 0x35, 0x3a,
-  0x37, 0x36, 0x37, 0x36, 0x2f, 0x73, 0x6d, 0x70,
-  0x5f, 0x32, 0x39, 0x5f, 0x23, 0x53, 0x48, 0x50,
-  0x2c, 0x20, 0x55, 0x50, 0x6e, 0x50, 0x2f, 0x31,
-  0x2e, 0x30, 0x2c, 0x20, 0x53, 0x61, 0x6d, 0x73,
-  0x75, 0x6e, 0x67, 0x20, 0x55, 0x50, 0x6e, 0x50,
-  0x20, 0x53, 0x44, 0x4b, 0x2f, 0x31, 0x2e, 0x30,
-  0x22, 0x68, 0x74, 0x74, 0x70, 0x3a, 0x2f, 0x2f,
-  0x31, 0x39, 0x32, 0x2e, 0x31, 0x36, 0x38, 0x2e,
-  0x34, 0x32, 0x2e, 0x31, 0x32, 0x35, 0x3a, 0x37,
-  0x36, 0x37, 0x36, 0x2f, 0x73, 0x6d, 0x70, 0x5f,
-  0x32, 0x39, 0x5f, 0x23, 0x53, 0x48, 0x50, 0x2c,
-  0x20, 0x55, 0x50, 0x6e, 0x50, 0x2f, 0x31, 0x2e,
-  0x30, 0x2c, 0x20, 0x53, 0x61, 0x6d, 0x73, 0x75,
-  0x6e, 0x67, 0x20, 0x55, 0x50, 0x6e, 0x50, 0x20,
-  0x53, 0x44, 0x4b, 0x2f, 0x31, 0x2e, 0x30, 0x22,
-  0x68, 0x74, 0x74, 0x70, 0x3a, 0x2f, 0x2f, 0x31,
-  0x39, 0x32, 0x2e, 0x31, 0x36, 0x38, 0x2e, 0x34,
-  0x32, 0x2e, 0x31, 0x32, 0x35, 0x3a, 0x37, 0x36,
-  0x37, 0x36, 0x2f, 0x73, 0x6d, 0x70, 0x5f, 0x32,
-  0x39, 0x5f, 0x23, 0x53, 0x48, 0x50, 0x2c, 0x20,
-  0x55, 0x50, 0x6e, 0x50, 0x2f, 0x31, 0x2e, 0x30,
-  0x2c, 0x20, 0x53, 0x61, 0x6d, 0x73, 0x75, 0x6e,
-  0x67, 0x20, 0x55, 0x50, 0x6e, 0x50, 0x20, 0x53,
-  0x44, 0x4b, 0x2f, 0x31, 0x2e, 0x30, 0x22, 0x68,
-  0x74, 0x74, 0x70, 0x3a, 0x2f, 0x2f, 0x31, 0x39,
-  0x32, 0x2e, 0x31, 0x36, 0x38, 0x2e, 0x34, 0x32,
-  0x2e, 0x31, 0x32, 0x35, 0x3a, 0x37, 0x36, 0x37,
-  0x36, 0x2f, 0x73, 0x6d, 0x70, 0x5f, 0x32, 0x39,
-  0x5f, 0x23, 0x53, 0x48, 0x50, 0x2c, 0x20, 0x55,
-  0x50, 0x6e, 0x50, 0x2f, 0x31, 0x2e, 0x30, 0x2c,
-  0x20, 0x53, 0x61, 0x6d, 0x73, 0x75, 0x6e, 0x67,
-  0x20, 0x55, 0x50, 0x6e, 0x50, 0x20, 0x53, 0x44,
-  0x4b, 0x2f, 0x31, 0x2e, 0x30, 0x22, 0x68, 0x74,
-  0x74, 0x70, 0x3a, 0x2f, 0x2f, 0x31, 0x39, 0x32,
-  0x2e, 0x31, 0x36, 0x38, 0x2e, 0x34, 0x32, 0x2e,
-  0x31, 0x32, 0x35, 0x3a, 0x37, 0x36, 0x37, 0x36,
-  0x2f, 0x73, 0x6d, 0x70, 0x5f, 0x31, 0x39, 0x5f,
-  0x23, 0x53, 0x48, 0x50, 0x2c, 0x20, 0x55, 0x50,
-  0x6e, 0x50, 0x2f, 0x31, 0x2e, 0x30, 0x2c, 0x20,
-  0x53, 0x61, 0x6d, 0x73, 0x75, 0x6e, 0x67, 0x20,
-  0x55, 0x50, 0x6e, 0x50, 0x20, 0x53, 0x44, 0x4b,
-  0x2f, 0x31, 0x2e, 0x30, 0x22, 0x68, 0x74, 0x74,
-  0x70, 0x3a, 0x2f, 0x2f, 0x31, 0x39, 0x32, 0x2e,
-  0x31, 0x36, 0x38, 0x2e, 0x34, 0x32, 0x2e, 0x31,
-  0x32, 0x35, 0x3a, 0x37, 0x36, 0x37, 0x36, 0x2f,
-  0x73, 0x6d, 0x70, 0x5f, 0x31, 0x39, 0x5f, 0x23,
-  0x53, 0x48, 0x50, 0x2c, 0x20, 0x55, 0x50, 0x6e,
-  0x50, 0x2f, 0x31, 0x2e, 0x30, 0x2c, 0x20, 0x53,
-  0x61, 0x6d, 0x73, 0x75, 0x6e, 0x67, 0x20, 0x55,
-  0x50, 0x6e, 0x50, 0x20, 0x53, 0x44, 0x4b, 0x2f,
-  0x31, 0x2e, 0x30, 0x22, 0x68, 0x74, 0x74, 0x70,
-  0x3a, 0x2f, 0x2f, 0x31, 0x39, 0x32, 0x2e, 0x31,
-  0x36, 0x38, 0x2e, 0x34, 0x32, 0x2e, 0x31, 0x32,
-  0x35, 0x3a, 0x37, 0x36, 0x37, 0x36, 0x2f, 0x73,
-  0x6d, 0x70, 0x5f, 0x31, 0x39, 0x5f, 0x23, 0x53,
-  0x48, 0x50, 0x2c, 0x20, 0x55, 0x50, 0x6e, 0x50,
-  0x2f, 0x31, 0x2e, 0x30, 0x2c, 0x20, 0x53, 0x61,
-  0x6d, 0x73, 0x75, 0x6e, 0x67, 0x20, 0x55, 0x50,
-  0x6e, 0x50, 0x20, 0x53, 0x44, 0x4b, 0x2f, 0x31,
-  0x2e, 0x30, 0x22, 0x68, 0x74, 0x74, 0x70, 0x3a,
-  0x2f, 0x2f, 0x31, 0x39, 0x32, 0x2e, 0x31, 0x36,
-  0x38, 0x2e, 0x34, 0x32, 0x2e, 0x31, 0x32, 0x35,
-  0x3a, 0x37, 0x36, 0x37, 0x36, 0x2f, 0x73, 0x6d,
-  0x70, 0x5f, 0x31, 0x39, 0x5f, 0x23, 0x53, 0x48,
-  0x50, 0x2c, 0x20, 0x55, 0x50, 0x6e, 0x50, 0x2f,
-  0x31, 0x2e, 0x30, 0x2c, 0x20, 0x53, 0x61, 0x6d,
-  0x73, 0x75, 0x6e, 0x67, 0x20, 0x55, 0x50, 0x6e,
-  0x50, 0x20, 0x53, 0x44, 0x4b, 0x2f, 0x31, 0x2e,
-  0x30, 0x22, 0x68, 0x74, 0x74, 0x70, 0x3a, 0x2f,
-  0x2f, 0x31, 0x39, 0x32, 0x2e, 0x31, 0x36, 0x38,
-  0x2e, 0x34, 0x32, 0x2e, 0x31, 0x32, 0x35, 0x3a,
-  0x37, 0x36, 0x37, 0x36, 0x2f, 0x73, 0x6d, 0x70,
-  0x5f, 0x31, 0x39, 0x5f, 0x23, 0x53, 0x48, 0x50,
-  0x2c, 0x20, 0x55, 0x50, 0x6e, 0x50, 0x2f, 0x31,
-  0x2e, 0x30, 0x2c, 0x20, 0x53, 0x61, 0x6d, 0x73,
-  0x75, 0x6e, 0x67, 0x20, 0x55, 0x50, 0x6e, 0x50,
-  0x20, 0x53, 0x44, 0x4b, 0x2f, 0x31, 0x2e, 0x30,
-  0x22, 0x68, 0x74, 0x74, 0x70, 0x3a, 0x2f, 0x2f,
-  0x31, 0x39, 0x32, 0x2e, 0x31, 0x36, 0x38, 0x2e,
-  0x34, 0x32, 0x2e, 0x31, 0x32, 0x35, 0x3a, 0x37,
-  0x36, 0x37, 0x36, 0x2f, 0x73, 0x6d, 0x70, 0x5f,
-  0x31, 0x39, 0x5f, 0x23, 0x53, 0x48, 0x50, 0x2c,
-  0x20, 0x55, 0x50, 0x6e, 0x50, 0x2f, 0x31, 0x2e,
-  0x30, 0x2c, 0x20, 0x53, 0x61, 0x6d, 0x73, 0x75,
-  0x6e, 0x67, 0x20, 0x55, 0x50, 0x6e, 0x50, 0x20,
-  0x53, 0x44, 0x4b, 0x2f, 0x31, 0x2e, 0x30, 0x22,
-  0x68, 0x74, 0x74, 0x70, 0x3a, 0x2f, 0x2f, 0x31,
-  0x39, 0x32, 0x2e, 0x31, 0x36, 0x38, 0x2e, 0x34,
-  0x32, 0x2e, 0x31, 0x32, 0x35, 0x3a, 0x37, 0x36,
-  0x37, 0x36, 0x2f, 0x73, 0x6d, 0x70, 0x5f, 0x31,
-  0x31, 0x5f, 0x23, 0x53, 0x48, 0x50, 0x2c, 0x20,
-  0x55, 0x50, 0x6e, 0x50, 0x2f, 0x31, 0x2e, 0x30,
-  0x2c, 0x20, 0x53, 0x61, 0x6d, 0x73, 0x75, 0x6e,
-  0x67, 0x20, 0x55, 0x50, 0x6e, 0x50, 0x20, 0x53,
-  0x44, 0x4b, 0x2f, 0x31, 0x2e, 0x30, 0x22, 0x68,
-  0x74, 0x74, 0x70, 0x3a, 0x2f, 0x2f, 0x31, 0x39,
-  0x32, 0x2e, 0x31, 0x36, 0x38, 0x2e, 0x34, 0x32,
-  0x2e, 0x31, 0x32, 0x35, 0x3a, 0x37, 0x36, 0x37,
-  0x36, 0x2f, 0x73, 0x6d, 0x70, 0x5f, 0x31, 0x31,
-  0x5f, 0x23, 0x53, 0x48, 0x50, 0x2c, 0x20, 0x55,
-  0x50, 0x6e, 0x50, 0x2f, 0x31, 0x2e, 0x30, 0x2c,
-  0x20, 0x53, 0x61, 0x6d, 0x73, 0x75, 0x6e, 0x67,
-  0x20, 0x55, 0x50, 0x6e, 0x50, 0x20, 0x53, 0x44,
-  0x4b, 0x2f, 0x31, 0x2e, 0x30, 0x22, 0x68, 0x74,
-  0x74, 0x70, 0x3a, 0x2f, 0x2f, 0x31, 0x39, 0x32,
-  0x2e, 0x31, 0x36, 0x38, 0x2e, 0x34, 0x32, 0x2e,
-  0x31, 0x32, 0x35, 0x3a, 0x37, 0x36, 0x37, 0x36,
-  0x2f, 0x73, 0x6d, 0x70, 0x5f, 0x31, 0x31, 0x5f,
-  0x23, 0x53, 0x48, 0x50, 0x2c, 0x20, 0x55, 0x50,
-  0x6e, 0x50, 0x2f, 0x31, 0x2e, 0x30, 0x2c, 0x20,
-  0x53, 0x61, 0x6d, 0x73, 0x75, 0x6e, 0x67, 0x20,
-  0x55, 0x50, 0x6e, 0x50, 0x20, 0x53, 0x44, 0x4b,
-  0x2f, 0x31, 0x2e, 0x30, 0x22, 0x68, 0x74, 0x74,
-  0x70, 0x3a, 0x2f, 0x2f, 0x31, 0x39, 0x32, 0x2e,
-  0x31, 0x36, 0x38, 0x2e, 0x34, 0x32, 0x2e, 0x31,
-  0x32, 0x35, 0x3a, 0x37, 0x36, 0x37, 0x36, 0x2f,
-  0x73, 0x6d, 0x70, 0x5f, 0x31, 0x31, 0x5f, 0x23,
-  0x53, 0x48, 0x50, 0x2c, 0x20, 0x55, 0x50, 0x6e,
-  0x50, 0x2f, 0x31, 0x2e, 0x30, 0x2c, 0x20, 0x53,
-  0x61, 0x6d, 0x73, 0x75, 0x6e, 0x67, 0x20, 0x55,
-  0x50, 0x6e, 0x50, 0x20, 0x53, 0x44, 0x4b, 0x2f,
-  0x31, 0x2e, 0x30, 0x21, 0x68, 0x74, 0x74, 0x70,
-  0x3a, 0x2f, 0x2f, 0x31, 0x39, 0x32, 0x2e, 0x31,
-  0x36, 0x38, 0x2e, 0x34, 0x32, 0x2e, 0x31, 0x32,
-  0x35, 0x3a, 0x37, 0x36, 0x37, 0x36, 0x2f, 0x73,
-  0x6d, 0x70, 0x5f, 0x32, 0x5f, 0x23, 0x53, 0x48,
-  0x50, 0x2c, 0x20, 0x55, 0x50, 0x6e, 0x50, 0x2f,
-  0x31, 0x2e, 0x30, 0x2c, 0x20, 0x53, 0x61, 0x6d,
-  0x73, 0x75, 0x6e, 0x67, 0x20, 0x55, 0x50, 0x6e,
-  0x50, 0x20, 0x53, 0x44, 0x4b, 0x2f, 0x31, 0x2e,
-  0x30, 0x21, 0x68, 0x74, 0x74, 0x70, 0x3a, 0x2f,
-  0x2f, 0x31, 0x39, 0x32, 0x2e, 0x31, 0x36, 0x38,
-  0x2e, 0x34, 0x32, 0x2e, 0x31, 0x32, 0x35, 0x3a,
-  0x37, 0x36, 0x37, 0x36, 0x2f, 0x73, 0x6d, 0x70,
-  0x5f, 0x32, 0x5f, 0x23, 0x53, 0x48, 0x50, 0x2c,
-  0x20, 0x55, 0x50, 0x6e, 0x50, 0x2f, 0x31, 0x2e,
-  0x30, 0x2c, 0x20, 0x53, 0x61, 0x6d, 0x73, 0x75,
-  0x6e, 0x67, 0x20, 0x55, 0x50, 0x6e, 0x50, 0x20,
-  0x53, 0x44, 0x4b, 0x2f, 0x31, 0x2e, 0x30, 0x21,
-  0x68, 0x74, 0x74, 0x70, 0x3a, 0x2f, 0x2f, 0x31,
-  0x39, 0x32, 0x2e, 0x31, 0x36, 0x38, 0x2e, 0x34,
-  0x32, 0x2e, 0x31, 0x32, 0x35, 0x3a, 0x37, 0x36,
-  0x37, 0x36, 0x2f, 0x73, 0x6d, 0x70, 0x5f, 0x32,
-  0x5f, 0x23, 0x53, 0x48, 0x50, 0x2c, 0x20, 0x55,
-  0x50, 0x6e, 0x50, 0x2f, 0x31, 0x2e, 0x30, 0x2c,
-  0x20, 0x53, 0x61, 0x6d, 0x73, 0x75, 0x6e, 0x67,
-  0x20, 0x55, 0x50, 0x6e, 0x50, 0x20, 0x53, 0x44,
-  0x4b, 0x2f, 0x31, 0x2e, 0x30, 0x21, 0x68, 0x74,
-  0x74, 0x70, 0x3a, 0x2f, 0x2f, 0x31, 0x39, 0x32,
-  0x2e, 0x31, 0x36, 0x38, 0x2e, 0x34, 0x32, 0x2e,
-  0x31, 0x32, 0x35, 0x3a, 0x37, 0x36, 0x37, 0x36,
-  0x2f, 0x73, 0x6d, 0x70, 0x5f, 0x32, 0x5f, 0x23,
-  0x53, 0x48, 0x50, 0x2c, 0x20, 0x55, 0x50, 0x6e,
-  0x50, 0x2f, 0x31, 0x2e, 0x30, 0x2c, 0x20, 0x53,
-  0x61, 0x6d, 0x73, 0x75, 0x6e, 0x67, 0x20, 0x55,
-  0x50, 0x6e, 0x50, 0x20, 0x53, 0x44, 0x4b, 0x2f,
-  0x31, 0x2e, 0x30, 0x00, 0x00, 0x00, 0x00, 0x00
-};
-
-
-char *get_test_ssdp_data()
-{
-  size_t len = sizeof(test_ssdp_data);
-  char *buffer = (char *)malloc(len);
-
-  if (buffer == NULL) {
-    perror("malloc failed");
-    exit(1);
-  }
-
-  memcpy(buffer, test_ssdp_data, len);
-  return buffer;
-}
-
-
-void get_test_l2_map(L2Map *l2map)
-{
-  (*l2map)[std::string("192.168.42.125")] = std::string("00:01:02:03:04:05");
-}
diff --git a/cmds/test-dhcpnametax.sh b/cmds/test-dhcpnametax.sh
new file mode 100755
index 0000000..73be720
--- /dev/null
+++ b/cmds/test-dhcpnametax.sh
@@ -0,0 +1,30 @@
+#!/bin/bash
+. ./wvtest/wvtest.sh
+
+pid=$$
+TAX=./host-dhcpnametax
+
+WVSTART "dhcpnametax test"
+
+WVPASSEQ "$($TAX -l label -d 1,3,6,12,15,28,42 -h Hopper_ETH0)" "name label DISH Networks Hopper;Hopper"
+WVPASSEQ "$($TAX -l label -d 3,1,252,42,15,6,12 -h steamlink)" "name label Steam Link;Steam Link"
+
+# Test serial numbers with special format handling
+WVPASSEQ "$($TAX -l label -d 1,3,6,15,12 -h NP-1G0123456789)" "name label Roku;Roku 3 4200X"
+WVPASSEQ "$($TAX -l label -d 1,3,6,15,12 -h NP-120123456789)" "name label Roku;Roku 2 XD 3050"
+WVPASSEQ "$($TAX -l label -d 1,28,2,3,15,6,12 -h TIVO-848XXXXXXXXXXXX)" "name label TiVo;TiVo Roamio Plus"
+WVPASSEQ "$($TAX -l label -d 1,28,2,3,15,6,12 -h TIVO-849XXXXXXXXXXXX)" "name label TiVo;TiVo BOLT"
+WVPASSEQ "$($TAX -l label -d 3,1,252,42,15,6,12 -h 01AA01AB23456789)" "name label Nest Thermostat;Nest Thermostat v1"
+WVPASSEQ "$($TAX -l label -d 3,1,252,42,15,6,12 -h 09AA01AB23456789)" "name label Nest Thermostat;Nest Thermostat v3"
+WVPASSEQ "$($TAX -l label -d 1,3,6,12,15,28,42 -h XL824-XXXXXXX)" "name label Trane Thermostat;XL824"
+WVPASSEQ "$($TAX -l label -d 1,3,6,12,15,28,40,41,42 -h DIRECTV-H21-01234567)" "name label DirecTV;H21"
+WVPASSEQ "$($TAX -l label -d 1,3,6,12,15,28,42 -h DIRECTV-HR22-01234567)" "name label DirecTV;HR22"
+WVPASSEQ "$($TAX -l label -d 1,28,2,3,15,6,119,12,44,47,26,121,42 -h 500-cc04b40XXXXX)" "name label Select Comfort SleepIQ;SleepIQ"
+
+# check invalid or missing arguments.
+WVFAIL $TAX
+WVFAIL $TAX -m mac
+WVFAIL $TAX -h hostname
+WVFAIL $TAX -d dhcpsig
+
+rm -f *.$pid.tmp
diff --git a/conman/connection_manager.py b/conman/connection_manager.py
index a2de7d6..aa1b320 100755
--- a/conman/connection_manager.py
+++ b/conman/connection_manager.py
@@ -12,6 +12,9 @@
 import subprocess
 import time
 
+# This is in site-packages on the device, but not when running tests, and so
+# raises lint errors.
+# pylint: disable=g-bad-import-order
 import pyinotify
 
 import cycler
@@ -50,21 +53,21 @@
 class WLANConfiguration(object):
   """Represents a WLAN configuration from cwmpd."""
 
-  WIFI_STOPAP = ['wifi', 'stopap']
+  WIFI_STOPAP = ['wifi', 'stopap', '--persist']
   WIFI_SETCLIENT = ['wifi', 'setclient', '--persist']
-  WIFI_STOPCLIENT = ['wifi', 'stopclient']
+  WIFI_STOPCLIENT = ['wifi', 'stopclient', '--persist']
 
-  def __init__(self, band, wifi, command_lines, _status):
+  def __init__(self, band, wifi, command_lines, _status, wpa_control_interface):
     self.band = band
     self.wifi = wifi
     self.command = command_lines.splitlines()
     self.access_point_up = False
-    self.client_up = False
     self.ssid = None
     self.passphrase = None
     self.interface_suffix = None
     self.access_point = None
     self._status = _status
+    self._wpa_control_interface = wpa_control_interface
 
     binwifi_option_attrs = {
         '-s': 'ssid',
@@ -89,7 +92,12 @@
 
     if self.wifi.initial_ssid == self.ssid:
       logging.debug('Connected to WLAN at startup')
-      self.client_up = True
+
+  @property
+  def client_up(self):
+    wpa_cli_status = self.wifi.wpa_cli_status()
+    return (wpa_cli_status.get('wpa_state') == 'COMPLETED'
+            and wpa_cli_status.get('ssid') == self.ssid)
 
   def start_access_point(self):
     """Start an access point."""
@@ -127,12 +135,11 @@
 
   def start_client(self):
     """Join the WLAN as a client."""
-    if self.client_up:
+    up = self.client_up
+    if up:
       logging.debug('Wifi client already started on %s GHz', self.band)
       return
 
-    self.wifi.detach_wpa_control()
-
     command = self.WIFI_SETCLIENT + ['--ssid', self.ssid, '--band', self.band]
     env = dict(os.environ)
     if self.passphrase:
@@ -140,11 +147,13 @@
     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)
+      return
+
+    self._status.connected_to_wlan = True
+    logging.info('Started wifi client on %s GHz', self.band)
+    self.wifi.attach_wpa_control(self._wpa_control_interface)
 
   def stop_client(self):
     if not self.client_up:
@@ -156,7 +165,6 @@
     try:
       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)
@@ -170,6 +178,7 @@
   # pylint: disable=invalid-name
   Bridge = interface.Bridge
   Wifi = interface.Wifi
+  FrenzyWifi = interface.FrenzyWifi
   WLANConfiguration = WLANConfiguration
 
   ETHERNET_STATUS_FILE = 'eth0'
@@ -184,6 +193,7 @@
   IFUP = ['ifup']
   IP_LINK = ['ip', 'link']
   IFPLUGD_ACTION = ['/etc/ifplugd/ifplugd.action']
+  BINWIFI = ['wifi']
 
   def __init__(self,
                bridge_interface='br0',
@@ -220,23 +230,7 @@
         bridge_interface, '10',
         acs_autoprovisioning_filepath=acs_autoprov_filepath)
 
-    # If we have multiple wcli interfaces, 5 GHz-only < both < 2.4 GHz-only.
-    def metric_for_bands(bands):
-      if '5' in bands:
-        if '2.4' in bands:
-          return interface.METRIC_24GHZ_5GHZ
-        return interface.METRIC_5GHZ
-      return interface.METRIC_24GHZ
-
-    self.wifi = sorted([self.Wifi(interface_name, metric_for_bands(bands),
-                                  # Prioritize 5 GHz over 2.4.
-                                  bands=sorted(bands, reverse=True))
-                        for interface_name, bands
-                        in get_client_interfaces().iteritems()],
-                       key=lambda w: w.metric)
-
-    for wifi in self.wifi:
-      wifi.last_wifi_scan_time = -self._wifi_scan_period_s
+    self.create_wifi_interfaces()
 
     self._status = status.Status(self._status_dir)
 
@@ -268,7 +262,8 @@
         wifi_up = self.is_interface_up(wifi.name)
         self.ifplugd_action(wifi.name, wifi_up)
         if wifi_up:
-          wifi.attach_wpa_control(self._wpa_control_interface)
+          self._status.attached_to_wpa_supplicant = wifi.attach_wpa_control(
+              self._wpa_control_interface)
 
     for path, prefix in ((self._tmp_dir, self.GATEWAY_FILE_PREFIX),
                          (self._interface_status_dir, ''),
@@ -277,7 +272,25 @@
       for filepath in glob.glob(os.path.join(path, prefix + '*')):
         self._process_file(path, os.path.split(filepath)[-1])
 
-    # Now that we've ready any existing state, it's okay to let interfaces touch
+    # Make sure no unwanted APs or clients are running.
+    for wifi in self.wifi:
+      for band in wifi.bands:
+        config = self._wlan_configuration.get(band, None)
+        if config:
+          if config.access_point:
+            # If we have a config and want an AP, we don't want a client.
+            self._stop_wifi(band, False, True)
+          else:
+            # If we have a config but don't want an AP, make sure we aren't
+            # running one.
+            self._stop_wifi(band, True, False)
+          break
+      else:
+        # If we have no config for this radio, neither a client nor an AP should
+        # be running.
+        self._stop_wifi(wifi.bands[0], True, True)
+
+    # Now that we've read any existing state, it's okay to let interfaces touch
     # the routing table.
     for ifc in [self.bridge] + self.wifi:
       ifc.initialize()
@@ -286,6 +299,32 @@
     self._interface_update_counter = 0
     self._try_wlan_after = {'5': 0, '2.4': 0}
 
+  def create_wifi_interfaces(self):
+    """Create Wifi interfaces."""
+
+    # If we have multiple client interfaces, 5 GHz-only < both < 2.4 GHz-only.
+    def metric_for_bands(bands):
+      if '5' in bands:
+        if '2.4' in bands:
+          return interface.METRIC_24GHZ_5GHZ
+        return interface.METRIC_5GHZ
+      return interface.METRIC_24GHZ
+
+    def wifi_class(attrs):
+      return self.FrenzyWifi if 'frenzy' in attrs else self.Wifi
+
+    self.wifi = sorted([
+        wifi_class(attrs)(interface_name,
+                          metric_for_bands(attrs['bands']),
+                          # Prioritize 5 GHz over 2.4.
+                          bands=sorted(attrs['bands'], reverse=True))
+        for interface_name, attrs
+        in get_client_interfaces().iteritems()
+    ], key=lambda w: w.metric)
+
+    for wifi in self.wifi:
+      wifi.last_wifi_scan_time = -self._wifi_scan_period_s
+
   def is_interface_up(self, interface_name):
     """Explicitly check whether an interface is up.
 
@@ -349,11 +388,6 @@
 
     for wifi in self.wifi:
       continue_wifi = False
-      if not wifi.attached():
-        logging.debug('Attempting to attach to wpa control interface for %s',
-                      wifi.name)
-        wifi.attach_wpa_control(self._wpa_control_interface)
-      wifi.handle_wpa_events()
 
       # Only one wlan_configuration per interface will have access_point ==
       # True.  Try 5 GHz first, then 2.4 GHz.  If both bands are supported by
@@ -375,6 +409,13 @@
           if wlan_configuration.access_point_up:
             continue_wifi = True
 
+      if not wifi.attached():
+        logging.debug('Attempting to attach to wpa control interface for %s',
+                      wifi.name)
+        self._status.attached_to_wpa_supplicant = wifi.attach_wpa_control(
+            self._wpa_control_interface)
+      wifi.handle_wpa_events()
+
       if continue_wifi:
         logging.debug('Running AP on %s, nothing else to do.', wifi.name)
         continue
@@ -550,7 +591,8 @@
           wifi = self.wifi_for_band(band)
           if wifi:
             self._update_wlan_configuration(
-                self.WLANConfiguration(band, wifi, contents, self._status))
+                self.WLANConfiguration(band, wifi, contents, self._status,
+                                       self._wpa_control_interface))
       elif filename.startswith(self.ACCESS_POINT_FILE_PREFIX):
         match = re.match(self.ACCESS_POINT_FILE_REGEXP, filename)
         if match:
@@ -610,20 +652,22 @@
     logging.info('Scanning on %s...', wifi.name)
     wifi.last_wifi_scan_time = time.time()
     subprocess.call(self.IFUP + [wifi.name])
-    with_ie, without_ie = self._find_bssids(wifi.name)
+    # /bin/wifi takes a --band option but then finds the right interface for it,
+    # so it's okay to just pick the first band here.
+    with_ie, without_ie = self._find_bssids(wifi.bands[0])
     logging.info('Done scanning on %s', wifi.name)
     items = [(bss_info, 3) for bss_info in with_ie]
     items += [(bss_info, 1) for bss_info in without_ie]
     wifi.cycler = cycler.AgingPriorityCycler(cycle_length_s=30, items=items)
 
-  def _find_bssids(self, wcli):
+  def _find_bssids(self, band):
     def supports_autoprovisioning(oui, vendor_ie):
       if oui not in GFIBER_OUIS:
         return False
 
       return vendor_ie.startswith(VENDOR_IE_FEATURE_ID_AUTOPROVISIONING)
 
-    return iw.find_bssids(wcli, supports_autoprovisioning, False)
+    return iw.find_bssids(band, supports_autoprovisioning, False)
 
   def _try_next_bssid(self, wifi):
     """Attempt to connect to the next BSSID in wifi's BSSID cycler.
@@ -701,22 +745,86 @@
       wlan_configuration.stop_access_point()
       wlan_configuration.start_client()
 
+  def _stop_wifi(self, band, stopap, stopclient):
+    """Stop running wifi processes.
+
+    At least one of [stopap, stopclient] must be True.
+
+    Args:
+      band:  The band on which to stop wifi.
+      stopap:  Whether to stop access points.
+      stopclient:  Whether to stop wifi clients.
+
+    Raises:
+      ValueError:  If neither stopap nor stopclient is True.
+    """
+    if stopap and stopclient:
+      command = 'stop'
+    elif stopap:
+      command = 'stopap'
+    elif stopclient:
+      command = 'stopclient'
+    else:
+      raise ValueError('Called _stop_wifi without specifying AP or client.')
+
+    full_command = [command, '--band', band, '--persist']
+
+    try:
+      self._binwifi(*full_command)
+    except subprocess.CalledProcessError as e:
+      logging.error('wifi %s failed: "%s"', ' '.join(full_command), e.output)
+
+  def _binwifi(self, *command):
+    """Test seam for calls to /bin/wifi.
+
+    Only used by _stop_wifi, and probably shouldn't be used by anything else.
+
+    Args:
+      *command:  A command for /bin/wifi
+
+    Raises:
+      subprocess.CalledProcessError:  If the command fails.  Deliberately not
+      handled here to make future authors think twice before using this.
+    """
+    subprocess.check_output(self.BINWIFI + list(command),
+                            stderr=subprocess.STDOUT)
+
 
 def _wifi_show():
   try:
     return subprocess.check_output(['wifi', 'show'])
   except subprocess.CalledProcessError as e:
     logging.error('Failed to call "wifi show": %s', e)
+    return ''
+
+
+def _get_quantenna_interface():
+  try:
+    return subprocess.check_output(['get-quantenna-interface']).strip()
+  except subprocess.CalledProcessError:
+    logging.fatal('Failed to call get-quantenna-interface')
+    raise
 
 
 def get_client_interfaces():
+  """Find all client interfaces on the device.
+
+  Returns:
+    A dict mapping wireless client interfaces to their supported bands.
+  """
+
   current_band = None
-  result = collections.defaultdict(set)
+  result = collections.defaultdict(lambda: collections.defaultdict(set))
   for line in _wifi_show().splitlines():
     if line.startswith('Band:'):
       current_band = line.split()[1]
     elif line.startswith('Client Interface:'):
-      result[line.split()[2]].add(current_band)
+      result[line.split()[2]]['bands'].add(current_band)
+
+  # TODO(rofrankel):  Make 'wifi show' (or wifi_files) include this information
+  # so we don't need a subprocess call to check.
+  quantenna_interface = _get_quantenna_interface()
+  if quantenna_interface in result:
+    result[quantenna_interface]['frenzy'] = True
 
   return result
-
diff --git a/conman/connection_manager_test.py b/conman/connection_manager_test.py
index 6b1142d..d96022e 100755
--- a/conman/connection_manager_test.py
+++ b/conman/connection_manager_test.py
@@ -27,7 +27,7 @@
 }
 """
 
-WIFI_SHOW_OUTPUT_ONE_RADIO = """Band: 2.4
+WIFI_SHOW_OUTPUT_MARVELL8897 = """Band: 2.4
 RegDomain: US
 Interface: wlan0  # 2.4 GHz ap
 Channel: 149
@@ -52,7 +52,7 @@
 Client BSSID: f4:f5:e8:81:1b:a1
 """
 
-WIFI_SHOW_OUTPUT_TWO_RADIOS = """Band: 2.4
+WIFI_SHOW_OUTPUT_ATH9K_ATH10K = """Band: 2.4
 RegDomain: US
 Interface: wlan0  # 2.4 GHz ap
 Channel: 149
@@ -78,7 +78,7 @@
 """
 
 # See b/27328894.
-WIFI_SHOW_OUTPUT_ONE_RADIO_NO_5GHZ = """Band: 2.4
+WIFI_SHOW_OUTPUT_MARVELL8897_NO_5GHZ = """Band: 2.4
 RegDomain: 00
 Interface: wlan0  # 2.4 GHz ap
 BSSID: 00:50:43:02:fe:01
@@ -92,29 +92,86 @@
 RegDomain: 00
 """
 
-IW_SCAN_OUTPUT = """BSS 00:11:22:33:44:55(on wcli0)
+WIFI_SHOW_OUTPUT_ATH9K_FRENZY = """Band: 2.4
+RegDomain: US
+Interface: wlan0  # 2.4 GHz ap
+Channel: 149
+BSSID: f4:f5:e8:81:1b:a0
+AutoChannel: True
+AutoType: NONDFS
+Station List for band: 2.4
+
+Client Interface: wcli0  # 2.4 GHz client
+Client BSSID: f4:f5:e8:81:1b:a1
+
+Band: 5
+RegDomain: 00
+Interface: wlan0  # 5 GHz ap
+AutoChannel: False
+Station List for band: 5
+
+Client Interface: wlan1  # 5 GHz client
+"""
+
+WIFI_SHOW_OUTPUT_FRENZY = """Band: 2.4
+RegDomain: 00
+Band: 5
+RegDomain: 00
+Interface: wlan0  # 5 GHz ap
+AutoChannel: False
+Station List for band: 5
+
+Client Interface: wlan0  # 5 GHz client
+"""
+
+IW_SCAN_DEFAULT_OUTPUT = """BSS 00:11:22:33:44:55(on wcli0)
   SSID: s1
-  Vendor specific: OUI f4:f5:e8, data: 01
 BSS 66:77:88:99:aa:bb(on wcli0)
   SSID: s1
-  Vendor specific: OUI f4:f5:e8, data: 01
 BSS 01:23:45:67:89:ab(on wcli0)
   SSID: s2
 """
 
+IW_SCAN_HIDDEN_OUTPUT = """BSS ff:ee:dd:cc:bb:aa(on wcli0)
+  Vendor specific: OUI f4:f5:e8, data: 01
+  Vendor specific: OUI f4:f5:e8, data: 03 73 33
+"""
+
 
 @wvtest.wvtest
 def get_client_interfaces_test():
   """Test get_client_interfaces."""
   # pylint: disable=protected-access
   original_wifi_show = connection_manager._wifi_show
-  connection_manager._wifi_show = lambda: WIFI_SHOW_OUTPUT_ONE_RADIO
+  original_get_quantenna_interface = connection_manager._get_quantenna_interface
+  connection_manager._get_quantenna_interface = lambda: ''
+  connection_manager._wifi_show = lambda: WIFI_SHOW_OUTPUT_MARVELL8897
   wvtest.WVPASSEQ(connection_manager.get_client_interfaces(),
-                  {'wcli0': set(['2.4', '5'])})
-  connection_manager._wifi_show = lambda: WIFI_SHOW_OUTPUT_TWO_RADIOS
+                  {'wcli0': {'bands': set(['2.4', '5'])}})
+  connection_manager._wifi_show = lambda: WIFI_SHOW_OUTPUT_ATH9K_ATH10K
+  wvtest.WVPASSEQ(connection_manager.get_client_interfaces(), {
+      'wcli0': {'bands': set(['2.4'])},
+      'wcli1': {'bands': set(['5'])}
+  })
+
+  # Test Quantenna devices.
+
+  # 2.4 GHz cfg80211 radio + 5 GHz Frenzy (Optimus Prime).
+  connection_manager._wifi_show = lambda: WIFI_SHOW_OUTPUT_ATH9K_FRENZY
+  connection_manager._get_quantenna_interface = lambda: 'wlan1'
+  wvtest.WVPASSEQ(connection_manager.get_client_interfaces(), {
+      'wcli0': {'bands': set(['2.4'])},
+      'wlan1': {'frenzy': True, 'bands': set(['5'])}
+  })
+
+  # Only Frenzy (e.g. Lockdown).
+  connection_manager._wifi_show = lambda: WIFI_SHOW_OUTPUT_FRENZY
+  connection_manager._get_quantenna_interface = lambda: 'wlan0'
   wvtest.WVPASSEQ(connection_manager.get_client_interfaces(),
-                  {'wcli0': set(['2.4']), 'wcli1': set(['5'])})
+                  {'wlan0': {'frenzy': True, 'bands': set(['5'])}})
+
   connection_manager._wifi_show = original_wifi_show
+  connection_manager._get_quantenna_interface = original_get_quantenna_interface
 
 
 class WLANConfiguration(connection_manager.WLANConfiguration):
@@ -125,16 +182,26 @@
   WIFI_STOPCLIENT = ['echo', 'stopclient']
 
   def start_client(self):
-    if not self.client_up:
+    client_was_up = self.client_up
+    was_attached = self.wifi.attached()
+    # Do this before calling the super method so that the attach call at the end
+    # succeeds.
+    if not client_was_up and not was_attached:
+      self.wifi._initial_ssid_testonly = self.ssid
+      self.wifi.start_wpa_supplicant_testonly(self._wpa_control_interface)
+
+    super(WLANConfiguration, self).start_client()
+
+    if not client_was_up:
       self.wifi.set_connection_check_result('succeed')
 
-      if self.wifi.attached():
+      if was_attached:
+        self.wifi._wpa_control.ssid_testonly = self.ssid
         self.wifi.add_connected_event()
-      else:
-        open(self._socket(), 'w')
 
-      # Normally, wpa_supplicant would bring up wcli*, which would trigger
-      # ifplugd, which would run ifplugd.action, which would do two things:
+      # Normally, wpa_supplicant would bring up the client interface, which
+      # would trigger ifplugd, which would run ifplugd.action, which would do
+      # two things:
       #
       # 1)  Write an interface status file.
       # 2)  Call run-dhclient, which would call dhclient-script, which would
@@ -144,22 +211,18 @@
       self.write_interface_status_file('1')
       self.write_gateway_file()
 
-    super(WLANConfiguration, self).start_client()
-
   def stop_client(self):
-    if self.client_up:
+    client_was_up = self.client_up
+
+    super(WLANConfiguration, self).stop_client()
+
+    if client_was_up:
       self.wifi.add_terminating_event()
-      os.unlink(self._socket())
       self.wifi.set_connection_check_result('fail')
 
     # See comments in start_client.
     self.write_interface_status_file('0')
 
-    super(WLANConfiguration, self).stop_client()
-
-  def _socket(self):
-    return os.path.join(self._wpa_control_interface, self.wifi.name)
-
   def write_gateway_file(self):
     gateway_file = os.path.join(self.tmp_dir,
                                 self.gateway_file_prefix + self.wifi.name)
@@ -178,8 +241,13 @@
 
   def __init__(self, *args, **kwargs):
     super(Wifi, self).__init__(*args, **kwargs)
-    # Whether wpa_supplicant is connected to a network.
-    self._initially_connected = True
+    self.wifi_scan_counter = 0
+
+
+class FrenzyWifi(interface_test.FrenzyWifi):
+
+  def __init__(self, *args, **kwargs):
+    super(FrenzyWifi, self).__init__(*args, **kwargs)
     self.wifi_scan_counter = 0
 
 
@@ -189,21 +257,26 @@
   # pylint: disable=invalid-name
   Bridge = interface_test.Bridge
   Wifi = Wifi
+  FrenzyWifi = FrenzyWifi
   WLANConfiguration = WLANConfiguration
 
   WIFI_SETCLIENT = ['echo', 'setclient']
   IFUP = ['echo', 'ifup']
   IFPLUGD_ACTION = ['echo', 'ifplugd.action']
+  BINWIFI = ['echo', 'wifi']
 
   def __init__(self, *args, **kwargs):
+    self._binwifi_commands = []
+
     self.interfaces_already_up = kwargs.pop('__test_interfaces_already_up',
                                             ['eth0'])
 
-    wifi_interfaces_already_up = [ifc for ifc in self.interfaces_already_up
-                                  if ifc.startswith('wcli')]
-    for wifi in wifi_interfaces_already_up:
-      # wcli1 is always 5 GHz.  wcli0 always *includes* 2.4.
-      band = '5' if wifi == 'wcli1' else '2.4'
+    self.wifi_interfaces_already_up = [ifc for ifc in self.interfaces_already_up
+                                       if ifc.startswith('w')]
+    for wifi in self.wifi_interfaces_already_up:
+      # wcli1 is always 5 GHz.  wcli0 always *includes* 2.4.  wlan* client
+      # interfaces are Frenzy interfaces and therefore 5 GHz-only.
+      band = '5' if wifi in ('wlan0', 'wlan1', 'wcli1') else '2.4'
       # This will happen in the super function, but in order for
       # write_wlan_config to work we have to do it now.  This has to happen
       # before the super function so that the files exist before the inotify
@@ -216,16 +289,20 @@
 
     super(ConnectionManager, self).__init__(*args, **kwargs)
 
-    for wifi in wifi_interfaces_already_up:
-      # pylint: disable=protected-access
-      self.interface_by_name(wifi)._initially_connected = True
-
-    self.scan_has_results = False
+    self.interface_with_scan_results = None
+    self.scan_results_include_hidden = False
     # Should we be able to connect to open network s2?
-    self.s2_connect = True
+    self.can_connect_to_s2 = True
+    self.can_connect_to_s3 = True
     # Will s2 fail rather than providing ACS access?
     self.s2_fail = False
 
+  def create_wifi_interfaces(self):
+    super(ConnectionManager, self).create_wifi_interfaces()
+    for wifi in self.wifi_interfaces_already_up:
+      # pylint: disable=protected-access
+      self.interface_by_name(wifi)._initial_ssid_testonly = 'my ssid'
+
   @property
   def IP_LINK(self):
     return ['echo'] + ['%s LOWER_UP' % ifc
@@ -240,50 +317,45 @@
         wifi.add_terminating_event()
 
   def _try_bssid(self, wifi, bss_info):
+    self.last_provisioning_attempt = bss_info
+
     super(ConnectionManager, self)._try_bssid(wifi, bss_info)
 
-    socket = os.path.join(self._wpa_control_interface, wifi.name)
-
-    if bss_info and bss_info.ssid == 's1':
+    def connect(connection_check_result):
+      # pylint: disable=protected-access
       if wifi.attached():
+        wifi._wpa_control._ssid_testonly = bss_info.ssid
         wifi.add_connected_event()
       else:
-        open(socket, 'w')
-      wifi.set_connection_check_result('fail')
-      self.write_interface_status_file(wifi.name, '1')
+        wifi._initial_ssid_testonly = bss_info.ssid
+        wifi.start_wpa_supplicant_testonly(self._wpa_control_interface)
+      wifi.set_connection_check_result(connection_check_result)
+      self.ifplugd_action(wifi.name, True)
+
+    if bss_info and bss_info.ssid == 's1':
+      connect('fail')
       return True
 
-    if bss_info and bss_info.ssid == 's2':
-      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:
-        logging.debug('s2 configured not to connect')
+    if bss_info and bss_info.ssid == 's2' and self.can_connect_to_s2:
+      connect('fail' if self.s2_fail else 'succeed')
+      return True
+
+    if bss_info and bss_info.ssid == 's3' and self.can_connect_to_s3:
+      connect('restricted')
+      return True
 
     return False
 
-  def _wifi_stopclient(self, band):
-    super(ConnectionManager, self)._wifi_stopclient(band)
-    self.wifi_for_band(band).add_terminating_event()
-
   # pylint: disable=unused-argument,protected-access
-  def _find_bssids(self, wcli):
-    # Only the 5 GHz scan finds anything.
-    if wcli == 'wcli0' and self.scan_has_results:
-      iw._scan = lambda interface: IW_SCAN_OUTPUT
-    else:
-      iw._scan = lambda interface: ''
-    return super(ConnectionManager, self)._find_bssids(wcli)
+  def _find_bssids(self, band):
+    scan_output = ''
+    if (self.interface_with_scan_results and
+        band in self.interface_by_name(self.interface_with_scan_results).bands):
+      scan_output = IW_SCAN_DEFAULT_OUTPUT
+      if self.scan_results_include_hidden:
+        scan_output += IW_SCAN_HIDDEN_OUTPUT
+    iw._scan = lambda interface: scan_output
+    return super(ConnectionManager, self)._find_bssids(band)
 
   def _update_wlan_configuration(self, wlan_configuration):
     wlan_configuration.command.insert(0, 'echo')
@@ -312,6 +384,10 @@
       self.write_gateway_file('br0' if interface_name in ('eth0', 'moca0')
                               else interface_name)
 
+  def _binwifi(self, *command):
+    super(ConnectionManager, self)._binwifi(*command)
+    self._binwifi_commands.append(command)
+
   # Non-overrides
 
   def access_point_up(self, band):
@@ -328,31 +404,17 @@
 
   # Test methods
 
-  def wlan_config_filename(self, band):
-    return os.path.join(self._config_dir, 'command.%s' % band)
-
-  def access_point_filename(self, band):
-    return os.path.join(self._config_dir, 'access_point.%s' % band)
-
   def delete_wlan_config(self, band):
-    os.unlink(self.wlan_config_filename(band))
+    delete_wlan_config(self._config_dir, band)
 
-  def write_wlan_config(self, band, ssid, psk, atomic=False):
-    final_filename = self.wlan_config_filename(band)
-    filename = final_filename + ('.tmp' if atomic else '')
-    with open(filename, 'w') as f:
-      f.write('\n'.join(['env', 'WIFI_PSK=%s' % psk,
-                         'wifi', 'set', '-b', band, '--ssid', ssid]))
-    if atomic:
-      os.rename(filename, final_filename)
+  def write_wlan_config(self, *args, **kwargs):
+    write_wlan_config(self._config_dir, *args, **kwargs)
 
   def enable_access_point(self, band):
-    open(self.access_point_filename(band), 'w')
+    enable_access_point(self._config_dir, band)
 
   def disable_access_point(self, band):
-    ap_filename = self.access_point_filename(band)
-    if os.path.isfile(ap_filename):
-      os.unlink(ap_filename)
+    disable_access_point(self._config_dir, band)
 
   def write_gateway_file(self, interface_name):
     gateway_file = os.path.join(self._tmp_dir,
@@ -389,25 +451,71 @@
     while wifi_scan_counter == wifi.wifi_scan_counter:
       self.run_once()
 
+  def run_until_interface_update_and_scan(self, band):
+    wifi = self.wifi_for_band(band)
+    wifi_scan_counter = wifi.wifi_scan_counter
+    self.run_until_interface_update()
+    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):
+def wlan_config_filename(path, band):
+  return os.path.join(path, 'command.%s' % band)
+
+
+def access_point_filename(path, band):
+  return os.path.join(path, 'access_point.%s' % band)
+
+
+def write_wlan_config(path, band, ssid, psk, atomic=False):
+  final_filename = wlan_config_filename(path, band)
+  filename = final_filename + ('.tmp' if atomic else '')
+  with open(filename, 'w') as f:
+    f.write('\n'.join(['env', 'WIFI_PSK=%s' % psk,
+                       'wifi', 'set', '-b', band, '--ssid', ssid]))
+  if atomic:
+    os.rename(filename, final_filename)
+
+
+def delete_wlan_config(path, band):
+  os.unlink(wlan_config_filename(path, band))
+
+
+def enable_access_point(path, band):
+  open(access_point_filename(path, band), 'w')
+
+
+def disable_access_point(path, band):
+  ap_filename = access_point_filename(path, band)
+  if os.path.isfile(ap_filename):
+    os.unlink(ap_filename)
+
+
+def connection_manager_test(radio_config, wlan_configs=None,
+                            quantenna_interface='', **cm_kwargs):
   """Returns a decorator that does ConnectionManager test boilerplate."""
+  if wlan_configs is None:
+    wlan_configs = {}
+
   def inner(f):
     """The actual decorator."""
     def actual_test():
       """The actual test function."""
       run_duration_s = .01
       interface_update_period = 5
-      wifi_scan_period = 5
+      wifi_scan_period = 15
       wifi_scan_period_s = run_duration_s * wifi_scan_period
 
       # pylint: disable=protected-access
       original_wifi_show = connection_manager._wifi_show
       connection_manager._wifi_show = lambda: radio_config
 
+      original_gqi = connection_manager._get_quantenna_interface
+      connection_manager._get_quantenna_interface = lambda: quantenna_interface
+
       try:
         # No initial state.
         tmp_dir = tempfile.mkdtemp()
@@ -415,6 +523,12 @@
         os.mkdir(os.path.join(tmp_dir, 'interfaces'))
         moca_tmp_dir = tempfile.mkdtemp()
         wpa_control_interface = tempfile.mkdtemp()
+        FrenzyWifi.WPACtrl.WIFIINFO_PATH = tempfile.mkdtemp()
+
+        for band, access_point in wlan_configs.iteritems():
+          write_wlan_config(config_dir, band, 'initial ssid', 'initial psk')
+          if access_point:
+            open(os.path.join(config_dir, 'access_point.%s' % band), 'w')
 
         # Test that missing directories are created by ConnectionManager.
         shutil.rmtree(tmp_dir)
@@ -437,8 +551,10 @@
         shutil.rmtree(config_dir)
         shutil.rmtree(moca_tmp_dir)
         shutil.rmtree(wpa_control_interface)
+        shutil.rmtree(FrenzyWifi.WPACtrl.WIFIINFO_PATH)
         # pylint: disable=protected-access
         connection_manager._wifi_show = original_wifi_show
+        connection_manager._get_quantenna_interface = original_gqi
 
     actual_test.func_name = f.func_name
     return actual_test
@@ -446,15 +562,15 @@
   return inner
 
 
-def connection_manager_test_radio_independent(c):
+def connection_manager_test_generic(c, band):
   """Test ConnectionManager for things independent of radio configuration.
 
-  To verify that these things are both independent, this function is called
-  twice below, once with each radio configuration.  Those wrappers have the
-  relevant test decorators.
+  To verify that these things are both independent, this function is called once
+  below with each radio configuration.
 
   Args:
-    c:  A ConnectionManager set up by @connection_manager_test.
+    c:  The ConnectionManager set up by @connection_manager_test.
+    band:  The band to test.
   """
   # This test only checks that this file gets created and deleted once each.
   # ConnectionManager cares that the file is created *where* expected, but it is
@@ -474,8 +590,8 @@
   wvtest.WVPASS(c.internet())
   wvtest.WVPASS(c.bridge.current_route())
   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())
+  for wifi in c.wifi:
+    wvtest.WVFAIL(wifi.current_route())
   wvtest.WVFAIL(c.has_status_files([status.P.CONNECTED_TO_WLAN,
                                     status.P.HAVE_CONFIG]))
 
@@ -533,41 +649,80 @@
   wvtest.WVFAIL(c.bridge.current_route())
 
   # Now there are some scan results.
-  c.scan_has_results = True
+  c.interface_with_scan_results = c.wifi_for_band(band).name
   # Wait for a scan, plus 3 cycles, so that s2 will have been tried.
-  c.run_until_scan('2.4')
+  c.run_until_scan(band)
   for _ in range(3):
     c.run_once()
     wvtest.WVPASS(c.has_status_files([status.P.CONNECTED_TO_OPEN]))
 
-  last_bss_info = c.wifi_for_band('2.4').last_attempted_bss_info
+  last_bss_info = c.wifi_for_band(band).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())
-  wvtest.WVFAIL(c.internet())
-  wvtest.WVFAIL(c.client_up('2.4'))
-  wvtest.WVPASS(c.wifi_for_band('2.4').current_route())
+  wvtest.WVPASS(c.internet())
+  wvtest.WVFAIL(c.client_up(band))
+  wvtest.WVPASS(c.wifi_for_band(band).current_route())
+  # Disable scan results again.
+  c.interface_with_scan_results = None
 
-  # Now, create a WLAN configuration which should be connected to.  Also, test
-  # that atomic writes/renames work.
+  # Now, create a WLAN configuration which should be connected to.
   ssid = 'wlan'
   psk = 'password'
-  c.write_wlan_config('2.4', ssid, psk, atomic=True)
-  c.disable_access_point('2.4')
+  c.write_wlan_config(band, ssid, psk)
+  c.disable_access_point(band)
   c.run_once()
-  wvtest.WVPASS(c.client_up('2.4'))
-  wvtest.WVPASS(c.wifi_for_band('2.4').current_route())
+  wvtest.WVPASS(c.client_up(band))
+  wvtest.WVPASS(c.wifi_for_band(band).current_route())
+  wvtest.WVPASS(c.has_status_files([status.P.CONNECTED_TO_WLAN]))
+
+  # Kill wpa_supplicant.  conman should restart it.
+  wvtest.WVPASS(c.client_up(band))
+  wvtest.WVPASS(c._connected_to_wlan(c.wifi_for_band(band)))
+  c.wifi_for_band(band).kill_wpa_supplicant_testonly(c._wpa_control_interface)
+  wvtest.WVFAIL(c.client_up(band))
+  wvtest.WVFAIL(c._connected_to_wlan(c.wifi_for_band(band)))
+  c.run_once()
+  wvtest.WVPASS(c.has_status_files([status.P.CONNECTED_TO_WLAN]))
+  wvtest.WVPASS(c.client_up(band))
+  wvtest.WVPASS(c._connected_to_wlan(c.wifi_for_band(band)))
+
+  # Now, remove the WLAN configuration and make sure we are disconnected.  Then
+  # disable the previously used ACS connection via s2, re-enable scan results,
+  # add the user's WLAN to the scan results, and scan again.  This time, the
+  # first SSID tried should be 's3', which is now present in the scan results
+  # (with its SSID hidden, but included via vendor IE).
+  c.delete_wlan_config(band)
+  c.can_connect_to_s2 = False
+  c.interface_with_scan_results = c.wifi_for_band(band).name
+  c.scan_results_include_hidden = True
+  c.run_until_interface_update_and_scan(band)
+  wvtest.WVFAIL(c.has_status_files([status.P.CONNECTED_TO_WLAN]))
+  c.run_until_interface_update()
+  wvtest.WVPASS(c.has_status_files([status.P.CONNECTED_TO_OPEN]))
+  wvtest.WVPASSEQ(c.last_provisioning_attempt.ssid, 's3')
+  wvtest.WVPASSEQ(c.last_provisioning_attempt.bssid, 'ff:ee:dd:cc:bb:aa')
+
+  # Now, recreate the same WLAN configuration, which should be connected to.
+  # Also, test that atomic writes/renames work.
+  ssid = 'wlan'
+  psk = 'password'
+  c.write_wlan_config(band, ssid, psk, atomic=True)
+  c.disable_access_point(band)
+  c.run_once()
+  wvtest.WVPASS(c.client_up(band))
+  wvtest.WVPASS(c.wifi_for_band(band).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.
-  c.enable_access_point('2.4')
+  c.enable_access_point(band)
   c.run_once()
-  wvtest.WVPASS(c.client_up('2.4'))
-  wvtest.WVPASS(c.wifi_for_band('2.4').current_route())
+  wvtest.WVPASS(c.client_up(band))
+  wvtest.WVPASS(c.wifi_for_band(band).current_route())
   wvtest.WVFAIL(c.bridge.current_route())
 
   # Now bring up the bridge.  We should remove the wifi connection and start
@@ -575,60 +730,61 @@
   c.set_ethernet(True)
   c.bridge.set_connection_check_result('succeed')
   c.run_until_interface_update()
-  wvtest.WVPASS(c.access_point_up('2.4'))
-  wvtest.WVFAIL(c.client_up('2.4'))
-  wvtest.WVFAIL(c.wifi_for_band('2.4').current_route())
+  wvtest.WVPASS(c.access_point_up(band))
+  wvtest.WVFAIL(c.client_up(band))
+  wvtest.WVFAIL(c.wifi_for_band(band).current_route())
   wvtest.WVPASS(c.bridge.current_route())
 
   # Now move (rather than delete) the configuration file.  The AP should go
   # away, and we should not be able to join the WLAN.  Routes should not be
   # affected.
-  filename = c.wlan_config_filename('2.4')
+  filename = wlan_config_filename(c._config_dir, band)
   other_filename = filename + '.bak'
   os.rename(filename, other_filename)
   c.run_once()
-  wvtest.WVFAIL(c.access_point_up('2.4'))
-  wvtest.WVFAIL(c.client_up('2.4'))
-  wvtest.WVFAIL(c.wifi_for_band('2.4').current_route())
+  wvtest.WVFAIL(c.access_point_up(band))
+  wvtest.WVFAIL(c.client_up(band))
+  wvtest.WVFAIL(c.wifi_for_band(band).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)
   c.run_once()
-  wvtest.WVPASS(c.access_point_up('2.4'))
-  wvtest.WVFAIL(c.client_up('2.4'))
-  wvtest.WVFAIL(c.wifi_for_band('2.4').current_route())
+  wvtest.WVPASS(c.access_point_up(band))
+  wvtest.WVFAIL(c.client_up(band))
+  wvtest.WVFAIL(c.wifi_for_band(band).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.delete_wlan_config(band)
   c.bridge.set_connection_check_result('fail')
-  scan_count_2_4 = c.wifi_for_band('2.4').wifi_scan_counter
+  scan_count_for_band = c.wifi_for_band(band).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,
+  # s3 is not what the cycler would suggest trying next.
+  wvtest.WVPASSNE('s3', c.wifi_for_band(band).cycler.peek())
+  # Run only once, so that only one BSS can be tried.  It should be the s3 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)
+  # Make sure we didn't scan on `band`.
+  wvtest.WVPASSEQ(scan_count_for_band, c.wifi_for_band(band).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)
+  # Now re-create the WLAN config, connect to the WLAN, and make sure that s3 is
+  # unset as last_successful_bss_info, since it is no longer available.
+  c.write_wlan_config(band, ssid, psk)
   c.run_once()
   wvtest.WVPASS(c.acs())
   wvtest.WVPASS(c.internet())
 
-  c.s2_connect = False
-  c.delete_wlan_config('2.4')
+  c.can_connect_to_s3 = False
+  c.scan_results_include_hidden = False
+  c.delete_wlan_config(band)
   c.run_once()
-  wvtest.WVPASSEQ(c.wifi_for_band('2.4').last_successful_bss_info, None)
+  wvtest.WVPASSEQ(c.wifi_for_band(band).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
@@ -640,52 +796,83 @@
   #    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.write_wlan_config(band, ssid, psk)
   c.run_once()
   wvtest.WVPASS(c.acs())
   wvtest.WVPASS(c.internet())
 
-  c.delete_wlan_config('2.4')
+  c.delete_wlan_config(band)
   c.run_once()
-  wvtest.WVFAIL(c.wifi_for_band('2.4').acs())
+  wvtest.WVFAIL(c.wifi_for_band(band).acs())
 
-  c.s2_connect = True
+  c.can_connect_to_s2 = 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)
+  wvtest.WVPASSEQ(c.wifi_for_band(band).last_successful_bss_info, s2_bss)
 
   c.s2_fail = True
-  c.write_wlan_config('2.4', ssid, psk)
+  c.write_wlan_config(band, 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')
+  wvtest.WVPASSEQ(c.wifi_for_band(band).last_successful_bss_info, s2_bss)
+  c.delete_wlan_config(band)
   # 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.WVPASSEQ(c.wifi_for_band(band).last_successful_bss_info, None)
 
 
 @wvtest.wvtest
-@connection_manager_test(WIFI_SHOW_OUTPUT_ONE_RADIO)
-def connection_manager_test_radio_independent_one_radio(c):
-  connection_manager_test_radio_independent(c)
+@connection_manager_test(WIFI_SHOW_OUTPUT_MARVELL8897)
+def connection_manager_test_generic_marvell8897_2g(c):
+  connection_manager_test_generic(c, '2.4')
 
 
 @wvtest.wvtest
-@connection_manager_test(WIFI_SHOW_OUTPUT_TWO_RADIOS)
-def connection_manager_test_radio_independent_two_radios(c):
-  connection_manager_test_radio_independent(c)
+@connection_manager_test(WIFI_SHOW_OUTPUT_MARVELL8897)
+def connection_manager_test_generic_marvell8897_5g(c):
+  connection_manager_test_generic(c, '5')
 
 
 @wvtest.wvtest
-@connection_manager_test(WIFI_SHOW_OUTPUT_TWO_RADIOS)
-def connection_manager_test_two_radios(c):
+@connection_manager_test(WIFI_SHOW_OUTPUT_ATH9K_ATH10K)
+def connection_manager_test_generic_ath9k_ath10k_2g(c):
+  connection_manager_test_generic(c, '2.4')
+
+
+@wvtest.wvtest
+@connection_manager_test(WIFI_SHOW_OUTPUT_ATH9K_ATH10K)
+def connection_manager_test_generic_ath9k_ath10k_5g(c):
+  connection_manager_test_generic(c, '5')
+
+
+@wvtest.wvtest
+@connection_manager_test(WIFI_SHOW_OUTPUT_ATH9K_FRENZY,
+                         quantenna_interface='wlan1')
+def connection_manager_test_generic_ath9k_frenzy_2g(c):
+  connection_manager_test_generic(c, '2.4')
+
+
+@wvtest.wvtest
+@connection_manager_test(WIFI_SHOW_OUTPUT_ATH9K_FRENZY,
+                         quantenna_interface='wlan1')
+def connection_manager_test_generic_ath9k_frenzy_5g(c):
+  connection_manager_test_generic(c, '5')
+
+
+@wvtest.wvtest
+@connection_manager_test(WIFI_SHOW_OUTPUT_FRENZY,
+                         quantenna_interface='wlan0')
+def connection_manager_test_generic_frenzy_5g(c):
+  connection_manager_test_generic(c, '5')
+
+
+def connection_manager_test_dual_band_two_radios(c):
   """Test ConnectionManager for devices with two radios.
 
   This test should be kept roughly parallel to the one-radio test.
@@ -693,6 +880,10 @@
   Args:
     c:  The ConnectionManager set up by @connection_manager_test.
   """
+  wvtest.WVPASSEQ(len(c._binwifi_commands), 2)
+  for band in ['2.4', '5']:
+    wvtest.WVPASS(('stop', '--band', band, '--persist') in c._binwifi_commands)
+
   # Bring up ethernet, access.
   c.set_ethernet(True)
   c.run_once()
@@ -711,6 +902,8 @@
   wvtest.WVPASS(c.access_point_up('2.4'))
   wvtest.WVPASS(c.access_point_up('5'))
   wvtest.WVPASS(c.bridge.current_route())
+  wvtest.WVFAIL(c.client_up('2.4'))
+  wvtest.WVFAIL(c.client_up('5'))
   wvtest.WVFAIL(c.wifi_for_band('2.4').current_route())
   wvtest.WVFAIL(c.wifi_for_band('5').current_route())
 
@@ -757,7 +950,7 @@
   wvtest.WVFAIL(c.wifi_for_band('5').current_route())
 
   # The next 2.4 GHz scan will have results.
-  c.scan_has_results = True
+  c.interface_with_scan_results = c.wifi_for_band('2.4').name
   c.run_until_scan('2.4')
   # Now run 3 cycles, so that s2 will have been tried.
   for _ in range(3):
@@ -770,15 +963,30 @@
 
 
 @wvtest.wvtest
-@connection_manager_test(WIFI_SHOW_OUTPUT_ONE_RADIO)
-def connection_manager_test_one_radio(c):
-  """Test ConnectionManager for devices with one radio.
+@connection_manager_test(WIFI_SHOW_OUTPUT_ATH9K_ATH10K)
+def connection_manager_test_dual_band_two_radios_ath9k_ath10k(c):
+  connection_manager_test_dual_band_two_radios(c)
 
-  This test should be kept roughly parallel to the two-radio test.
+
+@wvtest.wvtest
+@connection_manager_test(WIFI_SHOW_OUTPUT_ATH9K_FRENZY,
+                         quantenna_interface='wlan1')
+def connection_manager_test_dual_band_two_radios_ath9k_frenzy(c):
+  connection_manager_test_dual_band_two_radios(c)
+
+
+def connection_manager_test_dual_band_one_radio(c):
+  """Test ConnectionManager for devices with one dual-band radio.
+
+  This test should be kept roughly parallel to
+  connection_manager_test_dual_band_two_radios.
 
   Args:
     c:  The ConnectionManager set up by @connection_manager_test.
   """
+  wvtest.WVPASSEQ(len(c._binwifi_commands), 1)
+  wvtest.WVPASSEQ(('stop', '--band', '5', '--persist'), c._binwifi_commands[0])
+
   # Bring up ethernet, access.
   c.set_ethernet(True)
   c.run_once()
@@ -833,8 +1041,8 @@
   wvtest.WVFAIL(c.wifi_for_band('2.4').current_route())
   wvtest.WVFAIL(c.wifi_for_band('5').current_route())
 
-  # The wcli0 scan will have results that will lead to ACS access.
-  c.scan_has_results = True
+  # The 2.4 GHz scan will have results that will lead to ACS access.
+  c.interface_with_scan_results = c.wifi_for_band('2.4').name
   c.run_until_scan('5')
   for _ in range(3):
     c.run_once()
@@ -846,8 +1054,14 @@
 
 
 @wvtest.wvtest
-@connection_manager_test(WIFI_SHOW_OUTPUT_ONE_RADIO_NO_5GHZ)
-def connection_manager_test_one_radio_no_5ghz(c):
+@connection_manager_test(WIFI_SHOW_OUTPUT_MARVELL8897)
+def connection_manager_test_dual_band_one_radio_marvell8897(c):
+  connection_manager_test_dual_band_one_radio(c)
+
+
+@wvtest.wvtest
+@connection_manager_test(WIFI_SHOW_OUTPUT_MARVELL8897_NO_5GHZ)
+def connection_manager_test_marvell8897_no_5ghz(c):
   """Test ConnectionManager for the case documented in b/27328894.
 
   conman should be able to handle the lack of 5 GHz without actually
@@ -883,7 +1097,7 @@
 
 
 @wvtest.wvtest
-@connection_manager_test(WIFI_SHOW_OUTPUT_ONE_RADIO,
+@connection_manager_test(WIFI_SHOW_OUTPUT_MARVELL8897,
                          __test_interfaces_already_up=['eth0', 'wcli0'])
 def connection_manager_test_wifi_already_up(c):
   """Test ConnectionManager when wifi is already up.
@@ -895,5 +1109,32 @@
   wvtest.WVPASS(c.wifi_for_band('2.4').current_route)
 
 
+@wvtest.wvtest
+@connection_manager_test(WIFI_SHOW_OUTPUT_MARVELL8897, wlan_configs={'5': True})
+def connection_manager_one_radio_marvell8897_existing_config_5g_ap(c):
+  wvtest.WVPASSEQ(len(c._binwifi_commands), 1)
+  wvtest.WVPASSEQ(('stopclient', '--band', '5', '--persist'),
+                  c._binwifi_commands[0])
+
+
+@wvtest.wvtest
+@connection_manager_test(WIFI_SHOW_OUTPUT_MARVELL8897,
+                         wlan_configs={'5': False})
+def connection_manager_one_radio_marvell8897_existing_config_5g_no_ap(c):
+  wvtest.WVPASSEQ(len(c._binwifi_commands), 1)
+  wvtest.WVPASSEQ(('stopap', '--band', '5', '--persist'),
+                  c._binwifi_commands[0])
+
+
+@wvtest.wvtest
+@connection_manager_test(WIFI_SHOW_OUTPUT_ATH9K_ATH10K,
+                         wlan_configs={'5': True})
+def connection_manager_two_radios_ath9k_ath10k_existing_config_5g_ap(c):
+  wvtest.WVPASSEQ(len(c._binwifi_commands), 2)
+  wvtest.WVPASS(('stop', '--band', '2.4', '--persist') in c._binwifi_commands)
+  wvtest.WVPASS(('stopclient', '--band', '5', '--persist')
+                in c._binwifi_commands)
+
+
 if __name__ == '__main__':
   wvtest.wvtest_main()
diff --git a/conman/interface.py b/conman/interface.py
index 0f42e20..d45f42e 100755
--- a/conman/interface.py
+++ b/conman/interface.py
@@ -2,6 +2,7 @@
 
 """Models wired and wireless interfaces."""
 
+import json
 import logging
 import os
 import re
@@ -311,9 +312,11 @@
 
 
 class Wifi(Interface):
-  """Represents the wireless interface."""
+  """Represents a wireless interface."""
 
   WPA_EVENT_RE = re.compile(r'<\d+>CTRL-EVENT-(?P<event>[A-Z\-]+).*')
+  # pylint: disable=invalid-name
+  WPACtrl = wpactrl.WPACtrl
 
   def __init__(self, *args, **kwargs):
     self.bands = kwargs.pop('bands', [])
@@ -333,29 +336,53 @@
     return self._wpa_control and self._wpa_control.attached
 
   def attach_wpa_control(self, path):
+    """Attach to the wpa_supplicant control interface.
+
+    Args:
+      path:  The path containing the wpa_supplicant control interface socket.
+
+    Returns:
+      Whether attaching was successful.
+    """
     if self.attached():
-      return
+      return True
 
     socket = os.path.join(path, self.name)
-    if os.path.exists(socket):
-      try:
-        self._wpa_control = self.get_wpa_control(socket)
-        self._wpa_control.attach()
-      except wpactrl.error as e:
-        logging.error('Error attaching to wpa_supplicant: %s', e)
-        return
+    try:
+      self._wpa_control = self.get_wpa_control(socket)
+      self._wpa_control.attach()
+    except wpactrl.error as e:
+      logging.error('Error attaching to wpa_supplicant: %s', e)
+      return False
 
-      for line in self._wpa_control.request('STATUS').splitlines():
+    status = self.wpa_cli_status()
+    self.wpa_supplicant = status.get('wpa_state') == 'COMPLETED'
+    if not self._initialized:
+      self.initial_ssid = status.get('ssid')
+
+    return True
+
+  def wpa_cli_status(self):
+    """Parse the STATUS response from the wpa_supplicant CLI.
+
+    Returns:
+      A dict containing the parsed results, where key and value are separated by
+      '=' on each line.
+    """
+    status = {}
+
+    if self._wpa_control:
+      lines = self._wpa_control.request('STATUS').splitlines()
+      for line in lines:
         if '=' not in line:
           continue
-        key, value = line.split('=', 1)
-        if key == 'wpa_state':
-          self.wpa_supplicant = value == 'COMPLETED'
-        elif key == 'ssid' and not self._initialized:
-          self.initial_ssid = value
+        k, v = line.strip().split('=', 1)
+        status[k] = v
+
+    return status
 
   def get_wpa_control(self, socket):
-    return wpactrl.WPACtrl(socket)
+    return self.WPACtrl(socket)
 
   def detach_wpa_control(self):
     if self.attached():
@@ -394,3 +421,115 @@
     self.initial_ssid = None
     super(Wifi, self).initialize()
 
+
+class FrenzyWPACtrl(object):
+  """A WPACtrl for Frenzy devices.
+
+  Implements the same functions used on the normal WPACtrl, using a combination
+  of the QCSAPI and wifi_files.  Keeps state in order to generate events by
+  diffing saved state with current system state.
+  """
+
+  WIFIINFO_PATH = '/tmp/wifi/wifiinfo'
+
+  def __init__(self, socket):
+    self._interface = os.path.split(socket)[-1]
+
+    # State from QCSAPI and wifi_files.
+    self._client_mode = False
+    self._ssid = None
+    self._status = None
+
+    self._events = []
+
+  def _qcsapi(self, *command):
+    try:
+      return subprocess.check_output(['qcsapi'] + list(command)).strip()
+    except subprocess.CalledProcessError:
+      return None
+
+  def _wifiinfo_filename(self):
+    return os.path.join(self.WIFIINFO_PATH, self._interface)
+
+  def _get_wifiinfo(self):
+    try:
+      return json.load(open(self._wifiinfo_filename()))
+    except IOError:
+      return None
+
+  def _get_ssid(self):
+    wifiinfo = self._get_wifiinfo()
+    if wifiinfo:
+      return wifiinfo.get('SSID')
+
+  def _check_client_mode(self):
+    return self._qcsapi('get_mode', 'wifi0') == 'Station'
+
+  def attach(self):
+    self._update()
+
+  @property
+  def attached(self):
+    return self._client_mode
+
+  def detach(self):
+    raise wpactrl.error('Real WPACtrl always raises this when detaching.')
+
+  def pending(self):
+    self._update()
+    return bool(self._events)
+
+  def _update(self):
+    """Generate and cache events, update state."""
+    client_mode = self._check_client_mode()
+    ssid = self._get_ssid()
+    status = self._qcsapi('get_status', 'wifi0')
+
+    # If we have an SSID and are in client mode, and at least one of those is
+    # new, then we have just connected.
+    if client_mode and ssid and (not self._client_mode or ssid != self._ssid):
+      self._events.append('<2>CTRL-EVENT-CONNECTED')
+
+    # If we are in client mode but lost SSID, we disconnected.
+    if client_mode and self._ssid and not ssid:
+      self._events.append('<2>CTRL-EVENT-DISCONNECTED')
+
+    # If there is an auth/assoc failure, then status (above) is 'Error'.  We
+    # really want the converse of this implication (i.e. that 'Error' implies an
+    # auth/assoc failure), but due to limited documentation this will have to
+    # do.  It should be good enough:  if something else causes get_status to
+    # return 'Error', we are probably not connected, and we don't do anything
+    # special with auth/assoc failures specifically.
+    if client_mode and status == 'Error' and self._status != 'Error':
+      self._events.append('<2>CTRL-EVENT-AUTH-REJECT')
+
+    # If we left client mode, wpa_supplicant has terminated.
+    if self._client_mode and not client_mode:
+      self._events.append('<2>CTRL-EVENT-TERMINATING')
+
+    self._client_mode = client_mode
+    self._ssid = ssid
+    self._status = status
+
+  def recv(self):
+    return self._events.pop(0)
+
+  def request(self, request_type):
+    """Partial implementation of WPACtrl.request."""
+
+    if request_type != 'STATUS':
+      return ''
+
+    self._update()
+
+    if not self._client_mode or not self._ssid:
+      return ''
+
+    return 'wpa_state=COMPLETED\nssid=%s' % self._ssid
+
+
+class FrenzyWifi(Wifi):
+  """Represents a Frenzy wireless interface."""
+
+  # pylint: disable=invalid-name
+  WPACtrl = FrenzyWPACtrl
diff --git a/conman/interface_test.py b/conman/interface_test.py
index f6e03d2..8a376a6 100755
--- a/conman/interface_test.py
+++ b/conman/interface_test.py
@@ -2,11 +2,15 @@
 
 """Tests for connection_manager.py."""
 
+import json
 import logging
 import os
 import shutil
 import tempfile
 
+# This is in site-packages on the device, but not when running tests, and so
+# raises lint errors.
+# pylint: disable=g-bad-import-order
 import wpactrl
 
 import interface
@@ -70,11 +74,14 @@
     self.events = []
     self.attached = False
     self.connected = False
+    self.ssid_testonly = None
 
   def pending(self):
+    self.check_socket_exists('pending: socket does not exist')
     return bool(self.events)
 
   def recv(self):
+    self.check_socket_exists('recv: socket does not exist')
     return self.events.pop(0)
 
   def attach(self):
@@ -83,14 +90,15 @@
     self.attached = True
 
   def detach(self):
-    if not os.path.exists(self._socket):
-      raise wpactrl.error('wpactrl_detach failed')
     self.attached = False
+    self.ssid_testonly = None
+    self.connected = False
+    self.check_socket_exists('wpactrl_detach failed')
 
   def request(self, request_type):
     if request_type == 'STATUS':
-      return ('foo\nwpa_state=COMPLETED\nssid=my ssid\nbar' if self.connected
-              else 'foo')
+      return ('foo\nwpa_state=COMPLETED\nssid=%s\nbar' % self.ssid_testonly
+              if self.connected else 'foo')
     else:
       raise ValueError('Invalid request_type %s' % request_type)
 
@@ -99,6 +107,22 @@
   def add_event(self, event):
     self.events.append(event)
 
+  def add_connected_event(self):
+    self.connected = True
+    self.add_event(Wifi.CONNECTED_EVENT)
+
+  def add_disconnected_event(self):
+    self.connected = False
+    self.add_event(Wifi.DISCONNECTED_EVENT)
+
+  def add_terminating_event(self):
+    self.connected = False
+    self.add_event(Wifi.TERMINATING_EVENT)
+
+  def check_socket_exists(self, msg='Fake socket does not exist'):
+    if not os.path.exists(self._socket):
+      raise wpactrl.error(msg)
+
 
 class Wifi(FakeInterfaceMixin, interface.Wifi):
   """Fake Wifi for testing."""
@@ -107,31 +131,137 @@
   DISCONNECTED_EVENT = '<2>CTRL-EVENT-DISCONNECTED'
   TERMINATING_EVENT = '<2>CTRL-EVENT-TERMINATING'
 
+  WPACtrl = FakeWPACtrl
+
   def __init__(self, *args, **kwargs):
     super(Wifi, self).__init__(*args, **kwargs)
-    self._initially_connected = False
+    self._initial_ssid_testonly = None
 
-  def attach_wpa_control(self, *args, **kwargs):
-    if self._initially_connected and self._wpa_control:
+  def attach_wpa_control(self, path):
+    if self._initial_ssid_testonly and self._wpa_control:
       self._wpa_control.connected = True
-    super(Wifi, self).attach_wpa_control(*args, **kwargs)
+    super(Wifi, self).attach_wpa_control(path)
 
-  def get_wpa_control(self, socket):
-    result = FakeWPACtrl(socket)
-    result.connected = self._initially_connected
+  def get_wpa_control(self, *args, **kwargs):
+    result = super(Wifi, self).get_wpa_control(*args, **kwargs)
+    if self._initial_ssid_testonly:
+      result.connected = True
+      result.ssid_testonly = self._initial_ssid_testonly
     return result
 
   def add_connected_event(self):
     if self.attached():
-      self._wpa_control.add_event(self.CONNECTED_EVENT)
+      self._wpa_control.add_connected_event()
 
   def add_disconnected_event(self):
+    self._initial_ssid_testonly = None
     if self.attached():
-      self._wpa_control.add_event(self.DISCONNECTED_EVENT)
+      self._wpa_control.add_disconnected_event()
 
   def add_terminating_event(self):
+    self._initial_ssid_testonly = None
     if self.attached():
-      self._wpa_control.add_event(self.TERMINATING_EVENT)
+      self._wpa_control.add_terminating_event()
+
+  def detach_wpa_control(self):
+    self._initial_ssid_testonly = None
+    super(Wifi, self).detach_wpa_control()
+
+  def start_wpa_supplicant_testonly(self, path):
+    logging.debug('Starting fake wpa_supplicant for %s', self.name)
+    open(os.path.join(path, self.name), 'w')
+
+  def kill_wpa_supplicant_testonly(self, path):
+    logging.debug('Killing fake wpa_supplicant for %s', self.name)
+    if self.attached():
+      self.detach_wpa_control()
+      os.unlink(os.path.join(path, self.name))
+    else:
+      raise RuntimeError('Trying to kill wpa_supplicant while not attached')
+
+
+class FrenzyWPACtrl(interface.FrenzyWPACtrl):
+
+  def __init__(self, *args, **kwargs):
+    super(FrenzyWPACtrl, self).__init__(*args, **kwargs)
+    self.ssid_testonly = None
+
+  def _qcsapi(self, *command):
+    return self.fake_qcsapi.get(command[0], None)
+
+  def add_connected_event(self):
+    self.fake_qcsapi['get_mode'] = 'Station'
+    json.dump({'SSID': self.ssid_testonly},
+              open(self._wifiinfo_filename(), 'w'))
+
+  def add_disconnected_event(self):
+    self.ssid_testonly = None
+    json.dump({'SSID': ''}, open(self._wifiinfo_filename(), 'w'))
+
+  def add_terminating_event(self):
+    self.ssid_testonly = None
+    json.dump({'SSID': ''}, open(self._wifiinfo_filename(), 'w'))
+    self.fake_qcsapi['get_mode'] = 'AP'
+
+  def detach(self):
+    self.add_terminating_event()
+    super(FrenzyWPACtrl, self).detach()
+
+
+class FrenzyWifi(FakeInterfaceMixin, interface.FrenzyWifi):
+  WPACtrl = FrenzyWPACtrl
+
+  def __init__(self, *args, **kwargs):
+    super(FrenzyWifi, self).__init__(*args, **kwargs)
+    self._initial_ssid_testonly = None
+    self.fake_qcsapi = {}
+
+  def attach_wpa_control(self, *args, **kwargs):
+    super(FrenzyWifi, self).attach_wpa_control(*args, **kwargs)
+    if self._wpa_control:
+      self._wpa_control.ssid_testonly = self._initial_ssid_testonly
+      if self._initial_ssid_testonly:
+        self._wpa_control.add_connected_event()
+
+  def get_wpa_control(self, *args, **kwargs):
+    result = super(FrenzyWifi, self).get_wpa_control(*args, **kwargs)
+    result.fake_qcsapi = self.fake_qcsapi
+    if self._initial_ssid_testonly:
+      result.fake_qcsapi['get_mode'] = 'Station'
+      result.ssid_testonly = self._initial_ssid_testonly
+      result.add_connected_event()
+    return result
+
+  def add_connected_event(self):
+    if self.attached():
+      self._wpa_control.add_connected_event()
+
+  def add_disconnected_event(self):
+    self._initial_ssid_testonly = None
+    if self.attached():
+      self._wpa_control.add_disconnected_event()
+
+  def add_terminating_event(self):
+    self._initial_ssid_testonly = None
+    if self.attached():
+      self._wpa_control.add_terminating_event()
+
+  def detach_wpa_control(self):
+    self._initial_ssid_testonly = None
+    super(FrenzyWifi, self).detach_wpa_control()
+
+  def start_wpa_supplicant_testonly(self, unused_path):
+    logging.debug('Starting fake wpa_supplicant for %s', self.name)
+    self.fake_qcsapi['get_mode'] = 'Station'
+
+  def kill_wpa_supplicant_testonly(self, unused_path):
+    logging.debug('Killing fake wpa_supplicant for %s', self.name)
+    if self.attached():
+      # This happens to do what we need.
+      self.add_terminating_event()
+      self.detach_wpa_control()
+    else:
+      raise RuntimeError('Trying to kill wpa_supplicant while not attached')
 
 
 @wvtest.wvtest
@@ -199,6 +329,50 @@
     shutil.rmtree(tmp_dir)
 
 
+def generic_wifi_test(w, wpa_path):
+  # Not currently connected.
+  w.start_wpa_supplicant_testonly(wpa_path)
+  w.attach_wpa_control(wpa_path)
+  wvtest.WVFAIL(w.wpa_supplicant)
+
+  # pylint: disable=protected-access
+  wpa_control = w._wpa_control
+
+  # wpa_supplicant connects.
+  wpa_control.ssid_testonly = 'my=ssid'
+  wpa_control.add_connected_event()
+  wvtest.WVFAIL(w.wpa_supplicant)
+  w.handle_wpa_events()
+  wvtest.WVPASS(w.wpa_supplicant)
+  w.set_gateway_ip('192.168.1.1')
+
+  # wpa_supplicant disconnects.
+  wpa_control.add_disconnected_event()
+  w.handle_wpa_events()
+  wvtest.WVFAIL(w.wpa_supplicant)
+
+  # Now, start over so we can test what happens when wpa_supplicant is already
+  # connected when we attach.
+  w.detach_wpa_control()
+  # pylint: disable=protected-access
+  w._initial_ssid_testonly = 'my=ssid'
+  w._initialized = False
+  w.attach_wpa_control(wpa_path)
+  wpa_control = w._wpa_control
+
+  # wpa_supplicant was already connected when we attached.
+  wvtest.WVPASS(w.wpa_supplicant)
+  wvtest.WVPASSEQ(w.initial_ssid, 'my=ssid')
+  w.initialize()
+  wvtest.WVPASSEQ(w.initial_ssid, None)
+
+  # The wpa_supplicant process disconnects and terminates.
+  wpa_control.add_disconnected_event()
+  wpa_control.add_terminating_event()
+  w.handle_wpa_events()
+  wvtest.WVFAIL(w.wpa_supplicant)
+
+
 @wvtest.wvtest
 def wifi_test():
   """Test Wifi."""
@@ -208,53 +382,29 @@
 
   try:
     wpa_path = tempfile.mkdtemp()
-    socket = os.path.join(wpa_path, w.name)
-    open(socket, 'w')
-
-    # Not currently connected.
-    w.attach_wpa_control(wpa_path)
-    wvtest.WVFAIL(w.wpa_supplicant)
-
-    # pylint: disable=protected-access
-    wpa_control = w._wpa_control
-
-    # wpa_supplicant connects.
-    wpa_control.add_event(Wifi.CONNECTED_EVENT)
-    wvtest.WVFAIL(w.wpa_supplicant)
-    w.handle_wpa_events()
-    wvtest.WVPASS(w.wpa_supplicant)
-    w.set_gateway_ip('192.168.1.1')
-
-    # wpa_supplicant disconnects.
-    wpa_control.add_event(Wifi.DISCONNECTED_EVENT)
-    w.handle_wpa_events()
-    wvtest.WVFAIL(w.wpa_supplicant)
-
-    # Now, start over so we can test what happens when wpa_supplicant is already
-    # connected when we attach.
-    w.detach_wpa_control()
-    # pylint: disable=protected-access
-    w._initially_connected = True
-    w._initialized = False
-    w.attach_wpa_control(wpa_path)
-    wpa_control = w._wpa_control
-
-    # wpa_supplicant was already connected when we attached.
-    wvtest.WVPASS(w.wpa_supplicant)
-    wvtest.WVPASSEQ(w.initial_ssid, 'my ssid')
-    w.initialize()
-    wvtest.WVPASSEQ(w.initial_ssid, None)
-
-    # The wpa_supplicant process disconnects and terminates.
-    wpa_control.add_event(Wifi.DISCONNECTED_EVENT)
-    wpa_control.add_event(Wifi.TERMINATING_EVENT)
-    os.unlink(socket)
-    w.handle_wpa_events()
-    wvtest.WVFAIL(w.wpa_supplicant)
+    generic_wifi_test(w, wpa_path)
 
   finally:
     shutil.rmtree(wpa_path)
 
 
+@wvtest.wvtest
+def frenzy_wifi_test():
+  """Test FrenzyWifi."""
+  w = FrenzyWifi('wlan0', '20')
+  w.set_connection_check_result('succeed')
+  w.initialize()
+
+  try:
+    wpa_path = tempfile.mkdtemp()
+    FrenzyWifi.WPACtrl.WIFIINFO_PATH = wifiinfo_path = tempfile.mkdtemp()
+
+    generic_wifi_test(w, wpa_path)
+
+  finally:
+    shutil.rmtree(wpa_path)
+    shutil.rmtree(wifiinfo_path)
+
+
 if __name__ == '__main__':
   wvtest.wvtest_main()
diff --git a/conman/iw.py b/conman/iw.py
index 973d653..f4932f1 100755
--- a/conman/iw.py
+++ b/conman/iw.py
@@ -6,9 +6,12 @@
 import subprocess
 
 
-def _scan(interface, **kwargs):
+FIBER_OUI = 'f4:f5:e8'
+
+
+def _scan(band, **kwargs):
   try:
-    return subprocess.check_output(('iw', 'dev', interface, 'scan'), **kwargs)
+    return subprocess.check_output(('wifi', 'scan', '-b', band), **kwargs)
   except subprocess.CalledProcessError:
     return ''
 
@@ -36,6 +39,9 @@
     # pylint: disable=protected-access
     return isinstance(other, BssInfo) and self.__attrs() == other.__attrs()
 
+  def __ne__(self, other):
+    return not self.__eq__(other)
+
   def __hash__(self):
     return hash(self.__attrs())
 
@@ -47,11 +53,11 @@
 
 # TODO(rofrankel): waveguide also scans. Can we find a way to avoid two programs
 # scanning in parallel?
-def scan_parsed(interface, **kwargs):
+def scan_parsed(band, **kwargs):
   """Return the parsed results of 'iw scan'."""
   result = []
   bss_info = None
-  for line in _scan(interface, **kwargs).splitlines():
+  for line in _scan(band, **kwargs).splitlines():
     line = line.strip()
     match = re.match(_BSSID_RE, line)
     if match:
@@ -81,11 +87,11 @@
   return result
 
 
-def find_bssids(interface, vendor_ie_function, include_secure):
+def find_bssids(band, vendor_ie_function, include_secure):
   """Return information about interesting access points.
 
   Args:
-    interface:  The wireless interface with which to scan.
+    band:  The band on which to scan.
     vendor_ie_function:  A function that takes a vendor IE and returns a bool.
     include_secure:  Whether to exclude secure networks.
 
@@ -94,13 +100,21 @@
     BSSIDs which have a vendor IE accepted by vendor_ie_function, and the second
     list has those which don't.
   """
-  parsed = scan_parsed(interface)
+  parsed = scan_parsed(band)
   result_with_ie = set()
   result_without_ie = set()
 
   for bss_info in parsed:
     if bss_info.security and not include_secure:
       continue
+
+    for oui, data in bss_info.vendor_ies:
+      if oui == FIBER_OUI:
+        octets = data.split()
+        if octets[0] == '03' and not bss_info.ssid:
+          bss_info.ssid = ''.join(octets[1:]).decode('hex')
+          continue
+
     for oui, data in bss_info.vendor_ies:
       if vendor_ie_function(oui, data):
         result_with_ie.add(bss_info)
diff --git a/conman/iw_test.py b/conman/iw_test.py
index c069c91..9c259e8 100755
--- a/conman/iw_test.py
+++ b/conman/iw_test.py
@@ -486,6 +486,72 @@
      * BK: CW 15-1023, AIFSN 7
      * VI: CW 7-15, AIFSN 2, TXOP 3008 usec
      * VO: CW 3-7, AIFSN 2, TXOP 1504 usec
+BSS 94:b4:0f:f1:36:41(on wcli0)
+  TSF: 12499150000 usec (0d, 03:28:19)
+  freq: 2437
+  beacon interval: 100 TUs
+  capability: ESS Privacy ShortPreamble SpectrumMgmt ShortSlotTime RadioMeasure (0x1531)
+  signal: -66.00 dBm
+  last seen: 2350 ms ago
+  Information elements from Probe Response frame:
+  SSID:
+  Supported rates: 36.0* 48.0 54.0
+  DS Parameter set: channel 6
+  TIM: DTIM Count 0 DTIM Period 1 Bitmap Control 0x0 Bitmap[0] 0x0
+  Country: US Environment: Indoor/Outdoor
+    Channels [1 - 11] @ 36 dBm
+  Power constraint: 0 dB
+  TPC report: TX power: 3 dBm
+  ERP: <no flags>
+  BSS Load:
+     * station count: 0
+     * channel utilisation: 28/255
+     * available admission capacity: 27500 [*32us]
+  HT capabilities:
+    Capabilities: 0x19ad
+      RX LDPC
+      HT20
+      SM Power Save disabled
+      RX HT20 SGI
+      TX STBC
+      RX STBC 1-stream
+      Max AMSDU length: 7935 bytes
+      DSSS/CCK HT40
+    Maximum RX AMPDU length 65535 bytes (exponent: 0x003)
+    Minimum RX AMPDU time spacing: 4 usec (0x05)
+    HT RX MCS rate indexes supported: 0-23
+    HT TX MCS rate indexes are undefined
+  HT operation:
+     * primary channel: 6
+     * secondary channel offset: no secondary
+     * STA channel width: 20 MHz
+     * RIFS: 1
+     * HT protection: nonmember
+     * non-GF present: 1
+     * OBSS non-GF present: 1
+     * dual beacon: 0
+     * dual CTS protection: 0
+     * STBC beacon: 0
+     * L-SIG TXOP Prot: 0
+     * PCO active: 0
+     * PCO phase: 0
+  Overlapping BSS scan params:
+     * passive dwell: 20 TUs
+     * active dwell: 10 TUs
+     * channel width trigger scan interval: 300 s
+     * scan passive total per channel: 200 TUs
+     * scan active total per channel: 20 TUs
+     * BSS width channel transition delay factor: 5
+     * OBSS Scan Activity Threshold: 0.25 %
+  Extended capabilities: HT Information Exchange Supported, Extended Channel Switching, BSS Transition, 6
+  WMM:   * Parameter version 1
+     * u-APSD
+     * BE: CW 15-1023, AIFSN 3
+     * BK: CW 15-1023, AIFSN 7
+     * VI: CW 7-15, AIFSN 2, TXOP 3008 usec
+     * VO: CW 3-7, AIFSN 2, TXOP 1504 usec
+  Vendor specific: OUI 00:11:22, data: 01 23 45 67
+  Vendor specific: OUI f4:f5:e8, data: 03 47 46 69 62 65 72 53 65 74 75 70 41 75 74 6f 6d 61 74 69 6f 6e
 """
 
 
@@ -498,15 +564,23 @@
 @wvtest.wvtest
 def find_bssids_test():
   """Test iw.find_bssids."""
+  test_ie = ('00:11:22', '01 23 45 67')
+  ssid_ie = (
+      'f4:f5:e8',
+      '03 47 46 69 62 65 72 53 65 74 75 70 41 75 74 6f 6d 61 74 69 6f 6e',
+  )
   short_scan_result = iw.BssInfo(ssid='short scan result',
                                  bssid='00:23:97:57:f4:d8',
                                  security=['WEP'],
-                                 vendor_ies=[('00:11:22', '01 23 45 67')])
+                                 vendor_ies=[test_ie])
+  provisioning_bss_info = iw.BssInfo(ssid='GFiberSetupAutomation',
+                                     bssid='94:b4:0f:f1:36:41',
+                                     vendor_ies=[test_ie, ssid_ie])
 
   with_ie, without_ie = iw.find_bssids('wcli0', lambda o, d: o == '00:11:22',
                                        True)
 
-  wvtest.WVPASSEQ(with_ie, set([short_scan_result]))
+  wvtest.WVPASSEQ(with_ie, set([short_scan_result, provisioning_bss_info]))
 
   wvtest.WVPASSEQ(
       without_ie,
@@ -524,7 +598,7 @@
 
   with_ie, without_ie = iw.find_bssids('wcli0', lambda o, d: o == '00:11:22',
                                        False)
-  wvtest.WVPASSEQ(with_ie, set())
+  wvtest.WVPASSEQ(with_ie, set([provisioning_bss_info]))
   wvtest.WVPASSEQ(
       without_ie,
       set([iw.BssInfo(ssid='GoogleGuest', bssid='94:b4:0f:f1:36:41'),
diff --git a/conman/status.py b/conman/status.py
index 118bafc..e21dc01 100644
--- a/conman/status.py
+++ b/conman/status.py
@@ -29,6 +29,7 @@
   COULD_REACH_ACS = 'COULD_REACH_ACS'
   CAN_REACH_INTERNET = 'CAN_REACH_INTERNET'
   PROVISIONING_FAILED = 'PROVISIONING_FAILED'
+  ATTACHED_TO_WPA_SUPPLICANT = 'ATTACHED_TO_WPA_SUPPLICANT'
 
 
 # Format:  { proposition: (implications, counter-implications), ... }
@@ -63,9 +64,13 @@
         (P.COULD_REACH_ACS,),
     ),
     P.HAVE_WORKING_CONFIG: (
-        (),
         (P.HAVE_CONFIG,),
+        (),
     ),
+    P.ATTACHED_TO_WPA_SUPPLICANT: (
+        (),
+        (),
+    )
 }
 
 
@@ -78,7 +83,7 @@
   def __init__(self, name, export_path):
     self._name = name
     self._export_path = export_path
-    self._value = False
+    self._value = None
     self._implications = set()
     self._counter_implications = set()
     self._impliers = set()
diff --git a/craftui/.gitignore b/craftui/.gitignore
index 1a5f7ef..7b8a4bf 100644
--- a/craftui/.gitignore
+++ b/craftui/.gitignore
@@ -1,4 +1,6 @@
+*.pyo
 *.swp
 .started
 .sim.extracted
 sim
+LOG
diff --git a/craftui/HOW.restart_if_changed b/craftui/HOW.restart_if_changed
index 8c17154..0bfd11f 100644
--- a/craftui/HOW.restart_if_changed
+++ b/craftui/HOW.restart_if_changed
@@ -2,6 +2,8 @@
 
 # developer tool to restart server when file source changes
 
+export PATH="$(pwd)/../../../../out.gfch100_defconfig/host/usr/bin:$PATH"
+
 pid=
 
 restart() {
diff --git a/craftui/HOW.updatesim b/craftui/HOW.updatesim
index 8ae3f04..04e4cce 100644
--- a/craftui/HOW.updatesim
+++ b/craftui/HOW.updatesim
@@ -20,6 +20,7 @@
 	etc/version \
 	tmp/glaukus \
 	tmp/serial \
+	tmp/ssl \
 	tmp/platform \
 	tmp/gpio/ledstate \
 	tmp/sim \
diff --git a/craftui/Makefile b/craftui/Makefile
index b946953..782ca22 100644
--- a/craftui/Makefile
+++ b/craftui/Makefile
@@ -16,6 +16,7 @@
 	@echo "No libs to install."
 
 .sim.extracted: sim.tgz
+	-chmod -R +w sim
 	rm -rf sim
 	rsync -av sim-tools/ sim
 	tar xf sim.tgz -C sim
diff --git a/craftui/craftui b/craftui/craftui
index 2f5e143..527eba7 100755
--- a/craftui/craftui
+++ b/craftui/craftui
@@ -13,7 +13,7 @@
 # if running from developer desktop, use simulated data
 if [ "$isdev" = 1 ]; then
   cw="$devcw"
-  args="$args --port=8888 --sim=./sim"
+  args="$args --http-port=8888 --https-port=8889 --sim=./sim"
   pycode=./craftui_fortesting.py
   export PATH="$PWD/sim/bin:$PATH"
 fi
@@ -23,10 +23,25 @@
   args="$args --www=$localwww"
 fi
 
-# enable debugger
-if [ "$1" = -d ]; then
-  debug="-m pdb"
-fi
+# command line parsing
+while [ $# -gt 0 ]; do
+  # enable debugger
+  if [ "$1" = -d ]; then
+    debug="-m pdb"
+    shift
+    continue
+  fi
 
-export PYTHONPATH="$cw/tr/vendor/tornado:$PYTHONPATH"
-exec python -u $debug $pycode $args
+  # enable https
+  if [ "$1" = -S ]; then
+    httpsmode="-S"
+    shift
+    continue
+  fi
+
+  echo "$0: '$1': unknown command line option" >&2
+  exit 1
+done
+
+export PYTHONPATH="$cw/tr/vendor/tornado:$cw/tr/vendor/curtain:$PYTHONPATH"
+exec python -u $debug $pycode $args $httpsmode
diff --git a/craftui/craftui.py b/craftui/craftui.py
index d25b40b..59441b3 100755
--- a/craftui/craftui.py
+++ b/craftui/craftui.py
@@ -17,6 +17,7 @@
 
 __author__ = 'edjames@google.com (Ed James)'
 
+import base64
 import getopt
 import json
 import os
@@ -24,6 +25,8 @@
 import subprocess
 import sys
 import urllib2
+import digest
+import tornado.httpserver
 import tornado.ioloop
 import tornado.web
 
@@ -138,7 +141,7 @@
   """Validate as gain index."""
 
   def __init__(self):
-    super(VGainIndex, self).__init__(0, 5)
+    super(VGainIndex, self).__init__(1, 5)
 
 
 class VDict(Validator):
@@ -163,6 +166,22 @@
   dict = {'true': 'true', 'false': 'false'}
 
 
+class VPassword(Validator):
+  """Validate as base64 encoded and reasonable length."""
+  example = '******'
+
+  def Validate(self, value):
+    super(VPassword, self).Validate(value)
+    pw = ''
+    try:
+      pw = base64.b64decode(value)
+    except TypeError:
+      raise ConfigError('passwords must be base64 encoded')
+    # TODO(edjames) ascii decodes legally; how to check it's really base64?
+    if len(pw) < 5 or len(pw) > 16:
+      raise ConfigError('passwords should be 5-16 characters')
+
+
 class Config(object):
   """Configure the device after validation."""
 
@@ -247,6 +266,9 @@
   """A web server that configures and displays Chimera data."""
 
   handlers = {
+      'password_admin': PtpConfig(VPassword, 'password_admin'),
+      'password_guest': PtpConfig(VPassword, 'password_guest'),
+
       'craft_ipaddr': PtpConfig(VSlash, 'craft_ipaddr'),
       'link_ipaddr': PtpConfig(VSlash, 'local_ipaddr'),
       'peer_ipaddr': PtpConfig(VSlash, 'peer_ipaddr'),
@@ -300,11 +322,14 @@
       'tx_errors',
       'tx_dropped'
   ]
+  realm = 'gfch100'
 
-  def __init__(self, wwwroot, port, sim):
+  def __init__(self, wwwroot, http_port, https_port, use_https, sim):
     """initialize."""
     self.wwwroot = wwwroot
-    self.port = port
+    self.http_port = http_port
+    self.https_port = https_port
+    self.use_https = use_https
     self.sim = sim
     self.data = {}
     self.data['refreshCount'] = 0
@@ -466,40 +491,85 @@
         print 'Connection to %s failed: %s' % (url, ex.reason)
     return response
 
-  class MainHandler(tornado.web.RequestHandler):
+  def GetUserCreds(self, user):
+    if user not in ('admin', 'guest'):
+      return None
+    b64 = self.ReadFile('%s/config/settings/password_%s' % (self.sim, user))
+    pw = base64.b64decode(b64)
+    return {'auth_username': user, 'auth_password': pw}
+
+  def GetAdminCreds(self, user):
+    if user != 'admin':
+      return None
+    return self.GetUserCreds(user)
+
+  def Authenticate(self, request):
+    """Check if user is authenticated (sends challenge if not)."""
+    if not request.get_authenticated_user(self.GetUserCreds, self.realm):
+      return False
+    return True
+
+  def AuthenticateAdmin(self, request):
+    """Check if user is authenticated (sends challenge if not)."""
+    if not request.get_authenticated_user(self.GetAdminCreds, self.realm):
+      return False
+    return True
+
+  class RedirectHandler(tornado.web.RequestHandler):
+    """Redirect to the https_port."""
+
+    def get(self):
+      ui = self.settings['ui']
+      print 'GET craft redirect page'
+      host = re.sub(r':.*', '', self.request.host)
+      port = ui.https_port
+      self.redirect('https://%s:%d/' % (host, port))
+
+  class MainHandler(digest.DigestAuthMixin, tornado.web.RequestHandler):
     """Displays the Craft UI."""
 
     def get(self):
       ui = self.settings['ui']
+      if not ui.Authenticate(self):
+        return
       print 'GET craft HTML page'
       self.render(ui.wwwroot + '/index.thtml', peerurl='/?peer=1')
 
-  class ConfigHandler(tornado.web.RequestHandler):
+  class ConfigHandler(digest.DigestAuthMixin, tornado.web.RequestHandler):
     """Displays the Config page."""
 
     def get(self):
       ui = self.settings['ui']
+      if not ui.Authenticate(self):
+        return
       print 'GET config HTML page'
       self.render(ui.wwwroot + '/config.thtml', peerurl='/config/?peer=1')
 
-  class RestartHandler(tornado.web.RequestHandler):
+  class RestartHandler(digest.DigestAuthMixin, tornado.web.RequestHandler):
     """Restart the box."""
 
     def get(self):
+      ui = self.settings['ui']
+      if not ui.Authenticate(self):
+        return
       print 'displaying restart interstitial screen'
       self.render('restarting.html')
 
     def post(self):
+      ui = self.settings['ui']
+      if not ui.AuthenticateAdmin(self):
+        return
       print 'user requested restart'
       self.redirect('/restart')
       os.system('(sleep 5; reboot) &')
 
-  class JsonHandler(tornado.web.RequestHandler):
+  class JsonHandler(digest.DigestAuthMixin, tornado.web.RequestHandler):
     """Provides JSON-formatted content to be displayed in the UI."""
 
-    @tornado.web.asynchronous
     def get(self):
       ui = self.settings['ui']
+      if not ui.Authenticate(self):
+        return
       print 'GET JSON data for craft page'
       jsonstring = ui.GetData()
       self.set_header('Content-Type', 'application/json')
@@ -507,6 +577,9 @@
       self.finish()
 
     def post(self):
+      ui = self.settings['ui']
+      if not ui.AuthenticateAdmin(self):
+        return
       print 'POST JSON data for craft page'
       request = self.request.body
       result = {}
@@ -519,7 +592,6 @@
         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
@@ -534,8 +606,13 @@
       self.finish()
 
   def RunUI(self):
-    """Create the web server and run forever."""
-    handlers = [
+    """Create the http redirect and https web server and run forever."""
+    sim = self.sim
+
+    redirect_handlers = [
+        (r'.*', self.RedirectHandler),
+    ]
+    craftui_handlers = [
         (r'/', self.MainHandler),
         (r'/config', self.ConfigHandler),
         (r'/content.json', self.JsonHandler),
@@ -543,9 +620,22 @@
         (r'/static/([^/]*)$', tornado.web.StaticFileHandler,
          {'path': self.wwwroot + '/static'}),
     ]
-    app = tornado.web.Application(handlers)
-    app.settings['ui'] = self
-    app.listen(self.port)
+
+    http_handlers = redirect_handlers if self.use_https else craftui_handlers
+
+    http_app = tornado.web.Application(http_handlers)
+    http_app.settings['ui'] = self
+    http_app.listen(self.http_port)
+
+    if self.use_https:
+      https_app = tornado.web.Application(craftui_handlers)
+      https_app.settings['ui'] = self
+      https_server = tornado.httpserver.HTTPServer(https_app, ssl_options={
+          'certfile': sim + '/tmp/ssl/certs/device.pem',
+          'keyfile': sim + '/tmp/ssl/private/device.key'
+      })
+      https_server.listen(self.https_port)
+
     ioloop = tornado.ioloop.IOLoop.instance()
     ioloop.start()
 
@@ -558,11 +648,14 @@
 
 def main():
   www = '/usr/craftui/www'
-  port = 80
+  http_port = 80
+  https_port = 443
+  use_https = False
   sim = ''
   try:
-    opts, args = getopt.getopt(sys.argv[1:], 's:p:w:',
-                               ['sim=', 'port=', 'www='])
+    opts, args = getopt.getopt(sys.argv[1:], 's:p:P:w:S',
+                               ['sim=', 'http-port=', 'https-port=', 'www=',
+                                'use-https='])
   except getopt.GetoptError as err:
     # print help information and exit:
     print str(err)
@@ -571,8 +664,12 @@
   for o, a in opts:
     if o in ('-s', '--sim'):
       sim = a
-    elif o in ('-p', '--port'):
-      port = int(a)
+    elif o in ('-p', '--http-port'):
+      http_port = int(a)
+    elif o in ('-P', '--https-port'):
+      https_port = int(a)
+    elif o in ('-S', '--use-https'):
+      use_https = True
     elif o in ('-w', '--www'):
       www = a
     else:
@@ -583,7 +680,7 @@
     assert False, 'extra args'
     Usage()
     sys.exit(1)
-  craftui = CraftUI(www, port, sim)
+  craftui = CraftUI(www, http_port, https_port, use_https, sim)
   craftui.RunUI()
 
 
diff --git a/craftui/craftui_test.sh b/craftui/craftui_test.sh
index 9147945..dc71fe2 100755
--- a/craftui/craftui_test.sh
+++ b/craftui/craftui_test.sh
@@ -4,6 +4,7 @@
 
 # save stdout to 3, dup stdout to a file
 log=.testlog.$$
+ln -sf LOG $log
 exec 3>&1
 exec >$log 2>&1
 
@@ -11,80 +12,204 @@
 passcount=0
 
 fail() {
-	echo "FAIL: $*" >&3
-	echo "FAIL: $*"
-	((failcount++))
+  echo "FAIL: $*" >&3
+  echo "FAIL: $*"
+  ((failcount++))
 }
 
 pass() {
-	echo "PASS: $*" >&3
-	echo "PASS: $*"
-	((passcount++))
+  echo "PASS: $*" >&3
+  echo "PASS: $*"
+  ((passcount++))
 }
 
 testname() {
-	test="$*"
-	echo "---------------------------------------------------------"
-	echo "starting test $test"
+  test="$*"
+  echo ""
+  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
+  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
+  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
+  testname "process not running at exit"
+  kill -0 $pid
+  check_failure
 
-	# cleanup
-	kill -9 $pid
+  testname "end of script reached"
+  test "$eos" = 1
+  check_success
 
-	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
+  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
+  exit $failcount
 }
 
+run_tests() {
+  local use_https http https url curl n arg secure_arg curl_arg
+  use_https=$1
+
+  http=8888
+  https=8889
+  url=http://localhost:$http
+
+  if [ "$use_https" = 1 ]; then
+    url=https://localhost:$https
+    secure_arg=-S
+    curl_arg=-k
+
+    # not really testing here, just showing the mode change
+    testname "INFO: https mode"
+    true
+    check_success
+  else
+    # not really testing here, just showing the mode change
+    testname "INFO: http mode"
+    true
+    check_success
+  fi
+
+  testname "server not running"
+  curl -s http://localhost:8888/
+  check_failure
+
+  ./craftui $secure_arg &
+  pid=$!
+
+  testname "process running"
+  kill -0 $pid
+  check_success
+
+  sleep 1
+
+  curl="curl -v -s -m 1 $curl_arg"
+
+  if [ "$use_https" = 1 ]; then
+    for n in localhost 127.0.0.1; do
+      testname "redirect web page ($n)"
+      $curl "http://$n:8888/anything" |& grep "Location: https://$n:8889/"
+      check_success
+    done
+  fi
+
+  testname "404 not found"
+  $curl $url/notexist |& grep '404: Not Found'
+  check_success
+
+  baduser_auth="--digest --user root:admin"
+  badpass_auth="--digest --user guest:admin"
+
+  for auth in "" "$baduser_auth" "$badpass_auth"; do
+    for n in / /config /content.json; do
+      testname "page $n bad auth ($auth)"
+      $curl -v $auth $url/ |& grep 'WWW-Authenticate: Digest'
+      check_success
+    done
+  done
+
+  admin_auth="--digest --user admin:admin"
+  guest_auth="--digest --user guest:guest"
+
+  for auth in "$admin_auth" "$guest_auth"; do
+    testname "main web page ($auth)"
+    $curl $auth $url/ |& grep index.thtml
+    check_success
+
+    testname "config web page ($auth)"
+    $curl $auth $url/config |& grep config.thtml
+    check_success
+
+    testname "json ($auth)"
+    $curl $auth $url/content.json |& grep '"platform": "GFCH100"'
+    check_success
+  done
+
+  testname "bad json to config page"
+  $curl $admin_auth -d 'duck' $url/content.json | grep "json format error"
+  check_success
+
+  testname "good json config"
+  d='{"config":[{"peer_ipaddr":"192.168.99.99/24"}]}'
+  $curl $admin_auth -d $d $url/content.json |& grep '"error": 0}'
+  check_success
+
+  testname "good json config, bad value"
+  d='{"config":[{"peer_ipaddr":"192.168.99.99/240"}]}'
+  $curl $admin_auth -d $d $url/content.json |& grep '"error": 1}'
+  check_success
+
+  testname "good json config, guest access"
+  d='{"config":[{"peer_ipaddr":"192.168.99.99/24"}]}'
+  $curl $guest_auth -d $d $url/content.json |& grep '401 Unauthorized'
+  check_success
+
+  testname "good json config, no auth"
+  d='{"config":[{"peer_ipaddr":"192.168.99.99/24"}]}'
+  $curl -d $d $url/content.json |& grep '401 Unauthorized'
+  check_success
+
+  testname "password is base64"
+  d='{"config":[{"password_guest":"ZHVja3k="}]}'	# ducky
+  $curl $admin_auth -d $d $url/content.json |& grep '"error": 0}'
+  check_success
+
+  # TODO(edjames): duckduck does not fail.  Need to catch that.
+  testname "password not base64"
+  d='{"config":[{"password_guest":"abc123XXX"}]}'
+  $curl $admin_auth -d $d $url/content.json |& grep '"error": 1}'
+  check_success
+
+  testname "process still running at end of test sequence"
+  kill -0 $pid
+  check_success
+
+  # cleanup
+  t0=$(date +%s)
+  kill $pid
+  wait
+  t1=$(date +%s)
+  dt=$((t1 - t0))
+
+  testname "process stopped on TERM reasonably fast"
+  echo "process stopped in $dt seconds"
+  test "$dt" -lt 3
+  check_success
+}
+
+#
+# main()
+#
 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
-
+# sanity tests
 testname true
 true
 check_success
@@ -93,15 +218,13 @@
 false
 check_failure
 
-testname "main web page"
-curl -s http://localhost:8888/ > /dev/null
-check_success
+# run without https
+run_tests 0
 
-testname "404 not found"
-curl -s http://localhost:8888/notexist | grep '404: Not Found'
-check_success
+# run with https
+run_tests 1
 
-testname "json"
-curl -s http://localhost:8888/content.json | grep '"platform": "GFCH100"'
-check_success
-
+# If there's a syntax error in this script, trap 0 will call onexit,
+# so indicate we really hit the end of the script.
+eos=1
+# end of script, add more tests before this section
diff --git a/craftui/sim.tgz b/craftui/sim.tgz
index 136253f..d3b157d 100644
--- a/craftui/sim.tgz
+++ b/craftui/sim.tgz
Binary files differ
diff --git a/craftui/www/config.thtml b/craftui/www/config.thtml
index ddca423..aa81c3e 100644
--- a/craftui/www/config.thtml
+++ b/craftui/www/config.thtml
@@ -204,3 +204,4 @@
   <script src="static/craft.js"></script>
 </body>
 </html>
+<!-- end of config.thtml (used by unit test) -->
diff --git a/craftui/www/index.thtml b/craftui/www/index.thtml
index 20bba69..54a5ccb 100644
--- a/craftui/www/index.thtml
+++ b/craftui/www/index.thtml
@@ -493,3 +493,4 @@
   <script src="static/craft.js"></script>
 </body>
 </html>
+<!-- end of index.thtml (used by unit test) -->
diff --git a/craftui/www/static/craft.js b/craftui/www/static/craft.js
index 81501aa..f93df89 100644
--- a/craftui/www/static/craft.js
+++ b/craftui/www/static/craft.js
@@ -16,6 +16,7 @@
 };
 
 CraftUI.info = {checksum: 0};
+CraftUI.am_sending = false
 
 CraftUI.updateField = function(key, val) {
   var el = document.getElementById(key);
@@ -60,6 +61,9 @@
 
 CraftUI.getInfo = function() {
   // Request info, set the connected status, and update the fields.
+  if (CraftUI.am_sending) {
+    return;
+  }
   var xhr = new XMLHttpRequest();
   xhr.onreadystatechange = function() {
     self.unhandled = '';
@@ -68,11 +72,13 @@
       CraftUI.flattenAndUpdateFields(list, '');
     }
     CraftUI.updateField('unhandled', self.unhandled);
+    CraftUI.am_sending = false
   };
   var payload = [];
   payload.push('checksum=' + encodeURIComponent(CraftUI.info.checksum));
   payload.push('_=' + encodeURIComponent((new Date()).getTime()));
   xhr.open('get', 'content.json?' + payload.join('&'), true);
+  CraftUI.am_sending = true
   xhr.send();
 };
 
diff --git a/speedtest/options.cc b/speedtest/options.cc
index f657b0c..7267856 100644
--- a/speedtest/options.cc
+++ b/speedtest/options.cc
@@ -77,6 +77,7 @@
 const int kOptSkipUpload = 1004;
 const int kOptSkipPing = 1005;
 const int kOptNoReportResults = 1006;
+const int kOptServerId = 1007;
 
 const int kOptMinTransferTime = 1100;
 const int kOptMaxTransferTime = 1101;
@@ -121,6 +122,7 @@
     {"ping_timeout", required_argument, nullptr, kOptPingTimeout},
     {"exponential_moving_average", no_argument, nullptr,
         kOptExponentialMovingAverage},
+    {"serverid", required_argument, nullptr, kOptServerId},  // ignored
     {nullptr, 0, nullptr, 0},
 };
 const int kMaxNumber = 1000;
@@ -441,6 +443,9 @@
       case kOptExponentialMovingAverage:
         options->exponential_moving_average = true;
         break;
+      case kOptServerId:
+        // --serverid is accepted but ignored, for backwards compatibility.
+        break;
       default:
         return false;
     }
diff --git a/taxonomy/dhcp.py b/taxonomy/dhcp.py
index e75a337..db3797d 100644
--- a/taxonomy/dhcp.py
+++ b/taxonomy/dhcp.py
@@ -43,7 +43,7 @@
 
     '1,3,6': ['dashbutton', 'canonprinter'],
 
-    '1,3,6,28': ['ecobee'],
+    '1,3,6,28': ['ecobee', 'canonprinter'],
 
     '1,3,6,12,15,17,28,40,41,42': ['epsonprinter'],
 
@@ -51,13 +51,14 @@
     '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'],
+    '6,3,1,15,66,67,13,44,12,81,252': ['hpprinter'],
 
     '1,3,6,15,119,252': ['ios'],
     '1,121,3,6,15,119,252': ['ios'],
 
     '1,3,6,15,119,95,252,44,46,47': ['ipodtouch1'],
 
-    '252,3,42,15,6,1,12': ['lgtv'],
+    '252,3,42,15,6,1,12': ['lgtv', 'tizen'],
 
     '1,3,6,15,119,95,252,44,46,101': ['macos'],
     '1,3,6,15,119,95,252,44,46': ['macos'],
@@ -71,7 +72,9 @@
 
     '1,3,6,12,15,28,42,125': ['samsungtv'],
 
-    '1,3,6,12,15,28,42': ['visiotv'],
+    '1,28,2,3,15,6,12': ['tivo'],
+
+    '1,3,6,12,15,28,42': ['visiotv', 'wemo'],
     '1,3,6,12,15,28,40,41,42': ['visiotv', 'kindle'],
 
     '1,3,6,15,28,33': ['wii'],
diff --git a/taxonomy/ethernet.py b/taxonomy/ethernet.py
index c3673bb..7489a76 100644
--- a/taxonomy/ethernet.py
+++ b/taxonomy/ethernet.py
@@ -41,6 +41,7 @@
     '1c:87:2c': ['asus'],
     '2c:56:dc': ['asus'],
     '30:85:a9': ['asus'],
+    '50:46:5d': ['asus'],
     '5c:ff:35': ['asus'],
     '60:a4:4c': ['asus'],
     '74:d0:2b': ['asus'],
@@ -83,10 +84,12 @@
     'd8:b3:77': ['htc'],
     'e8:99:c4': ['htc'],
 
+    '00:1e:b2': ['lg'],
     '00:34:da': ['lg'],
     '0c:48:85': ['lg'],
     '10:68:3f': ['lg'],
     '2c:54:cf': ['lg'],
+    '34:4d:f7': ['lg'],
     '34:fc:ef': ['lg'],
     '3c:bd:d8': ['lg'],
     '40:b0:fa': ['lg'],
@@ -99,6 +102,7 @@
     '8c:3a:e3': ['lg'],
     'a0:39:f7': ['lg'],
     'a0:91:69': ['lg'],
+    'ac:0d:1b': ['lg'],
     'bc:f5:ac': ['lg'],
     'c4:43:8f': ['lg'],
     'c4:9a:02': ['lg'],
@@ -107,6 +111,8 @@
     'f8:95:c7': ['lg'],
     'f8:a9:d0': ['lg'],
 
+    '24:fd:52': ['liteon', 'sling'],
+
     '00:0d:3a': ['microsoft'],
     '00:12:5a': ['microsoft'],
     '00:17:fa': ['microsoft'],
@@ -173,7 +179,9 @@
     '00:26:37': ['samsung'],
     '08:d4:2b': ['samsung'],
     '08:ec:a9': ['samsung'],
+    '10:30:47': ['samsung'],
     '14:32:d1': ['samsung'],
+    '14:49:e0': ['samsung'],
     '18:22:7e': ['samsung'],
     '20:6e:9c': ['samsung'],
     '24:4b:81': ['samsung'],
@@ -188,6 +196,7 @@
     '3c:a1:0d': ['samsung'],
     '40:0e:85': ['samsung'],
     '48:5a:3f': ['samsung', 'wisol'],
+    '4c:3c:16': ['samsung'],
     '4c:bc:a5': ['samsung'],
     '50:cc:f8': ['samsung'],
     '54:88:0e': ['samsung'],
diff --git a/taxonomy/pcaptest.py b/taxonomy/pcaptest.py
index fd620d8..1e7baac 100644
--- a/taxonomy/pcaptest.py
+++ b/taxonomy/pcaptest.py
@@ -62,6 +62,8 @@
   ('Moto G or Moto X', './testdata/pcaps/Moto X 2.4GHz Specific.pcap'),
   ('Moto G or Moto X', './testdata/pcaps/Moto X 2.4GHz.pcap'),
   ('Nest Thermostat v1/v2', './testdata/pcaps/Nest Thermostat 2.4GHz.pcap'),
+  ('Roku 3 or Streaming Stick', './testdata/pcaps/Roku 3 2.4GHz 4230.pcap'),
+  ('Roku 3 or Streaming Stick', './testdata/pcaps/Roku 3 5GHz 4230.pcap'),
   ('Samsung Galaxy Note or S2+', './testdata/pcaps/Samsung Galaxy S2+ 5GHz.pcap'),
   ('Samsung Galaxy Note or S2+', './testdata/pcaps/Samsung Galaxy Note 5GHz.pcap'),
   ('Samsung Galaxy S2 or Infuse', './testdata/pcaps/Samsung Galaxy S2 2.4GHz.pcap'),
diff --git a/taxonomy/testdata/dhcp.leases b/taxonomy/testdata/dhcp.leases
index 6f13edf..7bbbc5f 100644
--- a/taxonomy/testdata/dhcp.leases
+++ b/taxonomy/testdata/dhcp.leases
@@ -49,3 +49,5 @@
 1432237016 00:1e:c2:24:7f:10 192.168.42.39 iPhoone-2
 1432237016 00:23:12:99:30:93 192.168.42.39 iPhoone-3
 1432237016 34:c8:03:89:d3:e8 192.168.42.40 Nokia-Lumia-920
+1432237016 14:91:82:07:c7:ed 192.168.42.41 WeMo
+1432237016 08:05:81:c5:1f:31 192.168.42.42 Roku3
diff --git a/taxonomy/testdata/dhcp.signatures b/taxonomy/testdata/dhcp.signatures
index 06b641b..0849bff 100644
--- a/taxonomy/testdata/dhcp.signatures
+++ b/taxonomy/testdata/dhcp.signatures
@@ -41,3 +41,5 @@
 00:1e:c2:24:7f:10 1,3,6,15,119,252
 00:23:12:99:30:93 1,3,6,15,119,252
 34:c8:03:89:d3:e8 1,15,3,6,44,46,47,31,33,121,249,252,43
+14:91:82:07:c7:ed 1,3,6,12,15,28,42
+08:05:81:c5:1f:31 1,3,6,15,12
diff --git a/taxonomy/testdata/pcaps/Belkin WeMo Switch 2.4GHz.pcap b/taxonomy/testdata/pcaps/Belkin WeMo Switch 2.4GHz.pcap
new file mode 100644
index 0000000..aa3bdeb
--- /dev/null
+++ b/taxonomy/testdata/pcaps/Belkin WeMo Switch 2.4GHz.pcap
Binary files differ
diff --git a/taxonomy/testdata/pcaps/Nexus 6 5GHz Android 5.1.1.pcap b/taxonomy/testdata/pcaps/Nexus 6 5GHz Android 5.1.1.pcap
new file mode 100644
index 0000000..67e16e2
--- /dev/null
+++ b/taxonomy/testdata/pcaps/Nexus 6 5GHz Android 5.1.1.pcap
Binary files differ
diff --git a/taxonomy/testdata/pcaps/Roku 3 2.4GHz 4230.pcap b/taxonomy/testdata/pcaps/Roku 3 2.4GHz 4230.pcap
new file mode 100644
index 0000000..9bd8a0c
--- /dev/null
+++ b/taxonomy/testdata/pcaps/Roku 3 2.4GHz 4230.pcap
Binary files differ
diff --git a/taxonomy/testdata/pcaps/Roku 3 5GHz 4230.pcap b/taxonomy/testdata/pcaps/Roku 3 5GHz 4230.pcap
new file mode 100644
index 0000000..d53b5a5
--- /dev/null
+++ b/taxonomy/testdata/pcaps/Roku 3 5GHz 4230.pcap
Binary files differ
diff --git a/taxonomy/wifi.py b/taxonomy/wifi.py
index ee4aee2..87c7d86 100644
--- a/taxonomy/wifi.py
+++ b/taxonomy/wifi.py
@@ -88,17 +88,28 @@
     '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'),
 
+    '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:00000804|assoc:0,1,48,50,45,70,221(001018,2),221(00904c,51),221(0050f2,2),htcap:0020,htagg:1a,htmcs:000000ff|os:ios':
+        ('', 'Apple Watch', '2.4GHz'),
+
+    'wifi4|probe:0,1,50,221(0050f2,4)|assoc:0,1,50,45,221(0050f2,2),48,htcap:000c,htagg:1b,htmcs:000000ff|os:wemo':
+        ('', 'Belkin WeMo Switch', '2.4GHz'),
+
     'wifi4|probe:0,1,50,221(0050f2,4),221(506f9a,9),wps:BLU_DASH_M|assoc:0,1,50,45,48,127,221(0050f2,2),htcap:1172,htagg:03,htmcs:000000ff,extcap:01':
         ('', 'BLU Dash M', '2.4GHz'),
 
     'wifi4|probe:0,1,50,127,107,221(0050f2,4),221(506f9a,9),221(506f9a,16),extcap:00000080,wps:BLU_STUDIO_5_0_C_HD|assoc:0,1,50,45,48,127,221(0050f2,2),htcap:1172,htagg:03,htmcs:000000ff,extcap:0100008000c6':
         ('', 'BLU Studio 5.0.C HD', '2.4GHz'),
 
+    'wifi4|probe:0,1,50,221(0050f2,4),221(506f9a,9),wps:BLU_STUDIO_C_SUPER_CAMERA|assoc:0,1,50,45,48,127,221(0050f2,2),htcap:1172,htagg:03,htmcs:000000ff,extcap:01':
+        ('', 'BLU Studio C Super Camera', '2.4GHz'),
+
     '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,3,45,50,htcap:007e,htagg:00,htmcs:000000ff|assoc:0,1,45,48,50,221(0050f2,2),htcap:000c,htagg:1b,htmcs:000000ff|os:canonprinter':
         ('', 'Canon Printer', '2.4GHz'),
+    'wifi4|probe:0,1,3,45,50,htcap:007e,htagg:00,htmcs:000000ff|assoc:0,1,48,50,221(0050f2,2),45,htcap:000c,htagg:1b,htmcs:000000ff|os:canonprinter':
+        ('', 'Canon 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':
         ('Intel_7260', 'Chromebook Pixel 2', '5GHz'),
@@ -157,12 +168,16 @@
         ('', 'HP Printer', '2.4GHz'),
     'wifi4|probe:0,1,50,3,45,221(001018,2),221(00904c,51),htcap:0020,htagg:1a,htmcs:000000ff|assoc:0,1,48,50,45,221(001018,2),221(0050f2,2),221(506f9a,9),htcap:0020,htagg:1a,htmcs:000000ff|os:hpprinter':
         ('', 'HP Printer', '2.4GHz'),
+    'wifi4|probe:0,1,50,3,45,221(001018,2),221(00904c,51),htcap:0020,htagg:1a,htmcs:000000ff|assoc:0,1,48,50,45,221(001018,2),221(0050f2,2),htcap:0020,htagg:1a,htmcs:000000ff|os:hpprinter':
+        ('', 'HP Printer', '2.4GHz'),
     'wifi4|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,extcap:00|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,50,221(001018,2)|assoc:0,1,48,50,221(001018,2)|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,48,50,221(0050f2,2)|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'),
@@ -298,10 +313,14 @@
     '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,70,221(001018,2),221(00904c,51),221(0050f2,2),htcap:0020,htagg:1a,htmcs:000000ff,txpow:1403|os:ios':
         ('BCM4334', 'iPhone 5', '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:00000804|assoc:0,1,33,36,48,45,221(001018,2),221(00904c,51),221(0050f2,2),htcap:0062,htagg:1a,htmcs:000000ff,txpow:1805|os:ios':
+        ('BCM4334', 'iPhone 5c', '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,221(001018,2),221(00904c,51),221(0050f2,2),htcap:0062,htagg:1a,htmcs:000000ff,txpow:1805|os:ios':
         ('BCM4334', 'iPhone 5c', '5GHz'),
     'wifi4|probe:0,1,45,127,107,221(001018,2),221(00904c,51),221(0050f2,8),htcap:0062,htagg:1a,htmcs:000000ff,extcap:00000804|assoc:0,1,33,36,48,45,70,221(001018,2),221(00904c,51),221(0050f2,2),htcap:0062,htagg:1a,htmcs:000000ff,txpow:1805|os:ios':
         ('BCM4334', 'iPhone 5c', '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:1805|os:ios':
+        ('BCM4334', 'iPhone 5c', '5GHz'),
     '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:1704|os:ios':
         ('BCM4334', 'iPhone 5c', '2.4GHz'),
     'wifi4|probe:0,1,50,3,45,107,221(001018,2),221(00904c,51),221(0050f2,8),htcap:0020,htagg:1a,htmcs:000000ff|assoc:0,1,33,36,48,50,45,221(001018,2),221(00904c,51),221(0050f2,2),htcap:0020,htagg:1a,htmcs:000000ff,txpow:1704|os:ios':
@@ -322,10 +341,16 @@
         ('BCM4339', 'iPhone 6/6+', '5GHz'),
     'wifi4|probe:0,1,45,127,107,191,221(0050f2,8),221(001018,2),htcap:0063,htagg:17,htmcs:000000ff,vhtcap:0f805032,vhtrxmcs:0000fffe,vhttxmcs:0000fffe,extcap:0400088400000040|assoc:0,1,33,36,48,45,127,191,221(001018,2),221(0050f2,2),htcap:0063,htagg:17,htmcs:000000ff,vhtcap:0f805032,vhtrxmcs:0000fffe,vhttxmcs:0000fffe,txpow:e002,extcap:0400000000000040|os:ios':
         ('BCM4339', 'iPhone 6/6+', '5GHz'),
+    'wifi4|probe:0,1,45,127,107,191,221(00904c,51),221(0050f2,8),221(001018,2),htcap:0063,htagg:17,htmcs:000000ff,vhtcap:0f805032,vhtrxmcs:0000fffe,vhttxmcs:0000fffe,extcap:0400088400000040|assoc:0,1,33,36,48,70,45,127,191,221(001018,2),221(0050f2,2),htcap:0063,htagg:17,htmcs:000000ff,vhtcap:0f805032,vhtrxmcs:0000fffe,vhttxmcs:0000fffe,txpow:e002,extcap:0400000000000040|os:ios':
+        ('BCM4339', 'iPhone 6/6+', '5GHz'),
+    'wifi4|probe:0,1,45,127,107,191,221(00904c,51),221(0050f2,8),221(001018,2),htcap:0063,htagg:17,htmcs:000000ff,vhtcap:0f805032,vhtrxmcs:0000fffe,vhttxmcs:0000fffe,extcap:0400088400000040|assoc:0,1,33,36,48,45,127,191,221(001018,2),221(0050f2,2),htcap:0063,htagg:17,htmcs:000000ff,vhtcap:0f805032,vhtrxmcs:0000fffe,vhttxmcs:0000fffe,txpow:e002,extcap:0400000000000040|os:ios':
+        ('BCM4339', 'iPhone 6/6+', '5GHz'),
     'wifi4|probe:0,1,50,3,45,127,107,221(0050f2,8),221(001018,2),htcap:0021,htagg:17,htmcs:000000ff,extcap:0400088400000040|assoc:0,1,50,33,36,48,70,45,127,221(001018,2),221(0050f2,2),htcap:0021,htagg:17,htmcs:000000ff,txpow:1302,extcap:0000000000000040|os:ios':
         ('BCM4339', 'iPhone 6', '2.4GHz'),
     'wifi4|probe:0,1,50,3,45,127,107,221(0050f2,8),221(001018,2),htcap:0021,htagg:17,htmcs:000000ff,extcap:0400088400000040|assoc:0,1,50,33,36,48,45,127,221(001018,2),221(0050f2,2),htcap:0021,htagg:17,htmcs:000000ff,txpow:1302,extcap:0000000000000040|os:ios':
         ('BCM4339', 'iPhone 6', '2.4GHz'),
+    'wifi4|probe:0,1,50,3,45,127,107,221(00904c,51),221(0050f2,8),221(001018,2),htcap:0021,htagg:17,htmcs:000000ff,extcap:0400088400000040|assoc:0,1,50,33,36,48,70,45,127,221(001018,2),221(0050f2,2),htcap:0021,htagg:17,htmcs:000000ff,txpow:1302,extcap:0000000000000040|os:ios':
+        ('BCM4339', 'iPhone 6', '2.4GHz'),
     'wifi4|probe:0,1,50,3,45,127,107,221(0050f2,8),221(001018,2),htcap:0021,htagg:17,htmcs:000000ff,extcap:0400088400000040|assoc:0,1,50,33,36,48,70,45,127,221(001018,2),221(0050f2,2),htcap:0021,htagg:17,htmcs:000000ff,txpow:1402,extcap:0000000000000040|os:ios':
         ('BCM4339', 'iPhone 6+', '2.4GHz'),
     'wifi4|probe:0,1,50,3,45,127,107,221(0050f2,8),221(001018,2),htcap:0021,htagg:17,htmcs:000000ff,extcap:0400088400000040|assoc:0,1,50,33,36,48,45,127,221(001018,2),221(0050f2,2),htcap:0021,htagg:17,htmcs:000000ff,txpow:1402,extcap:0000000000000040|os:ios':
@@ -349,6 +374,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:000000ff,vhtcap:0f815832,vhtrxmcs:0000fffe,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':
         ('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,70,45,127,191,221(001018,2),221(0050f2,2),htcap:006f,htagg:17,htmcs:000000ff,vhtcap:0f815832,vhtrxmcs:0000fffe,vhttxmcs:0000fffe,txpow:e002,extcap:0400000000000040|os:ios':
+        ('BCM4350', 'iPhone 6s/6s+', '5GHz'),
     '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,45,127,221(001018,2),221(0050f2,2),htcap:002d,htagg:17,htmcs:0000ffff,txpow:1202,extcap:0000000000000040|os:ios':
         ('BCM4350', 'iPhone 6s/6s+', '2.4GHz'),
     'wifi4|probe:0,1,50,3,45,127,107,221(0050f2,8),221(001018,2),htcap:002d,htagg:17,htmcs:000000ff,extcap:0400088400000040|assoc:0,1,50,33,36,48,45,127,221(001018,2),221(0050f2,2),htcap:002d,htagg:17,htmcs:0000ffff,txpow:1202,extcap:0000000000000040|os:ios':
@@ -361,6 +388,10 @@
         ('BCM4350', 'iPhone 6s/6s+', '2.4GHz'),
     'wifi4|probe:0,1,50,3,45,127,107,221(00904c,51),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:1202,extcap:0000000000000040|os:ios':
         ('BCM4350', 'iPhone 6s/6s+', '2.4GHz'),
+    'wifi4|probe:0,1,50,3,45,127,107,221(00904c,51),221(0050f2,8),221(001018,2),htcap:002d,htagg:17,htmcs:000000ff,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:1202,extcap:0000000000000040|os:ios':
+        ('BCM4350', 'iPhone 6s/6s+', '2.4GHz'),
+    '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:000000ff,txpow:1202,extcap:0000000000000040|os:ios':
+        ('BCM4350', 'iPhone 6s/6s+', '2.4GHz'),
 
     'wifi4|probe:0,1,3,50|assoc:0,1,48,50|os:ipodtouch1':
         ('Marvell_W8686B22', 'iPod Touch 1st/2nd gen', '2.4GHz'),
@@ -393,11 +424,11 @@
         ('BCM4339', 'LG G3', '2.4GHz'),
     'wifi4|probe:0,1,50,3,45,221(0050f2,8),127,107,221(506f9a,16),htcap:012c,htagg:03,htmcs:000000ff,extcap:000000800040|assoc:0,1,50,33,48,70,45,221(0050f2,2),127,htcap:012c,htagg:03,htmcs:000000ff,txpow:170d,extcap:00000a8201400000|oui:lg':
         ('BCM4339', 'LG G3', '2.4GHz'),
+    'wifi4|probe:0,1,50,3,45,221(0050f2,8),127,107,221(506f9a,16),htcap:016e,htagg:03,htmcs:000000ff,extcap:000000800040|assoc:0,1,50,33,48,70,45,221(0050f2,2),127,htcap:012c,htagg:03,htmcs:000000ff,txpow:170d,extcap:00000a8201400000|oui:lg':
+        ('BCM4339', 'LG G3', '2.4GHz'),
 
     '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:016f,htagg:17,htmcs:000000ff,vhtcap:0f805932,vhtrxmcs:0000fffe,vhttxmcs:0000fffe,extcap:0000088001400040|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:1d01,extcap:0000008001400040|oui:lg':
         ('BCM4339', 'LG G4', '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:0000088000400040|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:0000008000400040|oui:lg':
-        ('BCM4339', 'LG G4', '5GHz'),
     'wifi4|probe:0,1,50,45,127,107,221(506f9a,16),221(001018,2),221(00904c,51),221(00904c,4),221(0050f2,8),htcap:112d,htagg:17,htmcs:000000ff,extcap:0000088001400040|assoc:0,1,33,36,48,50,45,127,221(001018,2),221(0050f2,2),htcap:112d,htagg:17,htmcs:000000ff,txpow:1001,extcap:000000800140|oui:lg':
         ('BCM4339', 'LG G4', '2.4GHz'),
 
@@ -484,6 +515,10 @@
 
     'wifi4|probe:0,1,50,45,htcap:0130,htagg:18,htmcs:000000ff|assoc:0,1,50,48,45,221(0050f2,2),htcap:013c,htagg:18,htmcs:000000ff|oui:nest':
         ('TI_WL1270', 'Nest Thermostat v1/v2', '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:0f09|oui:nest':
+        ('', 'Nest Thermostat v3', '5GHz'),
+    'wifi4|probe:0,1,50,3,45,221(001018,2),221(00904c,51),htcap:0020,htagg:1a,htmcs:000000ff|assoc:0,1,33,36,48,50,45,221(001018,2),221(00904c,51),221(0050f2,2),htcap:0020,htagg:1a,htmcs:000000ff,txpow:150b|oui:nest':
+        ('', 'Nest Thermostat v3', '2.4GHz'),
 
     '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),htcap:012c,htagg:03,htmcs:000000ff':
         ('QCA_WCN3360', 'Nexus 4', '5GHz'),
@@ -564,6 +599,8 @@
         ('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,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:0000088001400040,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:0000088001400040':
+        ('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':
@@ -632,6 +669,8 @@
         ('BCM4354', 'Nexus 9', '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:000008800140|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|oui:samsung':
         ('BCM4354', 'Nexus 9', '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_9|assoc:0,1,50,33,36,48,45,221(001018,2),221(0050f2,2),htcap:002d,htagg:17,htmcs:0000ffff,txpow:1309':
+        ('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'),
@@ -672,6 +711,8 @@
         ('Marvell_88W8797', 'Playstation 4', '2.4GHz'),
     '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'),
+    'wifi4|probe:0,1,3,50|assoc:0,1,33,48,50,221(0050f2,2),45,htcap:010c,htagg:03,htmcs:0000ffff,txpow:0f06|os:playstation':
+        ('Marvell_88W8797', 'Playstation 4', '2.4GHz'),
 
     'wifi4|probe:0,1,50,221(0050f2,4),221(506f9a,9),wps:RCT6303W87DK|assoc:0,1,50,45,48,127,221(0050f2,2),htcap:1172,htagg:03,htmcs:000000ff,extcap:01':
         ('', 'RCA 10 Viking Pro', '2.4GHz'),
@@ -704,13 +745,15 @@
     'wifi4|probe:0,1,50,45,3,221(001018,2),221(00904c,51),htcap:187c,htagg:1a,htmcs:0000ffff|assoc:0,1,48,50,45,221(001018,2),221(00904c,51),221(0050f2,2),htcap:187c,htagg:1a,htmcs:0000ffff|os:roku':
         ('BCM4336', 'Roku 2', '2.4GHz'),
 
-    # Roku 3 model 4230, 4200, 4200X and Roku 2 model 4210 and Roku Streaming Stick model 3500
+    # Roku 3 model 4230, 4200, 4200X and Roku Streaming Stick model 3500
     'wifi4|probe:0,1,45,127,221(001018,2),221(00904c,51),htcap:09bc,htagg:16,htmcs:0000ffff,extcap:0000000000000040|assoc:0,1,33,36,48,45,127,221(001018,2),221(0050f2,2),htcap:09bc,htagg:16,htmcs:0000ffff,txpow:100a,extcap:0000000000000040|os:roku':
-        ('BCM43236', 'Roku 3/SS', '5GHz'),
+        ('BCM43236', 'Roku 3 or Streaming Stick', '5GHz'),
+    'wifi4|probe:0,1,3,45,127,221(001018,2),221(00904c,51),htcap:09bc,htagg:16,htmcs:0000ffff,extcap:0000000000000040|assoc:0,1,33,36,48,45,127,221(001018,2),221(0050f2,2),htcap:09bc,htagg:16,htmcs:0000ffff,txpow:100a,extcap:0000000000000040|os:roku':
+        ('BCM43236', 'Roku 3 or Streaming Stick', '5GHz'),
     '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/SS', '2.4GHz'),
+        ('BCM43236', 'Roku 3 or Streaming Stick', '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/SS', '2.4GHz'),
+        ('BCM43236', 'Roku 3 or Streaming Stick', '2.4GHz'),
 
     # Roku 4 model 4400
     'wifi4|probe:0,1,45,127,191,221(001018,2),htcap:01ad,htagg:17,htmcs:0000ffff,vhtcap:0f8159b2,vhtrxmcs:0000fffa,vhttxmcs:0000fffa,extcap:0000000000000040|assoc:0,1,33,36,48,45,127,191,199,221(001018,2),221(0050f2,2),htcap:01ad,htagg:17,htmcs:0000ffff,vhtcap:0f8159b2,vhtrxmcs:0000fffa,vhttxmcs:0000fffa,txpow:1109,extcap:0000000000000040|os:roku':
@@ -813,6 +856,8 @@
         ('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,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: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,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,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':
@@ -847,6 +892,8 @@
         ('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,70,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,45,127,107,191,221(506f9a,16),221(00904c,4),221(0050f2,8),221(001018,2),htcap:0163,htagg:17,htmcs:0000ffff,vhtcap:0f907032,vhtrxmcs:0000fffa,vhttxmcs:0000fffa,extcap:00080f8401400040|assoc:0,1,33,36,48,70,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':
         ('', 'Samsung Galaxy S7', '5GHz'),
@@ -884,6 +931,9 @@
     '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,3,45,221(001018,2),htcap:0020,htagg:1f,htmcs:000000ff|assoc:0,1,50,33,36,48,45,221(001018,2),221(0050f2,2),htcap:0020,htagg:1f,htmcs:000000ff,txpow:1203|os:tizen':
+        ('', 'Samsung Gear S2', '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'),
     'wifi4|probe:0,1,50,45,htcap:01ac,htagg:02,htmcs:0000ffff|assoc:0,1,50,45,127,48,221(0050f2,2),htcap:01ac,htagg:02,htmcs:0000ffff,extcap:01|os:samsungtv':
@@ -893,6 +943,9 @@
     'wifi4|probe:0,1,50,45,htcap:0120,htagg:02,htmcs:000000ff|assoc:0,1,50,48,221(0050f2,2),45,127,htcap:0120,htagg:02,htmcs:000000ff,extcap:01|os:samsungtv':
         ('', 'Samsung Smart TV', '2.4GHz'),
 
+    'wifi4|probe:0,1,50,3,45,htcap:11ef,htagg:1b,htmcs:0000ffff|assoc:0,1,50,48,45,221(0050f2,2),htcap:11ef,htagg:1b,htmcs:0000ffff|oui:sling':
+        ('', 'Slingbox 500', '2.4GHz'),
+
     'wifi4|probe:0,1,45,221(0050f2,4),htcap:11ee,htagg:02,htmcs:0000ffff,wps:Sony_BRAVIA|assoc:0,1,33,36,48,221(0050f2,2),45,127,htcap:11ee,htagg:02,htmcs:0000ffff,txpow:0500,extcap:01':
         ('', 'Sony Bravia TV', '5GHz'),
     'wifi4|probe:0,1,50,45,127,221(0050f2,4),221(506f9a,10),221(506f9a,9),htcap:01ed,htagg:13,htmcs:0000ffff,extcap:00,wps:BRAVIA_2015|assoc:0,1,50,45,127,221(000c43,6),221(0050f2,2),48,127,htcap:008c,htagg:13,htmcs:0000ffff,extcap:00000a02':
@@ -920,12 +973,30 @@
     'wifi4|probe:0,1,50,3,45,127,221(0050f2,4),221(506f9a,9),221(506f9a,16),221(0050f2,8),221(001018,2),htcap:1063,htagg:17,htmcs:000000ff,extcap:000008800140,wps:0PJA2|assoc:0,1,50,33,36,48,45,127,221(001018,2),221(0050f2,2),htcap:1063,htagg:17,htmcs:000000ff,txpow:1309,extcap:000008800140':
         ('', 'Sprint One M9', '2.4GHz'),
 
+    # TIVO-849
+    'wifi4|probe:0,1,45,127,191,221(001018,2),htcap:006f,htagg:17,htmcs:0000ffff,vhtcap:0f815832,vhtrxmcs:0000fffa,vhttxmcs:0000fffa,extcap:0000008001|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:0000008001|os:tivo':
+        ('', 'TiVo BOLT', '5GHz'),
+    # TIVO-746
+    'wifi4|probe:0,1,50,221(00904c,51),45,48,htcap:13ce,htagg:1b,htmcs:0000ffff|assoc:0,1,33,36,50,221(0050f2,2),221(00904c,51),45,221(002163,1),221(002163,4),48,htcap:13ce,htagg:1b,htmcs:0000ffff,txpow:0f0f|os:tivo':
+        ('', 'TiVo Premiere Series 4', '2.4GHz'),
+    # TIVO-846
+    'wifi4|probe:0,1,50,45,3,221(001018,2),221(00904c,51),htcap:107c,htagg:19,htmcs:0000ffff|assoc:0,1,33,36,48,50,45,221(001018,2),221(00904c,51),221(0050f2,2),htcap:107c,htagg:19,htmcs:0000ffff,txpow:1208|os:tivo':
+        ('', 'TiVo Roamio', '2.4GHz'),
+    # TIVO-848
+    'wifi4|probe:0,1,50|assoc:0,1,50,221(0050f2,2),45,51,48,htcap:01ac,htagg:1b,htmcs:0000ffff|os:tivo':
+        ('', 'TiVo Roamio Plus', '2.4GHz'),
+    # TiVo-652 HD and HD XL, TIVO-648, and TIVO-750 have the same signature
+    'wifi4|probe:0,1,50,221(001018,2)|assoc:0,1,48,50,221(001018,2)|os:tivo':
+        ('', 'TiVo Series 3/4', '2.4GHz'),
+
     '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'),
     '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:13,htmcs:000000ff,extcap:01|os:visiotv':
         ('', 'Vizio Smart TV', '2.4GHz'),
     'wifi4|probe:0,1,50,45,127,221(0050f2,4),htcap:106e,htagg:12,htmcs:000000ff,extcap:00,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'),
+    'wifi4|probe:0,1,50,221(0050f2,4),wps:Ralink_Wireless_Linux_Client|assoc:0,1,50,45,221(000c43,6),221(0050f2,2),48,htcap:000c,htagg:13,htmcs:000000ff|os:visiotv':
+        ('', 'Vizio Smart TV', '2.4GHz'),
     'wifi4|probe:0,1,50,48|assoc:0,1,50,221(0050f2,2),45,51,127,48,htcap:012c,htagg:1b,htmcs:000000ff,extcap:01|os:visiotv':
         ('', 'Vizio Smart TV', '2.4GHz'),
 
diff --git a/wifi/configs.py b/wifi/configs.py
index 11867b2..676182a 100644
--- a/wifi/configs.py
+++ b/wifi/configs.py
@@ -23,6 +23,14 @@
   experiment.register(_i)
 
 
+# From http://go/alphabet-ie-registry, OUI f4f5e8.
+# The properties of this class are hex string representations of varints.
+# pylint: disable=invalid-name
+class VENDOR_IE_FEATURE_ID(object):
+  SUPPORTS_PROVISIONING = '01'
+  PROVISIONING_SSID = '03'
+
+
 # Recommended HT40/VHT80 settings for given primary channels.
 # HT40 channels can fall back to 20 MHz, and VHT80 can fall back to 40 or 20.
 # So we configure using a "primary" 20 MHz channel, then allow wider
@@ -78,6 +86,7 @@
 {require_vht}
 {hidden}
 {ap_isolate}
+{vendor_elements}
 
 ht_capab={ht20}{ht40}{guard_interval}{ht_rxstbc}
 {vht_settings}
@@ -285,7 +294,8 @@
       ht_rxstbc=ht_rxstbc, vht_settings=vht_settings,
       guard_interval=guard_interval, enable_wmm=enable_wmm, hidden=hidden,
       ap_isolate=ap_isolate, auth_algs=auth_algs, bridge=bridge,
-      ssid=utils.sanitize_ssid(opt.ssid))]
+      ssid=utils.sanitize_ssid(opt.ssid),
+      vendor_elements=get_vendor_elements(opt))]
 
   if opt.encryption != 'NONE':
     hostapd_conf_parts.append(_HOSTCONF_WPA_TPL.format(
@@ -342,3 +352,47 @@
   ]
   return '\n'.join(lines)
 
+
+def create_vendor_ie(feature_id, payload=''):
+  """Create a vendor IE in hostapd config format.
+
+  Args:
+    feature_id:  The go/alphabet-ie-registry feature ID for OUI f4f5e8.
+    payload:  A string payload (must be ASCII), or none.
+
+  Returns:
+    The vendor IE, as a string.
+  """
+  length = '%02x' % (3 + (len(feature_id)/2) + len(payload))
+  oui = 'f4f5e8'
+  return 'dd%s%s%s%s' % (length, oui, feature_id, payload.encode('hex'))
+
+
+def get_vendor_elements(opt):
+  """Get vendor_elements value hostapd config.
+
+  The way to specify multiple vendor IEs in hostapd is to concatenate them, e.g.
+
+    vendor_elements=dd0411223301dd051122330203
+
+  Args:
+    opt:  The optdict containing user-specified options.
+
+  Returns:
+    The vendor_elements string (including that prefix, or empty if there are no
+    vendor IEs.)
+  """
+  vendor_ies = []
+
+  if opt.supports_provisioning:
+    vendor_ies.append(
+        create_vendor_ie(VENDOR_IE_FEATURE_ID.SUPPORTS_PROVISIONING))
+
+  if opt.hidden_mode:
+    vendor_ies.append(
+        create_vendor_ie(VENDOR_IE_FEATURE_ID.PROVISIONING_SSID, opt.ssid))
+
+  if vendor_ies:
+    return 'vendor_elements=%s' % ''.join(vendor_ies)
+
+  return ''
diff --git a/wifi/configs_test.py b/wifi/configs_test.py
index 77773ce..ece4ca9 100755
--- a/wifi/configs_test.py
+++ b/wifi/configs_test.py
@@ -310,6 +310,7 @@
 
 
 
+
 ht_capab=[HT20][RX-STBC1]
 
 """
@@ -333,6 +334,31 @@
 
 
 
+
+ht_capab=[HT20][RX-STBC1]
+
+"""
+
+_HOSTAPD_CONFIG_PROVISION_VIA = """ctrl_interface=/var/run/hostapd
+interface=wlan0
+
+ssid=TEST_SSID
+utf8_ssid=1
+auth_algs=1
+hw_mode=g
+channel=1
+country_code=US
+ieee80211d=1
+ieee80211h=1
+ieee80211n=1
+
+
+
+
+ignore_broadcast_ssid=1
+
+vendor_elements=dd04f4f5e801dd0df4f5e803544553545f53534944
+
 ht_capab=[HT20][RX-STBC1]
 
 """
@@ -366,6 +392,7 @@
     self.persist = False
     self.interface_suffix = ''
     self.client_isolation = False
+    self.supports_provisioning = False
 
 
 # pylint: disable=protected-access
@@ -393,6 +420,20 @@
                   config)
   opt.bridge = default_bridge
 
+  # Test provisioning IEs.
+  default_hidden_mode, opt.hidden_mode = opt.hidden_mode, True
+  default_supports_provisioning, opt.supports_provisioning = (
+      opt.supports_provisioning, True)
+  config = configs.generate_hostapd_config(
+      _PHY_INFO, 'wlan0', '2.4', '1', '20', set(('a', 'b', 'g', 'n', 'ac')),
+      'asdfqwer', opt)
+  wvtest.WVPASSEQ('\n'.join((_HOSTAPD_CONFIG_PROVISION_VIA,
+                             _HOSTAPD_CONFIG_WPA,
+                             '# Experiments: ()\n')),
+                  config)
+  opt.hidden_mode = default_hidden_mode
+  opt.supports_provisioning = default_supports_provisioning
+
   # Test with no encryption.
   default_encryption, opt.encryption = opt.encryption, 'NONE'
   config = configs.generate_hostapd_config(
@@ -444,5 +485,12 @@
   wvtest.WVPASSEQ(new_config, config)
 
 
+@wvtest.wvtest
+def create_vendor_ie_test():
+  wvtest.WVPASSEQ(configs.create_vendor_ie('01'), 'dd04f4f5e801')
+  wvtest.WVPASSEQ(configs.create_vendor_ie('03', 'GFiberSetupAutomation'),
+                  'dd19f4f5e80347466962657253657475704175746f6d6174696f6e')
+
+
 if __name__ == '__main__':
   wvtest.wvtest_main()
diff --git a/wifi/iw.py b/wifi/iw.py
index 647e56c..ae2a8b6 100644
--- a/wifi/iw.py
+++ b/wifi/iw.py
@@ -54,8 +54,8 @@
 
 
 def _scan(interface, scan_args, **kwargs):
-  return subprocess.check_output(['iw', 'dev', interface, 'scan'] + scan_args,
-                                 **kwargs)
+  return subprocess.check_output(
+      ['iw', 'dev', interface, 'scan', '-u'] + scan_args, **kwargs)
 
 
 _WIPHY_RE = re.compile(r'Wiphy (?P<phy>\S+)')
diff --git a/wifi/quantenna.py b/wifi/quantenna.py
index 12ae6f4..4724d88 100755
--- a/wifi/quantenna.py
+++ b/wifi/quantenna.py
@@ -2,6 +2,7 @@
 
 """Wifi commands for Quantenna using QCSAPI."""
 
+import json
 import os
 import subprocess
 import time
@@ -9,6 +10,9 @@
 import utils
 
 
+WIFIINFO_PATH = '/tmp/wifi/wifiinfo'
+
+
 ALREADY_MEMBER_FMT = ('device %s is already a member of a bridge; '
                       "can't enslave it to bridge %s.")
 NOT_MEMBER_FMT = 'device %s is not a slave of %s'
@@ -27,7 +31,7 @@
 
 
 def _qcsapi(*args):
-  return subprocess.check_output(['qcsapi'] + list(args)).strip()
+  return subprocess.check_output(['qcsapi'] + [str(x) for x in args]).strip()
 
 
 def _brctl(*args):
@@ -35,6 +39,23 @@
                                  stderr=subprocess.STDOUT).strip()
 
 
+def _ifplugd_action(*args):
+  return subprocess.check_output(['/etc/ifplugd/ifplugd.action'] + list(args),
+                                 stderr=subprocess.STDOUT).strip()
+
+
+def info_parsed(interface):
+  """Fake version of iw.info_parsed."""
+  wifiinfo_filename = os.path.join(WIFIINFO_PATH, interface)
+
+  if not os.path.exists(wifiinfo_filename):
+    return {}
+
+  wifiinfo = json.load(open(wifiinfo_filename))
+  return {'addr' if k == 'BSSID' else k.lower(): v
+          for k, v in wifiinfo.iteritems()}
+
+
 def _set_interface_in_bridge(bridge, interface, want_in_bridge):
   """Add/remove Quantenna interface from/to the bridge."""
   if want_in_bridge:
@@ -57,15 +78,21 @@
   if not interface:
     return False
 
-  _qcsapi('rfenable', '0')
+  if mode == 'scan':
+    mode = 'sta'
+    scan = True
+  else:
+    scan = False
+
+  _qcsapi('rfenable', 0)
   _qcsapi('restore_default_config', 'noreboot')
 
   config = {
       'bw': opt.width,
-      'channel': '149' if opt.channel == 'auto' else opt.channel,
+      'channel': 149 if opt.channel == 'auto' else opt.channel,
       'mode': mode,
-      'pmf': '0',
-      'scs': '0',
+      'pmf': 0,
+      'scs': 0,
   }
   for param, value in config.iteritems():
     _qcsapi('update_config_param', 'wifi0', param, value)
@@ -86,15 +113,26 @@
   if mode == 'ap':
     _set_interface_in_bridge(opt.bridge, interface, True)
     _qcsapi('set_ssid', 'wifi0', opt.ssid)
-    _qcsapi('set_passphrase', 'wifi0', '0', os.environ['WIFI_PSK'])
-    _qcsapi('set_option', 'wifi0', 'ssid_broadcast',
-            '0' if opt.hidden_mode else '1')
-    _qcsapi('rfenable', '1')
-  elif mode == 'sta':
+    _qcsapi('set_passphrase', 'wifi0', 0, os.environ['WIFI_PSK'])
+    _qcsapi('set_option', 'wifi0', 'ssid_broadcast', int(not opt.hidden_mode))
+    _qcsapi('rfenable', 1)
+  elif mode == 'sta' and not scan:
     _set_interface_in_bridge(opt.bridge, interface, False)
     _qcsapi('create_ssid', 'wifi0', opt.ssid)
-    _qcsapi('ssid_set_passphrase', 'wifi0', opt.ssid, '0',
-            os.environ['WIFI_CLIENT_PSK'])
+    if opt.encryption == 'NONE':
+      _qcsapi('ssid_set_authentication_mode', 'wifi0', opt.ssid, 'NONE')
+    elif opt.encryption == 'WEP':
+      raise utils.BinWifiException('WEP not supported')
+    else:
+      protocol, authentication, encryption = opt.encryption.split('_')
+      protocol = {'WPA': 'WPA', 'WPA2': '11i', 'WPA12': 'WPAand11i'}[protocol]
+      authentication += 'Authentication'
+      encryption += 'Encryption'
+      _qcsapi('ssid_set_proto', 'wifi0', opt.ssid, protocol)
+      _qcsapi('ssid_set_authentication_mode', 'wifi0', opt.ssid, authentication)
+      _qcsapi('ssid_set_encryption_modes', 'wifi0', opt.ssid, encryption)
+      _qcsapi('ssid_set_passphrase', 'wifi0', opt.ssid, 0,
+              os.environ['WIFI_CLIENT_PSK'])
     # In STA mode, 'rfenable 1' is already done by 'startprod'/'reload_in_mode'.
     # 'apply_security_config' must be called instead.
     _qcsapi('apply_security_config', 'wifi0')
@@ -106,9 +144,38 @@
     else:
       raise utils.BinWifiException('wpa_supplicant failed to connect')
 
+    try:
+      _ifplugd_action(interface, 'up')
+    except subprocess.CalledProcessError:
+      utils.log('Failed to call ifplugd.action.  %s may not get an IP address.'
+                % interface)
+
   return True
 
 
+def _parse_scan_result(line):
+  # Scan result format:
+  #
+  # "Quantenna1" 00:26:86:00:11:5f 60 56 1 2 1 2 0 15 80
+  # |            |                 |  |  | | | | | |  |
+  # |            |                 |  |  | | | | | |  Maximum bandwidth
+  # |            |                 |  |  | | | | | WPS flags
+  # |            |                 |  |  | | | | Qhop flags
+  # |            |                 |  |  | | | Encryption modes
+  # |            |                 |  |  | | Authentication modes
+  # |            |                 |  |  | Security protocols
+  # |            |                 |  |  Security enabled
+  # |            |                 |  RSSI
+  # |            |                 Channel
+  # |            MAC
+  # SSID
+  #
+  # The SSID may contain quotes and spaces. Split on whitespace from the right,
+  # making at most 10 splits, to preserve spaces in the SSID.
+  sp = line.strip().rsplit(None, 10)
+  return sp[0][1:-1], sp[1], int(sp[2]), float(sp[3]), int(sp[4]), int(sp[5])
+
+
 def set_wifi(opt):
   return _set('ap', opt)
 
@@ -123,7 +190,7 @@
     return False
 
   if _qcsapi('get_mode', 'wifi0') == 'Access point':
-    _qcsapi('rfenable', '0')
+    _qcsapi('rfenable', 0)
 
   return True
 
@@ -134,6 +201,39 @@
     return False
 
   if _qcsapi('get_mode', 'wifi0') == 'Station':
-    _qcsapi('rfenable', '0')
+    _qcsapi('rfenable', 0)
+
+  return True
+
+
+def scan_wifi(opt):
+  """Scan for APs."""
+  interface = _get_interface()
+  if not interface:
+    return False
+
+  if _qcsapi('rfstatus') == 'Off':
+    _set('scan', opt)
+
+  _qcsapi('start_scan', 'wifi0')
+  for _ in xrange(30):
+    if not int(_qcsapi('get_scanstatus', 'wifi0')):
+      break
+    time.sleep(1)
+  else:
+    raise utils.BinWifiException('start_scan timed out')
+
+  for i in xrange(int(_qcsapi('get_results_ap_scan', 'wifi0'))):
+    ssid, mac, channel, rssi, flags, protocols = _parse_scan_result(
+        _qcsapi('get_properties_ap', 'wifi0', i))
+    print 'BSS %s(on %s)' % (mac, interface)
+    print '\tfreq: %d' % (5000 + 5 * channel)
+    print '\tsignal: %.2f' % -rssi
+    print '\tSSID: %s' % ssid
+    if flags & 0x1:
+      if protocols & 0x1:
+        print '\tWPA:'
+      if protocols & 0x2:
+        print '\tRSN:'
 
   return True
diff --git a/wifi/quantenna_test.py b/wifi/quantenna_test.py
index 19aa0a2..e19c76e 100755
--- a/wifi/quantenna_test.py
+++ b/wifi/quantenna_test.py
@@ -2,8 +2,11 @@
 
 """Tests for quantenna.py."""
 
+import json
 import os
+import shutil
 from subprocess import CalledProcessError
+import tempfile
 
 from configs_test import FakeOptDict
 import quantenna
@@ -11,10 +14,11 @@
 
 
 calls = []
+ifplugd_action_calls = []
 
 
 def fake_qcsapi(*args):
-  calls.append(list(args))
+  calls.append([str(x) for x in args])
   if args[0] == 'is_startprod_done':
     return '1' if ['startprod', 'wifi0'] in calls else '0'
   if args[0] == 'get_bssid':
@@ -51,6 +55,7 @@
 
 def set_fakes(interface='wlan1'):
   del calls[:]
+  del ifplugd_action_calls[:]
   bridge_interfaces.clear()
   os.environ['WIFI_PSK'] = 'wifi_psk'
   os.environ['WIFI_CLIENT_PSK'] = 'wifi_client_psk'
@@ -58,6 +63,7 @@
   quantenna._get_mac_address = lambda _: '00:11:22:33:44:55'
   quantenna._qcsapi = fake_qcsapi
   quantenna._brctl = fake_brctl
+  quantenna._ifplugd_action = lambda *args: ifplugd_action_calls.append(args)
 
 
 def matching_calls_indices(accept):
@@ -132,6 +138,9 @@
   wvtest.WVPASSLT(sp, i[0])
   wvtest.WVPASSLT(i[-1], calls.index(['rfenable', '1']))
 
+  # We shouldn't touch ifplugd in AP mode.
+  wvtest.WVPASSEQ(len(ifplugd_action_calls), 0)
+
   # Run set_wifi again in client mode with new options.
   opt.channel = '147'
   opt.ssid = 'TEST_SSID2'
@@ -178,6 +187,10 @@
   wvtest.WVPASSLT(rim, i[0])
   wvtest.WVPASSLT(i[-1], calls.index(['apply_security_config', 'wifi0']))
 
+  # We should have called ipflugd.action after setclient.
+  wvtest.WVPASSEQ(len(ifplugd_action_calls), 1)
+  wvtest.WVPASSEQ(ifplugd_action_calls[0], ('wlan1', 'up'))
+
   # Make sure subsequent equivalent calls don't fail despite the redundant
   # bridge changes.
   wvtest.WVPASS(quantenna.set_client_wifi(opt))
@@ -200,5 +213,33 @@
   wvtest.WVPASS(['rfenable', '0'] not in calls[new_calls_start:])
 
 
+@wvtest.wvtest
+def info_parsed_test():
+  set_fakes()
+
+  try:
+    quantenna.WIFIINFO_PATH = tempfile.mkdtemp()
+    json.dump({
+        'Channel': '64',
+        'SSID': 'my ssid',
+        'BSSID': '00:00:00:00:00:00',
+    }, open(os.path.join(quantenna.WIFIINFO_PATH, 'wlan0'), 'w'))
+
+    wvtest.WVPASSEQ(quantenna.info_parsed('wlan0'), {
+        'ssid': 'my ssid',
+        'addr': '00:00:00:00:00:00',
+        'channel': '64',
+    })
+  finally:
+    shutil.rmtree(quantenna.WIFIINFO_PATH)
+
+
+@wvtest.wvtest
+def parse_scan_result_test():
+  result = '  " ssid with "quotes" " 00:11:22:33:44:55 40 25 0 0 0 0 0 1 40  '
+  wvtest.WVPASSEQ(quantenna._parse_scan_result(result),
+                  (' ssid with "quotes" ', '00:11:22:33:44:55', 40, 25, 0, 0))
+
+
 if __name__ == '__main__':
   wvtest.wvtest_main()
diff --git a/wifi/wifi.py b/wifi/wifi.py
index 9ba8b31..5531eee 100755
--- a/wifi/wifi.py
+++ b/wifi/wifi.py
@@ -55,6 +55,7 @@
 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.
+supports-provisioning             Indicate via vendor IE that this AP supports provisioning.  Corresponds to feature ID 01 of OUI f4f5e8 at go/alphabet-ie-registry.
 """
 
 _FINGERPRINTS_DIRECTORY = '/tmp/wifi/fingerprints'
@@ -458,6 +459,7 @@
     True.
   """
   for band in opt.band.split():
+    frenzy = False
     print('Band: %s' % band)
     for tokens in utils.subprocess_line_tokens(('iw', 'reg', 'get')):
       if len(tokens) >= 2 and tokens[0] == 'country':
@@ -469,11 +471,20 @@
       interface = iw.find_interface_from_band(
           band, interface_type, opt.interface_suffix)
       if interface is None:
-        continue
-      print('%sInterface: %s  # %s GHz %s' %
-            (prefix, interface, band, 'client' if 'cli' in interface else 'ap'))
+        if band == '5':
+          interface = _get_quantenna_interface()
+          if interface:
+            frenzy = True
+        if not interface:
+          continue
 
-      info_parsed = iw.info_parsed(interface)
+      print('%sInterface: %s  # %s GHz %s' %
+            (prefix, interface, band, prefix.lower() or 'ap'))
+
+      if frenzy:
+        info_parsed = quantenna.info_parsed(interface)
+      else:
+        info_parsed = iw.info_parsed(interface)
       for k, label in (('channel', 'Channel'),
                        ('ssid', 'SSID'),
                        ('addr', 'BSSID')):
@@ -500,6 +511,13 @@
   return True
 
 
+def _get_quantenna_interface():
+  try:
+    return subprocess.check_output(['get-quantenna-interface']).strip()
+  except subprocess.CalledProcessError as e:
+    utils.log('Failed to call get-quantenna-interface: %s', e)
+
+
 @iw.requires_iw
 def scan_wifi(opt):
   """Prints 'iw scan' results.
@@ -514,6 +532,10 @@
     BinWifiException: If an expected interface is not found.
   """
   band = opt.band.split()[0]
+
+  if band == '5' and quantenna.scan_wifi(opt):
+    return True
+
   interface = iw.find_interface_from_band(
       band, iw.INTERFACE_TYPE.ap, opt.interface_suffix)
   if interface is None: