← Retour à la documentation technique

Quai 10 · Patrick

Code source du firmware

Sketch Arduino complet, tel qu'actuellement flashé sur le boîtier.

sonnette_auto_esp32c6/sonnette_auto_esp32c6.ino
/*
 * Ouverture automatique de porte sur détection de sonnette — ESP32-C6
 * ------------------------------------------------------------------
 * - Micro I2S INMP441 : capte le son, FFT, détection de la signature de la sonnette.
 * - Servo (MZ996) qui pousse le bouton déporté : bascule 1,5 s pour ouvrir, puis
 *   revient au repos (bras écarté) → AUCUNE intrusion dans le circuit du bouton.
 *   Le bouton physique reste 100% actif même si l'ESP32 plante ou est débranché.
 * - Heure via WiFi/NTP ; plages horaires réglables par interface web,
 *   avec valeurs EN DUR en secours si pas de réseau.
 *
 * Détection (algorithme validé sur banc de test — cf. test_detection_switch_v2
 * et projet.md) :
 *   - accord de 6 raies : 1400 / 2600 / 2800 / 3297 / 4600 / 4297 Hz
 *   - contraste local (chaque raie ressort du voisinage spectral)
 *   - niveau minimum par bande (plancher de magnitude absolue)
 *   - stabilité temporelle (son tenu, pas un bruit fluctuant)
 *   - quorum : au moins 4 des 6 raies qualifiées
 *   - FFT à fenêtres chevauchées (hop court) pour une détection plus réactive
 *   - persistance ~0.2 s
 *
 * Bibliothèques requises : arduinoFFT (par Enrique Condes), ESP32Servo —
 * installer via le gestionnaire de bibliothèques Arduino.
 *
 * Câblage servo (MZ996) :
 *   Fil JAUNE (signal) -> GPIO18 (PIN_SERVO). Fil ROUGE (+5V) -> alim externe
 *   5V (chargeur USB, PAS la broche 5V de l'ESP32). Fil MARRON/NOIR (GND) ->
 *   GND commun : relié à la fois au GND de l'alim externe ET au GND de l'ESP32
 *   (indispensable pour que le signal PWM ait une référence — sans ce pont,
 *   le servo ne réagit pas du tout, même avec alim et signal branchés).
 *   Attention : ordre des fils différent du MG90S (signal orange) — vérifier
 *   l'étiquette/doc du servo avant de brancher, certains MZ996 génériques
 *   n'utilisent pas l'ordre standard JR/Futaba.
 */

#include <WiFi.h>
#include <WebServer.h>
#include <ESPmDNS.h>
#include <Preferences.h>
#include <time.h>
#include <driver/i2s.h>
#include <arduinoFFT.h>
#include <ESP32Servo.h>

// Identifiants WiFi privés — définis dans secrets.h (non versionné).
// Copier secrets.example.h -> secrets.h et y mettre tes vraies valeurs.
#include "secrets.h"

// ======================= À ADAPTER =======================
// --- WiFi (valeurs dans secrets.h) ---
const char* WIFI_SSID = WIFI_SSID_SECRET;
const char* WIFI_PASS = WIFI_PASS_SECRET;

// --- Nom mDNS : accès via http://<HOSTNAME>.local (ex. http://porte.local) ---
const char* HOSTNAME = "porte";

// --- NTP / fuseau (France : CET/CEST) ---
// 3 serveurs (fallback) : configTzTime() accepte jusqu'à 3 serveurs NTP.
// Un bug connu du core Arduino-ESP32 fait que les 2e/3e serveurs ne sont pas
// toujours pris en compte correctement (cf. arduino-esp32#4964), mais ça ne
// coûte rien d'essayer — l'échec NTP observé sur C6 reste inexpliqué sinon.
const char* NTP_SERVER  = "time.cloudflare.com";
const char* NTP_SERVER2 = "pool.ntp.org";
const char* NTP_SERVER3 = "time.google.com";
const char* TZ_INFO     = "CET-1CEST,M3.5.0,M10.5.0/3";  // Europe/Paris

// --- Broches (ESP32-C6 : GPIO libres, à ajuster à ton câblage) ---
#define I2S_SCK   4    // BCLK  du INMP441
#define I2S_WS    5    // WS / LRCL
#define I2S_SD    6    // DOUT (SD) du INMP441
#define PIN_SERVO 18   // signal PWM du servo MZ996 — fil JAUNE (pas orange comme MG90S).
                        // GPIO10/11 non sortis sur ce module (mini C6, broches dispo :
                        // 0-9, 14, 15, 18, 19, 20) → servo remappé sur GPIO18.
                        // +5V (rouge) sur alim externe (chargeur USB 5V, PAS la broche 5V
                        // de l'ESP32). GND (marron) commun : relier le GND de l'alim externe
                        // au GND de l'ESP32, sinon le PWM n'a pas de référence et le servo
                        // ne réagit pas du tout.
#define PIN_LED    8   // LED d'état + d'ouverture (souvent LED intégrée sur GPIO8 des C6)
#define PIN_BUZZER 20  // buzzer ACTIF BAS (2 fils) : LOW = sonne, HIGH = silence — remappé
                        // sur GPIO20 (GPIO11 non sorti sur ce module). Logique inversée
                        // constatée à l'usage (le buzzer sonnait en continu avec HIGH=repos).

#define LED_ACTIVE_HIGH    true   // mettre false si LED intégrée câblée en logique inversée

// --- Servo : positions et vitesse ---
// Angles inversés (90/0 au lieu de 0/90) : le servo est monté à l'envers sur
// son axe par rapport à ce que le code suppose à l'origine — compensation
// logicielle plutôt que remontage physique du bras.
#define SERVO_ANGLE_REPOS   90
#define SERVO_ANGLE_OUVERT  0
#define SERVO_MS_PAR_DEGRE  6     // vitesse simulée (course douce, ~0.5s pour 90°)

// --- Durée d'ouverture (maintien du bras en position appui) ---
const uint32_t RELAY_HOLD_MS = 1500;   // 1,5 s
// --- Retour ouverture ---
const uint32_t BUZZER_MS   = 150;      // bip court de confirmation
const uint32_t GARDE_APRES_MS = 500;   // détection ignorée après le relâchement du servo.
                                       // Total anti-répétition = RELAY_HOLD_MS (1,5 s, détection
                                       // suspendue) + 500 ms ≈ 2,5 s : couvre une sonnette tenue.

// ======================= DÉTECTION =======================
// Algorithme validé sur banc de test réel (test_detection_switch_v2, ESP32-S3 +
// INMP441) : 6 raies, quorum 4/6, FFT à fenêtres chevauchées.
#define SAMPLE_RATE   16000
// FFT_SIZE réduit de 2048 à 1024 sur C6 (mono-cœur 160MHz) : mesuré et confirmé
// que le CALCUL SEUL (hors attente I2S) dépasse le budget de 64ms/hop avec
// FFT_SIZE=2048 (69705-74508µs mesurés), causant 0 détection en 30s. Avec
// FFT_SIZE=1024, calcul mesuré à 33064-36545µs (marge confortable). Résolution
// ~15.6 Hz/bin (au lieu de 7.8).
#define FFT_SIZE      1024
// HOP = FFT_SIZE : fenêtres NON chevauchées sur C6 (contrairement au S3, cf.
// commentaire d'origine ci-dessous). Perd la réduction de flottement de phase
// du chevauchement (jusqu'à ~64ms de délai en plus dans le pire cas au tout
// début du son), mais nécessaire pour rester dans le budget CPU du C6.
// HOP historique (S3, FFT_SIZE=2048) : on ne relit que HOP échantillons neufs
// à chaque tour, glissés dans la fenêtre FFT_SIZE (fenêtres CHEVAUCHÉES).
#define HOP           1024

// Raies cibles de l'accord (Hz) — indices : 0=1400 (basse) 1=2600 2=2800 3=3297
// (hautes) 4=4600 5=4297 (très-hautes, les plus discriminantes vs bruit de
// machine). VALEURS PAR DÉFAUT, écrasées par l'auto-calibration (NVS).
#define NRAIES 6
float CHORD_HZ[NRAIES] = {1400.0f, 2600.0f, 2800.0f, 3297.0f, 4600.0f, 4297.0f};
// Seuil de CONTRASTE par raie : hautes classiques à 1.8, 3297 à 1.8, très-hautes
// (4600/4297) à 5.0 (en dessous du min mesuré 18-20, marge pour rattraper les
// sonneries plus faibles/lointaines).
float CONTRAST_MIN[NRAIES] = {1.1f, 1.8f, 1.8f, 1.8f, 5.0f, 5.0f};   // pic / voisinage, par raie
// Seuil de NIVEAU (magnitude FFT) minimum par bande — écarte les signaux
// globalement faibles mais contrastés (bruit résiduel).
float NIVEAU_MIN[NRAIES] = {3000.0f, 9000.0f, 9000.0f, 9000.0f, 10000.0f, 10000.0f};
// Largeur de fenêtre de fond local par raie (bins) : pics stables -> fenêtre resserrée.
// Gardée à 4 (comme l'original S3) même avec FFT_SIZE=1024 sur C6 : localContrast()
// exclut les bins à distance <= GUARD(2) du centre — avec HW=2, TOUS les bins de
// la fenêtre étaient exclus (cnt=0), faisant retomber le contraste sur une valeur
// de secours (base=1.0) qui renvoyait le niveau brut au lieu d'un vrai contraste.
// D'où le faux positif observé (c ≈ niv dans les logs). HW doit rester > GUARD.
int HW_PAR_RAIE[NRAIES] = {4, 4, 4, 4, 4, 4};
float CV_MAX       = 0.5f;        // stabilité (coeff. de variation)
const int   QUORUM_MIN   = 4;           // nb mini de raies qualifiées sur NRAIES (4/6)
const float PERSIST_S    = 0.19f;       // durée continue requise (~3 trames à hop=64ms)
const float FRAME_S = (float)HOP / SAMPLE_RATE;                 // 0,064 s
const int PERSIST_FRAMES = (int)(PERSIST_S / FRAME_S + 0.5f);   // 0,19s -> 3 trames

