Files
ESP32-P10-RGB-32x16-Digital…/examples/fullscreen_countdown_web.cpp
T
2025-08-17 17:31:46 +02:00

2124 lines
90 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* ESP32 P10 RGB Fullscreen Countdown avec Interface Web
* Compte à rebours plein écran avec réglages via interface web
*
* Ce programme affiche un compte à rebours en plein écran et permet de configurer :
* - La date/heure cible du compte à rebours
* - La couleur du texte
* - La taille de police
*
* L'interface web est accessible à l'adresse http://ip_de_l'esp32/
*
* Auteur: Clément Saillant (electron-rare)
* Date: Août 2025
*/
#define PxMATRIX_SPI_FREQUENCY 10000000
// Option d'optimisation démarrage : définir FAST_BOOT pour réduire fortement les délais init
#ifndef FAST_BOOT
#define FAST_BOOT 1
#endif
#if FAST_BOOT
#define BOOT_DELAY(ms) ((void)0)
#define BOOT_SPLASH_MS 300 // durée splash réduite
#define WIFI_STEP_DELAY(ms) vTaskDelay(pdMS_TO_TICKS(5))
#else
#define BOOT_DELAY(ms) delay(ms)
#define BOOT_SPLASH_MS 2000
#define WIFI_STEP_DELAY(ms) delay(ms)
#endif
#include <Arduino.h>
#include <PxMatrix.h>
#include <RTClib.h>
// Polices DejaVu avec support Latin-1 complet (accents et caractères spéciaux)
#include "DejaVuSans9ptLat1.h" // Normal
#include "DejaVuSansBold9ptLat1.h" // Gras
#include "DejaVuSansOblique9ptLat1.h" // Italique
#include <WiFi.h>
#include <WebServer.h>
#include <Preferences.h>
#include <DNSServer.h>
#include <time.h>
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "freertos/queue.h"
#include "freertos/semphr.h"
#include "nvs_flash.h"
#include "esp_err.h"
// Version firmware (uniformisé avec main)
static const char* FIRMWARE_VERSION = "1.0.0"; // garder synchro avec src/main.cpp
// === Personnalisation Auteur / GitHub ===
static const char* AUTHOR_NAME = "Clément Saillant (electron-rare)";
static const char* GITHUB_URL = "https://github.com/electron-rare";
// Pins pour la matrice LED
#define P_LAT 5
#define P_A 19
#define P_B 23
#define P_C 18
#define P_OE 4
// Définition des priorités des tâches (ajustées pour éviter les conflits WiFi)
#define TASK_DISPLAY_PRIORITY 2
#define TASK_WEBSERVER_PRIORITY 1
#define TASK_COUNTDOWN_PRIORITY 1
#define TASK_NETWORK_PRIORITY 3 // Priorité plus élevée pour le réseau
// Taille des stacks pour les tâches
#define TASK_DISPLAY_STACK 4096
#define TASK_WEBSERVER_STACK 4096
#define TASK_COUNTDOWN_STACK 2048
#define TASK_NETWORK_STACK 4096
// Handles pour les tâches
TaskHandle_t displayTaskHandle = NULL;
TaskHandle_t webServerTaskHandle = NULL;
TaskHandle_t countdownTaskHandle = NULL;
TaskHandle_t networkTaskHandle = NULL;
// Mutex pour protéger les ressources partagées
SemaphoreHandle_t displayMutex;
SemaphoreHandle_t countdownMutex;
SemaphoreHandle_t preferencesMutex;
// Configuration des panneaux - définie par les build flags
#ifndef MATRIX_WIDTH
#define MATRIX_WIDTH 32
#endif
#ifndef MATRIX_HEIGHT
#define MATRIX_HEIGHT 16
#endif
#ifndef MATRIX_PANELS_X
#define MATRIX_PANELS_X 3
#endif
#ifndef MATRIX_PANELS_Y
#define MATRIX_PANELS_Y 1
#endif
// Calcul des dimensions totales
#define TOTAL_WIDTH (MATRIX_WIDTH * MATRIX_PANELS_X)
#define TOTAL_HEIGHT (MATRIX_HEIGHT * MATRIX_PANELS_Y)
// Configuration du timer
hw_timer_t * timer = nullptr;
portMUX_TYPE timerMux = portMUX_INITIALIZER_UNLOCKED;
// Temps d'affichage ajusté selon le nombre de panneaux
uint8_t display_draw_time = (MATRIX_PANELS_X * MATRIX_PANELS_Y > 4) ? 20 : 30;
// Objet matrice
PxMATRIX display(TOTAL_WIDTH, TOTAL_HEIGHT, P_LAT, P_OE, P_A, P_B, P_C);
// RTC
RTC_DS3231 rtc;
// Préférences
Preferences preferences;
// Configuration WiFi - Modifiez selon vos besoins
const char* ssid = "YOUR_WIFI_SSID";
const char* password = "YOUR_WIFI_PASSWORD";
// Configuration Point d'accès (si pas de WiFi)
// DNS pour le portail captif
const byte DNS_PORT = 53;
DNSServer dnsServer;
const char* ap_ssid = "HOKA_CLOCK";
const char* ap_password = "hokahoka";
// Mode de fonctionnement WiFi
bool useStationMode = false; // true = se connecter au WiFi, false = créer un point d'accès
// Serveur web
WebServer server(80);
// Couleurs prédéfinies
uint16_t myRED = display.color565(255, 0, 0);
uint16_t myGREEN = display.color565(0, 255, 0);
uint16_t myBLUE = display.color565(0, 0, 255);
uint16_t myYELLOW = display.color565(255, 255, 0);
uint16_t myCYAN = display.color565(0, 255, 255);
uint16_t myMAGENTA = display.color565(255, 0, 255);
uint16_t myWHITE = display.color565(255, 255, 255);
uint16_t myBLACK = display.color565(0, 0, 0);
uint16_t myORANGE = display.color565(255, 165, 0);
// Prototypes des fonctions
void IRAM_ATTR display_updater();
void display_update_enable(bool is_enable); // prototype pour usage anticipé
// Fonction de vérification de la sanité des mutex
bool checkMutexSanity() {
// Vérifier que les mutex sont valides
if (displayMutex == NULL || countdownMutex == NULL || preferencesMutex == NULL) {
Serial.println("ERROR: One or more mutex is NULL!");
return false;
}
// Vérifier l'état du scheduler
if (xTaskGetSchedulerState() != taskSCHEDULER_RUNNING) {
Serial.println("WARNING: Scheduler not running");
return false;
}
// Vérifier que nous ne sommes pas dans une ISR
if (xPortInIsrContext()) {
Serial.println("WARNING: In ISR context");
return false;
}
return true;
}
// Fonction de récupération d'urgence en cas de corruption
void emergencyRecovery() {
Serial.println("EMERGENCY RECOVERY: System corruption detected");
// Arrêter tous les timers
if (timer != nullptr) {
timerEnd(timer);
timer = nullptr;
}
// Arrêter toutes les tâches
if (displayTaskHandle != NULL) {
vTaskDelete(displayTaskHandle);
displayTaskHandle = NULL;
}
if (countdownTaskHandle != NULL) {
vTaskDelete(countdownTaskHandle);
countdownTaskHandle = NULL;
}
if (networkTaskHandle != NULL) {
vTaskDelete(networkTaskHandle);
networkTaskHandle = NULL;
}
// Nettoyage des mutex
if (displayMutex != NULL) {
vSemaphoreDelete(displayMutex);
displayMutex = NULL;
}
if (countdownMutex != NULL) {
vSemaphoreDelete(countdownMutex);
countdownMutex = NULL;
}
if (preferencesMutex != NULL) {
vSemaphoreDelete(preferencesMutex);
preferencesMutex = NULL;
}
// Délai avant redémarrage
delay(1000);
// Redémarrage du système
ESP.restart();
}
// Fonction de réparation de la corruption NVS
void repairNVSCorruption() {
Serial.println("Attempting NVS corruption repair...");
// Fermer toutes les instances de preferences ouvertes
preferences.end();
// Attendre un peu
delay(100);
// Essayer de vider et réinitialiser la partition NVS
esp_err_t err = nvs_flash_erase();
if (err == ESP_OK) {
Serial.println("NVS partition erased successfully");
err = nvs_flash_init();
if (err == ESP_OK) {
Serial.println("NVS partition reinitialized successfully");
return;
}
}
Serial.println("NVS repair failed - will restart");
delay(1000);
ESP.restart();
}
// Utilitaire : copie tronquée en respectant les limites UTF-8 (nombre de caractères et non octets)
// maxChars : nombre maximum de caractères (glyphes) à conserver (excluant le nul final)
// destSize : taille du buffer destination (incluant place pour nul)
size_t utf8SafeCopyTruncate(const String &src, char *dest, size_t destSize, size_t maxChars) {
if (destSize == 0) return 0;
const uint8_t *bytes = (const uint8_t*)src.c_str();
size_t i = 0; // index source en octets
size_t o = 0; // index destination
size_t chars = 0; // compte de caractères
while (bytes[i] != '\0' && chars < maxChars && o + 1 < destSize) {
uint8_t c = bytes[i];
size_t seqLen = 1;
if ((c & 0x80) == 0x00) seqLen = 1; // 0xxxxxxx
else if ((c & 0xE0) == 0xC0) seqLen = 2; // 110xxxxx
else if ((c & 0xF0) == 0xE0) seqLen = 3; // 1110xxxx
else if ((c & 0xF8) == 0xF0) seqLen = 4; // 11110xxx
else { // octet invalide -> arrêter
break;
}
// Vérifier que la séquence tient dans la source
for (size_t k = 1; k < seqLen; ++k) {
uint8_t nc = bytes[i + k];
if ((nc & 0xC0) != 0x80) { // pas une continuation valide
seqLen = 1; // tronquer au premier octet
break;
}
}
// Vérifier que ça rentre dans dest
if (o + seqLen + 1 >= destSize) break;
// Copier
for (size_t k = 0; k < seqLen; ++k) {
dest[o++] = (char)bytes[i + k];
}
i += seqLen;
chars++;
}
dest[o] = '\0';
return o; // octets copiés
}
// === Classe helper pour gestion automatique des mutex ===
class MutexGuard {
private:
SemaphoreHandle_t mutex;
bool acquired;
public:
MutexGuard(SemaphoreHandle_t mtx, TickType_t timeout = pdMS_TO_TICKS(500)) : mutex(mtx), acquired(false) {
if (mutex != NULL) {
acquired = xSemaphoreTake(mutex, timeout) == pdTRUE;
}
}
~MutexGuard() {
if (acquired && mutex != NULL) {
xSemaphoreGive(mutex);
}
}
bool isLocked() const { return acquired; }
// Interdire la copie pour éviter double libération
MutexGuard(const MutexGuard&) = delete;
MutexGuard& operator=(const MutexGuard&) = delete;
};
// Macros pour simplifier l'utilisation
#define MUTEX_GUARD(mutex, timeout) MutexGuard guard_##mutex(mutex, timeout)
#define MUTEX_GUARD_CHECK(mutex, timeout) MutexGuard guard_##mutex(mutex, timeout); if (!guard_##mutex.isLocked())
// Watchdog pour surveiller la sanité du système
unsigned long lastWatchdogTime = 0;
int corruptionCounter = 0;
void systemWatchdog() {
unsigned long now = millis();
// Exécuter toutes les 30 secondes
if (now - lastWatchdogTime > 30000) {
lastWatchdogTime = now;
// Vérifier la sanité des mutex
if (!checkMutexSanity()) {
corruptionCounter++;
Serial.printf("System corruption detected #%d\n", corruptionCounter);
// Si corruption répétée, déclencher une récupération d'urgence
if (corruptionCounter >= 3) {
Serial.println("Too many corruptions - triggering emergency recovery");
emergencyRecovery();
}
} else {
// Réinitialiser le compteur si tout va bien
corruptionCounter = 0;
}
// Vérifier l'état des tâches
if (displayTaskHandle == NULL || countdownTaskHandle == NULL || networkTaskHandle == NULL) {
Serial.println("One or more tasks missing - system instability detected");
corruptionCounter++;
}
// Imprimer les statistiques de mémoire
Serial.printf("Free heap: %d bytes, Min free: %d bytes\n",
ESP.getFreeHeap(), ESP.getMinFreeHeap());
}
}
#define MUTEX_TIMEOUT_FAST pdMS_TO_TICKS(50) // Opérations rapides
#define MUTEX_TIMEOUT_NORMAL pdMS_TO_TICKS(200) // Opérations normales
#define MUTEX_TIMEOUT_SLOW pdMS_TO_TICKS(1000) // Opérations lentes (I/O)
#define MUTEX_TIMEOUT_CRITICAL pdMS_TO_TICKS(2000) // Opérations critiques
// Prototypes des tâches
void DisplayTask(void * parameter);
void CountdownTask(void * parameter);
void NetWebTask(void * parameter);
// Variables pour le countdown
DateTime countdownTarget;
bool countdownExpired = false;
bool blinkLastSeconds = false; // Clignotement pour les 10 dernières secondes
bool blinkState = true;
unsigned long lastBlinkTime = 0;
// Paramètres de clignotement configurables (pour les 10 dernières secondes)
bool blinkEnabled = true; // 1 = clignote sur la fin du compte à rebours
int blinkIntervalMs = 500; // Intervalle de clignotement en ms (modifiable via Web UI)
int blinkWindowSeconds = 10; // Nombre de dernières secondes pendant lesquelles le clignotement est actif
// Marquee (défilement) pour le texte final si trop long
volatile bool marqueeActive = false; // indicateur global pour la tâche d'affichage
// Paramètres configurables et état du défilement
bool marqueeEnabled = true; // activation auto si texte trop long
int marqueeIntervalMs = 40; // intervalle ms entre déplacements (1 px)
int marqueeGap = 24; // espace en pixels avant répétition
int marqueeMode = 0; // 0=Auto (si overflow, scroll gauche), 1=Toujours gauche, 2=Aller-Retour, 3=Une fois
int marqueeReturnIntervalMs = 60; // vitesse différente pour le retour (aller-retour)
int marqueeBouncePauseLeftMs = 400; // pause extrémité gauche (ms)
int marqueeBouncePauseRightMs = 400; // pause extrémité droite (ms)
int marqueeOneShotDelayMs = 800; // délai centré avant départ (ms)
bool marqueeOneShotStopCenter = true; // recadrer au centre à la fin
int marqueeOneShotRestartSec = 0; // redémarrage automatique (0=pas de restart)
// Accélération progressive
bool marqueeAccelEnabled = false;
int marqueeAccelStartIntervalMs = 80; // intervalle initial
int marqueeAccelEndIntervalMs = 30; // intervalle final
int marqueeAccelDurationMs = 3000; // durée interpolation sur un cycle (ms)
static int marqueeTextWidth = 0; // largeur pixels du texte courant
static int marqueeOffset = 0; // position X courante
static unsigned long lastMarqueeStep = 0; // dernière étape
static int marqueeDirection = -1; // pour mode aller-retour
static bool marqueeOneShotDone = false; // pour mode une fois
static bool marqueeInPause = false; // pause extrémités bounce
static unsigned long marqueePauseUntil = 0; // fin pause
static bool marqueeOneShotCenterPhase = false; // phase centrée initiale
static unsigned long marqueeOneShotStart = 0; // début phase centrée
static unsigned long marqueeOneShotRestartAt = 0; // moment de relance
static unsigned long marqueeCycleStartMs = 0; // début cycle pour accélération
// Flag de forçage de recalcul layout (modifié via Web)
volatile bool forceLayout = false;
// Padding supplémentaire aux extrémités (espaces visuels entrée/sortie défilement)
int marqueeEdgePadding = 2; // pixels
// --- Normalisation accents (UTF-8 -> ASCII approximatif) ---
// Remplace les caractères accentués français communs par leur équivalent non accentué.
// Objectif: permettre un rendu cohérent avec les polices GFX limitées au Basic ASCII.
size_t foldAccents(const char *in, char *out, size_t outSize) {
if (!in || !out || outSize == 0) return 0;
size_t o = 0;
while (*in && o + 1 < outSize) {
unsigned char c = (unsigned char)*in;
if (c < 0x80) {
// ASCII direct
out[o++] = *in++;
} else if ((c == 0xC3) && in[1]) {
unsigned char d = (unsigned char)in[1];
char rep = 0; // remplacement
switch (d) {
case 0x80: case 0x81: rep='A'; break; // ÀÁ
case 0x82: rep='A'; break; // Â
case 0x84: rep='A'; break; // Ä
case 0x87: rep='C'; break; // Ç
case 0x88: case 0x89: rep='E'; break; // ÈÉ
case 0x8A: case 0x8B: rep='E'; break; // ÊË
case 0x8E: case 0x8F: rep='I'; break; // ÎÏ
case 0x94: case 0x96: rep='O'; break; // ÔÖ
case 0x99: case 0x9B: rep='U'; break; // ÙÛ
case 0x9C: rep='U'; break; // Ü
case 0xA0: case 0xA1: rep='a'; break; // àá
case 0xA2: rep='a'; break; // â
case 0xA4: rep='a'; break; // ä
case 0xA7: rep='c'; break; // ç
case 0xA8: case 0xA9: rep='e'; break; // è é
case 0xAA: case 0xAB: rep='e'; break; // ê ë
case 0xAE: case 0xAF: rep='i'; break; // î ï
case 0xB4: case 0xB6: rep='o'; break; // ô ö
case 0xB9: case 0xBB: rep='u'; break; // ù û
case 0xBC: rep='u'; break; // ü
}
if (rep) {
out[o++] = rep;
in += 2;
} else {
// caractère non géré -> ignorer les deux bytes
in += 2;
}
} else {
// Séquence multioctet non gérée -> ignorer octet
in++;
}
}
out[o] = '\0';
return o;
}
// Conversion UTF-8 -> Latin-1 (ISO-8859-1). Les caractères hors plage 0x00-0xFF
// (émojis, symboles hors Latin-1) sont remplacés par '?'.
// Nécessaire car la police étendue générée couvre 0x20-0xFF (octets simples) alors
// que l'entrée (depuis le formulaire web) est en UTF-8 multioctet.
size_t utf8ToLatin1(const char *in, char *out, size_t outSize) {
if (!in || !out || outSize == 0) return 0;
size_t o = 0;
while (*in && o + 1 < outSize) {
unsigned char c = (unsigned char)*in;
if (c < 0x80) { // ASCII direct
out[o++] = c; in++;
} else if ((c & 0xE0) == 0xC0 && in[1]) { // 2 octets
unsigned char c2 = (unsigned char)in[1];
if ((c2 & 0xC0) == 0x80) {
uint16_t code = ((c & 0x1F) << 6) | (c2 & 0x3F); // U+0080..U+07FF
if (code <= 0x00FF) {
out[o++] = (char)code;
} else {
out[o++] = '?';
}
in += 2;
} else {
out[o++] = '?'; in++;
}
} else if ((c & 0xF0) == 0xE0 && in[1] && in[2]) { // 3 octets
unsigned char c2 = (unsigned char)in[1];
unsigned char c3 = (unsigned char)in[2];
if (((c2 & 0xC0) == 0x80) && ((c3 & 0xC0) == 0x80)) {
uint16_t code = ((c & 0x0F) << 12) | ((c2 & 0x3F) << 6) | (c3 & 0x3F);
if (code <= 0x00FF) {
out[o++] = (char)code;
} else {
// Remplacements spécifiques ponctuation “smart” courante
switch (code) {
case 0x2018: //
case 0x2019: //
out[o++] = '\''; break;
case 0x201C: // “
case 0x201D: // ”
out[o++] = '"'; break;
case 0x2013: //
case 0x2014: // —
out[o++] = '-'; break;
case 0x2026: // …
if (o + 3 < outSize) { out[o++]='.'; out[o++]='.'; out[o++]='.'; }
else out[o++]='.'; // fallback partiel si peu de place
break;
default:
out[o++] = '?';
}
}
in += 3;
} else {
out[o++] = '?'; in++;
}
} else { // 4 octets ou séquence invalide -> non représentable
out[o++] = '?';
// Skipper au moins 1 octet (si séquence UTF-8 valide on pourrait consommer tous, mais
// pour simplicité on avance octet par octet sur séquences plus longues)
in++;
}
}
out[o] = '\0';
return o;
}
// Variable pour indiquer qu'il faut sauvegarder les paramètres
volatile bool saveRequested = false;
volatile unsigned long saveRequestTime = 0;
// Paramètres configurables via l'interface web
int countdownYear = 2025;
int countdownMonth = 12;
int countdownDay = 31;
int countdownHour = 23;
int countdownMinute = 59;
int countdownSecond = 0;
char countdownTitle[51] = "COUNTDOWN";
// Polices DejaVu avec taille automatique et style configurable
int fontStyle = 0; // 0=Normal, 1=Gras, 2=Italique
int countdownColorR = 0;
int countdownColorG = 255;
int countdownColorB = 0;
uint16_t countdownColor;
int displayBrightness = -1; // -1 = auto (calculé selon nombre de panneaux)
// Format d'affichage (0=jours, 1=heures, 2=minutes, 3=secondes uniquement)
int displayFormat = 0;
// HTML Page
const char MAIN_page[] PROGMEM = R"rawliteral(
<!DOCTYPE html><html lang="fr"><head>
<meta charset="utf-8" />
<meta name="author" content="__AUTHOR_NAME__">
<title>ESP32 P10 Countdown</title>
<meta name="viewport" content="width=device-width,initial-scale=1" />
<style>
:root { --accent:#009688; --accent-hover:#017a6f; --danger:#d32f2f; --bg:#101418; --panel:#1c242b; --panel-alt:#232e36; --text:#e5ecec; --muted:#7d8b91; --ok:#4caf50; --warn:#ffc107; font-size:16px; }
* { box-sizing:border-box; }
body { margin:0; font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Arial,sans-serif; background:var(--bg); color:var(--text); -webkit-font-smoothing:antialiased; }
h1 { font-size:1.35rem; margin:0 0 1rem; text-align:center; letter-spacing:.5px; }
h2 { font-size:.95rem; margin:1.5rem 0 .75rem; text-transform:uppercase; letter-spacing:.08em; color:var(--muted); }
a { color:var(--accent); }
.wrap { max-width:760px; margin:0 auto; padding:clamp(12px,2vw,32px); }
.grid { display:grid; gap:14px; }
.card { background:linear-gradient(145deg,var(--panel) 0%,var(--panel-alt) 100%); padding:18px 20px 22px; border-radius:14px; position:relative; box-shadow:0 2px 4px rgba(0,0,0,.3),0 8px 32px -8px rgba(0,0,0,.45); overflow:hidden; }
form .field { display:flex; flex-direction:column; gap:4px; }
label { font-size:.70rem; font-weight:600; letter-spacing:.06em; text-transform:uppercase; color:var(--muted); }
input:not([type=color]), select { background:#0d1418; border:1px solid #233038; border-radius:8px; padding:10px 11px; font:inherit; color:var(--text); outline:none; transition:.2s border-color,.2s background; }
input:focus, select:focus { border-color:var(--accent); background:#111b20; }
input[type=color] { border:none; background:transparent; width:54px; height:42px; padding:0; cursor:pointer; }
.inline { display:flex; gap:10px; }
.inline > * { flex:1; }
.actions { display:flex; flex-wrap:wrap; gap:10px; margin-top:8px; }
button { --b:var(--accent); flex:1; cursor:pointer; border:none; border-radius:10px; padding:12px 18px; font:600 .9rem/1 system-ui; letter-spacing:.04em; background:linear-gradient(135deg,var(--b) 0%, #00b39e 100%); color:#fff; box-shadow:0 3px 10px -2px rgba(0,0,0,.5); transition:.25s transform,.25s filter; }
button:hover { transform:translateY(-2px); filter:brightness(1.08); }
button:active { transform:translateY(0); filter:brightness(.92); }
button.secondary { --b:#2d3a41; background:linear-gradient(135deg,#2d3a41,#37464f); }
button.danger { --b:var(--danger); background:linear-gradient(135deg,#d13737,#ef5350); }
.preview-panel { text-align:center; padding:18px 12px 24px; border-radius:16px; background:#06090b; position:relative; }
#countdown-preview { font-family:monospace; font-weight:600; font-size: clamp(28px,10vw,72px); line-height:1; color:#00ff73; letter-spacing:.04em; text-shadow:0 0 6px #00ff73, 0 0 14px rgba(0,255,115,.55); transition:.25s color,.25s text-shadow; word-break:break-word; }
#titlePreview { display:block; font-size:.75rem; font-weight:500; letter-spacing:.12em; margin-top:10px; color:var(--muted); text-transform:uppercase; }
.blink { animation:blink .9s steps(2,start) infinite; }
@keyframes blink { to { visibility:hidden; } }
.swatches { display:flex; flex-wrap:wrap; gap:6px; margin-top:6px; }
.swatch { width:30px; height:30px; border-radius:6px; cursor:pointer; position:relative; box-shadow:0 0 0 2px #141c22, 0 0 0 4px rgba(0,0,0,.3); transition:.2s transform,.2s box-shadow; }
.swatch:hover { transform:scale(1.1); box-shadow:0 0 0 2px #1f2d35,0 0 0 5px rgba(0,0,0,.55); }
.swatch.active { outline:2px solid #fff; }
.quick { display:flex; flex-wrap:wrap; gap:6px; margin-top:4px; }
.quick button { flex:1 0 120px; font-size:.65rem; padding:8px 10px; background:#25323a; }
.inline-note { font-size:.65rem; color:var(--muted); margin:6px 0 2px; }
.save-indicator { position:absolute; top:10px; right:14px; font-size:.65rem; letter-spacing:.1em; display:flex; align-items:center; gap:6px; color:var(--muted); }
.save-indicator.active { color:var(--accent); }
.dot { width:9px; height:9px; border-radius:50%; background:var(--muted); position:relative; overflow:hidden; }
.save-indicator.active .dot { background:var(--accent); box-shadow:0 0 0 4px rgba(0,150,136,.25); }
.toast { position:fixed; top:14px; left:50%; transform:translateX(-50%) translateY(-20px); background:#182125; color:var(--text); padding:10px 18px; border-radius:999px; font-size:.7rem; letter-spacing:.08em; opacity:0; pointer-events:none; transition:.35s; box-shadow:0 4px 22px -6px rgba(0,0,0,.6); }
.toast.show { opacity:1; transform:translateX(-50%) translateY(0); }
.row-2 { display:grid; gap:12px; grid-template-columns:repeat(auto-fit,minmax(120px,1fr)); }
footer { margin:40px 0 10px; text-align:center; font-size:.6rem; letter-spacing:.15em; color:var(--muted); }
@media (max-width:680px){ .row-2 { grid-template-columns:repeat(auto-fit,minmax(100px,1fr)); } }
</style>
</head><body>
<div class="wrap grid" style="gap:18px;">
<div class="card preview-panel" id="previewCard">
<div class="save-indicator" id="saveState"><span class="dot"></span><span id="saveLabel">PRÊT</span></div>
<div id="countdown-preview">--:--:--</div>
<span id="titlePreview"></span>
<div class="inline-note" id="expireNote" style="display:none;">Le compte à rebours est terminé.</div>
<div class="quick" aria-label="Ajustements rapides">
<button type="button" data-add="60">+1 min</button>
<button type="button" data-add="300">+5 min</button>
<button type="button" data-add="3600">+1 h</button>
<button type="button" data-add="86400">+1 jour</button>
<button type="button" data-add="-300">-5 min</button>
<button type="button" data-set="EOD">Fin de journée</button>
</div>
</div>
<div class="card" id="formCard">
<h1>Configuration du Compte à Rebours <span id="fwVer" style="font-size:.55rem;opacity:.6;vertical-align:middle;"></span></h1>
<form action="/settings" method="POST" id="settingsForm" autocomplete="off">
<div class="grid" style="gap:18px;">
<div class="field">
<label for="title">Titre</label>
<input id="title" name="title" maxlength="50" required placeholder="Mon Événement" />
</div>
<div class="row-2">
<div class="field"><label for="date">Date</label><input type="date" id="date" name="date" required /></div>
<div class="field"><label for="time">Heure (hh:mm:ss)</label><input type="time" id="time" name="time" step="1" required /></div>
</div>
<div class="row-2">
<div class="field" style="grid-column:1/-1;">
<label>Police : DejaVu Sans (Support complet accents et caractères spéciaux)</label>
<div style="padding:8px; background:var(--panel-alt); border-radius:4px; font-size:0.9em; color:var(--muted);">
Accents français : àâäéèêëïîôöùûüÿç<br>
Caractères spéciaux : ñߣ¥§°±×÷<br>
Taille optimisée automatiquement selon la résolution
</div>
</div>
</div>
<div class="row-2">
<div class="field">
<label for="fontStyle">Style de Police</label>
<select id="fontStyle" name="fontStyle">
<option value="0" selected>Normal</option>
<option value="1">Gras</option>
<option value="2">Italique</option>
</select>
</div>
<div class="field"></div>
</div>
<div class="row-2">
<div class="field" style="grid-column:1/-1;">
<label>Couleur</label>
<div class="inline" style="align-items:center;">
<input type="color" id="colorPicker" value="#00ff00" aria-label="Couleur personnalisée" />
<div style="display:flex; flex:1; flex-wrap:wrap; gap:6px;" class="swatches" id="swatches" aria-label="Couleurs prédéfinies"></div>
</div>
<input type="hidden" id="colorR" name="colorR" value="0" />
<input type="hidden" id="colorG" name="colorG" value="255" />
<input type="hidden" id="colorB" name="colorB" value="0" />
</div>
</div>
</div>
<div class="actions">
<button type="submit" id="btnSave">Enregistrer</button>
<button type="button" class="secondary" id="btnNow">Maintenant</button>
<button type="button" class="secondary" id="btnSyncTime" title="Synchroniser le RTC avec l'heure locale du navigateur">Sync Heure</button>
<button type="button" class="danger" id="btnReset">Réinitialiser</button>
</div>
<h2>Clignotement</h2>
<div class="row-2">
<div class="field">
<label for="blinkEnabled">Activer Clignotement</label>
<select id="blinkEnabled" name="blinkEnabled">
<option value="1" selected>Oui</option>
<option value="0">Non</option>
</select>
</div>
<div class="field">
<label for="blinkInterval">Intervalle (ms)</label>
<input type="number" id="blinkInterval" name="blinkInterval" min="50" max="5000" step="50" value="500" />
</div>
<div class="field" style="grid-column:1/-1;">
<label for="blinkWindow">Dernières secondes (fenêtre)</label>
<input type="number" id="blinkWindow" name="blinkWindow" min="1" max="3600" step="1" value="10" />
</div>
</div>
<h2>Défilement</h2>
<div class="row-2">
<div class="field">
<label for="marqueeEnabled">Activer Défilement</label>
<select id="marqueeEnabled" name="marqueeEnabled">
<option value="1" selected>Oui</option>
<option value="0">Non</option>
</select>
</div>
<div class="field">
<label for="marqueeInterval">Vitesse (ms/pixel)</label>
<input type="number" id="marqueeInterval" name="marqueeInterval" min="5" max="500" step="5" value="40" />
</div>
<div class="field">
<label for="marqueeGap">Espace (px)</label>
<input type="number" id="marqueeGap" name="marqueeGap" min="4" max="256" step="2" value="24" />
</div>
<div class="field" style="grid-column:1/-1;">
<label for="marqueeMode">Mode</label>
<select id="marqueeMode" name="marqueeMode">
<option value="0" selected>Auto (si dépasse)</option>
<option value="1">Toujours (gauche)</option>
<option value="2">Aller-Retour</option>
<option value="3">Une seule fois</option>
</select>
<div class="inline-note">Aller-Retour et Une seule fois nécessitent un texte plus large que l'afficheur (sauf Toujours).</div>
</div>
<div class="field">
<label for="marqueeReturnInterval">Vitesse Retour (ms/pixel)</label>
<input type="number" id="marqueeReturnInterval" name="marqueeReturnInterval" min="5" max="500" step="5" value="60" />
</div>
<div class="field">
<label for="marqueeBouncePauseLeft">Pause Gauche (ms)</label>
<input type="number" id="marqueeBouncePauseLeft" name="marqueeBouncePauseLeft" min="0" max="3000" step="50" value="400" />
</div>
<div class="field">
<label for="marqueeBouncePauseRight">Pause Droite (ms)</label>
<input type="number" id="marqueeBouncePauseRight" name="marqueeBouncePauseRight" min="0" max="3000" step="50" value="400" />
</div>
<div class="field">
<label for="marqueeOneShotDelay">Délai Centré One-Shot (ms)</label>
<input type="number" id="marqueeOneShotDelay" name="marqueeOneShotDelay" min="0" max="5000" step="100" value="800" />
</div>
<div class="field">
<label for="marqueeOneShotStopCenter">One-Shot Fin Centrée</label>
<select id="marqueeOneShotStopCenter" name="marqueeOneShotStopCenter">
<option value="1" selected>Oui</option>
<option value="0">Non</option>
</select>
</div>
<div class="field">
<label for="marqueeOneShotRestart">Restart One-Shot (s)</label>
<input type="number" id="marqueeOneShotRestart" name="marqueeOneShotRestart" min="0" max="86400" step="1" value="0" />
</div>
<div class="field" style="grid-column:1/-1;">
<label>Accélération Progressive</label>
<div style="display:flex;gap:8px;flex-wrap:wrap;align-items:flex-end;">
<select id="marqueeAccelEnabled" name="marqueeAccelEnabled">
<option value="0" selected>Off</option>
<option value="1">On</option>
</select>
<label style="font-size:12px;">Début(ms)<input type="number" id="marqueeAccelStart" name="marqueeAccelStart" min="5" max="500" step="5" value="80" style="width:80px;margin-left:4px;" /></label>
<label style="font-size:12px;">Fin(ms)<input type="number" id="marqueeAccelEnd" name="marqueeAccelEnd" min="5" max="500" step="5" value="30" style="width:80px;margin-left:4px;" /></label>
<label style="font-size:12px;">Durée(ms)<input type="number" id="marqueeAccelDuration" name="marqueeAccelDuration" min="100" max="20000" step="100" value="3000" style="width:90px;margin-left:4px;" /></label>
</div>
<div class="inline-note">Interpolation linéaire entre vitesse début et fin à chaque cycle (aller, retour ou boucle complete).</div>
</div>
</div>
<h2>Luminosité</h2>
<div class="row-2">
<div class="field">
<label for="brightnessMode">Mode</label>
<select id="brightnessMode">
<option value="auto" selected>Auto</option>
<option value="manual">Manuel</option>
</select>
</div>
<div class="field" style="grid-column:1/-1;">
<label for="brightness">Valeur (0-255)</label>
<input type="range" id="brightness" name="brightness" min="0" max="255" value="150" />
<div style="font-size:.65rem; color:var(--muted);">Auto = -1 (adapté au nombre de panneaux)</div>
<input type="hidden" id="brightnessHidden" name="brightness" value="-1" />
</div>
</div>
<div class="inline-note">Les modifications sont appliquées immédiatement sur l'afficheur après envoi.</div>
</form>
</div>
<footer>ESP32 P10 RGB Fullscreen Countdown 2025 __AUTHOR_NAME__ (<a href="__GITHUB_URL__" style="color:#4caf50" target="_blank" rel="noopener">GitHub</a>) <a href="/about" style="color:#888">À propos</a></footer>
</div>
<div class="toast" id="toast"></div>
<script>
// === Utilitaires Couleur ===
const hex = n => n.toString(16).padStart(2,'0');
function hexToRgb(h){h=h.replace('#','');return {r:parseInt(h.substr(0,2),16),g:parseInt(h.substr(2,2),16),b:parseInt(h.substr(4,2),16)};}
function rgbToHex(r,g,b){return '#'+hex(r)+hex(g)+hex(b);}
// === Éléments DOM ===
const el = id => document.getElementById(id);
const preview = el('countdown-preview');
const titlePreview = el('titlePreview');
const expireNote = el('expireNote');
const form = el('settingsForm');
const saveState = el('saveState');
const saveLabel = el('saveLabel');
const toast = el('toast');
const swatchesBox = el('swatches');
// === Swatches prédéfinies ===
const SWATCHES = ['#00ff00','#ffffff','#ff0000','#00bcd4','#ffeb3b','#ff9800','#ff00ff','#2196f3','#9c27b0','#4caf50'];
SWATCHES.forEach(c=>{const d=document.createElement('div');d.className='swatch';d.style.background=c;d.dataset.color=c;d.title=c;d.addEventListener('click',()=>{el('colorPicker').value=c;updateColor(c,true);});swatchesBox.appendChild(d);});
// === Variables de logique ===
let target = null; // Date target JS
let expired=false; let blink=false; let lastBlink=0;
let lastServerSync = 0; let offsetMs = 0; // Diff client/serveur (si future sync ajoutée)
function showToast(msg,ok=true){toast.textContent=msg;toast.classList.add('show');toast.style.background= ok?'#1e2c31':'#452222'; setTimeout(()=>toast.classList.remove('show'),2600);}
function setSaving(active,label){if(active){saveState.classList.add('active');saveLabel.textContent=label||'SAUVEGARDE';}else{saveState.classList.remove('active');saveLabel.textContent=label||'PRÊT';}}
function updateColor(val,fromSwatch=false){const {r,g,b}=hexToRgb(val); el('colorR').value=r; el('colorG').value=g; el('colorB').value=b; preview.style.color=val; preview.style.textShadow=`0 0 6px ${val},0 0 16px ${val}80`; document.querySelectorAll('.swatch').forEach(s=>s.classList.toggle('active',s.dataset.color===val)); if(fromSwatch){showToast('Couleur appliquée');}}
function parseFormDateTime(){ const d=el('date').value; const t=el('time').value || '00:00:00'; if(!d) return null; const parts = d.split('-').map(Number); const time=t.split(':').map(Number); return new Date(parts[0],parts[1]-1,parts[2],time[0]||0,time[1]||0,time[2]||0); }
function refreshTarget(){ target = parseFormDateTime(); }
function updatePreviewLoop(ts){ if(!target){ requestAnimationFrame(updatePreviewLoop); return; } const now = new Date(Date.now()+offsetMs); let diff = (target - now)/1000; if(diff<=0){diff=0; if(!expired){expired=true; preview.classList.remove('blink'); preview.textContent=el('title').value || 'TERMINÉ'; expireNote.style.display='block'; showToast('Compte à rebours terminé',false);} }
if(!expired){ expireNote.style.display='none'; let d=Math.floor(diff/86400); let h=Math.floor(diff%86400/3600); let m=Math.floor(diff%3600/60); let s=Math.floor(diff%60); // Choix format adaptatif
let txt=''; if(d>0) txt=`${d}d ${h.toString().padStart(2,'0')}:${m.toString().padStart(2,'0')}`; else if(h>0) txt=`${h.toString().padStart(2,'0')}:${m.toString().padStart(2,'0')}:${s.toString().padStart(2,'0')}`; else if(m>0) txt=`${m.toString().padStart(2,'0')}:${s.toString().padStart(2,'0')}`; else txt=s.toString().padStart(2,'0'); preview.textContent=txt; // blinking last N seconds
const bw = parseInt(el('blinkWindow').value)||10; if(d===0 && h===0 && m===0 && s<=bw){ if(ts-lastBlink>450){ lastBlink=ts; blink=!blink; preview.classList.toggle('blink',blink);} } else { preview.classList.remove('blink'); }
}
requestAnimationFrame(updatePreviewLoop); }
requestAnimationFrame(updatePreviewLoop);
function syncFieldsToPreview(){ titlePreview.textContent = (el('title').value||'').toUpperCase(); refreshTarget(); }
['title','date','time','fontStyle','blinkEnabled','blinkInterval','blinkWindow','marqueeEnabled','marqueeInterval','marqueeGap','marqueeMode','marqueeReturnInterval','marqueeBouncePauseLeft','marqueeBouncePauseRight','marqueeOneShotDelay','marqueeOneShotStopCenter','marqueeOneShotRestart','marqueeAccelEnabled','marqueeAccelStart','marqueeAccelEnd','marqueeAccelDuration','brightness','brightnessMode'].forEach(id=> el(id).addEventListener('input',()=>{ if(id==='brightnessMode'){ if(el('brightnessMode').value==='auto'){ el('brightnessHidden').value='-1'; } else { el('brightnessHidden').value=el('brightness').value; } } if(id==='brightness'){ if(el('brightnessMode').value==='manual'){ el('brightnessHidden').value=el('brightness').value; } } syncFieldsToPreview(); setSaving(true,'MODIFIÉ'); }));
el('colorPicker').addEventListener('input',e=>{ updateColor(e.target.value); setSaving(true,'MODIFIÉ'); });
// Ajustements rapides
document.querySelectorAll('.quick button[data-add], .quick button[data-set]').forEach(btn=>btn.addEventListener('click',()=>{
if(!target) refreshTarget(); if(btn.dataset.set==='EOD'){ const n=new Date(); n.setHours(23,59,59,0); target=n; } else { const add=parseInt(btn.dataset.add,10); target = new Date((target?target:new Date()).getTime()+add*1000); }
el('date').value = target.toISOString().split('T')[0]; const t = target.toTimeString().split(' ')[0]; el('time').value = t; syncFieldsToPreview(); showToast('Nouvelle cible '+ t); setSaving(true,'MODIFIÉ'); }));
// Bouton maintenant
el('btnNow').addEventListener('click',()=>{ const n=new Date(); el('date').value=n.toISOString().split('T')[0]; el('time').value=n.toTimeString().split(' ')[0]; syncFieldsToPreview(); showToast('Synchro à maintenant'); setSaving(true,'MODIFIÉ'); });
// Synchronisation de l'heure RTC côté ESP32 avec l'heure locale du navigateur
el('btnSyncTime').addEventListener('click',()=>{
const now = new Date();
const epoch = now.getTime(); // ms
const tz = now.getTimezoneOffset(); // minutes (UTC = local + offset)
showToast('Envoi heure locale...');
fetch(`/syncTime?epoch=${epoch}&tz=${tz}`)
.then(r=>r.json())
.then(j=>{ if(j.status==='OK'){ showToast('RTC synchronisé'); setSaving(false,'PRÊT'); } else { showToast('Erreur sync',false);} })
.catch(()=>showToast('Erreur réseau sync',false));
});
// Reset
el('btnReset').addEventListener('click',()=>{ if(!confirm('Réinitialiser les paramètres ?')) return; form.action='/reset'; form.submit(); });
// Soumission
form.addEventListener('submit',()=>{ setSaving(true,'ENVOI...'); el('btnSave').disabled=true; setTimeout(()=>{ setSaving(true,'SAUVEGARDE'); },150); });
// Chargement initial depuis l'ESP32
window.addEventListener('load',()=>{
// Injecter version firmware (fourni côté C++ via endpoint futur ou placeholder compilé)
document.getElementById('fwVer').textContent = 'v__FWVER__';
const now=new Date(); const tomorrow=new Date(now.getTime()+86400000); el('date').value=tomorrow.toISOString().split('T')[0]; el('time').value='23:59:59';
fetch('/getSettings').then(r=>r.json()).then(d=>{ el('title').value=d.title; const ds=`${d.year}-${String(d.month).padStart(2,'0')}-${String(d.day).padStart(2,'0')}`; el('date').value=ds; const ts=`${String(d.hour).padStart(2,'0')}:${String(d.minute).padStart(2,'0')}:${String(d.second).padStart(2,'0')}`; el('time').value=ts; if(d.blinkEnabled!==undefined){ el('blinkEnabled').value = d.blinkEnabled ? '1' : '0'; } if(d.blinkIntervalMs!==undefined){ el('blinkInterval').value = d.blinkIntervalMs; } if(d.blinkWindow!==undefined){ el('blinkWindow').value = d.blinkWindow; } if(d.marqueeEnabled!==undefined){ el('marqueeEnabled').value = d.marqueeEnabled ? '1':'0'; } if(d.marqueeIntervalMs!==undefined){ el('marqueeInterval').value = d.marqueeIntervalMs; } if(d.marqueeGap!==undefined){ el('marqueeGap').value = d.marqueeGap; } if(d.marqueeMode!==undefined){ el('marqueeMode').value = d.marqueeMode; } if(d.marqueeReturnIntervalMs!==undefined){ el('marqueeReturnInterval').value = d.marqueeReturnIntervalMs; } if(d.marqueeBouncePauseLeftMs!==undefined){ el('marqueeBouncePauseLeft').value = d.marqueeBouncePauseLeftMs; } if(d.marqueeBouncePauseRightMs!==undefined){ el('marqueeBouncePauseRight').value = d.marqueeBouncePauseRightMs; } if(d.marqueeOneShotDelayMs!==undefined){ el('marqueeOneShotDelay').value = d.marqueeOneShotDelayMs; } if(d.marqueeOneShotStopCenter!==undefined){ el('marqueeOneShotStopCenter').value = d.marqueeOneShotStopCenter? '1':'0'; } if(d.marqueeOneShotRestartSec!==undefined){ el('marqueeOneShotRestart').value = d.marqueeOneShotRestartSec; } if(d.marqueeAccelEnabled!==undefined){ el('marqueeAccelEnabled').value = d.marqueeAccelEnabled? '1':'0'; } if(d.marqueeAccelStartIntervalMs!==undefined){ el('marqueeAccelStart').value = d.marqueeAccelStartIntervalMs; } if(d.marqueeAccelEndIntervalMs!==undefined){ el('marqueeAccelEnd').value = d.marqueeAccelEndIntervalMs; } if(d.marqueeAccelDurationMs!==undefined){ el('marqueeAccelDuration').value = d.marqueeAccelDurationMs; } if(d.brightness!==undefined){ if(parseInt(d.brightness,10)===-1){ el('brightnessMode').value='auto'; el('brightnessHidden').value='-1'; } else { el('brightnessMode').value='manual'; el('brightness').value=d.brightness; el('brightnessHidden').value=d.brightness; } } if(d.fontStyle!==undefined){ el('fontStyle').value = d.fontStyle; } const color=rgbToHex(d.colorR,d.colorG,d.colorB); el('colorPicker').value=color; updateColor(color); syncFieldsToPreview(); setSaving(false,'PRÊT'); showToast('Paramètres chargés'); }).catch(()=>{ showToast('Échec chargement paramètres',false); setSaving(false,'HORS LIGNE'); syncFieldsToPreview(); });
});
</script>
</body></html>
)rawliteral";
// Ancien script legacy supprimé (désormais encapsulé dans MAIN_page)
// Note: La fonction display_update_enable() a été intégrée dans setup()
// pour une meilleure gestion avec FreeRTOS
// Prototypes des fonctions de calcul de countdown
void updateCountdown(int &days, int &hours, int &minutes, int &seconds);
void updateDisplayFormat(int days, int hours, int minutes, int seconds);
// === Fonctions helper pour opérations critiques avec mutex ===
inline bool safeTakeCountdownData(int &days, int &hours, int &minutes, int &seconds, bool &expired) {
MUTEX_GUARD(countdownMutex, MUTEX_TIMEOUT_FAST);
if (guard_countdownMutex.isLocked()) {
updateCountdown(days, hours, minutes, seconds);
updateDisplayFormat(days, hours, minutes, seconds);
expired = countdownExpired;
return true;
}
return false;
}
inline bool safeUpdateCountdownTarget(const DateTime &newTarget) {
MUTEX_GUARD(countdownMutex, MUTEX_TIMEOUT_NORMAL);
if (guard_countdownMutex.isLocked()) {
countdownTarget = newTarget;
countdownExpired = false; // Reset si nouvelle date
return true;
}
return false;
}
// Calcul du temps restant
void updateCountdown(int &days, int &hours, int &minutes, int &seconds) {
DateTime now = rtc.now();
// Vérifier si le countdown est expiré
if (now >= countdownTarget) {
countdownExpired = true;
days = hours = minutes = 0;
seconds = 0;
blinkLastSeconds = false; // Pas de clignotement une fois expiré
return;
}
countdownExpired = false;
// Calculer la différence
TimeSpan diff = countdownTarget - now;
long totalSeconds = diff.totalseconds();
days = totalSeconds / 86400;
hours = (totalSeconds % 86400) / 3600;
minutes = (totalSeconds % 3600) / 60;
seconds = totalSeconds % 60;
// Activer le clignotement uniquement sur la fenêtre configurée des dernières secondes
int bw = blinkWindowSeconds;
if (bw < 1) bw = 1;
blinkLastSeconds = (days == 0 && hours == 0 && minutes == 0 && seconds <= bw);
}
// Détermine le format d'affichage en fonction du temps restant
void updateDisplayFormat(int days, int hours, int minutes, int seconds) {
if (days > 0) {
displayFormat = 0; // Format jours
} else if (hours > 0) {
displayFormat = 1; // Format heures
} else if (minutes > 0) {
displayFormat = 2; // Format minutes
} else {
displayFormat = 3; // Format secondes uniquement
}
}
// Fonction pour obtenir la largeur du texte
uint16_t getTextWidth(const char* text, const GFXfont* font = NULL) {
int16_t x1, y1;
uint16_t w, h;
if (font) display.setFont(font);
display.getTextBounds(text, 0, 0, &x1, &y1, &w, &h);
display.setFont(); // Reset to default font
return w;
}
// Dessine les deux points du séparateur d'horloge
void drawColon(int16_t x, int16_t y, uint16_t colonColor) {
display.fillRect(x, y, 2, 2, colonColor);
display.fillRect(x, y+4, 2, 2, colonColor);
}
// Sélection de police selon le style choisi (Normal, Gras, Italique)
const GFXfont* getOptimalFont() {
switch (fontStyle) {
case 1: return &DejaVuSans_Bold9pt8b; // Gras
case 2: return &DejaVuSans_Oblique9pt8b; // Italique
default: return &DejaVuSans9ptLat1; // Normal (défaut)
}
}
// Calcul automatique de la taille optimale selon le texte et la police
int calculateAutoTextSize(const char* text, const GFXfont* font) {
// Taille de base selon la largeur totale disponible - ajustée pour minuteur
int baseSize;
if (TOTAL_WIDTH >= 128) {
baseSize = 2; // Taille réduite pour 4+ panneaux (128+ pixels)
} else if (TOTAL_WIDTH >= 96) {
baseSize = 1; // Taille réduite pour 3 panneaux (96 pixels) - mieux pour minuteur
} else if (TOTAL_WIDTH >= 64) {
baseSize = 1; // Taille appropriée pour 2 panneaux (64 pixels)
} else {
baseSize = 1; // Taille standard pour 1 panneau (32 pixels)
}
// Adapter selon la longueur du texte (textes longs = taille plus petite)
int textLen = strlen(text);
if (textLen > 8 && baseSize > 1) {
return baseSize - 1; // Réduire la taille pour les textes longs (seuil abaissé)
}
return baseSize;
}
// Affichage du compte à rebours en plein écran
void displayFullscreenCountdown(int days, int hours, int minutes, int seconds) {
// --- Cache layout ---
static char lastText[64] = ""; // élargi pour titres jusqu'à 50 chars
static bool lastExpired = false;
static int16_t cachedX = 0, cachedY = 0;
static uint8_t cachedSetSize = 1;
static bool cachedIsEndMsg = false;
static uint16_t cachedTextPixelWidth = 0; // pour calcul marquee
static int16_t cachedFontXOffset = 0; // x1 pour centrage correct
// Recalculer systématiquement la couleur utilisateur (évite usage d'une valeur obsolète)
int localR = countdownColorR;
int localG = countdownColorG;
int localB = countdownColorB;
if (localR < 0) localR = 0; if (localR > 255) localR = 255;
if (localG < 0) localG = 0; if (localG > 255) localG = 255;
if (localB < 0) localB = 0; if (localB > 255) localB = 255;
uint16_t userColor = display.color565(localR, localG, localB);
// Clignotement configurable des 10 dernières secondes (si activé)
uint16_t displayColor = userColor;
if (!countdownExpired && blinkEnabled && blinkLastSeconds) {
unsigned long currentTime = millis();
int localInterval = blinkIntervalMs;
if (localInterval < 50) localInterval = 50; // bornes de sécurité
if (localInterval > 5000) localInterval = 5000;
if (currentTime - lastBlinkTime >= (unsigned long)localInterval) {
lastBlinkTime = currentTime;
blinkState = !blinkState;
}
displayColor = blinkState ? userColor : myBLACK;
}
// Préparer le texte cible
char currentText[64];
if (countdownExpired) {
// Police DejaVu avec support complet UTF-8 -> Latin-1
utf8ToLatin1(countdownTitle, currentText, sizeof(currentText));
} else {
switch (displayFormat) {
case 0: snprintf(currentText, sizeof(currentText), "%dd %02d:%02d", days, hours, minutes); break;
case 1: snprintf(currentText, sizeof(currentText), "%02d:%02d:%02d", hours, minutes, seconds); break;
case 2: snprintf(currentText, sizeof(currentText), "%02d:%02d", minutes, seconds); break;
default: snprintf(currentText, sizeof(currentText), "%02d", seconds); break;
}
}
bool needRecalc = forceLayout || countdownExpired != lastExpired || (strcmp(currentText, lastText) != 0);
if (needRecalc) {
// Utiliser uniquement la police DejaVu optimale
const GFXfont *font = getOptimalFont();
display.setFont(font);
// Calcul automatique de la taille basé sur la résolution du panneau
int optimalSize = calculateAutoTextSize(currentText, font);
display.setTextSize(optimalSize);
cachedSetSize = optimalSize;
int16_t x1, y1; uint16_t w, h;
display.getTextBounds(currentText, 0, 0, &x1, &y1, &w, &h);
cachedTextPixelWidth = w; // conserver largeur
cachedY = (TOTAL_HEIGHT - h) / 2 - y1;
cachedFontXOffset = x1;
// Décider activation selon le mode
marqueeTextWidth = w;
marqueeActive = false;
marqueeOneShotDone = (marqueeMode == 3) ? marqueeOneShotDone : false; // réinitialiser si changement de texte
if (marqueeEnabled) {
switch (marqueeMode) {
case 0: // Auto (continuous gauche si dépasse)
if (w > TOTAL_WIDTH) marqueeActive = true;
break;
case 1: // Toujours gauche
marqueeActive = true;
break;
case 2: // Aller-Retour seulement si dépasse
if (w > TOTAL_WIDTH) marqueeActive = true;
break;
case 3: // Une seule fois (si dépasse et pas déjà fini)
if (w > TOTAL_WIDTH && !marqueeOneShotDone) marqueeActive = true;
break;
}
}
if (marqueeActive) {
marqueeInPause = false;
marqueePauseUntil = 0;
if (marqueeMode == 2) { // bounce
// Démarre avec padding gauche
marqueeOffset = marqueeEdgePadding;
marqueeDirection = -1;
// pause initiale gauche
if (marqueeBouncePauseLeftMs > 0) { marqueeInPause = true; marqueePauseUntil = millis() + marqueeBouncePauseLeftMs; }
} else if (marqueeMode == 1 || marqueeMode == 0) {
marqueeOffset = TOTAL_WIDTH + marqueeEdgePadding; // continuous depuis la droite + padding
} else if (marqueeMode == 3) { // one-shot centré d'abord
marqueeOneShotCenterPhase = true;
marqueeOneShotStart = millis();
cachedX = (TOTAL_WIDTH - w) / 2 - x1; // centré
}
lastMarqueeStep = millis();
marqueeCycleStartMs = millis();
} else {
cachedX = (TOTAL_WIDTH - w) / 2 - x1; // centré
}
// Si texte court (pas de marquee) on définit cachedX, sinon il sera dynamique
if (!marqueeActive) {
cachedX = (TOTAL_WIDTH - w) / 2 - x1;
}
// Conserver version pliée (marquee & mesure)
strncpy(lastText, currentText, sizeof(lastText)-1);
lastText[sizeof(lastText)-1] = '\0';
lastExpired = countdownExpired;
cachedIsEndMsg = countdownExpired;
forceLayout = false;
} else {
const GFXfont *font = getOptimalFont();
display.setFont(font);
display.setTextSize(cachedSetSize);
}
// Gestion de l'avancement du marquee (hors section critique)
if (marqueeActive) {
unsigned long nowMs = millis();
// ONE SHOT: phase centrée -> attendre délai puis lancer scroll
if (marqueeMode == 3 && marqueeOneShotCenterPhase) {
if (nowMs - marqueeOneShotStart >= (unsigned long)marqueeOneShotDelayMs) {
marqueeOneShotCenterPhase = false;
marqueeOffset = TOTAL_WIDTH; // début scroll
lastMarqueeStep = nowMs;
marqueeCycleStartMs = nowMs;
} else {
// ne rien faire pendant la phase centrée
}
} else if (marqueeMode == 3 && marqueeOneShotCenterPhase == false && marqueeOneShotDone) {
// terminé : si restart demandé
if (marqueeOneShotRestartSec > 0 && nowMs >= marqueeOneShotRestartAt && marqueeOneShotRestartAt != 0) {
// relance cycle
marqueeOneShotDone = false;
marqueeOneShotCenterPhase = true;
marqueeOneShotStart = nowMs;
lastMarqueeStep = nowMs;
marqueeCycleStartMs = nowMs;
}
} else {
// BOUNCE: gestion pause
if (marqueeMode == 2 && marqueeInPause) {
if (nowMs >= marqueePauseUntil) {
marqueeInPause = false;
lastMarqueeStep = nowMs; // reset timer pour éviter saut
marqueeCycleStartMs = nowMs; // nouveau cycle après pause
}
}
int forwardInt = marqueeIntervalMs; if (forwardInt < 5) forwardInt = 5; if (forwardInt > 500) forwardInt = 500;
int returnInt = marqueeReturnIntervalMs; if (returnInt < 5) returnInt = 5; if (returnInt > 500) returnInt = 500;
// Accélération progressive
if (marqueeAccelEnabled) {
int startI = marqueeAccelStartIntervalMs; if (startI < 5) startI = 5; if (startI > 500) startI = 500;
int endI = marqueeAccelEndIntervalMs; if (endI < 5) endI = 5; if (endI > 500) endI = 500;
unsigned long elapsed = nowMs - marqueeCycleStartMs;
float t = (marqueeAccelDurationMs <= 0) ? 1.0f : (float)elapsed / (float)marqueeAccelDurationMs;
if (t > 1.0f) t = 1.0f;
int interp = startI + (int)((endI - startI) * t);
// Pour bounce: appliquer sur direction actuelle (séparément pour retour si différent)
if (marqueeMode == 2) {
if (marqueeDirection == -1) forwardInt = interp; else returnInt = interp; // direction -1 = vers gauche (forward logique), 1 = retour
} else if (marqueeMode == 0 || marqueeMode == 1 || marqueeMode == 3) {
forwardInt = interp;
}
}
int effectiveInt = forwardInt;
if (marqueeMode == 2 && marqueeDirection == 1) effectiveInt = returnInt; // retour
if (!marqueeInPause && nowMs - lastMarqueeStep >= (unsigned long)effectiveInt) {
lastMarqueeStep = nowMs;
if (marqueeMode == 2) { // bounce
marqueeOffset += marqueeDirection; // -1 gauche, +1 droite
int minX = TOTAL_WIDTH - marqueeTextWidth - marqueeEdgePadding; // borne gauche avec padding
if (marqueeOffset <= minX) { marqueeOffset = minX; marqueeDirection = 1; if (marqueeBouncePauseRightMs>0){ marqueeInPause=true; marqueePauseUntil=nowMs+marqueeBouncePauseRightMs; } marqueeCycleStartMs = nowMs; }
if (marqueeOffset >= marqueeEdgePadding) { marqueeOffset = marqueeEdgePadding; marqueeDirection = -1; if (marqueeBouncePauseLeftMs>0){ marqueeInPause=true; marqueePauseUntil=nowMs+marqueeBouncePauseLeftMs; } marqueeCycleStartMs = nowMs; }
} else if (marqueeMode == 3) { // one-shot scrolling phase
if (!marqueeOneShotDone && !marqueeOneShotCenterPhase) {
marqueeOffset--; // vers la gauche
if (marqueeOffset + marqueeTextWidth < 0) {
marqueeActive = false; marqueeOneShotDone = true;
if (marqueeOneShotStopCenter) {
// recadrer avec correction x1
cachedX = (TOTAL_WIDTH - marqueeTextWidth) / 2 - cachedFontXOffset;
}
if (marqueeOneShotRestartSec > 0) {
marqueeOneShotRestartAt = nowMs + (unsigned long)marqueeOneShotRestartSec * 1000UL;
}
}
}
} else { // continuous modes
marqueeOffset--;
int localGap = marqueeGap; if (localGap < 4) localGap = 4; if (localGap > 256) localGap = 256;
if (marqueeOffset + marqueeTextWidth < 0) {
marqueeOffset = TOTAL_WIDTH + localGap + marqueeEdgePadding; // boucle avec padding
marqueeCycleStartMs = nowMs; // nouveau cycle -> reset accel
}
}
}
}
}
// Section critique minimale : effacement + écriture tampon
portENTER_CRITICAL(&timerMux);
display.clearDisplay();
// Toujours la couleur choisie (même si expiré) conformément à la demande
display.setTextColor(displayColor);
if (marqueeActive) {
if (marqueeMode == 2) { // bounce
display.setCursor(marqueeOffset, cachedY);
display.print(lastText);
} else if (marqueeMode == 3) { // one-shot
if (marqueeOneShotCenterPhase) {
display.setCursor((TOTAL_WIDTH - marqueeTextWidth)/2 - cachedFontXOffset, cachedY);
display.print(lastText);
} else if (marqueeOneShotDone && marqueeOneShotStopCenter) {
display.setCursor((TOTAL_WIDTH - marqueeTextWidth)/2 - cachedFontXOffset, cachedY);
display.print(lastText);
} else {
display.setCursor(marqueeOffset, cachedY);
display.print(lastText);
}
} else {
// continuous / always
int drawX = marqueeOffset;
display.setCursor(drawX, cachedY);
display.print(lastText);
int localGap = marqueeGap; if (localGap < 4) localGap = 4; if (localGap > 256) localGap = 256;
int secondX = marqueeOffset + marqueeTextWidth + localGap;
if (secondX < TOTAL_WIDTH) {
display.setCursor(secondX, cachedY);
display.print(lastText);
}
}
} else {
display.setCursor(cachedX, cachedY);
display.print(lastText);
}
portEXIT_CRITICAL(&timerMux);
}
// Chargement des paramètres depuis la mémoire flash (version thread-safe)
void loadSettings() {
Serial.println("Loading settings from NVS...");
// Utilisation de la classe helper pour mutex automatique avec timeout approprié
MUTEX_GUARD_CHECK(preferencesMutex, MUTEX_TIMEOUT_SLOW) {
Serial.println("Failed to acquire preferences mutex - using defaults");
return;
}
// Prendre aussi le mutex countdown pour cohérence des paramètres
MUTEX_GUARD_CHECK(countdownMutex, MUTEX_TIMEOUT_NORMAL) {
Serial.println("Failed to acquire countdown mutex - using defaults");
return;
}
bool success = false;
do {
if (!preferences.begin("countdown", true)) {
Serial.println("Failed to begin preferences for reading");
break;
}
// Paramètres du countdown
countdownYear = preferences.getInt("cd_Year", 2025);
countdownMonth = preferences.getInt("cd_Month", 12);
countdownDay = preferences.getInt("cd_Day", 31);
countdownHour = preferences.getInt("cd_Hour", 23);
countdownMinute = preferences.getInt("cd_Minute", 59);
countdownSecond = preferences.getInt("cd_Second", 0);
// Paramètres d'affichage - style de police et couleur
fontStyle = preferences.getInt("fontStyle", 0); // 0=Normal, 1=Gras, 2=Italique
countdownColorR = preferences.getInt("colorR", 0);
countdownColorG = preferences.getInt("colorG", 255);
countdownColorB = preferences.getInt("colorB", 0);
blinkEnabled = preferences.getBool("blinkEn", true);
blinkIntervalMs = preferences.getInt("blinkInt", 500);
blinkWindowSeconds = preferences.getInt("blinkWin", 10);
marqueeEnabled = preferences.getBool("mqEn", true);
marqueeIntervalMs = preferences.getInt("mqInt", 40);
marqueeGap = preferences.getInt("mqGap", 24);
marqueeMode = preferences.getInt("mqMode", 0);
marqueeReturnIntervalMs = preferences.getInt("mqRetInt", 60);
marqueeBouncePauseLeftMs = preferences.getInt("mqPL", 400);
marqueeBouncePauseRightMs = preferences.getInt("mqPR", 400);
marqueeOneShotDelayMs = preferences.getInt("mqOsDelay", 800);
marqueeOneShotStopCenter = preferences.getBool("mqOsStopC", true);
marqueeOneShotRestartSec = preferences.getInt("mqOsRst", 0);
marqueeAccelEnabled = preferences.getBool("mqAccEn", false);
marqueeAccelStartIntervalMs = preferences.getInt("mqAccSt", 80);
marqueeAccelEndIntervalMs = preferences.getInt("mqAccEnd", 30);
marqueeAccelDurationMs = preferences.getInt("mqAccDur", 3000);
displayBrightness = preferences.getInt("bright", -1);
String savedTitle = preferences.getString("cd_Title", "COUNTDOWN");
strcpy(countdownTitle, savedTitle.c_str());
preferences.end();
success = true;
} while(false);
// Les mutex sont libérés automatiquement par le destructeur de MutexGuard
if (success) {
Serial.println("Settings loaded successfully");
} else {
Serial.println("Using default settings");
}
// Mise à jour de la couleur
countdownColor = display.color565(countdownColorR, countdownColorG, countdownColorB);
// Mise à jour de la date cible
countdownTarget = DateTime(countdownYear, countdownMonth, countdownDay,
countdownHour, countdownMinute, countdownSecond);
// Appliquer brightness (auto si -1)
int effectiveBrightness;
if (displayBrightness < 0) {
effectiveBrightness = 150;
if (MATRIX_PANELS_X > 2) effectiveBrightness = 100;
if (MATRIX_PANELS_X > 4) effectiveBrightness = 80;
if (MATRIX_PANELS_X > 6) effectiveBrightness = 60;
} else {
effectiveBrightness = displayBrightness;
}
display.setBrightness(effectiveBrightness);
}
// Sauvegarde des paramètres dans la mémoire flash (version thread-safe optimisée)
void saveSettings() {
Serial.println("Saving settings to NVS...");
// Vérifications de sécurité préliminaires
if (!checkMutexSanity()) {
Serial.println("Mutex sanity check failed - aborting save");
return;
}
// Désactiver temporairement le timer d'affichage pour éviter les conflits
bool timerWasEnabled = (timer != nullptr);
if (timerWasEnabled) {
display_update_enable(false);
vTaskDelay(pdMS_TO_TICKS(10)); // Attendre que l'IRQ se termine
}
// Tentative d'acquisition des mutex avec timeout très court pour éviter les blocages
if (preferencesMutex && xSemaphoreTake(preferencesMutex, pdMS_TO_TICKS(50)) == pdTRUE) {
bool prefsStarted = false;
// Retry logic pour preferences.begin()
for (int retry = 0; retry < 3 && !prefsStarted; retry++) {
prefsStarted = preferences.begin("countdown", false);
if (!prefsStarted) {
vTaskDelay(pdMS_TO_TICKS(10));
}
}
if (prefsStarted) {
// Paramètres du countdown - capture des valeurs locales pour éviter les races
int year, month, day, hour, minute, second;
int colorR, colorG, colorB;
int fStyle;
// Acquisition TRÈS rapide du mutex countdown pour capturer les valeurs
if (countdownMutex && xSemaphoreTake(countdownMutex, pdMS_TO_TICKS(20)) == pdTRUE) {
year = countdownYear;
month = countdownMonth;
day = countdownDay;
hour = countdownHour;
minute = countdownMinute;
second = countdownSecond;
colorR = countdownColorR;
colorG = countdownColorG;
colorB = countdownColorB;
fStyle = fontStyle;
xSemaphoreGive(countdownMutex);
} else {
// Fallback sans mutex - utiliser les valeurs directement (atomic reads sur ESP32)
Serial.println("Mutex timeout - using direct read (atomic)");
year = countdownYear;
month = countdownMonth;
day = countdownDay;
hour = countdownHour;
minute = countdownMinute;
second = countdownSecond;
colorR = countdownColorR;
colorG = countdownColorG;
colorB = countdownColorB;
fStyle = fontStyle;
}
// Sauvegarde optimisée - grouper les écritures pour réduire les opérations NVS
try {
preferences.putInt("cd_Year", year);
preferences.putInt("cd_Month", month);
preferences.putInt("cd_Day", day);
preferences.putInt("cd_Hour", hour);
preferences.putInt("cd_Minute", minute);
preferences.putInt("cd_Second", second);
// Paramètres d'affichage - style de police et couleur
preferences.putInt("fontStyle", fStyle);
preferences.putInt("colorR", colorR);
preferences.putInt("colorG", colorG);
preferences.putInt("colorB", colorB);
preferences.putBool("blinkEn", blinkEnabled);
preferences.putInt("blinkInt", blinkIntervalMs);
preferences.putInt("blinkWin", blinkWindowSeconds);
preferences.putBool("mqEn", marqueeEnabled);
preferences.putInt("mqInt", marqueeIntervalMs);
preferences.putInt("mqGap", marqueeGap);
preferences.putInt("mqMode", marqueeMode);
preferences.putInt("mqRetInt", marqueeReturnIntervalMs);
preferences.putInt("mqPL", marqueeBouncePauseLeftMs);
preferences.putInt("mqPR", marqueeBouncePauseRightMs);
preferences.putInt("mqOsDelay", marqueeOneShotDelayMs);
preferences.putBool("mqOsStopC", marqueeOneShotStopCenter);
preferences.putInt("mqOsRst", marqueeOneShotRestartSec);
preferences.putBool("mqAccEn", marqueeAccelEnabled);
preferences.putInt("mqAccSt", marqueeAccelStartIntervalMs);
preferences.putInt("mqAccEnd", marqueeAccelEndIntervalMs);
preferences.putInt("mqAccDur", marqueeAccelDurationMs);
preferences.putInt("bright", displayBrightness);
preferences.putString("cd_Title", String(countdownTitle));
Serial.println("Settings saved successfully");
// Mise à jour de la couleur et de la date cible en dehors du contexte NVS
countdownColor = display.color565(colorR, colorG, colorB);
countdownTarget = DateTime(year, month, day, hour, minute, second);
} catch (...) {
Serial.println("Exception during NVS write - data may be corrupted");
}
preferences.end();
} else {
Serial.println("Failed to begin preferences after retries");
}
xSemaphoreGive(preferencesMutex);
} else {
Serial.println("Could not acquire preferences mutex - save skipped");
}
// Réactiver le timer d'affichage si nécessaire
if (timerWasEnabled) {
vTaskDelay(pdMS_TO_TICKS(5)); // Petit délai avant réactivation
display_update_enable(true);
}
}
// Connexion WiFi
void connecting_To_WiFi() {
Serial.println("\n-------------WIFI mode (STA async)");
WiFi.mode(WIFI_STA);
WiFi.persistent(false); // éviter écritures flash lentes
WiFi.setAutoReconnect(true);
WiFi.begin(ssid, password);
uint32_t startAttempt = millis();
const uint32_t timeoutMs = 8000; // timeout réduit
while (WiFi.status() != WL_CONNECTED && (millis() - startAttempt) < timeoutMs) {
WIFI_STEP_DELAY(50);
Serial.print('.');
// Donner du temps au scheduler (éviter WDT)
yield();
}
if (WiFi.status() == WL_CONNECTED) {
Serial.printf("\nWiFi connected in %lu ms\n", (unsigned long)(millis() - startAttempt));
Serial.print("IP: "); Serial.println(WiFi.localIP());
} else {
Serial.println("\nWiFi connect timeout -> fallback AP");
useStationMode = false;
}
Serial.println("-------------");
}
// Configuration du point d'accès
void set_ESP32_Access_Point() {
Serial.println("\n-------------");
Serial.println("WIFI mode : AP");
WiFi.mode(WIFI_AP);
Serial.println("-------------");
Serial.println("Configuring Access Point...");
WiFi.softAP(ap_ssid, ap_password);
IPAddress local_ip(192, 168, 1, 1);
IPAddress gateway(192, 168, 1, 1);
IPAddress subnet(255, 255, 255, 0);
WiFi.softAPConfig(local_ip, gateway, subnet);
Serial.print("SSID : "); Serial.println(ap_ssid);
Serial.print("AP IP : "); Serial.println(WiFi.softAPIP());
}
// Gestionnaire de la page principale
void handleRoot() {
String page = MAIN_page;
page.replace("v__FWVER__", String(FIRMWARE_VERSION));
page.replace("__AUTHOR_NAME__", String(AUTHOR_NAME));
page.replace("__GITHUB_URL__", String(GITHUB_URL));
server.send(200, "text/html", page);
}
// Gestionnaire des paramètres actuels en JSON
void handleGetSettings() {
String json = "{";
json += "\"title\":\"" + String(countdownTitle) + "\",";
json += "\"year\":" + String(countdownYear) + ",";
json += "\"month\":" + String(countdownMonth) + ",";
json += "\"day\":" + String(countdownDay) + ",";
json += "\"hour\":" + String(countdownHour) + ",";
json += "\"minute\":" + String(countdownMinute) + ",";
json += "\"second\":" + String(countdownSecond) + ",";
json += "\"fontStyle\":" + String(fontStyle) + ",";
json += "\"colorR\":" + String(countdownColorR) + ",";
json += "\"colorG\":" + String(countdownColorG) + ",";
json += "\"colorB\":" + String(countdownColorB) + ",";
json += "\"blinkEnabled\":" + String(blinkEnabled ? 1 : 0) + ",";
json += "\"blinkIntervalMs\":" + String(blinkIntervalMs) + ",";
json += "\"blinkWindow\":" + String(blinkWindowSeconds) + ",";
json += "\"marqueeEnabled\":" + String(marqueeEnabled ? 1 : 0) + ",";
json += "\"marqueeIntervalMs\":" + String(marqueeIntervalMs) + ",";
json += "\"marqueeGap\":" + String(marqueeGap) + ",";
json += "\"marqueeMode\":" + String(marqueeMode) + ",";
json += "\"marqueeReturnIntervalMs\":" + String(marqueeReturnIntervalMs) + ",";
json += "\"marqueeBouncePauseLeftMs\":" + String(marqueeBouncePauseLeftMs) + ",";
json += "\"marqueeBouncePauseRightMs\":" + String(marqueeBouncePauseRightMs) + ",";
json += "\"marqueeOneShotDelayMs\":" + String(marqueeOneShotDelayMs) + ",";
json += "\"marqueeOneShotStopCenter\":" + String(marqueeOneShotStopCenter ? 1:0) + ",";
json += "\"marqueeOneShotRestartSec\":" + String(marqueeOneShotRestartSec) + ",";
json += "\"marqueeAccelEnabled\":" + String(marqueeAccelEnabled ? 1:0) + ",";
json += "\"marqueeAccelStartIntervalMs\":" + String(marqueeAccelStartIntervalMs) + ",";
json += "\"marqueeAccelEndIntervalMs\":" + String(marqueeAccelEndIntervalMs) + ",";
json += "\"marqueeAccelDurationMs\":" + String(marqueeAccelDurationMs);
json += ",\"brightness\":" + String(displayBrightness);
json += "}";
server.send(200, "application/json", json);
}
// Synchronisation de l'heure depuis le navigateur (client envoie son epoch ms + offset minutes)
void handleSyncTime() {
if (!server.hasArg("epoch")) {
server.send(400, "application/json", "{\"status\":\"ERR\",\"msg\":\"missing epoch\"}");
return;
}
String epochStr = server.arg("epoch");
String tzStr = server.hasArg("tz") ? server.arg("tz") : String("0");
unsigned long long epochMs = strtoull(epochStr.c_str(), nullptr, 10);
long tzMinutes = tzStr.toInt(); // JS getTimezoneOffset(): UTC = local + offset
// epochMs est en UTC. Nous voulons régler le RTC sur l'heure locale perçue par l'utilisateur.
time_t epochSec = (time_t)(epochMs / 1000ULL);
// local = UTC - offsetMinutes*60 (car offset = UTC - local)
time_t localSec = epochSec - (tzMinutes * 60L);
DateTime localDT(localSec);
// Protection simple : vérifier année raisonnable
if (localDT.year() < 2020 || localDT.year() > 2099) {
server.send(400, "application/json", "{\"status\":\"ERR\",\"msg\":\"invalid time\"}");
return;
}
// Ajuster le RTC (mutex countdown pas nécessaire pour simple set, mais on peut briefer)
rtc.adjust(localDT);
// Recalculer le countdownTarget à partir des paramètres sauvegardés (utc inchangé) — laisser tel quel.
// Réponse
String resp = "{\"status\":\"OK\",\"set\":\"";
resp += String(localDT.year()) + "-" + String(localDT.month()) + "-" + String(localDT.day()) + "T" + String(localDT.hour()) + ":" + String(localDT.minute()) + ":" + String(localDT.second()) + "\"}";
server.send(200, "application/json", resp);
Serial.printf("RTC synchronized to client local time: %04d-%02d-%02d %02d:%02d:%02d (tz offset %ld mn)\n", localDT.year(), localDT.month(), localDT.day(), localDT.hour(), localDT.minute(), localDT.second(), tzMinutes);
}
// Gestionnaire des paramètres
void handleSettings() {
Serial.println("\n-------------Settings");
// Désactiver temporairement le timer d'affichage pour éviter conflits ISR pendant mise à jour
bool displayTimerWasOn = (timer != nullptr);
if (displayTimerWasOn) {
Serial.println("[handleSettings] Pause display timer");
display_update_enable(false);
}
// Récupérer les valeurs de la requête
String title = server.hasArg("title") ? server.arg("title") : String("");
String dateStr = server.hasArg("date") ? server.arg("date") : String("");
String timeStr = server.hasArg("time") ? server.arg("time") : String("");
if (dateStr.length() < 10 || timeStr.length() < 5) {
server.send(400, "text/plain", "Parametres invalides");
return;
}
// Parser la date (format YYYY-MM-DD)
// Protéger modifications
if (countdownMutex != NULL && xSemaphoreTake(countdownMutex, pdMS_TO_TICKS(200))) {
countdownYear = dateStr.substring(0, 4).toInt();
countdownMonth = dateStr.substring(5, 7).toInt();
countdownDay = dateStr.substring(8, 10).toInt();
// Parser l'heure (format HH:MM:SS)
countdownHour = timeStr.substring(0, 2).toInt();
countdownMinute = timeStr.substring(3, 5).toInt();
countdownSecond = timeStr.length() > 5 ? timeStr.substring(6, 8).toInt() : 0;
// Récupérer le style de police et la couleur
fontStyle = server.hasArg("fontStyle") ? server.arg("fontStyle").toInt() : fontStyle;
countdownColorR = server.hasArg("colorR") ? server.arg("colorR").toInt() : countdownColorR;
countdownColorG = server.hasArg("colorG") ? server.arg("colorG").toInt() : countdownColorG;
countdownColorB = server.hasArg("colorB") ? server.arg("colorB").toInt() : countdownColorB;
if (server.hasArg("blinkEnabled")) {
blinkEnabled = server.arg("blinkEnabled").toInt() != 0;
}
if (server.hasArg("blinkInterval")) {
int bi = server.arg("blinkInterval").toInt();
if (bi < 50) bi = 50; if (bi > 5000) bi = 5000; // bornes logiques
blinkIntervalMs = bi;
}
if (server.hasArg("blinkWindow")) {
int bw = server.arg("blinkWindow").toInt();
if (bw < 1) bw = 1; if (bw > 3600) bw = 3600;
blinkWindowSeconds = bw;
}
if (server.hasArg("marqueeEnabled")) {
marqueeEnabled = server.arg("marqueeEnabled").toInt() != 0;
forceLayout = true;
}
if (server.hasArg("marqueeInterval")) {
int mi = server.arg("marqueeInterval").toInt();
if (mi < 5) mi = 5; if (mi > 500) mi = 500;
marqueeIntervalMs = mi;
forceLayout = true;
}
if (server.hasArg("marqueeGap")) {
int mg = server.arg("marqueeGap").toInt();
if (mg < 4) mg = 4; if (mg > 256) mg = 256;
marqueeGap = mg;
forceLayout = true;
}
if (server.hasArg("marqueeMode")) {
int mm = server.arg("marqueeMode").toInt();
if (mm < 0) mm = 0; if (mm > 3) mm = 3;
marqueeMode = mm;
// réinitialiser états spécifiques
marqueeOneShotDone = false;
forceLayout = true;
}
if (server.hasArg("marqueeReturnInterval")) {
int ri = server.arg("marqueeReturnInterval").toInt();
if (ri < 5) ri = 5; if (ri > 500) ri = 500;
marqueeReturnIntervalMs = ri;
forceLayout = true;
}
if (server.hasArg("marqueeBouncePauseLeft")) {
int bpL = server.arg("marqueeBouncePauseLeft").toInt();
if (bpL < 0) bpL = 0; if (bpL > 5000) bpL = 5000;
marqueeBouncePauseLeftMs = bpL;
forceLayout = true;
}
if (server.hasArg("marqueeBouncePauseRight")) {
int bpR = server.arg("marqueeBouncePauseRight").toInt();
if (bpR < 0) bpR = 0; if (bpR > 5000) bpR = 5000;
marqueeBouncePauseRightMs = bpR;
forceLayout = true;
}
if (server.hasArg("marqueeOneShotDelay")) {
int od = server.arg("marqueeOneShotDelay").toInt();
if (od < 0) od = 0; if (od > 10000) od = 10000;
marqueeOneShotDelayMs = od;
forceLayout = true;
}
if (server.hasArg("marqueeOneShotStopCenter")) {
marqueeOneShotStopCenter = server.arg("marqueeOneShotStopCenter") == "1";
forceLayout = true;
}
if (server.hasArg("marqueeOneShotRestart")) {
int rs = server.arg("marqueeOneShotRestart").toInt();
if (rs < 0) rs = 0; if (rs > 86400) rs = 86400;
marqueeOneShotRestartSec = rs;
forceLayout = true;
}
if (server.hasArg("marqueeAccelEnabled")) {
marqueeAccelEnabled = server.arg("marqueeAccelEnabled") == "1";
forceLayout = true;
}
if (server.hasArg("marqueeAccelStart")) {
int as = server.arg("marqueeAccelStart").toInt();
if (as < 5) as = 5; if (as > 500) as = 500; marqueeAccelStartIntervalMs = as;
forceLayout = true;
}
if (server.hasArg("marqueeAccelEnd")) {
int ae = server.arg("marqueeAccelEnd").toInt();
if (ae < 5) ae = 5; if (ae > 500) ae = 500; marqueeAccelEndIntervalMs = ae;
forceLayout = true;
}
if (server.hasArg("marqueeAccelDuration")) {
int ad = server.arg("marqueeAccelDuration").toInt();
if (ad < 50) ad = 50; if (ad > 600000) ad = 600000; marqueeAccelDurationMs = ad;
forceLayout = true;
}
if (server.hasArg("brightness")) {
int b = server.arg("brightness").toInt();
if (b < -1) b = -1; if (b > 255) b = 255;
displayBrightness = b;
}
// Valider le style de police (0=Normal, 1=Gras, 2=Italique)
if (fontStyle < 0) fontStyle = 0;
if (fontStyle > 2) fontStyle = 2;
// Mettre à jour le titre
if (title.length() > 0) {
utf8SafeCopyTruncate(title, countdownTitle, sizeof(countdownTitle), 50);
}
// Valider la date
if (countdownMonth < 1) countdownMonth = 1;
if (countdownMonth > 12) countdownMonth = 12;
if (countdownDay < 1) countdownDay = 1;
if (countdownDay > 31) countdownDay = 31;
// Valider l'heure
if (countdownHour > 23) countdownHour = 23;
if (countdownMinute > 59) countdownMinute = 59;
if (countdownSecond > 59) countdownSecond = 59;
// Recalculer couleur immédiate (affichage plus réactif)
countdownColor = display.color565(countdownColorR, countdownColorG, countdownColorB);
// Mettre à jour cible localement pour affichage avant sauvegarde
countdownTarget = DateTime(countdownYear, countdownMonth, countdownDay,
countdownHour, countdownMinute, countdownSecond);
xSemaphoreGive(countdownMutex);
} else {
Serial.println("Could not get countdown mutex in handleSettings");
}
// Sauvegarder les paramètres (demander la sauvegarde plutôt que de la faire directement)
saveRequested = true;
saveRequestTime = millis();
// Ajuster luminosité immédiatement
int effectiveBrightness;
if (displayBrightness < 0) {
effectiveBrightness = 150;
if (MATRIX_PANELS_X > 2) effectiveBrightness = 100;
if (MATRIX_PANELS_X > 4) effectiveBrightness = 80;
if (MATRIX_PANELS_X > 6) effectiveBrightness = 60;
} else {
effectiveBrightness = displayBrightness;
}
display.setBrightness(effectiveBrightness);
Serial.println("Settings updated:");
Serial.printf("Target: %d-%02d-%02d %02d:%02d:%02d\n",
countdownYear, countdownMonth, countdownDay,
countdownHour, countdownMinute, countdownSecond);
Serial.printf("Title: %s\n", countdownTitle);
const char* fontStyleNames[] = {"Normal", "Gras", "Italique"};
Serial.printf("Font: DejaVu %s (taille automatique)\n", fontStyleNames[fontStyle]);
Serial.printf("Color (RGB): %d,%d,%d\n", countdownColorR, countdownColorG, countdownColorB);
Serial.printf("Blink: enabled=%d interval=%dms window=%ds\n", blinkEnabled, blinkIntervalMs, blinkWindowSeconds);
Serial.printf("Marquee: en=%d mode=%d fwdInt=%d retInt=%d gap=%d LPause=%d RPause=%d accel=%d start=%d end=%d dur=%d oneDelay=%d oneStopC=%d oneRst=%d\n",
marqueeEnabled, marqueeMode, marqueeIntervalMs, marqueeReturnIntervalMs, marqueeGap,
marqueeBouncePauseLeftMs, marqueeBouncePauseRightMs, marqueeAccelEnabled?1:0,
marqueeAccelStartIntervalMs, marqueeAccelEndIntervalMs, marqueeAccelDurationMs,
marqueeOneShotDelayMs, marqueeOneShotStopCenter?1:0, marqueeOneShotRestartSec);
Serial.printf("Brightness setting: %d ( -1 = auto )\n", displayBrightness);
// Répondre avec une redirection vers la page principale
server.sendHeader("Location", "/", true);
server.send(302, "text/plain", "");
// Réactiver timer si nécessaire
if (displayTimerWasOn) {
Serial.println("[handleSettings] Resume display timer");
display_update_enable(true);
// Forcer un redraw complet
forceLayout = true;
}
}
// Gestionnaire de reset
void handleReset() {
// Vérification clé supprimée
// Réinitialiser aux valeurs par défaut
if (countdownMutex != NULL && xSemaphoreTake(countdownMutex, pdMS_TO_TICKS(200))) {
countdownYear = 2025;
countdownMonth = 12;
countdownDay = 31;
countdownHour = 23;
countdownMinute = 59;
countdownSecond = 0;
strcpy(countdownTitle, "COUNTDOWN");
fontStyle = 0; // Normal par défaut
countdownColorR = 0;
countdownColorG = 255;
countdownColorB = 0;
countdownColor = display.color565(countdownColorR, countdownColorG, countdownColorB);
countdownTarget = DateTime(countdownYear, countdownMonth, countdownDay,
countdownHour, countdownMinute, countdownSecond);
xSemaphoreGive(countdownMutex);
}
// Sauvegarder les paramètres (demander la sauvegarde plutôt que de la faire directement)
saveRequested = true;
saveRequestTime = millis();
Serial.println("Settings reset to defaults");
// Répondre avec une redirection vers la page principale
server.sendHeader("Location", "/", true);
server.send(302, "text/plain", "");
}
// Configuration et démarrage du serveur
void prepare_and_start_The_Server() {
dnsServer.start(DNS_PORT, "*", WiFi.softAPIP());
server.on("/", handleRoot);
server.on("/settings", HTTP_POST, handleSettings);
server.on("/getSettings", handleGetSettings);
server.on("/syncTime", HTTP_GET, handleSyncTime);
server.on("/reset", HTTP_POST, handleReset);
server.onNotFound([]() { server.sendHeader("Location", "/", true); server.send(302, "text/plain", ""); });
server.begin();
Serial.println("HTTP server started (fast)");
if (useStationMode) {
Serial.print("URL: http://"); Serial.println(WiFi.localIP());
} else {
Serial.print("AP URL: http://"); Serial.println(WiFi.softAPIP());
}
}
// Fonction de callback pour le timer d'affichage
void IRAM_ATTR display_updater() {
// Vérification rapide de la validité avant d'entrer en section critique
if (timer == nullptr) {
return; // Timer désactivé
}
portENTER_CRITICAL_ISR(&timerMux);
// Double vérification dans la section critique
if (timer != nullptr) {
display.display(display_draw_time);
}
portEXIT_CRITICAL_ISR(&timerMux);
}
// Activation/désactivation du timer d'affichage
void display_update_enable(bool is_enable) {
if (is_enable) {
if (timer == nullptr) {
timer = timerBegin(0, 80, true);
if (timer != nullptr) {
timerAttachInterrupt(timer, &display_updater, true);
timerAlarmWrite(timer, 4000, true);
timerAlarmEnable(timer);
Serial.println("Display timer enabled");
} else {
Serial.println("Failed to initialize display timer");
}
} else {
Serial.println("Display timer already enabled");
}
} else {
if (timer != nullptr) {
timerAlarmDisable(timer);
timerDetachInterrupt(timer);
timerEnd(timer);
timer = nullptr;
Serial.println("Display timer disabled");
}
}
}
void setup() {
// Éviter grosse pause initiale; laisser hardware se stabiliser rapidement
BOOT_DELAY(100);
Serial.begin(115200);
Serial.println("\n=== ESP32 P10 RGB FULLSCREEN COUNTDOWN ===");
Serial.printf("Configuration: %dx%d panels (%dx%d total resolution)\n",
MATRIX_PANELS_X, MATRIX_PANELS_Y, TOTAL_WIDTH, TOTAL_HEIGHT);
// Création des mutex
displayMutex = xSemaphoreCreateMutex();
countdownMutex = xSemaphoreCreateMutex();
preferencesMutex = xSemaphoreCreateMutex();
if (displayMutex == NULL || countdownMutex == NULL || preferencesMutex == NULL) {
Serial.println("Error creating mutexes");
while(1);
}
// NE PAS activer le timer ici - attendre après le WiFi et les tâches
// Initialisation du RTC
Serial.println("Initializing RTC...");
if (!rtc.begin()) {
Serial.println("Couldn't find RTC");
while (1) delay(10);
}
Serial.println("RTC initialized successfully");
// Initialisation de l'affichage avec configuration P10 optimisée
display.begin(4); // 1/8 scan pour P10
display.setScanPattern(ZAGZIG);
display.setMuxPattern(BINARY);
const int muxdelay = 10; // Délai de multiplexage
display.setMuxDelay(muxdelay, muxdelay, muxdelay, muxdelay, muxdelay);
BOOT_DELAY(20);
// Luminosité adaptée au nombre de panneaux
int brightness = 150;
if (MATRIX_PANELS_X > 2) brightness = 100;
if (MATRIX_PANELS_X > 4) brightness = 80;
if (MATRIX_PANELS_X > 6) brightness = 60;
display.setBrightness(brightness);
Serial.printf("Brightness set to: %d\n", brightness);
display.setTextWrap(false);
display.setRotation(0);
// Chargement des paramètres
loadSettings();
// Affichage initial (y compris si déjà expiré au démarrage)
{
int d=0,h=0,m=0,s=0;
if (countdownMutex && xSemaphoreTake(countdownMutex, pdMS_TO_TICKS(50))) {
updateCountdown(d,h,m,s);
updateDisplayFormat(d,h,m,s);
xSemaphoreGive(countdownMutex);
}
if (displayMutex && xSemaphoreTake(displayMutex, pdMS_TO_TICKS(50))) {
displayFullscreenCountdown(d,h,m,s);
xSemaphoreGive(displayMutex);
}
}
// Splash écran réduit
display.clearDisplay();
display.setTextColor(countdownColor);
display.setTextSize(1);
display.setCursor(0, 0);
display.print("BOOT");
display.setCursor(0, 8);
display.print("COUNTDOWN");
BOOT_DELAY(BOOT_SPLASH_MS);
display.clearDisplay();
// Configuration WiFi initiale
if (useStationMode) {
connecting_To_WiFi();
if (!useStationMode) {
set_ESP32_Access_Point();
}
} else {
set_ESP32_Access_Point();
}
// Démarrage du serveur web
prepare_and_start_The_Server();
Serial.println("Creating FreeRTOS tasks (fast boot)...");
// Création des tâches FreeRTOS avec une meilleure distribution
BaseType_t result;
// Tâche d'affichage sur le Core 1 (isolé du WiFi)
result = xTaskCreatePinnedToCore(
DisplayTask,
"DisplayTask",
TASK_DISPLAY_STACK,
NULL,
TASK_DISPLAY_PRIORITY,
&displayTaskHandle,
1 // Core 1
);
if (result != pdPASS) Serial.println("Failed to create Display task");
// Tâche combinée réseau + serveur web Core 0
result = xTaskCreatePinnedToCore(
NetWebTask,
"NetWebTask",
TASK_NETWORK_STACK,
NULL,
TASK_NETWORK_PRIORITY,
&networkTaskHandle,
0
);
if (result != pdPASS) Serial.println("Failed to create NetWeb task");
// Tâche de calcul sur le Core 0
result = xTaskCreatePinnedToCore(
CountdownTask,
"CountdownTask",
TASK_COUNTDOWN_STACK,
NULL,
TASK_COUNTDOWN_PRIORITY,
&countdownTaskHandle,
0 // Core 0
);
if (result != pdPASS) Serial.println("Failed to create Countdown task");
Serial.println("FreeRTOS tasks created successfully");
Serial.println("Starting fullscreen countdown...");
// ACTIVATION DU TIMER D'AFFICHAGE EN DERNIÈRE ÉTAPE
Serial.println("Activating display timer earlier...");
display_update_enable(true);
BOOT_DELAY(50); // Stabilisation rapide
}
// Tâche d'affichage
void DisplayTask(void * parameter) {
// Attendre que le système soit prêt
vTaskDelay(pdMS_TO_TICKS(1000));
Serial.println("Display task started on core " + String(xPortGetCoreID()));
int prevSeconds = -1, prevMinutes = -1, prevHours = -1, prevDays = -1;
bool prevExpired = false;
for(;;) {
// Utiliser MUTEX_GUARD avec timeout optimisé pour l'affichage
{
MUTEX_GUARD(displayMutex, MUTEX_TIMEOUT_FAST);
if (guard_displayMutex.isLocked()) {
int days=0, hours=0, minutes=0, seconds=0;
bool expired = false;
// Utiliser la fonction helper optimisée
if (safeTakeCountdownData(days, hours, minutes, seconds, expired)) {
bool secondChanged = (seconds != prevSeconds) || (minutes != prevMinutes) || (hours != prevHours) || (days != prevDays);
bool needDraw = secondChanged || blinkLastSeconds || expired != prevExpired || marqueeActive;
if (needDraw) {
displayFullscreenCountdown(days, hours, minutes, seconds);
prevSeconds = seconds; prevMinutes = minutes; prevHours = hours; prevDays = days; prevExpired = expired;
}
}
}
}
// Fréquence un peu plus élevée si marquee actif pour fluidité, sinon économe
uint32_t baseDelay = marqueeActive ? 40 : 150;
if (blinkLastSeconds && baseDelay > 50) baseDelay = 50;
vTaskDelay(pdMS_TO_TICKS(baseDelay));
}
}
// Tâche combinée réseau + web
void NetWebTask(void * parameter) {
vTaskDelay(pdMS_TO_TICKS(1200));
Serial.println("NetWeb task started on core " + String(xPortGetCoreID()));
uint32_t lastReconnectCheck = 0;
for(;;) {
dnsServer.processNextRequest();
server.handleClient();
if (useStationMode) {
uint32_t now = millis();
if (WiFi.status() != WL_CONNECTED && now - lastReconnectCheck > 5000) {
lastReconnectCheck = now;
Serial.println("[NetWeb] WiFi lost -> reconnect");
WiFi.reconnect();
}
}
vTaskDelay(pdMS_TO_TICKS(5));
}
}
// Tâche de gestion du compte à rebours
void CountdownTask(void * parameter) {
// Attendre que le RTC soit prêt
vTaskDelay(pdMS_TO_TICKS(1500));
Serial.println("Countdown task started on core " + String(xPortGetCoreID()));
for(;;) {
// Vérifier s'il faut sauvegarder les paramètres (avec délai de sécurité)
if (saveRequested && (millis() - saveRequestTime) > 1500) { // Débounce 1.5s après dernière modif
saveRequested = false;
// Vérifications de sécurité avant sauvegarde
if (xTaskGetSchedulerState() == taskSCHEDULER_RUNNING && !xPortInIsrContext()) {
// Attendre un peu plus pour s'assurer que le contexte web est terminé
vTaskDelay(pdMS_TO_TICKS(100));
saveSettings();
Serial.println("Settings saved from CountdownTask");
} else {
Serial.println("Unsafe context - delaying save");
saveRequested = true; // Re-programmer la sauvegarde
saveRequestTime = millis() + 500; // Dans 500ms
}
}
// Utiliser MUTEX_GUARD avec timeout optimisé
{
MUTEX_GUARD(countdownMutex, MUTEX_TIMEOUT_NORMAL);
if (guard_countdownMutex.isLocked()) {
DateTime now = rtc.now();
if (now.isValid()) { // Vérification de la validité de la date/heure
if (now >= countdownTarget) {
countdownExpired = true;
}
} else {
Serial.println("RTC read error!");
}
}
}
vTaskDelay(pdMS_TO_TICKS(100));
}
}
// Ancienne NetworkTask fusionnée dans NetWebTask
void loop() {
// Surveillance du système et pause pour éviter les problèmes de watchdog
systemWatchdog();
// Les tâches FreeRTOS gèrent tout le travail
vTaskDelay(pdMS_TO_TICKS(1000));
}