display_oled.cpp (39743B)
1 // Monochrome OLED implementation of the display interface — SSD1309 2.42" 2 // (128×64) over I2C, via U8g2. This is the chosen badge display: self-lit 3 // (readable in a dark venue), dirt-simple, and a single 18650 runs it for days. 4 // 5 // 128×64 is tight, so layouts are deliberately terse: a header line + a handful 6 // of rows. Handles are truncated to fit. Pixels are the only power cost, so we 7 // keep the screen mostly dark. 8 9 #if defined(BADGE_DISPLAY_OLED) 10 11 #include <Arduino.h> 12 #include <U8g2lib.h> 13 #include <math.h> 14 #include "display.h" 15 #include "config.h" 16 #include "logos.h" // GENERATED — boot-card bitmaps 17 18 // Hardware-I2C SSD1309. Last two args are SCL/SDA for ESP32 (see config.h). 19 static U8G2_SSD1309_128X64_NONAME0_F_HW_I2C 20 u8g2(U8G2_R0, U8X8_PIN_NONE, OLED_SCL_PIN, OLED_SDA_PIN); 21 22 #define ROW_FONT u8g2_font_6x10_tf // ~6px wide → ~21 chars, ~6 rows tall 23 #define ROW_CHARS 21 24 25 static void truncate(char* dst, const char* src, int maxch) { 26 int i = 0; 27 for (; src[i] && i < maxch; i++) dst[i] = src[i]; 28 dst[i] = '\0'; 29 } 30 31 // Inverse-video multiplier chip ("x2"/"x3") — the group-bonus indicator on the 32 // FINDER and POINTS headers. Draws right-aligned ending at `right_x` in the 33 // header band (y 0..8); nothing at mult<2. Returns the horizontal space taken 34 // (width + gap) so callers can keep right-stacking header elements. 35 static int draw_mult_chip(int right_x, uint8_t mult) { 36 if (mult < 2) return 0; 37 char t[4]; 38 snprintf(t, sizeof(t), "x%u", (unsigned)mult); 39 int w = u8g2.getStrWidth(t) + 4; // 2px pad each side 40 u8g2.drawRBox(right_x - w, 0, w, 9, 1); 41 u8g2.setDrawColor(0); // cut the label out of the box 42 u8g2.drawStr(right_x - w + 2, 7, t); 43 u8g2.setDrawColor(1); 44 return w + 3; 45 } 46 47 // 4-segment signal bar (like a phone), filled to the RSSI level. Inactive slots 48 // render as a 1px base stub, NOT an outline: the bars are only 2px wide, and a 49 // 2px-wide drawFrame() is just its left+right edges — i.e. both columns — so it 50 // is indistinguishable from a filled box. (That bug made every bar look full.) 51 static void draw_bars(int x, int baseline, int8_t rssi) { 52 int lvl = rssi >= -55 ? 4 : rssi >= -65 ? 3 : rssi >= -75 ? 2 : rssi >= -85 ? 1 : 0; 53 for (int i = 0; i < 4; i++) { 54 int h = 2 + i * 2; 55 int bx = x + i * 3; 56 if (i < lvl) u8g2.drawBox(bx, baseline - h, 2, h); // filled to level 57 else u8g2.drawBox(bx, baseline - 1, 2, 1); // empty: base stub 58 } 59 } 60 61 // Line plot of the points samples in the box (x,y)-(x+w,y+h). Y auto-scales to 62 // the window's [min,max] points (flat series → baseline). X is real wall-clock 63 // over the fixed POINTS_GRAPH_SPAN_S window (anchored at the oldest sample) when 64 // the clock is synced; otherwise it falls back to uniform spacing. A sample 65 // flagged seg_start (the first after a reboot) breaks the line — the badge has 66 // no RTC so we mark the discontinuity without sizing the off-period. 67 // Plots samples within a time window: span_s wide, anchored at `anchor` (epoch). 68 // 5-day view → span=5d, anchor=oldest sample (all samples shown). 24h view → 69 // span=24h, anchor=newest−24h (only the last day shown, Y rescaled to it). When 70 // the clock was never synced, falls back to uniform spacing over all samples. 71 static void draw_sparkline(int x, int y, int w, int h, const PointSample* v, int n, 72 uint32_t span_s, uint32_t anchor, bool synced) { 73 if (n < 1) return; 74 // First pass: in-window count + Y range (in-window = epoch ≥ anchor, or all if unsynced). 75 int inwin = 0; bool any = false; uint16_t mn = 0, mx = 0; 76 for (int i = 0; i < n; i++) { 77 if (synced && v[i].epoch < anchor) continue; 78 inwin++; 79 if (!any) { mn = mx = v[i].points; any = true; } 80 else { if (v[i].points < mn) mn = v[i].points; if (v[i].points > mx) mx = v[i].points; } 81 } 82 if (!any) return; 83 uint32_t yspan = (uint32_t)mx - mn; 84 int prevx = 0, prevy = 0; bool first = true; int idx = 0; 85 for (int i = 0; i < n; i++) { 86 if (synced && v[i].epoch < anchor) continue; 87 int px; 88 if (synced) { 89 uint32_t dt = (v[i].epoch > anchor) ? (v[i].epoch - anchor) : 0; 90 if (dt > span_s) dt = span_s; 91 px = x + (int)(dt * (uint32_t)(w - 1) / span_s); 92 } else { 93 px = x + (inwin == 1 ? 0 : (idx * (w - 1)) / (inwin - 1)); 94 } 95 int py = yspan == 0 ? (y + h - 1) 96 : (y + (h - 1) - (int)(((uint32_t)(v[i].points - mn) * (h - 1)) / yspan)); 97 if (first || v[i].seg_start) u8g2.drawPixel(px, py); // segment break 98 else u8g2.drawLine(prevx, prevy, px, py); 99 prevx = px; prevy = py; first = false; idx++; 100 } 101 } 102 103 // I2C bus recovery — un-stick a wedged bus before init. If the ESP32 was reset 104 // via EN mid-transaction (a serial-monitor/OTA reset, or the physical RST button: 105 // EN resets the MCU but NOT the OLED, which stays powered on 3V3), the SSD1309 can 106 // be left holding SDA low. The hardware-I2C driver then returns ESP_ERR_INVALID_STATE 107 // on every write and the panel stays dark until a full power-cycle. Clock SCL up to 108 // 9 times to let the slave finish its hung byte + ACK and release SDA, then issue a 109 // STOP. Bit-banged before u8g2.begin() claims the pins. No-op on a healthy bus. 110 static void i2c_bus_recover() { 111 pinMode(OLED_SDA_PIN, INPUT_PULLUP); 112 pinMode(OLED_SCL_PIN, OUTPUT); 113 for (int i = 0; i < 9 && digitalRead(OLED_SDA_PIN) == LOW; i++) { 114 digitalWrite(OLED_SCL_PIN, HIGH); delayMicroseconds(5); // ~100kHz, matches the driver 115 digitalWrite(OLED_SCL_PIN, LOW); delayMicroseconds(5); 116 } 117 // STOP: SDA rises while SCL is high. 118 pinMode(OLED_SDA_PIN, OUTPUT); 119 digitalWrite(OLED_SDA_PIN, LOW); delayMicroseconds(5); 120 digitalWrite(OLED_SCL_PIN, HIGH); delayMicroseconds(5); 121 digitalWrite(OLED_SDA_PIN, HIGH); delayMicroseconds(5); 122 // Release both lines; u8g2.begin() re-inits the hardware I2C peripheral on these pins. 123 pinMode(OLED_SDA_PIN, INPUT_PULLUP); 124 pinMode(OLED_SCL_PIN, INPUT_PULLUP); 125 } 126 127 void display_init() { 128 i2c_bus_recover(); // un-wedge a bus left stuck by an EN-only reset before init 129 u8g2.begin(); 130 u8g2.setFont(ROW_FONT); 131 } 132 133 // Deep-sleep companion: SSD1309 display-off (sleep mode). Blanking the buffer would NOT 134 // do — an all-black frame still runs the panel's charge pump and draws nearly full 135 // current. setPowerSave(1) is the register write that actually stops it. 136 void display_sleep() { 137 u8g2.setPowerSave(1); 138 } 139 140 // Level index → SSD1309 contrast register. u8g2's begin() already programs 141 // OLED_BRIGHT_MED as part of the SSD1309 init sequence, so an unset/invalid level 142 // lands on the panel's pre-existing look rather than a surprise. 143 void display_set_brightness(uint8_t level) { 144 static const uint8_t kContrast[SCREEN_BRIGHT_LEVELS] = 145 { OLED_BRIGHT_LOW, OLED_BRIGHT_MED, OLED_BRIGHT_HIGH }; 146 if (level >= SCREEN_BRIGHT_LEVELS) level = SCREEN_BRIGHT_DEFAULT; 147 u8g2.setContrast(kContrast[level]); 148 } 149 150 void display_boot(const char* line) { 151 u8g2.clearBuffer(); 152 u8g2.setFont(ROW_FONT); 153 u8g2.drawStr(0, 12, "friend-finder"); 154 u8g2.drawStr(0, 28, line); 155 u8g2.sendBuffer(); 156 } 157 158 // Heat + LiPo-safety disclaimer, shown once at boot (after the splash). A static text 159 // card; blocking — it holds itself on screen for DISCLAIMER_HOLD_MS × BOOT_TIMING_SCALE 160 // (boot only) then returns. Header in the 6x10 row font; the body uses the smaller 5x7 161 // font (25 chars/line vs 21) so the full note fits the 128px width. ASCII-only. 162 void display_disclaimer() { 163 u8g2.clearBuffer(); 164 u8g2.setFont(ROW_FONT); 165 u8g2.drawStr(0, 8, "// WARNING"); 166 u8g2.drawHLine(0, 11, 128); 167 u8g2.setFont(u8g2_font_5x7_tf); // body: 5px/char → 25 fit vs 21 168 u8g2.drawStr(0, 22, "This badge runs on a LIPO"); 169 u8g2.drawStr(0, 32, "battery. We're in Vegas."); 170 u8g2.drawStr(0, 42, "Keep an eye on how hot"); 171 u8g2.drawStr(0, 52, "it gets. Spicy pillow?"); 172 u8g2.drawStr(0, 62, "Discontinue use."); 173 u8g2.sendBuffer(); 174 u8g2.setFont(ROW_FONT); // restore the default for later screens 175 delay((uint32_t)DISCLAIMER_HOLD_MS * BOOT_TIMING_SCALE); 176 } 177 178 // ─── Boot splash animations ────────────────────────────────────────────────── 179 // One of the BOOT_SPLASH_COUNT styles plays once at power-on (BOOT_SPLASH_* in 180 // config.h). All are blocking — they run in setup() before any mode screen. 181 182 // Final resting frame shared by radar + glitch: wordmark + signal-bar flourish. 183 static void splash_wordmark() { 184 u8g2.clearBuffer(); 185 u8g2.setFont(u8g2_font_7x13B_tf); 186 const char* t = "FRIEND-FINDER"; 187 u8g2.drawStr((128 - u8g2.getStrWidth(t)) / 2, 24, t); 188 const int bx = 40, by = 46; // rising bars, centered 189 for (int i = 0; i < 8; i++) { int h = 2 + i; u8g2.drawBox(bx + i * 6, by - h, 4, h); } 190 u8g2.setFont(ROW_FONT); 191 char v[ROW_CHARS + 1]; 192 snprintf(v, sizeof(v), "%s ch%d", FW_BUILD_STR, ESPNOW_CHANNEL); // version + rev + channel 193 u8g2.drawStr((128 - u8g2.getStrWidth(v)) / 2, 62, v); 194 u8g2.sendBuffer(); 195 delay(500 * BOOT_TIMING_SCALE); 196 } 197 198 // Style 0 — sonar sweep around a scope; crew "blips" ping in as the sweep passes 199 // them, then it resolves to the wordmark. Mirrors what the badge does. 200 static void splash_radar() { 201 const int cx = 64, cy = 31, R = 30; 202 const float D2R = 0.017453293f; 203 struct { int deg, r; } blip[] = { {55, 18}, {140, 27}, {235, 12}, {310, 23} }; 204 const int NB = 4; 205 bool found[NB] = { false, false, false, false }; 206 for (int s = 0; s <= 540; s += 12) { // 1.5 sweeps 207 float a = (s - 90) * D2R; 208 u8g2.clearBuffer(); 209 u8g2.drawCircle(cx, cy, R); 210 u8g2.drawCircle(cx, cy, R * 2 / 3); 211 u8g2.drawPixel(cx, cy); 212 u8g2.drawLine(cx, cy, cx + (int)(R * cosf(a)), cy + (int)(R * sinf(a))); 213 for (int i = 0; i < NB; i++) { 214 if (s >= blip[i].deg) found[i] = true; // discovered as the sweep crosses it 215 if (found[i]) { 216 float ba = (blip[i].deg - 90) * D2R; 217 u8g2.drawDisc(cx + (int)(blip[i].r * cosf(ba)), cy + (int)(blip[i].r * sinf(ba)), 2); 218 } 219 } 220 u8g2.sendBuffer(); 221 delay(22 * BOOT_TIMING_SCALE); 222 } 223 splash_wordmark(); 224 } 225 226 // Style 1 — POST-style typewriter self-test, revealed line by line, char by char. 227 static void splash_terminal() { 228 char l0[32]; 229 snprintf(l0, sizeof(l0), "./friend-finder_%d_%d.sh", // banner = "run" the versioned binary 230 FW_VERSION_MAJOR, FW_VERSION_MINOR); 231 char lb[32]; 232 snprintf(lb, sizeof(lb), "> beacon ... ch %d", ESPNOW_CHANNEL); // read the define, never hardcode it: 233 // the POST used to claim "ch 1" no 234 // matter what the radio actually did 235 const char* L[] = { 236 l0, "> radio ..... OK", "> crypto ..... OK", 237 "> fs ..... OK", "> clock .... SET", lb, 238 "badge online", 239 }; 240 const int NL = 7; 241 u8g2.setFont(u8g2_font_5x8_tf); 242 char shown[8][24]; 243 for (int i = 0; i < NL; i++) shown[i][0] = '\0'; 244 for (int i = 0; i < NL; i++) { 245 int len = (int)strlen(L[i]); 246 for (int c = 1; c <= len; c++) { 247 strncpy(shown[i], L[i], c); shown[i][c] = '\0'; 248 u8g2.clearBuffer(); 249 for (int j = 0; j <= i; j++) u8g2.drawStr(2, 9 + j * 8, shown[j]); 250 u8g2.drawBox(2 + c * 5, 9 + i * 8 - 7, 4, 8); // blinking-ish cursor block 251 u8g2.sendBuffer(); 252 delay(7 * BOOT_TIMING_SCALE); 253 } 254 } 255 delay(350 * BOOT_TIMING_SCALE); 256 } 257 258 // Style 2 — wordmark assembles out of scanline noise, then the bars sweep in. 259 static void splash_glitch() { 260 randomSeed(micros()); 261 const char* t1 = "FRIEND"; 262 const char* t2 = "FINDER"; 263 u8g2.setFont(u8g2_font_8x13B_tf); 264 for (int f = 0; f < 22; f++) { 265 u8g2.clearBuffer(); 266 int jx = (f < 16) ? (int)random(-3, 4) : 0; // jitter settles late 267 u8g2.drawStr((128 - u8g2.getStrWidth(t1)) / 2 + jx, 24, t1); 268 u8g2.drawStr((128 - u8g2.getStrWidth(t2)) / 2 - jx, 40, t2); 269 int noise = 120 - f * 6; if (noise < 0) noise = 0; // thinning static 270 for (int i = 0; i < noise; i++) u8g2.drawPixel(random(0, 128), random(0, 64)); 271 u8g2.sendBuffer(); 272 delay(28 * BOOT_TIMING_SCALE); 273 } 274 for (int k = 1; k <= 8; k++) { // bars sweep in 275 u8g2.clearBuffer(); 276 u8g2.setFont(u8g2_font_8x13B_tf); 277 u8g2.drawStr((128 - u8g2.getStrWidth(t1)) / 2, 24, t1); 278 u8g2.drawStr((128 - u8g2.getStrWidth(t2)) / 2, 40, t2); 279 for (int i = 0; i < k; i++) { int h = 2 + i; u8g2.drawBox(40 + i * 6, 60 - h, 4, h); } 280 u8g2.sendBuffer(); 281 delay(35 * BOOT_TIMING_SCALE); 282 } 283 delay(250 * BOOT_TIMING_SCALE); 284 } 285 286 // Style 3 — glyph rain: columns of falling characters, each with a bright head and a 287 // trail that fades out behind it, resolving to the wordmark. The alphabet is just the 288 // letters of "onlyn00bs" (so 'n' and '0' fall twice as often, which suits the wordmark). 289 // 290 // The panel is 1-bit, so a real brightness ramp down the trail isn't available. Depth is 291 // faked with three tiers instead: the head is drawn INVERSE (a filled cell with the glyph 292 // knocked out of it, the brightest thing on screen), the body is plain glyphs, and the 293 // last two cells get alternate scanlines knocked out — a 50% dither that reads as dim. 294 static void splash_matrix() { 295 static const char GLYPHS[] = "onlyn00bs"; 296 const int NG = (int)sizeof(GLYPHS) - 1; 297 const int CW = 5, CH = 8; // 5x7 font in a 5x8 cell 298 const int COLS = 128 / CW; // 25 columns (125px; 3px slack at the right) 299 const int ROWS = 64 / CH; // 8 rows 300 301 // Position is 1/16th-row fixed point so columns can fall at different sub-row speeds; 302 // stepping in whole rows would make every column move in lockstep. static, not stack: 303 // this is ~250B and setup()'s stack has better uses. 304 static int16_t pos[COLS]; // head position, 1/16 row units (negative = above screen) 305 static uint8_t vel[COLS]; // fall rate in the same units per frame 306 static uint8_t len[COLS]; // trail length, cells 307 static uint8_t cell[COLS][ROWS]; // which glyph sits in each grid cell 308 309 randomSeed(micros()); 310 for (int c = 0; c < COLS; c++) { 311 pos[c] = -(int16_t)random(0, ROWS * 16 * 2); // stagger entry so they don't arrive as a wall 312 vel[c] = 3 + random(0, 8); // ~0.2–0.6 rows/frame 313 len[c] = 3 + random(0, ROWS - 2); 314 for (int r = 0; r < ROWS; r++) cell[c][r] = random(0, NG); 315 } 316 317 u8g2.setFont(u8g2_font_5x7_tf); 318 for (int f = 0; f < 46; f++) { 319 u8g2.clearBuffer(); 320 for (int c = 0; c < COLS; c++) { 321 int head = pos[c] >> 4; 322 for (int t = 0; t < len[c]; t++) { 323 int r = head - t; 324 if (r < 0 || r >= ROWS) continue; 325 int x = c * CW, ytop = r * CH; 326 char s[2] = { GLYPHS[cell[c][r]], '\0' }; 327 if (t == 0) { // head — inverse cell 328 u8g2.drawBox(x, ytop, CW, CH); 329 u8g2.setDrawColor(0); 330 u8g2.drawStr(x, ytop + 7, s); 331 u8g2.setDrawColor(1); 332 } else { 333 u8g2.drawStr(x, ytop + 7, s); 334 if (t >= len[c] - 2) { // tail — dither to half density 335 u8g2.setDrawColor(0); 336 for (int yy = ytop; yy < ytop + CH; yy += 2) u8g2.drawHLine(x, yy, CW); 337 u8g2.setDrawColor(1); 338 } 339 } 340 } 341 pos[c] += vel[c]; 342 if ((pos[c] >> 4) - len[c] >= ROWS) { // whole trail cleared the bottom → respawn 343 pos[c] = -(int16_t)random(0, ROWS * 16); 344 vel[c] = 3 + random(0, 8); 345 len[c] = 3 + random(0, ROWS - 2); 346 } 347 if (random(0, 6) == 0) // shimmer: without this the columns just 348 cell[c][random(0, ROWS)] = random(0, NG); // scroll rigidly and it reads as a texture 349 } 350 u8g2.sendBuffer(); 351 delay(22 * BOOT_TIMING_SCALE); // same frame budget as the radar sweep 352 } 353 splash_wordmark(); 354 } 355 356 void display_boot_splash(int style) { 357 switch (style) { 358 case BOOT_SPLASH_TERMINAL: splash_terminal(); break; 359 case BOOT_SPLASH_GLITCH: splash_glitch(); break; 360 case BOOT_SPLASH_MATRIX: splash_matrix(); break; 361 case BOOT_SPLASH_RADAR: 362 default: splash_radar(); break; 363 } 364 delay(1000); // hold the final splash frame 1s longer 365 u8g2.setFont(ROW_FONT); // restore default for later screens 366 } 367 368 // The boot logo card: full-screen frames, each mark centred on the whole 128×64 panel. 369 // That's the only arrangement where these marks stay legible — a denser single-frame 370 // layout was tried on glass and rejected (see config.h). Geometry comes from the W/H that 371 // logos.h carries, so re-baking at a different size needs no change here. 372 // 373 // The set is currently the wordmark alone. LOGO_EMBLEM_* and LOGO_FOOT_* are still baked 374 // into logos.h, so restoring either is one more entry in this array — the per-frame hold 375 // divides the fixed budget, so boot time won't move when you do. 376 void display_boot_logos() { 377 static const struct { const unsigned char* bits; uint8_t w, h; } card[] = { 378 { LOGO_ONLYNOOBS_BITS, LOGO_ONLYNOOBS_W, LOGO_ONLYNOOBS_H }, 379 }; 380 const uint8_t n = sizeof(card) / sizeof(card[0]); 381 for (uint8_t i = 0; i < n; i++) { 382 u8g2.clearBuffer(); 383 u8g2.drawXBMP((128 - card[i].w) / 2, (64 - card[i].h) / 2, 384 card[i].w, card[i].h, card[i].bits); 385 u8g2.sendBuffer(); 386 delay(BOOT_LOGO_TOTAL_MS / n); 387 } 388 u8g2.setFont(ROW_FONT); // restore default for later screens 389 } 390 391 void display_provisioning(const char* device_name, const char* code) { 392 char buf[32]; 393 u8g2.clearBuffer(); 394 u8g2.setFont(ROW_FONT); 395 u8g2.drawStr(0, 10, "SETUP MODE"); 396 u8g2.setFont(u8g2_font_5x8_tf); // 5px wide → the full badge-setup-<MAC> name fits one line 397 truncate(buf, device_name, 25); // 25 * 5px = 125 < 128 398 u8g2.drawStr(0, 21, buf); 399 // pairing code — big, the web app needs it to unlock provisioning 400 u8g2.setFont(ROW_FONT); 401 u8g2.drawStr(0, 41, "code:"); 402 u8g2.setFont(u8g2_font_9x18B_tf); 403 u8g2.drawStr(36, 43, code); 404 u8g2.setFont(u8g2_font_5x8_tf); 405 // The escape hatch wins this line over the old "enter code in the app". The code is 406 // already labelled above and the web app tells you what to do with it, but nothing 407 // else on the device tells you setup mode can be left — and it never times out. 408 // 25 chars exactly: 25 * 5px = 125 < 128. 409 u8g2.drawStr(0, 60, "hold BTN 5s to exit setup"); 410 u8g2.sendBuffer(); 411 } 412 413 void display_peers(const char* my_handle, const Peer* peers, int n, bool show_rssi, 414 uint8_t mult, int page) { 415 (void)my_handle; // OLED header is a static title, like the other views 416 char buf[ROW_CHARS + 1]; 417 u8g2.clearBuffer(); 418 u8g2.setFont(ROW_FONT); 419 420 // Paginate: FINDER_PAGE_ROWS closest peers per screen. Clamp defensively (main.cpp 421 // already clamps as peers leave, but never trust the caller for indexing). 422 int pages = (n + FINDER_PAGE_ROWS - 1) / FINDER_PAGE_ROWS; 423 if (pages < 1) pages = 1; 424 if (page >= pages) page = pages - 1; 425 if (page < 0) page = 0; 426 int start = page * FINDER_PAGE_ROWS; 427 int end = n < start + FINDER_PAGE_ROWS ? n : start + FINDER_PAGE_ROWS; 428 429 // header: "// finder" title + right-aligned "(N)" peer count, plus a "p/P" page tag 430 // (only when there's more than one page). The inverse multiplier chip sits left of it 431 // when a group bonus is active. (Column mode lives in User Settings → Finder View.) 432 u8g2.drawStr(0, 8, "// finder"); 433 if (pages > 1) snprintf(buf, sizeof(buf), "(%d) %d/%d", n, page + 1, pages); 434 else snprintf(buf, sizeof(buf), "(%d)", n); 435 int rw = u8g2.getStrWidth(buf); 436 u8g2.drawStr(128 - rw, 8, buf); 437 draw_mult_chip(128 - rw - 3, mult); 438 u8g2.drawHLine(0, 11, 128); 439 440 if (n == 0) { 441 u8g2.drawStr(0, 30, "nobody in range"); 442 u8g2.sendBuffer(); 443 return; 444 } 445 446 for (int i = start; i < end; i++) { // this page's slice (≤ FINDER_PAGE_ROWS rows) 447 int y = 23 + (i - start) * 10; // baselines: 23,33,43,53,63 448 draw_bars(0, y, peers[i].rssi); 449 // right column toggles between the peer's pet face and signed dBm; the bars 450 // always show signal, so the column is free to carry the pet by default. 451 char rcol[PET_FACE_MAX + 1]; 452 if (show_rssi) snprintf(rcol, sizeof(rcol), "%d", peers[i].rssi); 453 else truncate(rcol, peers[i].pet_face, 8); 454 int rcol_w = u8g2.getStrWidth(rcol); 455 u8g2.drawStr(128 - rcol_w, y, rcol); 456 // handle fills whatever's left between the bars and the right column 457 int maxch = (128 - rcol_w - 2 - 16) / 6; 458 if (maxch > ROW_CHARS) maxch = ROW_CHARS; 459 if (maxch < 0) maxch = 0; 460 truncate(buf, peers[i].handle, maxch); 461 u8g2.drawStr(16, y, buf); 462 } 463 u8g2.sendBuffer(); 464 } 465 466 static void fmt_dur(uint32_t s, char* out, size_t n) { 467 uint32_t h = s / 3600, m = (s % 3600) / 60; 468 if (h) snprintf(out, n, "%uh%02um", (unsigned)h, (unsigned)m); 469 else snprintf(out, n, "%um", (unsigned)m); 470 } 471 472 void display_report(const char* my_handle, const Report& r) { 473 char buf[ROW_CHARS + 1], dur[16]; 474 (void)my_handle; 475 u8g2.clearBuffer(); 476 u8g2.setFont(ROW_FONT); 477 478 u8g2.drawStr(0, 8, "// con report"); 479 if (!r.time_synced) { // relative times → nudge a clock re-sync 480 const char* tag = "resync"; 481 u8g2.drawStr(128 - u8g2.getStrWidth(tag), 8, tag); 482 } 483 u8g2.drawHLine(0, 11, 128); 484 485 fmt_dur(r.total_seconds, dur, sizeof(dur)); 486 snprintf(buf, sizeof(buf), "%df %dx %s", r.unique, r.total_encounters, dur); 487 u8g2.drawStr(0, 23, buf); 488 489 int shown = r.n < 4 ? r.n : 4; // top 4 friends 490 for (int i = 0; i < shown; i++) { 491 int y = 35 + i * 10; // 35,45,55,(63) 492 if (y > 63) break; 493 fmt_dur(r.friends[i].total_seconds, dur, sizeof(dur)); 494 char name[15]; 495 truncate(name, r.friends[i].handle, 11); 496 snprintf(buf, sizeof(buf), "%-11s x%u %s", name, 497 (unsigned)r.friends[i].encounters, dur); 498 u8g2.drawStr(0, y, buf); 499 } 500 u8g2.sendBuffer(); 501 } 502 503 void display_points(const char* my_handle, uint32_t points, 504 uint32_t prox_seconds, int peers_now, bool earning, 505 const PointSample* hist, int hist_n, 506 uint32_t graph_span_s, const char* window_label, bool anchor_newest, 507 uint8_t mult, bool stealth) { 508 char buf[ROW_CHARS + 1]; 509 (void)my_handle; 510 u8g2.clearBuffer(); 511 u8g2.setFont(ROW_FONT); 512 513 // header: "// points" + window tag + [x2 chip] + right-aligned score. The graph 514 // owns the body now, so the score lives up here instead of as the big number. 515 u8g2.drawStr(0, 8, "// points"); 516 u8g2.drawStr(60, 8, window_label); 517 snprintf(buf, sizeof(buf), "%up", (unsigned)points); 518 int sw = u8g2.getStrWidth(buf); 519 u8g2.drawStr(128 - sw, 8, buf); 520 draw_mult_chip(128 - sw - 3, mult); 521 u8g2.drawHLine(0, 11, 128); 522 523 // points-over-time line graph fills the middle band (y 14..50) 524 if (hist_n >= 2) { 525 bool synced = (hist[hist_n - 1].epoch != 0); 526 uint32_t anchor = (anchor_newest && synced && hist[hist_n - 1].epoch > graph_span_s) 527 ? hist[hist_n - 1].epoch - graph_span_s : hist[0].epoch; 528 draw_sparkline(0, 14, 128, 37, hist, hist_n, graph_span_s, anchor, synced); 529 } else { 530 u8g2.drawStr(0, 34, "collecting graph..."); 531 } 532 533 // status line: live countdown when earning, else why we're paused. With a 534 // group bonus the tally ticks ×mult per wall second, so the wall-clock wait 535 // to the next point is the remainder ÷ mult (rounded up). 536 if (earning) { 537 uint32_t rem = 60 - prox_seconds % 60; 538 if (mult > 1) rem = (rem + mult - 1) / mult; 539 snprintf(buf, sizeof(buf), "+1 pt in %us", (unsigned)rem); 540 } 541 else if (stealth) // check first: crew can be right here and in-gate, 542 snprintf(buf, sizeof(buf), "paused stealth on"); // so "too far" would be a lie 543 else if (peers_now > 0) 544 snprintf(buf, sizeof(buf), "paused crew too far"); 545 else 546 snprintf(buf, sizeof(buf), "paused no crew"); 547 u8g2.drawStr(0, 62, buf); 548 549 u8g2.sendBuffer(); 550 } 551 552 void display_battery(const char* my_handle, uint16_t mv, uint8_t pct, bool low) { 553 char buf[ROW_CHARS + 1]; 554 (void)my_handle; 555 u8g2.clearBuffer(); 556 u8g2.setFont(ROW_FONT); 557 558 // header: "// battery LOW!" 559 u8g2.drawStr(0, 8, "// battery"); 560 if (low) { 561 const char* w = "LOW!"; 562 u8g2.drawStr(128 - u8g2.getStrWidth(w), 8, w); 563 } 564 u8g2.drawHLine(0, 11, 128); 565 566 // big percentage, centered (digit font for the number + small "%" after it) 567 snprintf(buf, sizeof(buf), "%u", (unsigned)pct); 568 u8g2.setFont(u8g2_font_logisoso28_tn); 569 int pw = u8g2.getStrWidth(buf); 570 u8g2.setFont(ROW_FONT); 571 int sw = u8g2.getStrWidth("%"); 572 int px = (128 - (pw + 3 + sw)) / 2; // center the "XX%" block 573 u8g2.setFont(u8g2_font_logisoso28_tn); 574 u8g2.drawStr(px, 44, buf); 575 u8g2.setFont(ROW_FONT); 576 u8g2.drawStr(px + pw + 3, 44, "%"); 577 578 // voltage on the right, e.g. "3.85V" 579 snprintf(buf, sizeof(buf), "%u.%02uV", mv / 1000, (mv % 1000) / 10); 580 u8g2.drawStr(128 - u8g2.getStrWidth(buf), 30, buf); 581 582 // full-width charge bar along the bottom 583 const int bx = 0, by = 52, bw = 128, bh = 11; 584 u8g2.drawFrame(bx, by, bw, bh); 585 int fill = (int)((long)(bw - 2) * pct / 100); 586 if (fill > 0) u8g2.drawBox(bx + 1, by + 1, fill, bh - 2); 587 588 u8g2.sendBuffer(); 589 } 590 591 // "742K" / "1.2M" / "60B" — a compact size for the 128px-wide readout. 592 static void fmt_bytes(size_t b, char* out, size_t n) { 593 if (b >= 1024UL * 1024UL) snprintf(out, n, "%u.%uM", (unsigned)(b >> 20), 594 (unsigned)((((b >> 10) & 1023) * 10) >> 10)); 595 else if (b >= 1024UL) snprintf(out, n, "%uK", (unsigned)(b >> 10)); 596 else snprintf(out, n, "%uB", (unsigned)b); 597 } 598 599 // Deliberately the same furniture as display_battery (header + big centred %, a figure on 600 // the right, a full-width bar) — it's the same view under a single tap, so it should read 601 // as one. It does invert the battery's polarity, though: a disk bar fills as the disk 602 // fills, per convention, so the number and the bar both report USED while the battery face 603 // reports what's left. Free space is still carried by the "871K free" label on the right. 604 void display_storage(const char* my_handle, size_t used, size_t total, bool full) { 605 char buf[ROW_CHARS + 1], sz[12]; 606 (void)my_handle; 607 size_t freeb = (total > used) ? total - used : 0; 608 // Drives BOTH the big number and the bar, so they can never disagree. 609 uint8_t pct = total ? (uint8_t)((uint64_t)used * 100 / total) : 0; // % USED 610 611 u8g2.clearBuffer(); 612 u8g2.setFont(ROW_FONT); 613 614 // header: "// storage FULL!" 615 u8g2.drawStr(0, 8, "// storage"); 616 if (full) { 617 const char* w = "FULL!"; 618 u8g2.drawStr(128 - u8g2.getStrWidth(w), 8, w); 619 } 620 u8g2.drawHLine(0, 11, 128); 621 622 // big used percentage, centered (digit font for the number + small "%" after it) 623 snprintf(buf, sizeof(buf), "%u", (unsigned)pct); 624 u8g2.setFont(u8g2_font_logisoso28_tn); 625 int pw = u8g2.getStrWidth(buf); 626 u8g2.setFont(ROW_FONT); 627 int sw = u8g2.getStrWidth("%"); 628 int px = (128 - (pw + 3 + sw)) / 2; // center the "XX%" block 629 u8g2.setFont(u8g2_font_logisoso28_tn); 630 u8g2.drawStr(px, 44, buf); 631 u8g2.setFont(ROW_FONT); 632 u8g2.drawStr(px + pw + 3, 44, "%"); 633 634 // free space on the right, e.g. "742K free" — the remaining-space figure the % no longer carries 635 fmt_bytes(freeb, sz, sizeof(sz)); 636 snprintf(buf, sizeof(buf), "%s free", sz); 637 u8g2.drawStr(128 - u8g2.getStrWidth(buf), 30, buf); 638 639 // full-width usage bar along the bottom — fills as the disk fills 640 const int bx = 0, by = 52, bw = 128, bh = 11; 641 u8g2.drawFrame(bx, by, bw, bh); 642 int fill = (int)((long)(bw - 2) * pct / 100); 643 if (fill > 0) u8g2.drawBox(bx + 1, by + 1, fill, bh - 2); 644 645 u8g2.sendBuffer(); 646 } 647 648 void display_friends(const char* my_handle, const PointFriend* top, int n, 649 uint32_t total_points) { 650 char buf[ROW_CHARS + 1], name[16]; 651 (void)my_handle; 652 u8g2.clearBuffer(); 653 u8g2.setFont(ROW_FONT); 654 655 u8g2.drawStr(0, 8, "// best friends"); 656 u8g2.drawHLine(0, 11, 128); 657 658 if (n == 0) { 659 u8g2.drawStr(0, 30, "no points yet"); 660 u8g2.sendBuffer(); 661 return; 662 } 663 664 int shown = n < 3 ? n : 3; // podium: top 3 665 for (int i = 0; i < shown; i++) { 666 int y = 24 + i * 12; // baselines 24,36,48 667 truncate(name, top[i].handle, 14); 668 snprintf(buf, sizeof(buf), "%d %s", i + 1, name); 669 u8g2.drawStr(0, y, buf); 670 snprintf(buf, sizeof(buf), "%u", (unsigned)(top[i].seconds / 60)); 671 u8g2.drawStr(128 - u8g2.getStrWidth(buf), y, buf); // points, right-aligned 672 } 673 674 snprintf(buf, sizeof(buf), "of %u pts total", (unsigned)total_points); 675 u8g2.drawStr(0, 62, buf); 676 u8g2.sendBuffer(); 677 } 678 679 void display_pet(const char* name, const char* face) { 680 char buf[ROW_CHARS + 1]; 681 u8g2.clearBuffer(); 682 u8g2.setFont(ROW_FONT); 683 snprintf(buf, sizeof(buf), "// %s", name); 684 u8g2.drawStr(0, 8, buf); 685 u8g2.drawHLine(0, 11, 128); 686 // big ASCII face, centered in the body 687 u8g2.setFont(u8g2_font_9x18B_tf); 688 u8g2.drawStr((128 - u8g2.getStrWidth(face)) / 2, 40, face); 689 // "tap to swap" hint (points/peers readout removed) 690 u8g2.setFont(ROW_FONT); 691 u8g2.drawStr(128 - u8g2.getStrWidth("tap:swap"), 62, "tap:swap"); 692 u8g2.sendBuffer(); 693 } 694 695 // Tiny battery glyph (11×7 body + 2px terminal nub); inner fill ∝ pct. Top-left at (x,y). 696 static void draw_batt_glyph(int x, int y, uint8_t pct) { 697 u8g2.drawFrame(x, y, 11, 7); // body outline 698 u8g2.drawBox(x + 11, y + 2, 2, 3); // + terminal nub on the right 699 uint8_t fw = (uint8_t)((pct * 9 + 50) / 100); // inner fill width 0..9 (rounded) 700 if (fw) u8g2.drawBox(x + 1, y + 1, fw, 5); // fill from the left 701 } 702 703 // Tiny floppy-disk glyph — 9×7 static, matching the battery glyph's height. Shutter on top, 704 // label below, both permanent: 705 // ######### 706 // #..###..# shutter 707 // #..###..# 708 // #.......# 709 // #.#####.# label 710 // #.#####.# 711 // ######### 712 // Deliberately NOT a fill gauge. A proportional fill failed at both ends: at a realistic 3% 713 // used it left the label blank — an empty box with a slot, unreadable as a disk — and near 714 // full it swallowed the icon into a blob. There are only ~35 interior pixels here, not enough 715 // to be an icon AND a gauge, and the adjacent "%" text already carries the number. So the 716 // glyph's one job is to say "disk". Hand-drawn rather than Open Iconic: the smallest iconic 717 // size (1x) is 8×8 and overflows this 7px band. Top-left at (x,y). 718 static void draw_disk_glyph(int x, int y) { 719 u8g2.drawFrame(x, y, 9, 7); // disk body 720 u8g2.drawBox(x + 3, y + 1, 3, 2); // shutter, top-centre 721 u8g2.drawBox(x + 2, y + 4, 5, 2); // label 722 } 723 724 void display_home(const char* handle, const char* face, uint32_t points, 725 uint8_t batt_pct, bool batt_low, uint8_t disk_pct, const char* clock_str, 726 bool radio_on) { 727 char buf[ROW_CHARS + 1], name[20]; 728 u8g2.clearBuffer(); 729 u8g2.setFont(ROW_FONT); 730 731 // header: "// home" (left), then disk glyph + % and battery glyph + % (right). Clock retired. 732 (void)clock_str; // wall-clock display being retired 733 u8g2.drawStr(0, 8, "// home"); 734 // battery: glyph + %, right-aligned. When low: a steady "!" after the % + a slow glyph blink. 735 snprintf(buf, sizeof(buf), "%u%%%s", (unsigned)batt_pct, batt_low ? "!" : ""); 736 int pw = u8g2.getStrWidth(buf); 737 u8g2.drawStr(128 - pw, 8, buf); // percentage (+ "!" when low) at the far right 738 int batt_x = 128 - pw - 15; // 13px glyph + 2px gap. Computed even while the 739 // glyph is blinked off, so storage never shifts. 740 if (!batt_low || (millis() / BATT_LOW_BLINK_MS) % 2 == 0) 741 draw_batt_glyph(batt_x, 2, batt_pct); // blinks while low 742 // storage: glyph + % used, parked left of the battery block. Worst realistic case (both 743 // reading 100%) still clears the "// home" title by ~8px. 744 snprintf(buf, sizeof(buf), "%u%%", (unsigned)disk_pct); 745 int dw = u8g2.getStrWidth(buf); 746 u8g2.drawStr(batt_x - 4 - dw, 8, buf); // 4px gap between the two blocks 747 draw_disk_glyph(batt_x - 4 - dw - 11, 2); // 9px glyph + 2px gap; the % text is the readout 748 u8g2.drawHLine(0, 11, 128); 749 750 // username — prominent, centered 751 truncate(name, handle, 18); 752 u8g2.setFont(u8g2_font_7x13B_tf); 753 u8g2.drawStr((128 - u8g2.getStrWidth(name)) / 2, 28, name); 754 755 // pet face (left) + total points (right), sharing the middle band 756 u8g2.setFont(u8g2_font_9x18B_tf); 757 u8g2.drawStr(4, 48, face); 758 u8g2.setFont(ROW_FONT); 759 snprintf(buf, sizeof(buf), "%u pts", (unsigned)points); 760 u8g2.drawStr(128 - u8g2.getStrWidth(buf), 46, buf); 761 762 // Stealth tell, in the free band under the face/points row. Inverse so it can't be 763 // mistaken for a stat, and named for the mode rather than the failure so it reads as 764 // deliberate. The header is already full (title + disk + battery) and its worst case 765 // leaves ~6px, so this lives down here where nothing can collide with it. 766 if (!radio_on) { 767 const char* lbl = "STEALTH"; 768 int tw = u8g2.getStrWidth(lbl); 769 int bw = tw + 6; // 3px pad each side 770 u8g2.drawRBox((128 - bw) / 2, 53, bw, 11, 1); 771 u8g2.setDrawColor(0); // cut the label out of the box 772 u8g2.drawStr((128 - tw) / 2, 62, lbl); 773 u8g2.setDrawColor(1); 774 } 775 776 u8g2.sendBuffer(); 777 } 778 779 // Factory-reset confirmation. Body in the 5x7 font (25 chars/line vs 21) so the list of 780 // what actually gets destroyed fits without abbreviating it into uselessness. 781 void display_reset_confirm(int cursor) { 782 u8g2.clearBuffer(); 783 u8g2.setFont(ROW_FONT); 784 u8g2.drawStr(0, 8, "// factory reset"); 785 u8g2.drawHLine(0, 11, 128); 786 u8g2.setFont(u8g2_font_5x7_tf); 787 u8g2.drawStr(0, 21, "Erases your handle,"); 788 u8g2.drawStr(0, 30, "avatar, points, log"); 789 u8g2.drawStr(0, 39, "and settings. Undo: no."); 790 u8g2.setFont(ROW_FONT); 791 static const char* opt[2] = { "Cancel", "ERASE" }; // Cancel first = the default row 792 for (int i = 0; i < 2; i++) { 793 int y = 50 + i * 11; // 50 → 43..52, 61 → 54..63; fits 64px 794 if (i == cursor) u8g2.drawStr(2, y, ">"); 795 u8g2.drawStr(14, y, opt[i]); 796 } 797 u8g2.sendBuffer(); 798 } 799 800 void display_menu(int splash_sel) { 801 static const char* names[BOOT_SPLASH_COUNT] = { "radar", "terminal", "glitch", "matrix" }; 802 u8g2.clearBuffer(); 803 u8g2.setFont(ROW_FONT); 804 u8g2.drawStr(0, 8, "// boot splash"); 805 u8g2.drawHLine(0, 11, 128); 806 // 22/10 rather than the old 24/11: with a 4th style the last row's baseline landed at 57 807 // (glyphs 50..59) and collided with the "tap: change" hint at 62 (55..64). This clears it. 808 for (int i = 0; i < BOOT_SPLASH_COUNT; i++) { 809 int y = 22 + i * 10; 810 if (i == splash_sel) u8g2.drawStr(2, y, ">"); 811 u8g2.drawStr(14, y, names[i]); 812 } 813 u8g2.drawStr(0, 62, "tap: change"); 814 u8g2.sendBuffer(); 815 } 816 817 void display_whoami(const char* handle, const char* mac) { 818 char buf[ROW_CHARS + 1], line[ROW_CHARS + 1]; 819 u8g2.clearBuffer(); 820 u8g2.setFont(ROW_FONT); 821 u8g2.drawStr(0, 10, "// whoami"); 822 u8g2.drawHLine(0, 13, 128); 823 truncate(buf, (handle && handle[0]) ? handle : "(unset)", ROW_CHARS - 6); 824 snprintf(line, sizeof(line), "user: %s", buf); 825 u8g2.drawStr(0, 30, line); 826 u8g2.drawStr(0, 46, "mac:"); 827 u8g2.drawStr(0, 61, mac); // "14:33:5C:xx:xx:xx" — 17 chars fits one line 828 u8g2.sendBuffer(); 829 } 830 831 void display_pet_popup(const char* face, const char* line1, const char* line2, uint16_t hold_ms) { 832 u8g2.clearBuffer(); 833 u8g2.setFont(u8g2_font_9x18B_tf); 834 u8g2.drawStr((128 - u8g2.getStrWidth(face)) / 2, line2 ? 30 : 36, face); // lift the face for 2 lines 835 u8g2.setFont(ROW_FONT); 836 u8g2.drawStr((128 - u8g2.getStrWidth(line1)) / 2, line2 ? 44 : 52, line1); 837 if (line2) u8g2.drawStr((128 - u8g2.getStrWidth(line2)) / 2, 54, line2); 838 u8g2.sendBuffer(); 839 delay(hold_ms); 840 u8g2.setFont(ROW_FONT); 841 } 842 843 // Same face + 1–2 lines as display_pet_popup, but NON-BLOCKING (no delay). The caller 844 // keeps it on screen and dismisses it (button / timeout) by redrawing the live view. 845 void display_toast(const char* face, const char* line1, const char* line2) { 846 u8g2.clearBuffer(); 847 u8g2.setFont(u8g2_font_9x18B_tf); 848 u8g2.drawStr((128 - u8g2.getStrWidth(face)) / 2, line2 ? 30 : 36, face); 849 u8g2.setFont(ROW_FONT); 850 u8g2.drawStr((128 - u8g2.getStrWidth(line1)) / 2, line2 ? 44 : 52, line1); 851 if (line2) u8g2.drawStr((128 - u8g2.getStrWidth(line2)) / 2, 54, line2); 852 u8g2.sendBuffer(); // no delay — caller owns the frame's lifetime 853 } 854 855 void display_options_menu(int screen, int cursor, const OptView& v) { 856 char r1[ROW_CHARS + 1], r2[ROW_CHARS + 1]; 857 const char* title; 858 const char* rows[5]; // Back first = the default row, every screen 859 int nrows; 860 if (screen == OPTM_USER) { 861 title = "// user settings"; 862 // "Avatar: " is the same 8 chars as the old "Pet: ", so it keeps the value column 863 // aligned with Splash:/Bright:/Debug:/Global:/Shower:. At 8 + an 11-char name it is 864 // exactly 19 chars = 114px at x=14 — the full width, with nothing to spare. 865 snprintf(r1, sizeof(r1), "Avatar: %s", v.pet); 866 snprintf(r2, sizeof(r2), "Finder View: %s", v.finder_mode); 867 rows[0] = "Back"; rows[1] = r1; rows[2] = r2; rows[3] = "Reminders"; nrows = 4; 868 } else if (screen == OPTM_DISPLAY) { 869 title = "// display"; 870 snprintf(r1, sizeof(r1), "Splash: %s", v.splash); 871 snprintf(r2, sizeof(r2), "Bright: %s", v.bright); 872 rows[0] = "Back"; rows[1] = r1; rows[2] = r2; nrows = 3; 873 } else if (screen == OPTM_DEVOPTS) { 874 title = "// developer options"; 875 snprintf(r1, sizeof(r1), "Debug: %s", v.test_state); 876 rows[0] = "Back"; rows[1] = "Whoami"; rows[2] = r1; 877 rows[3] = "Provisioning Mode"; rows[4] = "Factory Reset"; nrows = 5; // 5 = the rows[] cap 878 } else if (screen == OPTM_REMIND) { 879 title = "// reminders"; 880 snprintf(r1, sizeof(r1), "Global: %s", v.remind_global); 881 snprintf(r2, sizeof(r2), "Shower: %s", v.remind_shower); 882 rows[0] = "Back"; rows[1] = r1; rows[2] = r2; nrows = 3; 883 } else if (screen == OPTM_BADGE) { 884 title = "// badge settings"; 885 snprintf(r1, sizeof(r1), "Stealth: %s", v.stealth); 886 rows[0] = "Back"; rows[1] = "Display"; rows[2] = r1; 887 rows[3] = "Developer Options"; rows[4] = "Power"; nrows = 5; // 5 = the rows[] cap 888 } else if (screen == OPTM_POWER) { 889 title = "// power"; 890 rows[0] = "Back"; rows[1] = "Reboot"; rows[2] = "Deep sleep"; nrows = 3; 891 } else { // OPTM_ROOT 892 title = "// options"; 893 rows[0] = "Back"; rows[1] = "User Settings"; rows[2] = "Badge Settings"; nrows = 3; 894 } 895 u8g2.clearBuffer(); 896 u8g2.setFont(ROW_FONT); 897 u8g2.drawStr(0, 8, title); 898 u8g2.drawHLine(0, 11, 128); 899 int y0 = (nrows >= 5) ? 21 : (nrows == 4) ? 22 : 24; // tighten as rows grow 900 int dy = (nrows >= 5) ? 10 : (nrows == 4) ? 12 : 14; 901 for (int i = 0; i < nrows; i++) { 902 int y = y0 + i * dy; 903 if (i == cursor) u8g2.drawStr(2, y, ">"); 904 u8g2.drawStr(14, y, rows[i]); 905 } 906 u8g2.sendBuffer(); 907 } 908 909 #endif // BADGE_DISPLAY_OLED