// ---- MODE CALIBRATION ----
// true = ne déclenche JAMAIS le servo ; logue en continu les mesures (série + /calib)
// pour régler CHORD_HZ et les seuils sur le vrai micro. Basculable via /calib aussi.
bool MODE_CALIBRATION = false;

// Objet NVS — déclaré tôt : utilisé par sauverNVS()/chargerNVS() ci-dessous,
// bien avant la section ÉTAT/OBJETS où vivent les autres globales.
Preferences prefs;

// ======================= HORAIRES =======================
// jour: 0=dim..6=sam.
struct Plage { uint8_t jourDeb, jourFin; uint16_t minDeb, minFin; };

// Plages EN DUR (secours si NVS vide). Lun-Ven 09:00-18:00.
Plage plagesDefaut[] = {
  { 1, 5, 9*60, 18*60 },   // Lundi..Vendredi 09:00-18:00
};
const int N_PLAGES_DEFAUT = sizeof(plagesDefaut)/sizeof(plagesDefaut[0]);

// Plages ACTIVES (éditables via web, persistées en NVS).
#define MAX_PLAGES 8
Plage plages[MAX_PLAGES];
int   nPlages = 0;

// Exceptions ponctuelles (fermetures forcées : congés, pont…).
// Format date : année*10000 + mois*100 + jour (ex. 20260814). 0 = vide.
#define MAX_EXCEPT 16
uint32_t exceptions[MAX_EXCEPT];
int nExcept = 0;

void chargerDefaut() {
  nPlages = N_PLAGES_DEFAUT;
  for (int i=0;i<nPlages;i++) plages[i]=plagesDefaut[i];
  nExcept = 0;
}

// Sérialise plages+exceptions en une chaîne compacte pour NVS.
String serialiser() {
  String s = "P";
  for (int i=0;i<nPlages;i++) {
    s += String(plages[i].jourDeb)+","+plages[i].jourFin+","
       + plages[i].minDeb+","+plages[i].minFin+";";
  }
  s += "|E";
  for (int i=0;i<nExcept;i++) s += String(exceptions[i])+";";
  return s;
}

void sauverNVS() {
  prefs.begin("porte", false);
  prefs.putString("cfg", serialiser());
  prefs.end();
}

// Parse une chaîne NVS ; renvoie false si vide/invalide → on gardera le défaut.
bool chargerNVS() {
  prefs.begin("porte", true);
  String s = prefs.getString("cfg", "");
  prefs.end();
  if (s.length() < 2 || s[0] != 'P') return false;
  nPlages = 0; nExcept = 0;
  int bar = s.indexOf('|');
  String sp = s.substring(1, bar<0?s.length():bar);   // plages
  String se = (bar>=0 && bar+2<=s.length()) ? s.substring(bar+2) : ""; // après "|E"
  // plages
  int start=0;
  while (start < sp.length() && nPlages < MAX_PLAGES) {
    int sc = sp.indexOf(';', start); if (sc<0) break;
    String item = sp.substring(start, sc); start = sc+1;
    int c1=item.indexOf(','), c2=item.indexOf(',',c1+1), c3=item.indexOf(',',c2+1);
    if (c1<0||c2<0||c3<0) continue;
    Plage p;
    p.jourDeb=item.substring(0,c1).toInt();
    p.jourFin=item.substring(c1+1,c2).toInt();
    p.minDeb =item.substring(c2+1,c3).toInt();
    p.minFin =item.substring(c3+1).toInt();
    plages[nPlages++]=p;
  }
  // exceptions
  start=0;
  while (start < se.length() && nExcept < MAX_EXCEPT) {
    int sc = se.indexOf(';', start); if (sc<0) break;
    uint32_t d = (uint32_t)se.substring(start,sc).toInt(); start=sc+1;
    if (d>0) exceptions[nExcept++]=d;
  }
  if (nPlages==0) return false;
  return true;
}

// ---- Jours fériés France (métropole) ----
// Calcul du lundi de Pâques via l'algorithme de Gauss/Meeus (Pâques grégorien).
// Renvoie le jour de l'année (1..366) de Pâques pour l'année donnée.
void paquesGregorien(int annee, int &mois, int &jour) {
  int a = annee % 19;
  int b = annee / 100;
  int c = annee % 100;
  int d = b / 4, e = b % 4;
  int f = (b + 8) / 25;
  int g = (b - f + 1) / 3;
  int h = (19*a + b - d - g + 15) % 30;
  int i = c / 4, k = c % 4;
  int l = (32 + 2*e + 2*i - h - k) % 7;
  int m = (a + 11*h + 22*l) / 451;
  mois = (h + l - 7*m + 114) / 31;      // 3=mars, 4=avril
  jour = ((h + l - 7*m + 114) % 31) + 1;
}

// jour/mois -> vrai si férié français fixe ou mobile (basé Pâques)
bool estFerieFR(const struct tm &t) {
  int annee = t.tm_year + 1900;
  int mois  = t.tm_mon + 1;   // 1..12
  int jour  = t.tm_mday;

  // Fériés FIXES
  if (mois==1  && jour==1)  return true;  // Jour de l'An
  if (mois==5  && jour==1)  return true;  // Fête du Travail
  if (mois==5  && jour==8)  return true;  // Victoire 1945
  if (mois==7  && jour==14) return true;  // Fête nationale
  if (mois==8  && jour==15) return true;  // Assomption
  if (mois==11 && jour==1)  return true;  // Toussaint
  if (mois==11 && jour==11) return true;  // Armistice 1918
  if (mois==12 && jour==25) return true;  // Noël

  // Fériés MOBILES : à partir du dimanche de Pâques
  int pm, pj; paquesGregorien(annee, pm, pj);
  // On convertit Pâques en "numéro de jour absolu" simple pour additionner des offsets.
  struct tm tp = {};
  tp.tm_year = annee - 1900; tp.tm_mon = pm - 1; tp.tm_mday = pj;
  tp.tm_hour = 12;
  time_t paques = mktime(&tp);

  struct tm tc = {};
  tc.tm_year = annee - 1900; tc.tm_mon = mois - 1; tc.tm_mday = jour;
  tc.tm_hour = 12;
  time_t courant = mktime(&tc);

  long dj = (long)((courant - paques) / 86400);  // écart en jours vs Pâques
  if (dj == 1)  return true;   // Lundi de Pâques
  if (dj == 39) return true;   // Ascension (jeudi)
  if (dj == 50) return true;   // Lundi de Pentecôte

  return false;
}

// Liste les jours fériés à venir (à partir d'aujourd'hui inclus), sur nbJours,
// au format AAAAMMJJ (même format que exceptions[]). Renvoie le nombre trouvé.
int prochainsFeriesFR(uint32_t *out, int nMax, int nbJours) {
  struct tm base; if (!getLocalTime(&base)) return 0;
  int n = 0;
  for (int d = 0; d < nbJours && n < nMax; d++) {
    struct tm cand = base;
    cand.tm_mday += d; cand.tm_hour = 12; cand.tm_min = 0; cand.tm_sec = 0;
    time_t candT = mktime(&cand);
    struct tm candN; localtime_r(&candT, &candN);
    if (estFerieFR(candN)) {
      out[n++] = (uint32_t)((candN.tm_year+1900)*10000 + (candN.tm_mon+1)*100 + candN.tm_mday);
    }
  }
  return n;
}

// ======================= ÉTAT / OBJETS =======================
WebServer server(80);
ArduinoFFT<float> FFT = ArduinoFFT<float>();
Servo servoPorte;

float vReal[FFT_SIZE];
float vImag[FFT_SIZE];
int32_t i2sBuf[HOP];         // on ne lit que HOP échantillons neufs par tour
float rawWindow[FFT_SIZE];   // fenêtre glissante persistante (avant fenêtrage Hann)
bool  windowFull = false;    // tout début : pas encore assez d'échantillons

// historique pour la stabilité temporelle (énergie de chaque raie).
// Le retard qu'il causait au démarrage est corrigé par le PRÉ-REMPLISSAGE (cf.
// frameIsBell) : l'historique est initialisé à l'apparition du son. HIST est aligné
// sur PERSIST_FRAMES (3 trames à hop=64ms) pour que le cv reflète une vraie
// stabilité observée pendant la fenêtre de détection, pas du pré-remplissage résiduel.
static const int HIST = 3;
float binHist[NRAIES][HIST];
int   histIdx = 0;
int   framesOK = 0;
// pour le pré-remplissage : la raie passait-elle le contraste à la trame précédente ?
bool  contrastePrec[NRAIES] = {false,false,false,false,false,false};

