Загрузка данных


#include <Arduino.h>
#include <WiFi.h>
#include <WebServer.h>
#include <Adafruit_GFX.h>
#include <Adafruit_ST7735.h>

// =====================================================
// HOSAN DEVICE V20 PRO
// ESP32 DOIT DEVKIT V1
// =====================================================

// ---------- TFT ----------
#define TFT_CS   5
#define TFT_DC   2
#define TFT_RST  4
#define TFT_BL   16

// ---------- ENCODER ----------
#define ENC_A    32
#define ENC_B    33
#define ENC_SW   25

Adafruit_ST7735 tft(TFT_CS, TFT_DC, TFT_RST);
WebServer server(80);

// ---------- WiFi ----------
const char* WIFI_NAME = "HOSAN DEVICE";
const char* WIFI_PASS = "hosan2026";

// =====================================================
// EVENTS
// Префиксы нужны, чтобы никакие системные имена ESP32
// не конфликтовали с нашими переменными.
// =====================================================

enum EncoderEvent {
  EV_NONE,
  EV_CW,
  EV_CCW,
  EV_PRESS,
  EV_LONG
};

// =====================================================
// PAGES
// =====================================================

enum DevicePage {
  PG_HOME,
  PG_MENU,
  PG_CLOCK,
  PG_STOPWATCH,
  PG_TIMER,
  PG_RANDOM,
  PG_WIFI_SCAN,
  PG_WIFI_INFO,
  PG_SYSTEM,
  PG_GPIO,
  PG_ENCODER,
  PG_DISPLAY,
  PG_BACKLIGHT,
  PG_UPTIME,
  PG_POMODORO,
  PG_DICE,
  PG_REACTION,
  PG_BLE
};

DevicePage currentPage = PG_HOME;

// =====================================================
// MENU
// =====================================================

const char* menuItems[] = {
  "CLOCK",
  "STOPWATCH",
  "TIMER",
  "RANDOM",
  "WIFI SCAN",
  "WIFI INFO",
  "SYSTEM",
  "GPIO TEST",
  "ENCODER TEST",
  "DISPLAY TEST",
  "BACKLIGHT",
  "UPTIME",
  "POMODORO",
  "DICE",
  "REACTION",
  "BLE STATUS"
};

const int MENU_COUNT =
  sizeof(menuItems) / sizeof(menuItems[0]);

int menuIndex = 0;

// =====================================================
// ENCODER STATE
// =====================================================

uint8_t encoderPrevious = 0;
int encoderAccumulator = 0;

bool buttonStable = HIGH;
bool buttonRaw = HIGH;

unsigned long buttonChangedAt = 0;
unsigned long buttonPressedAt = 0;

// =====================================================
// TIMER / STOPWATCH
// =====================================================

bool stopwatchRunning = false;

unsigned long stopwatchStarted = 0;
unsigned long stopwatchSaved = 0;

bool timerRunning = false;

unsigned long timerEpoch = 0;
unsigned long timerLength = 60;

// =====================================================
// POMODORO
// =====================================================

bool pomodoroRunning = false;
unsigned long pomodoroStarted = 0;

const unsigned long POMODORO_LENGTH = 25UL * 60UL;

// =====================================================
// REACTION
// =====================================================

bool reactionWaiting = false;
unsigned long reactionTarget = 0;

// =====================================================
// DISPLAY HELPERS
// =====================================================

void clearDisplay() {
  tft.fillScreen(ST77XX_BLACK);
}

void drawText(
  int x,
  int y,
  const String& value,
  uint16_t color = ST77XX_WHITE,
  uint8_t size = 1
) {
  tft.setTextColor(color);
  tft.setTextSize(size);
  tft.setCursor(x, y);
  tft.print(value);
}

void drawCenter(
  int y,
  const String& value,
  uint16_t color = ST77XX_WHITE,
  uint8_t size = 1
) {
  int width = value.length() * 6 * size;
  int x = (160 - width) / 2;

  if (x < 0) {
    x = 0;
  }

  drawText(x, y, value, color, size);
}

void drawHeader(const String& title) {
  clearDisplay();

  drawText(
    3,
    2,
    "HOSAN",
    ST77XX_CYAN,
    1
  );

  drawText(
    108,
    2,
    title,
    ST77XX_WHITE,
    1
  );

  tft.drawFastHLine(
    0,
    14,
    160,
    ST77XX_CYAN
  );
}

