#include <Arduino.h>
#include <Wire.h>
#include <U8g2lib.h>

/* ============================================================
   HANDHELD RADAR (ESP32 + HLK-LD2451 + OLED SSD1306)

   WHAT THIS PROGRAM DOES (high level):
   1) Initializes OLED display (I2C) and shows a short "Matrix" boot animation.
   2) Initializes UART on ESP32 and configures the HLK-LD2451 radar module.
   3) Continuously reads binary frames coming from the radar.
   4) Parses frames using a simple state machine (header -> length -> payload -> tail).
   5) Decodes target parameters from the payload (speed, distance, angle, direction).
   6) Applies sanity checks + (optional) anti-glitch filtering.
   7) Displays the last stable measurement on OLED.
   8) Optionally prints decoded frames + HEX payload to Serial Monitor.

   IMPORTANT:
   - The code below is intentionally "stable" and tested.
   - To keep behavior unchanged, ONLY comments were added/expanded.

   ============================================================
   QUICK SETTINGS YOU CAN CHANGE (BEGINNER FRIENDLY)

   A) WIRING (pins) — if you use different ESP32 pins:
      - I2C_SDA / I2C_SCL
      - RADAR_RX / RADAR_TX

   B) RADAR BEHAVIOR:
      - RADAR_MAX_DIST        : max detection distance (meters, 0..255)
      - RADAR_DIR             : direction mode (both / only approach / only away)
      - RADAR_MIN_SPEED       : ignore slower targets (km/h)
      - RADAR_NO_TARGET_DELAY : how long radar waits before "no target" (seconds)
      - RADAR_TRIG_COUNT      : trigger count (radar sensitivity behavior)
      - RADAR_SNR_LEVEL       : signal-to-noise threshold (higher = less noise)

   C) DISPLAY BEHAVIOR:
      - OLED_REFRESH_MS : OLED redraw interval (ms)
      - TARGET_HOLD_MS  : keep last value visible after target disappears (ms)

   D) FILTERING / STABILITY:
      - REQUIRED_HITS   : number of similar frames required to accept target
                          1 = show every measurement (fastest)
                          2..N = smoother display, less flicker, more delay

   E) DEBUG OUTPUT (Serial Monitor):
      - DEBUG_FRAMES           : 1=print frames, 0=quiet
      - DEBUG_ONLY_WHEN_TARGET : 1=print only if target present
      - DEBUG_HEX_LIMIT        : limit number of bytes printed per frame

   ============================================================ */

/* ==============================
   OLED DISPLAY (SSD1306, 128x64)
   ==============================
   Using U8g2 library in "full buffer" mode:
   - clearBuffer() -> draw -> sendBuffer()
*/
U8G2_SSD1306_128X64_NONAME_F_HW_I2C oled(U8G2_R0, U8X8_PIN_NONE);

/* ==============================
   I2C PINS (ESP32)
   ==============================
   Default ESP32 I2C pins are often 21/22, but you can change them here.
*/
#define I2C_SDA 21
#define I2C_SCL 22

/* ==============================
   RADAR UART PINS (ESP32)
   ==============================
   HLK-LD2451 uses UART (TX/RX).
   ESP32 has multiple hardware serial ports.
   Here we use HardwareSerial(2), often mapped to UART2.
*/
#define RADAR_RX 16
#define RADAR_TX 17
#define RADAR_BAUD 115200
HardwareSerial Radar(2);

/* ============================================================
   RADAR PARAMETERS (sent to the module during configureRadar())
   ============================================================ */
const uint8_t RADAR_MAX_DIST        = 100;  // max distance in meters (0..255)
const uint8_t RADAR_DIR             = 0x02; // 0x02 = both directions (approach + away)
const uint8_t RADAR_MIN_SPEED       = 0;    // km/h (ignore slower targets if >0)
const uint8_t RADAR_NO_TARGET_DELAY = 2;    // seconds before radar reports "no target"
const uint8_t RADAR_TRIG_COUNT      = 1;    // radar trigger count (module-specific)
const uint8_t RADAR_SNR_LEVEL       = 1;    // radar SNR level (module-specific)

