Projet Stargate

Cette page reprend la partie électronique et les modifications que j’ai apportées au projet porte des étoiles, de Gatebuilder trouvé sur le site Makerworld, trouvé à l’adresse suivante : Modèle de Stargate SG-1 Deluxe

L’idée de base

En se baladant sur Makerworld avec les copains du discord d’Egalistel – Les impressions d’Ega, nous sommes tombés sur ce projet qui nous a semblé un peu fou au début par sa taille.
Puis chemin faisant on s’est dit pourquoi pas, mais qui se lance ?

Ce fut moi. Le créateur de la porte ne souhaitant pas fournir la partie électronique de son montage, il a fallu réfléchir à ce que l’on voulait.

Petit cahier des charges

La porte dans la série comporte 9 segments dont 7 sont allumés en fonction de la destination. Les fichiers mis à disposition en ont 8 avec 3 emplacements pour les leds et un « borgne » ou se cache la partie électronique. Il faut donc modifier ce dernier pour qu’il puisse aussi recevoir des leds.
Au niveau fonctionnement, lors de la mise sous tension, tous les chevrons s’allume ainsi que le bandeau LED. c’est le mode déco.

Aprés un appuie court sur le bouton, cela lance la séquence de connection de la porte.
Les 7 groupes de 3 leds (représentant un chevron) s’allume de manière aléatoire avec une animation de rotation de la porte avant chaque allumage de chevron.

Une fois les 7 chevrons allumés, un bandeau led représentant l’Horizon des évènements doit s’allumer avec pourquoi pas un scintillement.

Évidement, il nous faut le son des chevrons qui se verrouille et celui de l’ouverture de l’horizon des événements.

Après deux appuie court sur le bouton seuls les chevrons s’allument

Après trois appuie court sur le bouton seul le bandeau Led s’allume

Un appui de 2s sur le bouton remet la porte en état initial.

Liste des composants choisis

  • Un ESP32-C3 super mini : Lien
  • Un Mini lecteur MP3 : Lien
  • Une carte micro SD (pas besoin d’une grosse capacité) : Lien
  • Un haut-parleur 1W 8Ω 30mm : Lien
  • Un bouton pour relancer la séquence : Lien
  • 1 résistance 1kΩ et 2 résistances 330Ω : Lien
  • 1 condensateur 100µF 16 V sur VCC du DFPlayer (évite les pops audio) et 1 condensateur 1000µF 16V sur la ligne 5V principale (stabilise lors des pics d’allumage LED) : Lien
  • 1 interrupteur 3 broches 6mm pour couper l’alimentation : Lien
  • Une alimentation 5V 3A : Lien
  • Une prise femelle alimentation : Lien
  • Un bandeau LED WS2812B 5V IP30 60 Leds/m : Lien
  • 3,5 kg de PLA couleur sombre au choix (gris métal dans mon cas)
  • 50g de filament rouge transparent pour les chevrons
  • 75g de filament blanc pour le diffuseur du bandeau Led de l’anneau

Le schéma de montage :

Le programme

/*
 * =====================================================
 *  PORTE DES ÉTOILES — Stargate SG-1
 *  ESP32-C3 SuperMini — Version 14.1
 * =====================================================
 *  BROCHAGE :
 *  GPIO 2  → Data LED Chevrons  (WS2812B, 27 LEDs)
 *  GPIO 3  → Data LED Bandeau   (WS2812B, variable)
 *  GPIO 4  → Bouton poussoir (INPUT_PULLUP, actif LOW)
 *  GPIO 5  → DFPlayer Mini RX  (sortie TX de l'ESP)
 *  GPIO 6  → DFPlayer Mini TX  (entrée RX de l'ESP)
 *
 *  CARTE MICROSD :
 *    /01/001.mp3  → son chevron
 *    /01/002.mp3  → son bandeau
 *
 *  MODES :
 *    Démarrage      → Veille active (9 chevrons + bandeau scintillant)
 *    Appui court    → Lance la séquence
 *    2 appuis rap.  → Test bandeau seul
 *    3 appuis rap.  → Test chevrons seuls
 *    Appui long     → Retour veille active (depuis n'importe quel état)
 *
 *  OTA : http://<ip>/update — upload du fichier .bin
 *  Pour générer le .bin : Croquis → Exporter les binaires compilées
 *
 *  Bibliothèques : Adafruit NeoPixel + DFRobotDFPlayerMini
 * =====================================================
 */

#include <Arduino.h>
#include <Adafruit_NeoPixel.h>
#include <WiFi.h>
#include <esp_wifi.h>
#include <WebServer.h>
#include <ElegantOTA.h>
#include <Preferences.h>
#include <HardwareSerial.h>
#include <DFRobotDFPlayerMini.h>
#include <esp_task_wdt.h>

// ─── BROCHES ────────────────────────────────────────
#define PIN_CHEVRONS     2
#define PIN_BANDEAU      3
#define PIN_BUTTON       4
#define PIN_DFPLAYER_TX  5
#define PIN_DFPLAYER_RX  6

// ─── LED ────────────────────────────────────────────
#define NUM_CHEVRON_LEDS  27
#define MAX_BANDEAU_LEDS  300

Adafruit_NeoPixel stripChevrons(NUM_CHEVRON_LEDS, PIN_CHEVRONS, NEO_GRB + NEO_KHZ800);
Adafruit_NeoPixel stripBandeau(MAX_BANDEAU_LEDS,  PIN_BANDEAU,  NEO_GRB + NEO_KHZ800);

