diff --git a/.gitignore b/.gitignore index c5ca1ca..0f072ad 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,10 @@ -.pio +.pio/ .superpowers/ +.codex/ +.agents/ +.claude/ .vscode/.browse.c_cpp.db* +.vscode/.browse_c_cpp.db* .vscode/c_cpp_properties.json .vscode/launch.json .vscode/ipch diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 0000000..080e70d --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,10 @@ +{ + // See http://go.microsoft.com/fwlink/?LinkId=827846 + // for the documentation about the extensions.json format + "recommendations": [ + "platformio.platformio-ide" + ], + "unwantedRecommendations": [ + "ms-vscode.cpptools-extension-pack" + ] +} diff --git a/README.md b/README.md index 98c5e31..af386fa 100644 --- a/README.md +++ b/README.md @@ -19,9 +19,9 @@ Firmware ESP32 pour une station d'acquisition de températures avec interface we DS18B20 (×3 en parallèle) VCC → 3.3V GND → GND - DATA → GPIO 4 + DATA → GPIO27 / D6 -Résistance 4.7 kΩ entre 3.3V et GPIO 4 (pull-up) +Résistance 4.7 kΩ entre 3.3V et GPIO27 / D6 (pull-up) ``` > **Important :** Sans la résistance pull-up, les sondes retournent systématiquement −127°C. @@ -55,17 +55,30 @@ Dans `include/config.h` : #define MQTT_PORT 1883 ``` -### 3. Compiler et flasher +### 3. Compiler et téléverser par USB ```bash -# Flash du firmware -pio run -t upload +# Compiler le firmware sans téléverser +pio run -e esp32dev -# Flash de l'interface web (LittleFS) -pio run -t uploadfs +# Compiler l'image filesystem LittleFS sans téléverser +pio run -e esp32dev -t buildfs -# Moniteur série (débogage) -pio device monitor +# Téléverser le firmware par USB +pio run -e esp32dev -t upload --upload-port /dev/ttyUSB0 + +# Téléverser l'interface web LittleFS par USB +pio run -e esp32dev -t uploadfs --upload-port /dev/ttyUSB0 + +# Ouvrir le moniteur série +pio device monitor --port /dev/ttyUSB0 --baud 115200 +``` + +Si PlatformIO détecte automatiquement le port USB, `--upload-port /dev/ttyUSB0` peut être omis : + +```bash +pio run -e esp32dev -t upload +pio run -e esp32dev -t uploadfs ``` ### 4. Vérifier le boot @@ -78,7 +91,7 @@ pio device monitor [OTA] Prêt [FS] LittleFS monté [HTTP] Serveur web démarré sur port 80 -[SONDES] 3 capteur(s) DS18B20 détecté(s) sur GPIO 4 +[SONDES] 3 capteur(s) DS18B20 détecté(s) sur GPIO 27 ``` --- @@ -111,12 +124,23 @@ L'ESP tente de se reconnecter au WiFi STA toutes les 60 secondes. ## API REST +Les anciens endpoints `/api/...` restent disponibles pour compatibilité avec l'interface web. Les nouveaux endpoints recommandés pour un agent IA sont versionnés sous `/api/v1/...`. + | Méthode | Endpoint | Description | |---|---|---| | GET | `/api/status` | État système (WiFi, MQTT, RAM, uptime) | | GET | `/api/temperatures` | Températures instantanées | -| GET | `/api/history` | Historique 24h (288 points) | +| GET | `/api/history` | Historique glissant 24h, moyennes 5 min | | POST | `/api/config` | Mise à jour configuration | +| POST | `/api/restart` | Redémarrage logiciel de l'ESP | +| GET | `/api/v1/info` | Identité, version, capacités | +| GET | `/api/v1/status` | État système complet | +| GET | `/api/v1/readings/latest` | Dernière valeur instantanée connue | +| GET | `/api/v1/sensors` | Alias de la dernière lecture, avec noms de sondes | +| GET | `/api/v1/history` | Historique glissant 24h en moyennes 5 min | +| GET | `/api/v1/mqtt` | Broker actif et topics utilisés | +| POST | `/api/v1/config/mqtt` | Met à jour broker/port et reconnecte MQTT | +| GET | `/api/v1/agent` | Résumé compact pour agent IA | **Exemple `/api/temperatures` :** ```json @@ -131,32 +155,165 @@ curl -X POST http://esp_jardin.local/api/config \ -d '{"intervalleMs": 5000, "mqttBroker": "10.0.0.3", "mqttPort": 1883}' ``` +Les paramètres MQTT sont sauvegardés dans `/config.json` sur LittleFS. Après sauvegarde depuis l'interface web, la connexion MQTT est coupée puis relancée avec le broker et le port enregistrés, sans attendre un redémarrage complet. + +**Exemple `/api/v1/readings/latest` :** +```json +{ + "device": "esp_jardin", + "unit": "C", + "aggregation": { + "history_window_s": 300, + "samples_per_average": 30 + }, + "readings": [ + { "index": 0, "name": "T°C Ext", "error": false, "temperature": 19.3 } + ] +} +``` + +**Exemple `/api/v1/history` :** +```json +{ + "device": "esp_jardin", + "unit": "C", + "range_s": 86400, + "resolution_s": 300, + "points_max": 288, + "sequence": 42, + "points": [ + { + "ts": 12600, + "age_s": 120, + "window_s": 300, + "t": [19.3, 22.1, null], + "samples": [30, 30, 0] + } + ] +} +``` + +L'historique est glissant sur 24h : `24 × 12 = 288` points. Chaque point est une moyenne de 5 minutes, soit `5 min × 60 s / 10 s = 30` mesures par sonde. Les valeurs sont exposées avec 1 chiffre après la virgule. + +L'historique est gardé en RAM pour servir rapidement l'API. Il est sauvegardé dans `/history.bin` sur LittleFS une fois par heure, après 12 nouveaux points moyens. Le fichier est compact : environ 3.8 Ko pour 24h complètes (`288` points × `3` sondes), très inférieur à la partition LittleFS de 128 Ko. + +Le buffer RAM est circulaire : les nouveaux points remplacent automatiquement les points de plus de 24h. La sauvegarde horaire écrit l'état compact des dernières 24h ; en cas de coupure électrique, on perd au maximum environ 1h d'historique non encore persisté. + --- ## MQTT -Topics publiés (retain=true) : +Topics publiés pour les températures : -| Sonde | Topic | Deadband | +| Sonde | Topic | Cadence | |---|---|---| -| T°C Extérieur | `maison/jardin/ext/temperature` | 0.2°C | -| T°C Serre | `maison/jardin/serre/temperature` | 0.1°C | -| T°C Sol | `maison/jardin/sol/temperature` | 0.1°C | +| T°C Extérieur | `maison/jardin/ext/temperature` | moyenne 5 min | +| T°C Serre | `maison/jardin/serre/temperature` | moyenne 5 min | +| T°C Sol | `maison/jardin/sol/temperature` | moyenne 5 min | -Payload : valeur numérique en string, ex : `"19.3"`. Les erreurs ne sont jamais publiées. +Le topic d'état publie toujours un payload simple, compatible availability : + +```text +maison/jardin/status online +``` + +Ce topic est publié en `retain=true`. Le Last Will MQTT publie `offline` sur le même topic si la carte plante, perd le WiFi, ou disparaît brutalement. Le broker peut mettre quelques secondes à détecter la perte selon le keepalive MQTT. + +Les topics température publient uniquement la moyenne 5 minutes validée, en JSON **non retained**. Les mesures brutes toutes les 10 secondes restent utilisées pour le live web, mais ne sont pas publiées en MQTT : + +```json +{ + "device": "esp_jardin", + "index": 0, + "sensor": "T°C Ext", + "temperature": 19.3, + "unit": "C", + "kind": "avg", + "window_s": 300, + "samples": 30, + "ts": 12600, + "error": false, + "uptime_s": 123, + "rssi": -63 +} +``` + +Les topics erreur publient un JSON `retain=true`, car ils représentent l'état courant d'une sonde : + +```json +{ + "device": "esp_jardin", + "index": 0, + "sensor": "T°C Ext", + "error": true, + "uptime_s": 123, + "rssi": -63 +} +``` + +Pour visualiser les interruptions dans les courbes, utiliser `maison/jardin/status` comme disponibilité de l'appareil. Quand le status passe à `offline`, la période doit être affichée comme indisponible plutôt que prolonger la dernière température. --- -## Mise à jour OTA (après déploiement) +## Mise à jour OTA + +L'OTA est disponible uniquement quand l'ESP est connecté en WiFi STA. Le mode AP de secours reste accessible pour l'interface web, mais les uploads OTA passent par le réseau WiFi principal. L'OTA n'utilise pas de mot de passe. + +Les fichiers générés par PlatformIO sont : + +| Type | Fichier | +|---|---| +| Firmware | `.pio/build/esp32dev/firmware.bin` | +| Filesystem LittleFS | `.pio/build/esp32dev/littlefs.bin` | + +Ordre conseillé : + +1. Mettre à jour le firmware. +2. Attendre le redémarrage de l'ESP. +3. Mettre à jour le filesystem LittleFS. +4. Recharger la page web et vérifier le footer `FW ... / UI ...`. + +### OTA via PlatformIO ```bash -# Remplacer 192.168.1.42 par l'IP réelle de la carte -pio run -t upload --upload-port 192.168.1.42 +# Compiler le firmware OTA sans téléverser +pio run -e esp32dev_ota -# Mot de passe OTA : Jardin2026 +# Compiler l'image filesystem LittleFS OTA sans téléverser +pio run -e esp32dev_ota -t buildfs + +# Firmware OTA via mDNS +pio run -e esp32dev_ota -t upload + +# Firmware OTA via IP directe +pio run -e esp32dev_ota -t upload --upload-port 192.168.1.42 + +# Filesystem LittleFS OTA via mDNS +pio run -e esp32dev_ota -t uploadfs + +# Filesystem LittleFS OTA via IP directe +pio run -e esp32dev_ota -t uploadfs --upload-port 192.168.1.42 ``` -Le mot de passe OTA peut être changé dans `include/config.h` (`OTA_PASS`) et `platformio.ini` (`upload_flags = --auth=...`). +### OTA via interface web + +Depuis l'interface web : + +1. Ouvrir `http://esp_jardin.local`. +2. Cliquer sur l'icône engrenage. +3. Dans `Mise à jour OTA firmware`, sélectionner `.pio/build/esp32dev/firmware.bin`. +4. Envoyer le firmware et attendre le redémarrage. +5. Revenir sur `http://esp_jardin.local`. +6. Dans `Mise à jour OTA filesystem`, sélectionner `.pio/build/esp32dev/littlefs.bin`. +7. Envoyer le filesystem et attendre le redémarrage. + +Sécurités appliquées par l'ESP avant écriture flash : + +- le champ firmware accepte uniquement un fichier nommé `firmware.bin`, +- le champ filesystem accepte uniquement un fichier nommé `littlefs.bin`, +- le firmware doit commencer par l'en-tête binaire ESP32 attendu, +- la taille du fichier doit tenir dans la partition cible, +- en cas d'erreur, l'ESP renvoie un message JSON et ne redémarre pas volontairement. --- @@ -168,7 +325,6 @@ Le mot de passe OTA peut être changé dans `include/config.h` (`OTA_PASS`) et ` | 32 / 33 | Capteur humidité sol | ADC1 (compatible WiFi) | | 25 / 26 | Relais / électrovanne | Digital out | | 14 | Pluviomètre à augets | Interruption | -| 27 | Bouton reset / forçage AP | Interruption | > **Ne jamais utiliser ADC2** (GPIO 34–39) quand le WiFi est actif — conflit hardware ESP32. @@ -178,8 +334,8 @@ Le mot de passe OTA peut être changé dans `include/config.h` (`OTA_PASS`) et ` | Symptôme | Cause probable | Solution | |---|---|---| -| Sonde affiche −127°C | Résistance pull-up absente ou câble défectueux | Vérifier la résistance 4.7 kΩ sur GPIO 4 | +| Sonde affiche −127°C | Résistance pull-up absente ou câble défectueux | Vérifier la résistance 4.7 kΩ sur GPIO27 / D6 | | Sonde affiche 85.0°C | Sonde en court-circuit ou alimentation insuffisante | Vérifier l'alimentation 3.3V | | Interface web inaccessible | LittleFS non flashé | `pio run -t uploadfs` | | `esp_jardin.local` ne répond pas | mDNS non supporté sur certains réseaux | Utiliser l'IP directe | -| OTA échoue | Mauvais mot de passe | Vérifier `OTA_PASS` dans `config.h` | +| OTA échoue | ESP non connecté au WiFi STA ou fichier `.bin` incorrect | Vérifier l'IP, la connexion WiFi et choisir `firmware.bin` ou `littlefs.bin` selon le champ utilisé | diff --git a/amelioration.md b/amelioration.md index da131d1..cd4aff0 100644 --- a/amelioration.md +++ b/amelioration.md @@ -1 +1,2 @@ -- deepsleep ? \ No newline at end of file +- deepsleep ? +- ajouter un bouton rst sur interface web diff --git a/data/index.html b/data/index.html index f51f45a..024c9e5 100644 --- a/data/index.html +++ b/data/index.html @@ -223,7 +223,6 @@ input.fi:focus{border-color:var(--accent)} /* ── Responsive ── */ @media(max-width:900px){ .panel-right{display:none} - .btn-cfg{display:inline-flex!important} } @media(max-width:700px){ .sondes-grid{grid-template-columns:1fr} @@ -330,6 +329,54 @@ input.fi:focus{border-color:var(--accent)} } .btn-scan:hover{background:var(--bg-3);color:var(--ink-1)} .btn-scan:disabled{cursor:not-allowed;color:var(--ink-4)} +/* ── Modal paramètres ── */ +.settings-modal-backdrop{ + display:none;position:fixed;inset:0; + background:rgba(0,0,0,.72);z-index:180; + align-items:flex-start;justify-content:center; + padding:48px 12px;overflow-y:auto; +} +.settings-modal-backdrop.open{display:flex} +.settings-modal{ + background:var(--bg-2);border:1px solid var(--border-2); + border-radius:12px;width:min(96vw,620px); + box-shadow:0 8px 40px rgba(0,0,0,.62); + padding:22px;display:flex;flex-direction:column;gap:16px; +} +.settings-header{display:flex;align-items:center;justify-content:space-between;gap:12px} +.settings-title{font-size:15px;font-weight:700;color:var(--ink-1);letter-spacing:.04em} +.settings-section{ + background:var(--bg-3);border:1px solid var(--border-1); + border-radius:10px;padding:14px;display:flex;flex-direction:column;gap:10px; +} +.settings-section-title{ + font-size:11px;font-weight:600;text-transform:uppercase; + letter-spacing:.08em;color:var(--ink-3); +} +.settings-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:10px} +.ota-row{display:grid;grid-template-columns:1fr auto;gap:10px;align-items:end} +.ota-file{ + width:100%;background:var(--bg-4);border:1px solid var(--border-2); + color:var(--ink-1);border-radius:8px;padding:7px; + font-family:var(--font-mono);font-size:12px; +} +.ota-progress{ + height:8px;background:var(--bg-4);border:1px solid var(--border-1); + border-radius:6px;overflow:hidden; +} +.ota-progress-bar{height:100%;width:0;background:var(--accent);transition:width .15s} +.ota-feedback{font-size:12px;font-family:var(--font-terminal);min-height:18px;color:var(--ink-3)} +.btn-ota{ + background:var(--accent);color:#1a1a1a;font-weight:700; + border:none;border-radius:9px;padding:9px 14px;cursor:pointer; + font-size:12px;font-family:var(--font-ui);white-space:nowrap; +} +.btn-ota:hover{background:var(--accent-soft);color:#fff} +.btn-ota:disabled{background:var(--bg-4);color:var(--ink-4);cursor:not-allowed} +@media(max-width:700px){ + .settings-grid{grid-template-columns:1fr} + .ota-row{grid-template-columns:1fr} +} @@ -340,6 +387,8 @@ input.fi:focus{border-color:var(--accent)}
RSSI —
+ + @@ -460,6 +509,9 @@ input.fi:focus{border-color:var(--accent)}
Sondes actives : —
+
+ FW — / UI — +
@@ -488,6 +540,56 @@ input.fi:focus{border-color:var(--accent)} + +
+
+
+ ⚙ Paramètres + +
+ +
+
Configuration
+
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ +
+
Mise à jour OTA firmware
+
+ + +
+
+
Fichier attendu : firmware.bin
+
+ +
+
Mise à jour OTA filesystem
+
+ + +
+
+
Fichier attendu : littlefs.bin
+
+
+
+
@@ -564,10 +666,12 @@ input.fi:focus{border-color:var(--accent)} CONSTANTES ────────────────────────────────────────────────────────── */ var MAX_POINTS = 288; +var UI_VERSION = '1.2.2'; var COULEURS = ['#fe8019','#3db0d1','#c882c8']; var NOMS_DEFAUT = ['T°C Ext','T°C Serre','T°C Sol']; var WS_RETRY_MS = 3000; var STATUS_REFRESH_MS = 30000; +var HIST_REFRESH_MS = 60000; /* ────────────────────────────────────────────────────────── ÉTAT GLOBAL @@ -578,6 +682,7 @@ var chartInst = null; var nomsReels = NOMS_DEFAUT.slice(); var premierMessage = true; var statusTimer = null; +var dernierHistSeq = 0; /* ────────────────────────────────────────────────────────── UTILITAIRES @@ -595,6 +700,16 @@ function fmtHeure(ts){ return String(d.getHours()).padStart(2,'0')+':'+String(d.getMinutes()).padStart(2,'0'); } +function fmtPointHistorique(pt){ + var d; + if(pt.age_s!==undefined){ + d = new Date(Date.now() - (parseInt(pt.age_s)||0)*1000); + } else { + d = new Date((parseInt(pt.ts)||0)*1000); + } + return String(d.getHours()).padStart(2,'0')+':'+String(d.getMinutes()).padStart(2,'0'); +} + function fmtRam(b){ b = b || 0; if(b>=1024)return (b/1024).toFixed(0)+' ko'; @@ -732,16 +847,18 @@ function ajouterPointChart(label,valeurs){ } function chargerHistorique(){ - fetch('/api/history') + fetch('/api/v1/history') .then(function(r){return r.json();}) - .then(function(hist){ + .then(function(res){ if(!chartInst) return; + var hist = Array.isArray(res) ? res : (res.points || []); if(!hist||!hist.length)return; + if(res.sequence!==undefined) dernierHistSeq = res.sequence; var d = chartInst.data; d.labels=[]; for(var i=0;i<3;i++) d.datasets[i].data=[]; hist.forEach(function(pt){ - d.labels.push(fmtHeure(pt.ts)); + d.labels.push(fmtPointHistorique(pt)); for(var i=0;i<3;i++){ var v = pt.t&&pt.t[i]!=null?parseFloat(pt.t[i]):null; d.datasets[i].data.push(isNaN(v)?null:v); @@ -752,6 +869,23 @@ function chargerHistorique(){ .catch(function(){/* pas d'historique disponible */}); } +function chargerDernieresValeurs(){ + fetch('/api/v1/readings/latest') + .then(function(r){return r.json();}) + .then(function(res){ + var readings = res.readings || []; + var actives = 0; + readings.forEach(function(s){ + var i = parseInt(s.index); + if(isNaN(i) || i<0 || i>=3) return; + majSonde(i, s.name||nomsReels[i], s.temperature, s.error); + if(!s.error) actives++; + }); + document.getElementById('sbSondes').textContent='Sondes actives : '+actives+'/3'; + }) + .catch(function(){}); +} + /* ────────────────────────────────────────────────────────── CARTES SONDES ────────────────────────────────────────────────────────── */ @@ -793,6 +927,20 @@ function chargerStatus(){ } function majStatus(s){ + var versionBadge = document.getElementById('versionBadge'); + if(versionBadge) versionBadge.textContent = 'FW ' + (s.version || '—') + ' / UI ' + UI_VERSION; + if(s.mqttBroker){ + ['cfgBroker','mCfgBroker','sCfgBroker'].forEach(function(id){ + var el = document.getElementById(id); + if(el && !el.matches(':focus')) el.value = s.mqttBroker; + }); + } + if(s.mqttPort){ + ['cfgPort','mCfgPort','sCfgPort'].forEach(function(id){ + var el = document.getElementById(id); + if(el && !el.matches(':focus')) el.value = s.mqttPort; + }); + } // Panneau droit setWifi(s.rssi,s.modeAP); setMqtt(s.mqttConnecte); @@ -907,14 +1055,10 @@ function traiterMessageWS(data){ document.getElementById('rssiBadge').textContent=data.rssi+' dBm'; } - // Graphique — timestamp = maintenant si absent du payload - var ts = data.ts?data.ts:Math.floor(Date.now()/1000); - var valeurs = data.sondes.map(function(s){ - if(s.erreur||s.temp==null) return null; - var v=parseFloat(s.temp); - return isNaN(v)?null:v; - }); - ajouterPointChart(fmtHeure(ts), valeurs); + // Le graphique utilise uniquement les moyennes 5 min. + if(data.histSeq!==undefined && data.histSeq!==dernierHistSeq){ + chargerHistorique(); + } } /* ────────────────────────────────────────────────────────── @@ -947,6 +1091,9 @@ function envoyerConfig(intervalEl,brokerEl,portEl,feedbackEl){ document.getElementById('mCfgInterval').value=payload.intervalleMs; document.getElementById('mCfgBroker').value=payload.mqttBroker; document.getElementById('mCfgPort').value=payload.mqttPort; + document.getElementById('sCfgInterval').value=payload.intervalleMs; + document.getElementById('sCfgBroker').value=payload.mqttBroker; + document.getElementById('sCfgPort').value=payload.mqttPort; } document.getElementById('btnApply').addEventListener('click',function(){ @@ -967,6 +1114,23 @@ document.getElementById('mBtnApply').addEventListener('click',function(){ ); }); +function redemarrerEsp(){ + if(!confirm('Redémarrer esp_jardin maintenant ?')) return; + fetch('/api/restart',{method:'POST'}) + .catch(function(){}); +} + +document.getElementById('btnRestart').addEventListener('click', redemarrerEsp); + +document.getElementById('sBtnApply').addEventListener('click',function(){ + envoyerConfig( + document.getElementById('sCfgInterval'), + document.getElementById('sCfgBroker'), + document.getElementById('sCfgPort'), + document.getElementById('sCfgFeedback') + ); +}); + /* ────────────────────────────────────────────────────────── MODAL MOBILE ────────────────────────────────────────────────────────── */ @@ -981,6 +1145,98 @@ modal.addEventListener('click',function(e){ if(e.target===modal) modal.classList.remove('open'); }); +/* ────────────────────────────────────────────────────────── + PARAMÈTRES ET OTA HTTP +────────────────────────────────────────────────────────── */ +var settingsModal = document.getElementById('settingsModalBackdrop'); + +function ouvrirSettings(){ + document.getElementById('sCfgInterval').value = document.getElementById('cfgInterval').value; + document.getElementById('sCfgBroker').value = document.getElementById('cfgBroker').value; + document.getElementById('sCfgPort').value = document.getElementById('cfgPort').value; + settingsModal.classList.add('open'); +} + +function fermerSettings(){ + settingsModal.classList.remove('open'); +} + +function envoyerOta(type){ + var isFirmware = type === 'firmware'; + var fileEl = document.getElementById(isFirmware ? 'otaFirmwareFile' : 'otaFilesystemFile'); + var bar = document.getElementById(isFirmware ? 'otaFirmwareBar' : 'otaFilesystemBar'); + var feedback = document.getElementById(isFirmware ? 'otaFirmwareFeedback' : 'otaFilesystemFeedback'); + var btn = document.getElementById(isFirmware ? 'btnOtaFirmware' : 'btnOtaFilesystem'); + var file = fileEl.files && fileEl.files[0]; + + if(!file){ + feedback.style.color = 'var(--err)'; + feedback.textContent = 'Sélectionner un fichier .bin.'; + return; + } + + if(!/\.bin$/i.test(file.name)){ + feedback.style.color = 'var(--err)'; + feedback.textContent = 'Le fichier doit être un .bin.'; + return; + } + + var form = new FormData(); + form.append('update', file, file.name); + var xhr = new XMLHttpRequest(); + xhr.open('POST', isFirmware ? '/api/ota/firmware' : '/api/ota/filesystem'); + + btn.disabled = true; + bar.style.width = '0%'; + feedback.style.color = 'var(--warn)'; + feedback.textContent = 'Upload en cours…'; + + xhr.upload.onprogress = function(evt){ + if(evt.lengthComputable){ + var pct = Math.round((evt.loaded / evt.total) * 100); + bar.style.width = pct + '%'; + feedback.textContent = 'Upload en cours… ' + pct + '%'; + } + }; + + xhr.onload = function(){ + if(xhr.status >= 200 && xhr.status < 300){ + bar.style.width = '100%'; + feedback.style.color = 'var(--ok)'; + feedback.textContent = 'Mise à jour envoyée. Redémarrage de l’ESP…'; + } else { + var msg = 'Erreur OTA HTTP ' + xhr.status + '.'; + try{ + var res = JSON.parse(xhr.responseText || '{}'); + if(res.erreur) msg = 'Erreur OTA : ' + res.erreur; + }catch(e){} + feedback.style.color = 'var(--err)'; + feedback.textContent = msg; + btn.disabled = false; + } + }; + + xhr.onerror = function(){ + feedback.style.color = 'var(--err)'; + feedback.textContent = 'Connexion interrompue pendant l’upload.'; + btn.disabled = false; + }; + + xhr.send(form); +} + +document.getElementById('btnSettings').addEventListener('click', ouvrirSettings); +document.getElementById('btnCloseSettings').addEventListener('click', fermerSettings); +settingsModal.addEventListener('click',function(e){ + if(e.target===settingsModal) fermerSettings(); +}); +document.getElementById('btnOtaFirmware').addEventListener('click',function(){ + envoyerOta('firmware'); +}); +document.getElementById('btnOtaFilesystem').addEventListener('click',function(){ + envoyerOta('filesystem'); +}); + /* ────────────────────────────────────────────────────────── HORLOGE STATUS BAR ────────────────────────────────────────────────────────── */ @@ -1182,9 +1438,11 @@ document.getElementById('btnTogglePass').addEventListener('click', function() { ────────────────────────────────────────────────────────── */ initChart(NOMS_DEFAUT); chargerHistorique(); +chargerDernieresValeurs(); chargerStatus(); connecterWS(); setInterval(chargerStatus, STATUS_REFRESH_MS); +setInterval(chargerHistorique, HIST_REFRESH_MS); setInterval(majHorloge, 1000); majHorloge(); diff --git a/image/board.png b/image/board.png new file mode 100644 index 0000000..7678d6f Binary files /dev/null and b/image/board.png differ diff --git a/image/board2.png b/image/board2.png new file mode 100644 index 0000000..89ef81f Binary files /dev/null and b/image/board2.png differ diff --git a/image/screwshield.png b/image/screwshield.png new file mode 100644 index 0000000..a28eae9 Binary files /dev/null and b/image/screwshield.png differ diff --git a/include/README b/include/README new file mode 100644 index 0000000..49819c0 --- /dev/null +++ b/include/README @@ -0,0 +1,37 @@ + +This directory is intended for project header files. + +A header file is a file containing C declarations and macro definitions +to be shared between several project source files. You request the use of a +header file in your project source file (C, C++, etc) located in `src` folder +by including it, with the C preprocessing directive `#include'. + +```src/main.c + +#include "header.h" + +int main (void) +{ + ... +} +``` + +Including a header file produces the same results as copying the header file +into each source file that needs it. Such copying would be time-consuming +and error-prone. With a header file, the related declarations appear +in only one place. If they need to be changed, they can be changed in one +place, and programs that include the header file will automatically use the +new version when next recompiled. The header file eliminates the labor of +finding and changing all the copies as well as the risk that a failure to +find one copy will result in inconsistencies within a program. + +In C, the convention is to give header files names that end with `.h'. + +Read more about using header files in official GCC documentation: + +* Include Syntax +* Include Operation +* Once-Only Headers +* Computed Includes + +https://gcc.gnu.org/onlinedocs/cpp/Header-Files.html diff --git a/include/config.h b/include/config.h index 2567d89..19a0832 100644 --- a/include/config.h +++ b/include/config.h @@ -3,21 +3,28 @@ #include #include +// ── Version ───────────────────────────────────────────────────────── +#define FIRMWARE_VERSION "1.2.2" + // ── Constantes matérielles ────────────────────────────────────────── -#define ONE_WIRE_BUS 4 // GPIO 4 — bus OneWire DS18B20 +#define ONE_WIRE_BUS 27 // GPIO27 / D6 — bus OneWire DS18B20 #define NB_SONDES 3 -#define HIST_TAILLE 288 // 24h × (3600/10/300 × 60) = 288 pts #define MESURE_INTERVALLE 10000 // ms entre deux acquisitions +#define HIST_TAILLE 288 // 24h × 12 points/h = 288 moyennes de 5 min +#define HIST_PERIODE_MS 300000 // 5 min par point d'historique +#define HIST_ECHANTILLONS (HIST_PERIODE_MS / MESURE_INTERVALLE) // 30 mesures +#define HIST_SAVE_POINTS 12 // sauvegarde LittleFS toutes les 12 moyennes = 1h // ── Constantes réseau ─────────────────────────────────────────────── -#define WIFI_SSID "Mon_Reseau_WiFi" -#define WIFI_PASS "Mon_Mot_De_Passe_Securise" +#define WIFI_SSID_1 "Livebox-5F80" +#define WIFI_PASS_1 "3LU7fupGoVMta3qRcW" +#define WIFI_SSID_2 "WifiHome2" +#define WIFI_PASS_2 "louca2212" #define AP_SSID "ESP_CHEF_JARDIN" #define AP_PASS "Jardin2026" #define WIFI_TIMEOUT_MS 30000 // timeout avant bascule AP #define WIFI_RETRY_MS 30000 // délai entre retries STA #define MDNS_NOM "esp_jardin" -#define OTA_PASS "Jardin2026" // ── Constantes MQTT ───────────────────────────────────────────────── #define MQTT_BROKER "10.0.0.3" @@ -26,11 +33,15 @@ #define MQTT_PASS_STR "" #define MQTT_CLIENT_ID "esp_jardin" #define MQTT_RETRY_MS 5000 +#define MQTT_TOPIC_BASE "maison/jardin" +#define MQTT_TOPIC_STATUS "maison/jardin/status" +#define MQTT_HEARTBEAT_MS 60000 // publication "online" toutes les 60s // ── Structure : configuration d'une sonde (immuable) ──────────────── struct SondeConfig { const char* nom; const char* topic; + const char* topicErreur; uint32_t intervalleMs; float deadband; }; @@ -47,6 +58,7 @@ struct SondeEtat { struct PointHistorique { uint32_t timestamp; float temps[NB_SONDES]; // NAN si sonde en erreur + uint8_t samples[NB_SONDES]; }; // ── Structure : état réseau ───────────────────────────────────────── @@ -63,9 +75,23 @@ extern SondeConfig sondesConfig[NB_SONDES]; extern SondeEtat sondesEtat[NB_SONDES]; extern PointHistorique historique[HIST_TAILLE]; extern uint16_t histIdx; +extern uint32_t histSeq; extern NetworkStatus netStatus; +extern char mqttBrokerActif[64]; +extern uint16_t mqttPortActif; // Mutex FreeRTOS protégeant l'accès concurrent au buffer historique // (Core 0 : callbacks AsyncWebServer vs Core 1 : loop/sensors_update) extern SemaphoreHandle_t xHistMutex; // Mutex FreeRTOS protégeant l'accès concurrent à sondesEtat[] extern SemaphoreHandle_t xSondesMutex; + +struct WifiCredential { + const char* ssid; + const char* password; +}; + +static constexpr WifiCredential WIFI_RESEAUX[] = { + { WIFI_SSID_1, WIFI_PASS_1 }, + { WIFI_SSID_2, WIFI_PASS_2 }, +}; +static constexpr uint8_t NB_WIFI_RESEAUX = sizeof(WIFI_RESEAUX) / sizeof(WIFI_RESEAUX[0]); diff --git a/include/history_storage.h b/include/history_storage.h new file mode 100644 index 0000000..87749d0 --- /dev/null +++ b/include/history_storage.h @@ -0,0 +1,4 @@ +#pragma once + +void history_load(); +void history_save(); diff --git a/include/mqtt_manager.h b/include/mqtt_manager.h index a81d49d..4b08383 100644 --- a/include/mqtt_manager.h +++ b/include/mqtt_manager.h @@ -5,3 +5,6 @@ void mqtt_init(); // À appeler à chaque loop() : reconnexion non-bloquante + publication deadband void mqtt_update(); + +// Applique un nouveau broker/port et force une reconnexion MQTT. +void mqtt_reconfigure(); diff --git a/lib/README b/lib/README new file mode 100644 index 0000000..9379397 --- /dev/null +++ b/lib/README @@ -0,0 +1,46 @@ + +This directory is intended for project specific (private) libraries. +PlatformIO will compile them to static libraries and link into the executable file. + +The source code of each library should be placed in a separate directory +("lib/your_library_name/[Code]"). + +For example, see the structure of the following example libraries `Foo` and `Bar`: + +|--lib +| | +| |--Bar +| | |--docs +| | |--examples +| | |--src +| | |- Bar.c +| | |- Bar.h +| | |- library.json (optional. for custom build options, etc) https://docs.platformio.org/page/librarymanager/config.html +| | +| |--Foo +| | |- Foo.c +| | |- Foo.h +| | +| |- README --> THIS FILE +| +|- platformio.ini +|--src + |- main.c + +Example contents of `src/main.c` using Foo and Bar: +``` +#include +#include + +int main (void) +{ + ... +} + +``` + +The PlatformIO Library Dependency Finder will find automatically dependent +libraries by scanning project source files. + +More information about PlatformIO Library Dependency Finder +- https://docs.platformio.org/page/librarymanager/ldf.html diff --git a/parametrage.md b/parametrage.md index d1899ce..ed5d0f4 100644 --- a/parametrage.md +++ b/parametrage.md @@ -2,8 +2,13 @@ ## Connexion WiFi - Mode Station (STA) : - - SSID: "Mon_Reseau_WiFi" - - PASS: "Mon_Mot_De_Passe_Securise" + - SSID: "Livebox-5F80" + - PASS: "3LU7fupGoVMta3qRcW" +- Mode Station (STA) : + - SSID: "WifiHome2" + - PASS: "louca2212" + + - Mode Access Point (AP de secours) : - AP_SSID: "ESP_CHEF_JARDIN" - AP_PASS: "Jardin2026" diff --git a/plan_corrections.md b/plan_corrections.md new file mode 100644 index 0000000..26dc8e4 --- /dev/null +++ b/plan_corrections.md @@ -0,0 +1,211 @@ +# Plan de corrections — esp_jardin + +Toutes les améliorations identifiées lors de la revue de code, classées par priorité. + +--- + +## ✅ Déjà corrigé dans cette session + +| Correction | Fichier | Description | +|---|---|---| +| Race condition historique | `sensors.cpp` | Utilise `tempLues[i]` au lieu de `sondesEtat[i]` dans le buffer historique | +| MQTT timeout bloquant | `mqtt_manager.cpp` | `_wifiClient.setTimeout(2000)` — limite le blocage à 2s max | +| LWT (Last Will Testament) | `mqtt_manager.cpp` | Le broker publie `offline` automatiquement si l'ESP disparaît | +| Heartbeat MQTT | `mqtt_manager.cpp` | Publication `online` toutes les 60s sur `maison/jardin/status` | +| Erreurs sonde MQTT | `mqtt_manager.cpp` | Publication `1`/`0` sur `maison/jardin/ext/erreur` au changement d'état | + +--- + +## Corrections restantes + +### Tâche 1 — DS18B20 : lecture par adresse ROM (priorité haute) + +**Problème :** `getTempCByIndex(i)` retourne les capteurs dans l'ordre d'énumération OneWire, qui peut changer si une sonde est débranchée ou remplacée. Cela peut inverser T°C Ext ↔ T°C Sol sans avertissement. + +**Fichiers :** `include/config.h`, `include/sensors.h`, `src/sensors.cpp` + +**Changements :** + +`include/sensors.h` — ajouter la déclaration : +```cpp +extern DeviceAddress sondesAddr[NB_SONDES]; +``` + +`src/sensors.cpp` — dans `sensors_init()`, scanner et mémoriser les adresses : +```cpp +DeviceAddress sondesAddr[NB_SONDES] = {}; + +void sensors_init() { + _sensors.begin(); + _sensors.setWaitForConversion(false); + uint8_t nb = _sensors.getDeviceCount(); + Serial.printf("[SONDES] %u capteur(s) détecté(s) sur GPIO %d\n", nb, ONE_WIRE_BUS); + for (uint8_t i = 0; i < NB_SONDES; i++) { + if (_sensors.getAddress(sondesAddr[i], i)) { + Serial.printf("[SONDE %u] Adresse: %02X:%02X:%02X:%02X:%02X:%02X:%02X:%02X\n", + i, sondesAddr[i][0], sondesAddr[i][1], sondesAddr[i][2], sondesAddr[i][3], + sondesAddr[i][4], sondesAddr[i][5], sondesAddr[i][6], sondesAddr[i][7]); + } else { + Serial.printf("[SONDE %u] Adresse introuvable\n", i); + } + } +} +``` + +Dans la boucle de lecture, remplacer : +```cpp +// Avant +float t = _sensors.getTempCByIndex(i); + +// Après +float t = _sensors.getTempC(sondesAddr[i]); +``` + +> **Note :** Les adresses ROM doivent être fixées au premier branchement. Si tu changes l'ordre physique des sondes, il faut reprogrammer la correspondance dans `main.cpp` ou les sauvegarder en NVS. + +--- + +### Tâche 2 — DS18B20 : résolution 11-bit (priorité faible) + +**Problème :** La résolution par défaut est 12 bits (précision 0,0625°C, conversion 750ms). Pour une station jardin, 11 bits suffisent largement (précision 0,125°C, conversion 375ms). + +**Fichiers :** `include/config.h`, `src/sensors.cpp` + +`config.h` — modifier la constante : +```cpp +// Avant +static const uint32_t CONVERSION_MS = 750; + +// Après (déplacer dans config.h) +#define CONVERSION_MS 375 // 11-bit : 375ms au lieu de 750ms +``` + +`sensors.cpp` — ajouter dans `sensors_init()` après `_sensors.begin()` : +```cpp +_sensors.setResolution(11); // 0.125°C, 375ms — suffisant pour jardin +``` + +--- + +### Tâche 3 — WiFi : RSSI lu trop souvent (priorité faible) + +**Problème :** `WiFi.RSSI()` est appelé à chaque itération de `loop()` (plusieurs milliers de fois par seconde), alors que la valeur change sur une échelle de secondes. + +**Fichier :** `src/network.cpp` + +Remplacer dans `network_update()` : +```cpp +// Avant +} else { + netStatus.rssi = WiFi.RSSI(); +} + +// Après +} else { + static uint32_t _dernierRssiMs = 0; + if (millis() - _dernierRssiMs >= 10000) { + netStatus.rssi = WiFi.RSSI(); + _dernierRssiMs = millis(); + } +} +``` + +--- + +### Tâche 4 — WiFi : persistent(false) (priorité faible) + +**Problème :** Sans `WiFi.persistent(false)`, le SDK ESP32 sauvegarde aussi les credentials en NVS automatiquement, en doublon de notre propre gestion NVS. + +**Fichier :** `src/network.cpp` + +Ajouter dans `_demarrerSTA()` avant `WiFi.begin()` : +```cpp +WiFi.persistent(false); // on gère la persistance nous-mêmes via Preferences +WiFi.begin(_ssidActif, _passActif); +``` + +--- + +### Tâche 5 — Web server : delay() dans callback async (priorité faible) + +**Problème :** `delay(500)` dans le handler `/api/wifi/connect` bloque la tâche AsyncTCP (Core 0) pendant 500ms avant le redémarrage. + +**Fichiers :** `include/web_server.h`, `src/web_server.cpp`, `src/main.cpp` + +`web_server.h` — ajouter la déclaration : +```cpp +void web_server_update(); // à appeler dans loop() +``` + +`web_server.cpp` — remplacer le delay par un flag : +```cpp +static bool _redemarrerDemande = false; + +// Dans le handler POST /api/wifi/connect, remplacer : +// delay(500); +// ESP.restart(); +// Par : +req->send(200, "application/json", "{\"ok\":true}"); +_redemarrerDemande = true; + +// Ajouter la fonction : +void web_server_update() { + if (_redemarrerDemande) { + delay(300); // dans loop() : delay() est toléré + ESP.restart(); + } +} +``` + +`main.cpp` — ajouter dans `loop()` : +```cpp +void loop() { + network_update(); + web_server_update(); // ajouter + bool nouvelleMesure = sensors_update(); + ... +} +``` + +--- + +### Tâche 6 — Historique : timestamps absolus via NTP (priorité optionnelle) + +**Problème :** Les timestamps sont basés sur `millis()` (ms depuis le démarrage). Après un reboot, ils repartent à 0. L'interface web ne peut pas afficher des heures réelles (ex: "23h15"). + +**Fichier :** `src/sensors.cpp`, `src/network.cpp` + +`network.cpp` — synchroniser l'heure NTP après connexion WiFi : +```cpp +#include + +// Dans _configurerMDNS() ou après la connexion WiFi : +configTime(3600, 3600, "pool.ntp.org"); // UTC+1, +1h été (à adapter) +Serial.println("[NTP] Synchronisation heure..."); +``` + +`sensors.cpp` — utiliser `time(nullptr)` comme timestamp : +```cpp +// Avant +historique[histIdx].timestamp = maintenant; // millis() + +// Après +historique[histIdx].timestamp = (uint32_t)time(nullptr); // Unix timestamp +``` + +`config.h` — adapter le type si nécessaire (uint32_t Unix timestamp est valide jusqu'en 2106). + +> **Note :** Les graphiques de l'interface web devront adapter l'axe X pour des Unix timestamps plutôt que des ms depuis le boot. + +--- + +## Ordre d'exécution recommandé + +| Priorité | Tâche | Risque si ignoré | +|---|---|---| +| 🔴 Haute | Tâche 1 — DS18B20 adresses ROM | Fausses lectures MQTT si sonde déconnectée | +| 🟡 Moyenne | Tâche 2 — Résolution 11-bit | Aucun, juste optimisation | +| 🟡 Moyenne | Tâche 3 — RSSI throttle | Aucun, CPU légèrement gaspillé | +| 🟢 Faible | Tâche 4 — WiFi persistent(false) | Doublon NVS, usure Flash négligeable | +| 🟢 Faible | Tâche 5 — delay() async | 500ms de blocage une seule fois (reboot) | +| ⚪ Optionnel | Tâche 6 — NTP timestamps | Graphiques sans heure réelle | diff --git a/platformio.ini b/platformio.ini index 39f51be..4702387 100644 --- a/platformio.ini +++ b/platformio.ini @@ -1,10 +1,11 @@ ; esp_jardin — station de monitoring jardin, ESP32 + 3× DS18B20 ; WiFi hybride STA/AP, interface web WebSocket, API REST, MQTT avec deadband -[env:esp32dev] +[env] platform = espressif32 board = esp32dev framework = arduino board_build.filesystem = littlefs +board_build.partitions = min_spiffs.csv lib_deps = esp32async/ESPAsyncWebServer @@ -14,5 +15,16 @@ lib_deps = knolleary/PubSubClient @ ^2.8 bblanchon/ArduinoJson @ ^7.2 -; OTA : pio run -t upload --upload-port --upload-flag=--auth=Jardin2026 -; USB : pio run -t upload (pas de flag auth nécessaire) +monitor_speed = 115200 + +[env:esp32dev] + +[env:esp32dev_ota] +upload_protocol = espota +upload_port = esp_jardin.local + +; USB firmware : pio run -e esp32dev -t upload +; USB LittleFS : pio run -e esp32dev -t uploadfs +; OTA firmware : pio run -e esp32dev_ota -t upload +; OTA LittleFS : pio run -e esp32dev_ota -t uploadfs +; OTA avec IP fixe : pio run -e esp32dev_ota -t upload --upload-port diff --git a/release/1.2.2/README.md b/release/1.2.2/README.md new file mode 100644 index 0000000..5315365 --- /dev/null +++ b/release/1.2.2/README.md @@ -0,0 +1,26 @@ +# esp_jardin 1.2.2 + +Build PlatformIO pour ESP32 Wemos D1 R32 / ESP32-D0WDQ6. + +## Fichiers + +| Fichier | Usage | +|---|---| +| `firmware.bin` | Mise à jour firmware OTA ou USB | +| `littlefs.bin` | Mise à jour interface web / filesystem LittleFS | +| `firmware.factory.bin` | Image complète USB pour flash initial ou restauration | + +## Ordre OTA Web + +1. Envoyer `firmware.bin`. +2. Attendre le redémarrage. +3. Envoyer `littlefs.bin`. +4. Recharger la page et vérifier le footer `FW 1.2.2 / UI 1.2.2`. + +## Checksums SHA-256 + +```text +243ed62606781a17a5fb0600f6f1da3d016ca198086fd8ee4ff552ae738e25e7 firmware.bin +4aecfaa04b93403ebef5fedffb292f100a1034f3e0ae17be3f0f4f700b914d9f littlefs.bin +6a4a32c62afff5fffc39819fa24463861f786203045ebe4968e301e7edc53462 firmware.factory.bin +``` diff --git a/release/1.2.2/firmware.bin b/release/1.2.2/firmware.bin new file mode 100644 index 0000000..9689444 Binary files /dev/null and b/release/1.2.2/firmware.bin differ diff --git a/release/1.2.2/firmware.factory.bin b/release/1.2.2/firmware.factory.bin new file mode 100644 index 0000000..4b16d50 Binary files /dev/null and b/release/1.2.2/firmware.factory.bin differ diff --git a/release/1.2.2/littlefs.bin b/release/1.2.2/littlefs.bin new file mode 100644 index 0000000..4a38941 Binary files /dev/null and b/release/1.2.2/littlefs.bin differ diff --git a/src/history_storage.cpp b/src/history_storage.cpp new file mode 100644 index 0000000..4c3d8ce --- /dev/null +++ b/src/history_storage.cpp @@ -0,0 +1,113 @@ +#include "history_storage.h" +#include "config.h" +#include +#include +#include + +static const char* HISTORY_PATH = "/history.bin"; +static const uint32_t HISTORY_MAGIC = 0x31484A45; // EJH1 +static const uint16_t HISTORY_VERSION = 1; +static const int16_t HISTORY_NAN = INT16_MIN; + +static bool _writeBytes(File& f, const void* data, size_t len) { + return f.write(reinterpret_cast(data), len) == len; +} + +static bool _readBytes(File& f, void* data, size_t len) { + return f.read(reinterpret_cast(data), len) == static_cast(len); +} + +void history_save() { + if (!xHistMutex) return; + if (xSemaphoreTake(xHistMutex, pdMS_TO_TICKS(50)) != pdTRUE) return; + + File f = LittleFS.open(HISTORY_PATH, "w"); + if (!f) { + xSemaphoreGive(xHistMutex); + Serial.println("[HIST] Impossible d'écrire /history.bin"); + return; + } + + bool ok = true; + ok &= _writeBytes(f, &HISTORY_MAGIC, sizeof(HISTORY_MAGIC)); + ok &= _writeBytes(f, &HISTORY_VERSION, sizeof(HISTORY_VERSION)); + ok &= _writeBytes(f, &histIdx, sizeof(histIdx)); + ok &= _writeBytes(f, &histSeq, sizeof(histSeq)); + + for (uint16_t i = 0; ok && i < HIST_TAILLE; i++) { + ok &= _writeBytes(f, &historique[i].timestamp, sizeof(historique[i].timestamp)); + for (uint8_t j = 0; ok && j < NB_SONDES; j++) { + int16_t temp10 = isnan(historique[i].temps[j]) + ? HISTORY_NAN + : static_cast(lroundf(historique[i].temps[j] * 10.0f)); + ok &= _writeBytes(f, &temp10, sizeof(temp10)); + ok &= _writeBytes(f, &historique[i].samples[j], sizeof(historique[i].samples[j])); + } + } + + f.close(); + xSemaphoreGive(xHistMutex); + Serial.printf("[HIST] Sauvegarde %s (%u points max, ~3.7 Ko)\n", ok ? "OK" : "échouée", HIST_TAILLE); +} + +void history_load() { + if (!xHistMutex || !LittleFS.exists(HISTORY_PATH)) return; + + File f = LittleFS.open(HISTORY_PATH, "r"); + if (!f) { + Serial.println("[HIST] Impossible d'ouvrir /history.bin"); + return; + } + + uint32_t magic = 0; + uint16_t version = 0; + uint16_t idx = 0; + uint32_t seq = 0; + bool ok = _readBytes(f, &magic, sizeof(magic)); + ok &= _readBytes(f, &version, sizeof(version)); + ok &= _readBytes(f, &idx, sizeof(idx)); + ok &= _readBytes(f, &seq, sizeof(seq)); + + if (!ok || magic != HISTORY_MAGIC || version != HISTORY_VERSION || idx >= HIST_TAILLE) { + f.close(); + Serial.println("[HIST] /history.bin invalide — historique ignoré"); + return; + } + + if (xSemaphoreTake(xHistMutex, pdMS_TO_TICKS(50)) != pdTRUE) { + f.close(); + return; + } + + for (uint16_t i = 0; ok && i < HIST_TAILLE; i++) { + ok &= _readBytes(f, &historique[i].timestamp, sizeof(historique[i].timestamp)); + for (uint8_t j = 0; ok && j < NB_SONDES; j++) { + int16_t temp10 = HISTORY_NAN; + ok &= _readBytes(f, &temp10, sizeof(temp10)); + ok &= _readBytes(f, &historique[i].samples[j], sizeof(historique[i].samples[j])); + historique[i].temps[j] = (temp10 == HISTORY_NAN) ? NAN : (temp10 / 10.0f); + } + } + + if (ok) { + uint16_t lastIdx = (idx + HIST_TAILLE - 1) % HIST_TAILLE; + uint32_t lastTs = historique[lastIdx].timestamp; + uint32_t nowSec = millis() / 1000; + if (lastTs > 0) { + for (uint16_t i = 0; i < HIST_TAILLE; i++) { + if (historique[i].timestamp == 0) continue; + uint32_t ageFromLast = lastTs >= historique[i].timestamp + ? lastTs - historique[i].timestamp + : 0; + historique[i].timestamp = ageFromLast <= (24UL * 3600UL) + ? (ageFromLast <= nowSec ? nowSec - ageFromLast : 1) + : 0; + } + } + histIdx = idx; + histSeq = seq; + } + xSemaphoreGive(xHistMutex); + f.close(); + Serial.printf("[HIST] Chargement %s depuis /history.bin\n", ok ? "OK" : "échoué"); +} diff --git a/src/main.cpp b/src/main.cpp index a04d749..db839c6 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -6,14 +6,17 @@ #include "mqtt_manager.h" SondeConfig sondesConfig[NB_SONDES] = { - { "T°C Ext", "maison/jardin/ext/temperature", 60000, 0.2f }, - { "T°C Serre", "maison/jardin/serre/temperature", 60000, 0.1f }, - { "T°C Sol", "maison/jardin/sol/temperature", 60000, 0.1f }, + { "T°C Ext", "maison/jardin/ext/temperature", "maison/jardin/ext/erreur", 60000, 0.2f }, + { "T°C Serre", "maison/jardin/serre/temperature", "maison/jardin/serre/erreur", 60000, 0.1f }, + { "T°C Sol", "maison/jardin/sol/temperature", "maison/jardin/sol/erreur", 60000, 0.1f }, }; SondeEtat sondesEtat[NB_SONDES] = {}; PointHistorique historique[HIST_TAILLE] = {}; uint16_t histIdx = 0; +uint32_t histSeq = 0; NetworkStatus netStatus = {}; +char mqttBrokerActif[64] = MQTT_BROKER; +uint16_t mqttPortActif = MQTT_PORT; SemaphoreHandle_t xHistMutex = nullptr; SemaphoreHandle_t xSondesMutex = nullptr; diff --git a/src/mqtt_manager.cpp b/src/mqtt_manager.cpp index ea166a2..c9ef67e 100644 --- a/src/mqtt_manager.cpp +++ b/src/mqtt_manager.cpp @@ -2,23 +2,79 @@ #include "config.h" #include #include +#include static WiFiClient _wifiClient; static PubSubClient _mqtt(_wifiClient); -static uint32_t _dernierRetryMs = 0; +static uint32_t _dernierRetryMs = 0; +static uint32_t _dernierHeartbeatMs = 0; +static uint32_t _dernierHistSeqPublie = 0; -// Tente une connexion MQTT (bloquant le temps de la poignée de main TCP) +// Dernier état d'erreur publié par sonde (évite de flooder le broker) +static bool _dernierErreur[NB_SONDES] = {}; + +static uint32_t _uptimeSec() { + return (millis() - netStatus.uptimeDemarrage) / 1000; +} + +static size_t _buildErreurPayload(uint8_t index, bool erreur, char* out, size_t outSize) { + JsonDocument doc; + doc["device"] = MQTT_CLIENT_ID; + doc["index"] = index; + doc["sensor"] = sondesConfig[index].nom; + doc["error"] = erreur; + doc["uptime_s"] = _uptimeSec(); + doc["rssi"] = netStatus.rssi; + return serializeJson(doc, out, outSize); +} + +static size_t _buildTemperaturePayload(uint8_t index, const PointHistorique& point, + char* out, size_t outSize) { + JsonDocument doc; + doc["device"] = MQTT_CLIENT_ID; + doc["index"] = index; + doc["sensor"] = sondesConfig[index].nom; + doc["temperature"] = serialized(String(point.temps[index], 1)); + doc["unit"] = "C"; + doc["kind"] = "avg"; + doc["window_s"] = HIST_PERIODE_MS / 1000; + doc["samples"] = point.samples[index]; + doc["ts"] = point.timestamp; + doc["error"] = false; + doc["uptime_s"] = _uptimeSec(); + doc["rssi"] = netStatus.rssi; + return serializeJson(doc, out, outSize); +} + +// Publie l'état de présence et les erreurs courantes après (re)connexion +static void _publierEtatInitial() { + _mqtt.publish(MQTT_TOPIC_STATUS, "online", true); + if (xSemaphoreTake(xSondesMutex, pdMS_TO_TICKS(20)) == pdTRUE) { + for (uint8_t i = 0; i < NB_SONDES; i++) { + char payload[192]; + _buildErreurPayload(i, sondesEtat[i].erreur, payload, sizeof(payload)); + _mqtt.publish(sondesConfig[i].topicErreur, payload, true); + _dernierErreur[i] = sondesEtat[i].erreur; + } + xSemaphoreGive(xSondesMutex); + } +} + +// Tente une connexion MQTT avec LWT (bloquant le temps de la poignée de main TCP) static bool _connecter() { - Serial.printf("[MQTT] Connexion → %s:%d\n", MQTT_BROKER, MQTT_PORT); + Serial.printf("[MQTT] Connexion → %s:%u\n", mqttBrokerActif, mqttPortActif); bool ok; if (strlen(MQTT_USER) > 0) { - ok = _mqtt.connect(MQTT_CLIENT_ID, MQTT_USER, MQTT_PASS_STR); + ok = _mqtt.connect(MQTT_CLIENT_ID, MQTT_USER, MQTT_PASS_STR, + MQTT_TOPIC_STATUS, 1, true, "offline"); } else { - ok = _mqtt.connect(MQTT_CLIENT_ID); + ok = _mqtt.connect(MQTT_CLIENT_ID, + MQTT_TOPIC_STATUS, 1, true, "offline"); } if (ok) { Serial.println("[MQTT] Connecté"); netStatus.mqttConnecte = true; + _publierEtatInitial(); } else { Serial.printf("[MQTT] Échec, état=%d\n", _mqtt.state()); netStatus.mqttConnecte = false; @@ -27,7 +83,21 @@ static bool _connecter() { } void mqtt_init() { - _mqtt.setServer(MQTT_BROKER, MQTT_PORT); + _wifiClient.setTimeout(2000); // limite le blocage de connect() à ~2s + _mqtt.setBufferSize(512); + _mqtt.setServer(mqttBrokerActif, mqttPortActif); +} + +void mqtt_reconfigure() { + Serial.printf("[MQTT] Reconfiguration → %s:%u\n", mqttBrokerActif, mqttPortActif); + if (_mqtt.connected()) { + _mqtt.disconnect(); + } + _mqtt.setServer(mqttBrokerActif, mqttPortActif); + _dernierRetryMs = 0; + _dernierHeartbeatMs = 0; + _dernierHistSeqPublie = histSeq; + netStatus.mqttConnecte = false; } void mqtt_update() { @@ -47,28 +117,68 @@ void mqtt_update() { // Traitement du keepalive et des messages entrants _mqtt.loop(); - // Publication par sonde avec filtre deadband + // Heartbeat périodique — prouve que l'ESP est vivant + if (millis() - _dernierHeartbeatMs >= MQTT_HEARTBEAT_MS) { + _mqtt.publish(MQTT_TOPIC_STATUS, "online", true); + _dernierHeartbeatMs = millis(); + } + + // Publication des erreurs en direct, mais pas des températures brutes. if (xSemaphoreTake(xSondesMutex, pdMS_TO_TICKS(10)) != pdTRUE) return; for (uint8_t i = 0; i < NB_SONDES; i++) { - if (sondesEtat[i].erreur) continue; - - float delta = fabsf(sondesEtat[i].tempActuelle - sondesEtat[i].dernierPublie); - uint32_t ecart = millis() - sondesEtat[i].dernierPubliMs; - bool deadbandOk = delta >= sondesConfig[i].deadband; - bool intervalOk = ecart >= sondesConfig[i].intervalleMs; - - if (deadbandOk || intervalOk) { - char payload[10]; - snprintf(payload, sizeof(payload), "%.1f", sondesEtat[i].tempActuelle); - bool ok = _mqtt.publish(sondesConfig[i].topic, payload, true); - if (ok) { - sondesEtat[i].dernierPublie = sondesEtat[i].tempActuelle; - sondesEtat[i].dernierPubliMs = millis(); - Serial.printf("[MQTT] %s → %s °C\n", sondesConfig[i].topic, payload); + if (sondesEtat[i].erreur) { + // Publier l'erreur uniquement au changement d'état + if (!_dernierErreur[i]) { + char payload[192]; + _buildErreurPayload(i, true, payload, sizeof(payload)); + _mqtt.publish(sondesConfig[i].topicErreur, payload, true); + _dernierErreur[i] = true; + Serial.printf("[MQTT] %s ERREUR détectée\n", sondesConfig[i].nom); } + continue; } + + // Sonde revenue en ligne après une erreur + if (_dernierErreur[i]) { + char payload[192]; + _buildErreurPayload(i, false, payload, sizeof(payload)); + _mqtt.publish(sondesConfig[i].topicErreur, payload, true); + _dernierErreur[i] = false; + Serial.printf("[MQTT] %s erreur résolue\n", sondesConfig[i].nom); + } + } xSemaphoreGive(xSondesMutex); + + // Publication MQTT uniquement à chaque nouvelle moyenne 5 min. + PointHistorique dernierPoint = {}; + uint32_t seqCourante = 0; + bool nouveauPoint = false; + if (xSemaphoreTake(xHistMutex, pdMS_TO_TICKS(10)) == pdTRUE) { + seqCourante = histSeq; + if (seqCourante != _dernierHistSeqPublie && seqCourante > 0) { + uint16_t idx = (histIdx + HIST_TAILLE - 1) % HIST_TAILLE; + dernierPoint = historique[idx]; + nouveauPoint = dernierPoint.timestamp > 0; + } + xSemaphoreGive(xHistMutex); + } + + if (!nouveauPoint) return; + + for (uint8_t i = 0; i < NB_SONDES; i++) { + if (dernierPoint.samples[i] == 0 || isnan(dernierPoint.temps[i])) continue; + + char payload[288]; + _buildTemperaturePayload(i, dernierPoint, payload, sizeof(payload)); + bool ok = _mqtt.publish(sondesConfig[i].topic, payload, false); + if (ok) { + sondesEtat[i].dernierPublie = dernierPoint.temps[i]; + sondesEtat[i].dernierPubliMs = millis(); + Serial.printf("[MQTT] AVG %s → %s\n", sondesConfig[i].topic, payload); + } + } + _dernierHistSeqPublie = seqCourante; } diff --git a/src/network.cpp b/src/network.cpp index 4dadc98..324f2e3 100644 --- a/src/network.cpp +++ b/src/network.cpp @@ -4,14 +4,20 @@ #include #include #include +#include static uint32_t _dernierRetryMs = 0; static uint32_t _debutConnexionMs = 0; static bool _connexionEnCours = false; +static uint8_t _wifiIndex = 0; +static bool _otaConfiguree = false; -// Credentials actifs (NVS ou config.h) +// Credentials actifs static char _ssidActif[64] = {}; static char _passActif[128] = {}; +static char _ssidNvs[64] = {}; +static char _passNvs[128] = {}; +static bool _nvsDisponible = false; static Preferences _prefs; // Retourne le SSID actuellement utilisé @@ -26,14 +32,42 @@ void network_save_credentials(const char* ssid, const char* password) { Serial.printf("[WIFI] Credentials sauvegardés: %s\n", ssid); } +static void _chargerCredentials(uint8_t index) { + if (index < NB_WIFI_RESEAUX) { + strncpy(_ssidActif, WIFI_RESEAUX[index].ssid, sizeof(_ssidActif) - 1); + strncpy(_passActif, WIFI_RESEAUX[index].password, sizeof(_passActif) - 1); + } else if (_nvsDisponible) { + strncpy(_ssidActif, _ssidNvs, sizeof(_ssidActif) - 1); + strncpy(_passActif, _passNvs, sizeof(_passActif) - 1); + } + _ssidActif[sizeof(_ssidActif) - 1] = '\0'; + _passActif[sizeof(_passActif) - 1] = '\0'; +} + +static uint8_t _nbWifiDisponibles() { + return NB_WIFI_RESEAUX + (_nvsDisponible ? 1 : 0); +} + +static bool _ssidPredefini(const String& ssid) { + for (uint8_t i = 0; i < NB_WIFI_RESEAUX; i++) { + if (ssid == WIFI_RESEAUX[i].ssid) return true; + } + return false; +} + // Démarre la tentative de connexion STA (non-bloquant) -static void _demarrerSTA() { - Serial.printf("[WIFI] Connexion STA → SSID: %s\n", _ssidActif); - WiFi.mode(WIFI_STA); +static void _demarrerSTA(uint8_t index, bool garderAP = false) { + _wifiIndex = index; + _chargerCredentials(_wifiIndex); + Serial.printf("[WIFI] Connexion STA %u/%u → SSID: %s\n", + _wifiIndex + 1, _nbWifiDisponibles(), _ssidActif); + WiFi.disconnect(false, false); + WiFi.mode(garderAP ? WIFI_AP_STA : WIFI_STA); + delay(50); // laisse le driver sortir proprement de l'état STA_CONNECTING WiFi.begin(_ssidActif, _passActif); _debutConnexionMs = millis(); _connexionEnCours = true; - netStatus.modeAP = false; // reset dès la tentative STA + netStatus.modeAP = garderAP; } static void _demarrerAP() { @@ -55,38 +89,65 @@ static void _configurerMDNS() { } static void _configurerOTA() { - ArduinoOTA.setPassword(OTA_PASS); + if (_otaConfiguree) return; + + ArduinoOTA.setHostname(MDNS_NOM); + ArduinoOTA.onStart([]() { - Serial.println("[OTA] Mise à jour démarrée"); + if (ArduinoOTA.getCommand() == U_FLASH) { + Serial.println("[OTA] Mise à jour firmware démarrée"); + } else { + Serial.println("[OTA] Mise à jour filesystem LittleFS démarrée"); + LittleFS.end(); + } }); + ArduinoOTA.onEnd([]() { - Serial.println("[OTA] Terminée — redémarrage"); + Serial.println("\n[OTA] Terminée — redémarrage"); }); + + ArduinoOTA.onProgress([](unsigned int progress, unsigned int total) { + Serial.printf("[OTA] Progression: %u%%\r", (progress * 100U) / total); + }); + ArduinoOTA.onError([](ota_error_t err) { - Serial.printf("[OTA] Erreur [%u]\n", err); + Serial.printf("\n[OTA] Erreur [%u]: ", err); + if (err == OTA_AUTH_ERROR) { + Serial.println("authentification"); + } else if (err == OTA_BEGIN_ERROR) { + Serial.println("démarrage"); + } else if (err == OTA_CONNECT_ERROR) { + Serial.println("connexion"); + } else if (err == OTA_RECEIVE_ERROR) { + Serial.println("réception"); + } else if (err == OTA_END_ERROR) { + Serial.println("finalisation"); + } else { + Serial.println("inconnue"); + } }); + ArduinoOTA.begin(); - Serial.println("[OTA] Prêt"); + _otaConfiguree = true; + Serial.println("[OTA] Prêt — firmware et LittleFS"); } void network_init() { - // Chargement des credentials depuis NVS - _prefs.begin("wifi", true); // true = lecture seule + _prefs.begin("wifi", true); String savedSsid = _prefs.getString("ssid", ""); String savedPass = _prefs.getString("pass", ""); _prefs.end(); - if (savedSsid.length() > 0) { - savedSsid.toCharArray(_ssidActif, sizeof(_ssidActif)); - savedPass.toCharArray(_passActif, sizeof(_passActif)); - Serial.printf("[WIFI] Credentials NVS: %s\n", _ssidActif); - } else { - strncpy(_ssidActif, WIFI_SSID, sizeof(_ssidActif) - 1); - strncpy(_passActif, WIFI_PASS, sizeof(_passActif) - 1); - Serial.printf("[WIFI] Credentials config.h: %s\n", _ssidActif); + if (savedSsid.length() > 0 && !_ssidPredefini(savedSsid)) { + savedSsid.toCharArray(_ssidNvs, sizeof(_ssidNvs)); + savedPass.toCharArray(_passNvs, sizeof(_passNvs)); + _nvsDisponible = true; + Serial.printf("[WIFI] Credentials NVS disponibles: %s\n", _ssidNvs); + } else if (savedSsid.length() > 0) { + Serial.printf("[WIFI] Credentials NVS ignorés car déjà configurés: %s\n", savedSsid.c_str()); } - _demarrerSTA(); + _demarrerSTA(0); } void network_update() { @@ -107,8 +168,12 @@ void network_update() { _configurerOTA(); } else if (millis() - _debutConnexionMs > WIFI_TIMEOUT_MS) { _connexionEnCours = false; - Serial.println("[WIFI] Timeout STA"); - _demarrerAP(); + Serial.printf("[WIFI] Timeout STA: %s\n", _ssidActif); + if (_wifiIndex + 1 < _nbWifiDisponibles()) { + _demarrerSTA(_wifiIndex + 1, netStatus.modeAP); + } else { + _demarrerAP(); + } } return; } @@ -117,8 +182,10 @@ void network_update() { if (netStatus.wifiConnecte && !netStatus.modeAP) { if (WiFi.status() != WL_CONNECTED) { netStatus.wifiConnecte = false; - Serial.println("[WIFI] Déconnexion détectée — retry dans 30s"); + _wifiIndex = 0; + Serial.println("[WIFI] Déconnexion détectée — bascule en AP de secours"); _dernierRetryMs = millis(); + _demarrerAP(); } else { netStatus.rssi = WiFi.RSSI(); } @@ -128,16 +195,12 @@ void network_update() { // ── Mode AP : retry STA toutes les 60s ────────────────────────── if (netStatus.modeAP) { if (millis() - _dernierRetryMs > 60000) { - Serial.println("[WIFI] Mode AP — retry STA..."); + Serial.println("[WIFI] Mode AP — retry STA depuis le réseau prioritaire..."); _dernierRetryMs = millis(); - _demarrerSTA(); + _demarrerSTA(0, true); } return; } - // ── STA déconnecté (hors AP) : retry toutes les 30s ───────────── - if (!netStatus.wifiConnecte && millis() - _dernierRetryMs > WIFI_RETRY_MS) { - _dernierRetryMs = millis(); - _demarrerSTA(); - } + // Hors boot, les retries STA sont volontairement réservés au mode AP. } diff --git a/src/sensors.cpp b/src/sensors.cpp index 230eabc..de1d6f7 100644 --- a/src/sensors.cpp +++ b/src/sensors.cpp @@ -1,27 +1,83 @@ #include "sensors.h" #include "config.h" +#include "history_storage.h" #include #include static OneWire _oneWire(ONE_WIRE_BUS); static DallasTemperature _sensors(&_oneWire); +// Adresses ROM des sondes (indexées dans l'ordre de détection au boot) +static DeviceAddress _sondesAddr[NB_SONDES] = {}; +static bool _sondesPresentes[NB_SONDES] = {}; +static uint8_t _nbSondesDetectees = 0; + // États de la machine non-bloquante static uint32_t _derniereMesureMs = 0; static uint32_t _demandeMs = 0; static bool _demandeEnCours = false; +static float _histSommes[NB_SONDES] = {}; +static uint8_t _histCounts[NB_SONDES] = {}; +static uint8_t _histEchantillons = 0; +static uint8_t _histPointsDepuisSave = 0; -// Conversion 12 bits = 750 ms -static const uint32_t CONVERSION_MS = 750; +// Résolution 11-bit → 375ms de conversion, précision 0.125°C +static const uint8_t RESOLUTION = 11; +static const uint32_t CONVERSION_MS = 375; void sensors_init() { _sensors.begin(); - _sensors.setWaitForConversion(false); // mode non-bloquant - uint8_t nb = _sensors.getDeviceCount(); - Serial.printf("[SONDES] %u capteur(s) DS18B20 détecté(s) sur GPIO %d\n", nb, ONE_WIRE_BUS); + _sensors.setWaitForConversion(false); + _sensors.setResolution(RESOLUTION); + + _nbSondesDetectees = _sensors.getDeviceCount(); + Serial.printf("[SONDES] %u capteur(s) DS18B20 détecté(s) sur GPIO %d\n", + _nbSondesDetectees, ONE_WIRE_BUS); + + // Lecture des adresses ROM pour chaque sonde configurée + for (uint8_t i = 0; i < NB_SONDES; i++) { + if (i < _nbSondesDetectees && _sensors.getAddress(_sondesAddr[i], i)) { + _sondesPresentes[i] = true; + Serial.printf("[SONDE %u] %s — adresse ROM: " + "%02X:%02X:%02X:%02X:%02X:%02X:%02X:%02X\n", + i, sondesConfig[i].nom, + _sondesAddr[i][0], _sondesAddr[i][1], + _sondesAddr[i][2], _sondesAddr[i][3], + _sondesAddr[i][4], _sondesAddr[i][5], + _sondesAddr[i][6], _sondesAddr[i][7]); + _sensors.setResolution(_sondesAddr[i], RESOLUTION); + } else { + _sondesPresentes[i] = false; + Serial.printf("[SONDE %u] %s — non détectée\n", i, sondesConfig[i].nom); + } + } + + if (xSemaphoreTake(xSondesMutex, pdMS_TO_TICKS(10)) == pdTRUE) { + for (uint8_t i = 0; i < NB_SONDES; i++) { + sondesEtat[i].erreur = true; + sondesEtat[i].tempActuelle = NAN; + sondesEtat[i].dernierPublie = NAN; + } + xSemaphoreGive(xSondesMutex); + } + + // Aucune sonde : on initialise quand même sondesEtat en erreur + if (_nbSondesDetectees == 0) { + Serial.println("[SONDES] Aucune sonde — fonctionnement dégradé, API et MQTT actifs"); + if (xSemaphoreTake(xSondesMutex, pdMS_TO_TICKS(10)) == pdTRUE) { + for (uint8_t i = 0; i < NB_SONDES; i++) { + sondesEtat[i].erreur = true; + sondesEtat[i].tempActuelle = NAN; + } + xSemaphoreGive(xSondesMutex); + } + } } bool sensors_update() { + // Aucune sonde détectée au boot : pas de machine d'état, pas de blocage + if (_nbSondesDetectees == 0) return false; + uint32_t maintenant = millis(); // ── Lancement de la demande de conversion ──────────────────────── @@ -32,16 +88,23 @@ bool sensors_update() { return false; } - // ── Attente de la fin de conversion (750 ms) ───────────────────── + // ── Attente de la fin de conversion ───────────────────────────── if (_demandeEnCours && maintenant - _demandeMs >= CONVERSION_MS) { _demandeEnCours = false; _derniereMesureMs = maintenant; - // Lecture et validation de chaque sonde + // Lecture par adresse ROM (stable même si une sonde est absente) float tempLues[NB_SONDES]; bool erreurLues[NB_SONDES]; + for (uint8_t i = 0; i < NB_SONDES; i++) { - float t = _sensors.getTempCByIndex(i); + if (!_sondesPresentes[i]) { + // Sonde non détectée au boot → erreur permanente sans lecture + erreurLues[i] = true; + tempLues[i] = NAN; + continue; + } + float t = _sensors.getTempC(_sondesAddr[i]); if (t == DEVICE_DISCONNECTED_C || t == 85.0f) { erreurLues[i] = true; tempLues[i] = NAN; @@ -53,7 +116,7 @@ bool sensors_update() { } } - // Écriture atomique dans sondesEtat (protégé Core 0 AsyncWebServer) + // Écriture atomique dans sondesEtat if (xSemaphoreTake(xSondesMutex, pdMS_TO_TICKS(10)) == pdTRUE) { for (uint8_t i = 0; i < NB_SONDES; i++) { sondesEtat[i].erreur = erreurLues[i]; @@ -62,14 +125,43 @@ bool sensors_update() { xSemaphoreGive(xSondesMutex); } - // Enregistrement dans le buffer circulaire (protégé contre Core 0) - if (xSemaphoreTake(xHistMutex, pdMS_TO_TICKS(10)) == pdTRUE) { - historique[histIdx].timestamp = maintenant; - for (uint8_t i = 0; i < NB_SONDES; i++) { - historique[histIdx].temps[i] = sondesEtat[i].tempActuelle; + // Agrégation glissante 5 min : 30 mesures de 10s par point historique. + _histEchantillons++; + for (uint8_t i = 0; i < NB_SONDES; i++) { + if (!erreurLues[i]) { + _histSommes[i] += tempLues[i]; + _histCounts[i]++; + } + } + + bool pointHistoriqueFinalise = false; + if (_histEchantillons >= HIST_ECHANTILLONS) { + if (xSemaphoreTake(xHistMutex, pdMS_TO_TICKS(10)) == pdTRUE) { + historique[histIdx].timestamp = maintenant / 1000; + for (uint8_t i = 0; i < NB_SONDES; i++) { + historique[histIdx].samples[i] = _histCounts[i]; + if (_histCounts[i] > 0) { + historique[histIdx].temps[i] = _histSommes[i] / _histCounts[i]; + } else { + historique[histIdx].temps[i] = NAN; + } + _histSommes[i] = 0.0f; + _histCounts[i] = 0; + } + histIdx = (histIdx + 1) % HIST_TAILLE; + histSeq++; + xSemaphoreGive(xHistMutex); + _histEchantillons = 0; + pointHistoriqueFinalise = true; + } + } + + if (pointHistoriqueFinalise) { + _histPointsDepuisSave++; + if (_histPointsDepuisSave >= HIST_SAVE_POINTS) { + history_save(); + _histPointsDepuisSave = 0; } - histIdx = (histIdx + 1) % HIST_TAILLE; - xSemaphoreGive(xHistMutex); } return true; // nouvelle mesure disponible diff --git a/src/web_server.cpp b/src/web_server.cpp index c9fa718..49283ad 100644 --- a/src/web_server.cpp +++ b/src/web_server.cpp @@ -1,13 +1,179 @@ #include "web_server.h" #include "network.h" #include "config.h" +#include "mqtt_manager.h" +#include "history_storage.h" #include #include #include #include +#include +#include +#include static AsyncWebServer _server(80); static AsyncWebSocket _ws("/ws"); +static bool _otaUploadValide = true; +static String _otaErreur; +static const char* CONFIG_PATH = "/config.json"; +static volatile bool _restartPlanifie = false; + +static void _restartTask(void*) { + vTaskDelay(pdMS_TO_TICKS(700)); + ESP.restart(); + vTaskDelete(nullptr); +} + +static void _scheduleRestart() { + if (_restartPlanifie) return; + _restartPlanifie = true; + xTaskCreate(_restartTask, "http_restart", 2048, nullptr, 1, nullptr); +} + +static void _saveRuntimeConfig() { + JsonDocument doc; + doc["mqttBroker"] = mqttBrokerActif; + doc["mqttPort"] = mqttPortActif; + + File f = LittleFS.open(CONFIG_PATH, "w"); + if (!f) { + Serial.println("[CONFIG] Impossible d'écrire /config.json"); + return; + } + serializeJson(doc, f); + f.close(); + Serial.println("[CONFIG] /config.json sauvegardé"); +} + +static void _loadRuntimeConfig() { + if (!LittleFS.exists(CONFIG_PATH)) { + Serial.println("[CONFIG] Aucun /config.json — valeurs par défaut"); + return; + } + + File f = LittleFS.open(CONFIG_PATH, "r"); + if (!f) { + Serial.println("[CONFIG] Impossible d'ouvrir /config.json"); + return; + } + + JsonDocument doc; + DeserializationError err = deserializeJson(doc, f); + f.close(); + if (err) { + Serial.println("[CONFIG] /config.json invalide — valeurs par défaut"); + return; + } + + const char* broker = doc["mqttBroker"] | MQTT_BROKER; + uint16_t port = doc["mqttPort"] | MQTT_PORT; + if (strlen(broker) > 0 && port > 0) { + strlcpy(mqttBrokerActif, broker, sizeof(mqttBrokerActif)); + mqttPortActif = port; + Serial.printf("[CONFIG] MQTT chargé: %s:%u\n", mqttBrokerActif, mqttPortActif); + } +} + +static void _otaReject(const char* erreur) { + _otaUploadValide = false; + _otaErreur = erreur; + Serial.printf("[HTTP OTA] Refus: %s\n", erreur); +} + +static String _jsonErreur(const String& erreur) { + String out = "{\"ok\":false,\"erreur\":\""; + out += erreur; + out += "\"}"; + return out; +} + +static size_t _partitionSize(int command) { + if (command == U_FLASH) { + const esp_partition_t* part = esp_ota_get_next_update_partition(nullptr); + return part ? part->size : 0; + } + const esp_partition_t* part = esp_partition_find_first( + ESP_PARTITION_TYPE_DATA, ESP_PARTITION_SUBTYPE_DATA_SPIFFS, nullptr); + return part ? part->size : 0; +} + +static bool _nomFichierOk(const String& filename, int command) { + String lower = filename; + lower.toLowerCase(); + if (command == U_FLASH) { + return lower.endsWith("firmware.bin"); + } + return lower.endsWith("littlefs.bin"); +} + +static void _handleOtaUpload(AsyncWebServerRequest* req, const String& filename, + size_t index, uint8_t* data, size_t len, + bool final, int command) { + if (index == 0) { + _otaUploadValide = true; + _otaErreur = ""; + Serial.printf("[HTTP OTA] Début upload %s: %s\n", + command == U_FLASH ? "firmware" : "LittleFS", + filename.c_str()); + + if (!_nomFichierOk(filename, command)) { + _otaReject(command == U_FLASH ? "fichier_firmware_attendu" : "fichier_littlefs_attendu"); + return; + } + + if (command == U_FLASH && (len == 0 || data[0] != 0xE9)) { + _otaReject("firmware_magic_invalide"); + return; + } + + size_t maxSize = _partitionSize(command); + size_t contentLength = req->contentLength(); + if (maxSize == 0 || contentLength == 0 || contentLength > maxSize) { + _otaReject(command == U_FLASH ? "taille_firmware_invalide" : "taille_littlefs_invalide"); + return; + } + + if (command != U_FLASH) { + LittleFS.end(); + } + if (!Update.begin(maxSize, command)) { + Update.printError(Serial); + } + } + + if (!_otaUploadValide) return; + + if (!Update.hasError() && Update.write(data, len) != len) { + Update.printError(Serial); + } + + if (final) { + if (Update.end(true)) { + Serial.printf("[HTTP OTA] Upload terminé: %u octets\n", index + len); + } else { + Update.printError(Serial); + if (command != U_FLASH) { + LittleFS.begin(); + } + } + } +} + +static void _sendOtaResult(AsyncWebServerRequest* req) { + bool ok = _otaUploadValide && !Update.hasError(); + String body = ok ? "{\"ok\":true,\"restart\":true}" : + _jsonErreur(_otaErreur.length() ? _otaErreur : "mise_a_jour_echouee"); + AsyncWebServerResponse* res = req->beginResponse( + ok ? 200 : 500, + "application/json", + body + ); + res->addHeader("Connection", "close"); + req->send(res); + if (ok) { + _scheduleRestart(); + } +} // ── Construction JSON temps réel ───────────────────────────────────── static String _buildJsonSondes() { @@ -28,32 +194,127 @@ static String _buildJsonSondes() { } doc["uptime"] = (millis() - netStatus.uptimeDemarrage) / 1000; doc["rssi"] = netStatus.rssi; + doc["histSeq"] = histSeq; + doc["histResolutionS"] = HIST_PERIODE_MS / 1000; String out; serializeJson(doc, out); return out; } // ── Construction JSON historique ───────────────────────────────────── -static String _buildJsonHistory() { - JsonDocument doc; - JsonArray arr = doc.to(); +static void _appendHistoryPoints(JsonArray arr) { if (xSemaphoreTake(xHistMutex, pdMS_TO_TICKS(50)) == pdTRUE) { + uint16_t nbPoints = 0; + for (uint16_t i = 0; i < HIST_TAILLE; i++) { + uint16_t idx = (histIdx + i) % HIST_TAILLE; + if (historique[idx].timestamp != 0) nbPoints++; + } + uint16_t pointEmis = 0; for (uint16_t i = 0; i < HIST_TAILLE; i++) { uint16_t idx = (histIdx + i) % HIST_TAILLE; if (historique[idx].timestamp == 0) continue; JsonObject pt = arr.add(); pt["ts"] = historique[idx].timestamp; + uint32_t age = (nbPoints - pointEmis - 1) * (HIST_PERIODE_MS / 1000); + pt["age_s"] = age; + pt["window_s"] = HIST_PERIODE_MS / 1000; JsonArray t = pt["t"].to(); + JsonArray samples = pt["samples"].to(); for (uint8_t j = 0; j < NB_SONDES; j++) { + samples.add(historique[idx].samples[j]); if (isnan(historique[idx].temps[j])) { t.add(nullptr); } else { t.add(String(historique[idx].temps[j], 1)); } } + pointEmis++; } xSemaphoreGive(xHistMutex); } +} + +static String _buildJsonHistory() { + JsonDocument doc; + JsonArray arr = doc.to(); + _appendHistoryPoints(arr); + String out; + serializeJson(doc, out); + return out; +} + +static String _buildJsonHistoryV1() { + JsonDocument doc; + doc["device"] = MQTT_CLIENT_ID; + doc["unit"] = "C"; + doc["range_s"] = 24UL * 3600UL; + doc["resolution_s"] = HIST_PERIODE_MS / 1000; + doc["points_max"] = HIST_TAILLE; + doc["sequence"] = histSeq; + JsonArray arr = doc["points"].to(); + _appendHistoryPoints(arr); + String out; + serializeJson(doc, out); + return out; +} + +static String _buildJsonLatestV1() { + JsonDocument doc; + doc["device"] = MQTT_CLIENT_ID; + doc["unit"] = "C"; + doc["aggregation"]["history_window_s"] = HIST_PERIODE_MS / 1000; + doc["aggregation"]["samples_per_average"] = HIST_ECHANTILLONS; + JsonArray arr = doc["readings"].to(); + if (xSemaphoreTake(xSondesMutex, pdMS_TO_TICKS(20)) == pdTRUE) { + for (uint8_t i = 0; i < NB_SONDES; i++) { + JsonObject s = arr.add(); + s["index"] = i; + s["name"] = sondesConfig[i].nom; + s["error"] = sondesEtat[i].erreur; + if (!sondesEtat[i].erreur) { + s["temperature"] = serialized(String(sondesEtat[i].tempActuelle, 1)); + } else { + s["temperature"] = nullptr; + } + } + xSemaphoreGive(xSondesMutex); + } + String out; + serializeJson(doc, out); + return out; +} + +static String _buildJsonAgentV1() { + JsonDocument doc; + doc["device"] = MQTT_CLIENT_ID; + doc["firmware"] = FIRMWARE_VERSION; + doc["api"] = "1.0"; + doc["status"]["wifi"] = netStatus.wifiConnecte; + doc["status"]["mode"] = netStatus.modeAP ? "AP" : "STA"; + doc["status"]["mqtt"] = netStatus.mqttConnecte; + doc["status"]["rssi"] = netStatus.rssi; + doc["status"]["uptime_s"] = (millis() - netStatus.uptimeDemarrage) / 1000; + doc["history"]["range_s"] = 24UL * 3600UL; + doc["history"]["resolution_s"] = HIST_PERIODE_MS / 1000; + doc["history"]["points_max"] = HIST_TAILLE; + doc["history"]["sequence"] = histSeq; + JsonArray sensors = doc["sensors"].to(); + if (xSemaphoreTake(xSondesMutex, pdMS_TO_TICKS(20)) == pdTRUE) { + for (uint8_t i = 0; i < NB_SONDES; i++) { + JsonObject s = sensors.add(); + s["index"] = i; + s["name"] = sondesConfig[i].nom; + s["topic"] = sondesConfig[i].topic; + s["error_topic"] = sondesConfig[i].topicErreur; + s["error"] = sondesEtat[i].erreur; + if (!sondesEtat[i].erreur) { + s["temperature"] = serialized(String(sondesEtat[i].tempActuelle, 1)); + } else { + s["temperature"] = nullptr; + } + } + xSemaphoreGive(xSondesMutex); + } String out; serializeJson(doc, out); return out; @@ -74,6 +335,8 @@ void web_server_init() { Serial.println("[FS] Erreur montage LittleFS — interface web indisponible, API REST active"); } else { Serial.println("[FS] LittleFS monté"); + _loadRuntimeConfig(); + history_load(); } _ws.onEvent(_onWsEvent); @@ -87,6 +350,9 @@ void web_server_init() { doc["ramLibre"] = ESP.getFreeHeap(); doc["modeAP"] = netStatus.modeAP; doc["mqttConnecte"] = netStatus.mqttConnecte; + doc["version"] = FIRMWARE_VERSION; + doc["mqttBroker"] = mqttBrokerActif; + doc["mqttPort"] = mqttPortActif; String out; serializeJson(doc, out); req->send(200, "application/json", out); @@ -117,6 +383,101 @@ void web_server_init() { req->send(200, "application/json", _buildJsonHistory()); }); + // API REST v1 — endpoints lisibles par un agent IA + _server.on("/api/v1/info", HTTP_GET, [](AsyncWebServerRequest* req) { + JsonDocument doc; + doc["device"] = MQTT_CLIENT_ID; + doc["firmware"] = FIRMWARE_VERSION; + doc["api"] = "1.0"; + doc["sensors"] = NB_SONDES; + doc["history_range_s"] = 24UL * 3600UL; + doc["history_resolution_s"] = HIST_PERIODE_MS / 1000; + doc["history_points"] = HIST_TAILLE; + doc["mqtt_temperature_payload"] = "json_avg_5m"; + String out; + serializeJson(doc, out); + req->send(200, "application/json", out); + }); + + _server.on("/api/v1/status", HTTP_GET, [](AsyncWebServerRequest* req) { + JsonDocument doc; + doc["device"] = MQTT_CLIENT_ID; + doc["firmware"] = FIRMWARE_VERSION; + doc["rssi"] = netStatus.rssi; + doc["uptime"] = (millis() - netStatus.uptimeDemarrage) / 1000; + doc["ramLibre"] = ESP.getFreeHeap(); + doc["modeAP"] = netStatus.modeAP; + doc["wifiConnecte"] = netStatus.wifiConnecte; + doc["mqttConnecte"] = netStatus.mqttConnecte; + doc["mqttBroker"] = mqttBrokerActif; + doc["mqttPort"] = mqttPortActif; + String out; + serializeJson(doc, out); + req->send(200, "application/json", out); + }); + + _server.on("/api/v1/readings/latest", HTTP_GET, [](AsyncWebServerRequest* req) { + req->send(200, "application/json", _buildJsonLatestV1()); + }); + + _server.on("/api/v1/sensors", HTTP_GET, [](AsyncWebServerRequest* req) { + req->send(200, "application/json", _buildJsonLatestV1()); + }); + + _server.on("/api/v1/history", HTTP_GET, [](AsyncWebServerRequest* req) { + req->send(200, "application/json", _buildJsonHistoryV1()); + }); + + _server.on("/api/v1/mqtt", HTTP_GET, [](AsyncWebServerRequest* req) { + JsonDocument doc; + doc["broker"] = mqttBrokerActif; + doc["port"] = mqttPortActif; + doc["connected"] = netStatus.mqttConnecte; + doc["status_topic"] = MQTT_TOPIC_STATUS; + doc["temperature_payload"] = "json_avg_5m"; + doc["retained_temperatures"] = false; + JsonArray sensors = doc["sensors"].to(); + for (uint8_t i = 0; i < NB_SONDES; i++) { + JsonObject s = sensors.add(); + s["index"] = i; + s["name"] = sondesConfig[i].nom; + s["temperature_topic"] = sondesConfig[i].topic; + s["error_topic"] = sondesConfig[i].topicErreur; + } + String out; + serializeJson(doc, out); + req->send(200, "application/json", out); + }); + + _server.on("/api/v1/config/mqtt", HTTP_POST, + [](AsyncWebServerRequest* req) {}, + nullptr, + [](AsyncWebServerRequest* req, uint8_t* data, size_t len, size_t, size_t) { + JsonDocument doc; + if (deserializeJson(doc, data, len)) { + req->send(400, "application/json", "{\"erreur\":\"JSON invalide\"}"); + return; + } + const char* broker = doc["broker"] | ""; + if (strlen(broker) == 0) broker = doc["mqttBroker"] | ""; + uint16_t port = doc["port"] | 0; + if (port == 0) port = doc["mqttPort"] | 0; + if (strlen(broker) == 0 || port == 0) { + req->send(400, "application/json", "{\"erreur\":\"broker_ou_port_invalide\"}"); + return; + } + strlcpy(mqttBrokerActif, broker, sizeof(mqttBrokerActif)); + mqttPortActif = port; + _saveRuntimeConfig(); + mqtt_reconfigure(); + req->send(200, "application/json", "{\"ok\":true,\"reconnect\":true}"); + } + ); + + _server.on("/api/v1/agent", HTTP_GET, [](AsyncWebServerRequest* req) { + req->send(200, "application/json", _buildJsonAgentV1()); + }); + // POST /api/config — body: {"intervalleMs":10000,"mqttBroker":"10.0.0.3","mqttPort":1883} _server.on("/api/config", HTTP_POST, [](AsyncWebServerRequest* req) {}, @@ -131,10 +492,33 @@ void web_server_init() { if (doc["intervalleMs"].is()) { Serial.printf("[CONFIG] intervalleMs: %u\n", (uint32_t)doc["intervalleMs"]); } + bool mqttConfigOk = false; + const char* broker = doc["mqttBroker"] | ""; + uint16_t port = doc["mqttPort"] | 0; + if (strlen(broker) > 0) { + strlcpy(mqttBrokerActif, broker, sizeof(mqttBrokerActif)); + mqttConfigOk = true; + } + if (port > 0) { + mqttPortActif = port; + mqttConfigOk = true; + } + _saveRuntimeConfig(); + if (mqttConfigOk) { + mqtt_reconfigure(); + } req->send(200, "application/json", "{\"ok\":true}"); } ); + // POST /api/restart — redémarrage logiciel depuis l'interface web + _server.on("/api/restart", HTTP_POST, [](AsyncWebServerRequest* req) { + AsyncWebServerResponse* res = req->beginResponse(200, "application/json", "{\"ok\":true,\"restart\":true}"); + res->addHeader("Connection", "close"); + req->send(res); + _scheduleRestart(); + }); + // GET /api/wifi/current — infos réseau actuel _server.on("/api/wifi/current", HTTP_GET, [](AsyncWebServerRequest* req) { JsonDocument doc; @@ -208,6 +592,28 @@ void web_server_init() { } ); + // POST /api/ota/firmware — upload HTTP du firmware .bin + _server.on("/api/ota/firmware", HTTP_POST, + [](AsyncWebServerRequest* req) { + _sendOtaResult(req); + }, + [](AsyncWebServerRequest* req, const String& filename, size_t index, + uint8_t* data, size_t len, bool final) { + _handleOtaUpload(req, filename, index, data, len, final, U_FLASH); + } + ); + + // POST /api/ota/filesystem — upload HTTP de l'image LittleFS .bin + _server.on("/api/ota/filesystem", HTTP_POST, + [](AsyncWebServerRequest* req) { + _sendOtaResult(req); + }, + [](AsyncWebServerRequest* req, const String& filename, size_t index, + uint8_t* data, size_t len, bool final) { + _handleOtaUpload(req, filename, index, data, len, final, U_SPIFFS); + } + ); + if (fsOk) { _server.serveStatic("/", LittleFS, "/").setDefaultFile("index.html"); } diff --git a/test/README b/test/README new file mode 100644 index 0000000..9b1e87b --- /dev/null +++ b/test/README @@ -0,0 +1,11 @@ + +This directory is intended for PlatformIO Test Runner and project tests. + +Unit Testing is a software testing method by which individual units of +source code, sets of one or more MCU program modules together with associated +control data, usage procedures, and operating procedures, are tested to +determine whether they are fit for use. Unit testing finds problems early +in the development cycle. + +More information about PlatformIO Unit Testing: +- https://docs.platformio.org/en/latest/advanced/unit-testing/index.html diff --git a/wemos_specs.md b/wemos_specs.md new file mode 100644 index 0000000..d337057 --- /dev/null +++ b/wemos_specs.md @@ -0,0 +1,301 @@ +# Spécifications — Wemos D1 R32 ESP32 + +## Microcontrôleur + +![Vue de la carte Wemos D1 R32 ESP32](image/board2.png) + +> Les images du dossier `image/` correspondent à une **Wemos D1 R32 ESP32**, au format Arduino UNO, et non à une D1 Mini compacte. Les indications de GPIO du firmware restent valables : c'est toujours le numéro GPIO ESP32 qui compte. + +| Paramètre | Valeur | +|---|---| +| Module | ESP32-WROOM-32 | +| Puce détectée | ESP32-D0WDQ6 revision v1.0 | +| Famille Espressif | ESP32 classique / ESP32 original series | +| Architecture | Xtensa LX6 dual-core 32 bits | +| Fréquence CPU | jusqu'à 240 MHz (configurable : 80 / 160 / 240 MHz) | + +Identification locale effectuée avec `esptool` sur `/dev/ttyUSB0` : + +```text +Chip type: ESP32-D0WDQ6 (revision v1.0) +Features: Wi-Fi, BT, Dual Core + LP Core, 240MHz +Crystal frequency: 40MHz +USB-série: CH340, VID:PID=1A86:7523 +``` + +Source constructeur : [Espressif — ESP32 Series Datasheet](https://documentation.espressif.com/esp32_datasheet_en.html) et [Espressif — ESP32-WROOM-32 Datasheet](https://documentation.espressif.com/esp32-wroom-32_datasheet_en.html). Le module ESP32-WROOM-32 embarque un **ESP32-D0WDQ6**, avec un microprocesseur **Xtensa dual-core 32-bit LX6** jusqu'à 240 MHz, WiFi 2,4 GHz et Bluetooth/BLE. + +### Position par rapport aux autres familles ESP32 + +Sur Internet, on voit souvent les variantes `ESP32-C3`, `ESP32-C6`, `ESP32-S2`, `ESP32-S3`, `ESP32-H2`, etc. Ta carte **n'appartient à aucune de ces familles** : elle utilise la famille ESP32 historique. + +| Famille | Architecture typique | Connectivité typique | Ta carte ? | +|---|---|---|---| +| ESP32 / ESP32-D0WDQ6 | Xtensa LX6 dual-core | WiFi + Bluetooth Classic + BLE | ✅ Oui | +| ESP32-C3 | RISC-V single-core | WiFi + BLE | Non | +| ESP32-C6 | RISC-V | WiFi 6 + BLE + 802.15.4 | Non | +| ESP32-S2 | Xtensa LX7 single-core | WiFi, pas Bluetooth classique | Non | +| ESP32-S3 | Xtensa LX7 dual-core | WiFi + BLE, orienté IA/vectoriel | Non | +| ESP32-H2 | RISC-V | BLE + 802.15.4, pas WiFi | Non | + +Conséquence pratique : pour PlatformIO, le choix actuel `board = esp32dev` et `framework = arduino` est cohérent avec cette carte. Les guides ou pinouts spécifiques `C3`, `C6`, `S2`, `S3` ou `H2` ne doivent pas être appliqués directement à ton câblage. + +## Mémoire + +| Type | Capacité | +|---|---| +| Flash SPI (interne au module) | 4 MB | +| ROM | 448 KB | +| SRAM | 520 KB | + +> GPIO6–11 réservés au bus Flash — ne jamais les utiliser. + +## Dimensions + +| Paramètre | Valeur | +|---|---| +| PCB | format Arduino UNO | +| Pas des broches | 2,54 mm | +| Compatibilité breadboard | Non directement, carte large type UNO | +| Compatibilité shields | Shields Arduino UNO, selon brochage et tensions | + +## Add-on de câblage : screw shield + +![Screw shield / proto shield à borniers](image/screwshield.png) + +Le shield à borniers montré dans `image/screwshield.png` est un **screw shield / proto shield au format Arduino UNO**. Il est adapté à cette carte parce que la Wemos D1 R32 reprend l'empreinte mécanique Arduino UNO : le shield vient s'enficher sur les deux rangées latérales et expose les broches sur des borniers à vis. + +### Compatibilité + +| Point | Compatibilité | Commentaire | +|---|---|---| +| Format physique | ✅ Oui | La D1 R32 est au format Arduino UNO | +| Alimentation 3V3 / 5V / GND | ✅ Oui | Les borniers reprennent les broches d'alimentation | +| Signaux numériques | ✅ Oui | Utilisables si la correspondance GPIO est respectée | +| Zone de prototypage | ✅ Oui | Pratique pour la résistance pull-up 4,7 kΩ des DS18B20 | +| Shields Arduino 5 V | ⚠️ Prudence | L'ESP32 n'est pas tolérant 5 V sur ses GPIO | + +### Point d'attention important + +Le shield peut afficher des noms de broches **Arduino** (`A0`, `A1`, `D13`, etc.), alors que le firmware utilise les vrais numéros **ESP32 GPIO** (`GPIO4`, `GPIO27`, `GPIO21`, etc.). + +Pour éviter une erreur de câblage : + +1. Repérer la broche voulue sur le pinout D1 R32 ci-dessous. +2. Vérifier à quel bornier du screw shield elle correspond. +3. Câbler selon le numéro GPIO utilisé dans `include/config.h`. + +Sur la D1 R32, **GPIO27 est exposé sur la broche Arduino `D6`**. Sur le screw shield, le bornier à utiliser pour le bus DS18B20 est donc **D6**. Il faut se fier à la table de correspondance ci-dessous, pas seulement au numéro GPIO écrit dans le firmware. + +### Utilisation dans le projet esp_jardin + +Dans ton cas, le screw shield sert surtout à fiabiliser le câblage extérieur des sondes DS18B20 : + +- les trois fils `VCC` des sondes vont sur un bornier relié au `3V3`, +- les trois fils `GND` vont sur un bornier `GND`, +- les trois fils `DATA` vont sur le bornier **D6**, correspondant à `GPIO27`, +- la résistance **4,7 kΩ** peut être placée dans la zone de prototypage entre `3V3` et `DATA`. + +## Alimentation + +| Paramètre | Valeur | +|---|---| +| USB | 5 V Micro-USB | +| Broche VIN | 5 V – 12 V (non régulé) | +| Sortie 3V3 | 3,3 V régulé (usage externe limité) | +| Régulateur | ME6211 (500 mA) ou AMS1117-3.3 (800 mA) selon version | +| Courant actif WiFi | ~80 mA pic | +| Deep sleep | < 10 µA | + +### Réponse à la question d'alimentation + +**L'USB suffit largement.** +Une batterie solaire avec sortie USB 5 V alimente parfaitement la carte via le port Micro-USB. Le régulateur embarqué gère la tension 5 V → 3,3 V. La broche VIN (5 V–12 V) est une entrée alternative pour une alimentation sans connecteur USB (adaptateur secteur, alimentation industrielle, etc.) — elle n'est pas nécessaire ici. + +Consommation typique du projet esp_jardin : +- ESP32 WiFi actif : ~80 mA +- 3 × DS18B20 : ~5 mA chacune +- **Total : ~95–100 mA**, bien en dessous des 500 mA du régulateur. + +Une batterie solaire USB standard de 10 000 mAh offre environ **4 jours d'autonomie** sans soleil. + +## Connectivité sans fil + +| Paramètre | Valeur | +|---|---| +| WiFi | 802.11 b/g/n — 2,4 GHz uniquement | +| Bluetooth | 4.2 (BR/EDR + BLE) | +| Antenne | PCB intégrée (version WROOM-32 standard) | + +La version WROOM-32**U** dispose d'un connecteur U.FL pour antenne externe. + +## GPIO disponibles + +![Pinout Wemos D1 R32 ESP32](image/board.png) + +> Pour le câblage, se fier aux étiquettes **GPIOxx** du pinout. Les libellés type `IO4`, `IO27`, `SDA`, `SCL` ou `RX/TX` sont des repères de connecteur, mais le firmware PlatformIO utilise les numéros GPIO ESP32. + +### Synthèse RIOT-OS / ESP32 + +Source : [RIOT-OS — ESP32 SoC Series, Common Peripherals](https://api.riot-os.org/group__cpu__esp32.html#esp32_peripherals) et [RIOT-OS — ESP32 family](https://api.riot-os.org/group__cpu__esp32__esp32.html). + +La documentation RIOT-OS confirme les points suivants pour l'ESP32 utilisé par la D1 R32 : + +| Sujet | Synthèse utile pour esp_jardin | +|---|---| +| GPIO disponibles | L'ESP32 expose 34 GPIO, mais tous ne sont pas équivalents. Certains sont réservés, entrée seule ou liés au boot. | +| GPIO34 à GPIO39 | Entrée uniquement. Bons candidats pour de l'analogique, mais pas pour piloter un bus OneWire ou une sortie. | +| ADC1 | GPIO32 à GPIO39. C'est le bon choix pour de futures sondes analogiques, notamment humidité du sol. | +| ADC2 | GPIO0, 2, 4, 12 à 15, 25 à 27. À éviter pour l'analogique quand le WiFi est actif, car ADC2 est aussi utilisé par le WiFi. | +| GPIO4 | Utilisable en entrée/sortie numérique, mais laissé libre dans ce projet depuis le passage du bus DS18B20 sur GPIO27. | +| GPIO25 / GPIO26 | Sorties DAC matérielles disponibles. Utilisables aussi en numérique/PWM si le DAC n'est pas utilisé. | +| I2C par défaut | SDA=GPIO21, SCL=GPIO22. À réserver pour capteurs I2C futurs. | +| SPI général | VSPI utilise classiquement GPIO18/19/23/5. HSPI utilise GPIO14/12/13/15. | +| Flash interne | GPIO6 à GPIO11 sont liés à la flash SPI et ne doivent pas être utilisés pour le projet. | +| Bootstrapping | GPIO0, GPIO2, GPIO12 et GPIO15 ont des contraintes au démarrage. À éviter pour des signaux critiques ou tirés dans un état risqué. | + +Conclusion pour le projet : + +- `GPIO27 / D6` est utilisé pour les DS18B20 en **numérique OneWire**. +- `GPIO4 / A1` reste compatible en numérique, mais n'est plus utilisé pour éviter l'ambiguïté ADC2. +- Pour l'humidité du sol analogique, utiliser `GPIO32` ou `GPIO33`, pas `GPIO4`, `GPIO25`, `GPIO26` ou `GPIO27`. +- Ne jamais utiliser `GPIO6`, `GPIO7`, `GPIO8`, `GPIO9`, `GPIO10`, `GPIO11`. + +### Correspondance Arduino UNO / GPIO ESP32 + +Cette table reprend la configuration de compatibilité Arduino UNO de la D1 R32. Elle est utile avec le screw shield, car les borniers sont souvent marqués avec les noms Arduino (`A1`, `D6`, `SDA`, etc.). + +| Fonction | GPIO ESP32 | Broche Arduino | Remarque | +|---|---:|---|---| +| ADC_LINE(0) / LED | GPIO2 | A0 | LED intégrée, éviter pour signaux critiques | +| ADC_LINE(1) | GPIO4 | A1 | Libre, ancien choix DS18B20 | +| ADC_LINE(2) | GPIO35 | A2 | Entrée seule, ADC1 | +| ADC_LINE(3) | GPIO34 | A3 | Entrée seule, ADC1 | +| ADC_LINE(4) | GPIO36 | A4 | Entrée seule, ADC1 | +| ADC_LINE(5) | GPIO39 | A5 | Entrée seule, ADC1 | +| DAC_LINE(0) / PWM | GPIO25 | D3 | DAC1 | +| DAC_LINE(1) | GPIO26 | D2 | DAC2 | +| I2C SDA | GPIO21 | SDA | I2C futur | +| I2C SCL | GPIO22 | SCL | I2C futur | +| PWM | GPIO16 | D5 | Libre possible | +| PWM | GPIO27 | D6 | Bus DS18B20 actuel du projet | +| PWM | GPIO13 | D9 | ADC2, éviter pour analogique avec WiFi | +| SPI CS | GPIO5 | D10 | SPI VSPI CS | +| SPI MOSI | GPIO23 | D11 | SPI VSPI MOSI | +| SPI MISO | GPIO19 | D12 | SPI VSPI MISO | +| SPI CLK | GPIO18 | D13 | SPI VSPI CLK | +| UART0 TX | GPIO1 | D1 | USB/programming, éviter | +| UART0 RX | GPIO3 | D0 | USB/programming, éviter | +| UART1 TX | GPIO10 | D4 | Réservé flash sur ESP32-WROOM, ne pas utiliser | +| UART1 RX | GPIO9 | D5 | Réservé flash sur ESP32-WROOM, ne pas utiliser | + +> La ligne `UART1 GPIO10/GPIO9` existe dans certaines configurations théoriques Arduino UNO, mais sur ESP32-WROOM les GPIO6 à GPIO11 sont utilisés par la flash interne. Pour ce projet, ils doivent rester interdits. + +### Interfaces de communication + +| Interface | GPIO | +|---|---| +| UART0 (USB/prog) | TX=GPIO1, RX=GPIO3 | +| UART2 possible | TX=GPIO17, RX=GPIO16 | +| I2C (défaut) | SDA=GPIO21, SCL=GPIO22 | +| SPI VSPI | MOSI=GPIO23, MISO=GPIO19, SCK=GPIO18, CS=GPIO5 | +| SPI HSPI | MOSI=GPIO13, MISO=GPIO12, SCK=GPIO14, CS=GPIO15 | + +### ADC — contrainte WiFi critique + +| ADC | GPIO | WiFi actif | +|---|---|---| +| **ADC1** | GPIO32, 33, 34, 35, 36, 39 | ✅ Utilisable | +| **ADC2** | GPIO0, 2, 4, 12, 13, 14, 15, 25, 26, 27 | ❌ Inutilisable en mode analogique | + +> GPIO27 (OneWire DS18B20 du projet) appartient à ADC2 mais utilisé en numérique — pas de conflit. +> Pour les futures sondes sol (humidité analogique) : utiliser GPIO32–39 (ADC1 uniquement). + +### DAC + +| Canal | GPIO | +|---|---| +| DAC1 | GPIO25 | +| DAC2 | GPIO26 | + +### Broches strapping (contraintes au boot) + +| GPIO | Contrainte | +|---|---| +| GPIO0 | HIGH = boot normal / LOW = mode flash (bouton BOOT) | +| GPIO2 | LED intégrée — éviter pour signaux critiques | +| GPIO12 | LOW au boot obligatoire | +| GPIO15 | Contrôle logs de boot | + +## Interface USB + +| Paramètre | Valeur | +|---|---| +| Puce USB-UART | CH340C (certains clones : CH9102X ou CP2104) | +| Connecteur | Micro-USB | +| Auto-reset | Oui (circuit DTR/RTS) | + +## LED et boutons + +| Composant | Détail | +|---|---| +| LED bleue | GPIO2 — logique inversée (LOW = allumée) | +| Bouton RST | Reset hardware | +| Bouton BOOT (IO0) | Maintenir + RST pour entrer en mode flash | + +## Correspondance avec le projet esp_jardin + +| Fonction | GPIO | Statut | +|---|---|---| +| OneWire DS18B20 | GPIO27 / D6 | ✅ Utilisé | +| I2C SDA (BH1750/SHT31) | GPIO21 | Réservé (futur) | +| I2C SCL | GPIO22 | Réservé (futur) | +| ADC sol (humidité) | GPIO32 ou GPIO33 | ADC1 — futur | +| Relais | GPIO25 / GPIO26 | Futur | +| Interruptions | GPIO14 | Futur | + +### Branchement actuel des DS18B20 + +Les trois sondes DS18B20 se branchent en parallèle sur le même bus OneWire. + +| Wemos D1 R32 | Screw shield | DS18B20 | +|---|---|---| +| 3V3 | 3V3 | VCC des 3 sondes | +| GND | GND | GND des 3 sondes | +| GPIO27 | D6 | DATA des 3 sondes | + +Ajouter une résistance **4,7 kΩ** entre **3V3** et **D6 / GPIO27**. + +```text +3V3 ─────┬──────── VCC sonde 1 + ├──────── VCC sonde 2 + ├──────── VCC sonde 3 + │ + └─[ 4.7 kΩ ]─┐ + │ +D6 / GPIO27 ───────────┼──────── DATA sonde 1 + ├──────── DATA sonde 2 + └──────── DATA sonde 3 + +GND ─────┬──────── GND sonde 1 + ├──────── GND sonde 2 + └──────── GND sonde 3 +``` + +Couleurs fréquentes des sondes waterproof : + +| Couleur | Signal | +|---|---| +| Rouge | VCC / 3V3 | +| Noir | GND | +| Jaune ou blanc | DATA / D6 / GPIO27 | + +### Choix retenu + +Le bus DS18B20 utilise désormais **GPIO27 / D6** : + +```cpp +#define ONE_WIRE_BUS 27 +``` + +Ce choix libère `A1 / GPIO4`, garde `GPIO21/22` pour l'I2C, et garde `GPIO32/33` pour les futures mesures analogiques.