/* ============================================================
   OLED/UI TIMING
   ============================================================
   OLED_REFRESH_MS:
     - How often we redraw the screen.
     - Lower = smoother but more CPU usage.
     - Higher = less CPU but slower updates.

   TARGET_HOLD_MS:
     - When target disappears for a moment, we keep last reading on screen.
     - Prevents blinking/zeroing if target is intermittent.
*/
static const uint32_t OLED_REFRESH_MS = 80;
static const uint32_t TARGET_HOLD_MS  = 800;

/* ============================================================
   BOOT SCREEN: "MATRIX" STYLE (0/1 rain)
   ============================================================
   Pure visual effect. You can shorten or disable it:
   - MATRIX_BOOT_MS = 0 -> it ends immediately.
*/
static const uint16_t MATRIX_BOOT_MS = 3000;
static const uint8_t MATRIX_COL_W = 6;
static const uint8_t MATRIX_ROW_H = 8;

// random 0/1 character for Matrix animation
static inline char randBit() { return (random(0, 2) == 0) ? '0' : '1'; }

/*
  matrixBoot(durationMs):
  - draws fake "digital rain" animation on OLED for durationMs milliseconds
  - does NOT affect radar logic; it's just startup visuals
*/
static void matrixBoot(uint16_t durationMs = MATRIX_BOOT_MS) {
  // number of columns/rows of characters on 128x64 with given char spacing
  const uint8_t cols = 128 / MATRIX_COL_W;
  const uint8_t rows = 64  / MATRIX_ROW_H;

  // arrays define each column "rain drop" head position, speed, tail length, and gap randomness
  int8_t  head[32];
  uint8_t speed[32];
  uint8_t tail[32];
  uint8_t gapChance[32];

  // initialize the rain columns
  for (uint8_t c = 0; c < cols; c++) {
    head[c]      = random(-rows, 0);  // start above screen
    speed[c]     = random(1, 4);      // falling speed
    tail[c]      = random(5, 14);     // tail length
    gapChance[c] = random(8, 18);     // probability of "missing" characters
  }

  oled.setFont(u8g2_font_5x7_tf);

  uint32_t t0 = millis();
  uint32_t last = 0;

  while (millis() - t0 < durationMs) {
    uint32_t now = millis();
    // cap animation fps (roughly 25fps)
    if (now - last < 40) continue;
    last = now;

    oled.clearBuffer();

    for (uint8_t c = 0; c < cols; c++) {
      int8_t h = head[c];

      // draw tail characters from tail length down to head
      for (int8_t k = (int8_t)tail[c]; k >= 0; k--) {
        int8_t r = h - k;
        if (r < 0 || r >= (int8_t)rows) continue;

        // create gaps in the rain
        if (k != 0 && (random(0, gapChance[c]) == 0)) continue;

        int x = c * MATRIX_COL_W;
        int y = (r + 1) * MATRIX_ROW_H - 1;

        char ch = randBit();

        // head of the drop: draw a filled box for contrast
        if (k == 0) {
          oled.setDrawColor(1);
          oled.drawBox(x, y - (MATRIX_ROW_H - 1), MATRIX_COL_W - 1, MATRIX_ROW_H);

          oled.setDrawColor(0);
          oled.setCursor(x, y);
          oled.print(ch);

          oled.setDrawColor(1);
        } else {
          oled.setCursor(x, y);
          oled.print(ch);
        }
      }

      // move head down based on its speed
      head[c] += speed[c];

      // if it passed the screen (and some extra), restart that column
      if (head[c] > (int8_t)(rows + tail[c] + random(0, rows))) {
        head[c]      = random(-rows, 0);
        speed[c]     = random(1, 4);
        tail[c]      = random(5, 14);
        gapChance[c] = random(8, 18);
      }
    }

    oled.sendBuffer();
    delay(1);
  }

  // clear screen after animation
  oled.clearBuffer();
  oled.sendBuffer();
}