// ─── OBJETS ─────────────────────────────────────────
WebServer            server(80);
Preferences          prefs;
HardwareSerial       dfSerial(1);
DFRobotDFPlayerMini  dfPlayer;
bool                 dfPlayerOK = false;

// ─── PARAMÈTRES ─────────────────────────────────────
struct Config {
  uint16_t chevronDelay;
  uint16_t chevronSoundMs;
  uint16_t chevronPauseMs;
  uint16_t bandeauPauseMs;
  uint8_t  chevronR, chevronG, chevronB;
  uint8_t  chevronBright;
  uint16_t bandeauNumLeds;
  uint16_t bandeauSoundMs;
  uint8_t  bandeauR, bandeauG, bandeauB;
  uint8_t  bandeauBright;
  bool     bandeauScintillement;
  uint8_t  volume;
  char     ssid[64];
  char     password[64];
} cfg;

// ─── ÉTAT ───────────────────────────────────────────
enum State { STANDBY, SEQUENCING, ACTIVE, TEST_BANDEAU, TEST_CHEVRONS };
State    state             = STANDBY;
bool     bandeauOn         = false;
bool     sequencePending   = false;
bool     standbyPending    = false;
bool     testBandeauPending  = false;
bool     testChevronsPending = false;
bool otaInProgress = false;
uint32_t lastSequenceTime  = 0;
#define  MIN_RETRIGGER_MS  5000

float    shimmerPhase[MAX_BANDEAU_LEDS];
uint32_t lastShimmerTime   = 0;

// Bouton
bool     lastButtonState   = HIGH;
uint32_t lastDebounceTime  = 0;
uint32_t buttonPressTime   = 0;
bool     buttonHeld        = false;
bool     btnArmed          = true;
bool     wifiNeedsReconnect = false;
// Multi-tap
uint8_t  tapCount          = 0;
uint32_t lastTapTime       = 0;
#define  MULTI_TAP_WINDOW  600   // ms entre deux taps
#define  DEBOUNCE_DELAY    50
#define  LONG_PRESS_MS     2000

#define AP_SSID "Stargate-Config"
#define AP_PASS "stargate1969"

// ====================================================
//  ATTENTE WIFI-SAFE
// ====================================================
void waitMs(uint32_t ms) {
  if (ms == 0) return;
  uint32_t t0 = millis();
  while (millis() - t0 < ms) {
    server.handleClient();
    delay(1);
  }
}

// ====================================================
//  CONFIG
// ====================================================
void loadConfig() {
  prefs.begin("stargate", false);
  cfg.chevronDelay         = prefs.getUShort("chvDelay",    500);
  cfg.chevronSoundMs       = prefs.getUShort("chvSndMs",   2000);
  cfg.chevronPauseMs       = prefs.getUShort("chvPauseMs", 1000);
  cfg.bandeauPauseMs       = prefs.getUShort("bndPauseMs", 2000);
  cfg.chevronR             = prefs.getUChar("chvR",         255);
  cfg.chevronG             = prefs.getUChar("chvG",         255);
  cfg.chevronB             = prefs.getUChar("chvB",         255);
  cfg.chevronBright        = prefs.getUChar("chvBright",    150);
  cfg.bandeauNumLeds       = prefs.getUShort("bndNum",       60);
  cfg.bandeauSoundMs       = prefs.getUShort("bndSndMs",   6000);
  cfg.bandeauR             = prefs.getUChar("bndR",           0);
  cfg.bandeauG             = prefs.getUChar("bndG",         180);
  cfg.bandeauB             = prefs.getUChar("bndB",         255);
  cfg.bandeauBright        = prefs.getUChar("bndBright",    180);
  cfg.bandeauScintillement = prefs.getBool("bndShimmer",   true);
  cfg.volume               = prefs.getUChar("volume",        20);
  String ssid = prefs.getString("ssid", "");
  String pass = prefs.getString("password", "");
  ssid.toCharArray(cfg.ssid, 64);
  pass.toCharArray(cfg.password, 64);
  prefs.end();
}

void saveConfig() {
  prefs.begin("stargate", false);
  prefs.putUShort("chvDelay",   cfg.chevronDelay);
  prefs.putUShort("chvSndMs",   cfg.chevronSoundMs);
  prefs.putUShort("chvPauseMs", cfg.chevronPauseMs);
  prefs.putUShort("bndPauseMs", cfg.bandeauPauseMs);
  prefs.putUChar("chvR",        cfg.chevronR);
  prefs.putUChar("chvG",        cfg.chevronG);
  prefs.putUChar("chvB",        cfg.chevronB);
  prefs.putUChar("chvBright",   cfg.chevronBright);
  prefs.putUShort("bndNum",     cfg.bandeauNumLeds);
  prefs.putUShort("bndSndMs",   cfg.bandeauSoundMs);
  prefs.putUChar("bndR",        cfg.bandeauR);
  prefs.putUChar("bndG",        cfg.bandeauG);
  prefs.putUChar("bndB",        cfg.bandeauB);
  prefs.putUChar("bndBright",   cfg.bandeauBright);
  prefs.putBool("bndShimmer",   cfg.bandeauScintillement);
  prefs.putUChar("volume",      cfg.volume);
  prefs.putString("ssid",       cfg.ssid);
  prefs.putString("password",   cfg.password);
  prefs.end();
}

