/*
 * ESP32-S3 USB Gamepad — Sip & Puff Controller
 * GC9A01 240x240 round display
 * 6× HX710B sensors
 */

#include "USB.h"
#include "USBHIDGamepad.h"
#include <WiFi.h>
#include <DNSServer.h>
#include <ESPAsyncWebServer.h>
#include <Preferences.h>
#include <Arduino_GFX_Library.h>
#include "web_ui.h"
#include "logo.h"   // 240×240 RGB565 boot logo
#include <Adafruit_NeoPixel.h>

// ── Onboard RGB LED (pin 48, 1 NeoPixel) ─────────────────────────────────────
#define LED_PIN   48
#define LED_COUNT 1
Adafruit_NeoPixel rgbLed(LED_COUNT, LED_PIN, NEO_GRB + NEO_KHZ800);

struct LedConfig {
  bool    on;
  uint8_t r, g, b;
  uint8_t brightness;  // 0-255
};
LedConfig ledCfg = { true, 0, 255, 200, 128 };  // default: on, cyan-ish, mid brightness

// ── Pin definitions ───────────────────────────────────────────────────────────
#define S1_DOUT  40
#define S1_SCK   41
#define S2_DOUT  42
#define S2_SCK   44
#define S3_DOUT  8
#define S3_SCK   9
#define S4_DOUT  15
#define S4_SCK   16
#define S5_DOUT  17
#define S5_SCK   18
#define S6_DOUT  21
#define S6_SCK   47

// Thumbstick
#define THUMB_X_PIN  4
#define THUMB_Y_PIN  6

// Capacitive touch
#define TOUCH_PIN   1
#define TOUCH2_PIN  2

// GC9A01 display pins
#define TFT_CS   10
#define TFT_DC   14
#define TFT_SDA  11
#define TFT_SCL  12
#define TFT_RST  13

const char* AP_SSID = "SipPuffConfig";
const char* AP_PASS = "12345678";

// ── Display setup ─────────────────────────────────────────────────────────────
Arduino_DataBus* bus = new Arduino_SWSPI(TFT_DC, TFT_CS, TFT_SCL, TFT_SDA, -1);
Arduino_GFX* gfx     = new Arduino_GC9A01(bus, TFT_RST, 0, true);

// ── Color palette ─────────────────────────────────────────────────────────────
#define C_BG        0x0000  // black
#define C_RING      0x1082  // dark grey ring background
#define C_BORDER    0x4208  // circle border
#define C_NAME      0xFFFF  // white profile name
#define C_LABEL     0x8410  // dim label text
#define C_UNSAVED   0x8410  // grey for "Unsaved"
#define C_DOT_TEXT  0xFFFF  // number inside circle

// ── Runtime icon colors (editable via web UI, persisted in NVS) ───────────────
// Defaults match the original hard-coded values.
uint16_t C_PUFF_ON      = 0x07E0;  // bright green  — puffing
uint16_t C_SIP_ON       = 0xF800;  // bright red    — sipping
uint16_t C_OFF          = 0x2965;  // dim grey      — inactive
uint16_t C_PROFILE_NAME = 0xFFFF;  // white         — profile name text
uint16_t C_PROFILE_LBL  = 0x8410;  // dim grey      — "PROFILE" label text
uint16_t C_BTN_LABEL    = 0xFFFF;  // white         — icon number labels
uint16_t C_BG_RT        = 0x0000;  // black         — screen background
uint16_t C_ICON_BORDER  = 0x4208;  // dark grey     — inactive icon outline
uint16_t C_TOUCH1_ON    = 0xC81F;  // purple        — cap touch pin 1 active
uint16_t C_TOUCH2_ON    = 0x07E0;  // green         — cap touch pin 2 active

// ── Display state ─────────────────────────────────────────────────────────────
String  displayProfileName    = "";
bool    displayPuffActive[6]  = {false,false,false,false,false,false};
bool    displaySipActive[6]   = {false,false,false,false,false,false};
bool    displayTouch1Active   = false;
bool    displayTouch2Active   = false;
bool    displayNeedsRedraw    = true;

// ── Icon positions ────────────────────────────────────────────────────────────
// 6 icons evenly spaced at 60° intervals around the ring.
// r=96, centre=(120,120)
// x = 120 + round(96·cos θ),  y = 120 - round(96·sin θ)
// S1:   0°   S2:  60°   S3: 120°
// S4: 180°   S5: 240°   S6: 300°

struct IconPos { int16_t x, y; };

const IconPos ICON_POS[6] = {
  {216, 120},  // S1:   0°   x=120+96=216    y=120
  {168,  37},  // S2:  60°   x=120+48=168    y=120-83=37
  { 72,  37},  // S3: 120°   x=120-48=72     y=120-83=37
  { 24, 120},  // S4: 180°   x=120-96=24     y=120
  { 72, 203},  // S5: 240°   x=120-48=72     y=120+83=203
  {168, 203},  // S6: 300°   x=120+48=168    y=120+83=203
};

#define ICON_R  20  // icon circle radius

const char* ICON_LABELS[6] = { "1","2","3","4","5","6" };

USBHIDGamepad  Gamepad;
AsyncWebServer server(80);
DNSServer      dnsServer;
Preferences    prefs;

#define MAX_PROFILES     8
#define PROFILE_NAME_LEN 24
#define NUM_SENSORS      6

// ── Special mappable actions (button values above normal HID range) ────────────
#define ACTION_RECALIBRATE   17   // triggers calibrateBaselines()
#define ACTION_NEXT_PROFILE  18   // cycles to next saved profile slot
#define ACTION_DPAD_UP       19   // POV hat — up
#define ACTION_DPAD_RIGHT    20   // POV hat — right
#define ACTION_DPAD_DOWN     21   // POV hat — down
#define ACTION_DPAD_LEFT     22   // POV hat — left

// ── All structs ───────────────────────────────────────────────────────────────

struct HX710B { uint8_t dout, sck; };

struct ButtonConfig {
  uint8_t  button;
  uint8_t  mode;
  uint16_t repeatMs;
  uint8_t  axis;
  uint8_t  curve;
  int32_t  deadzone;
  int32_t  maxRange;
  bool     invert;
  uint16_t combo;     // bitmask of additional HID buttons (bit 0 = button 1, …, bit 15 = button 16)
};

struct SensorConfig {
  ButtonConfig puff;
  ButtonConfig sip;
  int32_t      puffThresh;
  int32_t      sipThresh;
};

struct ButtonState {
  bool     wasActive;
  bool     btnPhysical;
  uint32_t nextFireMs;
  bool     latchOn;
};

// ── Thumbstick config ─────────────────────────────────────────────────────────
// axisX / axisY: which output axis (0-5) the thumbstick X and Y feed into.
// deadzone: raw ADC half-width (0-2047) around center to ignore (prevents drift).
// invertX / invertY: flip the direction.
struct ThumbConfig {
  uint8_t  axisX;      // default: Left Stick X (0)
  uint8_t  axisY;      // default: Left Stick Y (1)
  uint16_t deadzone;   // ADC units (0-2047); default 80
  bool     invertX;
  bool     invertY;
};

// ── Capacitive touch config ───────────────────────────────────────────────────
// button: HID button to press (1-16) or special action, 0 = disabled
// mode: 0=Hold, 1=Repeat, 2=Latch  (no analog)
// repeatMs: repeat interval when mode==1
// threshold: raw touch reading above which a touch is considered active
//            (ESP32-S3 touch reads INCREASE when touched; resting ~30k, touched ~80k+)
// combo: bitmask of additional HID buttons to press alongside the primary
struct TouchConfig {
  uint8_t  button;
  uint8_t  mode;
  uint16_t repeatMs;
  uint32_t threshold;
  uint16_t combo;
};

// ── Profile blob structs (defined here so pack/unpack helpers compile cleanly) ─
struct __attribute__((packed)) BcPacked {
  uint8_t  button;
  uint8_t  mode;
  uint16_t repeatMs;
  uint8_t  axis;
  uint8_t  curve;
  int32_t  deadzone;
  int32_t  maxRange;
  uint8_t  invert;
  uint16_t combo;
};
static_assert(sizeof(BcPacked) == 17, "BcPacked size mismatch");

struct __attribute__((packed)) SensorPacked {
  BcPacked puff;
  BcPacked sip;
  int32_t  puffThresh;
  int32_t  sipThresh;
};
static_assert(sizeof(SensorPacked) == 42, "SensorPacked size mismatch");

// ── ProfileBlob versioning ─────────────────────────────────────────────────────
// Version history (stored in the 'ver' field; older blobs have no ver field
// and are identified purely by size):
//
//   BLOB_V0 (258 bytes) — original layout: sensors + thumb only, no ver field.
//   BLOB_V1 (268 bytes) — added touch pin 1 (10 bytes: u8+u8+u16+u32+u16), no ver field.
//   BLOB_V2 (278 bytes) — added touch pin 2 (10 bytes), no ver field.
//   BLOB_V3 (279 bytes) — added ver byte (this version). New saves always write V3.
//
// When loading, ver==0xFF (unset) means the blob predates versioning and size
// alone is used to determine which fields are present, exactly as before.
// Future additions: bump BLOB_CURRENT_VER, add fields at the END of the struct,
// add a new size constant + static_assert, and handle the new case in loadProfile().

#define BLOB_V0          258u   // sensors + thumb, no touch
#define BLOB_V1          268u   // + touch pin 1 (10 bytes)
#define BLOB_V2          278u   // + touch pin 2 (10 bytes)
#define BLOB_CURRENT_VER   3    // written into ver field for new saves

struct __attribute__((packed)) ProfileBlob {
  SensorPacked sensors[NUM_SENSORS];
  uint8_t  thumbAxisX;
  uint8_t  thumbAxisY;
  uint16_t thumbDeadzone;
  uint8_t  thumbInvertX;
  uint8_t  thumbInvertY;
  // capacitive touch pin 1 (present when blob size >= BLOB_V1)
  uint8_t  touchButton;
  uint8_t  touchMode;
  uint16_t touchRepeatMs;
  uint32_t touchThreshold;
  uint16_t touchCombo;
  // capacitive touch pin 2 (present when blob size >= BLOB_V2)
  uint8_t  touch2Button;
  uint8_t  touch2Mode;
  uint16_t touch2RepeatMs;
  uint32_t touch2Threshold;
  uint16_t touch2Combo;
  // version byte (present when blob size >= BLOB_V3 / sizeof(ProfileBlob))
  uint8_t  ver;
};

// Compile-time guards: these fire if a struct edit shifts the historic sizes.
// If you add fields, append them AFTER 'ver', bump BLOB_CURRENT_VER, and add
// a new BLOB_VN constant + static_assert below.
static_assert(offsetof(ProfileBlob, touchButton)   == BLOB_V0,  "BLOB_V0 layout shifted");
static_assert(offsetof(ProfileBlob, touch2Button)  == BLOB_V1,  "BLOB_V1 layout shifted");
static_assert(offsetof(ProfileBlob, ver)           == BLOB_V2,  "BLOB_V2 layout shifted");
static_assert(sizeof(ProfileBlob)                  == BLOB_V2 + 1, "BLOB_V3 size mismatch");

// ── Globals ───────────────────────────────────────────────────────────────────
SensorConfig cfg[NUM_SENSORS];
int32_t  baseline[NUM_SENSORS]  = {0,0,0,0,0,0};
int32_t  lastDelta[NUM_SENSORS] = {0,0,0,0,0,0};
// axisVal[0]=LX, [1]=LY, [2]=RX, [3]=RY, [4]=LT, [5]=RT
// axisVal[6]=ThumbX (physical), [7]=ThumbY (physical) — read from ADC each loop,
// then routed to their configured output axes via thumbCfg.axisX / .axisY
int16_t  axisVal[8]             = {0,0,0,0,0,0,0,0};
uint8_t  hatState               = 0;  // bitmask: bit0=up, bit1=right, bit2=down, bit3=left
volatile bool doCalibrate         = false;
volatile bool doNextProfile       = false;
volatile bool doSaveColors        = false;
uint32_t      nextProfileCooldown = 0;  // millis() deadline; suppress triggers before this

char activeProfile[PROFILE_NAME_LEN] = "Unsaved";
int  activeProfileSlot               = -1;   // -1 = unsaved / unknown

ButtonState btnState[NUM_SENSORS][2];

HX710B sensors[NUM_SENSORS] = {
  {S1_DOUT, S1_SCK},
  {S2_DOUT, S2_SCK},
  {S3_DOUT, S3_SCK},
  {S4_DOUT, S4_SCK},
  {S5_DOUT, S5_SCK},
  {S6_DOUT, S6_SCK},
};

ThumbConfig thumbCfg = { 0, 1, 80, false, false };
TouchConfig touchCfg  = { 0, 0, 200, 60000, 0 };
TouchConfig touch2Cfg = { 0, 0, 200, 60000, 0 };
ButtonState touchBtnState  = {};  // state for cap-touch pin 1
ButtonState touch2BtnState = {};  // state for cap-touch pin 2