bool wifiOK = false;
bool ecoute = false;                 // true = on écoute le micro (dans le créneau)
unsigned long resterEveilleJusqu = 0; // horodatage (millis) jusqu'auquel on reste éveillé hors créneau
const uint32_t MAINTENANCE_MS = 180000; // 3 min de fenêtre de config au réveil hors créneau

// ---------- COMPTEURS de déclenchements (ouvertures AUTO) — glissants + tranches horaires ----------
// Objectif : aujourd'hui / 30 j / 365 j / total, ET la répartition par TRANCHE horaire
//   T0 = avant 10h   T1 = 10h–12h   T2 = 12h–14h   T3 = après 14h
// affichée en % à la fois sur le CUMUL total et sur les 30 DERNIERS jours.
//
// Pour du glissant par tranche, on stocke le détail JOUR × TRANCHE → buffer circulaire
// jours[365][4]. Le total d'un jour = somme de ses 4 tranches. Persisté en NVS (flash) :
// survit au deep sleep ET aux coupures. Écriture flash UNIQUEMENT à l'ouverture (durable).
#define NJOURS   365
#define NTRANCHE 4
uint16_t jours[NJOURS][NTRANCHE]; // [jourAbs % NJOURS][tranche]
uint32_t jourCourantAbs = 0;      // n° de jour absolu du dernier événement connu
uint32_t cptTotal = 0;            // cumul total depuis toujours (jamais remis à 0)
uint32_t cumTranche[NTRANCHE] = {0,0,0,0};   // cumul par tranche depuis toujours

const char* NOM_TRANCHE[NTRANCHE] = {"avant 10h", "10h-12h", "12h-14h", "apres 14h"};

// tranche horaire (0..3) à partir de l'heure du jour.
int trancheDe(int heure) {
  if (heure < 10) return 0;
  if (heure < 12) return 1;
  if (heure < 14) return 2;
  return 3;
}

// n° de jour absolu (jours écoulés depuis 2020-01-01) pour indexer/comparer les jours.
uint32_t jourAbsMaintenant() {
  struct tm t; if (!getLocalTime(&t)) return jourCourantAbs;  // pas d'heure → garde l'actuel
  struct tm e = {}; e.tm_year = 2020-1900; e.tm_mon = 0; e.tm_mday = 1; e.tm_hour = 12;
  struct tm c = {}; c.tm_year = t.tm_year; c.tm_mon = t.tm_mon; c.tm_mday = t.tm_mday; c.tm_hour = 12;
  double d = difftime(mktime(&c), mktime(&e));
  return (uint32_t)(d / 86400.0 + 0.5);
}

void chargerCompteurs() {
  prefs.begin("cpt", true);
  cptTotal       = prefs.getUInt("total", 0);
  jourCourantAbs = prefs.getUInt("jabs", 0);
  prefs.getBytes("cumt", cumTranche, sizeof(cumTranche));
  size_t n = prefs.getBytes("jours", jours, sizeof(jours));
  if (n != sizeof(jours)) memset(jours, 0, sizeof(jours));   // 1re fois → vide
  prefs.end();
}
void sauverCompteurs() {
  prefs.begin("cpt", false);
  prefs.putUInt("total", cptTotal);
  prefs.putUInt("jabs", jourCourantAbs);
  prefs.putBytes("cumt", cumTranche, sizeof(cumTranche));
  prefs.putBytes("jours", jours, sizeof(jours));
  prefs.end();
}

// Avance jusqu'à aujourd'hui en EFFAÇANT les cases des jours traversés (sinon on
// recompterait de vieilles valeurs vieilles de >365 j revenues au même index).
void recadrerCompteurs() {
  uint32_t auj = jourAbsMaintenant();
  if (auj <= jourCourantAbs) return;                 // même jour (ou pas d'heure) → rien
  uint32_t gap = auj - jourCourantAbs;
  if (gap >= NJOURS) { memset(jours, 0, sizeof(jours)); } // trop vieux → tout effacer
  else {
    for (uint32_t d = 1; d <= gap; d++)
      for (int tr = 0; tr < NTRANCHE; tr++) jours[(jourCourantAbs + d) % NJOURS][tr] = 0;
  }
  jourCourantAbs = auj;
}

// À appeler à chaque ouverture AUTO : recadre, incrémente la bonne tranche du jour,
// le cumul de tranche et le total, puis sauve.
void incrementerCompteurs() {
  recadrerCompteurs();
  struct tm t; int tr = getLocalTime(&t) ? trancheDe(t.tm_hour) : 3;   // défaut : après 14h
  uint16_t &c = jours[jourCourantAbs % NJOURS][tr];
  if (c < 65000) c++;
  if (cumTranche[tr] < 0xFFFFFFF0) cumTranche[tr]++;
  cptTotal++;
  sauverCompteurs();
}

// Total d'un jour (toutes tranches) et sommes glissantes.
uint32_t totalJour(uint32_t idxAbs) {
  uint32_t s = 0; for (int tr = 0; tr < NTRANCHE; tr++) s += jours[idxAbs % NJOURS][tr];
  return s;
}
uint32_t sommeGlissante(int nbJours) {
  recadrerCompteurs();
  uint32_t s = 0;
  for (int d = 0; d < nbJours && d < NJOURS; d++) s += totalJour(jourCourantAbs - d);
  return s;
}
uint32_t cptAujourdhui() { recadrerCompteurs(); return totalJour(jourCourantAbs); }

// Répartition par tranche : nombre d'ouvertures d'une tranche sur les N derniers jours.
uint32_t trancheGlissante(int tr, int nbJours) {
  recadrerCompteurs();
  uint32_t s = 0;
  for (int d = 0; d < nbJours && d < NJOURS; d++) s += jours[(jourCourantAbs - d) % NJOURS][tr];
  return s;
}

// ---------- AUTO-CALIBRATION : état de capture ----------
bool  captureEnCours = false;
// histogramme : combien de fois chaque bin a été "pic dominant fort" pendant la capture
uint16_t histoBin[FFT_SIZE/2];
float niveauSonnetteMax = 0;         // magnitude max observée (trames fortes = sonnette)
float niveauFondMoy = 0;             // moyenne des trames faibles (fond)
uint32_t nFond = 0;
String calibMsg = "";                // dernier résultat de calibration (affiché sur /calib)

// ---------- bornes de bin pour chaque raie ----------
int chordBin[NRAIES];
void computeBins() {
  for (int i = 0; i < NRAIES; i++) {
    chordBin[i] = (int)(CHORD_HZ[i] * FFT_SIZE / SAMPLE_RATE + 0.5f);
  }
}

// ---------- persistance des paramètres de détection (calibration) ----------
void sauverCalibNVS() {
  prefs.begin("calib", false);
  for (int i=0;i<NRAIES;i++) prefs.putFloat(("f"+String(i)).c_str(), CHORD_HZ[i]);
  // Le contraste et le niveau minimum sont des seuils PAR BANDE fixés en dur ;
  // la calibration ne recale que les fréquences et laisse le CV. On sauve juste le CV.
  prefs.putFloat("cvmax", CV_MAX);
  prefs.end();
}
bool chargerCalibNVS() {
  prefs.begin("calib", true);
  bool has = prefs.isKey("f0");
  if (has) {
    for (int i=0;i<NRAIES;i++) CHORD_HZ[i] = prefs.getFloat(("f"+String(i)).c_str(), CHORD_HZ[i]);
    CV_MAX = prefs.getFloat("cvmax", CV_MAX);
  }
  prefs.end();
  if (has) computeBins();
  return has;
}

// ---------- capture d'une trame pendant l'auto-calibration ----------
// vReal contient les magnitudes de la trame courante.
void captureTrame() {
  // pic dominant de la trame
  int kmax=1; float vmax=0;
  for (int k=1;k<FFT_SIZE/2;k++) if (vReal[k]>vmax){vmax=vReal[k];kmax=k;}
  // seuil "trame forte" = clairement au-dessus du fond courant
  if (vmax > 3.0f*(niveauFondMoy+1e-6f) && vmax > niveauSonnetteMax*0.25f) {
    // trame considérée "sonnette" : on vote pour ses pics marquants
    if (vmax>niveauSonnetteMax) niveauSonnetteMax=vmax;
    // voter pour les bins nettement au-dessus de la moyenne de la trame
    float moy=0; for (int k=1;k<FFT_SIZE/2;k++) moy+=vReal[k]; moy/=(FFT_SIZE/2);
    for (int k=2;k<FFT_SIZE/2-2;k++)
      if (vReal[k]>4*moy && vReal[k]>vReal[k-1] && vReal[k]>vReal[k+1] && histoBin[k]<65000)
        histoBin[k]++;
  } else {
    // trame faible = fond
    niveauFondMoy = (niveauFondMoy*nFond + vmax)/(nFond+1); nFond++;
  }
}

