BarbareDestroy

Code Arduino de ragnaclock

Remplace les deux XXXXXXXXXXXXXXXXXX par ton SSID Wi-Fi et ton mot de passe avant le téléversement.

Télécharger le fichier

Ce code est entièrement gratuit : tu peux l’utiliser, le copier, le modifier, l’intégrer à un autre projet et le partager où tu veux, sans demander d’autorisation.

#include <Arduino.h>
#include <WiFi.h>
#include <WebServer.h>
#include <ESPmDNS.h>
#include <Preferences.h>
#include <time.h>
#include <sys/time.h>
#include <esp_sntp.h>
#include <vector>

#include <Adafruit_GFX.h>
#include <ESP32-HUB75-MatrixPanel-I2S-DMA.h>
#include <ESP32-VirtualMatrixPanel-I2S-DMA.h>

// =====================================================
// WIFI
// =====================================================

const char* WIFI_SSID     = "XXXXXXXXXXXXXXXXXX";
const char* WIFI_PASSWORD = "XXXXXXXXXXXXXXXXXX";

// Synchronisation de l'heure et reconnexion automatique
const unsigned long NTP_SYNC_INTERVAL_MS = 5UL * 60UL * 1000UL;
const unsigned long WIFI_RETRY_INTERVAL_MS = 10UL * 1000UL;
const unsigned long AP_START_DELAY_MS = 15UL * 1000UL;

bool rescueAccessPointActive = false;
bool wifiWasConnected = false;
unsigned long wifiDisconnectedSince = 0;
unsigned long lastWifiRetry = 0;

// =====================================================
// PANNEAUX - CONFIGURATION VALIDEE, NE PLUS TOUCHER
// =====================================================

#define PANEL_RES_X 32
#define PANEL_RES_Y 32
#define NUM_ROWS 1
#define NUM_COLS 3
#define PANEL_CHAIN 3
#define DEFAULT_BRIGHTNESS 2
#define VIRTUAL_MATRIX_CHAIN_TYPE CHAIN_TOP_LEFT_DOWN

// SEENGREAT RGB Matrix Adapter Board (E) Rev 2.2
#define R1_PIN 18
#define G1_PIN 8
#define B1_PIN 17
#define R2_PIN 16
#define G2_PIN 1
#define B2_PIN 15
#define A_PIN 7
#define B_PIN 48
#define C_PIN 6
#define D_PIN 47
#define E_PIN 2
#define LAT_PIN 21
#define OE_PIN 4
#define CLK_PIN 5

MatrixPanel_I2S_DMA *dma_display = nullptr;
VirtualMatrixPanel *display = nullptr;

WebServer server(80);
Preferences preferences;

// =====================================================
// REGLAGES PERSISTANTS
// =====================================================

uint8_t displayBrightness = DEFAULT_BRIGHTNESS;
uint8_t digitSize = 100;
uint8_t digitThickness = 3;
uint16_t scrollDelayMs = 45;

String digitFontStyle = "segments";
bool autoFontRotation = false;

const char* FONT_STYLE_LIST[8] = {
  "segments", "segments_thin", "segments_thick", "lcd_retro",
  "block", "block_round", "spaced", "compact"
};

String effectiveFontStyle = "segments";
String lastEffectiveFontStyle = "";

String hourColorHex   = "#00ff96";
String minuteColorHex = "#00ff96";
String secondColorHex = "#00ff96";

String messageColorMode = "single";
String messageColorHex   = "#00ff96";
String messageP1Hex      = "#00ff96";
String messageP2Hex      = "#a050ff";
String messageP3Hex      = "#ff1493";

uint16_t hourColor;
uint16_t minuteColor;
uint16_t secondColor;
uint16_t messageColor;
uint16_t messagePanel1Color;
uint16_t messagePanel2Color;
uint16_t messagePanel3Color;
uint16_t blackColor;

void resetClockCache();

// =====================================================
// WIFI ET HEURE
// =====================================================

void setTemporaryMidnight() {
  // Heure provisoire : 1er janvier 2024 à 00:00:00 en France (heure d'hiver).
  // Elle avance normalement jusqu'à la première synchronisation Internet.
  struct timeval tv;
  tv.tv_sec = 1704063600;
  tv.tv_usec = 0;
  settimeofday(&tv, nullptr);
}

void configureNtp() {
  configTzTime(
    "CET-1CEST,M3.5.0/2,M10.5.0/3",
    "pool.ntp.org",
    "time.google.com"
  );

  // Le service NTP redemande l'heure toutes les 5 minutes.
  sntp_set_sync_interval(NTP_SYNC_INTERVAL_MS);
}

void startRescueAccessPoint() {
  if (rescueAccessPointActive) return;

  WiFi.mode(WIFI_AP_STA);
  WiFi.softAP("Barbapocalypse", "metroid8");
  rescueAccessPointActive = true;

  Serial.println("WiFi de secours : Barbapocalypse");
  Serial.println("Adresse : http://192.168.4.1");
}

void stopRescueAccessPoint() {
  if (!rescueAccessPointActive) return;

  WiFi.softAPdisconnect(true);
  WiFi.mode(WIFI_STA);
  rescueAccessPointActive = false;
}

void maintainWifiAndTime() {
  const bool connected = WiFi.status() == WL_CONNECTED;

  if (connected) {
    if (!wifiWasConnected) {
      Serial.println("WiFi connecte ou retabli");
      Serial.print("Adresse : http://");
      Serial.println(WiFi.localIP());

      // Force une synchronisation immédiate après chaque retour du WiFi.
      configureNtp();
      stopRescueAccessPoint();
      resetClockCache();
    }

    wifiWasConnected = true;
    wifiDisconnectedSince = 0;
    return;
  }

  if (wifiWasConnected || wifiDisconnectedSince == 0) {
    wifiDisconnectedSince = millis();
    Serial.println("WiFi indisponible, tentative de reconnexion...");
  }
  wifiWasConnected = false;

  if (millis() - lastWifiRetry >= WIFI_RETRY_INTERVAL_MS) {
    lastWifiRetry = millis();
    WiFi.reconnect();
  }

  if (!rescueAccessPointActive &&
      millis() - wifiDisconnectedSince >= AP_START_DELAY_MS) {
    startRescueAccessPoint();
  }
}

// =====================================================
// MESSAGES
// =====================================================

String manualMessage = "";
bool manualMode = false;

#define MAX_SCHEDULES 10
String schedDate[MAX_SCHEDULES];
String schedMsg[MAX_SCHEDULES];

String currentPlaybackMessage = "";
String currentPlaybackSource = "";
int passCount = 0;
bool clockPause = false;
unsigned long clockPauseStart = 0;

int scrollX = 96;
unsigned long lastScrollRefresh = 0;

// =====================================================
// CACHE HORLOGE
// =====================================================

