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 }