// ── WiFi STA credential helpers ───────────────────────────────────────────────
// Credentials are stored in NVS namespace "wifi".
// The device always keeps the AP up (WIFI_AP_STA mode) so the config page
// remains reachable on 192.168.4.1 even while joined to a local network.

void saveWiFiCreds(const char* ssid, const char* pass) {
  prefs.begin("wifi", false);
  prefs.putString("ssid", ssid);
  prefs.putString("pass", pass);
  prefs.end();
}

bool loadWiFiCreds(String& ssid, String& pass) {
  prefs.begin("wifi", true);
  ssid = prefs.getString("ssid", "");
  pass = prefs.getString("pass", "");
  prefs.end();
  return ssid.length() > 0;
}

void clearWiFiCreds() {
  prefs.begin("wifi", false);
  prefs.remove("ssid");
  prefs.remove("pass");
  prefs.end();
}

String buildWiFiStatusJSON() {
  wl_status_t s = WiFi.status();
  String ip   = (s == WL_CONNECTED) ? WiFi.localIP().toString() : "";
  String mac  = WiFi.macAddress();
  String ssid = (s == WL_CONNECTED) ? WiFi.SSID() : "";
  int8_t rssi = (s == WL_CONNECTED) ? WiFi.RSSI() : 0;
  const char* stateStr =
    (s == WL_CONNECTED)    ? "connected"    :
    (s == WL_CONNECT_FAILED ||
     s == WL_NO_SSID_AVAIL) ? "failed"      : "disconnected";
  char buf[256];
  snprintf(buf, sizeof(buf),
    "{\"state\":\"%s\",\"ip\":\"%s\",\"ssid\":\"%s\",\"rssi\":%d,\"mac\":\"%s\"}",
    stateStr, ip.c_str(), ssid.c_str(), (int)rssi, mac.c_str());
  return String(buf);
}

// ── Moving average filter ─────────────────────────────────────────────────────
#define FILTER_SIZE 8
int32_t filterBuf[NUM_SENSORS][FILTER_SIZE] = {};
uint8_t filterIdx[NUM_SENSORS]              = {};
bool    filterFull[NUM_SENSORS]             = {};
bool    filterSeeded[NUM_SENSORS]           = {};

int32_t filteredDelta(int sensor, int32_t newVal) {
  if (!filterSeeded[sensor]) {
    filterSeeded[sensor] = true;
    for (int i = 0; i < FILTER_SIZE; i++) filterBuf[sensor][i] = newVal;
    filterIdx[sensor]  = 0;
    filterFull[sensor] = true;
    return newVal;
  }
  filterBuf[sensor][filterIdx[sensor]] = newVal;
  filterIdx[sensor] = (filterIdx[sensor] + 1) % FILTER_SIZE;
  if (!filterFull[sensor] && filterIdx[sensor] == 0) filterFull[sensor] = true;
  int32_t sum   = 0;
  int     count = filterFull[sensor] ? FILTER_SIZE : filterIdx[sensor];
  if (count == 0) return newVal;
  for (int i = 0; i < count; i++) sum += filterBuf[sensor][i];
  return sum / count;
}

void resetFilters() {
  memset(filterBuf,    0, sizeof(filterBuf));
  memset(filterIdx,    0, sizeof(filterIdx));
  memset(filterFull,   0, sizeof(filterFull));
  memset(filterSeeded, 0, sizeof(filterSeeded));
}

// ── HX710B ───────────────────────────────────────────────────────────────────
// Non-blocking read: if DOUT is still HIGH (conversion not ready), return the
// last cached value immediately rather than busy-waiting.  This keeps the loop
// running fast so the thumbstick ADC is sampled continuously regardless of the
// HX710B sample rate (~40 Hz).  Each sensor caches its own last reading.
static int32_t hx710b_cache[NUM_SENSORS] = {0,0,0,0,0,0};

// Index-aware wrapper used in the main loop.
int32_t hx710b_read_nb(int idx) {
  const HX710B& s = sensors[idx];
  if (digitalRead(s.dout) == HIGH) {
    return hx710b_cache[idx];   // not ready — return last good value
  }
  int32_t value = 0;
  for (int i = 0; i < 24; i++) {
    digitalWrite(s.sck, HIGH); delayMicroseconds(1);
    value = (value << 1) | digitalRead(s.dout);
    digitalWrite(s.sck, LOW);  delayMicroseconds(1);
  }
  digitalWrite(s.sck, HIGH); delayMicroseconds(1);
  digitalWrite(s.sck, LOW);  delayMicroseconds(1);
  if (value & 0x800000) value |= 0xFF000000;
  hx710b_cache[idx] = value;
  return value;
}

// Blocking version — kept for calibration only (needs fresh confirmed readings).
int32_t hx710b_read(const HX710B& s) {
  uint32_t t = millis();
  while (digitalRead(s.dout) == HIGH) {
    if (millis() - t > 200) return 0;
    delay(1);
  }
  int32_t value = 0;
  for (int i = 0; i < 24; i++) {
    digitalWrite(s.sck, HIGH); delayMicroseconds(1);
    value = (value << 1) | digitalRead(s.dout);
    digitalWrite(s.sck, LOW);  delayMicroseconds(1);
  }
  digitalWrite(s.sck, HIGH); delayMicroseconds(1);
  digitalWrite(s.sck, LOW);  delayMicroseconds(1);
  if (value & 0x800000) value |= 0xFF000000;
  return value;
}

// ── Analog mapping ────────────────────────────────────────────────────────────
int16_t mapAnalog(int32_t delta, int32_t deadzone, int32_t maxRange,
                  uint8_t curve, bool invert) {
  int32_t sign     = (delta >= 0) ? 1 : -1;
  int32_t absDelta = abs(delta);
  if (absDelta > maxRange) absDelta = maxRange;
  if (absDelta < deadzone) return 0;
  int32_t effective  = absDelta - deadzone;
  int32_t range      = maxRange - deadzone;
  if (range <= 0) return 0;
  float normalized = (float)effective / (float)range;
  if (normalized > 1.0f) normalized = 1.0f;
  if (normalized < 0.0f) normalized = 0.0f;
  float curved;
  switch (curve) {
    case 1:  curved = normalized * normalized;              break;
    case 2:  curved = normalized * normalized * normalized; break;
    default: curved = normalized;                           break;
  }
  int32_t result32 = (int32_t)(curved * 127.0f) * sign;
  if (result32 >  127) result32 =  127;
  if (result32 < -127) result32 = -127;
  int16_t result = (int16_t)result32;
  return invert ? -result : result;
}

// ── Combo helpers ─────────────────────────────────────────────────────────────
// Press all buttons set in the combo bitmask (skipping the primary button).
void pressCombo(uint16_t combo, uint8_t primaryBtn) {
  for (int b = 0; b < 16; b++) {
    if ((combo >> b) & 1) {
      uint8_t hidBtn = (uint8_t)b;   // bit 0 → HID button 0 (button 1), etc.
      if (hidBtn != primaryBtn) Gamepad.pressButton(hidBtn);
    }
  }
}

void releaseCombo(uint16_t combo, uint8_t primaryBtn) {
  for (int b = 0; b < 16; b++) {
    if ((combo >> b) & 1) {
      uint8_t hidBtn = (uint8_t)b;
      if (hidBtn != primaryBtn) Gamepad.releaseButton(hidBtn);
    }
  }
}

// ── Button handler ────────────────────────────────────────────────────────────

// Convert d-pad bitmask (bit0=up, bit1=right, bit2=down, bit3=left) to
// ESP32 USBHIDGamepad hat value:
//   HAT_CENTER=0, N=1, NE=2, E=3, SE=4, S=5, SW=6, W=7, NW=8
// (1-based, NOT 0-based — confirmed from espressif arduino-esp32 example)
uint8_t hatBitsToHID(uint8_t bits) {
  bool u = bits & 1, r = bits & 2, d = bits & 4, l = bits & 8;
  if (u && r) return 2;   // NE
  if (r && d) return 4;   // SE
  if (d && l) return 6;   // SW
  if (l && u) return 8;   // NW
  if (u)      return 1;   // N
  if (r)      return 3;   // E
  if (d)      return 5;   // S
  if (l)      return 7;   // W
  return HAT_CENTER;      // 0 = centered
}

void handleButton(ButtonConfig& bc, ButtonState& bs, bool active) {
  // ── Special mappable actions ────────────────────────────────────────────────
  if (bc.button == ACTION_RECALIBRATE || bc.button == ACTION_NEXT_PROFILE) {
    bool risingEdge = active && !bs.wasActive;
    if (risingEdge) {
      if (bc.button == ACTION_RECALIBRATE) {
        doCalibrate = true;
      } else {
        // Only fire if the cooldown has expired.  This prevents a single long
        // puff/sip from racing through multiple profiles, and gives the user
        // a clear moment to release before the next advance is accepted.
        uint32_t now = millis();
        if (now >= nextProfileCooldown) {
          doNextProfile       = true;
          nextProfileCooldown = now + 1500;  // 1.5 s lockout after each advance
        }
      }
    }
    bs.wasActive = active;
    return;
  }
  // ── D-pad hat switch ────────────────────────────────────────────────────────
  if (bc.button >= ACTION_DPAD_UP && bc.button <= ACTION_DPAD_LEFT) {
    uint8_t bit = 1 << (bc.button - ACTION_DPAD_UP);  // up=1,right=2,down=4,left=8
    if (active) hatState |= bit;
    else        hatState &= ~bit;
    bs.wasActive = active;
    return;
  }
  // ── Normal / disabled ───────────────────────────────────────────────────────
  if (bc.button == 0 || bc.mode == 3) {
    if (bs.btnPhysical && bc.button > 0) {
      Gamepad.releaseButton(bc.button - 1);
      releaseCombo(bc.combo, bc.button - 1);
      bs.btnPhysical = false;
    }
    bs.wasActive = false;
    bs.latchOn   = false;
    return;
  }
  uint8_t  btn    = bc.button - 1;
  uint32_t now    = millis();
  bool risingEdge = active && !bs.wasActive;
  switch (bc.mode) {
    case 0:
      if ( active && !bs.btnPhysical) { Gamepad.pressButton(btn);  pressCombo(bc.combo, btn);  bs.btnPhysical = true;  }
      if (!active &&  bs.btnPhysical) { Gamepad.releaseButton(btn); releaseCombo(bc.combo, btn); bs.btnPhysical = false; }
      break;
    case 1:
      if (risingEdge) { Gamepad.pressButton(btn); pressCombo(bc.combo, btn); bs.btnPhysical = true; bs.nextFireMs = now + 30; }
      if (active) {
        if ( bs.btnPhysical && now >= bs.nextFireMs) { Gamepad.releaseButton(btn); releaseCombo(bc.combo, btn); bs.btnPhysical = false; bs.nextFireMs = now + bc.repeatMs; }
        if (!bs.btnPhysical && now >= bs.nextFireMs) { Gamepad.pressButton(btn);   pressCombo(bc.combo, btn);   bs.btnPhysical = true;  bs.nextFireMs = now + 30; }
      } else {
        if (bs.btnPhysical) { Gamepad.releaseButton(btn); releaseCombo(bc.combo, btn); bs.btnPhysical = false; }
      }
      break;
    case 2:
      if (risingEdge) {
        bs.latchOn = !bs.latchOn;
        if (bs.latchOn) { Gamepad.pressButton(btn);  pressCombo(bc.combo, btn);  bs.btnPhysical = true;  }
        else            { Gamepad.releaseButton(btn); releaseCombo(bc.combo, btn); bs.btnPhysical = false; }
      }
      break;
  }
  bs.wasActive = active;
}

// ── Axis output ───────────────────────────────────────────────────────────────
void sendAxes() {
  Gamepad.hat(hatBitsToHID(hatState));
  Gamepad.leftStick(  (int8_t)axisVal[0], (int8_t)axisVal[1]);
  Gamepad.rightStick( (int8_t)axisVal[2], (int8_t)axisVal[3]);
  Gamepad.leftTrigger( axisVal[4] > 0 ? (uint8_t)axisVal[4] : -127);
  Gamepad.rightTrigger(axisVal[5] > 0 ? (uint8_t)axisVal[5] : -127);
}

// ── Boot logo ─────────────────────────────────────────────────────────────────
// Draws the 240×240 RGB565 logo from PROGMEM one row at a time to keep
// RAM usage flat — only a single 480-byte row buffer is needed at a time.
// LVGL exports RGB565 little-endian (low byte first), so each pair is
// swapped before handing off to draw16bitRGBBitmap.
void drawLogo() {
  uint16_t rowBuf[LOGO_W];
  for (int y = 0; y < LOGO_H; y++) {
    for (int x = 0; x < LOGO_W; x++) {
      uint32_t byteOffset = ((uint32_t)y * LOGO_W + x) * 2;
      uint8_t lo = pgm_read_byte(LOGO_MAP + byteOffset);
      uint8_t hi = pgm_read_byte(LOGO_MAP + byteOffset + 1);
      rowBuf[x] = ((uint16_t)hi << 8) | lo;
    }
    gfx->draw16bitRGBBitmap(0, y, rowBuf, LOGO_W, 1);
  }
}

