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