int oldH1 = -1;
int oldH2 = -1;
int oldM1 = -1;
int oldM2 = -1;
int oldS1 = -1;
int oldS2 = -1;
int oldSecond = -1;
int oldColonVisible = -1;

// =====================================================
// POLICE : 7 SEGMENTS
// =====================================================

const uint8_t digitSegments[10] = {
  0x3F, 0x06, 0x5B, 0x4F, 0x66,
  0x6D, 0x7D, 0x07, 0x7F, 0x6F
};

// =====================================================
// POLICE : PIXEL BLOC 5x7
// =====================================================

const uint8_t blockDigits[10][7] = {
  {0x0E,0x11,0x13,0x15,0x19,0x11,0x0E}, // 0
  {0x04,0x0C,0x04,0x04,0x04,0x04,0x0E}, // 1
  {0x0E,0x11,0x01,0x02,0x04,0x08,0x1F}, // 2
  {0x1F,0x02,0x04,0x02,0x01,0x11,0x0E}, // 3
  {0x02,0x06,0x0A,0x12,0x1F,0x02,0x02}, // 4
  {0x1F,0x10,0x1E,0x01,0x01,0x11,0x0E}, // 5
  {0x06,0x08,0x10,0x1E,0x11,0x11,0x0E}, // 6
  {0x1F,0x01,0x02,0x04,0x08,0x08,0x08}, // 7
  {0x0E,0x11,0x11,0x0E,0x11,0x11,0x0E}, // 8
  {0x0E,0x11,0x11,0x0F,0x01,0x02,0x0C}  // 9
};

// =====================================================
// EMOJIS PIXEL 8x8 (20 disponibles, utilises via codes dans le texte)
// =====================================================

const uint8_t emojiHeart[8]       = {0x66,0xFF,0xFF,0xFF,0x7E,0x3C,0x18,0x00};
const uint8_t emojiBrokenHeart[8] = {0x66,0xFF,0xDB,0xBD,0x66,0x3C,0x18,0x00};
const uint8_t emojiStar[8]        = {0x18,0x18,0xFF,0x7E,0x3C,0x66,0xC3,0x81};
const uint8_t emojiBolt[8]        = {0x18,0x30,0x60,0xFE,0x18,0x30,0x60,0x00};
const uint8_t emojiFire[8]        = {0x18,0x18,0x3C,0x3C,0x7E,0x7E,0x3C,0x18};
const uint8_t emojiCoffee[8]      = {0x14,0x28,0x00,0x7C,0x46,0x46,0x7C,0x38};
const uint8_t emojiSmile[8]       = {0x3C,0x42,0xA5,0x81,0xA5,0x99,0x42,0x3C};
const uint8_t emojiWink[8]        = {0x3C,0x42,0x81,0x85,0xA5,0x99,0x42,0x3C};
const uint8_t emojiSad[8]         = {0x3C,0x42,0xA5,0x81,0x99,0xA5,0x42,0x3C};
const uint8_t emojiAngry[8]       = {0x42,0x24,0xA5,0x81,0xBD,0xC3,0x42,0x3C};
const uint8_t emojiSun[8]         = {0x24,0x18,0xA5,0x7E,0x7E,0xA5,0x18,0x24};
const uint8_t emojiMoon[8]        = {0x1C,0x36,0x66,0x60,0x60,0x66,0x36,0x1C};
const uint8_t emojiRain[8]        = {0x00,0x3C,0x7E,0xFF,0x00,0x24,0x24,0x00};
const uint8_t emojiSnow[8]        = {0x18,0xDB,0x7E,0x24,0x24,0x7E,0xDB,0x18};
const uint8_t emojiSkull[8]       = {0x3C,0x7E,0xDB,0xFF,0x7E,0x3C,0x24,0x24};
const uint8_t emojiGhost[8]       = {0x3C,0x7E,0xFF,0xDB,0xFF,0xFF,0xFF,0xA5};
const uint8_t emojiMusic[8]       = {0x06,0x0E,0x0A,0x0A,0x0A,0xEA,0xEE,0x6C};
const uint8_t emojiMountain[8]    = {0x00,0x08,0x1C,0x36,0x63,0xC1,0xFF,0x00};
const uint8_t emojiRocket[8]      = {0x18,0x3C,0x3C,0x7E,0xFF,0x5A,0x66,0x18};
const uint8_t emojiTrophy[8]      = {0x3C,0x7E,0x7E,0x3C,0x18,0x18,0x3C,0x00};

// =====================================================
// COULEURS
// =====================================================

uint16_t hexTo565(String hex) {
  if (hex.length() != 7 || hex.charAt(0) != '#') {
    return dma_display->color565(0, 255, 150);
  }

  long rgb = strtol(hex.substring(1).c_str(), nullptr, 16);
  uint8_t r = (rgb >> 16) & 0xFF;
  uint8_t g = (rgb >> 8) & 0xFF;
  uint8_t b = rgb & 0xFF;
  return dma_display->color565(r, g, b);
}

void refreshDisplayColors() {
  hourColor = hexTo565(hourColorHex);
  minuteColor = hexTo565(minuteColorHex);
  secondColor = hexTo565(secondColorHex);
  messageColor = hexTo565(messageColorHex);
  messagePanel1Color = hexTo565(messageP1Hex);
  messagePanel2Color = hexTo565(messageP2Hex);
  messagePanel3Color = hexTo565(messageP3Hex);
  blackColor = dma_display->color565(0, 0, 0);
}

uint16_t rainbowColor(uint8_t pos) {
  pos = 255 - pos;

  if (pos < 85) {
    return dma_display->color565(255 - pos * 3, 0, pos * 3);
  }

  if (pos < 170) {
    pos -= 85;
    return dma_display->color565(0, pos * 3, 255 - pos * 3);
  }

  pos -= 170;
  return dma_display->color565(pos * 3, 255 - pos * 3, 0);
}

uint16_t scrollingTextColor(int x) {
  if (messageColorMode == "rainbow") {
    return rainbowColor((uint8_t)(x * 4 + millis() / 12));
  }

  if (messageColorMode == "panels") {
    if (x < 32) return messagePanel1Color;
    if (x < 64) return messagePanel2Color;
    return messagePanel3Color;
  }

  return messageColor;
}

// =====================================================
// CHIFFRES - RENDU "7 SEGMENTS" (et ses variantes)
// =====================================================