// ── Display drawing ───────────────────────────────────────────────────────────

// Draw a single sensor icon with 3-state color:
//   puffActive → green fill, cyan border
//   sipActive  → red fill, orange border
//   inactive   → dim gray fill, dark border
void drawIcon(int idx, bool puffActive, bool sipActive) {
  int16_t cx = ICON_POS[idx].x;
  int16_t cy = ICON_POS[idx].y;

  uint16_t fillCol, borderCol;
  if (puffActive) {
    fillCol   = C_PUFF_ON;
    borderCol = C_PUFF_ON;
  } else if (sipActive) {
    fillCol   = C_SIP_ON;
    borderCol = C_SIP_ON;
  } else {
    fillCol   = C_OFF;
    borderCol = C_ICON_BORDER;
  }

  gfx->fillCircle(cx, cy, ICON_R, fillCol);
  gfx->drawCircle(cx, cy, ICON_R,     borderCol);
  gfx->drawCircle(cx, cy, ICON_R - 1, borderCol);

  gfx->setTextColor(C_BTN_LABEL);
  gfx->setTextSize(1);
  gfx->setCursor(cx - 3, cy - 4);
  gfx->print(ICON_LABELS[idx]);
}

// Draw the profile name centered in the middle of the display
void drawProfileName(const char* name) {
  gfx->fillCircle(120, 120, 68, C_BG_RT);

  bool unsaved = (strcmp(name, "Unsaved") == 0);
  uint16_t nameColor = unsaved ? C_UNSAVED : C_PROFILE_NAME;

  gfx->setTextColor(C_PROFILE_LBL);
  gfx->setTextSize(1);
  gfx->setCursor(97, 95);
  gfx->print("PROFILE");

  uint8_t sz = 2;
  int nameLen = strlen(name);
  if      (nameLen > 10) sz = 1;
  else if (nameLen >  6) sz = 2;
  else                   sz = 3;

  int16_t textW = nameLen * 6 * sz;
  int16_t textX = 120 - textW / 2;
  int16_t textY = 110;

  gfx->setTextColor(nameColor);
  gfx->setTextSize(sz);
  gfx->setCursor(textX, textY);
  gfx->print(name);

  // ── Cap touch status icons ─────────────────────────────────────────────────
  // Two small circles below the profile name, centered at y=148.
  // Left = Touch Pin 1, Right = Touch Pin 2, spaced 22px apart.
  const int16_t T_R  = 7;    // icon radius
  const int16_t T_Y  = 148;  // vertical centre
  const int16_t T1_X = 109;  // Pin 1 centre x
  const int16_t T2_X = 131;  // Pin 2 centre x

  // Pin 1
  uint16_t t1Fill   = displayTouch1Active ? C_TOUCH1_ON : C_OFF;
  uint16_t t1Border = displayTouch1Active ? C_TOUCH1_ON : C_ICON_BORDER;
  gfx->fillCircle(T1_X, T_Y, T_R, t1Fill);
  gfx->drawCircle(T1_X, T_Y, T_R, t1Border);
  gfx->setTextColor(C_BTN_LABEL);
  gfx->setTextSize(1);
  gfx->setCursor(T1_X - 2, T_Y - 4);
  gfx->print("T");

  // Pin 2
  uint16_t t2Fill   = displayTouch2Active ? C_TOUCH2_ON : C_OFF;
  uint16_t t2Border = displayTouch2Active ? C_TOUCH2_ON : C_ICON_BORDER;
  gfx->fillCircle(T2_X, T_Y, T_R, t2Fill);
  gfx->drawCircle(T2_X, T_Y, T_R, t2Border);
  gfx->setTextColor(C_BTN_LABEL);
  gfx->setTextSize(1);
  gfx->setCursor(T2_X - 2, T_Y - 4);
  gfx->print("T");
}

// Full screen redraw
void drawDisplay() {
  gfx->fillScreen(C_BG_RT);

  // Outer decorative ring
  gfx->drawCircle(120, 120, 119, C_RING);
  gfx->drawCircle(120, 120, 118, C_RING);

  // Subtle connector lines centre → icons
  for (int i = 0; i < NUM_SENSORS; i++) {
    gfx->drawLine(120, 120, ICON_POS[i].x, ICON_POS[i].y, C_RING);
  }

  // 6 sensor icons, each showing puff/sip/inactive state
  for (int i = 0; i < NUM_SENSORS; i++) {
    drawIcon(i, displayPuffActive[i], displaySipActive[i]);
  }

  drawProfileName(activeProfile);
}

// Partial update — only redraw icons that changed state, plus name if needed
void updateDisplay(bool nameChanged, bool changed[NUM_SENSORS]) {
  if (nameChanged) drawProfileName(activeProfile);
  for (int i = 0; i < NUM_SENSORS; i++) {
    if (changed[i]) drawIcon(i, displayPuffActive[i], displaySipActive[i]);
  }
}

// ── NVS helpers ───────────────────────────────────────────────────────────────
void loadButtonConfig(Preferences& p, const char* pfx, ButtonConfig& bc,
                      uint8_t defBtn, uint8_t defAxis) {
  char k[20];
  sprintf(k,"%sb",pfx); bc.button   = p.getUChar(k,  defBtn);
  sprintf(k,"%sm",pfx); bc.mode     = p.getUChar(k,  0);
  sprintf(k,"%si",pfx); bc.repeatMs = p.getUShort(k, 200);
  sprintf(k,"%sa",pfx); bc.axis     = p.getUChar(k,  defAxis);
  sprintf(k,"%sc",pfx); bc.curve    = p.getUChar(k,  0);
  sprintf(k,"%sd",pfx); bc.deadzone = p.getInt(k,    20000);
  sprintf(k,"%sr",pfx); bc.maxRange = p.getInt(k,    500000);
  sprintf(k,"%sv",pfx); bc.invert   = p.getBool(k,   false);
  sprintf(k,"%sco",pfx); bc.combo   = p.getUShort(k, 0);
}

void saveButtonConfig(Preferences& p, const char* pfx, ButtonConfig& bc) {
  char k[20];
  sprintf(k,"%sb",pfx); p.putUChar(k,  bc.button);
  sprintf(k,"%sm",pfx); p.putUChar(k,  bc.mode);
  sprintf(k,"%si",pfx); p.putUShort(k, bc.repeatMs);
  sprintf(k,"%sa",pfx); p.putUChar(k,  bc.axis);
  sprintf(k,"%sc",pfx); p.putUChar(k,  bc.curve);
  sprintf(k,"%sd",pfx); p.putInt(k,    bc.deadzone);
  sprintf(k,"%sr",pfx); p.putInt(k,    bc.maxRange);
  sprintf(k,"%sv",pfx); p.putBool(k,   bc.invert);
  sprintf(k,"%sco",pfx); p.putUShort(k, bc.combo);
}

static uint8_t defaultBtn(int sensor, bool isSip) {
  int n = sensor * 2 + 1 + (isSip ? 1 : 0);
  return (n <= 16) ? (uint8_t)n : 0;
}
static uint8_t defaultAxis(int sensor) {
  return (uint8_t)(sensor % 6);
}

// ── LED helpers ───────────────────────────────────────────────────────────────
void applyLed() {
  rgbLed.setBrightness(ledCfg.on ? ledCfg.brightness : 0);
  rgbLed.setPixelColor(0, rgbLed.Color(ledCfg.r, ledCfg.g, ledCfg.b));
  rgbLed.show();
}

void loadLedConfig() {
  prefs.begin("led", true);
  ledCfg.on         = prefs.getBool("on",   true);
  ledCfg.r          = prefs.getUChar("r",   0);
  ledCfg.g          = prefs.getUChar("g",   255);
  ledCfg.b          = prefs.getUChar("b",   200);
  ledCfg.brightness = prefs.getUChar("bri", 128);
  prefs.end();
}

void saveLedConfig() {
  prefs.begin("led", false);
  prefs.putBool("on",   ledCfg.on);
  prefs.putUChar("r",   ledCfg.r);
  prefs.putUChar("g",   ledCfg.g);
  prefs.putUChar("b",   ledCfg.b);
  prefs.putUChar("bri", ledCfg.brightness);
  prefs.end();
}

String buildLedJSON() {
  char buf[120];
  snprintf(buf, sizeof(buf),
    "{\"on\":%s,\"color\":\"#%02x%02x%02x\",\"brightness\":%d}",
    ledCfg.on ? "true" : "false",
    ledCfg.r, ledCfg.g, ledCfg.b,
    ledCfg.brightness);
  return String(buf);
}

// ── Display color persistence ──────────────────────────────────────────────────
// Uses a dedicated NVS namespace "dispclr" so it never collides with "gpad".
// Colors are stored as uint32_t 24-bit RGB (0xRRGGBB) for easy JSON exchange,
// then converted to RGB565 at load time.

// Pack RGB565 → 24-bit RGB for storage
static uint32_t rgb565_to_rgb888(uint16_t c) {
  uint8_t r = (c >> 11) & 0x1F; r = (r << 3) | (r >> 2);
  uint8_t g = (c >>  5) & 0x3F; g = (g << 2) | (g >> 4);
  uint8_t b =  c        & 0x1F; b = (b << 3) | (b >> 2);
  return ((uint32_t)r << 16) | ((uint32_t)g << 8) | b;
}

// 24-bit RGB → RGB565
static uint16_t rgb888_to_rgb565(uint32_t rgb) {
  uint8_t r = (rgb >> 16) & 0xFF;
  uint8_t g = (rgb >>  8) & 0xFF;
  uint8_t b =  rgb        & 0xFF;
  return ((uint16_t)(r & 0xF8) << 8) | ((uint16_t)(g & 0xFC) << 3) | (b >> 3);
}

void loadDisplayColors() {
  prefs.begin("gpad", true);
  C_PUFF_ON      = rgb888_to_rgb565(prefs.getUInt("cpuff",  rgb565_to_rgb888(0x07E0)));
  C_SIP_ON       = rgb888_to_rgb565(prefs.getUInt("csip",   rgb565_to_rgb888(0xF800)));
  C_OFF          = rgb888_to_rgb565(prefs.getUInt("coff",   rgb565_to_rgb888(0x2965)));
  C_PROFILE_NAME = rgb888_to_rgb565(prefs.getUInt("cpname", rgb565_to_rgb888(0xFFFF)));
  C_PROFILE_LBL  = rgb888_to_rgb565(prefs.getUInt("cplbl",  rgb565_to_rgb888(0x8410)));
  C_BTN_LABEL    = rgb888_to_rgb565(prefs.getUInt("cblbl",  rgb565_to_rgb888(0xFFFF)));
  C_BG_RT        = rgb888_to_rgb565(prefs.getUInt("cbg",    rgb565_to_rgb888(0x0000)));
  C_ICON_BORDER  = rgb888_to_rgb565(prefs.getUInt("cibdr",  rgb565_to_rgb888(0x4208)));
  C_TOUCH1_ON    = rgb888_to_rgb565(prefs.getUInt("ct1on",  rgb565_to_rgb888(0xC81F)));
  C_TOUCH2_ON    = rgb888_to_rgb565(prefs.getUInt("ct2on",  rgb565_to_rgb888(0x07E0)));
  prefs.end();
}

void saveDisplayColors() {
  prefs.begin("gpad", false);
  prefs.putUInt("cpuff",  rgb565_to_rgb888(C_PUFF_ON));
  prefs.putUInt("csip",   rgb565_to_rgb888(C_SIP_ON));
  prefs.putUInt("coff",   rgb565_to_rgb888(C_OFF));
  prefs.putUInt("cpname", rgb565_to_rgb888(C_PROFILE_NAME));
  prefs.putUInt("cplbl",  rgb565_to_rgb888(C_PROFILE_LBL));
  prefs.putUInt("cblbl",  rgb565_to_rgb888(C_BTN_LABEL));
  prefs.putUInt("cbg",    rgb565_to_rgb888(C_BG_RT));
  prefs.putUInt("cibdr",  rgb565_to_rgb888(C_ICON_BORDER));
  prefs.putUInt("ct1on",  rgb565_to_rgb888(C_TOUCH1_ON));
  prefs.putUInt("ct2on",  rgb565_to_rgb888(C_TOUCH2_ON));
  prefs.end();
}
// ── Persistent exponential moving average state for thumbstick ────────────────
// Stored as fixed-point (value × 256) to avoid float globals.
static int32_t thumbEmaX = 0;   // EMA accumulator for X, ×256
static int32_t thumbEmaY = 0;   // EMA accumulator for Y, ×256
static bool    thumbEmaSeeded = false;