// ---------- calcul final : déduit les NRAIES fréquences + seuils, sauve en NVS ----------
void terminerCalibration() {
  // 1) trouver les NRAIES bins les plus votés, en évitant les doublons trop proches (>150 Hz)
  int minSep = (int)(150.0f * FFT_SIZE / SAMPLE_RATE);   // séparation mini en bins
  int chosen[NRAIES]={0,0,0,0,0,0}; int nc=0;
  uint16_t tmp[FFT_SIZE/2]; memcpy(tmp,histoBin,sizeof(tmp));
  while (nc<NRAIES) {
    int kbest=-1; uint16_t vbest=0;
    for (int k=1;k<FFT_SIZE/2;k++) if (tmp[k]>vbest){vbest=tmp[k];kbest=k;}
    if (kbest<0 || vbest==0) break;
    bool tropProche=false;
    for (int j=0;j<nc;j++) if (abs(kbest-chosen[j])<minSep) tropProche=true;
    if (!tropProche) chosen[nc++]=kbest;
    // effacer un voisinage pour ne pas re-choisir le même pic élargi
    for (int d=-minSep; d<=minSep; d++){ int kk=kbest+d; if(kk>=0&&kk<FFT_SIZE/2) tmp[kk]=0; }
  }
  if (nc<2) { calibMsg="Échec : sonnette pas assez captée. Réessaie en sonnant plus fort/plus près."; return; }

  // remplir CHORD_HZ (si < NRAIES trouvés, on répète le dernier)
  for (int i=0;i<NRAIES;i++){ int idx = (i<nc)?chosen[i]:chosen[nc-1];
    CHORD_HZ[i] = (float)idx * SAMPLE_RATE / FFT_SIZE; }
  // trier croissant pour la lisibilité
  for (int a=0;a<NRAIES-1;a++) for (int b=a+1;b<NRAIES;b++) if (CHORD_HZ[b]<CHORD_HZ[a]){float x=CHORD_HZ[a];CHORD_HZ[a]=CHORD_HZ[b];CHORD_HZ[b]=x;}
  computeBins();

  // 2) seuils : on garde les valeurs éprouvées (robustes). La calib sert surtout à
  //    recaler les FRÉQUENCES sur le vrai micro. Contraste/niveau par bande inchangés.
  CV_MAX = 0.5f;

  sauverCalibNVS();
  char b[220];
  snprintf(b,sizeof(b),"OK ! Fréquences détectées : %.0f / %.0f / %.0f / %.0f / %.0f / %.0f Hz (sauvé). "
           "Niveau sonnette=%.0f, fond=%.0f.",
           CHORD_HZ[0],CHORD_HZ[1],CHORD_HZ[2],CHORD_HZ[3],CHORD_HZ[4],CHORD_HZ[5],
           niveauSonnetteMax, niveauFondMoy);
  calibMsg = b;
  Serial.println(calibMsg);
}

// ======================= I2S =======================
void i2sInit() {
  i2s_config_t cfg = {
    .mode = (i2s_mode_t)(I2S_MODE_MASTER | I2S_MODE_RX),
    .sample_rate = SAMPLE_RATE,
    .bits_per_sample = I2S_BITS_PER_SAMPLE_32BIT,
    .channel_format = I2S_CHANNEL_FMT_ONLY_LEFT,
    .communication_format = I2S_COMM_FORMAT_STAND_I2S,
    .intr_alloc_flags = 0,
    .dma_buf_count = 4,
    .dma_buf_len = 256,
    .use_apll = false,
    .tx_desc_auto_clear = false,
    .fixed_mclk = 0
  };
  i2s_pin_config_t pins = {
    .bck_io_num = I2S_SCK,
    .ws_io_num  = I2S_WS,
    .data_out_num = I2S_PIN_NO_CHANGE,
    .data_in_num  = I2S_SD
  };
  i2s_driver_install(I2S_NUM_0, &cfg, 0, NULL);
  i2s_set_pin(I2S_NUM_0, &pins);
  i2s_zero_dma_buffer(I2S_NUM_0);
}

// Lit HOP échantillons neufs et les fait glisser dans la fenêtre FFT_SIZE :
// [ancien FFT_SIZE-HOP restant] + [HOP nouveaux] -> chevauchement (overlap).
void readSamples() {
  size_t bytesRead = 0;
  i2s_read(I2S_NUM_0, i2sBuf, HOP * sizeof(int32_t), &bytesRead, portMAX_DELAY);
  int n = bytesRead / sizeof(int32_t);

  if (!windowFull) {
    static int filled = 0;
    filled += n;
    if (filled >= FFT_SIZE) windowFull = true;
  }

  memmove(rawWindow, rawWindow + HOP, (FFT_SIZE - HOP) * sizeof(float));
  // INMP441 : donnée sur les 24 bits de poids fort d'un mot 32 bits ; gain >>12 (+24 dB),
  // validé meilleur que >>8 sur ce micro (cf. test_detection_switch_v2).
  for (int i = 0; i < n; i++) rawWindow[FFT_SIZE - HOP + i] = (float)(i2sBuf[i] >> 12);
  for (int i = n; i < HOP; i++) rawWindow[FFT_SIZE - HOP + i] = 0.0f;

  for (int i = 0; i < FFT_SIZE; i++) { vReal[i] = rawWindow[i]; vImag[i] = 0.0f; }
}

// ======================= DÉTECTION =======================
// contraste local d'un bin : puissance(bin) / moyenne approx du voisinage (HW par bande)
float localContrast(int k, int HW) {
  const int GUARD = 2;
  int lo = max(1, k - HW), hi = min(FFT_SIZE/2, k + HW);
  float sum = 0; int cnt = 0;
  for (int i = lo; i <= hi; i++) {
    if (abs(i - k) <= GUARD) continue;
    sum += vReal[i]; cnt++;
  }
  float base = (cnt > 0) ? sum / cnt : 1.0f;
  if (base < 1e-6f) base = 1e-6f;
  return vReal[k] / base;
}

// --- Mesures de la dernière trame (pour la calibration) ---
float mesC[NRAIES], mesCV[NRAIES], mesNiv[NRAIES];  // contraste, CV, niveau par raie cible
int   mesQualifies = 0;        // nb de raies qui passent les seuils
float mesPicHz = 0, mesPicMag = 0;  // pic spectral dominant (Hz, magnitude)
bool  raieOK[NRAIES];          // état qualifié de chaque raie (contraste + niveau + stabilité)

// renvoie true si la trame courante ressemble à la sonnette (quorum QUORUM_MIN/NRAIES)
bool frameIsBell() {
  // magnitudes déjà dans vReal après FFT.complexToMagnitude
  int qualifies = 0;
  for (int i = 0; i < NRAIES; i++) {
    int k = chordBin[i];
    float c = localContrast(k, HW_PAR_RAIE[i]);
    float niv = vReal[k];
    bool contrasteOK = (c > CONTRAST_MIN[i]);
    bool niveauOK    = (niv > NIVEAU_MIN[i]);

    // PRÉ-REMPLISSAGE : quand la raie vient d'APPARAÎTRE (contraste OK maintenant, pas à
    // la trame précédente), on remplit tout son historique avec la valeur courante. Ainsi
    // le CV est bon dès la 1re trame de son → l'historique ne RETARDE plus la détection.
    if (contrasteOK && !contrastePrec[i]) {
      for (int h = 0; h < HIST; h++) binHist[i][h] = niv;
    } else {
      binHist[i][histIdx] = niv;
    }
    contrastePrec[i] = contrasteOK;

    // stabilité : coeff. de variation sur l'historique
    float m = 0, m2 = 0;
    for (int h = 0; h < HIST; h++) { m += binHist[i][h]; m2 += binHist[i][h]*binHist[i][h]; }
    m /= HIST; m2 /= HIST;
    float var = m2 - m*m; if (var < 0) var = 0;
    float cv = (m > 1e-6f) ? sqrtf(var) / m : 999.0f;
    mesC[i] = c; mesCV[i] = cv; mesNiv[i] = niv;   // mémorise pour calibration
    raieOK[i] = (contrasteOK && niveauOK && cv < CV_MAX);
    if (raieOK[i]) qualifies++;
  }
  histIdx = (histIdx + 1) % HIST;
  mesQualifies = qualifies;

  // pic dominant global (utile pour découvrir les vraies fréquences de la sonnette)
  int kmax = 1; float vmax = 0;
  for (int k = 1; k < FFT_SIZE/2; k++) if (vReal[k] > vmax) { vmax = vReal[k]; kmax = k; }
  mesPicHz = (float)kmax * SAMPLE_RATE / FFT_SIZE;
  mesPicMag = vmax;

  // RÈGLE validée sur banc de test : quorum, au moins QUORUM_MIN des NRAIES raies
  // qualifiées, quelle que soit la combinaison. Plus tolérant qu'une règle par
  // groupes fixes tout en gardant une exigence forte (4/6) contre les faux positifs.
  return qualifies >= QUORUM_MIN;
}

// ======================= HORAIRES =======================
bool dansPlageMaintenant() {
  struct tm t;
  if (!wifiOK || !getLocalTime(&t)) {
    // pas d'heure fiable → on applique les plages EN DUR sur l'heure système
    // si même l'heure système est absente, on refuse par sécurité
    if (!getLocalTime(&t)) return false;
  }
  // Jour férié français → jamais d'ouverture auto
  if (estFerieFR(t)) {
    Serial.println("   (jour férié FR → pas d'ouverture auto)");
    return false;
  }
  // Exception ponctuelle (fermeture forcée : pont, congés…)
  uint32_t dateNum = (uint32_t)((t.tm_year+1900)*10000 + (t.tm_mon+1)*100 + t.tm_mday);
  for (int i=0;i<nExcept;i++) if (exceptions[i]==dateNum) {
    Serial.println("   (exception/fermeture → pas d'ouverture auto)");
    return false;
  }

  int jour = t.tm_wday;               // 0=dim..6=sam
  int minutes = t.tm_hour*60 + t.tm_min;
  for (int i = 0; i < nPlages; i++) {
    Plage &p = plages[i];
    if (jour >= p.jourDeb && jour <= p.jourFin &&
        minutes >= p.minDeb && minutes < p.minFin) return true;
  }
  return false;
}