// =====================================================
// SPLASH
// =====================================================

void showSplash() {

  clearDisplay();

  int x = 80;

  // Верхняя стрелка
  tft.fillTriangle(
    x,
    18,
    x - 9,
    34,
    x + 9,
    34,
    ST77XX_WHITE
  );

  // Вертикальная линия
  tft.fillRect(
    x - 2,
    34,
    4,
    22,
    ST77XX_WHITE
  );

  // Горизонтальная линия
  tft.fillRect(
    42,
    53,
    76,
    6,
    ST77XX_WHITE
  );

  // Круг
  tft.drawCircle(
    x,
    56,
    9,
    ST77XX_WHITE
  );

  tft.fillCircle(
    x,
    56,
    2,
    ST77XX_BLACK
  );

  // Нижняя стрелка
  tft.fillTriangle(
    x,
    94,
    x - 9,
    78,
    x + 9,
    78,
    ST77XX_WHITE
  );

  drawCenter(
    108,
    "HOSAN DEVICE",
    ST77XX_WHITE,
    1
  );

  drawCenter(
    121,
    "V20 PRO",
    ST77XX_CYAN,
    1
  );
}

// =====================================================
// ENCODER
// =====================================================

EncoderEvent readEncoder() {

  EncoderEvent result = EV_NONE;

  uint8_t current =
    (digitalRead(ENC_A) << 1) |
     digitalRead(ENC_B);

  static const int8_t table[16] = {
     0, -1,  1,  0,
     1,  0,  0, -1,
    -1,  0,  0,  1,
     0,  1, -1,  0
  };

  if (current != encoderPrevious) {

    int index =
      (encoderPrevious << 2) |
      current;

    encoderAccumulator += table[index];

    encoderPrevious = current;

    // Полный detent энкодера.
    // Именно здесь фиксируем вращение.
    if (encoderAccumulator >= 4) {

      encoderAccumulator = 0;
      result = EV_CW;
    }

    else if (encoderAccumulator <= -4) {

      encoderAccumulator = 0;
      result = EV_CCW;
    }
  }

  // ---------- BUTTON ----------

  unsigned long now = millis();

  bool raw = digitalRead(ENC_SW);

  if (raw != buttonRaw) {

    buttonRaw = raw;
    buttonChangedAt = now;
  }

  if (
    now - buttonChangedAt >= 35 &&
    raw != buttonStable
  ) {

    buttonStable = raw;

    // Нажали
    if (raw == LOW) {

      buttonPressedAt = now;
    }

    // Отпустили
    else {

      if (buttonPressedAt != 0) {

        unsigned long duration =
          now - buttonPressedAt;

        buttonPressedAt = 0;

        if (duration >= 700) {

          result = EV_LONG;
        }

        else {

          result = EV_PRESS;
        }
      }
    }
  }

  return result;
}

// =====================================================
// INITIALIZE ENCODER
// =====================================================

void setupEncoder() {

  pinMode(
    ENC_A,
    INPUT_PULLUP
  );

  pinMode(
    ENC_B,
    INPUT_PULLUP
  );

  pinMode(
    ENC_SW,
    INPUT_PULLUP
  );

  encoderPrevious =
    (digitalRead(ENC_A) << 1) |
     digitalRead(ENC_B);

  buttonStable =
    digitalRead(ENC_SW);

  buttonRaw =
    buttonStable;

  buttonChangedAt = millis();
}

// =====================================================
// HOME
// =====================================================

void drawHome() {

  drawHeader("HOME");

  drawCenter(
    30,
    "READY",
    ST77XX_GREEN,
    2
  );

  drawCenter(
    58,
    "HOSAN DEVICE",
    ST77XX_CYAN,
    1
  );

  drawText(
    5,
    82,
    "IP: " +
    WiFi.softAPIP().toString()
  );

  drawText(
    5,
    98,
    "RAM: " +
    String(ESP.getFreeHeap())
  );

  drawText(
    5,
    114,
    "PRESS = MENU"
  );
}

// =====================================================
// MENU
// =====================================================