/* ============================================================
   ACK PARSER (for radar configuration commands)
   ============================================================
   HLK-LD2451 responds to config commands with a frame starting:
     FD FC FB FA ...
   We are not decoding content; we just verify that a response arrives.

   If your Serial shows "[CFG] ACK fail", it means the module did not
   respond in time OR wiring/baud is wrong.
*/
static bool readAck(uint16_t timeoutMs = 500) {
  uint32_t t0 = millis();
  const uint8_t H[4] = {0xFD,0xFC,0xFB,0xFA};
  uint8_t stt = 0;

  while (millis() - t0 < timeoutMs) {
    while (Radar.available()) {
      uint8_t b = Radar.read();

      // simple header matching state machine
      if (b == H[stt]) {
        stt++;
        if (stt == 4) {
          // after header, radar sends payload length (2 bytes)
          while (Radar.available() < 2 && millis() - t0 < timeoutMs) delay(1);
          if (Radar.available() < 2) return false;

          uint16_t len = (uint8_t)Radar.read();
          len |= (uint16_t)((uint8_t)Radar.read()) << 8;

          // read remaining bytes: payload + tail (here treated as "len + 4")
          uint32_t need = (uint32_t)len + 4;
          uint32_t got2 = 0;
          while (got2 < need && millis() - t0 < timeoutMs) {
            if (Radar.available()) { Radar.read(); got2++; }
          }
          return (got2 == need);
        }
      } else {
        // restart header match
        stt = (b == H[0]) ? 1 : 0;
      }
    }
    delay(1);
  }
  return false;
}

/* Send a raw command packet to the radar UART */
static void sendCmd(const uint8_t* data, size_t n) {
  Radar.write(data, n);
  Radar.flush(); // ensure bytes are transmitted
}

/* ============================================================
   RADAR CONFIGURATION SEQUENCE
   ============================================================
   The radar supports a configuration mode.
   We send:
   1) enable config
   2) set detection parameters (distance, direction, min speed, no-target delay)
   3) set sensitivity parameters (trigger count, SNR level)
   4) end config

   These bytes are module-specific (HLK-LD2451 protocol).
*/
static void configureRadar() {
  // 1) Enter config mode
  const uint8_t enableCfg[] = {
    0xFD,0xFC,0xFB,0xFA,
    0x04,0x00,
    0xFF,0x00,
    0x01,0x00,
    0x04,0x03,0x02,0x01
  };
  sendCmd(enableCfg, sizeof(enableCfg));
  Serial.println("[CFG] enable config"); Serial.println(readAck() ? "[CFG] ACK ok" : "[CFG] ACK fail");

  // 2) Set detection parameters
  const uint8_t setDetect[] = {
    0xFD,0xFC,0xFB,0xFA,
    0x06,0x00,
    0x02,0x00,
    RADAR_MAX_DIST, RADAR_DIR, RADAR_MIN_SPEED, RADAR_NO_TARGET_DELAY,
    0x04,0x03,0x02,0x01
  };
  sendCmd(setDetect, sizeof(setDetect));
  Serial.println("[CFG] set detect params"); Serial.println(readAck() ? "[CFG] ACK ok" : "[CFG] ACK fail");

  // 3) Set sensitivity parameters
  const uint8_t setSens[] = {
    0xFD,0xFC,0xFB,0xFA,
    0x06,0x00,
    0x03,0x00,
    RADAR_TRIG_COUNT, RADAR_SNR_LEVEL, 0x00,0x00,
    0x04,0x03,0x02,0x01
  };
  sendCmd(setSens, sizeof(setSens));
  Serial.println("[CFG] set sensitivity"); Serial.println(readAck() ? "[CFG] ACK ok" : "[CFG] ACK fail");

  // 4) Exit config mode
  const uint8_t endCfg[] = {
    0xFD,0xFC,0xFB,0xFA,
    0x02,0x00,
    0xFE,0x00,
    0x04,0x03,0x02,0x01
  };
  sendCmd(endCfg, sizeof(endCfg));
  Serial.println("[CFG] end config"); Serial.println(readAck() ? "[CFG] ACK ok" : "[CFG] ACK fail");
}

/* ============================================================
   RADAR DATA FRAME PARSER (runtime frames, NOT config ACK)
   ============================================================
   The radar continuously streams frames that start with header:
     F4 F3 F2 F1
   Then:
     length (2 bytes, little-endian)
     payload bytes (length)
   Then tail:
     F8 F7 F6 F5

   This code uses a state machine to reconstruct each frame.
*/
static const uint16_t PAYLOAD_MAX = 256;
static uint8_t payload[PAYLOAD_MAX]; // buffer where payload bytes are stored

// Parser states: read header, read length, read payload, read tail
enum class RxState : uint8_t { H0,H1,H2,H3,L0,L1,PAY,T0,T1,T2,T3 };
static RxState st = RxState::H0;

