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

#ifndef ENABLE_PISTAR_TOUCH_SSH
  #define ENABLE_PISTAR_TOUCH_SSH 1
#endif

#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 DISPLAY_BACKEND_2432S028R
#endif

#define DISPLAY_VERSION "0.0.1.2"

#if DISPLAY_BACKEND == DISPLAY_BACKEND_2432S028R
  #define DISPLAY_NATIVE_WIDTH  240
  #define DISPLAY_NATIVE_HEIGHT 320

  #define DISPLAY_LOGICAL_WIDTH  DISPLAY_NATIVE_HEIGHT
  #define DISPLAY_LOGICAL_HEIGHT DISPLAY_NATIVE_WIDTH
  #define DISPLAY_ROTATION_NORMAL  1
  #define DISPLAY_ROTATION_FLIPPED 3

  #include <TFT_eSPI.h>
  #include <XPT2046_Touchscreen.h>

  #if defined(ST7789_DRIVER)
    #define DISPLAY_CONTROLLER_NAME "ST7789"
  #elif defined(ILI9341_2_DRIVER)
    #define DISPLAY_CONTROLLER_NAME "ILI9341_2"
  #elif defined(ILI9341_DRIVER)
    #define DISPLAY_CONTROLLER_NAME "ILI9341"
  #else
    #define DISPLAY_CONTROLLER_NAME "TFT_eSPI"
  #endif
#elif DISPLAY_BACKEND == DISPLAY_BACKEND_JC3248W535C
  #include <Arduino_GFX_Library.h>
  #include <lvgl.h>
  #include <Wire.h>

  #define USER_SETUP_INFO "JC3248W535C"
  #define DISPLAY_CONTROLLER_NAME "AXS15231B"

  #define TFT_WIDTH  480
  #define TFT_HEIGHT 320

  #define DISPLAY_ROTATION_NORMAL  1
  #define DISPLAY_ROTATION_FLIPPED 3

  #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 0
  #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);
      if (!bus) { initOk = false; return; }

      panel = new Arduino_AXS15231B(bus, TFT_RST, 0, false, 320, 480);
      if (!panel) { initOk = false; return; }

      gfx = new Arduino_Canvas(320, 480, panel, 0, 0, rotation);
      if (!gfx) { initOk = false; return; }

      initOk = gfx->begin();
      if (!initOk) return;

      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 (ready()) gfx->setRotation(rotation);
      flush();
    }

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

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

    void fillScreen(uint16_t c) { if (ready()) gfx->fillScreen(c); }
    void fillRect(int16_t x, int16_t y, int16_t w, int16_t h, uint16_t c) {
      if (ready()) gfx->fillRect(tx(x), ty(y), tw(w), th(h), c);
    }
    void drawPixel(int16_t x, int16_t y, uint16_t c) { if (ready()) 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 (ready()) 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 (ready()) gfx->drawFastHLine(tx(x), ty(y), tw(w), c);
    }
    void drawFastVLine(int16_t x, int16_t y, int16_t h, uint16_t c) {
      if (ready()) gfx->drawFastVLine(tx(x), ty(y), th(h), c);
    }
    void drawCircle(int16_t x, int16_t y, int16_t r, uint16_t c) {
      if (ready()) gfx->drawCircle(tx(x), ty(y), tr(r), c);
    }
    void fillCircle(int16_t x, int16_t y, int16_t r, uint16_t c) {
      if (ready()) 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 (ready()) 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 (ready()) 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 (ready()) 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 (!ready() || !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>
#include "freertos/FreeRTOS.h"
#include "freertos/semphr.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);

#ifndef NETWORK_LOCK_TIMEOUT_MS
  #define NETWORK_LOCK_TIMEOUT_MS 30000UL
#endif

SemaphoreHandle_t networkMutex = nullptr;

class NetworkGuard {
public:
  bool locked = false;

  explicit NetworkGuard(uint32_t timeoutMs = NETWORK_LOCK_TIMEOUT_MS) {
    if (!networkMutex) {
      locked = true;
      return;
    }
    locked = (xSemaphoreTakeRecursive(networkMutex, pdMS_TO_TICKS(timeoutMs)) == pdTRUE);
  }

  ~NetworkGuard() {
    if (locked && networkMutex) xSemaphoreGiveRecursive(networkMutex);
  }

  operator bool() const { return locked; }
};

static void handleWebClientSafely(uint32_t timeoutMs = 0) {
  NetworkGuard net(timeoutMs);
  if (net) server.handleClient();
}

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
}

#ifndef CYD_TOUCH_MIN_X
  #define CYD_TOUCH_MIN_X 200
#endif
#ifndef CYD_TOUCH_MAX_X
  #define CYD_TOUCH_MAX_X 3700
#endif
#ifndef CYD_TOUCH_MIN_Y
  #define CYD_TOUCH_MIN_Y 240
#endif
#ifndef CYD_TOUCH_MAX_Y
  #define CYD_TOUCH_MAX_Y 3800
#endif

int touchMinX = CYD_TOUCH_MIN_X;
int touchMaxX = CYD_TOUCH_MAX_X;
int touchMinY = CYD_TOUCH_MIN_Y;
int touchMaxY = CYD_TOUCH_MAX_Y;

#if DISPLAY_BACKEND == DISPLAY_BACKEND_2432S028R
  const int SCREEN_W = DISPLAY_LOGICAL_WIDTH;
  const int SCREEN_H = DISPLAY_LOGICAL_HEIGHT;

  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;
#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 cleanSvxlinkTargetValue(String value);
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;
};

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

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;
static const uint8_t SVXLINK_ACTION_SLOT_COUNT = 4;

struct AppConfig {
  String backend;
  String wifiSsid;
  String wifiPass;
  String pistarHost;
  bool svxlinkEnabled = false;
  String svxlinkSource = "url";
  String svxlinkHost = "";
  String svxlinkApiPath = "/index.php";
  String svxlinkApiToken = "";
  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;
  bool svxlinkSshEnabled = false;
  String svxlinkSshUser = "svxlink";
  String svxlinkSshPass = "";
  uint16_t svxlinkSshPort = 22;
  QuickActionSlotConfig quickActions[QUICK_ACTION_SLOT_COUNT];
  QuickActionSlotConfig svxlinkActions[SVXLINK_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 = 16;
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 SvxlinkModuleState {
  String name = "";
  bool active = false;
};

static const uint8_t SVXLINK_MAX_LOGICS = 6;
static const uint8_t SVXLINK_MAX_MODULES = 8;
String svxlinkCallsign = "";
String svxlinkNetworkName = "";
String svxlinkRadioStatus = "";
String svxlinkReflectorStatus = "";
String svxlinkModeText = "";
String svxlinkDefaultTg = "";
String svxlinkActiveTg = "";
String svxlinkMonitorTgs = "";
String svxlinkLastReboot = "";
String svxlinkSystemText = "";
String svxlinkLogics[SVXLINK_MAX_LOGICS];
uint8_t svxlinkLogicCount = 0;
SvxlinkModuleState svxlinkModules[SVXLINK_MAX_MODULES];
uint8_t svxlinkModuleCount = 0;
unsigned long svxlinkLastSuccessMillis = 0;

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
};

enum WatchSource {
  WATCH_MMDVM = 0,
  WATCH_SVXLINK = 1
};

enum LastHeardFilter {
  LAST_HEARD_FILTER_ALL = 0,
  LAST_HEARD_FILTER_DMR,
  LAST_HEARD_FILTER_DSTAR,
  LAST_HEARD_FILTER_YSF,
  LAST_HEARD_FILTER_P25,
  LAST_HEARD_FILTER_NXDN,
  LAST_HEARD_FILTER_POCSAG
};

enum LastHeardSort {
  LAST_HEARD_SORT_NEWEST = 0,
  LAST_HEARD_SORT_CALLSIGN,
  LAST_HEARD_SORT_MODE
};

Page currentPage = PAGE_LIVE;
Page previousPage = PAGE_LIVE;
WatchSource currentWatchSource = WATCH_MMDVM;
bool watchSourcePopupOpen = false;
Page watchSourcePopupTargetPage = 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;
LastHeardFilter lastHeardFilter = LAST_HEARD_FILTER_ALL;
LastHeardSort lastHeardSort = LAST_HEARD_SORT_NEWEST;
int lastHeardPageIndex = 0;
static const uint8_t LAST_HEARD_ROWS_PER_PAGE = 4;
static const unsigned long SCROLL_TEXT_STEP_MS = 230UL;
bool scrollingTextActiveFrame = false;
bool scrollingTextDirty = false;
unsigned long lastScrollingTextFrameMs = 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 = "", const String& e = "");
bool svxlinkFeatureEnabled();
bool isCurrentWatchSvxlink();
bool isSvxlinkBackend();
void setCurrentWatchSource(WatchSource source);
void saveActiveHeardCache();
void loadActiveHeardCache();
String activeBackendHost();
String displayBackendHost();
String svxlinkApiUrl();
void resetSvxlinkState();
void parseSvxlinkStatusFromObject(JsonObject root);
bool fetchSvxlinkData();
void drawSvxlinkLivePage();
void drawSvxlinkHeardPage();
void updateSessionInfoFromRecord(const HeardRecord& rec);
void drawInfoPage();
void drawAboutPage();
String getDateText();
String getDetailedClockText();
String getLastHeardFilterLabel();
String getLastHeardSortLabel();
bool heardRecordMatchesLastHeardFilter(const HeardRecord& rec);
int buildLastHeardVisibleIndices(int* outIndices, int maxCount);
void normalizeLastHeardBrowseState();

void setFooterText(const String& text, bool countAsScreenActivity = true) {
  if (footerText != text) {
    footerText = text;
    footerDirty = true;
    if (countAsScreenActivity) 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;
bool activeQuickActionIsSvxlink = false;
bool parsingSvxlinkData = 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;
  bool svxlinkAction;
};

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();
uint8_t quickActionSlotLimit(bool svxlinkAction = false);
bool hasConfiguredQuickActions(bool svxlinkAction = false);
bool isQuickActionSlotConfigured(uint8_t slot, bool svxlinkAction = false);
String quickActionSlotLabel(uint8_t slot, bool svxlinkAction = false);
bool startQuickActionTask(uint8_t slot, bool svxlinkAction = false);
void processQuickActionTaskResult();
String renderSvxlinkActionRows();
void readSvxlinkActionRowsFromForm();
void drawSignalMeter(int x, int y, int w, int h, bool active, const HeardRecord* rec);
void drawDetailPopup(const HeardRecord& r);
void drawQuickActionsPopup();
void drawWatchSourcePopup();
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 = 48;

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 = "";
bool otaHeaderChecked = false;
size_t otaBytesWritten = 0;

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

HeardRecord liveRecord;
unsigned long liveRecordMillis = 0;

HeardRecord mmdvmHeardList[MAX_HEARD];
int mmdvmHeardCount = 0;
HeardRecord mmdvmLiveRecord;
unsigned long mmdvmLiveRecordMillis = 0;
HeardRecord svxlinkHeardList[MAX_HEARD];
int svxlinkHeardCount = 0;
HeardRecord svxlinkLiveRecord;
unsigned long svxlinkLiveRecordMillis = 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();
  s.replace("_", "-");
  while (s.indexOf("  ") >= 0) s.replace("  ", " ");

  if (s.startsWith("DMR")) return "DMR";
  if (s.startsWith("D-STAR") || s.startsWith("DSTAR")) return "DSTAR";
  if (s.startsWith("YSF") || s.indexOf("FUSION") >= 0 || s.startsWith("C4FM")) return "YSF";
  if (s.startsWith("P25")) return "P25";
  if (s.startsWith("NXDN")) return "NXDN";
  if (s.startsWith("POCSAG")) return "POCSAG";
  if (s.startsWith("SVXLINK") || s.startsWith("SVX-LINK") || s.startsWith("SVX")) return "SVXLINK";

  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 canonicalBackendValue(const String& rawBackend) {
  String l = trimSafe(rawBackend);
  l.toLowerCase();
  l.replace("_", "-");
  while (l.indexOf("  ") >= 0) l.replace("  ", " ");

  if (l == "wpsd") return "WPSD";
  if (l == "pi-star" || l == "pistar" || l == "pi star") return "Pi-Star";
  return "";
}

String canonicalSvxlinkSourceValue(const String& rawSource) {
  String l = trimSafe(rawSource);
  l.toLowerCase();
  l.replace("_", "-");
  while (l.indexOf("  ") >= 0) l.replace("  ", " ");
  if (l == "api" || l == "svxlink-api" || l == "svxlink api" || l == "json") return "api";
  if (l == "url" || l == "html" || l == "dashboard" || l == "svxlink-url" || l == "svxlink url") return "url";
  return "";
}

bool isValidBackendValue(const String& rawBackend) {
  return canonicalBackendValue(rawBackend).length() > 0;
}

String normalizedBackend() {
  String canonical = canonicalBackendValue(cfg.backend);
  if (canonical == "WPSD") return "wpsd";
  return "pistar";
}

String normalizedSvxlinkSource() {
  String canonical = canonicalSvxlinkSourceValue(cfg.svxlinkSource);
  return canonical.length() ? canonical : String("url");
}

bool svxlinkFeatureEnabled() {
  return cfg.svxlinkEnabled && trimSafe(cfg.svxlinkHost).length() > 0;
}

bool isCurrentWatchSvxlink() {
  return currentWatchSource == WATCH_SVXLINK && svxlinkFeatureEnabled();
}

bool isSvxlinkBackend() {
  return isCurrentWatchSvxlink();
}

bool isSvxlinkApiBackend() {
  return normalizedSvxlinkSource() == "api";
}

bool isSvxlinkUrlBackend() {
  return normalizedSvxlinkSource() == "url";
}

String defaultSvxlinkPathForBackend() {
  return isSvxlinkApiBackend() ? String("/api/display-json.php") : String("/index.php");
}

String normalizedSvxlinkPathForBackend(const String& rawPath) {
  String path = trimSafe(rawPath);
  if (!path.length()) path = defaultSvxlinkPathForBackend();
  if (!path.startsWith("/")) path = "/" + path;
  if (isSvxlinkApiBackend() && path == "/index.php") path = "/api/display-json.php";
  if (isSvxlinkUrlBackend() && (path == "/api/display-json.php" || path == "/display-json.php")) path = "/index.php";
  return path;
}

String activeBackendHost() {
  if (isCurrentWatchSvxlink()) return trimSafe(cfg.svxlinkHost);
  return trimSafe(cfg.pistarHost);
}

String displayBackendHost() {
  String host = trimSafe(activeBackendHost());
  String lower = host;
  lower.toLowerCase();
  if (lower.startsWith("http://")) {
    host = host.substring(7);
  } else if (lower.startsWith("https://")) {
    host = host.substring(8);
  }
  while (host.endsWith("/") && host.length() > 1) host.remove(host.length() - 1);
  return host;
}

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 isAbsoluteHttpUrl(const String& rawUrl) {
  String u = trimSafe(rawUrl);
  u.toLowerCase();
  return u.startsWith("http://") || u.startsWith("https://");
}

String ensureLeadingSlash(const String& rawPath) {
  String path = trimSafe(rawPath);
  if (!path.length()) return "/";
  if (!path.startsWith("/")) path = "/" + path;
  return path;
}

String stripTrailingSlash(const String& rawUrl) {
  String u = trimSafe(rawUrl);
  while (u.endsWith("/") && u.length() > 8) u.remove(u.length() - 1);
  return u;
}

String svxlinkApiUrl() {
  String hostOrUrl = activeBackendHost();
  if (!hostOrUrl.length()) return "";

  String path = normalizedSvxlinkPathForBackend(cfg.svxlinkApiPath);

  if (isAbsoluteHttpUrl(hostOrUrl)) {
    String full = trimSafe(hostOrUrl);
    int schemeEnd = full.indexOf("://");
    int pathStart = (schemeEnd >= 0) ? full.indexOf('/', schemeEnd + 3) : -1;

    if (pathStart < 0) {
      return stripTrailingSlash(full) + path;
    }

    if (isSvxlinkApiBackend() && (path == "/api/display-json.php")) {
      String base = full.substring(0, pathStart);
      return stripTrailingSlash(base) + path;
    }

    return full;
  }

  return "http://" + hostOrUrl + path;
}

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 "Mode: " + mode;
      return "Listening";
    }
    return status;
  }

  String mode = normalizeModeName(rec.mode);
  if (active && mode.length()) return "Mode: " + 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);
}

