onlyn00bs-badge

OnlyN00bs: a DEF CON 34 friend-finder badge. ESP32 firmware, Web Bluetooth setup app, printable case
git clone https://git.virtualshack.io/onlyn00bs-badge.git
Log | Files | Refs | README | LICENSE

encounters.h (10137B)


      1 #pragma once
      2 #include <Arduino.h>
      3 #include <LittleFS.h>
      4 #include <string.h>
      5 #include "config.h"
      6 
      7 // ─────────────────────────────────────────────────────────────────────────
      8 //  Encounter log + report aggregation (your "social graph of the con").
      9 //
     10 //  An *encounter* is one continuous stretch you were near a friend. When a
     11 //  peer ages out of the table (leaves range), one record is appended to
     12 //  LittleFS. The report aggregates the whole log into per-friend stats.
     13 //
     14 //  Privacy by construction: only HMAC-verified crew beacons ever reach the
     15 //  peer table, so the log can only ever contain your own group — never randos.
     16 //
     17 //  Wire format (tab-separated; a sanitized handle can't contain TAB/newline):
     18 //     first_epoch <TAB> last_epoch <TAB> sightings <TAB> rssi_max <TAB> handle\n
     19 // ─────────────────────────────────────────────────────────────────────────
     20 
     21 #define ENC_LOG_PATH        "/encounters.log"
     22 #define ENC_LOG_TMP         "/encounters.tmp"   // scratch for drop-oldest compaction
     23 #define MAX_REPORT_FRIENDS  48
     24 
     25 struct Encounter {
     26   char     handle[HANDLE_MAX_LEN + 1];
     27   uint32_t first_epoch;
     28   uint32_t last_epoch;
     29   uint16_t sightings;
     30   int8_t   rssi_max;
     31 };
     32 
     33 struct FriendStat {
     34   char     handle[HANDLE_MAX_LEN + 1];
     35   uint16_t encounters;
     36   uint32_t total_seconds;
     37   int8_t   rssi_max;
     38   uint32_t first_epoch;
     39   uint32_t last_epoch;
     40 };
     41 
     42 struct Report {
     43   int        unique;
     44   int        total_encounters;
     45   uint32_t   total_seconds;
     46   bool       time_synced;
     47   FriendStat friends[MAX_REPORT_FRIENDS];
     48   int        n;
     49 };
     50 
     51 // Defined below; both are used by enc_append, which sits above them for readability.
     52 inline size_t enc_trim_oldest(size_t keep_bytes);
     53 inline size_t enc_size();
     54 
     55 inline bool enc_begin() {
     56   bool ok = LittleFS.begin(true);   // format on first mount
     57   // A power cut during a trim leaves ENC_LOG_TMP behind, and nothing else ever cleans it up —
     58   // it would just sit there consuming space the next trim needs. Boot is the safe moment: no
     59   // trim can be in flight.
     60   if (ok) LittleFS.remove(ENC_LOG_TMP);
     61   return ok;
     62 }
     63 
     64 inline void enc_append(const Encounter& e) {
     65   File f = LittleFS.open(ENC_LOG_PATH, FILE_APPEND);
     66   if (!f) return;
     67   f.printf("%u\t%u\t%u\t%d\t%s\n",
     68            (unsigned)e.first_epoch, (unsigned)e.last_epoch,
     69            (unsigned)e.sightings, (int)e.rssi_max, e.handle);
     70   f.close();                                  // close BEFORE measuring: a File opened for
     71                                               //   append re-stats the path, which can't see
     72                                               //   still-buffered bytes.
     73   // Proactive cap. Keeping the log bounded here — with hundreds of KB free — is what
     74   // makes the trim always feasible. Left to the 90%-full disk guard instead, the copy has
     75   // nowhere to go and silently keeps the WRONG end of the log.
     76   if (enc_size() > ENC_LOG_MAX_BYTES) enc_trim_oldest(ENC_LOG_TRIM_BYTES);
     77 }
     78 
     79 inline void enc_clear() { LittleFS.remove(ENC_LOG_PATH); }
     80 
     81 // Current on-disk size of the log (bytes); 0 if absent.
     82 inline size_t enc_size() {
     83   File f = LittleFS.open(ENC_LOG_PATH, FILE_READ);
     84   if (!f) return 0;
     85   size_t s = f.size();
     86   f.close();
     87   return s;
     88 }
     89 
     90 // Drop the OLDEST records so the log keeps only (about) the newest `keep_bytes` — the
     91 // disk guard's "drop-oldest cap" (bounds the log without ever stopping recording). Records
     92 // are appended chronologically, so the newest live at the tail: we keep the tail. Streaming
     93 // LittleFS→LittleFS copy (never slurps the file into RAM), cut aligned to the next record
     94 // boundary so no half-line survives. Returns bytes freed (0 = no-op / nothing to keep).
     95 inline size_t enc_trim_oldest(size_t keep_bytes) {
     96   // The tmp copy coexists with the original until the rename, so the tail we preserve has to
     97   // fit in the space that is ACTUALLY free — not in whatever the caller hoped for. Asking for
     98   // more than fits is how this silently kept the oldest slice instead of the newest.
     99   // Clamping degrades gracefully: worst case we keep less recent history than requested, but
    100   // it is always the most recent history.
    101   size_t total = LittleFS.totalBytes(), used = LittleFS.usedBytes();
    102   size_t room  = (total > used) ? total - used : 0;
    103   room = (room > ENC_TRIM_FREE_MARGIN) ? room - ENC_TRIM_FREE_MARGIN : 0;
    104   if (room == 0) return 0;                        // no working space — leave the log intact
    105   if (keep_bytes > room) keep_bytes = room;
    106 
    107   File f = LittleFS.open(ENC_LOG_PATH, FILE_READ);
    108   if (!f) return 0;
    109   size_t sz = f.size();
    110   if (sz <= keep_bytes) { f.close(); return 0; }
    111   f.seek(sz - keep_bytes);                        // jump to ~keep_bytes before EOF
    112   while (f.available() && f.read() != '\n') { }   // advance past the partial first line
    113   if (!f.available()) { f.close(); return 0; }    // nothing whole left after the cut — leave as-is
    114   File t = LittleFS.open(ENC_LOG_TMP, FILE_WRITE);
    115   if (!t) { f.close(); return 0; }
    116   uint8_t buf[256];
    117   bool ok = true;
    118   while (f.available()) {
    119     size_t n = f.read(buf, sizeof(buf));
    120     if (!n) break;
    121     if (t.write(buf, n) != n) { ok = false; break; }   // ENOSPC/IO — an unchecked write here
    122   }                                                    //   once silently lost the newest history
    123   size_t newsz = t.size();
    124   t.close();
    125   f.close();
    126   // Anything short of a complete copy: throw the partial away and leave the live log alone.
    127   // A truncated log is worse than an oversized one — it's the con souvenir.
    128   if (!ok || newsz == 0) { LittleFS.remove(ENC_LOG_TMP); return 0; }
    129 
    130   // Replace by rename, WITHOUT removing the live log first: littlefs replaces the destination
    131   // as part of the rename, so there is no window where neither file exists. The old
    132   // remove-then-rename could lose the ENTIRE log to a battery cut and orphan the tmp.
    133   // Fallback kept because overwrite-on-rename wasn't verifiable on this bench (the VFS ships
    134   // precompiled) — if it refuses, we do it the old way rather than not trim at all.
    135   if (!LittleFS.rename(ENC_LOG_TMP, ENC_LOG_PATH)) {
    136     LittleFS.remove(ENC_LOG_PATH);
    137     if (!LittleFS.rename(ENC_LOG_TMP, ENC_LOG_PATH)) { LittleFS.remove(ENC_LOG_TMP); return 0; }
    138   }
    139   return (sz > newsz) ? (sz - newsz) : 0;
    140 }
    141 
    142 // Fold one tab-separated record into the aggregate. `line` is NUL-terminated and is modified
    143 // in place (tabs become NULs). Malformed lines are dropped, as before.
    144 inline void enc_report_line(Report* r, char* line) {
    145   char* save = nullptr;
    146   char* c_first = strtok_r(line, "\t", &save);
    147   char* c_last  = strtok_r(nullptr, "\t", &save);
    148   char* c_sght  = strtok_r(nullptr, "\t", &save);
    149   char* c_rssi  = strtok_r(nullptr, "\t", &save);
    150   char* h       = strtok_r(nullptr, "\t", &save);
    151   if (!c_first || !c_last || !c_sght || !c_rssi || !h) return;
    152   (void)c_sght;                                    // sightings parsed but unused, as before
    153 
    154   uint32_t first = (uint32_t)strtoul(c_first, nullptr, 10);
    155   uint32_t last  = (uint32_t)strtoul(c_last,  nullptr, 10);
    156   int8_t   rssi  = (int8_t)strtol(c_rssi,     nullptr, 10);
    157 
    158   while (*h == ' ') h++;                           // trim, matching the old String::trim()
    159   for (int i = (int)strlen(h) - 1; i >= 0 && (h[i] == ' ' || h[i] == '\r'); i--) h[i] = '\0';
    160   if (!*h) return;
    161 
    162   int idx = -1;
    163   for (int i = 0; i < r->n; i++)
    164     if (strcmp(h, r->friends[i].handle) == 0) { idx = i; break; }
    165   if (idx < 0) {
    166     if (r->n >= MAX_REPORT_FRIENDS) return;
    167     idx = r->n++;
    168     FriendStat& nf = r->friends[idx];
    169     strncpy(nf.handle, h, HANDLE_MAX_LEN);
    170     nf.handle[HANDLE_MAX_LEN] = '\0';
    171     nf.rssi_max     = -127;
    172     nf.first_epoch  = first;
    173     nf.last_epoch   = last;
    174   }
    175 
    176   FriendStat& fs = r->friends[idx];
    177   fs.encounters++;
    178   if (last > first) fs.total_seconds += (last - first);
    179   if (rssi > fs.rssi_max)       fs.rssi_max    = rssi;
    180   if (first < fs.first_epoch)   fs.first_epoch = first;
    181   if (last  > fs.last_epoch)    fs.last_epoch  = last;
    182 
    183   r->total_encounters++;
    184   if (last > first) r->total_seconds += (last - first);
    185 }
    186 
    187 // Read the whole log and aggregate into per-friend stats.
    188 //
    189 // Block reads + a fixed line buffer, deliberately — NOT String/readStringUntil. That combo is
    190 // one buffered fread PER BYTE plus a String::concat per byte, plus five substring temporaries
    191 // per record; a 10-digit epoch sits exactly at the SSO boundary so both epochs heap-allocate.
    192 // ~6-7 malloc/free per record turned entering REPORT — an ordinary double-tap — into a
    193 // multi-second freeze on a large log. This version allocates nothing.
    194 inline void enc_report(Report* r, bool time_synced) {
    195   memset(r, 0, sizeof(*r));
    196   r->time_synced = time_synced;
    197 
    198   File f = LittleFS.open(ENC_LOG_PATH, FILE_READ);
    199   if (!f) return;
    200 
    201   uint8_t blk[256];
    202   char    line[96];                 // a record is ~44 B; 96 is slack, not a limit in practice
    203   size_t  ll = 0;
    204   bool    overlong = false;         // line outgrew the buffer → drop it, resync at the next \n
    205 
    206   for (;;) {
    207     int n = f.read(blk, sizeof(blk));
    208     if (n <= 0) break;
    209     for (int i = 0; i < n; i++) {
    210       char c = (char)blk[i];
    211       if (c != '\n') {
    212         if (ll < sizeof(line) - 1) line[ll++] = c;
    213         else                       overlong = true;
    214         continue;
    215       }
    216       if (!overlong && ll) { line[ll] = '\0'; enc_report_line(r, line); }
    217       ll = 0; overlong = false;
    218     }
    219   }
    220   if (!overlong && ll) { line[ll] = '\0'; enc_report_line(r, line); }   // last line, no \n
    221   f.close();
    222 
    223   // Sort friends by time-together, descending (your most-seen crew first).
    224   for (int i = 1; i < r->n; i++) {
    225     FriendStat key = r->friends[i];
    226     int j = i - 1;
    227     while (j >= 0 && r->friends[j].total_seconds < key.total_seconds) {
    228       r->friends[j + 1] = r->friends[j];
    229       j--;
    230     }
    231     r->friends[j + 1] = key;
    232   }
    233   r->unique = r->n;
    234 }