// Read a thumbstick ADC axis with two layers of smoothing:
//   1. Burst average  — takes THUMB_SAMPLES raw reads and averages them.
//      Kills high-frequency ADC noise cheaply (each read ~5µs on S3).
//   2. EMA (exponential moving average) — smooths across loop iterations.
//      Alpha ≈ 0.20 (fast enough for responsive input, slow enough to kill jitter).
//
// Returns -127..127. deadzone and invert applied after smoothing.
#define THUMB_SAMPLES 8   // raw reads to burst-average per call

int16_t readThumbAxis(uint8_t pin, uint16_t deadzone, bool invert) {
  // ── 1. Burst average ────────────────────────────────────────────────────────
  int32_t sum = 0;
  for (int i = 0; i < THUMB_SAMPLES; i++) sum += analogRead(pin);
  int32_t raw = sum / THUMB_SAMPLES;

  // ── 2. EMA across loop calls ─────────────────────────────────────────────────
  // Use the correct accumulator for each pin. Because this function is called
  // once for X and once for Y per loop, we track which call it is by pin number.
  int32_t* ema = (pin == THUMB_X_PIN) ? &thumbEmaX : &thumbEmaY;
  if (!thumbEmaSeeded) {
    // Seed both on first call; flag cleared only once both are seeded below.
    thumbEmaX = (int32_t)analogRead(THUMB_X_PIN) << 8;
    thumbEmaY = (int32_t)analogRead(THUMB_Y_PIN) << 8;
    thumbEmaSeeded = true;
  }
  // alpha = 20/256 ≈ 0.078 — smooth but responsive
  // ema = ema + alpha*(raw - ema)  →  ema256 += (raw*256 - ema256) * 20 / 256
  *ema += ((raw << 8) - *ema) * 20 / 256;
  raw = *ema >> 8;   // back to integer ADC units

  // ── 3. Center, deadzone, scale ────────────────────────────────────────────────
  int32_t center = 2047;
  int32_t delta  = raw - center;
  int32_t sign   = (delta >= 0) ? 1 : -1;
  int32_t absd   = abs(delta);
  if (absd < (int32_t)deadzone) return 0;
  absd -= deadzone;
  int32_t range = center - deadzone;
  if (range <= 0) return 0;
  int32_t scaled = (absd * 127) / range;
  if (scaled > 127) scaled = 127;
  int16_t result = (int16_t)(scaled * sign);
  return invert ? -result : result;
}


// Build the JSON the web UI needs
String buildColorsJSON() {
  char buf[300];
  uint32_t p  = rgb565_to_rgb888(C_PUFF_ON);
  uint32_t s  = rgb565_to_rgb888(C_SIP_ON);
  uint32_t o  = rgb565_to_rgb888(C_OFF);
  uint32_t pn = rgb565_to_rgb888(C_PROFILE_NAME);
  uint32_t pl = rgb565_to_rgb888(C_PROFILE_LBL);
  uint32_t bl = rgb565_to_rgb888(C_BTN_LABEL);
  uint32_t bg = rgb565_to_rgb888(C_BG_RT);
  uint32_t ib = rgb565_to_rgb888(C_ICON_BORDER);
  uint32_t t1 = rgb565_to_rgb888(C_TOUCH1_ON);
  uint32_t t2 = rgb565_to_rgb888(C_TOUCH2_ON);
  snprintf(buf, sizeof(buf),
    "{\"puff\":\"#%06x\",\"sip\":\"#%06x\",\"off\":\"#%06x\","
    "\"profileName\":\"#%06x\",\"profileLabel\":\"#%06x\",\"btnLabel\":\"#%06x\","
    "\"bg\":\"#%06x\",\"iconBorder\":\"#%06x\","
    "\"touch1\":\"#%06x\",\"touch2\":\"#%06x\"}",
    (unsigned)p, (unsigned)s, (unsigned)o,
    (unsigned)pn, (unsigned)pl, (unsigned)bl,
    (unsigned)bg, (unsigned)ib,
    (unsigned)t1, (unsigned)t2);
  return String(buf);
}

void loadActiveConfig() {
  prefs.begin("gpad", true);
  for (int i = 0; i < NUM_SENSORS; i++) {
    char pp[8], sp[8];
    sprintf(pp, "p%d_", i); sprintf(sp, "s%d_", i);
    loadButtonConfig(prefs, pp, cfg[i].puff, defaultBtn(i, false), defaultAxis(i));
    loadButtonConfig(prefs, sp, cfg[i].sip,  defaultBtn(i, true),  defaultAxis(i));
    char k[12];
    sprintf(k, "pt%d", i); cfg[i].puffThresh = prefs.getInt(k, 100000);
    sprintf(k, "st%d", i); cfg[i].sipThresh  = prefs.getInt(k, 100000);
  }
  thumbCfg.axisX    = prefs.getUChar("thax",  0);
  thumbCfg.axisY    = prefs.getUChar("thay",  1);
  thumbCfg.deadzone = prefs.getUShort("thdz", 80);
  thumbCfg.invertX  = prefs.getBool("thix",   false);
  thumbCfg.invertY  = prefs.getBool("thiy",   false);
  touchCfg.button    = prefs.getUChar("tcb",   0);
  touchCfg.mode      = prefs.getUChar("tcm",   0);
  touchCfg.repeatMs  = prefs.getUShort("tci",  200);
  touchCfg.threshold = prefs.getUInt("tct",    60000);
  touchCfg.combo     = prefs.getUShort("tcco", 0);
  touch2Cfg.button    = prefs.getUChar("tc2b",   0);
  touch2Cfg.mode      = prefs.getUChar("tc2m",   0);
  touch2Cfg.repeatMs  = prefs.getUShort("tc2i",  200);
  touch2Cfg.threshold = prefs.getUInt("tc2t",    60000);
  touch2Cfg.combo     = prefs.getUShort("tc2co", 0);
  prefs.end();
}

void saveActiveConfig() {
  prefs.begin("gpad", false);
  for (int i = 0; i < NUM_SENSORS; i++) {
    char pp[8], sp[8];
    sprintf(pp, "p%d_", i); sprintf(sp, "s%d_", i);
    saveButtonConfig(prefs, pp, cfg[i].puff);
    saveButtonConfig(prefs, sp, cfg[i].sip);
    char k[12];
    sprintf(k, "pt%d", i); prefs.putInt(k, cfg[i].puffThresh);
    sprintf(k, "st%d", i); prefs.putInt(k, cfg[i].sipThresh);
  }
  prefs.putUChar("thax",  thumbCfg.axisX);
  prefs.putUChar("thay",  thumbCfg.axisY);
  prefs.putUShort("thdz", thumbCfg.deadzone);
  prefs.putBool("thix",   thumbCfg.invertX);
  prefs.putBool("thiy",   thumbCfg.invertY);
  prefs.putUChar("tcb",   touchCfg.button);
  prefs.putUChar("tcm",   touchCfg.mode);
  prefs.putUShort("tci",  touchCfg.repeatMs);
  prefs.putUInt("tct",    touchCfg.threshold);
  prefs.putUShort("tcco", touchCfg.combo);
  prefs.putUChar("tc2b",   touch2Cfg.button);
  prefs.putUChar("tc2m",   touch2Cfg.mode);
  prefs.putUShort("tc2i",  touch2Cfg.repeatMs);
  prefs.putUInt("tc2t",    touch2Cfg.threshold);
  prefs.putUShort("tc2co", touch2Cfg.combo);
  prefs.end();
}

// ── Profile storage ───────────────────────────────────────────────────────────
//
// FIX: ESP32 NVS has a hard limit of ~97 entries per namespace.  The old
// scheme stored ~126 separate keys per profile (9 fields × 2 sides × 6
// sensors + thresholds + thumb), so even a single profile could fill the
// namespace and subsequent saves would silently fail — causing profiles 4-8
// to never appear in the list.
//
// New scheme: each profile occupies exactly 2 NVS keys:
//   "nN"  — the profile name string  (same as before)
//   "dN"  — a packed binary blob containing all sensor + thumb data
//
// That's 2 keys × 8 slots = 16 entries total, well within any NVS budget.
// Existing profiles saved with the old many-key scheme will not be found
// (their "dN" blob key won't exist) and will be silently skipped; users
// just need to re-save them once.

static void packBc(BcPacked& dst, const ButtonConfig& src) {
  dst.button   = src.button;
  dst.mode     = src.mode;
  dst.repeatMs = src.repeatMs;
  dst.axis     = src.axis;
  dst.curve    = src.curve;
  dst.deadzone = src.deadzone;
  dst.maxRange = src.maxRange;
  dst.invert   = src.invert ? 1 : 0;
  dst.combo    = src.combo;
}

static void unpackBc(ButtonConfig& dst, const BcPacked& src) {
  dst.button   = src.button;
  dst.mode     = src.mode;
  dst.repeatMs = src.repeatMs;
  dst.axis     = src.axis;
  dst.curve    = src.curve;
  dst.deadzone = src.deadzone;
  dst.maxRange = src.maxRange;
  dst.invert   = (src.invert != 0);
  dst.combo    = src.combo;
}

String buildProfileListJSON() {
  String out = "["; bool first = true;
  prefs.begin("profiles", true);
  for (int s = 0; s < MAX_PROFILES; s++) {
    char nk[4]; sprintf(nk, "n%d", s);
    if (!prefs.isKey(nk)) continue;
    String n = prefs.getString(nk, "");
    if (n.length() == 0) continue;
    if (!first) out += ",";
    out += "{\"slot\":" + String(s) + ",\"name\":\"" + n + "\"}";
    first = false;
  }
  prefs.end();
  out += "]";
  return out;
}

void saveProfile(int slot, const char* name) {
  if (slot < 0 || slot >= MAX_PROFILES) return;

  ProfileBlob blob;
  for (int i = 0; i < NUM_SENSORS; i++) {
    packBc(blob.sensors[i].puff, cfg[i].puff);
    packBc(blob.sensors[i].sip,  cfg[i].sip);
    blob.sensors[i].puffThresh = cfg[i].puffThresh;
    blob.sensors[i].sipThresh  = cfg[i].sipThresh;
  }
  blob.thumbAxisX   = thumbCfg.axisX;
  blob.thumbAxisY   = thumbCfg.axisY;
  blob.thumbDeadzone= thumbCfg.deadzone;
  blob.thumbInvertX = thumbCfg.invertX ? 1 : 0;
  blob.thumbInvertY = thumbCfg.invertY ? 1 : 0;
  blob.touchButton   = touchCfg.button;
  blob.touchMode     = touchCfg.mode;
  blob.touchRepeatMs = touchCfg.repeatMs;
  blob.touchThreshold= touchCfg.threshold;
  blob.touchCombo    = touchCfg.combo;
  blob.touch2Button   = touch2Cfg.button;
  blob.touch2Mode     = touch2Cfg.mode;
  blob.touch2RepeatMs = touch2Cfg.repeatMs;
  blob.touch2Threshold= touch2Cfg.threshold;
  blob.touch2Combo    = touch2Cfg.combo;
  blob.ver            = BLOB_CURRENT_VER;

  prefs.begin("profiles", false);
  char nk[4], dk[4];
  sprintf(nk, "n%d", slot); prefs.putString(nk, name);
  sprintf(dk, "d%d", slot); prefs.putBytes(dk, &blob, sizeof(blob));
  prefs.end();
}

bool loadProfile(int slot) {
  if (slot < 0 || slot >= MAX_PROFILES) return false;
  prefs.begin("profiles", true);
  char nk[4]; sprintf(nk, "n%d", slot);
  if (!prefs.isKey(nk)) { prefs.end(); return false; }
  char dk[4]; sprintf(dk, "d%d", slot);
  if (!prefs.isKey(dk)) { prefs.end(); return false; }

  ProfileBlob blob;
  size_t got = prefs.getBytes(dk, &blob, sizeof(blob));
  prefs.end();
  // Accept all known blob sizes. Fields missing from older blobs default to off.
  // Blobs smaller than BLOB_V0 are corrupt/unknown — reject them.
  if (got != sizeof(blob) && got != BLOB_V2 && got != BLOB_V1 && got != BLOB_V0) return false;

  for (int i = 0; i < NUM_SENSORS; i++) {
    unpackBc(cfg[i].puff, blob.sensors[i].puff);
    unpackBc(cfg[i].sip,  blob.sensors[i].sip);
    cfg[i].puffThresh = blob.sensors[i].puffThresh;
    cfg[i].sipThresh  = blob.sensors[i].sipThresh;
  }
  thumbCfg.axisX    = blob.thumbAxisX;
  thumbCfg.axisY    = blob.thumbAxisY;
  thumbCfg.deadzone = blob.thumbDeadzone;
  thumbCfg.invertX  = (blob.thumbInvertX != 0);
  thumbCfg.invertY  = (blob.thumbInvertY != 0);
  if (got >= BLOB_V1) {
    touchCfg.button    = blob.touchButton;
    touchCfg.mode      = blob.touchMode;
    touchCfg.repeatMs  = blob.touchRepeatMs;
    touchCfg.threshold = blob.touchThreshold;
    touchCfg.combo     = blob.touchCombo;
  } else {
    touchCfg = { 0, 0, 200, 60000, 0 };
  }
  if (got >= BLOB_V2) {
    touch2Cfg.button    = blob.touch2Button;
    touch2Cfg.mode      = blob.touch2Mode;
    touch2Cfg.repeatMs  = blob.touch2RepeatMs;
    touch2Cfg.threshold = blob.touch2Threshold;
    touch2Cfg.combo     = blob.touch2Combo;
  } else {
    touch2Cfg = { 0, 0, 200, 60000, 0 };
  }
  return true;
}