void drawMenu() {

  drawHeader("TOOLS");

  int first =
    menuIndex - 2;

  if (first < 0) {
    first = 0;
  }

  if (first > MENU_COUNT - 5) {
    first = MENU_COUNT - 5;
  }

  if (first < 0) {
    first = 0;
  }

  for (int i = 0; i < 5; i++) {

    int index =
      first + i;

    if (index >= MENU_COUNT) {
      break;
    }

    int y =
      22 + i * 18;

    if (index == menuIndex) {

      tft.fillRoundRect(
        2,
        y - 2,
        156,
        16,
        3,
        ST77XX_CYAN
      );

      drawText(
        7,
        y,
        String(index + 1) +
        " " +
        menuItems[index],
        ST77XX_BLACK,
        1
      );
    }

    else {

      drawText(
        7,
        y,
        String(index + 1) +
        " " +
        menuItems[index],
        ST77XX_WHITE,
        1
      );
    }
  }
}

// =====================================================
// CLOCK
// =====================================================

void drawClockPage() {

  drawHeader("CLOCK");

  unsigned long seconds =
    millis() / 1000UL;

  unsigned long h =
    (seconds / 3600UL) % 24UL;

  unsigned long m =
    (seconds / 60UL) % 60UL;

  unsigned long s =
    seconds % 60UL;

  char buffer[16];

  sprintf(
    buffer,
    "%02lu:%02lu:%02lu",
    h,
    m,
    s
  );

  drawCenter(
    45,
    buffer,
    ST77XX_WHITE,
    2
  );

  drawCenter(
    80,
    "UPTIME",
    ST77XX_CYAN
  );

  drawCenter(
    98,
    String(seconds) + " SEC",
    ST77XX_GREEN
  );

  drawCenter(
    118,
    "LONG = BACK",
    ST77XX_YELLOW
  );
}

// =====================================================
// STOPWATCH
// =====================================================

void drawStopwatch() {

  drawHeader("STOP");

  unsigned long elapsed =
    stopwatchSaved;

  if (stopwatchRunning) {

    elapsed +=
      (millis() - stopwatchStarted)
      / 1000UL;
  }

  unsigned long h =
    elapsed / 3600UL;

  unsigned long m =
    (elapsed / 60UL) % 60UL;

  unsigned long s =
    elapsed % 60UL;

  char buffer[16];

  sprintf(
    buffer,
    "%02lu:%02lu:%02lu",
    h,
    m,
    s
  );

  drawCenter(
    45,
    buffer,
    ST77XX_WHITE,
    2
  );

  drawCenter(
    80,
    stopwatchRunning
      ? "RUNNING"
      : "PAUSED",
    ST77XX_GREEN
  );

  drawCenter(
    105,
    "PRESS START/PAUSE",
    ST77XX_YELLOW
  );
}

// =====================================================
// TIMER
// =====================================================

void drawTimer() {

  drawHeader("TIMER");

  unsigned long elapsed = 0;

  if (timerRunning) {

    elapsed =
      (millis() - timerEpoch)
      / 1000UL;
  }

  unsigned long remaining = 0;

  if (elapsed < timerLength) {

    remaining =
      timerLength - elapsed;
  }

  unsigned long minutes =
    remaining / 60UL;

  unsigned long seconds =
    remaining % 60UL;

  char buffer[12];

  sprintf(
    buffer,
    "%02lu:%02lu",
    minutes,
    seconds
  );

  uint16_t color =
    remaining == 0
      ? ST77XX_RED
      : ST77XX_WHITE;

  drawCenter(
    42,
    buffer,
    color,
    2
  );

  drawCenter(
    72,
    "ROTATE = MIN",
    ST77XX_CYAN
  );

  drawCenter(
    92,
    timerRunning
      ? "RUNNING"
      : "READY",
    ST77XX_GREEN
  );

  drawCenter(
    112,
    "PRESS = START",
    ST77XX_YELLOW
  );

  if (
    timerRunning &&
    remaining == 0
  ) {

    timerRunning = false;
  }
}

// =====================================================
// RANDOM
// =====================================================

void drawRandom() {

  drawHeader("RANDOM");

  uint32_t value =
    esp_random();

  drawCenter(
    45,
    String(value),
    ST77XX_WHITE
  );

  drawCenter(
    80,
    "HARDWARE RNG",
    ST77XX_CYAN
  );

  drawCenter(
    105,
    "PRESS = NEW",
    ST77XX_YELLOW
  );
}

// =====================================================
// WIFI INFO
// =====================================================