// ====================================================
//  BOUTON — retourne 0=rien, 1=court, 2=double, 3=triple, 4=long
// ====================================================
uint8_t readButton() {
  bool reading = digitalRead(PIN_BUTTON);

  if (reading != lastButtonState) {
    lastDebounceTime = millis();
    lastButtonState  = reading;
  }

  if ((millis() - lastDebounceTime) > DEBOUNCE_DELAY) {
    // Appui long
    if (reading == LOW && btnArmed) {
      btnArmed        = false;
      buttonHeld      = true;
      buttonPressTime = millis();
    }
    if (reading == LOW && buttonHeld) {
      if (millis() - buttonPressTime >= LONG_PRESS_MS) {
        buttonHeld = false;
        tapCount   = 0;
        return 4; // appui long
      }
    }
    // Relâchement → comptage tap
    if (reading == HIGH) {
      btnArmed = true;
      if (buttonHeld) {
        buttonHeld = false;
        tapCount++;
        lastTapTime = millis();
      }
    }
  }

  // Fin de fenêtre multi-tap : on tranche
  if (tapCount > 0 && (millis() - lastTapTime) > MULTI_TAP_WINDOW) {
    uint8_t count = tapCount;
    tapCount = 0;
    if (count == 1) return 1;
    if (count == 2) return 2;
    if (count >= 3) return 3;
  }

  return 0;
}

// ====================================================
//  LED HELPERS
// ====================================================
void allChevronsOff() { stripChevrons.clear(); stripChevrons.show(); }
void allBandeauOff()  { stripBandeau.clear();  stripBandeau.show();  }

void lightChevron(uint8_t idx, bool on) {
  for (int i = 0; i < 3; i++) {
    int n = idx * 3 + i;
    if (on) {
      stripChevrons.setPixelColor(n,
        map(cfg.chevronR, 0,255, 0,cfg.chevronBright),
        map(cfg.chevronG, 0,255, 0,cfg.chevronBright),
        map(cfg.chevronB, 0,255, 0,cfg.chevronBright));
    } else {
      stripChevrons.setPixelColor(n, 0);
    }
  }
  stripChevrons.show();
}

void allChevronsOn() {
  for (int c = 0; c < 9; c++) lightChevron(c, true);
}

void setBandeauSolid() {
  uint16_t n = constrain(cfg.bandeauNumLeds, 1, MAX_BANDEAU_LEDS);
  for (uint16_t i = 0; i < n; i++) {
    stripBandeau.setPixelColor(i,
      map(cfg.bandeauR, 0,255, 0,cfg.bandeauBright),
      map(cfg.bandeauG, 0,255, 0,cfg.bandeauBright),
      map(cfg.bandeauB, 0,255, 0,cfg.bandeauBright));
  }
  stripBandeau.show();
}

// ====================================================
//  SCINTILLEMENT
// ====================================================
void initShimmer() {
  for (int i = 0; i < MAX_BANDEAU_LEDS; i++)
    shimmerPhase[i] = random(0, 628) / 100.0f;
}

void updateShimmer() {
  if (!bandeauOn) return;
  uint32_t now = millis();
  if (now - lastShimmerTime < 33) return;
  lastShimmerTime = now;
  float t = now / 1000.0f;
  uint16_t n = constrain(cfg.bandeauNumLeds, 1, MAX_BANDEAU_LEDS);
  for (uint16_t i = 0; i < n; i++) {
    float w = constrain(
      sin(t*0.8f + shimmerPhase[i])*0.4f +
      sin(t*1.7f + shimmerPhase[i]*1.3f)*0.3f + 0.3f,
      0.0f, 1.0f);
    stripBandeau.setPixelColor(i,
      (uint8_t)(map(cfg.bandeauR,0,255,0,cfg.bandeauBright)*w),
      (uint8_t)(map(cfg.bandeauG,0,255,0,cfg.bandeauBright)*w),
      (uint8_t)(map(cfg.bandeauB,0,255,0,cfg.bandeauBright)*w));
  }
  stripBandeau.show();
}

// ====================================================
//  VEILLE ACTIVE — 9 chevrons + bandeau scintillant
// ====================================================
void enterStandby() {
  allChevronsOn();
  bandeauOn = true;
  initShimmer();
  btnArmed        = false;
  buttonHeld      = false;
  tapCount        = 0;
  sequencePending = false;
  standbyPending  = false;
  testBandeauPending  = false;
  testChevronsPending = false;
  state           = STANDBY;
  Serial.println("=== VEILLE ACTIVE ===");
}

// ====================================================
//  MODES TEST
// ====================================================
void enterTestBandeau() {
  allChevronsOff();
  bandeauOn = true;
  if (cfg.bandeauScintillement) { initShimmer(); } else { setBandeauSolid(); }
  state = TEST_BANDEAU;
  Serial.println("=== TEST BANDEAU ===");
}

void enterTestChevrons() {
  allBandeauOff();
  bandeauOn = false;
  allChevronsOn();
  state = TEST_CHEVRONS;
  Serial.println("=== TEST CHEVRONS ===");
}

// ====================================================
//  ROTATION CHEVRON
// ====================================================
void spinToChevron(uint8_t targetIdx, bool alreadyLit[9]) {
  const uint8_t  SPIN_BRIGHT = 80;
  const uint16_t STEP_MS     = 80;
  int totalSteps = 9 + (int)targetIdx + 1;

  for (int step = 0; step < totalSteps; step++) {
    int cur = step % 9;
    for (int c = 0; c < 9; c++) {
      if (alreadyLit[c]) {
        for (int p = 0; p < 3; p++)
          stripChevrons.setPixelColor(c*3+p,
            map(cfg.chevronR,0,255,0,cfg.chevronBright),
            map(cfg.chevronG,0,255,0,cfg.chevronBright),
            map(cfg.chevronB,0,255,0,cfg.chevronBright));
      } else if (c == cur) {
        for (int p = 0; p < 3; p++)
          stripChevrons.setPixelColor(c*3+p, SPIN_BRIGHT, SPIN_BRIGHT, SPIN_BRIGHT);
      } else {
        for (int p = 0; p < 3; p++)
          stripChevrons.setPixelColor(c*3+p, 0);
      }
    }
    stripChevrons.show();
    delay(STEP_MS);
  }
  for (int p = 0; p < 3; p++)
    stripChevrons.setPixelColor(targetIdx*3+p,
      map(cfg.chevronR,0,255,0,cfg.chevronBright),
      map(cfg.chevronG,0,255,0,cfg.chevronBright),
      map(cfg.chevronB,0,255,0,cfg.chevronBright));
  stripChevrons.show();
}