void drawDigitSegments(int cellX, int number, uint16_t color,
                        int thickOverride, bool gap, bool forceMax = false) {
  if (number < 0 || number > 9) return;

  int digitW = forceMax ? 12 : map(digitSize, 50, 100, 7, 12);
  int digitH = forceMax ? 25 : map(digitSize, 50, 100, 14, 25);

  int thick = (thickOverride == -1)
                ? constrain((int)digitThickness, 1, 3)
                : constrain(thickOverride, 1, 3);

  int x = cellX + (16 - digitW) / 2;
  int y = (32 - digitH) / 2;

  int horizontalW = digitW - (2 * thick);
  int halfH = (digitH - (3 * thick)) / 2;

  if (gap) {
    horizontalW -= 1;
    halfH -= 1;
  }

  if (horizontalW < 1) horizontalW = 1;
  if (halfH < 1) halfH = 1;

  uint8_t s = digitSegments[number];

  display->fillRect(x + thick, y, horizontalW, thick, (s & 0x01) ? color : blackColor);
  display->fillRect(x + digitW - thick, y + thick, thick, halfH, (s & 0x02) ? color : blackColor);
  display->fillRect(x + digitW - thick, y + (2 * thick) + halfH, thick, halfH, (s & 0x04) ? color : blackColor);
  display->fillRect(x + thick, y + (2 * halfH) + (2 * thick), horizontalW, thick, (s & 0x08) ? color : blackColor);
  display->fillRect(x, y + (2 * thick) + halfH, thick, halfH, (s & 0x10) ? color : blackColor);
  display->fillRect(x, y + thick, thick, halfH, (s & 0x20) ? color : blackColor);
  display->fillRect(x + thick, y + halfH + thick, horizontalW, thick, (s & 0x40) ? color : blackColor);
}

// =====================================================
// CHIFFRES - RENDU "PIXEL BLOC" (et ses variantes)
// =====================================================

void drawDigitBlock(int cellX, int number, uint16_t color,
                     int scaleOverride, bool roundCorners) {
  if (number < 0 || number > 9) return;

  int scale = (scaleOverride == -1)
                ? ((digitSize > 75) ? 2 : 1)
                : scaleOverride;

  int w = 5 * scale;
  int h = 7 * scale;

  int x = cellX + (16 - w) / 2;
  int y = (32 - h) / 2;

  for (int row = 0; row < 7; row++) {
    uint8_t bits = blockDigits[number][row];

    for (int col = 0; col < 5; col++) {
      bool on = bits & (1 << (4 - col));

      bool isCorner = (row == 0 || row == 6) && (col == 0 || col == 4);
      if (roundCorners && isCorner) on = false;

      display->fillRect(x + col * scale, y + row * scale, scale, scale, on ? color : blackColor);
    }
  }
}

void drawDigit(int cellX, int number, uint16_t color) {
  if (effectiveFontStyle == "segments_thin") {
    drawDigitSegments(cellX, number, color, 1, false);
  } else if (effectiveFontStyle == "segments_thick") {
    drawDigitSegments(cellX, number, color, 3, false, true);
  } else if (effectiveFontStyle == "lcd_retro") {
    drawDigitSegments(cellX, number, color, -1, true);
  } else if (effectiveFontStyle == "block") {
    drawDigitBlock(cellX, number, color, -1, false);
  } else if (effectiveFontStyle == "block_round") {
    drawDigitBlock(cellX, number, color, -1, true);
  } else if (effectiveFontStyle == "spaced") {
    drawDigitBlock(cellX, number, color, 1, false);
  } else if (effectiveFontStyle == "compact") {
    drawDigitBlock(cellX, number, color, 2, false);
  } else {
    drawDigitSegments(cellX, number, color, -1, false);
  }
}

void resetClockCache() {
  oldH1 = oldH2 = oldM1 = oldM2 = oldS1 = oldS2 = -1;
  oldSecond = -1;
  oldColonVisible = -1;
}

void updateClock() {
  struct tm timeinfo;
  if (!getLocalTime(&timeinfo, 10)) return;

  if (autoFontRotation) {
    int idx = timeinfo.tm_yday % 8;
    effectiveFontStyle = FONT_STYLE_LIST[idx];
  } else {
    effectiveFontStyle = digitFontStyle;
  }

  if (effectiveFontStyle != lastEffectiveFontStyle) {
    lastEffectiveFontStyle = effectiveFontStyle;
    oldH1 = oldH2 = oldM1 = oldM2 = oldS1 = oldS2 = -1;
  }

  if (timeinfo.tm_sec == oldSecond) return;
  oldSecond = timeinfo.tm_sec;

  int h1 = timeinfo.tm_hour / 10;
  int h2 = timeinfo.tm_hour % 10;
  int m1 = timeinfo.tm_min / 10;
  int m2 = timeinfo.tm_min % 10;
  int s1 = timeinfo.tm_sec / 10;
  int s2 = timeinfo.tm_sec % 10;

  if (h1 != oldH1) { drawDigit(0,  h1, hourColor);   oldH1 = h1; }
  if (h2 != oldH2) { drawDigit(16, h2, hourColor);   oldH2 = h2; }
  if (m1 != oldM1) { drawDigit(32, m1, minuteColor); oldM1 = m1; }
  if (m2 != oldM2) { drawDigit(48, m2, minuteColor); oldM2 = m2; }
  if (s1 != oldS1) { drawDigit(64, s1, secondColor); oldS1 = s1; }
  if (s2 != oldS2) { drawDigit(80, s2, secondColor); oldS2 = s2; }

  int colonVisible = (timeinfo.tm_sec % 2 == 0) ? 1 : 0;

  if (colonVisible != oldColonVisible) {
    uint16_t c = colonVisible ? minuteColor : blackColor;

    display->fillRect(31, 11, 2, 2, c);
    display->fillRect(31, 19, 2, 2, c);
    display->fillRect(63, 11, 2, 2, c);
    display->fillRect(63, 19, 2, 2, c);

    oldColonVisible = colonVisible;
  }
}

void showClockNow() {
  dma_display->clearScreen();
  resetClockCache();
  updateClock();
}

// =====================================================
// EMOJIS - RENDU BAS NIVEAU
// =====================================================

void drawPixelEmojiBitmap(const uint8_t* bitmap, uint16_t color, int x, int y) {
  if (!bitmap) return;

  for (int row = 0; row < 8; row++) {
    for (int col = 0; col < 8; col++) {
      if (bitmap[row] & (1 << (7 - col))) {
        display->fillRect(x + col * 2, y + row * 2, 2, 2, color);
      }
    }
  }
}

// =====================================================
// MESSAGE : DECOUPAGE EN JETONS (TEXTE + EMOJIS INTEGRES)
//
// Codes reconnus dans le texte, entre crochets :
//   [<3] [</3] [star] [bolt] [fire] [coffee] [:)] [:D]
//   [;)] [:(] [>:(]  [sun] [moon] [rain] [snow]
//   [skull] [ghost] [music] [mountain] [rocket] [trophy]
// =====================================================

struct MsgToken {
  bool isEmoji;
  char ch;
  const uint8_t* bitmap;
  uint16_t color;
};