// ======================= LED / BUZZER =======================
void led(bool on){ digitalWrite(PIN_LED, (LED_ACTIVE_HIGH?on:!on) ? HIGH : LOW); }

// ======================= SERVO =======================
void bougerServoProgressif(int angleDepart, int angleArrivee, int delaiParPas) {
  if (angleArrivee > angleDepart) {
    for (int a = angleDepart; a <= angleArrivee; a++) { servoPorte.write(a); delay(delaiParPas); }
  } else {
    for (int a = angleDepart; a >= angleArrivee; a--) { servoPorte.write(a); delay(delaiParPas); }
  }
}

// comptabiliser = true → compte cette ouverture dans les stats (ouvertures AUTO
// seulement ; le test manuel via le web passe false).
void ouvrirPorte(bool comptabiliser = false) {
  if (comptabiliser) incrementerCompteurs();
  led(true);
  Serial.println(">>> OUVERTURE (bras servo, appui 1,5 s)");

  // Bip court de confirmation. Buzzer ACTIF BAS : LOW = sonne, HIGH = silence.
  // La détection est de toute façon suspendue (on est dans ouvrirPorte, pas dans
  // la boucle d'écoute) → pas de boucle audio.
  digitalWrite(PIN_BUZZER, LOW);
  delay(BUZZER_MS);
  digitalWrite(PIN_BUZZER, HIGH);

  bougerServoProgressif(SERVO_ANGLE_REPOS, SERVO_ANGLE_OUVERT, SERVO_MS_PAR_DEGRE);
  delay(RELAY_HOLD_MS - BUZZER_MS);     // compléter les 1,5 s d'ouverture (bras en appui)
  bougerServoProgressif(SERVO_ANGLE_OUVERT, SERVO_ANGLE_REPOS, SERVO_MS_PAR_DEGRE);
  Serial.println("    bras servo relâché");

  // Garde anti-boucle : on ignore l'audio un court instant après le bip,
  // et on vide l'historique + la fenêtre + le buffer I2S pour repartir propre.
  led(false);
  delay(GARDE_APRES_MS);
  size_t dump;
  for (int i=0;i<4;i++) i2s_read(I2S_NUM_0, i2sBuf, HOP*sizeof(int32_t), &dump, 0); // purge DMA
  for (int i=0;i<NRAIES;i++) for (int hh=0;hh<HIST;hh++) binHist[i][hh]=0;
  for (int i=0;i<NRAIES;i++) contrastePrec[i]=false;   // réarme le pré-remplissage
  for (int i=0;i<FFT_SIZE;i++) rawWindow[i]=0.0f;
  framesOK = 0;
}

// ======================= WEB (réglage des plages) =======================
const char* JOURS[7] = {"Dim","Lun","Mar","Mer","Jeu","Ven","Sam"};
String hhmm(int minutes){ char b[6]; sprintf(b,"%02d:%02d",minutes/60,minutes%60); return b; }

// nom du jour de semaine pour une date AAAAMMJJ
String jourSemaineDe(uint32_t d){
  struct tm tt={}; tt.tm_year=d/10000-1900; tt.tm_mon=(d/100)%100-1; tt.tm_mday=d%100; tt.tm_hour=12;
  mktime(&tt); return String(JOURS[tt.tm_wday]);
}

void handleRoot() {
  String h = "<!doctype html><meta charset=utf-8><meta name=viewport content='width=device-width,initial-scale=1'>";
  h += "<style>body{font-family:sans-serif;max-width:640px;margin:auto;padding:1em}"
       "table{border-collapse:collapse;width:100%}td,th{border:1px solid #ccc;padding:4px}"
       "button{padding:6px 10px;margin:4px 0}fieldset{margin:1em 0}</style>";
  h += "<h2>Ouvre-porte Quai 10</h2>";

  struct tm t; bool ok=getLocalTime(&t);
  h += "<p><b>Heure ESP :</b> ";
  if (ok){ char b[40]; strftime(b,sizeof(b),"%a %d/%m/%Y %H:%M:%S",&t); h+=b; } else h+="(non synchronisée)";
  h += "<br><b>Mode :</b> "; h += ecoute ? "écoute (dans le créneau)" : "maintenance (hors créneau)";
  h += "</p>";

  // Statistiques d'ouvertures AUTO (fenêtres glissantes + répartition horaire, NVS)
  h += "<fieldset><legend>Ouvertures automatiques</legend><table>";
  h += "<tr><th>Aujourd'hui</th><th>30 jours glissants</th><th>365 jours glissants</th><th>Total</th></tr>";
  h += "<tr><td>"+String(cptAujourdhui())+"</td><td>"+String(sommeGlissante(30))
     + "</td><td>"+String(sommeGlissante(365))+"</td><td>"+String(cptTotal)+"</td></tr>";
  h += "</table>";

  // Répartition par tranche horaire : % sur le cumul total et sur les 30 derniers jours.
  uint32_t tot30 = sommeGlissante(30);
  h += "<p style='margin:.8em 0 .2em'><b>Répartition par heure d'arrivée</b></p><table>";
  h += "<tr><th>Tranche</th><th>Cumulé (%)</th><th>30 jours (%)</th></tr>";
  for (int tr=0; tr<4; tr++) {
    uint32_t nCum = cumTranche[tr];
    uint32_t n30  = trancheGlissante(tr, 30);
    int pCum = (cptTotal>0) ? (int)((nCum*100 + cptTotal/2) / cptTotal) : 0;
    int p30  = (tot30>0)    ? (int)((n30*100  + tot30/2)   / tot30)    : 0;
    h += "<tr><td>"+String(NOM_TRANCHE[tr])+"</td><td>"+String(pCum)+" % ("+String(nCum)+")"
       + "</td><td>"+String(p30)+" % ("+String(n30)+")</td></tr>";
  }
  h += "</table>";
  if (!ok) h += "<p><i>(compteurs approximatifs tant que l'heure n'est pas synchronisée)</i></p>";
  h += "</fieldset>";

  // Plages
  h += "<fieldset><legend>Plages d'ouverture automatique</legend><table>";
  h += "<tr><th>#</th><th>Du</th><th>Au</th><th>Début</th><th>Fin</th></tr>";
  for (int i=0;i<nPlages;i++)
    h += "<tr><td>"+String(i)+"</td><td>"+JOURS[plages[i].jourDeb]+"</td><td>"+JOURS[plages[i].jourFin]
       +"</td><td>"+hhmm(plages[i].minDeb)+"</td><td>"+hhmm(plages[i].minFin)+"</td></tr>";
  h += "</table>";
  // formulaire d'édition simple (remplace toutes les plages par une liste)
  h += "<form action='/save' method='post'><p>Redéfinir (une plage par ligne : "
       "<code>jourDeb jourFin HH:MM HH:MM</code>, jours 0=Dim..6=Sam) :</p>";
  h += "<textarea name='p' rows=4 style='width:100%'>";
  for (int i=0;i<nPlages;i++)
    h += String(plages[i].jourDeb)+" "+plages[i].jourFin+" "+hhmm(plages[i].minDeb)+" "+hhmm(plages[i].minFin)+"\n";
  h += "</textarea><button>Enregistrer les plages</button></form></fieldset>";

  // Exceptions + jours fériés — liste fusionnée des PROCHAINES fermetures (à venir),
  // triée, avec suppression (uniquement pour les exceptions manuelles ; les fériés
  // sont calculés, non supprimables).
  h += "<fieldset><legend>Exceptions (fermetures : congés, ponts, jours fériés…)</legend>";
  {
    uint32_t auj = ok ? (uint32_t)((t.tm_year+1900)*10000+(t.tm_mon+1)*100+t.tm_mday) : 0;
    // fusion : exceptions manuelles (supprimables) + fériés calculés (~15 mois, non supprimables)
    #define MAX_FERMETURES_AFFICHEES (MAX_EXCEPT+40)
    uint32_t fut[MAX_FERMETURES_AFFICHEES]; bool estFerie[MAX_FERMETURES_AFFICHEES]; int nf=0;
    for (int i=0;i<nExcept && nf<MAX_FERMETURES_AFFICHEES;i++)
      if (exceptions[i]>=auj) { fut[nf]=exceptions[i]; estFerie[nf]=false; nf++; }
    if (ok) {
      uint32_t feries[40];
      int nFer = prochainsFeriesFR(feries, 40, 450);   // ~15 mois à venir
      for (int i=0;i<nFer && nf<MAX_FERMETURES_AFFICHEES;i++) {
        bool dejaPresent=false;
        for (int j=0;j<nf;j++) if (fut[j]==feries[i]) dejaPresent=true;   // évite un doublon si déjà saisi en exception
        if (!dejaPresent) { fut[nf]=feries[i]; estFerie[nf]=true; nf++; }
      }
    }
    // tri croissant (tri à bulles simple, en gardant estFerie synchronisé)
    for (int a=0;a<nf-1;a++) for (int b=a+1;b<nf;b++) if (fut[b]<fut[a]){
      uint32_t x=fut[a];fut[a]=fut[b];fut[b]=x; bool y=estFerie[a];estFerie[a]=estFerie[b];estFerie[b]=y;
    }
    if (nf==0) {
      h += "<p><i>Aucune fermeture à venir.</i></p>";
    } else {
      h += "<p>Prochaines fermetures :</p><table>";
      int montre = nf<8?nf:8;
      for (int i=0;i<montre;i++){ uint32_t d=fut[i];
        char b2[12]; sprintf(b2,"%02d/%02d/%04d",(int)(d%100),(int)((d/100)%100),(int)(d/10000));
        h += "<tr><td>"+jourSemaineDe(d)+"</td><td>"+String(b2)+"</td><td>"
           + (estFerie[i]?"<i>férié</i>":"exception") + "</td>";
        if (estFerie[i]) h += "<td></td>";
        else h += "<td><form action='/except' method='post' style='margin:0'>"
                  "<input type=hidden name=del value='"+String(d)+"'>"
                  "<button>Supprimer</button></form></td>";
        h += "</tr>";
      }
      h += "</table>";
      if (nf>montre) h += "<p><i>… et "+String(nf-montre)+" autre(s) plus tard.</i></p>";
    }
  }
  h += "<form action='/except' method='post'>Ajouter une fermeture : "
       "<input type='date' name='d' required><button>Fermer ce jour</button></form>";
  h += "<form action='/except' method='post'><input type=hidden name=clear value=1>"
       "<button>Tout effacer</button></form></fieldset>";

  // Actions
  h += "<fieldset><legend>Actions</legend>";
  h += "<form action='/test' method='post'><button>Tester l'ouverture (1,5 s)</button></form>";
  if (!ecoute)
    h += "<form action='/wake' method='post'><button>Rester éveillé 30 min</button></form>";
  h += "<form action='/reset' method='post'><button>Réinit. plages par défaut</button></form>";
  // Lien vers /calib volontairement masqué de l'accueil (pas destiné aux utilisateurs
  // normaux) — la route reste active, accessible directement via http://porte.local/calib
  // pour un usage technique (recalibration de la détection sur le micro en place).
  h += "</fieldset>";

  server.send(200, "text/html; charset=utf-8", h);
}