void drawWifiInfo() {

  drawHeader("WIFI");

  drawText(
    4,
    25,
    "AP: " +
    String(WIFI_NAME)
  );

  drawText(
    4,
    43,
    "IP: " +
    WiFi.softAPIP().toString()
  );

  drawText(
    4,
    61,
    "CH: " +
    String(WiFi.channel())
  );

  drawText(
    4,
    79,
    "MAC:"
  );

  drawText(
    4,
    94,
    WiFi.softAPmacAddress()
  );

  drawText(
    4,
    112,
    "CLIENTS: " +
    String(WiFi.softAPgetStationNum())
  );
}

// =====================================================
// WIFI SCAN
// =====================================================

void drawWifiScan() {

  drawHeader("SCAN");

  int count =
    WiFi.scanComplete();

  if (count == WIFI_SCAN_RUNNING) {

    drawCenter(
      55,
      "SCANNING...",
      ST77XX_CYAN
    );

    return;
  }

  if (count < 0) {

    WiFi.scanNetworks(
      true,
      true
    );

    drawCenter(
      55,
      "STARTING...",
      ST77XX_CYAN
    );

    return;
  }

  if (count == 0) {

    drawCenter(
      55,
      "NO NETWORKS",
      ST77XX_RED
    );

    WiFi.scanDelete();

    return;
  }

  int visible =
    min(count, 5);

  for (int i = 0; i < visible; i++) {

    String ssid =
      WiFi.SSID(i);

    if (ssid.length() > 16) {
      ssid =
        ssid.substring(0, 16);
    }

    int y =
      22 + i * 18;

    drawText(
      3,
      y,
      ssid
    );

    drawText(
      118,
      y,
      String(WiFi.RSSI(i))
    );
  }

  WiFi.scanDelete();
}

// =====================================================
// SYSTEM
// =====================================================

void drawSystem() {

  drawHeader("SYSTEM");

  drawText(
    4,
    24,
    "CPU: " +
    String(ESP.getCpuFreqMHz()) +
    " MHz"
  );

  drawText(
    4,
    42,
    "HEAP: " +
    String(ESP.getFreeHeap())
  );

  drawText(
    4,
    60,
    "MIN: " +
    String(ESP.getMinFreeHeap())
  );

  drawText(
    4,
    78,
    "FLASH: " +
    String(
      ESP.getFlashChipSize() /
      1024
    ) +
    " KB"
  );

  drawText(
    4,
    96,
    "CORES: " +
    String(ESP.getChipCores())
  );

  drawText(
    4,
    114,
    "SDK: " +
    String(ESP.getSdkVersion())
  );
}

// =====================================================
// GPIO TEST
// =====================================================

void drawGPIO() {

  drawHeader("GPIO");

  drawText(
    5,
    27,
    "ENC A: " +
    String(digitalRead(ENC_A))
  );

  drawText(
    5,
    45,
    "ENC B: " +
    String(digitalRead(ENC_B))
  );

  drawText(
    5,
    63,
    "BUTTON: " +
    String(digitalRead(ENC_SW))
  );

  drawText(
    5,
    81,
    "TFT BL: " +
    String(digitalRead(TFT_BL))
  );

  drawText(
    5,
    105,
    "INPUT DIAGNOSTIC",
    ST77XX_GREEN
  );
}

// =====================================================
// ENCODER TEST
// =====================================================

void drawEncoderTest() {

  drawHeader("ENCODER");

  drawText(
    5,
    28,
    "A: " +
    String(digitalRead(ENC_A))
  );

  drawText(
    5,
    46,
    "B: " +
    String(digitalRead(ENC_B))
  );

  drawText(
    5,
    64,
    "SW: " +
    String(digitalRead(ENC_SW))
  );

  drawCenter(
    94,
    "ROTATE / PRESS",
    ST77XX_GREEN
  );

  drawCenter(
    112,
    "LONG = BACK",
    ST77XX_YELLOW
  );
}

// =====================================================
// DISPLAY TEST
// =====================================================

void drawDisplayTest() {

  drawHeader("DISPLAY");

  tft.drawRect(
    3,
    22,
    154,
    70,
    ST77XX_RED
  );

  tft.drawLine(
    3,
    22,
    157,
    92,
    ST77XX_GREEN
  );

  tft.drawLine(
    157,
    22,
    3,
    92,
    ST77XX_CYAN
  );

  drawCenter(
    105,
    "TFT OK",
    ST77XX_WHITE
  );
}

// =====================================================
// BACKLIGHT
// =====================================================