String fitTextInCurrentFont(const String& input, int maxWidth) {
  String value = trimSafe(input);
  if (!value.length()) value = "-";
  if (maxWidth <= 0 || tft.textWidth(value) <= maxWidth) return value;

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

  return String("~");
}

String fitTextSliceInCurrentFont(const String& input, int maxWidth) {
  String out = "";
  if (maxWidth <= 0) return out;

  for (size_t i = 0; i < input.length(); i++) {
    String trial = out + input.charAt(i);
    if (trial.length() && tft.textWidth(trial) > maxWidth) break;
    out = trial;
  }

  while (out.length() && tft.textWidth(out) > maxWidth) {
    out.remove(out.length() - 1);
  }

  return out;
}

String scrollingTextFrameInCurrentFont(const String& input, int maxWidth) {
  String value = trimSafe(input);
  if (!value.length()) value = "-";
  if (maxWidth <= 0) return "";
  if (tft.textWidth(value) <= maxWidth) return value;

  scrollingTextActiveFrame = true;

  const String gap = "   ";
  String loopText = value + gap;
  unsigned long cycle = (unsigned long)loopText.length();
  if (!cycle) return "";

  int offset = (int)((millis() / SCROLL_TEXT_STEP_MS) % cycle);
  String rotated = loopText.substring(offset) + loopText.substring(0, offset) + value + gap;
  return fitTextSliceInCurrentFont(rotated, maxWidth);
}

void drawScrollingTextInCurrentFont(const String& value, int x, int y, int maxWidth) {
  tft.drawString(scrollingTextFrameInCurrentFont(value, maxWidth), x, y);
}

void drawCompactValueBox(int x, int y, int w, int h,
                         const String& label, const String& value,
                         int labelFont, int valueFont, int valueXOffset,
                         uint16_t labelColor, uint16_t valueColor,
                         bool scrollValue = false) {
  fillSoftPanel(x, y, w, h, theme.panel2, theme.border, 6);

  const int centerY = y + h / 2;
  tft.setTextDatum(ML_DATUM);

  String boxLabel = trimSafe(label);
  int valueX = x + valueXOffset;
  int valueW = max(UW(12), w - valueXOffset - UW(6));

  if (boxLabel.length()) {
    tft.setTextFont(labelFont);
    tft.setTextColor(labelColor, theme.panel2);
    tft.drawString(boxLabel, x + UW(8), centerY);
  } else {
    valueX = x + UW(8);
    valueW = max(UW(12), w - UW(16));
  }

  tft.setTextFont(valueFont);
  tft.setTextColor(valueColor, theme.panel2);
  String shownValue = scrollValue ? scrollingTextFrameInCurrentFont(value, valueW) : fitTextInCurrentFont(value, valueW);
  tft.drawString(shownValue, valueX, centerY);
}

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 rowY = y + UH(30);

  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 = y + UH(-4);
#else
  int flagY = y + UH(4);
#endif
  drawFlagForCallsign(flagX, flagY, flagW, flagH, flagCallsign);

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

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

  String statusText = status.length() ? status : "-";
  String targetText = target.length() ? target : "-";

  drawCompactValueBox(statusX, metaY, statusW, metaH, "", statusText,
                      1, 2, UW(8), theme.warn, active ? theme.good : theme.text, true);
  drawCompactValueBox(targetX, metaY, targetW, metaH, "", targetText,
                      1, 2, UW(8), theme.warn, theme.text, true);

  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;
}

uint8_t quickActionSlotLimit(bool svxlinkAction) {
  return svxlinkAction ? SVXLINK_ACTION_SLOT_COUNT : QUICK_ACTION_SLOT_COUNT;
}

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

bool hasConfiguredQuickActions(bool svxlinkAction) {
  uint8_t limit = quickActionSlotLimit(svxlinkAction);
  for (uint8_t i = 0; i < limit; i++) {
    if (isQuickActionSlotConfigured(i, svxlinkAction)) return true;
  }
  return false;
}

String quickActionSlotLabel(uint8_t slot, bool svxlinkAction) {
  if (slot >= quickActionSlotLimit(svxlinkAction)) return String("");
  const QuickActionSlotConfig& qa = svxlinkAction ? cfg.svxlinkActions[slot] : cfg.quickActions[slot];
  String label = trimSafe(qa.label);
  if (!label.length()) label = svxlinkAction ? (String("DTMF ") + String(slot + 1)) : (String("Action ") + String(slot + 1));
  return label;
}

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

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

  const QuickActionSlotConfig& qa = svxlinkAction ? cfg.svxlinkActions[slot] : 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, svxlinkAction ? cfg.svxlinkSshUser : cfg.pistarSshUser, 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.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;
}

String sshHostFromHostOrUrl(const String& rawHost) {
  String host = trimSafe(rawHost);
  if (!host.length()) return "";
  int schemeEnd = host.indexOf("://");
  if (schemeEnd >= 0) host = host.substring(schemeEnd + 3);
  int slash = host.indexOf('/');
  if (slash >= 0) host = host.substring(0, slash);
  int colon = host.indexOf(':');
  if (colon > 0) host = host.substring(0, colon);
  return trimSafe(host);
}

bool runSshCommandWithLogin(const String& rawHost, const String& sshUser, const String& sshPass, uint16_t sshPort,
                            const String& command, bool sendSudoPassword, String* output, String* errMsg) {
  String cleanHost = sshHostFromHostOrUrl(rawHost);
  if (!cleanHost.length()) {
    if (errMsg) *errMsg = "Missing SSH host.";
    return false;
  }
  if (!trimSafe(sshUser).length()) {
    if (errMsg) *errMsg = "Missing SSH username.";
    return false;
  }
  if (!sshPass.length()) {
    if (errMsg) *errMsg = "Missing SSH password.";
    return false;
  }

  NetworkGuard net(NETWORK_LOCK_TIMEOUT_MS);
  if (!net) {
    if (errMsg) *errMsg = "Network is busy; try again.";
    return false;
  }

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

  const char* host = cleanHost.c_str();
  const char* user = sshUser.c_str();
  const char* pass = sshPass.c_str();
  int port = (sshPort > 0) ? sshPort : 22;
  long timeoutSec = 6;
  int strictHostKey = 0;
  int verbosity = SSH_LOG_NOLOG;
  bool needSudoPassword = sendSudoPassword && trimSafe(sshUser) != "root";

  ssh_options_set(session, SSH_OPTIONS_HOST, host);
  ssh_options_set(session, SSH_OPTIONS_USER, user);
  ssh_options_set(session, SSH_OPTIONS_PORT, &port);
  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);

  int rc = ssh_connect(session);
  if (rc != SSH_OK) {
    if (errMsg) *errMsg = String("SSH connect failed: ") + ssh_get_error(session);
    ssh_free(session);
    return false;
  }

  rc = ssh_userauth_password(session, nullptr, pass);
  if (rc != SSH_AUTH_SUCCESS) {
    if (errMsg) *errMsg = String("SSH auth failed: ") + ssh_get_error(session);
    ssh_disconnect(session);
    ssh_free(session);
    return false;
  }

  ssh_channel channel = ssh_channel_new(session);
  if (channel == nullptr) {
    if (errMsg) *errMsg = "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 channel open failed: ") + ssh_get_error(session);
    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 exec failed: ") + ssh_get_error(session);
    ssh_channel_close(channel);
    ssh_channel_free(channel);
    ssh_disconnect(session);
    ssh_free(session);
    return false;
  }

  if (needSudoPassword) {
    String passLine = sshPass + "\n";
    sshWriteAll(channel, passLine.c_str(), passLine.length());
  }
  ssh_channel_send_eof(channel);

  String out;
  char buffer[160];
  int n = 0;
  do {
    n = ssh_channel_read(channel, buffer, sizeof(buffer) - 1, 0);
    if (n > 0) {
      buffer[n] = 0;
      if (out.length() < 512) out += String(buffer);
    }
    yield();
  } while (n > 0);

  do {
    n = ssh_channel_read(channel, buffer, sizeof(buffer) - 1, 1);
    if (n > 0) {
      buffer[n] = 0;
      if (out.length() < 512) out += String(buffer);
    }
    yield();
  } while (n > 0);

  unsigned long waitStartMs = millis();
  while (!ssh_channel_is_eof(channel) && millis() - waitStartMs < 10000UL) {
    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);

  if (output) *output = out;
  if (exitStatus != 0) {
    if (errMsg) *errMsg = "Remote command exit status " + String(exitStatus) + ".";
    return false;
  }
  return true;
}

bool runHotspotSshCommand(const String& command, bool sendSudoPassword, String* output, String* errMsg) {
  return runSshCommandWithLogin(cfg.pistarHost, cfg.pistarSshUser, cfg.pistarSshPass, cfg.pistarSshPort,
                                command, sendSudoPassword, output, errMsg);
}

bool runSvxlinkSshCommand(const String& command, bool sendSudoPassword, String* output, String* errMsg) {
  return runSshCommandWithLogin(cfg.svxlinkHost, cfg.svxlinkSshUser, cfg.svxlinkSshPass, cfg.svxlinkSshPort,
                                command, sendSudoPassword, output, errMsg);
}