static uint16_t frameLen = 0, got = 0; // frameLen=payload length, got=how many payload bytes collected
static uint32_t frames = 0;            // total frames received (counter for debugging/UI)

/* ============================================================
   SERIAL DEBUG SETTINGS
   ============================================================
   If you want clean Serial output, set DEBUG_FRAMES to 0.
*/
#define DEBUG_FRAMES 1
#define DEBUG_ONLY_WHEN_TARGET 0
#define DEBUG_HEX_LIMIT 160

static void printHexByte(uint8_t b) {
  if (b < 0x10) Serial.print('0');
  Serial.print(b, HEX);
}

/* Print payload bytes in HEX (useful for reverse engineering protocol) */
static void dumpFrameHex(uint16_t len, const uint8_t* p) {
  Serial.print("[FRM] len=");
  Serial.print(len);
  Serial.print(" payload: ");

  uint16_t n = (len > DEBUG_HEX_LIMIT) ? DEBUG_HEX_LIMIT : len;
  for (uint16_t i = 0; i < n; i++) {
    Serial.print("0x");
    printHexByte(p[i]);
    if (i != n - 1) Serial.print(' ');
  }
  if (n < len) Serial.print(" ...");
  Serial.println();
}

/*
  Print decoded values for quick inspection.
  NOTE: The decoding here matches your existing payload layout:
    p[0] = count
    p[1] = alarm
    p[2] = angle (offset)
    p[3] = distance
    p[4] = direction
    p[5] = speed
*/
static void dumpFrameDecoded(uint16_t len, const uint8_t* p) {
  if (len < 2) { Serial.println("[FRM] too short"); return; }

  uint8_t count = p[0];
  uint8_t alarm = p[1];

  Serial.print("[FRM] count=");
  Serial.print(count);
  Serial.print(" alarm=");
  Serial.print(alarm);

  // if at least one target exists and payload is long enough
  if (count >= 1 && len >= 7) {
    int angle = (int)p[2] - 0x80;
    int dist  = p[3];
    int dir   = p[4];
    int spd   = p[5];

    Serial.print(" | T1: A=");
    Serial.print(angle);
    Serial.print(" D=");
    Serial.print(dist);
    Serial.print("m V=");
    Serial.print(spd);
    Serial.print("km/h DIR=");
    Serial.print((dir == 0x01) ? "approach" : "away");
  }

  Serial.println();
}

/* Decide when to print debug output */
static void logFrame(uint16_t len, const uint8_t* p) {
#if DEBUG_FRAMES
  // ignore empty payload frames
  if (len == 0) return;

  // optional: print only when radar reports at least one target
  if (DEBUG_ONLY_WHEN_TARGET && len >= 1 && p[0] == 0) return;

  dumpFrameDecoded(len, p);
  dumpFrameHex(len, p);
#endif
}

/* ============================================================
   TARGET STRUCTURE (decoded values)
   ============================================================ */
struct Target {
  int angle_deg = 0;          // angle in degrees (approx), relative to radar axis
  uint8_t dist_m = 0;         // distance in meters
  bool approaching = false;   // true = approaching, false = moving away
  uint8_t speed_kmh = 0;      // speed in km/h
};

/* ============================================================
   SANITY CHECK (reject impossible values)
   ============================================================
   Helps against noise frames / corrupt data.
   You can widen these limits if needed.
*/
static const uint8_t  SPEED_MAX = 250; // km/h
static const int      ANGLE_MIN = -90; // degrees
static const int      ANGLE_MAX =  90; // degrees

static bool saneTarget(const Target& t) {
  if (t.dist_m > RADAR_MAX_DIST) return false;
  if (t.speed_kmh > SPEED_MAX) return false;
  if (t.angle_deg < ANGLE_MIN || t.angle_deg > ANGLE_MAX) return false;
  return true;
}

/* ============================================================
   ANTI-GLITCH FILTER (frame consistency)
   ============================================================
   REQUIRED_HITS controls how many similar frames must appear in a row
   before we accept them as "stable" and show them.

   - REQUIRED_HITS = 1  -> fastest response, shows every measurement
   - REQUIRED_HITS = 2+ -> smoother but adds delay
*/
static const uint8_t REQUIRED_HITS = 1;