// ====================================================
//  SON
// ====================================================
void playSound(uint8_t track) {
  if (!dfPlayerOK) return;
  dfPlayer.volume(cfg.volume);
  dfPlayer.playFolder(1, track);
}

// ====================================================
//  SÉQUENCE
// ====================================================
void generateChevronOrder(int order[7]) {
  uint8_t pool[9] = {0,1,2,3,4,5,6,7,8};
  for (int i = 8; i > 0; i--) {
    int j = random(0, i+1);
    uint8_t tmp = pool[i]; pool[i] = pool[j]; pool[j] = tmp;
  }
  for (int i = 0; i < 7; i++) order[i] = pool[i];
}

void runSequenceBlocking() {
  allChevronsOff();
  allBandeauOff();
  bandeauOn       = false;
  btnArmed        = false;
  buttonHeld      = false;
  state           = SEQUENCING;
  lastSequenceTime = millis();

  int order[7];
  generateChevronOrder(order);
  bool litChevrons[9] = {false};

  Serial.println("=== SEQUENCE DEMARREE ===");
  Serial.print("Ordre : ");
  for (int i = 0; i < 7; i++) { Serial.print(order[i]+1); Serial.print(" "); }
  Serial.println();

  for (int i = 0; i < 7; i++) {
    waitMs(cfg.chevronDelay);
    playSound(1);
    Serial.print("Son chevron "); Serial.print(i+1); Serial.println("/7");

    spinToChevron(order[i], litChevrons);
    litChevrons[order[i]] = true;
    Serial.print("Chevron "); Serial.print(order[i]+1); Serial.println(" VERROUILLE");

    uint32_t spinDuration = (uint32_t)(9 + order[i] + 1) * 80UL;
    if ((uint32_t)cfg.chevronSoundMs > spinDuration)
      waitMs(cfg.chevronSoundMs - spinDuration);

    if (i < 6) waitMs(cfg.chevronPauseMs);
    else        waitMs(cfg.bandeauPauseMs);
  }

  playSound(2);
  Serial.println(">>> SON WORMHOLE <<<");
  waitMs(200);

  bandeauOn = true;
  if (cfg.bandeauScintillement) { initShimmer(); } else { setBandeauSolid(); }
  Serial.println(">>> WORMHOLE ETABLI <<<");

  if (cfg.bandeauSoundMs > 200) waitMs(cfg.bandeauSoundMs - 200);

  Serial.println(">>> ACTIF <<<");
  btnArmed = true;
  state    = ACTIVE;
}

// ====================================================
//  INTERFACE WEB
// ====================================================
String colorHex(uint8_t r, uint8_t g, uint8_t b) {
  char buf[8];
  snprintf(buf, sizeof(buf), "#%02X%02X%02X", r, g, b);
  return String(buf);
}