#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, bool svxlinkAction = false) {
  if (svxlinkAction) {
    if (!svxlinkFeatureEnabled()) {
      String msg = "Enable SVXLink and set its host first.";
      if (userMsg) *userMsg = msg;
      return false;
    }
    if (!cfg.svxlinkSshEnabled) {
      String msg = "Enable SVXLink SSH in the SVXLink page first.";
      if (userMsg) *userMsg = msg;
      return false;
    }
    if (!cfg.svxlinkHost.length()) {
      String msg = "Missing SVXLink host.";
      if (userMsg) *userMsg = msg;
      return false;
    }
  } else {
    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, svxlinkAction, sendSudoPassword, &buildErr);
  String label = quickActionSlotLabel(slot, svxlinkAction);
  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 = svxlinkAction ? runSvxlinkSshCommand(command, sendSudoPassword, &output, &sshErr)
                           : 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;
  bool svxlinkAction = args ? args->svxlinkAction : false;
  delete args;

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

  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, bool svxlinkAction) {
  if (quickActionTaskRunning || modeToggleTaskRunning) {
    setFooterText("Wait for the current SSH action to finish.");
    return false;
  }

  if (!isQuickActionSlotConfigured(slot, svxlinkAction)) {
    setFooterText("Preset slot is empty.");
    return false;
  }

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

  quickActionTaskDone = false;
  quickActionTaskOk = false;
  strlcpy(quickActionTaskLabel, quickActionSlotLabel(slot, svxlinkAction).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, svxlinkAction) + ": 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 svx = isCurrentWatchSvxlink();
  bool ready = hasConfiguredQuickActions(svx);
  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) {
  String shownValue = trimSafe(value);
  if (shownValue.length() > 8) shownValue = shownValue.substring(0, 8);
  drawCompactValueBox(x, y, w, UH(24), label, shownValue,
                      2, 2, UW(30), theme.warn, theme.good);
}

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;
  }

  int dash = s.indexOf('-');
  if (dash > 0) {
    String base = s.substring(0, dash);
    bool baseHasDigit = false;
    bool baseHasLetter = false;
    for (size_t i = 0; i < base.length(); ++i) {
      if (isDigit(base[i])) baseHasDigit = true;
      if (isAlpha(base[i])) baseHasLetter = true;
    }
    if (baseHasDigit && baseHasLetter) s = base;
  }

  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 = firstNonEmpty(String(obj["country"] | ""), String(obj["country_name"] | ""));
  entry.city = trimSafe(String(obj["city"] | ""));
  entry.state = firstNonEmpty(String(obj["state"] | ""), String(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;

  NetworkGuard net(NETWORK_LOCK_TIMEOUT_MS);
  if (!net) return false;

  WiFiClientSecure client;
  client.setInsecure();

  HTTPClient http;
  http.setConnectTimeout(HTTP_CONNECT_TIMEOUT_MS);
  http.setTimeout(HTTP_READ_TIMEOUT_MS);
  http.useHTTP10(true);
  http.setReuse(false);
  if (!http.begin(client, url)) {
    client.stop();
    return false;
  }

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

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

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

  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 (normalizeModeName(r.mode) == "SVXLINK") displayName = cleanSvxlinkTargetValue(displayName);
  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()) { saveActiveHeardCache(); 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()) { saveActiveHeardCache(); 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();


String getLastHeardFilterLabel() {
  switch (lastHeardFilter) {
    case LAST_HEARD_FILTER_DMR: return "DMR";
    case LAST_HEARD_FILTER_DSTAR: return "DSTAR";
    case LAST_HEARD_FILTER_YSF: return "YSF";
    case LAST_HEARD_FILTER_P25: return "P25";
    case LAST_HEARD_FILTER_NXDN: return "NXDN";
    case LAST_HEARD_FILTER_POCSAG: return "POCSAG";
    case LAST_HEARD_FILTER_ALL:
    default: return "ALL";
  }
}

String getLastHeardSortLabel() {
  switch (lastHeardSort) {
    case LAST_HEARD_SORT_CALLSIGN: return "CALL";
    case LAST_HEARD_SORT_MODE: return "MODE";
    case LAST_HEARD_SORT_NEWEST:
    default: return "NEWEST";
  }
}

bool heardRecordMatchesLastHeardFilter(const HeardRecord& rec) {
  if (lastHeardFilter == LAST_HEARD_FILTER_ALL) return true;
  String mode = normalizeModeName(rec.mode);
  if (lastHeardFilter == LAST_HEARD_FILTER_DMR) return mode == "DMR";
  if (lastHeardFilter == LAST_HEARD_FILTER_DSTAR) return mode == "DSTAR";
  if (lastHeardFilter == LAST_HEARD_FILTER_YSF) return mode == "YSF";
  if (lastHeardFilter == LAST_HEARD_FILTER_P25) return mode == "P25";
  if (lastHeardFilter == LAST_HEARD_FILTER_NXDN) return mode == "NXDN";
  if (lastHeardFilter == LAST_HEARD_FILTER_POCSAG) return mode == "POCSAG";
  return true;
}

static int compareLastHeardField(const String& a, const String& b) {
  String aa = a;
  String bb = b;
  aa.toUpperCase();
  bb.toUpperCase();
  return aa.compareTo(bb);
}

int buildLastHeardVisibleIndices(int* outIndices, int maxCount) {
  if (!outIndices || maxCount <= 0) return 0;
  int count = 0;
  for (int i = 0; i < heardCount && count < maxCount; i++) {
    if (heardRecordMatchesLastHeardFilter(heardList[i])) outIndices[count++] = i;
  }

  if (lastHeardSort == LAST_HEARD_SORT_NEWEST || count <= 1) return count;

  for (int i = 0; i < count - 1; i++) {
    for (int j = i + 1; j < count; j++) {
      bool swapNeeded = false;
      const HeardRecord& a = heardList[outIndices[i]];
      const HeardRecord& b = heardList[outIndices[j]];

      if (lastHeardSort == LAST_HEARD_SORT_CALLSIGN) {
        int cmp = compareLastHeardField(a.callsign, b.callsign);
        if (cmp == 0) cmp = compareLastHeardField(a.timeUtc, b.timeUtc);
        swapNeeded = cmp > 0;
      } else if (lastHeardSort == LAST_HEARD_SORT_MODE) {
        int cmp = compareLastHeardField(normalizeModeName(a.mode), normalizeModeName(b.mode));
        if (cmp == 0) cmp = compareLastHeardField(a.callsign, b.callsign);
        if (cmp == 0) cmp = compareLastHeardField(a.timeUtc, b.timeUtc);
        swapNeeded = cmp > 0;
      }

      if (swapNeeded) {
        int tmp = outIndices[i];
        outIndices[i] = outIndices[j];
        outIndices[j] = tmp;
      }
    }
  }

  return count;
}

void normalizeLastHeardBrowseState() {
  int indices[MAX_HEARD];
  int visibleCount = buildLastHeardVisibleIndices(indices, MAX_HEARD);
  int maxPage = visibleCount > 0 ? ((visibleCount - 1) / LAST_HEARD_ROWS_PER_PAGE) : 0;
  if (lastHeardPageIndex < 0) lastHeardPageIndex = 0;
  if (lastHeardPageIndex > maxPage) lastHeardPageIndex = maxPage;
}

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 bool svx = svxlinkFeatureEnabled();
  const char* labels[4] = {svx ? "WATCH" : "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() {
  if (isSvxlinkBackend()) {
    drawSvxlinkLivePage();
    return;
  }

  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(104), "TX", txFreq);
  drawQuickActionLauncherBox(UX(120), UY(190), UW(80));
  drawFrequencyBox(UX(206), UY(190), UW(104), "RX", rxFreq);

  drawFooter();
}

void drawLastPage() {
  if (isSvxlinkBackend()) {
    drawSvxlinkHeardPage();
    return;
  }

  drawTopTabs();
  normalizeLastHeardBrowseState();

  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 rows / Filter, sort and browse pages", UX(16), UY(64));

  int visibleIndices[MAX_HEARD];
  int visibleCount = buildLastHeardVisibleIndices(visibleIndices, MAX_HEARD);
  int pageCount = visibleCount > 0 ? ((visibleCount + LAST_HEARD_ROWS_PER_PAGE - 1) / LAST_HEARD_ROWS_PER_PAGE) : 0;
  int pageStart = lastHeardPageIndex * LAST_HEARD_ROWS_PER_PAGE;
  int rows = max(0, min((int)LAST_HEARD_ROWS_PER_PAGE, visibleCount - pageStart));

  const int navY = UY(76);
  fillSoftPanel(UX(14), navY, UW(24), UH(20), theme.panel2, theme.border, 6);
  fillSoftPanel(UX(44), navY, UW(68), UH(20), theme.panel2, theme.border, 6);
  fillSoftPanel(UX(118), navY, UW(96), UH(20), theme.panel2, theme.border, 6);
  fillSoftPanel(UX(220), navY, UW(56), UH(20), theme.panel2, theme.border, 6);
  fillSoftPanel(UX(282), navY, UW(24), UH(20), theme.panel2, theme.border, 6);

  tft.setTextDatum(MC_DATUM);
  tft.setTextFont(2);
  tft.setTextColor(lastHeardPageIndex > 0 ? theme.text : theme.subtext, theme.panel2);
  tft.drawString("<", UX(26), navY + UH(10));
  tft.setTextColor(theme.warn, theme.panel2);
  tft.drawString(getLastHeardFilterLabel(), UX(78), navY + UH(10));
  tft.setTextColor(theme.text, theme.panel2);
  tft.drawString(getLastHeardSortLabel(), UX(166), navY + UH(10));
  tft.setTextColor(theme.subtext, theme.panel2);
  String pageText = visibleCount > 0 ? (String(lastHeardPageIndex + 1) + "/" + String(max(1, pageCount))) : String("--");
  tft.drawString(pageText, UX(248), navY + UH(10));
  tft.setTextColor((lastHeardPageIndex + 1) < pageCount ? theme.text : theme.subtext, theme.panel2);
  tft.drawString(">", UX(294), navY + UH(10));

  int y = UY(104);
  tft.setTextDatum(TL_DATUM);

  for (int row = 0; row < rows; row++) {
    HeardRecord& r = heardList[visibleIndices[pageStart + row]];
    uint16_t lineBg = (row % 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.setTextColor(theme.accent, lineBg);
    tft.drawString(clipWithTilde(normalizeHamCallsign(r.callsign), 9), UX(20), y + UY(5));

    tft.setTextColor(theme.warn, lineBg);
    tft.drawString(clipWithTilde(normalizeModeName(r.mode), 6), UX(92), y + UY(5));

    tft.setTextColor(theme.text, lineBg);
    tft.drawString(clipWithTilde(resolveTargetDisplay(r), 13), UX(136), y + UY(5));

    tft.setTextColor(theme.subtext, lineBg);
    String timeText = r.timeUtc.length() > 11 ? r.timeUtc.substring(11) : r.timeUtc;
    tft.drawString(clipWithTilde(timeText, 6), UX(250), y + UY(5));

    y += UH(28);
  }

  if (visibleCount == 0) {
    tft.setTextColor(theme.subtext, theme.panel);
    tft.setTextFont(2);
    tft.drawString(heardCount == 0 ? "No traffic received yet" : "No rows match this filter", UX(18), UY(118));
  }

  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() {
  drawTopTabs();

  const int cardX = UX(6);
  const int cardY = UY(36);
  const int cardW = SCREEN_W - UW(12);
  const int cardH = UH(184);

  fillSoftPanel(cardX, cardY, cardW, cardH, theme.panel2, theme.border, 12);
  fillSoftPanel(cardX + UW(8), cardY + UH(8), cardW - UW(16), UH(42), theme.panel, theme.border, 10);

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

  drawInfoRow(cardX + UW(14), cardY + UH(58), cardW - UW(28), "DEVICE", getDeviceCallsign(), theme.panel, theme.panel2, theme.warn, theme.good);
  drawInfoRow(cardX + UW(14), cardY + UH(80), cardW - UW(28), "UPTIME", formatUptimeBrief(), theme.panel, theme.panel2, theme.warn, theme.text);
  drawInfoRow(cardX + UW(14), cardY + UH(102), cardW - UW(28), "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(14), cardY + UH(124), cardW - UW(28), "RSSI B/W", rssiSummary, theme.panel, theme.panel2, theme.warn, theme.text);

  int miniY = cardY + UH(150);
  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(14) + i * UW(70);
    fillSoftPanel(x, miniY, miniW, UH(28), theme.panel, theme.border, 8);
    tft.setTextFont(1);
    tft.setTextColor(theme.warn, theme.panel);
    tft.drawString(labels[i], x + UW(6), miniY + UH(5));
    tft.setTextFont(2);
    tft.setTextColor(theme.text, theme.panel);
    tft.drawString(String(values[i]), x + UW(6), miniY + UH(15));
  }

  drawFooter();
}

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:", normalizeHamCallsign(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 bool svx = activeQuickActionIsSvxlink;
  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(svx ? "Send DTMF / Preset" : "Run a saved remote preset", popupX + popupW / 2, popupY + UH(28));

  uint8_t limit = quickActionSlotLimit(svx);
  for (uint8_t i = 0; i < limit; 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, svx);
    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, svx), 13) : String("Empty");
    tft.drawString(label, bx + btnW / 2, by + btnH / 2);
  }

  tft.setTextDatum(TL_DATUM);
}

void drawWatchSourcePopup() {
  const int popupW = UW(248);
  const int popupH = UH(110);
  const int popupX = (SCREEN_W - popupW) / 2;
  const int popupY = (SCREEN_H - popupH) / 2;
  const int btnW = UW(92);
  const int btnH = UH(30);
  const int mmdvmX = popupX + UW(24);
  const int svxX = popupX + popupW - btnW - UW(24);
  const int btnY = popupY + UH(52);
  const int closeX = popupX + UW(78);
  const int closeY = popupY + popupH - UH(24);
  const int closeW = UW(92);
  const int closeH = UH(18);

  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("WATCH SOURCE", popupX + popupW / 2, popupY + UH(20));
  tft.setTextFont(1);
  tft.setTextColor(theme.subtext, theme.panel2);
  tft.drawString(watchSourcePopupTargetPage == PAGE_LAST ? "Open heard list for" : "Open live page for", popupX + popupW / 2, popupY + UH(36));

  fillSoftPanel(mmdvmX, btnY, btnW, btnH, theme.panel, currentWatchSource == WATCH_MMDVM ? theme.good : theme.border, 8);
  fillSoftPanel(svxX, btnY, btnW, btnH, theme.panel, currentWatchSource == WATCH_SVXLINK ? theme.good : theme.border, 8);
  tft.setTextFont(2);
  tft.setTextColor(theme.text, theme.panel);
  tft.drawString("MMDVM", mmdvmX + btnW / 2, btnY + btnH / 2);
  tft.drawString("SVXLINK", svxX + btnW / 2, btnY + btnH / 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(2);
  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() {
  drawTopTabs();

  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));

  drawFooter();
}

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 || watchSourcePopupOpen;
  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();
  }

  scrollingTextActiveFrame = false;

  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();
  }
  if (watchSourcePopupOpen) {
    drawWatchSourcePopup();
  }

  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(14336);
  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 rawBackend = String(doc["backend"] | "");
  String backend = canonicalBackendValue(rawBackend);
  String rawBackendLower = trimSafe(rawBackend);
  rawBackendLower.toLowerCase();
  if (!backend.length() && rawBackendLower.indexOf("svxlink") >= 0) {
    backend = "Pi-Star";
    doc["svxlink_enabled"] = true;
    doc["svxlink_source"] = (rawBackendLower.indexOf("api") >= 0) ? "api" : "url";
  }
  String wifiSsid = trimSafe(String(doc["wifi_ssid"] | ""));
  String mmdvmHost = trimSafe(String(doc["pistar_host"] | ""));
  bool backendOk = backend.length() > 0;
  bool hostOk = mmdvmHost.length() > 0;
  if (backendOk) doc["backend"] = backend;
  if (!backendOk || !wifiSsid.length() || !hostOk) {
    if (errMsg) *errMsg = "Config is missing required fields (backend, wifi_ssid and 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(14336);

  doc["backend"] = cfg.backend;
  doc["wifi_ssid"] = cfg.wifiSsid;
  doc["wifi_pass"] = cfg.wifiPass;
  doc["pistar_host"] = cfg.pistarHost;
  doc["svxlink_enabled"] = cfg.svxlinkEnabled;
  doc["svxlink_source"] = cfg.svxlinkSource;
  doc["svxlink_host"] = cfg.svxlinkHost;
  doc["svxlink_api_path"] = cfg.svxlinkApiPath;
  doc["svxlink_api_token"] = cfg.svxlinkApiToken;
  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;
  doc["svxlink_ssh_enabled"] = cfg.svxlinkSshEnabled;
  doc["svxlink_ssh_user"] = cfg.svxlinkSshUser;
  doc["svxlink_ssh_pass"] = cfg.svxlinkSshPass;
  doc["svxlink_ssh_port"] = cfg.svxlinkSshPort;
  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;
  }
  JsonArray sva = doc.createNestedArray("svxlink_actions");
  for (uint8_t i = 0; i < SVXLINK_ACTION_SLOT_COUNT; i++) {
    JsonObject item = sva.createNestedObject();
    item["enabled"] = cfg.svxlinkActions[i].enabled;
    item["use_sudo"] = cfg.svxlinkActions[i].useSudo;
    item["label"] = cfg.svxlinkActions[i].label;
    item["command"] = cfg.svxlinkActions[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(14336);

  doc["backend"] = "Pi-Star";
  doc["wifi_ssid"] = "YOUR_WIFI";
  doc["wifi_pass"] = "YOUR_PASSWORD";
  doc["pistar_host"] = "192.168.1.50";
  doc["svxlink_enabled"] = false;
  doc["svxlink_source"] = "url";
  doc["svxlink_host"] = "192.168.1.60";
  doc["svxlink_api_path"] = "/index.php";
  doc["svxlink_api_token"] = "";
  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;
  doc["svxlink_ssh_enabled"] = false;
  doc["svxlink_ssh_user"] = "svxlink";
  doc["svxlink_ssh_pass"] = "";
  doc["svxlink_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"] = "";
  }
  JsonArray sva = doc.createNestedArray("svxlink_actions");
  for (uint8_t i = 0; i < SVXLINK_ACTION_SLOT_COUNT; i++) {
    JsonObject item = sva.createNestedObject();
    item["enabled"] = false;
    item["use_sudo"] = false;
    item["label"] = i == 0 ? "DTMF 1" : (i == 1 ? "DTMF 2" : (i == 2 ? "DTMF 3" : "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() {
  bool backendOk = isValidBackendValue(cfg.backend);
  bool hostOk = cfg.pistarHost.length() > 0;
  return cfg.wifiSsid.length() > 0 && hostOk && backendOk;
}


bool loadConfig() {
  setDefaultTheme();

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

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

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

  String rawBackendConfig = String(doc["backend"] | "");
  String rawBackendLower = trimSafe(rawBackendConfig);
  rawBackendLower.toLowerCase();
  bool migratedSvxBackend = rawBackendLower.indexOf("svxlink") >= 0;
  cfg.backend        = canonicalBackendValue(rawBackendConfig);
  if (!cfg.backend.length()) cfg.backend = "Pi-Star";
  cfg.wifiSsid       = String(doc["wifi_ssid"] | "");
  cfg.wifiPass       = String(doc["wifi_pass"] | "");
  cfg.pistarHost     = String(doc["pistar_host"] | "");
  cfg.svxlinkEnabled = doc.containsKey("svxlink_enabled") ? (bool)doc["svxlink_enabled"] : migratedSvxBackend;
  String loadedSvxSource = String(doc["svxlink_source"] | "url");
  if (!doc.containsKey("svxlink_source") && migratedSvxBackend && rawBackendLower.indexOf("api") >= 0) loadedSvxSource = "api";
  cfg.svxlinkSource  = canonicalSvxlinkSourceValue(loadedSvxSource);
  if (!cfg.svxlinkSource.length()) cfg.svxlinkSource = "url";
  cfg.svxlinkHost    = String(doc["svxlink_host"] | "");
  cfg.svxlinkApiPath = String(doc["svxlink_api_path"] | "/index.php");
  cfg.svxlinkApiToken = String(doc["svxlink_api_token"] | "");
  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;
  cfg.svxlinkSshEnabled = doc["svxlink_ssh_enabled"] | false;
  cfg.svxlinkSshUser = String(doc["svxlink_ssh_user"] | "svxlink");
  cfg.svxlinkSshPass = String(doc["svxlink_ssh_pass"] | "");
  cfg.svxlinkSshPort = doc["svxlink_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 = "";
  }
  for (uint8_t i = 0; i < SVXLINK_ACTION_SLOT_COUNT; i++) {
    cfg.svxlinkActions[i].enabled = false;
    cfg.svxlinkActions[i].useSudo = false;
    cfg.svxlinkActions[i].label = "";
    cfg.svxlinkActions[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++;
    }
  }
  if (doc.containsKey("svxlink_actions") && doc["svxlink_actions"].is<JsonArray>()) {
    JsonArray qa = doc["svxlink_actions"].as<JsonArray>();
    uint8_t i = 0;
    for (JsonObject item : qa) {
      if (i >= SVXLINK_ACTION_SLOT_COUNT) break;
      cfg.svxlinkActions[i].enabled = item["enabled"] | false;
      cfg.svxlinkActions[i].useSudo = item["use_sudo"] | false;
      cfg.svxlinkActions[i].label = String(item["label"] | "");
      cfg.svxlinkActions[i].command = String(item["command"] | "");
      i++;
    }
  }

  cfg.wifiSsid = trimSafe(cfg.wifiSsid);

  cfg.pistarHost = trimSafe(cfg.pistarHost);
  cfg.svxlinkSource = canonicalSvxlinkSourceValue(cfg.svxlinkSource);
  if (!cfg.svxlinkSource.length()) cfg.svxlinkSource = "url";
  cfg.svxlinkHost = trimSafe(cfg.svxlinkHost);
  cfg.svxlinkApiPath = normalizedSvxlinkPathForBackend(cfg.svxlinkApiPath);
  cfg.svxlinkApiToken = trimSafe(cfg.svxlinkApiToken);
  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);
  cfg.svxlinkSshUser = trimSafe(cfg.svxlinkSshUser);
  if (!cfg.radioIdUsersApiBase.length()) cfg.radioIdUsersApiBase = String(RADIOID_USERS_API_BASE_DEFAULT);
  if (!cfg.pistarSshUser.length()) cfg.pistarSshUser = "pi-star";
  if (!cfg.svxlinkSshUser.length()) cfg.svxlinkSshUser = "svxlink";
  if (cfg.pistarSshPort == 0) cfg.pistarSshPort = 22;
  if (cfg.svxlinkSshPort == 0) cfg.svxlinkSshPort = 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;
    }
  }
  for (uint8_t i = 0; i < SVXLINK_ACTION_SLOT_COUNT; i++) {
    cfg.svxlinkActions[i].label = trimSafe(cfg.svxlinkActions[i].label);
    cfg.svxlinkActions[i].command = trimSafe(cfg.svxlinkActions[i].command);
    if (!cfg.svxlinkActions[i].label.length() || !cfg.svxlinkActions[i].command.length()) {
      cfg.svxlinkActions[i].enabled = false;
    }
  }
  if (!svxlinkFeatureEnabled() && currentWatchSource == WATCH_SVXLINK) currentWatchSource = WATCH_MMDVM;

  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() && isValidBackendValue(cfg.backend);
}

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