/*
  similar(a,b):
  Defines what "similar measurements" mean.
  If radar readings jump slightly, we still treat them as same target.
*/
static bool similar(const Target& a, const Target& b) {
  return abs(a.angle_deg - b.angle_deg) <= 6 &&
         abs((int)a.dist_m - (int)b.dist_m) <= 3 &&
         abs((int)a.speed_kmh - (int)b.speed_kmh) <= 10 &&
         a.approaching == b.approaching;
}

// Candidate target used for filtering
static Target  candT;
static uint8_t candCount = 0;
static uint8_t candHits  = 0;

/* ============================================================
   PAYLOAD DECODING (common fields)
   ============================================================
   parseCommon():
   - Reads payload and extracts:
       count  : number of detected targets
       alarm  : boolean
       t1     : first target (angle, distance, dir, speed)
       hasT1  : whether target1 exists

   RETURNS:
   - true  = payload format ok
   - false = invalid/corrupt payload or failed sanity check
*/
static bool parseCommon(const uint8_t* p, uint16_t len,
                        uint8_t &count, bool &alarm, Target &t1, bool &hasT1)
{
  hasT1 = false;

  // len=0 frames exist in some cases -> treat as "no target"
  if (len == 0) { count = 0; alarm = false; return true; }

  // minimum bytes required for count + alarm
  if (len < 2) return false;

  count = p[0];
  alarm = (p[1] == 0x01);

  // if no targets -> nothing else to decode
  if (count == 0) return true;

  // require enough bytes for target1 fields
  if (len < 7) return false;

  // extract raw fields (module-specific layout)
  uint8_t a   = p[2];
  uint8_t d   = p[3];
  uint8_t dir = p[4];
  uint8_t spd = p[5];

  // convert to human-friendly values
  t1.angle_deg   = (int)a - 0x80;    // module uses 0x80 offset
  t1.dist_m      = d;
  t1.approaching = (dir == 0x01);
  t1.speed_kmh   = spd;

  // discard values that don't make sense
  if (!saneTarget(t1)) return false;

  hasT1 = true;
  return true;
}

/* ============================================================
   OLED DRAW FUNCTION
   ============================================================
   drawOLED():
   - Updates the entire screen:
       frames count, number of targets, speed, distance, angle, direction

   NOTE:
   - This function only draws what you pass in.
   - It does NOT read radar directly.
*/
static void drawOLED(uint32_t framesLocal, uint8_t count, const Target& t, bool showTarget) {
  oled.clearBuffer();

  // Top line: frames + target count
  oled.setFont(u8g2_font_6x12_tf);
  oled.setCursor(0, 12);
  oled.print("frames:");
  oled.print(framesLocal);

  oled.setCursor(78, 12);
  oled.print("targets:");
  oled.print((int)count);

  // Big speed number in the center-left
  oled.setFont(u8g2_font_logisoso32_tn);
  oled.setCursor(0, 56);
  oled.print(showTarget ? (int)t.speed_kmh : 0);

  // Right side details
  oled.setFont(u8g2_font_6x12_tf);
  oled.setCursor(78, 30);
  oled.print("km/h");

  oled.setCursor(78, 42);
  oled.print("D:");
  oled.print(showTarget ? (int)t.dist_m : 0);
  oled.print("m");

  oled.setCursor(78, 53);
  oled.print("A:");
  if (showTarget) {
    oled.print(t.angle_deg);
    oled.print((char)176); // degree symbol
  } else {
    oled.print("0");
    oled.print((char)176);
  }

  oled.setCursor(78, 62);
  if (showTarget) oled.print(t.approaching ? "approach" : "away");
  else oled.print("direction");

  oled.sendBuffer();
}

/* ============================================================
   HOLD FUNCTIONALITY
   ============================================================
   We store last stable target in lastT/lastCount.
   If target disappears briefly, we still show it for TARGET_HOLD_MS.
*/
static Target lastT;
static uint8_t lastCount = 0;
static uint32_t lastGoodMs = 0;

/* ============================================================
   SETUP()
   ============================================================
   Runs once after reset/power-on.
*/
void setup() {
  // Serial for debugging (PlatformIO Monitor)
  Serial.begin(115200);
  delay(150);

  // I2C init for OLED
  Wire.begin(I2C_SDA, I2C_SCL);

  // OLED init
  oled.begin();
  oled.clearBuffer();
  oled.sendBuffer();

  // seed RNG for Matrix animation randomness
  randomSeed(esp_random());

  // startup visual effect
  matrixBoot(MATRIX_BOOT_MS);

  // Radar UART init
  Radar.begin(RADAR_BAUD, SERIAL_8N1, RADAR_RX, RADAR_TX);
  Radar.setTimeout(50);

  // configure radar module parameters
  configureRadar();

  // small delay, then clear any leftover bytes from UART buffer
  delay(200);
  while (Radar.available()) Radar.read();

  // initialize "last good time" so UI doesn't flicker at startup
  lastGoodMs = millis();
}