std::vector<MsgToken> activeTokens;
int activeTextWidthPx = 0;

// La police classique d'Adafruit_GFX utilise les codes CP437 alors que
// les telephones Android envoient les messages en UTF-8. Cette conversion
// permet d'afficher correctement les accents francais sur les panneaux.
uint8_t unicodeToCp437(uint32_t codepoint) {
  switch (codepoint) {
    case 0x00C7: return 128; // Ç
    case 0x00FC: return 129; // ü
    case 0x00E9: return 130; // é
    case 0x00E2: return 131; // â
    case 0x00E4: return 132; // ä
    case 0x00E0: return 133; // à
    case 0x00E5: return 134; // å
    case 0x00E7: return 135; // ç
    case 0x00EA: return 136; // ê
    case 0x00EB: return 137; // ë
    case 0x00E8: return 138; // è
    case 0x00EF: return 139; // ï
    case 0x00EE: return 140; // î
    case 0x00C4: return 142; // Ä
    case 0x00C9: return 144; // É
    case 0x00F4: return 147; // ô
    case 0x00F6: return 148; // ö
    case 0x00FB: return 150; // û
    case 0x00F9: return 151; // ù
    case 0x00FF: return 152; // ÿ
    case 0x00D6: return 153; // Ö
    case 0x00DC: return 154; // Ü
    case 0x2018:
    case 0x2019: return '\'';
    case 0x201C:
    case 0x201D: return '"';
    default: return '?';
  }
}

uint8_t readUtf8Character(const String &text, int &index) {
  uint8_t first = (uint8_t)text.charAt(index++);
  if (first < 0x80) return first;

  uint32_t codepoint = 0;
  int continuationCount = 0;

  if ((first & 0xE0) == 0xC0) {
    codepoint = first & 0x1F;
    continuationCount = 1;
  } else if ((first & 0xF0) == 0xE0) {
    codepoint = first & 0x0F;
    continuationCount = 2;
  } else if ((first & 0xF8) == 0xF0) {
    codepoint = first & 0x07;
    continuationCount = 3;
  } else {
    return '?';
  }

  for (int n = 0; n < continuationCount; n++) {
    if (index >= text.length()) return '?';
    uint8_t next = (uint8_t)text.charAt(index);
    if ((next & 0xC0) != 0x80) return '?';
    index++;
    codepoint = (codepoint << 6) | (next & 0x3F);
  }

  return unicodeToCp437(codepoint);
}

bool resolveEmojiCode(const String &code, const uint8_t* &bmp, uint16_t &col) {
  bmp = nullptr;

  if (code == "<3")        { bmp = emojiHeart;       col = dma_display->color565(255, 20, 80); }
  else if (code == "</3")  { bmp = emojiBrokenHeart;  col = dma_display->color565(180, 20, 60); }
  else if (code == "star") { bmp = emojiStar;         col = dma_display->color565(255, 230, 0); }
  else if (code == "bolt") { bmp = emojiBolt;         col = dma_display->color565(255, 240, 60); }
  else if (code == "fire") { bmp = emojiFire;         col = dma_display->color565(255, 90, 0); }
  else if (code == "coffee") { bmp = emojiCoffee;     col = dma_display->color565(255, 170, 70); }
  else if (code == ":)" || code == ":D") { bmp = emojiSmile; col = dma_display->color565(255, 220, 0); }
  else if (code == ";)")   { bmp = emojiWink;         col = dma_display->color565(255, 220, 0); }
  else if (code == ":(")   { bmp = emojiSad;          col = dma_display->color565(90, 170, 255); }
  else if (code == ">:(")  { bmp = emojiAngry;        col = dma_display->color565(255, 60, 40); }
  else if (code == "sun")  { bmp = emojiSun;          col = dma_display->color565(255, 200, 0); }
  else if (code == "moon") { bmp = emojiMoon;         col = dma_display->color565(220, 220, 255); }
  else if (code == "rain") { bmp = emojiRain;         col = dma_display->color565(90, 170, 255); }
  else if (code == "snow") { bmp = emojiSnow;         col = dma_display->color565(200, 230, 255); }
  else if (code == "skull") { bmp = emojiSkull;       col = dma_display->color565(220, 255, 240); }
  else if (code == "ghost") { bmp = emojiGhost;       col = dma_display->color565(220, 220, 255); }
  else if (code == "music") { bmp = emojiMusic;       col = dma_display->color565(180, 90, 255); }
  else if (code == "mountain") { bmp = emojiMountain; col = dma_display->color565(140, 200, 160); }
  else if (code == "rocket") { bmp = emojiRocket;     col = dma_display->color565(255, 100, 60); }
  else if (code == "trophy") { bmp = emojiTrophy;     col = dma_display->color565(255, 210, 0); }

  return bmp != nullptr;
}

std::vector<MsgToken> parseMessageTokens(const String &message) {
  std::vector<MsgToken> tokens;

  int i = 0;
  int len = message.length();

  while (i < len) {

    if (message.charAt(i) == '[') {
      int close = message.indexOf(']', i);

      if (close != -1 && close - i <= 10) {
        String code = message.substring(i + 1, close);

        const uint8_t* bmp = nullptr;
        uint16_t col = messageColor;

        if (resolveEmojiCode(code, bmp, col)) {
          MsgToken t;
          t.isEmoji = true;
          t.bitmap = bmp;
          t.color = col;
          t.ch = 0;
          tokens.push_back(t);

          i = close + 1;
          continue;
        }
      }
    }

    MsgToken t;
    t.isEmoji = false;
    t.ch = (char)readUtf8Character(message, i);
    t.bitmap = nullptr;
    t.color = 0;
    tokens.push_back(t);
  }

  return tokens;
}

void prepareMessage(const String &message) {
  activeTokens = parseMessageTokens(message);

  activeTextWidthPx = 0;
  for (auto &t : activeTokens) {
    activeTextWidthPx += t.isEmoji ? 18 : 12;
  }

  scrollX = 96;
}

// =====================================================
// MESSAGE DEFILANT
// =====================================================

bool drawScrollingMessage() {
  if (activeTokens.empty()) return true;
  if (millis() - lastScrollRefresh < scrollDelayMs) return false;
  lastScrollRefresh = millis();

  dma_display->clearScreen();

  display->setTextWrap(false);
  display->setTextSize(2);
  display->cp437(true);

  int cursorX = scrollX;

  for (auto &t : activeTokens) {
    if (t.isEmoji) {
      drawPixelEmojiBitmap(t.bitmap, t.color, cursorX, 8);
      cursorX += 18;
    } else {
      display->setTextColor(scrollingTextColor(cursorX + 6));
      display->setCursor(cursorX, 9);
      display->write((uint8_t)t.ch);
      cursorX += 12;
    }
  }

  scrollX--;

  if (scrollX < -activeTextWidthPx) {
    scrollX = 96;
    return true;
  }

  return false;
}