static inline bool runtimeFlipScreenEnabled() {
  return cfg.flipScreen;
}

static inline uint8_t currentDisplayRotation() {
  return runtimeFlipScreenEnabled() ? DISPLAY_ROTATION_FLIPPED : DISPLAY_ROTATION_NORMAL;
}

void applyPanelOrientation() {
  tft.setRotation(currentDisplayRotation());
}

void applyDisplaySettings() {
  applyPanelOrientation();
  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) {
    handleWebClientSafely();
    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 canonical = canonicalBackendValue(current);
  if (!canonical.length()) canonical = "Pi-Star";
  String row = "<label><span>" + htmlEscape(label) + "</span><select name='" + htmlEscape(name) + "'>";
  row += "<option value='Pi-Star'";
  if (canonical == "Pi-Star") row += " selected";
  row += ">Pi-Star</option>";
  row += "<option value='WPSD'";
  if (canonical == "WPSD") row += " selected";
  row += ">WPSD</option>";
  row += "</select></label>";
  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, const String& hint = "") {
  String row = "<div class='form-row'>";
  row += "<label for='" + name + "'>" + label + "</label>";
  row += "<div>";
  row += "<label class='toggle-row'><input type='checkbox' id='" + name + "' name='" + name + "' value='1'";
  if (checked) row += " checked";
  row += ">Enable</label>";
  if (hint.length()) row += "<div class='mini-note' style='margin:8px 0 0 0'>" + htmlEscape(hint) + "</div>";
  row += "</div></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 buildTopbarBrandHtml() {
  String s;
  s.reserve(240);
  s += "<div class='topbar-left'>";
  s += "<button class='sidebar-toggle' type='button' aria-label='Open navigation' onclick='toggleSidebar(true)'>&#9776;</button>";
  s += "<div><div class='brand'>MMDVM Display</div><div class='brand-sub'>Version " + String(DISPLAY_VERSION) + " by 13MAD86 / Martin</div></div>";
  s += "</div>";
  return s;
}

String buildSidebarMobileCss() {
  return R"SIDEBARCSS(*{scrollbar-width:none;-ms-overflow-style:none}*::-webkit-scrollbar{width:0;height:0;display:none}.topbar-left{display:flex;align-items:center;gap:12px;min-width:0}.sidebar-toggle,.sidebar-close{appearance:none;-webkit-appearance:none;border:1px solid var(--line);background:rgba(255,255,255,.05);color:#fff;border-radius:14px;cursor:pointer;transition:background .18s ease,border-color .18s ease,transform .18s ease}.sidebar-toggle{display:none;align-items:center;justify-content:center;width:44px;height:44px;padding:0;font-size:22px;line-height:1;box-shadow:none}.sidebar-toggle:hover,.sidebar-close:hover{background:rgba(255,255,255,.10);border-color:rgba(255,255,255,.22);transform:translateY(-1px)}.sidebar-backdrop{display:none}.sidebar-mobile-head{display:none}body.sidebar-open{overflow:hidden}@media(max-width:1080px){.topbar{padding:14px 16px}.topbar-left{width:100%;align-items:center}.brand{font-size:24px}.brand-sub{font-size:12px}.sidebar-toggle{display:inline-flex;flex:0 0 auto}.sidebar-backdrop{display:block;position:fixed;inset:0;background:rgba(2,6,23,.58);opacity:0;visibility:hidden;pointer-events:none;transition:opacity .26s ease,visibility .26s ease;z-index:90}.sidebar{position:fixed !important;top:0 !important;left:0;width:min(82vw,300px) !important;max-width:300px;height:100dvh !important;padding:16px 14px 22px !important;border-right:1px solid var(--line) !important;border-bottom:none !important;overflow:auto;transform:translateX(-104%);opacity:.35;transition:transform .28s ease,opacity .28s ease;z-index:100;box-shadow:0 24px 60px rgba(2,6,23,.48)}.sidebar-mobile-head{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:8px;padding:4px 6px 12px;border-bottom:1px solid var(--line2)}.sidebar-mobile-title{font-size:12px;text-transform:uppercase;letter-spacing:.16em;color:var(--muted);font-weight:800}.sidebar-close{display:inline-flex;align-items:center;justify-content:center;width:38px;height:38px;padding:0;font-size:26px;line-height:1}.side-title{margin-left:6px}.topbar-right{width:100%;justify-content:flex-start}.header-actions{justify-content:flex-start}.btn-compact{min-height:42px}.content{padding:18px}.page-head{flex-direction:column}.stats-grid,.chart-grid,.mini-grid,.form-grid,.theme-grid,.startup-grid,.quick-grid{grid-template-columns:1fr}body.sidebar-open .sidebar{transform:translateX(0);opacity:1}body.sidebar-open .sidebar-backdrop{opacity:1;visibility:visible;pointer-events:auto}}</style>)SIDEBARCSS";
}

String buildSidebarMobileScript() {
  return R"SIDEBARSCRIPT(<script>(function(){function mobileNav(){return window.matchMedia('(max-width: 1080px)').matches;}window.toggleSidebar=function(open){if(!mobileNav()){document.body.classList.remove('sidebar-open');return;}document.body.classList.toggle('sidebar-open',!!open);};window.addEventListener('resize',function(){if(!mobileNav())document.body.classList.remove('sidebar-open');});window.addEventListener('keydown',function(e){if(e.key==='Escape')document.body.classList.remove('sidebar-open');});document.addEventListener('click',function(e){var target=e.target;if(!target||!target.closest)return;var link=target.closest('.sidebar .side-link');if(link&&mobileNav())document.body.classList.remove('sidebar-open');});})();</script>)SIDEBARSCRIPT";
}


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(1600);
  s += "<div class='sidebar-backdrop' onclick='toggleSidebar(false)'></div>";
  s += "<aside class='sidebar'>";
  s += "<div class='sidebar-mobile-head'><div class='sidebar-mobile-title'>Navigation</div><button class='sidebar-close' type='button' aria-label='Close navigation' onclick='toggleSidebar(false)'>&times;</button></div>";
  s += "<div class='side-title' style='margin-top:18px'>Configuration</div>";
  if (onRoot) {
    s += navItem("system", "&#128223; System mode");
    s += navItem("mmdvm", "&#128246; MMDVM");
    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; System mode");
    s += navHrefItem("/#mmdvm", "&#128246; MMDVM");
    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/svxlink", "&#128199; SVXLink", activeApp == "svxlink");
  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 = canonicalBackendValue(cfg.backend);
  if (!backend.length()) 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 += buildSidebarMobileCss();
  page += "</head><body>";

  page += "<div class='topbar'>" + buildTopbarBrandHtml();
  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>";
  page += buildSidebarMobileScript();
  page += "</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 = canonicalBackendValue(cfg.backend);
  if (!backend.length()) 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 += buildSidebarMobileCss();
  page += "</head><body>";

  page += "<div class='topbar'>" + buildTopbarBrandHtml();
  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, "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>";
  page += buildSidebarMobileScript();
  page += "</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 = canonicalBackendValue(cfg.backend);
  if (!backend.length()) 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 += buildSidebarMobileCss();
  page += "</head><body>";

  page += "<div class='topbar'>" + buildTopbarBrandHtml();
  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,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 only the application .bin compiled for this exact ESP32-S3 board and partition layout. Do not upload .ino, .zip, merged-flash .bin, bootloader .bin, partitions .bin or .bin.gz files.</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>";
  page += buildSidebarMobileScript();
  page += "</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 = "";
    otaHeaderChecked = false;
    otaBytesWritten = 0;

    String fn = upload.filename;
    fn.toLowerCase();
    addWebLog("INFO", "OTA", "OTA upload started: " + upload.filename);

    if (!fn.endsWith(".bin")) {
      otaLastError = "Invalid OTA file. Upload only the application .bin file, not .ino, .zip, .bin.gz, bootloader, partitions, or merged-flash files.";
      addWebLog("ERROR", "OTA", otaLastError);
      return;
    }

    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 (!otaHeaderChecked) {
        otaHeaderChecked = true;
        if (upload.currentSize < 1 || upload.buf[0] != 0xE9) {
          otaLastError = "Invalid ESP32 application image. The first byte is not 0xE9. Do not upload source, zip, gzip, bootloader, partition-table, or merged-flash images via OTA.";
          Update.abort();
          addWebLog("ERROR", "OTA", otaLastError);
          return;
        }
      }

      size_t written = Update.write(upload.buf, upload.currentSize);
      otaBytesWritten += written;
      if (written != upload.currentSize) {
        otaLastError = otaErrorText();
        addWebLog("ERROR", "OTA", "OTA write failed: " + otaLastError);
      }
    }
  } else if (upload.status == UPLOAD_FILE_END) {
    if (!otaLastError.length()) {
      if (otaBytesWritten == 0) {
        otaLastError = "OTA upload ended with zero bytes written.";
        Update.abort();
        addWebLog("ERROR", "OTA", otaLastError);
      } else if (Update.end(true)) {
        otaUpdateSuccessful = true;
        otaRestartAt = millis() + 3500UL;
        addWebLog("INFO", "OTA", "OTA image written successfully (" + String((uint32_t)otaBytesWritten) + " bytes). Reboot scheduled.");
      } else {
        otaLastError = otaErrorText();
        addWebLog("ERROR", "OTA", "OTA finalize failed: " + otaLastError);
      }
    }
  } else if (upload.status == UPLOAD_FILE_ABORTED) {
    otaLastError = "Upload aborted.";
    Update.abort();
    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 += buildSidebarMobileCss();
  page += "</head><body>";

  page += "<div class='topbar'>" + buildTopbarBrandHtml();
  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>";
  page += buildSidebarMobileScript();
  page += "</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 += buildSidebarMobileCss();
  page += "</head><body>";

  page += "<div class='topbar'>" + buildTopbarBrandHtml();
  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.1.2</div><div class='version-meta'>SVXLink monitor and scrolling polish</div></div></div><ul class='change-list'><li><span class='tag tag-added'>Added</span>SVXLink Monitor as an experimental live monitor page.</li><li><span class='tag tag-fixed'>Fixed</span>Network access is now serialized with a recursive mutex to prevent lwIP/FreeRTOS socket crashes when WebServer, HTTP/HTTPS and SSH features overlap.</li><li><span class='tag tag-fixed'>Fixed</span>HTTP/HTTPS polling now closes sockets more aggressively, including RadioID, weather and Last Heard lookups.</li><li><span class='tag tag-improved'>Improved</span>Web UI scrollbars are now hidden while pages and sidebars remain scrollable.</li><li><span class='tag tag-improved'>Improved</span>Live page drawLiveInfoPanel status and target values now scroll when too long.</li><li><span class='tag tag-fixed'>Fixed</span>Enabled WPSD Actions.</li><li><span class='tag tag-improved'>Improved</span>Increased the font size for the screensaver target display.</li><li><span class='tag tag-improved'>Improved</span>Status and target values stay inside their boxes, and longer text now scrolls.</li></ul><div class='release-summary'>Focus: experimental SVXLink monitoring, WPSD action fixes, network stability, cleaner browser UI, larger screensaver target text and long-text readability.</div></article><article class='timeline-card'><div class='version-head'><div><div class='version-title'>v0.0.1.1</div><div class='version-meta'>Live display readability and target display fixes</div></div></div><ul class='change-list'><li><span class='tag tag-improved'>Improved</span>Live Info status and target boxes now use the same boxed display style as RX/TX with larger, easier-to-read text.</li><li><span class='tag tag-improved'>Improved</span>Removed the internal STATUS and TARGET labels so the live values have more room inside their boxes.</li><li><span class='tag tag-improved'>Improved</span>Moved the flag and callsign slightly upward to give the status and target area more room.</li><li><span class='tag tag-improved'>Improved</span>RX/TX now displays up to 8 characters instead of 7 and includes a small gap between the RX/TX label and the frequency.</li><li><span class='tag tag-fixed'>Fixed</span>Fixed TG TG xxx by stripping already-present target prefixes before adding display labels.</li><li><span class='tag tag-improved'>Improved</span>Removed the ROOM prefix for YSF/Fusion target display across tabs.</li><li><span class='tag tag-improved'>Improved</span>Gave the Heard tab target/DG-ID field more horizontal space.</li><li><span class='tag tag-fixed'>Fixed</span>Restored the top tab bar on the Info tab.</li><li><span class='tag tag-fixed'>Fixed</span>Removed the old Info-tab &ldquo;tap anywhere to go back&rdquo; behavior so the visible tabs work normally.</li></ul><div class='release-summary'>Focus: easier live-screen reading, longer RX/TX labels and cleaner target/tab behavior.</div></article><article class='timeline-card'><div class='version-head'><div><div class='version-title'>v0.0.1.0</div><div class='version-meta'>CYD ST7789 touch orientation support</div></div></div><ul class='change-list'><li><span class='tag tag-added'>Added</span>Separate CYD touch transform settings for ILI9341 and ST7789 panels.</li><li><span class='tag tag-added'>Added</span>Selectable CYD TFT_eSPI controller support for ILI9341 and ST7789 panels.</li></ul><div class='release-summary'>Focus: CYD panel and touch compatibility plus easier on-device browsing of recent traffic.</div></article><article class='timeline-card'><div class='version-head'><div><div class='version-title'>v0.0.0.9</div><div class='version-meta'>Fixes and Improved</div></div></div><ul class='change-list'><li><span class='tag tag-improved'>Improved</span>Last Heard browsing with paging, filter and sort controls on the device.</li><li><span class='tag tag-fixed'>Fixed</span>Web UI minor bug fixes.</li><li><span class='tag tag-fixed'>Fixed</span>Stats fixed for DMR records.</li></ul><div class='release-summary'>Focus: CYD panel and touch compatibility plus easier on-device browsing of recent traffic.</div></article><article class='timeline-card'><div class='version-head'><div><div class='version-title'>v0.0.0.8</div><div class='version-meta'>OTA, logs and quick presets</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>";
  page += buildSidebarMobileScript();
  page += "</body></html>";
  server.send(200, "text/html", page);
}