void drawBacklight() {

  drawHeader("BACKLIGHT");

  drawCenter(
    42,
    "GPIO 16",
    ST77XX_CYAN,
    2
  );

  drawCenter(
    72,
    "BACKLIGHT ON",
    ST77XX_GREEN
  );

  drawCenter(
    102,
    "WEB CONTROL",
    ST77XX_WHITE
  );
}

// =====================================================
// UPTIME
// =====================================================

void drawUptime() {

  drawHeader("UPTIME");

  unsigned long sec =
    millis() / 1000UL;

  drawCenter(
    45,
    String(sec) + " SEC",
    ST77XX_WHITE,
    2
  );

  drawCenter(
    82,
    String(sec / 60UL) + " MIN",
    ST77XX_GREEN
  );
}

// =====================================================
// POMODORO
// =====================================================

void drawPomodoro() {

  drawHeader("POMODORO");

  unsigned long elapsed = 0;

  if (pomodoroRunning) {

    elapsed =
      (millis() - pomodoroStarted)
      / 1000UL;
  }

  unsigned long remaining = 0;

  if (elapsed < POMODORO_LENGTH) {

    remaining =
      POMODORO_LENGTH - elapsed;
  }

  unsigned long min =
    remaining / 60UL;

  unsigned long sec =
    remaining % 60UL;

  char buffer[12];

  sprintf(
    buffer,
    "%02lu:%02lu",
    min,
    sec
  );

  drawCenter(
    45,
    buffer,
    ST77XX_WHITE,
    2
  );

  drawCenter(
    80,
    pomodoroRunning
      ? "FOCUS"
      : "READY",
    ST77XX_GREEN
  );
}

// =====================================================
// DICE
// =====================================================

void drawDice() {

  drawHeader("DICE");

  int value =
    random(1, 7);

  drawCenter(
    42,
    String(value),
    ST77XX_WHITE,
    4
  );

  drawCenter(
    105,
    "PRESS = ROLL",
    ST77XX_YELLOW
  );
}

// =====================================================
// REACTION
// =====================================================

void drawReaction() {

  drawHeader("REACTION");

  if (reactionWaiting) {

    drawCenter(
      48,
      "PRESS!",
      ST77XX_GREEN,
      2
    );
  }

  else {

    drawCenter(
      48,
      "WAIT...",
      ST77XX_YELLOW,
      2
    );
  }
}

// =====================================================
// BLE STATUS
// =====================================================

void drawBLE() {

  drawHeader("BLE");

  drawCenter(
    45,
    "BLE READY",
    ST77XX_CYAN,
    2
  );

  drawCenter(
    76,
    "SAFE MODE",
    ST77XX_GREEN
  );

  drawCenter(
    102,
    "DIAGNOSTICS",
    ST77XX_WHITE
  );
}

// =====================================================
// PAGE DRAW
// =====================================================

void renderPage() {

  switch (currentPage) {

    case PG_HOME:
      drawHome();
      break;

    case PG_MENU:
      drawMenu();
      break;

    case PG_CLOCK:
      drawClockPage();
      break;

    case PG_STOPWATCH:
      drawStopwatch();
      break;

    case PG_TIMER:
      drawTimer();
      break;

    case PG_RANDOM:
      drawRandom();
      break;

    case PG_WIFI_SCAN:
      drawWifiScan();
      break;

    case PG_WIFI_INFO:
      drawWifiInfo();
      break;

    case PG_SYSTEM:
      drawSystem();
      break;

    case PG_GPIO:
      drawGPIO();
      break;

    case PG_ENCODER:
      drawEncoderTest();
      break;

    case PG_DISPLAY:
      drawDisplayTest();
      break;

    case PG_BACKLIGHT:
      drawBacklight();
      break;

    case PG_UPTIME:
      drawUptime();
      break;

    case PG_POMODORO:
      drawPomodoro();
      break;

    case PG_DICE:
      drawDice();
      break;

    case PG_REACTION:
      drawReaction();
      break;

    case PG_BLE:
      drawBLE();
      break;

    default:
      currentPage = PG_HOME;
      drawHome();
      break;
  }
}

// =====================================================
// OPEN MENU ITEM
// =====================================================