void prolongerMaintenance(uint32_t ms){ if(!ecoute) resterEveilleJusqu = millis()+ms; }

// Page calibration : mesures en direct (auto-refresh) + activation du mode.
void handleCalib() {
  String h = "<!doctype html><meta charset=utf-8><meta http-equiv=refresh content=1>";
  h += "<meta name=viewport content='width=device-width,initial-scale=1'>";
  h += "<style>body{font-family:monospace;max-width:640px;margin:auto;padding:1em}"
       "table{border-collapse:collapse;width:100%}td,th{border:1px solid #ccc;padding:4px}"
       ".ok{background:#d6f5d6}.no{background:#f5d6d6}</style>";
  h += "<h2>Calibration sonnette</h2>";
  h += "<p>Mode calibration : <b>"; h += MODE_CALIBRATION ? "ACTIF" : "inactif";
  h += "</b> — <form style='display:inline' action='/calibtoggle' method='post'>"
       "<button>"; h += MODE_CALIBRATION ? "Désactiver" : "Activer"; h += "</button></form></p>";
  h += "<p>Fais sonner la vraie sonnette et observe. Cette page se rafraîchit chaque seconde.</p>";
  h += "<p><b>Pic dominant :</b> "+String((int)mesPicHz)+" Hz (mag "+String((int)mesPicMag)+")<br>";
  h += "<b>Raies qui passent les seuils :</b> "+String(mesQualifies)+"/"+String(NRAIES)+" "
       "(règle : quorum >= "+String(QUORUM_MIN)+"/"+String(NRAIES)+" ; cv<"+String(CV_MAX,2)+")</p>";
  h += "<table><tr><th>Raie cible</th><th>Contraste</th><th>Seuil c</th><th>Niveau</th><th>Seuil niv</th><th>Stabilité (CV)</th><th>Passe ?</th></tr>";
  for (int i=0;i<NRAIES;i++){
    bool passe = raieOK[i];
    h += "<tr class='"; h += passe?"ok":"no"; h += "'><td>"+String((int)CHORD_HZ[i])+" Hz</td><td>"
       + String(mesC[i],1)+"</td><td>"+String(CONTRAST_MIN[i],1)+"</td><td>"+String((int)mesNiv[i])
       + "</td><td>"+String((int)NIVEAU_MIN[i])+"</td><td>"+String(mesCV[i],2)+"</td><td>"+(passe?"oui":"non")+"</td></tr>";
  }
  h += "</table>";

  // --- Auto-calibration ---
  h += "<hr><h3>Auto-calibration (recale les fréquences sur ta sonnette)</h3>";
  if (captureEnCours) {
    h += "<p><b style='color:#b00'>● CAPTURE EN COURS</b> — fais sonner la sonnette "
         "3-4 fois, puis clique Terminer.</p>";
    h += "<form action='/capstop' method='post'><button>Terminer et calculer</button></form>";
  } else {
    h += "<p>1) Clique Démarrer &nbsp; 2) sonne 3-4 fois &nbsp; 3) Terminer. "
         "Les fréquences seront déduites et <b>sauvées</b> (pas besoin de reflasher).</p>";
    h += "<form action='/capstart' method='post'><button>Démarrer la capture</button></form>";
  }
  if (calibMsg.length()) h += "<p style='background:#eef;padding:.5em'>"+calibMsg+"</p>";
  h += "<p>Fréquences actives : ";
  for (int i=0;i<NRAIES;i++){ if(i) h+=" / "; h+=String((int)CHORD_HZ[i]); }
  h += " Hz</p>";

  h += "<p><a href='/'>← retour</a></p>";
  server.send(200,"text/html; charset=utf-8",h);
}
void handleCalibToggle(){ MODE_CALIBRATION=!MODE_CALIBRATION;
  if(MODE_CALIBRATION && !ecoute){ i2sInit(); ecoute=true; }
  prolongerMaintenance(30UL*60UL*1000UL);
  server.sendHeader("Location","/calib"); server.send(303); }

void handleCapStart(){
  // réinitialise l'histogramme et les niveaux, active capture + mode calib + écoute
  memset(histoBin,0,sizeof(histoBin));
  niveauSonnetteMax=0; niveauFondMoy=0; nFond=0; calibMsg="";
  MODE_CALIBRATION=true; captureEnCours=true;
  if(!ecoute){ i2sInit(); ecoute=true; }
  prolongerMaintenance(30UL*60UL*1000UL);
  Serial.println("Capture calibration démarrée.");
  server.sendHeader("Location","/calib"); server.send(303);
}
void handleCapStop(){
  captureEnCours=false;
  terminerCalibration();        // calcule + sauve en NVS, remplit calibMsg
  prolongerMaintenance(30UL*60UL*1000UL);
  server.sendHeader("Location","/calib"); server.send(303);
}

void handleSave() {
  String p = server.arg("p");
  int nn=0; Plage tmp[MAX_PLAGES];
  int start=0;
  while (start < p.length() && nn < MAX_PLAGES) {
    int nl = p.indexOf('\n', start); if (nl<0) nl=p.length();
    String line = p.substring(start, nl); start=nl+1; line.trim();
    if (line.length()==0) continue;
    // format: jd jf HH:MM HH:MM
    int jd,jf,h1,m1,h2,m2;
    if (sscanf(line.c_str(), "%d %d %d:%d %d:%d",&jd,&jf,&h1,&m1,&h2,&m2)==6){
      tmp[nn].jourDeb=constrain(jd,0,6); tmp[nn].jourFin=constrain(jf,0,6);
      tmp[nn].minDeb=constrain(h1*60+m1,0,1439); tmp[nn].minFin=constrain(h2*60+m2,0,1440);
      nn++;
    }
  }
  if (nn>0){ nPlages=nn; for(int i=0;i<nn;i++) plages[i]=tmp[i]; sauverNVS();
             Serial.println("Plages mises à jour + sauvées NVS."); }
  prolongerMaintenance(MAINTENANCE_MS);
  server.sendHeader("Location","/"); server.send(303);
}

void handleExcept() {
  if (server.hasArg("clear")) { nExcept=0; sauverNVS(); }
  else if (server.hasArg("del")) {
    uint32_t cible = (uint32_t)server.arg("del").toInt();
    int w=0; for (int i=0;i<nExcept;i++) if (exceptions[i]!=cible) exceptions[w++]=exceptions[i];
    nExcept=w; sauverNVS();
  } else {
    String d = server.arg("d"); int y,m,j;
    if (sscanf(d.c_str(),"%d-%d-%d",&y,&m,&j)==3 && nExcept<MAX_EXCEPT){
      exceptions[nExcept++] = (uint32_t)(y*10000+m*100+j); sauverNVS();
    }
  }
  prolongerMaintenance(MAINTENANCE_MS);
  server.sendHeader("Location","/"); server.send(303);
}

void handleTest()  { ouvrirPorte(); prolongerMaintenance(MAINTENANCE_MS);
                     server.sendHeader("Location","/"); server.send(303); }