void handleRoot() {
  String html = R"rawhtml(<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Stargate — Configuration</title>
<style>
  @import url('https://fonts.googleapis.com/css2?family=Orbitron:wght@400;700;900&family=Share+Tech+Mono&display=swap');
  :root{--bg:#050a0f;--panel:#0a1520;--border:#1a4060;--accent:#00aaff;--text:#c0d8f0;--dim:#4a6a80;}
  *{box-sizing:border-box;margin:0;padding:0;}
  body{background:var(--bg);color:var(--text);font-family:'Share Tech Mono',monospace;min-height:100vh;background-image:radial-gradient(ellipse at 50% 0%,rgba(0,100,200,.15) 0%,transparent 70%),repeating-linear-gradient(0deg,transparent,transparent 40px,rgba(0,80,160,.05) 40px,rgba(0,80,160,.05) 41px),repeating-linear-gradient(90deg,transparent,transparent 40px,rgba(0,80,160,.05) 40px,rgba(0,80,160,.05) 41px);}
  header{text-align:center;padding:2rem 1rem 1rem;border-bottom:1px solid var(--border);}
  header h1{font-family:'Orbitron',sans-serif;font-weight:900;font-size:clamp(1.4rem,4vw,2.2rem);letter-spacing:.2em;color:var(--accent);text-shadow:0 0 20px rgba(0,170,255,.6);}
  header p{color:var(--dim);font-size:.8rem;margin-top:.4rem;letter-spacing:.1em;}
  .container{max-width:700px;margin:0 auto;padding:2rem 1rem;display:grid;gap:1.5rem;}
  .card{background:var(--panel);border:1px solid var(--border);border-radius:4px;padding:1.5rem;position:relative;}
  .card::before{content:'';position:absolute;top:0;left:0;right:0;height:2px;background:linear-gradient(90deg,transparent,var(--accent),transparent);}
  .card h2{font-family:'Orbitron',sans-serif;font-size:.75rem;letter-spacing:.25em;color:var(--accent);margin-bottom:1.2rem;text-transform:uppercase;}
  .field{margin-bottom:1rem;}
  .field label{display:block;font-size:.75rem;letter-spacing:.1em;color:var(--dim);margin-bottom:.4rem;text-transform:uppercase;}
  .field input[type=number],.field input[type=text],.field input[type=password]{width:100%;background:#030810;border:1px solid var(--border);border-radius:3px;color:var(--text);font-family:'Share Tech Mono',monospace;font-size:.95rem;padding:.5rem .75rem;outline:none;transition:border-color .2s;}
  .field input:focus{border-color:var(--accent);}
  .field input[type=range]{width:100%;padding:.3rem 0;cursor:pointer;accent-color:var(--accent);}
  .field input[type=color]{width:100%;height:2.5rem;border:1px solid var(--border);border-radius:3px;background:none;cursor:pointer;padding:2px;}
  .row2{display:grid;grid-template-columns:1fr 1fr;gap:1rem;}
  .row3{display:grid;grid-template-columns:1fr 1fr 1fr;gap:1rem;}
  .hint{font-size:.7rem;color:var(--dim);margin-top:.3rem;}
  .sep{border:none;border-top:1px solid var(--border);margin:.8rem 0;}
  .toggle-row{display:flex;align-items:center;gap:1rem;}
  .toggle-row label{margin:0;flex:1;}
  .toggle{position:relative;width:48px;height:24px;flex-shrink:0;}
  .toggle input{opacity:0;width:0;height:0;}
  .slider-tog{position:absolute;inset:0;background:#0a1520;border:1px solid var(--border);border-radius:24px;cursor:pointer;transition:.3s;}
  .slider-tog::before{content:'';position:absolute;height:16px;width:16px;left:3px;bottom:3px;background:var(--dim);border-radius:50%;transition:.3s;}
  input:checked+.slider-tog{border-color:var(--accent);}
  input:checked+.slider-tog::before{transform:translateX(24px);background:var(--accent);}
  .btn-group{display:flex;gap:1rem;margin-top:.5rem;flex-wrap:wrap;}
  button{font-family:'Orbitron',sans-serif;font-size:.7rem;letter-spacing:.15em;padding:.75rem 1.5rem;border:1px solid var(--accent);background:transparent;color:var(--accent);border-radius:3px;cursor:pointer;text-transform:uppercase;transition:all .2s;flex:1;min-width:140px;}
  button:hover{background:rgba(0,170,255,.1);}
  button.standby{border-color:#aaaaff;color:#aaaaff;}
  button.standby:hover{background:rgba(170,170,255,.1);}
  button.test{border-color:#00ffcc;color:#00ffcc;}
  button.test:hover{background:rgba(0,255,200,.1);}
  .status{font-size:.75rem;padding:.5rem .75rem;border-radius:3px;margin-top:1rem;display:none;}
  .status.ok{background:rgba(0,255,100,.1);border:1px solid #00aa44;color:#00ff66;display:block;}
  .status.err{background:rgba(255,60,60,.1);border:1px solid #aa2222;color:#ff4444;display:block;}
  .info-box{background:rgba(0,100,200,.06);border:1px solid var(--border);border-radius:4px;padding:.75rem 1rem;font-size:.75rem;color:var(--dim);margin-top:.8rem;line-height:1.7;}
  footer{text-align:center;padding:2rem;color:var(--dim);font-size:.7rem;letter-spacing:.1em;}
</style>
</head>
<body>
<header>
  <h1>✦ STARGATE SG-1 ✦</h1>
  <p>SYSTÈME DE CONFIGURATION — PORTE DES ÉTOILES — v14.1</p>
</header>
<div class="container">

  <div class="card">
    <h2>▶ Chevrons — Timing</h2>
    <div class="row3">
      <div class="field">
        <label>Délai avant son (ms)</label>
        <input type="number" id="chevronDelay" min="0" max="5000" step="100" value=")rawhtml";
  html += String(cfg.chevronDelay);
  html += R"rawhtml(">
        <p class="hint">Silence avant 001.mp3</p>
      </div>
      <div class="field">
        <label>Durée 001.mp3 (ms)</label>
        <input type="number" id="chevronSoundMs" min="100" max="10000" step="100" value=")rawhtml";
  html += String(cfg.chevronSoundMs);
  html += R"rawhtml(">
        <p class="hint">Durée réelle du fichier</p>
      </div>
      <div class="field">
        <label>Pause après chevron (ms)</label>
        <input type="number" id="chevronPauseMs" min="0" max="5000" step="100" value=")rawhtml";
  html += String(cfg.chevronPauseMs);
  html += R"rawhtml(">
        <p class="hint">Entre les chevrons 1-6</p>
      </div>
    </div>
    <hr class="sep">
    <h2>▶ Chevrons — Apparence</h2>
    <div class="row2">
      <div class="field">
        <label>Couleur</label>
        <input type="color" id="chevronColor" value=")rawhtml";
  html += colorHex(cfg.chevronR, cfg.chevronG, cfg.chevronB);
  html += R"rawhtml(">
      </div>
      <div class="field">
        <label>Intensité — <span id="chvBrightVal">)rawhtml";
  html += String(cfg.chevronBright);
  html += R"rawhtml(</span></label>
        <input type="range" id="chevronBright" min="10" max="255" value=")rawhtml";
  html += String(cfg.chevronBright);
  html += R"rawhtml(" oninput="document.getElementById('chvBrightVal').textContent=this.value">
      </div>
    </div>
  </div>

  <div class="card">
    <h2>▶ Bandeau — Timing</h2>
    <div class="row2">
      <div class="field">
        <label>Pause avant bandeau (ms)</label>
        <input type="number" id="bandeauPauseMs" min="0" max="10000" step="100" value=")rawhtml";
  html += String(cfg.bandeauPauseMs);
  html += R"rawhtml(">
        <p class="hint">Après le 7ème chevron</p>
      </div>
      <div class="field">
        <label>Durée 002.mp3 (ms)</label>
        <input type="number" id="bandeauSoundMs" min="100" max="30000" step="100" value=")rawhtml";
  html += String(cfg.bandeauSoundMs);
  html += R"rawhtml(">
        <p class="hint">Durée réelle du fichier</p>
      </div>
    </div>
    <hr class="sep">
    <h2>▶ Bandeau — Apparence</h2>
    <div class="field">
      <label>Nombre de LEDs</label>
      <input type="number" id="bandeauNumLeds" min="1" max="300" value=")rawhtml";
  html += String(cfg.bandeauNumLeds);
  html += R"rawhtml(">
    </div>
    <div class="row2">
      <div class="field">
        <label>Couleur</label>
        <input type="color" id="bandeauColor" value=")rawhtml";
  html += colorHex(cfg.bandeauR, cfg.bandeauG, cfg.bandeauB);
  html += R"rawhtml(">
      </div>
      <div class="field">
        <label>Intensité — <span id="bndBrightVal">)rawhtml";
  html += String(cfg.bandeauBright);
  html += R"rawhtml(</span></label>
        <input type="range" id="bandeauBright" min="10" max="255" value=")rawhtml";
  html += String(cfg.bandeauBright);
  html += R"rawhtml(" oninput="document.getElementById('bndBrightVal').textContent=this.value">
      </div>
    </div>
    <div class="field toggle-row">
      <label>Scintillement (horizon des origines)</label>
      <label class="toggle">
        <input type="checkbox" id="bandeauShimmer" )rawhtml";
  html += cfg.bandeauScintillement ? "checked" : "";
  html += R"rawhtml(>
        <span class="slider-tog"></span>
      </label>
    </div>
  </div>

  <div class="card">
    <h2>▶ Son</h2>
    <div class="field">
      <label>Volume DFPlayer — <span id="volVal">)rawhtml";
  html += String(cfg.volume);
  html += R"rawhtml(</span> / 30</label>
      <input type="range" id="volume" min="0" max="30" value=")rawhtml";
  html += String(cfg.volume);
  html += R"rawhtml(" oninput="document.getElementById('volVal').textContent=this.value">
    </div>
    <p class="hint">SD : /01/001.mp3 → chevron &nbsp;|&nbsp; /01/002.mp3 → bandeau</p>
  </div>

  <div class="card">
    <h2>▶ WiFi Domestique</h2>
    <div class="field">
      <label>SSID</label>
      <input type="text" id="wifiSsid" value=")rawhtml";
  html += String(cfg.ssid);
  html += R"rawhtml(">
    </div>
    <div class="field">
      <label>Mot de passe</label>
      <input type="password" id="wifiPass" placeholder="••••••••">
    </div>
  </div>

  <div class="card">
    <h2>▶ Actions</h2>
    <div class="btn-group">
      <button onclick="saveConfig()">💾 Sauvegarder</button>
      <button onclick="triggerSequence()">▶ Séquence</button>
      <button class="standby" onclick="triggerStandby()">⏸ Tout allumer</button>
    </div>
    <div class="btn-group" style="margin-top:.75rem;">
      <button class="test" onclick="triggerTestBandeau()">🔵 Bandeau seul</button>
      <button class="test" onclick="triggerTestChevrons()">⭐ Chevrons seuls</button>
    </div>
    <div id="status" class="status"></div>
    <div class="info-box">
      🎨 Couleurs de référence :<br>
      — Chevrons : R:)rawhtml";
  html += String(cfg.chevronR);
  html += R"rawhtml( G:)rawhtml";
  html += String(cfg.chevronG);
  html += R"rawhtml( B:)rawhtml";
  html += String(cfg.chevronB);
  html += R"rawhtml( — Intensité : )rawhtml";
  html += String(cfg.chevronBright);
  html += R"rawhtml(<br>
      — Bandeau &nbsp;: R:)rawhtml";
  html += String(cfg.bandeauR);
  html += R"rawhtml( G:)rawhtml";
  html += String(cfg.bandeauG);
  html += R"rawhtml( B:)rawhtml";
  html += String(cfg.bandeauB);
  html += R"rawhtml( — Intensité : )rawhtml";
  html += String(cfg.bandeauBright);
  html += R"rawhtml(
    </div>
    <div class="info-box" style="margin-top:.5rem;">
      🕹 Bouton physique :<br>
      — 1 appui court : lancer la séquence<br>
      — 2 appuis rapides : bandeau seul<br>
      — 3 appuis rapides : chevrons seuls<br>
      — Appui long (2s) : tout allumer
    </div>
  </div>

   <div class="card">
    <h2>▶ Mise à jour firmware OTA</h2>
    <div class="info-box">
      Interface OTA disponible sur :<br>
      <a href="/update" style="color:#00aaff">http://[IP-locale]/update</a><br>
      Login : admin &nbsp;|&nbsp; Mot de passe : stargate<br><br>
      Générer le .bin : Arduino IDE → Croquis → Exporter les binaires compilées
    </div>
  </div>

</div>
<footer>STARGATE SG-1 PROP CONTROLLER — ESP32-C3 — v14.1<BR> Lolonene (2026)</BR></footer>
<script>
function hexToRgb(h){return{r:parseInt(h.slice(1,3),16),g:parseInt(h.slice(3,5),16),b:parseInt(h.slice(5,7),16)};}
function showStatus(msg,ok){const el=document.getElementById('status');el.textContent=msg;el.className='status '+(ok?'ok':'err');setTimeout(()=>el.className='status',5000);}
async function fetchT(url,ms=60000){const c=new AbortController();const id=setTimeout(()=>c.abort(),ms);try{const r=await fetch(url,{signal:c.signal});clearTimeout(id);return r;}catch(e){clearTimeout(id);throw e;}}
async function saveConfig(){
  const c=hexToRgb(document.getElementById('chevronColor').value);
  const b=hexToRgb(document.getElementById('bandeauColor').value);
  const p=new URLSearchParams({
    chevronDelay:document.getElementById('chevronDelay').value,
    chevronSoundMs:document.getElementById('chevronSoundMs').value,
    chevronPauseMs:document.getElementById('chevronPauseMs').value,
    bandeauPauseMs:document.getElementById('bandeauPauseMs').value,
    chevronR:c.r,chevronG:c.g,chevronB:c.b,
    chevronBright:document.getElementById('chevronBright').value,
    bandeauNumLeds:document.getElementById('bandeauNumLeds').value,
    bandeauSoundMs:document.getElementById('bandeauSoundMs').value,
    bandeauR:b.r,bandeauG:b.g,bandeauB:b.b,
    bandeauBright:document.getElementById('bandeauBright').value,
    bandeauShimmer:document.getElementById('bandeauShimmer').checked?'1':'0',
    volume:document.getElementById('volume').value,
    ssid:document.getElementById('wifiSsid').value,
    password:document.getElementById('wifiPass').value,
  });
  showStatus('⏳ Sauvegarde...',true);
  try{await fetchT('/save?'+p,60000);showStatus('✓ Configuration sauvegardée',true);}
  catch(e){showStatus('✗ '+e,false);}
}
async function triggerSequence(){showStatus('⏳ Séquence en cours...',true);try{await fetchT('/trigger',120000);showStatus('✓ Séquence terminée',true);}catch(e){showStatus('✗ '+e,false);}}
async function triggerStandby(){try{await fetchT('/standby',5000);showStatus('✓ Tout allumé',true);}catch(e){showStatus('✗ '+e,false);}}
async function triggerTestBandeau(){try{await fetchT('/testbandeau',5000);showStatus('✓ Bandeau actif',true);}catch(e){showStatus('✗ '+e,false);}}
async function triggerTestChevrons(){try{await fetchT('/testchevrons',5000);showStatus('✓ Chevrons actifs',true);}catch(e){showStatus('✗ '+e,false);}}
)rawhtml";
  html += "</script>\n</body>\n</html>";
  server.send(200, "text/html", html);
}
// ====================================================
//  HANDLERS WEB
// ====================================================
void handleSave() {
  if (server.hasArg("chevronDelay"))   cfg.chevronDelay         = server.arg("chevronDelay").toInt();
  if (server.hasArg("chevronSoundMs")) cfg.chevronSoundMs       = server.arg("chevronSoundMs").toInt();
  if (server.hasArg("chevronPauseMs")) cfg.chevronPauseMs       = server.arg("chevronPauseMs").toInt();
  if (server.hasArg("bandeauPauseMs")) cfg.bandeauPauseMs       = server.arg("bandeauPauseMs").toInt();
  if (server.hasArg("chevronR"))       cfg.chevronR             = server.arg("chevronR").toInt();
  if (server.hasArg("chevronG"))       cfg.chevronG             = server.arg("chevronG").toInt();
  if (server.hasArg("chevronB"))       cfg.chevronB             = server.arg("chevronB").toInt();
  if (server.hasArg("chevronBright"))  cfg.chevronBright        = server.arg("chevronBright").toInt();
  if (server.hasArg("bandeauNumLeds")) cfg.bandeauNumLeds       = constrain(server.arg("bandeauNumLeds").toInt(),1,MAX_BANDEAU_LEDS);
  if (server.hasArg("bandeauSoundMs")) cfg.bandeauSoundMs       = server.arg("bandeauSoundMs").toInt();
  if (server.hasArg("bandeauR"))       cfg.bandeauR             = server.arg("bandeauR").toInt();
  if (server.hasArg("bandeauG"))       cfg.bandeauG             = server.arg("bandeauG").toInt();
  if (server.hasArg("bandeauB"))       cfg.bandeauB             = server.arg("bandeauB").toInt();
  if (server.hasArg("bandeauBright"))  cfg.bandeauBright        = server.arg("bandeauBright").toInt();
  if (server.hasArg("bandeauShimmer")) cfg.bandeauScintillement = server.arg("bandeauShimmer")=="1";
  if (server.hasArg("volume"))         cfg.volume               = constrain(server.arg("volume").toInt(),0,30);
  if (server.hasArg("ssid"))           server.arg("ssid").toCharArray(cfg.ssid,64);
  if (server.hasArg("password") && server.arg("password").length()>0)
    server.arg("password").toCharArray(cfg.password,64);
  saveConfig();
  stripBandeau.updateLength(cfg.bandeauNumLeds);

  // Appliquer immédiatement les nouvelles couleurs sur les LEDs
  if (state == STANDBY || state == ACTIVE || state == TEST_CHEVRONS) {
    allChevronsOn();
  }
  if (bandeauOn && (state == STANDBY || state == ACTIVE || state == TEST_BANDEAU)) {
    if (cfg.bandeauScintillement) { initShimmer(); } else { setBandeauSolid(); }
  }

  server.send(200,"text/plain","OK");
  if (strlen(cfg.ssid) > 0) wifiNeedsReconnect = true;
}