void deleteProfile(int slot) {
  if (slot < 0 || slot >= MAX_PROFILES) return;
  prefs.begin("profiles", false);
  char nk[4]; sprintf(nk, "n%d", slot); prefs.remove(nk);
  char dk[4]; sprintf(dk, "d%d", slot); prefs.remove(dk);
  prefs.end();
}

// ── Next-profile cycling ──────────────────────────────────────────────────────
// Finds the next occupied slot after activeProfileSlot (wraps around),
// loads it, and updates the display. If no profiles are saved, does nothing.
void loadNextProfile() {
  int slots[MAX_PROFILES];
  char names[MAX_PROFILES][PROFILE_NAME_LEN];
  int count = 0;

  prefs.begin("profiles", true);
  for (int s = 0; s < MAX_PROFILES; s++) {
    char nk[4]; sprintf(nk, "n%d", s);
    if (!prefs.isKey(nk)) continue;
    String n = prefs.getString(nk, "");
    if (n.length() == 0) continue;
    slots[count] = s;
    strncpy(names[count], n.c_str(), PROFILE_NAME_LEN - 1);
    names[count][PROFILE_NAME_LEN - 1] = '\0';
    count++;
  }
  prefs.end();

  if (count == 0) return;          // no saved profiles — nothing to cycle

  // Find the slot that comes after the currently active one
  int nextIdx = 0;                 // default: first saved slot
  for (int i = 0; i < count; i++) {
    if (slots[i] == activeProfileSlot) {
      nextIdx = (i + 1) % count;
      break;
    }
  }
  int nextSlot = slots[nextIdx];

  if (!loadProfile(nextSlot)) return;
  saveActiveConfig();
  memset(btnState, 0, sizeof(btnState));
  memset(axisVal,  0, sizeof(axisVal));
  memset(&touchBtnState,  0, sizeof(touchBtnState));
  memset(&touch2BtnState, 0, sizeof(touch2BtnState));
  resetFilters();

  activeProfileSlot = nextSlot;
  strncpy(activeProfile, names[nextIdx], PROFILE_NAME_LEN - 1);
  activeProfile[PROFILE_NAME_LEN - 1] = '\0';
  drawProfileName(activeProfile);
  Serial.printf("Next profile loaded: slot %d \"%s\"\n", nextSlot, activeProfile);
}

// ── Config JSON ───────────────────────────────────────────────────────────────
String bcJSON(ButtonConfig& bc) {
  char buf[160];
  snprintf(buf, sizeof(buf),
    "{\"b\":%d,\"m\":%d,\"i\":%d,\"a\":%d,\"c\":%d,\"d\":%ld,\"r\":%ld,\"v\":%s,\"co\":%d}",
    bc.button, bc.mode, bc.repeatMs, bc.axis, bc.curve,
    (long)bc.deadzone, (long)bc.maxRange,
    bc.invert ? "true" : "false",
    bc.combo);
  return String(buf);
}

String buildConfigJSON() {
  String out = "{";
  for (int i = 0; i < NUM_SENSORS; i++) {
    if (i) out += ",";
    char key[4]; sprintf(key, "s%d", i);
    out += "\"" + String(key) + "\":{";
    out += "\"p\":" + bcJSON(cfg[i].puff) + ",";
    out += "\"s\":" + bcJSON(cfg[i].sip)  + ",";
    out += "\"pt\":" + String((long)cfg[i].puffThresh) + ",";
    out += "\"st\":" + String((long)cfg[i].sipThresh);
    out += "}";
  }
  char tbuf[96];
  snprintf(tbuf, sizeof(tbuf),
    ",\"thumb\":{\"axisX\":%d,\"axisY\":%d,\"deadzone\":%d,\"invertX\":%s,\"invertY\":%s}",
    thumbCfg.axisX, thumbCfg.axisY, thumbCfg.deadzone,
    thumbCfg.invertX ? "true" : "false",
    thumbCfg.invertY ? "true" : "false");
  out += tbuf;
  char tcbuf[80];
  snprintf(tcbuf, sizeof(tcbuf),
    ",\"touch\":{\"b\":%d,\"m\":%d,\"i\":%d,\"t\":%lu,\"co\":%d}",
    touchCfg.button, touchCfg.mode, touchCfg.repeatMs,
    (unsigned long)touchCfg.threshold, touchCfg.combo);
  out += tcbuf;
  char tc2buf[80];
  snprintf(tc2buf, sizeof(tc2buf),
    ",\"touch2\":{\"b\":%d,\"m\":%d,\"i\":%d,\"t\":%lu,\"co\":%d}",
    touch2Cfg.button, touch2Cfg.mode, touch2Cfg.repeatMs,
    (unsigned long)touch2Cfg.threshold, touch2Cfg.combo);
  out += tc2buf;
  out += "}";
  return out;
}

void parseBCFromBody(const String& body, int pos, ButtonConfig& bc) {
  int p;
  p = body.indexOf("\"b\":",  pos); if (p>=0) bc.button   = (uint8_t)body.substring(p+4).toInt();
  p = body.indexOf("\"m\":",  pos); if (p>=0) bc.mode     = (uint8_t)body.substring(p+4).toInt();
  p = body.indexOf("\"i\":",  pos); if (p>=0) bc.repeatMs = (uint16_t)body.substring(p+4).toInt();
  p = body.indexOf("\"a\":",  pos); if (p>=0) bc.axis     = (uint8_t)body.substring(p+4).toInt();
  p = body.indexOf("\"c\":",  pos); if (p>=0) bc.curve    = (uint8_t)body.substring(p+4).toInt();
  p = body.indexOf("\"d\":",  pos); if (p>=0) bc.deadzone = body.substring(p+4).toInt();
  p = body.indexOf("\"r\":",  pos); if (p>=0) bc.maxRange = body.substring(p+4).toInt();
  p = body.indexOf("\"v\":",  pos); if (p>=0) bc.invert   = body.substring(p+4).startsWith("true");
  p = body.indexOf("\"co\":", pos); if (p>=0) bc.combo    = (uint16_t)body.substring(p+5).toInt();
}

void parseConfigBody(const String& body) {
  for (int i = 0; i < NUM_SENSORS; i++) {
    char sk[4]; sprintf(sk, "s%d", i);
    int spos = body.indexOf(sk); if (spos < 0) continue;
    int pp = body.indexOf("\"p\":", spos);
    int sp = body.indexOf("\"s\":", spos);
    if (pp >= 0) parseBCFromBody(body, pp + 4, cfg[i].puff);
    if (sp >= 0) parseBCFromBody(body, sp + 4, cfg[i].sip);
    int p;
    p = body.indexOf("\"pt\":", spos); if (p>=0) cfg[i].puffThresh = body.substring(p+5).toInt();
    p = body.indexOf("\"st\":", spos); if (p>=0) cfg[i].sipThresh  = body.substring(p+5).toInt();
  }
  // Parse thumbstick config if present
  int tp = body.indexOf("\"thumb\":");
  if (tp >= 0) {
    int p;
    p = body.indexOf("\"axisX\":",   tp); if (p>=0) thumbCfg.axisX    = (uint8_t)body.substring(p+8).toInt();
    p = body.indexOf("\"axisY\":",   tp); if (p>=0) thumbCfg.axisY    = (uint8_t)body.substring(p+8).toInt();
    p = body.indexOf("\"deadzone\":",tp); if (p>=0) thumbCfg.deadzone = (uint16_t)body.substring(p+11).toInt();
    p = body.indexOf("\"invertX\":", tp); if (p>=0) thumbCfg.invertX  = body.substring(p+10).startsWith("true");
    p = body.indexOf("\"invertY\":", tp); if (p>=0) thumbCfg.invertY  = body.substring(p+10).startsWith("true");
  }
  // Parse capacitive touch config if present
  int tcp = body.indexOf("\"touch\":");
  if (tcp >= 0) {
    int p;
    p = body.indexOf("\"b\":", tcp); if (p>=0) touchCfg.button    = (uint8_t)body.substring(p+4).toInt();
    p = body.indexOf("\"m\":", tcp); if (p>=0) touchCfg.mode      = (uint8_t)body.substring(p+4).toInt();
    p = body.indexOf("\"i\":", tcp); if (p>=0) touchCfg.repeatMs  = (uint16_t)body.substring(p+4).toInt();
    p = body.indexOf("\"t\":", tcp); if (p>=0) touchCfg.threshold = (uint32_t)body.substring(p+4).toInt();
    p = body.indexOf("\"co\":",tcp); if (p>=0) touchCfg.combo     = (uint16_t)body.substring(p+5).toInt();
  }
  // Parse capacitive touch 2 config if present
  int tc2p = body.indexOf("\"touch2\":");
  if (tc2p >= 0) {
    int p;
    p = body.indexOf("\"b\":", tc2p); if (p>=0) touch2Cfg.button    = (uint8_t)body.substring(p+4).toInt();
    p = body.indexOf("\"m\":", tc2p); if (p>=0) touch2Cfg.mode      = (uint8_t)body.substring(p+4).toInt();
    p = body.indexOf("\"i\":", tc2p); if (p>=0) touch2Cfg.repeatMs  = (uint16_t)body.substring(p+4).toInt();
    p = body.indexOf("\"t\":", tc2p); if (p>=0) touch2Cfg.threshold = (uint32_t)body.substring(p+4).toInt();
    p = body.indexOf("\"co\":",tc2p); if (p>=0) touch2Cfg.combo     = (uint16_t)body.substring(p+5).toInt();
  }
}

// ── Calibration ───────────────────────────────────────────────────────────────
// FIX: Discard initial unstable readings before averaging the baseline.
//
// HX710B sensors need time to stabilize after power-on. The first several
// readings can be wildly off, causing the calibration to lock in a bad
// baseline and making sensors appear active even at rest.
//
// Strategy:
//   1. One warm-up read across all sensors (flushes any stale conversion).
//   2. Per-sensor: discard DISCARD_READS samples, then average AVERAGE_READS.
//
// This path is used both at boot and when triggered via the web UI.
// The web UI calibration always worked because the sensors had been running
// for many seconds by then — this makes boot calibration equally reliable.
void calibrateBaselines() {
  const int DISCARD_READS = 10;   // throw away; sensors may still be settling
  const int AVERAGE_READS = 16;   // more samples = more stable baseline

  Serial.println("Calibrating baselines...");

  gfx->fillCircle(120, 120, 68, C_BG);
  gfx->setTextColor(C_LABEL);
  gfx->setTextSize(1);
  gfx->setCursor(82, 115);
  gfx->print("CALIBRATING...");

  // Warm-up pass: one read per sensor to flush any stale ADC conversion.
  // These values are discarded.
  for (int i = 0; i < NUM_SENSORS; i++) {
    hx710b_read(sensors[i]);
  }

  // Per-sensor: discard then average
  for (int i = 0; i < NUM_SENSORS; i++) {
    // Discard unstable startup readings
    for (int j = 0; j < DISCARD_READS; j++) {
      hx710b_read(sensors[i]);
    }
    // Average stable readings for baseline
    int32_t sum = 0;
    for (int j = 0; j < AVERAGE_READS; j++) {
      sum += hx710b_read(sensors[i]);
    }
    baseline[i] = sum / AVERAGE_READS;
    Serial.printf("  Sensor %d baseline: %ld\n", i + 1, (long)baseline[i]);
  }

  Serial.println("Done.");
  drawDisplay();
}