void handleRoot() {
  String ip = wifiOk ? WiFi.localIP().toString() : (apModeActive ? WiFi.softAPIP().toString() : "not connected");
  String backend = canonicalBackendValue(cfg.backend);
  if (!backend.length()) {
    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 += buildSidebarMobileCss();
  page += "</head><body>";

  page += "<div class='topbar'>" + buildTopbarBrandHtml();
  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>&#9881;&#65039; 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'>MMDVM Host</span><span class='status-val'>" + htmlEscape(cfg.pistarHost) + "</span></div>";
  page += "<div class='status-row'><span class='status-key'>SVXLink Host</span><span class='status-val'>" + htmlEscape(cfg.svxlinkHost) + "</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; System mode</h2><p>Select the MMDVM dashboard source. SVXLink is configured on its own page and can be enabled as an extra WATCH source.</p></div>";
  page += "<div class='section-body'><div class='form-grid'>";
  page += selectRow("Active backend", "backend", backend);
  page += formRow("Device callsign", "device_callsign", cfg.deviceCallsign, "text", DEVICE_CALLSIGN);
  page += "</div><div class='mini-note'>Pi-Star and WPSD are used for the MMDVM LIVE/HEARD pages. Enable SVXLink separately under Application > SVXLink.</div>";
  page += "</div></section>";

  page += "<section class='section' id='mmdvm'>";
  page += "<div class='section-head'><h2>&#128246; MMDVM settings</h2><p>Pi-Star / WPSD host and SSH remote-control settings.</p></div>";
  page += "<div class='section-body'><div class='form-grid'>";
  page += formRow("Pi-Star / WPSD Host or IP", "pistar_host", cfg.pistarHost, "text", "192.168.1.50");
  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><div class='mini-note'>These settings are used only when the active backend is Pi-Star or WPSD. The password is stored locally on the display, so use it only 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. These presets are available from the touchscreen ACTIONS popup on the live page.</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, these presets are launched from the fast <span class='mono'>ACTIONS</span> popup on the live page.</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 += buildSidebarMobileScript();
  page += "</body></html>";

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

void handleSave() {
  DynamicJsonDocument doc(14336);

  cfg.backend = canonicalBackendValue(server.arg("backend"));
  if (!cfg.backend.length()) {
    cfg.backend = "Pi-Star";
  }
  cfg.wifiSsid = server.arg("wifi_ssid");
  cfg.wifiPass = server.arg("wifi_pass");
  cfg.pistarHost = trimSafe(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["svxlink_enabled"] = cfg.svxlinkEnabled;
  doc["svxlink_source"] = cfg.svxlinkSource;
  doc["svxlink_host"] = cfg.svxlinkHost;
  doc["svxlink_api_path"] = cfg.svxlinkApiPath;
  doc["svxlink_api_token"] = cfg.svxlinkApiToken;
  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;
  doc["svxlink_ssh_enabled"] = cfg.svxlinkSshEnabled;
  doc["svxlink_ssh_user"] = cfg.svxlinkSshUser;
  doc["svxlink_ssh_pass"] = cfg.svxlinkSshPass;
  doc["svxlink_ssh_port"] = cfg.svxlinkSshPort;
  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;
  }
  JsonArray sva = doc.createNestedArray("svxlink_actions");
  for (uint8_t i = 0; i < SVXLINK_ACTION_SLOT_COUNT; i++) {
    JsonObject item = sva.createNestedObject();
    item["enabled"] = cfg.svxlinkActions[i].enabled;
    item["use_sudo"] = cfg.svxlinkActions[i].useSudo;
    item["label"] = cfg.svxlinkActions[i].label;
    item["command"] = cfg.svxlinkActions[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 += buildSidebarMobileCss();
  page += "</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 += buildSidebarMobileScript();
  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 += buildSidebarMobileCss();
  page += "</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 += buildSidebarMobileScript();
  page += "</body></html>";
  server.send(isError ? 400 : 200, "text/html", page);
}

void sendNoCacheHeaders() {
  server.sendHeader("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0");
  server.sendHeader("Pragma", "no-cache");
  server.sendHeader("Expires", "0");
}

String pageTop(const String& title, const String& activeNav, const String& subtitle) {
  String ip = wifiOk ? WiFi.localIP().toString() : (apModeActive ? WiFi.softAPIP().toString() : "not connected");
  String backend = canonicalBackendValue(cfg.backend);
  if (!backend.length()) backend = "Pi-Star";
  String accentHex = cfg.themeColor.length() ? cfg.themeColor : String("#35C2FF");

  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;--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,.sticky-actions{display:flex;justify-content:flex-end;gap:12px;padding-top:8px;flex-wrap:wrap}";
  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;border:1px solid rgba(255,255,255,.18);background:var(--accent);box-shadow:0 12px 24px rgba(0,0,0,.18)}";
  page += ".alert-card{background:rgba(248,113,113,.12);border:1px solid rgba(248,113,113,.32);border-radius:20px;padding:16px 18px;margin-bottom:18px;color:#fecaca}";
  page += ".alert-card strong{color:#fff}";
  page += ".mono{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}";
  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,.quick-grid,.theme-grid,.startup-grid{grid-template-columns:1fr}.topbar{position:relative}.topbar-right{width:100%;justify-content:flex-start}.header-actions{justify-content:flex-start}.hero-card{align-items:flex-start}.hero-pill{white-space:normal}}";
  page += buildSidebarMobileCss();
  page += "</head><body>";

  page += "<div class='topbar'>" + buildTopbarBrandHtml();
  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, activeNav);
  page += "<main class='content'>";
  page += "<div class='page-head'><div><h1>" + title + "</h1><div class='page-sub'>" + htmlEscape(subtitle) + "</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></div>";
  return page;
}

String pageBottom() {
  String page;
  page += "</main></div>";
  page += buildSidebarMobileScript();
  page += "</body></html>";
  return page;
}

String svxlinkSourceSelectRow() {
  String source = normalizedSvxlinkSource();
  String row = "<div class='form-row'>";
  row += "<label for='svxlink_source'>SVXLink source</label>";
  row += "<select id='svxlink_source' name='svxlink_source'>";
  row += "<option value='url'";
  if (source == "url") row += " selected";
  row += ">URL / dashboard HTML</option>";
  row += "<option value='api'";
  if (source == "api") row += " selected";
  row += ">API / JSON bridge</option>";
  row += "</select></div>";
  return row;
}

String renderSvxlinkActionRows() {
  const char* prefix = "svxlink_action_";
  QuickActionSlotConfig* slots = cfg.svxlinkActions;
  const uint8_t count = SVXLINK_ACTION_SLOT_COUNT;
  String html;
  for (uint8_t i = 0; i < count; i++) {
    String base = String(prefix) + String(i + 1) + "_";
    html += "<div class='quick-card'><h3>Slot " + String(i + 1) + "</h3>";
    html += checkboxRow("Enabled", base + "enabled", slots[i].enabled, "Enable this saved remote action.");
    html += checkboxRow("Use sudo", base + "sudo", slots[i].useSudo, "Prefix this command with sudo on the remote system.");
    html += formRow("Button label", base + "label", slots[i].label, "text", "DTMF 1");
    html += formRow("SSH command / DTMF command", base + "command", slots[i].command, "text", "svxlink command here");
    html += "</div>";
  }
  return html;
}

void readSvxlinkActionRowsFromForm() {
  const char* prefix = "svxlink_action_";
  QuickActionSlotConfig* slots = cfg.svxlinkActions;
  const uint8_t count = SVXLINK_ACTION_SLOT_COUNT;
  for (uint8_t i = 0; i < count; i++) {
    String base = String(prefix) + String(i + 1) + "_";
    slots[i].enabled = server.hasArg(base + "enabled");
    slots[i].useSudo = server.hasArg(base + "sudo");
    slots[i].label = trimSafe(server.arg(base + "label"));
    slots[i].command = trimSafe(server.arg(base + "command"));
    if (!slots[i].label.length() || !slots[i].command.length()) slots[i].enabled = false;
  }
}

void handleSvxlinkPage() {
  sendNoCacheHeaders();
  String page = pageTop("&#128199; SVXLink", "svxlink", "Configure SVXLink as an optional WATCH/HEARD source with its own API/URL and SSH action buttons.");

  page += "<form id='svxlink-form' method='POST' action='/app/svxlink'>";
  page += "<section class='section' id='svxlink-source'>";
  page += "<div class='section-head'><h2>&#128199; SVXLink watch source</h2><p>Enable SVXLink separately from the MMDVM backend. When enabled, LIVE becomes WATCH and lets you choose MMDVM or SVXLink.</p></div>";
  page += "<div class='section-body'><div class='form-grid'>";
  page += checkboxRow("Enable SVXLink", "svxlink_enabled", cfg.svxlinkEnabled, "Adds SVXLink as a second WATCH/HEARD source.");
  page += svxlinkSourceSelectRow();
  page += formRow("SVXLink Host/IP or full URL", "svxlink_host", cfg.svxlinkHost, "text", "192.168.1.60 or https://host/index.php");
  page += formRow("SVXLink URL/API path", "svxlink_api_path", cfg.svxlinkApiPath, "text", isSvxlinkApiBackend() ? "/api/display-json.php" : "/index.php");
  page += formRow("SVXLink API token", "svxlink_api_token", cfg.svxlinkApiToken, "password");
  page += "</div><div class='mini-note'><strong>URL</strong> reads the existing dashboard HTML. <strong>API</strong> uses the display-json bridge and optional token header.</div></div></section>";

  page += "<section class='section' id='svxlink-ssh'>";
  page += "<div class='section-head'><h2>&#128272; SVXLink SSH login</h2><p>Separate SSH credentials for SVXLink actions / DTMF commands.</p></div>";
  page += "<div class='section-body'><div class='form-grid'>";
  page += checkboxRow("Enable SVXLink SSH actions", "svxlink_ssh_enabled", cfg.svxlinkSshEnabled, "Allow the display to run the SVXLink action buttons over SSH.");
  page += formRow("SVXLink SSH user", "svxlink_ssh_user", cfg.svxlinkSshUser, "text", "svxlink");
  page += formRow("SVXLink SSH password", "svxlink_ssh_pass", cfg.svxlinkSshPass, "password");
  page += formRow("SVXLink SSH port", "svxlink_ssh_port", String(cfg.svxlinkSshPort), "number", "22");
  page += "</div><div class='mini-note'>The password is stored locally on the display, so use this only on trusted hardware and networks.</div></div></section>";

  page += "<section class='section' id='svxlink-actions'>";
  page += "<div class='section-head'><h2>&#128241; SVXLink actions / DTMF</h2><p>These buttons appear on the SVXLink live page and run through the SVXLink SSH login above.</p></div>";
  page += "<div class='section-body'><div class='quick-grid'>";
  page += renderSvxlinkActionRows();
  page += "</div><div class='mini-note'>Each slot runs the exact command you enter over SSH. Fill in both a label and command before enabling a slot.</div></div></section>";

  page += "<div class='footer-actions' style='margin-top:18px'>";
  page += "<button class='btn btn-secondary' type='submit' form='svxlink-form'>&#128190; Save configuration</button>";
  page += "</div></form>";
  page += pageBottom();
  server.send(200, "text/html", page);
}

void handleSaveSvxlink() {
  cfg.svxlinkEnabled = server.hasArg("svxlink_enabled");
  cfg.svxlinkSource = canonicalSvxlinkSourceValue(server.arg("svxlink_source"));
  if (!cfg.svxlinkSource.length()) cfg.svxlinkSource = "url";
  cfg.svxlinkHost = trimSafe(server.arg("svxlink_host"));
  cfg.svxlinkApiPath = normalizedSvxlinkPathForBackend(server.arg("svxlink_api_path"));
  cfg.svxlinkApiToken = server.arg("svxlink_api_token");
  cfg.svxlinkSshEnabled = server.hasArg("svxlink_ssh_enabled");
  cfg.svxlinkSshUser = trimSafe(server.arg("svxlink_ssh_user"));
  if (!cfg.svxlinkSshUser.length()) cfg.svxlinkSshUser = "svxlink";
  cfg.svxlinkSshPass = server.arg("svxlink_ssh_pass");
  int svxlinkPortValue = server.arg("svxlink_ssh_port").toInt();
  cfg.svxlinkSshPort = (svxlinkPortValue <= 0) ? 22 : (uint16_t)constrain(svxlinkPortValue, 1, 65535);
  readSvxlinkActionRowsFromForm();

  if (!svxlinkFeatureEnabled() && currentWatchSource == WATCH_SVXLINK) setCurrentWatchSource(WATCH_MMDVM);
  bool ok = saveConfigToFile();
  if (ok) applyConfigAfterLoad();
  addWebLog(ok ? "INFO" : "ERROR", "CONFIG", ok ? "SVXLink settings saved from Web UI." : "SVXLink settings save failed.");

  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 += buildSidebarMobileCss();
  page += "</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 += buildSidebarMobileScript();
  page += "</body></html>";
  server.send(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, false);
  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("/app/svxlink", HTTP_GET, handleSvxlinkPage);
  server.on("/app/svxlink", HTTP_POST, handleSaveSvxlink);
  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;
  lastHeardPageIndex = 0;
  for (int i = 0; i < MAX_HEARD; i++) heardList[i] = HeardRecord();
}

void copyCurrentHeardStateTo(HeardRecord* dstList, int& dstCount, HeardRecord& dstLive, unsigned long& dstLiveMillis) {
  dstCount = heardCount;
  for (int i = 0; i < MAX_HEARD; i++) dstList[i] = heardList[i];
  dstLive = liveRecord;
  dstLiveMillis = liveRecordMillis;
}

void restoreHeardStateFrom(HeardRecord* srcList, int srcCount, const HeardRecord& srcLive, unsigned long srcLiveMillis) {
  heardCount = constrain(srcCount, 0, MAX_HEARD);
  for (int i = 0; i < MAX_HEARD; i++) heardList[i] = (i < heardCount) ? srcList[i] : HeardRecord();
  liveRecord = srcLive;
  liveRecordMillis = srcLiveMillis;
  normalizeLastHeardBrowseState();
}

void saveActiveHeardCache() {
  if (currentWatchSource == WATCH_SVXLINK) copyCurrentHeardStateTo(svxlinkHeardList, svxlinkHeardCount, svxlinkLiveRecord, svxlinkLiveRecordMillis);
  else copyCurrentHeardStateTo(mmdvmHeardList, mmdvmHeardCount, mmdvmLiveRecord, mmdvmLiveRecordMillis);
}

void loadActiveHeardCache() {
  if (currentWatchSource == WATCH_SVXLINK) restoreHeardStateFrom(svxlinkHeardList, svxlinkHeardCount, svxlinkLiveRecord, svxlinkLiveRecordMillis);
  else restoreHeardStateFrom(mmdvmHeardList, mmdvmHeardCount, mmdvmLiveRecord, mmdvmLiveRecordMillis);
}

void setCurrentWatchSource(WatchSource source) {
  if (source == WATCH_SVXLINK && !svxlinkFeatureEnabled()) source = WATCH_MMDVM;
  if (source == currentWatchSource) {
    loadActiveHeardCache();
    return;
  }
  saveActiveHeardCache();
  currentWatchSource = source;
  loadActiveHeardCache();
  lastPoll = 0;
  nextFetchHeard = false;
  requestRedraw();
}


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, const String& e) {
  if (trimSafe(a).length()) return trimSafe(a);
  if (trimSafe(b).length()) return trimSafe(b);
  if (trimSafe(c).length()) return trimSafe(c);
  if (trimSafe(d).length()) return trimSafe(d);
  return trimSafe(e);
}

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 "";
  if (mode == "P25" || mode == "NXDN") return "NET";
  if (mode == "SVXLINK") return "";
  return "DST";
}