void handleTrigger() {
  if (millis() - lastSequenceTime > MIN_RETRIGGER_MS) sequencePending = true;
  server.send(200,"text/plain","OK");
}

void handleStandby() {
  standbyPending = true;
  server.send(200,"text/plain","OK");
}

void handleTestBandeau() {
  testBandeauPending = true;
  server.send(200,"text/plain","OK");
}

void handleTestChevrons() {
  testChevronsPending = true;
  server.send(200,"text/plain","OK");
}



// ====================================================
//  WIFI
// ====================================================
void setupWifi() {
  esp_wifi_set_ps(WIFI_PS_NONE);
  WiFi.mode(WIFI_AP_STA);
  WiFi.softAP(AP_SSID, AP_PASS);
  Serial.print("AP : "); Serial.print(AP_SSID);
  Serial.print("  http://"); Serial.println(WiFi.softAPIP());
  if (strlen(cfg.ssid) > 0) {
    WiFi.disconnect(false);
    delay(1000);
    WiFi.begin(cfg.ssid, cfg.password);
    uint8_t a = 0;
    while (WiFi.status() != WL_CONNECTED && a < 30) { delay(500); a++; }
    if (WiFi.status() == WL_CONNECTED) {
      Serial.print("WiFi OK — IP : "); Serial.println(WiFi.localIP());
    } else {
      Serial.println("WiFi echec — AP seul actif");
    }
  }
  server.on("/",             handleRoot);
  server.on("/save",         handleSave);
  server.on("/trigger",      handleTrigger);
  server.on("/standby",      handleStandby);
  server.on("/testbandeau",  handleTestBandeau);
  server.on("/testchevrons", handleTestChevrons);
  ElegantOTA.begin(&server, "admin", "stargate");
  server.begin();
  Serial.println("Serveur web OK");
}