// ── HTML streaming ────────────────────────────────────────────────────────────
void sendHTML(AsyncWebServerRequest* req) {
  Serial.printf("Free heap before HTML send: %lu bytes\n", ESP.getFreeHeap());
  String cfgJson   = buildConfigJSON();
  const char* html = WEB_UI_HTML;
  size_t htmlLen   = strlen_P(html);
  const char* placeholder = "__CONFIG_JSON__";
  size_t phLen     = strlen(placeholder);

  int splitAt = -1;
  for (size_t i = 0; i <= htmlLen - phLen; i++) {
    bool match = true;
    for (size_t j = 0; j < phLen; j++) {
      if (pgm_read_byte(html + i + j) != placeholder[j]) { match = false; break; }
    }
    if (match) { splitAt = (int)i; break; }
  }

  if (splitAt < 0) { req->send_P(200, "text/html", html); return; }

  size_t part1Len   = (size_t)splitAt;
  size_t part2Start = splitAt + phLen;

  AsyncResponseStream* response = req->beginResponseStream("text/html");
  response->addHeader("Cache-Control", "no-cache");

  const size_t CHUNK = 512;
  char buf[CHUNK + 1];
  size_t pos = 0;
  while (pos < part1Len) {
    size_t toRead = min(CHUNK, part1Len - pos);
    memcpy_P(buf, html + pos, toRead);
    buf[toRead] = '\0';
    response->print(buf);
    pos += toRead;
  }
  response->print(cfgJson);
  pos = part2Start;
  while (pos < htmlLen) {
    size_t toRead = min(CHUNK, htmlLen - pos);
    memcpy_P(buf, html + pos, toRead);
    buf[toRead] = '\0';
    response->print(buf);
    pos += toRead;
  }
  req->send(response);
}

// ── Web server ────────────────────────────────────────────────────────────────
void setupWebServer() {
  server.on("/", HTTP_GET, [](AsyncWebServerRequest* req) { sendHTML(req); });

  // ── Captive portal detection — each OS expects a specific response ───────────
  // Android / Chrome OS: expects exactly 204 No Content, NOT a redirect.
  // Redirecting this endpoint causes the "sign in to network" notification
  // instead of auto-opening the browser.
  auto noContent = [](AsyncWebServerRequest* req) {
    req->send(204);
  };
  server.on("/generate_204", HTTP_GET, noContent);
  server.on("/gen_204",      HTTP_GET, noContent);

  // iOS / macOS: expects 200 with a non-empty HTML body. The OS checks the
  // content; returning the actual portal page here triggers Safari to open
  // automatically without any popup.
  auto iosPortal = [](AsyncWebServerRequest* req) {
    AsyncWebServerResponse* resp = req->beginResponse(
      200, "text/html",
      "<HTML><HEAD><TITLE>Success</TITLE></HEAD><BODY>Success</BODY></HTML>"
    );
    resp->addHeader("Cache-Control", "no-cache, no-store, must-revalidate");
    resp->addHeader("Pragma",  "no-cache");
    resp->addHeader("Expires", "-1");
    req->send(resp);
  };
  server.on("/hotspot-detect.html",       HTTP_GET, iosPortal);
  server.on("/library/test/success.html", HTTP_GET, iosPortal);
  server.on("/success.html",              HTTP_GET, iosPortal);

  // Windows NCSI: expects specific plain-text bodies, not a redirect.
  server.on("/ncsi.txt", HTTP_GET, [](AsyncWebServerRequest* req) {
    req->send(200, "text/plain", "Microsoft NCSI");
  });
  server.on("/connecttest.txt", HTTP_GET, [](AsyncWebServerRequest* req) {
    req->send(200, "text/plain", "Microsoft Connect Test");
  });

  // Everything else (unknown paths, other OS probes) → redirect to config page.
  // Using explicit 302 + Location header is more reliable than req->redirect().
  auto redirect = [](AsyncWebServerRequest* req) {
    AsyncWebServerResponse* resp = req->beginResponse(302, "text/plain", "");
    resp->addHeader("Location",      "http://192.168.4.1/");
    resp->addHeader("Cache-Control", "no-cache");
    req->send(resp);
  };
  server.on("/redirect", HTTP_GET, redirect);
  server.onNotFound(redirect);

  // /save — parse config, update working config, and write through to the active
  // profile slot if one is set. The firmware owns activeProfileSlot so there is
  // no slot-lookup round-trip and no race condition.
  server.on("/save", HTTP_POST,
    [](AsyncWebServerRequest* req) { req->send(200, "application/json", "{\"ok\":true}"); },
    NULL,
    [](AsyncWebServerRequest* req, uint8_t* data, size_t len, size_t index, size_t total) {
      parseConfigBody(String((char*)data, len));
      saveActiveConfig();
      if (activeProfileSlot >= 0 && activeProfileSlot < MAX_PROFILES) {
        saveProfile(activeProfileSlot, activeProfile);
      }
    }
  );

  server.on("/readings", HTTP_GET, [](AsyncWebServerRequest* req) {
    String json = "{";
    for (int i = 0; i < NUM_SENSORS; i++) {
      if (i) json += ",";
      json += "\"s" + String(i) + "\":" + String((long)lastDelta[i]);
    }
    json += ",\"l\":[";
    for (int i = 0; i < NUM_SENSORS; i++) {
      if (i) json += ",";
      json += String(btnState[i][0].latchOn ? 1 : 0);
      json += ",";
      json += String(btnState[i][1].latchOn ? 1 : 0);
    }
    // Thumbstick — report the mixed output value on each configured axis
    json += "],\"tx\":" + String(axisVal[thumbCfg.axisX]);
    json += ",\"ty\":" + String(axisVal[thumbCfg.axisY]);
    // Cap touch — report raw reading and active state
    json += ",\"touch\":"       + String((unsigned long)touchRead(TOUCH_PIN));
    json += ",\"touchActive\":"  + String(touchBtnState.btnPhysical  ? "true" : "false");
    json += ",\"touch2\":"      + String((unsigned long)touchRead(TOUCH2_PIN));
    json += ",\"touch2Active\":" + String(touch2BtnState.btnPhysical ? "true" : "false");
    json += "}";
    req->send(200, "application/json", json);
  });

  server.on("/calibrate", HTTP_GET, [](AsyncWebServerRequest* req) {
    doCalibrate = true;
    req->send(200, "application/json", "{\"ok\":true}");
  });

  server.on("/profiles", HTTP_GET, [](AsyncWebServerRequest* req) {
    req->send(200, "application/json", buildProfileListJSON());
  });

  // /profile/save accepts: {"slot":N,"name":"...","cfg":{...full config...}}
  // When "cfg" is present the config is parsed and written atomically in the same
  // body handler — no second /save request needed, no timing race.
  // The Apply button also routes here (slot=-1 means active slot) when a profile
  // is loaded, so edits persist without a separate /save call.
  server.on("/profile/save", HTTP_POST,
    [](AsyncWebServerRequest* req) { req->send(200, "application/json", "{\"ok\":true}"); },
    NULL,
    [](AsyncWebServerRequest* req, uint8_t* data, size_t len, size_t index, size_t total) {
      String body((char*)data, len);
      int sp = body.indexOf("\"slot\":"); if (sp < 0) return;
      int slot = body.substring(sp + 7).toInt();
      int np = body.indexOf("\"name\":\""); if (np < 0) return;
      int ns = np + 8, ne = body.indexOf("\"", ns);
      String name = body.substring(ns, ne); name.trim();
      if (name.length() == 0) name = "Profile " + String(slot + 1);
      if (name.length() >= PROFILE_NAME_LEN) name = name.substring(0, PROFILE_NAME_LEN - 1);
      // If a "cfg" block is embedded, parse it into cfg[] and save working config too
      int cp = body.indexOf("\"cfg\":");
      if (cp >= 0) {
        parseConfigBody(body.substring(cp + 6));
        saveActiveConfig();
      }
      saveProfile(slot, name.c_str());
      strncpy(activeProfile, name.c_str(), PROFILE_NAME_LEN - 1);
      activeProfile[PROFILE_NAME_LEN - 1] = '\0';
      activeProfileSlot = slot;
      drawProfileName(activeProfile);
    }
  );

  server.on("/profile/load", HTTP_GET, [](AsyncWebServerRequest* req) {
    if (!req->hasParam("slot")) { req->send(400, "application/json", "{\"ok\":false}"); return; }
    int slot = req->getParam("slot")->value().toInt();
    bool ok = loadProfile(slot);
    if (ok) {
      saveActiveConfig();
      memset(btnState, 0, sizeof(btnState));
      memset(axisVal,  0, sizeof(axisVal));
      memset(&touchBtnState,  0, sizeof(touchBtnState));
      memset(&touch2BtnState, 0, sizeof(touch2BtnState));
      resetFilters();
      prefs.begin("profiles", true);
      char nk[12]; sprintf(nk, "n%d", slot);
      String n = prefs.getString(nk, "Unsaved");
      prefs.end();
      strncpy(activeProfile, n.c_str(), PROFILE_NAME_LEN - 1);
      activeProfile[PROFILE_NAME_LEN - 1] = '\0';
      activeProfileSlot = slot;
      drawProfileName(activeProfile);
    }
    String json = "{\"ok\":" + String(ok?"true":"false") + ",\"cfg\":" + buildConfigJSON() + "}";
    req->send(200, "application/json", json);
  });

  server.on("/profile", HTTP_DELETE, [](AsyncWebServerRequest* req) {
    if (!req->hasParam("slot")) { req->send(400, "application/json", "{\"ok\":false}"); return; }
    deleteProfile(req->getParam("slot")->value().toInt());
    req->send(200, "application/json", "{\"ok\":true}");
  });

  // ── Profile reorder ────────────────────────────────────────────────────────
  // Body: {"order":[slotA,slotB,...]} — the desired new slot sequence.
  // We copy each profile into a scratch namespace, clear the originals, then
  // write them back into slots 0, 1, 2… in the requested order.
  server.on("/profile/reorder", HTTP_POST,
    [](AsyncWebServerRequest* req) { req->send(200, "application/json", "{\"ok\":true}"); },
    NULL,
    [](AsyncWebServerRequest* req, uint8_t* data, size_t len, size_t index, size_t total) {
      String body((char*)data, len);
      // Parse the order array: grab every integer after "order":[
      int ap = body.indexOf("\"order\":[");
      if (ap < 0) return;
      ap += 9;  // skip past "order":[

      // Read up to MAX_PROFILES slot indices from the array
      int order[MAX_PROFILES];
      int orderLen = 0;
      int p = ap;
      while (orderLen < MAX_PROFILES) {
        while (p < (int)body.length() && (body[p] == ' ' || body[p] == ',')) p++;
        if (p >= (int)body.length() || body[p] == ']') break;
        int val = body.substring(p).toInt();
        if (val < 0 || val >= MAX_PROFILES) break;
        order[orderLen++] = val;
        while (p < (int)body.length() && body[p] != ',' && body[p] != ']') p++;
      }
      if (orderLen == 0) return;

      // ── 1. Snapshot each profile into RAM using the blob format ────────────
      struct ProfSnap {
        bool        valid;
        char        name[PROFILE_NAME_LEN];
        ProfileBlob blob;
      };
      ProfSnap* snaps = new ProfSnap[MAX_PROFILES];
      if (!snaps) return;
      memset(snaps, 0, sizeof(ProfSnap) * MAX_PROFILES);

      prefs.begin("profiles", true);
      for (int i = 0; i < orderLen; i++) {
        int s = order[i];
        char nk[4]; sprintf(nk, "n%d", s);
        if (!prefs.isKey(nk)) { snaps[i].valid = false; continue; }
        String n = prefs.getString(nk, "");
        strncpy(snaps[i].name, n.c_str(), PROFILE_NAME_LEN - 1);
        snaps[i].name[PROFILE_NAME_LEN - 1] = '\0';
        char dk[4]; sprintf(dk, "d%d", s);
        if (!prefs.isKey(dk)) { snaps[i].valid = false; continue; }
        size_t got = prefs.getBytes(dk, &snaps[i].blob, sizeof(ProfileBlob));
        snaps[i].valid = (got == sizeof(ProfileBlob));
      }
      prefs.end();

      // ── 2. Delete all affected slots ────────────────────────────────────────
      for (int i = 0; i < orderLen; i++) deleteProfile(order[i]);

      // ── 3. Write snaps back into slots 0, 1, 2… in the new order ───────────
      prefs.begin("profiles", false);
      for (int newSlot = 0; newSlot < orderLen; newSlot++) {
        if (!snaps[newSlot].valid) continue;
        char nk[4]; sprintf(nk, "n%d", newSlot);
        prefs.putString(nk, snaps[newSlot].name);
        char dk[4]; sprintf(dk, "d%d", newSlot);
        prefs.putBytes(dk, &snaps[newSlot].blob, sizeof(ProfileBlob));
      }
      prefs.end();
      delete[] snaps;

      // ── 4. Update activeProfileSlot to reflect its new position ─────────────
      for (int i = 0; i < orderLen; i++) {
        if (order[i] == activeProfileSlot) {
          activeProfileSlot = i;
          break;
        }
      }
    }
  );

  // ── WiFi management endpoints ─────────────────────────────────────────────
  // ── Logo endpoint — serves LOGO_MAP (already in flash) as a BMP ────────────
  // Uses BITMAPV4 header with BI_BITFIELDS so browsers render RGB565 correctly.
  server.on("/logo.bmp", HTTP_GET, [](AsyncWebServerRequest* req) {
    // BMP file = 122-byte BITMAPV4 header + raw pixel data (bottom-up, no padding for 240px)
    const uint32_t W          = 240;
    const uint32_t H          = 240;
    const uint32_t pixelBytes = W * H * 2;          // RGB565 = 2 bytes/pixel
    const uint32_t headerSize = 122;                // BITMAPV4HEADER
    const uint32_t fileSize   = headerSize + pixelBytes;

    // Build the header in RAM (122 bytes is trivial)
    uint8_t hdr[122] = {};
    // BMP file header (14 bytes)
    hdr[0]='B'; hdr[1]='M';
    hdr[2]=(uint8_t)fileSize;       hdr[3]=(uint8_t)(fileSize>>8);
    hdr[4]=(uint8_t)(fileSize>>16); hdr[5]=(uint8_t)(fileSize>>24);
    // reserved: hdr[6..9] = 0
    hdr[10]=headerSize; // pixel data offset (low byte, fits in 1 byte)
    // DIB header (BITMAPV4HEADER = 108 bytes)
    hdr[14]=108; // biSize
    hdr[18]=(uint8_t)W;       hdr[19]=(uint8_t)(W>>8);
    hdr[20]=(uint8_t)(W>>16); hdr[21]=(uint8_t)(W>>24);
    // Height negative = top-down. LOGO_MAP is stored top-to-bottom.
    int32_t negH = -(int32_t)H;
    hdr[22]=(uint8_t)negH;       hdr[23]=(uint8_t)(negH>>8);
    hdr[24]=(uint8_t)(negH>>16); hdr[25]=(uint8_t)(negH>>24);
    hdr[26]=1; hdr[27]=0;   // biPlanes = 1
    hdr[28]=16; hdr[29]=0;  // biBitCount = 16
    hdr[30]=3;               // biCompression = BI_BITFIELDS
    // biSizeImage
    hdr[34]=(uint8_t)pixelBytes;       hdr[35]=(uint8_t)(pixelBytes>>8);
    hdr[36]=(uint8_t)(pixelBytes>>16); hdr[37]=(uint8_t)(pixelBytes>>24);
    // pixels/meter (2835 ≈ 72dpi) — not critical
    hdr[38]=0x13; hdr[39]=0x0B; hdr[42]=0x13; hdr[43]=0x0B;
    // RGB565 bit masks (little-endian)
    // Red   mask: 0xF800
    hdr[54]=0x00; hdr[55]=0xF8; hdr[56]=0x00; hdr[57]=0x00;
    // Green mask: 0x07E0
    hdr[58]=0xE0; hdr[59]=0x07; hdr[60]=0x00; hdr[61]=0x00;
    // Blue  mask: 0x001F
    hdr[62]=0x1F; hdr[63]=0x00; hdr[64]=0x00; hdr[65]=0x00;
    // Alpha mask: 0x00000000
    // (hdr[66..69] already 0)

    // LOGO_MAP is stored little-endian RGB565 but with bytes swapped (hi then lo).
    // BMP RGB565 wants little-endian (lo byte first). We need to swap each pair.
    // Stream in chunks to avoid large heap allocation.
    AsyncResponseStream* resp = req->beginResponseStream("image/bmp");
    resp->addHeader("Cache-Control", "public, max-age=3600");
    resp->write(hdr, headerSize);

    // Stream pixel rows from PROGMEM, swapping each 2-byte pixel
    const size_t CHUNK_PIXELS = 240;  // one full row at a time
    uint8_t rowBuf[CHUNK_PIXELS * 2];
    for (uint32_t row = 0; row < H; row++) {
      uint32_t base = row * W * 2;
      for (uint32_t col = 0; col < W; col++) {
        uint8_t lo = pgm_read_byte(LOGO_MAP + base + col*2);
        uint8_t hi = pgm_read_byte(LOGO_MAP + base + col*2 + 1);
        // LVGL stores hi-byte first; BMP RGB565 wants lo-byte first
        rowBuf[col*2]   = lo;
        rowBuf[col*2+1] = hi;
      }
      resp->write(rowBuf, sizeof(rowBuf));
    }
    req->send(resp);
  });

  server.on("/wifi/status", HTTP_GET, [](AsyncWebServerRequest* req) {
    req->send(200, "application/json", buildWiFiStatusJSON());
  });

  server.on("/wifi/scan", HTTP_GET, [](AsyncWebServerRequest* req) {
    int n = WiFi.scanNetworks(false, true);
    String json = "[";
    for (int i = 0; i < n; i++) {
      if (i) json += ",";
      String ssid = WiFi.SSID(i);
      ssid.replace("\"", "\\\"");
      json += "{\"ssid\":\"" + ssid + "\","
              "\"rssi\":"    + String(WiFi.RSSI(i)) + ","
              "\"enc\":"     + String((WiFi.encryptionType(i) == WIFI_AUTH_OPEN) ? 0 : 1) + "}";
    }
    json += "]";
    WiFi.scanDelete();
    req->send(200, "application/json", json);
  });

  server.on("/wifi/connect", HTTP_POST,
    [](AsyncWebServerRequest* req) { req->send(200, "application/json", "{\"ok\":true}"); },
    NULL,
    [](AsyncWebServerRequest* req, uint8_t* data, size_t len, size_t index, size_t total) {
      String body((char*)data, len);
      int si = body.indexOf("\"ssid\":\""); if (si < 0) return;
      int ss = si + 8, se = body.indexOf("\"", ss); if (se < 0) return;
      int pi = body.indexOf("\"pass\":\""); if (pi < 0) return;
      int ps = pi + 8, pe = body.indexOf("\"", ps); if (pe < 0) return;
      String ssid = body.substring(ss, se);
      String pass = body.substring(ps, pe);
      if (ssid.length() == 0) return;
      saveWiFiCreds(ssid.c_str(), pass.c_str());
      WiFi.disconnect(false);
      delay(100);
      WiFi.begin(ssid.c_str(), pass.c_str());
      Serial.print("WiFi connect requested: "); Serial.println(ssid);
    }
  );

  server.on("/wifi/forget", HTTP_POST, [](AsyncWebServerRequest* req) {
    clearWiFiCreds();
    WiFi.disconnect(true);
    req->send(200, "application/json", "{\"ok\":true}");
  });

  // ── Display color endpoints ────────────────────────────────────────────────
  server.on("/colors/get", HTTP_GET, [](AsyncWebServerRequest* req) {
    req->send(200, "application/json", buildColorsJSON());
  });

  server.on("/colors/set", HTTP_POST,
    [](AsyncWebServerRequest* req) { /* body arrives in the handler below */ },
    NULL,
    [](AsyncWebServerRequest* req, uint8_t* data, size_t len, size_t index, size_t total) {
      String body((char*)data, len);
      auto parseField = [&](const char* key) -> int32_t {
        String sk = String("\"") + key + "\":\"#";
        int pos = body.indexOf(sk);
        if (pos < 0) return -1;
        pos += sk.length();
        String hex = body.substring(pos, pos + 6);
        return (int32_t)strtoul(hex.c_str(), nullptr, 16);
      };
      int32_t puff  = parseField("puff");
      int32_t sip   = parseField("sip");
      int32_t off   = parseField("off");
      int32_t pname = parseField("profileName");
      int32_t plbl  = parseField("profileLabel");
      int32_t blbl  = parseField("btnLabel");
      int32_t bg    = parseField("bg");
      int32_t ibdr  = parseField("iconBorder");
      int32_t t1    = parseField("touch1");
      int32_t t2    = parseField("touch2");
      if (puff  >= 0) C_PUFF_ON      = rgb888_to_rgb565((uint32_t)puff);
      if (sip   >= 0) C_SIP_ON       = rgb888_to_rgb565((uint32_t)sip);
      if (off   >= 0) C_OFF          = rgb888_to_rgb565((uint32_t)off);
      if (pname >= 0) C_PROFILE_NAME = rgb888_to_rgb565((uint32_t)pname);
      if (plbl  >= 0) C_PROFILE_LBL  = rgb888_to_rgb565((uint32_t)plbl);
      if (blbl  >= 0) C_BTN_LABEL    = rgb888_to_rgb565((uint32_t)blbl);
      if (bg    >= 0) C_BG_RT        = rgb888_to_rgb565((uint32_t)bg);
      if (ibdr  >= 0) C_ICON_BORDER  = rgb888_to_rgb565((uint32_t)ibdr);
      if (t1    >= 0) C_TOUCH1_ON    = rgb888_to_rgb565((uint32_t)t1);
      if (t2    >= 0) C_TOUCH2_ON    = rgb888_to_rgb565((uint32_t)t2);
      doSaveColors = true;  // NVS write happens safely in loop(), not here
      drawDisplay();
      req->send(200, "application/json", "{\"ok\":true}");
    }
  );

  // ── LED endpoints ──────────────────────────────────────────────────────────
  server.on("/led/get", HTTP_GET, [](AsyncWebServerRequest* req) {
    req->send(200, "application/json", buildLedJSON());
  });

  server.on("/led/set", HTTP_POST,
    [](AsyncWebServerRequest* req) { /* body arrives below */ },
    NULL,
    [](AsyncWebServerRequest* req, uint8_t* data, size_t len, size_t index, size_t total) {
      String body((char*)data, len);
      // Parse "on": true/false
      int op = body.indexOf("\"on\":");
      if (op >= 0) {
        ledCfg.on = body.substring(op + 5).startsWith("true");
      }
      // Parse "color": "#rrggbb"
      int cp = body.indexOf("\"color\":\"#");
      if (cp >= 0) {
        String hex = body.substring(cp + 10, cp + 16);
        uint32_t rgb = (uint32_t)strtoul(hex.c_str(), nullptr, 16);
        ledCfg.r = (rgb >> 16) & 0xFF;
        ledCfg.g = (rgb >>  8) & 0xFF;
        ledCfg.b =  rgb        & 0xFF;
      }
      // Parse "brightness": 0-255
      int bp = body.indexOf("\"brightness\":");
      if (bp >= 0) {
        int val = body.substring(bp + 13).toInt();
        if (val < 0)   val = 0;
        if (val > 255) val = 255;
        ledCfg.brightness = (uint8_t)val;
      }
      applyLed();
      saveLedConfig();
      req->send(200, "application/json", "{\"ok\":true}");
    }
  );

  server.begin();
  Serial.println("Web server ready.");
}