// =====================================================
// DATE
// =====================================================

String getToday() {
  struct tm timeinfo;
  if (!getLocalTime(&timeinfo, 10)) return "";

  char buffer[11];
  strftime(buffer, sizeof(buffer), "%Y-%m-%d", &timeinfo);
  return String(buffer);
}

// =====================================================
// PROGRAMMATION : SAUVEGARDE / CHARGEMENT
// =====================================================

void loadSchedules() {
  for (int i = 0; i < MAX_SCHEDULES; i++) {
    schedDate[i] = preferences.getString(("d" + String(i)).c_str(), "");
    schedMsg[i]  = preferences.getString(("m" + String(i)).c_str(), "");
  }
}

// =====================================================
// PAGE WEB
// =====================================================

String buildScheduleListHtml() {
  String html = "";
  bool any = false;

  for (int i = 0; i < MAX_SCHEDULES; i++) {
    if (schedDate[i].length() && schedMsg[i].length()) {
      any = true;

      html += "<div class='schedRow'>";
      html += "<span class='schedText'><b>" + schedDate[i] + "</b> - " + schedMsg[i] + "</span>";
      html += "<form action='/deleteSchedule' method='POST'>";
      html += "<input type='hidden' name='index' value='" + String(i) + "'>";
      html += "<button class='danger' type='submit'>Suppr.</button>";
      html += "</form>";
      html += "</div>";
    }
  }

  if (!any) {
    html = "<p class='info'>Aucun message programme pour le moment.</p>";
  }

  return html;
}

String fontOptionsHtml() {
  struct FO { const char* value; const char* label; };
  FO options[8] = {
    {"segments",       "7 segments (classique)"},
    {"segments_thin",  "7 segments fin"},
    {"segments_thick", "7 segments epais"},
    {"lcd_retro",      "LCD retro (avec espaces)"},
    {"block",          "Pixel bloc"},
    {"block_round",    "Pixel bloc arrondi"},
    {"spaced",         "Pixel espace"},
    {"compact",        "Pixel compact"}
  };

  String html = "";
  for (int i = 0; i < 8; i++) {
    html += "<option value='";
    html += options[i].value;
    html += "'";
    if (digitFontStyle == options[i].value) html += " selected";
    html += ">";
    html += options[i].label;
    html += "</option>";
  }
  return html;
}

// Rangee de boutons emoji cliquables, inseres au curseur dans le champ vise.
String emojiRowHtml(const String &targetInputId) {
  struct EB { const char* glyph; const char* code; };
  EB buttons[20] = {
    {"\xE2\x9D\xA4\xEF\xB8\x8F", "<3"},
    {"\xF0\x9F\x92\x94", "</3"},
    {"\xE2\xAD\x90", "star"},
    {"\xE2\x9A\xA1", "bolt"},
    {"\xF0\x9F\x94\xA5", "fire"},
    {"\xE2\x98\x95", "coffee"},
    {"\xF0\x9F\x99\x82", ":)"},
    {"\xF0\x9F\x98\x89", ";)"},
    {"\xF0\x9F\x99\x81", ":("},
    {"\xF0\x9F\x98\xA0", ">:("},
    {"\xE2\x98\x80\xEF\xB8\x8F", "sun"},
    {"\xF0\x9F\x8C\x99", "moon"},
    {"\xF0\x9F\x8C\xA7\xEF\xB8\x8F", "rain"},
    {"\xE2\x9D\x84\xEF\xB8\x8F", "snow"},
    {"\xF0\x9F\x92\x80", "skull"},
    {"\xF0\x9F\x91\xBB", "ghost"},
    {"\xF0\x9F\x8E\xB5", "music"},
    {"\xF0\x9F\x8F\x94\xEF\xB8\x8F", "mountain"},
    {"\xF0\x9F\x9A\x80", "rocket"},
    {"\xF0\x9F\x8F\x86", "trophy"}
  };

  String html = "<div class='emojiRow'>";
  for (int i = 0; i < 20; i++) {
    html += "<button type='button' class='emojiBtn' onclick=\"insertEmoji('";
    html += targetInputId;
    html += "','";
    html += buttons[i].code;
    html += "')\">";
    html += buttons[i].glyph;
    html += "</button>";
  }
  html += "</div>";

  return html;
}