// ====================================================
//  SETUP
// ====================================================
void setup() {
  Serial.begin(115200);
  delay(500);
  Serial.println("\n=== STARGATE SG-1 — BOOT v14.1 ===");
   // Effacer les credentials WiFi mémorisés automatiquement
  WiFi.mode(WIFI_STA);
  WiFi.disconnect(true);  // true = efface la NVS WiFi interne
  delay(500);
  WiFi.mode(WIFI_OFF);
  delay(200);
  randomSeed(esp_random());
  loadConfig();

  stripChevrons.begin(); stripChevrons.clear(); stripChevrons.show();
  stripBandeau.updateLength(cfg.bandeauNumLeds);
  stripBandeau.begin(); stripBandeau.clear(); stripBandeau.show();
  Serial.println("LEDs OK");

  pinMode(PIN_BUTTON, INPUT_PULLUP);

  dfSerial.begin(9600, SERIAL_8N1, PIN_DFPLAYER_RX, PIN_DFPLAYER_TX);
  delay(500);
  if (dfPlayer.begin(dfSerial)) {
    dfPlayerOK = true;
    dfPlayer.volume(cfg.volume);
    Serial.println("DFPlayer OK");
  } else {
    Serial.println("DFPlayer absent — sans son");
  }

  setupWifi();
  initShimmer();

  // Démarrage en veille active
  enterStandby();
  Serial.println("=== PRET ===");
}