// ── Boot: load the first saved profile ────────────────────────────────────────
// Scans slots 0-7 in order and loads the first one that exists.
// If no profiles are saved, the active working config (from "gpad") is kept.
void loadFirstProfile() {
  int firstSlot = -1;
  char firstName[PROFILE_NAME_LEN] = "Unsaved";

  prefs.begin("profiles", true);
  for (int s = 0; s < MAX_PROFILES; s++) {
    char nk[4]; sprintf(nk, "n%d", s);
    if (!prefs.isKey(nk)) continue;
    String n = prefs.getString(nk, "");
    if (n.length() == 0) continue;
    firstSlot = s;
    strncpy(firstName, n.c_str(), PROFILE_NAME_LEN - 1);
    firstName[PROFILE_NAME_LEN - 1] = '\0';
    break;
  }
  prefs.end();

  if (firstSlot < 0) {
    Serial.println("No saved profiles — using active config.");
    return;
  }

  if (!loadProfile(firstSlot)) return;
  saveActiveConfig();
  memset(btnState, 0, sizeof(btnState));
  memset(axisVal,  0, sizeof(axisVal));
  resetFilters();

  activeProfileSlot = firstSlot;
  strncpy(activeProfile, firstName, PROFILE_NAME_LEN - 1);
  activeProfile[PROFILE_NAME_LEN - 1] = '\0';
  Serial.printf("Boot: loaded profile slot %d \"%s\"\n", firstSlot, activeProfile);
}

