#include <WiFi.h>
#include <WiFiClientSecure.h>
#include <HTTPClient.h>
#include <ArduinoJson.h>

#define ENABLE_PISTAR_TOUCH_SSH 1

#if ENABLE_PISTAR_TOUCH_SSH
  #include "libssh_esp32.h"
  extern "C" {
    #include <libssh/libssh.h>
  }
#endif

#define DISPLAY_BACKEND_2432S028R     0
#define DISPLAY_BACKEND_JC3248W535C   1

#if defined(JC3248W535C)
  #undef DISPLAY_BACKEND
  #define DISPLAY_BACKEND DISPLAY_BACKEND_JC3248W535C
#elif !defined(DISPLAY_BACKEND)
  #define DISPLAY_BACKEND 1
#endif

#define DISPLAY_VERSION "0.0.0.8"

#if DISPLAY_BACKEND == DISPLAY_BACKEND_2432S028R
  #define USER_SETUP_INFO "ESP32-2432S028R"
  #define ILI9341_DRIVER

  #define TFT_WIDTH  320
  #define TFT_HEIGHT 240

  #define TFT_MISO 12
  #define TFT_MOSI 13
  #define TFT_SCLK 14
  #define TFT_CS   15
  #define TFT_DC    2
  #define TFT_RST  -1

  #define TOUCH_IRQ      36
  #define TOUCH_MOSI     32
  #define TOUCH_MISO     39
  #define TOUCH_CLK      25
  #define TOUCH_CS       33

  #define TFT_BL   21
  #define TFT_BACKLIGHT_ON HIGH

  #define SD_CS   5
  #define SD_MISO 19
  #define SD_MOSI 23
  #define SD_SCLK 18

  #define SPI_FREQUENCY       27000000
  #define SPI_READ_FREQUENCY  16000000
  #define SPI_TOUCH_FREQUENCY 2500000

  #define LOAD_GLCD
  #define LOAD_FONT2
  #define LOAD_FONT4
  #define LOAD_FONT6
  #define LOAD_FONT7
  #define LOAD_FONT8
  #define LOAD_GFXFF
  #define SMOOTH_FONT

  #include <TFT_eSPI.h>
  #include <XPT2046_Touchscreen.h>
#elif DISPLAY_BACKEND == DISPLAY_BACKEND_JC3248W535C
  #include <Arduino_GFX_Library.h>
  #include <lvgl.h>
  #include <Wire.h>

  #define USER_SETUP_INFO "JC3248W535C"

  #define TFT_WIDTH  480
  #define TFT_HEIGHT 320

  #define TFT_BL   1
  #define TFT_BACKLIGHT_ON HIGH

  #define TFT_MISO -1
  #define TFT_MOSI -1
  #define TFT_SCLK 47
  #define TFT_CS   45
  #define TFT_DC   -1
  #define TFT_RST  -1

  #define TOUCH_IRQ    3
  #define TOUCH_MOSI   4
  #define TOUCH_MISO  -1
  #define TOUCH_CLK    8L
  #define TOUCH_CS    -1

  #define SD_CS   10
  #define SD_MISO 13
  #define SD_MOSI 11
  #define SD_SCLK 12

  #define SPI_FREQUENCY       40000000
  #define SPI_READ_FREQUENCY  40000000
  #define SPI_TOUCH_FREQUENCY 400000

  #ifndef JC3248_ENABLE_LVGL
    #define JC3248_ENABLE_LVGL 1
  #endif

  static inline uint16_t color565(uint8_t r, uint8_t g, uint8_t b) {
    return ((r & 0xF8) << 8) | ((g & 0xFC) << 3) | (b >> 3);
  }

  #ifndef TFT_BLACK
    #define TFT_BLACK     color565(0, 0, 0)
    #define TFT_WHITE     color565(255, 255, 255)
    #define TFT_RED       color565(255, 0, 0)
    #define TFT_GREEN     color565(0, 255, 0)
    #define TFT_BLUE      color565(0, 0, 255)
    #define TFT_YELLOW    color565(255, 255, 0)
    #define TFT_CYAN      color565(0, 255, 255)
    #define TFT_DARKGREY  color565(64, 64, 64)
    #define TFT_LIGHTGREY color565(192, 192, 192)
  #endif

  #ifndef TL_DATUM
    #define TL_DATUM 0
    #define TC_DATUM 1
    #define TR_DATUM 2
    #define ML_DATUM 3
    #define MC_DATUM 4
    #define MR_DATUM 5
  #endif

  struct TS_Point {
    int16_t x;
    int16_t y;
    int16_t z;
    TS_Point(int16_t px = 0, int16_t py = 0, int16_t pz = 0) : x(px), y(py), z(pz) {}
  };

  class DisplayCompat {
  public:
    static const int16_t PHYS_W = 480;
    static const int16_t PHYS_H = 320;
    static const int16_t LOGICAL_W = PHYS_W;
    static const int16_t LOGICAL_H = PHYS_H;

    Arduino_DataBus *bus = nullptr;
    Arduino_AXS15231B *panel = nullptr;
    Arduino_Canvas *gfx = nullptr;
    uint8_t datum = TL_DATUM;
    uint8_t rotation = 1;
    uint8_t textSize = 1;
    uint16_t fg = TFT_WHITE;
    uint16_t bg = TFT_BLACK;

    bool initOk = false;

    float scale() const {
      float sx = (float)PHYS_W / (float)LOGICAL_W;
      float sy = (float)PHYS_H / (float)LOGICAL_H;
      return (sx < sy) ? sx : sy;
    }

    int16_t scaledW() const { return (int16_t)lroundf(LOGICAL_W * scale()); }
    int16_t scaledH() const { return (int16_t)lroundf(LOGICAL_H * scale()); }
    int16_t originX() const { return (PHYS_W - scaledW()) / 2; }
    int16_t originY() const { return (PHYS_H - scaledH()) / 2; }
    int16_t tx(int16_t x) const { return originX() + (int16_t)lroundf(x * scale()); }
    int16_t ty(int16_t y) const { return originY() + (int16_t)lroundf(y * scale()); }
    int16_t tw(int16_t w) const {
      int16_t v = (int16_t)lroundf(w * scale());
      return (v < 1) ? 1 : v;
    }
    int16_t th(int16_t h) const {
      int16_t v = (int16_t)lroundf(h * scale());
      return (v < 1) ? 1 : v;
    }
    int16_t tr(int16_t r) const {
      int16_t v = (int16_t)lroundf(r * scale());
      return (v < 1) ? 1 : v;
    }

    void init() {
      bus = new Arduino_ESP32QSPI(TFT_CS, TFT_SCLK, 21, 48, 40, 39);
      panel = new Arduino_AXS15231B(bus, TFT_RST, 0, false, 320, 480);
      gfx = new Arduino_Canvas(320, 480, panel, 0, 0, rotation);

      if (gfx) {
        initOk = gfx->begin();
        gfx->setRotation(rotation);
        gfx->setTextWrap(false);
        gfx->setTextSize(textSize);
        gfx->setTextColor(fg, bg);
        gfx->fillScreen(TFT_BLACK);
        gfx->flush();
      }
    }

    bool ready() const { return initOk && gfx != nullptr; }

    void setRotation(uint8_t r) {
      rotation = r;
      if (gfx) gfx->setRotation(rotation);
      flush();
    }

    int16_t width() const { return LOGICAL_W; }
    int16_t height() const { return LOGICAL_H; }

    void flush() { if (gfx) gfx->flush(); }

    void fillScreen(uint16_t c) { if (gfx) gfx->fillScreen(c); }
    void fillRect(int16_t x, int16_t y, int16_t w, int16_t h, uint16_t c) {
      if (gfx) gfx->fillRect(tx(x), ty(y), tw(w), th(h), c);
    }
    void drawPixel(int16_t x, int16_t y, uint16_t c) { if (gfx) gfx->drawPixel(tx(x), ty(y), c); }
    void drawLine(int16_t x0, int16_t y0, int16_t x1, int16_t y1, uint16_t c) {
      if (gfx) gfx->drawLine(tx(x0), ty(y0), tx(x1), ty(y1), c);
    }
    void drawFastHLine(int16_t x, int16_t y, int16_t w, uint16_t c) {
      if (gfx) gfx->drawFastHLine(tx(x), ty(y), tw(w), c);
    }
    void drawFastVLine(int16_t x, int16_t y, int16_t h, uint16_t c) {
      if (gfx) gfx->drawFastVLine(tx(x), ty(y), th(h), c);
    }
    void drawCircle(int16_t x, int16_t y, int16_t r, uint16_t c) {
      if (gfx) gfx->drawCircle(tx(x), ty(y), tr(r), c);
    }
    void fillCircle(int16_t x, int16_t y, int16_t r, uint16_t c) {
      if (gfx) gfx->fillCircle(tx(x), ty(y), tr(r), c);
    }
    void drawRoundRect(int16_t x, int16_t y, int16_t w, int16_t h, int16_t r, uint16_t c) {
      if (gfx) gfx->drawRoundRect(tx(x), ty(y), tw(w), th(h), tr(r), c);
    }
    void fillRoundRect(int16_t x, int16_t y, int16_t w, int16_t h, int16_t r, uint16_t c) {
      if (gfx) gfx->fillRoundRect(tx(x), ty(y), tw(w), th(h), tr(r), c);
    }

    void setTextColor(uint16_t fgColor, uint16_t bgColor = TFT_BLACK) {
      fg = fgColor;
      bg = bgColor;
      if (gfx) gfx->setTextColor(fg, bg);
    }

    void setTextDatum(uint8_t d) { datum = d; }

    void setTextFont(uint8_t font) {
      if (font >= 8) textSize = 10;
      else if (font >= 7) textSize = 8;
      else if (font >= 6) textSize = 6;
      else if (font >= 4) textSize = 4;
      else if (font >= 2) textSize = 2;
      else textSize = 1;

      if (gfx) {
        gfx->setFont(nullptr);
        gfx->setTextSize(textSize);
      }
    }

    int16_t textWidth(const String& s) {
      if (!gfx) return 0;

      gfx->setFont(nullptr);
      gfx->setTextSize(textSize);
      gfx->setTextWrap(false);

      int16_t bx = 0;
      int16_t by = 0;
      uint16_t bw = 0;
      uint16_t bh = 0;
      gfx->getTextBounds(s, 0, 0, &bx, &by, &bw, &bh);

      return (int16_t)lroundf((float)bw / scale());
    }

    int16_t drawString(const String& s, int32_t x, int32_t y) {
      if (!gfx) return 0;

      gfx->setFont(nullptr);
      gfx->setTextSize(textSize);
      gfx->setTextWrap(false);

      int16_t bx = 0;
      int16_t by = 0;
      uint16_t bw = 0;
      uint16_t bh = 0;
      gfx->getTextBounds(s, 0, 0, &bx, &by, &bw, &bh);

      int16_t logicalW = (int16_t)lroundf((float)bw / scale());
      int16_t logicalH = (int16_t)lroundf((float)bh / scale());

      int32_t px = tx((int16_t)x);
      int32_t py = ty((int16_t)y);

      switch (datum) {
        case TC_DATUM:
          px -= (int32_t)bw / 2;
          break;
        case TR_DATUM:
          px -= (int32_t)bw;
          break;
        case ML_DATUM:
          py -= (int32_t)bh / 2;
          break;
        case MC_DATUM:
          px -= (int32_t)bw / 2;
          py -= (int32_t)bh / 2;
          break;
        case MR_DATUM:
          px -= (int32_t)bw;
          py -= (int32_t)bh / 2;
          break;
        default:
          break;
      }

      px -= bx;
      py -= by;

      gfx->setCursor(px, py);
      gfx->print(s);
      return logicalW > 0 ? logicalW : logicalH;
    }

    bool pushImage(int16_t x, int16_t y, int16_t w, int16_t h, const uint16_t *pixels) {
      if (!gfx || !pixels || w <= 0 || h <= 0) return false;
      gfx->draw16bitRGBBitmap(tx(x), ty(y), const_cast<uint16_t*>(pixels), w, h);
      return true;
    }

    bool pushLvglArea(int32_t x, int32_t y, int32_t w, int32_t h, const uint16_t *pixels) {
      if (!panel || !pixels || w <= 0 || h <= 0) return false;
      panel->draw16bitRGBBitmap(tx((int16_t)x), ty((int16_t)y), const_cast<uint16_t*>(pixels), w, h);
      return true;
    }
  };

  class TouchCompat {
  public:
    static const uint8_t TOUCH_ADDR = 0x3B;
    static const uint8_t MAX_TOUCHES = 1;
    static const int16_t PHYS_W = 480;
    static const int16_t PHYS_H = 320;
    static const int16_t LOGICAL_W = PHYS_W;
    static const int16_t LOGICAL_H = PHYS_H;

    bool initOk = false;
    uint8_t rotation = 1;
    TS_Point lastPoint;
    bool lastTouched = false;
    uint32_t lastPollMs = 0;

    float scale() const {
      float sx = (float)PHYS_W / (float)LOGICAL_W;
      float sy = (float)PHYS_H / (float)LOGICAL_H;
      return (sx < sy) ? sx : sy;
    }

    int16_t scaledW() const { return (int16_t)lroundf(LOGICAL_W * scale()); }
    int16_t scaledH() const { return (int16_t)lroundf(LOGICAL_H * scale()); }
    int16_t originX() const { return (PHYS_W - scaledW()) / 2; }
    int16_t originY() const { return (PHYS_H - scaledH()) / 2; }

    void begin(SPIClass&) {
      Wire.begin(TOUCH_MOSI, TOUCH_CLK);
      Wire.setClock(400000);
      if (TOUCH_IRQ >= 0) pinMode(TOUCH_IRQ, INPUT_PULLUP);
      if (TOUCH_CS >= 0) {
        pinMode(TOUCH_CS, OUTPUT);
        digitalWrite(TOUCH_CS, LOW);
        delay(20);
        digitalWrite(TOUCH_CS, HIGH);
        delay(200);
      } else {
        delay(80);
      }
      initOk = true;
      lastTouched = false;
    }

    void setRotation(uint8_t r) { rotation = r; }

    bool touched() {
      poll();
      return lastTouched;
    }

    TS_Point getPoint() const { return lastPoint; }

  private:
    void poll() {
      if (!initOk) {
        lastTouched = false;
        lastPoint = TS_Point();
        return;
      }

      if ((millis() - lastPollMs) < 12) return;
      lastPollMs = millis();

      uint8_t data[MAX_TOUCHES * 6 + 2] = {0};
      const uint8_t read_cmd[11] = {
        0xB5, 0xAB, 0xA5, 0x5A, 0x00, 0x00,
        (uint8_t)(((MAX_TOUCHES * 6) + 2) >> 8),
        (uint8_t)(((MAX_TOUCHES * 6) + 2) & 0xFF),
        0x00, 0x00, 0x00
      };

      Wire.beginTransmission(TOUCH_ADDR);
      Wire.write(read_cmd, sizeof(read_cmd));
      if (Wire.endTransmission() != 0) {
        lastTouched = false;
        lastPoint = TS_Point();
        return;
      }

      if (Wire.requestFrom((int)TOUCH_ADDR, (int)sizeof(data)) != (int)sizeof(data)) {
        lastTouched = false;
        lastPoint = TS_Point();
        return;
      }

      for (size_t i = 0; i < sizeof(data); ++i) {
        data[i] = Wire.read();
      }

      if (data[1] == 0 || data[1] > MAX_TOUCHES) {
        lastTouched = false;
        lastPoint = TS_Point();
        return;
      }

      uint16_t rawX = ((data[2] & 0x0F) << 8) | data[3];
      uint16_t rawY = ((data[4] & 0x0F) << 8) | data[5];

      if ((rawX == 273 && rawY == 273) || rawX > 4000 || rawY > 4000) {
        lastTouched = false;
        lastPoint = TS_Point();
        return;
      }

      int16_t physX = constrain((int16_t)rawY, 0, PHYS_W - 1);
      int16_t physY = constrain((int16_t)map(rawX, 0, 320, PHYS_H, 0), 0, PHYS_H - 1);

      if (rotation == 3) {
        physX = (PHYS_W - 1) - physX;
        physY = (PHYS_H - 1) - physY;
      }

      if (physX < originX() || physX >= (originX() + scaledW()) || physY < originY() || physY >= (originY() + scaledH())) {
        lastTouched = false;
        lastPoint = TS_Point();
        return;
      }

      int16_t logicalX = (int16_t)((physX - originX()) / scale());
      int16_t logicalY = (int16_t)((physY - originY()) / scale());

      lastPoint = TS_Point(
        constrain(logicalX, 0, LOGICAL_W - 1),
        constrain(logicalY, 0, LOGICAL_H - 1),
        1
      );
      lastTouched = true;
    }
  };

  extern DisplayCompat tft;

  #if JC3248_ENABLE_LVGL
    static uint32_t jc3248_lv_last_tick = 0;
    static bool jc3248_lv_ready = false;

    #if LVGL_VERSION_MAJOR >= 9
      static lv_display_t *jc3248_lv_display = nullptr;
      static lv_color_t *jc3248_lv_buf_1 = nullptr;
      static lv_color_t *jc3248_lv_buf_2 = nullptr;

      static void jc3248LvglFlush(lv_display_t *disp, const lv_area_t *area, uint8_t *color_p) {
        (void)disp;
        int32_t w = area->x2 - area->x1 + 1;
        int32_t h = area->y2 - area->y1 + 1;
        tft.pushLvglArea(area->x1, area->y1, w, h, reinterpret_cast<const uint16_t *>(color_p));
        lv_display_flush_ready(disp);
      }
    #else
      static lv_disp_draw_buf_t jc3248_lv_draw_buf;
      static lv_color_t *jc3248_lv_buf_1 = nullptr;
      static lv_color_t *jc3248_lv_buf_2 = nullptr;
      static lv_disp_drv_t jc3248_lv_drv;

      static void jc3248LvglFlush(lv_disp_drv_t *disp, const lv_area_t *area, lv_color_t *color_p) {
        int32_t w = area->x2 - area->x1 + 1;
        int32_t h = area->y2 - area->y1 + 1;
        tft.pushLvglArea(area->x1, area->y1, w, h, reinterpret_cast<const uint16_t *>(color_p));
        lv_disp_flush_ready(disp);
      }
    #endif

    static void jc3248InitLvglBridge() {
      if (jc3248_lv_ready) return;

      lv_init();

      size_t buffer_pixels = TFT_WIDTH * 40;
      jc3248_lv_buf_1 = static_cast<lv_color_t*>(heap_caps_malloc(buffer_pixels * sizeof(lv_color_t), MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT));
      jc3248_lv_buf_2 = static_cast<lv_color_t*>(heap_caps_malloc(buffer_pixels * sizeof(lv_color_t), MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT));
      if (!jc3248_lv_buf_1) jc3248_lv_buf_1 = static_cast<lv_color_t*>(heap_caps_malloc(buffer_pixels * sizeof(lv_color_t), MALLOC_CAP_8BIT));
      if (!jc3248_lv_buf_2) jc3248_lv_buf_2 = static_cast<lv_color_t*>(heap_caps_malloc(buffer_pixels * sizeof(lv_color_t), MALLOC_CAP_8BIT));
      if (!jc3248_lv_buf_1 || !jc3248_lv_buf_2) return;

      #if LVGL_VERSION_MAJOR >= 9
        jc3248_lv_display = lv_display_create(TFT_WIDTH, TFT_HEIGHT);
        if (!jc3248_lv_display) return;
        lv_display_set_flush_cb(jc3248_lv_display, jc3248LvglFlush);
        lv_display_set_buffers(
          jc3248_lv_display,
          jc3248_lv_buf_1,
          jc3248_lv_buf_2,
          buffer_pixels * sizeof(lv_color_t),
          LV_DISPLAY_RENDER_MODE_PARTIAL
        );
      #else
        lv_disp_draw_buf_init(&jc3248_lv_draw_buf, jc3248_lv_buf_1, jc3248_lv_buf_2, buffer_pixels);
        lv_disp_drv_init(&jc3248_lv_drv);
        jc3248_lv_drv.hor_res = TFT_WIDTH;
        jc3248_lv_drv.ver_res = TFT_HEIGHT;
        jc3248_lv_drv.flush_cb = jc3248LvglFlush;
        jc3248_lv_drv.draw_buf = &jc3248_lv_draw_buf;
        lv_disp_drv_register(&jc3248_lv_drv);
      #endif

      jc3248_lv_last_tick = millis();
      jc3248_lv_ready = true;
    }

    static void jc3248HandleLvgl() {
      if (!jc3248_lv_ready) return;
      uint32_t now = millis();
      uint32_t elapsed = now - jc3248_lv_last_tick;
      if (elapsed > 0) {
        #if LVGL_VERSION_MAJOR >= 9
          lv_tick_inc(elapsed);
          lv_timer_handler();
        #else
          lv_tick_inc(elapsed);
          lv_timer_handler();
        #endif
        jc3248_lv_last_tick = now;
      }
    }
  #else
    static void jc3248InitLvglBridge() {}
    static void jc3248HandleLvgl() {}
  #endif
#else
  #error "Unsupported DISPLAY_BACKEND value"
#endif

#include <FS.h>
using fs::FS;
using fs::File;
#include <SD.h>
#include <SPI.h>
#include <PNGdec.h>
#include <time.h>
#include <WebServer.h>
#include <Update.h>
#include <esp_system.h>
#include <esp_heap_caps.h>
#include <rom/rtc.h>

#if DISPLAY_BACKEND == DISPLAY_BACKEND_2432S028R
TFT_eSPI tft = TFT_eSPI();
XPT2046_Touchscreen ts(TOUCH_CS, TOUCH_IRQ);
#else
DisplayCompat tft;
TouchCompat ts;
#endif

#if DISPLAY_BACKEND == DISPLAY_BACKEND_2432S028R
  #if defined(ESP32) && !defined(CONFIG_IDF_TARGET_ESP32S3)
    SPIClass touchSPI(VSPI);
  #else
    SPIClass touchSPI(FSPI);
  #endif
#else
  SPIClass touchSPI(FSPI);
#endif
SPIClass sdSPI(HSPI);
WebServer server(80);

PNG pngDecoder;
File pngFile;
uint16_t* pngLineBuffer = nullptr;
size_t pngLineBufferPixels = 0;
uint8_t* pngMaskBuffer = nullptr;
size_t pngMaskBufferBytes = 0;
int16_t pngDrawX = 0;
int16_t pngDrawY = 0;
int16_t pngMaxW = 0;
int16_t pngMaxH = 0;
bool pngDecodeOk = false;
uint16_t pngBlendBg565 = 0;
uint16_t pngBlendBg565Buffer = 0;
uint8_t pngBlendBgR = 0;
uint8_t pngBlendBgG = 0;
uint8_t pngBlendBgB = 0;
int pngPixelType = 0;
bool pngHasAlpha = false;

#ifndef PNG_PIXEL_TRUECOLOR_ALPHA
  #define PNG_PIXEL_TRUECOLOR_ALPHA 6
#endif

static inline void rgb565To888Components(uint16_t c, uint8_t &r, uint8_t &g, uint8_t &b) {
  r = (uint8_t)(((c >> 11) & 0x1F) * 255 / 31);
  g = (uint8_t)(((c >> 5) & 0x3F) * 255 / 63);
  b = (uint8_t)((c & 0x1F) * 255 / 31);
}

static inline uint16_t pngBufferColor565(uint16_t c) {
#if DISPLAY_BACKEND == DISPLAY_BACKEND_2432S028R
  return (uint16_t)((c << 8) | (c >> 8));
#else
  return c;
#endif
}

int touchMinX = 200;
int touchMaxX = 3700;
int touchMinY = 240;
int touchMaxY = 3800;

#if DISPLAY_BACKEND == DISPLAY_BACKEND_2432S028R
const int SCREEN_W = TFT_HEIGHT;
const int SCREEN_H = TFT_WIDTH;

static const int UI_REF_W = 320;
static const int UI_REF_H = 240;
#elif DISPLAY_BACKEND == DISPLAY_BACKEND_JC3248W535C
const int SCREEN_W = TFT_WIDTH;
const int SCREEN_H = TFT_HEIGHT;

static const int UI_REF_W = 320;
static const int UI_REF_H = 240;
#else

#endif

#define UX(v) ((int)((((int32_t)(v)) * SCREEN_W + (UI_REF_W / 2)) / UI_REF_W))
#define UY(v) ((int)((((int32_t)(v)) * SCREEN_H + (UI_REF_H / 2)) / UI_REF_H))
#define UW(v) ((int)((((int32_t)(v)) * SCREEN_W + (UI_REF_W / 2)) / UI_REF_W))
#define UH(v) ((int)((((int32_t)(v)) * SCREEN_H + (UI_REF_H / 2)) / UI_REF_H))
#define UR(v) ((int)min(UW(v), UH(v)))

const char* CONFIG_FILE = "/config.json";
const char* IMPORT_CONFIG_TEMP_FILE = "/config-import.json";
const char* DEVICE_CALLSIGN = "13MAD86";

#ifndef AP_SETUP_TIMEOUT_MS
  #define AP_SETUP_TIMEOUT_MS 180000UL
#endif

#ifndef WIFI_CONNECT_TIMEOUT_MS
  #define WIFI_CONNECT_TIMEOUT_MS 15000UL
#endif

#ifndef WIFI_RETRY_INTERVAL_MS
  #define WIFI_RETRY_INTERVAL_MS 30000UL
#endif

#ifndef WIFI_RETRY_TIMEOUT_MS
  #define WIFI_RETRY_TIMEOUT_MS 3000UL
#endif

#ifndef HTTP_CONNECT_TIMEOUT_MS
  #define HTTP_CONNECT_TIMEOUT_MS 3000
#endif

#ifndef HTTP_READ_TIMEOUT_MS
  #define HTTP_READ_TIMEOUT_MS 3000
#endif

bool apModeActive = false;
String activeStorageLabel = "SD";
String apSsid = "";
String apPass = "12345678";

struct HeardRecord;
struct RadioIdLookupCacheEntry;

String normalizeHamCallsign(const String& input);
String trimSafe(const String& s);
String countryCodeFromCallsign(const String& cs);
String flagBmpPathFromCode(const String& code);
bool drawFlagForCallsign(int x, int y, int w, int h, const String& callsign);
bool drawBmp24FromSD(const char *filename, int16_t x, int16_t y, int16_t maxW, int16_t maxH);
bool drawPngFromSD(const char *filename, int16_t x, int16_t y, int16_t maxW, int16_t maxH, uint16_t bg565 = 0);
void *pngOpen(const char *filename, int32_t *size);
void pngClose(void *handle);
int32_t pngRead(PNGFILE *page, uint8_t *buffer, int32_t length);
int32_t pngSeek(PNGFILE *page, int32_t position);
int pngDraw(PNGDRAW *pDraw);
String csvFieldAt(const String& line, int wantedIndex);
String buildOperatorName(const String& firstName, const String& lastName);
String clipWithTilde(const String& input, size_t maxChars);
String formatCallsignName(const String& callsign, const String& name, size_t maxNameChars = 10, size_t maxCallsignChars = 10);
String urlEncodeComponent(const String& value);
String buildRadioIdUsersUrl(const String& callsign);
bool radioIdLookupNeeded(const String& callsign, const String& name);
String radioIdLookupSignatureForRecord(const HeardRecord& r);
void queueRadioIdLookupForRecord(const HeardRecord& r);
void clearRadioIdLookupCache();
int radioIdLookupCacheIndexForCallsign(const String& normalizedCallsign);
struct RadioIdLookupCacheEntry* radioIdLookupCacheSlotForCallsign(const String& normalizedCallsign, bool createIfMissing);
bool parseRadioIdUserResponse(const String& payload, struct RadioIdLookupCacheEntry& entry);
bool fetchRadioIdProfile(const String& normalizedCallsign, struct RadioIdLookupCacheEntry& outEntry);
void applyRadioIdCacheToRecord(struct HeardRecord& r, bool allowFetch = true);
String radioIdDisplayNameForRecord(const struct HeardRecord& r, bool allowFetch = true);
String radioIdDisplayCountryForRecord(const struct HeardRecord& r, bool allowFetch = true);
void applyRadioIdCacheToList(struct HeardRecord* list, int count);
bool applyRadioIdCacheToActiveData();
void processRadioIdLookups();

struct ModeStatus {
  bool known = false;
  bool enabled = false;
};

File storageOpen(const char* path, const char* mode = FILE_READ) {
#if DISPLAY_BACKEND == DISPLAY_BACKEND_2432S028R
  const char* openMode = mode;
  if (strcmp(mode, "rb") == 0) openMode = FILE_READ;
  else if (strcmp(mode, "wb") == 0) openMode = FILE_WRITE;
  else if (strcmp(mode, "ab") == 0) openMode = FILE_APPEND;
  return SD.open(path, openMode);
#else
  const char* openMode = mode;
  if (strcmp(mode, "rb") == 0) openMode = FILE_READ;
  else if (strcmp(mode, "wb") == 0) openMode = FILE_WRITE;
  else if (strcmp(mode, "ab") == 0) openMode = FILE_APPEND;
  return SD.open(path, openMode);
#endif
}

bool storageExists(const char* path) {
#if DISPLAY_BACKEND == DISPLAY_BACKEND_2432S028R
  return SD.exists(path);
#else
  return SD.exists(path);
#endif
}

bool storageRemove(const char* path) {
#if DISPLAY_BACKEND == DISPLAY_BACKEND_2432S028R
  return !SD.exists(path) || SD.remove(path);
#else
  return !SD.exists(path) || SD.remove(path);
#endif
}

static const char* DEFAULT_TZ_RULE = "CET-1CEST,M3.5.0/2,M10.5.0/3";
static const uint8_t QUICK_ACTION_SLOT_COUNT = 4;

struct QuickActionSlotConfig {
  bool enabled = false;
  bool useSudo = false;
  String label = "";
  String command = "";
};

struct AppConfig {
  String backend;
  String wifiSsid;
  String wifiPass;
  String pistarHost;
  String deviceCallsign = "";
  String themeColor = "#35C2FF";
  uint32_t refreshMs = 1500;
  uint32_t callsignHoldMs = 12000;
  bool apiUtcToLocalTime = true;
  String timezoneTz = DEFAULT_TZ_RULE;
  uint8_t brightness = 220;
  bool flipScreen = false;
  bool screensaverEnabled = true;
  uint32_t screensaverTimeoutMs = 240000;
  uint8_t screensaverDimBrightness = 25;
  bool weatherEnabled = false;
  uint16_t weatherRefreshMinutes = 10;
  String weatherLat = "";
  String weatherLon = "";
  String weatherApiKey = "";
  bool radioIdLookupEnabled = true;
  String radioIdUsersApiBase = "https://radioid.net/api/users?callsign=";
  bool touchModeSshEnabled = false;
  String pistarSshUser = "pi-star";
  String pistarSshPass = "";
  uint16_t pistarSshPort = 22;
  QuickActionSlotConfig quickActions[QUICK_ACTION_SLOT_COUNT];
} cfg;

struct WeatherState {
  bool valid = false;
  bool fetchInProgress = false;
  float tempC = 0.0f;
  String icon = "";
  String summary = "";
  String location = "";
  unsigned long lastSuccessMillis = 0;
  unsigned long lastAttemptMillis = 0;
  int lastHttpCode = 0;
} weatherState;

static const char* RADIOID_USERS_API_BASE_DEFAULT = "https://radioid.net/api/users?callsign=";
static const int RADIOID_LOOKUP_CACHE_SIZE = 24;
static const unsigned long RADIOID_LOOKUP_TTL_MS = 24UL * 60UL * 60UL * 1000UL;
static const unsigned long RADIOID_LOOKUP_RETRY_MS = 15UL * 60UL * 1000UL;
static const unsigned long RADIOID_LOOKUP_MIN_INTERVAL_MS = 1500UL;

struct RadioIdLookupCacheEntry {
  String callsign = "";
  String name = "";
  String country = "";
  String city = "";
  String state = "";
  bool valid = false;
  bool found = false;
  unsigned long lastFetchMillis = 0;
};

RadioIdLookupCacheEntry radioIdLookupCache[RADIOID_LOOKUP_CACHE_SIZE];
int radioIdLookupCacheWriteIndex = 0;
unsigned long lastRadioIdLookupMillis = 0;
String pendingRadioIdLookupCallsign = "";
String lastRadioIdLookupSignature = "";

struct Theme {
  uint16_t bg;
  uint16_t panel;
  uint16_t panel2;
  uint16_t border;
  uint16_t text;
  uint16_t subtext;
  uint16_t accent;
  uint16_t good;
  uint16_t warn;
  uint16_t bad;
  uint16_t live;
  uint16_t rx;
  uint16_t tx;
  uint16_t tabIdle;
  uint16_t tabActive;
} theme;

ModeStatus modeDMR, modeDSTAR, modeYSF, modeP25, modeNXDN, modePOCSAG;

enum Page {
  PAGE_LIVE = 0,
  PAGE_LAST = 1,
  PAGE_STATUS = 2,
  PAGE_AP = 3,
  PAGE_INFO = 4,
  PAGE_ABOUT = 5
};

Page currentPage = PAGE_LIVE;
Page previousPage = PAGE_LIVE;
unsigned long lastPoll = 0;
bool wifiOk = false;
bool configOkState = false;
bool setupRequired = false;
String footerText = "Booting...";
bool timeConfigured = false;
String lastError = "";
bool uiDirty = true;
bool parseHexColor(String s, uint16_t &out565);
bool footerDirty = true;
bool lastLiveActiveState = false;
int lastRenderedPage = -1;
bool lastDetailPopupState = false;
bool screensaverActive = false;
unsigned long lastInteractionMillis = 0;
unsigned long lastScreensaverFrameMillis = 0;
unsigned long lastWifiRetryMillis = 0;
bool pendingConfigApply = false;
unsigned long pendingConfigApplyAt = 0;
bool wifiConnectInProgress = false;
unsigned long wifiConnectStartedAt = 0;
unsigned long lastWifiStateCheck = 0;
int screensaverPosX = 16;
int screensaverPosY = 72;
int screensaverVelX = 4;
int screensaverVelY = 3;
String lastHeaderClock = "";
unsigned long lastClockCheck = 0;
bool nextFetchHeard = false;
unsigned long lastWeatherPollCheck = 0;

void requestRedraw() {
  uiDirty = true;
}

String getDeviceCallsign();
void noteScreenActivity(bool fromTouch = false);

String resolveTargetDisplay(const HeardRecord& rec);
String resolveTargetLabel(const HeardRecord& rec);
String prettyMetricValue(const String& value, const char* suffix = nullptr, bool keepPercent = false);
String firstNonEmpty(const String& a, const String& b, const String& c = "", const String& d = "");
void updateSessionInfoFromRecord(const HeardRecord& rec);
void drawInfoPage();
void drawAboutPage();
String getDateText();
String getDetailedClockText();

void setFooterText(const String& text) {
  if (footerText != text) {
    footerText = text;
    footerDirty = true;
    noteScreenActivity(false);
  }
}

void applyDisplaySettings();
void setScreensaverActive(bool active);
void noteUserInteraction(bool fromTouch = false);
void drawScreensaver(bool forceClear = false);
bool configLooksValid();
bool startSetupAccessPoint();
void stopSetupAccessPoint();
void beginWiFiConnectAttempt(bool stopApFirst = true);
void handleWiFiConnectProgress();
void syncTimeIfNeeded();

String weatherRequestUrl();
void clearWeatherData();
bool weatherConfigured();
String weatherDisplayText();
String weatherIconPath();
bool fetchWeatherData();
bool persistConfigDocument(DynamicJsonDocument& doc, const char* path = CONFIG_FILE);
bool applyConfigFromFile(const char* path, String* errMsg = nullptr);
void applyConfigAfterLoad();
void sendWebMessagePage(const String& title, const String& lead, const String& detail = "", bool isError = false);
void handleExportConfig();
void handleImportConfig();
void handleImportConfigUpload();

String jsonEscape(const String& s);
String formatMillisAgoBrief(unsigned long sinceMs);
String otaErrorText();
String buildSidebarHtml(bool onRoot, const String& activeApp = "");
void recordDiagnosticsSample(bool force = false);
void noteApiFetchResult(bool ok, int statusCode, unsigned long latencyMs);
void noteWeatherFetchResult(bool ok, int statusCode);
String buildDiagnosticsJson();
void handleDiagnosticsPage();
void handleDiagnosticsApi();
void handleOtaPage();
void handleOtaUpload();
void handleOtaUpdate();
void handleAboutPage();
void handleChangelogPage();
String buildWebLogsJson();
void handleLogsApi();
void handleLogsPage();
void handleLogsClear();
void addWebLog(const String& level, const String& tag, const String& message, bool mirrorSerial = true);
String formatLogAgeBrief(unsigned long sinceMs);

String formatUptimeBrief() {
  unsigned long totalSeconds = millis() / 1000UL;
  unsigned long days = totalSeconds / 86400UL;
  unsigned long hours = (totalSeconds % 86400UL) / 3600UL;
  unsigned long minutes = (totalSeconds % 3600UL) / 60UL;

  if (days > 0) return String(days) + "d " + String(hours) + "h";
  if (hours > 0) return String(hours) + "h " + String(minutes) + "m";
  return String(minutes) + "m";
}

String getBuildStamp() {
  return String(__DATE__) + " " + String(__TIME__);
}

String getWifiSignalText() {
  if (!wifiOk) return "Offline";
  long rssi = WiFi.RSSI();
  if (rssi == 0) return "Connected";
  return String(rssi) + " dBm";
}

void clearWeatherData() {
  weatherState.valid = false;
  weatherState.fetchInProgress = false;
  weatherState.tempC = 0.0f;
  weatherState.icon = "";
  weatherState.summary = "";
  weatherState.location = "";
  weatherState.lastSuccessMillis = 0;
  weatherState.lastAttemptMillis = 0;
  weatherState.lastHttpCode = 0;
}

bool weatherConfigured() {
  return cfg.weatherEnabled &&
         trimSafe(cfg.weatherLat).length() &&
         trimSafe(cfg.weatherLon).length() &&
         trimSafe(cfg.weatherApiKey).length();
}

String weatherRequestUrl() {
  if (!weatherConfigured()) return "";
  String url = "https://api.openweathermap.org/data/2.5/weather?lat=";
  url += trimSafe(cfg.weatherLat);
  url += "&lon=";
  url += trimSafe(cfg.weatherLon);
  url += "&units=metric&appid=";
  url += trimSafe(cfg.weatherApiKey);
  return url;
}

String weatherDisplayText() {
  if (!weatherState.valid) return "";
  return String((int)lroundf(weatherState.tempC)) + " C";
}

String weatherIconPath() {
  if (!weatherState.valid) return "";
  String icon = trimSafe(weatherState.icon);
  if (icon.length() < 2) return "";

  String candidate = "/weather/" + icon + ".png";
  if (storageExists(candidate.c_str())) return candidate;

  String shortCode = icon.substring(0, 2);
  candidate = "/weather/" + shortCode + ".png";
  if (storageExists(candidate.c_str())) return candidate;

  return "";
}

bool detailPopupOpen = false;
int selectedHeardIndex = -1;
bool modeConfirmPopupOpen = false;
String modeConfirmTargetLabel = "";
bool modeConfirmEnableRequested = false;
bool quickActionPopupOpen = false;

TaskHandle_t modeToggleTaskHandle = nullptr;
volatile bool modeToggleTaskRunning = false;
volatile bool modeToggleTaskDone = false;
volatile bool modeToggleTaskOk = false;
volatile bool modeToggleTaskEnable = false;
char modeToggleTaskLabel[16] = {0};
char modeToggleTaskMessage[192] = {0};

struct ModeToggleTaskArgs {
  String label;
  bool enable;
};

TaskHandle_t quickActionTaskHandle = nullptr;
volatile bool quickActionTaskRunning = false;
volatile bool quickActionTaskDone = false;
volatile bool quickActionTaskOk = false;
char quickActionTaskLabel[32] = {0};
char quickActionTaskMessage[192] = {0};

struct QuickActionTaskArgs {
  uint8_t slot;
};

const int BRIGHT_SLIDER_X = UX(108);
const int BRIGHT_SLIDER_Y = UY(193);
const int BRIGHT_SLIDER_W = UW(150);
const int BRIGHT_SLIDER_H = UH(8);

struct HeardRecord {
  String timeUtc;
  String mode;
  String callsign;
  String name;
  String country;
  String grid;
  String target;
  String targetName;
  String src;
  String duration;
  String loss;
  String ber;
  String rssi;
  String rxFreq;
  String txFreq;
  bool valid = false;
};

void setModeStatusIfPresent(ModeStatus& st, JsonVariantConst v);
bool modeStatusEqual(const ModeStatus& a, const ModeStatus& b);
bool allModeStatusesEqual(const ModeStatus& dmr, const ModeStatus& dstar, const ModeStatus& ysf,
                          const ModeStatus& p25, const ModeStatus& nxdn, const ModeStatus& pocsag);
void inferModeStatusesFromHeard(const HeardRecord* list, int count);
uint16_t modeColor(const ModeStatus& st, bool active);
bool sameRecord(const HeardRecord& a, const HeardRecord& b);
String liveStatusText(bool active, const HeardRecord& rec);
void drawModeButton(int x, int y, int w, int h, const String& label, const ModeStatus& st, bool active);
ModeStatus* modeStatusFromTouchLabel(const String& label);
bool startModeToggleTask(const String& label, bool enable);
void processModeToggleTaskResult();
bool hasConfiguredQuickActions();
bool isQuickActionSlotConfigured(uint8_t slot);
String quickActionSlotLabel(uint8_t slot);
bool startQuickActionTask(uint8_t slot);
void processQuickActionTaskResult();
void drawSignalMeter(int x, int y, int w, int h, bool active, const HeardRecord* rec);
void drawDetailPopup(const HeardRecord& r);
void drawQuickActionsPopup();
bool heardListsEqual(const HeardRecord* a, int aCount, const HeardRecord* b, int bCount);
bool extractWpsdStatusPill(const String& html, const String& label, ModeStatus& st);
void parseModeCellWPSD(const String& html, const String& label, ModeStatus& st);
void parseModeCellPiStar(const String& html, const String& label, ModeStatus& st);
uint16_t bmpRead16(fs::File &f);
uint32_t bmpRead32(fs::File &f);

struct SessionInfo {
  uint32_t totalQsos = 0;
  uint32_t totalLiveHits = 0;
  uint32_t modeDMR = 0;
  uint32_t modeDSTAR = 0;
  uint32_t modeYSF = 0;
  uint32_t modeP25 = 0;
  uint32_t modeNXDN = 0;
  uint32_t modePOCSAG = 0;
  int32_t bestRssi = -999;
  int32_t worstRssi = 0;
  bool hasRssi = false;
} sessionInfo;

static const size_t DIAG_HISTORY_POINTS = 60;
static const unsigned long DIAG_SAMPLE_INTERVAL_MS = 5000UL;
static const int32_t DIAG_NULL_INT = -32768;
static const size_t WEB_LOG_CAPACITY = 160;

struct WebLogEntry {
  unsigned long ms = 0;
  String level = "INFO";
  String tag = "";
  String message = "";
};

WebLogEntry webLogBuffer[WEB_LOG_CAPACITY];
size_t webLogHead = 0;
size_t webLogCount = 0;

struct DiagnosticsState {
  uint32_t fetchOk = 0;
  uint32_t fetchFail = 0;
  uint32_t consecutiveFetchFailures = 0;
  uint32_t lastApiLatencyMs = 0;
  int32_t lastApiStatusCode = 0;
  unsigned long lastFetchMillis = 0;
  unsigned long lastFetchSuccessMillis = 0;
  uint32_t weatherOk = 0;
  uint32_t weatherFail = 0;
  int32_t lastWeatherHttpCode = 0;
  unsigned long lastWeatherAttemptMillis = 0;
  unsigned long lastWeatherSuccessMillis = 0;
  size_t sampleHead = 0;
  size_t sampleCount = 0;
  int32_t wifiRssi[DIAG_HISTORY_POINTS];
  uint32_t heapFree[DIAG_HISTORY_POINTS];
  uint32_t apiLatency[DIAG_HISTORY_POINTS];
  uint32_t qsoTotal[DIAG_HISTORY_POINTS];

  DiagnosticsState() {
    for (size_t i = 0; i < DIAG_HISTORY_POINTS; i++) {
      wifiRssi[i] = DIAG_NULL_INT;
      heapFree[i] = 0;
      apiLatency[i] = 0;
      qsoTotal[i] = 0;
    }
  }
} diagnosticsState;

unsigned long lastDiagnosticsSampleMillis = 0;
bool webLogApiStateKnown = false;
bool webLogLastApiOk = false;
int32_t webLogLastApiCode = 0;
bool webLogWeatherStateKnown = false;
bool webLogLastWeatherOk = false;
int32_t webLogLastWeatherCode = 0;
bool otaUpdateSuccessful = false;
unsigned long otaRestartAt = 0;
String otaLastError = "";

const int MAX_HEARD = 12;
HeardRecord heardList[MAX_HEARD];
int heardCount = 0;

HeardRecord liveRecord;
unsigned long liveRecordMillis = 0;

String currentRxFreq = "438.8000";
String currentTxFreq = "430.8000";
String hotspotRxFreq = "";
String hotspotTxFreq = "";
String hotspotStatusText = "";

String trimSafe(const String& s) {
  String out = s;
  out.trim();
  return out;
}

String shellSingleQuote(const String& s) {
  String out = "'";
  for (size_t i = 0; i < s.length(); ++i) {
    char c = s.charAt(i);
    if (c == '\'') out += "'\\''";
    else out += c;
  }
  out += "'";
  return out;
}

String buildOperatorName(const String& firstName, const String& lastName) {
  String full = trimSafe(firstName);
  String last = trimSafe(lastName);
  if (last.length()) {
    if (full.length()) full += " ";
    full += last;
  }
  return trimSafe(full);
}

String clipWithTilde(const String& input, size_t maxChars) {
  String value = trimSafe(input);
  if (!maxChars || value.length() <= maxChars) return value;
  if (maxChars == 1) return String("~");
  return value.substring(0, maxChars - 1) + "~";
}

String formatCallsignName(const String& callsign, const String& name, size_t maxNameChars, size_t maxCallsignChars) {
  String shownCallsign = normalizeHamCallsign(callsign);
  if (!shownCallsign.length()) shownCallsign = "-";
  if (maxCallsignChars) shownCallsign = clipWithTilde(shownCallsign, maxCallsignChars);

  String shownName = trimSafe(name);
  if (!shownName.length()) return shownCallsign;
  if (maxNameChars) shownName = clipWithTilde(shownName, maxNameChars);

  return shownCallsign + " - " + shownName;
}

String fitLivePanelPrimaryText(const String& input, int availableWidth, int& font) {
  String value = trimSafe(input);
  if (!value.length()) value = "-";

  const uint8_t candidateFonts[] = {4, 2, 1};
  for (size_t i = 0; i < sizeof(candidateFonts) / sizeof(candidateFonts[0]); ++i) {
    font = candidateFonts[i];
    tft.setTextFont(font);
    if (tft.textWidth(value) <= availableWidth) return value;
  }

  font = 1;
  tft.setTextFont(font);
  if (tft.textWidth(value) <= availableWidth) return value;

  String clipped = value;
  while (clipped.length() > 1) {
    clipped.remove(clipped.length() - 1);
    String trial = clipped + "~";
    if (tft.textWidth(trial) <= availableWidth) return trial;
  }

  return String("~");
}

String getDeviceCallsign() {
  String callsign = trimSafe(cfg.deviceCallsign);
  return callsign.length() ? callsign : String(DEVICE_CALLSIGN);
}
String normalizeModeName(String s) {
  s = trimSafe(s);
  s.toUpperCase();
  return s;
}

String extractFreqString(JsonVariantConst v) {
  if (v.isNull()) return "";

  String out;
  if (v.is<const char*>()) out = String(v.as<const char*>());
  else if (v.is<String>()) out = v.as<String>();
  else if (v.is<float>() || v.is<double>()) out = String(v.as<double>(), 4);
  else if (v.is<int>() || v.is<long>() || v.is<unsigned int>() || v.is<unsigned long>()) {
    double raw = v.as<double>();
    if (raw > 1000000.0) raw /= 1000000.0;
    out = String(raw, 4);
  } else {
    return "";
  }

  out = trimSafe(out);
  out.replace(" MHz", "");
  out.replace("MHz", "");
  out.trim();
  return out;
}

void setFreqIfPresent(String& dst, JsonVariantConst v) {
  String parsed = extractFreqString(v);
  if (parsed.length()) dst = parsed;
}

void parseFrequencyFieldsFromObject(JsonObjectConst obj) {
  setFreqIfPresent(hotspotRxFreq, obj["rxFreq"]);
  setFreqIfPresent(hotspotRxFreq, obj["rx_freq"]);
  setFreqIfPresent(hotspotRxFreq, obj["RXFrequency"]);
  setFreqIfPresent(hotspotRxFreq, obj["rx_frequency"]);
  setFreqIfPresent(hotspotRxFreq, obj["rxfrequency"]);
  setFreqIfPresent(hotspotRxFreq, obj["RX Freq"]);
  setFreqIfPresent(hotspotRxFreq, obj["rx_mhz"]);

  setFreqIfPresent(hotspotTxFreq, obj["txFreq"]);
  setFreqIfPresent(hotspotTxFreq, obj["tx_freq"]);
  setFreqIfPresent(hotspotTxFreq, obj["TXFrequency"]);
  setFreqIfPresent(hotspotTxFreq, obj["tx_frequency"]);
  setFreqIfPresent(hotspotTxFreq, obj["txfrequency"]);
  setFreqIfPresent(hotspotTxFreq, obj["TX Freq"]);
  setFreqIfPresent(hotspotTxFreq, obj["tx_mhz"]);

  JsonVariantConst modem = obj["modem"];
  if (!modem.isNull() && modem.is<JsonObjectConst>()) {
    JsonObjectConst mo = modem.as<JsonObjectConst>();
    setFreqIfPresent(hotspotRxFreq, mo["rxFreq"]);
    setFreqIfPresent(hotspotRxFreq, mo["rx_freq"]);
    setFreqIfPresent(hotspotRxFreq, mo["RXFrequency"]);
    setFreqIfPresent(hotspotRxFreq, mo["rx_frequency"]);
    setFreqIfPresent(hotspotTxFreq, mo["txFreq"]);
    setFreqIfPresent(hotspotTxFreq, mo["tx_freq"]);
    setFreqIfPresent(hotspotTxFreq, mo["TXFrequency"]);
    setFreqIfPresent(hotspotTxFreq, mo["tx_frequency"]);
  }

  JsonVariantConst frequencies = obj["frequencies"];
  if (!frequencies.isNull() && frequencies.is<JsonObjectConst>()) {
    JsonObjectConst fq = frequencies.as<JsonObjectConst>();
    setFreqIfPresent(hotspotRxFreq, fq["rx"]);
    setFreqIfPresent(hotspotRxFreq, fq["RX"]);
    setFreqIfPresent(hotspotTxFreq, fq["tx"]);
    setFreqIfPresent(hotspotTxFreq, fq["TX"]);
  }

  JsonVariantConst status = obj["status"];
  if (!status.isNull() && status.is<JsonObjectConst>()) {
    JsonObjectConst st = status.as<JsonObjectConst>();
    setFreqIfPresent(hotspotRxFreq, st["rxFreq"]);
    setFreqIfPresent(hotspotRxFreq, st["rx_frequency"]);
    setFreqIfPresent(hotspotTxFreq, st["txFreq"]);
    setFreqIfPresent(hotspotTxFreq, st["tx_frequency"]);
  }
}

bool strToBoolLoose(String v, bool &out) {
  v = trimSafe(v);
  v.toLowerCase();
  if (v == "1" || v == "true" || v == "on" || v == "yes" || v == "enabled") { out = true; return true; }
  if (v == "0" || v == "false" || v == "off" || v == "no" || v == "disabled") { out = false; return true; }
  return false;
}

bool jsonValueToBoolLoose(JsonVariantConst v, bool &out) {
  if (v.is<bool>()) { out = v.as<bool>(); return true; }
  if (v.is<int>() || v.is<long>() || v.is<unsigned int>() || v.is<unsigned long>()) { out = v.as<long>() != 0; return true; }
  if (v.is<const char*>()) return strToBoolLoose(String(v.as<const char*>()), out);
  if (v.is<String>()) return strToBoolLoose(v.as<String>(), out);
  return false;
}

void setModeStatusIfPresent(ModeStatus& st, JsonVariantConst v) {
  bool parsed = false;
  if (jsonValueToBoolLoose(v, parsed)) {
    st.known = true;
    st.enabled = parsed;
  }
}

void resetModeStatuses() {
  modeDMR = ModeStatus();
  modeDSTAR = ModeStatus();
  modeYSF = ModeStatus();
  modeP25 = ModeStatus();
  modeNXDN = ModeStatus();
  modePOCSAG = ModeStatus();
}

bool modeStatusEqual(const ModeStatus& a, const ModeStatus& b) {
  return a.known == b.known && a.enabled == b.enabled;
}

bool allModeStatusesEqual(const ModeStatus& dmr, const ModeStatus& dstar, const ModeStatus& ysf,
                          const ModeStatus& p25, const ModeStatus& nxdn, const ModeStatus& pocsag) {
  return modeStatusEqual(modeDMR, dmr) &&
         modeStatusEqual(modeDSTAR, dstar) &&
         modeStatusEqual(modeYSF, ysf) &&
         modeStatusEqual(modeP25, p25) &&
         modeStatusEqual(modeNXDN, nxdn) &&
         modeStatusEqual(modePOCSAG, pocsag);
}

void inferModeStatusesFromHeard(const HeardRecord* list, int count) {
  for (int i = 0; i < count; i++) {
    String m = normalizeModeName(list[i].mode);
    if (m == "DMR")   { modeDMR.known = true; modeDMR.enabled = true; }
    if (m == "DSTAR" || m == "D-STAR") { modeDSTAR.known = true; modeDSTAR.enabled = true; }
    if (m == "YSF" || m == "FUSION") { modeYSF.known = true; modeYSF.enabled = true; }
    if (m == "P25")   { modeP25.known = true; modeP25.enabled = true; }
    if (m == "NXDN")  { modeNXDN.known = true; modeNXDN.enabled = true; }
    if (m == "POCSAG") { modePOCSAG.known = true; modePOCSAG.enabled = true; }
  }
}

void parseModeStatusesFromObject(JsonObjectConst obj) {
  parseFrequencyFieldsFromObject(obj);

  JsonVariantConst modes = obj["modes"];
  if (!modes.isNull() && modes.is<JsonObjectConst>()) {
    JsonObjectConst mo = modes.as<JsonObjectConst>();
    setModeStatusIfPresent(modeDMR, mo["DMR"]);
    setModeStatusIfPresent(modeDMR, mo["dmr"]);
    setModeStatusIfPresent(modeDSTAR, mo["DSTAR"]);
    setModeStatusIfPresent(modeDSTAR, mo["dstar"]);
    setModeStatusIfPresent(modeDSTAR, mo["d_star"]);
    setModeStatusIfPresent(modeYSF, mo["YSF"]);
    setModeStatusIfPresent(modeYSF, mo["ysf"]);
    setModeStatusIfPresent(modeP25, mo["P25"]);
    setModeStatusIfPresent(modeP25, mo["p25"]);
    setModeStatusIfPresent(modeNXDN, mo["NXDN"]);
    setModeStatusIfPresent(modeNXDN, mo["nxdn"]);
    setModeStatusIfPresent(modePOCSAG, mo["POCSAG"]);
    setModeStatusIfPresent(modePOCSAG, mo["pocsag"]);
  }

  setModeStatusIfPresent(modeDMR, obj["dmr_enabled"]);
  setModeStatusIfPresent(modeDMR, obj["DMREnabled"]);
  setModeStatusIfPresent(modeDMR, obj["dmrEnabled"]);
  setModeStatusIfPresent(modeDMR, obj["DMR"]);

  setModeStatusIfPresent(modeDSTAR, obj["dstar_enabled"]);
  setModeStatusIfPresent(modeDSTAR, obj["DSTAREnabled"]);
  setModeStatusIfPresent(modeDSTAR, obj["dstarEnabled"]);
  setModeStatusIfPresent(modeDSTAR, obj["DSTAR"]);
  setModeStatusIfPresent(modeDSTAR, obj["D-Star"]);

  setModeStatusIfPresent(modeYSF, obj["ysf_enabled"]);
  setModeStatusIfPresent(modeYSF, obj["YSFEnabled"]);
  setModeStatusIfPresent(modeYSF, obj["ysfEnabled"]);
  setModeStatusIfPresent(modeYSF, obj["YSF"]);
  setModeStatusIfPresent(modeYSF, obj["fusion_enabled"]);

  setModeStatusIfPresent(modeP25, obj["p25_enabled"]);
  setModeStatusIfPresent(modeP25, obj["P25Enabled"]);
  setModeStatusIfPresent(modeP25, obj["p25Enabled"]);
  setModeStatusIfPresent(modeP25, obj["P25"]);

  setModeStatusIfPresent(modeNXDN, obj["nxdn_enabled"]);
  setModeStatusIfPresent(modeNXDN, obj["NXDNEnabled"]);
  setModeStatusIfPresent(modeNXDN, obj["nxdnEnabled"]);
  setModeStatusIfPresent(modeNXDN, obj["NXDN"]);

  setModeStatusIfPresent(modePOCSAG, obj["pocsag_enabled"]);
  setModeStatusIfPresent(modePOCSAG, obj["POCSAGEnabled"]);
  setModeStatusIfPresent(modePOCSAG, obj["pocsagEnabled"]);
  setModeStatusIfPresent(modePOCSAG, obj["POCSAG"]);
}

uint16_t modeColor(const ModeStatus& st, bool active) {
  if (active) return theme.warn;
  if (st.known) return st.enabled ? theme.good : theme.bad;
  return theme.border;
}

bool sameRecord(const HeardRecord& a, const HeardRecord& b) {
  return a.callsign == b.callsign &&
         a.timeUtc   == b.timeUtc &&
         a.mode      == b.mode &&
         a.target    == b.target &&
         a.src       == b.src &&
         a.rxFreq    == b.rxFreq &&
         a.txFreq    == b.txFreq;
}

String normalizedBackend() {
  String b = trimSafe(cfg.backend);
  b.toLowerCase();
  if (b == "wpsd") return "wpsd";
  if (b == "pi-star" || b == "pistar") return "pistar";
  if (b == "pi star") return "pistar";
  return "pistar";
}

String heardUrl() {
  if (normalizedBackend() == "wpsd") {
    return "http://" + cfg.pistarHost + "/api/?limit=12&names=true&country=true";
  }
  return "http://" + cfg.pistarHost + "/api/last_heard.php";
}

String dashboardStatusUrl() {
  return "http://" + cfg.pistarHost + "/mmdvmhost/repeaterinfo.php";
}

String dashboardFreqUrl() {
  if (normalizedBackend() == "wpsd") {
    return "http://" + cfg.pistarHost + "/mmdvmhost/radioinfo.php";
  }
  return "http://" + cfg.pistarHost + "/mmdvmhost/repeaterinfo.php";
}

bool isLiveActive() {
  if (!liveRecord.valid) return false;
  return (millis() - liveRecordMillis) <= cfg.callsignHoldMs;
}

String liveStatusText(bool active, const HeardRecord& rec) {
  String status = trimSafe(hotspotStatusText);
  if (status.length()) {
    if (status.equalsIgnoreCase("Listening")) {
      String mode = normalizeModeName(rec.mode);
      if (active && mode.length()) return "RX: " + mode;
      return "Listening";
    }
    return status;
  }

  String mode = normalizeModeName(rec.mode);
  if (active && mode.length()) return "RX: " + mode;
  return active ? "RX ACTIVE" : "MMDVM IDLE";
}

void setDefaultTheme() {
  theme.bg        = TFT_BLACK;
  theme.panel     = 0x18C3;
  theme.panel2    = 0x2104;
  theme.border    = TFT_DARKGREY;
  theme.text      = TFT_WHITE;
  theme.subtext   = TFT_LIGHTGREY;
  theme.accent    = TFT_CYAN;
  theme.good      = TFT_GREEN;
  theme.warn      = TFT_YELLOW;
  theme.bad       = TFT_RED;
  theme.live      = 0xFBE0;
  theme.rx        = TFT_GREEN;
  theme.tx        = TFT_BLUE;
  theme.tabIdle   = 0x39C7;
  theme.tabActive = 0x059F;
}

uint16_t mix565(uint16_t a, uint16_t b, float t) {
  t = constrain(t, 0.0f, 1.0f);

  uint8_t ar = (a >> 11) & 0x1F;
  uint8_t ag = (a >> 5)  & 0x3F;
  uint8_t ab = a & 0x1F;

  uint8_t br = (b >> 11) & 0x1F;
  uint8_t bg = (b >> 5)  & 0x3F;
  uint8_t bb = b & 0x1F;

  uint8_t rr = ar + (int)((br - ar) * t);
  uint8_t rg = ag + (int)((bg - ag) * t);
  uint8_t rb = ab + (int)((bb - ab) * t);

  return (rr << 11) | (rg << 5) | rb;
}

void applyBaseThemeColor(const String& baseColorHex) {
  uint16_t base;
  if (!parseHexColor(baseColorHex, base)) return;

  theme.accent    = base;
  theme.tabActive = base;
  theme.warn      = mix565(base, TFT_YELLOW, 0.35f);
  theme.good      = mix565(base, TFT_GREEN, 0.60f);
  theme.bad       = mix565(base, TFT_RED, 0.65f);
  theme.live      = mix565(base, TFT_WHITE, 0.40f);
  theme.tx        = mix565(base, TFT_BLUE, 0.55f);
  theme.rx        = mix565(base, TFT_GREEN, 0.45f);
  theme.panel     = mix565(TFT_BLACK, base, 0.16f);
  theme.panel2    = mix565(TFT_BLACK, base, 0.10f);
  theme.border    = mix565(base, TFT_WHITE, 0.22f);
  theme.tabIdle   = mix565(TFT_BLACK, base, 0.24f);
  theme.subtext   = mix565(TFT_LIGHTGREY, base, 0.08f);
}

void fillSoftPanel(int x, int y, int w, int h, uint16_t fill, uint16_t border, int radius = 10) {
  int rr = max(1, UR(radius));
  tft.fillRoundRect(x, y, w, h, rr, fill);
  tft.drawRoundRect(x, y, w, h, rr, border);
  if (w > 2 && h > 2) {
    tft.drawRoundRect(x + 1, y + 1, w - 2, h - 2, rr, mix565(fill, TFT_WHITE, 0.10f));
  }
}

void drawInfoRow(int x, int y, int w, const String& label, const String& value,
                 uint16_t labelBg, uint16_t valueBg, uint16_t labelColor, uint16_t valueColor) {
  const int labelW = UW(76);
  const int rowH = UH(22);
  const int gap = UW(4);
  const int valueX = x + labelW + gap;

  fillSoftPanel(x, y, labelW, rowH, labelBg, theme.border, 5);
  fillSoftPanel(valueX, y, w - labelW - gap, rowH, valueBg, theme.border, 5);

  tft.setTextDatum(ML_DATUM);
  tft.setTextFont(2);

  tft.setTextColor(labelColor, labelBg);
  tft.drawString(label, x + UW(8), y + rowH / 2);

  tft.setTextColor(valueColor, valueBg);
  String clipped = value;
  int maxChars = max(4, (w - labelW - UW(16)) / 6);
  if (maxChars > 4 && clipped.length() > maxChars) clipped = clipped.substring(0, maxChars - 1) + "~";
  tft.drawString(clipped, valueX + UW(8), y + rowH / 2);
}

void drawLiveInfoPanel(int x, int y, int w, int h,
                       const String& callsign, const String& status, const String& target,
                       bool active) {
  fillSoftPanel(x, y, w, h, theme.panel2, theme.border, 10);

  const int flagW = UW(32);
  const int flagH = UH(32);
  const int gap = UW(8);
  const int rowY = y + UH(34);

  String caller = trimSafe(callsign);
  if (!caller.length()) caller = "-";

  String flagCallsign = caller;
  int callerSeparator = flagCallsign.indexOf(" - ");
  if (callerSeparator > 0) flagCallsign = flagCallsign.substring(0, callerSeparator);

  int textX = x + UW(58);
  int availableTextW = max(UW(36), w - (textX - x) - UW(10));
  int callerFont = 4;
  caller = fitLivePanelPrimaryText(caller, availableTextW, callerFont);

  int flagX = x + UW(14);
#if DISPLAY_BACKEND == DISPLAY_BACKEND_JC3248W535C
  int flagY = rowY - UH(28);
  flagY = constrain(flagY, y + UH(6), y + h - flagH - UH(24)) - 4;
#else
  int flagY = rowY - UH(24);
  flagY = constrain(flagY, y + UH(6), y + h - flagH - UH(24)) - 4;
#endif
  drawFlagForCallsign(flagX, flagY, flagW, flagH, flagCallsign);

  tft.setTextFont(callerFont);
  tft.setTextDatum(ML_DATUM);
  tft.setTextColor(active ? theme.warn : theme.text, theme.panel2);
  int callerTextY = rowY - (callerFont >= 4 ? UH(8) : (callerFont == 2 ? UH(8) : UH(6)));
#if DISPLAY_BACKEND == DISPLAY_BACKEND_JC3248W535C
  tft.drawString(caller, textX, callerTextY);
#else
  tft.drawString(caller, textX, callerTextY - 4);
#endif

  int metaY = y + h - UH(22);
  int statusW = (w - UW(22)) / 2;
  int targetW = w - UW(22) - statusW;
  int statusX = x + UW(8);
  int targetX = statusX + statusW + UW(6);

  fillSoftPanel(statusX, metaY, statusW, UH(16), theme.panel, theme.border, 4);
  fillSoftPanel(targetX, metaY, targetW, UH(16), theme.panel, theme.border, 4);

  tft.setTextDatum(MC_DATUM);
  tft.setTextFont(1);

  String statusText = status.length() ? status : "-";
  tft.setTextColor(active ? theme.good : theme.text, theme.panel);
  tft.drawString(statusText, statusX + statusW / 2, metaY + UH(8));

  String targetText = target.length() ? target : "-";
  tft.setTextColor(theme.text, theme.panel);
  tft.drawString(targetText, targetX + targetW / 2, metaY + UH(8));

  tft.setTextDatum(TL_DATUM);
}

void drawModeButton(int x, int y, int w, int h, const String& label, const ModeStatus& st, bool active) {
  uint16_t textColor = modeColor(st, active);
  uint16_t fill = theme.panel2;
  uint16_t border = active ? mix565(textColor, TFT_WHITE, 0.20f) : theme.border;
  fillSoftPanel(x, y, w, h, fill, border, 8);
  tft.setTextDatum(MC_DATUM);
  tft.setTextColor(textColor, fill);
  tft.setTextFont(2);
  tft.drawString(label, x + w / 2, y + h / 2);
  tft.setTextDatum(TL_DATUM);
}

bool pointInRect(int px, int py, int x, int y, int w, int h) {
  return px >= x && px < (x + w) && py >= y && py < (y + h);
}

ModeStatus* modeStatusFromTouchLabel(const String& label) {
  if (label == "DMR") return &modeDMR;
  if (label == "DSTAR") return &modeDSTAR;
  if (label == "YSF") return &modeYSF;
  if (label == "P25") return &modeP25;
  if (label == "NXDN") return &modeNXDN;
  return nullptr;
}

bool isQuickActionSlotConfigured(uint8_t slot) {
  if (slot >= QUICK_ACTION_SLOT_COUNT) return false;
  const QuickActionSlotConfig& qa = cfg.quickActions[slot];
  return qa.enabled && trimSafe(qa.label).length() && trimSafe(qa.command).length();
}

bool hasConfiguredQuickActions() {
  for (uint8_t i = 0; i < QUICK_ACTION_SLOT_COUNT; i++) {
    if (isQuickActionSlotConfigured(i)) return true;
  }
  return false;
}

String quickActionSlotLabel(uint8_t slot) {
  if (slot >= QUICK_ACTION_SLOT_COUNT) return String("");
  String label = trimSafe(cfg.quickActions[slot].label);
  if (!label.length()) label = String("Action ") + String(slot + 1);
  return label;
}

String buildWrappedRemoteShellCommand(const String& rawCommand, bool useSudo, bool& sendSudoPassword) {
  sendSudoPassword = false;
  String wrapped = "/bin/sh -c " + shellSingleQuote(rawCommand);
  if (useSudo && trimSafe(cfg.pistarSshUser) != "root") {
    sendSudoPassword = true;
    return "sudo -S -p '' " + wrapped;
  }
  return wrapped;
}

String buildQuickActionRemoteCommand(uint8_t slot, bool& sendSudoPassword, String* errMsg = nullptr) {
  sendSudoPassword = false;
  if (slot >= QUICK_ACTION_SLOT_COUNT) {
    if (errMsg) *errMsg = "Invalid quick action slot.";
    return String("");
  }

  const QuickActionSlotConfig& qa = cfg.quickActions[slot];
  if (!qa.enabled) {
    if (errMsg) *errMsg = "Quick action is disabled.";
    return String("");
  }

  String command = trimSafe(qa.command);
  if (!command.length()) {
    if (errMsg) *errMsg = "Quick action command is empty.";
    return String("");
  }

  return buildWrappedRemoteShellCommand(command, qa.useSudo, sendSudoPassword);
}

String hostModeManagerModuleNameFromTouchLabel(const String& label) {
  if (label == "DSTAR") return "D-Star";
  return label;
}

String hostModeManagerPath() {
  if (normalizedBackend() == "wpsd") return "/usr/local/sbin/wpsd-mode-manager";
  return "/usr/local/sbin/pistar-mmdvmhost-module";
}

String hostModeManagerAction(bool enable) {
  if (normalizedBackend() == "wpsd") return enable ? "Enable" : "Disable";
  return enable ? "enable" : "disable";
}

String buildHostModeManagerCommand(const String& label, bool enable) {
  String moduleName = hostModeManagerModuleNameFromTouchLabel(label);
  String action = hostModeManagerAction(enable);
  String baseCmd = hostModeManagerPath() + " " + shellSingleQuote(moduleName) + " " + action;

  String sshUser = trimSafe(cfg.pistarSshUser);
  if (!sshUser.length() || sshUser == "root") {
    return baseCmd;
  }

  return "sudo -S -p '' " + baseCmd;
}

bool liveModeButtonTouchHit(int x, int y, String& label) {
  struct Btn { const char* label; int x; int y; int w; int h; };
  const Btn buttons[] = {
    {"DMR",   UX(10), UY(38), UW(70), UH(20)},
    {"DSTAR", UX(86), UY(38), UW(70), UH(20)},
    {"YSF",   UX(10), UY(62), UW(70), UH(20)},
    {"P25",   UX(86), UY(62), UW(70), UH(20)},
    {"NXDN",  UX(10), UY(86), UW(70), UH(20)}
  };

  for (const auto& b : buttons) {
    if (pointInRect(x, y, b.x, b.y, b.w, b.h)) {
      label = b.label;
      return true;
    }
  }
  return false;
}

void getModeConfirmPopupLayout(int& popupX, int& popupY, int& popupW, int& popupH,
                               int& noX, int& noY, int& noW, int& noH,
                               int& yesX, int& yesY, int& yesW, int& yesH) {
  popupW = UW(240);
  popupH = UH(112);
  popupX = (SCREEN_W - popupW) / 2;
  popupY = (SCREEN_H - popupH) / 2;
  noW = UW(84);
  noH = UH(28);
  yesW = UW(84);
  yesH = UH(28);
  noX = popupX + UW(22);
  yesX = popupX + popupW - yesW - UW(22);
  noY = popupY + popupH - noH - UH(18);
  yesY = noY;
}

void openModeConfirmPopup(const String& label) {
  if (cfg.backend != "Pi-Star") {
    setFooterText("Mode touch control is only available with Pi-Star.");
    return;
  }

  if (!cfg.touchModeSshEnabled) {
    setFooterText("Enable touch SSH mode control in the config page first.");
    return;
  }

  ModeStatus* st = modeStatusFromTouchLabel(label);
  if (!st || !st->known) {
    setFooterText("Cannot toggle " + label + " because its current state is unknown.");
    return;
  }

  modeConfirmTargetLabel = label;
  modeConfirmEnableRequested = !st->enabled;
  modeConfirmPopupOpen = true;
  requestRedraw();
}

#if ENABLE_PISTAR_TOUCH_SSH
static bool sshWriteAll(ssh_channel channel, const char* data, size_t len) {
  size_t sent = 0;
  while (sent < len) {
    int rc = ssh_channel_write(channel, data + sent, len - sent);
    if (rc <= 0) return false;
    sent += (size_t)rc;
    yield();
  }
  return true;
}

bool runHotspotSshCommand(const String& command, bool sendSudoPassword, String* output, String* errMsg) {
  if (!cfg.pistarHost.length()) {
    if (errMsg) *errMsg = "Missing hotspot host.";
    return false;
  }
  if (!cfg.pistarSshUser.length()) {
    if (errMsg) *errMsg = "Missing SSH username.";
    return false;
  }
  if (!cfg.pistarSshPass.length()) {
    if (errMsg) *errMsg = "Missing SSH password.";
    return false;
  }

  ssh_session session = ssh_new();
  if (session == nullptr) {
    if (errMsg) *errMsg = "ssh_new failed.";
    return false;
  }

  const char* host = cfg.pistarHost.c_str();
  const char* user = cfg.pistarSshUser.c_str();
  const char* pass = cfg.pistarSshPass.c_str();
  int port = (cfg.pistarSshPort > 0) ? cfg.pistarSshPort : 22;
  long timeoutSec = 6;
  int strictHostKey = 0;
  int verbosity = SSH_LOG_NOLOG;
  bool needSudoPassword = sendSudoPassword && trimSafe(cfg.pistarSshUser) != "root";

  ssh_options_set(session, SSH_OPTIONS_HOST, host);
  ssh_options_set(session, SSH_OPTIONS_USER, user);
  ssh_options_set(session, SSH_OPTIONS_TIMEOUT, &timeoutSec);
  ssh_options_set(session, SSH_OPTIONS_STRICTHOSTKEYCHECK, &strictHostKey);
  ssh_options_set(session, SSH_OPTIONS_LOG_VERBOSITY, &verbosity);
  if (port != 22) {
    ssh_options_set(session, SSH_OPTIONS_PORT, &port);
  }

  Serial.println(String("[SSH] Host: ") + cfg.pistarHost + ", user: " + cfg.pistarSshUser + ", port: " + String(port));
  Serial.println(String("[SSH] Exec: ") + command);
  addWebLog("INFO", "SSH", "Connecting to " + cfg.pistarHost + ":" + String(port) + " as " + cfg.pistarSshUser + ".");
  addWebLog("INFO", "SSH", "Exec: " + command);

  int rc = ssh_connect(session);
  if (rc != SSH_OK) {
    if (errMsg) *errMsg = String(ssh_get_error(session));
    addWebLog("ERROR", "SSH", "Connect failed: " + (errMsg ? *errMsg : String("unknown error")) + ".");
    ssh_disconnect(session);
    ssh_free(session);
    return false;
  }

  rc = ssh_userauth_password(session, nullptr, pass);
  if (rc != SSH_AUTH_SUCCESS) {
    if (errMsg) *errMsg = String(ssh_get_error(session));
    addWebLog("ERROR", "SSH", "Authentication failed: " + (errMsg ? *errMsg : String("unknown error")) + ".");
    ssh_disconnect(session);
    ssh_free(session);
    return false;
  }

  ssh_channel channel = ssh_channel_new(session);
  if (channel == nullptr) {
    if (errMsg) *errMsg = "ssh_channel_new failed.";
    addWebLog("ERROR", "SSH", "Channel allocation failed.");
    ssh_disconnect(session);
    ssh_free(session);
    return false;
  }

  rc = ssh_channel_open_session(channel);
  if (rc != SSH_OK) {
    if (errMsg) *errMsg = String(ssh_get_error(session));
    addWebLog("ERROR", "SSH", "Opening remote session failed: " + (errMsg ? *errMsg : String("unknown error")) + ".");
    ssh_channel_free(channel);
    ssh_disconnect(session);
    ssh_free(session);
    return false;
  }

  rc = ssh_channel_request_exec(channel, command.c_str());
  if (rc != SSH_OK) {
    if (errMsg) *errMsg = String(ssh_get_error(session));
    addWebLog("ERROR", "SSH", "Remote exec request failed: " + (errMsg ? *errMsg : String("unknown error")) + ".");
    ssh_channel_close(channel);
    ssh_channel_free(channel);
    ssh_disconnect(session);
    ssh_free(session);
    return false;
  }

  if (needSudoPassword) {
    String sudoInput = cfg.pistarSshPass + "\n";
    if (!sshWriteAll(channel, sudoInput.c_str(), sudoInput.length())) {
      if (errMsg) *errMsg = "Failed to write sudo password to SSH channel.";
      addWebLog("ERROR", "SSH", "Writing sudo password to SSH channel failed.");
      ssh_channel_send_eof(channel);
      ssh_channel_close(channel);
      ssh_channel_free(channel);
      ssh_disconnect(session);
      ssh_free(session);
      return false;
    }
  }

  ssh_channel_send_eof(channel);

  char buffer[160];
  int n = 0;
  if (output) output->remove(0);
  do {
    n = ssh_channel_read(channel, buffer, sizeof(buffer) - 1, 0);
    if (n > 0) {
      buffer[n] = 0;
      if (output && output->length() < 800) *output += String(buffer);
    }
    yield();
  } while (n > 0);

  do {
    n = ssh_channel_read(channel, buffer, sizeof(buffer) - 1, 1);
    if (n > 0) {
      buffer[n] = 0;
      if (output && output->length() < 800) *output += String(buffer);
    }
    yield();
  } while (n > 0);

  while (!ssh_channel_is_eof(channel)) {
    delay(5);
    yield();
  }

  int exitStatus = ssh_channel_get_exit_status(channel);

  ssh_channel_close(channel);
  ssh_channel_free(channel);
  ssh_disconnect(session);
  ssh_free(session);

  Serial.println(String("[SSH] Exit status: ") + String(exitStatus));
  if (output && output->length()) {
    Serial.println(String("[SSH] Output: ") + *output);
  }

  if (exitStatus != 0) {
    addWebLog("WARN", "SSH", "Remote command failed with exit status " + String(exitStatus) + ".");
    if (errMsg) {
      if (output && output->length()) *errMsg = trimSafe(*output);
      if (!errMsg->length()) *errMsg = "Remote command returned exit status " + String(exitStatus) + ".";
    }
    return false;
  }

  addWebLog("INFO", "SSH", "Remote command completed successfully.");
  return true;
}
#endif


void modeToggleTaskEntry(void* pv) {
  ModeToggleTaskArgs* args = static_cast<ModeToggleTaskArgs*>(pv);
  String label = args ? args->label : String("");
  bool enable = args ? args->enable : false;
  delete args;

  String command = buildHostModeManagerCommand(label, enable);
  String output;
  String err;

#if ENABLE_PISTAR_TOUCH_SSH
  bool ok = runHotspotSshCommand(command, trimSafe(cfg.pistarSshUser) != "root", &output, &err);
#else
  bool ok = false;
  err = "SSH mode control is not compiled into this build.";
#endif

  String msg;
  if (!ok) {
    msg = trimSafe(err);
    if (!msg.length()) msg = "SSH command failed.";
  } else {
    msg = label + String(enable ? " enabled via SSH." : " disabled via SSH.");
  }

  strlcpy(modeToggleTaskLabel, label.c_str(), sizeof(modeToggleTaskLabel));
  strlcpy(modeToggleTaskMessage, msg.c_str(), sizeof(modeToggleTaskMessage));
  modeToggleTaskEnable = enable;
  modeToggleTaskOk = ok;
  modeToggleTaskDone = true;
  modeToggleTaskRunning = false;
  modeToggleTaskHandle = nullptr;
  vTaskDelete(nullptr);
}

bool startModeToggleTask(const String& label, bool enable) {
  if (modeToggleTaskRunning) {
    setFooterText("Wait for the current SSH action to finish.");
    return false;
  }

  ModeToggleTaskArgs* args = new ModeToggleTaskArgs{label, enable};
  if (!args) {
    setFooterText("Could not allocate SSH task memory.");
    return false;
  }

  modeToggleTaskDone = false;
  modeToggleTaskOk = false;
  modeToggleTaskEnable = enable;
  strlcpy(modeToggleTaskLabel, label.c_str(), sizeof(modeToggleTaskLabel));
  modeToggleTaskMessage[0] = 0;
  modeToggleTaskRunning = true;

  BaseType_t rc = xTaskCreatePinnedToCore(
    modeToggleTaskEntry,
    "modeToggleSSH",
    16384,
    args,
    1,
    &modeToggleTaskHandle,
    1
  );

  if (rc != pdPASS) {
    modeToggleTaskRunning = false;
    delete args;
    setFooterText("Could not start SSH task.");
    return false;
  }

  setFooterText(label + String(enable ? ": enabling..." : ": disabling..."));
  return true;
}

void processModeToggleTaskResult() {
  if (!modeToggleTaskDone) return;

  String label = String(modeToggleTaskLabel);
  bool enable = modeToggleTaskEnable;
  bool ok = modeToggleTaskOk;
  String msg = String(modeToggleTaskMessage);
  modeToggleTaskDone = false;

  if (ok) {
    ModeStatus* st = modeStatusFromTouchLabel(label);
    if (st) {
      st->known = true;
      st->enabled = enable;
    }
    nextFetchHeard = false;
    setFooterText(msg);
  } else {
    setFooterText(label + " " + (enable ? "enable" : "disable") + " failed: " + clipWithTilde(msg, 40));
  }

  requestRedraw();
}

bool runQuickActionSlotNow(uint8_t slot, String* userMsg = nullptr) {
  if (!cfg.touchModeSshEnabled) {
    String msg = "Enable SSH remote control in the config page first.";
    if (userMsg) *userMsg = msg;
    return false;
  }

  if (!cfg.pistarHost.length()) {
    String msg = "Missing hotspot host.";
    if (userMsg) *userMsg = msg;
    return false;
  }

  String buildErr;
  bool sendSudoPassword = false;
  String command = buildQuickActionRemoteCommand(slot, sendSudoPassword, &buildErr);
  String label = quickActionSlotLabel(slot);
  if (!command.length()) {
    String msg = buildErr.length() ? buildErr : String("Quick action is not configured.");
    if (userMsg) *userMsg = msg;
    return false;
  }

  String output;
  String sshErr;
#if ENABLE_PISTAR_TOUCH_SSH
  bool ok = runHotspotSshCommand(command, sendSudoPassword, &output, &sshErr);
#else
  bool ok = false;
  sshErr = "SSH remote control is not compiled into this build.";
#endif

  if (!ok) {
    String msg = trimSafe(sshErr);
    if (!msg.length()) msg = "SSH command failed.";
    if (userMsg) *userMsg = label + " failed: " + msg;
    return false;
  }

  if (userMsg) *userMsg = label + " completed.";
  lastPoll = 0;
  nextFetchHeard = false;
  return true;
}

void quickActionTaskEntry(void* pv) {
  QuickActionTaskArgs* args = static_cast<QuickActionTaskArgs*>(pv);
  uint8_t slot = args ? args->slot : 255;
  delete args;

  String label = quickActionSlotLabel(slot);
  String msg;
  bool ok = runQuickActionSlotNow(slot, &msg);

  strlcpy(quickActionTaskLabel, label.c_str(), sizeof(quickActionTaskLabel));
  strlcpy(quickActionTaskMessage, msg.c_str(), sizeof(quickActionTaskMessage));
  quickActionTaskOk = ok;
  quickActionTaskDone = true;
  quickActionTaskRunning = false;
  quickActionTaskHandle = nullptr;
  vTaskDelete(nullptr);
}

bool startQuickActionTask(uint8_t slot) {
  if (quickActionTaskRunning || modeToggleTaskRunning) {
    setFooterText("Wait for the current SSH action to finish.");
    return false;
  }

  if (!isQuickActionSlotConfigured(slot)) {
    setFooterText("Quick action is not configured.");
    return false;
  }

  QuickActionTaskArgs* args = new QuickActionTaskArgs{slot};
  if (!args) {
    setFooterText("Could not allocate SSH task memory.");
    return false;
  }

  quickActionTaskDone = false;
  quickActionTaskOk = false;
  strlcpy(quickActionTaskLabel, quickActionSlotLabel(slot).c_str(), sizeof(quickActionTaskLabel));
  quickActionTaskMessage[0] = 0;
  quickActionTaskRunning = true;

  BaseType_t rc = xTaskCreatePinnedToCore(
    quickActionTaskEntry,
    "quickActionSSH",
    16384,
    args,
    1,
    &quickActionTaskHandle,
    1
  );

  if (rc != pdPASS) {
    quickActionTaskRunning = false;
    delete args;
    setFooterText("Could not start SSH task.");
    return false;
  }

  setFooterText(quickActionSlotLabel(slot) + ": running...");
  return true;
}

void processQuickActionTaskResult() {
  if (!quickActionTaskDone) return;

  String label = String(quickActionTaskLabel);
  bool ok = quickActionTaskOk;
  String msg = String(quickActionTaskMessage);
  quickActionTaskDone = false;

  if (!msg.length()) msg = ok ? (label + " completed.") : (label + " failed.");
  setFooterText(clipWithTilde(msg, 44));
  requestRedraw();
}

bool toggleHotspotModeOverSsh(const String& label, bool enable) {
  String command = buildHostModeManagerCommand(label, enable);
  String output;
  String err;

#if ENABLE_PISTAR_TOUCH_SSH
  bool ok = runHotspotSshCommand(command, trimSafe(cfg.pistarSshUser) != "root", &output, &err);
#else
  bool ok = false;
  err = "SSH mode control is not compiled into this build.";
#endif

  if (!ok) {
    String msg = trimSafe(err);
    if (!msg.length()) msg = "SSH command failed.";
    setFooterText(label + " " + (enable ? "enable" : "disable") + " failed: " + clipWithTilde(msg, 40));
    return false;
  }

  ModeStatus* st = modeStatusFromTouchLabel(label);
  if (st) {
    st->known = true;
    st->enabled = enable;
  }

  setFooterText(label + String(enable ? " enabled via SSH." : " disabled via SSH."));
  nextFetchHeard = false;
  requestRedraw();
  return true;
}

String compactRssiValue(const String& value) {
  String out = trimSafe(value);
  if (!out.length()) return "-";
  if (out.equalsIgnoreCase("n/a") || out.equalsIgnoreCase("nan") || out.equalsIgnoreCase("null")) return "-";

  int p1 = out.indexOf('(');
  int p2 = out.indexOf(')', p1 + 1);

  if (p1 >= 0 && p2 > p1) {
    String inside = trimSafe(out.substring(p1 + 1, p2));
    if (inside.indexOf("dBm") >= 0) return inside;
  }

  if (out.indexOf("dBm") >= 0) return out;

  return prettyMetricValue(out, " dBm", false);
}

void drawSignalMeter(int x, int y, int w, int h, bool active, const HeardRecord* rec) {
  fillSoftPanel(x, y, w, h, theme.panel2, theme.border, 8);

  int cx = x + UW(34);
  int cy = y + h - UH(14);
  int radius = UR(24);

  for (int i = 0; i <= 6; i++) {
    float frac = (float)i / 6.0f;
    float angle = PI - frac * (PI * 0.85f);
    int x1 = cx + cos(angle) * (radius - UR(6));
    int y1 = cy - sin(angle) * (radius - UR(6));
    int x2 = cx + cos(angle) * radius;
    int y2 = cy - sin(angle) * radius;
    uint16_t tickColor = (i >= 4) ? theme.warn : theme.subtext;
    tft.drawLine(x1, y1, x2, y2, tickColor);
  }

  int level = 1;
  if (active) {
    if (rec && rec->rssi.length()) {
      int raw = rec->rssi.toInt();
      if (raw != 0 || trimSafe(rec->rssi) == "0") level = constrain(map(raw, -130, -70, 0, 9), 0, 9);
      else level = 8;
    } else level = 8;
  }

  float needleAngle = PI - ((float)level / 9.0f) * (PI * 0.85f);
  int nx = cx + cos(needleAngle) * (radius - UR(8));
  int ny = cy - sin(needleAngle) * (radius - UR(8));
  tft.drawLine(cx, cy, nx, ny, theme.bad);
  tft.fillCircle(cx, cy, max(2, UR(3)), theme.bad);

  tft.setTextFont(1);
  tft.setTextDatum(MC_DATUM);
  tft.setTextColor(theme.text, theme.panel2);
  tft.drawString("QUALITY", cx, y + UH(8));

  int infoX = x + UW(72);
  int infoW = w - UW(80);
  int rowY = y + UH(6);
  fillSoftPanel(infoX, rowY, infoW, UH(13), theme.panel, theme.border, 4);
  fillSoftPanel(infoX, rowY + UH(16), infoW, UH(13), theme.panel, theme.border, 4);
  fillSoftPanel(infoX, rowY + UH(32), infoW, UH(13), theme.panel, theme.border, 4);

  String rssiText = active && rec ? compactRssiValue(rec->rssi) : "-";
  String berText  = active && rec ? prettyMetricValue(rec->ber, "%", false) : "-";
  String lossText = active && rec ? prettyMetricValue(rec->loss, "%", true) : "-";

  tft.setTextDatum(ML_DATUM);
  tft.setTextFont(1);
  tft.setTextColor(theme.warn, theme.panel);
  tft.drawString("RSSI", infoX + UW(5), rowY + UH(7));
  tft.drawString("BER",  infoX + UW(5), rowY + UH(23));
  tft.drawString("LOSS", infoX + UW(5), rowY + UH(39));

  tft.setTextDatum(MR_DATUM);
  tft.setTextColor(theme.text, theme.panel);
  tft.drawString(rssiText, infoX + infoW - UW(5), rowY + UH(7));
  tft.drawString(berText,  infoX + infoW - UW(5), rowY + UH(23));
  tft.drawString(lossText, infoX + infoW - UW(5), rowY + UH(39));

  tft.setTextDatum(TL_DATUM);
}

void drawQuickActionLauncherBox(int x, int y, int w) {
  bool ready = hasConfiguredQuickActions();
  uint16_t border = ready ? theme.accent : theme.border;
  uint16_t textColor = ready ? theme.accent : theme.subtext;
  fillSoftPanel(x, y, w, UH(24), theme.panel2, border, 6);
  tft.setTextDatum(MC_DATUM);
  tft.setTextFont(2);
  tft.setTextColor(textColor, theme.panel2);
  tft.drawString("ACTIONS", x + w / 2, y + UH(12));
  tft.setTextDatum(TL_DATUM);
}

void drawFrequencyBox(int x, int y, int w, const String& label, const String& value) {
  fillSoftPanel(x, y, w, UH(24), theme.panel2, theme.border, 6);
  tft.setTextDatum(ML_DATUM);
  tft.setTextFont(2);
  tft.setTextColor(theme.warn, theme.panel2);
  tft.drawString(label, x + UW(8), y + UH(12));
  tft.setTextColor(theme.good, theme.panel2);
  tft.drawString(value.substring(0, 7), x + UW(32), y + UH(12));
}

bool bootSplashReady = false;
int bootLastProgress = -1;
int bootLastScanX = -1;
String bootLastStatus = "";

void drawBootSplashFrame() {
  int cx = SCREEN_W / 2;

  tft.fillScreen(theme.bg);
  tft.drawRoundRect(UX(12), UY(16), SCREEN_W - UW(24), SCREEN_H - UH(32), UR(12), theme.border);
  tft.drawRoundRect(UX(16), UY(20), SCREEN_W - UW(32), SCREEN_H - UH(40), UR(10), theme.panel2);

  tft.fillCircle(cx, UY(72), UR(8), theme.accent);
  for (int r = UR(18); r <= UR(54); r += UR(12)) {
    tft.drawCircle(cx, UY(72), r, theme.border);
  }

  tft.setTextDatum(MC_DATUM);
  tft.setTextColor(theme.text, theme.bg);
  tft.setTextFont(4);
  tft.drawString("MMDVM", cx, UY(106));

  tft.setTextFont(2);
  tft.setTextColor(theme.subtext, theme.bg);
  tft.drawString("BOOT SEQUENCE", cx, UY(130));

  const int barX = UX(34);
  const int barY = UY(202);
  const int barW = UW(252);
  const int barH = UH(14);
  tft.drawRoundRect(barX, barY, barW, barH, UR(7), theme.border);

  bootSplashReady = true;
  bootLastProgress = -1;
  bootLastScanX = -1;
  bootLastStatus = "";
}

void drawBootSplash(const String& status, int progress, int frame = 0) {
  progress = constrain(progress, 0, 100);

  if (!bootSplashReady) {
    drawBootSplashFrame();
  }

  const int cx = SCREEN_W / 2;
  const int barX = UX(34);
  const int barY = UY(202);
  const int barW = UW(252);
  const int barH = UH(14);
  const int innerX = barX + max(1, UW(2));
  const int innerY = barY + max(1, UH(2));
  const int innerW = max(1, barW - max(2, UW(4)));
  const int innerH = max(1, barH - max(2, UH(4)));

  int pulse = frame % 16;
  for (int r = UR(18); r <= UR(54); r += UR(12)) {
    uint16_t c = ((pulse / 2) % 3 == ((r / max(1, UR(12))) % 3)) ? theme.warn : theme.border;
    tft.drawCircle(cx, UY(72), r, c);
  }

  if (status != bootLastStatus) {
    tft.fillRect(UX(34), UY(182), UW(220), UH(16), theme.bg);
    tft.setTextDatum(TL_DATUM);
    tft.setTextColor(theme.subtext, theme.bg);
    tft.setTextFont(2);
    tft.drawString(status, UX(34), UY(182));
    bootLastStatus = status;
  }

  if (progress != bootLastProgress) {
    int oldFillW = bootLastProgress < 0 ? 0 : map(bootLastProgress, 0, 100, 0, innerW);
    int newFillW = map(progress, 0, 100, 0, innerW);

    if (newFillW > oldFillW) {
      tft.fillRoundRect(innerX, innerY, newFillW, innerH, UR(5), theme.accent);
    } else if (newFillW < oldFillW) {
      tft.fillRect(innerX + newFillW, innerY, oldFillW - newFillW, innerH, theme.bg);
    }

    tft.fillRect(UX(258), UY(182), UW(28), UH(16), theme.bg);
    tft.setTextDatum(TR_DATUM);
    tft.setTextColor(theme.text, theme.bg);
    tft.setTextFont(2);
    tft.drawString(String(progress) + "%", UX(286), UY(182));

    bootLastProgress = progress;
  }

  int scanMinX = innerX;
  int progressFillW = (int)map(progress, 0, 100, 0, innerW);
  int scanWidth = progressFillW > 1 ? progressFillW : 1;
  int scanMaxX = innerX + scanWidth - max(1, UW(2));
  int scanX = scanMinX;
  if (scanMaxX > scanMinX) {
    int scanRange = scanMaxX - scanMinX + 1;
    if (scanRange < 1) scanRange = 1;
    scanX = scanMinX + ((frame * 7) % scanRange);
  }

  if (bootLastScanX >= scanMinX && bootLastScanX <= innerX + innerW - max(1, UW(2))) {
    uint16_t restore = (bootLastScanX - innerX) < progressFillW ? theme.accent : theme.bg;
    tft.drawFastVLine(bootLastScanX, innerY, innerH, restore);
    tft.drawFastVLine(bootLastScanX + 1, innerY, innerH, restore);
  }

  tft.drawFastVLine(scanX, innerY, innerH, theme.warn);
  tft.drawFastVLine(scanX + 1, innerY, innerH, theme.warn);
  bootLastScanX = scanX;
}

void animateBootProgress(const String& status, int fromProgress, int toProgress, int frameDelay = 20) {
  if (toProgress < fromProgress) toProgress = fromProgress;
  int frame = 0;
  if (!bootSplashReady) drawBootSplashFrame();
  for (int p = fromProgress; p <= toProgress; p += 2) {
    drawBootSplash(status, p, frame++);
    delay(frameDelay);
  }
  drawBootSplash(status, toProgress, frame);
#if DISPLAY_BACKEND == DISPLAY_BACKEND_JC3248W535C
  tft.flush();
#endif
}
uint16_t rgb888To565(uint8_t r, uint8_t g, uint8_t b) {
  return ((r & 0xF8) << 8) | ((g & 0xFC) << 3) | (b >> 3);
}

String color565ToHex(uint16_t c) {
  uint8_t r = ((c >> 11) & 0x1F) * 255 / 31;
  uint8_t g = ((c >> 5) & 0x3F) * 255 / 63;
  uint8_t b = (c & 0x1F) * 255 / 31;

  char buf[8];
  snprintf(buf, sizeof(buf), "#%02X%02X%02X", r, g, b);
  return String(buf);
}

bool parseHexColor(String s, uint16_t &out565) {
  s.trim();

  if (s.startsWith("#")) s.remove(0, 1);
  else if (s.startsWith("0x") || s.startsWith("0X")) s.remove(0, 2);

  if (s.length() != 6) return false;

  char buf[7];
  s.toCharArray(buf, sizeof(buf));
  char* endptr = nullptr;
  long value = strtol(buf, &endptr, 16);

  if (*endptr != '\0') return false;
  if (value < 0 || value > 0xFFFFFF) return false;

  uint8_t r = (value >> 16) & 0xFF;
  uint8_t g = (value >> 8) & 0xFF;
  uint8_t b = value & 0xFF;
  out565 = rgb888To565(r, g, b);
  return true;
}

void loadThemeColor(JsonObject themeObj, const char* key, uint16_t &target) {
  if (!themeObj.containsKey(key)) return;
  String s = String(themeObj[key] | "");
  uint16_t c;
  if (parseHexColor(s, c)) target = c;
}

void loadThemeFromJson(JsonObject themeObj) {
  loadThemeColor(themeObj, "bg", theme.bg);
  loadThemeColor(themeObj, "panel", theme.panel);
  loadThemeColor(themeObj, "panel2", theme.panel2);
  loadThemeColor(themeObj, "border", theme.border);
  loadThemeColor(themeObj, "text", theme.text);
  loadThemeColor(themeObj, "subtext", theme.subtext);
  loadThemeColor(themeObj, "accent", theme.accent);
  loadThemeColor(themeObj, "good", theme.good);
  loadThemeColor(themeObj, "warn", theme.warn);
  loadThemeColor(themeObj, "bad", theme.bad);
  loadThemeColor(themeObj, "live", theme.live);
  loadThemeColor(themeObj, "rx", theme.rx);
  loadThemeColor(themeObj, "tx", theme.tx);
  loadThemeColor(themeObj, "tabIdle", theme.tabIdle);
  loadThemeColor(themeObj, "tabActive", theme.tabActive);
}

void fillPanel(int x, int y, int w, int h, uint16_t fill, uint16_t border) {
  int rr = max(1, UR(8));
  tft.fillRoundRect(x, y, w, h, rr, fill);
  tft.drawRoundRect(x, y, w, h, rr, border);
}

void drawLabelValue(int x, int y, const String& label, const String& value,
                    uint16_t labelColor, uint16_t valueColor, uint16_t bg) {
  tft.setTextDatum(TL_DATUM);
  tft.setTextFont(2);
  tft.setTextColor(labelColor, bg);
  tft.drawString(label, x, y);
  tft.setTextColor(valueColor, bg);
  tft.drawString(value, x + UW(78), y);
}

String normalizeHamCallsign(const String& input) {
  String s = input;
  s.trim();
  s.toUpperCase();
  if (!s.length()) return s;

  int slash = s.indexOf('/');
  if (slash >= 0) {
    String left = s.substring(0, slash);
    String right = s.substring(slash + 1);
    bool leftHasDigit = false;
    bool rightHasDigit = false;
    for (size_t i = 0; i < left.length(); ++i) if (isDigit(left[i])) { leftHasDigit = true; break; }
    for (size_t i = 0; i < right.length(); ++i) if (isDigit(right[i])) { rightHasDigit = true; break; }
    if (leftHasDigit && !rightHasDigit) s = left;
    else if (!leftHasDigit && rightHasDigit) s = right;
    else s = left;
  }

  s.replace(" ", "");
  return s;
}

bool ensureLookupDirectory() {
  if (storageExists("/lookup")) return true;
  return SD.mkdir("/lookup");
}

String urlEncodeComponent(const String& value) {
  static const char hex[] = "0123456789ABCDEF";
  String encoded;
  encoded.reserve(value.length() * 3);

  for (size_t i = 0; i < value.length(); ++i) {
    uint8_t c = (uint8_t)value[i];
    if ((c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '-' || c == '_' || c == '.' || c == '~') {
      encoded += (char)c;
    } else {
      encoded += '%';
      encoded += hex[(c >> 4) & 0x0F];
      encoded += hex[c & 0x0F];
    }
  }

  return encoded;
}

String buildRadioIdUsersUrl(const String& callsign) {
  String normalized = normalizeHamCallsign(callsign);
  if (!normalized.length()) return "";

  String encodedCallsign = urlEncodeComponent(normalized);
  String base = trimSafe(cfg.radioIdUsersApiBase);
  if (!base.length()) base = String(RADIOID_USERS_API_BASE_DEFAULT);

  if (base.indexOf("%CALLSIGN%") >= 0) {
    base.replace("%CALLSIGN%", encodedCallsign);
    return base;
  }

  return base + encodedCallsign;
}

void clearRadioIdLookupCache() {
  for (int i = 0; i < RADIOID_LOOKUP_CACHE_SIZE; ++i) {
    radioIdLookupCache[i] = RadioIdLookupCacheEntry();
  }
  radioIdLookupCacheWriteIndex = 0;
}

int radioIdLookupCacheIndexForCallsign(const String& normalizedCallsign) {
  if (!normalizedCallsign.length()) return -1;
  for (int i = 0; i < RADIOID_LOOKUP_CACHE_SIZE; ++i) {
    if (radioIdLookupCache[i].valid && radioIdLookupCache[i].callsign == normalizedCallsign) return i;
  }
  return -1;
}

struct RadioIdLookupCacheEntry* radioIdLookupCacheSlotForCallsign(const String& normalizedCallsign, bool createIfMissing) {
  int existing = radioIdLookupCacheIndexForCallsign(normalizedCallsign);
  if (existing >= 0) return &radioIdLookupCache[existing];
  if (!createIfMissing) return nullptr;

  RadioIdLookupCacheEntry& slot = radioIdLookupCache[radioIdLookupCacheWriteIndex];
  slot = RadioIdLookupCacheEntry();
  slot.callsign = normalizedCallsign;
  slot.valid = true;
  radioIdLookupCacheWriteIndex = (radioIdLookupCacheWriteIndex + 1) % RADIOID_LOOKUP_CACHE_SIZE;
  return &slot;
}

bool radioIdLookupNeeded(const String& callsign, const String& name) {
  if (!trimSafe(callsign).length()) return false;

  String currentName = trimSafe(name);
  if (!currentName.length()) return true;

  String normalizedCurrent = normalizeHamCallsign(currentName);
  String normalizedCallsign = normalizeHamCallsign(callsign);
  if (normalizedCurrent == normalizedCallsign) return true;

  bool digitsOnly = currentName.length() > 0;
  for (size_t i = 0; i < currentName.length(); ++i) {
    if (!isDigit(currentName[i])) {
      digitsOnly = false;
      break;
    }
  }

  return digitsOnly;
}

String radioIdLookupSignatureForRecord(const HeardRecord& r) {
  String mode = trimSafe(r.mode);
  mode.toUpperCase();

  String callsign = normalizeHamCallsign(r.callsign);
  String target = trimSafe(r.target);
  target.toUpperCase();

  if (!callsign.length()) return "";
  return mode + "|" + callsign + "|" + target;
}

void queueRadioIdLookupForRecord(const HeardRecord& r) {
  if (!cfg.radioIdLookupEnabled) return;

  String normalized = normalizeHamCallsign(r.callsign);
  if (!normalized.length()) return;

  if (!radioIdLookupNeeded(r.callsign, r.name) && trimSafe(r.country).length()) return;

  String signature = radioIdLookupSignatureForRecord(r);
  if (!signature.length() || signature == lastRadioIdLookupSignature) return;

  lastRadioIdLookupSignature = signature;
  pendingRadioIdLookupCallsign = normalized;
}

bool parseRadioIdUserResponse(const String& payload, struct RadioIdLookupCacheEntry& entry) {
  DynamicJsonDocument doc(4096);
  DeserializationError err = deserializeJson(doc, payload);
  if (err) return false;
  if (!doc.is<JsonObject>()) return false;

  JsonObject root = doc.as<JsonObject>();
  entry.found = false;
  entry.name = "";
  entry.country = "";
  entry.city = "";
  entry.state = "";

  JsonArray results = root["results"].as<JsonArray>();
  if (results.isNull() || results.size() == 0) return true;

  JsonObject obj = results[0];
  String apiName = firstNonEmpty(
    String(obj["name"] | ""),
    String(obj["display_name"] | ""),
    String(obj["full_name"] | ""),
    String(obj["fullname"] | "")
  );
  if (!apiName.length()) {
    apiName = buildOperatorName(
      firstNonEmpty(String(obj["fname"] | ""), String(obj["first_name"] | ""), String(obj["firstname"] | ""), String(obj["forename"] | "")),
      firstNonEmpty(String(obj["lname"] | ""), String(obj["last_name"] | ""), String(obj["lastname"] | ""), String(obj["surname"] | ""))
    );
  }

  entry.name = trimSafe(apiName);
  entry.country = trimSafe(String(obj["country"] | obj["country_name"] | ""));
  entry.city = trimSafe(String(obj["city"] | ""));
  entry.state = trimSafe(String(obj["state"] | obj["region"] | ""));
  entry.found = entry.name.length() || entry.country.length() || entry.city.length() || entry.state.length();
  return true;
}

bool fetchRadioIdProfile(const String& normalizedCallsign, struct RadioIdLookupCacheEntry& outEntry) {
  String url = buildRadioIdUsersUrl(normalizedCallsign);
  if (!url.length()) return false;

  WiFiClientSecure client;
  client.setInsecure();

  HTTPClient http;
  http.setConnectTimeout(HTTP_CONNECT_TIMEOUT_MS);
  http.setTimeout(HTTP_READ_TIMEOUT_MS);
  if (!http.begin(client, url)) return false;

  String userAgent = String("MMDVM-Display/") + DISPLAY_VERSION + " (operator=" + getDeviceCallsign() + ")";
  http.setUserAgent(userAgent);
  http.addHeader("Accept", "application/json");

  int code = http.GET();
  if (code != 200) {
    http.end();
    return false;
  }

  String payload = http.getString();
  http.end();

  outEntry = RadioIdLookupCacheEntry();
  outEntry.callsign = normalizedCallsign;
  outEntry.valid = true;
  outEntry.lastFetchMillis = millis();
  return parseRadioIdUserResponse(payload, outEntry);
}

void applyRadioIdCacheToRecord(struct HeardRecord& r, bool allowFetch) {
  if (!cfg.radioIdLookupEnabled) return;

  String normalized = normalizeHamCallsign(r.callsign);
  if (!normalized.length()) return;

  struct RadioIdLookupCacheEntry* entry = radioIdLookupCacheSlotForCallsign(normalized, false);
  if (!entry || !entry->valid || !entry->lastFetchMillis) {
    if (allowFetch && radioIdLookupNeeded(r.callsign, r.name)) { }
    return;
  }

  if (radioIdLookupNeeded(r.callsign, r.name) && entry->name.length()) r.name = entry->name;
  if (!trimSafe(r.country).length() && entry->country.length()) r.country = entry->country;
}

void applyRadioIdCacheToList(struct HeardRecord* list, int count) {
  if (count <= 0) return;
  for (int i = 0; i < count; ++i) applyRadioIdCacheToRecord(list[i], false);
}

bool applyRadioIdCacheToActiveData() {
  bool changed = false;

  for (int i = 0; i < heardCount; ++i) {
    HeardRecord before = heardList[i];
    applyRadioIdCacheToRecord(heardList[i], false);
    if (!sameRecord(before, heardList[i])) changed = true;
  }

  HeardRecord beforeLive = liveRecord;
  applyRadioIdCacheToRecord(liveRecord, false);
  if (!sameRecord(beforeLive, liveRecord)) changed = true;

  return changed;
}

String radioIdDisplayNameForRecord(const struct HeardRecord& r, bool allowFetch) {
  String displayName = trimSafe(r.name);
  if (!radioIdLookupNeeded(r.callsign, displayName)) return displayName;

  String normalized = normalizeHamCallsign(r.callsign);
  if (!normalized.length()) return displayName;

  struct RadioIdLookupCacheEntry* entry = radioIdLookupCacheSlotForCallsign(normalized, false);
  if (entry && entry->valid && entry->name.length()) return trimSafe(entry->name);
  if (allowFetch) { }

  return displayName;
}

String radioIdDisplayCountryForRecord(const struct HeardRecord& r, bool allowFetch) {
  String currentCountry = trimSafe(r.country);
  if (currentCountry.length()) return currentCountry;

  String normalized = normalizeHamCallsign(r.callsign);
  if (!normalized.length()) return currentCountry;

  struct RadioIdLookupCacheEntry* entry = radioIdLookupCacheSlotForCallsign(normalized, false);
  if (entry && entry->valid && entry->country.length()) return trimSafe(entry->country);
  if (allowFetch) { }

  return currentCountry;
}

String nextRadioIdLookupCandidate() {
  if (!cfg.radioIdLookupEnabled || WiFi.status() != WL_CONNECTED) return "";

  auto pick = [&](const HeardRecord& rec) -> String {
    String normalized = normalizeHamCallsign(rec.callsign);
    if (!normalized.length()) return "";

    struct RadioIdLookupCacheEntry* entry = radioIdLookupCacheSlotForCallsign(normalized, false);
    unsigned long now = millis();
    if (entry && entry->valid && entry->lastFetchMillis) {
      unsigned long age = now - entry->lastFetchMillis;
      if (entry->found && age < RADIOID_LOOKUP_TTL_MS) return "";
      if (!entry->found && age < RADIOID_LOOKUP_RETRY_MS) return "";
    }

    if (!radioIdLookupNeeded(rec.callsign, rec.name) && trimSafe(rec.country).length()) return "";
    return normalized;
  };

  if (detailPopupOpen && selectedHeardIndex >= 0 && selectedHeardIndex < heardCount) {
    String candidate = pick(heardList[selectedHeardIndex]);
    if (candidate.length()) return candidate;
  }

  if (isLiveActive()) {
    String candidate = pick(liveRecord);
    if (candidate.length()) return candidate;
  }

  int rows = min(heardCount, 5);
  for (int i = 0; i < rows; ++i) {
    String candidate = pick(heardList[i]);
    if (candidate.length()) return candidate;
  }

  return "";
}

void processRadioIdLookups() {
  if (!cfg.radioIdLookupEnabled || WiFi.status() != WL_CONNECTED) return;
  if (!pendingRadioIdLookupCallsign.length()) return;

  unsigned long now = millis();
  if (now - lastRadioIdLookupMillis < RADIOID_LOOKUP_MIN_INTERVAL_MS) return;

  String normalized = pendingRadioIdLookupCallsign;
  pendingRadioIdLookupCallsign = "";

  struct RadioIdLookupCacheEntry* cached = radioIdLookupCacheSlotForCallsign(normalized, false);
  if (cached && cached->valid && cached->lastFetchMillis) {
    unsigned long age = now - cached->lastFetchMillis;
    if ((cached->found && age < RADIOID_LOOKUP_TTL_MS) || (!cached->found && age < RADIOID_LOOKUP_RETRY_MS)) {
      if (applyRadioIdCacheToActiveData()) requestRedraw();
      return;
    }
  }

  lastRadioIdLookupMillis = now;

  struct RadioIdLookupCacheEntry fetched;
  if (!fetchRadioIdProfile(normalized, fetched)) {
    return;
  }

  struct RadioIdLookupCacheEntry* slot = radioIdLookupCacheSlotForCallsign(normalized, true);
  if (!slot) return;
  *slot = fetched;

  if (applyRadioIdCacheToActiveData()) requestRedraw();
}

String flagBmpPathFromCode(const String& code) {
  String cc = code;
  cc.toLowerCase();
  if (cc.length()) {
    String p = "/flags/" + cc + ".bmp";
    if (storageExists(p.c_str())) return p;
    p = "/" + cc + ".bmp";
    if (storageExists(p.c_str())) return p;
  }
  if (storageExists("/flags/de.bmp")) return "/flags/de.bmp";
  return "/de.bmp";
}

bool drawFlagForCallsign(int x, int y, int w, int h, const String& callsign) {
  String path = flagBmpPathFromCode(countryCodeFromCallsign(callsign));

  const int bmpW = 32;
  const int bmpH = 32;

  if (w < bmpW || h < bmpH) return false;

  int drawX = x + (w - bmpW) / 2;
  int drawY = y + (h - bmpH) / 2;

  return drawBmp24FromSD(path.c_str(), drawX, drawY, bmpW, bmpH);
}

String getHeaderTimeText();

void drawTopTabs() {
  const int tabY = UY(6);
  const int tabH = UH(24);
  const int tabGap = UW(4);
  const int tabW = UW(54);
  const int startX = UX(6);
  const bool showApTab = setupRequired && apModeActive;

  const Page pages[4] = {PAGE_LIVE, PAGE_LAST, PAGE_STATUS, showApTab ? PAGE_AP : PAGE_INFO};
  const char* labels[4] = {"LIVE", "HEARD", "STATUS", showApTab ? "AP" : "INFO"};

  for (int i = 0; i < 4; i++) {
    int x = startX + i * (tabW + tabGap);
    uint16_t fill = (currentPage == pages[i]) ? theme.tabActive : theme.tabIdle;
    if (pages[i] == PAGE_AP && apModeActive) fill = theme.tabActive;
    fillSoftPanel(x, tabY, tabW, tabH, fill, theme.border, 8);

    tft.setTextDatum(MC_DATUM);
    tft.setTextFont(2);
    tft.setTextColor(TFT_WHITE, fill);
    tft.drawString(labels[i], x + tabW / 2, tabY + tabH / 2);
  }

  const int clockX = UX(246);
  const int clockY = UY(6);
  const int clockW = UW(68);
  const int clockH = UH(24);

  fillSoftPanel(clockX, clockY, clockW, clockH, theme.panel2, theme.border, 8);

  tft.setTextDatum(MC_DATUM);
  tft.setTextFont(2);
  tft.setTextColor(TFT_WHITE, theme.panel2);
  tft.drawString(getHeaderTimeText(), clockX + clockW / 2, clockY + clockH / 2);
}

void drawFooter() {
  tft.fillRect(0, UY(222), SCREEN_W, UH(18), theme.panel2);
  tft.drawFastHLine(0, UY(221), SCREEN_W, theme.border);
  tft.setTextDatum(TL_DATUM);
  tft.setTextFont(1);
  tft.setTextColor(theme.subtext, theme.panel2);
  tft.drawString(footerText, UX(4), UY(227));
}

void clearBody() {
  tft.fillRect(0, UY(34), SCREEN_W, UY(188), theme.bg);
}

String countryCodeFromCallsign(const String& cs) {
  String c = normalizeHamCallsign(cs);
  c.toUpperCase();
  if (!c.length()) return "";

  auto hasPrefix = [&](const char* p) -> bool {
    return c.startsWith(p);
  };

  auto hasAnyPrefix = [&](std::initializer_list<const char*> prefixes) -> bool {
    for (const char* p : prefixes) {
      if (c.startsWith(p)) return true;
    }
    return false;
  };

  if (hasPrefix("4U")) return "un";
  if (hasPrefix("4Y")) return "icao";
  if (hasPrefix("C7")) return "wmo";
  if (hasPrefix("3Y") || hasPrefix("JW") || hasPrefix("JX") || hasAnyPrefix({"LA","LB","LC","LD","LE","LF","LG","LH","LI","LJ","LK","LL","LM","LN"})) return "no";
  if (hasPrefix("VR")) return "hk";
  if (hasPrefix("XX")) return "mo";
  if (hasPrefix("E4")) return "ps";

  if (hasAnyPrefix({"2","G","M","VP","VQ","VS","ZB","ZC","ZD","ZE","ZF","ZG","ZH","ZI","ZJ","ZN","ZO","ZQ"})) return "uk";
  if (hasAnyPrefix({"DA","DB","DC","DD","DE","DF","DG","DH","DI","DJ","DK","DL","DM","DN","DO","DP","DQ","DR",
                    "Y2","Y3","Y4","Y5","Y6","Y7","Y8","Y9"})) return "de";
  if (hasAnyPrefix({"F","HW","HX","HY","TH","TK","TM","TO","TP","TQ","TV","TW","TX"})) return "fr";
  if (hasAnyPrefix({"EA","EB","EC","ED","EE","EF","EG","EH","AM","AN","AO"})) return "es";
  if (hasAnyPrefix({"I"})) return "it";
  if (hasAnyPrefix({"PA","PB","PC","PD","PE","PF","PG","PH","PI"})) return "nl";
  if (hasAnyPrefix({"ON","OO","OP","OQ","OR","OS","OT"})) return "be";
  if (hasAnyPrefix({"OU","OV","OW","OX","OY","OZ","5P","5Q","XP"})) return "dk";
  if (hasAnyPrefix({"OH","OF","OG","OI","OJ"})) return "fi";
  if (hasAnyPrefix({"OE"})) return "at";
  if (hasAnyPrefix({"OK","OL"})) return "cz";
  if (hasAnyPrefix({"OM"})) return "sk";
  if (hasAnyPrefix({"HB","HE"})) return "ch";
  if (hasAnyPrefix({"LX"})) return "lu";
  if (hasAnyPrefix({"LY"})) return "lt";
  if (hasAnyPrefix({"LZ"})) return "bg";
  if (hasAnyPrefix({"ER"})) return "md";
  if (hasAnyPrefix({"ES"})) return "ee";
  if (hasAnyPrefix({"EU","EV","EW"})) return "by";
  if (hasAnyPrefix({"EK"})) return "am";
  if (hasAnyPrefix({"EX"})) return "kg";
  if (hasAnyPrefix({"EY"})) return "tj";
  if (hasAnyPrefix({"EZ"})) return "tm";
  if (hasAnyPrefix({"S5"})) return "si";
  if (hasAnyPrefix({"9A"})) return "hr";
  if (hasAnyPrefix({"HA","HG"})) return "hu";
  if (hasAnyPrefix({"HF","3Z","SN","SO","SP","SQ","SR"})) return "pl";
  if (hasAnyPrefix({"YO","YP","YQ","YR"})) return "ro";
  if (hasAnyPrefix({"YT","YU"})) return "rs";
  if (hasAnyPrefix({"Z3"})) return "mk";
  if (hasAnyPrefix({"E7"})) return "ba";
  if (hasAnyPrefix({"4O"})) return "me";
  if (hasAnyPrefix({"SV","SW","SX","SY","SZ","J4"})) return "gr";
  if (hasAnyPrefix({"TA","TB","TC","YM"})) return "tr";
  if (hasAnyPrefix({"T7"})) return "sm";
  if (hasAnyPrefix({"CT","CQ","CR","CS","CU"})) return "pt";
  if (hasAnyPrefix({"EI","EJ"})) return "ie";
  if (hasAnyPrefix({"UA","UB","UC","UD","UE","UF","UG","UH","UI","RA","RB","RC","RD","RE","RF","RG","RH","RI","RJ","RK","RL","RM","RN","RO","RP","RQ","RR","RS","RT","RU","RV","RW","RX","RY","RZ"})) return "ru";
  if (hasAnyPrefix({"UR","US","UT","UU","UV","UW","UX","UY","UZ","EM","EN","EO"})) return "ua";
  if (hasAnyPrefix({"UJ","UK","UL","UM"})) return "uz";
  if (hasAnyPrefix({"UN","UO","UP","UQ"})) return "kz";

  if (hasAnyPrefix({"K","N","W","AA","AB","AC","AD","AE","AF","AG","AH","AI","AJ","AK","KA","KB","KC","KD","KE","KF","KG","KH","KI","KJ","KK","KL","KM","KN","KO","KP","KQ","KR","KS","KT","KU","KV","KW","KX","KY","KZ",
                    "NA","NB","NC","ND","NE","NF","NG","NH","NI","NJ","NK","NL","NM","NN","NO","NP","NQ","NR","NS","NT","NU","NV","NW","NX","NY","NZ",
                    "WA","WB","WC","WD","WE","WF","WG","WH","WI","WJ","WK","WL","WM","WN","WO","WP","WQ","WR","WS","WT","WU","WV","WW","WX","WY","WZ"})) return "us";
  if (hasAnyPrefix({"CF","CG","CH","CI","CJ","CK","CY","CZ","VA","VB","VC","VD","VE","VF","VG","VO","VX","VY","XJ","XK","XL","XM","XN","XO"})) return "ca";
  if (hasAnyPrefix({"XA","XB","XC","XD","XE","XF","XG","XH","XI","4A","4B","4C","6D","6E","6F","6G","6H","6I","6J"})) return "mx";
  if (hasAnyPrefix({"V2"})) return "ag";
  if (hasAnyPrefix({"V3"})) return "bz";
  if (hasAnyPrefix({"V4"})) return "kn";
  if (hasAnyPrefix({"J3"})) return "gd";
  if (hasAnyPrefix({"J6"})) return "lc";
  if (hasAnyPrefix({"J7"})) return "dm";
  if (hasAnyPrefix({"J8"})) return "vc";
  if (hasAnyPrefix({"8P"})) return "bb";
  if (hasAnyPrefix({"8R"})) return "gy";
  if (hasAnyPrefix({"9Y","9Z"})) return "tt";
  if (hasAnyPrefix({"C6"})) return "bs";
  if (hasAnyPrefix({"CO","CL","CM","T4"})) return "cu";
  if (hasAnyPrefix({"HP","HO","H3","H8","H9","3E","3F"})) return "pa";
  if (hasAnyPrefix({"P4"})) return "aw";
  if (hasAnyPrefix({"PJ"})) return "cw";
  if (hasAnyPrefix({"PZ"})) return "sr";
  if (hasAnyPrefix({"TI","TE"})) return "cr";
  if (hasAnyPrefix({"TG","TD"})) return "gt";
  if (hasAnyPrefix({"YN","H6","H7","HT"})) return "ni";
  if (hasAnyPrefix({"HR","HQ"})) return "hn";
  if (hasAnyPrefix({"HU","YS"})) return "sv";
  if (hasAnyPrefix({"HI"})) return "do";
  if (hasAnyPrefix({"HH","4V"})) return "ht";

  if (hasAnyPrefix({"AY","AZ","L2","L3","L4","L5","L6","L7","L8","L9","LO","LP","LQ","LR","LS","LT","LU","LV","LW"})) return "ar";
  if (hasAnyPrefix({"CP"})) return "bo";
  if (hasAnyPrefix({"CX","CV","CW"})) return "uy";
  if (hasAnyPrefix({"HC","HD"})) return "ec";
  if (hasAnyPrefix({"HK","HJ","5J","5K"})) return "co";
  if (hasAnyPrefix({"OA","OB","OC","4T"})) return "pe";
  if (hasAnyPrefix({"YV","YW","YX","YY","4M"})) return "ve";
  if (hasAnyPrefix({"PP","PQ","PR","PS","PT","PU","PV","PW","PX","PY","ZV","ZW","ZX","ZY","ZZ"})) return "br";
  if (hasAnyPrefix({"CA","CB","CC","CD","CE","3G","XQ","XR"})) return "cl";
  if (hasAnyPrefix({"ZP"})) return "py";

  if (hasAnyPrefix({"A2","8O"})) return "bw";
  if (hasAnyPrefix({"3B"})) return "mu";
  if (hasAnyPrefix({"3C"})) return "gq";
  if (hasPrefix("3D")) return c.startsWith("3DN") ? "fj" : "sz";
  if (hasAnyPrefix({"3X"})) return "gn";
  if (hasAnyPrefix({"5A"})) return "ly";
  if (hasAnyPrefix({"5H","5I"})) return "tz";
  if (hasAnyPrefix({"5L","5M","6Z","A8","D5","EL"})) return "lr";
  if (hasAnyPrefix({"5N","5O"})) return "ng";
  if (hasAnyPrefix({"5R","5S","6X"})) return "mg";
  if (hasAnyPrefix({"5T"})) return "mr";
  if (hasAnyPrefix({"5U"})) return "ne";
  if (hasAnyPrefix({"5V"})) return "tg";
  if (hasAnyPrefix({"5X"})) return "ug";
  if (hasAnyPrefix({"5Y","5Z"})) return "ke";
  if (hasAnyPrefix({"6V","6W"})) return "sn";
  if (hasAnyPrefix({"7P"})) return "ls";
  if (hasAnyPrefix({"7Q"})) return "mw";
  if (hasAnyPrefix({"7R","7T","7U","7V","7W","7X","7Y"})) return "dz";
  if (hasAnyPrefix({"9E","9F","ET"})) return "et";
  if (hasAnyPrefix({"9G"})) return "gh";
  if (hasAnyPrefix({"9I","9J"})) return "zm";
  if (hasAnyPrefix({"9L"})) return "sl";
  if (hasAnyPrefix({"9O","9P","9Q","9R","9S","9T"})) return "cd";
  if (hasAnyPrefix({"9U"})) return "bi";
  if (hasAnyPrefix({"9X"})) return "rw";
  if (hasAnyPrefix({"C5"})) return "gm";
  if (hasAnyPrefix({"C8","C9"})) return "mz";
  if (hasAnyPrefix({"CN","5C","5D","5E","5F","5G"})) return "ma";
  if (hasAnyPrefix({"D2","D3"})) return "ao";
  if (hasAnyPrefix({"D4"})) return "cv";
  if (hasAnyPrefix({"D6"})) return "km";
  if (hasAnyPrefix({"J2"})) return "dj";
  if (hasAnyPrefix({"S7"})) return "sc";
  if (hasAnyPrefix({"S8","ZR","ZS","ZT","ZU"})) return "za";
  if (hasAnyPrefix({"S9"})) return "st";
  if (hasAnyPrefix({"SU","SSA","SSB","SSC","SSD","SSE","SSF","SSG","SSH","SSI","SSJ","SSK","SSL","SSM"})) return "eg";
  if (hasAnyPrefix({"SSN","SSO","SSP","SSQ","SSR","SSS","SST","SSU","SSV","SSW","SSX","SSY","SSZ","ST","6T","6U"})) return "sd";
  if (hasAnyPrefix({"TJ"})) return "cm";
  if (hasAnyPrefix({"TL"})) return "cf";
  if (hasAnyPrefix({"TN"})) return "cg";
  if (hasAnyPrefix({"TR"})) return "ga";
  if (hasAnyPrefix({"TS","3V"})) return "tn";
  if (hasAnyPrefix({"TT"})) return "td";
  if (hasAnyPrefix({"TU"})) return "ci";
  if (hasAnyPrefix({"TY"})) return "bj";
  if (hasAnyPrefix({"TZ"})) return "ml";
  if (hasAnyPrefix({"XT"})) return "bf";
  if (hasAnyPrefix({"Z2"})) return "zw";
  if (hasAnyPrefix({"Z8"})) return "ss";

  if (hasAnyPrefix({"4J","4K"})) return "az";
  if (hasAnyPrefix({"4L"})) return "ge";
  if (hasAnyPrefix({"4X","4Z"})) return "il";
  if (hasAnyPrefix({"5B","C4","H2","P3"})) return "cy";
  if (hasAnyPrefix({"7O"})) return "ye";
  if (hasAnyPrefix({"7Z","8Z","HZ"})) return "sa";
  if (hasAnyPrefix({"9K"})) return "kw";
  if (hasAnyPrefix({"9N"})) return "np";
  if (hasAnyPrefix({"A4"})) return "om";
  if (hasAnyPrefix({"A5"})) return "bt";
  if (hasAnyPrefix({"A6"})) return "ae";
  if (hasAnyPrefix({"A7"})) return "qa";
  if (hasAnyPrefix({"A9"})) return "bh";
  if (hasAnyPrefix({"AP","AQ","AR","AS","6P","6Q","6R","6S"})) return "pk";
  if (hasAnyPrefix({"EP","EQ","9B","9C","9D"})) return "ir";
  if (hasAnyPrefix({"HN","YI"})) return "iq";
  if (hasAnyPrefix({"JY"})) return "jo";
  if (hasAnyPrefix({"OD"})) return "lb";
  if (hasAnyPrefix({"YK","6C"})) return "sy";
  if (hasAnyPrefix({"E4"})) return "ps";
  if (hasAnyPrefix({"T6","YA"})) return "af";

  if (hasAnyPrefix({"8Q"})) return "mv";
  if (hasAnyPrefix({"8T","8U","8V","8W","8X","8Y","AT","AU","AV","AW","VT","VU","VV","VW"})) return "in";
  if (hasAnyPrefix({"9M","9W"})) return "my";
  if (hasAnyPrefix({"9V","S6"})) return "sg";
  if (hasAnyPrefix({"B","3H","3I","3J","3K","3L","3M","3N","3O","3P","3Q","3R","3S","3T","3U","BA","BB","BC","BD","BE","BF","BG","BH","BI","BJ","BK","BL","BM","BN","BO","BP","BQ","BR","BS","BT","BU","BV","BW","BX","BY","BZ","XS"})) return "cn";
  if (hasAnyPrefix({"BV"})) return "tw";
  if (hasAnyPrefix({"DS","DT","D7","D8","D9","6K","6L","6M","6N","HL"})) return "kr";
  if (hasAnyPrefix({"HM","P5","P6","P7","P8","P9"})) return "kp";
  if (hasAnyPrefix({"E2","HS"})) return "th";
  if (hasAnyPrefix({"JA","JB","JC","JD","JE","JF","JG","JH","JI","JJ","JK","JL","JM","JN","JO","JP","JQ","JR","JS","7J","7K","7L","7M","7N","8J","8K","8L","8M","8N"})) return "jp";
  if (hasAnyPrefix({"JT","JU","JV"})) return "mn";
  if (hasAnyPrefix({"PK","PL","PM","PN","PO","7A","7B","7C","7D","7E","7F","7G","7H","7I","8A","8B","8C","8D","8E","8F","8G","8H","8I","JZ","YB","YC","YD","YE","YF","YG","YH"})) return "id";
  if (hasAnyPrefix({"V8"})) return "bn";
  if (hasAnyPrefix({"XU"})) return "kh";
  if (hasAnyPrefix({"XV","3W"})) return "vn";
  if (hasAnyPrefix({"XW"})) return "la";
  if (hasAnyPrefix({"XY","XZ"})) return "mm";
  if (hasAnyPrefix({"P2"})) return "pg";
  if (hasAnyPrefix({"A3"})) return "to";
  if (hasAnyPrefix({"C2"})) return "nr";
  if (hasAnyPrefix({"E5"})) return "ck";
  if (hasAnyPrefix({"E6"})) return "nu";
  if (hasAnyPrefix({"H4"})) return "sb";
  if (hasAnyPrefix({"T2"})) return "tv";
  if (hasAnyPrefix({"T3"})) return "ki";
  if (hasAnyPrefix({"T8"})) return "pw";
  if (hasAnyPrefix({"V6"})) return "fm";
  if (hasAnyPrefix({"V7"})) return "mh";
  if (hasAnyPrefix({"YJ"})) return "vu";
  if (hasAnyPrefix({"4W"})) return "tl";
  if (hasAnyPrefix({"AX","VH","VI","VJ","VK","VL","VM","VN","VZ"})) return "au";
  if (hasAnyPrefix({"ZK","ZL","ZM"})) return "nz";
  if (hasAnyPrefix({"5W"})) return "ws";

  return "de";
}

uint16_t bmpRead16(fs::File &f) {
  uint16_t result;
  ((uint8_t *)&result)[0] = f.read();
  ((uint8_t *)&result)[1] = f.read();
  return result;
}

uint32_t bmpRead32(fs::File &f) {
  uint32_t result;
  ((uint8_t *)&result)[0] = f.read();
  ((uint8_t *)&result)[1] = f.read();
  ((uint8_t *)&result)[2] = f.read();
  ((uint8_t *)&result)[3] = f.read();
  return result;
}

bool drawBmp24FromSD(const char *filename, int16_t x, int16_t y, int16_t maxW, int16_t maxH) {
  File bmpFS = storageOpen(filename, FILE_READ);
  if (!bmpFS) return false;

  if (bmpRead16(bmpFS) != 0x4D42) {
    bmpFS.close();
    return false;
  }

  bmpRead32(bmpFS);
  bmpRead32(bmpFS);
  uint32_t imageOffset = bmpRead32(bmpFS);
  uint32_t headerSize = bmpRead32(bmpFS);
  int32_t bmpWidth = (int32_t)bmpRead32(bmpFS);
  int32_t bmpHeight = (int32_t)bmpRead32(bmpFS);

  if (bmpRead16(bmpFS) != 1) {
    bmpFS.close();
    return false;
  }

  uint16_t depth = bmpRead16(bmpFS);
  uint32_t compression = bmpRead32(bmpFS);

  if (headerSize < 40 || depth != 24 || compression != 0) {
    bmpFS.close();
    return false;
  }

  bool flip = true;
  if (bmpHeight < 0) {
    bmpHeight = -bmpHeight;
    flip = false;
  }

  if (bmpWidth <= 0 || bmpHeight <= 0) {
    bmpFS.close();
    return false;
  }

  if (bmpWidth > maxW || bmpHeight > maxH) {
    bmpFS.close();
    return false;
  }

  uint32_t rowSize = (bmpWidth * 3 + 3) & ~3;
  uint8_t sdbuffer[3 * 20];
  uint8_t buffidx = sizeof(sdbuffer);

  for (int row = 0; row < bmpHeight; row++) {
    uint32_t pos = imageOffset + (flip ? (bmpHeight - 1 - row) : row) * rowSize;
    bmpFS.seek(pos);

    buffidx = sizeof(sdbuffer);

    for (int col = 0; col < bmpWidth; col++) {
      if (buffidx >= sizeof(sdbuffer)) {
        bmpFS.read(sdbuffer, sizeof(sdbuffer));
        buffidx = 0;
      }

      uint8_t b = sdbuffer[buffidx++];
      uint8_t g = sdbuffer[buffidx++];
      uint8_t r = sdbuffer[buffidx++];

#if DISPLAY_BACKEND == DISPLAY_BACKEND_2432S028R
      tft.drawPixel(x + col, y + row, rgb888To565(r, g, b));
#elif DISPLAY_BACKEND == DISPLAY_BACKEND_JC3248W535C
      tft.drawPixel(x + 8 + col, y + 8 + row, rgb888To565(r, g, b));
#else

#endif
    }
  }

  bmpFS.close();
  return true;
}

void *pngOpen(const char *filename, int32_t *size) {
  pngFile = storageOpen(filename, FILE_READ);
  if (!pngFile) {
    *size = 0;
    return nullptr;
  }

  *size = pngFile.size();
  return &pngFile;
}

void pngClose(void *handle) {
  (void)handle;
  if (pngFile) pngFile.close();
}

int32_t pngRead(PNGFILE *page, uint8_t *buffer, int32_t length) {
  (void)page;
  if (!pngFile) return 0;
  return pngFile.read(buffer, length);
}

int32_t pngSeek(PNGFILE *page, int32_t position) {
  (void)page;
  if (!pngFile) return 0;
  return pngFile.seek(position) ? position : 0;
}

int pngDraw(PNGDRAW *pDraw) {
  if (!pngLineBuffer || !pngDecodeOk) return 0;
  if (pDraw->iWidth <= 0 || pDraw->iWidth > pngMaxW || (pDraw->y + 1) > pngMaxH) {
    pngDecodeOk = false;
    return 0;
  }

  if (pngHasAlpha && pngPixelType == PNG_PIXEL_TRUECOLOR_ALPHA && pDraw->pPixels) {
    const uint8_t* s = (const uint8_t*)pDraw->pPixels;
    for (int x = 0; x < pDraw->iWidth; ++x) {
      uint8_t fr = s[0];
      uint8_t fg = s[1];
      uint8_t fb = s[2];
      uint8_t fa = s[3];
      s += 4;

      if (fa == 0) {
        pngLineBuffer[x] = pngBlendBg565Buffer;
      } else if (fa == 255) {
        pngLineBuffer[x] = pngBufferColor565(rgb888To565(fr, fg, fb));
      } else {
        uint16_t inv = 255 - fa;
        uint8_t r = (uint8_t)((fr * fa + pngBlendBgR * inv + 127) / 255);
        uint8_t g = (uint8_t)((fg * fa + pngBlendBgG * inv + 127) / 255);
        uint8_t b = (uint8_t)((fb * fa + pngBlendBgB * inv + 127) / 255);
        pngLineBuffer[x] = pngBufferColor565(rgb888To565(r, g, b));
      }
    }
  } else {
    pngDecoder.getLineAsRGB565(pDraw, pngLineBuffer, PNG_RGB565_BIG_ENDIAN, 0xffffffff);

#if DISPLAY_BACKEND == DISPLAY_BACKEND_2432S028R
    if (pngHasAlpha) {
      size_t neededMaskBytes = ((size_t)pDraw->iWidth + 7U) / 8U;
      if (pngMaskBufferBytes < neededMaskBytes) {
        uint8_t* newMask = (uint8_t*)realloc(pngMaskBuffer, neededMaskBytes);
        if (!newMask) {
          pngDecodeOk = false;
          return 0;
        }
        pngMaskBuffer = newMask;
        pngMaskBufferBytes = neededMaskBytes;
      }

      if (pngMaskBuffer && pngDecoder.getAlphaMask(pDraw, pngMaskBuffer, 255)) {
        for (int x = 0; x < pDraw->iWidth; ++x) {
          uint8_t maskByte = pngMaskBuffer[(size_t)x >> 3];
          uint8_t maskBit = 0x80 >> (x & 7);
          if ((maskByte & maskBit) == 0) {
            pngLineBuffer[x] = pngBlendBg565Buffer;
          }
        }
      } else {
        for (int x = 0; x < pDraw->iWidth; ++x) {
          pngLineBuffer[x] = pngBlendBg565Buffer;
        }
      }
    }
#endif
  }

#if DISPLAY_BACKEND == DISPLAY_BACKEND_2432S028R
  tft.pushImage(pngDrawX, pngDrawY + pDraw->y, pDraw->iWidth, 1, pngLineBuffer);
#elif DISPLAY_BACKEND == DISPLAY_BACKEND_JC3248W535C
  tft.pushImage(pngDrawX, pngDrawY + pDraw->y, pDraw->iWidth, 1, pngLineBuffer);
#endif
  return 1;
}

bool drawPngFromSD(const char *filename, int16_t x, int16_t y, int16_t maxW, int16_t maxH, uint16_t bg565) {
  if (!filename || maxW <= 0 || maxH <= 0) return false;

  pngDrawX = x;
  pngDrawY = y;
  pngMaxW = maxW;
  pngMaxH = maxH;
  pngDecodeOk = true;
  pngBlendBg565 = bg565;
  pngBlendBg565Buffer = pngBufferColor565(bg565);
  rgb565To888Components(bg565, pngBlendBgR, pngBlendBgG, pngBlendBgB);
  pngPixelType = 0;
  pngHasAlpha = false;

  int32_t rc = pngDecoder.open(filename, pngOpen, pngClose, pngRead, pngSeek, pngDraw);
  if (rc != PNG_SUCCESS) {
    pngClose(nullptr);
    return false;
  }

  pngPixelType = pngDecoder.getPixelType();
  pngHasAlpha = pngDecoder.hasAlpha();

  int32_t pngWidth = pngDecoder.getWidth();
  int32_t pngHeight = pngDecoder.getHeight();
  if (pngWidth <= 0 || pngHeight <= 0 || pngWidth > maxW || pngHeight > maxH) {
    pngDecoder.close();
    pngClose(nullptr);
    return false;
  }

  size_t neededPixels = (size_t)pngWidth;
  if (pngLineBufferPixels < neededPixels) {
    uint16_t* newBuffer = (uint16_t*)realloc(pngLineBuffer, neededPixels * sizeof(uint16_t));
    if (!newBuffer) {
      pngDecoder.close();
      pngClose(nullptr);
      return false;
    }
    pngLineBuffer = newBuffer;
    pngLineBufferPixels = neededPixels;
  }

#if DISPLAY_BACKEND == DISPLAY_BACKEND_2432S028R
  tft.startWrite();
#endif
  rc = pngDecoder.decode(nullptr, 0);
#if DISPLAY_BACKEND == DISPLAY_BACKEND_2432S028R
  tft.endWrite();
#endif

  pngDecoder.close();
  pngClose(nullptr);
  return pngDecodeOk && (rc == PNG_SUCCESS);
}

void drawFlagOrPlaceholder(int x, int y, int w, int h, const String& code) {

  fillPanel(x, y, w, h, theme.panel2, theme.border);

  if (code.length()) {
    String path = "/flags/" + code + ".bmp";
#if DISPLAY_BACKEND == DISPLAY_BACKEND_JC3248W535C
    if (drawBmp24FromSD(path.c_str(), x + UW(11), y + UH(5) - 4, w - UW(10), h - UH(10))) {
#else
    if (drawBmp24FromSD(path.c_str(), x + UW(11), y + UH(5), w - UW(10), h - UH(10))) {
#endif
      return;
    }
  }

  tft.setTextDatum(MC_DATUM);
  tft.setTextColor(theme.accent, theme.panel2);
  tft.setTextFont(2);
  tft.drawString(code.length() ? code : "--", x + w / 2, y + h / 2);
}

void drawBrightnessSlider() {
  int rowX = UX(14);
  int rowY = UY(186);
  int rowW = UW(290);
  int rowH = UH(22);

  fillSoftPanel(rowX, rowY, UW(76), rowH, theme.panel2, theme.border, 5);
  fillSoftPanel(rowX + UW(80), rowY, rowW - UW(80), rowH, theme.panel2, theme.border, 5);

  tft.setTextDatum(ML_DATUM);
  tft.setTextFont(2);
  tft.setTextColor(theme.warn, theme.panel2);
  tft.drawString("BRIGHT", rowX + UW(8), rowY + rowH / 2);

  tft.fillRoundRect(BRIGHT_SLIDER_X, BRIGHT_SLIDER_Y, BRIGHT_SLIDER_W, BRIGHT_SLIDER_H, UR(4), theme.panel);
  tft.drawRoundRect(BRIGHT_SLIDER_X, BRIGHT_SLIDER_Y, BRIGHT_SLIDER_W, BRIGHT_SLIDER_H, UR(4), theme.border);

  int knobX = map(cfg.brightness, 0, 255,
                  BRIGHT_SLIDER_X + max(1, UW(4)),
                  BRIGHT_SLIDER_X + BRIGHT_SLIDER_W - max(1, UW(5)));
  int fillW = knobX - BRIGHT_SLIDER_X;
  if (fillW > 0) tft.fillRoundRect(BRIGHT_SLIDER_X, BRIGHT_SLIDER_Y, fillW, BRIGHT_SLIDER_H, UR(4), theme.accent);
  tft.fillCircle(knobX, BRIGHT_SLIDER_Y + BRIGHT_SLIDER_H / 2, max(2, UR(5)), theme.warn);

  tft.setTextDatum(MR_DATUM);
  tft.setTextFont(1);
  tft.setTextColor(theme.text, theme.panel2);
  tft.drawString(String(cfg.brightness), rowX + rowW - UW(8), rowY + rowH / 2);
}

void drawLivePage() {
  drawTopTabs();

  HeardRecord rec;
  bool active = isLiveActive();
  if (active) rec = liveRecord;

  String activeMode = active ? normalizeModeName(rec.mode) : "";
  bool dmrActive = active && activeMode == "DMR";
  bool dstarActive = active && (activeMode == "DSTAR" || activeMode == "D-STAR");
  bool ysfActive = active && (activeMode == "YSF" || activeMode == "FUSION");
  bool p25Active = active && activeMode == "P25";
  bool nxdnActive = active && activeMode == "NXDN";
  bool pocsagActive = active && activeMode == "POCSAG";

  drawModeButton(UX(10), UY(38), UW(70), UH(20), "DMR", modeDMR, dmrActive);
  drawModeButton(UX(86), UY(38), UW(70), UH(20), "DSTAR", modeDSTAR, dstarActive);
  drawModeButton(UX(10), UY(62), UW(70), UH(20), "YSF", modeYSF, ysfActive);
  drawModeButton(UX(86), UY(62), UW(70), UH(20), "P25", modeP25, p25Active);
  drawModeButton(UX(10), UY(86), UW(70), UH(20), "NXDN", modeNXDN, nxdnActive);
  drawModeButton(UX(86), UY(86), UW(70), UH(20), "POCSAG", modePOCSAG, pocsagActive);

  drawSignalMeter(UX(170), UY(44), UW(140), UH(54), active, active ? &rec : nullptr);

  String operatorName = active ? radioIdDisplayNameForRecord(rec, true) : "";
  String operatorLine = active ? formatCallsignName(rec.callsign, operatorName, 0, 0) : "-";
  String statusLine = liveStatusText(active, rec);
  String ts1Line = active ? resolveTargetDisplay(rec) : "-";
  String rxFreq = hotspotRxFreq.length() ? hotspotRxFreq : currentRxFreq;
  String txFreq = hotspotTxFreq.length() ? hotspotTxFreq : currentTxFreq;
  if (!rxFreq.length()) rxFreq = (normalizedBackend() == "wpsd") ? "Auto" : "438.8000";
  if (!txFreq.length()) txFreq = (normalizedBackend() == "wpsd") ? "Auto" : "430.8000";

  drawLiveInfoPanel(UX(10), UY(112), UW(300), UH(68), operatorLine, statusLine, ts1Line, active);

  drawFrequencyBox(UX(10), UY(190), UW(94), "TX", txFreq);
  drawQuickActionLauncherBox(UX(112), UY(190), UW(96));
  drawFrequencyBox(UX(216), UY(190), UW(94), "RX", rxFreq);

  drawFooter();
}

void drawLastPage() {
  drawTopTabs();

  fillSoftPanel(UX(6), UY(36), UW(308), UH(184), theme.panel, theme.border, 12);

  tft.setTextDatum(TL_DATUM);
  tft.setTextFont(2);
  tft.setTextColor(theme.accent, theme.panel);
  tft.drawString("LAST HEARD", UX(16), UY(46));

  tft.setTextColor(theme.subtext, theme.panel);
  tft.setTextFont(1);
  tft.drawString("Tap any row for details", UX(16), UY(64));

  int rows = min(heardCount, 5);
  int y = UY(76);

  for (int i = 0; i < rows; i++) {
    HeardRecord& r = heardList[i];
    uint16_t lineBg = (i % 2 == 0) ? theme.panel2 : mix565(theme.panel2, theme.bg, 0.18f);
    fillSoftPanel(UX(14), y, UW(292), UH(24), lineBg, theme.border, 6);

    tft.setTextFont(2);
    tft.setTextDatum(TL_DATUM);

    tft.setTextColor(theme.accent, lineBg);
    tft.drawString(r.callsign.substring(0, 10), UX(22), y + UY(5));

    tft.setTextColor(theme.warn, lineBg);
    tft.drawString(r.mode.substring(0, 6), UX(94), y + UY(5));

    tft.setTextColor(theme.text, lineBg);
    tft.drawString(r.target.substring(0, 10), UX(150), y + UY(5));

    tft.setTextColor(theme.subtext, lineBg);
    tft.drawString(r.timeUtc.length() > 11 ? r.timeUtc.substring(11) : r.timeUtc, UX(236), y + UY(5));

    y += UH(28);
  }

  if (rows == 0) {
    tft.setTextColor(theme.subtext, theme.panel);
    tft.setTextFont(2);
    tft.drawString("No traffic received yet", UX(18), UY(86));
  }

  drawFooter();
}

void drawSetupPage() {
  drawTopTabs();

  fillSoftPanel(UX(6), UY(36), UW(308), UH(184), theme.panel, theme.border, 12);

  tft.setTextDatum(TL_DATUM);
  tft.setTextFont(2);
  tft.setTextColor(theme.accent, theme.panel);
  tft.drawString("CONFIG / STATUS", UX(14), UY(46));

  drawInfoRow(UX(14), UY(66), UW(290), "Wi-Fi", wifiOk ? "Connected" : "Disconnected", theme.panel2, theme.panel2,
              theme.warn, wifiOk ? theme.good : theme.bad);
  drawInfoRow(UX(14), UY(90), UW(290), "SSID", cfg.wifiSsid, theme.panel2, theme.panel2, theme.warn, theme.text);
  drawInfoRow(UX(14), UY(114), UW(290), "HOST", WiFi.localIP().toString(), theme.panel2, theme.panel2, theme.warn, theme.text);
  drawInfoRow(UX(14), UY(138), UW(290), "CALLSIGN", getDeviceCallsign(), theme.panel2, theme.panel2, theme.warn, theme.good);
  drawInfoRow(UX(14), UY(162), UW(138), "REFRESH", String(cfg.refreshMs) + " ms", theme.panel2, theme.panel2, theme.warn, theme.text);
  drawInfoRow(UX(166), UY(162), UW(138), "FLIP", cfg.flipScreen ? "On" : "Off", theme.panel2, theme.panel2, theme.warn, cfg.flipScreen ? theme.good : theme.subtext);
  drawInfoRow(UX(14), UY(186), UW(138), "SAVER", cfg.screensaverEnabled ? (String(cfg.screensaverTimeoutMs / 1000) + " s") : "Off", theme.panel2, theme.panel2, theme.warn, cfg.screensaverEnabled ? theme.good : theme.subtext);

  drawBrightnessSlider();
  drawFooter();
}

void drawApModePage() {
  drawTopTabs();

  fillSoftPanel(UX(6), UY(36), UW(308), UH(184), theme.panel, theme.border, 12);

  tft.setTextDatum(TL_DATUM);
  tft.setTextFont(2);
  tft.setTextColor(theme.accent, theme.panel);
  tft.drawString("ACCESS POINT MODE", UX(14), UY(46));

  String modeText = apModeActive ? "Active" : "Inactive";
  String ipText = apModeActive ? WiFi.softAPIP().toString() : "-";
  String stateText = setupRequired ? "Setup required before normal run" : "Optional AP status";
  String ssidText = apSsid.length() ? apSsid : "-";
  String passText = apModeActive ? apPass : "-";

  drawInfoRow(UX(14), UY(66), UW(290), "AP MODE", modeText, theme.panel2, theme.panel2,
              theme.warn, apModeActive ? theme.good : theme.subtext);
  drawInfoRow(UX(14), UY(90), UW(290), "SSID", ssidText, theme.panel2, theme.panel2, theme.warn, theme.text);
  drawInfoRow(UX(14), UY(114), UW(290), "PASSWORD", passText, theme.panel2, theme.panel2, theme.warn, theme.text);
  drawInfoRow(UX(14), UY(138), UW(290), "OPEN", ipText, theme.panel2, theme.panel2, theme.warn, theme.good);
  drawInfoRow(UX(14), UY(162), UW(290), "STORAGE", activeStorageLabel, theme.panel2, theme.panel2, theme.warn, theme.text);

  fillSoftPanel(UX(14), UY(188), UW(290), UH(22), theme.panel2, theme.border, 6);
  tft.setTextDatum(TL_DATUM);
  tft.setTextFont(1);
  tft.setTextColor(theme.subtext, theme.panel2);
  tft.drawString(stateText, UX(20), UY(195));

  drawFooter();
}

void drawInfoPage() {
  tft.fillScreen(theme.bg);

  const int cardX = UX(10);
  const int cardY = UY(10);
  const int cardW = SCREEN_W - UW(20);
  const int cardH = UH(220);

  fillSoftPanel(cardX, cardY, cardW, cardH, theme.panel2, theme.border, 14);
  fillSoftPanel(cardX + UW(10), cardY + UH(10), cardW - UW(20), UH(52), theme.panel, theme.border, 12);

  tft.setTextDatum(TL_DATUM);
  tft.setTextFont(2);
  tft.setTextColor(theme.accent, theme.panel);
  tft.drawString("SESSION STATISTICS", cardX + UW(20), cardY + UH(20));
  tft.setTextColor(theme.subtext, theme.panel);
  tft.setTextFont(1);
  tft.drawString(getDateText() + "  " + getDetailedClockText(), cardX + UW(20), cardY + UH(38));

  drawInfoRow(cardX + UW(16), cardY + UH(72), cardW - UW(32), "DEVICE", getDeviceCallsign(), theme.panel, theme.panel2, theme.warn, theme.good);
  drawInfoRow(cardX + UW(16), cardY + UH(96), cardW - UW(32), "UPTIME", formatUptimeBrief(), theme.panel, theme.panel2, theme.warn, theme.text);
  drawInfoRow(cardX + UW(16), cardY + UH(120), cardW - UW(32), "TRAFFIC", String(sessionInfo.totalQsos) + " hits", theme.panel, theme.panel2, theme.warn, theme.text);
  String rssiSummary = sessionInfo.hasRssi ? (String(sessionInfo.bestRssi) + "/" + String(sessionInfo.worstRssi) + " dBm") : "-";
  drawInfoRow(cardX + UW(16), cardY + UH(144), cardW - UW(32), "RSSI B/W", rssiSummary, theme.panel, theme.panel2, theme.warn, theme.text);

  int miniY = cardY + UH(172);
  int miniW = UW(66);
  const char* labels[4] = {"DMR", "DSTAR", "YSF", "OTHERS"};
  uint32_t values[4] = {sessionInfo.modeDMR, sessionInfo.modeDSTAR, sessionInfo.modeYSF,
                        sessionInfo.modeP25 + sessionInfo.modeNXDN + sessionInfo.modePOCSAG};

  for (int i = 0; i < 4; ++i) {
    int x = cardX + UW(18) + i * UW(66);
    fillSoftPanel(x, miniY, miniW, UH(34), theme.panel, theme.border, 10);
    tft.setTextFont(1);
    tft.setTextColor(theme.warn, theme.panel);
    tft.drawString(labels[i], x + UW(8), miniY + UH(7));
    tft.setTextFont(2);
    tft.setTextColor(theme.text, theme.panel);
    tft.drawString(String(values[i]), x + UW(8), miniY + UH(18));
  }
}

void drawDetailPopup(const HeardRecord& r) {
  fillPanel(UX(12), UY(32), SCREEN_W - UX(24), SCREEN_H - UY(58), theme.panel2, theme.border);

  tft.setTextDatum(TL_DATUM);
  tft.setTextFont(2);
  tft.setTextColor(theme.accent, theme.panel2);
  tft.drawString("QSO DETAILS", UX(22), UY(42));

  String cc = countryCodeFromCallsign(r.callsign);

  drawFlagOrPlaceholder(SCREEN_W - UW(82), UY(42), UW(52), UH(42), cc);

  String countryText = radioIdDisplayCountryForRecord(r, true);
  if (!countryText.length()) countryText = cc;
  String heardName = radioIdDisplayNameForRecord(r, true);
  drawLabelValue(UX(22), UY(70),  "User:", r.callsign, theme.warn, theme.text, theme.panel2);
  drawLabelValue(UX(22), UY(86),  "Name:", heardName.length() ? clipWithTilde(heardName, 18) : "-", theme.warn, theme.text, theme.panel2);
  drawLabelValue(UX(22), UY(102), "Mode:", r.mode, theme.warn, theme.text, theme.panel2);
  drawLabelValue(UX(22), UY(118), "Dest:", clipWithTilde(resolveTargetDisplay(r), 18), theme.warn, theme.text, theme.panel2);
  drawLabelValue(UX(22), UY(134), "Grid:", r.grid.length() ? r.grid : "-", theme.warn, theme.good, theme.panel2);
  drawLabelValue(UX(22), UY(150), "Cntry:", countryText.length() ? countryText : "-", theme.warn, theme.text, theme.panel2);
  String quality = "RSSI " + compactRssiValue(r.rssi) + "  BER " + prettyMetricValue(r.ber, "%", false);
  drawLabelValue(UX(22), UY(166), "Qual:", clipWithTilde(quality, 18), theme.warn, theme.subtext, theme.panel2);
  drawLabelValue(UX(22), UY(182), cfg.apiUtcToLocalTime ? "Local:" : "UTC:", r.timeUtc.length() ? r.timeUtc : "-", theme.warn, theme.subtext, theme.panel2);
}

void drawModeConfirmPopup() {
  int popupX, popupY, popupW, popupH;
  int noX, noY, noW, noH;
  int yesX, yesY, yesW, yesH;
  getModeConfirmPopupLayout(popupX, popupY, popupW, popupH, noX, noY, noW, noH, yesX, yesY, yesW, yesH);

  fillSoftPanel(popupX, popupY, popupW, popupH, theme.panel2, theme.accent, 14);

  tft.setTextDatum(MC_DATUM);
  tft.setTextColor(theme.accent, theme.panel2);
  tft.setTextFont(2);
  tft.drawString("MODE CONTROL", popupX + popupW / 2, popupY + UH(22));

  tft.setTextColor(theme.text, theme.panel2);
  tft.setTextFont(2);
  String actionText = modeConfirmEnableRequested ? "Enable " : "Disable ";
  tft.drawString(actionText + modeConfirmTargetLabel + " ?", popupX + popupW / 2, popupY + UH(50));

  fillSoftPanel(noX, noY, noW, noH, theme.panel, theme.border, 8);
  fillSoftPanel(yesX, yesY, yesW, yesH, theme.panel, theme.good, 8);

  tft.setTextColor(theme.text, theme.panel);
  tft.drawString("NO", noX + noW / 2, noY + noH / 2);
  tft.setTextColor(theme.good, theme.panel);
  tft.drawString("YES", yesX + yesW / 2, yesY + yesH / 2);

  tft.setTextDatum(TL_DATUM);
}

void drawQuickActionsPopup() {
  const int popupW = UW(250);
  const int popupH = UH(128);
  const int popupX = (SCREEN_W - popupW) / 2;
  const int popupY = (SCREEN_H - popupH) / 2;
  const int btnW = UW(106);
  const int btnH = UH(24);
  const int gapX = UW(10);
  const int gapY = UH(10);
  const int startX = popupX + UW(14);
  const int startY = popupY + UH(34);
  const int closeX = popupX + UW(78);
  const int closeY = popupY + popupH - UH(30);
  const int closeW = UW(94);
  const int closeH = UH(20);

  fillSoftPanel(popupX, popupY, popupW, popupH, theme.panel2, theme.accent, 14);

  tft.setTextDatum(MC_DATUM);
  tft.setTextColor(theme.accent, theme.panel2);
  tft.setTextFont(2);
  tft.drawString("QUICK ACTIONS", popupX + popupW / 2, popupY + UH(16));

  tft.setTextFont(1);
  tft.setTextColor(theme.subtext, theme.panel2);
  tft.drawString("Run a saved remote preset", popupX + popupW / 2, popupY + UH(28));

  for (uint8_t i = 0; i < QUICK_ACTION_SLOT_COUNT; i++) {
    int col = i % 2;
    int row = i / 2;
    int bx = startX + col * (btnW + gapX);
    int by = startY + row * (btnH + gapY);
    bool ready = isQuickActionSlotConfigured(i);
    fillSoftPanel(bx, by, btnW, btnH, theme.panel, ready ? theme.good : theme.border, 8);
    tft.setTextColor(ready ? theme.good : theme.subtext, theme.panel);
    tft.setTextFont(2);
    String label = ready ? clipWithTilde(quickActionSlotLabel(i), 13) : String("Empty");
    tft.drawString(label, bx + btnW / 2, by + btnH / 2);
  }

  fillSoftPanel(closeX, closeY, closeW, closeH, theme.panel, theme.border, 8);
  tft.setTextColor(theme.text, theme.panel);
  tft.setTextFont(2);
  tft.drawString("CLOSE", closeX + closeW / 2, closeY + closeH / 2);
  tft.setTextDatum(TL_DATUM);
}

String getScreensaverTimeText() {
  struct tm timeinfo;
  if (getLocalTime(&timeinfo, 10)) {
    char buf[8];
    strftime(buf, sizeof(buf), "%H:%M", &timeinfo);
    return String(buf);
  }
  return "--:--";
}

String getHeaderTimeText() {
  struct tm timeinfo;
  if (getLocalTime(&timeinfo, 10)) {
    char buf[8];
    strftime(buf, sizeof(buf), "%H:%M", &timeinfo);
    return String(buf);
  }
  return "--:--";
}

String getDetailedClockText() {
  struct tm timeinfo;
  if (getLocalTime(&timeinfo, 10)) {
    char buf[10];
    strftime(buf, sizeof(buf), "%H:%M", &timeinfo);
    return String(buf);
  }
  return "--:--";
}

String getDateText() {
  struct tm timeinfo;
  if (getLocalTime(&timeinfo, 10)) {
    char buf[20];
    strftime(buf, sizeof(buf), "%d %b %Y", &timeinfo);
    return String(buf);
  }
  return "-- --- ----";
}

static int64_t daysFromCivil(int year, unsigned month, unsigned day) {
  year -= (month <= 2) ? 1 : 0;
  const int era = (year >= 0 ? year : year - 399) / 400;
  const unsigned yoe = (unsigned)(year - era * 400);
  const unsigned doy = (153 * (month + (month > 2 ? -3 : 9)) + 2) / 5 + day - 1;
  const unsigned doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
  return (int64_t)era * 146097 + (int64_t)doe - 719468;
}

time_t utcTmToEpoch(const struct tm& tmValue) {
  const int year = tmValue.tm_year + 1900;
  const unsigned month = (unsigned)tmValue.tm_mon + 1;
  const unsigned day = (unsigned)tmValue.tm_mday;

  int64_t days = daysFromCivil(year, month, day);
  int64_t seconds = days * 86400LL +
                    (int64_t)tmValue.tm_hour * 3600LL +
                    (int64_t)tmValue.tm_min * 60LL +
                    (int64_t)tmValue.tm_sec;

  return (time_t)seconds;
}

bool parseApiDateTime(const String& input, struct tm& tmOut) {
  String s = trimSafe(input);
  if (s.length() < 19) return false;

  s.replace('T', ' ');

  int year = 0;
  int month = 0;
  int day = 0;
  int hour = 0;
  int minute = 0;
  int second = 0;

  if (sscanf(s.c_str(), "%d-%d-%d %d:%d:%d", &year, &month, &day, &hour, &minute, &second) != 6) {
    return false;
  }

  if (year < 1970 || month < 1 || month > 12 || day < 1 || day > 31 ||
      hour < 0 || hour > 23 || minute < 0 || minute > 59 || second < 0 || second > 60) {
    return false;
  }

  memset(&tmOut, 0, sizeof(tmOut));
  tmOut.tm_year = year - 1900;
  tmOut.tm_mon = month - 1;
  tmOut.tm_mday = day;
  tmOut.tm_hour = hour;
  tmOut.tm_min = minute;
  tmOut.tm_sec = second;
  tmOut.tm_isdst = -1;
  return true;
}

String formatApiDateTime(const struct tm& tmValue) {
  char buf[24];
  strftime(buf, sizeof(buf), "%Y-%m-%d %H:%M:%S", &tmValue);
  return String(buf);
}

String normalizeHeardTimeDisplay(const String& apiTimeUtc) {
  String raw = trimSafe(apiTimeUtc);
  if (!raw.length()) return "";
  if (!cfg.apiUtcToLocalTime) return raw;

  struct tm parsedUtc;
  if (!parseApiDateTime(raw, parsedUtc)) return raw;

  time_t epochUtc = utcTmToEpoch(parsedUtc);
  if (epochUtc == (time_t)-1) return raw;

  struct tm localTm;
  localtime_r(&epochUtc, &localTm);
  return formatApiDateTime(localTm);
}

void drawScreensaver(bool forceClear) {
  static String lastFrameKey = "";

  if (forceClear) {
    lastFrameKey = "";
  }

  HeardRecord rec;
  bool active = isLiveActive();
  bool hasLastHeard = liveRecord.valid;
  if (hasLastHeard) rec = liveRecord;

  String clockText = getDetailedClockText();
  String dateText = getDateText();
  String callsignText = getDeviceCallsign();
  String shownCallsign = callsignText.length() ? normalizeHamCallsign(callsignText) : "-";
  String operatorText = rec.callsign;
  String shownOperator = operatorText.length() ? normalizeHamCallsign(operatorText) : "-";
  String screensaverName = hasLastHeard ? radioIdDisplayNameForRecord(rec, true) : "";
  String screensaverUser = hasLastHeard ? formatCallsignName(rec.callsign, screensaverName, 10, 10) : shownCallsign;
  String flagCallsign = hasLastHeard && operatorText.length() ? operatorText : callsignText;
  String targetText = hasLastHeard ? resolveTargetDisplay(rec) : "Standby";
  if (targetText.length() > 24) targetText = targetText.substring(0, 23) + "~";

  uint32_t phase = millis() / 12000UL;
  int driftX = ((int)(phase % 5) - 2) * UW(4);
  int driftY = ((int)((phase / 2) % 5) - 2) * UH(3);
  int cx = SCREEN_W / 2 + driftX;
  int weatherY = UY(40) + driftY;
  int clockY = UY(86) + driftY;
  int dateY = UY(122) + driftY;
#if DISPLAY_BACKEND == DISPLAY_BACKEND_JC3248W535C
  int CallsignY = UY(150) + driftY;
#else
  int CallsignY = UY(158) + driftY;
#endif

  int badgeY = UY(178) + driftY;

  String weatherText = weatherDisplayText();
  String weatherIcon = weatherIconPath();
  String frameKey = clockText + "|" + dateText + "|" + shownCallsign + "|" + screensaverUser + "|" + targetText + "|" + weatherText + "|" + weatherIcon + "|" + String(driftX) + "|" + String(driftY);
  if (frameKey == lastFrameKey && !forceClear) return;

  tft.fillScreen(TFT_BLACK);

#if DISPLAY_BACKEND == DISPLAY_BACKEND_JC3248W535C
  tft.drawFastHLine(UX(18), UY(10), SCREEN_W - UW(36), theme.panel2);
  tft.drawFastHLine(UX(30), UY(11), SCREEN_W - UW(60), theme.border);
  tft.drawFastHLine(UX(18), SCREEN_H - UY(10), SCREEN_W - UW(36), theme.panel2);
  tft.drawFastHLine(UX(30), SCREEN_H - UY(11), SCREEN_W - UW(60), theme.border);
#else
  tft.drawFastHLine(UX(18), UY(20), SCREEN_W - UW(36), theme.panel2);
  tft.drawFastHLine(UX(30), UY(21), SCREEN_W - UW(60), theme.border);
  tft.drawFastHLine(UX(18), SCREEN_H - UY(20), SCREEN_W - UW(36), theme.panel2);
  tft.drawFastHLine(UX(30), SCREEN_H - UY(21), SCREEN_W - UW(60), theme.border);
#endif

  if (weatherConfigured() && weatherState.valid) {
    int weatherCardW = weatherText.length() ? UW(108) : UW(56);
    int weatherCardH = UH(30);
    int weatherCardX = cx - (weatherCardW / 2);
    fillSoftPanel(weatherCardX, weatherY - (weatherCardH / 2), weatherCardW, weatherCardH, theme.panel, theme.border, 12);

    int cursorX = weatherCardX + UW(10);
    if (weatherIcon.length()) {
#if DISPLAY_BACKEND == DISPLAY_BACKEND_JC3248W535C
      drawPngFromSD(weatherIcon.c_str(), cursorX, weatherY - UH(12) + 4, 24, 24, theme.panel);
#else
      drawPngFromSD(weatherIcon.c_str(), cursorX, weatherY - UH(12), 24, 24, theme.panel);
#endif
      cursorX += UW(32);
    }

    if (weatherText.length()) {
      tft.setTextDatum(ML_DATUM);
      tft.setTextFont(2);
      tft.setTextColor(theme.text, theme.panel);
      tft.drawString(weatherText, cursorX, weatherY);
    }
  }

  tft.setTextDatum(MC_DATUM);
  tft.setTextFont(7);
  tft.setTextColor(theme.accent, TFT_BLACK);
  tft.drawString(clockText, cx, clockY);

  tft.setTextFont(2);
  tft.setTextColor(theme.subtext, TFT_BLACK);
  tft.drawString(dateText, cx, dateY);

  tft.setTextFont(4);
  tft.setTextColor(theme.warn, theme.panel);
  tft.drawString(shownCallsign, cx, CallsignY);

  int badgeW = UW(248);
  int badgeH = UH(44);
  int badgeX = (SCREEN_W - badgeW) / 2 + driftX;
  badgeX = constrain(badgeX, UX(8), SCREEN_W - badgeW - UX(8));
  fillSoftPanel(badgeX, badgeY, badgeW, badgeH, theme.panel, theme.border, 14);

  const int badgeFlagW = 32;
  const int badgeFlagH = 32;
  int badgeFlagX = badgeX + UW(10);
#if DISPLAY_BACKEND == DISPLAY_BACKEND_JC3248W535C
  int badgeFlagY = badgeY + UH(6) - 3;
#else
  int badgeFlagY = badgeY + UH(6);
#endif
  badgeFlagY = constrain(badgeFlagY, badgeY + UH(4), badgeY + badgeH - badgeFlagH - UH(4));
  drawFlagForCallsign(badgeFlagX, badgeFlagY, badgeFlagW, badgeFlagH, flagCallsign);

  int badgeTextX = badgeFlagX + badgeFlagW + UW(10);
  int badgeTextMax = max(8, (badgeW - (badgeTextX - badgeX) - UW(10)) / 6);
  String badgeUser = clipWithTilde(screensaverUser, badgeTextMax);
  String badgeTarget = clipWithTilde(targetText, badgeTextMax + 2);

  tft.setTextDatum(ML_DATUM);
  tft.setTextFont(2);
  tft.setTextColor(theme.warn, theme.panel);

#if DISPLAY_BACKEND == DISPLAY_BACKEND_JC3248W535C
  tft.drawString(badgeUser, badgeTextX, badgeY + UH(13) + 5);
#else
  tft.drawString(badgeUser, badgeTextX, badgeY + UH(13) + 3);
#endif

  tft.setTextFont(1);
  tft.setTextColor(theme.text, theme.panel);
#if DISPLAY_BACKEND == DISPLAY_BACKEND_JC3248W535C
  tft.drawString(badgeTarget, badgeTextX, badgeY + UH(27) + 5);
#else
  tft.drawString(badgeTarget, badgeTextX, badgeY + UH(27) + 3);
#endif

  lastFrameKey = frameKey;
}

void drawAboutPage() {
  tft.setTextDatum(TL_DATUM);

  const int cardX = UX(10);
  const int cardY = UY(10);
  const int cardW = SCREEN_W - UW(20);

  fillSoftPanel(UX(12), UY(44), SCREEN_W - UW(24), UH(164), theme.panel, theme.border, 12);

  tft.setTextColor(theme.accent, theme.panel);
  tft.setTextFont(4);
  tft.drawString("MMDVM Display", UX(24), UY(56));

  drawInfoRow(cardX + UW(16), cardY + UH(88), cardW - UW(32), "CREATOR", "13MAD86 / Martin", theme.panel, theme.panel2, theme.warn, theme.good);
  drawInfoRow(cardX + UW(16), cardY + UH(112), cardW - UW(32), "VERSION", DISPLAY_VERSION, theme.panel, theme.panel2, theme.warn, theme.text);

  tft.setTextDatum(MC_DATUM);
  tft.setTextColor(theme.warn, theme.panel);
  tft.setTextFont(2);
  tft.drawString("Free for Public, use at own Risk!", SCREEN_W / 2, UY(176));
}

void redrawCurrentPage(bool fullRedraw = false) {
  Page pageToDraw = (setupRequired && currentPage != PAGE_INFO && currentPage != PAGE_STATUS && currentPage != PAGE_ABOUT) ? PAGE_AP : currentPage;
  bool pageChanged = (lastRenderedPage != pageToDraw);
  bool overlayPopupOpen = detailPopupOpen || modeConfirmPopupOpen || quickActionPopupOpen;
  bool popupChanged = (lastDetailPopupState != overlayPopupOpen);

if (screensaverActive) {
  drawScreensaver(fullRedraw || pageChanged || popupChanged);
  lastRenderedPage = pageToDraw;
  lastDetailPopupState = overlayPopupOpen;
#if DISPLAY_BACKEND == DISPLAY_BACKEND_JC3248W535C
  tft.flush();
#endif
  return;
}

  if (fullRedraw || pageChanged || popupChanged) {
    clearBody();
  }

  if (pageToDraw == PAGE_LIVE) drawLivePage();
  else if (pageToDraw == PAGE_LAST) drawLastPage();
  else if (pageToDraw == PAGE_STATUS) drawSetupPage();
  else if (pageToDraw == PAGE_AP) drawApModePage();
  else if (pageToDraw == PAGE_INFO) drawInfoPage();
  else if (pageToDraw == PAGE_ABOUT) drawAboutPage();

  if (detailPopupOpen && selectedHeardIndex >= 0 && selectedHeardIndex < heardCount) {
    drawDetailPopup(heardList[selectedHeardIndex]);
  }
  if (modeConfirmPopupOpen) {
    drawModeConfirmPopup();
  }
  if (quickActionPopupOpen) {
    drawQuickActionsPopup();
  }

  lastRenderedPage = pageToDraw;
  lastDetailPopupState = overlayPopupOpen;
#if DISPLAY_BACKEND == DISPLAY_BACKEND_JC3248W535C
  tft.flush();
#endif
}

bool persistConfigDocument(DynamicJsonDocument& doc, const char* path) {
  storageRemove(path);
  File f = storageOpen(path, FILE_WRITE);
  if (!f) return false;
  bool ok = serializeJsonPretty(doc, f) > 0;
  f.close();
  return ok;
}

void applyConfigAfterLoad() {
  configOkState = loadConfig();
  timeConfigured = false;
  applyDisplaySettings();
  if (!weatherConfigured()) {
    clearWeatherData();
  } else {
    fetchWeatherData();
  }
  if (cfg.radioIdLookupEnabled && heardCount > 0) {
    applyRadioIdCacheToActiveData();
    if (heardCount > 0) liveRecord = heardList[0];
    requestRedraw();
  }
  noteUserInteraction(false);
  lastLiveActiveState = isLiveActive();
  pendingConfigApply = true;
  pendingConfigApplyAt = millis() + 350;
  addWebLog("INFO", "CONFIG", "Runtime configuration applied.");
}

bool applyConfigFromFile(const char* path, String* errMsg) {
  File in = storageOpen(path, FILE_READ);
  if (!in) {
    if (errMsg) *errMsg = "Uploaded config could not be opened.";
    return false;
  }

  DynamicJsonDocument doc(10240);
  DeserializationError err = deserializeJson(doc, in);
  in.close();

  if (err) {
    if (errMsg) *errMsg = "JSON parse failed: " + String(err.c_str());
    return false;
  }

  if (!doc.is<JsonObject>()) {
    if (errMsg) *errMsg = "Config file must contain a JSON object.";
    return false;
  }

  String backend = String(doc["backend"] | "");
  String wifiSsid = trimSafe(String(doc["wifi_ssid"] | ""));
  String host = trimSafe(String(doc["pistar_host"] | ""));
  if ((backend != "WPSD" && backend != "Pi-Star") || !wifiSsid.length() || !host.length()) {
    if (errMsg) *errMsg = "Config is missing required fields (backend, wifi_ssid or pistar_host).";
    return false;
  }

  if (!persistConfigDocument(doc, CONFIG_FILE)) {
    if (errMsg) *errMsg = "Imported config could not be saved.";
    return false;
  }

  applyConfigAfterLoad();
  return true;
}

bool saveConfigToFile() {
  DynamicJsonDocument doc(10240);

  doc["backend"] = cfg.backend;
  doc["wifi_ssid"] = cfg.wifiSsid;
  doc["wifi_pass"] = cfg.wifiPass;
  doc["pistar_host"] = cfg.pistarHost;
  doc["device_callsign"] = cfg.deviceCallsign;
  doc["theme_color"] = cfg.themeColor;
  doc["refresh_ms"] = cfg.refreshMs;
  doc["callsign_hold_ms"] = cfg.callsignHoldMs;
  doc["api_utc_to_local_time"] = cfg.apiUtcToLocalTime;
  doc["timezone_tz"] = cfg.timezoneTz;
  doc["brightness"] = cfg.brightness;
  doc["flip_screen"] = cfg.flipScreen;
  doc["screensaver_enabled"] = cfg.screensaverEnabled;
  doc["screensaver_timeout_ms"] = cfg.screensaverTimeoutMs;
  doc["screensaver_dim_brightness"] = cfg.screensaverDimBrightness;
  doc["weather_enabled"] = cfg.weatherEnabled;
  doc["weather_refresh_minutes"] = cfg.weatherRefreshMinutes;
  doc["weather_lat"] = cfg.weatherLat;
  doc["weather_lon"] = cfg.weatherLon;
  doc["weather_api_key"] = cfg.weatherApiKey;
  doc["radioid_lookup_enabled"] = cfg.radioIdLookupEnabled;
  doc["radioid_users_api_base"] = cfg.radioIdUsersApiBase;
  doc["touch_mode_ssh_enabled"] = cfg.touchModeSshEnabled;
  doc["pistar_ssh_user"] = cfg.pistarSshUser;
  doc["pistar_ssh_pass"] = cfg.pistarSshPass;
  doc["pistar_ssh_port"] = cfg.pistarSshPort;
  JsonArray qa = doc.createNestedArray("quick_actions");
  for (uint8_t i = 0; i < QUICK_ACTION_SLOT_COUNT; i++) {
    JsonObject item = qa.createNestedObject();
    item["enabled"] = cfg.quickActions[i].enabled;
    item["use_sudo"] = cfg.quickActions[i].useSudo;
    item["label"] = cfg.quickActions[i].label;
    item["command"] = cfg.quickActions[i].command;
  }

  JsonObject th = doc.createNestedObject("theme");

  th["bg"] = color565ToHex(theme.bg);
  th["panel"] = color565ToHex(theme.panel);
  th["panel2"] = color565ToHex(theme.panel2);
  th["border"] = color565ToHex(theme.border);
  th["text"] = color565ToHex(theme.text);
  th["subtext"] = color565ToHex(theme.subtext);
  th["accent"] = color565ToHex(theme.accent);
  th["good"] = color565ToHex(theme.good);
  th["warn"] = color565ToHex(theme.warn);
  th["bad"] = color565ToHex(theme.bad);
  th["live"] = color565ToHex(theme.live);
  th["rx"] = color565ToHex(theme.rx);
  th["tx"] = color565ToHex(theme.tx);
  th["tabIdle"] = color565ToHex(theme.tabIdle);
  th["tabActive"] = color565ToHex(theme.tabActive);

  return persistConfigDocument(doc, CONFIG_FILE);
}

bool saveDefaultConfig() {
  DynamicJsonDocument doc(10240);

  doc["backend"] = "Pi-Star";
  doc["wifi_ssid"] = "YOUR_WIFI";
  doc["wifi_pass"] = "YOUR_PASSWORD";
  doc["pistar_host"] = "192.168.1.50";
  doc["device_callsign"] = "13MAD86";
  doc["theme_color"] = "#35C2FF";
  doc["refresh_ms"] = 1500;
  doc["callsign_hold_ms"] = 12000;
  doc["api_utc_to_local_time"] = true;
  doc["timezone_tz"] = DEFAULT_TZ_RULE;
  doc["brightness"] = 220;
  doc["flip_screen"] = false;
  doc["screensaver_enabled"] = true;
  doc["screensaver_timeout_ms"] = 60000;
  doc["screensaver_dim_brightness"] = 20;
  doc["weather_enabled"] = false;
  doc["weather_refresh_minutes"] = 10;
  doc["weather_lat"] = "";
  doc["weather_lon"] = "";
  doc["weather_api_key"] = "";
  doc["radioid_lookup_enabled"] = true;
  doc["radioid_users_api_base"] = RADIOID_USERS_API_BASE_DEFAULT;
  doc["touch_mode_ssh_enabled"] = false;
  doc["pistar_ssh_user"] = "pi-star";
  doc["pistar_ssh_pass"] = "";
  doc["pistar_ssh_port"] = 22;
  JsonArray qa = doc.createNestedArray("quick_actions");
  for (uint8_t i = 0; i < QUICK_ACTION_SLOT_COUNT; i++) {
    JsonObject item = qa.createNestedObject();
    item["enabled"] = false;
    item["use_sudo"] = false;
    item["label"] = i == 0 ? "DMR TG91" : (i == 1 ? "DMR TG263" : (i == 2 ? "REF001C" : "Disconnect"));
    item["command"] = "";
  }

  JsonObject th = doc.createNestedObject("theme");
  th["bg"] = "#000000";
  th["panel"] = "#1C1C1C";
  th["panel2"] = "#2A2A2A";
  th["border"] = "#666666";
  th["text"] = "#FFFFFF";
  th["subtext"] = "#C0C0C0";
  th["accent"] = "#00FFFF";
  th["good"] = "#00FF00";
  th["warn"] = "#FFFF00";
  th["bad"] = "#FF0000";
  th["live"] = "#FFA500";
  th["rx"] = "#00FF00";
  th["tx"] = "#0080FF";
  th["tabIdle"] = "#406070";
  th["tabActive"] = "#008080";

  return persistConfigDocument(doc, CONFIG_FILE);
}

bool configLooksValid() {
  return cfg.wifiSsid.length() > 0 &&
         cfg.pistarHost.length() > 0 &&
         (cfg.backend == "WPSD" || cfg.backend == "Pi-Star");
}

bool loadConfig() {
  setDefaultTheme();

  if (!storageExists(CONFIG_FILE)) {
    if (!saveDefaultConfig()) return false;
  }

  File f = storageOpen(CONFIG_FILE, FILE_READ);
  if (!f) return false;

  DynamicJsonDocument doc(10240);
  DeserializationError err = deserializeJson(doc, f);
  f.close();
  if (err) return false;

  cfg.backend        = String(doc["backend"] | "");
  cfg.wifiSsid       = String(doc["wifi_ssid"] | "");
  cfg.wifiPass       = String(doc["wifi_pass"] | "");
  cfg.pistarHost     = String(doc["pistar_host"] | "");
  cfg.deviceCallsign = String(doc["device_callsign"] | "");
  cfg.themeColor     = String(doc["theme_color"] | "#35C2FF");
  cfg.refreshMs      = doc["refresh_ms"] | 1500;
  cfg.callsignHoldMs = doc["callsign_hold_ms"] | 12000;
  cfg.apiUtcToLocalTime = doc["api_utc_to_local_time"] | true;
  cfg.timezoneTz    = String(doc["timezone_tz"] | DEFAULT_TZ_RULE);
  cfg.brightness     = doc["brightness"] | 220;
  cfg.flipScreen     = doc["flip_screen"] | false;
  cfg.screensaverEnabled = doc["screensaver_enabled"] | true;
  cfg.screensaverTimeoutMs = doc["screensaver_timeout_ms"] | 60000;
  cfg.screensaverDimBrightness = doc["screensaver_dim_brightness"] | 20;
  cfg.weatherEnabled = doc["weather_enabled"] | false;
  cfg.weatherRefreshMinutes = doc["weather_refresh_minutes"] | 10;
  cfg.weatherLat = String(doc["weather_lat"] | "");
  cfg.weatherLon = String(doc["weather_lon"] | "");
  cfg.weatherApiKey = String(doc["weather_api_key"] | "");
  cfg.radioIdLookupEnabled = doc.containsKey("radioid_lookup_enabled") ? (bool)doc["radioid_lookup_enabled"] : true;
  cfg.radioIdUsersApiBase = String(doc["radioid_users_api_base"] | RADIOID_USERS_API_BASE_DEFAULT);
  cfg.touchModeSshEnabled = doc["touch_mode_ssh_enabled"] | false;
  cfg.pistarSshUser = String(doc["pistar_ssh_user"] | "pi-star");
  cfg.pistarSshPass = String(doc["pistar_ssh_pass"] | "");
  cfg.pistarSshPort = doc["pistar_ssh_port"] | 22;

  for (uint8_t i = 0; i < QUICK_ACTION_SLOT_COUNT; i++) {
    cfg.quickActions[i].enabled = false;
    cfg.quickActions[i].useSudo = false;
    cfg.quickActions[i].label = "";
    cfg.quickActions[i].command = "";
  }

  if (doc.containsKey("quick_actions") && doc["quick_actions"].is<JsonArray>()) {
    JsonArray qa = doc["quick_actions"].as<JsonArray>();
    uint8_t i = 0;
    for (JsonObject item : qa) {
      if (i >= QUICK_ACTION_SLOT_COUNT) break;
      cfg.quickActions[i].enabled = item["enabled"] | false;
      cfg.quickActions[i].useSudo = item["use_sudo"] | false;
      cfg.quickActions[i].label = String(item["label"] | "");
      cfg.quickActions[i].command = String(item["command"] | "");
      i++;
    }
  }

  cfg.wifiSsid = trimSafe(cfg.wifiSsid);
  cfg.pistarHost = trimSafe(cfg.pistarHost);
  cfg.deviceCallsign = trimSafe(cfg.deviceCallsign);
  cfg.timezoneTz = trimSafe(cfg.timezoneTz);
  if (!cfg.timezoneTz.length()) cfg.timezoneTz = String(DEFAULT_TZ_RULE);
  cfg.themeColor = trimSafe(cfg.themeColor);
  cfg.weatherLat = trimSafe(cfg.weatherLat);
  cfg.weatherLon = trimSafe(cfg.weatherLon);
  cfg.weatherApiKey = trimSafe(cfg.weatherApiKey);
  cfg.radioIdUsersApiBase = trimSafe(cfg.radioIdUsersApiBase);
  cfg.pistarSshUser = trimSafe(cfg.pistarSshUser);
  if (!cfg.radioIdUsersApiBase.length()) cfg.radioIdUsersApiBase = String(RADIOID_USERS_API_BASE_DEFAULT);
  if (!cfg.pistarSshUser.length()) cfg.pistarSshUser = "pi-star";
  if (cfg.pistarSshPort == 0) cfg.pistarSshPort = 22;
  for (uint8_t i = 0; i < QUICK_ACTION_SLOT_COUNT; i++) {
    cfg.quickActions[i].label = trimSafe(cfg.quickActions[i].label);
    cfg.quickActions[i].command = trimSafe(cfg.quickActions[i].command);
    if (!cfg.quickActions[i].label.length() || !cfg.quickActions[i].command.length()) {
      cfg.quickActions[i].enabled = false;
    }
  }
  cfg.brightness = constrain(cfg.brightness, 0, 255);
  cfg.screensaverDimBrightness = constrain(cfg.screensaverDimBrightness, 0, 255);
  cfg.weatherRefreshMinutes = constrain(cfg.weatherRefreshMinutes, (uint16_t)1, (uint16_t)1440);
  if (cfg.screensaverTimeoutMs < 5000) cfg.screensaverTimeoutMs = 5000;
  if (!weatherConfigured()) clearWeatherData();

  applyBaseThemeColor(cfg.themeColor);

  clearRadioIdLookupCache();

  if (doc.containsKey("theme") && doc["theme"].is<JsonObject>()) {
    JsonObject themeObj = doc["theme"].as<JsonObject>();
    loadThemeFromJson(themeObj);
  }

  return cfg.wifiSsid.length() && cfg.pistarHost.length();
}

void applyBrightness() {
  analogWrite(TFT_BL, screensaverActive ? cfg.screensaverDimBrightness : cfg.brightness);
}

void applyDisplaySettings() {
  tft.setRotation(cfg.flipScreen ? 3 : 1);
  tft.fillScreen(theme.bg);
  applyBrightness();
  requestRedraw();
}

void setScreensaverActive(bool active) {
  if (screensaverActive == active) return;
  screensaverActive = active;
  applyBrightness();
  footerDirty = true;
  requestRedraw();
  if (active) {
    detailPopupOpen = false;
    selectedHeardIndex = -1;
    lastScreensaverFrameMillis = 0;
  }
}

void noteScreenActivity(bool fromTouch) {
  lastInteractionMillis = millis();
  if (screensaverActive) {
    setScreensaverActive(false);
    tft.fillScreen(theme.bg);
    requestRedraw();
  }
}

void noteUserInteraction(bool fromTouch) {
  noteScreenActivity(fromTouch);
}

void setBrightnessFromTouchX(int x) {
  int val = map(x, BRIGHT_SLIDER_X + max(2, UW(4)), BRIGHT_SLIDER_X + BRIGHT_SLIDER_W - max(2, UW(5)), 0, 255);
  cfg.brightness = constrain(val, 0, 255);
  applyBrightness();
}

bool connectWiFi(uint32_t timeoutMs = WIFI_CONNECT_TIMEOUT_MS) {
  String ssid = cfg.wifiSsid;
  ssid.trim();
  if (!ssid.length()) {
    wifiOk = false;
    wifiConnectInProgress = false;
    setFooterText("Wi-Fi SSID missing");
    addWebLog("WARN", "WIFI", "Wi-Fi SSID missing in configuration.");
    return false;
  }

  if (apModeActive) {
    stopSetupAccessPoint();
    delay(100);
  }

  WiFi.mode(WIFI_STA);
  WiFi.persistent(false);
  WiFi.setAutoReconnect(true);
  delay(1000);
  WiFi.begin(cfg.wifiSsid.c_str(), cfg.wifiPass.c_str());
  addWebLog("INFO", "WIFI", "Connecting to SSID \"" + ssid + "\".");

  unsigned long start = millis();
  wifiConnectInProgress = true;
  wifiConnectStartedAt = start;
  lastWifiRetryMillis = start;
  setFooterText("Connecting Wi-Fi...");
  requestRedraw();

  while (WiFi.status() != WL_CONNECTED && millis() - start < timeoutMs) {
    server.handleClient();
    delay(50);
  }

  wifiConnectInProgress = false;
  wifiOk = (WiFi.status() == WL_CONNECTED);
  if (wifiOk) {
    syncTimeIfNeeded();
    addWebLog("INFO", "WIFI", "Connected. IP " + WiFi.localIP().toString() + ".");
  } else {
    addWebLog("WARN", "WIFI", "Connection attempt timed out.");
  }
  setFooterText(wifiOk ? ("Wi-Fi OK: " + WiFi.localIP().toString()) : "Wi-Fi failed");
  return wifiOk;
}

void syncTimeIfNeeded() {
  if (!timeConfigured) {
    String tzRule = trimSafe(cfg.timezoneTz);
    if (!tzRule.length()) tzRule = String(DEFAULT_TZ_RULE);
    configTzTime(tzRule.c_str(), "pool.ntp.org", "time.nist.gov", "time.google.com");
    timeConfigured = true;
  }
}

void beginWiFiConnectAttempt(bool stopApFirst) {
  String ssid = cfg.wifiSsid;
  ssid.trim();
  if (!ssid.length()) {
    wifiOk = false;
    wifiConnectInProgress = false;
    setFooterText("Wi-Fi SSID missing");
    requestRedraw();
    addWebLog("WARN", "WIFI", "Wi-Fi SSID missing in configuration.");
    return;
  }

  if (stopApFirst && apModeActive) {
    stopSetupAccessPoint();
    delay(50);
  }

  WiFi.mode(WIFI_STA);
  WiFi.persistent(false);
  WiFi.setAutoReconnect(true);
  delay(1000);
  WiFi.begin(cfg.wifiSsid.c_str(), cfg.wifiPass.c_str());
  addWebLog("INFO", "WIFI", "Starting reconnect to SSID \"" + ssid + "\".");

  wifiOk = false;
  wifiConnectInProgress = true;
  wifiConnectStartedAt = millis();
  lastWifiRetryMillis = wifiConnectStartedAt;
  setFooterText("Connecting Wi-Fi...");
  requestRedraw();
}

void handleWiFiConnectProgress() {
  if (!wifiConnectInProgress) return;

  wl_status_t status = WiFi.status();
  if (status == WL_CONNECTED) {
    wifiConnectInProgress = false;
    wifiOk = true;
    setupRequired = false;
    syncTimeIfNeeded();
    if (apModeActive) stopSetupAccessPoint();
    setFooterText("Wi-Fi OK: " + WiFi.localIP().toString());
    addWebLog("INFO", "WIFI", "Connected. IP " + WiFi.localIP().toString() + ".");
    requestRedraw();
    return;
  }

  if (millis() - wifiConnectStartedAt >= WIFI_CONNECT_TIMEOUT_MS) {
    wifiConnectInProgress = false;
    wifiOk = false;

    if (!configLooksValid()) {
      setupRequired = true;
      currentPage = PAGE_AP;
      addWebLog("WARN", "WIFI", "Wi-Fi connect failed and setup AP is required.");
      startSetupAccessPoint();
      setFooterText(apModeActive ? ("Setup required: " + apSsid + " / http://" + WiFi.softAPIP().toString() + "/") : "Wi-Fi failed");
    } else {
      setupRequired = false;
      if (apModeActive) stopSetupAccessPoint();
      lastWifiRetryMillis = millis();
      addWebLog("WARN", "WIFI", "Wi-Fi connect attempt failed. Retrying in background.");
      setFooterText("Wi-Fi failed, retrying...");
    }

    requestRedraw();
  }
}

bool startSetupAccessPoint() {
  if (apModeActive) {
    IPAddress ip = WiFi.softAPIP();
    setFooterText("Setup AP: " + apSsid + " / " + ip.toString());
    addWebLog("INFO", "AP", "Setup AP already active at " + ip.toString() + ".");
    return true;
  }

  WiFi.disconnect(true, true);
  delay(200);
  WiFi.mode(WIFI_AP);

  String chip = String((uint32_t)(ESP.getEfuseMac() & 0xFFFFFF), HEX);
  chip.toUpperCase();
  while (chip.length() < 6) chip = "0" + chip;
  apSsid = "MMDVM-Display-" + chip;

  bool ok = WiFi.softAP(apSsid.c_str(), apPass.c_str());
  apModeActive = ok;
  if (ok) {
    IPAddress ip = WiFi.softAPIP();
    setFooterText("Setup AP: " + apSsid + " / " + ip.toString());
    addWebLog("INFO", "AP", "Setup AP started as " + apSsid + " at " + ip.toString() + ".");
  } else {
    setFooterText("Setup AP failed");
    addWebLog("ERROR", "AP", "Failed to start setup AP.");
  }
  return ok;
}

void stopSetupAccessPoint() {
  if (!apModeActive) return;
  WiFi.softAPdisconnect(true);
  apModeActive = false;
  addWebLog("INFO", "AP", "Setup AP stopped.");
}

String htmlEscape(const String& s) {
  String out = s;
  out.replace("&", "&amp;");
  out.replace("<", "&lt;");
  out.replace(">", "&gt;");
  out.replace("\"", "&quot;");
  return out;
}

String formRow(const String& label, const String& name, const String& value, const String& type = "text", const String& placeholder = "") {
  String row = "<div class='form-row'>";
  row += "<label for='" + name + "'>" + label + "</label>";
  row += "<input type='" + type + "' id='" + name + "' name='" + name + "' value='" + htmlEscape(value) + "'";
  if (placeholder.length()) row += " placeholder='" + htmlEscape(placeholder) + "'";
  row += ">";
  row += "</div>";
  return row;
}

String selectRow(const String& label, const String& name, const String& current) {
  String row = "<div class='form-row'>";
  row += "<label for='" + name + "'>" + label + "</label>";
  row += "<select id='" + name + "' name='" + name + "'>";
  row += "<option value='WPSD'";
  if (current == "WPSD") row += " selected";
  row += ">WPSD</option>";
  row += "<option value='Pi-Star'";
  if (current == "Pi-Star") row += " selected";
  row += ">Pi-Star</option>";
  row += "</select>";
  row += "</div>";
  return row;
}

String colorRow(const String& label, const String& name, const String& value) {
  String row = "<div class='form-row'>";
  row += "<label for='" + name + "'>" + label + "</label>";
  row += "<div class='color-inline'>";
  row += "<input class='color-picker' type='color' id='" + name + "_picker' value='" + htmlEscape(value) + "' ";
  row += "oninput=\"document.getElementById('" + name + "').value=this.value\">";
  row += "<input class='color-text' type='text' id='" + name + "' name='" + name + "' value='" + htmlEscape(value) + "'>";
  row += "</div>";
  row += "</div>";
  return row;
}

String checkboxRow(const String& label, const String& name, bool checked) {
  String row = "<div class='form-row'>";
  row += "<label for='" + name + "'>" + label + "</label>";
  row += "<label class='toggle-row'><input type='checkbox' id='" + name + "' name='" + name + "' value='1'";
  if (checked) row += " checked";
  row += ">Enable</label>";
  row += "</div>";
  return row;
}

String navItem(const String& id, const String& label, bool active = false) {
  String s = "<a class='side-link";
  if (active) s += " active";
  s += "' href='#" + id + "'>" + label + "</a>";
  return s;
}

String navHrefItem(const String& href, const String& label, bool active = false) {
  String s = "<a class='side-link";
  if (active) s += " active";
  s += "' href='" + href + "'>" + label + "</a>";
  return s;
}


String jsonEscape(const String& s) {
  String out;
  out.reserve(s.length() + 8);
  for (size_t i = 0; i < s.length(); i++) {
    char c = s.charAt(i);
    switch (c) {
      case '\\': out += "\\\\"; break;
      case '"': out += "\\\""; break;
      case '\n': out += "\\n"; break;
      case '\r': out += "\\r"; break;
      case '\t': out += "\\t"; break;
      default: out += c; break;
    }
  }
  return out;
}

String formatLogAgeBrief(unsigned long sinceMs) {
  unsigned long now = millis();
  unsigned long delta = (sinceMs && now >= sinceMs) ? (now - sinceMs) : 0;
  unsigned long totalSeconds = delta / 1000UL;
  unsigned long hours = totalSeconds / 3600UL;
  unsigned long minutes = (totalSeconds % 3600UL) / 60UL;
  unsigned long seconds = totalSeconds % 60UL;

  if (hours > 0) return String(hours) + "h " + String(minutes) + "m ago";
  if (minutes > 0) return String(minutes) + "m " + String(seconds) + "s ago";
  return String(seconds) + "s ago";
}

void addWebLog(const String& level, const String& tag, const String& message, bool mirrorSerial) {
  String safeLevel = trimSafe(level);
  String safeTag = trimSafe(tag);
  String safeMessage = message;
  safeMessage.replace("\r", " ");
  safeMessage.replace("\n", " ");
  if (safeMessage.length() > 220) safeMessage = safeMessage.substring(0, 217) + "...";

  size_t idx = webLogHead;
  webLogBuffer[idx].ms = millis();
  webLogBuffer[idx].level = safeLevel.length() ? safeLevel : String("INFO");
  webLogBuffer[idx].tag = safeTag;
  webLogBuffer[idx].message = safeMessage;

  webLogHead = (webLogHead + 1) % WEB_LOG_CAPACITY;
  if (webLogCount < WEB_LOG_CAPACITY) webLogCount++;

  if (mirrorSerial) {
    String line = "[" + webLogBuffer[idx].level + "]";
    if (webLogBuffer[idx].tag.length()) line += "[" + webLogBuffer[idx].tag + "]";
    line += " " + webLogBuffer[idx].message;
    Serial.println(line);
  }
}

String buildWebLogsJson() {
  String json;
  json.reserve(24000);
  json += "{";
  json += "\"count\":" + String(webLogCount) + ",";
  json += "\"capacity\":" + String(WEB_LOG_CAPACITY) + ",";
  json += "\"items\":[";
  if (webLogCount) {
    size_t start = (webLogHead + WEB_LOG_CAPACITY - webLogCount) % WEB_LOG_CAPACITY;
    for (size_t i = 0; i < webLogCount; i++) {
      size_t idx = (start + i) % WEB_LOG_CAPACITY;
      if (i) json += ",";
      json += "{";
      json += "\"age\":\"" + jsonEscape(formatLogAgeBrief(webLogBuffer[idx].ms)) + "\",";
      json += "\"level\":\"" + jsonEscape(webLogBuffer[idx].level) + "\",";
      json += "\"tag\":\"" + jsonEscape(webLogBuffer[idx].tag) + "\",";
      json += "\"message\":\"" + jsonEscape(webLogBuffer[idx].message) + "\"";
      json += "}";
    }
  }
  json += "]}";
  return json;
}

String formatMillisAgoBrief(unsigned long sinceMs) {
  if (!sinceMs) return "Never";
  unsigned long delta = millis() - sinceMs;
  if (delta < 1000UL) return "Just now";
  if (delta < 60000UL) return String(delta / 1000UL) + "s ago";
  if (delta < 3600000UL) return String(delta / 60000UL) + "m ago";
  if (delta < 86400000UL) return String(delta / 3600000UL) + "h ago";
  return String(delta / 86400000UL) + "d ago";
}

String otaErrorText() {
  switch (Update.getError()) {
    case UPDATE_ERROR_OK: return "";
    case UPDATE_ERROR_WRITE: return "Flash write failed.";
    case UPDATE_ERROR_ERASE: return "Flash erase failed.";
    case UPDATE_ERROR_READ: return "Flash read failed.";
    case UPDATE_ERROR_SPACE: return "Not enough free space for this firmware image.";
    case UPDATE_ERROR_SIZE: return "Firmware image size is invalid.";
    case UPDATE_ERROR_STREAM: return "Firmware upload stream failed.";
    case UPDATE_ERROR_MD5: return "Firmware checksum validation failed.";
    case UPDATE_ERROR_MAGIC_BYTE: return "Uploaded file is not a valid ESP firmware image.";
    case UPDATE_ERROR_ACTIVATE: return "Updated firmware could not be activated.";
    case UPDATE_ERROR_NO_PARTITION: return "No OTA app partition available. Flash once by USB with an OTA partition table (ota_0 + ota_1).";
    case UPDATE_ERROR_BAD_ARGUMENT: return "Bad OTA argument.";
    case UPDATE_ERROR_ABORT: return "OTA update aborted.";
    default: return "OTA update failed with error " + String(Update.getError()) + ".";
  }
}

String buildSidebarHtml(bool onRoot, const String& activeApp) {
  String s;
  s.reserve(1200);
  s += "<aside class='sidebar'>";
  s += "<div class='side-title' style='margin-top:18px'>Configuration</div>";
  if (onRoot) {
    s += navItem("system", "&#128223; Hotspot");
    s += navItem("quick-actions", "&#9889; Quick actions");
    s += navItem("network", "&#128655; Network");
    s += navItem("wifi", "&#128225; Wifi");
    s += navItem("display", "&#128250; Display");
    s += navItem("weather", "&#9925; Weather");
    s += navItem("lookup", "&#128218; Lookup");
    s += navItem("theme", "&#127912; Theme");
  } else {
    s += navHrefItem("/#system", "&#128223; Hotspot");
    s += navHrefItem("/#quick-actions", "&#9889; Quick actions");
    s += navHrefItem("/#network", "&#128655; Network");
    s += navHrefItem("/#wifi", "&#128225; Wifi");
    s += navHrefItem("/#display", "&#128250; Display");
    s += navHrefItem("/#weather", "&#9925; Weather");
    s += navHrefItem("/#lookup", "&#128218; Lookup");
    s += navHrefItem("/#theme", "&#127912; Theme");
  }
  s += "<div class='side-title' style='margin-top:18px'>Application</div>";
  s += navHrefItem("/app/ota", "&#128187; OTA firmware", activeApp == "ota");
  s += navHrefItem("/app/diagnostics", "&#128200; Diagnostics", activeApp == "diagnostics");
  s += navHrefItem("/app/logs", "&#128211; Device log", activeApp == "logs");
  s += "<div class='side-title' style='margin-top:18px'>Help</div>";
  s += navHrefItem("/app/about", "&#8505;&#65039; About", activeApp == "about");
  s += navHrefItem("/app/changelog", "&#128221; Changelog", activeApp == "changelog");
  s += "</aside>";
  return s;
}

void noteApiFetchResult(bool ok, int statusCode, unsigned long latencyMs) {
  diagnosticsState.lastApiLatencyMs = latencyMs;
  diagnosticsState.lastApiStatusCode = statusCode;
  diagnosticsState.lastFetchMillis = millis();
  if (ok) {
    diagnosticsState.fetchOk++;
    diagnosticsState.consecutiveFetchFailures = 0;
    diagnosticsState.lastFetchSuccessMillis = diagnosticsState.lastFetchMillis;
  } else {
    diagnosticsState.fetchFail++;
    diagnosticsState.consecutiveFetchFailures++;
  }

  bool shouldLog = false;
  if (!webLogApiStateKnown) shouldLog = true;
  else if (ok != webLogLastApiOk || statusCode != webLogLastApiCode) shouldLog = true;
  else if (!ok && (diagnosticsState.consecutiveFetchFailures <= 3 || (diagnosticsState.consecutiveFetchFailures % 10UL) == 0)) shouldLog = true;

  if (shouldLog) {
    if (ok) addWebLog("INFO", "API", "Hotspot API OK (HTTP " + String(statusCode) + ", " + String(latencyMs) + " ms).");
    else addWebLog("WARN", "API", "Hotspot API failed (HTTP " + String(statusCode) + ", " + String(latencyMs) + " ms, streak " + String(diagnosticsState.consecutiveFetchFailures) + ").");
  }

  webLogApiStateKnown = true;
  webLogLastApiOk = ok;
  webLogLastApiCode = statusCode;
}

void noteWeatherFetchResult(bool ok, int statusCode) {
  diagnosticsState.lastWeatherAttemptMillis = millis();
  diagnosticsState.lastWeatherHttpCode = statusCode;
  if (ok) {
    diagnosticsState.weatherOk++;
    diagnosticsState.lastWeatherSuccessMillis = diagnosticsState.lastWeatherAttemptMillis;
  } else {
    diagnosticsState.weatherFail++;
  }

  bool shouldLog = false;
  if (!webLogWeatherStateKnown) shouldLog = true;
  else if (ok != webLogLastWeatherOk || statusCode != webLogLastWeatherCode) shouldLog = true;
  else if (!ok && diagnosticsState.weatherFail <= 3) shouldLog = true;

  if (shouldLog) {
    if (ok) addWebLog("INFO", "WEATHER", "Weather refresh OK (HTTP " + String(statusCode) + ").");
    else addWebLog("WARN", "WEATHER", "Weather refresh failed (HTTP " + String(statusCode) + ").");
  }

  webLogWeatherStateKnown = true;
  webLogLastWeatherOk = ok;
  webLogLastWeatherCode = statusCode;
}

void recordDiagnosticsSample(bool force) {
  unsigned long now = millis();
  if (!force && lastDiagnosticsSampleMillis && (now - lastDiagnosticsSampleMillis) < DIAG_SAMPLE_INTERVAL_MS) return;
  lastDiagnosticsSampleMillis = now;

  size_t idx = diagnosticsState.sampleHead;
  diagnosticsState.wifiRssi[idx] = wifiOk ? (int32_t)WiFi.RSSI() : DIAG_NULL_INT;
  diagnosticsState.heapFree[idx] = ESP.getFreeHeap();
  diagnosticsState.apiLatency[idx] = diagnosticsState.lastApiLatencyMs;
  diagnosticsState.qsoTotal[idx] = sessionInfo.totalQsos;

  diagnosticsState.sampleHead = (idx + 1) % DIAG_HISTORY_POINTS;
  if (diagnosticsState.sampleCount < DIAG_HISTORY_POINTS) diagnosticsState.sampleCount++;
}

void appendDiagnosticsI32Array(String& out, const int32_t* values, size_t count, size_t head, bool useNullSentinel, int32_t nullSentinel) {
  out += "[";
  if (count) {
    size_t start = (head + DIAG_HISTORY_POINTS - count) % DIAG_HISTORY_POINTS;
    for (size_t i = 0; i < count; i++) {
      size_t idx = (start + i) % DIAG_HISTORY_POINTS;
      if (i) out += ",";
      int32_t v = values[idx];
      if (useNullSentinel && v == nullSentinel) out += "null";
      else out += String(v);
    }
  }
  out += "]";
}

void appendDiagnosticsU32Array(String& out, const uint32_t* values, size_t count, size_t head) {
  out += "[";
  if (count) {
    size_t start = (head + DIAG_HISTORY_POINTS - count) % DIAG_HISTORY_POINTS;
    for (size_t i = 0; i < count; i++) {
      size_t idx = (start + i) % DIAG_HISTORY_POINTS;
      if (i) out += ",";
      out += String(values[idx]);
    }
  }
  out += "]";
}

String buildDiagnosticsJson() {
  recordDiagnosticsSample(true);

  String rx = trimSafe(hotspotRxFreq.length() ? hotspotRxFreq : currentRxFreq);
  String tx = trimSafe(hotspotTxFreq.length() ? hotspotTxFreq : currentTxFreq);
  String hotspot = trimSafe(hotspotStatusText);
  if (!hotspot.length()) hotspot = wifiOk ? "Connected" : "Offline";

  String json;
  json.reserve(8192);
  json += "{";
  json += "\"uptime\":\"" + jsonEscape(formatUptimeBrief()) + "\",";
  json += "\"wifi_text\":\"" + jsonEscape(getWifiSignalText()) + "\",";
  json += "\"wifi_ok\":" + String(wifiOk ? "true" : "false") + ",";
  json += "\"heap_free\":" + String((uint32_t)ESP.getFreeHeap()) + ",";
  json += "\"api_latency_ms\":" + String(diagnosticsState.lastApiLatencyMs) + ",";
  json += "\"api_status_code\":" + String(diagnosticsState.lastApiStatusCode) + ",";
  json += "\"fetch_ok\":" + String(diagnosticsState.fetchOk) + ",";
  json += "\"fetch_fail\":" + String(diagnosticsState.fetchFail) + ",";
  json += "\"fetch_streak\":" + String(diagnosticsState.consecutiveFetchFailures) + ",";
  json += "\"last_fetch\":\"" + jsonEscape(formatMillisAgoBrief(diagnosticsState.lastFetchMillis)) + "\",";
  json += "\"last_fetch_success\":\"" + jsonEscape(formatMillisAgoBrief(diagnosticsState.lastFetchSuccessMillis)) + "\",";
  json += "\"weather_status_code\":" + String(diagnosticsState.lastWeatherHttpCode) + ",";
  json += "\"weather_ok\":" + String(diagnosticsState.weatherOk) + ",";
  json += "\"weather_fail\":" + String(diagnosticsState.weatherFail) + ",";
  json += "\"weather_last\":\"" + jsonEscape(formatMillisAgoBrief(weatherState.lastAttemptMillis)) + "\",";
  json += "\"weather_last_success\":\"" + jsonEscape(formatMillisAgoBrief(weatherState.lastSuccessMillis)) + "\",";
  json += "\"hotspot_status\":\"" + jsonEscape(hotspot) + "\",";
  json += "\"rx_freq\":\"" + jsonEscape(rx.length() ? rx : String("-")) + "\",";
  json += "\"tx_freq\":\"" + jsonEscape(tx.length() ? tx : String("-")) + "\",";
  json += "\"total_qsos\":" + String(sessionInfo.totalQsos) + ",";
  json += "\"best_rssi\":" + String(sessionInfo.hasRssi ? sessionInfo.bestRssi : DIAG_NULL_INT) + ",";
  json += "\"worst_rssi\":" + String(sessionInfo.hasRssi ? sessionInfo.worstRssi : DIAG_NULL_INT) + ",";
  json += "\"live_callsign\":\"" + jsonEscape(trimSafe(liveRecord.callsign)) + "\",";
  json += "\"live_mode\":\"" + jsonEscape(trimSafe(liveRecord.mode)) + "\",";
  json += "\"mode_counts\":{";
  json += "\"dmr\":" + String(sessionInfo.modeDMR) + ",";
  json += "\"dstar\":" + String(sessionInfo.modeDSTAR) + ",";
  json += "\"ysf\":" + String(sessionInfo.modeYSF) + ",";
  json += "\"p25\":" + String(sessionInfo.modeP25) + ",";
  json += "\"nxdn\":" + String(sessionInfo.modeNXDN) + ",";
  json += "\"pocsag\":" + String(sessionInfo.modePOCSAG);
  json += "},";
  json += "\"history\":{";
  json += "\"wifi_rssi\":";
  appendDiagnosticsI32Array(json, diagnosticsState.wifiRssi, diagnosticsState.sampleCount, diagnosticsState.sampleHead, true, DIAG_NULL_INT);
  json += ",\"heap_free\":";
  appendDiagnosticsU32Array(json, diagnosticsState.heapFree, diagnosticsState.sampleCount, diagnosticsState.sampleHead);
  json += ",\"api_latency\":";
  appendDiagnosticsU32Array(json, diagnosticsState.apiLatency, diagnosticsState.sampleCount, diagnosticsState.sampleHead);
  json += ",\"qso_total\":";
  appendDiagnosticsU32Array(json, diagnosticsState.qsoTotal, diagnosticsState.sampleCount, diagnosticsState.sampleHead);
  json += "}";
  json += "}";
  return json;
}

void handleDiagnosticsApi() {
  server.sendHeader("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0");
  server.send(200, "application/json", buildDiagnosticsJson());
}

void handleDiagnosticsPage() {
  String accentHex = cfg.themeColor.length() ? cfg.themeColor : String("#35C2FF");
  String ip = wifiOk ? WiFi.localIP().toString() : (apModeActive ? WiFi.softAPIP().toString() : "not connected");
  String backend = cfg.backend;
  if (backend != "WPSD" && backend != "Pi-Star") backend = "Pi-Star";

  String page;
  page.reserve(22000);
  page += "<!doctype html><html><head><meta charset='utf-8'>";
  page += "<meta name='viewport' content='width=device-width,initial-scale=1'>";
  page += "<title>MMDVM Display</title>";
  page += "<style>";
  page += ":root{--bg:#0b1220;--panel:rgba(15,23,42,.82);--line:rgba(148,163,184,.20);--line2:rgba(148,163,184,.12);--text:#e5eefc;--muted:#94a3b8;--side:rgba(7,13,24,.76);--accent:" + accentHex + ";--accent-soft:rgba(53,194,255,.16);--shadow:0 18px 45px rgba(2,6,23,.34)}";
  page += R"DIAGCSS(*{box-sizing:border-box}html{scroll-behavior:smooth}body{margin:0;font-family:Inter,Segoe UI,Arial,Helvetica,sans-serif;background:#0b1220;color:var(--text)}body:before{content:'';position:fixed;inset:0;background:linear-gradient(135deg,rgba(255,255,255,.03),transparent 45%,rgba(255,255,255,.015));pointer-events:none}.topbar{position:sticky;top:0;z-index:40;backdrop-filter:blur(14px);background:rgba(11,18,32,.72);border-bottom:1px solid var(--line);padding:18px 24px;display:flex;justify-content:space-between;align-items:center;gap:16px;flex-wrap:wrap}.brand{font-size:28px;font-weight:700;letter-spacing:-.03em;color:#fff}.brand-sub{font-size:13px;color:var(--muted);margin-top:4px}.topbar-right{display:flex;align-items:center;gap:12px;flex-wrap:wrap;justify-content:flex-end}.header-actions{display:flex;gap:10px;flex-wrap:wrap;justify-content:flex-end}.btn{display:inline-flex;align-items:center;justify-content:center;padding:13px 20px;border-radius:14px;border:1px solid transparent;background:linear-gradient(135deg,var(--accent),rgba(255,255,255,.18));color:#08111d;font-weight:800;text-decoration:none;cursor:pointer;box-shadow:0 14px 30px rgba(53,194,255,.20)}.btn-secondary{background:rgba(255,255,255,.03);color:#fff;border-color:var(--line);box-shadow:none}.btn-compact{padding:10px 14px;font-size:13px}.layout{display:flex;min-height:calc(100vh - 78px)}.sidebar{width:248px;background:var(--side);border-right:1px solid var(--line);padding:24px 16px;position:sticky;top:78px;height:calc(100vh - 78px);overflow:auto;backdrop-filter:blur(16px)}.side-title{font-size:11px;text-transform:uppercase;letter-spacing:.16em;color:var(--muted);margin:0 0 12px 10px;font-weight:800}.side-link{display:block;text-decoration:none;color:#cbd5e1;padding:12px 14px;border-radius:14px;margin-bottom:8px;border:1px solid transparent;font-size:14px;font-weight:700;transition:all .18s ease}.side-link:hover{background:rgba(255,255,255,.04);border-color:var(--line);transform:translateX(2px)}.side-link.active{background:linear-gradient(135deg,var(--accent-soft),rgba(255,255,255,.02));border-color:rgba(53,194,255,.28);color:#fff;box-shadow:inset 0 0 0 1px rgba(255,255,255,.04)}.content{flex:1;min-width:0;padding:28px}.page-head{display:flex;justify-content:space-between;align-items:flex-start;gap:18px;margin-bottom:20px}.page-head h1{margin:0;font-size:34px;font-weight:800;letter-spacing:-.04em;color:#fff}.page-sub{margin-top:8px;color:var(--muted);font-size:15px;max-width:720px}.status-box,.section{background:var(--panel);border:1px solid var(--line);border-radius:22px;box-shadow:var(--shadow);backdrop-filter:blur(18px)}.status-box{padding:14px 16px;min-width:260px}.status-row{display:flex;justify-content:space-between;gap:12px;padding:6px 0;font-size:13px}.status-key{color:var(--muted);font-weight:700}.status-val{font-weight:700;color:#fff;text-align:right}.section{margin-bottom:18px;overflow:hidden}.section-head{padding:16px 20px;background:linear-gradient(180deg,rgba(255,255,255,.035),rgba(255,255,255,.01));border-bottom:1px solid var(--line2)}.section-head h2{margin:0;font-size:19px;font-weight:800;color:#fff}.section-head p{margin:6px 0 0 0;font-size:13px;color:var(--muted)}.section-body{padding:20px}.stats-grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:14px}.stat-card,.chart-card{padding:16px;border-radius:18px;background:rgba(255,255,255,.03);border:1px solid var(--line2)}.stat-label,.chart-title{font-size:12px;text-transform:uppercase;letter-spacing:.12em;color:var(--muted);font-weight:800;margin-bottom:10px}.stat-value{font-size:24px;font-weight:800;color:#fff;line-height:1.15}.stat-sub,.chart-sub{margin-top:8px;font-size:13px;color:#cbd5e1}.chart-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:16px}.spark{height:130px;border-radius:14px;background:rgba(255,255,255,.02);border:1px solid var(--line2);padding:8px;color:var(--accent)}.spark svg{width:100%;height:100%;display:block}.chart-foot{display:flex;justify-content:space-between;font-size:12px;color:var(--muted);margin-top:8px}.mini-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:16px}.mini-note{color:var(--muted);font-size:13px;line-height:1.65}.muted{color:var(--muted)}@media(max-width:1080px){.layout{display:block}.sidebar{width:auto;height:auto;position:relative;top:0;border-right:none;border-bottom:1px solid var(--line)}.content{padding:18px}.page-head{flex-direction:column}.stats-grid,.chart-grid,.mini-grid{grid-template-columns:1fr}.topbar{position:relative}.topbar-right{width:100%;justify-content:flex-start}.header-actions{justify-content:flex-start}})DIAGCSS";
  page += "</style></head><body>";

  page += "<div class='topbar'><div><div class='brand'>MMDVM Display</div><div class='brand-sub'>Version "+ String(DISPLAY_VERSION) +" by 13MAD86 / Martin</div></div>";
  page += "<div class='topbar-right'>";
  page += "<div class='header-actions'>";
  page += "<a class='btn btn-secondary btn-compact' href='/export'>&#128228; Export config</a>";
  page += "<form class='inline-form' method='POST' action='/import' enctype='multipart/form-data'><input type='file' id='import_config_file' name='config_file' accept='.json,application/json' style='display:none' onchange='this.form.submit()'><label class='btn btn-secondary btn-compact' for='import_config_file'>&#128229; Import config</label></form>";
  page += "</div></div></div>";

  page += "<div class='layout'>";
  page += buildSidebarHtml(false, "diagnostics");
  page += "<main class='content'>";
  page += "<div class='page-head'><div><h1>&#128200; Diagnostics</h1><div class='page-sub'>Live runtime counters, transport health and mini trend graphs for the device and hotspot link.</div></div>";
  page += "<div class='status-box'>";
  page += "<div class='status-row'><span class='status-key'>Device IP</span><span class='status-val'>" + htmlEscape(ip) + "</span></div>";
  page += "<div class='status-row'><span class='status-key'>Backend</span><span class='status-val'>" + htmlEscape(backend) + "</span></div>";
  page += "<div class='status-row'><span class='status-key'>Hotspot</span><span class='status-val'>" + htmlEscape(cfg.pistarHost) + "</span></div>";
  page += "<div class='status-row'><span class='status-key'>Build</span><span class='status-val'>" + htmlEscape(getBuildStamp()) + "</span></div>";
  page += "</div></div>";
  page += R"DIAGHTML(<section class='section'><div class='section-head'><h2>Overview</h2><p>These cards refresh automatically while the page is open.</p></div><div class='section-body'><div id='diag-error' class='mini-note muted' style='margin-bottom:14px'></div><div class='stats-grid'><div class='stat-card'><div class='stat-label'>Uptime</div><div class='stat-value' id='metric-uptime'>-</div><div class='stat-sub' id='metric-fetch-last'>Last fetch: -</div></div><div class='stat-card'><div class='stat-label'>Wi-Fi</div><div class='stat-value' id='metric-wifi'>-</div><div class='stat-sub' id='metric-hotspot'>Hotspot: -</div></div><div class='stat-card'><div class='stat-label'>Free heap</div><div class='stat-value' id='metric-heap'>-</div><div class='stat-sub' id='metric-api'>API latency: -</div></div><div class='stat-card'><div class='stat-label'>Traffic</div><div class='stat-value' id='metric-qsos'>-</div><div class='stat-sub' id='metric-live'>Live: -</div></div><div class='stat-card'><div class='stat-label'>Fetch results</div><div class='stat-value' id='metric-fetch-ok'>-</div><div class='stat-sub' id='metric-fetch-fail'>Failures: -</div></div><div class='stat-card'><div class='stat-label'>Weather</div><div class='stat-value' id='metric-weather'>-</div><div class='stat-sub' id='metric-weather-last'>Last refresh: -</div></div><div class='stat-card'><div class='stat-label'>Frequencies</div><div class='stat-value' id='metric-rx'>-</div><div class='stat-sub' id='metric-tx'>TX: -</div></div><div class='stat-card'><div class='stat-label'>Mode totals</div><div class='stat-value' id='metric-modes'>-</div><div class='stat-sub' id='metric-rssi-range'>RSSI range: -</div></div></div></div></section><section class='section'><div class='section-head'><h2>Mini graphs</h2><p>Rolling samples taken every 5 seconds while the device is running.</p></div><div class='section-body'><div class='chart-grid'><div class='chart-card'><div class='chart-title'>Wi-Fi RSSI</div><div class='chart-sub'>Signal strength over time.</div><div class='spark' id='chart-wifi'></div><div class='chart-foot'><span id='chart-wifi-min'>-</span><span id='chart-wifi-max'>-</span></div></div><div class='chart-card'><div class='chart-title'>Free heap</div><div class='chart-sub'>Available heap memory.</div><div class='spark' id='chart-heap'></div><div class='chart-foot'><span id='chart-heap-min'>-</span><span id='chart-heap-max'>-</span></div></div><div class='chart-card'><div class='chart-title'>API latency</div><div class='chart-sub'>Last dashboard or last-heard request duration.</div><div class='spark' id='chart-api'></div><div class='chart-foot'><span id='chart-api-min'>-</span><span id='chart-api-max'>-</span></div></div><div class='chart-card'><div class='chart-title'>Total QSOs</div><div class='chart-sub'>Monotonic session traffic count.</div><div class='spark' id='chart-qso'></div><div class='chart-foot'><span id='chart-qso-min'>-</span><span id='chart-qso-max'>-</span></div></div></div></div></section><section class='section'><div class='section-head'><h2>What to watch</h2><p>Quick hints for interpreting the graphs.</p></div><div class='section-body'><div class='mini-grid'><div class='chart-card'><div class='chart-title'>Healthy link</div><div class='mini-note'>Look for stable Wi-Fi RSSI, low latency and a fetch failure streak that stays at <strong>0</strong>. If the QSO graph moves but the API latency graph spikes, the hotspot is reachable but the backend is slowing down.</div></div><div class='chart-card'><div class='chart-title'>Potential trouble</div><div class='mini-note'>A flat or missing RSSI graph usually means the device is offline. Heap trending down over a longer period can hint at fragmentation or repeated allocation pressure. Repeated weather or dashboard failures help separate internet issues from local hotspot issues.</div></div></div></div></section>)DIAGHTML";
  page += R"DIAGSCRIPT(<script>
function setText(id, value){ const el=document.getElementById(id); if(el) el.textContent=value; }
function formatBytes(v){ if(v === null || v === undefined) return '-'; if(v >= 1048576) return (v/1048576).toFixed(2) + ' MB'; return (v/1024).toFixed(0) + ' KB'; }
function spark(targetId, minId, maxId, values, formatter){ const host=document.getElementById(targetId); const minEl=document.getElementById(minId); const maxEl=document.getElementById(maxId); if(!host) return; const clean = values.filter(v => v !== null && v !== undefined); if(!clean.length){ host.innerHTML = '<div class="muted">No samples yet</div>'; if(minEl) minEl.textContent='-'; if(maxEl) maxEl.textContent='-'; return; } let min = Math.min(...clean); let max = Math.max(...clean); if(min === max){ min -= 1; max += 1; } const W=100, H=40, P=4; let segments=[]; let current=[]; values.forEach((v, i) => { if(v === null || v === undefined){ if(current.length) segments.push(current.join(' ')); current=[]; return; } const x = P + (i * ((W - P*2) / Math.max(values.length - 1, 1))); const y = P + (1 - ((v - min) / (max - min))) * (H - P*2); current.push(x.toFixed(2) + ',' + y.toFixed(2)); }); if(current.length) segments.push(current.join(' ')); const grid = '<line x1="4" y1="4" x2="96" y2="4" stroke="rgba(148,163,184,.15)" stroke-width=".7"/><line x1="4" y1="20" x2="96" y2="20" stroke="rgba(148,163,184,.10)" stroke-width=".7"/><line x1="4" y1="36" x2="96" y2="36" stroke="rgba(148,163,184,.15)" stroke-width=".7"/>'; const poly = segments.map(seg => '<polyline fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" points="' + seg + '"/>').join(''); host.innerHTML = '<svg viewBox="0 0 100 40" preserveAspectRatio="none">' + grid + poly + '</svg>'; if(minEl) minEl.textContent = 'Min ' + formatter(min); if(maxEl) maxEl.textContent = 'Max ' + formatter(max); }
async function loadDiagnostics(){ try { const res = await fetch('/api/diagnostics', { cache:'no-store' }); if(!res.ok) throw new Error('HTTP ' + res.status); const d = await res.json(); setText('diag-error', ''); setText('metric-uptime', d.uptime); setText('metric-fetch-last', 'Last fetch: ' + d.last_fetch + ' • OK: ' + d.last_fetch_success); setText('metric-wifi', d.wifi_text); setText('metric-hotspot', 'Hotspot: ' + d.hotspot_status); setText('metric-heap', formatBytes(d.heap_free)); setText('metric-api', 'API latency: ' + d.api_latency_ms + ' ms • HTTP ' + d.api_status_code); setText('metric-qsos', d.total_qsos + ' total'); setText('metric-live', 'Live: ' + ((d.live_callsign || '-') + (d.live_mode ? ' • ' + d.live_mode : ''))); setText('metric-fetch-ok', d.fetch_ok + ' ok'); setText('metric-fetch-fail', 'Failures: ' + d.fetch_fail + ' • streak ' + d.fetch_streak); setText('metric-weather', 'HTTP ' + d.weather_status_code); setText('metric-weather-last', 'Last refresh: ' + d.weather_last + ' • OK: ' + d.weather_last_success); setText('metric-rx', 'RX ' + d.rx_freq); setText('metric-tx', 'TX: ' + d.tx_freq); setText('metric-modes', 'DMR ' + d.mode_counts.dmr + ' • DSTAR ' + d.mode_counts.dstar + ' • YSF ' + d.mode_counts.ysf); const best = d.best_rssi === -32768 ? '-' : d.best_rssi + ' dBm'; const worst = d.worst_rssi === -32768 ? '-' : d.worst_rssi + ' dBm'; setText('metric-rssi-range', 'RSSI range: ' + best + ' / ' + worst); spark('chart-wifi', 'chart-wifi-min', 'chart-wifi-max', d.history.wifi_rssi, v => v + ' dBm'); spark('chart-heap', 'chart-heap-min', 'chart-heap-max', d.history.heap_free, v => formatBytes(v)); spark('chart-api', 'chart-api-min', 'chart-api-max', d.history.api_latency, v => Math.round(v) + ' ms'); spark('chart-qso', 'chart-qso-min', 'chart-qso-max', d.history.qso_total, v => Math.round(v)); } catch (err) { setText('diag-error', 'Unable to load live diagnostics right now. ' + err.message); } }
loadDiagnostics(); setInterval(loadDiagnostics, 4000);
</script>)DIAGSCRIPT";
  page += "</main></div></body></html>";
  server.send(200, "text/html", page);
}

void handleLogsApi() {
  server.sendHeader("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0");
  server.send(200, "application/json", buildWebLogsJson());
}

void handleLogsClear() {
  webLogHead = 0;
  webLogCount = 0;
  addWebLog("INFO", "WEB", "Device log cleared from Web UI.");
  server.sendHeader("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0");
  server.send(200, "application/json", buildWebLogsJson());
}

void handleLogsPage() {
  String accentHex = cfg.themeColor.length() ? cfg.themeColor : String("#35C2FF");
  String ip = wifiOk ? WiFi.localIP().toString() : (apModeActive ? WiFi.softAPIP().toString() : "not connected");
  String backend = cfg.backend;
  if (backend != "WPSD" && backend != "Pi-Star") backend = "Pi-Star";

  String page;
  page.reserve(20000);
  page += "<!doctype html><html><head><meta charset='utf-8'>";
  page += "<meta name='viewport' content='width=device-width,initial-scale=1'>";
  page += "<title>MMDVM Display</title>";
  page += "<style>";
  page += ":root{--bg:#0b1220;--panel:rgba(15,23,42,.82);--line:rgba(148,163,184,.20);--line2:rgba(148,163,184,.12);--text:#e5eefc;--muted:#94a3b8;--side:rgba(7,13,24,.76);--accent:" + accentHex + ";--accent-soft:rgba(53,194,255,.16);--shadow:0 18px 45px rgba(2,6,23,.34);--warn:#fbbf24;--bad:#f87171;--good:#34d399}";
  page += R"LOGCSS(*{box-sizing:border-box}html{scroll-behavior:smooth}body{margin:0;font-family:Inter,Segoe UI,Arial,Helvetica,sans-serif;background:#0b1220;color:var(--text)}body:before{content:'';position:fixed;inset:0;background:linear-gradient(135deg,rgba(255,255,255,.03),transparent 45%,rgba(255,255,255,.015));pointer-events:none}.topbar{position:sticky;top:0;z-index:40;backdrop-filter:blur(14px);background:rgba(11,18,32,.72);border-bottom:1px solid var(--line);padding:18px 24px;display:flex;justify-content:space-between;align-items:center;gap:16px;flex-wrap:wrap}.brand{font-size:28px;font-weight:700;letter-spacing:-.03em;color:#fff}.brand-sub{font-size:13px;color:var(--muted);margin-top:4px}.topbar-right{display:flex;align-items:center;gap:12px;flex-wrap:wrap;justify-content:flex-end}.header-actions{display:flex;gap:10px;flex-wrap:wrap;justify-content:flex-end}.btn{display:inline-flex;align-items:center;justify-content:center;padding:13px 20px;border-radius:14px;border:1px solid transparent;background:linear-gradient(135deg,var(--accent),rgba(255,255,255,.18));color:#08111d;font-weight:800;text-decoration:none;cursor:pointer;box-shadow:0 14px 30px rgba(53,194,255,.20)}.btn-secondary{background:rgba(255,255,255,.03);color:#fff;border-color:var(--line);box-shadow:none}.btn-compact{padding:10px 14px;font-size:13px}.layout{display:flex;min-height:calc(100vh - 78px)}.sidebar{width:248px;background:var(--side);border-right:1px solid var(--line);padding:24px 16px;position:sticky;top:78px;height:calc(100vh - 78px);overflow:auto;backdrop-filter:blur(16px)}.side-title{font-size:11px;text-transform:uppercase;letter-spacing:.16em;color:var(--muted);margin:0 0 12px 10px;font-weight:800}.side-link{display:block;text-decoration:none;color:#cbd5e1;padding:12px 14px;border-radius:14px;margin-bottom:8px;border:1px solid transparent;font-size:14px;font-weight:700;transition:all .18s ease}.side-link:hover{background:rgba(255,255,255,.04);border-color:var(--line);transform:translateX(2px)}.side-link.active{background:linear-gradient(135deg,var(--accent-soft),rgba(255,255,255,.02));border-color:rgba(53,194,255,.28);color:#fff;box-shadow:inset 0 0 0 1px rgba(255,255,255,.04)}.content{flex:1;min-width:0;padding:28px}.page-head{display:flex;justify-content:space-between;align-items:flex-start;gap:18px;margin-bottom:20px}.page-head h1{margin:0;font-size:34px;font-weight:800;letter-spacing:-.04em;color:#fff}.page-sub{margin-top:8px;color:var(--muted);font-size:15px;max-width:760px}.status-box,.section{background:var(--panel);border:1px solid var(--line);border-radius:22px;box-shadow:var(--shadow);backdrop-filter:blur(18px)}.status-box{padding:14px 16px;min-width:260px}.status-row{display:flex;justify-content:space-between;gap:12px;padding:6px 0;font-size:13px}.status-key{color:var(--muted);font-weight:700}.status-val{font-weight:700;color:#fff;text-align:right}.section{margin-bottom:18px;overflow:hidden}.section-head{padding:16px 20px;background:linear-gradient(180deg,rgba(255,255,255,.035),rgba(255,255,255,.01));border-bottom:1px solid var(--line2)}.section-head h2{margin:0;font-size:19px;font-weight:800;color:#fff}.section-head p{margin:6px 0 0 0;font-size:13px;color:var(--muted)}.section-body{padding:20px}.toolbar{display:flex;gap:10px;flex-wrap:wrap;align-items:center;justify-content:space-between;margin-bottom:14px}.toolbar-left,.toolbar-right{display:flex;gap:10px;flex-wrap:wrap;align-items:center}.pill{display:inline-flex;align-items:center;gap:8px;padding:10px 14px;border-radius:999px;background:rgba(255,255,255,.04);border:1px solid var(--line);font-size:12px;font-weight:800;color:#cbd5e1}.console{background:#050b16;border:1px solid rgba(148,163,184,.18);border-radius:18px;padding:0;overflow:hidden}.console-head{display:grid;grid-template-columns:120px 88px 92px minmax(0,1fr);gap:12px;padding:12px 16px;border-bottom:1px solid var(--line2);background:rgba(255,255,255,.02)}.console-title{font-size:12px;text-transform:uppercase;letter-spacing:.14em;color:var(--muted);font-weight:800}.console-body{max-height:68vh;overflow:auto;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}.log-row{display:grid;grid-template-columns:120px 88px 92px minmax(0,1fr);gap:12px;padding:10px 16px;border-bottom:1px solid rgba(148,163,184,.08);font-size:13px;align-items:start}.log-row:last-child{border-bottom:none}.log-age{color:#93c5fd}.log-level{font-weight:800}.log-level.INFO{color:var(--good)}.log-level.WARN{color:var(--warn)}.log-level.ERROR{color:var(--bad)}.log-tag{color:#c4b5fd}.log-msg{color:#e2e8f0;word-break:break-word}.empty-note{padding:18px;color:var(--muted)}.switch{display:inline-flex;align-items:center;gap:8px;color:#cbd5e1;font-size:13px;font-weight:700}.muted{color:var(--muted)}@media(max-width:1080px){.layout{display:block}.sidebar{width:auto;height:auto;position:relative;top:0;border-right:none;border-bottom:1px solid var(--line)}.content{padding:18px}.page-head{flex-direction:column}.topbar{position:relative}.topbar-right{width:100%;justify-content:flex-start}.header-actions{justify-content:flex-start}.console-head,.log-row{grid-template-columns:82px 66px 72px minmax(0,1fr);font-size:12px;padding:9px 12px}})LOGCSS";
  page += "</style></head><body>";

  page += "<div class='topbar'><div><div class='brand'>MMDVM Display</div><div class='brand-sub'>Version " + String(DISPLAY_VERSION) + " by 13MAD86 / Martin</div></div>";
  page += "<div class='topbar-right'><div class='header-actions'><a class='btn btn-secondary btn-compact' href='/app/diagnostics'>&#128200; Diagnostics</a><a class='btn btn-secondary btn-compact' href='/'>&#9881;&#65039; Configuration</a></div></div></div>";

  page += "<div class='layout'>";
  page += buildSidebarHtml(false, "logs");
  page += "<main class='content'>";
  page += "<div class='page-head'><div><h1>&#128211; Device log</h1><div class='page-sub'>Live runtime messages from the ESP32 so you can see Wi-Fi retries, API failures, OTA events and other background activity without a serial cable.</div></div>";
  page += "<div class='status-box'>";
  page += "<div class='status-row'><span class='status-key'>Device IP</span><span class='status-val'>" + htmlEscape(ip) + "</span></div>";
  page += "<div class='status-row'><span class='status-key'>Backend</span><span class='status-val'>" + htmlEscape(backend) + "</span></div>";
  page += "<div class='status-row'><span class='status-key'>Buffered lines</span><span class='status-val' id='log-count'>-</span></div>";
  page += "<div class='status-row'><span class='status-key'>Capacity</span><span class='status-val'>" + String(WEB_LOG_CAPACITY) + "</span></div>";
  page += "</div></div>";
  page += R"LOGHTML(<section class='section'><div class='section-head'><h2>Runtime console</h2><p>The newest entries appear at the bottom. Auto-refresh is enabled by default.</p></div><div class='section-body'><div class='toolbar'><div class='toolbar-left'><div class='pill' id='log-meta'>Loading…</div></div><div class='toolbar-right'><label class='switch'><input type='checkbox' id='autoscroll' checked> Auto-scroll</label><button class='btn btn-secondary btn-compact' id='refresh-btn' type='button'>Refresh</button><button class='btn btn-secondary btn-compact' id='clear-btn' type='button'>Clear log</button></div></div><div class='console'><div class='console-head'><div class='console-title'>Age</div><div class='console-title'>Level</div><div class='console-title'>Tag</div><div class='console-title'>Message</div></div><div class='console-body' id='log-body'><div class='empty-note'>Loading device log…</div></div></div><div class='muted' style='margin-top:12px'>Tip: this is an in-memory ring buffer, so it survives page refreshes but not a reboot.</div></div></section>)LOGHTML";
  page += R"LOGSCRIPT(<script>
const logBody = document.getElementById('log-body');
const logMeta = document.getElementById('log-meta');
const logCount = document.getElementById('log-count');
const autoScroll = document.getElementById('autoscroll');

function esc(v){ return String(v ?? '').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); }

function renderLogs(data){
  const items = Array.isArray(data.items) ? data.items : [];
  logCount.textContent = String(data.count ?? items.length ?? 0);
  logMeta.textContent = String(items.length) + ' entries buffered';
  if(!items.length){
    logBody.innerHTML = "<div class='empty-note'>No device log entries yet.</div>";
    return;
  }
  logBody.innerHTML = items.map(item => {
    const level = esc(item.level || 'INFO');
    const tag = esc(item.tag || '-');
    const age = esc(item.age || '-');
    const msg = esc(item.message || '');
    return "<div class='log-row'><div class='log-age'>" + age + "</div><div class='log-level " + level + "'>" + level + "</div><div class='log-tag'>" + tag + "</div><div class='log-msg'>" + msg + "</div></div>";
  }).join('');
  if(autoScroll.checked) logBody.scrollTop = logBody.scrollHeight;
}

async function loadLogs(){
  try{
    const res = await fetch('/api/logs', { cache:'no-store' });
    if(!res.ok) throw new Error('HTTP ' + res.status);
    renderLogs(await res.json());
  }catch(err){
    logBody.innerHTML = "<div class='empty-note'>Unable to load device log right now. " + esc(err.message) + "</div>";
  }
}

async function clearLogs(){
  try{
    const res = await fetch('/api/logs/clear', { method:'POST', cache:'no-store' });
    if(!res.ok) throw new Error('HTTP ' + res.status);
    renderLogs(await res.json());
  }catch(err){
    alert('Unable to clear log: ' + err.message);
  }
}

document.getElementById('refresh-btn').addEventListener('click', loadLogs);
document.getElementById('clear-btn').addEventListener('click', clearLogs);
loadLogs();
setInterval(loadLogs, 2000);
</script>)LOGSCRIPT";
  page += "</main></div></body></html>";
  server.send(200, "text/html", page);
}

void handleOtaPage() {
  String accentHex = cfg.themeColor.length() ? cfg.themeColor : String("#35C2FF");
  String ip = wifiOk ? WiFi.localIP().toString() : (apModeActive ? WiFi.softAPIP().toString() : "not connected");
  String backend = cfg.backend;
  if (backend != "WPSD" && backend != "Pi-Star") backend = "Pi-Star";

  String page;
  page.reserve(16000);
  page += "<!doctype html><html><head><meta charset='utf-8'>";
  page += "<meta name='viewport' content='width=device-width,initial-scale=1'>";
  page += "<title>MMDVM Display</title>";
  page += "<style>";
  page += ":root{--bg:#0b1220;--panel:rgba(15,23,42,.82);--line:rgba(148,163,184,.20);--line2:rgba(148,163,184,.12);--text:#e5eefc;--muted:#94a3b8;--side:rgba(7,13,24,.76);--accent:" + accentHex + ";--accent-soft:rgba(53,194,255,.16);--shadow:0 18px 45px rgba(2,6,23,.34);--warn:#fdba74}";
  page += R"OTACSS(*{box-sizing:border-box}body{margin:0;font-family:Inter,Segoe UI,Arial,Helvetica,sans-serif;background:#0b1220;color:var(--text)}body:before{content:'';position:fixed;inset:0;background:linear-gradient(135deg,rgba(255,255,255,.03),transparent 45%,rgba(255,255,255,.015));pointer-events:none}.topbar{position:sticky;top:0;z-index:40;backdrop-filter:blur(14px);background:rgba(11,18,32,.72);border-bottom:1px solid var(--line);padding:18px 24px;display:flex;justify-content:space-between;align-items:center;gap:16px;flex-wrap:wrap}.brand{font-size:28px;font-weight:700;letter-spacing:-.03em;color:#fff}.brand-sub{font-size:13px;color:var(--muted);margin-top:4px}.topbar-right{display:flex;align-items:center;gap:12px;flex-wrap:wrap;justify-content:flex-end}.header-actions{display:flex;gap:10px;flex-wrap:wrap;justify-content:flex-end}.btn{display:inline-flex;align-items:center;justify-content:center;padding:13px 20px;border-radius:14px;border:1px solid transparent;background:linear-gradient(135deg,var(--accent),rgba(255,255,255,.18));color:#08111d;font-weight:800;text-decoration:none;cursor:pointer;box-shadow:0 14px 30px rgba(53,194,255,.20)}.btn-secondary{background:rgba(255,255,255,.03);color:#fff;border-color:var(--line);box-shadow:none}.btn-compact{padding:10px 14px;font-size:13px}.layout{display:flex;min-height:calc(100vh - 78px)}.sidebar{width:248px;background:var(--side);border-right:1px solid var(--line);padding:24px 16px;position:sticky;top:78px;height:calc(100vh - 78px);overflow:auto;backdrop-filter:blur(16px)}.side-title{font-size:11px;text-transform:uppercase;letter-spacing:.16em;color:var(--muted);margin:0 0 12px 10px;font-weight:800}.side-link{display:block;text-decoration:none;color:#cbd5e1;padding:12px 14px;border-radius:14px;margin-bottom:8px;border:1px solid transparent;font-size:14px;font-weight:700}.side-link:hover{background:rgba(255,255,255,.04);border-color:var(--line)}.side-link.active{background:linear-gradient(135deg,var(--accent-soft),rgba(255,255,255,.02));border-color:rgba(53,194,255,.28);color:#fff}.content{flex:1;min-width:0;padding:28px}.page-head{display:flex;justify-content:space-between;align-items:flex-start;gap:18px;margin-bottom:20px}.page-head h1{margin:0;font-size:34px;font-weight:800;letter-spacing:-.04em;color:#fff}.page-sub{margin-top:8px;color:var(--muted);font-size:15px;max-width:720px}.status-box,.section{background:var(--panel);border:1px solid var(--line);border-radius:22px;box-shadow:var(--shadow);backdrop-filter:blur(18px)}.status-box{padding:14px 16px;min-width:260px}.status-row{display:flex;justify-content:space-between;gap:12px;padding:6px 0;font-size:13px}.status-key{color:var(--muted);font-weight:700}.status-val{font-weight:700;color:#fff;text-align:right}.section{margin-bottom:18px;overflow:hidden}.section-head{padding:16px 20px;background:linear-gradient(180deg,rgba(255,255,255,.035),rgba(255,255,255,.01));border-bottom:1px solid var(--line2)}.section-head h2{margin:0;font-size:19px;font-weight:800;color:#fff}.section-head p{margin:6px 0 0 0;font-size:13px;color:var(--muted)}.section-body{padding:20px}.upload-box{padding:18px;border-radius:18px;border:1px dashed rgba(148,163,184,.28);background:rgba(255,255,255,.02)}input[type='file']{width:100%;padding:13px 14px;border:1px solid rgba(148,163,184,.26);border-radius:14px;background:rgba(15,23,42,.80);color:#fff;font-size:14px}.alert-card{padding:16px 18px;border-radius:18px;background:rgba(253,186,116,.08);border:1px solid rgba(253,186,116,.20);color:#fde68a}.mini-note{color:var(--muted);font-size:13px;line-height:1.65}.bullet{margin:8px 0 0 18px;color:#cbd5e1}.bullet li{margin:6px 0}@media(max-width:1080px){.layout{display:block}.sidebar{width:auto;height:auto;position:relative;top:0;border-right:none;border-bottom:1px solid var(--line)}.content{padding:18px}.page-head{flex-direction:column}.topbar{position:relative}.topbar-right{width:100%;justify-content:flex-start}.header-actions{justify-content:flex-start}})OTACSS";
  page += "</style></head><body>";

  page += "<div class='topbar'><div><div class='brand'>MMDVM Display</div><div class='brand-sub'>Version "+ String(DISPLAY_VERSION) +" by 13MAD86 / Martin</div></div>";
  page += "<div class='topbar-right'>";
  page += "<div class='header-actions'>";
  page += "<a class='btn btn-secondary btn-compact' href='/export'>&#128228; Export config</a>";
  page += "<form class='inline-form' method='POST' action='/import' enctype='multipart/form-data'><input type='file' id='import_config_file' name='config_file' accept='.json,application/json' style='display:none' onchange='this.form.submit()'><label class='btn btn-secondary btn-compact' for='import_config_file'>&#128229; Import config</label></form>";
  page += "</div></div></div>";

  page += "<div class='layout'>";
  page += buildSidebarHtml(false, "ota");
  page += "<main class='content'>";
  page += "<div class='page-head'><div><h1>&#128187; OTA firmware update</h1><div class='page-sub'>Upload a compiled ESP32 firmware image directly from the browser. Successful installs reboot the device automatically.</div></div>";
  page += "<div class='status-box'>";
  page += "<div class='status-row'><span class='status-key'>Device IP</span><span class='status-val'>" + htmlEscape(ip) + "</span></div>";
  page += "<div class='status-row'><span class='status-key'>Backend</span><span class='status-val'>" + htmlEscape(backend) + "</span></div>";
  page += "<div class='status-row'><span class='status-key'>Version</span><span class='status-val'>" + htmlEscape(String(DISPLAY_VERSION)) + "</span></div>";
  page += "<div class='status-row'><span class='status-key'>Build</span><span class='status-val'>" + htmlEscape(getBuildStamp()) + "</span></div>";
  page += "</div></div>";
  page += R"OTAHTML(<section class='section'><div class='section-head'><h2>Upload firmware</h2><p>Select the .bin file produced by your ESP32 build.</p></div><div class='section-body'><div class='alert-card'><strong>Important:</strong> stay on this page until the upload finishes. The display will reboot by itself after a successful flash, so the browser may briefly lose connection.</div><div class='upload-box' style='margin-top:16px'><form id='ota-form' method='POST' action='/app/ota' enctype='multipart/form-data'><input type='file' name='firmware' id='firmware' accept='.bin,.bin.gz,application/octet-stream' required><div style='margin-top:14px;display:flex;gap:10px;flex-wrap:wrap'><button class='btn btn-secondary' type='submit' id='ota-submit'>&#128189; Start OTA update</button></div></form><div class='mini-note' id='ota-note' style='margin-top:12px'>Use a firmware image compiled for this exact board and partition layout.</div></div></div></section><section class='section'><div class='section-head'><h2>Recommended flow</h2><p>A couple of small safety checks before you flash.</p></div><div class='section-body'><ul class='bullet'><li>Export your current configuration first from the main configuration page.</li><li>Confirm the board target matches this device and display backend.</li><li>Keep the browser connected to the same network until the reboot completes.</li><li>If the update changes Wi-Fi settings, reconnect using the new address or the setup AP.</li></ul></div></section><script>document.getElementById('ota-form').addEventListener('submit', function(){ const btn = document.getElementById('ota-submit'); const note = document.getElementById('ota-note'); btn.disabled = true; btn.textContent = 'Uploading...'; note.textContent = 'Firmware upload in progress. Do not close this tab.'; });</script>)OTAHTML";
  page += "</main></div></body></html>";
  server.send(200, "text/html", page);
}

void handleOtaUpload() {
  HTTPUpload& upload = server.upload();

  if (upload.status == UPLOAD_FILE_START) {
    otaUpdateSuccessful = false;
    otaRestartAt = 0;
    otaLastError = "";
    addWebLog("INFO", "OTA", "OTA upload started.");
    if (!Update.begin(UPDATE_SIZE_UNKNOWN)) {
      otaLastError = otaErrorText();
      addWebLog("ERROR", "OTA", "OTA begin failed: " + otaLastError);
    }
  } else if (upload.status == UPLOAD_FILE_WRITE) {
    if (!otaLastError.length()) {
      if (Update.write(upload.buf, upload.currentSize) != upload.currentSize) {
        otaLastError = otaErrorText();
        addWebLog("ERROR", "OTA", "OTA write failed: " + otaLastError);
      }
    }
  } else if (upload.status == UPLOAD_FILE_END) {
    if (!otaLastError.length()) {
      if (Update.end(true)) {
        otaUpdateSuccessful = true;
        otaRestartAt = millis() + 3500UL;
        addWebLog("INFO", "OTA", "OTA image written successfully. Reboot scheduled.");
      } else {
        otaLastError = otaErrorText();
        addWebLog("ERROR", "OTA", "OTA finalize failed: " + otaLastError);
      }
    }
  } else if (upload.status == UPLOAD_FILE_ABORTED) {
    otaLastError = "Upload aborted.";
    addWebLog("WARN", "OTA", "OTA upload aborted.");
  }
}

void handleOtaUpdate() {
  server.sendHeader("Connection", "close");
  if (otaUpdateSuccessful) {
    sendWebMessagePage("Firmware updated", "The firmware image was written successfully.", "The device will reboot automatically in a few seconds. Reload the web UI after the reboot finishes.", false);
  } else {
    sendWebMessagePage("OTA update failed", otaLastError.length() ? otaLastError : String("The firmware upload could not be applied."), "Verify that the uploaded .bin matches this board and try again.", true);
  }
}


void handleAboutPage() {
  String accentHex = cfg.themeColor.length() ? cfg.themeColor : String("#35C2FF");
  String ip = wifiOk ? WiFi.localIP().toString() : (apModeActive ? WiFi.softAPIP().toString() : "not connected");
  String backend = cfg.backend;
  if (backend != "WPSD" && backend != "Pi-Star") backend = "Pi-Star";

  String page;
  page.reserve(18000);
  page += "<!doctype html><html><head><meta charset='utf-8'>";
  page += "<meta name='viewport' content='width=device-width,initial-scale=1'>";
  page += "<title>MMDVM Display</title>";
  page += "<style>";
  page += ":root{--bg:#0b1220;--panel:rgba(15,23,42,.82);--line:rgba(148,163,184,.20);--line2:rgba(148,163,184,.12);--text:#e5eefc;--muted:#94a3b8;--side:rgba(7,13,24,.76);--accent:" + accentHex + ";--accent-soft:rgba(53,194,255,.16);--shadow:0 18px 45px rgba(2,6,23,.34)}";
  page += R"ABOUTCSS(*{box-sizing:border-box}body{margin:0;font-family:Inter,Segoe UI,Arial,Helvetica,sans-serif;background:#0b1220;color:var(--text)}body:before{content:'';position:fixed;inset:0;background:linear-gradient(135deg,rgba(255,255,255,.03),transparent 45%,rgba(255,255,255,.015));pointer-events:none}.topbar{position:sticky;top:0;z-index:40;backdrop-filter:blur(14px);background:rgba(11,18,32,.72);border-bottom:1px solid var(--line);padding:18px 24px;display:flex;justify-content:space-between;align-items:center;gap:16px;flex-wrap:wrap}.brand{font-size:28px;font-weight:700;letter-spacing:-.03em;color:#fff}.brand-sub{font-size:13px;color:var(--muted);margin-top:4px}.topbar-right{display:flex;align-items:center;gap:12px;flex-wrap:wrap;justify-content:flex-end}.header-actions{display:flex;gap:10px;flex-wrap:wrap;justify-content:flex-end}.btn{display:inline-flex;align-items:center;justify-content:center;padding:13px 20px;border-radius:14px;border:1px solid transparent;background:linear-gradient(135deg,var(--accent),rgba(255,255,255,.18));color:#08111d;font-weight:800;text-decoration:none;cursor:pointer;box-shadow:0 14px 30px rgba(53,194,255,.20)}.btn-secondary{background:rgba(255,255,255,.03);color:#fff;border-color:var(--line);box-shadow:none}.btn-compact{padding:10px 14px;font-size:13px}.layout{display:flex;min-height:calc(100vh - 78px)}.sidebar{width:248px;background:var(--side);border-right:1px solid var(--line);padding:24px 16px;position:sticky;top:78px;height:calc(100vh - 78px);overflow:auto;backdrop-filter:blur(16px)}.side-title{font-size:11px;text-transform:uppercase;letter-spacing:.16em;color:var(--muted);margin:0 0 12px 10px;font-weight:800}.side-link{display:block;text-decoration:none;color:#cbd5e1;padding:12px 14px;border-radius:14px;margin-bottom:8px;border:1px solid transparent;font-size:14px;font-weight:700}.side-link:hover{background:rgba(255,255,255,.04);border-color:var(--line)}.side-link.active{background:linear-gradient(135deg,var(--accent-soft),rgba(255,255,255,.02));border-color:rgba(53,194,255,.28);color:#fff}.content{flex:1;min-width:0;padding:28px}.page-head{display:flex;justify-content:space-between;align-items:flex-start;gap:18px;margin-bottom:20px}.page-head h1{margin:0;font-size:34px;font-weight:800;letter-spacing:-.04em;color:#fff}.page-sub{margin-top:8px;color:var(--muted);font-size:15px;max-width:760px}.status-box,.section,.hero-card,.feature-card{background:var(--panel);border:1px solid var(--line);border-radius:22px;box-shadow:var(--shadow);backdrop-filter:blur(18px)}.status-box{padding:14px 16px;min-width:260px}.status-row{display:flex;justify-content:space-between;gap:12px;padding:6px 0;font-size:13px}.status-key{color:var(--muted);font-weight:700}.status-val{font-weight:700;color:#fff;text-align:right}.section{margin-bottom:18px;overflow:hidden}.section-head{padding:16px 20px;background:linear-gradient(180deg,rgba(255,255,255,.035),rgba(255,255,255,.01));border-bottom:1px solid var(--line2)}.section-head h2{margin:0;font-size:19px;font-weight:800;color:#fff}.section-head p{margin:6px 0 0 0;font-size:13px;color:var(--muted)}.section-body{padding:20px}.hero-card{padding:22px;margin-bottom:18px;display:flex;justify-content:space-between;gap:18px;align-items:center;flex-wrap:wrap}.hero-eyebrow{font-size:12px;text-transform:uppercase;letter-spacing:.16em;color:var(--muted);font-weight:800;margin-bottom:8px}.hero-card h3{margin:0;font-size:24px;color:#fff}.hero-card p{margin:10px 0 0 0;max-width:780px;color:#cbd5e1;line-height:1.7}.hero-pill{display:inline-flex;align-items:center;padding:12px 16px;border-radius:999px;background:rgba(53,194,255,.12);border:1px solid rgba(53,194,255,.24);font-weight:800;color:#fff}.feature-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:16px}.feature-card{padding:18px}.feature-card h3{margin:0 0 10px 0;font-size:18px;color:#fff}.feature-card p{margin:0;color:#cbd5e1;line-height:1.7}.bullet{margin:0;padding-left:18px;color:#cbd5e1}.bullet li{margin:8px 0;line-height:1.6}.mini-note{color:var(--muted);font-size:13px;line-height:1.65}.quick-links{display:flex;flex-wrap:wrap;gap:10px;margin-top:16px}@media(max-width:1080px){.layout{display:block}.sidebar{width:auto;height:auto;position:relative;top:0;border-right:none;border-bottom:1px solid var(--line)}.content{padding:18px}.page-head{flex-direction:column}.feature-grid{grid-template-columns:1fr}.topbar{position:relative}.topbar-right{width:100%;justify-content:flex-start}.header-actions{justify-content:flex-start}})ABOUTCSS";
  page += "</style></head><body>";

  page += "<div class='topbar'><div><div class='brand'>MMDVM Display</div><div class='brand-sub'>Version "+ String(DISPLAY_VERSION) +" by 13MAD86 / Martin</div></div>";
  page += "<div class='topbar-right'>";
  page += "<div class='header-actions'>";
  page += "<a class='btn btn-secondary btn-compact' href='/export'>&#128228; Export config</a>";
  page += "<form class='inline-form' method='POST' action='/import' enctype='multipart/form-data'><input type='file' id='import_config_file' name='config_file' accept='.json,application/json' style='display:none' onchange='this.form.submit()'><label class='btn btn-secondary btn-compact' for='import_config_file'>&#128229; Import config</label></form>";
  page += "</div></div></div>";

  page += "<div class='layout'>";
  page += buildSidebarHtml(false, "about");
  page += "<main class='content'>";
  page += "<div class='page-head'><div><h1>&#8505;&#65039; About MMDVM Display</h1><div class='page-sub'>A compact web-managed touchscreen companion for MMDVM hotspots and repeaters running Pi-Star or WPSD.</div></div>";
  page += "<div class='status-box'>";
  page += "<div class='status-row'><span class='status-key'>Device IP</span><span class='status-val'>" + htmlEscape(ip) + "</span></div>";
  page += "<div class='status-row'><span class='status-key'>Backend</span><span class='status-val'>" + htmlEscape(backend) + "</span></div>";
  page += "<div class='status-row'><span class='status-key'>Version</span><span class='status-val'>" + htmlEscape(String(DISPLAY_VERSION)) + "</span></div>";
  page += "<div class='status-row'><span class='status-key'>Build</span><span class='status-val'>" + htmlEscape(getBuildStamp()) + "</span></div>";
  page += "</div></div>";

  page += "<div class='hero-card'><div><div class='hero-eyebrow'>What this software is</div><h3>Purpose-built MMDVM companion UI</h3><p>MMDVM Display turns an ESP32 touchscreen into a live status and control panel for your radio hotspot. It shows activity, system state and visual information on the device itself, while the Web UI gives you a simple place to configure, maintain and update the firmware.</p></div><div class='hero-pill'>Active callsign: " + htmlEscape(getDeviceCallsign()) + "</div></div>";

  page += R"ABOUTHTML(<section class='section'><div class='section-head'><h2>Core capabilities</h2><p>Main features available on the device and in the Web UI.</p></div><div class='section-body'><div class='feature-grid'><div class='feature-card'><h3>Live hotspot display</h3><p>Shows recent traffic, status information and screensaver content directly on the connected display hardware.</p></div><div class='feature-card'><h3>Web-based configuration</h3><p>Configure backend, network, Wi-Fi, display, weather, lookup and theme settings from the browser without rebuilding firmware.</p></div><div class='feature-card'><h3>Maintenance tools</h3><p>Use OTA firmware updates, diagnostics and config import/export to manage the device more easily in the field.</p></div><div class='feature-card'><h3>Remote control support</h3><p>Optional SSH-assisted functions let the touchscreen control DigiModes on supported Pi-Star based setups.</p></div></div></div></section><section class='section'><div class='section-head'><h2>Supported environment</h2><p>Backends and hardware the project is built around.</p></div><div class='section-body'><ul class='bullet'><li>Backend support for <strong>Pi-Star</strong> and <strong>WPSD</strong>.</li><li>Display support for compatible ESP32 touchscreen targets already included in this firmware.</li><li>Optional weather integration for screensaver mode using OpenWeather.</li><li>Theme personalization including callsign and Web UI accent color.</li></ul></div></section>)ABOUTHTML";
  page += "</main></div></body></html>";
  server.send(200, "text/html", page);
}

void handleChangelogPage() {
  String accentHex = cfg.themeColor.length() ? cfg.themeColor : String("#35C2FF");
  String ip = wifiOk ? WiFi.localIP().toString() : (apModeActive ? WiFi.softAPIP().toString() : "not connected");
  String backend = cfg.backend;
  if (backend != "WPSD" && backend != "Pi-Star") backend = "Pi-Star";

  String page;
  page.reserve(20000);
  page += "<!doctype html><html><head><meta charset='utf-8'>";
  page += "<meta name='viewport' content='width=device-width,initial-scale=1'>";
  page += "<title>MMDVM Display</title>";
  page += "<style>";
  page += ":root{--bg:#0b1220;--panel:rgba(15,23,42,.82);--line:rgba(148,163,184,.20);--line2:rgba(148,163,184,.12);--text:#e5eefc;--muted:#94a3b8;--side:rgba(7,13,24,.76);--accent:" + accentHex + ";--accent-soft:rgba(53,194,255,.16);--shadow:0 18px 45px rgba(2,6,23,.34);--good:#4ade80;--fix:#fda4af;--improve:#facc15;--feature:#60a5fa}";
  page += R"CHANGELOGCSS(*{box-sizing:border-box}body{margin:0;font-family:Inter,Segoe UI,Arial,Helvetica,sans-serif;background:#0b1220;color:var(--text)}body:before{content:'';position:fixed;inset:0;background:linear-gradient(135deg,rgba(255,255,255,.03),transparent 45%,rgba(255,255,255,.015));pointer-events:none}.topbar{position:sticky;top:0;z-index:40;backdrop-filter:blur(14px);background:rgba(11,18,32,.72);border-bottom:1px solid var(--line);padding:18px 24px;display:flex;justify-content:space-between;align-items:center;gap:16px;flex-wrap:wrap}.brand{font-size:28px;font-weight:700;letter-spacing:-.03em;color:#fff}.brand-sub{font-size:13px;color:var(--muted);margin-top:4px}.topbar-right{display:flex;align-items:center;gap:12px;flex-wrap:wrap;justify-content:flex-end}.header-actions{display:flex;gap:10px;flex-wrap:wrap;justify-content:flex-end}.btn{display:inline-flex;align-items:center;justify-content:center;padding:13px 20px;border-radius:14px;border:1px solid transparent;background:linear-gradient(135deg,var(--accent),rgba(255,255,255,.18));color:#08111d;font-weight:800;text-decoration:none;cursor:pointer;box-shadow:0 14px 30px rgba(53,194,255,.20)}.btn-secondary{background:rgba(255,255,255,.03);color:#fff;border-color:var(--line);box-shadow:none}.btn-compact{padding:10px 14px;font-size:13px}.layout{display:flex;min-height:calc(100vh - 78px)}.sidebar{width:248px;background:var(--side);border-right:1px solid var(--line);padding:24px 16px;position:sticky;top:78px;height:calc(100vh - 78px);overflow:auto;backdrop-filter:blur(16px)}.side-title{font-size:11px;text-transform:uppercase;letter-spacing:.16em;color:var(--muted);margin:0 0 12px 10px;font-weight:800}.side-link{display:block;text-decoration:none;color:#cbd5e1;padding:12px 14px;border-radius:14px;margin-bottom:8px;border:1px solid transparent;font-size:14px;font-weight:700}.side-link:hover{background:rgba(255,255,255,.04);border-color:var(--line)}.side-link.active{background:linear-gradient(135deg,var(--accent-soft),rgba(255,255,255,.02));border-color:rgba(53,194,255,.28);color:#fff}.content{flex:1;min-width:0;padding:28px}.page-head{display:flex;justify-content:space-between;align-items:flex-start;gap:18px;margin-bottom:20px}.page-head h1{margin:0;font-size:34px;font-weight:800;letter-spacing:-.04em;color:#fff}.page-sub{margin-top:8px;color:var(--muted);font-size:15px;max-width:760px}.status-box,.section,.timeline-card{background:var(--panel);border:1px solid var(--line);border-radius:22px;box-shadow:var(--shadow);backdrop-filter:blur(18px)}.status-box{padding:14px 16px;min-width:260px}.status-row{display:flex;justify-content:space-between;gap:12px;padding:6px 0;font-size:13px}.status-key{color:var(--muted);font-weight:700}.status-val{font-weight:700;color:#fff;text-align:right}.section{margin-bottom:18px;overflow:hidden}.section-head{padding:16px 20px;background:linear-gradient(180deg,rgba(255,255,255,.035),rgba(255,255,255,.01));border-bottom:1px solid var(--line2)}.section-head h2{margin:0;font-size:19px;font-weight:800;color:#fff}.section-head p{margin:6px 0 0 0;font-size:13px;color:var(--muted)}.section-body{padding:20px}.timeline{display:flex;flex-direction:column;gap:16px}.timeline-card{padding:20px;position:relative;overflow:hidden}.version-head{display:flex;justify-content:space-between;gap:12px;align-items:flex-start;flex-wrap:wrap;margin-bottom:12px}.version-title{font-size:22px;font-weight:800;color:#fff}.version-meta{font-size:13px;color:var(--muted)}.change-list{margin:0;padding-left:20px;color:#cbd5e1}.change-list li{margin:10px 0;line-height:1.65}.tag{display:inline-flex;align-items:center;padding:7px 10px;border-radius:999px;font-size:11px;text-transform:uppercase;letter-spacing:.12em;font-weight:800;border:1px solid transparent;margin-right:8px}.tag-added{background:rgba(96,165,250,.14);border-color:rgba(96,165,250,.26);color:#bfdbfe}.tag-fixed{background:rgba(253,164,175,.12);border-color:rgba(253,164,175,.22);color:#fecdd3}.tag-improved{background:rgba(250,204,21,.12);border-color:rgba(250,204,21,.24);color:#fde68a}.tag-feature{background:rgba(74,222,128,.12);border-color:rgba(74,222,128,.24);color:#bbf7d0}.release-summary{margin-top:14px;color:var(--muted);font-size:13px;line-height:1.65}.release-current{box-shadow:0 18px 45px rgba(53,194,255,.18), inset 0 0 0 1px rgba(53,194,255,.10)}.mono{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}@media(max-width:1080px){.layout{display:block}.sidebar{width:auto;height:auto;position:relative;top:0;border-right:none;border-bottom:1px solid var(--line)}.content{padding:18px}.page-head{flex-direction:column}.topbar{position:relative}.topbar-right{width:100%;justify-content:flex-start}.header-actions{justify-content:flex-start}})CHANGELOGCSS";
  page += "</style></head><body>";

  page += "<div class='topbar'><div><div class='brand'>MMDVM Display</div><div class='brand-sub'>Version "+ String(DISPLAY_VERSION) +" by 13MAD86 / Martin</div></div>";
  page += "<div class='topbar-right'>";
  page += "<div class='header-actions'>";
  page += "<a class='btn btn-secondary btn-compact' href='/export'>&#128228; Export config</a>";
  page += "<form class='inline-form' method='POST' action='/import' enctype='multipart/form-data'><input type='file' id='import_config_file' name='config_file' accept='.json,application/json' style='display:none' onchange='this.form.submit()'><label class='btn btn-secondary btn-compact' for='import_config_file'>&#128229; Import config</label></form>";
  page += "</div></div></div>";

  page += "<div class='layout'>";
  page += buildSidebarHtml(false, "changelog");
  page += "<main class='content'>";
  page += "<div class='page-head'><div><h1>&#128221; Changelog</h1><div class='page-sub'>Release notes for the Web UI, display firmware and maintenance features.</div></div>";
  page += "<div class='status-box'>";
  page += "<div class='status-row'><span class='status-key'>Device IP</span><span class='status-val'>" + htmlEscape(ip) + "</span></div>";
  page += "<div class='status-row'><span class='status-key'>Backend</span><span class='status-val'>" + htmlEscape(backend) + "</span></div>";
  page += "<div class='status-row'><span class='status-key'>Current version</span><span class='status-val'>" + htmlEscape(String(DISPLAY_VERSION)) + "</span></div>";
  page += "<div class='status-row'><span class='status-key'>Build</span><span class='status-val'>" + htmlEscape(getBuildStamp()) + "</span></div>";
  page += "</div></div>";

  page += R"CHANGELOGHTML(<section class='section'><div class='section-head'><h2>Release history</h2><p>Newest changes are shown first. This page is intended to be kept with the firmware so users can quickly review what changed.</p></div><div class='section-body'><div class='timeline'><article class='timeline-card release-current'><div class='version-head'><div><div class='version-title'>v0.0.0.8</div><div class='version-meta'>Current release</div></div></div><ul class='change-list'><li><span class='tag tag-fixed'>Fixed</span>OTA Update.</li><li><span class='tag tag-added'>Added</span>Device log.</li><li><span class='tag tag-added'>Added</span>SSH quick action presets for TG, reflector and disconnect commands.</li></ul><div class='release-summary'>Focus: OTA update reliability, device-side logging and saved remote presets.</div></article><article class='timeline-card'><div class='version-head'><div><div class='version-title'>v0.0.0.7</div><div class='version-meta'>Remote control and maintenance tools</div></div></div><ul class='change-list'><li><span class='tag tag-added'>Added</span>SSH remote login to control DigiModes.</li><li><span class='tag tag-added'>Added</span>OTA firmware update over Web UI.</li><li><span class='tag tag-added'>Added</span>Diagnostics, About and Changelog to Web UI.</li></ul><div class='release-summary'>Focus: remote control, browser-based updates and runtime visibility.</div></article><article class='timeline-card'><div class='version-head'><div><div class='version-title'>v0.0.0.6</div><div class='version-meta'>Maintenance and UI update</div></div></div><ul class='change-list'><li><span class='tag tag-fixed'>Fixed</span>PNG transparency issues.</li><li><span class='tag tag-added'>Added</span>New set of weather icons.</li><li><span class='tag tag-improved'>Improved</span>Touchscreen responsiveness (resolved 300ms delay).</li><li><span class='tag tag-added'>Added</span>Import/Export functionality to the Web UI.</li></ul><div class='release-summary'>Focus: rendering fixes, better touch response and easier configuration backup/restore.</div></article><article class='timeline-card'><div class='version-head'><div><div class='version-title'>v0.0.0.5</div><div class='version-meta'>Weather expansion</div></div></div><ul class='change-list'><li><span class='tag tag-added'>Added</span>New weather icons.</li><li><span class='tag tag-added'>Added</span>Weather configuration settings to the Web UI.</li></ul><div class='release-summary'>Focus: weather visuals and configurable weather support from the browser.</div></article><article class='timeline-card'><div class='version-head'><div><div class='version-title'>v0.0.0.4</div><div class='version-meta'>Stability update</div></div></div><ul class='change-list'><li><span class='tag tag-fixed'>Fixed</span>RadioID polling logic.</li></ul><div class='release-summary'>Focus: more reliable online callsign lookup polling.</div></article><article class='timeline-card'><div class='version-head'><div><div class='version-title'>v0.0.0.3</div><div class='version-meta'>Display and backend polish</div></div></div><ul class='change-list'><li><span class='tag tag-fixed'>Fixed</span>WPSD fetching issues.</li><li><span class='tag tag-improved'>Improved</span>GUI refresh performance and overall UI tweaks.</li><li><span class='tag tag-added'>Added</span>New screensaver mode.</li></ul><div class='release-summary'>Focus: better WPSD communication, smoother rendering and improved idle behavior.</div></article><article class='timeline-card'><div class='version-head'><div><div class='version-title'>v0.0.0.2</div><div class='version-meta'>Hardware support</div></div></div><ul class='change-list'><li><span class='tag tag-feature'>Feature</span>Initial support for JC3248W535C hardware.</li></ul><div class='release-summary'>Focus: bringing the firmware to the JC3248W535C platform.</div></article><article class='timeline-card'><div class='version-head'><div><div class='version-title'>v0.0.0.1</div><div class='version-meta'>Project start</div></div></div><ul class='change-list'><li><span class='tag tag-added'>Added</span>Initial release.</li></ul><div class='release-summary'>The first public baseline for the project.</div></article></div></div></section>)CHANGELOGHTML";
  page += "</main></div></body></html>";
  server.send(200, "text/html", page);
}

void handleRoot() {
  String ip = wifiOk ? WiFi.localIP().toString() : (apModeActive ? WiFi.softAPIP().toString() : "not connected");
  String backend = cfg.backend;
  if (backend != "WPSD" && backend != "Pi-Star") {
    backend = "Pi-Star";
  }

  String accentHex = cfg.themeColor.length() ? cfg.themeColor : String("#35C2FF");
  String startupMode = apModeActive ? String("Setup AP") : (wifiOk ? String("Wi-Fi station") : String("Offline"));
  String startupAccess = apModeActive ? ("http://" + WiFi.softAPIP().toString() + "/") : (wifiOk ? ("http://" + WiFi.localIP().toString() + "/") : String("Waiting for network"));

  String page;
  page.reserve(25000);

  page += "<!doctype html><html><head><meta charset='utf-8'>";
  page += "<meta name='viewport' content='width=device-width,initial-scale=1'>";
  page += "<title>MMDVM Display</title>";
  page += "<style>";
  page += ":root{--bg:#0b1220;--bg2:#111827;--panel:rgba(15,23,42,.82);--panel-solid:#111827;--line:rgba(148,163,184,.20);--line2:rgba(148,163,184,.12);--text:#e5eefc;--muted:#94a3b8;--side:rgba(7,13,24,.76);--accent:" + accentHex + ";--accent-soft:rgba(53,194,255,.16);--danger:#f87171;--success:#34d399;--shadow:0 18px 45px rgba(2,6,23,.34)}";
  page += "*{box-sizing:border-box}";
  page += "html{scroll-behavior:smooth}";
  page += "body{margin:0;font-family:Inter,Segoe UI,Arial,Helvetica,sans-serif;background:var(--bg);background-attachment:fixed;color:var(--text)}";
  page += "body:before{content:'';position:fixed;inset:0;background:linear-gradient(135deg,rgba(255,255,255,.03),transparent 45%,rgba(255,255,255,.015));pointer-events:none}";
  page += ".topbar{position:sticky;top:0;z-index:40;backdrop-filter:blur(14px);background:rgba(11,18,32,.72);border-bottom:1px solid var(--line);padding:18px 24px;display:flex;justify-content:space-between;align-items:center;gap:16px;flex-wrap:wrap}";
  page += ".brand{font-size:28px;font-weight:700;letter-spacing:-.03em;color:#fff}";
  page += ".brand-sub{font-size:13px;color:var(--muted);margin-top:4px}";
  page += ".topbar-right{display:flex;align-items:center;gap:12px;flex-wrap:wrap;justify-content:flex-end}";
  page += ".header-status{display:flex;flex-direction:column;gap:4px;padding:10px 14px;border-radius:16px;border:1px solid var(--line);background:rgba(148,163,184,.08);min-width:190px}";
  page += ".header-status-label{font-size:10px;text-transform:uppercase;letter-spacing:.14em;color:var(--muted);font-weight:800}";
  page += ".header-status-value{font-size:13px;font-weight:800;color:#fff}";
  page += ".header-actions{display:flex;gap:10px;flex-wrap:wrap;justify-content:flex-end}";
  page += ".inline-form{margin:0}";
  page += ".top-chip{display:inline-flex;align-items:center;gap:10px;padding:10px 14px;border-radius:999px;border:1px solid var(--line);background:rgba(148,163,184,.08);color:#fff;font-size:13px;font-weight:700;box-shadow:var(--shadow)}";
  page += ".chip-dot{width:10px;height:10px;border-radius:999px;background:var(--accent);box-shadow:0 0 0 5px rgba(53,194,255,.14)}";
  page += ".layout{display:flex;min-height:calc(100vh - 78px)}";
  page += ".sidebar{width:248px;background:var(--side);border-right:1px solid var(--line);padding:24px 16px;position:sticky;top:78px;height:calc(100vh - 78px);overflow:auto;backdrop-filter:blur(16px)}";
  page += ".side-title{font-size:11px;text-transform:uppercase;letter-spacing:.16em;color:var(--muted);margin:0 0 12px 10px;font-weight:800}";
  page += ".side-link{display:block;text-decoration:none;color:#cbd5e1;padding:12px 14px;border-radius:14px;margin-bottom:8px;border:1px solid transparent;font-size:14px;font-weight:700;transition:all .18s ease}";
  page += ".side-link:hover{background:rgba(255,255,255,.04);border-color:var(--line);transform:translateX(2px)}";
  page += ".side-link.active{background:linear-gradient(135deg,var(--accent-soft),rgba(255,255,255,.02));border-color:rgba(53,194,255,.28);color:#fff;box-shadow:inset 0 0 0 1px rgba(255,255,255,.04)}";
  page += ".content{flex:1;min-width:0;padding:28px}";
  page += ".breadcrumb{font-size:13px;color:var(--muted);margin-bottom:14px}";
  page += ".breadcrumb a{text-decoration:none;color:var(--text)}";
  page += ".page-head{display:flex;justify-content:space-between;align-items:flex-start;gap:18px;margin-bottom:20px}";
  page += ".page-head h1{margin:0;font-size:34px;font-weight:800;letter-spacing:-.04em;color:#fff}";
  page += ".page-sub{margin-top:8px;color:var(--muted);font-size:15px;max-width:720px}";
  page += ".status-box,.startup-card,.section{background:var(--panel);border:1px solid var(--line);border-radius:22px;box-shadow:var(--shadow);backdrop-filter:blur(18px)}";
  page += ".startup-card{margin-bottom:18px;padding:18px 20px}";
  page += ".startup-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:14px;margin-top:14px}";
  page += ".metric{padding:14px 16px;border-radius:16px;background:rgba(255,255,255,.03);border:1px solid var(--line2)}";
  page += ".metric-label{font-size:12px;text-transform:uppercase;letter-spacing:.12em;color:var(--muted);font-weight:800;margin-bottom:8px}";
  page += ".metric-value{font-size:16px;font-weight:700;color:#fff;word-break:break-word}";
  page += ".status-box{padding:14px 16px;min-width:260px}";
  page += ".status-row{display:flex;justify-content:space-between;gap:12px;padding:6px 0;font-size:13px}";
  page += ".status-key{color:var(--muted);font-weight:700}";
  page += ".status-val{font-weight:700;color:#fff;text-align:right}";
  page += ".section{margin-bottom:18px;overflow:hidden;scroll-margin-top:18px;transition:transform .18s ease,box-shadow .18s ease,border-color .18s ease}";
  page += ".section:hover{transform:translateY(-2px);border-color:rgba(255,255,255,.18)}";
  page += ".section-head{padding:16px 20px;background:linear-gradient(180deg,rgba(255,255,255,.035),rgba(255,255,255,.01));border-bottom:1px solid var(--line2)}";
  page += ".section-head h2{margin:0;font-size:19px;font-weight:800;color:#fff}";
  page += ".section-head p{margin:6px 0 0 0;font-size:13px;color:var(--muted)}";
  page += ".section-body{padding:20px}";
  page += ".form-grid{display:grid;grid-template-columns:1fr 1fr;gap:16px 18px}";
  page += ".form-row{display:flex;flex-direction:column}";
  page += ".form-row label{font-size:13px;font-weight:800;color:#dbe5f5;margin-bottom:8px}";
  page += "input,select{width:100%;padding:13px 14px;border:1px solid rgba(148,163,184,.26);border-radius:14px;background:rgba(15,23,42,.80);color:#fff;font-size:14px;transition:border-color .18s ease,box-shadow .18s ease,background .18s ease}";
  page += "input::placeholder{color:#64748b}";
  page += "input[type='checkbox']{width:auto;transform:scale(1.12);margin-right:10px;padding:0;border:none;accent-color:var(--accent)}";
  page += "input:focus,select:focus{outline:none;border-color:var(--accent);box-shadow:0 0 0 4px rgba(53,194,255,.14);background:rgba(15,23,42,.95)}";
  page += ".toggle-row{display:flex;align-items:center;min-height:48px;padding:0 2px;color:#e2e8f0;font-weight:700}";
  page += ".color-inline{display:flex;gap:10px;align-items:center}";
  page += ".color-picker{width:58px;min-width:58px;height:46px;padding:4px;border-radius:14px;border:1px solid rgba(148,163,184,.26);background:rgba(15,23,42,.8)}";
  page += ".color-text{flex:1}";
  page += ".theme-grid{display:grid;grid-template-columns:1fr 1fr;gap:16px 18px}";
  page += ".footer-actions{display:flex;justify-content:flex-end;gap:12px;padding-top:8px}";
  page += ".btn{display:inline-flex;align-items:center;justify-content:center;padding:13px 20px;border-radius:14px;border:1px solid transparent;background:linear-gradient(135deg,var(--accent),rgba(255,255,255,.18));color:#08111d;font-weight:800;text-decoration:none;cursor:pointer;box-shadow:0 14px 30px rgba(53,194,255,.20);transition:transform .16s ease,filter .16s ease,box-shadow .16s ease}";
  page += ".btn:hover{transform:translateY(-1px);filter:brightness(1.04);box-shadow:0 18px 34px rgba(53,194,255,.24)}";
  page += ".btn-secondary{background:rgba(255,255,255,.03);color:#fff;border-color:var(--line);box-shadow:none}";
  page += ".btn-compact{padding:10px 14px;font-size:13px;min-height:42px}";
  page += ".mini-note{font-size:12px;color:var(--muted);margin-top:12px;line-height:1.55}";
  page += ".quick-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14px}";
  page += ".quick-card{padding:16px;border-radius:18px;background:rgba(255,255,255,.03);border:1px solid var(--line2)}";
  page += ".quick-card h3{margin:0 0 10px 0;font-size:15px;color:#fff}";
  page += ".quick-run{display:flex;gap:10px;align-items:center;justify-content:space-between;margin-top:12px;flex-wrap:wrap}";
  page += ".hero-card{display:flex;justify-content:space-between;gap:18px;align-items:center;background:linear-gradient(135deg,rgba(15,23,42,.94),rgba(17,24,39,.82));border:1px solid rgba(148,163,184,.18);border-radius:26px;padding:24px 26px;margin:0 0 18px 0;box-shadow:var(--shadow);position:relative;overflow:hidden}";
  page += ".hero-card:after{content:'';position:absolute;right:-40px;top:-40px;width:180px;height:180px;background:radial-gradient(circle,var(--accent-soft),transparent 70%)}";
  page += ".hero-card h3{position:relative;margin:6px 0 8px 0;font-size:28px;font-weight:800;letter-spacing:-.04em;color:#fff}";
  page += ".hero-card p{position:relative;margin:0;color:#c8d4ea;max-width:640px;line-height:1.6}";
  page += ".hero-eyebrow{position:relative;font-size:11px;letter-spacing:.18em;text-transform:uppercase;color:#7dd3fc;font-weight:900}";
  page += ".hero-pill{position:relative;padding:14px 18px;border-radius:999px;background:rgba(255,255,255,.05);border:1px solid rgba(148,163,184,.18);color:#fff;font-weight:800;white-space:nowrap}";
  page += ".theme-preview{display:flex;align-items:center;gap:12px;margin-top:14px;padding:14px 16px;border-radius:16px;background:rgba(255,255,255,.03);border:1px solid var(--line2)}";
  page += ".theme-swatch{width:42px;height:42px;border-radius:14px;background:var(--accent);box-shadow:0 0 0 6px rgba(53,194,255,.10)}";
  page += ".theme-preview strong{display:block;color:#fff;margin-bottom:2px}";
  page += ".alert-card{margin:0 0 18px 0;padding:16px 18px;border-radius:18px;border:1px solid rgba(248,113,113,.34);background:rgba(127,29,29,.18);color:#fecaca;box-shadow:0 8px 22px rgba(127,29,29,.14)}";
  page += ".alert-card p{color:#fecaca}";
  page += "@media(max-width:1080px){.layout{display:block}.sidebar{width:auto;height:auto;position:relative;top:0;border-right:none;border-bottom:1px solid var(--line)}.content{padding:18px}.page-head{flex-direction:column}.form-grid,.theme-grid,.startup-grid{grid-template-columns:1fr}.hero-card{flex-direction:column;align-items:flex-start}.hero-pill{white-space:normal}.topbar{position:relative}.topbar-right{width:100%;justify-content:flex-start}.header-actions{justify-content:flex-start}}"; 
  page += "</style></head><body>";

  page += "<div class='topbar'><div><div class='brand'>MMDVM Display</div><div class='brand-sub'>Version "+ String(DISPLAY_VERSION) +" by 13MAD86 / Martin</div></div>";
  page += "<div class='topbar-right'>";
  page += "<div class='header-actions'>";
  page += "<a class='btn btn-secondary btn-compact' href='/export'>&#128228; Export config</a>";
  page += "<form class='inline-form' method='POST' action='/import' enctype='multipart/form-data'><input type='file' id='import_config_file' name='config_file' accept='.json,application/json' style='display:none' onchange='this.form.submit()'><label class='btn btn-secondary btn-compact' for='import_config_file'>&#128229; Import config</label></form>";
  page += "</div></div></div>";

  page += "<div class='layout'>";

  page += buildSidebarHtml(true, "");

  page += "<main class='content'>";
  page += "<div class='page-head'>";
  page += "<div>";
  page += "<h1>&#128261; Configuration</h1>";
  page += "<div class='page-sub'>To set up an MMDVM display.</div>";
  page += "</div>";

  page += "<div class='status-box'>";
  page += "<div class='status-row'><span class='status-key'>Device IP</span><span class='status-val'>" + htmlEscape(ip) + "</span></div>";
  page += "<div class='status-row'><span class='status-key'>Backend</span><span class='status-val'>" + htmlEscape(backend) + "</span></div>";
  page += "<div class='status-row'><span class='status-key'>Hotspot</span><span class='status-val'>" + htmlEscape(cfg.pistarHost) + "</span></div>";
  page += "<div class='status-row'><span class='status-key'>Callsign</span><span class='status-val'>" + htmlEscape(getDeviceCallsign()) + "</span></div>";
  page += "</div>";
  page += "</div>";

  page += "<div class='hero-card'><div><div class='hero-eyebrow'>Personalization</div><h3>Make the display yours</h3><p>Set a custom device callsign for the boot splash, about page and status panels. Leave it empty to keep the default callsign. The admin UI now also picks up your configured theme color as its accent.</p></div><div class='hero-pill'>Active callsign: " + htmlEscape(getDeviceCallsign()) + "</div></div>";

  if (setupRequired) {
    page += "<div class='alert-card'><strong>Setup required.</strong><p>";
    page += (configOkState ? "Wi-Fi could not connect with the saved settings." : "No valid config was found yet.");
    page += "</p><p>Please complete the setup on this page and save it before normal operation starts.</p></div>";
  }

  page += "<form id='config-form' method='POST' action='/save'>";

  page += "<section class='section' id='system'>";
  page += "<div class='section-head'><h2>&#128223; Hotspot</h2><p>MMDVM backend and Host IP settings.</p></div>";
  page += "<div class='section-body'><div class='form-grid'>";
  page += selectRow("Backend", "backend", backend);
  page += formRow("Pi-Star / WPSD Host or IP", "pistar_host", cfg.pistarHost, "text", "192.168.1.50");
  page += formRow("Device callsign", "device_callsign", cfg.deviceCallsign, "text", DEVICE_CALLSIGN);
  page += checkboxRow("Enable SSH remote control (touch + quick actions)", "touch_mode_ssh_enabled", cfg.touchModeSshEnabled);
  page += formRow("SSH user", "pistar_ssh_user", cfg.pistarSshUser, "text", "pi-star");
  page += formRow("SSH password", "pistar_ssh_pass", cfg.pistarSshPass, "password");
  page += formRow("SSH port", "pistar_ssh_port", String(cfg.pistarSshPort), "number");
  page += "</div>";
  page += "<div class='mini-note'>Select which backend the display should talk to. The host can be a local IP or hostname. Set a custom device callsign here or leave it blank to use the built-in default. Touch SSH mode control stores the Pi-Star SSH password locally in the device config, so only enable it on trusted hardware.</div>";
  page += "</div></section>";

  page += "<section class='section' id='quick-actions'>";
  page += "<div class='section-head'><h2>&#9889; Quick actions</h2><p>Save up to four preset SSH commands for talkgroup, reflector or disconnect actions.</p></div>";
  page += "<div class='section-body'>";
  page += "<div class='quick-grid'>";
  for (uint8_t i = 0; i < QUICK_ACTION_SLOT_COUNT; i++) {
    String n = String(i + 1);
    String prefix = String("quick_action_") + n + String("_");
    page += "<div class='quick-card'>";
    page += "<h3>Preset " + n + "</h3>";
    page += checkboxRow("Enable preset", prefix + "enabled", cfg.quickActions[i].enabled);
    page += checkboxRow("Prefix command with sudo", prefix + "use_sudo", cfg.quickActions[i].useSudo);
    page += formRow("Button label", prefix + "label", cfg.quickActions[i].label, "text", i == 0 ? "DMR TG91" : "REF001C");
    page += formRow("Remote shell command", prefix + "command", cfg.quickActions[i].command, "text", "pistar-link ... or your script");
    page += "<div class='quick-run'>";
    if (isQuickActionSlotConfigured(i)) {
      page += "<span class='mini-note' style='margin:0'>Ready: " + htmlEscape(quickActionSlotLabel(i)) + "</span>";
      page += "<a class='btn btn-secondary btn-compact' href='/quick-action?slot=" + n + "'>&#9654; Run now</a>";
    } else {
      page += "<span class='mini-note' style='margin:0'>Fill in a label and command, then save the config to enable this preset.</span>";
    }
    page += "</div></div>";
  }
  page += "</div>";
  page += "<div class='mini-note'>Each preset runs the exact remote shell command you enter over SSH. This keeps the feature backend-agnostic: you can use it for Pi-Star or WPSD commands, talkgroup helpers, reflector scripts or disconnect commands. On the touchscreen, the live page now has an <span class='mono'>ACTIONS</span> button that opens these presets.</div>";
  page += "</div></section>";

  page += "<section class='section' id='network'>";
  page += "<div class='section-head'><h2>&#128655; Network</h2><p>Polling and connection related settings.</p></div>";
  page += "<div class='section-body'><div class='form-grid'>";
  page += formRow("Refresh interval (ms)", "refresh_ms", String(cfg.refreshMs), "number");
  page += formRow("Callsign hold (ms)", "callsign_hold_ms", String(cfg.callsignHoldMs), "number");
  page += checkboxRow("Convert API UTC time to local device time", "api_utc_to_local_time", cfg.apiUtcToLocalTime);
  page += formRow("Time zone rule (POSIX TZ)", "timezone_tz", cfg.timezoneTz, "text", DEFAULT_TZ_RULE);
  page += "</div><div class='mini-note'>When enabled, last heard entries such as time_utc are converted from UTC to the device local time zone used by the header clock. The time zone rule below is also used by the header clock itself. Example for Germany: <span class='mono'>CET-1CEST,M3.5.0/2,M10.5.0/3</span>.</div></div></section>";

  page += "<section class='section' id='wifi'>";
  page += "<div class='section-head'><h2>&#128225; Wifi</h2><p>Wireless access credentials for the device.</p></div>";
  page += "<div class='section-body'><div class='form-grid'>";
  page += formRow("WiFi SSID", "wifi_ssid", cfg.wifiSsid);
  page += formRow("WiFi Password", "wifi_pass", cfg.wifiPass, "password");
  page += "</div></div></section>";

  page += "<section class='section' id='display'>";
  page += "<div class='section-head'><h2>&#128250; Display</h2><p>Local display behavior, intensity, orientation and idle mode.</p></div>";
  page += "<div class='section-body'><div class='form-grid'>";
  page += formRow("Brightness (0-255)", "brightness", String(cfg.brightness), "number");
  page += formRow("Theme base color", "theme_color", cfg.themeColor);
  page += checkboxRow("Flip screen 180°", "flip_screen", cfg.flipScreen);
  page += checkboxRow("Enable screensaver", "screensaver_enabled", cfg.screensaverEnabled);
  page += formRow("Screensaver timeout (ms)", "screensaver_timeout_ms", String(cfg.screensaverTimeoutMs), "number");
  page += formRow("Screensaver brightness (0-255)", "screensaver_dim_brightness", String(cfg.screensaverDimBrightness), "number");
  page += "</div><div class='mini-note'>When enabled, the screensaver dims the backlight after inactivity. Any touch wakes the display.</div></div></section>";

  page += "<section class='section' id='weather'>";
  page += "<div class='section-head'><h2>&#9925; Weather</h2><p>Optional OpenWeather block for the screensaver.</p></div>";
  page += "<div class='section-body'><div class='form-grid'>";
  page += checkboxRow("Enable weather on screensaver", "weather_enabled", cfg.weatherEnabled);
  page += formRow("Refresh time (minutes)", "weather_refresh_minutes", String(cfg.weatherRefreshMinutes), "number");
  page += formRow("Latitude", "weather_lat", cfg.weatherLat, "text", "50.11552");
  page += formRow("Longitude", "weather_lon", cfg.weatherLon, "text", "8.68417");
  page += formRow("OpenWeather API key", "weather_api_key", cfg.weatherApiKey, "password");
  page += "</div><div class='mini-note'>When enabled, the device fetches current weather from OpenWeather using your latitude, longitude and API key.</div></div></section>";

  page += "<section class='section' id='lookup'>";
  page += "<div class='section-head'><h2>&#128218; Lookup</h2><p>Optional online callsign fallback with a configurable users API source.</p></div>";
  page += "<div class='section-body'><div class='form-grid'>";
  page += checkboxRow("Enable RadioID fallback", "radioid_lookup_enabled", cfg.radioIdLookupEnabled);
  page += formRow("Users API base URL", "radioid_users_api_base", cfg.radioIdUsersApiBase, "text", RADIOID_USERS_API_BASE_DEFAULT);
  page += "</div><div class='mini-note'>The callsign is appended automatically. For other APIs you can also use <span class='mono'>%CALLSIGN%</span> anywhere in the URL and it will be replaced with the encoded callsign.</div>";
  page += "</div></section>";

  page += "<section class='section' id='theme'>";
  page += "<div class='section-head'><h2>&#127912; Theme</h2><p>UI colors for panels, labels and state indicators.</p></div>";
  page += "<div class='section-body'>";
  page += "<div class='theme-grid'>";
  page += colorRow("Background", "bg", color565ToHex(theme.bg));
  page += colorRow("Panel", "panel", color565ToHex(theme.panel));
  page += colorRow("Panel 2", "panel2", color565ToHex(theme.panel2));
  page += colorRow("Border", "border", color565ToHex(theme.border));
  page += colorRow("Text", "text", color565ToHex(theme.text));
  page += colorRow("Subtext", "subtext", color565ToHex(theme.subtext));
  page += colorRow("Accent", "accent", color565ToHex(theme.accent));
  page += colorRow("Good", "good", color565ToHex(theme.good));
  page += colorRow("Warn", "warn", color565ToHex(theme.warn));
  page += colorRow("Bad", "bad", color565ToHex(theme.bad));
  page += colorRow("Live", "live", color565ToHex(theme.live));
  page += colorRow("RX", "rx", color565ToHex(theme.rx));
  page += colorRow("TX", "tx", color565ToHex(theme.tx));
  page += colorRow("Tab idle", "tabIdle", color565ToHex(theme.tabIdle));
  page += colorRow("Tab active", "tabActive", color565ToHex(theme.tabActive));
  page += "</div></div>";
  page += "</section>";

  page += "<div class='footer-actions' style='margin-top:18px'>";
  page += "<button class='btn btn-secondary' type='submit' form='config-form'>&#128190; Save configuration</button>";
  page += "</div>";

  page += "</form>";

  page += "</main></div>";
  page += "</body></html>";

  server.send(200, "text/html", page);
}

void handleSave() {
  DynamicJsonDocument doc(10240);

  cfg.backend = server.arg("backend");
  if (cfg.backend != "WPSD" && cfg.backend != "Pi-Star") {
    cfg.backend = "Pi-Star";
  }
  cfg.wifiSsid = server.arg("wifi_ssid");
  cfg.wifiPass = server.arg("wifi_pass");
  cfg.pistarHost = server.arg("pistar_host");
  cfg.deviceCallsign = trimSafe(server.arg("device_callsign"));
  cfg.themeColor = server.arg("theme_color");
  cfg.refreshMs = server.arg("refresh_ms").toInt();
  cfg.callsignHoldMs = server.arg("callsign_hold_ms").toInt();
  cfg.apiUtcToLocalTime = server.hasArg("api_utc_to_local_time");
  cfg.timezoneTz = trimSafe(server.arg("timezone_tz"));
  if (!cfg.timezoneTz.length()) cfg.timezoneTz = String(DEFAULT_TZ_RULE);
  cfg.brightness = constrain(server.arg("brightness").toInt(), 0, 255);
  cfg.flipScreen = server.hasArg("flip_screen");
  cfg.screensaverEnabled = server.hasArg("screensaver_enabled");
  cfg.screensaverTimeoutMs = max(5000L, server.arg("screensaver_timeout_ms").toInt());
  cfg.screensaverDimBrightness = constrain(server.arg("screensaver_dim_brightness").toInt(), 0, 255);
  cfg.weatherEnabled = server.hasArg("weather_enabled");
  cfg.weatherRefreshMinutes = constrain((uint16_t)server.arg("weather_refresh_minutes").toInt(), (uint16_t)1, (uint16_t)1440);
  cfg.weatherLat = trimSafe(server.arg("weather_lat"));
  cfg.weatherLon = trimSafe(server.arg("weather_lon"));
  cfg.weatherApiKey = trimSafe(server.arg("weather_api_key"));
  cfg.radioIdLookupEnabled = server.hasArg("radioid_lookup_enabled");
  cfg.radioIdUsersApiBase = trimSafe(server.arg("radioid_users_api_base"));
  cfg.touchModeSshEnabled = server.hasArg("touch_mode_ssh_enabled");
  cfg.pistarSshUser = trimSafe(server.arg("pistar_ssh_user"));
  if (!cfg.pistarSshUser.length()) cfg.pistarSshUser = "pi-star";
  cfg.pistarSshPass = server.arg("pistar_ssh_pass");
  int sshPortValue = server.arg("pistar_ssh_port").toInt();
  cfg.pistarSshPort = (sshPortValue <= 0) ? 22 : constrain(sshPortValue, 1, 65535);
  for (uint8_t i = 0; i < QUICK_ACTION_SLOT_COUNT; i++) {
    String prefix = String("quick_action_") + String(i + 1) + String("_");
    cfg.quickActions[i].enabled = server.hasArg(prefix + "enabled");
    cfg.quickActions[i].useSudo = server.hasArg(prefix + "use_sudo");
    cfg.quickActions[i].label = trimSafe(server.arg(prefix + "label"));
    cfg.quickActions[i].command = trimSafe(server.arg(prefix + "command"));
    if (!cfg.quickActions[i].label.length() || !cfg.quickActions[i].command.length()) {
      cfg.quickActions[i].enabled = false;
    }
  }
  if (!cfg.radioIdUsersApiBase.length()) cfg.radioIdUsersApiBase = String(RADIOID_USERS_API_BASE_DEFAULT);

  doc["backend"] = cfg.backend;
  doc["wifi_ssid"] = cfg.wifiSsid;
  doc["wifi_pass"] = cfg.wifiPass;
  doc["pistar_host"] = cfg.pistarHost;
  doc["device_callsign"] = cfg.deviceCallsign;
  doc["theme_color"] = cfg.themeColor;
  doc["refresh_ms"] = cfg.refreshMs;
  doc["callsign_hold_ms"] = cfg.callsignHoldMs;
  doc["api_utc_to_local_time"] = cfg.apiUtcToLocalTime;
  doc["timezone_tz"] = cfg.timezoneTz;
  doc["brightness"] = cfg.brightness;
  doc["flip_screen"] = cfg.flipScreen;
  doc["screensaver_enabled"] = cfg.screensaverEnabled;
  doc["screensaver_timeout_ms"] = cfg.screensaverTimeoutMs;
  doc["screensaver_dim_brightness"] = cfg.screensaverDimBrightness;
  doc["weather_enabled"] = cfg.weatherEnabled;
  doc["weather_refresh_minutes"] = cfg.weatherRefreshMinutes;
  doc["weather_lat"] = cfg.weatherLat;
  doc["weather_lon"] = cfg.weatherLon;
  doc["weather_api_key"] = cfg.weatherApiKey;
  doc["radioid_lookup_enabled"] = cfg.radioIdLookupEnabled;
  doc["radioid_users_api_base"] = cfg.radioIdUsersApiBase;
  doc["touch_mode_ssh_enabled"] = cfg.touchModeSshEnabled;
  doc["pistar_ssh_user"] = cfg.pistarSshUser;
  doc["pistar_ssh_pass"] = cfg.pistarSshPass;
  doc["pistar_ssh_port"] = cfg.pistarSshPort;
  JsonArray qa = doc.createNestedArray("quick_actions");
  for (uint8_t i = 0; i < QUICK_ACTION_SLOT_COUNT; i++) {
    JsonObject item = qa.createNestedObject();
    item["enabled"] = cfg.quickActions[i].enabled;
    item["use_sudo"] = cfg.quickActions[i].useSudo;
    item["label"] = cfg.quickActions[i].label;
    item["command"] = cfg.quickActions[i].command;
  }

  JsonObject th = doc.createNestedObject("theme");
  th["bg"] = server.arg("bg");
  th["panel"] = server.arg("panel");
  th["panel2"] = server.arg("panel2");
  th["border"] = server.arg("border");
  th["text"] = server.arg("text");
  th["subtext"] = server.arg("subtext");
  th["accent"] = server.arg("accent");
  th["good"] = server.arg("good");
  th["warn"] = server.arg("warn");
  th["bad"] = server.arg("bad");
  th["live"] = server.arg("live");
  th["rx"] = server.arg("rx");
  th["tx"] = server.arg("tx");
  th["tabIdle"] = server.arg("tabIdle");
  th["tabActive"] = server.arg("tabActive");

  storageRemove(CONFIG_FILE);
  File f = storageOpen(CONFIG_FILE, FILE_WRITE);
  if (!f) {
    server.send(500, "text/plain", "Failed to save config");
    return;
  }

  serializeJsonPretty(doc, f);
  f.close();

  addWebLog("INFO", "CONFIG", "Configuration saved from Web UI. Backend " + cfg.backend + ", host " + cfg.pistarHost + ".");
  applyConfigAfterLoad();

  String accentHex = cfg.themeColor.length() ? cfg.themeColor : String("#35C2FF");

  String page;
  page.reserve(7600);
  page += "<!doctype html><html><head><meta charset='utf-8'>";
  page += "<meta name='viewport' content='width=device-width,initial-scale=1'>";
  page += "<title>MMDVM Display</title>";
  page += "<style>";
  page += ":root{--accent:" + accentHex + ";--bg:#0b1220;--panel:rgba(15,23,42,.88);--line:rgba(148,163,184,.18);--text:#e5eefc;--muted:#94a3b8}";
  page += "*{box-sizing:border-box}";
  page += "body{margin:0;font-family:Inter,Segoe UI,Arial,Helvetica,sans-serif;background:#0b1220;color:var(--text)}";
  page += ".topbar{position:sticky;top:0;z-index:40;backdrop-filter:blur(14px);background:rgba(11,18,32,.72);border-bottom:1px solid var(--line);padding:18px 24px;display:flex;justify-content:space-between;align-items:center;gap:16px}";
  page += ".brand{font-size:28px;font-weight:700;letter-spacing:-.03em;color:#fff}";
  page += ".brand-sub{font-size:13px;color:var(--muted);margin-top:4px}";
  page += ".sub{font-size:13px;color:var(--muted);margin-top:4px}";
  page += ".wrap{max-width:820px;margin:34px auto;padding:0 18px}";
  page += ".card{background:var(--panel);border:1px solid var(--line);border-radius:26px;padding:28px;box-shadow:0 24px 46px rgba(2,6,23,.34)}";
  page += ".pill{display:inline-flex;align-items:center;gap:10px;padding:10px 14px;border-radius:999px;background:rgba(255,255,255,.04);border:1px solid var(--line);font-size:13px;font-weight:800}";
  page += ".dot{width:10px;height:10px;border-radius:999px;background:var(--accent);box-shadow:0 0 0 5px rgba(53,194,255,.12)}";
  page += "h2{margin:16px 0 10px 0;font-size:34px;font-weight:800;letter-spacing:-.04em;color:#fff}";
  page += "p{color:#cbd5e1;font-size:15px;line-height:1.65}";
  page += ".btn{display:inline-flex;align-items:center;justify-content:center;padding:13px 20px;border-radius:14px;border:1px solid transparent;background:linear-gradient(135deg,var(--accent),rgba(255,255,255,.18));color:#08111d;font-weight:800;text-decoration:none;cursor:pointer;box-shadow:0 14px 30px rgba(53,194,255,.20);transition:transform .16s ease,filter .16s ease,box-shadow .16s ease}";
  page += ".btn:hover{transform:translateY(-1px);filter:brightness(1.04);box-shadow:0 18px 34px rgba(53,194,255,.24)}";
  page += ".btn-secondary{background:rgba(255,255,255,.03);color:#fff;border-color:var(--line);box-shadow:none}";
  page += ".mono{font-family:monospace;background:rgba(255,255,255,.05);padding:2px 8px;border-radius:7px;color:#fff}";
  page += "</style></head><body>";
  page += "<div class='topbar'><div><div class='brand'>MMDVM Display</div><div class='brand-sub'>Version "+ String(DISPLAY_VERSION) +" by 13MAD86 / Martin</div></div></div>";
  page += "<div class='wrap'><div class='card'>";
  page += "<h2>&#128190; Configuration saved</h2>";
  page += "<p>The settings were saved to the device. Current last-heard entries were refreshed with the new online lookup setting, and the network is now restarting, so the current AP page may disconnect for a moment.</p>";
  page += "<p>After a few seconds, reopen the device on its new address from your router or use the setup AP again if Wi-Fi did not connect.</p>";
  page += "<p><a class='btn btn-secondary' href='/' onclick='setTimeout(function(){location.href=\"/\";},1200);return true;'>Back to configuration</a></p>";
  page += "</div></div>";
  page += "</body></html>";
  server.send(200, "text/html", page);
}

void sendWebMessagePage(const String& title, const String& lead, const String& detail, bool isError) {
  String accentHex = cfg.themeColor.length() ? cfg.themeColor : String("#35C2FF");

  String page;
  page.reserve(7600);
  page += "<!doctype html><html><head><meta charset='utf-8'>";
  page += "<meta name='viewport' content='width=device-width,initial-scale=1'>";
  page += "<title>MMDVM Display</title>";
  page += "<style>";
  page += ":root{--accent:" + accentHex + ";--bg:#0b1220;--panel:rgba(15,23,42,.88);--line:rgba(148,163,184,.18);--text:#e5eefc;--muted:#94a3b8;--danger:#f87171}";
  page += "*{box-sizing:border-box}";
  page += "body{margin:0;font-family:Inter,Segoe UI,Arial,Helvetica,sans-serif;background:#0b1220;color:var(--text)}";
  page += ".topbar{position:sticky;top:0;z-index:40;backdrop-filter:blur(14px);background:rgba(11,18,32,.72);border-bottom:1px solid var(--line);padding:18px 24px;display:flex;justify-content:space-between;align-items:center;gap:16px}";
  page += ".brand{font-size:28px;font-weight:700;letter-spacing:-.03em;color:#fff}";
  page += ".brand-sub{font-size:13px;color:var(--muted);margin-top:4px}";
  page += ".wrap{max-width:820px;margin:34px auto;padding:0 18px}";
  page += ".card{background:var(--panel);border:1px solid " + String(isError ? "rgba(248,113,113,.30)" : "var(--line)") + ";border-radius:26px;padding:28px;box-shadow:0 24px 46px rgba(2,6,23,.34)}";
  page += "h2{margin:0 0 12px 0;font-size:34px;font-weight:800;letter-spacing:-.04em;color:#fff}";
  page += "p{color:#cbd5e1;font-size:15px;line-height:1.65}";
  page += ".btn{display:inline-flex;align-items:center;justify-content:center;padding:13px 20px;border-radius:14px;border:1px solid rgba(148,163,184,.18);background:rgba(255,255,255,.03);color:#fff;font-weight:800;text-decoration:none;cursor:pointer}";
  page += ".btn:hover{transform:translateY(-1px)}";
  page += "</style></head><body>";
  page += "<div class='topbar'><div><div class='brand'>MMDVM Display</div><div class='brand-sub'>Version "+ String(DISPLAY_VERSION) +" by 13MAD86 / Martin</div></div></div>";
  page += "<div class='wrap'><div class='card'>";
  page += "<h2>&#127756; " + htmlEscape(title) + "</h2>";
  page += "<p>" + htmlEscape(lead) + "</p>";
  if (detail.length()) {
    page += "<p>" + htmlEscape(detail) + "</p>";
  }
  page += "<p><a class='btn' href='/'>Back to configuration</a></p>";
  page += "</div></div>";
  page += "</body></html>";
  server.send(isError ? 400 : 200, "text/html", page);
}

void handleRunQuickAction() {
  int slot = server.arg("slot").toInt() - 1;
  if (slot < 0 || slot >= QUICK_ACTION_SLOT_COUNT) {
    sendWebMessagePage("Quick action failed", "The selected preset slot is invalid.", "Open the configuration page and try again.", true);
    return;
  }

  String msg;
  bool ok = runQuickActionSlotNow((uint8_t)slot, &msg);
  addWebLog(ok ? "INFO" : "WARN", "ACTION", msg.length() ? msg : (ok ? String("Quick action completed.") : String("Quick action failed.")));
  sendWebMessagePage(ok ? String("Quick action sent") : String("Quick action failed"),
                     msg.length() ? msg : (ok ? String("The remote preset was executed.") : String("The remote preset could not be executed.")),
                     ok ? String("The live page will refresh again on its next poll.") : String("Check the SSH settings, command text and hotspot host configuration."),
                     !ok);
}

void handleExportConfig() {
  if (!saveConfigToFile()) {
    addWebLog("ERROR", "CONFIG", "Config export failed: unable to prepare export file.");
    server.send(500, "text/plain", "Failed to prepare config export");
    return;
  }

  File f = storageOpen(CONFIG_FILE, FILE_READ);
  if (!f) {
    addWebLog("ERROR", "CONFIG", "Config export failed: export file not found.");
    server.send(404, "text/plain", "Config file not found");
    return;
  }

  server.sendHeader("Cache-Control", "no-store");
  server.sendHeader("Content-Disposition", "attachment; filename=\"mmdvm-display-config.json\"");
  server.streamFile(f, "application/json");
  f.close();
  addWebLog("INFO", "CONFIG", "Configuration exported from Web UI.");
}

void handleImportConfigUpload() {
  HTTPUpload& upload = server.upload();
  static File uploadFile;

  if (upload.status == UPLOAD_FILE_START) {
    storageRemove(IMPORT_CONFIG_TEMP_FILE);
    uploadFile = storageOpen(IMPORT_CONFIG_TEMP_FILE, FILE_WRITE);
    if (!uploadFile) {
      if (uploadFile) uploadFile.close();
    }
  } else if (upload.status == UPLOAD_FILE_WRITE) {
    if (uploadFile) {
      if (uploadFile.write(upload.buf, upload.currentSize) != upload.currentSize) {
        uploadFile.close();
        storageRemove(IMPORT_CONFIG_TEMP_FILE);
      }
    }
  } else if (upload.status == UPLOAD_FILE_END) {
    if (uploadFile) uploadFile.close();
  } else if (upload.status == UPLOAD_FILE_ABORTED) {
    if (uploadFile) uploadFile.close();
    storageRemove(IMPORT_CONFIG_TEMP_FILE);
  }
}

void handleImportConfig() {
  if (!storageExists(IMPORT_CONFIG_TEMP_FILE)) {
    addWebLog("WARN", "CONFIG", "Config import failed: no file uploaded.");
    sendWebMessagePage("Config import failed", "No config file was uploaded.", "Choose an exported JSON config and try again.", true);
    return;
  }

  String err;
  bool ok = applyConfigFromFile(IMPORT_CONFIG_TEMP_FILE, &err);
  storageRemove(IMPORT_CONFIG_TEMP_FILE);

  if (!ok) {
    addWebLog("WARN", "CONFIG", "Config import failed: " + (err.length() ? err : String("unknown error")) + ".");
    sendWebMessagePage("Config import failed", err.length() ? err : String("The uploaded config could not be applied."), "Use a JSON file exported by this device UI.", true);
    return;
  }

  addWebLog("INFO", "CONFIG", "Configuration imported from Web UI.");
  sendWebMessagePage("Config imported", "The uploaded configuration was applied and the device is refreshing its network and runtime settings.", "If Wi-Fi credentials changed, reconnect using the new device address or use the setup AP again if the station connection cannot be established.", false);
}

void setupWebServer() {

  server.on("/", HTTP_GET, handleRoot);
  server.on("/save", HTTP_POST, handleSave);
  server.on("/quick-action", HTTP_GET, handleRunQuickAction);
  server.on("/export", HTTP_GET, handleExportConfig);
  server.on("/import", HTTP_POST, handleImportConfig, handleImportConfigUpload);
  server.on("/app/diagnostics", HTTP_GET, handleDiagnosticsPage);
  server.on("/api/diagnostics", HTTP_GET, handleDiagnosticsApi);
  server.on("/app/logs", HTTP_GET, handleLogsPage);
  server.on("/api/logs", HTTP_GET, handleLogsApi);
  server.on("/api/logs/clear", HTTP_POST, handleLogsClear);
  server.on("/app/ota", HTTP_GET, handleOtaPage);
  server.on("/app/ota", HTTP_POST, handleOtaUpdate, handleOtaUpload);
  server.on("/app/about", HTTP_GET, handleAboutPage);
  server.on("/app/changelog", HTTP_GET, handleChangelogPage);
  server.begin();
  addWebLog("INFO", "WEB", "Web server started on port 80.");
}

void clearHeard() {
  heardCount = 0;
  for (int i = 0; i < MAX_HEARD; i++) heardList[i] = HeardRecord();
}

bool heardListsEqual(const HeardRecord* a, int aCount, const HeardRecord* b, int bCount) {
  if (aCount != bCount) return false;
  for (int i = 0; i < aCount; i++) {
    if (!sameRecord(a[i], b[i])) return false;
  }
  return true;
}

String firstNonEmpty(const String& a, const String& b, const String& c, const String& d) {
  if (trimSafe(a).length()) return trimSafe(a);
  if (trimSafe(b).length()) return trimSafe(b);
  if (trimSafe(c).length()) return trimSafe(c);
  return trimSafe(d);
}

String prettyMetricValue(const String& value, const char* suffix, bool keepPercent) {
  String out = trimSafe(value);
  if (!out.length()) return "-";
  if (out.equalsIgnoreCase("n/a") || out.equalsIgnoreCase("nan") || out.equalsIgnoreCase("null")) return "-";
  if (!keepPercent) out.replace("%", "");
  if (suffix && strlen(suffix) > 0) {
    String suf = String(suffix);
    if (!out.endsWith(suf)) out += suf;
  }
  return out;
}

String lookupCommonTalkgroupName(const String& target) {
  String tg = trimSafe(target);
  if (!tg.length()) return "";
  if (tg == "9") return "Local";
  if (tg == "91") return "World-Wide";
  if (tg == "92") return "Europe";
  if (tg == "93") return "USA";
  if (tg == "204") return "Netherlands";
  if (tg == "208") return "France";
  if (tg == "214") return "Spain";
  if (tg == "222") return "Italy";
  if (tg == "235") return "United Kingdom";
  if (tg == "262") return "Germany";
  if (tg == "263") return "Austria";
  if (tg == "310") return "USA TAC";
  if (tg == "9990") return "Parrot";
  return "";
}

String resolveTargetLabel(const HeardRecord& rec) {
  String mode = normalizeModeName(rec.mode);
  if (mode == "DMR") return "TG";
  if (mode == "DSTAR" || mode == "D-STAR") return "REF";
  if (mode == "YSF" || mode == "FUSION") return "ROOM";
  if (mode == "P25" || mode == "NXDN") return "NET";
  return "DST";
}

String resolveTargetDisplay(const HeardRecord& rec) {
  String label = resolveTargetLabel(rec);
  String rawTarget = trimSafe(rec.target);
  String explicitName = trimSafe(rec.targetName);
  String mode = normalizeModeName(rec.mode);

  if (explicitName.length() && rawTarget.length()) return label + " " + rawTarget + " " + explicitName;
  if (explicitName.length()) return explicitName;
  if (!rawTarget.length()) return "-";

  if (mode == "DMR") {
    String mapped = lookupCommonTalkgroupName(rawTarget);
    if (mapped.length()) return label + " " + rawTarget + " " + mapped;
    return label + " " + rawTarget;
  }

  if (mode == "DSTAR" || mode == "D-STAR") return label + " " + rawTarget;
  if (mode == "YSF" || mode == "FUSION") return label + " " + rawTarget;
  return rawTarget;
}

void updateSessionInfoFromRecord(const HeardRecord& rec) {
  if (!rec.valid) return;
  sessionInfo.totalLiveHits++;
  sessionInfo.totalQsos++;
  String mode = normalizeModeName(rec.mode);
  if (mode == "DMR") sessionInfo.modeDMR++;
  else if (mode == "DSTAR" || mode == "D-STAR") sessionInfo.modeDSTAR++;
  else if (mode == "YSF" || mode == "FUSION") sessionInfo.modeYSF++;
  else if (mode == "P25") sessionInfo.modeP25++;
  else if (mode == "NXDN") sessionInfo.modeNXDN++;
  else if (mode == "POCSAG") sessionInfo.modePOCSAG++;

  String rssiText = trimSafe(rec.rssi);
  if (rssiText.length()) {
    int rssi = rssiText.toInt();
    if (rssi != 0 || rssiText == "0") {
      if (!sessionInfo.hasRssi) {
        sessionInfo.bestRssi = rssi;
        sessionInfo.worstRssi = rssi;
        sessionInfo.hasRssi = true;
      } else {
        if (rssi > sessionInfo.bestRssi) sessionInfo.bestRssi = rssi;
        if (rssi < sessionInfo.worstRssi) sessionInfo.worstRssi = rssi;
      }
    }
  }
}

bool parseHeardArray(const String& payload) {
  DynamicJsonDocument doc(32768);
  auto err = deserializeJson(doc, payload);
  if (err) {
    lastError = String("JSON: ") + err.c_str();
    return false;
  }

  JsonArray arr;
  if (doc.is<JsonArray>()) {
    arr = doc.as<JsonArray>();
  } else if (doc.is<JsonObject>()) {
    JsonObject root = doc.as<JsonObject>();
    parseModeStatusesFromObject(root);

    if (root["last_heard"].is<JsonArray>()) arr = root["last_heard"].as<JsonArray>();
    else if (root["traffic"].is<JsonArray>()) arr = root["traffic"].as<JsonArray>();
    else if (root["data"].is<JsonArray>()) arr = root["data"].as<JsonArray>();
    else if (root["results"].is<JsonArray>()) arr = root["results"].as<JsonArray>();
    else {
      lastError = "JSON object missing heard array";
      return false;
    }
  } else {
    lastError = "JSON root invalid";
    return false;
  }

  HeardRecord newList[MAX_HEARD];
  int newCount = 0;

  for (JsonObject obj : arr) {
    if (newCount >= MAX_HEARD) break;

    HeardRecord r;
    r.timeUtc   = normalizeHeardTimeDisplay(firstNonEmpty(String(obj["time_utc"] | ""), String(obj["timestamp"] | ""), String(obj["time"] | "")));
    r.mode      = firstNonEmpty(String(obj["mode"] | ""), String(obj["module"] | ""));
    r.callsign  = firstNonEmpty(String(obj["callsign"] | ""), String(obj["callsign_long"] | ""), String(obj["call"] | ""));
    String apiName = firstNonEmpty(
                      String(obj["name"] | ""),
                      String(obj["display_name"] | ""),
                      String(obj["full_name"] | ""),
                      String(obj["fullname"] | "")
                    );
    String apiFirstName = firstNonEmpty(String(obj["fname"] | ""), String(obj["first_name"] | ""), String(obj["firstname"] | ""), String(obj["forename"] | ""));
    String apiLastName  = firstNonEmpty(String(obj["lname"] | ""), String(obj["last_name"] | ""), String(obj["lastname"] | ""), String(obj["surname"] | ""));
    r.name      = trimSafe(apiName.length() ? apiName : buildOperatorName(apiFirstName, apiLastName));
    r.country   = firstNonEmpty(String(obj["country"] | ""), String(obj["country_name"] | ""));
    r.grid      = firstNonEmpty(String(obj["grid"] | ""), String(obj["locator"] | ""), String(obj["maidenhead"] | ""), String(obj["qth_locator"] | ""));
    r.target    = firstNonEmpty(String(obj["target"] | ""), String(obj["talkgroup"] | ""), String(obj["destination"] | ""), String(obj["reflector"] | ""));
    r.targetName= firstNonEmpty(String(obj["target_name"] | ""), String(obj["talkgroup_name"] | ""), String(obj["room_name"] | ""), String(obj["reflector_name"] | ""));
    r.src       = firstNonEmpty(String(obj["src"] | ""), String(obj["source"] | ""));
    r.duration  = firstNonEmpty(String(obj["duration"] | ""), String(obj["length"] | ""));
    r.loss      = firstNonEmpty(String(obj["loss"] | ""), String(obj["packet_loss"] | ""));
    r.ber       = firstNonEmpty(String(obj["bit_error_rate"] | ""), String(obj["ber"] | ""));
    r.rssi      = firstNonEmpty(String(obj["rssi"] | ""), String(obj["signal"] | ""));
    r.rxFreq    = trimSafe(String(obj["rxFreq"] | obj["rx_freq"] | obj["RXFrequency"] | obj["rx_frequency"] | ""));
    r.txFreq    = trimSafe(String(obj["txFreq"] | obj["tx_freq"] | obj["TXFrequency"] | obj["tx_frequency"] | ""));
    r.valid     = r.callsign.length() > 0;

    if (r.valid) {
      newList[newCount] = r;
      newCount++;
    }
  }

  applyRadioIdCacheToList(newList, newCount);
  inferModeStatusesFromHeard(newList, newCount);

  bool listChanged = !heardListsEqual(heardList, heardCount, newList, newCount);

  if (arr.size() == 0 || newCount == 0) {
    if (heardCount != 0) {
      clearHeard();
      requestRedraw();
    }
    setFooterText("API OK, no entries");
    return true;
  }

  if (listChanged) {
    clearHeard();
    for (int i = 0; i < newCount; i++) heardList[i] = newList[i];
    heardCount = newCount;
    requestRedraw();
  }

  if (newCount > 0) {
    if (newList[0].rxFreq.length()) currentRxFreq = newList[0].rxFreq;
    if (newList[0].txFreq.length()) currentTxFreq = newList[0].txFreq;
    queueRadioIdLookupForRecord(newList[0]);
    if (!sameRecord(liveRecord, newList[0])) {
      liveRecord = newList[0];
      updateSessionInfoFromRecord(liveRecord);
      liveRecordMillis = millis();
      requestRedraw();
      noteScreenActivity(false);
    } else {
      liveRecordMillis = millis();
    }
  }

  setFooterText("Updated from " + cfg.pistarHost);
  return true;
}

String htmlDecodeBasic(String s) {
  s.replace("&nbsp;", " ");
  s.replace("&amp;", "&");
  s.replace("&quot;", "\"");
  s.replace("&#39;", "'");
  s.replace("&lt;", "<");
  s.replace("&gt;", ">");
  return s;
}

String extractHtmlText(const String& html) {
  String out;
  out.reserve(html.length());
  bool inTag = false;
  for (size_t i = 0; i < html.length(); i++) {
    char c = html[i];
    if (c == '<') { inTag = true; continue; }
    if (c == '>') { inTag = false; continue; }
    if (!inTag) out += c;
  }
  out = htmlDecodeBasic(out);
  out.replace("\n", " ");
  out.replace("\r", " ");
  out.trim();
  while (out.indexOf("  ") >= 0) out.replace("  ", " ");
  return out;
}

bool extractTableCellAfterHeader(const String& html, const String& headerLabel, String& valueOut) {
  int h = html.indexOf(">" + headerLabel + "</th>");
  if (h < 0) return false;
  int tdStart = html.indexOf("<td", h);
  if (tdStart < 0) return false;
  int gt = html.indexOf(">", tdStart);
  if (gt < 0) return false;
  int tdEnd = html.indexOf("</td>", gt);
  if (tdEnd < 0) return false;
  valueOut = extractHtmlText(html.substring(gt + 1, tdEnd));
  return valueOut.length() > 0;
}

bool extractDivCellAfterHeader(const String& html, const String& headerLabel, String& valueOut) {
  int h = html.indexOf(">" + headerLabel + "</div>");
  if (h < 0) return false;
  int cell = html.indexOf("divTableCell", h);
  if (cell < 0) return false;
  int gt = html.indexOf(">", cell);
  if (gt < 0) return false;
  int end = html.indexOf("</div>", gt);
  if (end < 0) return false;
  valueOut = extractHtmlText(html.substring(gt + 1, end));
  return valueOut.length() > 0;
}

bool extractWpsdTableFirstCellValue(const String& html, String& valueOut) {
  int tableStart = html.indexOf("<div class=\"divTable\"");
  if (tableStart < 0) return false;
  int bodyStart = html.indexOf("<div class=\"divTableBody\"", tableStart);
  if (bodyStart < 0) return false;
  int row2 = html.indexOf("<div class=\"divTableRow", bodyStart);
  if (row2 < 0) return false;
  row2 = html.indexOf("<div class=\"divTableRow", row2 + 1);
  if (row2 < 0) return false;

  int cell = html.indexOf("divTableCell", row2);
  if (cell < 0) return false;
  int gt = html.indexOf(">", cell);
  if (gt < 0) return false;
  int end = html.indexOf("</div>", gt);
  if (end < 0) return false;

  valueOut = extractHtmlText(html.substring(gt + 1, end));
  return valueOut.length() > 0;
}

bool extractPiStarTrxValue(const String& html, String& valueOut) {
  return extractTableCellAfterHeader(html, "Trx", valueOut);
}

bool extractWpsdStatusPill(const String& html, const String& label, ModeStatus& st) {
  int searchFrom = 0;
  while (true) {
    int pill = html.indexOf("<div class='status-pill ", searchFrom);
    if (pill < 0) return false;
    int pillEnd = html.indexOf("</div>", pill);
    if (pillEnd < 0) return false;

    int labelSpan = html.indexOf("<span>", pill);
    if (labelSpan < 0 || labelSpan > pillEnd) {
      searchFrom = pill + 10;
      continue;
    }
    int labelEnd = html.indexOf("</span>", labelSpan);
    if (labelEnd < 0 || labelEnd > pillEnd) {
      searchFrom = pill + 10;
      continue;
    }

    String pillLabel = extractHtmlText(html.substring(labelSpan, labelEnd + 7));
    if (pillLabel != label) {
      searchFrom = pillEnd + 6;
      continue;
    }

    st.known = true;
    String openTag = html.substring(pill, min(pill + 80, (int)html.length()));
    if (openTag.indexOf(" paused") >= 0 || openTag.indexOf(" inactive") >= 0) st.enabled = false;
    else st.enabled = true;
    return true;
  }
}

bool extractWpsdRadioInfoValue(const String& html, const String& headerLabel, String& valueOut) {
  int tableStart = html.indexOf("<div class=\"divTable\"");
  if (tableStart < 0) return false;
  int bodyStart = html.indexOf("<div class=\"divTableBody\"", tableStart);
  if (bodyStart < 0) return false;

  int row1 = html.indexOf("<div class=\"divTableRow", bodyStart);
  if (row1 < 0) return false;
  int row1End = html.indexOf("</div>", row1);
  if (row1End < 0) return false;
  int row2 = html.indexOf("<div class=\"divTableRow", row1End);
  if (row2 < 0) return false;

  String headers[16];
  int headerCount = 0;
  int pos = row1;
  while (headerCount < 16) {
    int cell = html.indexOf("divTableHeadCell", pos);
    if (cell < 0 || cell > row2) break;
    int gt = html.indexOf(">", cell);
    if (gt < 0 || gt > row2) break;
    int end = html.indexOf("</div>", gt);
    if (end < 0 || end > row2) break;
    headers[headerCount++] = extractHtmlText(html.substring(gt + 1, end));
    pos = end + 6;
  }

  String values[16];
  int valueCount = 0;
  pos = row2;
  while (valueCount < 16) {
    int cell = html.indexOf("divTableCell", pos);
    if (cell < 0) break;
    int gt = html.indexOf(">", cell);
    if (gt < 0) break;
    int end = html.indexOf("</div>", gt);
    if (end < 0) break;
    values[valueCount++] = extractHtmlText(html.substring(gt + 1, end));
    pos = end + 6;
    if (valueCount >= headerCount) break;
  }

  for (int i = 0; i < headerCount && i < valueCount; i++) {
    if (headers[i] == headerLabel) {
      valueOut = values[i];
      return valueOut.length() > 0;
    }
  }
  return false;
}

void parseModeCellWPSD(const String& html, const String& label, ModeStatus& st) {
  int pos = html.indexOf(">" + label + "</div>");
  if (pos < 0) return;
  int divStart = html.lastIndexOf("<div", pos);
  if (divStart < 0) return;
  String snippet = html.substring(divStart, min(pos + (int)label.length() + 20, (int)html.length()));
  st.known = true;
  if (snippet.indexOf("disabled-mode-cell") >= 0) st.enabled = false;
  else st.enabled = true;
}

void parseModeCellPiStar(const String& html, const String& label, ModeStatus& st) {
  int pos = html.indexOf(">" + label + "</td>");
  if (pos < 0) return;
  int tdStart = html.lastIndexOf("<td", pos);
  if (tdStart < 0) return;
  int gt = html.indexOf(">", tdStart);
  if (gt < 0 || gt > pos) return;
  String openTag = html.substring(tdStart, gt + 1);
  st.known = true;
  if (openTag.indexOf("#606060") >= 0 || openTag.indexOf("aria-disabled") >= 0) st.enabled = false;
  else st.enabled = true;
}

bool parseDashboardStatusHtml(const String& payload) {
  ModeStatus oldDMR = modeDMR;
  ModeStatus oldDSTAR = modeDSTAR;
  ModeStatus oldYSF = modeYSF;
  ModeStatus oldP25 = modeP25;
  ModeStatus oldNXDN = modeNXDN;
  ModeStatus oldPOCSAG = modePOCSAG;
  String oldHotspotRxFreq = hotspotRxFreq;
  String oldHotspotTxFreq = hotspotTxFreq;
  String oldHotspotStatusText = hotspotStatusText;

  bool hadKnownModes = modeDMR.known || modeDSTAR.known || modeYSF.known || modeP25.known || modeNXDN.known || modePOCSAG.known;
  resetModeStatuses();

  String rx, tx, statusText;
  if (normalizedBackend() == "wpsd") {
    extractWpsdStatusPill(payload, "DMR", modeDMR);
    extractWpsdStatusPill(payload, "D-Star", modeDSTAR);
    extractWpsdStatusPill(payload, "YSF", modeYSF);
    extractWpsdStatusPill(payload, "P25", modeP25);
    extractWpsdStatusPill(payload, "NXDN", modeNXDN);
    extractWpsdStatusPill(payload, "POCSAG", modePOCSAG);

    extractWpsdRadioInfoValue(payload, "TX Freq.", tx);
    extractWpsdRadioInfoValue(payload, "RX Freq.", rx);
    if (!tx.length() && !rx.length()) {
      if (extractWpsdRadioInfoValue(payload, "TX/RX Freq.", tx) && !rx.length()) rx = tx;
    }
    extractWpsdTableFirstCellValue(payload, statusText);
  } else {
    parseModeCellPiStar(payload, "DMR", modeDMR);
    parseModeCellPiStar(payload, "D-Star", modeDSTAR);
    parseModeCellPiStar(payload, "YSF", modeYSF);
    parseModeCellPiStar(payload, "P25", modeP25);
    parseModeCellPiStar(payload, "NXDN", modeNXDN);
    parseModeCellPiStar(payload, "POCSAG", modePOCSAG);

    extractTableCellAfterHeader(payload, "Tx", tx);
    extractTableCellAfterHeader(payload, "Rx", rx);
    extractPiStarTrxValue(payload, statusText);
  }

  tx = trimSafe(tx);
  rx = trimSafe(rx);
  statusText = trimSafe(statusText);
  tx.replace(" MHz", "");
  tx.replace("MHz", "");
  rx.replace(" MHz", "");
  rx.replace("MHz", "");
  tx.trim();
  rx.trim();

  if (tx.length()) hotspotTxFreq = tx;
  if (rx.length()) hotspotRxFreq = rx;
  if (statusText.length()) hotspotStatusText = statusText;

  bool modesKnownNow = modeDMR.known || modeDSTAR.known || modeYSF.known || modeP25.known || modeNXDN.known || modePOCSAG.known;
  if (!modesKnownNow && hadKnownModes) {
    modeDMR = oldDMR;
    modeDSTAR = oldDSTAR;
    modeYSF = oldYSF;
    modeP25 = oldP25;
    modeNXDN = oldNXDN;
    modePOCSAG = oldPOCSAG;
    modesKnownNow = true;
  }

  bool modeStatusChanged = !allModeStatusesEqual(oldDMR, oldDSTAR, oldYSF, oldP25, oldNXDN, oldPOCSAG) ||
                           oldHotspotRxFreq != hotspotRxFreq || oldHotspotTxFreq != hotspotTxFreq ||
                           oldHotspotStatusText != hotspotStatusText;
  if (modeStatusChanged) {
    requestRedraw();
    if (oldHotspotStatusText != hotspotStatusText) noteScreenActivity(false);
  }

  return modesKnownNow || hotspotRxFreq.length() || hotspotTxFreq.length();
}

bool httpGetString(const String& url, String& payload, int& codeOut) {
  HTTPClient http;
  http.setConnectTimeout(HTTP_CONNECT_TIMEOUT_MS);
  http.setTimeout(HTTP_READ_TIMEOUT_MS);
  if (!http.begin(url)) {
    codeOut = -1;
    return false;
  }
  codeOut = http.GET();
  if (codeOut == 200) payload = http.getString();
  http.end();
  return codeOut == 200;
}

bool fetchWeatherData() {
  if (!weatherConfigured() || WiFi.status() != WL_CONNECTED) return false;
  if (weatherState.fetchInProgress) return false;

  String url = weatherRequestUrl();
  if (!url.length()) return false;

  unsigned long startMs = millis();
  weatherState.fetchInProgress = true;
  weatherState.lastAttemptMillis = millis();

  WiFiClientSecure client;
  client.setInsecure();

  HTTPClient http;
  http.setConnectTimeout(HTTP_CONNECT_TIMEOUT_MS);
  http.setTimeout(HTTP_READ_TIMEOUT_MS);
  http.useHTTP10(true);

  if (!http.begin(client, url)) {
    weatherState.fetchInProgress = false;
    weatherState.lastHttpCode = -1;
    noteWeatherFetchResult(false, -1);
    return false;
  }

  http.setUserAgent(String("MMDVM-Display/") + DISPLAY_VERSION + " (weather)");
  http.addHeader("Accept", "application/json");

  int code = http.GET();
  weatherState.lastHttpCode = code;
  if (code != 200) {
    http.end();
    weatherState.fetchInProgress = false;
    noteWeatherFetchResult(false, code);
    diagnosticsState.lastApiLatencyMs = millis() - startMs;
    return false;
  }

  StaticJsonDocument<128> filter;
  filter["main"]["temp"] = true;
  filter["weather"][0]["icon"] = true;
  filter["weather"][0]["main"] = true;
  filter["weather"][0]["description"] = true;
  filter["name"] = true;

  StaticJsonDocument<1024> doc;
  DeserializationError err = deserializeJson(doc, *http.getStreamPtr(), DeserializationOption::Filter(filter));
  http.end();
  if (err) {
    weatherState.fetchInProgress = false;
    noteWeatherFetchResult(false, code);
    diagnosticsState.lastApiLatencyMs = millis() - startMs;
    return false;
  }

  JsonObject root = doc.as<JsonObject>();
  JsonArray weatherArr = root["weather"].as<JsonArray>();
  JsonObject mainObj = root["main"].as<JsonObject>();
  if (weatherArr.isNull() || weatherArr.size() == 0 || mainObj.isNull() || !mainObj.containsKey("temp")) {
    weatherState.fetchInProgress = false;
    noteWeatherFetchResult(false, code);
    diagnosticsState.lastApiLatencyMs = millis() - startMs;
    return false;
  }

  JsonObject first = weatherArr[0];
  weatherState.tempC = mainObj["temp"].as<float>();
  weatherState.icon = trimSafe(String(first["icon"] | ""));
  weatherState.summary = trimSafe(String(first["main"] | first["description"] | ""));
  weatherState.location = trimSafe(String(root["name"] | ""));
  weatherState.valid = true;
  weatherState.lastSuccessMillis = millis();
  weatherState.fetchInProgress = false;
  noteWeatherFetchResult(true, code);
  diagnosticsState.lastApiLatencyMs = millis() - startMs;

  requestRedraw();
  return true;
}

bool fetchModeStatus() {
  if (WiFi.status() != WL_CONNECTED) {
    wifiOk = false;
    setFooterText("Wi-Fi disconnected");
    requestRedraw();
    noteApiFetchResult(false, -1, 0);
    return false;
  }

  unsigned long startMs = millis();
  bool ok = false;
  String payload;
  int code = 0;

  if (httpGetString(dashboardStatusUrl(), payload, code)) {
    ok = parseDashboardStatusHtml(payload) || ok;
  }

  String freqUrl = dashboardFreqUrl();
  if (freqUrl != dashboardStatusUrl()) {
    payload = "";
    code = 0;
    if (httpGetString(freqUrl, payload, code)) {
      ok = parseDashboardStatusHtml(payload) || ok;
    }
  }

  if (!ok) {
    if (code == -1) setFooterText("Dashboard begin failed");
    else if (code != 0 && code != 200) setFooterText("Dashboard HTTP: " + String(code));
    else setFooterText("Dashboard parse failed");
  }

  noteApiFetchResult(ok, code, millis() - startMs);
  return ok;
}

bool fetchLastHeard() {
  if (WiFi.status() != WL_CONNECTED) {
    wifiOk = false;
    setFooterText("Wi-Fi disconnected");
    requestRedraw();
    noteApiFetchResult(false, -1, 0);
    return false;
  }

  unsigned long startMs = millis();
  int code = 0;
  HTTPClient http;
  String url = heardUrl();
  http.setConnectTimeout(HTTP_CONNECT_TIMEOUT_MS);
  http.setTimeout(HTTP_READ_TIMEOUT_MS);

  if (!http.begin(url)) {
    setFooterText("HTTP begin failed");
    requestRedraw();
    noteApiFetchResult(false, -1, millis() - startMs);
    return false;
  }

  code = http.GET();
  if (code != 200) {
    setFooterText("HTTP error: " + String(code));
    http.end();
    requestRedraw();
    noteApiFetchResult(false, code, millis() - startMs);
    return false;
  }

  String payload = http.getString();
  http.end();

  bool ok = parseHeardArray(payload);
  noteApiFetchResult(ok, code, millis() - startMs);
  return ok;
}

bool fetchAllData() {
  bool ok = false;

  if (nextFetchHeard) {
    ok = fetchLastHeard();
  } else {
    ok = fetchModeStatus();
  }

  nextFetchHeard = !nextFetchHeard;
  return ok;
}

static const bool TOUCH_SWAP_XY = true;
static const bool TOUCH_FLIP_X  = false;
static const bool TOUCH_FLIP_Y  = true;
static const unsigned long TOUCH_DEBOUNCE_MS = 220;
static const unsigned long TOUCH_POLL_GRACE_MS = 700;

bool getTouchPoint(int& x, int& y) {
  if (!ts.touched()) return false;

  TS_Point p = ts.getPoint();

#if DISPLAY_BACKEND == DISPLAY_BACKEND_JC3248W535C
  x = constrain((int)p.x, 0, SCREEN_W - 1);
  y = constrain((int)p.y, 0, SCREEN_H - 1);
  if (cfg.flipScreen) {
    x = (SCREEN_W - 1) - x;
    y = (SCREEN_H - 1) - y;
  }
  return true;
#else
  int rawX = p.x;
  int rawY = p.y;

  if (TOUCH_SWAP_XY) {
    x = map(rawY, touchMinY, touchMaxY, 0, SCREEN_W - 1);
    y = map(rawX, touchMinX, touchMaxX, 0, SCREEN_H - 1);
  } else {
    x = map(rawX, touchMinX, touchMaxX, 0, SCREEN_W - 1);
    y = map(rawY, touchMinY, touchMaxY, 0, SCREEN_H - 1);
  }

  if (TOUCH_FLIP_X) x = (SCREEN_W - 1) - x;
  if (TOUCH_FLIP_Y) y = (SCREEN_H - 1) - y;
  if (cfg.flipScreen) {
    x = (SCREEN_W - 1) - x;
    y = (SCREEN_H - 1) - y;
  }

  x = constrain(x, 0, SCREEN_W - 1);
  y = constrain(y, 0, SCREEN_H - 1);

  return true;
#endif
}

void handleTouch() {
  static unsigned long lastTouch = 0;
  static unsigned long touchHeldSince = 0;
  static bool touchHeld = false;

  int x, y;
  bool touched = getTouchPoint(x, y);
  unsigned long now = millis();

  if (!touched) {
    touchHeld = false;
    touchHeldSince = 0;
    return;
  }

  if (!touchHeld) {
    touchHeldSince = now;
  }

  if (screensaverActive) {
    touchHeld = true;
    lastTouch = now;
    lastInteractionMillis = now;
    setScreensaverActive(false);
    tft.fillScreen(theme.bg);
    requestRedraw();
    return;
  }

  if (touchHeld && touchHeldSince && (now - touchHeldSince > 1500UL)) {
    touchHeld = false;
    touchHeldSince = now;
  }

  if (touchHeld) return;
  if (now - lastTouch < TOUCH_DEBOUNCE_MS) return;

  touchHeld = true;
  touchHeldSince = now;
  lastTouch = now;
  lastInteractionMillis = now;
  noteUserInteraction(true);

  if (modeConfirmPopupOpen) {
    int popupX, popupY, popupW, popupH;
    int noX, noY, noW, noH;
    int yesX, yesY, yesW, yesH;
    getModeConfirmPopupLayout(popupX, popupY, popupW, popupH, noX, noY, noW, noH, yesX, yesY, yesW, yesH);

    if (pointInRect(x, y, yesX, yesY, yesW, yesH)) {
      String label = modeConfirmTargetLabel;
      bool enable = modeConfirmEnableRequested;
      modeConfirmPopupOpen = false;
      requestRedraw();
      startModeToggleTask(label, enable);
    } else {
      modeConfirmPopupOpen = false;
      requestRedraw();
    }
    return;
  }

  if (quickActionPopupOpen) {
    const int popupW = UW(250);
    const int popupH = UH(128);
    const int popupX = (SCREEN_W - popupW) / 2;
    const int popupY = (SCREEN_H - popupH) / 2;
    const int btnW = UW(106);
    const int btnH = UH(24);
    const int gapX = UW(10);
    const int gapY = UH(10);
    const int startX = popupX + UW(14);
    const int startY = popupY + UH(34);
    const int closeX = popupX + UW(78);
    const int closeY = popupY + popupH - UH(30);
    const int closeW = UW(94);
    const int closeH = UH(20);

    if (pointInRect(x, y, closeX, closeY, closeW, closeH)) {
      quickActionPopupOpen = false;
      requestRedraw();
      return;
    }

    for (uint8_t i = 0; i < QUICK_ACTION_SLOT_COUNT; i++) {
      int col = i % 2;
      int row = i / 2;
      int bx = startX + col * (btnW + gapX);
      int by = startY + row * (btnH + gapY);
      if (pointInRect(x, y, bx, by, btnW, btnH)) {
        if (!isQuickActionSlotConfigured(i)) {
          setFooterText("Preset slot is empty.");
          return;
        }
        quickActionPopupOpen = false;
        requestRedraw();
        startQuickActionTask(i);
        return;
      }
    }

    quickActionPopupOpen = false;
    requestRedraw();
    return;
  }

  if (detailPopupOpen) {
    detailPopupOpen = false;
    selectedHeardIndex = -1;
    requestRedraw();
    return;
  }

  if (currentPage == PAGE_INFO) {
    currentPage = previousPage;
    tft.fillScreen(theme.bg);
    requestRedraw();
    return;
  }

  if (y <= UY(32)) {
    const int tabGap = UW(4);
    const int tabW = UW(54);
    const int startX = UX(6);
    const bool showApTab = setupRequired && apModeActive;
    const bool showStatTab = !showApTab;

    const int clockW = UW(68);
    const int clockH = UH(24);
    const int clockX = UX(246);
    const int clockY = UY(6);

    if (x >= clockX && x < clockX + clockW && y >= clockY && y < clockY + clockH) {
      previousPage = currentPage;
      currentPage = PAGE_ABOUT;
      requestRedraw();
      return;
    }

    int tabIndex = -1;
    for (int i = 0; i < 4; i++) {
      int tabX = startX + i * (tabW + tabGap);
      if (x >= tabX && x < tabX + tabW) {
        tabIndex = i;
        break;
      }
    }

    if (tabIndex == 0) currentPage = PAGE_LIVE;
    else if (tabIndex == 1) currentPage = PAGE_LAST;
    else if (tabIndex == 2) currentPage = PAGE_STATUS;
    else if (tabIndex == 3 && showApTab) currentPage = PAGE_AP;
    else if (tabIndex == 3 && showStatTab) currentPage = PAGE_INFO;
    else return;

    requestRedraw();
    return;
  }

  if (!setupRequired && currentPage == PAGE_LIVE) {
    String modeLabel;
    if (liveModeButtonTouchHit(x, y, modeLabel)) {
      openModeConfirmPopup(modeLabel);
      return;
    }

    if (pointInRect(x, y, UX(112), UY(190), UW(96), UH(24))) {
      if (!hasConfiguredQuickActions()) {
        setFooterText("No quick actions are configured yet.");
        return;
      }
      quickActionPopupOpen = true;
      requestRedraw();
      return;
    }
  }

  if (currentPage == PAGE_LAST) {
    int rows = min(heardCount, 5);
    for (int i = 0; i < rows; i++) {
      int rowY = UY(76) + (i * UH(28));
      if (x >= UX(14) && x <= SCREEN_W - UX(14) && y >= rowY && y <= rowY + UH(24)) {
        selectedHeardIndex = i;
        detailPopupOpen = true;
        requestRedraw();
        return;
      }
    }
  }

  if (currentPage == PAGE_STATUS) {
    if (x >= BRIGHT_SLIDER_X && x <= BRIGHT_SLIDER_X + BRIGHT_SLIDER_W &&
        y >= BRIGHT_SLIDER_Y - UH(8) && y <= BRIGHT_SLIDER_Y + BRIGHT_SLIDER_H + UH(8)) {
      setBrightnessFromTouchX(x);
      applyBrightness();
      saveConfigToFile();
      requestRedraw();
      return;
    }
  }
}

void initBacklight() {
  pinMode(TFT_BL, OUTPUT);
  digitalWrite(TFT_BL, TFT_BACKLIGHT_ON);
}

static void deselectSpiDevices() {
#if DISPLAY_BACKEND == DISPLAY_BACKEND_2432S028R
  pinMode(TFT_CS, OUTPUT);
  pinMode(TOUCH_CS, OUTPUT);
  pinMode(SD_CS, OUTPUT);
  digitalWrite(TFT_CS, HIGH);
  digitalWrite(TOUCH_CS, HIGH);
  digitalWrite(SD_CS, HIGH);
#endif
}

void setup() {
  Serial.begin(115200);
  delay(300);
  Serial.println();
  Serial.println("Boot start");
  Serial.printf("Reset reason CPU0: %d\n", (int)rtc_get_reset_reason(0));
  addWebLog("INFO", "BOOT", "Boot start.");
  addWebLog("INFO", "BOOT", "Reset reason CPU0: " + String((int)rtc_get_reset_reason(0)) + ".");

#if ENABLE_PISTAR_TOUCH_SSH
  libssh_begin();
#endif

  initBacklight();
  deselectSpiDevices();

#if DISPLAY_BACKEND == DISPLAY_BACKEND_2432S028R || DISPLAY_BACKEND == DISPLAY_BACKEND_JC3248W535C
  sdSPI.begin(SD_SCLK, SD_MISO, SD_MOSI, SD_CS);
#endif

  setDefaultTheme();

  tft.init();
  tft.setRotation(1);
#if DISPLAY_BACKEND == DISPLAY_BACKEND_JC3248W535C
  jc3248InitLvglBridge();
#endif
  animateBootProgress("POWERING DISPLAY", 0, 14, 24);

#if DISPLAY_BACKEND == DISPLAY_BACKEND_2432S028R
  deselectSpiDevices();
#endif
  if (!SD.begin(SD_CS, sdSPI, 10000000)) {
    activeStorageLabel = "FLASH";
    animateBootProgress("SD mount failed / using FLASH", 14, 38, 18);
    addWebLog("WARN", "BOOT", "SD mount failed. Falling back to FLASH storage.");
  } else {
    activeStorageLabel = "SD";
    animateBootProgress("SD CARD READY", 14, 38, 18);
    addWebLog("INFO", "BOOT", "SD storage mounted.");
  }

  bool configOk = loadConfig();
  configOkState = configOk;
  setupRequired = !configOk;
  if (!configOk) {
    animateBootProgress("Config missing / invalid", 38, 62, 18);
    addWebLog("WARN", "BOOT", "Configuration missing or invalid.");
  } else {
    animateBootProgress("CONFIG LOADED", 38, 62, 18);
    addWebLog("INFO", "BOOT", "Configuration loaded.");
  }

  tft.fillRoundRect(UX(92), UY(142), UW(136), UH(28), UR(8), theme.panel);
  tft.drawRoundRect(UX(92), UY(142), UW(136), UH(28), UR(8), theme.accent);
  tft.setTextFont(2);
  tft.setTextColor(theme.accent, theme.panel);
  tft.setTextDatum(MC_DATUM);
  tft.drawString(getDeviceCallsign(), SCREEN_W / 2, UY(156));
  tft.setTextDatum(TL_DATUM);

  lastLiveActiveState = isLiveActive();
  lastInteractionMillis = millis();

#if DISPLAY_BACKEND == DISPLAY_BACKEND_2432S028R
  touchSPI.begin(TOUCH_CLK, TOUCH_MISO, TOUCH_MOSI, TOUCH_CS);
  ts.begin(touchSPI);
  ts.setRotation(0);
  animateBootProgress("TOUCH ONLINE", 62, 78, 18);
#else
  ts.begin(touchSPI);
  ts.setRotation(1);
  animateBootProgress("TOUCH ONLINE", 62, 78, 18);
#endif

  animateBootProgress("LINKING WIFI", 78, 88, 18);
  if (configOk && configLooksValid()) {
    connectWiFi(WIFI_CONNECT_TIMEOUT_MS);
  } else {
    wifiOk = false;
  }

  if (!configOk) {
    setupRequired = true;
    animateBootProgress("STARTING SETUP AP", 88, 94, 18);
    startSetupAccessPoint();
    currentPage = PAGE_AP;
    previousPage = PAGE_AP;
  } else if (!wifiOk) {
    setupRequired = false;
    setFooterText("Wi-Fi failed, retrying...");
  } else {
    setupRequired = false;
  }
  setupWebServer();

  animateBootProgress(wifiOk ? "BOOT COMPLETE" : (apModeActive ? "SETUP AP READY" : "BOOT COMPLETE / OFFLINE"), 94, 100, 18);
  addWebLog("INFO", "BOOT", wifiOk ? ("Boot complete. Web UI at http://" + WiFi.localIP().toString() + "/") : (apModeActive ? ("Boot complete with setup AP at http://" + WiFi.softAPIP().toString() + "/") : String("Boot complete without network connection.")));
  delay(350);

  setFooterText(wifiOk ? ("Web UI: http://" + WiFi.localIP().toString() + "/") : (apModeActive ? ("Setup required: " + apSsid + " / http://" + WiFi.softAPIP().toString() + "/") : "Wi-Fi failed, retrying..."));

  applyDisplaySettings();
  tft.fillScreen(theme.bg);

  requestRedraw();
  redrawCurrentPage(true);
  lastHeaderClock = getHeaderTimeText();
  recordDiagnosticsSample(true);
  uiDirty = false;
}

void loop() {
  handleTouch();
#if DISPLAY_BACKEND == DISPLAY_BACKEND_JC3248W535C
  jc3248HandleLvgl();
#endif
  server.handleClient();

  if (otaUpdateSuccessful && otaRestartAt && millis() >= otaRestartAt) {
    addWebLog("INFO", "OTA", "Rebooting into updated firmware.");
    delay(150);
    ESP.restart();
  }

  if (pendingConfigApply && millis() >= pendingConfigApplyAt) {
    pendingConfigApply = false;

    if (configOkState && configLooksValid()) {
      setupRequired = false;
      beginWiFiConnectAttempt(true);
    } else {
      wifiConnectInProgress = false;
      wifiOk = false;
      setupRequired = true;
      currentPage = PAGE_AP;
      startSetupAccessPoint();
      setFooterText(apModeActive ? ("Setup required: " + apSsid + " / http://" + WiFi.softAPIP().toString() + "/") : "Config saved from web");
      requestRedraw();
    }
  }

  if (millis() - lastWifiStateCheck >= 250) {
    lastWifiStateCheck = millis();

    bool configValidNow = configLooksValid();
    bool stationConnected = (WiFi.status() == WL_CONNECTED);

    if (!configValidNow) {
      wifiConnectInProgress = false;
      wifiOk = false;
      setupRequired = true;
      currentPage = PAGE_AP;
      if (!apModeActive) startSetupAccessPoint();
    } else {
      if (wifiConnectInProgress) {
        handleWiFiConnectProgress();
        stationConnected = (WiFi.status() == WL_CONNECTED);
      }

      if (stationConnected) {
        wifiOk = true;
        setupRequired = false;
        syncTimeIfNeeded();
        if (apModeActive) stopSetupAccessPoint();
      } else {
        wifiOk = false;
        if (!setupRequired && !wifiConnectInProgress && millis() - lastWifiRetryMillis >= 10000UL) {
          beginWiFiConnectAttempt(false);
        }
      }
    }
  }

  bool pageNeedsNetworkPolling = (currentPage == PAGE_LIVE || currentPage == PAGE_LAST);
  bool touchGraceActive = (millis() - lastInteractionMillis) < TOUCH_POLL_GRACE_MS;
  bool canPollNetwork = !setupRequired && wifiOk && !apModeActive && !wifiConnectInProgress;
  bool normalUiPoll = !screensaverActive && canPollNetwork && pageNeedsNetworkPolling && !touchGraceActive && millis() - lastPoll >= cfg.refreshMs;
  bool screensaverWakePoll = screensaverActive && canPollNetwork && millis() - lastPoll >= cfg.refreshMs;

  if (normalUiPoll) {
    lastPoll = millis();
    fetchAllData();
  } else if (screensaverWakePoll) {
    lastPoll = millis();
    fetchLastHeard();
  }

  processRadioIdLookups();

  if (millis() - lastWeatherPollCheck >= 1000UL) {
    lastWeatherPollCheck = millis();
    if (wifiOk && !setupRequired && !apModeActive && weatherConfigured()) {
      unsigned long weatherRefreshMs = max(60000UL, (unsigned long)cfg.weatherRefreshMinutes * 60000UL);
      if (!weatherState.lastAttemptMillis || millis() - weatherState.lastAttemptMillis >= weatherRefreshMs) {
        fetchWeatherData();
      }
    }
  }

  if (cfg.screensaverEnabled && !screensaverActive && millis() - lastInteractionMillis >= cfg.screensaverTimeoutMs) {
    setScreensaverActive(true);
  }

  if (screensaverActive && millis() - lastScreensaverFrameMillis >= 60000) {
    lastScreensaverFrameMillis = millis();
	requestRedraw();
  }

  bool liveActiveNow = isLiveActive();
  if (liveActiveNow != lastLiveActiveState) {
    lastLiveActiveState = liveActiveNow;
    if (currentPage == PAGE_LIVE) requestRedraw();
  }

  recordDiagnosticsSample(false);
  processModeToggleTaskResult();
  processQuickActionTaskResult();

  if (!screensaverActive && millis() - lastClockCheck >= 5000) {
    lastClockCheck = millis();
    String nowClock = getHeaderTimeText();
    if (nowClock != lastHeaderClock) {
      lastHeaderClock = nowClock;
      requestRedraw();
    }
  }

  if (uiDirty || footerDirty) {
    redrawCurrentPage(uiDirty);
    footerDirty = false;
    uiDirty = false;
  }
}