// ====================================================
//  LOOP
// ====================================================
void loop() {
  server.handleClient();
  ElegantOTA.loop();

  // Reconnexion WiFi différée
  if (wifiNeedsReconnect) {
    wifiNeedsReconnect = false;
    delay(300);
    WiFi.disconnect(false);
    delay(500);
    WiFi.begin(cfg.ssid, cfg.password);
    uint8_t a = 0;
    while (WiFi.status() != WL_CONNECTED && a < 40) {
      server.handleClient(); delay(500); a++;
    }
    if (WiFi.status() == WL_CONNECTED) {
      Serial.print("WiFi OK — IP : "); Serial.println(WiFi.localIP());
    } else {
      Serial.println("WiFi echec — AP seul actif");
    }
  }

  // Lecture bouton
  uint8_t btn = readButton();
  if (btn == 1) {
    Serial.println(">>> APPUI COURT — SEQUENCE <<<");
    if (state == STANDBY || state == ACTIVE || state == TEST_BANDEAU || state == TEST_CHEVRONS)
      runSequenceBlocking();
  } else if (btn == 2) {
    Serial.println(">>> DOUBLE TAP — TEST BANDEAU <<<");
    enterTestBandeau();
  } else if (btn == 3) {
    Serial.println(">>> TRIPLE TAP — TEST CHEVRONS <<<");
    enterTestChevrons();
  } else if (btn == 4) {
    Serial.println(">>> APPUI LONG — VEILLE ACTIVE <<<");
    enterStandby();
  }

  // Déclenchements depuis l'interface web
  if (sequencePending) {
    sequencePending = false;
    if (state == STANDBY || state == ACTIVE || state == TEST_BANDEAU || state == TEST_CHEVRONS)
      runSequenceBlocking();
  }
  if (standbyPending) {
    standbyPending = false;
    enterStandby();
  }
  if (testBandeauPending) {
    testBandeauPending = false;
    enterTestBandeau();
  }
  if (testChevronsPending) {
    testChevronsPending = false;
    enterTestChevrons();
  }

  // Scintillement en veille, mode actif et test bandeau
  if ((state == STANDBY || state == ACTIVE || state == TEST_BANDEAU) && bandeauOn && cfg.bandeauScintillement) {
    updateShimmer();
  }
}

Publications similaires