void openSelected() {

  switch (menuIndex) {

    case 0:
      currentPage = PG_CLOCK;
      break;

    case 1:
      currentPage = PG_STOPWATCH;
      break;

    case 2:
      currentPage = PG_TIMER;
      break;

    case 3:
      currentPage = PG_RANDOM;
      break;

    case 4:
      currentPage = PG_WIFI_SCAN;
      WiFi.scanNetworks(
        true,
        true
      );
      break;

    case 5:
      currentPage = PG_WIFI_INFO;
      break;

    case 6:
      currentPage = PG_SYSTEM;
      break;

    case 7:
      currentPage = PG_GPIO;
      break;

    case 8:
      currentPage = PG_ENCODER;
      break;

    case 9:
      currentPage = PG_DISPLAY;
      break;

    case 10:
      currentPage = PG_BACKLIGHT;
      break;

    case 11:
      currentPage = PG_UPTIME;
      break;

    case 12:
      currentPage = PG_POMODORO;
      break;

    case 13:
      currentPage = PG_DICE;
      break;

    case 14:
      currentPage = PG_REACTION;

      reactionWaiting = false;

      reactionTarget =
        millis() +
        random(1500, 4000);

      break;

    case 15:
      currentPage = PG_BLE;
      break;

    default:
      currentPage = PG_HOME;
      break;
  }

  renderPage();
}

// =====================================================
// ENCODER ACTION
// =====================================================

void handleEvent(
  EncoderEvent event
) {

  // HOME
  if (currentPage == PG_HOME) {

    if (event == EV_PRESS) {

      currentPage = PG_MENU;
      renderPage();
    }

    return;
  }

  // MENU
  if (currentPage == PG_MENU) {

    if (event == EV_CW) {

      menuIndex++;

      if (menuIndex >= MENU_COUNT) {
        menuIndex = 0;
      }

      renderPage();
    }

    else if (event == EV_CCW) {

      menuIndex--;

      if (menuIndex < 0) {
        menuIndex = MENU_COUNT - 1;
      }

      renderPage();
    }

    else if (event == EV_PRESS) {

      openSelected();
    }

    return;
  }

  // LONG PRESS = BACK
  if (event == EV_LONG) {

    currentPage = PG_MENU;
    renderPage();

    return;
  }

  // STOPWATCH
  if (
    currentPage == PG_STOPWATCH &&
    event == EV_PRESS
  ) {

    if (stopwatchRunning) {

      stopwatchSaved +=
        (millis() - stopwatchStarted)
        / 1000UL;

      stopwatchRunning = false;
    }

    else {

      stopwatchStarted =
        millis();

      stopwatchRunning = true;
    }

    renderPage();
    return;
  }

  // TIMER
  if (currentPage == PG_TIMER) {

    if (event == EV_CW) {

      timerLength += 60;

      if (timerLength > 59940) {
        timerLength = 59940;
      }

      renderPage();
    }

    else if (event == EV_CCW) {

      if (timerLength > 60) {
        timerLength -= 60;
      }

      renderPage();
    }

    else if (event == EV_PRESS) {

      timerEpoch =
        millis();

      timerRunning = true;

      renderPage();
    }

    return;
  }

  // RANDOM
  if (
    currentPage == PG_RANDOM &&
    event == EV_PRESS
  ) {

    renderPage();
    return;
  }

  // DICE
  if (
    currentPage == PG_DICE &&
    event == EV_PRESS
  ) {

    renderPage();
    return;
  }

  // POMODORO
  if (
    currentPage == PG_POMODORO &&
    event == EV_PRESS
  ) {

    if (pomodoroRunning) {

      pomodoroRunning = false;
    }

    else {

      pomodoroStarted =
        millis();

      pomodoroRunning = true;
    }

    renderPage();
    return;
  }

  // REACTION
  if (
    currentPage == PG_REACTION &&
    event == EV_PRESS
  ) {

    if (reactionWaiting) {

      unsigned long result =
        millis() - reactionTarget;

      drawHeader("RESULT");

      drawCenter(
        48,
        String(result) + " ms",
        ST77XX_GREEN,
        2
      );

      drawCenter(
        90,
        "REACTION TIME",
        ST77XX_CYAN
      );

      reactionWaiting = false;
    }

    return;
  }
}

// =====================================================
// WEB INTERFACE
// =====================================================

const char WEB_PAGE[] PROGMEM = R"HTML(
<!DOCTYPE html>
<html>
<head>

<meta name="viewport"
      content="width=device-width,initial-scale=1">

<title>HOSAN DEVICE V20 PRO</title>

<style>

* {
  box-sizing:border-box;
}

body {
  margin:0;
  background:
    radial-gradient(
      circle at top,
      #10202b,
      #05080c 60%
    );
  color:#f4f7f9;
  font-family:
    system-ui,
    Arial,
    sans-serif;
}

