wifi_files: retain data longer and add tests.

wifi_files relies on nl80211 to supply data about clients, but
nl80211 forgets about clients after 5 minutes idle. We want to
report data about the client for longer than that. Have wifi_files
retain the last known data about each client.

Also:
+ add unit tests. The actual netlink operations are hard to
  test on the build system, but many utility methods can be tested.
+ switch from the SVr4 hash functions to the much-more-sane glib.
+ track MCS and channel width, and the maximum MCS and width we've
  seen from the client.
+ log information about clients to stdout periodically.

Fixes b/21446814
Fixes b/22152957

Change-Id: I79d72dfddee4f74476ac62aafb8ff2a8b8da8030
diff --git a/cmds/Makefile b/cmds/Makefile
index 11b2c8b..b29485a 100644
--- a/cmds/Makefile
+++ b/cmds/Makefile
@@ -48,10 +48,11 @@
 HOST_TARGETS=$(addprefix host-,$(TARGETS))
 LIB_TARGETS=\
 	stdoutline.so
-TEST_TARGETS=\
+HOST_TEST_TARGETS=\
 	host-asus_hosts_test \
 	host-netusage_test \
-	host-utils_test
+	host-utils_test \
+	host-wifi_files_test
 SCRIPT_TARGETS=\
 	is-secure-boot
 ARCH_TARGETS=\
@@ -208,9 +209,14 @@
 netusage: CFLAGS += -Wno-sign-compare
 host-netusage_test: host-netusage_test.o
 wifi_files: wifi_files.o
-wifi_files: LIBS+=-lnl-3 -lnl-genl-3
+wifi_files: LIBS += -lnl-3 -lnl-genl-3 -lglib-2.0
+host-wifi_files_test: host-wifi_files_test.o
+host-wifi_files_test: LIBS += -lnl-3 -lnl-genl-3 -lglib-2.0
 
-TESTS=$(wildcard test-*.sh) $(wildcard test-*.py) $(wildcard *_test.py) $(TEST_TARGETS)
+TESTS = $(wildcard test-*.sh) $(wildcard test-*.py) $(wildcard *_test.py) $(TEST_TARGETS)
+ifeq ($(RUN_HOST_TESTS),y)
+TESTS += $(HOST_TEST_TARGETS)
+endif
 runtests: all $(TESTS)
 	set -e; \
 	for d in $(TESTS); do \
@@ -226,6 +232,7 @@
 		$(HOST_TARGETS) \
 		$(LIB_TARGETS) \
 		$(TEST_TARGETS) \
+		$(HOST_TEST_TARGETS) \
 		$(ARCH_TARGETS) \
 		*~ .*~ */*.pyc test_file *.pb.*
 	rm -rf test_dir
diff --git a/cmds/wifi_files.c b/cmds/wifi_files.c
index 1d95683..31f9d4a 100644
--- a/cmds/wifi_files.c
+++ b/cmds/wifi_files.c
@@ -21,7 +21,10 @@
  */
 
 #include <ctype.h>
+#include <errno.h>
 #include <fcntl.h>
+#include <getopt.h>
+#include <glib.h>
 #include <inttypes.h>
 #include <limits.h>
 #include <linux/if_ether.h>
@@ -34,7 +37,6 @@
 #include <netlink/msg.h>
 #include <netlink/netlink.h>
 #include <netlink/socket.h>
-#include <search.h>
 #include <stdio.h>
 #include <sys/ioctl.h>
 #include <sys/resource.h>
@@ -46,24 +48,135 @@
 #include <unistd.h>
 
 
+#ifndef UNIT_TESTS
 #define STATIONS_DIR "/tmp/stations"
-#define WIFISHOW_DIR "/tmp/wifi/wifiinfo"
+#define WIFIINFO_DIR "/tmp/wifi/wifiinfo"
+#endif
 
 
+// Hash table of known Wifi clients.
+GHashTable *clients = NULL;
+#define MAX_CLIENT_AGE_SECS  (4 * 60 * 60)
+
+
+#ifndef UNIT_TESTS
+static time_t monotime(void) {
+  struct timespec ts;
+  if (clock_gettime(CLOCK_MONOTONIC, &ts) < 0) {
+    return time(NULL);
+  } else {
+    return ts.tv_sec;
+  }
+}
+#endif
+
+
+/*
+ * Saved state for each associated Wifi device. Wifi clients drop out
+ * after 5 minutes inactive, we want to export information about the
+ * client for a while longer than that.
+ */
 typedef struct client_state {
   double inactive_since;
+
+  uint64_t rx_drop64;
+
+  // Accumulated values from the 32 bit counters.
+  uint64_t rx_bytes64;
+  uint64_t tx_bytes64;
+  uint64_t rx_packets64;
+  uint64_t tx_packets64;
+  uint64_t tx_retries64;
+  uint64_t tx_failed64;
+
+  time_t first_seen;  // CLOCK_MONOTONIC
+  time_t last_seen;  // CLOCK_MONOTONIC
+
+  uint32_t inactive_msec;
+  uint32_t connected_secs;
+
+  uint32_t rx_bitrate;
+  uint32_t rx_bytes;
+  uint32_t rx_packets;
+
+  uint32_t tx_bitrate;
+  uint32_t tx_bytes;
+  uint32_t tx_packets;
+  uint32_t tx_retries;
+  uint32_t tx_failed;
+  uint32_t expected_mbps;
+
+  int sample_index;
+#define MAX_SAMPLE_INDEX 150
+  uint8_t rx_ht_mcs_samples[MAX_SAMPLE_INDEX];
+  uint8_t rx_vht_mcs_samples[MAX_SAMPLE_INDEX];
+  uint8_t rx_width_samples[MAX_SAMPLE_INDEX];
+  uint8_t rx_ht_nss_samples[MAX_SAMPLE_INDEX];
+  uint8_t rx_vht_nss_samples[MAX_SAMPLE_INDEX];
+  uint8_t short_gi_samples[MAX_SAMPLE_INDEX];
+
+  /*
+   * Clients spend a lot of time mostly idle, where they
+   * are only sending management frames and ACKs. These
+   * tend to be sent at much lower MCS rates than bulk data;
+   * if we report that MCS rate it gives a misleading
+   * picture of what the client is capable of getting.
+   *
+   * Instead, we choose the largest sample over the reporting
+   * interval. This is more likely to report a meaningful
+   * MCS rate.
+   */
+  uint8_t rx_ht_mcs;
+  uint8_t rx_vht_mcs;
+  uint8_t rx_width;
+  uint8_t rx_ht_nss;
+  uint8_t rx_vht_nss;
+  uint8_t short_gi;
+
+  /* Track the largest value we've ever seen from this client. This
+   * shows client capabilities, even if current interference
+   * conditions don't allow it to use its full capability. */
+  uint8_t rx_max_ht_mcs;
+  uint8_t rx_max_vht_mcs;
+  uint8_t rx_max_width;
+  uint8_t rx_max_ht_nss;
+  uint8_t rx_max_vht_nss;
+  uint8_t ever_short_gi;
+
+  int8_t signal;
+  int8_t signal_avg;
+
+  uint8_t authorized:1;
+  uint8_t authenticated:1;
+  uint8_t preamble:1;
+  uint8_t wmm_wme:1;
+  uint8_t mfp:1;
+  uint8_t tdls_peer:1;
+  uint8_t preamble_length:1;
+
+  #define MAC_STR_LEN 18
+  char macstr[MAC_STR_LEN];
+  #define IFNAME_STR_LEN 16
+  char ifname[IFNAME_STR_LEN];
 } client_state_t;
 
 
+typedef struct callback_data {
+  time_t mono_now;
+} callback_data_t;
+
+
 /* List of wifi interfaces in the system. */
 #define NINTERFACES 16
-int ifindexes[NINTERFACES];
-const char *interfaces[NINTERFACES];
+int ifindexes[NINTERFACES] = {0};
+const char *interfaces[NINTERFACES] = {0};
 int ninterfaces = 0;
 
-static FILE *wifi_show_handle = NULL;
+/* FILE handle to /tmp/wifi/wifiinfo, while open. */
+static FILE *wifi_info_handle = NULL;
 