bool targetPrefixSeparator(char c) {
  return c == ' ' || c == ':' || c == '-' || c == '#' || c == '\t';
}

String stripTargetLabelPrefix(String value, const String& label) {
  value = trimSafe(value);
  String cleanLabel = trimSafe(label);
  if (!value.length() || !cleanLabel.length()) return value;

  String upperValue = value;
  String upperLabel = cleanLabel;
  upperValue.toUpperCase();
  upperLabel.toUpperCase();

  if (upperValue == upperLabel) return "";
  if (!upperValue.startsWith(upperLabel)) return value;

  int pos = cleanLabel.length();
  if (pos >= (int)value.length()) return "";

  char nextChar = value.charAt(pos);
  if (targetPrefixSeparator(nextChar)) {
    while (pos < (int)value.length() && targetPrefixSeparator(value.charAt(pos))) pos++;
  } else if (!(nextChar >= '0' && nextChar <= '9')) {
    return value;
  }

  return trimSafe(value.substring(pos));
}

String cleanSvxlinkTargetValue(String value) {
  value = trimSafe(value);
  if (!value.length()) return value;

  value = stripTargetLabelPrefix(value, "DST TG");
  value = stripTargetLabelPrefix(value, "DST");
  value = stripTargetLabelPrefix(value, "TG");
  return trimSafe(value);
}

String cleanTargetValueForMode(String value, const String& mode) {
  value = trimSafe(value);
  if (!value.length()) return value;

  if (mode == "DMR") return stripTargetLabelPrefix(value, "TG");
  if (mode == "DSTAR" || mode == "D-STAR") return stripTargetLabelPrefix(value, "REF");
  if (mode == "YSF" || mode == "FUSION") return stripTargetLabelPrefix(value, "ROOM");
  if (mode == "P25" || mode == "NXDN") return stripTargetLabelPrefix(value, "NET");
  if (mode == "SVXLINK") return cleanSvxlinkTargetValue(value);
  return stripTargetLabelPrefix(value, "DST");
}

String joinTargetDisplay(const String& label, const String& target, const String& name) {
  String cleanLabel = trimSafe(label);
  String cleanTarget = trimSafe(target);
  String cleanName = trimSafe(name);

  String upperTarget = cleanTarget;
  String upperName = cleanName;
  upperTarget.toUpperCase();
  upperName.toUpperCase();
  if (upperTarget.length() && upperName == upperTarget) cleanName = "";

  String out = "";

  if (cleanLabel.length()) out += cleanLabel;
  if (cleanTarget.length()) {
    if (out.length()) out += " ";
    out += cleanTarget;
  }
  if (cleanName.length()) {
    if (out.length()) out += " ";
    out += cleanName;
  }
  return out.length() ? out : String("-");
}

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

  if (mode == "SVXLINK") {
    if (explicitName.length()) return explicitName;
    return rawTarget.length() ? rawTarget : String("-");
  }

  if (mode == "DMR") {
    String mapped = lookupCommonTalkgroupName(rawTarget);
    if (!explicitName.length() && mapped.length()) explicitName = mapped;
    return joinTargetDisplay(label, rawTarget, explicitName);
  }

  return joinTargetDisplay(label, rawTarget, explicitName);
}

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>();
    parseSvxlinkStatusFromObject(root);
    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"] | ""), String(obj["time_local"] | "")));
    r.mode      = firstNonEmpty(String(obj["mode"] | ""), String(obj["module"] | ""), String(obj["source_mode"] | ""));
    r.callsign  = normalizeHamCallsign(firstNonEmpty(String(obj["callsign"] | ""), String(obj["callsign_long"] | ""), String(obj["call"] | ""), String(obj["station"] | "")));
    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"] | ""), String(obj["tg_name"] | ""));
    r.src       = firstNonEmpty(String(obj["src"] | ""), String(obj["source"] | ""), String(obj["source_system"] | ""));
    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    = firstNonEmpty(String(obj["rxFreq"] | ""), String(obj["rx_freq"] | ""), String(obj["RXFrequency"] | ""), String(obj["rx_frequency"] | ""));
    r.txFreq    = firstNonEmpty(String(obj["txFreq"] | ""), String(obj["tx_freq"] | ""), String(obj["TXFrequency"] | ""), String(obj["tx_frequency"] | ""));
    if (parsingSvxlinkData && !r.mode.length()) r.mode = "SVXLink";
    if (parsingSvxlinkData && !r.src.length()) r.src = "SVXRef";
    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(parsingSvxlinkData ? String("SVXLink API OK, no entries") : String("API OK, no entries"), !parsingSvxlinkData);
    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 if (!parsingSvxlinkData) {
      liveRecordMillis = millis();
    }
  }

  String okFooter = parsingSvxlinkData ? (String("SVXLink API updated from ") + displayBackendHost()) : (String("Updated from ") + displayBackendHost());
  setFooterText(okFooter, !parsingSvxlinkData);
  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, const String& accept = "", const String& token = "") {
  payload = "";
  codeOut = 0;

  NetworkGuard net(NETWORK_LOCK_TIMEOUT_MS);
  if (!net) {
    codeOut = -98;
    return false;
  }

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

  bool began = false;
  String lowerUrl = trimSafe(url);
  lowerUrl.toLowerCase();

  WiFiClient plainClient;
  WiFiClientSecure secureClient;
  if (lowerUrl.startsWith("https://")) {
    secureClient.setInsecure();
    began = http.begin(secureClient, url);
  } else {
    began = http.begin(plainClient, url);
  }

  if (!began) {
    codeOut = -1;
    plainClient.stop();
    secureClient.stop();
    return false;
  }

  http.setUserAgent(String("MMDVM-Display/") + DISPLAY_VERSION);
  http.addHeader("Connection", "close");
  if (accept.length()) http.addHeader("Accept", accept);
  if (token.length()) http.addHeader("X-Display-Token", token);

  codeOut = http.GET();
  if (codeOut == 200) payload = http.getString();

  http.end();
  plainClient.stop();
  secureClient.stop();
  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();

  String payload;
  int code = 0;
  bool httpOk = httpGetString(url, payload, code, "application/json");
  weatherState.lastHttpCode = code;
  if (!httpOk) {
    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, payload, DeserializationOption::Filter(filter));
  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;
}



String svxCleanDashboardText(String s) {
  s = htmlDecodeBasic(s);
  s.replace("\n", " ");
  s.replace("\r", " ");
  s.replace("\t", " ");
  s.trim();
  while (s.indexOf("  ") >= 0) s.replace("  ", " ");
  return s;
}

bool svxExtractModeFromText(const String& text, String& modeOut) {
  int p = text.indexOf("Mode:");
  if (p < 0) return false;
  int start = p + 5;
  while (start < (int)text.length() && text[start] == ' ') start++;
  int end = start;
  while (end < (int)text.length()) {
    char c = text[end];
    if (c == ' ' || c == '<' || c == '&') break;
    end++;
  }
  modeOut = text.substring(start, end);
  modeOut.trim();
  return modeOut.length() > 0;
}

bool svxExtractLastRebootFromHtml(const String& html, String& rebootOut) {
  int p = html.indexOf("Last Reboot");
  if (p < 0) return false;
  int end = html.indexOf("</td>", p);
  if (end < 0) end = min(p + 160, (int)html.length());
  String txt = extractHtmlText(html.substring(p, end));
  txt.replace("Last Reboot", "");
  txt.trim();
  rebootOut = svxCleanDashboardText(txt);
  return rebootOut.length() > 0;
}

bool svxExtractTableItems(const String& html, const String& title, String items[], uint8_t maxItems, uint8_t& countOut) {
  countOut = 0;
  int titlePos = html.indexOf(title);
  if (titlePos < 0) return false;
  int tableEnd = html.indexOf("</table>", titlePos);
  if (tableEnd < 0) return false;

  int pos = titlePos;
  while (countOut < maxItems) {
    int td = html.indexOf("<td", pos);
    if (td < 0 || td > tableEnd) break;
    int gt = html.indexOf(">", td);
    if (gt < 0 || gt > tableEnd) break;
    int tdEnd = html.indexOf("</td>", gt);
    if (tdEnd < 0 || tdEnd > tableEnd) break;

    String item = svxCleanDashboardText(extractHtmlText(html.substring(gt + 1, tdEnd)));
    if (item.length() && item != "No Modules") {
      items[countOut++] = item;
    }
    pos = tdEnd + 5;
  }
  return countOut > 0;
}

bool svxExtractModules(const String& html) {
  svxlinkModuleCount = 0;
  int titlePos = html.indexOf("Modules Loaded");
  if (titlePos < 0) return false;
  int tableEnd = html.indexOf("</table>", titlePos);
  if (tableEnd < 0) return false;

  int pos = titlePos;
  while (svxlinkModuleCount < SVXLINK_MAX_MODULES) {
    int td = html.indexOf("<td", pos);
    if (td < 0 || td > tableEnd) break;
    int gt = html.indexOf(">", td);
    if (gt < 0 || gt > tableEnd) break;
    int tdEnd = html.indexOf("</td>", gt);
    if (tdEnd < 0 || tdEnd > tableEnd) break;

    String item = svxCleanDashboardText(extractHtmlText(html.substring(gt + 1, tdEnd)));
    if (item.length() && item != "No Modules") {
      String openTag = html.substring(td, min(gt + 1, (int)html.length()));
      svxlinkModules[svxlinkModuleCount].name = item;
      svxlinkModules[svxlinkModuleCount].active = (openTag.indexOf("MediumSeaGreen") >= 0 || openTag.indexOf("green") >= 0);
      svxlinkModuleCount++;
    }
    pos = tdEnd + 5;
  }
  return svxlinkModuleCount > 0;
}

uint8_t svxCollectTdTexts(const String& rowHtml, String cells[], uint8_t maxCells) {
  uint8_t count = 0;
  int pos = 0;
  while (count < maxCells) {
    int td = rowHtml.indexOf("<td", pos);
    if (td < 0) break;
    int gt = rowHtml.indexOf(">", td);
    if (gt < 0) break;
    int tdEnd = rowHtml.indexOf("</td>", gt);
    if (tdEnd < 0) break;
    cells[count++] = svxCleanDashboardText(extractHtmlText(rowHtml.substring(gt + 1, tdEnd)));
    pos = tdEnd + 5;
  }
  return count;
}

bool svxApplyParsedHeardList(HeardRecord newList[], int newCount, const String& okFooter) {
  applyRadioIdCacheToList(newList, newCount);
  inferModeStatusesFromHeard(newList, newCount);

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

  if (newCount == 0) {
    if (heardCount != 0) {
      clearHeard();
      requestRedraw();
    }
    setFooterText(okFooter.length() ? okFooter : String("SVXLink dashboard OK, no entries"), !parsingSvxlinkData);
    return true;
  }

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

  if (newCount > 0) {
    queueRadioIdLookupForRecord(newList[0]);
    if (!sameRecord(liveRecord, newList[0])) {
      liveRecord = newList[0];
      updateSessionInfoFromRecord(liveRecord);
      liveRecordMillis = millis();
      requestRedraw();
      noteScreenActivity(false);
    } else if (!parsingSvxlinkData) {
      liveRecordMillis = millis();
    }
  }

  setFooterText(okFooter.length() ? okFooter : String("SVXLink dashboard OK"), !parsingSvxlinkData);
  return true;
}