main {
  max-width:720px;
  margin:auto;
  padding:16px;
}

.hero {
  padding:22px;
  border-radius:24px;
  border:1px solid #263944;
  background:#0b1218;
  box-shadow:
    0 15px 40px #0008;
}

h1 {
  margin:0;
  color:#00e5ff;
  letter-spacing:6px;
}

.sub {
  color:#71818d;
  margin-top:5px;
}

.card {
  margin-top:12px;
  padding:16px;
  border-radius:20px;
  background:#0c141b;
  border:1px solid #21303a;
}

.grid {
  display:grid;
  grid-template-columns:
    repeat(2,1fr);
  gap:8px;
  margin-top:10px;
}

button {
  border:0;
  border-radius:13px;
  padding:14px;
  font-weight:800;
  background:#e8f0f3;
  color:#071017;
}

button.dark {
  background:#14232c;
  color:#bdefff;
  border:1px solid #28404c;
}

.stat {
  color:#00e5ff;
  font-size:22px;
}

pre {
  white-space:pre-wrap;
  word-break:break-word;
  color:#9fefff;
}

@media(max-width:500px) {

  .grid {
    grid-template-columns:1fr;
  }

}

</style>

</head>

<body>

<main>

<div class="hero">

<h1>HOSAN</h1>

<div class="sub">
DEVICE V20 PRO · ESP32
</div>

<p id="status">
Connecting...
</p>

</div>

<div class="card">

<b>REMOTE ENCODER</b>

<div class="grid">

<button onclick="cmd('PREV')">
◀ PREVIOUS
</button>

<button onclick="cmd('NEXT')">
NEXT ▶
</button>

<button onclick="cmd('OK')">
OK
</button>

<button onclick="cmd('BACK')">
BACK
</button>

</div>

</div>

<div class="card">

<b>TOOLS</b>

<div class="grid">

<button onclick="openPage('CLOCK')">
CLOCK
</button>

<button onclick="openPage('STOP')">
STOPWATCH
</button>

<button onclick="openPage('TIMER')">
TIMER
</button>

<button onclick="openPage('RANDOM')">
RANDOM
</button>

<button onclick="openPage('SCAN')">
WIFI SCAN
</button>

<button onclick="openPage('WIFI')">
WIFI INFO
</button>

<button onclick="openPage('SYSTEM')">
SYSTEM
</button>

<button onclick="openPage('ENCODER')">
ENCODER
</button>

<button onclick="openPage('DISPLAY')">
DISPLAY
</button>

<button onclick="openPage('POMO')">
POMODORO
</button>

<button onclick="openPage('DICE')">
DICE
</button>

<button onclick="openPage('UPTIME')">
UPTIME
</button>

</div>

</div>

<div class="card">

<b>LIVE SYSTEM</b>

<pre id="data">
Loading...
</pre>

</div>

</main>

<script>

async function cmd(x) {

  await fetch(
    '/cmd?x=' +
    encodeURIComponent(x)
  );
}

async function openPage(x) {

  await fetch(
    '/open?x=' +
    encodeURIComponent(x)
  );
}

async function update() {

  try {

    const response =
      await fetch('/api/status');

    const data =
      await response.json();

    document.getElementById(
      'status'
    ).textContent =
      'IP ' +
      data.ip +
      ' · RAM ' +
      data.heap +
      ' B';

    document.getElementById(
      'data'
    ).textContent =
      JSON.stringify(
        data,
        null,
        2
      );

  }

  catch(e) {

    document.getElementById(
      'status'
    ).textContent =
      'Connection lost';

  }

}

setInterval(
  update,
  1000
);

update();

</script>

</body>
</html>
)HTML";

// =====================================================
// WEB COMMAND
// =====================================================

void executeCommand(
  const String& command
) {

  if (command == "NEXT") {

    handleEvent(EV_CW);
  }

  else if (command == "PREV") {

    handleEvent(EV_CCW);
  }

  else if (command == "OK") {

    handleEvent(EV_PRESS);
  }

  else if (command == "BACK") {

    handleEvent(EV_LONG);
  }
}

// =====================================================
// DIRECT WEB OPEN
// =====================================================