// ── Setup ─────────────────────────────────────────────────────────────────────
void setup() {
  Serial.begin(115200);
  delay(500);

  // Uncomment once to clear stale NVS, then comment out and reflash:
  // prefs.begin("gpad", false); prefs.clear(); prefs.end();

  gfx->begin();
  gfx->fillScreen(C_BG);

  // ── Boot logo ───────────────────────────────────────────────────────────────
  drawLogo();
  delay(2000);  // show logo for 2 seconds before continuing boot
  // ───────────────────────────────────────────────────────────────────────────

  for (int i = 0; i < NUM_SENSORS; i++) {
    pinMode(sensors[i].dout, INPUT);
    pinMode(sensors[i].sck,  OUTPUT);
    digitalWrite(sensors[i].sck, LOW);
  }

  memset(btnState, 0, sizeof(btnState));
  memset(axisVal,  0, sizeof(axisVal));
  resetFilters();

  loadActiveConfig();
  loadFirstProfile();   // override with first saved profile if any exist
  loadDisplayColors();  // load persisted icon colors before first draw
  loadLedConfig();
  rgbLed.begin();
  applyLed();
  // Configure thumbstick ADC pins (INPUT; no pull-up needed for resistive sticks)
  pinMode(THUMB_X_PIN, INPUT);
  pinMode(THUMB_Y_PIN, INPUT);
  analogReadResolution(12);  // 12-bit ADC, 0-4095
  USB.begin();
  Gamepad.begin();
  Gamepad.hat(HAT_CENTER);  // initialize hat to centered; prevents stale HID report value at boot

  WiFi.mode(WIFI_AP_STA);
  WiFi.softAP(AP_SSID, AP_PASS);
  delay(500);
  IPAddress apIP = WiFi.softAPIP();
  Serial.print("AP SSID: "); Serial.println(AP_SSID);
  Serial.print("AP IP:   "); Serial.println(apIP);

  // ── Try connecting to saved local WiFi credentials ──────────────────────────
  {
    String savedSSID, savedPass;
    if (loadWiFiCreds(savedSSID, savedPass)) {
      Serial.print("Connecting to saved WiFi: "); Serial.println(savedSSID);
      WiFi.begin(savedSSID.c_str(), savedPass.c_str());
      uint32_t t = millis();
      while (WiFi.status() != WL_CONNECTED && millis() - t < 8000) delay(200);
      if (WiFi.status() == WL_CONNECTED) {
        Serial.print("STA IP: "); Serial.println(WiFi.localIP());
      } else {
        Serial.println("STA connect timed out — staying on AP only.");
        WiFi.disconnect(false);
      }
    }
  }

  dnsServer.setErrorReplyCode(DNSReplyCode::NoError);
  dnsServer.start(53, "*", apIP);

  setupWebServer();

  // ── Final calibration — everything is up, do a clean baseline read ─────────
  // Increased pre-calibration delay: gives WiFi/DNS/web server time to fully
  // settle, and lets the HX710B sensors stabilize after the busy init phase.
  Serial.printf("Free heap before calibration: %lu bytes\n", ESP.getFreeHeap());
  delay(1500);  // was 500ms — extra time helps sensors stabilize at boot
  calibrateBaselines();
  resetFilters();

  Serial.printf("Free heap after setup: %lu bytes\n", ESP.getFreeHeap());
  Serial.println("Ready.");
}

// ── Loop ──────────────────────────────────────────────────────────────────────
void loop() {
  dnsServer.processNextRequest();

  if (doCalibrate) {
    doCalibrate = false;
    calibrateBaselines();
    resetFilters();
  }

  if (doNextProfile) {
    doNextProfile = false;
    loadNextProfile();
  }

  if (doSaveColors) {
    doSaveColors = false;
    saveDisplayColors();
  }

  // ── Thumbstick — read first, composite with sensors before sending ────────────
  // Read thumb ADC now (before sensor processing) so the EMA filter keeps running
  // at full loop rate, but defer sending until sensor analog values are also ready.
  // This prevents the double-send that caused pressure sensor axes to accumulate
  // the thumbstick contribution twice per frame (jitter + wrap-around).
  int16_t tx = readThumbAxis(THUMB_X_PIN, thumbCfg.deadzone, thumbCfg.invertX);
  int16_t ty = readThumbAxis(THUMB_Y_PIN, thumbCfg.deadzone, thumbCfg.invertY);

  memset(axisVal, 0, sizeof(axisVal));
  hatState = 0;

  if (thumbCfg.axisX < 6) axisVal[thumbCfg.axisX] = tx;
  if (thumbCfg.axisY < 6) axisVal[thumbCfg.axisY] = ty;

  // ── HX710B sensors — non-blocking, cached value returned if not ready ─────────
  // Snapshot display state for partial-update diff
  bool puffWas[NUM_SENSORS], sipWas[NUM_SENSORS];
  for (int i = 0; i < NUM_SENSORS; i++) {
    puffWas[i] = displayPuffActive[i];
    sipWas[i]  = displaySipActive[i];
  }

  bool sensorChanged   = false;
  bool anyAnalogSensor = false;   // true if any sensor is in analog mode this frame

  for (int i = 0; i < NUM_SENSORS; i++) {
    int32_t raw   = hx710b_read_nb(i);   // non-blocking
    int32_t delta = raw - baseline[i];
    lastDelta[i]  = delta;

    bool puffAnalog = (cfg[i].puff.mode == 3);
    bool sipAnalog  = (cfg[i].sip.mode  == 3);
    if (puffAnalog || sipAnalog) anyAnalogSensor = true;

    int32_t smoothed = (puffAnalog || sipAnalog) ? filteredDelta(i, delta) : 0;

    // ── Puff side ──────────────────────────────────────────────────────────────
    if (puffAnalog) {
      int16_t val = mapAnalog(smoothed, cfg[i].puff.deadzone, cfg[i].puff.maxRange,
                              cfg[i].puff.curve, cfg[i].puff.invert);
      if (cfg[i].puff.axis < 6) {
        int32_t added = (int32_t)axisVal[cfg[i].puff.axis] + (int32_t)val;
        if (added >  127) added =  127;
        if (added < -127) added = -127;
        axisVal[cfg[i].puff.axis] = (int16_t)added;
      }

      // ── Analog + combo/button: press when pressure exceeds dead-zone ─────────
      // This allows a button (and optional combo) to fire alongside the axis
      // output. The button tracks the dead-zone crossing, not the threshold
      // slider, since analog mode has no separate threshold concept.
      if (cfg[i].puff.button > 0 && cfg[i].puff.button <= 16) {
        bool analogActive = (delta > cfg[i].puff.deadzone);
        uint8_t btn = cfg[i].puff.button - 1;
        if (analogActive && !btnState[i][0].btnPhysical) {
          Gamepad.pressButton(btn);
          pressCombo(cfg[i].puff.combo, btn);
          btnState[i][0].btnPhysical = true;
        } else if (!analogActive && btnState[i][0].btnPhysical) {
          Gamepad.releaseButton(btn);
          releaseCombo(cfg[i].puff.combo, btn);
          btnState[i][0].btnPhysical = false;
        }
      } else if (btnState[i][0].btnPhysical) {
        // No button assigned — make sure nothing is stuck
        Gamepad.releaseButton(cfg[i].puff.button > 0 ? cfg[i].puff.button - 1 : 0);
        releaseCombo(cfg[i].puff.combo, cfg[i].puff.button > 0 ? cfg[i].puff.button - 1 : 0);
        btnState[i][0].btnPhysical = false;
      }

      displayPuffActive[i] = (delta > cfg[i].puff.deadzone);
    } else {
      bool puffActive = (cfg[i].puff.button > 0 && delta >  cfg[i].puffThresh);
      handleButton(cfg[i].puff, btnState[i][0], puffActive);
      displayPuffActive[i] = puffActive;
    }

    // ── Sip side ───────────────────────────────────────────────────────────────
    if (sipAnalog) {
      int16_t val = mapAnalog(smoothed, cfg[i].sip.deadzone, cfg[i].sip.maxRange,
                              cfg[i].sip.curve, cfg[i].sip.invert);
      if (cfg[i].sip.axis < 6) {
        int32_t added = (int32_t)axisVal[cfg[i].sip.axis] + (int32_t)val;
        if (added >  127) added =  127;
        if (added < -127) added = -127;
        axisVal[cfg[i].sip.axis] = (int16_t)added;
      }

      // ── Analog + combo/button: press when pressure exceeds dead-zone ─────────
      if (cfg[i].sip.button > 0 && cfg[i].sip.button <= 16) {
        bool analogActive = (delta < -cfg[i].sip.deadzone);
        uint8_t btn = cfg[i].sip.button - 1;
        if (analogActive && !btnState[i][1].btnPhysical) {
          Gamepad.pressButton(btn);
          pressCombo(cfg[i].sip.combo, btn);
          btnState[i][1].btnPhysical = true;
        } else if (!analogActive && btnState[i][1].btnPhysical) {
          Gamepad.releaseButton(btn);
          releaseCombo(cfg[i].sip.combo, btn);
          btnState[i][1].btnPhysical = false;
        }
      } else if (btnState[i][1].btnPhysical) {
        // No button assigned — make sure nothing is stuck
        Gamepad.releaseButton(cfg[i].sip.button > 0 ? cfg[i].sip.button - 1 : 0);
        releaseCombo(cfg[i].sip.combo, cfg[i].sip.button > 0 ? cfg[i].sip.button - 1 : 0);
        btnState[i][1].btnPhysical = false;
      }

      displaySipActive[i] = (delta < -cfg[i].sip.deadzone);
    } else {
      bool sipActive = (cfg[i].sip.button > 0 && delta < -cfg[i].sipThresh);
      handleButton(cfg[i].sip, btnState[i][1], sipActive);
      displaySipActive[i] = sipActive;
    }

    if (displayPuffActive[i] != puffWas[i] || displaySipActive[i] != sipWas[i])
      sensorChanged = true;
  }

  // ── Capacitive touch input (pin 1) ───────────────────────────────────────────
  // ESP32-S3: touchRead() increases when touched; active when raw > threshold.
  // Read once here; the same value is reused for handleButton AND display state.
  uint32_t touchRaw1 = touchRead(TOUCH_PIN);
  bool touchActive = false;
  if (touchCfg.button != 0) {
    touchActive = (touchRaw1 > touchCfg.threshold);
    // Reuse handleButton with a synthetic ButtonConfig
    ButtonConfig tcBc;
    tcBc.button   = touchCfg.button;
    tcBc.mode     = touchCfg.mode;
    tcBc.repeatMs = touchCfg.repeatMs;
    tcBc.axis     = 0;    // unused for touch (no analog mode)
    tcBc.curve    = 0;
    tcBc.deadzone = 0;
    tcBc.maxRange = 0;
    tcBc.invert   = false;
    tcBc.combo    = touchCfg.combo;
    handleButton(tcBc, touchBtnState, touchActive);
  } else if (touchBtnState.btnPhysical) {
    // Button was disabled mid-use — release any stuck press
    Gamepad.releaseButton(touchCfg.button > 0 ? touchCfg.button - 1 : 0);
    releaseCombo(touchCfg.combo, touchCfg.button > 0 ? touchCfg.button - 1 : 0);
    touchBtnState.btnPhysical = false;
  }

  // ── Capacitive touch input 2 (pin 2) ─────────────────────────────────────────
  uint32_t touchRaw2 = touchRead(TOUCH2_PIN);
  bool touch2Active = false;
  if (touch2Cfg.button != 0) {
    touch2Active = (touchRaw2 > touch2Cfg.threshold);
    ButtonConfig tc2Bc;
    tc2Bc.button   = touch2Cfg.button;
    tc2Bc.mode     = touch2Cfg.mode;
    tc2Bc.repeatMs = touch2Cfg.repeatMs;
    tc2Bc.axis     = 0;
    tc2Bc.curve    = 0;
    tc2Bc.deadzone = 0;
    tc2Bc.maxRange = 0;
    tc2Bc.invert   = false;
    tc2Bc.combo    = touch2Cfg.combo;
    handleButton(tc2Bc, touch2BtnState, touch2Active);
  } else if (touch2BtnState.btnPhysical) {
    Gamepad.releaseButton(touch2Cfg.button > 0 ? touch2Cfg.button - 1 : 0);
    releaseCombo(touch2Cfg.combo, touch2Cfg.button > 0 ? touch2Cfg.button - 1 : 0);
    touch2BtnState.btnPhysical = false;
  }

  // ── Touch display state update ───────────────────────────────────────────────
  // Reuse the cached reads from above — no second touchRead() call needed.
  bool rawTouch1 = (touchRaw1 > touchCfg.threshold);
  bool rawTouch2 = (touchRaw2 > touch2Cfg.threshold);
  bool touchIconChanged = (rawTouch1 != displayTouch1Active) || (rawTouch2 != displayTouch2Active);
  if (touchIconChanged) {
    displayTouch1Active = rawTouch1;
    displayTouch2Active = rawTouch2;
    drawProfileName(activeProfile);  // redraws center circle including touch icons
  }

  // Send the single combined HID report (thumb + sensors) when anything changed.
  // Analog sensors change continuously; digital sensors fire on threshold crossings.
  // thumbActive: fire whenever the stick is deflected, OR on the frame it returns
  // to zero (so the host sees the release, not a stuck axis value).
  static int16_t lastTx = 0, lastTy = 0;
  bool thumbActive = (tx != 0 || ty != 0 || lastTx != 0 || lastTy != 0);
  lastTx = tx; lastTy = ty;

  if (anyAnalogSensor || sensorChanged || thumbActive) sendAxes();

  // Partial display update — only icons whose state flipped
  bool changed[NUM_SENSORS];
  for (int i = 0; i < NUM_SENSORS; i++) {
    changed[i] = (displayPuffActive[i] != puffWas[i]) || (displaySipActive[i] != sipWas[i]);
  }
  if (sensorChanged) updateDisplay(false, changed);
}