void handleWake()  { prolongerMaintenance(30UL*60UL*1000UL);
                     server.sendHeader("Location","/"); server.send(303); }
void handleReset() { chargerDefaut(); sauverNVS(); prolongerMaintenance(MAINTENANCE_MS);
                     server.sendHeader("Location","/"); server.send(303); }

// ======================= DEEP SLEEP HORS CRÉNEAU =======================
// Un jour est-il "ouvrable" (au moins une plage le couvre, pas férié, pas exception) ?
// minDebDuJour renvoie la minute de début de la 1re plage couvrant ce jour.
bool jourOuvrable(const struct tm &d, int &minDebDuJour) {
  if (estFerieFR(d)) return false;
  uint32_t dateNum = (uint32_t)((d.tm_year+1900)*10000 + (d.tm_mon+1)*100 + d.tm_mday);
  for (int i=0;i<nExcept;i++) if (exceptions[i]==dateNum) return false;
  int best = 24*60+1;
  for (int i=0;i<nPlages;i++)
    if (d.tm_wday >= plages[i].jourDeb && d.tm_wday <= plages[i].jourFin)
      if (plages[i].minDeb < best) best = plages[i].minDeb;
  if (best > 24*60) return false;
  minDebDuJour = best;
  return true;
}

// Secondes jusqu'au prochain début de plage (jour ouvrable, à l'heure de début réelle).
long secondesAvantProchainCreneau(const struct tm &now) {
  for (int addDays = 0; addDays < 15; addDays++) {
    struct tm cand = now;
    cand.tm_mday += addDays; cand.tm_hour = 12; cand.tm_min = 0; cand.tm_sec = 0;
    time_t candT = mktime(&cand);
    struct tm candN; localtime_r(&candT, &candN);
    int minDeb;
    if (!jourOuvrable(candN, minDeb)) continue;
    // reconstruire l'instant exact = ce jour à minDeb
    candN.tm_hour = minDeb/60; candN.tm_min = minDeb%60; candN.tm_sec = 0;
    time_t debutT = mktime(&candN);
    time_t nowT = mktime((struct tm*)&now);
    if (debutT > nowT) return (long)(debutT - nowT);
  }
  return 12L * 3600;  // filet de sécurité
}

void entrerDeepSleep(long secondes) {
  if (secondes < 30) secondes = 30;         // garde-fou
  Serial.printf("Deep sleep %ld s (~%.1f h) jusqu'au prochain créneau.\n",
                secondes, secondes/3600.0);
  Serial.flush();
  // le servo est déjà au repos (bras écarté) ; le bouton physique reste 100% actif
  esp_sleep_enable_timer_wakeup((uint64_t)secondes * 1000000ULL);
  esp_deep_sleep_start();                    // ne revient pas : reboot au réveil
}

// ======================= WiFi + heure (bloquant tant que pas d'heure) =======================
// Au réveil, on N'ÉCOUTE et on NE DORT jamais sans heure fiable.
bool connecterEtSynchroniser(uint32_t timeoutMs = 15000) {
  // WiFi/mDNS : établis une seule fois (pas la peine de refaire WiFi.begin() si
  // déjà connecté sur un retry précédent).
  static bool wifiMdnsInit = false;
  if (!wifiMdnsInit) {
    WiFi.begin(WIFI_SSID, WIFI_PASS);
    unsigned long t0 = millis();
    while (WiFi.status() != WL_CONNECTED && millis() - t0 < timeoutMs) { delay(250); Serial.print("."); }
    Serial.println();
    wifiOK = (WiFi.status() == WL_CONNECTED);
    if (!wifiOK) { Serial.println("WiFi KO."); return false; }
    // Désactive le power-save WiFi (modem sleep) : par défaut l'ESP32 éteint
    // périodiquement le module radio, ce qui peut faire perdre des paquets
    // UDP unicast (dont NTP) reçus pendant que le radio dort — cause probable
    // de l'échec NTP intermittent observé (marche parfois, échoue souvent).
    WiFi.setSleep(false);
    Serial.print("WiFi OK : "); Serial.println(WiFi.localIP());
    // mDNS : accès via http://porte.local (au lieu de retenir l'IP)
    if (MDNS.begin(HOSTNAME)) {
      MDNS.addService("http", "tcp", 80);
      Serial.printf("mDNS actif : http://%s.local\n", HOSTNAME);
    } else {
      Serial.println("mDNS KO (accès par IP seulement).");
    }
    wifiMdnsInit = true;
  }
  // VRAI RETRY NTP : on relance activement une requête à chaque tentative (au
  // lieu d'un seul appel figé qui attendait passivement en arrière-plan sans
  // jamais renvoyer de requête si le premier essai échouait).
  Serial.println("[DIAG] Lancement/relance de configTzTime().");
  configTzTime(TZ_INFO, NTP_SERVER, NTP_SERVER2, NTP_SERVER3);
  // attendre une heure valide (année > 2020). Fenêtre élargie à 25s (au lieu de
  // 10s) : la réponse SNTP peut légitimement prendre jusqu'à 25s sur ESP-IDF,
  // 10-15s fréquemment (cf. esp-idf#4718) — 10s abandonnait trop tôt la plupart
  // du temps, ce qui explique l'échec NTP intermittent observé sur C6.
  struct tm t; unsigned long t1 = millis();
  while (millis() - t1 < 25000) {
    if (getLocalTime(&t, 500) && (t.tm_year + 1900) > 2020) {
      char b[32]; strftime(b, sizeof(b), "%a %d/%m %H:%M:%S", &t);
      Serial.print("Heure NTP : "); Serial.println(b);
      return true;
    }
  }
  Serial.println("NTP KO.");
  // Bip court (buzzer actif bas) pour signaler l'échec NTP sans avoir besoin du moniteur série.
  digitalWrite(PIN_BUZZER, LOW); delay(100); digitalWrite(PIN_BUZZER, HIGH);
  return false;
}

// ======================= SETUP =======================
void setup() {
  Serial.begin(115200);
  delay(300);
  servoPorte.attach(PIN_SERVO, 500, 2400);
  servoPorte.write(SERVO_ANGLE_REPOS);   // bras écarté au repos : bouton manuel libre
  pinMode(PIN_LED, OUTPUT);    led(false);
  pinMode(PIN_BUZZER, OUTPUT); digitalWrite(PIN_BUZZER, HIGH);  // actif bas : HIGH = silence

  computeBins();
  for (int i=0;i<NRAIES;i++) for (int h=0;h<HIST;h++) binHist[i][h]=0;
  for (int i=0;i<FFT_SIZE;i++) rawWindow[i]=0.0f;

  // 0) RELIRE LA CONFIG depuis la NVS (survit au deep sleep et aux coupures).
  //    Si NVS vide/invalide → on retombe sur les plages EN DUR.
  // Paramètres de détection : calib NVS si elle existe, sinon les défauts (mon analyse audio).
  if (chargerCalibNVS()) Serial.println("Calibration chargée depuis NVS (fréquences recalées).");
  else Serial.println("Pas de calib NVS → paramètres par défaut (analyse enregistrement).");

  if (chargerNVS()) Serial.println("Config chargée depuis NVS.");
  else { chargerDefaut(); Serial.println("NVS vide → plages par défaut (Lun-Ven 9h-18h)."); }

  chargerCompteurs();   // stats d'ouvertures (glissant 30j/365j + total), persistées NVS
  Serial.printf("Compteurs : total=%lu\n", cptTotal);

  // 1) Obtenir une heure fiable AVANT toute décision.
  //    Tant qu'on n'a pas l'heure, on ne dort pas "à l'aveugle" : on réessaie.
  int essais = 0;
  while (!connecterEtSynchroniser()) {
    essais++;
    Serial.printf("Pas d'heure (essai %d) — nouvelle tentative dans 20 s.\n", essais);
    delay(20000);   // on reste éveillé et on réessaie (jamais de sleep sans heure)
  }

  // Double bip : signale que le système est opérationnel (WiFi + heure NTP OK),
  // utile sans avoir besoin du moniteur série branché.
  digitalWrite(PIN_BUZZER, LOW); delay(100); digitalWrite(PIN_BUZZER, HIGH);
  delay(100);
  digitalWrite(PIN_BUZZER, LOW); delay(100); digitalWrite(PIN_BUZZER, HIGH);

  // Serveur web disponible dans tous les cas (créneau OU maintenance)
  server.on("/",        handleRoot);
  server.on("/save",    HTTP_POST, handleSave);
  server.on("/except",  HTTP_POST, handleExcept);
  server.on("/test",    HTTP_POST, handleTest);
  server.on("/wake",    HTTP_POST, handleWake);
  server.on("/reset",   HTTP_POST, handleReset);
  server.on("/calib",   handleCalib);
  server.on("/calibtoggle", HTTP_POST, handleCalibToggle);
  server.on("/capstart", HTTP_POST, handleCapStart);
  server.on("/capstop",  HTTP_POST, handleCapStop);
  server.begin();

  // 2) Heure connue : sommes-nous dans le créneau ?
  struct tm now; getLocalTime(&now);
  if (MODE_CALIBRATION) {
    Serial.println("=== MODE CALIBRATION === (aucune ouverture, logs continus)");
    Serial.println("Fais sonner la vraie sonnette et lis les fréquences/contrastes.");
    i2sInit();
    ecoute = true;
  } else if (dansPlageMaintenant()) {
    Serial.println("Dans le créneau → écoute du micro.");
    i2sInit();
    ecoute = true;
    led(false);                // LED éteinte en écoute passive ; ne s'allume qu'à l'ouverture
  } else {
    // Hors créneau : FENÊTRE DE MAINTENANCE.
    // On reste éveillé MAINTENANCE_MS pour permettre la config web,
    // puis deep sleep si personne n'a demandé à rester éveillé.
    Serial.printf("Hors créneau → fenêtre de maintenance %lus (config web possible).\n",
                  MAINTENANCE_MS/1000);
    Serial.print("IP : "); Serial.println(WiFi.localIP());
    resterEveilleJusqu = millis() + MAINTENANCE_MS;
    ecoute = false;
  }
}