bool svxParseLastHeardFromHtml(const String& html) {
  int marker = html.indexOf("SVXReflector Activity");
  if (marker < 0) marker = html.indexOf("LastHeard");
  if (marker < 0) return false;

  int tableStart = html.indexOf("<table", marker);
  if (tableStart < 0) return false;
  int tableEnd = html.indexOf("</table>", tableStart);
  if (tableEnd < 0) return false;

  HeardRecord newList[MAX_HEARD];
  int newCount = 0;

  int pos = tableStart;
  while (newCount < MAX_HEARD) {
    int tr = html.indexOf("<tr", pos);
    if (tr < 0 || tr > tableEnd) break;
    int trEnd = html.indexOf("</tr>", tr);
    if (trEnd < 0 || trEnd > tableEnd) break;

    String row = html.substring(tr, trEnd + 5);
    String cells[8];
    uint8_t cellCount = svxCollectTdTexts(row, cells, 8);

    if (cellCount >= 5) {
      String timeText = cells[0];
      String callText = cells[1];
      String tgText = cells[2];
      String sourceText = (cellCount >= 5) ? cells[4] : "";
      String tgName = (cellCount >= 6) ? cells[5] : "";

      callText.trim();
      tgText.trim();
      sourceText.trim();
      tgName.trim();

      if (callText.length() && callText != "Callsign" && tgText.length() && tgText != "TG #") {
        HeardRecord r;
        r.timeUtc = normalizeHeardTimeDisplay(timeText);
        r.mode = "SVXLink";
        r.callsign = normalizeHamCallsign(callText);
        r.target = tgText;
        if (!r.target.startsWith("TG")) r.target = "TG " + r.target;
        r.targetName = tgName;
        r.src = sourceText.length() ? sourceText : String("SVXRef");
        r.valid = true;
        newList[newCount++] = r;
      }
    }

    pos = trEnd + 5;
  }

  return svxApplyParsedHeardList(newList, newCount, "SVXLink dashboard OK");
}

bool parseSvxlinkDashboardHtml(const String& payload) {
  if (payload.indexOf("SVXLink Info") < 0 && payload.indexOf("SVXReflector Activity") < 0 && payload.indexOf("Dashboard") < 0) {
    lastError = "SVXLink dashboard HTML not recognized";
    return false;
  }

  String oldRadioStatus = svxlinkRadioStatus;
  String oldReflectorStatus = svxlinkReflectorStatus;
  String oldActiveTg = svxlinkActiveTg;
  String oldMonitor = svxlinkMonitorTgs;

  String text = svxCleanDashboardText(extractHtmlText(payload));

  if (!svxlinkNetworkName.length()) svxlinkNetworkName = activeBackendHost();

  String val;
  if (extractTableCellAfterHeader(payload, "TG Default", val)) svxlinkDefaultTg = val;
  if (extractTableCellAfterHeader(payload, "TG Monitor", val)) svxlinkMonitorTgs = val;
  if (extractTableCellAfterHeader(payload, "TG Active", val)) svxlinkActiveTg = val;
  if (extractTableCellAfterHeader(payload, "Radio Status", val) || extractTableCellAfterHeader(payload, "Repeater Status", val)) svxlinkRadioStatus = val;
  if (svxExtractModeFromText(text, val)) svxlinkModeText = val;
  if (svxExtractLastRebootFromHtml(payload, val)) svxlinkLastReboot = val;

  if (text.indexOf("Not connected") >= 0) {
    svxlinkReflectorStatus = "Not connected";
  } else if (text.indexOf("No status") >= 0) {
    svxlinkReflectorStatus = "No status";
  } else if (text.indexOf("Connected") >= 0) {
    svxlinkReflectorStatus = "Connected";
  }

  svxExtractTableItems(payload, "Active Logics", svxlinkLogics, SVXLINK_MAX_LOGICS, svxlinkLogicCount);
  svxExtractModules(payload);

  if (!svxlinkCallsign.length()) {
    int p = text.indexOf("Dashboard ");
    if (p >= 0) {
      int start = p + 10;
      int end = text.indexOf(" Host:", start);
      if (end < 0) end = text.indexOf(" Lokale Zeit:", start);
      if (end > start) {
        svxlinkCallsign = text.substring(start, end);
        svxlinkCallsign.trim();
      }
    }
  }

  svxlinkLastSuccessMillis = millis();
  bool statusChanged = oldRadioStatus != svxlinkRadioStatus || oldReflectorStatus != svxlinkReflectorStatus ||
                       oldActiveTg != svxlinkActiveTg || oldMonitor != svxlinkMonitorTgs;

  bool heardOk = svxParseLastHeardFromHtml(payload);
  if (statusChanged) {
    requestRedraw();
    noteScreenActivity(false);
  }
  return heardOk || svxlinkRadioStatus.length() || svxlinkReflectorStatus.length() || svxlinkLogicCount > 0;
}


void resetSvxlinkState() {
  svxlinkCallsign = "";
  svxlinkNetworkName = "";
  svxlinkRadioStatus = "";
  svxlinkReflectorStatus = "";
  svxlinkModeText = "";
  svxlinkDefaultTg = "";
  svxlinkActiveTg = "";
  svxlinkMonitorTgs = "";
  svxlinkLastReboot = "";
  svxlinkSystemText = "";
  svxlinkLogicCount = 0;
  svxlinkModuleCount = 0;
}

String joinJsonStringArray(JsonArray arr, const String& sep = ", ") {
  String out;
  for (JsonVariant v : arr) {
    String item = trimSafe(String(v | ""));
    if (!item.length()) continue;
    if (out.length()) out += sep;
    out += item;
  }
  return out;
}

void parseSvxlinkStatusFromObject(JsonObject root) {
  bool looksLikeSvxlink = root.containsKey("radio_status") || root.containsKey("reflector_status") ||
                          root.containsKey("tg") || root.containsKey("last_heard") ||
                          root.containsKey("api") || root.containsKey("backend") ||
                          root.containsKey("active_logics") || root.containsKey("logics");
  if (!looksLikeSvxlink) return;

  String oldRadioStatus = svxlinkRadioStatus;
  String oldReflectorStatus = svxlinkReflectorStatus;
  String oldActiveTg = svxlinkActiveTg;
  String oldMonitor = svxlinkMonitorTgs;

  JsonObject systemObj;
  if (root["system"].is<JsonObject>()) systemObj = root["system"].as<JsonObject>();
  bool hasSystemObj = !systemObj.isNull();

  svxlinkCallsign = firstNonEmpty(String(root["callsign"] | ""), String(root["node"] | ""), svxlinkCallsign);
  svxlinkNetworkName = firstNonEmpty(String(root["network_name"] | ""), String(root["network"] | ""), String(root["fmnetwork"] | ""), String(root["host"] | ""), svxlinkNetworkName);
  svxlinkRadioStatus = firstNonEmpty(String(root["radio_status"] | ""), String(root["status"] | ""), svxlinkRadioStatus);
  svxlinkReflectorStatus = firstNonEmpty(String(root["reflector_status"] | ""), String(root["svxreflector_status"] | ""), svxlinkReflectorStatus);
  svxlinkModeText = firstNonEmpty(String(root["mode"] | ""), String(root["logic_mode"] | ""), hasSystemObj ? String(systemObj["mode"] | "") : String(""), svxlinkModeText);
  svxlinkLastReboot = firstNonEmpty(String(root["last_reboot"] | ""), String(root["uptime_since"] | ""), hasSystemObj ? String(systemObj["reboot"] | "") : String(""), svxlinkLastReboot);
  svxlinkSystemText = firstNonEmpty(String(root["system_text"] | ""), hasSystemObj ? String(systemObj["process"] | "") : String(""), svxlinkSystemText);

  if (root["tg"].is<JsonObject>()) {
    JsonObject tg = root["tg"].as<JsonObject>();
    svxlinkDefaultTg = firstNonEmpty(String(tg["default"] | ""), svxlinkDefaultTg);
    svxlinkActiveTg = firstNonEmpty(String(tg["active"] | ""), svxlinkActiveTg);
    if (tg["monitor"].is<JsonArray>()) {
      svxlinkMonitorTgs = joinJsonStringArray(tg["monitor"].as<JsonArray>(), " ");
    } else {
      svxlinkMonitorTgs = firstNonEmpty(String(tg["monitor"] | ""), svxlinkMonitorTgs);
    }
  } else {
    svxlinkDefaultTg = firstNonEmpty(String(root["tg_default"] | ""), String(root["default_tg"] | ""), svxlinkDefaultTg);
    svxlinkActiveTg = firstNonEmpty(String(root["tg_active"] | ""), String(root["active_tg"] | ""), svxlinkActiveTg);
    if (root["tg_monitor"].is<JsonArray>()) {
      svxlinkMonitorTgs = joinJsonStringArray(root["tg_monitor"].as<JsonArray>(), " ");
    } else {
      svxlinkMonitorTgs = firstNonEmpty(String(root["tg_monitor"] | ""), String(root["monitor_tgs"] | ""), svxlinkMonitorTgs);
    }
  }

  svxlinkLogicCount = 0;
  JsonArray logicArray;
  if (root["active_logics"].is<JsonArray>()) logicArray = root["active_logics"].as<JsonArray>();
  else if (root["logics"].is<JsonArray>()) logicArray = root["logics"].as<JsonArray>();
  if (!logicArray.isNull()) {
    for (JsonVariant v : logicArray) {
      if (svxlinkLogicCount >= SVXLINK_MAX_LOGICS) break;
      String item = trimSafe(String(v | ""));
      if (!item.length()) continue;
      svxlinkLogics[svxlinkLogicCount++] = item;
    }
  }

  svxlinkModuleCount = 0;
  if (root["modules"].is<JsonArray>()) {
    for (JsonObject mod : root["modules"].as<JsonArray>()) {
      if (svxlinkModuleCount >= SVXLINK_MAX_MODULES) break;
      String name = trimSafe(String(mod["name"] | ""));
      if (!name.length()) continue;
      svxlinkModules[svxlinkModuleCount].name = name;
      svxlinkModules[svxlinkModuleCount].active = mod["active"] | false;
      svxlinkModuleCount++;
    }
  } else if (root["modules_loaded"].is<JsonArray>()) {
    for (JsonVariant v : root["modules_loaded"].as<JsonArray>()) {
      if (svxlinkModuleCount >= SVXLINK_MAX_MODULES) break;
      String item = trimSafe(String(v | ""));
      if (!item.length()) continue;
      svxlinkModules[svxlinkModuleCount].name = item;
      svxlinkModules[svxlinkModuleCount].active = true;
      svxlinkModuleCount++;
    }
  }

  svxlinkLastSuccessMillis = millis();
  if (oldRadioStatus != svxlinkRadioStatus || oldReflectorStatus != svxlinkReflectorStatus ||
      oldActiveTg != svxlinkActiveTg || oldMonitor != svxlinkMonitorTgs) {
    requestRedraw();
    noteScreenActivity(false);
  }
}

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

  String url = svxlinkApiUrl();
  if (!url.length()) {
    setFooterText("SVXLink host missing", false);
    requestRedraw();
    noteApiFetchResult(false, -1, 0);
    return false;
  }

  unsigned long startMs = millis();
  String payload;
  int code = 0;
  String accept = isSvxlinkApiBackend() ? String("application/json") : String("text/html");
  String token = isSvxlinkApiBackend() ? trimSafe(cfg.svxlinkApiToken) : String("");
  bool ok = httpGetString(url, payload, code, accept, token);
  if (!ok) {
    if (code == -1) setFooterText("SVXLink HTTP begin failed", false);
    else setFooterText("SVXLink HTTP: " + String(code), false);
    requestRedraw();
    noteApiFetchResult(false, code, millis() - startMs);
    return false;
  }

  parsingSvxlinkData = true;
  if (isSvxlinkApiBackend()) {
    ok = parseHeardArray(payload);
  } else {
    ok = parseSvxlinkDashboardHtml(payload);
  }
  parsingSvxlinkData = false;
  if (ok) saveActiveHeardCache();

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

String svxCompactList(String items[], uint8_t count, uint8_t maxItems) {
  String out;
  for (uint8_t i = 0; i < count && i < maxItems; i++) {
    if (out.length()) out += ", ";
    out += items[i];
  }
  if (count > maxItems) out += " +" + String(count - maxItems);
  return out;
}

void drawSvxlinkLastHeardCard(int x, int y, int w, int h) {
  fillSoftPanel(x, y, w, h, theme.panel2, theme.border, 10);

  HeardRecord rec;
  bool hasLastHeard = false;
  if (liveRecord.valid && normalizeHamCallsign(liveRecord.callsign).length()) {
    rec = liveRecord;
    hasLastHeard = true;
  } else if (heardCount > 0 && heardList[0].valid && normalizeHamCallsign(heardList[0].callsign).length()) {
    rec = heardList[0];
    hasLastHeard = true;
  }

  String operatorName = hasLastHeard ? radioIdDisplayNameForRecord(rec, true) : String("");
  String operatorLine = hasLastHeard ? formatCallsignName(rec.callsign, operatorName, 0, 0) : String("No last heard yet");
  String targetLine = hasLastHeard ? resolveTargetDisplay(rec) : String("Waiting for SVXLink activity");
  String timeText = "";
  if (hasLastHeard && rec.timeUtc.length()) {
    timeText = rec.timeUtc.length() > 11 ? rec.timeUtc.substring(11) : rec.timeUtc;
  }

  const int flagW = 32;
  const int flagH = 32;
  int flagX = x + UW(12);
#if DISPLAY_BACKEND == DISPLAY_BACKEND_JC3248W535C
  int flagY = y + UH(11) - 3;
#else
  int flagY = y + UH(11);
#endif
  flagY = constrain(flagY, y + UH(5), y + h - flagH - UH(5));
  drawFlagForCallsign(flagX, flagY, flagW, flagH, hasLastHeard ? rec.callsign : getDeviceCallsign());

  int textX = flagX + flagW + UW(10);
  int textW = max(UW(40), w - (textX - x) - UW(12));
  int callFont = 4;
  String fittedOperator = fitLivePanelPrimaryText(operatorLine, textW, callFont);

  tft.setTextDatum(ML_DATUM);
  tft.setTextColor(hasLastHeard ? theme.warn : theme.text, theme.panel2);
  tft.setTextFont(callFont);
#if DISPLAY_BACKEND == DISPLAY_BACKEND_JC3248W535C
  int callY = y + UH(18) + (callFont >= 4 ? UH(1) : UH(0));
#else
  int callY = y + UH(18);
#endif
  tft.drawString(fittedOperator, textX, callY);

  String detailLine = targetLine;
  if (timeText.length()) detailLine += "  " + timeText;
  tft.setTextFont(1);
  tft.setTextColor(theme.subtext, theme.panel2);
  tft.drawString(scrollingTextFrameInCurrentFont(detailLine, textW), textX, y + h - UH(14));

  tft.setTextDatum(TL_DATUM);
}

