Quai 10 · Patrick
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 2) sonne 3-4 fois 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
}
}