// ======================= LOOP =======================
// compteur de détections de la SESSION courante (remis à 0 à chaque reboot/réveil),
// distinct de cptTotal (persisté en NVS, utilisé par les stats web).
uint32_t nbDetectionsSession = 0;

// --- MESURE PERF (temporaire) : durée du cycle acquisition+FFT+détection ---
uint32_t cycleMaxUs = 0, cycleSumUs = 0, cycleCount = 0;

void loop() {
  server.handleClient();

  // Récapitulatif toutes les 30 s, dans un bloc bien séparé (même format que le
  // banc de test) — uniquement pendant l'écoute (pas pendant la maintenance).
  if (ecoute) {
    static uint32_t lastRecap = 0;
    if (millis() - lastRecap > 30000) {
      lastRecap = millis();
      uint32_t sec = millis() / 1000;
      Serial.println();
      Serial.println();
      Serial.println("        +--------------------------------------------+");
      Serial.println("        |                  R E C A P                 |");
      Serial.println("        +--------------------------------------------+");
      Serial.printf ("        |  Detections totales : %-20lu |\n", nbDetectionsSession);
      Serial.printf ("        |  Temps ecoule       : %3lu min %02lu s%-9s|\n",
                     sec/60, sec%60, "");
      Serial.printf ("        |  Calcul FFT+detect  : moy=%-4lu max=%-4lu us (hors I2S)%-1s|\n",
                     (unsigned long)(cycleCount ? cycleSumUs/cycleCount : 0),
                     (unsigned long)cycleMaxUs, "");
      Serial.printf ("        |  Heap libre         : %-20u |\n", (unsigned)ESP.getFreeHeap());
      Serial.println("        +--------------------------------------------+");
      Serial.println();
      Serial.println();
      cycleMaxUs = 0; cycleSumUs = 0; cycleCount = 0;
    }
  }

  struct tm now;
  // Timeout réduit à 10ms (au lieu du défaut 5000ms) : évite un blocage de
  // plusieurs secondes À CHAQUE TOUR de boucle tant que NTP n'est pas
  // synchronisé (confirmé par mesure : facteur ~5x de ralentissement de la
  // détection quand NTP échoue, faute de ce paramètre).
  bool heureOK = getLocalTime(&now, 10);

  // -------- MODE MAINTENANCE (hors créneau, éveillé pour config web) --------
  if (!ecoute) {
    // Si le créneau vient de s'ouvrir pendant qu'on était en maintenance → passer en écoute.
    if (heureOK && dansPlageMaintenant()) {
      Serial.println("Créneau ouvert → passage en écoute.");
      i2sInit(); ecoute = true; led(false); return;
    }
    // Fenêtre de maintenance expirée et personne ne l'a prolongée → deep sleep.
    if ((long)(millis() - resterEveilleJusqu) >= 0) {
      if (heureOK) entrerDeepSleep(secondesAvantProchainCreneau(now)); // ne revient pas
      else { resterEveilleJusqu = millis() + 30000; } // pas d'heure → on ne dort pas à l'aveugle
    }
    delay(20);
    return;
  }

  // -------- MODE ÉCOUTE (dans le créneau) --------
  // Fin de créneau ? → deep sleep jusqu'au prochain.
  // En calibration : jamais de deep sleep (on écoute quand on veut).
  if (!MODE_CALIBRATION && heureOK && !dansPlageMaintenant()) {
    Serial.println("Fin de créneau.");
    entrerDeepSleep(secondesAvantProchainCreneau(now));  // ne revient pas
  }

  // 1) acquisition + FFT
  readSamples();   // bloquant ~HOP/SAMPLE_RATE (64ms) : attente DMA I2S normale, pas du calcul
  uint32_t tCycle0 = micros();
  FFT.windowing(vReal, FFT_SIZE, FFT_WIN_TYP_HANN, FFT_FORWARD);
  FFT.compute(vReal, vImag, FFT_SIZE, FFT_FORWARD);
  FFT.complexToMagnitude(vReal, vImag, FFT_SIZE);

  // 2) détection trame + persistance
  bool bell = frameIsBell();
  if (bell) framesOK++; else framesOK = 0;

  // --- MESURE PERF (temporaire) : durée du CALCUL seul (FFT+détection, hors
  // attente I2S de readSamples() qui est bloquante par design ~64ms).
  // Gardée en mémoire seulement (pas de Serial.printf ici).
  uint32_t dCycle = micros() - tCycle0;
  if (dCycle > cycleMaxUs) cycleMaxUs = dCycle;
  cycleSumUs += dCycle; cycleCount++;

  // tVues = premier instant où le quorum apparaît (pour afficher le délai depuis
  // le début du son au moment de la détection). On exige plusieurs trames de
  // silence consécutives (SILENCE_FRAMES) avant d'oublier tVues, pour ne pas
  // sous-estimer ce délai si le quorum oscille pendant la montée du son.
  static const int SILENCE_FRAMES = 5;   // ~320ms de silence continu sur le quorum (hop=64ms)
  static uint32_t tVues = 0;
  static bool enCoursQuorum = false;
  static int silenceCount = 0;
  bool quorumVu = (mesQualifies >= QUORUM_MIN);
  if (quorumVu) {
    silenceCount = 0;
    if (!enCoursQuorum) { tVues = millis(); enCoursQuorum = true; }
  } else if (enCoursQuorum) {
    silenceCount++;
    if (silenceCount >= SILENCE_FRAMES) { enCoursQuorum = false; silenceCount = 0; }
  }

  // --- CAPTURE d'auto-calibration en cours : on accumule les votes ---
  if (captureEnCours) captureTrame();

  // --- MODE CALIBRATION : on logue, on n'ouvre pas ---
  if (MODE_CALIBRATION) {
    static uint32_t lastLog = 0;
    if (millis() - lastLog > 200) {   // 5 logs/s pour ne pas noyer le port série
      lastLog = millis();
      Serial.printf("[CALIB] pic=%.0fHz mag=%.0f | qualif=%d/%d | ", mesPicHz, mesPicMag, mesQualifies, NRAIES);
      for (int i=0;i<NRAIES;i++)
        Serial.printf("%.0fHz(c=%.1f niv=%.0f cv=%.2f) ", CHORD_HZ[i], mesC[i], mesNiv[i], mesCV[i]);
      Serial.println();
      // LED clignote = calibration active
      led((millis()/200)%2);
    }
    return;   // pas d'ouverture en calibration
  }

  // --- TRACE d'écoute continue (1 ligne/s), même format que le banc de test ---
  static uint32_t lastLog = 0;
  static bool detFlag = false;
  if (millis() - lastLog > 1000) {
    lastLog = millis();
    int pct = (int)(100.0f * framesOK / PERSIST_FRAMES); if (pct > 100) pct = 100;
    const char* etat = detFlag ? ">>> DETECTION <<<" : (bell ? "REGLE OK - persistance" : "en ecoute");
    Serial.printf("qualif=%d/%d  pic=%4.0fHz  persist=%3d%%  %s\n",
                  mesQualifies, NRAIES, mesPicHz, pct, etat);
    if (mesQualifies >= 1 || bell) {
      Serial.printf("   ");
      for (int i=0;i<NRAIES;i++)
        Serial.printf("%.0fHz[c=%.1f niv=%.0f cv=%.2f %s] ", CHORD_HZ[i], mesC[i], mesNiv[i], mesCV[i], raieOK[i]?"OK":"--");
      Serial.println();
    }
    detFlag = false;
  }

  // 3) sonnette confirmée → ouverture (on est déjà, par construction, dans le créneau)
  if (framesOK >= PERSIST_FRAMES) {
    detFlag = true;
    nbDetectionsSession++;
    uint32_t dTotal = millis() - tVues;
    Serial.println("");
    Serial.println("################################################");
    Serial.printf ("###  *** SONNETTE DETECTEE ***  [quorum %d/%d]  (session: %lu)\n",
                   QUORUM_MIN, NRAIES, nbDetectionsSession);
    Serial.printf ("###  delai depuis debut du son : %lu ms\n", dTotal);
    Serial.printf ("###  raies : ");
    for (int i=0;i<NRAIES;i++)
      Serial.printf("%.0fHz[c=%.1f niv=%.0f cv=%.2f %s] ", CHORD_HZ[i], mesC[i], mesNiv[i], mesCV[i], raieOK[i]?"OK":"--");
    Serial.println();
    Serial.println("################################################");
    Serial.println("");

    ouvrirPorte(true);      // ouverture AUTO → comptabilisée dans les stats
  }
}