/* ============================================================
   LOOP()
   ============================================================
   Runs repeatedly forever:
   - read bytes from radar UART
   - reconstruct frames
   - decode payload into target info
   - update last stable target
   - refresh OLED at fixed interval
*/
void loop() {
  // Read all available radar bytes and feed the state machine
  while (Radar.available()) {
    uint8_t b = (uint8_t)Radar.read();

    switch (st) {
      /* ---- HEADER MATCHING: expect F4 F3 F2 F1 ---- */
      case RxState::H0: st = (b==0xF4)?RxState::H1:RxState::H0; break;
      case RxState::H1: st = (b==0xF3)?RxState::H2:RxState::H0; break;
      case RxState::H2: st = (b==0xF2)?RxState::H3:RxState::H0; break;
      case RxState::H3: st = (b==0xF1)?RxState::L0:RxState::H0; break;

      /* ---- LENGTH (2 bytes, little-endian) ---- */
      case RxState::L0:
        frameLen = b;        // low byte
        st = RxState::L1;
        break;

      case RxState::L1:
        frameLen |= (uint16_t)b << 8; // high byte
        got = 0;

        // safety: if length too big -> reset parser
        if (frameLen > PAYLOAD_MAX) { st = RxState::H0; break; }

        // if payload length is 0, skip directly to tail
        st = (frameLen == 0) ? RxState::T0 : RxState::PAY;
        break;

      /* ---- PAYLOAD BYTES ---- */
      case RxState::PAY:
        payload[got++] = b;            // store payload byte
        if (got >= frameLen) st = RxState::T0; // done -> read tail
        break;

      /* ---- TAIL MATCHING: expect F8 F7 F6 F5 ---- */
      case RxState::T0: st = (b==0xF8)?RxState::T1:RxState::H0; break;
      case RxState::T1: st = (b==0xF7)?RxState::T2:RxState::H0; break;
      case RxState::T2: st = (b==0xF6)?RxState::T3:RxState::H0; break;

      case RxState::T3:
        // final tail byte
        if (b == 0xF5) {
          // frame complete!
          frames++;

          // optional debug print
          logFrame(frameLen, payload);

          // decode the payload into useful values
          uint8_t count = 0;
          bool alarm = false;
          Target t{};
          bool hasT = false;

          bool ok = parseCommon(payload, frameLen, count, alarm, t, hasT);

          /*
            Filtering logic:
            - if ok and target exists -> compare with previous candidate
            - if similar -> increase hits
            - once hits >= REQUIRED_HITS -> accept as stable and save to lastT
          */
          // REQUIRED_HITS = 1 -> immediate update
          if (ok && hasT) {
            if (candHits == 0) {
              candT = t;
              candCount = count;
              candHits = 1;
            } else {
              if (similar(candT, t)) candHits++;
              else { candT = t; candCount = count; candHits = 1; }
            }

            if (candHits >= REQUIRED_HITS) {
              // store as last stable target (used by UI)
              lastT = candT;
              lastCount = candCount;
              lastGoodMs = millis();
              candHits = 0;
            }
          }
        }

        // after tail processing, reset parser to search for next header
        st = RxState::H0;
        break;
    }
  }

  /* ==========================================================
     UI UPDATE (timed)
     ==========================================================
     - showTarget is true if last stable measurement is recent enough
     - otherwise display zeros / "no target"
  */
  uint32_t now = millis();
  bool showTarget = (now - lastGoodMs) <= TARGET_HOLD_MS;
  uint8_t uiCount = showTarget ? lastCount : 0;

  // Redraw OLED only every OLED_REFRESH_MS milliseconds (prevents flicker)
  static uint32_t lastOledMs = 0;
  if (now - lastOledMs >= OLED_REFRESH_MS) {
    lastOledMs = now;
    drawOLED(frames, uiCount, lastT, showTarget);
  }
}