-int GetIfIndex(const char *ifname)
+
+static int GetIfIndex(const char *ifname)
 {
   int fd;
   struct ifreq ifr;
@@ -104,6 +217,9 @@
 
   if (il[NL80211_ATTR_IFNAME]) {
     const char *name = nla_get_string(il[NL80211_ATTR_IFNAME]);
+    if (interfaces[ninterfaces] != NULL) {
+      free((void *)interfaces[ninterfaces]);
+    }
     interfaces[ninterfaces] = strdup(name);
     ifindexes[ninterfaces] = GetIfIndex(name);
     ninterfaces++;
@@ -113,9 +229,9 @@
 }
 
 
-void HandleNLCommand(struct nl_sock *nlsk, int nl80211_id, int n,
-                     int cb(struct nl_msg *, void *),
-                     int cmd, int flag)
+static void HandleNLCommand(struct nl_sock *nlsk, int nl80211_id, int n,
+                            int cb(struct nl_msg *, void *),
+                            int cmd, int flag)
 {
   struct nl_msg *msg;
   int ifindex = n >= 0 ? ifindexes[n] : -1;
@@ -157,226 +273,7 @@
 }  /* RequestInterfaceList */
 
 
-uint32_t GetBitrate(struct nlattr *attr)
-{
-  int rate = 0;
-  struct nlattr *ri[NL80211_RATE_INFO_MAX + 1];
-  static struct nla_policy rate_policy[NL80211_RATE_INFO_MAX + 1] = {
-    [NL80211_RATE_INFO_BITRATE] = { .type = NLA_U16 },
-  };
-
-  if (nla_parse_nested(ri, NL80211_RATE_INFO_MAX, attr, rate_policy)) {
-    fprintf(stderr, "nla_parse_nested NL80211_RATE_INFO_MAX failed");
-    return 0;
-  }
-
-  if (ri[NL80211_RATE_INFO_BITRATE]) {
-    rate = nla_get_u16(ri[NL80211_RATE_INFO_BITRATE]);
-  }
-
-  return rate;
-}
-
-static int StationDumpCallback(struct nl_msg *msg, void *arg)
-{
-  const char *ifname = (const char *)arg;
-  char tmpfile[PATH_MAX];
-  char filename[PATH_MAX];
-  FILE *f;
-  struct genlmsghdr *gh = nlmsg_data(nlmsg_hdr(msg));
-  struct nlattr *tb[NL80211_ATTR_MAX + 1] = {0};
-  struct nlattr *si[NL80211_STA_INFO_MAX + 1] = {0};
-  uint8_t *mac;
-  char macstr[18];
-  static struct nla_policy stats_policy[NL80211_STA_INFO_MAX + 1] = {
-    [NL80211_STA_INFO_INACTIVE_TIME] = { .type = NLA_U32 },
-    [NL80211_STA_INFO_RX_BITRATE] = { .type = NLA_NESTED },
-    [NL80211_STA_INFO_RX_BYTES] = { .type = NLA_U32 },
-    [NL80211_STA_INFO_RX_PACKETS] = { .type = NLA_U32 },
-    [NL80211_STA_INFO_TX_BITRATE] = { .type = NLA_NESTED },
-    [NL80211_STA_INFO_TX_BYTES] = { .type = NLA_U32 },
-    [NL80211_STA_INFO_TX_PACKETS] = { .type = NLA_U32 },
-    [NL80211_STA_INFO_TX_RETRIES] = { .type = NLA_U32 },
-    [NL80211_STA_INFO_TX_FAILED] = { .type = NLA_U32 },
-    [NL80211_STA_INFO_SIGNAL] = { .type = NLA_U8 },
-    [NL80211_STA_INFO_SIGNAL_AVG] = { .type = NLA_U8 },
-    [NL80211_STA_INFO_STA_FLAGS] = {
-      .minlen = sizeof(struct nl80211_sta_flag_update) },
-
-#ifdef NL80211_RECENT_FIELDS
-    [NL80211_STA_INFO_RX_BYTES64] = { .type = NLA_U64 },
-    [NL80211_STA_INFO_RX_DROP_MISC] = { .type = NLA_U64 },
-    [NL80211_STA_INFO_TX_BYTES64] = { .type = NLA_U64 },
-    [NL80211_STA_INFO_EXPECTED_THROUGHPUT] = { .type = NLA_U32 },
-#endif
-  };
-
-  if (nla_parse(tb, NL80211_ATTR_MAX,
-                genlmsg_attrdata(gh, 0), genlmsg_attrlen(gh, 0), NULL)) {
-    fprintf(stderr, "nla_parse failed.\n");
-    return NL_SKIP;
-  }
-
-  if (!tb[NL80211_ATTR_STA_INFO]) {
-    return NL_SKIP;
-  }
-
-  if (nla_parse_nested(si, NL80211_STA_INFO_MAX,
-                       tb[NL80211_ATTR_STA_INFO],
-                       stats_policy)) {
-    fprintf(stderr, "nla_parse_nested failed\n");
-    return NL_SKIP;
-  }
-
-  if (!tb[NL80211_ATTR_MAC]) {
-    fprintf(stderr, "No NL80211_ATTR_MAC\n");
-    return NL_SKIP;
-  }
-
-  mac = (uint8_t *)nla_data(tb[NL80211_ATTR_MAC]);
-  snprintf(macstr, sizeof(macstr), "%02x:%02x:%02x:%02x:%02x:%02x",
-      mac[0], mac[1], mac[2], mac[3], mac[4], mac[5]);
-
-  snprintf(tmpfile, sizeof(tmpfile), "%s/%s.new", STATIONS_DIR, macstr);
-  snprintf(filename, sizeof(filename), "%s/%s", STATIONS_DIR, macstr);
-
-  if ((f = fopen(tmpfile, "w+")) == NULL) {
-    perror("fopen");
-    return NL_SKIP;
-  }
-
-  fprintf(f, "{\n");
-
-  if (si[NL80211_STA_INFO_INACTIVE_TIME]) {
-    ENTRY e, *ep;
-    uint32_t inactive = nla_get_u32(si[NL80211_STA_INFO_INACTIVE_TIME]);
-    double inactive_since = time(NULL) - ((double)inactive / 1000.0);
-
-    memset(&e, 0, sizeof(e));
-    e.key = macstr;
-    if ((ep = hsearch(e, FIND)) != NULL) {
-      client_state_t *prev = (client_state_t *)ep->data;
-      if ((fabs(inactive_since - prev->inactive_since)) > 2.0) {
-        prev->inactive_since = inactive_since;
-      } else {
-        inactive_since = prev->inactive_since;
-      }
-    } else {
-      client_state_t *state = (client_state_t *)malloc(sizeof(client_state_t));
-
-      state->inactive_since = inactive_since;
-      memset(&e, 0, sizeof(e));
-      e.key = strdup(macstr);
-      e.data = (void *)state;
-      if (hsearch(e, ENTER) == NULL) {
-        fprintf(stderr, "hsearch(ENTER) failed\n");
-        exit(1);  // rely on babysitter to restart us.
-      }
-    }
-
-    fprintf(f, "  \"inactive since\": %.3f,\n", inactive_since);
-    fprintf(f, "  \"inactive msec\": %u,\n", inactive);
-  }
-
-  if (si[NL80211_STA_INFO_RX_BITRATE]) {
-    uint32_t rate = GetBitrate(si[NL80211_STA_INFO_RX_BITRATE]);
-    if (rate) {
-      fprintf(f, "  \"rx bitrate\": %u.%u,\n", rate / 10, rate % 10);
-    }
-  }
-  if (si[NL80211_STA_INFO_RX_BYTES])
-    fprintf(f, "  \"rx bytes\": %u,\n",
-           nla_get_u32(si[NL80211_STA_INFO_RX_BYTES]));
-  if (si[NL80211_STA_INFO_RX_PACKETS])
-    fprintf(f, "  \"rx packets\": %u,\n",
-           nla_get_u32(si[NL80211_STA_INFO_RX_PACKETS]));
-  if (si[NL80211_STA_INFO_TX_BITRATE]) {
-    uint32_t rate = GetBitrate(si[NL80211_STA_INFO_TX_BITRATE]);
-    if (rate) {
-      fprintf(f, "  \"tx bitrate\": %u.%u,\n", rate / 10, rate % 10);
-    }
-  }
-  if (si[NL80211_STA_INFO_TX_BYTES])
-    fprintf(f, "  \"tx bytes\": %u,\n",
-           nla_get_u32(si[NL80211_STA_INFO_TX_BYTES]));
-  if (si[NL80211_STA_INFO_TX_PACKETS])
-    fprintf(f, "  \"tx packets\": %u,\n",
-           nla_get_u32(si[NL80211_STA_INFO_TX_PACKETS]));
-  if (si[NL80211_STA_INFO_TX_RETRIES])
-    fprintf(f, "  \"tx retries\": %u,\n",
-           nla_get_u32(si[NL80211_STA_INFO_TX_RETRIES]));
-  if (si[NL80211_STA_INFO_TX_FAILED])
-    fprintf(f, "  \"tx failed\": %u,\n",
-           nla_get_u32(si[NL80211_STA_INFO_TX_FAILED]));
-
-  if (si[NL80211_STA_INFO_SIGNAL]) {
-    fprintf(f, "  \"signal\": %hhd,\n",
-           (int8_t)nla_get_u8(si[NL80211_STA_INFO_SIGNAL]));
-  }
-
-  if (si[NL80211_STA_INFO_SIGNAL_AVG]) {
-    fprintf(f, "  \"signal avg\": %hhd,\n",
-           (int8_t)nla_get_u8(si[NL80211_STA_INFO_SIGNAL_AVG]));
-  }
-
-  if (si[NL80211_STA_INFO_STA_FLAGS]) {
-    struct nl80211_sta_flag_update *sta_flags;
-    sta_flags = (struct nl80211_sta_flag_update *)nla_data(
-        si[NL80211_STA_INFO_STA_FLAGS]);
-    #define BIT(x) (1ULL<<(x))
-    #define PRINT_BOOL(name, bit) if (sta_flags->mask & BIT(bit)) \
-      fprintf(f, "  \"%s\": \"%s\",\n", name, \
-             (sta_flags->set & BIT(bit) ? "yes" : "no"));
-
-    PRINT_BOOL("authorized", NL80211_STA_FLAG_AUTHORIZED);
-    PRINT_BOOL("authenticated", NL80211_STA_FLAG_AUTHENTICATED);
-    PRINT_BOOL("preamble", NL80211_STA_FLAG_SHORT_PREAMBLE);
-    PRINT_BOOL("WMM/WME", NL80211_STA_FLAG_WME);
-    PRINT_BOOL("MFP", NL80211_STA_FLAG_MFP);
-    PRINT_BOOL("TDLS peer", NL80211_STA_FLAG_TDLS_PEER);
-
-    if (sta_flags->mask & BIT(NL80211_STA_FLAG_SHORT_PREAMBLE)) {
-      uint32_t bit = BIT(NL80211_STA_FLAG_SHORT_PREAMBLE);
-      const char *p = (sta_flags->set & bit ?  "short" : "long");
-      fprintf(f, "  \"preamble\": \"%s\",\n", p);
-    }
-  }
-
-#ifdef NL80211_RECENT_FIELDS
-  if (si[NL80211_STA_INFO_RX_BYTES64])
-    fprintf(f, "  \"rx bytes64\": %" PRIu64 ",\n",
-           nla_get_u64(si[NL80211_STA_INFO_RX_BYTES64]));
-  if (si[NL80211_STA_INFO_RX_DROP_MISC])
-    fprintf(f, "  \"rx drop64\": %" PRIu64 ",\n",
-           nla_get_u64(si[NL80211_STA_INFO_RX_DROP_MISC]));
-  if (si[NL80211_STA_INFO_TX_BYTES64])
-    fprintf(f, "  \"tx bytes64\": %" PRIu64 ",\n",
-           nla_get_u64(si[NL80211_STA_INFO_TX_BYTES64]));
-  if (si[NL80211_STA_INFO_EXPECTED_THROUGHPUT]) {
-    uint32_t thr = nla_get_u32(si[NL80211_STA_INFO_EXPECTED_THROUGHPUT]);
-    fprintf(f, "  \"expected throughput\": \"%u.%uMbps\",\n",
-           thr / 1000, thr % 1000);
-  }
-#endif
-
-  fprintf(f, "  \"ifname\": \"%s\"\n", ifname);
-  fprintf(f, "}\n");
-
-  fclose(f);
-  if (rename(tmpfile, filename)) {
-    perror("rename");
-  }
-  return NL_OK;
-}  /* StationDumpCallback */
-
-
-void RequestAssociatedDevices(struct nl_sock *nlsk, int nl80211_id, int n)
-{
-  HandleNLCommand(nlsk, nl80211_id, n, StationDumpCallback,
-                  NL80211_CMD_GET_STATION, NLM_F_DUMP);
-}  /* RequestAssociatedDevices */
-
-static int NlFinish(struct nl_msg *msg, void *arg)
+int NlFinish(struct nl_msg *msg, void *arg)
 {
   int *ret = arg;
   *ret = 1;
@@ -406,7 +303,7 @@
 }  /* InitNetlinkSocket */
 
 
-void ProcessNetlinkMessages(struct nl_sock *nlsk, int *done)
+static void ProcessNetlinkMessages(struct nl_sock *nlsk, int *done)
 {
   for (;;) {
     int s = nl_socket_get_fd(nlsk);
@@ -431,7 +328,456 @@
 }
 
 
-void TouchUpdateFile()
+static uint32_t GetBitrate(struct nlattr *attr)
+{
+  int rate = 0;
+  struct nlattr *ri[NL80211_RATE_INFO_MAX + 1];
+  static struct nla_policy rate_policy[NL80211_RATE_INFO_MAX + 1] = {
+    [NL80211_RATE_INFO_BITRATE] = { .type = NLA_U16 },
+  };
+
+  if (nla_parse_nested(ri, NL80211_RATE_INFO_MAX, attr, rate_policy)) {
+    fprintf(stderr, "nla_parse_nested NL80211_RATE_INFO_MAX failed");
+    return 0;
+  }
+
+  if (ri[NL80211_RATE_INFO_BITRATE]) {
+    rate = nla_get_u16(ri[NL80211_RATE_INFO_BITRATE]);
+  }
+
+  return rate;
+}
+
+
+static void GetRxMCS(struct nlattr *attr,
+    int *mcs, int *vht_mcs, int *width, int *short_gi, int *vht_nss)
+{
+  int w160 = 0, w80_80 = 0, w80 = 0, w40 = 0;
+  struct nlattr *ri[NL80211_RATE_INFO_MAX + 1];
+  static struct nla_policy rate_policy[NL80211_RATE_INFO_MAX + 1] = {
+    [NL80211_RATE_INFO_MCS] = { .type = NLA_U8 },
+    [NL80211_RATE_INFO_VHT_MCS] = { .type = NLA_U8 },
+    [NL80211_RATE_INFO_VHT_NSS] = { .type = NLA_U8 },
+    [NL80211_RATE_INFO_40_MHZ_WIDTH] = { .type = NLA_FLAG },
+    [NL80211_RATE_INFO_80_MHZ_WIDTH] = { .type = NLA_FLAG },
+    [NL80211_RATE_INFO_80P80_MHZ_WIDTH] = { .type = NLA_FLAG },
+    [NL80211_RATE_INFO_160_MHZ_WIDTH] = { .type = NLA_FLAG },
+    [NL80211_RATE_INFO_SHORT_GI] = { .type = NLA_FLAG },
+  };
+
+  if (nla_parse_nested(ri, NL80211_RATE_INFO_MAX, attr, rate_policy)) {
+    fprintf(stderr, "nla_parse_nested NL80211_RATE_INFO_MAX failed");
+    return;
+  }
+
+  if (ri[NL80211_RATE_INFO_MCS]) {
+    *mcs = nla_get_u8(ri[NL80211_RATE_INFO_MCS]);
+  }
+  if (ri[NL80211_RATE_INFO_VHT_MCS]) {
+    *vht_mcs = nla_get_u8(ri[NL80211_RATE_INFO_VHT_MCS]);
+  }
+  if (ri[NL80211_RATE_INFO_VHT_NSS]) {
+    *vht_nss = nla_get_u8(ri[NL80211_RATE_INFO_VHT_NSS]);
+  }
+  if (ri[NL80211_RATE_INFO_160_MHZ_WIDTH])   w160 = 1;
+  if (ri[NL80211_RATE_INFO_80P80_MHZ_WIDTH]) w80_80 = 1;
+  if (ri[NL80211_RATE_INFO_80_MHZ_WIDTH])    w80 = 1;
+  if (ri[NL80211_RATE_INFO_40_MHZ_WIDTH])    w40 = 1;
+  if (ri[NL80211_RATE_INFO_SHORT_GI])        *short_gi = 1;
+
+  if (w160 || w80_80) {
+    *width = 160;
+  } else if (w80) {
+    *width = 80;
+  } else if (w40) {
+    *width = 40;
+  } else {
+    *width = 20;
+  }
+}
+
+
+static client_state_t *FindClientState(const uint8_t mac[6])
+{
+  client_state_t *s;
+  char macstr[MAC_STR_LEN];
+
+  snprintf(macstr, sizeof(macstr), "%02x:%02x:%02x:%02x:%02x:%02x",
+      mac[0], mac[1], mac[2], mac[3], mac[4], mac[5]);
+
+  /* Find any existing state for this STA, or allocate new. */
+  if ((s = (client_state_t *)g_hash_table_lookup(clients, macstr)) == NULL) {
+    s = (client_state_t *)malloc(sizeof(*s));
+    memset(s, 0, sizeof(*s));
+    memcpy(s->macstr, macstr, sizeof(s->macstr));
+    s->first_seen = monotime();
+    g_hash_table_insert(clients, strdup(macstr), s);
+  }
+
+  return s;
+}
+
+
+static int RxHtMcsToNss(int rxmcs)
+{
+  /* https://en.wikipedia.org/wiki/IEEE_802.11n-2009 */
+  switch(rxmcs) {
+    case 0 ... 7:   return 1;
+    case 8 ... 15:  return 2;
+    case 16 ... 23: return 3;
+    case 24 ... 31: return 4;
+    case 32:        return 1;
+    case 33 ... 38: return 2;
+    case 39 ... 52: return 3;
+    case 53 ... 76: return 4;
+    default:        return 0;
+  }
+}
+
+static int StationDumpCallback(struct nl_msg *msg, void *arg)
+{
+  const char *ifname = (const char *)arg;
+  struct genlmsghdr *gh = nlmsg_data(nlmsg_hdr(msg));
+  struct nlattr *tb[NL80211_ATTR_MAX + 1] = {0};
+  struct nlattr *si[NL80211_STA_INFO_MAX + 1] = {0};
+  uint8_t *mac;
+  client_state_t *state;
+  static struct nla_policy stats_policy[NL80211_STA_INFO_MAX + 1] = {
+    [NL80211_STA_INFO_INACTIVE_TIME] = { .type = NLA_U32 },
+    [NL80211_STA_INFO_RX_BITRATE] = { .type = NLA_NESTED },
+    [NL80211_STA_INFO_RX_BYTES] = { .type = NLA_U32 },
+    [NL80211_STA_INFO_RX_PACKETS] = { .type = NLA_U32 },
+    [NL80211_STA_INFO_TX_BITRATE] = { .type = NLA_NESTED },
+    [NL80211_STA_INFO_TX_BYTES] = { .type = NLA_U32 },
+    [NL80211_STA_INFO_TX_PACKETS] = { .type = NLA_U32 },
+    [NL80211_STA_INFO_TX_RETRIES] = { .type = NLA_U32 },
+    [NL80211_STA_INFO_TX_FAILED] = { .type = NLA_U32 },
+    [NL80211_STA_INFO_CONNECTED_TIME] = { .type = NLA_U32 },
+    [NL80211_STA_INFO_SIGNAL] = { .type = NLA_U8 },
+    [NL80211_STA_INFO_SIGNAL_AVG] = { .type = NLA_U8 },
+    [NL80211_STA_INFO_STA_FLAGS] = {
+      .minlen = sizeof(struct nl80211_sta_flag_update) },
+
+#ifdef NL80211_RECENT_FIELDS
+    [NL80211_STA_INFO_RX_DROP_MISC] = { .type = NLA_U64 },
+    [NL80211_STA_INFO_EXPECTED_THROUGHPUT] = { .type = NLA_U32 },
+#endif
+  };
+
+  if (nla_parse(tb, NL80211_ATTR_MAX,
+                genlmsg_attrdata(gh, 0), genlmsg_attrlen(gh, 0), NULL)) {
+    fprintf(stderr, "nla_parse failed.\n");
+    return NL_SKIP;
+  }
+
+  if (!tb[NL80211_ATTR_STA_INFO]) {
+    return NL_SKIP;
+  }
+
+  if (nla_parse_nested(si, NL80211_STA_INFO_MAX,
+                       tb[NL80211_ATTR_STA_INFO],
+                       stats_policy)) {
+    fprintf(stderr, "nla_parse_nested failed\n");
+    return NL_SKIP;
+  }
+
+  if (!tb[NL80211_ATTR_MAC]) {
+    fprintf(stderr, "No NL80211_ATTR_MAC\n");
+    return NL_SKIP;
+  }
+
+  mac = (uint8_t *)nla_data(tb[NL80211_ATTR_MAC]);
+  state = FindClientState(mac);
+  state->last_seen = monotime();
+  snprintf(state->ifname, sizeof(state->ifname), "%s", ifname);
+
+  if (si[NL80211_STA_INFO_INACTIVE_TIME]) {
+    uint32_t inactive_msec = nla_get_u32(si[NL80211_STA_INFO_INACTIVE_TIME]);
+    double inactive_since = time(NULL) - ((double)inactive_msec / 1000.0);
+
+    state->inactive_msec = inactive_msec;
+    if ((fabs(inactive_since - state->inactive_since)) > 2.0) {
+      state->inactive_since = inactive_since;
+    }
+  }
+
+  if (si[NL80211_STA_INFO_RX_BITRATE]) {
+    int rx_ht_mcs=0, rx_vht_mcs=0, rx_vht_nss=0, rx_width=0, short_gi=0;
+    int ht_nss;
+    int n = state->sample_index + 1;
+
+    if (n >= MAX_SAMPLE_INDEX) n = 0;
+
+    state->rx_bitrate = GetBitrate(si[NL80211_STA_INFO_RX_BITRATE]);
+    GetRxMCS(si[NL80211_STA_INFO_RX_BITRATE], &rx_ht_mcs, &rx_vht_mcs,
+        &rx_width, &short_gi, &rx_vht_nss);
+
+    state->rx_ht_mcs_samples[n] = rx_ht_mcs;
+    if (rx_ht_mcs > state->rx_max_ht_mcs) state->rx_max_ht_mcs = rx_ht_mcs;
+
+    ht_nss = RxHtMcsToNss(rx_ht_mcs);
+    state->rx_ht_nss_samples[n] = ht_nss;
+    if (ht_nss > state->rx_max_ht_nss) state->rx_max_ht_nss = ht_nss;
+
+    state->rx_vht_mcs_samples[n] = rx_vht_mcs;
+    if (rx_vht_mcs > state->rx_max_vht_mcs) state->rx_max_vht_mcs = rx_vht_mcs;
+
+    state->rx_vht_nss_samples[n] = rx_vht_nss;
+    if (rx_vht_nss > state->rx_max_vht_nss) state->rx_max_vht_nss = rx_vht_nss;
+
+    state->short_gi_samples[n] = short_gi;
+    if (short_gi) state->ever_short_gi = 1;
+
+    state->rx_width_samples[n] = rx_width;
+    if (rx_width > state->rx_max_width) state->rx_max_width = rx_width;
+
+    state->sample_index = n;
+  }
+  if (si[NL80211_STA_INFO_RX_BYTES]) {
+    uint32_t last_rx_bytes = state->rx_bytes;
+    state->rx_bytes = nla_get_u32(si[NL80211_STA_INFO_RX_BYTES]);
+    state->rx_bytes64 += (state->rx_bytes - last_rx_bytes);
+  }
+  if (si[NL80211_STA_INFO_RX_PACKETS]) {
+    uint32_t last_rx_packets = state->rx_packets;
+    state->rx_packets = nla_get_u32(si[NL80211_STA_INFO_RX_PACKETS]);
+    state->rx_packets64 += (state->rx_packets - last_rx_packets);
+  }
+  if (si[NL80211_STA_INFO_TX_BITRATE]) {
+    state->tx_bitrate = GetBitrate(si[NL80211_STA_INFO_TX_BITRATE]);
+  }
+  if (si[NL80211_STA_INFO_TX_BYTES]) {
+    uint32_t last_tx_bytes = state->tx_bytes;
+    state->tx_bytes = nla_get_u32(si[NL80211_STA_INFO_TX_BYTES]);
+    state->tx_bytes64 += (state->tx_bytes - last_tx_bytes);
+  }
+  if (si[NL80211_STA_INFO_TX_PACKETS]) {
+    uint32_t last_tx_packets = state->tx_packets;
+    state->tx_packets = nla_get_u32(si[NL80211_STA_INFO_TX_PACKETS]);
+    state->tx_packets64 += (state->tx_packets - last_tx_packets);
+  }
+  if (si[NL80211_STA_INFO_TX_RETRIES]) {
+    uint32_t last_tx_retries = state->tx_retries;
+    state->tx_retries = nla_get_u32(si[NL80211_STA_INFO_TX_RETRIES]);
+    state->tx_retries64 += (state->tx_retries - last_tx_retries);
+  }
+  if (si[NL80211_STA_INFO_TX_FAILED]) {
+    uint32_t last_tx_failed = state->tx_failed;
+    state->tx_failed = nla_get_u32(si[NL80211_STA_INFO_TX_FAILED]);
+    state->tx_failed64 += (state->tx_failed - last_tx_failed);
+  }
+  if (si[NL80211_STA_INFO_CONNECTED_TIME]) {
+    state->connected_secs = nla_get_u32(si[NL80211_STA_INFO_CONNECTED_TIME]);
+  }
+  if (si[NL80211_STA_INFO_SIGNAL]) {
+    state->signal = (int8_t)nla_get_u8(si[NL80211_STA_INFO_SIGNAL]);
+  }
+  if (si[NL80211_STA_INFO_SIGNAL_AVG]) {
+    state->signal_avg = (int8_t)nla_get_u8(si[NL80211_STA_INFO_SIGNAL_AVG]);
+  }
+
+  if (si[NL80211_STA_INFO_STA_FLAGS]) {
+    struct nl80211_sta_flag_update *sta_flags;
+    sta_flags = (struct nl80211_sta_flag_update *)nla_data(
+        si[NL80211_STA_INFO_STA_FLAGS]);
+
+    #define BIT(x) ((sta_flags->mask & (1ULL<<(x))) ? 1 : 0)
+    state->authorized = BIT(NL80211_STA_FLAG_AUTHORIZED);
+    state->authenticated = BIT(NL80211_STA_FLAG_AUTHENTICATED);
+    state->preamble = BIT(NL80211_STA_FLAG_SHORT_PREAMBLE);
+    state->wmm_wme = BIT(NL80211_STA_FLAG_WME);
+    state->mfp = BIT(NL80211_STA_FLAG_MFP);
+    state->tdls_peer = BIT(NL80211_STA_FLAG_TDLS_PEER);
+    state->preamble_length = BIT(NL80211_STA_FLAG_SHORT_PREAMBLE);
+    #undef BIT
+  }
+
+#ifdef NL80211_RECENT_FIELDS
+  if (si[NL80211_STA_INFO_RX_DROP_MISC]) {
+    state->rx_drop64 = nla_get_u64(si[NL80211_STA_INFO_RX_DROP_MISC]);
+  }
+  if (si[NL80211_STA_INFO_EXPECTED_THROUGHPUT]) {
+    state->expected_mbps =
+      nla_get_u32(si[NL80211_STA_INFO_EXPECTED_THROUGHPUT]);
+  }
+#endif
+
+  return NL_OK;
+}  /* StationDumpCallback */
+
+
+void RequestAssociatedDevices(struct nl_sock *nlsk,
+    int nl80211_id, int n)
+{
+  HandleNLCommand(nlsk, nl80211_id, n, StationDumpCallback,
+                  NL80211_CMD_GET_STATION, NLM_F_DUMP);
+}  /* RequestAssociatedDevices */
+
+
+static void ClearClientCounters(client_state_t *state)
+{
+  /* Kernel cleared its counters when client re-joined the WLAN,
+   * clear out previous state as well. */
+  state->rx_bytes = 0;
+  state->rx_packets = 0;
+  state->tx_bytes = 0;
+  state->tx_packets = 0;
+  state->tx_retries = 0;
+  state->tx_failed = 0;
+}
+
+
+static gboolean AgeOutClient(gpointer key, gpointer value, gpointer user_data)
+{
+  client_state_t *state = (client_state_t *)value;
+  time_t mono_now = monotime();
+
+  if ((mono_now - state->last_seen) > MAX_CLIENT_AGE_SECS) {
+    char filename[PATH_MAX];
+    snprintf(filename, sizeof(filename), "%s/%s", STATIONS_DIR, state->macstr);
+    unlink(filename);
+    return TRUE;
+  }
+
+  if (state->connected_secs < 60) {
+    /* If the client recently dropped off and came back, clear any counters
+     * we've been maintaining. */
+    ClearClientCounters(state);
+  }
+
+  return FALSE;
+}
+
+
+static void ConsolidateClientSamples(gpointer key, gpointer value,
+    gpointer user_data)
+{
+  client_state_t *state = (client_state_t *)value;
+  int i;
+  uint8_t rx_ht_mcs=0, rx_vht_mcs=0, rx_width=0, rx_ht_nss=0;
+  uint8_t rx_vht_nss=0, short_gi=0;
+
+  for (i = 0; i < MAX_SAMPLE_INDEX; ++i) {
+    if (state->rx_ht_mcs_samples[i] > rx_ht_mcs) {
+      rx_ht_mcs = state->rx_ht_mcs_samples[i];
+    }
+    if (state->rx_vht_mcs_samples[i] > rx_vht_mcs) {
+      rx_vht_mcs = state->rx_vht_mcs_samples[i];
+    }
+    if (state->rx_width_samples[i] > rx_width) {
+      rx_width = state->rx_width_samples[i];
+    }
+    if (state->rx_ht_nss_samples[i] > rx_ht_nss) {
+      rx_ht_nss = state->rx_ht_nss_samples[i];
+    }
+    if (state->rx_vht_nss_samples[i] > rx_vht_nss) {
+      rx_vht_nss = state->rx_vht_nss_samples[i];
+    }
+    if (state->short_gi_samples[i] > short_gi) {
+      short_gi = state->short_gi_samples[i];
+    }
+  }
+
+  state->rx_ht_mcs = rx_ht_mcs;
+  state->rx_vht_mcs = rx_vht_mcs;
+  state->rx_width = rx_width;
+  state->rx_ht_nss = rx_ht_nss;
+  state->rx_vht_nss = rx_vht_nss;
+  state->short_gi = short_gi;
+}
+
+
+static void ClientStateToJson(gpointer key, gpointer value, gpointer user_data)
+{
+  const client_state_t *state = (const client_state_t *)value;
+  char tmpfile[PATH_MAX];
+  char filename[PATH_MAX];
+  time_t mono_now = monotime();
+  FILE *f;
+
+  snprintf(tmpfile, sizeof(tmpfile), "%s/%s.new", STATIONS_DIR, state->macstr);
+  snprintf(filename, sizeof(filename), "%s/%s", STATIONS_DIR, state->macstr);
+
+  if ((f = fopen(tmpfile, "w+")) == NULL) {
+    char errbuf[80];
+    snprintf(errbuf, sizeof(errbuf), "fopen %s", tmpfile);
+    perror(errbuf);
+    return;
+  }
+
+  fprintf(f, "{\n");
+
+  fprintf(f, "  \"addr\": \"%s\",\n", state->macstr);
+  fprintf(f, "  \"inactive since\": %.3f,\n", state->inactive_since);
+  fprintf(f, "  \"inactive msec\": %u,\n", state->inactive_msec);
+
+  fprintf(f, "  \"active\": %s,\n",
+      ((mono_now - state->last_seen) < 600) ? "true" : "false");
+
+  fprintf(f, "  \"rx bitrate\": %u.%u,\n",
+      (state->rx_bitrate / 10), (state->rx_bitrate % 10));
+  fprintf(f, "  \"rx bytes\": %u,\n", state->rx_bytes);
+  fprintf(f, "  \"rx packets\": %u,\n", state->rx_packets);
+
+  fprintf(f, "  \"tx bitrate\": %u.%u,\n",
+      (state->tx_bitrate / 10), (state->tx_bitrate % 10));
+  fprintf(f, "  \"tx bytes\": %u,\n", state->tx_bytes);
+  fprintf(f, "  \"tx packets\": %u,\n", state->tx_packets);
+  fprintf(f, "  \"tx retries\": %u,\n", state->tx_retries);
+  fprintf(f, "  \"tx failed\": %u,\n", state->tx_failed);
+
+  fprintf(f, "  \"rx mcs\": %u,\n", state->rx_ht_mcs);
+  fprintf(f, "  \"rx max mcs\": %u,\n", state->rx_max_ht_mcs);
+  fprintf(f, "  \"rx vht mcs\": %u,\n", state->rx_vht_mcs);
+  fprintf(f, "  \"rx max vht mcs\": %u,\n", state->rx_max_vht_mcs);
+  fprintf(f, "  \"rx width\": %u,\n", state->rx_width);
+  fprintf(f, "  \"rx max width\": %u,\n", state->rx_max_width);
+  fprintf(f, "  \"rx ht_nss\": %u,\n", state->rx_ht_nss);
+  fprintf(f, "  \"rx max ht_nss\": %u,\n", state->rx_max_ht_nss);
+  fprintf(f, "  \"rx vht_nss\": %u,\n", state->rx_vht_nss);
+  fprintf(f, "  \"rx max vht_nss\": %u,\n", state->rx_max_vht_nss);
+
+  #define BOOL(x) (x ? "true" : "false")
+  fprintf(f, "  \"rx SHORT_GI\": %s,\n", BOOL(state->short_gi));
+  fprintf(f, "  \"rx SHORT_GI seen\": %s,\n", BOOL(state->ever_short_gi));
+  #undef BOOL
+
+  fprintf(f, "  \"signal\": %hhd,\n", state->signal);
+  fprintf(f, "  \"signal_avg\": %hhd,\n", state->signal_avg);
+
+  #define BOOL(x) (x ? "yes" : "no")
+  fprintf(f, "  \"authorized\": \"%s\",\n", BOOL(state->authorized));
+  fprintf(f, "  \"authenticated\": \"%s\",\n", BOOL(state->authenticated));
+  fprintf(f, "  \"preamble\": \"%s\",\n", BOOL(state->preamble));
+  fprintf(f, "  \"wmm_wme\": \"%s\",\n", BOOL(state->wmm_wme));
+  fprintf(f, "  \"mfp\": \"%s\",\n", BOOL(state->mfp));
+  fprintf(f, "  \"tdls_peer\": \"%s\",\n", BOOL(state->tdls_peer));
+  #undef BOOL
+
+  fprintf(f, "  \"preamble length\": \"%s\",\n",
+      (state->preamble_length ? "short" : "long"));
+
+  fprintf(f, "  \"rx bytes64\": %" PRIu64 ",\n", state->rx_bytes64);
+  fprintf(f, "  \"rx drop64\": %" PRIu64 ",\n", state->rx_drop64);
+  fprintf(f, "  \"tx bytes64\": %" PRIu64 ",\n", state->tx_bytes64);
+  fprintf(f, "  \"tx retries64\": %" PRIu64 ",\n", state->tx_retries64);
+  fprintf(f, "  \"expected Mbps\": %u.%03u,\n",
+          (state->expected_mbps / 1000), (state->expected_mbps % 1000));
+
+  fprintf(f, "  \"ifname\": \"%s\"\n", state->ifname);
+  fprintf(f, "}\n");
+
+  fclose(f);
+  if (rename(tmpfile, filename)) {
+    char errstr[160];
+    snprintf(errstr, sizeof(errstr), "%s: rename %s to %s",
+        __FUNCTION__, tmpfile, filename);
+    perror(errstr);
+  }
+}
+
+
+static void TouchUpdateFile()
 {
   char filename[PATH_MAX];
   int fd;
@@ -451,16 +797,74 @@
 } /* TouchUpdateFile */
 
 
-void usage(const char *progname)
+static void ClientStateToLog(gpointer key, gpointer value, gpointer user_data)
 {
-  printf("usage: %s\n", progname);
-  printf("\tWill write files to /tmp/stations for Wifi clients.\n");
-  exit(1);
-}  /* usage */
+  const client_state_t *state = (const client_state_t *)value;
+  const callback_data_t *cb_data = (const callback_data_t *)user_data;
+  time_t mono_now = cb_data->mono_now;
+
+  if (!state->authorized || !state->authenticated) {
+    /* Don't log about non-associated clients */
+    return;
+  }
+
+  if ((mono_now - state->first_seen) < 120) {
+    /* Allow data to accumulate before beginning to log it. */
+    return;
+  }
+
+  printf(
+      "%s %s %ld %" PRIu64 ",%" PRIu64 ",%" PRIu64 ",%" PRIu64 ",%" PRIu64
+      " %c,%hhd,%hhd,%u,%u,%u,%u,%u,%d"
+      " %u,%u,%u,%u,%u,%d"
+      "\n",
+      state->macstr, state->ifname,
+      ((mono_now - state->last_seen) + (state->inactive_msec / 1000)),
+
+      /* L2 traffic stats */
+      state->rx_bytes64, state->rx_drop64, state->tx_bytes64,
+      state->tx_retries64, state->tx_failed64,
+
+      /* L1 information */
+      (state->preamble_length ? 'S' : 'L'),
+      state->signal, state->signal_avg,
+      state->rx_ht_mcs, state->rx_ht_nss,
+      state->rx_vht_mcs, state->rx_vht_nss,
+      state->rx_width, state->short_gi,
+
+      /* information about the maximum we've ever seen from this client. */
+      state->rx_max_ht_mcs, state->rx_max_ht_nss,
+      state->rx_max_vht_mcs, state->rx_max_vht_nss,
+      state->rx_max_width, state->ever_short_gi);
+}
 
 
-/* From iw package, try untouched except indentation */
-int ieee80211_frequency_to_channel(int freq)
+void ConsolidateAssociatedDevices()
+{
+  g_hash_table_foreach_remove(clients, AgeOutClient, NULL);
+  g_hash_table_foreach(clients, ConsolidateClientSamples, NULL);
+}
+
+
+/* Walk through all Wifi clients, printing their info to JSON files. */
+void UpdateAssociatedDevices()
+{
+  g_hash_table_foreach(clients, ClientStateToJson, NULL);
+  TouchUpdateFile();
+}
+
+
+void LogAssociatedDevices()
+{
+  callback_data_t cb_data;
+
+  memset(&cb_data, 0, sizeof(cb_data));
+  cb_data.mono_now = monotime();
+  g_hash_table_foreach(clients, ClientStateToLog, &cb_data);
+}
+
+
+static int ieee80211_frequency_to_channel(int freq)
 {
   /* see 802.11-2007 17.3.8.3.2 and Annex J */
   if (freq == 2484)
@@ -478,7 +882,7 @@
 }
 
 
-void print_ssid_escaped(FILE* f, const uint8_t len, const uint8_t *data)
+static void print_ssid_escaped(FILE *f, int len, const uint8_t *data)
 {
   int i;
 
@@ -486,7 +890,7 @@
     if (isprint(data[i]) && data[i] != ' ' && data[i] != '\\')
       fprintf(f, "%c", data[i]);
     else if (data[i] == ' ' && (i != 0 && i != len -1))
-      fprintf(f," ");
+      fprintf(f, " ");
     else
       fprintf(f, "\\x%.2x", data[i]);
   }
@@ -503,31 +907,28 @@
 
   if (tb_msg[NL80211_ATTR_MAC]) {
     unsigned char *mac_addr = nla_data(tb_msg[NL80211_ATTR_MAC]);
-    fprintf(wifi_show_handle,
+    fprintf(wifi_info_handle,
             "  \"BSSID\": \"%02x:%02x:%02x:%02x:%02x:%02x\",\n",
             mac_addr[0], mac_addr[1], mac_addr[2],
             mac_addr[3], mac_addr[4], mac_addr[5]);
   }
   if (tb_msg[NL80211_ATTR_SSID]) {
-    fprintf(wifi_show_handle, "  \"SSID\": \"");
-    print_ssid_escaped(wifi_show_handle, nla_len(tb_msg[NL80211_ATTR_SSID]),
+    fprintf(wifi_info_handle, "  \"SSID\": \"");
+    print_ssid_escaped(wifi_info_handle, nla_len(tb_msg[NL80211_ATTR_SSID]),
                        nla_data(tb_msg[NL80211_ATTR_SSID]));
-    fprintf(wifi_show_handle, "\",\n");
+    fprintf(wifi_info_handle, "\",\n");
   }
   if (tb_msg[NL80211_ATTR_WIPHY_FREQ]) {
     uint32_t freq = nla_get_u32(tb_msg[NL80211_ATTR_WIPHY_FREQ]);
-
-    fprintf(wifi_show_handle, "  \"Channel\": %d",
+    fprintf(wifi_info_handle, "  \"Channel\": %d,\n",
             ieee80211_frequency_to_channel(freq));
-
-    fprintf(wifi_show_handle, ",\n");
   }
 
   return NL_SKIP;
 }
 
 
-void UpdateWifiShowContent(struct nl_sock *nlsk, int nl80211_id, int n)
+void RequestWifiInfo(struct nl_sock *nlsk, int nl80211_id, int n)
 {
   HandleNLCommand(nlsk, nl80211_id, n, WlanInfoCallback,
                   NL80211_CMD_GET_INTERFACE, 0);
@@ -538,7 +939,7 @@
 {
   struct nlattr *tb_msg[NL80211_ATTR_MAX + 1];
   struct genlmsghdr *gnlh = nlmsg_data(nlmsg_hdr(msg));
-  char *alpha2;
+  char *reg;
 
   nla_parse(tb_msg, NL80211_ATTR_MAX, genlmsg_attrdata(gnlh, 0),
             genlmsg_attrlen(gnlh, 0), NULL);
@@ -551,14 +952,14 @@
     return NL_SKIP;
   }
 
-  alpha2 = nla_data(tb_msg[NL80211_ATTR_REG_ALPHA2]);
-  fprintf(wifi_show_handle, "  \"RegDomain\": \"%c%c\",\n", alpha2[0], alpha2[1]);
+  reg = nla_data(tb_msg[NL80211_ATTR_REG_ALPHA2]);
+  fprintf(wifi_info_handle, "  \"RegDomain\": \"%c%c\",\n", reg[0], reg[1]);
 
   return NL_SKIP;
 }
 
 
-void UpdateWifiRegdomain(struct nl_sock *nlsk, int nl80211_id)
+void RequestRegdomain(struct nl_sock *nlsk, int nl80211_id)
 {
   HandleNLCommand(nlsk, nl80211_id, -1, RegdomainCallback,
                   NL80211_CMD_GET_REG, 0);
@@ -579,48 +980,53 @@
     return;
   }
 
-  snprintf(tmpfile, sizeof(tmpfile), "%s/%s.new", WIFISHOW_DIR, ifname);
-  snprintf(filename, sizeof(filename), "%s/%s", WIFISHOW_DIR, ifname);
+  snprintf(tmpfile, sizeof(tmpfile), "%s/%s.new", WIFIINFO_DIR, ifname);
+  snprintf(filename, sizeof(filename), "%s/%s", WIFIINFO_DIR, ifname);
 
-  if ((wifi_show_handle = fopen(tmpfile, "w+")) == NULL) {
+  if ((wifi_info_handle = fopen(tmpfile, "w+")) == NULL) {
     perror("fopen");
     return;
   }
 
-  fprintf(wifi_show_handle, "{\n");
+  fprintf(wifi_info_handle, "{\n");
   done = 0;
-  UpdateWifiShowContent(nlsk, nl80211_id, n);
+  RequestWifiInfo(nlsk, nl80211_id, n);
   ProcessNetlinkMessages(nlsk, &done);
 
   done = 0;
-  UpdateWifiRegdomain(nlsk, nl80211_id);
+  RequestRegdomain(nlsk, nl80211_id);
   ProcessNetlinkMessages(nlsk, &done);
 
   snprintf(autofile, sizeof(autofile), "/tmp/autochan.%s", ifname);
   if (stat(autofile, &buffer) == 0) {
-    fprintf(wifi_show_handle, "  \"AutoChannel\": true,\n");
+    fprintf(wifi_info_handle, "  \"AutoChannel\": true,\n");
   } else {
-    fprintf(wifi_show_handle, "  \"AutoChannel\": false,\n");
+    fprintf(wifi_info_handle, "  \"AutoChannel\": false,\n");
   }
   snprintf(autofile, sizeof(autofile), "/tmp/autotype.%s", ifname);
   if ((fptr = fopen(autofile, "r")) == NULL) {
-    fprintf(wifi_show_handle, "  \"AutoType\": \"LOW\"\n");
+    fprintf(wifi_info_handle, "  \"AutoType\": \"LOW\"\n");
   } else {
     char buf[24];
-    if (fgets(buf, sizeof(buf), fptr) != NULL)
-      fprintf(wifi_show_handle, "  \"AutoType\": \"%s\"\n", buf);
+    if (fgets(buf, sizeof(buf), fptr) != NULL) {
+      fprintf(wifi_info_handle, "  \"AutoType\": \"%s\"\n", buf);
+    }
     fclose(fptr);
     fptr = NULL;
   }
-  fprintf(wifi_show_handle, "}\n");
+  fprintf(wifi_info_handle, "}\n");
 
-  fclose(wifi_show_handle);
-  wifi_show_handle = NULL;
+  fclose(wifi_info_handle);
+  wifi_info_handle = NULL;
   if (rename(tmpfile, filename)) {
-    perror("rename");
+    char errbuf[256];
+    snprintf(errbuf, sizeof(errbuf), "%s: rename %s to %s : errno=%d",
+        __FUNCTION__, tmpfile, filename, errno);
+    perror(errbuf);
   }
 }
 
+#ifndef UNIT_TESTS
 int main(int argc, char **argv)
 {
   int done = 0;
@@ -628,19 +1034,21 @@
   struct nl_sock *nlsk = NULL;
   struct rlimit rlim;
 
-  hcreate(512);
-
   memset(&rlim, 0, sizeof(rlim));
   if (getrlimit(RLIMIT_AS, &rlim)) {
     perror("getrlimit RLIMIT_AS failed");
     exit(1);
   }
-  rlim.rlim_cur = 5 * 1024 * 1024;
+  rlim.rlim_cur = 6 * 1024 * 1024;
   if (setrlimit(RLIMIT_AS, &rlim)) {
     perror("getrlimit RLIMIT_AS failed");
     exit(1);
   }
 
+  setlinebuf(stdout);
+
+  clients = g_hash_table_new_full(g_str_hash, g_str_equal, free, free);
+
   nlsk = InitNetlinkSocket();
   if (nl_socket_modify_cb(nlsk, NL_CB_FINISH, NL_CB_CUSTOM, NlFinish, &done)) {
     fprintf(stderr, "nl_socket_modify_cb failed\n");
@@ -650,21 +1058,34 @@
     fprintf(stderr, "genl_ctrl_resolve failed\n");
     exit(1);
   }
-  RequestInterfaceList(nlsk, nl80211_id);
-  ProcessNetlinkMessages(nlsk, &done);
 
   while (1) {
-    int i;
-    for (i = 0; i < ninterfaces; i++) {
-      done = 0;
-      RequestAssociatedDevices(nlsk, nl80211_id, i);
-      ProcessNetlinkMessages(nlsk, &done);
+    int i, j;
 
+    /* Check if new interfaces have appeared */
+    ninterfaces = 0;
+    RequestInterfaceList(nlsk, nl80211_id);
+    ProcessNetlinkMessages(nlsk, &done);
+    for (i = 0; i < ninterfaces; ++i) {
       UpdateWifiShow(nlsk, nl80211_id, i);
     }
-    TouchUpdateFile();
-    sleep(2);
+
+    /* Accumulate MAX_SAMPLE_INDEX samples between calls to
+     * LogAssociatedDevices() */
+    for (i = 0; i < MAX_SAMPLE_INDEX; ++i) {
+      sleep(2);
+      for (j = 0; j < ninterfaces; ++j) {
+        done = 0;
+        RequestAssociatedDevices(nlsk, nl80211_id, j);
+        ProcessNetlinkMessages(nlsk, &done);
+        ConsolidateAssociatedDevices();
+        UpdateAssociatedDevices();
+      }
+      TouchUpdateFile();
+    }
+    LogAssociatedDevices();
   }
 
   exit(0);
 }
+#endif  /* UNIT_TESTS */
diff --git a/cmds/wifi_files_test.c b/cmds/wifi_files_test.c
new file mode 100644
index 0000000..bb0043c
--- /dev/null
+++ b/cmds/wifi_files_test.c
@@ -0,0 +1,183 @@
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <sys/types.h>
+#include <sys/socket.h>
+
+
+time_t now = 1000;
+static time_t monotime(void)
+{
+  return now;
+}
+
+
+#define UNIT_TESTS
+const char *stations_dir;
+#define STATIONS_DIR stations_dir
+#define WIFIINFO_DIR STATIONS_DIR
+#include "wifi_files.c"
+
+
+int exit_code = 0;
+
+
+#define TEST_ASSERT(x) \
+  if (x) printf("! %s:%d \"x\"\tok\n", __FUNCTION__, __LINE__); \
+  else { \
+    printf("! %s:%d \"x\"\tFAILED\n", __FUNCTION__, __LINE__); \
+    exit_code = 1; \
+  }
+
+
+void testPrintSsidEscaped()
+{
+  FILE *f = tmpfile();
+  char buf[32];
+  const uint8_t ssid[] = {'a', 'b', 0x86, ' ', 'c'};  /* not NUL terminated. */
+  const uint8_t expected[] = {'a', 'b', '\\', 'x', '8', '6', ' ', 'c'};
+
+  printf("Testing \"%s\" in %s:\n", __FUNCTION__, __FILE__);
+  memset(buf, 0, sizeof(buf));
+  TEST_ASSERT(f != NULL);
+  print_ssid_escaped(f, sizeof(ssid), ssid);
+  fflush(f);
+  rewind(f);
+  TEST_ASSERT(fread(buf, 1, sizeof(buf), f) > 0);
+  TEST_ASSERT(memcmp(buf, expected, sizeof(expected)) == 0);
+  fclose(f);
+  printf("! %s:%d\t%s\tok\n", __FILE__, __LINE__, __FUNCTION__);
+}
+
+
+void testFrequencyToChannel()
+{
+  printf("Testing \"%s\" in %s:\n", __FUNCTION__, __FILE__);
+  TEST_ASSERT(ieee80211_frequency_to_channel(2484) == 14);
+  TEST_ASSERT(ieee80211_frequency_to_channel(5745) == 149);
+  printf("! %s:%d\t%s\tok\n", __FILE__, __LINE__, __FUNCTION__);
+}
+
+
+static char *expected_json = "{\n"
+"  \"addr\": \"00:11:22:33:44:55\",\n"
+"  \"inactive since\": 0.000,\n"
+"  \"inactive msec\": 0,\n"
+"  \"active\": false,\n"
+"  \"rx bitrate\": 4.7,\n"
+"  \"rx bytes\": 0,\n"
+"  \"rx packets\": 0,\n"
+"  \"tx bitrate\": 0.0,\n"
+"  \"tx bytes\": 0,\n"
+"  \"tx packets\": 0,\n"
+"  \"tx retries\": 0,\n"
+"  \"tx failed\": 0,\n"
+"  \"rx mcs\": 0,\n"
+"  \"rx max mcs\": 0,\n"
+"  \"rx vht mcs\": 0,\n"
+"  \"rx max vht mcs\": 0,\n"
+"  \"rx width\": 0,\n"
+"  \"rx max width\": 0,\n"
+"  \"rx ht_nss\": 0,\n"
+"  \"rx max ht_nss\": 0,\n"
+"  \"rx vht_nss\": 0,\n"
+"  \"rx max vht_nss\": 0,\n"
+"  \"rx SHORT_GI\": false,\n"
+"  \"rx SHORT_GI seen\": false,\n"
+"  \"signal\": 0,\n"
+"  \"signal_avg\": 0,\n"
+"  \"authorized\": \"no\",\n"
+"  \"authenticated\": \"yes\",\n"
+"  \"preamble\": \"no\",\n"
+"  \"wmm_wme\": \"no\",\n"
+"  \"mfp\": \"no\",\n"
+"  \"tdls_peer\": \"no\",\n"
+"  \"preamble length\": \"long\",\n"
+"  \"rx bytes64\": 1,\n"
+"  \"rx drop64\": 2,\n"
+"  \"tx bytes64\": 0,\n"
+"  \"tx retries64\": 0,\n"
+"  \"expected Mbps\": 7.009,\n"
+"  \"ifname\": \"\"\n"
+"}";
+
+
+void testClientStateToJson()
+{
+  client_state_t state;
+  char *buf;
+  char filename[PATH_MAX];
+  int fd;
+
+  printf("Testing \"%s\" in %s:\n", __FUNCTION__, __FILE__);
+  TEST_ASSERT(ieee80211_frequency_to_channel(2484) == 14);
+  memset(&state, 0, sizeof(client_state_t));
+  snprintf(state.macstr, sizeof(state.macstr), "00:11:22:33:44:55");
+  state.rx_bytes64 = 1ULL;
+  state.rx_drop64 = 2ULL;
+  state.rx_bitrate = 47;
+  state.authorized = 0;
+  state.authenticated = 1;
+  state.preamble_length = 0;
+  state.expected_mbps = 7009;
+  ClientStateToJson((gpointer)(&state.macstr), (gpointer)&state, NULL);
+
+  #define SIZ 65536
+  TEST_ASSERT((buf = malloc(SIZ)) != NULL);
+  memset(buf, 0, SIZ);
+  snprintf(filename, sizeof(filename), "%s/%s", STATIONS_DIR, state.macstr);
+  TEST_ASSERT((fd = open(filename, O_RDONLY)) >= 0);
+  TEST_ASSERT(read(fd, buf, SIZ) > 0);
+  close(fd);
+  TEST_ASSERT(unlink(filename) == 0);
+  TEST_ASSERT(strcmp(buf, expected_json));
+  free(buf);
+  printf("! %s:%d\t%s\tok\n", __FILE__, __LINE__, __FUNCTION__);
+}
+
+
+void testAgeOutClients()
+{
+  uint8_t mac[] = {0x00, 0x00, 0x01, 0x00, 0x00, 0x00};
+  client_state_t *state;
+
+  printf("Testing \"%s\" in %s:\n", __FUNCTION__, __FILE__);
+  mac[5] = 0x01;
+  state = FindClientState(mac);
+  state->last_seen = 1000;
+
+  mac[5] = 0x02;
+  state = FindClientState(mac);
+  state->last_seen = 10000;
+  TEST_ASSERT(g_hash_table_size(clients) == 2);
+
+  now = 1000 + MAX_CLIENT_AGE_SECS + 1;
+  ConsolidateAssociatedDevices();
+  TEST_ASSERT(g_hash_table_size(clients) == 1);
+
+  now = 10000 + MAX_CLIENT_AGE_SECS + 1;
+  ConsolidateAssociatedDevices();
+  TEST_ASSERT(g_hash_table_size(clients) == 0);
+  printf("! %s:%d\t%s\tok\n", __FILE__, __LINE__, __FUNCTION__);
+}
+
+
+int main(int argc, char** argv)
+{
+  char filename[PATH_MAX];
+
+  stations_dir = mkdtemp(strdup("/tmp/wifi_files_test_XXXXXX"));
+  printf("stations_dir = %s\n", stations_dir);
+  clients = g_hash_table_new(g_str_hash, g_str_equal);
+
+  testPrintSsidEscaped();
+  testFrequencyToChannel();
+  testClientStateToJson();
+  testAgeOutClients();
+
+  snprintf(filename, sizeof(filename), "%s/updated.new", STATIONS_DIR);
+  unlink(filename);
+  TEST_ASSERT(rmdir(stations_dir) == 0);
+
+  exit(exit_code);
+}