String webPage() {
  String page = R"rawliteral(
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Barbapocalypse</title>
<style>
*{box-sizing:border-box}
body{margin:0;background:#07100d;color:#d8fff1;font-family:Arial,sans-serif;text-align:center;padding:18px}
h1{color:#00ff96;margin:10px 0 24px;text-shadow:0 0 14px #00ff9670}
h2{color:#00ff96;margin-top:4px}
.card{max-width:620px;margin:16px auto;background:#101a17;border:1px solid #1d3b32;padding:18px;border-radius:16px;box-shadow:0 5px 22px #0008}
.row{display:flex;gap:10px;justify-content:center;align-items:center;flex-wrap:wrap}
label{display:block;margin-top:12px;color:#a9d9c9}
input[type=text],input[type=date],select{width:92%;max-width:460px;padding:12px;margin:7px;border-radius:9px;border:1px solid #31594d;background:#08120f;color:white;font-size:16px}
input[type=color]{width:70px;height:42px;border:0;background:transparent}

input[type=range]{-webkit-appearance:none;appearance:none;width:60%;max-width:320px;height:10px;border-radius:5px;background:#1d3b32}
input[type=range]::-webkit-slider-thumb{-webkit-appearance:none;width:30px;height:30px;border-radius:50%;background:#00cc80;border:3px solid #001b11;cursor:pointer}
input[type=range]::-moz-range-thumb{width:30px;height:30px;border-radius:50%;background:#00cc80;border:3px solid #001b11;cursor:pointer}

.stepBtn{width:46px;height:46px;font-size:24px;font-weight:900;border:0;border-radius:10px;background:#1d3b32;color:#00ff96;cursor:pointer;flex-shrink:0}
.stepBtn:hover{background:#254a3f}

button{padding:12px 18px;margin:9px;border:0;border-radius:10px;background:#00cc80;color:#001b11;font-weight:800;font-size:16px;cursor:pointer}
button:hover{background:#00ff96}
.danger{background:#db4c5c;color:white}
.info{color:#a9d9c9;font-size:14px}
.colors{display:flex;justify-content:center;gap:16px;flex-wrap:wrap}
.colorbox{min-width:120px}
.codes{color:#7fd9bd;font-size:13px;margin-top:4px}
.schedRow{display:flex;justify-content:space-between;align-items:center;gap:10px;background:#0c1613;border:1px solid #1d3b32;border-radius:10px;padding:8px 12px;margin:8px 0;text-align:left}
.schedText{font-size:14px;word-break:break-word}
.schedRow form{margin:0}
.checkRow{display:flex;align-items:center;justify-content:center;gap:10px;margin-top:14px}
.checkRow input{width:22px;height:22px}
.emojiRow{display:flex;flex-wrap:wrap;justify-content:center;gap:6px;margin:10px 0}
.emojiBtn{width:42px;height:42px;font-size:20px;border:0;border-radius:8px;background:#1d3b32;cursor:pointer;padding:0}
.emojiBtn:hover{background:#254a3f}
</style>
</head>
<body>

<h1>CLOCK DESTROY</h1>

<div class="card">
  <h2>Horloge</h2>
  <form action="/clock" method="POST">
    <button type="submit">AFFICHER L'HORLOGE</button>
  </form>
</div>

<div class="card">
  <h2>Affichage</h2>
  <form action="/displaySettings" method="POST">

    <label>Luminosité : <span id="val_brightness">BRIGHTNESS_VALUE</span> / 255</label>
    <div class="row">
      <button type="button" class="stepBtn" onclick="step('brightness',-5)">−</button>
      <input type="range" id="brightness" name="brightness" min="1" max="255" value="BRIGHTNESS_VALUE" oninput="syncVal('brightness')">
      <button type="button" class="stepBtn" onclick="step('brightness',5)">+</button>
    </div>

    <label>Taille des chiffres : <span id="val_size">SIZE_VALUE</span> %</label>
    <div class="row">
      <button type="button" class="stepBtn" onclick="step('size',-5)">−</button>
      <input type="range" id="size" name="size" min="50" max="100" value="SIZE_VALUE" oninput="syncVal('size')">
      <button type="button" class="stepBtn" onclick="step('size',5)">+</button>
    </div>

    <label>Épaisseur (police 7 segments) : <span id="val_thickness">THICK_VALUE</span></label>
    <div class="row">
      <button type="button" class="stepBtn" onclick="step('thickness',-1)">−</button>
      <input type="range" id="thickness" name="thickness" min="1" max="3" value="THICK_VALUE" oninput="syncVal('thickness')">
      <button type="button" class="stepBtn" onclick="step('thickness',1)">+</button>
    </div>

    <label>Vitesse de défilement des messages : <span id="val_scrollSpeed">SCROLL_VALUE</span> ms</label>
    <div class="row">
      <span class="info">Rapide</span>
      <button type="button" class="stepBtn" onclick="step('scrollSpeed',-5)">−</button>
      <input type="range" id="scrollSpeed" name="scrollSpeed" min="15" max="150" value="SCROLL_VALUE" oninput="syncVal('scrollSpeed')">
      <button type="button" class="stepBtn" onclick="step('scrollSpeed',5)">+</button>
      <span class="info">Lent</span>
    </div>

    <label>Police d'affichage des chiffres</label>
    <select name="fontStyle">
      FONT_OPTIONS_HTML
    </select>

    <div class="checkRow">
      <input type="checkbox" name="autoFont" id="autoFont" value="1" AUTO_FONT_CHECKED>
      <label for="autoFont" style="margin:0">Rotation automatique de la police (change chaque jour, ignore le choix ci-dessus)</label>
    </div>

    <div class="colors">
      <div class="colorbox"><label>Heures</label><input type="color" name="hourColor" value="HOUR_COLOR"></div>
      <div class="colorbox"><label>Minutes</label><input type="color" name="minuteColor" value="MINUTE_COLOR"></div>
      <div class="colorbox"><label>Secondes</label><input type="color" name="secondColor" value="SECOND_COLOR"></div>
    </div>

    <br><button type="submit">APPLIQUER</button>
  </form>
</div>

<div class="card">
  <h2>Message immédiat</h2>
  <p class="info">2 passages du message, puis retour à l'horloge pendant 1 minute, en boucle.</p>
  <form action="/message" method="POST">
    <input type="text" id="msgText" name="message" maxlength="140" placeholder="Ton message..." required>

    MSG_EMOJI_ROW

    <p class="codes">Clique un emoji pour l'insérer où est ton curseur dans le texte.</p>

    <label>Couleurs</label>
    <select name="colorMode" id="colorMode" onchange="updateColorMode()">
      <option value="single">Une seule couleur</option>
      <option value="panels">Une couleur par panneau</option>
      <option value="rainbow">Arc-en-ciel</option>
    </select>

    <div id="singleColor"><label>Couleur</label><input type="color" name="messageColor" value="MSG_COLOR"></div>

    <div id="panelColors" style="display:none" class="colors">
      <div class="colorbox"><label>Panneau 1</label><input type="color" name="messageP1" value="MSG_P1"></div>
      <div class="colorbox"><label>Panneau 2</label><input type="color" name="messageP2" value="MSG_P2"></div>
      <div class="colorbox"><label>Panneau 3</label><input type="color" name="messageP3" value="MSG_P3"></div>
    </div>

    <br><button type="submit">AFFICHER LE MESSAGE</button>
  </form>
</div>

<div class="card">
  <h2>Messages programmés (10 maximum)</h2>
  <form action="/schedule" method="POST">
    <input type="date" name="date" required>
    <input type="text" id="schedText" name="message" maxlength="140" placeholder="Message pour cette journée" required>

    SCHED_EMOJI_ROW

    <br><button type="submit">AJOUTER</button>
  </form>

  SCHEDULE_LIST_HTML

</div>

<script>
function updateColorMode(){
  const m=document.getElementById('colorMode').value;
  document.getElementById('singleColor').style.display=(m==='single')?'block':'none';
  document.getElementById('panelColors').style.display=(m==='panels')?'flex':'none';
}

function syncVal(id){
  document.getElementById('val_'+id).innerText = document.getElementById(id).value;
}

function step(id, amount){
  const el = document.getElementById(id);
  let v = parseInt(el.value) + amount;
  const min = parseInt(el.min);
  const max = parseInt(el.max);
  if (v < min) v = min;
  if (v > max) v = max;
  el.value = v;
  syncVal(id);
}

function insertEmoji(inputId, code){
  const el = document.getElementById(inputId);
  const start = el.selectionStart != null ? el.selectionStart : el.value.length;
  const end = el.selectionEnd != null ? el.selectionEnd : el.value.length;
  const text = el.value;
  const insert = '[' + code + ']';
  el.value = text.substring(0, start) + insert + text.substring(end);
  const newPos = start + insert.length;
  el.focus();
  el.setSelectionRange(newPos, newPos);
}

fetch('/settime?epoch='+Math.floor(Date.now()/1000));
</script>

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

  page.replace("BRIGHTNESS_VALUE", String(displayBrightness));
  page.replace("SIZE_VALUE", String(digitSize));
  page.replace("THICK_VALUE", String(digitThickness));
  page.replace("SCROLL_VALUE", String(scrollDelayMs));

  page.replace("FONT_OPTIONS_HTML", fontOptionsHtml());
  page.replace("AUTO_FONT_CHECKED", autoFontRotation ? "checked" : "");

  page.replace("HOUR_COLOR", hourColorHex);
  page.replace("MINUTE_COLOR", minuteColorHex);
  page.replace("SECOND_COLOR", secondColorHex);

  page.replace("MSG_COLOR", messageColorHex);
  page.replace("MSG_P1", messageP1Hex);
  page.replace("MSG_P2", messageP2Hex);
  page.replace("MSG_P3", messageP3Hex);

  page.replace("MSG_EMOJI_ROW", emojiRowHtml("msgText"));
  page.replace("SCHED_EMOJI_ROW", emojiRowHtml("schedText"));

  page.replace("SCHEDULE_LIST_HTML", buildScheduleListHtml());

  return page;
}

// =====================================================
// SERVEUR WEB
// =====================================================

void setupWebServer() {
  server.on("/", HTTP_GET, []() {
    server.send(200, "text/html; charset=utf-8", webPage());
  });

  server.on("/clock", HTTP_POST, []() {
    manualMode = false;
    manualMessage = "";
    passCount = 0;
    clockPause = false;
    currentPlaybackMessage = "";
    showClockNow();
    server.sendHeader("Location", "/");
    server.send(303);
  });

  server.on("/displaySettings", HTTP_GET, []() {
    String json = "{";
    json += "\"brightness\":" + String(displayBrightness) + ",";
    json += "\"size\":" + String(digitSize) + ",";
    json += "\"thickness\":" + String(digitThickness) + ",";
    json += "\"scrollSpeed\":" + String(scrollDelayMs) + ",";
    json += "\"fontStyle\":\"" + digitFontStyle + "\",";
    json += "\"autoFont\":" + String(autoFontRotation ? "true" : "false") + ",";
    json += "\"hourColor\":\"" + hourColorHex + "\",";
    json += "\"minuteColor\":\"" + minuteColorHex + "\",";
    json += "\"secondColor\":\"" + secondColorHex + "\"";
    json += "}";

    server.send(200, "application/json; charset=utf-8", json);
  });

  server.on("/displaySettings", HTTP_POST, []() {
    displayBrightness = constrain(server.arg("brightness").toInt(), 1, 255);
    digitSize = constrain(server.arg("size").toInt(), 50, 100);
    digitThickness = constrain(server.arg("thickness").toInt(), 1, 3);
    scrollDelayMs = constrain(server.arg("scrollSpeed").toInt(), 15, 150);

    String fs = server.arg("fontStyle");
    bool validFont = false;
    for (int i = 0; i < 8; i++) {
      if (fs == FONT_STYLE_LIST[i]) { validFont = true; break; }
    }
    if (validFont) digitFontStyle = fs;

    autoFontRotation = server.hasArg("autoFont");

    if (server.arg("hourColor").length() == 7) hourColorHex = server.arg("hourColor");
    if (server.arg("minuteColor").length() == 7) minuteColorHex = server.arg("minuteColor");
    if (server.arg("secondColor").length() == 7) secondColorHex = server.arg("secondColor");

    preferences.putUChar("bright", displayBrightness);
    preferences.putUChar("size", digitSize);
    preferences.putUChar("thick", digitThickness);
    preferences.putUShort("scrollms", scrollDelayMs);
    preferences.putString("font", digitFontStyle);
    preferences.putBool("autofont", autoFontRotation);
    preferences.putString("hourcol", hourColorHex);
    preferences.putString("mincol", minuteColorHex);
    preferences.putString("seccol", secondColorHex);

    dma_display->setBrightness8(displayBrightness);
    refreshDisplayColors();

    manualMode = false;
    manualMessage = "";
    passCount = 0;
    clockPause = false;
    currentPlaybackMessage = "";

    lastEffectiveFontStyle = "";
    showClockNow();
    server.sendHeader("Location", "/");
    server.send(303);
  });

  server.on("/message", HTTP_POST, []() {
    manualMessage = server.arg("message");
    messageColorMode = server.arg("colorMode");

    if (messageColorMode != "single" &&
        messageColorMode != "panels" &&
        messageColorMode != "rainbow") {
      messageColorMode = "single";
    }

    if (server.arg("messageColor").length() == 7) messageColorHex = server.arg("messageColor");
    if (server.arg("messageP1").length() == 7) messageP1Hex = server.arg("messageP1");
    if (server.arg("messageP2").length() == 7) messageP2Hex = server.arg("messageP2");
    if (server.arg("messageP3").length() == 7) messageP3Hex = server.arg("messageP3");

    preferences.putString("msgmode", messageColorMode);
    preferences.putString("msgcol", messageColorHex);
    preferences.putString("msgp1", messageP1Hex);
    preferences.putString("msgp2", messageP2Hex);
    preferences.putString("msgp3", messageP3Hex);

    refreshDisplayColors();

    if (manualMessage.length()) {
      manualMode = true;
    }

    server.sendHeader("Location", "/");
    server.send(303);
  });

  server.on("/schedule", HTTP_POST, []() {
    String date = server.arg("date");
    String msg = server.arg("message");

    if (date.length() && msg.length()) {
      int slot = -1;

      for (int i = 0; i < MAX_SCHEDULES; i++) {
        if (schedDate[i].length() == 0) { slot = i; break; }
      }

      if (slot != -1) {
        schedDate[slot] = date;
        schedMsg[slot] = msg;

        preferences.putString(("d" + String(slot)).c_str(), date);
        preferences.putString(("m" + String(slot)).c_str(), msg);
      }
    }

    server.sendHeader("Location", "/");
    server.send(303);
  });

  server.on("/deleteSchedule", HTTP_POST, []() {
    int idx = server.arg("index").toInt();

    if (idx >= 0 && idx < MAX_SCHEDULES) {
      schedDate[idx] = "";
      schedMsg[idx] = "";

      preferences.remove(("d" + String(idx)).c_str());
      preferences.remove(("m" + String(idx)).c_str());
    }

    server.sendHeader("Location", "/");
    server.send(303);
  });

server.on("/schedules", HTTP_GET, []() {
    String json = "[";
    bool first = true;
    for (int i = 0; i < MAX_SCHEDULES; i++) {
      if (schedDate[i].length() && schedMsg[i].length()) {
        if (!first) json += ",";
        first = false;
        String safeMsg = schedMsg[i];
        safeMsg.replace("\\", "\\\\");
        safeMsg.replace("\"", "\\\"");
        json += "{\"index\":" + String(i) + ",\"date\":\"" + schedDate[i] + "\",\"message\":\"" + safeMsg + "\"}";
      }
    }
    json += "]";
    server.send(200, "application/json; charset=utf-8", json);
  });

  server.on("/status", HTTP_GET, []() {
    String json = "{";
    json += "\"mode\":\"" + String(currentPlaybackMessage.length() ? "message" : "clock") + "\",";
    json += "\"phase\":\"" + String(currentPlaybackMessage.length() ? (clockPause ? "clockPause" : "scrolling") : "none") + "\",";
    json += "\"source\":\"" + currentPlaybackSource + "\",";

    String safeText = currentPlaybackMessage;
    safeText.replace("\\", "\\\\");
    safeText.replace("\"", "\\\"");

    json += "\"text\":\"" + safeText + "\"";
    json += "}";

    server.send(200, "application/json; charset=utf-8", json);
  });

  server.on("/settime", HTTP_GET, []() {
    time_t epoch = (time_t)server.arg("epoch").toInt();
    if (epoch > 1700000000) {
      struct timeval tv;
      tv.tv_sec = epoch;
      tv.tv_usec = 0;
      settimeofday(&tv, nullptr);
      resetClockCache();
    }
    server.send(200, "text/plain", "OK");
  });

  server.begin();
}

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

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

  preferences.begin("barbapocalypse", false);

  displayBrightness = preferences.getUChar("bright", DEFAULT_BRIGHTNESS);
  digitSize = preferences.getUChar("size", 100);
  digitThickness = preferences.getUChar("thick", 3);
  scrollDelayMs = preferences.getUShort("scrollms", 45);
  digitFontStyle = preferences.getString("font", "segments");
  autoFontRotation = preferences.getBool("autofont", false);

  displayBrightness = constrain(displayBrightness, 1, 255);
  digitSize = constrain(digitSize, 50, 100);
  digitThickness = constrain(digitThickness, 1, 3);
  scrollDelayMs = constrain(scrollDelayMs, 15, 150);

  hourColorHex = preferences.getString("hourcol", "#00ff96");
  minuteColorHex = preferences.getString("mincol", "#00ff96");
  secondColorHex = preferences.getString("seccol", "#00ff96");

  messageColorMode = preferences.getString("msgmode", "single");
  messageColorHex = preferences.getString("msgcol", "#00ff96");
  messageP1Hex = preferences.getString("msgp1", "#00ff96");
  messageP2Hex = preferences.getString("msgp2", "#a050ff");
  messageP3Hex = preferences.getString("msgp3", "#ff1493");

  loadSchedules();

  // ===================================================
  // WIFI
  // ===================================================

  // Toujours définir le fuseau horaire, même si Internet est absent.
  setenv("TZ", "CET-1CEST,M3.5.0/2,M10.5.0/3", 1);
  tzset();
  setTemporaryMidnight();

  WiFi.mode(WIFI_STA);
  WiFi.setSleep(false);
  WiFi.setAutoReconnect(true);
  WiFi.persistent(true);
  WiFi.setHostname("Barbapocalypse");
  WiFi.begin(WIFI_SSID, WIFI_PASSWORD);

  Serial.println();
  Serial.println("Connexion WiFi...");

  unsigned long wifiStart = millis();
  while (WiFi.status() != WL_CONNECTED && millis() - wifiStart < 15000) {
    delay(250);
    Serial.print(".");
  }

  if (WiFi.status() == WL_CONNECTED) {
    Serial.println();
    Serial.println("WiFi connecte");
    Serial.print("Adresse : http://");
    Serial.println(WiFi.localIP());

    wifiWasConnected = true;
    configureNtp();
  } else {
    Serial.println();
    Serial.println("WiFi indisponible : horloge provisoire a 00:00:00");
    setTemporaryMidnight();
    wifiDisconnectedSince = millis();
    startRescueAccessPoint();
  }

  // ===================================================
  // HUB75
  // ===================================================

  HUB75_I2S_CFG::i2s_pins pins = {
    R1_PIN, G1_PIN, B1_PIN,
    R2_PIN, G2_PIN, B2_PIN,
    A_PIN, B_PIN, C_PIN, D_PIN, E_PIN,
    LAT_PIN, OE_PIN, CLK_PIN
  };

  HUB75_I2S_CFG config(
    PANEL_RES_X * 2,
    PANEL_RES_Y / 2,
    PANEL_CHAIN,
    pins
  );

  config.clkphase = false;

  dma_display = new MatrixPanel_I2S_DMA(config);

  if (!dma_display->begin()) {
    Serial.println("ERREUR PANNEAUX");
    while (true) delay(1000);
  }

  dma_display->setBrightness8(displayBrightness);
  dma_display->clearScreen();

  display = new VirtualMatrixPanel(
    *dma_display,
    NUM_ROWS,
    NUM_COLS,
    PANEL_RES_X,
    PANEL_RES_Y,
    VIRTUAL_MATRIX_CHAIN_TYPE
  );

  display->setPhysicalPanelScanRate(FOUR_SCAN_32PX_HIGH);
  display->setRotation(2);  // Rotation 180 degres : affichage remis tete en haut

  refreshDisplayColors();

  setupWebServer();

  if (MDNS.begin("horloge")) {
    Serial.println("Adresse locale : http://horloge.local");
  }

  resetClockCache();
  updateClock();
}

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

void loop() {
  server.handleClient();
  maintainWifiAndTime();

  static unsigned long lastDateCheck = 0;
  static int todaySchedIndex = -1;

  if (millis() - lastDateCheck >= 1000) {
    lastDateCheck = millis();
    String today = getToday();

    todaySchedIndex = -1;
    for (int i = 0; i < MAX_SCHEDULES; i++) {
      if (schedDate[i].length() && schedMsg[i].length() && schedDate[i] == today) {
        todaySchedIndex = i;
        break;
      }
    }
  }

String desired = "";
  String desiredSource = "";

  if (todaySchedIndex != -1) {
    desired = schedMsg[todaySchedIndex];
    desiredSource = "schedule";
  } else if (manualMode && manualMessage.length()) {
    desired = manualMessage;
    desiredSource = "manual";
  }

  if (desired != currentPlaybackMessage) {
    currentPlaybackMessage = desired;
    currentPlaybackSource = desiredSource;   // <-- AJOUT
    passCount = 0;
    clockPause = false;

    if (desired.length()) {
      prepareMessage(desired);
      dma_display->clearScreen();
    } else {
      showClockNow();
    }
  }

  if (currentPlaybackMessage.length()) {

    if (clockPause) {
      updateClock();

      if (millis() - clockPauseStart >= 60000UL) {
        clockPause = false;
        passCount = 0;
        scrollX = 96;
        dma_display->clearScreen();
      }

      delay(2);
      return;
    }

    if (drawScrollingMessage()) {
      passCount++;

      if (passCount >= 2) {
        clockPause = true;
        clockPauseStart = millis();
        showClockNow();
      }
    }

    return;
  }

  updateClock();
  delay(2);
}