void openFromWeb(
  const String& command
) {

  if (command == "CLOCK") {
    currentPage = PG_CLOCK;
  }

  else if (command == "STOP") {
    currentPage = PG_STOPWATCH;
  }

  else if (command == "TIMER") {
    currentPage = PG_TIMER;
  }

  else if (command == "RANDOM") {
    currentPage = PG_RANDOM;
  }

  else if (command == "SCAN") {

    currentPage = PG_WIFI_SCAN;

    WiFi.scanNetworks(
      true,
      true
    );
  }

  else if (command == "WIFI") {
    currentPage = PG_WIFI_INFO;
  }

  else if (command == "SYSTEM") {
    currentPage = PG_SYSTEM;
  }

  else if (command == "ENCODER") {
    currentPage = PG_ENCODER;
  }

  else if (command == "DISPLAY") {
    currentPage = PG_DISPLAY;
  }

  else if (command == "POMO") {
    currentPage = PG_POMODORO;
  }

  else if (command == "DICE") {
    currentPage = PG_DICE;
  }

  else if (command == "UPTIME") {
    currentPage = PG_UPTIME;
  }

  renderPage();
}

// =====================================================
// WEB SERVER
// =====================================================

void setupWeb() {

  WiFi.mode(WIFI_AP);

  WiFi.softAP(
    WIFI_NAME,
    WIFI_PASS
  );

  server.on(
    "/",
    []() {

      server.send_P(
        200,
        "text/html",
        WEB_PAGE
      );
    }
  );

  server.on(
    "/cmd",
    []() {

      if (
        server.hasArg("x")
      ) {

        executeCommand(
          server.arg("x")
        );
      }

      server.send(
        200,
        "text/plain",
        "OK"
      );
    }
  );

  server.on(
    "/open",
    []() {

      if (
        server.hasArg("x")
      ) {

        openFromWeb(
          server.arg("x")
        );
      }

      server.send(
        200,
        "text/plain",
        "OK"
      );
    }
  );

  server.on(
    "/api/status",
    []() {

      String json = "{";

      json +=
        "\"device\":\"HOSAN DEVICE V20 PRO\",";

      json +=
        "\"ip\":\"" +
        WiFi.softAPIP().toString() +
        "\",";

      json +=
        "\"channel\":" +
        String(WiFi.channel()) +
        ",";

      json +=
        "\"clients\":" +
        String(
          WiFi.softAPgetStationNum()
        ) +
        ",";

      json +=
        "\"heap\":" +
        String(
          ESP.getFreeHeap()
        ) +
        ",";

      json +=
        "\"minHeap\":" +
        String(
          ESP.getMinFreeHeap()
        ) +
        ",";

      json +=
        "\"uptime\":" +
        String(
          millis() / 1000UL
        ) +
        ",";

      json +=
        "\"cpuMHz\":" +
        String(
          ESP.getCpuFreqMHz()
        ) +
        ",";

      json +=
        "\"flashKB\":" +
        String(
          ESP.getFlashChipSize() /
          1024
        );

      json += "}";

      server.send(
        200,
        "application/json",
        json
      );
    }
  );

  server.begin();
}

// =====================================================
// SETUP
// =====================================================

void setup() {

  Serial.begin(115200);

  // TFT BACKLIGHT
  pinMode(
    TFT_BL,
    OUTPUT
  );

  digitalWrite(
    TFT_BL,
    HIGH
  );

  // TFT
  tft.initR(
    INITR_BLACKTAB
  );

  tft.setRotation(1);

  // ENCODER
  setupEncoder();

  // Random
  randomSeed(
    esp_random()
  );

  // Splash
  showSplash();

  delay(800);

  // WiFi
  setupWeb();

  // Home
  currentPage =
    PG_HOME;

  renderPage();
}

// =====================================================
// LOOP
// =====================================================

void loop() {

  server.handleClient();

  EncoderEvent event =
    readEncoder();

  if (event != EV_NONE) {

    handleEvent(event);
  }

  // Reaction timer
  if (
    currentPage == PG_REACTION &&
    !reactionWaiting &&
    millis() >= reactionTarget
  ) {

    reactionWaiting = true;

    renderPage();
  }

  // Dynamic pages
  static unsigned long lastDraw = 0;

  if (
    millis() - lastDraw >= 250
  ) {

    lastDraw = millis();

    if (
      currentPage == PG_CLOCK ||
      currentPage == PG_STOPWATCH ||
      currentPage == PG_TIMER ||
      currentPage == PG_UPTIME ||
      currentPage == PG_POMODORO
    ) {

      renderPage();
    }
  }

  delay(5);
}