void drawSvxlinkLivePage() {
  drawTopTabs();

  bool transmitting = svxlinkRadioStatus.equalsIgnoreCase("TX") || svxlinkRadioStatus.indexOf("TX") >= 0;
  bool receiving = svxlinkRadioStatus.equalsIgnoreCase("RX") || svxlinkRadioStatus.indexOf("RX") >= 0;
  uint16_t stateColor = transmitting ? theme.tx : (receiving ? theme.rx : theme.good);

  fillSoftPanel(UX(8), UY(38), UW(304), UH(34), theme.panel, theme.border, 12);
  tft.setTextDatum(TL_DATUM);
  tft.setTextFont(2);
  tft.setTextColor(theme.subtext, theme.panel);
  tft.drawString("Host: " + clipWithTilde(displayBackendHost(), 20), UX(18), UY(49));

  bool actionReady = hasConfiguredQuickActions(true);
  fillSoftPanel(UX(226), UY(44), UW(76), UH(22), theme.panel2, actionReady ? theme.good : theme.border, 8);
  tft.setTextDatum(MC_DATUM);
  tft.setTextFont(2);
  tft.setTextColor(actionReady ? theme.good : theme.subtext, theme.panel2);
  tft.drawString("ACTIONS", UX(264), UY(55));

  fillSoftPanel(UX(8), UY(78), UW(146), UH(34), theme.panel2, theme.border, 10);
  tft.setTextDatum(MC_DATUM);
  tft.setTextFont(2);
  tft.setTextColor(stateColor, theme.panel2);
  tft.drawString(clipWithTilde(svxlinkRadioStatus.length() ? svxlinkRadioStatus : "LISTEN", 12), UX(81), UY(90));
  tft.setTextFont(1);
  tft.setTextColor(theme.subtext, theme.panel2);
  tft.drawString("Radio status", UX(81), UY(105));

  fillSoftPanel(UX(166), UY(78), UW(146), UH(34), theme.panel2, theme.border, 10);
  tft.setTextFont(2);
  tft.setTextColor(theme.good, theme.panel2);
  tft.drawString(clipWithTilde(svxlinkReflectorStatus.length() ? svxlinkReflectorStatus : "Unknown", 14), UX(239), UY(90));
  tft.setTextFont(1);
  tft.setTextColor(theme.subtext, theme.panel2);
  tft.drawString("Reflector", UX(239), UY(105));

  drawSvxlinkLastHeardCard(UX(8), UY(118), UW(304), UH(50));

  fillSoftPanel(UX(8), UY(174), UW(304), UH(44), theme.panel, theme.border, 10);
  tft.setTextDatum(TL_DATUM);
  tft.setTextFont(1);
  tft.setTextColor(theme.subtext, theme.panel);
  tft.drawString("TG default", UX(18), UY(181));
  tft.drawString("TG monitor", UX(112), UY(181));
  tft.drawString("TG active", UX(230), UY(181));
  String defaultTgText = svxlinkDefaultTg.length() ? svxlinkDefaultTg : "-";
  String monitorTgsText = svxlinkMonitorTgs.length() ? svxlinkMonitorTgs : "-";
  String activeTgText = svxlinkActiveTg.length() ? svxlinkActiveTg : "-";
  tft.setTextFont(2);
  tft.setTextColor(theme.text, theme.panel);
  drawScrollingTextInCurrentFont(defaultTgText, UX(18), UY(199), UW(84));
  tft.setTextColor(theme.warn, theme.panel);
  drawScrollingTextInCurrentFont(monitorTgsText, UX(112), UY(199), UW(108));
  tft.setTextColor(theme.accent, theme.panel);
  drawScrollingTextInCurrentFont(activeTgText, UX(230), UY(199), UW(72));

  drawFooter();
}

void drawSvxlinkHeardPage() {
  drawTopTabs();
  normalizeLastHeardBrowseState();

  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("SVXLINK HEARD", UX(16), UY(46));

  tft.setTextFont(1);
  tft.setTextColor(theme.subtext, theme.panel);
  String sub = svxlinkReflectorStatus.length() ? ("Reflector: " + svxlinkReflectorStatus) : "Tap rows for details";
  tft.drawString(clipWithTilde(sub, 42), UX(16), UY(64));

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

    tft.setTextDatum(TL_DATUM);
    tft.setTextFont(2);
    tft.setTextColor(theme.accent, lineBg);
    tft.drawString(clipWithTilde(normalizeHamCallsign(r.callsign), 9), UX(20), y + UY(5));
    tft.setTextColor(theme.warn, lineBg);
    drawScrollingTextInCurrentFont(resolveTargetDisplay(r), UX(98), y + UY(5), UW(132));
    tft.setTextColor(theme.subtext, lineBg);
    String timeText = r.timeUtc.length() > 11 ? r.timeUtc.substring(11) : r.timeUtc;
    tft.drawString(clipWithTilde(timeText, 8), UX(238), y + UY(5));
    y += UH(28);
  }

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

  drawFooter();
}

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;
  String url = heardUrl();
  if (!url.length()) {
    setFooterText("Backend host missing");
    requestRedraw();
    noteApiFetchResult(false, -1, millis() - startMs);
    return false;
  }

  String payload;
  bool httpOk = httpGetString(url, payload, code, "application/json");
  if (!httpOk) {
    if (code == -1) setFooterText("HTTP begin failed");
    else if (code == -98) setFooterText("Network busy");
    else setFooterText("HTTP error: " + String(code));
    requestRedraw();
    noteApiFetchResult(false, code, millis() - startMs);
    return false;
  }

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

bool fetchAllData() {
  if (isCurrentWatchSvxlink()) {
    return fetchSvxlinkData();
  }

  bool ok = false;

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

  if (ok) saveActiveHeardCache();
  nextFetchHeard = !nextFetchHeard;
  return ok;
}


#ifndef CYD_TOUCH_SWAP_XY
  #define CYD_TOUCH_SWAP_XY 1
#endif
#ifndef CYD_TOUCH_FLIP_X
  #define CYD_TOUCH_FLIP_X 0
#endif
#ifndef CYD_TOUCH_FLIP_Y
  #define CYD_TOUCH_FLIP_Y 1
#endif

static const bool TOUCH_SWAP_XY = (CYD_TOUCH_SWAP_XY != 0);
static const bool TOUCH_FLIP_X  = (CYD_TOUCH_FLIP_X != 0);
static const bool TOUCH_FLIP_Y  = (CYD_TOUCH_FLIP_Y != 0);
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 (watchSourcePopupOpen) {
    const int popupW = UW(248);
    const int popupH = UH(110);
    const int popupX = (SCREEN_W - popupW) / 2;
    const int popupY = (SCREEN_H - popupH) / 2;
    const int btnW = UW(92);
    const int btnH = UH(30);
    const int mmdvmX = popupX + UW(24);
    const int svxX = popupX + popupW - btnW - UW(24);
    const int btnY = popupY + UH(52);
    const int closeX = popupX + UW(78);
    const int closeY = popupY + popupH - UH(24);
    const int closeW = UW(92);
    const int closeH = UH(18);

    if (pointInRect(x, y, mmdvmX, btnY, btnW, btnH)) {
      watchSourcePopupOpen = false;
      setCurrentWatchSource(WATCH_MMDVM);
      currentPage = watchSourcePopupTargetPage;
      requestRedraw();
      return;
    }
    if (pointInRect(x, y, svxX, btnY, btnW, btnH)) {
      if (!svxlinkFeatureEnabled()) {
        setFooterText("Enable SVXLink in the Web UI first.");
        watchSourcePopupOpen = false;
        requestRedraw();
        return;
      }
      watchSourcePopupOpen = false;
      setCurrentWatchSource(WATCH_SVXLINK);
      currentPage = watchSourcePopupTargetPage;
      requestRedraw();
      return;
    }
    if (pointInRect(x, y, closeX, closeY, closeW, closeH) || !pointInRect(x, y, popupX, popupY, popupW, popupH)) {
      watchSourcePopupOpen = false;
      requestRedraw();
      return;
    }
    return;
  }

  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;
    }

    uint8_t limit = quickActionSlotLimit(activeQuickActionIsSvxlink);
    for (uint8_t i = 0; i < limit; 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, activeQuickActionIsSvxlink)) {
          setFooterText("Preset slot is empty.");
          return;
        }
        bool svx = activeQuickActionIsSvxlink;
        quickActionPopupOpen = false;
        requestRedraw();
        startQuickActionTask(i, svx);
        return;
      }
    }

    quickActionPopupOpen = false;

    requestRedraw();
    return;
  }

  if (detailPopupOpen) {
    detailPopupOpen = false;
    selectedHeardIndex = -1;
    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) {
      if (svxlinkFeatureEnabled()) {
        watchSourcePopupTargetPage = PAGE_LIVE;
        watchSourcePopupOpen = true;
        requestRedraw();
        return;
      }
      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) {
    if (isCurrentWatchSvxlink()) {
      if (pointInRect(x, y, UX(226), UY(44), UW(76), UH(22))) {
        if (!hasConfiguredQuickActions(true)) {
          setFooterText("No SVXLink actions are configured yet.");
          return;
        }
        activeQuickActionIsSvxlink = true;
        quickActionPopupOpen = true;
        requestRedraw();
        return;
      }
    } else {
      String modeLabel;
      if (liveModeButtonTouchHit(x, y, modeLabel)) {
        openModeConfirmPopup(modeLabel);
        return;
      }

      if (pointInRect(x, y, UX(112), UY(190), UW(96), UH(24))) {
        if (!hasConfiguredQuickActions(false)) {
          setFooterText("No quick actions are configured yet.");
          return;
        }
        activeQuickActionIsSvxlink = false;
        quickActionPopupOpen = true;
        requestRedraw();
        return;
      }
    }
  }

  if (currentPage == PAGE_LAST)
 {
    normalizeLastHeardBrowseState();

    if (pointInRect(x, y, UX(14), UY(76), UW(24), UH(20))) {
      if (lastHeardPageIndex > 0) {
        lastHeardPageIndex--;
        requestRedraw();
      }
      return;
    }
    if (pointInRect(x, y, UX(44), UY(76), UW(68), UH(20))) {
      lastHeardFilter = (LastHeardFilter)(((int)lastHeardFilter + 1) % 7);
      lastHeardPageIndex = 0;
      requestRedraw();
      return;
    }
    if (pointInRect(x, y, UX(118), UY(76), UW(96), UH(20))) {
      lastHeardSort = (LastHeardSort)(((int)lastHeardSort + 1) % 3);
      lastHeardPageIndex = 0;
      requestRedraw();
      return;
    }

    int visibleIndices[MAX_HEARD];
    int visibleCount = buildLastHeardVisibleIndices(visibleIndices, MAX_HEARD);
    int pageCount = visibleCount > 0 ? ((visibleCount + LAST_HEARD_ROWS_PER_PAGE - 1) / LAST_HEARD_ROWS_PER_PAGE) : 0;
    if (pointInRect(x, y, UX(282), UY(76), UW(24), UH(20))) {
      if ((lastHeardPageIndex + 1) < pageCount) {
        lastHeardPageIndex++;
        requestRedraw();
      }
      return;
    }

    int pageStart = lastHeardPageIndex * LAST_HEARD_ROWS_PER_PAGE;
    int rows = max(0, min((int)LAST_HEARD_ROWS_PER_PAGE, visibleCount - pageStart));
    for (int row = 0; row < rows; row++) {
      int rowY = UY(104) + (row * UH(28));
      if (x >= UX(14) && x <= SCREEN_W - UX(14) && y >= rowY && y <= rowY + UH(24)) {
        selectedHeardIndex = visibleIndices[pageStart + row];
        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 (!networkMutex) networkMutex = xSemaphoreCreateRecursiveMutex();
  if (!networkMutex) addWebLog("WARN", "BOOT", "Network mutex allocation failed; network calls are not serialized.");

#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(DISPLAY_ROTATION_NORMAL);

#if DISPLAY_BACKEND == DISPLAY_BACKEND_2432S028R
  Serial.printf("CYD TFT controller: %s\n", DISPLAY_CONTROLLER_NAME);
  Serial.printf("CYD touch transform: swap_xy=%d flip_x=%d flip_y=%d raw_x=%d..%d raw_y=%d..%d\n",
                TOUCH_SWAP_XY, TOUCH_FLIP_X, TOUCH_FLIP_Y,
                touchMinX, touchMaxX, touchMinY, touchMaxY);
  Serial.printf("TFT_eSPI runtime size after rotation: %d x %d, expected: %d x %d\n",
                tft.width(), tft.height(), SCREEN_W, SCREEN_H);
  if (tft.width() != SCREEN_W || tft.height() != SCREEN_H) {
    Serial.println("WARNING: TFT_eSPI User_Setup.h controller/geometry does not match this sketch. Select ILI9341 or ST7789 in the companion User_Setup.h and keep TFT_WIDTH/TFT_HEIGHT at 240x320 for this CYD layout.");
  }
#endif

#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);
  tft.setRotation(DISPLAY_ROTATION_NORMAL);
  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
  handleWebClientSafely();

  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;
  unsigned long normalPollInterval = isCurrentWatchSvxlink() ? max(3000UL, (unsigned long)cfg.refreshMs) : (unsigned long)cfg.refreshMs;
  unsigned long screensaverPollInterval = isCurrentWatchSvxlink() ? max(10000UL, (unsigned long)cfg.refreshMs) : (unsigned long)cfg.refreshMs;
  bool normalUiPoll = !screensaverActive && canPollNetwork && pageNeedsNetworkPolling && !touchGraceActive && millis() - lastPoll >= normalPollInterval;
  bool screensaverWakePoll = screensaverActive && canPollNetwork && millis() - lastPoll >= screensaverPollInterval;

  if (normalUiPoll) {
    lastPoll = millis();
    fetchAllData();
  } else if (screensaverWakePoll) {
    lastPoll = millis();
    if (isCurrentWatchSvxlink()) fetchSvxlinkData();
    else 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 (!screensaverActive && scrollingTextActiveFrame &&
      !detailPopupOpen && !modeConfirmPopupOpen && !quickActionPopupOpen && !watchSourcePopupOpen &&
      (currentPage == PAGE_LIVE || currentPage == PAGE_LAST) &&
      millis() - lastScrollingTextFrameMs >= SCROLL_TEXT_STEP_MS) {
    lastScrollingTextFrameMs = millis();
    scrollingTextDirty = true;
  }

  if (uiDirty || footerDirty || scrollingTextDirty) {
    redrawCurrentPage(uiDirty);
    footerDirty = false;
    scrollingTextDirty = false;
    uiDirty = false;
  }
}
