Initial commit — KC868-A2 contrôleur solaire ESP32

Fonctionnalités :
- Lecture RS485 Modbus Epever Tracer 4210N (115200 bps, FC03/FC04/FC16)
- Moteur de règles JSON (LittleFS) — commande automatique des relais
- Interface web mobile-first (dashboard, règles, config, historique, EPEVER, debug)
- WiFi AP+STA simultanés avec reconnexion automatique et portail captif
- mDNS configurable (pv.local par défaut)
- Configuration registres EPEVER depuis l'UI (18 registres holding)
- Historique basse/haute résolution avec graphes canvas
- VPN WireGuard optionnel (désactivé par défaut, config via UI)
- OTA firmware + filesystem via ElegantOTA
- Deep sleep / économie d'énergie

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-09 19:25:01 +02:00
commit a8f0d6ccba
88 changed files with 13162 additions and 0 deletions
+19
View File
@@ -0,0 +1,19 @@
# PlatformIO — build artifacts et bibliothèques téléchargées
.pio/
# VS Code — fichiers générés automatiquement
.vscode/.browse.c_cpp.db*
.vscode/c_cpp_properties.json
.vscode/launch.json
.vscode/ipch
# Workspace IDE (fichier local)
src/*.code-workspace
# Binaires compilés (firmware et filesystem)
backup/firmware/
emulator/firmware/
# WireGuard — fichier de configuration contenant la clé privée
kc868-a2.conf
*.conf
+10
View File
@@ -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"
]
}
+126
View File
@@ -0,0 +1,126 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
Autonomous solar controller running on a **Kincony KC868-A2 (ESP32)** board. It reads data from an **Epever Tracer 4210N** solar regulator via RS485 Modbus, controls 2 relays using a programmable rule engine, and hosts a mobile-first web interface over a WiFi access point. No internet connection required.
> **Note:** `platformio.ini` currently targets `espressif8266/esp_wroom_02` but the hardware is an **ESP32** (espressif32). This will need to be corrected before flashing.
## Build Commands
```bash
pio run # Compile
pio run --target upload # Compile and flash
pio run --target monitor # Open serial monitor
pio run --target uploadfs # Upload /data filesystem (LittleFS)
pio run --target clean # Clean build artifacts
```
The full project specification is at `../vscode/esp-32-epever/consigne.md` (outside the PlatformIO project root).
## Architecture
System flow:
```
Epever 4210N (RS485 Modbus) → ESP32 → Rule Engine → Relays 1/2
Web UI (WiFi AP 192.168.4.1)
Smartphone
```
### Module structure (target)
| File | Responsibility |
|------|----------------|
| `src/main.cpp` | Init, main loop dispatcher |
| `src/wifi.cpp` | WiFi AP setup (`SSID: KC868_SOLAR`) |
| `src/webserver.cpp` | HTTP server, REST endpoints |
| `src/ota.cpp` | OTA firmware update (`/update`) |
| `src/modbus.cpp` | Non-blocking RS485 Modbus reads from Epever |
| `src/relais.cpp` | Relay GPIO control |
| `src/rules.cpp` | JSON rule engine (LittleFS) |
| `src/buttons.cpp` | DI1/DI2 dry-contact inputs |
| `src/sleep.cpp` | Deep sleep / power management |
| `data/` | LittleFS web assets (index.html, style.css, app.js, rules.json) |
### System state
```cpp
struct SystemState {
float battery; // Battery voltage (V) — register 0x3104
float pv; // Panel voltage (V) — register 0x3100
bool sun; // Day/night — register 0x200C
bool relay1, relay2;
bool di1, di2; // DI1=auto/manual toggle, DI2=manual relay
bool rs485_ok;
unsigned long last_update;
};
```
### Rule engine (JSON in LittleFS)
```json
[{ "enabled": true, "sun": true, "battery": 13.0, "relay": 1, "state": true, "delay": 0 }]
```
Conditions: sunlight, battery threshold, relay state, DI state.
Actions: relay ON/OFF with optional delay.
## Critical Design Constraints
**No blocking code anywhere.** RS485 errors must never stall the web server, relays, buttons, or rule engine.
```cpp
// FORBIDDEN
while (!response) { }
delay(x);
// REQUIRED
millis() // for all timing
```
RS485 reads must have short timeouts, limited retries, and fall back to the last valid cached value on error.
## Hardware Reference
**RS485 wiring:**
```
Epever RS485 A (D+) → KC868-A2 A1/RXI
Epever RS485 B (D-) → KC868-A2 B1/TXO
Epever GND → KC868-A2 GND
```
⚠️ Never connect the RJ45 Epever power supply rail.
**Modbus parameters:** baud=9600, slave address=1, RTU mode, half-duplex.
**Key Modbus registers:**
| Data | Register |
|------|----------|
| PV voltage | 0x3100 |
| PV current | 0x3101 |
| Battery voltage | 0x3104 |
| Day/night state | 0x200C |
## Power Modes
| Mode | WiFi | Web | Epever | Rules | Consumption |
|------|------|-----|--------|-------|-------------|
| Day | ON | ON | Active | Active | 1.84 W |
| Night | OFF | OFF | Periodic wake | Suspended | 0.10.6 W |
Deep sleep = ESP32 reboot on wake-up. UART unavailable during sleep. Relays retain state.
## Development Phases
1. WiFi + Web UI + OTA
2. Relay control
3. Button handling (DI1/DI2)
4. RS485 Modbus reads
5. Rule engine
6. Sleep / power optimization
GPIO assignments must be validated against the KC868-A2 schematic images in `../vscode/esp-32-epever/photo/` before use.
Binary file not shown.
Binary file not shown.
+155
View File
@@ -0,0 +1,155 @@
# Améliorations prévues
---
## 1. Historique 24h (graphique)
Graphique des tensions batterie et PV sur les dernières 24h.
**Approche :**
- Buffer circulaire en RAM : 1 point toutes les 5 min → 288 points max
- Rendu avec Chart.js (version minifiée, stockée dans LittleFS)
- Endpoint GET `/api/history` → JSON tableau horodaté
---
## 2. Valeurs sortie de charge (load) Epever
Lecture des registres de la sortie load du régulateur.
| Donnée | Registre | Unité | Facteur |
|--------------------|----------|-------|---------|
| Tension load | 0x310C | V | ÷100 |
| Courant load | 0x310D | A | ÷100 |
| Puissance load | 0x310E | W | ÷100 |
À ajouter dans `SystemState` et dans `modbus_epever.cpp`.
---
## 3. Consommation et production en kWh
L'Epever Tracer 4210N calcule les kWh en interne — aucun calcul côté ESP32 nécessaire.
| Donnée | Registre | Unité | Facteur |
|-------------------------------|----------|-------|---------|
| Énergie générée aujourd'hui | 0x3300 | kWh | ÷100 |
| Énergie générée totale | 0x3302 | kWh | ÷100 |
| Énergie consommée aujourd'hui | 0x3304 | kWh | ÷100 |
| Énergie consommée totale | 0x3306 | kWh | ÷100 |
> Les registres 0x3302 et 0x3306 (total) sont sur 32 bits — lire 2 registres consécutifs et combiner : `valeur = (reg_H << 16) | reg_L`.
À ajouter dans `SystemState`, lus dans `modbus_epever.cpp` avec les autres registres.
---
## 4. État batterie
L'Epever fournit le SOC, la température et le statut de charge.
### 4.1 SOC et température
| Donnée | Registre | Unité | Facteur |
|-------------------------|----------|-------|---------|
| SOC (charge restante) | 0x311A | % | ÷1 |
| Température batterie | 0x3110 | °C | ÷100 |
### 4.2 Statut de charge — registre 0x3200
| Bits | Valeur | Signification |
|------|--------|--------------------------|
| 3-2 | 00 | Pas de charge |
| 3-2 | 01 | Charge float |
| 3-2 | 10 | Charge boost |
| 3-2 | 11 | Charge égalisation |
| 1 | 1 | Batterie en sous-tension |
| 0 | 1 | Batterie en surtension |
```cpp
// Extraction du statut de charge (bits 3-2)
uint8_t statutCharge = (reg0x3200 >> 2) & 0x03;
// "Float" / "Boost" / "Égalisation" / "Arrêt"
bool sousVoltage = (reg0x3200 >> 1) & 0x01;
bool surVoltage = (reg0x3200 >> 0) & 0x01;
```
### 4.3 Champs à ajouter dans `SystemState`
```cpp
float batTemperature = 0.0f; // °C
uint8_t batSOC = 0; // %
uint8_t batStatutCharge = 0; // 0=arrêt, 1=float, 2=boost, 3=égalisation
bool batSousVoltage = false;
bool batSurVoltage = false;
```
---
## Ordre d'implémentation suggéré
1. État batterie + load + kWh (ajout dans la séquence Modbus existante)
2. Historique 24h (après étape 6 sleep)
---
## 5. Émulateur ESP32 (investigation)
Interface web 3 volets : GPIO / terminal série / webserver embarqué.
### Options évaluées
| Technologie | Offline | Docker | .bin Arduino | WiFi AP | Complexité | Licence |
|---|---|---|---|---|---|---|
| **Wokwi CLI** | ✗ (cloud token) | partiel | ✓ | ✓ simulé | faible | payant |
| **QEMU Espressif** | ✓ | ✓ | ✓ | ✗ (réseau non émulé) | moyenne | GPL |
| **Velxio** | ✓ | ✓ (1 commande) | ✓ (backend Wokwi) | ? | faible | AGPL |
| **Renode** | ✓ | ✓ | ✓ | ✗ partiel | haute | MIT |
### Blocage principal
Ce projet repose sur **WiFi AP + AsyncWebServer** — le webserver de l'ESP32 ne peut fonctionner que si la couche réseau WiFi est émulée. Seul **Wokwi** émule le WiFi (IP simulée accessible depuis l'hôte). QEMU n'émule pas le WiFi.
### Recommandation
**Wokwi CLI + VS Code extension** est la seule option viable pour voir tourner le webserver dans un navigateur :
- Fonctionne avec le `.bin` compilé par PlatformIO + un fichier `wokwi.toml` et `diagram.json`
- Émule WiFi, Serial, GPIO sur ESP32
- Accès au webserver ESP32 via `http://localhost:9080` (port-forward Wokwi)
- Licence : gratuit pour open-source (nécessite un token API gratuit)
**Architecture envisagée** (sous-dossier `emulator/`) :
```
emulator/
wokwi.toml # pointe vers ../.pio/build/kc868_a2/firmware.bin
diagram.json # schéma KC868-A2 : relais, DI, RS485 stub
README.md # instructions lancement
```
Commande de lancement : `wokwi-cli simulate --timeout 0`
### Limitation connue
Les registres Modbus RS485 devront être stubés dans `diagram.json` (composant `uart-stub` ou script Python injecté via Wokwi API) pour que la lecture Epever renvoie des données simulées. Sans cela, `rs485_ok = false` et l'historique ne s'alimente pas.
### Décision → **Implémenté** (`emulator/`)
Solution retenue : **QEMU fork Espressif (GPL)** + stub Modbus Python + UI 3 volets.
```
emulator/
├── Dockerfile # Ubuntu + QEMU Espressif + esptool
├── docker-compose.yml # volume .pio/build, ports 8888/10080
├── entrypoint.sh # merge flash, lance stub + UI + QEMU
├── modbus_stub.py # esclave Modbus RTU, registres Epever simulés
├── server.py # proxy /api/* + SSE serial + sert UI
└── ui/index.html # 3 volets : GPIO | webserver iframe | terminal
```
Lancement : `cd emulator && docker compose up --build`
+2
View File
@@ -0,0 +1,2 @@
2026-05-09T10:51:51Z
firmware: kc868_a2 — modbus RS485 fonctionnel (115200 bauds, lecture brute)
+706
View File
@@ -0,0 +1,706 @@
'use strict';
const REFRESH_MS = (parseInt(localStorage.getItem('refresh_ms') || '1000', 10));
// Noms configurables — chargés depuis /api/names au démarrage
const noms = { relay1: 'Relais 1', relay2: 'Relais 2', di1: 'Entrée 1', di2: 'Entrée 2' };
async function chargerNoms() {
try {
const d = await (await fetch('/api/names')).json();
if (d.relay1) noms.relay1 = d.relay1;
if (d.relay2) noms.relay2 = d.relay2;
if (d.di1) noms.di1 = d.di1;
if (d.di2) noms.di2 = d.di2;
} catch {}
appliquerNoms();
}
function appliquerNoms() {
setText('label-relay1', noms.relay1);
setText('label-relay2', noms.relay2);
setText('label-di1', noms.di1);
setText('label-di2', noms.di2);
setText('cmd-label-r1', noms.relay1);
setText('cmd-label-r2', noms.relay2);
const r1 = document.getElementById('c-n-relay1'); if (r1) r1.value = noms.relay1;
const r2 = document.getElementById('c-n-relay2'); if (r2) r2.value = noms.relay2;
const d1 = document.getElementById('c-n-di1'); if (d1) d1.value = noms.di1;
const d2 = document.getElementById('c-n-di2'); if (d2) d2.value = noms.di2;
}
async function sauvegarderNoms() {
const payload = {
relay1: document.getElementById('c-n-relay1').value.trim() || 'Relais 1',
relay2: document.getElementById('c-n-relay2').value.trim() || 'Relais 2',
di1: document.getElementById('c-n-di1').value.trim() || 'Entrée 1',
di2: document.getElementById('c-n-di2').value.trim() || 'Entrée 2',
};
await fetch('/api/names', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
location.reload();
}
// --- Navigation onglets ---
function afficherOnglet(nom, bouton) {
document.querySelectorAll('.onglet').forEach(s => s.classList.remove('actif'));
document.querySelectorAll('.tab').forEach(b => b.classList.remove('active'));
document.getElementById(nom).classList.add('actif');
bouton.classList.add('active');
if (nom === 'regles') chargerRegles();
if (nom === 'config') { chargerSleep(); chargerWifi(); chargerPrefsUI(); chargerModbus(); }
if (nom === 'historique') chargerHistorique();
if (nom === 'debug') chargerDebug();
}
// --- Rafraîchissement de l'état ---
async function rafraichir() {
try {
const res = await fetch('/api/state');
if (!res.ok) throw new Error('HTTP ' + res.status);
const d = await res.json();
mettreAJourUI(d);
} catch {
const b = document.getElementById('rs485-badge');
b.textContent = 'Hors ligne';
b.className = 'badge badge-err';
}
}
function mettreAJourUI(d) {
// PV & batterie
setText('battery', d.battery.toFixed(2) + ' V');
setText('pv', d.pv.toFixed(2) + ' V');
setText('pvCurrent', d.pvCurrent.toFixed(2) + ' A');
setText('sun', d.sun ? '☀ Jour' : '🌙 Nuit');
setText('epeverTime', d.epeverTime || '--');
setText('epeverClockOk', d.epeverClockOk ? 'OK' : 'ERR');
setText('header-clock', d.espClockOk ? d.espTime : (d.epeverTime || '--'));
// Batterie détaillée
setText('batSOC', d.batSOC + ' %');
setText('batTemp', d.batTemperature.toFixed(1) + ' °C');
setText('batStatut', ['Arrêt','Float','Boost','Égalisation'][d.batStatut] || '--');
// Load & énergie
setText('loadVoltage', d.loadVoltage.toFixed(2) + ' V');
setText('loadCurrent', d.loadCurrent.toFixed(2) + ' A');
setText('loadPower', d.loadPower.toFixed(1) + ' W');
setText('energieGenJour', d.energieGenJour.toFixed(2) + ' kWh');
setText('energieConJour', d.energieConJour.toFixed(2) + ' kWh');
setText('energieGenTotal', d.energieGenTotal.toFixed(2) + ' kWh');
setText('energieConTotal', d.energieConTotal.toFixed(2) + ' kWh');
// Relais & boutons
setRelais('relay1-etat', 'carte-relay1', 'led-r1', 'btn-r1-on', 'btn-r1-off', d.relay1);
setRelais('relay2-etat', 'carte-relay2', 'led-r2', 'btn-r2-on', 'btn-r2-off', d.relay2);
setBouton('di1-etat', d.di1);
setBouton('di2-etat', d.di2);
const badge = document.getElementById('rs485-badge');
badge.textContent = d.rs485_ok ? 'RS485 OK' : 'RS485 ERR';
badge.className = 'badge ' + (d.rs485_ok ? 'badge-ok' : 'badge-err');
document.getElementById('pied-page').textContent =
'Mise à jour : ' + new Date().toLocaleTimeString('fr-FR');
}
function setBouton(id, etat) {
const el = document.getElementById(id);
if (!el) return;
el.textContent = etat ? '● Appuyé' : '○ Relâché';
el.className = 'valeur ' + (etat ? 'val-on' : 'val-off');
}
function setRelais(id, carteId, ledId, btnOnId, btnOffId, etat) {
const el = document.getElementById(id);
if (el) { el.textContent = etat ? '● ON' : '○ OFF'; el.className = 'valeur ' + (etat ? 'val-on' : 'val-off'); }
const carte = document.getElementById(carteId);
if (carte) carte.classList.toggle('carte-on', etat);
const led = document.getElementById(ledId);
if (led) led.className = 'led ' + (etat ? 'led-on' : 'led-off');
const btnOn = document.getElementById(btnOnId);
const btnOff = document.getElementById(btnOffId);
if (btnOn) btnOn.className = 'btn btn-vert' + (etat ? ' btn-glow-vert' : ' btn-dim');
if (btnOff) btnOff.className = 'btn btn-rouge' + (!etat ? ' btn-glow-rouge' : ' btn-dim');
}
function setText(id, val) {
const el = document.getElementById(id);
if (el) el.textContent = val;
}
// --- Relais / Mode ---
async function relay(n, cmd) {
const res = await fetch('/api/relay/' + n + '/' + cmd, { method: 'POST' });
if (!res.ok) {
const d = await res.json().catch(() => ({}));
afficherToast(d.err || 'Commande refusée');
}
rafraichir();
}
function afficherToast(msg) {
let t = document.getElementById('toast');
if (!t) {
t = document.createElement('div');
t.id = 'toast';
document.body.appendChild(t);
}
t.textContent = msg;
t.classList.add('visible');
clearTimeout(t._timer);
t._timer = setTimeout(() => t.classList.remove('visible'), 2800);
}
async function rebootESP() {
if (!confirm('Redémarrer l\'ESP32 ?')) return;
await fetch('/api/reboot', { method: 'POST' });
afficherToast('Redémarrage en cours…');
setTimeout(() => location.reload(), 5000);
}
// --- Règles ---
async function chargerRegles() {
try {
const res = await fetch('/api/rules');
const data = await res.json();
afficherRegles(data);
} catch {
document.getElementById('liste-regles').innerHTML =
'<p style="color:var(--muted);text-align:center">Erreur chargement</p>';
}
}
function afficherRegles(regles) {
const el = document.getElementById('liste-regles');
if (!regles.length) {
el.innerHTML = '<p style="color:var(--muted);text-align:center;padding:1rem">Aucune règle</p>';
return;
}
el.innerHTML = regles.map(r => {
const cond = construireDescription(r);
const cls = r.enabled ? '' : ' regle-desactivee';
return `<div class="regle-item${cls}">
<div class="regle-desc">
<div class="regle-id">Règle #${r.id}</div>
${cond}
</div>
<button class="btn btn-sm" onclick="toggleRegle(${r.id})">${r.enabled ? 'OFF' : 'ON'}</button>
<button class="btn btn-sm btn-rouge" onclick="supprimerRegle(${r.id})">✕</button>
</div>`;
}).join('');
}
function construireDescription(r) {
const dec = [];
if (r.sun !== undefined) dec.push(r.sun ? '☀ Jour' : '🌙 Nuit');
if (r.di1 !== undefined) dec.push('DI1 ' + (r.di1 ? 'fermé' : 'ouvert'));
if (r.di2 !== undefined) dec.push('DI2 ' + (r.di2 ? 'fermé' : 'ouvert'));
const cond = [];
if (r.battery_min) cond.push('Bat ≥ ' + r.battery_min + 'V');
if (r.battery_max) cond.push('Bat ≤ ' + r.battery_max + 'V');
if (r.pv_min) cond.push('PV ≥ ' + r.pv_min + 'V');
if (r.pv_max) cond.push('PV ≤ ' + r.pv_max + 'V');
const extras = [];
if (r.delay) extras.push('délai ' + r.delay + 's');
if (r.hysteresis) extras.push('hyst. ±' + r.hysteresis + 'V');
const c = '<span style="color:var(--accent);font-size:0.72rem">▶</span>';
const lines = [];
if (dec.length) lines.push(c + ' <em>Si</em> ' + dec.join(' + '));
if (cond.length) lines.push(c + ' <em>Et</em> ' + cond.join(' + '));
lines.push(c + ' → Relais ' + r.relay + ' <strong>' + (r.state ? 'ON' : 'OFF') + '</strong>'
+ (extras.length ? ' <span style="color:var(--muted);font-size:0.78rem">(' + extras.join(', ') + ')</span>' : ''));
return lines.join('<br>');
}
async function toggleRegle(id) {
await fetch('/api/rules/toggle?id=' + id, { method: 'POST' });
chargerRegles();
}
async function supprimerRegle(id) {
await fetch('/api/rules/delete?id=' + id, { method: 'POST' });
chargerRegles();
}
async function ajouterRegle() {
const sun = document.getElementById('f-sun').value;
const di1 = document.getElementById('f-di1').value;
const di2 = document.getElementById('f-di2').value;
const batMin = parseFloat(document.getElementById('f-batmin').value) || 0;
const batMax = parseFloat(document.getElementById('f-batmax').value) || 0;
const pvMin = parseFloat(document.getElementById('f-pvmin').value) || 0;
const pvMax = parseFloat(document.getElementById('f-pvmax').value) || 0;
const relay = parseInt(document.getElementById('f-relay').value);
const state = document.getElementById('f-state').value === 'true';
const delay = parseInt(document.getElementById('f-delay').value) || 0;
const hyst = parseFloat(document.getElementById('f-hysteresis').value) || 0;
const regle = { enabled: true, relay, state, delay };
if (sun !== '') regle.sun = sun === 'true';
if (di1 !== '') regle.di1 = di1 === 'true';
if (di2 !== '') regle.di2 = di2 === 'true';
if (batMin > 0) regle.battery_min = batMin;
if (batMax > 0) regle.battery_max = batMax;
if (pvMin > 0) regle.pv_min = pvMin;
if (pvMax > 0) regle.pv_max = pvMax;
if (hyst > 0) regle.hysteresis = hyst;
const res = await fetch('/api/rules', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(regle)
});
if (res.ok) chargerRegles();
}
// --- Préférences UI (stockées en localStorage) ---
function getRefreshMs() {
return parseInt(localStorage.getItem('refresh_ms') || '1000', 10);
}
function chargerPrefsUI() {
const r = document.getElementById('c-refresh');
if (r) r.value = Math.round(getRefreshMs() / 1000);
const lp = document.getElementById('c-longpress2');
if (lp) lp.value = getLongPressMs();
}
function sauvegarderInterface() {
const s = parseInt(document.getElementById('c-refresh').value) || 1;
const lp = parseInt(document.getElementById('c-longpress2').value) || 500;
localStorage.setItem('refresh_ms', Math.min(60000, Math.max(1000, s * 1000)));
localStorage.setItem('longpress_ms', Math.min(3000, Math.max(200, lp)));
location.reload();
}
// --- WiFi info ---
async function chargerWifi() {
try {
const d = await (await fetch('/api/wifi')).json();
setText('wifi-ssid', d.ssid || '--');
setText('wifi-pwd', d.password || '(vide)');
} catch { /* silencieux */ }
}
// --- Sleep / Config ---
async function chargerSleep() {
try {
const d = await (await fetch('/api/sleep')).json();
document.getElementById('c-sleep-actif').value = String(d.actif);
document.getElementById('c-sleep-intervalle').value = Math.round(d.intervalle / 60);
document.getElementById('c-sleep-seuil').value = d.seuil;
} catch { /* silencieux */ }
}
async function sauvegarderSleep() {
const actif = document.getElementById('c-sleep-actif').value === 'true';
const intervalle = parseInt(document.getElementById('c-sleep-intervalle').value) * 60;
const seuil = parseFloat(document.getElementById('c-sleep-seuil').value);
await fetch('/api/sleep', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ actif, intervalle, seuil })
});
}
// --- Intervalles Modbus ---
async function chargerModbus() {
try {
const d = await (await fetch('/api/modbus')).json();
const j = document.getElementById('c-mb-jour'); if (j) j.value = Math.round(d.jour / 1000);
const n = document.getElementById('c-mb-nuit'); if (n) n.value = Math.round(d.nuit / 1000);
} catch { /* silencieux */ }
}
async function sauvegarderModbus() {
const jour = (parseInt(document.getElementById('c-mb-jour').value) || 5) * 1000;
const nuit = (parseInt(document.getElementById('c-mb-nuit').value) || 30) * 1000;
await fetch('/api/modbus', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ jour, nuit })
});
afficherToast('Intervalles Modbus sauvegardés');
}
// --- Horloge EPEVER ---
function toDatetimeLocalValue(date) {
const pad = n => String(n).padStart(2, '0');
return date.getFullYear() + '-' + pad(date.getMonth() + 1) + '-' + pad(date.getDate()) +
'T' + pad(date.getHours()) + ':' + pad(date.getMinutes()) + ':' + pad(date.getSeconds());
}
function remplirHeureNavigateur() {
const input = document.getElementById('c-epever-time');
if (input) input.value = toDatetimeLocalValue(new Date());
}
async function sauvegarderHeureEpever() {
const input = document.getElementById('c-epever-time');
if (!input || !input.value) {
afficherToast('Date/heure manquante');
return;
}
const d = new Date(input.value);
if (Number.isNaN(d.getTime())) {
afficherToast('Date/heure invalide');
return;
}
const payload = {
year: d.getFullYear(),
month: d.getMonth() + 1,
day: d.getDate(),
hour: d.getHours(),
minute: d.getMinutes(),
second: d.getSeconds()
};
const res = await fetch('/api/epever/time', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
afficherToast(res.ok ? 'Horloge EPEVER réglée' : 'Réglage refusé, réessaie dans quelques secondes');
rafraichir();
}
// --- Debug série / RS485 ---
async function chargerDebug() {
const consoleEl = document.getElementById('debug-console');
const metaEl = document.getElementById('debug-meta');
if (!consoleEl || !metaEl) return;
try {
const d = await (await fetch('/api/debug/logs')).json();
metaEl.textContent = d.lines.length + ' lignes en mémoire, compteur ' + d.count;
consoleEl.textContent = d.lines.map(l => {
const s = Math.floor((l.t || 0) / 1000);
return '[' + s.toString().padStart(6, ' ') + 's] ' + l.m;
}).join('\n') || 'Aucun message.';
consoleEl.scrollTop = consoleEl.scrollHeight;
} catch {
metaEl.textContent = 'Erreur lecture journal';
consoleEl.textContent = 'Impossible de lire /api/debug/logs';
}
}
async function viderDebug() {
await fetch('/api/debug/clear', { method: 'POST' });
chargerDebug();
}
// --- Historique ---
let histMode = 'hires';
function setHistMode(mode) {
histMode = mode;
document.getElementById('btn-hires').classList.toggle('active-mode', mode === 'hires');
document.getElementById('btn-lores').classList.toggle('active-mode', mode === 'lores');
chargerHistorique();
}
async function chargerHistorique() {
try {
const url = histMode === 'hires' ? '/api/history/hires' : '/api/history';
const res = await fetch(url);
if (!res.ok) throw new Error('HTTP ' + res.status);
const d = await res.json();
await chargerHistoriqueStatus(d);
const ids = [
{ id: 'chart-battery', data: d.b, couleur: '#00b894', unite: 'V' },
{ id: 'chart-pv', data: d.p, couleur: '#e94560', unite: 'V' },
{ id: 'chart-load', data: d.l, couleur: '#fdcb6e', unite: 'W' },
{ id: 'chart-soc', data: d.s, couleur: '#74b9ff', unite: '%' },
];
ids.forEach(({ id, data, couleur, unite }) => {
const canvas = document.getElementById(id);
if (canvas) dessinerGraphe(canvas, data, couleur, unite, d.step);
});
afficherDerniersPoints(d);
} catch (e) {
setText('hist-debug', 'Erreur chargement historique: ' + e.message);
}
}
async function chargerHistoriqueStatus(hist) {
try {
const s = await (await fetch('/api/history/status')).json();
hist = hist || {};
const mode = hist.mode || histMode;
const n = hist.n !== undefined ? hist.n : 0;
const nbBat = hist.b ? hist.b.length : 0;
const nbPv = hist.p ? hist.p.length : 0;
const nbLoad = hist.l ? hist.l.length : 0;
const nbSoc = hist.s ? hist.s.length : 0;
const nextH = Math.ceil((s.next_hires_ms || 0) / 1000);
const nextL = Math.ceil((s.next_lores_ms || 0) / 1000);
setText('hist-debug',
'Mode ' + mode +
' | points affichés: ' + n +
' | séries b/p/l/s: ' + nbBat + '/' + nbPv + '/' + nbLoad + '/' + nbSoc +
' | hires: ' + s.hires_n + '/' + s.hires_max +
' | lores: ' + s.lores_n + '/' + s.lores_max +
' | acc: ' + s.acc_n +
' | RS485: ' + (s.rs485_ok ? 'OK' : 'ERR') +
' | prochain 1min: ' + nextH + 's' +
' | prochain 5min: ' + nextL + 's'
);
} catch {
setText('hist-debug', 'Debug historique indisponible');
}
}
function afficherDerniersPoints(hist) {
const el = document.getElementById('hist-last');
if (!el) return;
const b = (hist.b || []).map(Number);
const p = (hist.p || []).map(Number);
const l = (hist.l || []).map(Number);
const s = (hist.s || []).map(Number);
const n = Math.min(b.length, p.length, l.length, s.length);
if (!n) {
el.textContent = 'Aucun point historique reçu depuis lAPI.';
return;
}
const debut = Math.max(0, n - 5);
const lignes = [];
for (let i = debut; i < n; i++) {
lignes.push(
'#' + (i + 1) +
' bat=' + b[i].toFixed(2) + 'V' +
' pv=' + p[i].toFixed(2) + 'V' +
' load=' + l[i].toFixed(1) + 'W' +
' soc=' + s[i].toFixed(0) + '%'
);
}
el.textContent = 'Derniers points: ' + lignes.join(' | ');
}
function dessinerGraphe(canvas, data, couleur, unite, stepSec) {
stepSec = stepSec || 300;
data = (data || []).map(Number).filter(Number.isFinite);
const ctx = canvas.getContext('2d');
const W = canvas.width, H = canvas.height;
const pad = { top: 12, right: 8, bottom: 28, left: 38 };
const w = W - pad.left - pad.right;
const h = H - pad.top - pad.bottom;
ctx.clearRect(0, 0, W, H);
if (!data.length) {
ctx.fillStyle = '#a0aec0';
ctx.font = '14px system-ui';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText('Pas encore de données', W / 2, H / 2);
return;
}
const min = Math.min(...data);
const max = Math.max(...data);
const marge = Math.max((max - min) * 0.15, unite === '%' ? 2 : 0.2);
const yMin = min === max ? min - marge : min - marge;
const yMax = min === max ? max + marge : max + marge;
const range = yMax - yMin || 1;
// grille horizontale
ctx.lineWidth = 1;
for (let i = 0; i <= 4; i++) {
const y = pad.top + (h / 4) * i;
ctx.strokeStyle = '#0f3460';
ctx.beginPath(); ctx.moveTo(pad.left, y); ctx.lineTo(pad.left + w, y); ctx.stroke();
const val = yMax - (range / 4) * i;
ctx.fillStyle = '#a0aec0';
ctx.font = '10px system-ui';
ctx.textAlign = 'right';
ctx.textBaseline = 'middle';
ctx.fillText(val.toFixed(1) + unite, pad.left - 4, y);
}
if (data.length === 1) {
const x = pad.left + w / 2;
const y = pad.top + h - ((data[0] - yMin) / range) * h;
ctx.fillStyle = couleur;
ctx.beginPath();
ctx.arc(x, y, 4, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = '#a0aec0';
ctx.font = '11px system-ui';
ctx.textAlign = 'center';
ctx.fillText(data[0].toFixed(1) + unite, x, Math.max(12, y - 8));
return;
}
// courbe
ctx.strokeStyle = couleur;
ctx.lineWidth = 2;
ctx.lineJoin = 'round';
ctx.beginPath();
data.forEach((v, i) => {
const x = pad.left + (i / (data.length - 1)) * w;
const y = pad.top + h - ((v - yMin) / range) * h;
i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
});
ctx.stroke();
// remplissage sous la courbe
ctx.lineTo(pad.left + w, pad.top + h);
ctx.lineTo(pad.left, pad.top + h);
ctx.closePath();
ctx.fillStyle = couleur + '22';
ctx.fill();
// labels temps (axe X — ~5 labels)
const now = Date.now();
const step = Math.max(1, Math.floor(data.length / 5));
ctx.fillStyle = '#a0aec0';
ctx.font = '10px system-ui';
ctx.textAlign = 'center';
ctx.textBaseline = 'alphabetic';
for (let i = 0; i < data.length; i += step) {
const x = pad.left + (i / (data.length - 1)) * w;
const t = new Date(now - (data.length - 1 - i) * stepSec * 1000);
ctx.fillText(
t.toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' }),
x, H - 5
);
}
}
// --- Long press relais dashboard ---
function getLongPressMs() {
return parseInt(localStorage.getItem('longpress_ms') || '500', 10);
}
function setupLongPress(carteId, relayNum) {
const el = document.getElementById(carteId);
if (!el) return;
let timer = null;
const start = (e) => {
e.preventDefault();
el.classList.add('press-hold');
timer = setTimeout(async () => {
timer = null;
el.classList.remove('press-hold');
el.classList.add('press-done');
setTimeout(() => el.classList.remove('press-done'), 500);
const res = await fetch('/api/relay/' + relayNum + '/toggle', { method: 'POST' });
if (!res.ok) {
const d = await res.json().catch(() => ({}));
afficherToast(d.err || 'Commande refusée');
} else {
afficherToast((relayNum === 1 ? noms.relay1 : noms.relay2) + ' basculé et sauvegardé');
}
rafraichir();
}, getLongPressMs());
};
const cancel = () => {
clearTimeout(timer);
timer = null;
el.classList.remove('press-hold');
};
el.addEventListener('pointerdown', start);
el.addEventListener('pointerup', cancel);
el.addEventListener('pointerleave', cancel);
el.addEventListener('contextmenu', e => e.preventDefault());
}
function setupSunLongPress() {
const el = document.getElementById('carte-sun');
if (!el) return;
let timer = null;
const start = (e) => {
e.preventDefault();
el.classList.add('press-hold');
timer = setTimeout(() => {
timer = null;
el.classList.remove('press-hold');
ouvrirSunPopup();
}, 2000);
};
const cancel = () => {
clearTimeout(timer);
timer = null;
el.classList.remove('press-hold');
};
el.addEventListener('pointerdown', start);
el.addEventListener('pointerup', cancel);
el.addEventListener('pointerleave', cancel);
el.addEventListener('contextmenu', e => e.preventDefault());
}
function formatSunHistoryTime(value) {
if (!value || value.indexOf('uptime') === 0) return value || '--';
const parts = value.split(' ');
if (parts.length !== 2) return value;
const d = parts[0].split('-');
const t = parts[1].split(':');
if (d.length !== 3 || t.length < 2) return value;
return t[0] + ':' + t[1] + ' ' + d[2] + '/' + d[1];
}
async function ouvrirSunPopup() {
const modal = document.getElementById('sun-modal');
const list = document.getElementById('sun-history-list');
if (!modal || !list) return;
modal.classList.remove('hidden');
list.textContent = 'Chargement...';
try {
const d = await (await fetch('/api/sun/history')).json();
const changes = d.changes || [];
if (!changes.length) {
list.innerHTML = '<div class="modal-empty">Aucun changement enregistré depuis le boot.</div>';
return;
}
list.innerHTML = changes.slice().reverse().map(c =>
'<div class="modal-row"><span>' + formatSunHistoryTime(c.time) + '</span><strong>' + c.label + '</strong></div>'
).join('');
} catch {
list.innerHTML = '<div class="modal-empty">Erreur lecture historique jour/nuit.</div>';
}
}
function fermerSunPopup() {
const modal = document.getElementById('sun-modal');
if (modal) modal.classList.add('hidden');
}
// --- Démarrage ---
chargerNoms();
rafraichir();
setInterval(rafraichir, REFRESH_MS);
setInterval(() => {
const debugEl = document.getElementById('debug');
const debugActif = debugEl && debugEl.classList.contains('actif');
if (debugActif) chargerDebug();
}, 2000);
setInterval(() => {
const histEl = document.getElementById('historique');
const histActif = histEl && histEl.classList.contains('actif');
if (histActif) chargerHistorique();
}, 5000);
setupLongPress('carte-relay1', 1);
setupLongPress('carte-relay2', 2);
setupSunLongPress();
Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

+20
View File
@@ -0,0 +1,20 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect width="32" height="32" rx="6" fill="#1a1a2e"/>
<!-- soleil -->
<circle cx="16" cy="13" r="5" fill="#fdcb6e"/>
<!-- rayons -->
<g stroke="#fdcb6e" stroke-width="2" stroke-linecap="round">
<line x1="16" y1="4" x2="16" y2="6"/>
<line x1="16" y1="20" x2="16" y2="22"/>
<line x1="7" y1="13" x2="9" y2="13"/>
<line x1="23" y1="13" x2="25" y2="13"/>
<line x1="9.5" y1="6.5" x2="11" y2="8"/>
<line x1="21" y1="18" x2="22.5" y2="19.5"/>
<line x1="22.5" y1="6.5" x2="21" y2="8"/>
<line x1="9.5" y1="19.5" x2="11" y2="18"/>
</g>
<!-- batterie -->
<rect x="9" y="23" width="14" height="6" rx="1.5" fill="none" stroke="#00b894" stroke-width="1.5"/>
<rect x="23" y="25" width="2" height="2" rx="0.5" fill="#00b894"/>
<rect x="10.5" y="24.5" width="8" height="3" rx="1" fill="#00b894"/>
</svg>

After

Width:  |  Height:  |  Size: 912 B

+535
View File
@@ -0,0 +1,535 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>KC868 Solaire</title>
<link rel="icon" type="image/svg+xml" href="favicon.svg">
<link rel="stylesheet" href="style.css">
</head>
<body>
<header>
<div class="header-title">
<h1>⚡ Contrôleur Solaire</h1>
<span id="header-clock" class="header-clock">--</span>
</div>
<span id="rs485-badge" class="badge badge-err">RS485 --</span>
</header>
<nav>
<!-- Dashboard : grille 2×2 -->
<button class="tab active" title="Dashboard" onclick="afficherOnglet('dashboard', this)">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/>
<rect x="3" y="14" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/>
</svg>
</button>
<!-- Règles : liste à puces -->
<button class="tab" title="Règles" onclick="afficherOnglet('regles', this)">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
<line x1="9" y1="6" x2="20" y2="6"/><line x1="9" y1="12" x2="20" y2="12"/><line x1="9" y1="18" x2="20" y2="18"/>
<circle cx="4.5" cy="6" r="1.5" fill="currentColor" stroke="none"/>
<circle cx="4.5" cy="12" r="1.5" fill="currentColor" stroke="none"/>
<circle cx="4.5" cy="18" r="1.5" fill="currentColor" stroke="none"/>
</svg>
</button>
<!-- Config : engrenage -->
<button class="tab" title="Configuration" onclick="afficherOnglet('config', this)">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="3"/>
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/>
</svg>
</button>
<!-- Historique : courbe -->
<button class="tab" title="Historique" onclick="afficherOnglet('historique', this)">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/>
</svg>
</button>
<!-- Debug : terminal -->
<button class="tab" title="Debug" onclick="afficherOnglet('debug', this)">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="4 17 10 11 4 5"/>
<line x1="12" y1="19" x2="20" y2="19"/>
</svg>
</button>
</nav>
<main>
<!-- Dashboard -->
<section id="dashboard" class="onglet actif">
<div class="dash-section">Relais <span class="dash-hint">appui 1,1s = toggle + save</span></div>
<div class="grille">
<div class="carte" id="carte-relay1">
<div class="etiquette" id="label-relay1">Relais 1</div>
<div class="valeur" id="relay1-etat">--</div>
</div>
<div class="carte" id="carte-relay2">
<div class="etiquette" id="label-relay2">Relais 2</div>
<div class="valeur" id="relay2-etat">--</div>
</div>
</div>
<div class="dash-section">Entrées</div>
<div class="grille">
<div class="carte">
<div class="etiquette" id="label-di1">Entrée 1</div>
<div class="valeur" id="di1-etat">--</div>
</div>
<div class="carte">
<div class="etiquette" id="label-di2">Entrée 2</div>
<div class="valeur" id="di2-etat">--</div>
</div>
</div>
<div class="dash-section">Solaire</div>
<div class="grille grille-3">
<div class="carte">
<div class="etiquette">Tension PV</div>
<div class="valeur" id="pv">-- V</div>
</div>
<div class="carte">
<div class="etiquette">Courant PV</div>
<div class="valeur" id="pvCurrent">-- A</div>
</div>
<div class="carte" id="carte-sun">
<div class="etiquette">Ensoleillement</div>
<div class="valeur" id="sun">--</div>
</div>
</div>
<div class="grille">
<div class="carte">
<div class="etiquette">Horloge EPEVER</div>
<div class="valeur valeur-compacte" id="epeverTime">--</div>
</div>
<div class="carte">
<div class="etiquette">RTC EPEVER</div>
<div class="valeur" id="epeverClockOk">--</div>
</div>
</div>
<div class="dash-section">Batterie</div>
<div class="grille">
<div class="carte">
<div class="etiquette">Tension</div>
<div class="valeur" id="battery">-- V</div>
</div>
<div class="carte">
<div class="etiquette">SOC</div>
<div class="valeur" id="batSOC">-- %</div>
</div>
<div class="carte">
<div class="etiquette">Statut</div>
<div class="valeur" id="batStatut">--</div>
</div>
<div class="carte">
<div class="etiquette">Température</div>
<div class="valeur" id="batTemp">-- °C</div>
</div>
</div>
<div class="dash-section">Sortie 12V EPEVER</div>
<div class="grille grille-3">
<div class="carte">
<div class="etiquette">Tension load</div>
<div class="valeur" id="loadVoltage">-- V</div>
</div>
<div class="carte">
<div class="etiquette">Courant load</div>
<div class="valeur" id="loadCurrent">-- A</div>
</div>
<div class="carte">
<div class="etiquette">Puissance load</div>
<div class="valeur" id="loadPower">-- W</div>
</div>
</div>
<div class="dash-section">Énergie</div>
<div class="grille">
<div class="carte">
<div class="etiquette">Prod. jour</div>
<div class="valeur" id="energieGenJour">-- kWh</div>
</div>
<div class="carte">
<div class="etiquette">Conso. jour</div>
<div class="valeur" id="energieConJour">-- kWh</div>
</div>
<div class="carte">
<div class="etiquette">Prod. total</div>
<div class="valeur" id="energieGenTotal">-- kWh</div>
</div>
<div class="carte">
<div class="etiquette">Conso. total</div>
<div class="valeur" id="energieConTotal">-- kWh</div>
</div>
</div>
</section>
<!-- Règles -->
<section id="regles" class="onglet">
<p class="aide">Chaque règle surveille des conditions (ensoleillement, tension batterie) et commande automatiquement un relais. Un délai optionnel évite les basculements intempestifs. Les règles s'appliquent en parallèle de la commande manuelle.</p>
<!-- Liste des règles -->
<div id="liste-regles"></div>
<!-- Formulaire ajout -->
<div class="regle-form">
<div class="form-titre">Ajouter une règle</div>
<div class="form-section-label">Déclencheur</div>
<div class="form-ligne">
<label>Soleil</label>
<select id="f-sun">
<option value="">Ignoré</option>
<option value="true">Jour</option>
<option value="false">Nuit</option>
</select>
</div>
<div class="form-ligne">
<label>Entrée DI1</label>
<select id="f-di1">
<option value="">Ignoré</option>
<option value="true">Fermé (ON)</option>
<option value="false">Ouvert (OFF)</option>
</select>
</div>
<div class="form-ligne">
<label>Entrée DI2</label>
<select id="f-di2">
<option value="">Ignoré</option>
<option value="true">Fermé (ON)</option>
<option value="false">Ouvert (OFF)</option>
</select>
</div>
<div class="form-section-label">Condition</div>
<div class="form-ligne">
<label>Batt. min (V)</label>
<input type="number" id="f-batmin" step="0.1" min="0" max="30" placeholder="0 = ignoré">
</div>
<div class="form-ligne">
<label>Batt. max (V)</label>
<input type="number" id="f-batmax" step="0.1" min="0" max="30" placeholder="0 = ignoré">
</div>
<div class="form-ligne">
<label>PV min (V)</label>
<input type="number" id="f-pvmin" step="0.1" min="0" max="200" placeholder="0 = ignoré">
</div>
<div class="form-ligne">
<label>PV max (V)</label>
<input type="number" id="f-pvmax" step="0.1" min="0" max="200" placeholder="0 = ignoré">
</div>
<div class="form-section-label">Action</div>
<div class="form-ligne">
<label>Relais</label>
<select id="f-relay">
<option value="1">Relais 1</option>
<option value="2">Relais 2</option>
</select>
</div>
<div class="form-ligne">
<label>État</label>
<select id="f-state">
<option value="true">ON</option>
<option value="false">OFF</option>
</select>
</div>
<div class="form-ligne">
<label>Délai (s)</label>
<input type="number" id="f-delay" min="0" value="0" placeholder="0 = immédiat">
</div>
<div class="form-ligne">
<label>Hystérésis (V)</label>
<input type="number" id="f-hysteresis" step="0.1" min="0" max="5" value="0" placeholder="0 = désactivé">
</div>
<button class="btn btn-primaire btn-plein" onclick="ajouterRegle()">Ajouter</button>
</div>
</section>
<!-- Config / Paramètres -->
<section id="config" class="onglet">
<p class="aide">Commande manuelle des relais, noms personnalisables, sleep, OTA et redémarrage. Un appui maintenu sur une carte relais du dashboard bascule et sauvegarde l'état.</p>
<div class="regle-form">
<div class="form-titre">Commande manuelle</div>
<div class="ligne-commande relay-row">
<span class="label-cmd"><span id="cmd-label-r1">Relais 1</span> <span id="led-r1" class="led led-off"></span></span>
<button id="btn-r1-on" class="btn btn-vert btn-dim" onclick="relay(1,'on')">ON</button>
<button id="btn-r1-off" class="btn btn-rouge btn-glow-rouge" onclick="relay(1,'off')">OFF</button>
</div>
<div class="ligne-commande relay-row">
<span class="label-cmd"><span id="cmd-label-r2">Relais 2</span> <span id="led-r2" class="led led-off"></span></span>
<button id="btn-r2-on" class="btn btn-vert btn-dim" onclick="relay(2,'on')">ON</button>
<button id="btn-r2-off" class="btn btn-rouge btn-glow-rouge" onclick="relay(2,'off')">OFF</button>
</div>
</div>
<div class="regle-form">
<div class="form-titre">Noms des relais et entrées</div>
<div class="form-ligne">
<label>Relais 1</label>
<input type="text" id="c-n-relay1" maxlength="20" placeholder="Relais 1">
</div>
<div class="form-ligne">
<label>Relais 2</label>
<input type="text" id="c-n-relay2" maxlength="20" placeholder="Relais 2">
</div>
<div class="form-ligne">
<label>Entrée 1</label>
<input type="text" id="c-n-di1" maxlength="20" placeholder="Entrée 1">
</div>
<div class="form-ligne">
<label>Entrée 2</label>
<input type="text" id="c-n-di2" maxlength="20" placeholder="Entrée 2">
</div>
<button class="btn btn-primaire btn-plein" onclick="sauvegarderNoms()">Enregistrer et recharger</button>
</div>
<div class="regle-form">
<div class="form-titre">Mode économie d'énergie (sleep)</div>
<p class="aide">En mode sleep, l'ESP32 s'éteint entre deux cycles de mesure pour réduire la consommation. Il se réveille périodiquement, lit les données Modbus, évalue les règles, puis se rendort si la tension PV est inférieure au seuil (nuit détectée). En journée (PV &gt; seuil), il reste actif en permanence.</p>
<div class="form-ligne">
<label>Activé</label>
<select id="c-sleep-actif">
<option value="false">Non</option>
<option value="true">Oui</option>
</select>
</div>
<div class="form-ligne">
<label>Réveil (min)</label>
<input type="number" id="c-sleep-intervalle" min="1" max="120" value="10">
</div>
<div class="form-ligne">
<label>Seuil PV (V)</label>
<input type="number" id="c-sleep-seuil" step="0.5" min="0" max="10" value="2.0">
</div>
<button class="btn btn-primaire btn-plein" onclick="sauvegarderSleep()">Enregistrer</button>
</div>
<div class="regle-form">
<div class="form-titre">Interface</div>
<div class="form-ligne">
<label>Rafraîchissement (s)</label>
<input type="number" id="c-refresh" min="1" max="60" step="1" value="1">
</div>
<div class="form-ligne">
<label>Appui long (ms)</label>
<input type="number" id="c-longpress2" min="200" max="3000" step="100" value="500">
</div>
<button class="btn btn-primaire btn-plein" onclick="sauvegarderInterface()">Enregistrer et recharger</button>
</div>
<div class="regle-form">
<div class="form-titre">Intervalles Modbus</div>
<p class="aide">En <strong>mode soleil</strong> (PV actif), les données sont lues fréquemment. En <strong>mode veille</strong> (nuit / PV absent), l'intervalle est plus long pour économiser l'énergie.</p>
<div class="form-ligne">
<label>Mode soleil (s)</label>
<input type="number" id="c-mb-jour" min="1" max="60" step="1" value="5">
</div>
<div class="form-ligne">
<label>Mode veille (s)</label>
<input type="number" id="c-mb-nuit" min="5" max="300" step="5" value="30">
</div>
<button class="btn btn-primaire btn-plein" onclick="sauvegarderModbus()">Enregistrer</button>
</div>
<div class="regle-form">
<div class="form-titre">Horloge EPEVER</div>
<p class="aide">L'ESP32 cale son horloge sur l'EPEVER au boot puis toutes les 6h. Utilise ce réglage si l'heure du MPPT est décalée.</p>
<div class="form-ligne">
<label>Date/heure</label>
<input type="datetime-local" id="c-epever-time" step="1">
</div>
<button class="btn btn-primaire btn-plein" onclick="remplirHeureNavigateur()">Utiliser l'heure du navigateur</button>
<button class="btn btn-vert btn-plein" onclick="sauvegarderHeureEpever()">Régler l'EPEVER</button>
</div>
<div class="regle-form">
<div class="form-titre">Connexion WiFi</div>
<div class="form-ligne">
<label>SSID</label>
<span id="wifi-ssid" class="wifi-val">--</span>
</div>
<div class="form-ligne">
<label>Mot de passe</label>
<span id="wifi-pwd" class="wifi-val">--</span>
</div>
</div>
<div class="regle-form" style="margin-top:0.75rem">
<div class="form-titre">Mise à jour firmware (OTA)</div>
<p class="ota-info">Identifiants&nbsp;: aucun requis</p>
<a href="/update" class="btn btn-primaire btn-plein">Ouvrir l'interface OTA</a>
</div>
<div class="regle-form" style="margin-top:0.75rem">
<div class="form-titre">Exporter les données</div>
<p class="aide">Historique basse résolution : jusqu'à 30h de mesures (pas 5 min). Fichier CSV importable dans Excel, LibreOffice ou Google Sheets.</p>
<a href="/api/history/csv" download="historique.csv" class="btn btn-primaire btn-plein">Télécharger l'historique (CSV)</a>
</div>
<div class="regle-form" style="margin-top:0.75rem">
<div class="form-titre">Système</div>
<button class="btn btn-rouge btn-plein" onclick="rebootESP()">Redémarrer l'ESP32</button>
</div>
<img src="board.jpg" alt="KC868-A2 board" class="board-img">
<div class="regle-form rs485-info">
<div class="form-titre">Raccordement RS485 — Epever Tracer 4210N</div>
<p class="aide">Le contrôleur Epever utilise un connecteur <strong>RJ45 8P8C</strong> pour la communication RS485 (Modbus RTU, <strong>115200 bps</strong>, 8N1). Les signaux A et B sont doublés (pins 3&amp;4 = B, pins 5&amp;6 = A).<br>⚠ Ne jamais connecter les pins 1&amp;2 (+7.5V) au KC868-A2.</p>
<pre class="rs485-schema">
Epever 4210N — RJ45 vue de face (languette vers le bas)
╔══════════════════════════════════╗
║ ┌──────────────────────────┐ ║
║ │ ╷ ╷ ╷ ╷ ╷ ╷ ╷ ╷ │ ║
║ │ 1 2 3 4 5 6 7 8 │ ║
║ └──────────────────────────┘ ║
╚══════════════════════════════════╝
│ │ │ │ │ │ │ │
GRI ORA NOI ROU VER JAU BLE MAR
+7V +7V B B A+ A+ GND GND
⚠️ ⚠️
│ │ │
(au choix, ex: ROU / JAU / BLE)
│ │ │
▼ ▼ ▼
KC868-A2 : B A+ GND
</pre>
<table class="rs485-table">
<thead><tr><th>Pin</th><th>Couleur</th><th>Signal</th><th>KC868-A2</th></tr></thead>
<tbody>
<tr><td>1</td><td><span class="fil" style="background:#888">Gris</span></td><td>+7.5V ⚠</td><td>Ne pas connecter</td></tr>
<tr><td>2</td><td><span class="fil" style="background:#f80">Orange</span></td><td>+7.5V ⚠</td><td>Ne pas connecter</td></tr>
<tr><td>3</td><td><span class="fil" style="background:#222">Noir</span></td><td>RS-485-B</td><td rowspan="2">B</td></tr>
<tr><td>4</td><td><span class="fil" style="background:#e00">Rouge</span></td><td>RS-485-B</td></tr>
<tr><td>5</td><td><span class="fil" style="background:#0a0">Vert</span></td><td>RS-485-A</td><td rowspan="2">A+</td></tr>
<tr><td>6</td><td><span class="fil" style="background:#cc0;color:#333">Jaune</span></td><td>RS-485-A</td></tr>
<tr><td>7</td><td><span class="fil" style="background:#00c">Bleu</span></td><td>GND</td><td rowspan="2">GND (optionnel)</td></tr>
<tr><td>8</td><td><span class="fil" style="background:#6b3a2a">Marron</span></td><td>GND</td></tr>
</tbody>
</table>
<p class="aide" style="margin-top:0.5rem">⚠ Le port RS485 de l'Epever n'est pas isolé. Un module d'isolation RS485 est recommandé pour éviter les boucles de masse.</p>
<p class="aide">🔋 <strong>Alimentation KC868-A2</strong> : nécessite <strong>12V DC</strong> — brancher directement sur la batterie du système solaire. Ne pas utiliser les pins 1&amp;2 du RJ45 (+7.5V, courant trop faible).</p>
</div>
<div class="regle-form rs485-info">
<div class="form-titre">Raccordement des relais</div>
<p class="aide">Chaque relais dispose de 3 bornes : <strong>COM</strong> (commun), <strong>NO</strong> (normalement ouvert) et <strong>NC</strong> (normalement fermé). Capacité max : <strong>10A / 250V AC</strong>.</p>
<pre class="rs485-schema">
┌──────────────────────────────────────────────┐
│ RELAIS HORS TENSION (OFF) │
│ │
│ COM ────● ○ NO (circuit ouvert) │
│ COM ────●───● NC (circuit fermé) │
└──────────────────────────────────────────────┘
┌──────────────────────────────────────────────┐
│ RELAIS ALIMENTÉ (ON) │
│ │
│ COM ────●───● NO (circuit fermé) │
│ COM ────● ○ NC (circuit ouvert) │
└──────────────────────────────────────────────┘
</pre>
<table class="rs485-table">
<thead><tr><th>Contact</th><th>Relais OFF</th><th>Relais ON</th><th>Usage typique</th></tr></thead>
<tbody>
<tr><td>NO</td><td>Ouvert</td><td>Fermé</td><td>Charge OFF par défaut</td></tr>
<tr><td>NC</td><td>Fermé</td><td>Ouvert</td><td>Charge ON par défaut</td></tr>
</tbody>
</table>
<div class="form-titre" style="margin-top:1rem">Exemple : commande d'une lampe 230V</div>
<p class="aide">⚡ Travaux sur le 230V : couper le disjoncteur avant toute intervention. La tension 230V est dangereuse et potentiellement mortelle.</p>
<pre class="rs485-schema">
Utilisation du contact NO (lampe éteinte par défaut)
Tableau Bornier relais KC868-A2 Lampe
électrique ┌───────────────────────────┐
│ │
Phase (L) ───►│ COM NO ►───┼──── Lampe ──┐
│ NC │ │
└───────────────────────────┘ │
Neutre (N) ──────────────────────────────────────────────┘
Relais OFF → NO ouvert → lampe ÉTEINTE
Relais ON → NO fermé → lampe ALLUMÉE
Utilisation du contact NC (lampe allumée par défaut)
Tableau Bornier relais KC868-A2 Lampe
électrique ┌───────────────────────────┐
│ │
Phase (L) ───►│ COM NC ►───┼──── Lampe ──┐
│ NO │ │
└───────────────────────────┘ │
Neutre (N) ──────────────────────────────────────────────┘
Relais OFF → NC fermé → lampe ALLUMÉE
Relais ON → NC ouvert → lampe ÉTEINTE
</pre>
</div>
</section>
<!-- Historique -->
<section id="historique" class="onglet">
<p class="aide">Mode <strong>4h</strong> : un point par minute (RAM uniquement). Mode <strong>30h</strong> : moyenne sur 5 min, sauvegardée toutes les heures sur le système de fichiers.</p>
<div class="hist-toggle">
<button id="btn-hires" class="btn btn-primaire active-mode" onclick="setHistMode('hires')">4h</button>
<button id="btn-lores" class="btn" onclick="setHistMode('lores')">30h</button>
<button class="btn" onclick="chargerHistorique()"></button>
</div>
<div id="hist-debug" class="hist-debug">Historique en attente...</div>
<div id="hist-last" class="hist-debug">Derniers points en attente...</div>
<div class="graphe-conteneur">
<div class="graphe-titre">Tension batterie (V)</div>
<canvas id="chart-battery" width="600" height="150"></canvas>
</div>
<div class="graphe-conteneur">
<div class="graphe-titre">Tension PV (V)</div>
<canvas id="chart-pv" width="600" height="150"></canvas>
</div>
<div class="graphe-conteneur">
<div class="graphe-titre">Puissance load (W)</div>
<canvas id="chart-load" width="600" height="150"></canvas>
</div>
<div class="graphe-conteneur">
<div class="graphe-titre">SOC batterie (%)</div>
<canvas id="chart-soc" width="600" height="150"></canvas>
</div>
</section>
<!-- Debug -->
<section id="debug" class="onglet">
<div class="debug-actions">
<button class="btn btn-primaire" onclick="chargerDebug()">Rafraîchir</button>
<button class="btn btn-rouge" onclick="viderDebug()">Vider</button>
</div>
<div class="debug-meta" id="debug-meta">Journal en attente</div>
<pre id="debug-console" class="debug-console">Chargement...</pre>
</section>
</main>
<div id="sun-modal" class="modal hidden">
<div class="modal-box">
<div class="modal-title">Changements Jour/Nuit</div>
<div id="sun-history-list" class="modal-list">Chargement...</div>
<button class="btn btn-primaire btn-plein" onclick="fermerSunPopup()">Fermer</button>
</div>
</div>
<footer id="pied-page">En attente de données…</footer>
<script src="app.js"></script>
</body>
</html>
+1
View File
@@ -0,0 +1 @@
[]
+501
View File
@@ -0,0 +1,501 @@
:root {
--bg: #1a1a2e;
--surface: #16213e;
--carte: #0f3460;
--accent: #e94560;
--vert: #00b894;
--rouge: #d63031;
--texte: #eaeaea;
--muted: #a0aec0;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
background: var(--bg);
color: var(--texte);
font-family: system-ui, -apple-system, sans-serif;
min-height: 100vh;
display: flex;
flex-direction: column;
}
/* --- En-tête --- */
header {
background: var(--surface);
padding: 0.6rem 0.9rem;
display: flex;
align-items: center;
justify-content: space-between;
border-bottom: 1px solid var(--carte);
}
header h1 { font-size: 0.95rem; font-weight: 700; }
.header-title {
min-width: 0;
}
.header-clock {
display: block;
margin-top: 0.1rem;
color: var(--muted);
font-family: "Courier New", monospace;
font-size: 0.68rem;
}
/* --- Badges --- */
.badge {
padding: 0.2rem 0.7rem;
border-radius: 999px;
font-size: 0.7rem;
font-weight: 700;
letter-spacing: 0.03em;
}
.badge-ok { background: var(--vert); color: #000; }
.badge-err { background: var(--rouge); color: #fff; }
/* --- Navigation --- */
nav {
display: flex;
background: var(--surface);
border-bottom: 1px solid var(--carte);
}
.tab {
flex: 1;
padding: 0.55rem 0;
background: none;
border: none;
border-bottom: 2px solid transparent;
color: var(--muted);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
min-width: 0;
transition: color 0.15s, border-color 0.15s;
}
.tab svg {
width: 20px;
height: 20px;
flex-shrink: 0;
pointer-events: none;
}
.tab.active {
color: var(--accent);
border-bottom-color: var(--accent);
}
/* --- Contenu principal --- */
main { flex: 1; padding: 0.65rem; }
.onglet { display: none; }
.onglet.actif { display: block; }
/* --- Dashboard --- */
.grille {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 0.45rem;
}
.grille-3 { grid-template-columns: repeat(3, 1fr); }
.carte {
background: var(--carte);
border-radius: 0.55rem;
padding: 0.45rem 0.35rem;
text-align: center;
border: 2px solid transparent;
transition: border-color 0.2s, transform 0.1s;
}
.carte-on { border-color: var(--vert); }
/* Long press feedback — user-select uniquement sur les cartes relais */
#carte-relay1, #carte-relay2 {
cursor: default;
user-select: none;
-webkit-user-select: none;
}
.press-hold {
border-color: var(--accent) !important;
transform: scale(0.95);
transition: transform 0.1s, border-color 0.1s !important;
}
@keyframes flash-save {
0% { background: var(--accent); }
100% { background: var(--carte); }
}
.press-done { animation: flash-save 0.5s ease-out forwards; }
.etiquette {
font-size: 0.58rem;
color: var(--muted);
text-transform: uppercase;
letter-spacing: 0.04em;
margin-bottom: 0.18rem;
}
.valeur {
font-size: 1.05rem;
font-weight: 700;
}
.valeur-compacte {
font-size: 0.82rem;
overflow-wrap: anywhere;
}
.val-on { color: var(--vert); }
.val-off { color: var(--muted); }
.dash-section {
font-size: 0.6rem;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--accent);
font-weight: 700;
padding: 0.3rem 0 0.15rem;
}
.dash-hint {
font-weight: 400;
color: var(--muted);
text-transform: none;
letter-spacing: 0;
font-size: 0.56rem;
}
/* --- Commandes --- */
.ligne-commande {
background: var(--surface);
border-radius: 0.65rem;
padding: 0.7rem 0.75rem;
margin-bottom: 0.5rem;
display: flex;
align-items: center;
gap: 0.5rem;
}
.label-cmd {
flex: 1;
font-weight: 500;
font-size: 0.9rem;
min-width: 0;
}
/* --- Boutons --- */
.btn {
padding: 0.55rem 1rem;
border: none;
border-radius: 0.5rem;
font-size: 0.85rem;
font-weight: 600;
cursor: pointer;
background: var(--carte);
color: var(--texte);
white-space: nowrap;
transition: opacity 0.15s, box-shadow 0.15s;
touch-action: manipulation;
}
.btn:active { opacity: 0.75; }
.btn-actif { background: var(--accent); color: #fff; }
.btn-vert { background: var(--vert); color: #000; }
.btn-rouge { background: var(--rouge); color: #fff; }
.btn-dim { opacity: 0.3; }
.btn-glow-vert { box-shadow: 0 0 10px var(--vert); }
.btn-glow-rouge { box-shadow: 0 0 10px var(--rouge); }
/* --- Voyant LED relais --- */
.led {
display: inline-block;
width: 12px; height: 12px;
border-radius: 50%;
vertical-align: middle;
margin-left: 6px;
transition: background 0.2s, box-shadow 0.2s;
}
.led-on { background: var(--vert); box-shadow: 0 0 7px var(--vert); }
.led-off { background: #444; box-shadow: none; }
.btn-primaire {
display: inline-block;
text-decoration: none;
background: var(--accent);
color: #fff;
margin-top: 1.2rem;
padding: 0.6rem 1.5rem;
border-radius: 0.5rem;
font-weight: 600;
}
/* --- Règles --- */
.regle-item {
background: var(--surface);
border-radius: 0.75rem;
padding: 0.75rem 1rem;
margin-bottom: 0.6rem;
display: flex;
align-items: center;
gap: 0.6rem;
}
.regle-desc { flex: 1; font-size: 0.85rem; line-height: 1.5; }
.regle-id { font-size: 0.7rem; color: var(--muted); }
.regle-desactivee { opacity: 0.45; }
.regle-form {
background: var(--surface);
border-radius: 0.75rem;
padding: 1rem;
margin-top: 1rem;
}
.form-titre {
font-weight: 600;
margin-bottom: 0.75rem;
font-size: 0.9rem;
}
.form-ligne {
display: flex;
align-items: center;
margin-bottom: 0.5rem;
gap: 0.5rem;
}
.form-ligne label {
width: 90px;
font-size: 0.8rem;
color: var(--muted);
flex-shrink: 0;
}
.form-ligne select,
.form-ligne input {
flex: 1;
background: var(--carte);
border: none;
border-radius: 0.4rem;
padding: 0.4rem 0.6rem;
color: var(--texte);
font-size: 0.85rem;
}
.btn-plein { display: block; width: 100%; margin-top: 0.75rem; text-align: center; }
.btn-sm { padding: 0.3rem 0.7rem; font-size: 0.75rem; }
.form-section-label {
font-size: 0.65rem;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--accent);
font-weight: 700;
margin: 0.8rem 0 0.35rem;
border-bottom: 1px solid var(--carte);
padding-bottom: 0.25rem;
}
/* --- Toast notification --- */
#toast {
position: fixed;
bottom: 1.5rem;
left: 50%;
transform: translateX(-50%) translateY(1rem);
background: var(--rouge);
color: #fff;
padding: 0.55rem 1.2rem;
border-radius: 2rem;
font-size: 0.85rem;
font-weight: 600;
opacity: 0;
pointer-events: none;
transition: opacity 0.2s, transform 0.2s;
white-space: nowrap;
z-index: 999;
}
#toast.visible {
opacity: 1;
transform: translateX(-50%) translateY(0);
}
/* --- Modales --- */
.modal {
position: fixed;
inset: 0;
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
padding: 1rem;
background: rgba(0, 0, 0, 0.65);
}
.modal.hidden { display: none; }
.modal-box {
width: 100%;
max-width: 420px;
background: var(--surface);
border: 1px solid var(--carte);
border-radius: 0.75rem;
padding: 1rem;
}
.modal-title {
font-weight: 700;
margin-bottom: 0.75rem;
}
.modal-list {
display: grid;
gap: 0.4rem;
}
.modal-row {
display: flex;
justify-content: space-between;
gap: 0.75rem;
background: var(--carte);
border-radius: 0.45rem;
padding: 0.55rem 0.65rem;
font-size: 0.82rem;
}
.modal-row span {
color: var(--muted);
font-family: "Courier New", monospace;
}
.modal-empty {
color: var(--muted);
font-size: 0.85rem;
}
/* --- Lignes relais désactivées en mode Auto --- */
.row-disabled { opacity: 0.4; pointer-events: none; }
/* --- Textes d'aide onglets --- */
.aide {
font-size: 0.78rem;
color: var(--muted);
line-height: 1.55;
background: var(--surface);
border-left: 3px solid var(--accent);
border-radius: 0 0.5rem 0.5rem 0;
padding: 0.55rem 0.75rem;
margin-bottom: 0.75rem;
}
.aide strong { color: var(--texte); }
/* --- Board image --- */
.board-img {
display: block;
width: 100%;
max-width: 700px;
height: auto;
margin: 0.75rem auto 0;
border-radius: 0.75rem;
border: 1px solid var(--carte);
}
.ota-info {
font-size: 0.8rem;
color: var(--muted);
margin-bottom: 0.5rem;
}
/* --- Historique / Graphes --- */
.hist-toggle {
display: flex;
gap: 0.5rem;
margin-bottom: 0.75rem;
}
.hist-toggle .btn { flex: 1; }
.active-mode { background: var(--accent) !important; color: #fff !important; opacity: 1 !important; }
.hist-debug {
color: var(--muted);
background: var(--surface);
border: 1px solid var(--carte);
border-radius: 0.5rem;
padding: 0.45rem 0.6rem;
margin-bottom: 0.75rem;
font-family: "Courier New", monospace;
font-size: 0.68rem;
line-height: 1.35;
}
.graphe-conteneur {
background: var(--surface);
border-radius: 0.75rem;
padding: 0.75rem;
margin-bottom: 0.75rem;
overflow: hidden;
}
.graphe-titre {
font-size: 0.75rem;
color: var(--muted);
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: 0.5rem;
}
canvas {
display: block;
width: 100%;
height: auto;
border-radius: 0.4rem;
}
/* --- Debug console --- */
.debug-actions {
display: flex;
gap: 0.5rem;
margin-bottom: 0.6rem;
}
.debug-actions .btn {
flex: 1;
margin-top: 0;
}
.debug-meta {
color: var(--muted);
font-size: 0.75rem;
margin-bottom: 0.45rem;
}
.debug-console {
width: 100%;
min-height: 60vh;
max-height: 68vh;
overflow: auto;
background: #070b12;
color: #d7f7df;
border: 1px solid var(--carte);
border-radius: 0.55rem;
padding: 0.75rem;
font-family: "Courier New", monospace;
font-size: 0.72rem;
line-height: 1.45;
white-space: pre-wrap;
word-break: break-word;
}
/* --- WiFi info --- */
.wifi-val { font-family: monospace; font-size: 0.9rem; color: var(--texte); }
/* --- RS485 wiring info --- */
.rs485-schema {
font-family: 'Courier New', monospace;
font-size: 0.7rem;
line-height: 1.4;
color: var(--texte);
background: var(--fond);
border-radius: 0.4rem;
padding: 0.6rem 0.75rem;
overflow-x: auto;
white-space: pre;
margin: 0.5rem 0;
}
.rs485-table {
width: 100%;
border-collapse: collapse;
font-size: 0.8rem;
margin: 0.5rem 0;
}
.rs485-table th, .rs485-table td {
padding: 0.35rem 0.5rem;
border: 1px solid var(--carte);
text-align: left;
}
.rs485-table th { background: var(--fond); color: var(--muted); }
.fil {
display: inline-block;
padding: 0.1rem 0.45rem;
border-radius: 0.3rem;
font-size: 0.75rem;
font-weight: 600;
color: #fff;
}
/* --- Pied de page --- */
footer {
text-align: center;
padding: 0.45rem;
font-size: 0.7rem;
color: var(--muted);
background: var(--surface);
border-top: 1px solid var(--carte);
}
+49
View File
@@ -0,0 +1,49 @@
#include <Arduino.h>
#include "config.h"
#include "state.h"
// Suivi anti-rebond pour un bouton
struct Bouton {
uint8_t pin;
bool lectureRaw; // dernière lecture brute
bool etatConfirme; // état validé après anti-rebond
unsigned long tChangement;
};
static Bouton di1 = { PIN_DI1, HIGH, HIGH, 0 };
static Bouton di2 = { PIN_DI2, HIGH, HIGH, 0 };
// Retourne true une seule fois au moment où l'appui est confirmé (front descendant)
static bool detecterAppui(Bouton &b) {
bool lecture = digitalRead(b.pin);
if (lecture != b.lectureRaw) {
b.lectureRaw = lecture;
b.tChangement = millis();
}
if ((millis() - b.tChangement) >= DEBOUNCE_BOUTON && lecture != b.etatConfirme) {
b.etatConfirme = lecture;
return (b.etatConfirme == LOW); // LOW = contact fermé = appui
}
return false;
}
void initBoutons() {
// GPIO36 et GPIO39 sont input-only — pas de pull-up interne possible sur ESP32
pinMode(PIN_DI1, INPUT);
pinMode(PIN_DI2, INPUT);
Serial.println("Boutons DI1/DI2 initialisés");
}
void gererBoutons() {
bool appui1 = detecterAppui(di1);
bool appui2 = detecterAppui(di2);
if (appui1) Serial.println("[DI] DI1 appui détecté");
if (appui2) Serial.println("[DI] DI2 appui détecté");
// Mise à jour état pour l'interface web et les règles
state.di1 = (di1.etatConfirme == LOW);
state.di2 = (di2.etatConfirme == LOW);
}
+4
View File
@@ -0,0 +1,4 @@
#pragma once
void initBoutons();
void gererBoutons();
+33
View File
@@ -0,0 +1,33 @@
#pragma once
#include <IPAddress.h>
// --- WiFi point d'accès ---
#define WIFI_SSID "kc868-a2"
#define WIFI_PASSWORD "soleil12" // mot de passe WiFi AP
#define WIFI_IP IPAddress(192, 168, 4, 1)
#define WIFI_GATEWAY IPAddress(192, 168, 4, 1)
#define WIFI_SUBNET IPAddress(255, 255, 255, 0)
// --- GPIO ---
#define PIN_RELAY1 15
#define PIN_RELAY2 2 // pin de strapping boot — doit être HIGH au démarrage
#define PIN_RS485_TX 32
#define PIN_RS485_RX 35 // input only
#define PIN_DI1 36 // input only
#define PIN_DI2 39 // input only
// --- OTA ---
#define OTA_USER "admin"
#define OTA_PASSWORD "solar123"
// --- Modbus ---
#define MODBUS_ADRESSE 1 // adresse esclave Epever
#define MODBUS_BAUDRATE 115200 // baudrate principal de l'Epever
#define TIMEOUT_MODBUS 3000 // timeout réponse (ms) — doit être > délai interne lib (1s)
#define MODBUS_DEBUG_BOOT 1 // sonde RS485 détaillée au démarrage
#define MODBUS_DEBUG_RX_MAX 64 // octets max affichés en cas d'erreur
// --- Intervalles (ms) ---
#define INTERVALLE_MODBUS 5000
#define INTERVALLE_REGLES 1000
#define DEBOUNCE_BOUTON 50
+79
View File
@@ -0,0 +1,79 @@
#include <Arduino.h>
#include <stdarg.h>
#include "debug_log.h"
static const uint8_t NB_LIGNES = 80;
static const uint8_t TAILLE_LIGNE = 160;
static char lignes[NB_LIGNES][TAILLE_LIGNE];
static uint32_t horodatage[NB_LIGNES];
static uint8_t prochaineLigne = 0;
static uint8_t nbLignes = 0;
static uint32_t compteur = 0;
static void appendJsonString(String &out, const char *s) {
out += '"';
while (*s) {
char c = *s++;
switch (c) {
case '\\': out += "\\\\"; break;
case '"': out += "\\\""; break;
case '\n': out += "\\n"; break;
case '\r': break;
case '\t': out += "\\t"; break;
default:
if ((uint8_t)c < 0x20) out += ' ';
else out += c;
break;
}
}
out += '"';
}
void debugLogLine(const char *message) {
if (!message) return;
snprintf(lignes[prochaineLigne], TAILLE_LIGNE, "%s", message);
horodatage[prochaineLigne] = millis();
prochaineLigne = (prochaineLigne + 1) % NB_LIGNES;
if (nbLignes < NB_LIGNES) nbLignes++;
compteur++;
}
void debugLogf(const char *format, ...) {
char buffer[TAILLE_LIGNE];
va_list args;
va_start(args, format);
vsnprintf(buffer, sizeof(buffer), format, args);
va_end(args);
Serial.println(buffer);
debugLogLine(buffer);
}
void getDebugLogJson(String &out) {
out.reserve(NB_LIGNES * 96);
out = "{\"count\":";
out += compteur;
out += ",\"lines\":[";
for (uint8_t i = 0; i < nbLignes; i++) {
uint8_t idx = (prochaineLigne + NB_LIGNES - nbLignes + i) % NB_LIGNES;
if (i) out += ',';
out += "{\"t\":";
out += horodatage[idx];
out += ",\"m\":";
appendJsonString(out, lignes[idx]);
out += '}';
}
out += "]}";
}
void clearDebugLog() {
prochaineLigne = 0;
nbLignes = 0;
compteur = 0;
debugLogf("[DEBUG] Journal vidé");
}
+7
View File
@@ -0,0 +1,7 @@
#pragma once
#include <Arduino.h>
void debugLogLine(const char *message);
void debugLogf(const char *format, ...);
void getDebugLogJson(String &out);
void clearDebugLog();
+262
View File
@@ -0,0 +1,262 @@
#include "historique.h"
#include "state.h"
#include "debug_log.h"
#include <LittleFS.h>
// ─── Dimensions ───────────────────────────────────────────────────────────────
#define HIRES_MAX 240 // 4h × 60 min, une mesure/min
#define LORES_MAX 312 // 26h × 12 pts/h (5 min)
#define FICHIER_HIST "/hist.bin"
// ─── Format binaire du fichier (13 octets/entrée) ────────────────────────────
struct __attribute__((packed)) HistEntry {
float bat;
float pv;
float load;
uint8_t soc;
};
// ─── Buffers haute résolution (RAM uniquement) ───────────────────────────────
static float hrBat[HIRES_MAX], hrPV[HIRES_MAX], hrLoad[HIRES_MAX];
static uint8_t hrSOC[HIRES_MAX];
static uint16_t hrTete = 0, hrN = 0;
// ─── Buffers basse résolution (RAM + fichier) ────────────────────────────────
static float lrBat[LORES_MAX], lrPV[LORES_MAX], lrLoad[LORES_MAX];
static uint8_t lrSOC[LORES_MAX];
static uint16_t lrTete = 0, lrN = 0;
// ─── Accumulateurs pour la moyenne 5 min ─────────────────────────────────────
static float accBat = 0, accPV = 0, accLoad = 0;
static uint16_t accSOC = 0, accN = 0;
static uint8_t lrDepuisHr = 0; // pts lores ajoutés depuis la dernière sauvegarde
// ─── Timers ───────────────────────────────────────────────────────────────────
static unsigned long tDernMin = 0;
static unsigned long tDern5Min = 0;
static unsigned long tDernHr = 0;
static bool premierPointAjoute = false;
// ─── Index ordonné dans un ring buffer ───────────────────────────────────────
static inline uint16_t hrIdx(uint16_t i) {
return (uint16_t)((hrTete - hrN + i + HIRES_MAX) % HIRES_MAX);
}
static inline uint16_t lrIdx(uint16_t i) {
return (uint16_t)((lrTete - lrN + i + LORES_MAX) % LORES_MAX);
}
// ─── Ajout d'un point hires ──────────────────────────────────────────────────
static void pushHires() {
hrBat[hrTete] = state.battery;
hrPV[hrTete] = state.pv;
hrLoad[hrTete] = state.loadPower;
hrSOC[hrTete] = state.batSOC;
hrTete = (hrTete + 1) % HIRES_MAX;
if (hrN < HIRES_MAX) hrN++;
accBat += state.battery;
accPV += state.pv;
accLoad += state.loadPower;
accSOC += state.batSOC;
accN++;
debugLogf("[HIST] Point hires #%u — bat=%.2fV pv=%.2fV load=%.1fW soc=%u",
hrN, state.battery, state.pv, state.loadPower, state.batSOC);
}
// ─── Flush de la moyenne 5 min vers lores ────────────────────────────────────
static void pushLores() {
if (accN == 0) return;
lrBat[lrTete] = accBat / accN;
lrPV[lrTete] = accPV / accN;
lrLoad[lrTete] = accLoad / accN;
lrSOC[lrTete] = (uint8_t)(accSOC / accN);
lrTete = (lrTete + 1) % LORES_MAX;
if (lrN < LORES_MAX) lrN++;
lrDepuisHr++;
debugLogf("[HIST] Point lores #%u — moyennes sur %u pt(s): bat=%.2fV pv=%.2fV load=%.1fW soc=%u",
lrN, accN, lrBat[(lrTete + LORES_MAX - 1) % LORES_MAX],
lrPV[(lrTete + LORES_MAX - 1) % LORES_MAX],
lrLoad[(lrTete + LORES_MAX - 1) % LORES_MAX],
lrSOC[(lrTete + LORES_MAX - 1) % LORES_MAX]);
accBat = accPV = accLoad = 0;
accSOC = 0; accN = 0;
}
// ─── Sauvegarde horaire vers /hist.bin ───────────────────────────────────────
static void sauvegarderHeure() {
if (lrDepuisHr == 0) return;
// Lire les entrées existantes (max LORES_MAX)
HistEntry buf[LORES_MAX];
uint16_t existant = 0;
File fr = LittleFS.open(FICHIER_HIST, "r");
if (fr) {
existant = (uint16_t)min((size_t)(fr.size() / sizeof(HistEntry)),
(size_t)LORES_MAX);
fr.read((uint8_t*)buf, existant * sizeof(HistEntry));
fr.close();
}
// Ajouter les lrDepuisHr nouvelles entrées depuis le ring lores
uint16_t debut = (lrN >= lrDepuisHr) ? (lrN - lrDepuisHr) : 0;
for (uint16_t i = debut; i < lrN; i++) {
if (existant >= LORES_MAX) {
memmove(buf, buf + 1, (existant - 1) * sizeof(HistEntry));
existant--;
}
uint16_t idx = lrIdx(i);
buf[existant++] = { lrBat[idx], lrPV[idx], lrLoad[idx], lrSOC[idx] };
}
// Réécrire le fichier
File fw = LittleFS.open(FICHIER_HIST, "w");
if (fw) {
fw.write((uint8_t*)buf, existant * sizeof(HistEntry));
fw.close();
Serial.printf("[HIST] Sauvegarde %d pts → %d total (%dB)\n",
lrDepuisHr, existant,
(int)(existant * sizeof(HistEntry)));
} else {
Serial.println("[HIST] Erreur écriture hist.bin");
}
lrDepuisHr = 0;
}
// ─── Chargement du fichier au démarrage ──────────────────────────────────────
static void chargerFichier() {
File f = LittleFS.open(FICHIER_HIST, "r");
if (!f) { Serial.println("[HIST] Aucun fichier — démarrage à zéro"); return; }
uint16_t n = (uint16_t)(f.size() / sizeof(HistEntry));
if (n > LORES_MAX) n = LORES_MAX;
Serial.printf("[HIST] Chargement de %d pts depuis hist.bin\n", n);
HistEntry e;
for (uint16_t i = 0; i < n; i++) {
f.read((uint8_t*)&e, sizeof(e));
lrBat[lrTete] = e.bat;
lrPV[lrTete] = e.pv;
lrLoad[lrTete] = e.load;
lrSOC[lrTete] = e.soc;
lrTete = (lrTete + 1) % LORES_MAX;
if (lrN < LORES_MAX) lrN++;
}
f.close();
}
// ─── Sérialisation JSON générique ────────────────────────────────────────────
static void serJson(String &out, const char *mode, uint16_t n,
uint16_t step_s,
float *bat, float *pv, float *load, uint8_t *soc,
uint16_t maxBuf,
uint16_t (*idxFn)(uint16_t)) {
char tmp[12];
out.reserve(n * 26 + 80);
out = "{\"mode\":\""; out += mode;
out += "\",\"step\":"; out += step_s;
out += ",\"n\":"; out += n;
out += ",\"b\":[";
for (uint16_t i = 0; i < n; i++) {
if (i) out += ',';
snprintf(tmp, sizeof(tmp), "%.2f", bat[idxFn(i)]);
out += tmp;
}
out += "],\"p\":[";
for (uint16_t i = 0; i < n; i++) {
if (i) out += ',';
snprintf(tmp, sizeof(tmp), "%.2f", pv[idxFn(i)]);
out += tmp;
}
out += "],\"l\":[";
for (uint16_t i = 0; i < n; i++) {
if (i) out += ',';
snprintf(tmp, sizeof(tmp), "%.1f", load[idxFn(i)]);
out += tmp;
}
out += "],\"s\":[";
for (uint16_t i = 0; i < n; i++) {
if (i) out += ',';
out += soc[idxFn(i)];
}
out += "]}";
(void)maxBuf;
}
// ─── API publique ─────────────────────────────────────────────────────────────
void initHistorique() {
hrTete = hrN = lrTete = lrN = 0;
accBat = accPV = accLoad = 0;
accSOC = accN = lrDepuisHr = 0;
premierPointAjoute = false;
unsigned long t = millis();
tDernMin = tDern5Min = tDernHr = t;
chargerFichier();
debugLogf("[HIST] Init — hires=%u lores=%u", hrN, lrN);
}
void gererHistorique() {
if (!state.rs485_ok) return;
unsigned long maintenant = millis();
if (!premierPointAjoute) {
premierPointAjoute = true;
tDernMin = maintenant;
pushHires();
} else if (maintenant - tDernMin >= 60000UL) {
tDernMin = maintenant;
pushHires();
}
if (maintenant - tDern5Min >= 300000UL) {
tDern5Min = maintenant;
pushLores();
}
if (maintenant - tDernHr >= 3600000UL) {
tDernHr = maintenant;
sauvegarderHeure();
}
}
// Lores : jusqu'à 30h, résolution 5 min
void getHistoriqueJson(String &out) {
serJson(out, "lores", lrN, 300,
lrBat, lrPV, lrLoad, lrSOC, LORES_MAX, lrIdx);
}
// Hires : jusqu'à 4h, résolution 1 min
void getHistoriqueHiresJson(String &out) {
serJson(out, "hires", hrN, 60,
hrBat, hrPV, hrLoad, hrSOC, HIRES_MAX, hrIdx);
}
// Export CSV lores (30h, pas 5 min) — temps relatif en minutes depuis maintenant
void getHistoriqueCsv(String &out) {
out.reserve(lrN * 32 + 64);
out = "temps_min,batterie_V,pv_V,charge_W,soc_pct\r\n";
char tmp[48];
for (uint16_t i = 0; i < lrN; i++) {
uint16_t idx = lrIdx(i);
int32_t min = -((int32_t)(lrN - 1 - i) * 5);
snprintf(tmp, sizeof(tmp), "%ld,%.2f,%.2f,%.1f,%d\r\n",
(long)min, lrBat[idx], lrPV[idx], lrLoad[idx], lrSOC[idx]);
out += tmp;
}
}
void getHistoriqueStatusJson(String &out) {
out = "{";
out += "\"hires_n\":"; out += hrN;
out += ",\"hires_max\":"; out += HIRES_MAX;
out += ",\"lores_n\":"; out += lrN;
out += ",\"lores_max\":"; out += LORES_MAX;
out += ",\"acc_n\":"; out += accN;
out += ",\"rs485_ok\":"; out += state.rs485_ok ? "true" : "false";
out += ",\"last_update\":"; out += state.last_update;
out += ",\"millis\":"; out += millis();
out += ",\"next_hires_ms\":";
unsigned long now = millis();
out += (now - tDernMin >= 60000UL) ? 0 : (60000UL - (now - tDernMin));
out += ",\"next_lores_ms\":";
out += (now - tDern5Min >= 300000UL) ? 0 : (300000UL - (now - tDern5Min));
out += "}";
}
+9
View File
@@ -0,0 +1,9 @@
#pragma once
#include <Arduino.h>
void initHistorique();
void gererHistorique();
void getHistoriqueJson(String &out); // lores : 30h, point toutes les 5 min
void getHistoriqueHiresJson(String &out); // hires : 4h, point toutes les 1 min
void getHistoriqueCsv(String &out); // export CSV lores pour téléchargement
void getHistoriqueStatusJson(String &out); // debug compteurs internes
+54
View File
@@ -0,0 +1,54 @@
#include <Arduino.h>
#include "config.h"
#include "state.h"
#include "wifi_ap.h"
#include "webserver.h"
#include "ota.h"
#include "buttons.h"
#include "modbus_epever.h"
#include "rules.h"
#include "sleep.h"
#include "historique.h"
#include "debug_log.h"
// Instance globale partagée entre tous les modules
SystemState state;
void setup() {
Serial.begin(115200);
Serial.println("\n==============================");
Serial.println(" KC868-A2 Contrôleur solaire");
Serial.printf (" Reset reason : %d\n", (int)esp_reset_reason());
Serial.println("==============================");
debugLogf("Boot KC868-A2 — reset reason %d", (int)esp_reset_reason());
// Réveil timer : vérification rapide — peut retourner en deep sleep ici
verifierEtDormirSiNuit();
// Init GPIO relais
pinMode(PIN_RELAY1, OUTPUT);
pinMode(PIN_RELAY2, OUTPUT);
restaurerRelaisNVS(); // restaure depuis NVS (survit au power-off)
demarrerWifi();
demarrerWebserveur(); // monte LittleFS
demarrerOTA();
initBoutons();
initModbus();
chargerConfigSleep(); // après montage LittleFS
restaurerRelais(); // restaure l'état relais si réveil depuis deep sleep
initRegles();
initHistorique();
debugLogf("Système prêt.");
}
void loop() {
traiterDNS();
gererOTA();
gererBoutons();
gererModbus();
gererRegles();
gererHistorique();
gererSleep();
}
+741
View File
@@ -0,0 +1,741 @@
#include <ModbusRTU.h>
#include <Arduino.h>
#include <Preferences.h>
#include <time.h>
#include <sys/time.h>
#include "config.h"
#include "state.h"
#include "debug_log.h"
static ModbusRTU mb;
static uint32_t intervalleJour = INTERVALLE_MODBUS; // ms — mode soleil
static uint32_t intervalleNuit = 30000UL; // ms — mode veille
static uint32_t intervalCourant() {
if (state.last_update == 0) return intervalleJour; // première lecture toujours rapide
return state.sun ? intervalleJour : intervalleNuit;
}
void setIntervallesModbus(uint32_t jour_ms, uint32_t nuit_ms) {
intervalleJour = jour_ms;
intervalleNuit = nuit_ms;
Preferences p; p.begin("modbus", false);
p.putUInt("jour", jour_ms);
p.putUInt("nuit", nuit_ms);
p.end();
Serial.printf("[Modbus] Intervalles — jour:%ums nuit:%ums\n", jour_ms, nuit_ms);
}
void getIntervallesModbus(uint32_t &jour_ms, uint32_t &nuit_ms) {
jour_ms = intervalleJour;
nuit_ms = intervalleNuit;
}
// Buffers de réception — un par groupe de registres
static uint16_t bufPV[8]; // 0x3100..0x3107 : PV, batterie, courant/puissance charge
static uint16_t bufLoad[5]; // 0x310C..0x3110 : load + température batterie
static uint16_t bufSOC[1]; // 0x311A : SOC %
static uint16_t bufStatus[2]; // 0x3200 : Battery status | 0x3201 : Charging status
static uint16_t bufEnergie[16]; // 0x3304..0x3313 : kWh consommés/générés
static bool bufJourNuit[1]; // 0x200C : jour/nuit (FC02 discrete input)
static unsigned long tDerniereLecture = 0;
static unsigned long tDebutRequete = 0;
static bool lectureEnCours = false;
static uint32_t nbLecturesOK = 0;
static uint32_t nbErreurs = 0;
static uint8_t derniereErreur = 0;
static const char *derniereEtape = "aucune";
static unsigned long tDerniereSyncRtc = 0;
static bool rtcSyncedOnce = false;
static const unsigned long INTERVALLE_SYNC_RTC = 21600000UL; // 6h
static bool dernierSun = false;
static bool dernierSunValide = false;
static void finaliserLecture();
static uint16_t crc16Modbus(const uint8_t *buf, size_t len) {
uint16_t crc = 0xFFFF;
for (size_t i = 0; i < len; i++) {
crc ^= buf[i];
for (uint8_t bit = 0; bit < 8; bit++) {
crc = (crc & 1) ? (crc >> 1) ^ 0xA001 : crc >> 1;
}
}
return crc;
}
static void dumpHex(const char *prefix, const uint8_t *buf, size_t len) {
String ligne;
ligne.reserve(40 + len * 3);
ligne += prefix;
ligne += " (";
ligne += (unsigned)len;
ligne += " octets):";
for (size_t i = 0; i < len; i++) {
char hex[4];
snprintf(hex, sizeof(hex), " %02X", buf[i]);
ligne += hex;
}
Serial.println(ligne);
debugLogLine(ligne.c_str());
}
static void viderRx(const char *raison) {
uint8_t buf[MODBUS_DEBUG_RX_MAX];
size_t n = 0;
while (Serial2.available() && n < sizeof(buf)) {
buf[n++] = (uint8_t)Serial2.read();
}
while (Serial2.available()) Serial2.read();
if (n > 0) {
debugLogf("[Modbus][debug] RX vidé avant %s", raison);
dumpHex("[Modbus][debug] octets parasites", buf, n);
}
}
static bool probeRegistreBatterie(uint32_t baudrate) {
#if MODBUS_DEBUG_BOOT
debugLogf("[Modbus][probe] Test direct 0x3104 à %u bauds, esclave %d",
baudrate, MODBUS_ADRESSE);
Serial2.end();
delay(20);
Serial2.begin(baudrate, SERIAL_8N1, PIN_RS485_RX, PIN_RS485_TX);
delay(50);
viderRx("probe");
uint8_t req[8] = {
MODBUS_ADRESSE,
0x04,
0x31, 0x04,
0x00, 0x01,
0x00, 0x00
};
uint16_t crc = crc16Modbus(req, 6);
req[6] = crc & 0xFF;
req[7] = crc >> 8;
dumpHex("[Modbus][probe] TX", req, sizeof(req));
Serial2.write(req, sizeof(req));
Serial2.flush();
uint8_t resp[MODBUS_DEBUG_RX_MAX];
size_t n = 0;
unsigned long t0 = millis();
unsigned long dernierOctet = t0;
while ((millis() - t0) < 700 && n < sizeof(resp)) {
while (Serial2.available() && n < sizeof(resp)) {
resp[n++] = (uint8_t)Serial2.read();
dernierOctet = millis();
}
if (n >= 7 && (millis() - dernierOctet) > 20) break;
delay(1);
}
if (n == 0) {
debugLogf("[Modbus][probe] Aucun octet reçu à %u bauds", baudrate);
debugLogf("[Modbus][probe] Causes probables: A/B inversés, GND absent, mauvais baudrate, mauvais ID, Epever non alimenté.");
return false;
}
dumpHex("[Modbus][probe] RX", resp, n);
if (n < 5) {
debugLogf("[Modbus][probe] Réponse trop courte: bruit RS485 ou baudrate incorrect probable.");
return false;
}
if (resp[0] != MODBUS_ADRESSE) {
debugLogf("[Modbus][probe] Adresse inattendue: reçu %u, attendu %u",
resp[0], MODBUS_ADRESSE);
return false;
}
if (resp[1] & 0x80) {
debugLogf("[Modbus][probe] Exception Modbus fonction 0x%02X code 0x%02X",
resp[1], n > 2 ? resp[2] : 0);
return false;
}
if (resp[1] != 0x04 || resp[2] != 0x02 || n < 7) {
debugLogf("[Modbus][probe] Format inattendu pour lecture input register 0x3104.");
return false;
}
uint16_t crcCalc = crc16Modbus(resp, 5);
uint16_t crcRx = (uint16_t)resp[5] | ((uint16_t)resp[6] << 8);
if (crcCalc != crcRx) {
debugLogf("[Modbus][probe] CRC invalide: calcul 0x%04X, reçu 0x%04X", crcCalc, crcRx);
return false;
}
uint16_t brut = ((uint16_t)resp[3] << 8) | resp[4];
debugLogf("[Modbus][probe] OK à %u bauds: batterie %.2f V (brut 0x%04X)",
baudrate, brut * 0.01f, brut);
return true;
#else
(void)baudrate;
return false;
#endif
}
static bool lireRegistresBrutsFc(uint8_t fonction, uint16_t registre, uint16_t quantite,
uint16_t *dest, uint16_t timeoutMs, bool logSucces = false) {
if (quantite == 0 || quantite > 24) return false;
viderRx("lecture brute");
uint8_t req[8] = {
MODBUS_ADRESSE,
fonction,
(uint8_t)(registre >> 8), (uint8_t)(registre & 0xFF),
(uint8_t)(quantite >> 8), (uint8_t)(quantite & 0xFF),
0x00, 0x00
};
uint16_t crc = crc16Modbus(req, 6);
req[6] = crc & 0xFF;
req[7] = crc >> 8;
Serial2.write(req, sizeof(req));
Serial2.flush();
const size_t attendu = 5 + (size_t)quantite * 2;
uint8_t resp[MODBUS_DEBUG_RX_MAX];
size_t n = 0;
unsigned long t0 = millis();
unsigned long dernierOctet = t0;
while ((millis() - t0) < timeoutMs && n < sizeof(resp)) {
while (Serial2.available() && n < sizeof(resp)) {
resp[n++] = (uint8_t)Serial2.read();
dernierOctet = millis();
}
if (n >= attendu && (millis() - dernierOctet) > 5) break;
delay(1);
}
if (n == 0) {
nbErreurs++;
derniereErreur = 0xE0;
derniereEtape = "Brut";
debugLogf("[Modbus][brut] Timeout FC%02u registre 0x%04X, aucun octet reçu", fonction, registre);
dumpHex("[Modbus][brut] TX", req, sizeof(req));
return false;
}
if (n < attendu) {
nbErreurs++;
derniereErreur = 0xE2;
derniereEtape = "Brut court";
debugLogf("[Modbus][brut] Réponse courte FC%02u registre 0x%04X: reçu %u, attendu %u",
fonction, registre, (unsigned)n, (unsigned)attendu);
dumpHex("[Modbus][brut] TX", req, sizeof(req));
dumpHex("[Modbus][brut] RX", resp, n);
return false;
}
uint16_t crcCalc = crc16Modbus(resp, attendu - 2);
uint16_t crcRx = (uint16_t)resp[attendu - 2] | ((uint16_t)resp[attendu - 1] << 8);
if (crcCalc != crcRx) {
nbErreurs++;
derniereErreur = 0xE1;
derniereEtape = "Brut CRC";
debugLogf("[Modbus][brut] CRC invalide FC%02u registre 0x%04X: calcul 0x%04X, reçu 0x%04X",
fonction, registre, crcCalc, crcRx);
dumpHex("[Modbus][brut] TX", req, sizeof(req));
dumpHex("[Modbus][brut] RX", resp, n);
return false;
}
if (resp[0] != MODBUS_ADRESSE || resp[1] != fonction || resp[2] != quantite * 2) {
nbErreurs++;
derniereErreur = resp[1];
derniereEtape = "Brut format";
debugLogf("[Modbus][brut] Format inattendu FC%02u registre 0x%04X", fonction, registre);
dumpHex("[Modbus][brut] TX", req, sizeof(req));
dumpHex("[Modbus][brut] RX", resp, n);
return false;
}
for (uint16_t i = 0; i < quantite; i++) {
dest[i] = ((uint16_t)resp[3 + i * 2] << 8) | resp[4 + i * 2];
}
if (logSucces) {
debugLogf("[Modbus][brut] OK FC%02u registre 0x%04X x%u", fonction, registre, quantite);
}
return true;
}
static bool lireRegistresBruts(uint16_t registre, uint16_t quantite, uint16_t *dest,
uint16_t timeoutMs, bool logSucces = false) {
return lireRegistresBrutsFc(0x04, registre, quantite, dest, timeoutMs, logSucces);
}
static bool lireHoldingBruts(uint16_t registre, uint16_t quantite, uint16_t *dest,
uint16_t timeoutMs, bool logSucces = false) {
return lireRegistresBrutsFc(0x03, registre, quantite, dest, timeoutMs, logSucces);
}
static bool ecrireHoldingMultiplesBrut(uint16_t registre, uint16_t quantite,
const uint16_t *valeurs, uint16_t timeoutMs) {
if (quantite == 0 || quantite > 12) return false;
viderRx("écriture holding brute");
uint8_t req[MODBUS_DEBUG_RX_MAX];
size_t len = 7 + (size_t)quantite * 2 + 2;
if (len > sizeof(req)) return false;
req[0] = MODBUS_ADRESSE;
req[1] = 0x10;
req[2] = registre >> 8;
req[3] = registre & 0xFF;
req[4] = quantite >> 8;
req[5] = quantite & 0xFF;
req[6] = quantite * 2;
for (uint16_t i = 0; i < quantite; i++) {
req[7 + i * 2] = valeurs[i] >> 8;
req[8 + i * 2] = valeurs[i] & 0xFF;
}
uint16_t crc = crc16Modbus(req, len - 2);
req[len - 2] = crc & 0xFF;
req[len - 1] = crc >> 8;
dumpHex("[Modbus][write] TX", req, len);
Serial2.write(req, len);
Serial2.flush();
uint8_t resp[8];
size_t n = 0;
unsigned long t0 = millis();
while ((millis() - t0) < timeoutMs && n < sizeof(resp)) {
while (Serial2.available() && n < sizeof(resp)) resp[n++] = (uint8_t)Serial2.read();
if (n >= 8) break;
delay(1);
}
if (n < 8) {
debugLogf("[Modbus][write] Réponse courte FC16 registre 0x%04X: %u octets", registre, (unsigned)n);
if (n) dumpHex("[Modbus][write] RX", resp, n);
return false;
}
uint16_t crcCalc = crc16Modbus(resp, 6);
uint16_t crcRx = (uint16_t)resp[6] | ((uint16_t)resp[7] << 8);
bool ok = resp[0] == MODBUS_ADRESSE && resp[1] == 0x10 &&
resp[2] == (registre >> 8) && resp[3] == (registre & 0xFF) &&
resp[4] == (quantite >> 8) && resp[5] == (quantite & 0xFF) &&
crcCalc == crcRx;
dumpHex("[Modbus][write] RX", resp, n);
debugLogf("[Modbus][write] FC16 0x%04X x%u -> %s", registre, quantite, ok ? "OK" : "ERREUR");
return ok;
}
static bool lireEntreesDiscretesBrut(uint16_t adresse, bool *dest, uint16_t timeoutMs) {
viderRx("lecture discrete brute");
uint8_t req[8] = {
MODBUS_ADRESSE,
0x02,
(uint8_t)(adresse >> 8), (uint8_t)(adresse & 0xFF),
0x00, 0x01,
0x00, 0x00
};
uint16_t crc = crc16Modbus(req, 6);
req[6] = crc & 0xFF;
req[7] = crc >> 8;
Serial2.write(req, sizeof(req));
Serial2.flush();
uint8_t resp[8];
size_t n = 0;
unsigned long t0 = millis();
while ((millis() - t0) < timeoutMs && n < 6) {
while (Serial2.available() && n < sizeof(resp)) resp[n++] = (uint8_t)Serial2.read();
if (n >= 6) break;
delay(1);
}
if (n < 6) {
debugLogf("[Modbus][brut] Jour/nuit 0x%04X ignoré: pas de réponse FC02", adresse);
return false;
}
uint16_t crcCalc = crc16Modbus(resp, 4);
uint16_t crcRx = (uint16_t)resp[4] | ((uint16_t)resp[5] << 8);
if (resp[0] != MODBUS_ADRESSE || resp[1] != 0x02 || resp[2] != 1 || crcCalc != crcRx) {
debugLogf("[Modbus][brut] Jour/nuit 0x%04X ignoré: format/CRC invalide", adresse);
dumpHex("[Modbus][brut] RX FC02", resp, n);
return false;
}
*dest = (resp[3] & 0x01) != 0;
debugLogf("[Modbus][brut] FC02 0x%04X = %u", adresse, *dest ? 1 : 0);
return true;
}
static float u32x100(const uint16_t *reg, uint8_t indexL) {
// Le PDF EPEVER indique les 32 bits en deux registres: L puis H.
return (((uint32_t)reg[indexL + 1] << 16) | reg[indexL]) * 0.01f;
}
static void calerHorlogeEspDepuisEpever() {
struct tm tmRtc = {};
tmRtc.tm_year = state.epeverYear - 1900;
tmRtc.tm_mon = state.epeverMonth - 1;
tmRtc.tm_mday = state.epeverDay;
tmRtc.tm_hour = state.epeverHour;
tmRtc.tm_min = state.epeverMinute;
tmRtc.tm_sec = state.epeverSecond;
time_t epoch = mktime(&tmRtc);
if (epoch <= 0) {
state.espClockOk = false;
debugLogf("[RTC] Impossible de convertir l'heure Epever en epoch");
return;
}
timeval tv = { epoch, 0 };
settimeofday(&tv, nullptr);
state.espClockOk = true;
debugLogf("[RTC] Horloge ESP32 calée depuis Epever");
}
static void lireHorlogeEpever(bool force) {
unsigned long maintenant = millis();
if (!force && rtcSyncedOnce && (maintenant - tDerniereSyncRtc) < INTERVALLE_SYNC_RTC) return;
uint16_t rtc[3];
if (!lireHoldingBruts(0x9013, 3, rtc, 700, true)) {
state.epeverClockOk = false;
debugLogf("[Modbus][rtc] Horloge Epever indisponible");
return;
}
state.epeverSecond = rtc[0] & 0xFF;
state.epeverMinute = (rtc[0] >> 8) & 0xFF;
state.epeverHour = rtc[1] & 0xFF;
state.epeverDay = (rtc[1] >> 8) & 0xFF;
state.epeverMonth = rtc[2] & 0xFF;
state.epeverYear = 2000 + ((rtc[2] >> 8) & 0xFF);
bool valide = state.epeverSecond < 60 && state.epeverMinute < 60 &&
state.epeverHour < 24 && state.epeverDay >= 1 &&
state.epeverDay <= 31 && state.epeverMonth >= 1 &&
state.epeverMonth <= 12;
state.epeverClockOk = valide;
debugLogf("[Modbus][rtc] %04u-%02u-%02u %02u:%02u:%02u (%s)",
state.epeverYear, state.epeverMonth, state.epeverDay,
state.epeverHour, state.epeverMinute, state.epeverSecond,
valide ? "OK" : "invalide");
if (valide) {
tDerniereSyncRtc = maintenant;
rtcSyncedOnce = true;
calerHorlogeEspDepuisEpever();
}
}
static void enregistrerChangementSoleil(bool nouveauSun) {
if (!dernierSunValide) {
dernierSun = nouveauSun;
dernierSunValide = true;
return;
}
if (nouveauSun == dernierSun) return;
dernierSun = nouveauSun;
uint8_t idx = state.sunHistoryHead;
state.sunHistoryState[idx] = nouveauSun;
if (state.espClockOk) {
time_t now = time(nullptr);
struct tm tmNow;
localtime_r(&now, &tmNow);
snprintf(state.sunHistoryTime[idx], sizeof(state.sunHistoryTime[idx]),
"%04d-%02d-%02d %02d:%02d:%02d",
tmNow.tm_year + 1900, tmNow.tm_mon + 1, tmNow.tm_mday,
tmNow.tm_hour, tmNow.tm_min, tmNow.tm_sec);
} else if (state.epeverClockOk) {
snprintf(state.sunHistoryTime[idx], sizeof(state.sunHistoryTime[idx]),
"%04u-%02u-%02u %02u:%02u:%02u",
state.epeverYear, state.epeverMonth, state.epeverDay,
state.epeverHour, state.epeverMinute, state.epeverSecond);
} else {
snprintf(state.sunHistoryTime[idx], sizeof(state.sunHistoryTime[idx]),
"uptime %lus", millis() / 1000);
}
state.sunHistoryHead = (state.sunHistoryHead + 1) % 5;
if (state.sunHistoryCount < 5) state.sunHistoryCount++;
state.sunHistoryValid = true;
debugLogf("[SUN] Changement état -> %s à %s",
nouveauSun ? "JOUR" : "NUIT", state.sunHistoryTime[idx]);
}
static bool effectuerLectureBruteEpever() {
uint16_t pv[8];
uint16_t load[5];
uint16_t soc[1];
uint16_t status[2];
uint16_t energie[18];
bool nuit = false;
debugLogf("[Modbus][brut] Début cycle lecture Epever");
if (!lireRegistresBruts(0x3100, 8, pv, 700, true)) return false;
if (!lireRegistresBruts(0x310C, 5, load, 700, true)) return false;
if (!lireRegistresBruts(0x311A, 1, soc, 700, true)) return false;
if (!lireRegistresBruts(0x3200, 2, status, 700, true)) return false;
lireHorlogeEpever(false);
if (lireRegistresBruts(0x3302, 18, energie, 900, false)) {
// Base 0x3302 selon MODBUS-Protocol-v25.pdf:
// 0x3304/05 conso jour, 0x330A/0B conso totale,
// 0x330C/0D production jour, 0x3312/13 production totale.
state.energieConJour = u32x100(energie, 2);
state.energieConTotal = u32x100(energie, 8);
state.energieGenJour = u32x100(energie, 10);
state.energieGenTotal = u32x100(energie, 16);
debugLogf("[Modbus][brut] Energie: genJ=%.2fkWh consoJ=%.2fkWh genTot=%.2fkWh consoTot=%.2fkWh",
state.energieGenJour, state.energieConJour,
state.energieGenTotal, state.energieConTotal);
} else {
debugLogf("[Modbus][brut] Energie ignorée, les valeurs précédentes sont conservées");
}
state.pv = pv[0] * 0.01f;
state.pvCurrent = pv[1] * 0.01f;
state.battery = pv[4] * 0.01f;
state.loadVoltage = load[0] * 0.01f;
state.loadCurrent = load[1] * 0.01f;
state.loadPower = u32x100(load, 2); // 0x310E/0x310F L/H
state.batTemperature = (int16_t)load[4] * 0.01f;
state.batSOC = (uint8_t)constrain((int)soc[0], 0, 100);
uint8_t batVoltStatus = status[0] & 0x0F;
state.batSousVoltage = (batVoltStatus == 2);
state.batSurVoltage = (batVoltStatus == 1);
state.batStatut = (status[1] >> 2) & 0x03;
if (lireEntreesDiscretesBrut(0x200C, &nuit, 500)) {
// Le registre officiel dit 1=Nuit, 0=Jour. Si le PV est clairement
// présent, on force jour pour éviter un état incohérent côté UI.
state.sun = !nuit || state.pv > 2.0f;
} else {
state.sun = state.pv > 2.0f;
}
enregistrerChangementSoleil(state.sun);
debugLogf("[Modbus][brut] Bruts: 3110(temp)=0x%04X 311A(SOC)=%u 3200=0x%04X 3201=0x%04X sun=%u",
load[4], soc[0], status[0], status[1], state.sun ? 1 : 0);
finaliserLecture();
return true;
}
bool reglerHorlogeEpever(uint16_t annee, uint8_t mois, uint8_t jour,
uint8_t heure, uint8_t minute, uint8_t seconde) {
if (lectureEnCours) {
debugLogf("[Modbus][rtc] Réglage refusé: cycle lecture en cours");
return false;
}
if (annee < 2000 || annee > 2099 || mois < 1 || mois > 12 || jour < 1 || jour > 31 ||
heure > 23 || minute > 59 || seconde > 59) {
debugLogf("[Modbus][rtc] Réglage refusé: date/heure invalide");
return false;
}
uint16_t regs[3];
regs[0] = ((uint16_t)minute << 8) | seconde; // 0x9013
regs[1] = ((uint16_t)jour << 8) | heure; // 0x9014
regs[2] = ((uint16_t)(annee - 2000) << 8) | mois; // 0x9015
lectureEnCours = true;
bool ok = ecrireHoldingMultiplesBrut(0x9013, 3, regs, 1000);
lectureEnCours = false;
if (ok) {
rtcSyncedOnce = false;
lireHorlogeEpever(true);
}
return ok;
}
static void debugBootModbus() {
#if MODBUS_DEBUG_BOOT
debugLogf("--- Diagnostic RS485 boot ---");
debugLogf(" UART ESP32 : Serial2");
debugLogf(" RX GPIO : %d", PIN_RS485_RX);
debugLogf(" TX GPIO : %d", PIN_RS485_TX);
debugLogf(" Adresse Epever : %d", MODBUS_ADRESSE);
debugLogf(" Baud principal : %u", (uint32_t)MODBUS_BAUDRATE);
debugLogf(" Trame test : FC04 registre 0x3104 tension batterie");
debugLogf(" Rappel câblage : Epever A/D+ vers KC868 A, Epever B/D- vers KC868 B, GND recommandé");
bool okPrincipal = probeRegistreBatterie(MODBUS_BAUDRATE);
if (!okPrincipal && MODBUS_BAUDRATE != 9600) probeRegistreBatterie(9600);
if (!okPrincipal && MODBUS_BAUDRATE != 115200) probeRegistreBatterie(115200);
debugLogf("--- Fin diagnostic RS485 boot ---");
#endif
}
// Fin de chaîne — appelé après la dernière lecture
static void finaliserLecture() {
state.rs485_ok = true;
state.last_update = millis();
lectureEnCours = false;
nbLecturesOK++;
debugLogf("Modbus OK #%u — Bat:%.2fV %d%% PV:%.2fV %.2fA Load:%.1fW %s",
nbLecturesOK,
state.battery, state.batSOC, state.pv, state.pvCurrent,
state.loadPower, state.sun ? "JOUR" : "NUIT");
}
static const char* codeModbus(uint8_t code) {
switch (code) {
case 0x01: return "Fonction non supportée";
case 0x02: return "Adresse registre invalide";
case 0x03: return "Valeur invalide";
case 0x04: return "Erreur matérielle esclave";
case 0xE0: return "Timeout (pas de réponse)";
case 0xE1: return "CRC invalide";
case 0xE2: return "Exception générale";
default: return "Inconnu";
}
}
static void erreurLecture(const char *etape, uint8_t code) {
state.rs485_ok = false;
lectureEnCours = false;
nbErreurs++;
derniereErreur = code;
derniereEtape = etape;
debugLogf("Modbus ERREUR #%u [%s] code=0x%02X (%s), ok=%u, uptime=%lums",
nbErreurs, etape, code, codeModbus(code), nbLecturesOK, millis());
debugLogf("[Modbus][aide] Si timeout: vérifier A/B, GND, baudrate, ID=%d, RJ45 Epever non branché sur Ethernet.",
MODBUS_ADRESSE);
}
// Chaîne de lectures : PV → Load → SOC → Status → Energie → JourNuit → fin
static bool cbJourNuit(Modbus::ResultCode ev, uint16_t, void*) {
if (ev == Modbus::EX_SUCCESS) {
// 0x200C FC02 : bit D0 = 1 → Nuit, 0 → Jour
state.sun = !bufJourNuit[0];
} else {
Serial.printf("Modbus [JourNuit] : 0x%02X — ignoré\n", ev);
}
finaliserLecture(); // non-fatal : on finalise dans tous les cas
return true;
}
static bool cbEnergie(Modbus::ResultCode ev, uint16_t, void*) {
if (ev == Modbus::EX_SUCCESS) {
// Lecture depuis 0x3300, registres 32 bits little-endian (L word first)
state.energieGenJour = ((uint32_t)bufEnergie[1] << 16 | bufEnergie[0]) * 0.01f; // 0x3300-01
state.energieGenTotal = ((uint32_t)bufEnergie[7] << 16 | bufEnergie[6]) * 0.01f; // 0x3306-07
state.energieConJour = ((uint32_t)bufEnergie[9] << 16 | bufEnergie[8]) * 0.01f; // 0x3308-09
state.energieConTotal = ((uint32_t)bufEnergie[15] << 16 | bufEnergie[14]) * 0.01f; // 0x330E-0F
tDebutRequete = millis();
mb.readIsts(MODBUS_ADRESSE, 0x200C, bufJourNuit, 1, cbJourNuit); // FC02 discrete input
} else {
Serial.printf("Modbus [Energie] : 0x%02X — ignoré\n", ev);
finaliserLecture(); // non-fatal
}
return true;
}
static bool cbStatus(Modbus::ResultCode ev, uint16_t, void*) {
if (ev != Modbus::EX_SUCCESS) { erreurLecture("Status", ev); return true; }
// 0x3200 D3-D0 : 00=Normal, 01=Over voltage, 02=Under voltage, 03=Over discharge
uint8_t batVoltStatus = bufStatus[0] & 0x0F;
state.batSousVoltage = (batVoltStatus == 2);
state.batSurVoltage = (batVoltStatus == 1);
// 0x3201 D3-D2 : 00=No charge, 01=Float, 02=Boost, 03=Equalization
state.batStatut = (bufStatus[1] >> 2) & 0x03;
tDebutRequete = millis();
mb.readIreg(MODBUS_ADRESSE, 0x3300, bufEnergie, 16, cbEnergie);
return true;
}
static bool cbSOC(Modbus::ResultCode ev, uint16_t, void*) {
if (ev != Modbus::EX_SUCCESS) { erreurLecture("SOC", ev); return true; }
state.batSOC = (uint8_t)bufSOC[0];
tDebutRequete = millis();
mb.readIreg(MODBUS_ADRESSE, 0x3200, bufStatus, 2, cbStatus); // 0x3200 + 0x3201
return true;
}
static bool cbLoad(Modbus::ResultCode ev, uint16_t, void*) {
if (ev != Modbus::EX_SUCCESS) { erreurLecture("Load", ev); return true; }
state.loadVoltage = bufLoad[0] * 0.01f; // 0x310C
state.loadCurrent = bufLoad[1] * 0.01f; // 0x310D
state.loadPower = bufLoad[2] * 0.01f; // 0x310E
// bufLoad[3] = 0x310F réservé
state.batTemperature = (int16_t)bufLoad[4] * 0.01f; // 0x3110 signé
tDebutRequete = millis();
mb.readIreg(MODBUS_ADRESSE, 0x311A, bufSOC, 1, cbSOC);
return true;
}
static bool cbPV(Modbus::ResultCode ev, uint16_t, void*) {
if (ev != Modbus::EX_SUCCESS) { erreurLecture("PV", ev); return true; }
state.pv = bufPV[0] * 0.01f; // 0x3100 Tension PV
state.pvCurrent = bufPV[1] * 0.01f; // 0x3101 Courant PV
state.battery = bufPV[4] * 0.01f; // 0x3104 Tension batterie
tDebutRequete = millis();
mb.readIreg(MODBUS_ADRESSE, 0x310C, bufLoad, 5, cbLoad);
return true;
}
void initModbus() {
Preferences p; p.begin("modbus", true);
intervalleJour = p.getUInt("jour", INTERVALLE_MODBUS);
intervalleNuit = p.getUInt("nuit", 30000UL);
p.end();
debugLogf("--- Modbus init ---");
debugLogf(" Adresse esclave : %d", MODBUS_ADRESSE);
debugLogf(" Baud rate : %u", (uint32_t)MODBUS_BAUDRATE);
debugLogf(" TX GPIO : %d", PIN_RS485_TX);
debugLogf(" RX GPIO : %d", PIN_RS485_RX);
debugLogf(" Timeout requête : %d ms", TIMEOUT_MODBUS);
debugLogf(" Intervalle jour : %u ms", intervalleJour);
debugLogf(" Intervalle nuit : %u ms", intervalleNuit);
debugBootModbus();
Serial2.end();
delay(20);
Serial2.begin(MODBUS_BAUDRATE, SERIAL_8N1, PIN_RS485_RX, PIN_RS485_TX);
viderRx("démarrage ModbusRTU");
mb.begin(&Serial2);
mb.master();
debugLogf(" → Serial2 + Modbus master démarrés");
debugLogf("-------------------");
}
void gererModbus() {
unsigned long maintenant = millis();
if (!lectureEnCours && (maintenant - tDerniereLecture) >= intervalCourant()) {
tDerniereLecture = maintenant;
tDebutRequete = maintenant;
lectureEnCours = true;
debugLogf("[Modbus] Début lecture brute — uptime=%lus, baud=%u, ID=%d, erreurs=%u, dernière=%s/0x%02X",
maintenant / 1000, (uint32_t)MODBUS_BAUDRATE, MODBUS_ADRESSE,
nbErreurs, derniereEtape, derniereErreur);
if (!effectuerLectureBruteEpever()) {
state.rs485_ok = false;
lectureEnCours = false;
debugLogf("[Modbus][brut] Cycle échoué — dernière=%s/0x%02X, erreurs=%u",
derniereEtape, derniereErreur, nbErreurs);
}
}
}
+9
View File
@@ -0,0 +1,9 @@
#pragma once
#include <stdint.h>
void initModbus();
void gererModbus();
void setIntervallesModbus(uint32_t jour_ms, uint32_t nuit_ms);
void getIntervallesModbus(uint32_t &jour_ms, uint32_t &nuit_ms);
bool reglerHorlogeEpever(uint16_t annee, uint8_t mois, uint8_t jour,
uint8_t heure, uint8_t minute, uint8_t seconde);
+13
View File
@@ -0,0 +1,13 @@
#include <ElegantOTA.h>
#include "config.h"
#include "webserver.h"
void demarrerOTA() {
ElegantOTA.begin(&server);
Serial.println("OTA disponible sur http://192.168.4.1/update (sans authentification)");
}
// Doit être appelé dans loop() pour que l'OTA async fonctionne
void gererOTA() {
ElegantOTA.loop();
}
+4
View File
@@ -0,0 +1,4 @@
#pragma once
void demarrerOTA();
void gererOTA();
+220
View File
@@ -0,0 +1,220 @@
#include <LittleFS.h>
#include <ArduinoJson.h>
#include <Arduino.h>
#include "config.h"
#include "state.h"
#include "rules.h"
#define MAX_REGLES 20
#define FICHIER_REGLES "/rules.json"
struct Regle {
int id;
bool enabled;
// Déclencheurs
int8_t sun; // -1=ignoré, 0=nuit requis, 1=jour requis
int8_t di1; // -1=ignoré, 0=ouvert requis, 1=fermé requis
int8_t di2; // -1=ignoré, 0=ouvert requis, 1=fermé requis
// Conditions
float batteryMin; // seuil min batterie en V (0 = ignoré)
float batteryMax; // seuil max batterie en V (0 = ignoré)
float pvMin; // seuil min PV en V (0 = ignoré)
float pvMax; // seuil max PV en V (0 = ignoré)
// Action
uint8_t relay; // 1 ou 2
bool etat; // true=ON, false=OFF
uint32_t delai; // délai avant action (secondes)
float hysteresis; // bande morte en V
// État runtime — non persisté
bool delaiEnCours;
unsigned long tDebutDelai;
bool estActif;
};
static Regle regles[MAX_REGLES];
static int nbRegles = 0;
static unsigned long tDerniereEval = 0;
// --- Persistance ---
static int8_t parseTri(JsonObject &obj, const char *key) {
if (!obj[key].is<bool>()) return -1;
return obj[key].as<bool>() ? 1 : 0;
}
static void jsonVersRegle(JsonObject obj, Regle &r) {
r.enabled = obj["enabled"] | true;
r.sun = parseTri(obj, "sun");
r.di1 = parseTri(obj, "di1");
r.di2 = parseTri(obj, "di2");
r.batteryMin = obj["battery_min"] | 0.0f;
r.batteryMax = obj["battery_max"] | 0.0f;
r.pvMin = obj["pv_min"] | 0.0f;
r.pvMax = obj["pv_max"] | 0.0f;
r.relay = obj["relay"] | 1;
r.etat = obj["state"] | false;
r.delai = obj["delay"] | 0u;
r.hysteresis = obj["hysteresis"] | 0.0f;
r.delaiEnCours = false;
r.tDebutDelai = 0;
r.estActif = false;
}
static void chargerRegles() {
nbRegles = 0;
if (!LittleFS.exists(FICHIER_REGLES)) {
Serial.println("rules.json absent — aucune règle chargée");
return;
}
File f = LittleFS.open(FICHIER_REGLES, "r");
if (!f) { Serial.println("Erreur ouverture rules.json"); return; }
JsonDocument doc;
if (deserializeJson(doc, f)) {
Serial.println("Erreur parsing rules.json");
f.close();
return;
}
f.close();
for (JsonObject obj : doc.as<JsonArray>()) {
if (nbRegles >= MAX_REGLES) break;
regles[nbRegles].id = obj["id"] | (nbRegles + 1);
jsonVersRegle(obj, regles[nbRegles]);
nbRegles++;
}
Serial.printf("%d règle(s) chargée(s)\n", nbRegles);
}
static bool sauvegarderRegles() {
File f = LittleFS.open(FICHIER_REGLES, "w");
if (!f) { Serial.println("Erreur écriture rules.json"); return false; }
JsonDocument doc;
JsonArray arr = doc.to<JsonArray>();
reglesToJson(arr);
serializeJson(doc, f);
f.close();
return true;
}
// --- Logique d'évaluation ---
// Hystérésis : quand la règle est active, les seuils sont relâchés d'une
// bande `hysteresis` pour éviter les oscillations autour du point de consigne.
static bool conditionsSatisfaites(const Regle &r) {
// Seuils batterie avec hystérésis si la règle était déjà active
float batMinEff = (r.hysteresis > 0 && r.estActif) ? r.batteryMin - r.hysteresis : r.batteryMin;
float batMaxEff = (r.hysteresis > 0 && r.estActif) ? r.batteryMax + r.hysteresis : r.batteryMax;
float pvMinEff = (r.hysteresis > 0 && r.estActif) ? r.pvMin - r.hysteresis : r.pvMin;
float pvMaxEff = (r.hysteresis > 0 && r.estActif) ? r.pvMax + r.hysteresis : r.pvMax;
// Déclencheurs
if (r.sun >= 0 && (bool)(r.sun == 1) != state.sun) return false;
if (r.di1 >= 0 && (bool)(r.di1 == 1) != state.di1) return false;
if (r.di2 >= 0 && (bool)(r.di2 == 1) != state.di2) return false;
// Conditions
if (r.batteryMin > 0 && state.battery < batMinEff) return false;
if (r.batteryMax > 0 && state.battery > batMaxEff) return false;
if (r.pvMin > 0 && state.pv < pvMinEff) return false;
if (r.pvMax > 0 && state.pv > pvMaxEff) return false;
return true;
}
static void appliquerAction(const Regle &r) {
if (r.relay == 1) {
state.relay1 = r.etat;
digitalWrite(PIN_RELAY1, r.etat ? HIGH : LOW);
} else if (r.relay == 2) {
state.relay2 = r.etat;
digitalWrite(PIN_RELAY2, r.etat ? HIGH : LOW);
}
Serial.printf("Règle %d appliquée — relais %d : %s\n", r.id, r.relay, r.etat ? "ON" : "OFF");
}
// --- API publique ---
void initRegles() {
chargerRegles();
}
void gererRegles() {
unsigned long maintenant = millis();
if (maintenant - tDerniereEval < INTERVALLE_REGLES) return;
tDerniereEval = maintenant;
for (int i = 0; i < nbRegles; i++) {
Regle &r = regles[i];
if (!r.enabled) continue;
if (conditionsSatisfaites(r)) {
r.estActif = true;
if (r.delai == 0) {
appliquerAction(r);
} else if (!r.delaiEnCours) {
r.delaiEnCours = true;
r.tDebutDelai = maintenant;
Serial.printf("Règle %d — délai %ds démarré\n", r.id, r.delai);
} else if (maintenant - r.tDebutDelai >= (unsigned long)r.delai * 1000UL) {
appliquerAction(r);
r.delaiEnCours = false;
}
} else {
r.estActif = false;
if (r.delaiEnCours) {
r.delaiEnCours = false;
Serial.printf("Règle %d — conditions perdues, délai annulé\n", r.id);
}
}
}
}
void reglesToJson(JsonArray arr) {
for (int i = 0; i < nbRegles; i++) {
const Regle &r = regles[i];
JsonObject obj = arr.add<JsonObject>();
obj["id"] = r.id;
obj["enabled"] = r.enabled;
if (r.sun >= 0) obj["sun"] = (bool)(r.sun == 1);
if (r.di1 >= 0) obj["di1"] = (bool)(r.di1 == 1);
if (r.di2 >= 0) obj["di2"] = (bool)(r.di2 == 1);
if (r.batteryMin > 0) obj["battery_min"] = r.batteryMin;
if (r.batteryMax > 0) obj["battery_max"] = r.batteryMax;
if (r.pvMin > 0) obj["pv_min"] = r.pvMin;
if (r.pvMax > 0) obj["pv_max"] = r.pvMax;
obj["relay"] = r.relay;
obj["state"] = r.etat;
obj["delay"] = r.delai;
if (r.hysteresis > 0) obj["hysteresis"] = r.hysteresis;
}
}
bool ajouterRegle(JsonObject obj) {
if (nbRegles >= MAX_REGLES) return false;
int maxId = 0;
for (int i = 0; i < nbRegles; i++) if (regles[i].id > maxId) maxId = regles[i].id;
regles[nbRegles].id = maxId + 1;
jsonVersRegle(obj, regles[nbRegles]);
nbRegles++;
return sauvegarderRegles();
}
bool supprimerRegle(int id) {
for (int i = 0; i < nbRegles; i++) {
if (regles[i].id != id) continue;
for (int j = i; j < nbRegles - 1; j++) regles[j] = regles[j + 1];
nbRegles--;
return sauvegarderRegles();
}
return false;
}
bool toggleRegle(int id) {
for (int i = 0; i < nbRegles; i++) {
if (regles[i].id != id) continue;
regles[i].enabled = !regles[i].enabled;
return sauvegarderRegles();
}
return false;
}
+10
View File
@@ -0,0 +1,10 @@
#pragma once
#include <ArduinoJson.h>
void initRegles();
void gererRegles();
void reglesToJson(JsonArray arr);
bool ajouterRegle(JsonObject obj);
bool supprimerRegle(int id);
bool toggleRegle(int id);
+155
View File
@@ -0,0 +1,155 @@
#include <WiFi.h>
#include <LittleFS.h>
#include <ArduinoJson.h>
#include <Arduino.h>
#include <esp_sleep.h>
#include "config.h"
#include "state.h"
// Persisté en mémoire RTC — survit au deep sleep, perdu au power-off complet
RTC_DATA_ATTR static bool rtcSleepActif = false; // désactivé par défaut
RTC_DATA_ATTR static uint32_t rtcIntervalle = 600; // secondes entre réveil
RTC_DATA_ATTR static float rtcSeuilSoleil = 2.0f; // V PV minimum = jour
RTC_DATA_ATTR static bool rtcRelay1 = false; // état relais sauvegardé
RTC_DATA_ATTR static bool rtcRelay2 = false;
// Runtime
static bool enModeNuit = false;
static unsigned long tDebutNuit = 0;
#define TEMPO_CONFIRMATION_NUIT 60000UL // 60s de nuit confirmée avant de dormir
#define FICHIER_SLEEP "/sleep.json"
// --- Utilitaires ---
static uint16_t crc16Modbus(const uint8_t *buf, int len) {
uint16_t crc = 0xFFFF;
for (int i = 0; i < len; i++) {
crc ^= buf[i];
for (int b = 0; b < 8; b++)
crc = (crc & 1) ? (crc >> 1) ^ 0xA001 : crc >> 1;
}
return crc;
}
// Lecture synchrone de la tension PV via Modbus RTU brut
// Utilisé uniquement au réveil, avant que le serveur web soit démarré
static float lirePVSync() {
uint8_t req[8] = { MODBUS_ADRESSE, 0x04, 0x31, 0x00, 0x00, 0x01, 0x00, 0x00 };
uint16_t crc = crc16Modbus(req, 6);
req[6] = crc & 0xFF;
req[7] = crc >> 8;
while (Serial2.available()) Serial2.read(); // vider buffer résiduel
Serial2.write(req, 8);
Serial2.flush();
unsigned long t = millis();
while (Serial2.available() < 7 && millis() - t < 300);
if (Serial2.available() < 7) return -1.0f;
uint8_t resp[7];
Serial2.readBytes(resp, 7);
if (resp[0] != MODBUS_ADRESSE || resp[1] != 0x04 || resp[2] != 2) return -1.0f;
return ((resp[3] << 8) | resp[4]) * 0.01f;
}
static void entrerEnDeepSleep() {
Serial.printf("Deep sleep — réveil dans %ds\n", rtcIntervalle);
Serial.flush();
WiFi.mode(WIFI_OFF);
delay(50);
esp_sleep_enable_timer_wakeup((uint64_t)rtcIntervalle * 1000000ULL);
esp_deep_sleep_start();
// Ne revient jamais ici
}
// --- API publique ---
void verifierEtDormirSiNuit() {
if (esp_sleep_get_wakeup_cause() != ESP_SLEEP_WAKEUP_TIMER) return;
if (!rtcSleepActif) return;
Serial.println("Réveil timer — vérification ensoleillement...");
Serial2.begin(9600, SERIAL_8N1, PIN_RS485_RX, PIN_RS485_TX);
delay(50);
float pv = lirePVSync();
Serial2.end();
Serial.printf("PV = %.2fV (seuil %.1fV)\n", pv, rtcSeuilSoleil);
if (pv >= 0.0f && pv < rtcSeuilSoleil) {
Serial.println("Toujours nuit → re-sleep");
entrerEnDeepSleep(); // ne revient pas
}
Serial.println("Jour détecté → démarrage complet");
}
void restaurerRelais() {
if (esp_sleep_get_wakeup_cause() != ESP_SLEEP_WAKEUP_TIMER) return;
state.relay1 = rtcRelay1;
state.relay2 = rtcRelay2;
digitalWrite(PIN_RELAY1, rtcRelay1 ? HIGH : LOW);
digitalWrite(PIN_RELAY2, rtcRelay2 ? HIGH : LOW);
Serial.printf("Relais restaurés — R1:%d R2:%d\n", rtcRelay1, rtcRelay2);
}
void chargerConfigSleep() {
if (!LittleFS.exists(FICHIER_SLEEP)) return;
File f = LittleFS.open(FICHIER_SLEEP, "r");
if (!f) return;
JsonDocument doc;
if (!deserializeJson(doc, f)) {
rtcSleepActif = doc["actif"] | false;
rtcIntervalle = doc["intervalle"] | 600u;
rtcSeuilSoleil = doc["seuil"] | 2.0f;
}
f.close();
Serial.printf("Sleep config — actif:%d intervalle:%ds seuil:%.1fV\n",
rtcSleepActif, rtcIntervalle, rtcSeuilSoleil);
}
void gererSleep() {
if (!rtcSleepActif) return;
if (!state.rs485_ok) return; // pas de données fiables — ne pas dormir
unsigned long maintenant = millis();
if (!state.sun) {
if (!enModeNuit) {
enModeNuit = true;
tDebutNuit = maintenant;
Serial.printf("Nuit — sleep dans %lus si confirmé\n", TEMPO_CONFIRMATION_NUIT / 1000);
return;
}
if (maintenant - tDebutNuit < TEMPO_CONFIRMATION_NUIT) return;
rtcRelay1 = state.relay1; // sauvegarder état relais en RTC
rtcRelay2 = state.relay2;
entrerEnDeepSleep(); // ne revient pas
} else {
enModeNuit = false;
}
}
void getSleepConfigJson(String &out) {
JsonDocument doc;
doc["actif"] = rtcSleepActif;
doc["intervalle"] = rtcIntervalle;
doc["seuil"] = rtcSeuilSoleil;
serializeJson(doc, out);
}
bool setSleepConfig(bool actif, uint32_t intervalle, float seuil) {
rtcSleepActif = actif;
rtcIntervalle = intervalle;
rtcSeuilSoleil = seuil;
File f = LittleFS.open(FICHIER_SLEEP, "w");
if (!f) return false;
JsonDocument doc;
doc["actif"] = actif;
doc["intervalle"] = intervalle;
doc["seuil"] = seuil;
serializeJson(doc, f);
f.close();
return true;
}
+18
View File
@@ -0,0 +1,18 @@
#pragma once
#include <Arduino.h>
// Appelé au tout début de setup() — entre en deep sleep si réveil timer + nuit
void verifierEtDormirSiNuit();
// Appelé après montage de LittleFS
void chargerConfigSleep();
// Restaure les relais depuis la mémoire RTC après un réveil
void restaurerRelais();
// Appelé dans loop()
void gererSleep();
// API REST
void getSleepConfigJson(String &out);
bool setSleepConfig(bool actif, uint32_t intervalle, float seuil);
+61
View File
@@ -0,0 +1,61 @@
#pragma once
struct SystemState {
// --- PV ---
float pv = 0.0f; // Tension PV (V)
float pvCurrent = 0.0f; // Courant PV (A)
// --- Batterie ---
float battery = 0.0f; // Tension (V)
float batTemperature = 0.0f; // Température (°C)
uint8_t batSOC = 0; // Charge restante (%)
uint8_t batStatut = 0; // 0=arrêt 1=float 2=boost 3=égalisation
bool batSousVoltage = false;
bool batSurVoltage = false;
// --- Sortie de charge (load) ---
float loadVoltage = 0.0f; // Tension (V)
float loadCurrent = 0.0f; // Courant (A)
float loadPower = 0.0f; // Puissance (W)
// --- Énergie (kWh, calculées par l'Epever) ---
float energieGenJour = 0.0f; // Générée aujourd'hui
float energieGenTotal = 0.0f; // Générée total
float energieConJour = 0.0f; // Consommée aujourd'hui
float energieConTotal = 0.0f; // Consommée total
// --- Ensoleillement ---
bool sun = false; // true = jour
bool sunHistoryValid = false;
uint8_t sunHistoryCount = 0;
uint8_t sunHistoryHead = 0;
bool sunHistoryState[5] = {};
char sunHistoryTime[5][20] = {};
// --- Horloge interne Epever ---
bool epeverClockOk = false;
uint8_t epeverSecond = 0;
uint8_t epeverMinute = 0;
uint8_t epeverHour = 0;
uint8_t epeverDay = 0;
uint8_t epeverMonth = 0;
uint16_t epeverYear = 0;
bool espClockOk = false;
// --- Relais ---
bool relay1 = false;
bool relay2 = false;
// --- Boutons DI ---
bool di1 = false;
bool di2 = false;
// --- Mode ---
bool autoMode = true; // true = automatique, false = manuel
// --- Santé RS485 ---
bool rs485_ok = false;
unsigned long last_update = 0;
};
extern SystemState state;
+368
View File
@@ -0,0 +1,368 @@
#include <ESPAsyncWebServer.h>
#include <AsyncJson.h>
#include <LittleFS.h>
#include <ArduinoJson.h>
#include <Preferences.h>
#include <time.h>
#include "config.h"
#include "state.h"
#include "webserver.h"
#include "rules.h"
#include "sleep.h"
#include "historique.h"
#include "modbus_epever.h"
#include "debug_log.h"
AsyncWebServer server(80);
// --- Persistance relais (NVS — survit au power-off) ---
static void sauvegarderRelaisNVS() {
Preferences prefs;
prefs.begin("relais", false);
prefs.putBool("r1", state.relay1);
prefs.putBool("r2", state.relay2);
prefs.end();
Serial.printf("[NVS] Relais sauvegardés — R1:%d R2:%d\n", state.relay1, state.relay2);
}
void restaurerRelaisNVS() {
Preferences prefs;
prefs.begin("relais", true);
state.relay1 = prefs.getBool("r1", false);
state.relay2 = prefs.getBool("r2", false);
prefs.end();
digitalWrite(PIN_RELAY1, state.relay1 ? HIGH : LOW);
digitalWrite(PIN_RELAY2, state.relay2 ? HIGH : LOW);
Serial.printf("[NVS] Relais restaurés — R1:%d R2:%d\n", state.relay1, state.relay2);
}
// Sérialise l'état système en JSON et répond à la requête
static void envoyerEtat(AsyncWebServerRequest *request) {
JsonDocument doc;
// PV
doc["pv"] = state.pv;
doc["pvCurrent"] = state.pvCurrent;
// Batterie
doc["battery"] = state.battery;
doc["batSOC"] = state.batSOC;
doc["batTemperature"] = state.batTemperature;
doc["batStatut"] = state.batStatut;
doc["batSousVoltage"] = state.batSousVoltage;
doc["batSurVoltage"] = state.batSurVoltage;
// Load
doc["loadVoltage"] = state.loadVoltage;
doc["loadCurrent"] = state.loadCurrent;
doc["loadPower"] = state.loadPower;
// Énergie kWh
doc["energieGenJour"] = state.energieGenJour;
doc["energieGenTotal"] = state.energieGenTotal;
doc["energieConJour"] = state.energieConJour;
doc["energieConTotal"] = state.energieConTotal;
// Général
doc["sun"] = state.sun;
doc["espClockOk"] = state.espClockOk;
if (state.espClockOk) {
time_t now = time(nullptr);
struct tm tmNow;
localtime_r(&now, &tmNow);
char espTime[20];
snprintf(espTime, sizeof(espTime), "%04d-%02d-%02d %02d:%02d:%02d",
tmNow.tm_year + 1900, tmNow.tm_mon + 1, tmNow.tm_mday,
tmNow.tm_hour, tmNow.tm_min, tmNow.tm_sec);
doc["espTime"] = espTime;
} else {
doc["espTime"] = "--";
}
doc["epeverClockOk"] = state.epeverClockOk;
if (state.epeverClockOk) {
char rtc[20];
snprintf(rtc, sizeof(rtc), "%04u-%02u-%02u %02u:%02u:%02u",
state.epeverYear, state.epeverMonth, state.epeverDay,
state.epeverHour, state.epeverMinute, state.epeverSecond);
doc["epeverTime"] = rtc;
} else {
doc["epeverTime"] = "--";
}
doc["relay1"] = state.relay1;
doc["relay2"] = state.relay2;
doc["di1"] = state.di1;
doc["di2"] = state.di2;
doc["autoMode"] = state.autoMode;
doc["rs485_ok"] = state.rs485_ok;
doc["last_update"] = state.last_update;
String json;
serializeJson(doc, json);
request->send(200, "application/json", json);
}
// Commande un relais et met à jour l'état global
static void commanderRelais(AsyncWebServerRequest *request, int relais, bool etat) {
if (relais == 1) {
state.relay1 = etat;
digitalWrite(PIN_RELAY1, etat ? HIGH : LOW);
} else if (relais == 2) {
state.relay2 = etat;
digitalWrite(PIN_RELAY2, etat ? HIGH : LOW);
}
Serial.printf("[WEB] Relais %d → %s (client %s)\n",
relais, etat ? "ON" : "OFF",
request->client()->remoteIP().toString().c_str());
request->send(200, "application/json", "{\"ok\":true}");
}
void demarrerWebserveur() {
if (!LittleFS.begin(true)) {
Serial.println("Erreur : impossible de monter LittleFS");
return;
}
Serial.println("LittleFS monté");
// --- API REST (définie avant le handler statique) ---
server.on("/api/state", HTTP_GET, envoyerEtat);
server.on("/api/debug/logs", HTTP_GET, [](AsyncWebServerRequest *r) {
String json;
getDebugLogJson(json);
r->send(200, "application/json", json);
});
server.on("/api/debug/clear", HTTP_POST, [](AsyncWebServerRequest *r) {
clearDebugLog();
r->send(200, "application/json", "{\"ok\":true}");
});
server.on("/api/sun/history", HTTP_GET, [](AsyncWebServerRequest *r) {
JsonDocument doc;
JsonArray arr = doc["changes"].to<JsonArray>();
for (uint8_t i = 0; i < state.sunHistoryCount; i++) {
uint8_t idx = (state.sunHistoryHead + 5 - state.sunHistoryCount + i) % 5;
JsonObject item = arr.add<JsonObject>();
item["sun"] = state.sunHistoryState[idx];
item["label"] = state.sunHistoryState[idx] ? "Jour" : "Nuit";
item["time"] = state.sunHistoryTime[idx];
}
String json;
serializeJson(doc, json);
r->send(200, "application/json", json);
});
server.on("/api/relay/1/on", HTTP_POST, [](AsyncWebServerRequest *r){ commanderRelais(r, 1, true); });
server.on("/api/relay/1/off", HTTP_POST, [](AsyncWebServerRequest *r){ commanderRelais(r, 1, false); });
server.on("/api/relay/2/on", HTTP_POST, [](AsyncWebServerRequest *r){ commanderRelais(r, 2, true); });
server.on("/api/relay/2/off", HTTP_POST, [](AsyncWebServerRequest *r){ commanderRelais(r, 2, false); });
// Toggle + sauvegarde NVS (appui long dashboard)
server.on("/api/relay/1/toggle", HTTP_POST, [](AsyncWebServerRequest *r){
state.relay1 = !state.relay1;
digitalWrite(PIN_RELAY1, state.relay1 ? HIGH : LOW);
sauvegarderRelaisNVS();
Serial.printf("[WEB] Relais 1 toggle → %s (NVS sauvegardé)\n", state.relay1 ? "ON" : "OFF");
r->send(200, "application/json", "{\"ok\":true}");
});
server.on("/api/relay/2/toggle", HTTP_POST, [](AsyncWebServerRequest *r){
state.relay2 = !state.relay2;
digitalWrite(PIN_RELAY2, state.relay2 ? HIGH : LOW);
sauvegarderRelaisNVS();
Serial.printf("[WEB] Relais 2 toggle → %s (NVS sauvegardé)\n", state.relay2 ? "ON" : "OFF");
r->send(200, "application/json", "{\"ok\":true}");
});
server.on("/api/reboot", HTTP_POST, [](AsyncWebServerRequest *r){
Serial.println("[WEB] Reboot demandé");
r->send(200, "application/json", "{\"ok\":true}");
delay(200);
ESP.restart();
});
auto *handlerEpeverTime = new AsyncCallbackJsonWebHandler("/api/epever/time",
[](AsyncWebServerRequest *r, JsonVariant &json) {
JsonObject obj = json.as<JsonObject>();
uint16_t year = obj["year"] | 0;
uint8_t month = obj["month"] | 0;
uint8_t day = obj["day"] | 0;
uint8_t hour = obj["hour"] | 0;
uint8_t minute = obj["minute"] | 0;
uint8_t second = obj["second"] | 0;
bool ok = reglerHorlogeEpever(year, month, day, hour, minute, second);
r->send(ok ? 200 : 409, "application/json", ok ? "{\"ok\":true}" : "{\"ok\":false}");
});
server.addHandler(handlerEpeverTime);
// --- API règles ---
server.on("/api/rules", HTTP_GET, [](AsyncWebServerRequest *r) {
JsonDocument doc;
reglesToJson(doc.to<JsonArray>());
String json;
serializeJson(doc, json);
r->send(200, "application/json", json);
});
server.on("/api/rules/toggle", HTTP_POST, [](AsyncWebServerRequest *r) {
if (!r->hasParam("id")) { r->send(400); return; }
int id = r->getParam("id")->value().toInt();
bool ok = toggleRegle(id);
Serial.printf("[WEB] Règle %d toggle → %s\n", id, ok ? "ok" : "introuvable");
r->send(ok ? 200 : 404, "application/json", "{\"ok\":true}");
});
server.on("/api/rules/delete", HTTP_POST, [](AsyncWebServerRequest *r) {
if (!r->hasParam("id")) { r->send(400); return; }
int id = r->getParam("id")->value().toInt();
bool ok = supprimerRegle(id);
Serial.printf("[WEB] Règle %d supprimée → %s\n", id, ok ? "ok" : "introuvable");
r->send(ok ? 200 : 404, "application/json", "{\"ok\":true}");
});
// Ajout de règle — corps JSON
auto *handlerRegle = new AsyncCallbackJsonWebHandler("/api/rules",
[](AsyncWebServerRequest *r, JsonVariant &json) {
bool ok = ajouterRegle(json.as<JsonObject>());
Serial.printf("[WEB] Ajout règle → %s\n", ok ? "ok" : "erreur");
r->send(ok ? 201 : 500, "application/json", ok ? "{\"ok\":true}" : "{\"ok\":false}");
});
server.addHandler(handlerRegle);
// --- API historique ---
server.on("/api/history", HTTP_GET, [](AsyncWebServerRequest *r) {
String json;
getHistoriqueJson(json); // lores : 30h, 5 min
r->send(200, "application/json", json);
});
server.on("/api/history/hires", HTTP_GET, [](AsyncWebServerRequest *r) {
String json;
getHistoriqueHiresJson(json); // hires : 4h, 1 min
r->send(200, "application/json", json);
});
server.on("/api/history/status", HTTP_GET, [](AsyncWebServerRequest *r) {
String json;
getHistoriqueStatusJson(json);
r->send(200, "application/json", json);
});
server.on("/api/history/csv", HTTP_GET, [](AsyncWebServerRequest *r) {
String csv;
getHistoriqueCsv(csv);
AsyncWebServerResponse *resp = r->beginResponse(200, "text/csv", csv);
resp->addHeader("Content-Disposition", "attachment; filename=\"historique.csv\"");
r->send(resp);
});
// --- API noms (relais / entrées) ---
server.on("/api/names", HTTP_GET, [](AsyncWebServerRequest *r) {
Preferences p; p.begin("noms", true);
JsonDocument doc;
doc["relay1"] = p.getString("r1", "Relais 1");
doc["relay2"] = p.getString("r2", "Relais 2");
doc["di1"] = p.getString("d1", "Entrée 1");
doc["di2"] = p.getString("d2", "Entrée 2");
p.end();
String json; serializeJson(doc, json);
r->send(200, "application/json", json);
});
auto *handlerNoms = new AsyncCallbackJsonWebHandler("/api/names",
[](AsyncWebServerRequest *r, JsonVariant &json) {
JsonObject obj = json.as<JsonObject>();
Preferences p; p.begin("noms", false);
if (obj["relay1"].is<const char*>()) p.putString("r1", obj["relay1"].as<const char*>());
if (obj["relay2"].is<const char*>()) p.putString("r2", obj["relay2"].as<const char*>());
if (obj["di1"].is<const char*>()) p.putString("d1", obj["di1"].as<const char*>());
if (obj["di2"].is<const char*>()) p.putString("d2", obj["di2"].as<const char*>());
p.end();
Serial.println("[NVS] Noms sauvegardés");
r->send(200, "application/json", "{\"ok\":true}");
});
server.addHandler(handlerNoms);
// --- API sleep / config ---
server.on("/api/sleep", HTTP_GET, [](AsyncWebServerRequest *r) {
String json;
getSleepConfigJson(json);
r->send(200, "application/json", json);
});
auto *handlerSleep = new AsyncCallbackJsonWebHandler("/api/sleep",
[](AsyncWebServerRequest *r, JsonVariant &json) {
JsonObject obj = json.as<JsonObject>();
bool actif = obj["actif"] | false;
uint32_t inv = obj["intervalle"] | 600u;
float seuil = obj["seuil"] | 2.0f;
bool ok = setSleepConfig(actif, inv, seuil);
Serial.printf("[WEB] Sleep config — actif:%d intervalle:%ds seuil:%.1fV → %s\n",
actif, inv, seuil, ok ? "ok" : "erreur");
r->send(ok ? 200 : 500, "application/json", ok ? "{\"ok\":true}" : "{\"ok\":false}");
});
server.addHandler(handlerSleep);
// --- API intervalles Modbus ---
server.on("/api/modbus", HTTP_GET, [](AsyncWebServerRequest *r) {
uint32_t jour, nuit;
getIntervallesModbus(jour, nuit);
JsonDocument doc;
doc["jour"] = jour;
doc["nuit"] = nuit;
String json; serializeJson(doc, json);
r->send(200, "application/json", json);
});
auto *handlerModbus = new AsyncCallbackJsonWebHandler("/api/modbus",
[](AsyncWebServerRequest *r, JsonVariant &json) {
JsonObject obj = json.as<JsonObject>();
uint32_t jour = obj["jour"] | 5000u;
uint32_t nuit = obj["nuit"] | 30000u;
jour = constrain(jour, 1000u, 60000u);
nuit = constrain(nuit, 5000u, 300000u);
setIntervallesModbus(jour, nuit);
r->send(200, "application/json", "{\"ok\":true}");
});
server.addHandler(handlerModbus);
// --- API WiFi (SSID / mot de passe) ---
server.on("/api/wifi", HTTP_GET, [](AsyncWebServerRequest *r) {
JsonDocument doc;
doc["ssid"] = WIFI_SSID;
doc["password"] = WIFI_PASSWORD;
String json; serializeJson(doc, json);
r->send(200, "application/json", json);
});
// --- Captive portal --- iOS, Android, Windows détectent l'absence d'internet
// et ouvrent automatiquement le navigateur sur notre page principale.
auto redirect = [](AsyncWebServerRequest *r) {
r->redirect("http://192.168.4.1/");
};
// iOS / macOS
server.on("/hotspot-detect.html", HTTP_GET, redirect);
server.on("/library/test/success.html", HTTP_GET, redirect);
server.on("/canonical.html", HTTP_GET, redirect);
// Android
server.on("/generate_204", HTTP_GET, redirect);
server.on("/gen_204", HTTP_GET, redirect);
server.on("/connecttest.txt", HTTP_GET, redirect);
// Windows
server.on("/ncsi.txt", HTTP_GET, redirect);
server.on("/redirect", HTTP_GET, redirect);
server.on("/success.txt", HTTP_GET, redirect);
// --- Fichiers statiques depuis LittleFS ---
server.serveStatic("/", LittleFS, "/").setDefaultFile("index.html");
server.onNotFound([](AsyncWebServerRequest *r){
// Tout GET inconnu → portail captif (navigateur s'ouvre sur la page d'accueil)
if (r->method() == HTTP_GET) {
r->redirect("http://192.168.4.1/");
} else {
r->send(404, "text/plain", "Non trouvé");
}
});
server.begin();
Serial.println("Serveur web démarré sur http://192.168.4.1");
}
+7
View File
@@ -0,0 +1,7 @@
#pragma once
#include <ESPAsyncWebServer.h>
extern AsyncWebServer server;
void demarrerWebserveur();
void restaurerRelaisNVS();
+53
View File
@@ -0,0 +1,53 @@
#include <WiFi.h>
#include <DNSServer.h>
#include <Arduino.h>
#include "config.h"
static DNSServer dnsServer;
static void onWifiEvent(WiFiEvent_t event, WiFiEventInfo_t info) {
switch (event) {
case ARDUINO_EVENT_WIFI_AP_STACONNECTED:
Serial.printf("[WiFi] Client connecté — MAC %02X:%02X:%02X:%02X:%02X:%02X clients: %d\n",
info.wifi_ap_staconnected.mac[0], info.wifi_ap_staconnected.mac[1],
info.wifi_ap_staconnected.mac[2], info.wifi_ap_staconnected.mac[3],
info.wifi_ap_staconnected.mac[4], info.wifi_ap_staconnected.mac[5],
WiFi.softAPgetStationNum());
break;
case ARDUINO_EVENT_WIFI_AP_STADISCONNECTED:
Serial.printf("[WiFi] Client déconnecté — MAC %02X:%02X:%02X:%02X:%02X:%02X clients: %d\n",
info.wifi_ap_stadisconnected.mac[0], info.wifi_ap_stadisconnected.mac[1],
info.wifi_ap_stadisconnected.mac[2], info.wifi_ap_stadisconnected.mac[3],
info.wifi_ap_stadisconnected.mac[4], info.wifi_ap_stadisconnected.mac[5],
WiFi.softAPgetStationNum());
break;
default:
break;
}
}
void demarrerWifi() {
WiFi.onEvent(onWifiEvent);
WiFi.mode(WIFI_AP);
WiFi.softAPConfig(WIFI_IP, WIFI_GATEWAY, WIFI_SUBNET);
if (strlen(WIFI_PASSWORD) > 0) {
WiFi.softAP(WIFI_SSID, WIFI_PASSWORD);
} else {
WiFi.softAP(WIFI_SSID); // AP ouvert
}
Serial.printf("[WiFi] AP démarré — SSID: %s IP: %s MAC: %s\n",
WIFI_SSID,
WiFi.softAPIP().toString().c_str(),
WiFi.softAPmacAddress().c_str());
// Captive portal : tous les noms DNS → 192.168.4.1
dnsServer.setErrorReplyCode(DNSReplyCode::NoError);
dnsServer.start(53, "*", WIFI_IP);
Serial.println("[WiFi] DNS captive portal démarré (port 53)");
}
void traiterDNS() {
dnsServer.processNextRequest();
}
+4
View File
@@ -0,0 +1,4 @@
#pragma once
void demarrerWifi();
void traiterDNS();
+572
View File
@@ -0,0 +1,572 @@
# consigne.md
# Projet : Contrôleur solaire autonome KC868-A2 + Epever 4210N
---
## Rôle de l'assistant
Tu es un expert en développement d'objets IoT ESP32 avec PlatformIO.
Tu emploieras le français pour la discussion. Les commentaires de code seront en français.
---
# 1. Objectif du projet
Développer un système autonome basé sur une carte Kincony KC868-A2 (ESP32) permettant :
* Lecture des données d'un régulateur solaire Epever 4210N via RS485 Modbus
* Pilotage de 2 relais
* Gestion de 2 boutons via contacts secs DI1 / DI2
* Hébergement d'une interface web responsive adaptée smartphone
* Fonctionnement autonome via point d'accès WiFi ESP32
* Mise à jour firmware OTA via interface web
* Moteur de règles programmable depuis l'interface web
* Gestion d'un mode économie d'énergie / sleep
* Système robuste tolérant aux erreurs RS485
Le système doit fonctionner sans internet.
---
# 2. Hardware utilisé
## Carte principale
* Kincony KC868-A2
* ESP32-WROOM-32E intégré
* 2 relais
* RS485 intégré (MAX13487 — direction automatique, pas de pin DE/RE à gérer)
* Entrées contacts secs DI1 / DI2
* Alimentation 12V
## Régulateur solaire
* Epever Tracer 4210N
* Communication Modbus RS485 via RJ45
---
# 3. GPIO KC868-A2
Extraits du schéma bloc officiel de la carte :
| Fonction | GPIO | Notes |
| -------------- | ----- | ---------------------------------------------- |
| Relay 1 | GPIO15 | Actif haut |
| Relay 2 | GPIO2 | ⚠️ Pin de boot — doit être HIGH au démarrage |
| RS485 TXD | GPIO32 | Vers MAX13487 |
| RS485 RXD | GPIO35 | Depuis MAX13487 (input only) |
| DI1 | GPIO36 | Input only, pull-up interne |
| DI2 | GPIO39 | Input only, pull-up interne |
| SDA (I2C) | GPIO4 | Disponible si besoin |
| SCL (I2C) | GPIO5 | Disponible si besoin |
| 1-Wire / DTH1 | GPIO27 | Disponible si besoin |
| DTH2 / LED | GPIO26 | Disponible si besoin |
⚠️ **GPIO2** : relais 2 est sur ce pin de boot. Le relais ne doit pas forcer GPIO2 à LOW au démarrage sous peine de bloquer l'ESP32 en mode flash.
⚠️ **GPIO35 et GPIO36 / GPIO39** : ces pins sont en entrée uniquement (input-only) sur l'ESP32 — pas de sortie possible.
Tous les GPIO sont en logique 3,3V.
---
# 4. Développement logiciel
## Environnement
* Visual Studio Code
* PlatformIO
* Framework Arduino ESP32
## Langages
* C++
* HTML / CSS / JavaScript
---
# 5. Configuration PlatformIO
## platformio.ini
```ini
[env:kc868_a2]
platform = espressif32
board = esp32dev
framework = arduino
monitor_speed = 115200
board_build.filesystem = littlefs
lib_deps =
bblanchon/ArduinoJson
emelianov/modbus-esp8266
me-no-dev/ESPAsyncWebServer
me-no-dev/AsyncTCP
ayushsharma82/AsyncElegantOTA
```
## Commandes utiles
```bash
pio run # Compiler
pio run --target upload # Compiler et flasher
pio run --target monitor # Moniteur série
pio run --target uploadfs # Uploader le filesystem LittleFS (/data)
pio run --target clean # Nettoyer le build
```
---
# 6. Architecture générale
```text
Epever 4210N
└── RS485 (Modbus RTU) ──► ESP32 KC868-A2
┌───────────┼───────────┐
▼ ▼ ▼
Moteur règles Relais 1/2 Boutons DI1/DI2
Interface Web (WiFi AP 192.168.4.1)
Smartphone utilisateur
```
---
# 7. Ordre de développement validé
## Étape 1 — WiFi + Interface Web + OTA
* Point d'accès WiFi actif
* Serveur web async avec pages statiques depuis LittleFS
* OTA via `/update`
## Étape 2 — Pilotage relais
* API REST ON/OFF relais 1 et 2
* Retour état relais dans le dashboard
## Étape 3 — Gestion boutons DI1 / DI2
* Lecture non-bloquante avec anti-rebond
* DI1 : bascule mode auto / manuel
* DI2 : commande manuelle relais
## Étape 4 — Lecture RS485 Epever
* Initialisation Serial2 (GPIO32 TX, GPIO35 RX)
* Lecture périodique non-bloquante des registres Epever
* Mise à jour du `SystemState` global
## Étape 5 — Moteur de règles
* Chargement règles depuis LittleFS (`rules.json`)
* Évaluation périodique des conditions
* Application des actions sur les relais
## Étape 6 — Gestion sleep / économie d'énergie
* Détection mode JOUR / NUIT via état Epever
* Deep sleep la nuit, réveil périodique
---
# 8. Câblage
## 8.1 RS485
```text
EPEVER RS485 A (D+) ───── KC868-A2 A1 / RXI
EPEVER RS485 B (D-) ───── KC868-A2 B1 / TXO
EPEVER GND ───── KC868-A2 GND (recommandé)
```
⚠️ Ne jamais connecter une alimentation provenant du RJ45 Epever.
## 8.2 Boutons (contacts secs)
```text
DI1 ----[Bouton]---- GND
DI2 ----[Bouton]---- GND
```
* DI1 : mode auto / manuel
* DI2 : commande manuelle relais
## 8.3 Relais
* Relay 1 (GPIO15) → charge 1
* Relay 2 (GPIO2) → charge 2
---
# 9. WiFi
## Mode point d'accès (AP)
```text
SSID : KC868_SOLAR
IP : 192.168.4.1
```
Le smartphone se connecte directement à la carte, sans routeur ni internet.
---
# 10. Interface Web
## Objectifs
* Responsive smartphone (mobile-first)
* Interface légère — pas de framework lourd (pas de React, Vue, etc.)
* Mise à jour dynamique par fetch JSON sans rechargement de page
* Vanilla JS + CSS simple
## Sections
```text
[ Dashboard ]
- Tension batterie (V)
- Tension PV (V)
- Courant PV (A)
- Etat jour / nuit
- Etat relais 1 et 2
- Etat RS485 (OK / erreur)
[ Commandes ]
- ON/OFF relais 1 et 2
- Bascule mode auto / manuel
[ Programmation ]
- Liste des règles actives
- Ajout / suppression règle
[ Configuration ]
- Activation sleep
- Seuils batterie
- Intervalle de lecture RS485
- Temporisations règles
[ Firmware ]
- OTA update (upload .bin)
[ Debug ]
- Logs série
- Erreurs RS485
- Etats DI1 / DI2
```
---
# 11. OTA (mise à jour firmware)
```text
http://192.168.4.1/update
```
* Upload fichier `.bin` depuis l'interface web
* Redémarrage automatique après mise à jour
* Protection par mot de passe
---
# 12. Communication Modbus Epever
## Paramètres
* Baudrate : 9600
* Adresse esclave : 1
* Modbus RTU (half-duplex)
* Serial2 : TX=GPIO32, RX=GPIO35
## Registres utiles
| Donnée | Registre | Unité | Facteur |
| --------------------- | -------- | ----------- | ------- |
| Tension PV | 0x3100 | V | ÷100 |
| Courant PV | 0x3101 | A | ÷100 |
| Puissance PV | 0x3102 | W | ÷100 |
| Tension batterie | 0x3104 | V | ÷100 |
| Courant de charge | 0x3106 | A | ÷100 |
| Etat jour/nuit | 0x200C | bit 0 = nuit | — |
| Etat charge batterie | 0x3201 | bits | — |
## Timing de lecture
* Intervalle recommandé : toutes les 5 secondes
* Timeout réponse : 200 ms maximum
* Retry : 2 tentatives maximum
---
# 13. Gestion des erreurs RS485
## Règle critique
Une erreur RS485 ne doit **jamais** bloquer :
* le serveur web
* le pilotage relais
* les boutons
* le moteur de règles
## Interdit
```cpp
// JAMAIS de boucle bloquante
while (!response) { }
delay(x);
```
## Comportement attendu
* Lecture périodique pilotée par `millis()`
* Timeout court (200 ms)
* Maximum 2 retries
* En cas d'échec : conserver la dernière valeur valide, passer `rs485_ok = false`
* L'interface web affiche l'état RS485
---
# 14. Etat système global
```cpp
struct SystemState {
// Données Epever
float battery; // Tension batterie (V)
float pv; // Tension PV (V)
float pvCurrent; // Courant PV (A)
bool sun; // true = jour
// Relais
bool relay1;
bool relay2;
// Boutons
bool di1;
bool di2;
// Santé RS485
bool rs485_ok;
unsigned long last_update; // millis() de la dernière lecture OK
};
```
---
# 15. Pilotage relais
* ON/OFF manuel depuis l'interface web
* Pilotage automatique via moteur de règles
* Pilotage manuel via boutons DI1/DI2
* État affiché en temps réel dans le dashboard
---
# 16. Boutons DI1 / DI2
* Anti-rebond logiciel (50 ms)
* DI1 : bascule mode auto ↔ manuel
* DI2 : en mode manuel, toggle relais 1
* En mode auto : les règles reprennent le contrôle
---
# 17. Moteur de règles
## Structure logique
```text
SI (conditions)
ALORS (action)
AVEC (délai optionnel en secondes)
```
## Conditions possibles
* Soleil (jour/nuit)
* Batterie > seuil
* Batterie < seuil
* Etat relais
* Etat DI
## Actions possibles
* Activer relais 1 ou 2
* Désactiver relais 1 ou 2
## Exemple règle
```text
SI soleil ET batterie > 13V → ALORS relais1 ON
SI soleil ET batterie > 13V → ATTENDRE 3600s → ALORS relais2 ON
```
## Format JSON (stocké dans LittleFS `rules.json`)
```json
[
{
"id": 1,
"enabled": true,
"sun": true,
"battery_min": 13.0,
"battery_max": 0,
"relay": 1,
"state": true,
"delay": 0
}
]
```
| Champ | Type | Description |
| ------------- | ------- | ----------------------------------------- |
| `id` | int | Identifiant unique |
| `enabled` | bool | Règle active ou non |
| `sun` | bool | Condition soleil (true=jour, false=nuit) |
| `battery_min` | float | Seuil min batterie en V (0 = ignoré) |
| `battery_max` | float | Seuil max batterie en V (0 = ignoré) |
| `relay` | int | Numéro relais (1 ou 2) |
| `state` | bool | true = ON, false = OFF |
| `delay` | int | Délai avant action (secondes) |
## Timing des règles
```cpp
// Évaluation pilotée par millis(), jamais par delay()
if (millis() - lastRuleEval > RULE_INTERVAL_MS) {
evaluerRegles();
lastRuleEval = millis();
}
```
---
# 18. Gestion de l'énergie
## Mode JOUR
```text
WiFi ON
Serveur web ON
Lecture Epever active (toutes les 5s)
Règles actives
```
## Mode NUIT
```text
WiFi OFF
Serveur web OFF
Deep sleep
Réveil périodique (configurable, ex: 10 min)
Lecture Epever au réveil
Si toujours nuit → retour deep sleep
```
## Paramètres configurables
* Activation / désactivation du sleep
* Intervalle de réveil (secondes)
* Seuil de détection soleil (valeur registre 0x200C)
* Mode : deep sleep ou light sleep
## Contraintes deep sleep
* Deep sleep = reboot complet de l'ESP32
* UART indisponible pendant le sleep
* Les relais conservent leur état (verrouillage mécanique)
* Variables RAM perdues au réveil → sauvegarder en RTC memory si besoin
---
# 19. Consommation estimée
| Mode | Consommation |
| ------ | ------------ |
| Normal | 1.8 à 4 W |
| Sleep | 0.1 à 0.6 W |
---
# 20. Structure projet PlatformIO
```text
/src
main.cpp ← boucle principale, init, dispatcher
wifi.cpp ← AP WiFi
webserver.cpp ← serveur HTTP async, endpoints REST
ota.cpp ← mise à jour OTA
modbus.cpp ← lecture RS485 Epever non-bloquante
relais.cpp ← contrôle GPIO relais
rules.cpp ← évaluation moteur de règles
sleep.cpp ← gestion deep sleep
buttons.cpp ← lecture DI1/DI2 avec anti-rebond
/include
config.h ← constantes GPIO, SSID, intervalles
state.h ← déclaration SystemState
/data
index.html ← interface web principale
style.css
app.js ← fetch JSON, mise à jour dynamique
rules.json ← règles persistées (LittleFS)
```
---
# 21. Contraintes de conception
## Non-bloquant partout
Toute la logique doit utiliser `millis()` et des machines à états. Jamais de `delay()` ni de boucle d'attente.
## RS485 half-duplex
* Le MAX13487 gère automatiquement la direction (pas de pin DE)
* Ne pas lire trop fréquemment : respecter l'intervalle de 5 secondes
* Bien gérer le timeout de 200 ms par requête Modbus
## GPIO2 (Relay 2)
* GPIO2 est un pin de strapping de boot sur ESP32
* S'assurer que le relais ne tire pas GPIO2 à LOW au démarrage
## LittleFS
* Le filesystem doit être uploadé séparément : `pio run --target uploadfs`
* La partition LittleFS doit être configurée dans `platformio.ini`
---
# 22. Evolutions possibles
* MQTT / Home Assistant
* Historique graphique (Chart.js)
* Mode STA (connexion au routeur existant)
* Accès distant VPN
* Conditions avancées ET / OU dans les règles
* Gestion des priorités entre règles
* Notifications push
---
# 23. Objectif final
Le système final doit être :
* **autonome** — fonctionne sans internet ni serveur externe
* **robuste** — tolérant aux erreurs RS485 et aux redémarrages
* **configurable** — réglable depuis un smartphone
* **optimisé énergie** — mode sleep la nuit
* **extensible** — architecture modulaire facilitant les ajouts
* **maintenable** — code commenté en français, structure claire
---
+352
View File
@@ -0,0 +1,352 @@
# amelioration.md
# Ajout de la gestion complète de configuration EPEVER dans l'application
## Objectif
Ajouter dans l'application une nouvelle section de configuration avancée dédiée au régulateur solaire EPEVER AN4210N.
Cette fonctionnalité doit permettre :
- de lire automatiquement tous les paramètres importants du régulateur via Modbus RS485 ;
- d'afficher ces paramètres dans l'interface web ;
- de modifier certains paramètres depuis l'interface ;
- d'écrire les nouveaux paramètres dans le régulateur EPEVER ;
- de sauvegarder localement la configuration ;
- d'éviter toute écriture dangereuse ou involontaire ;
- de conserver une architecture stable et propre sans casser le fonctionnement actuel.
Cette fonctionnalité doit être conçue pour être robuste, maintenable et extensible.
---
# Contexte matériel
Le projet utilise :
- carte KC868-A2 (ESP32)
- régulateur EPEVER AN4210N
- communication RS485 Modbus RTU
- interface web embarquée sur ESP32
- système déjà existant de lecture Modbus
Le projet possède déjà :
- une interface web
- OTA
- gestion des relais
- lecture des données EPEVER
- règles automatiques
- mode économie d'énergie
L'amélioration demandée doit s'intégrer dans cette architecture existante.
---
# Nouvelle fonctionnalité à ajouter
## Nouvel onglet web :
Créer un nouvel onglet :
- "Configuration EPEVER"
ou
- "Paramètres batterie"
Cet onglet doit permettre :
- lecture des paramètres actuels ;
- affichage des valeurs ;
- modification ;
- écriture vers EPEVER ;
- sauvegarde/restauration ;
- export/import JSON.
---
# Fonctionnement attendu
## Lecture automatique des paramètres
Au chargement de la page :
- lire automatiquement les registres Modbus de configuration EPEVER ;
- afficher les valeurs dans les champs web ;
- indiquer les erreurs de lecture ;
- ne jamais bloquer l'interface web si une lecture échoue.
La lecture doit être asynchrone et non bloquante.
---
# Paramètres à récupérer
Commencer par gérer au minimum :
## Paramètres batterie
- type batterie
- capacité batterie (Ah)
- tension nominale
## Paramètres charge
- Boost Voltage
- Float Voltage
- Equalize Voltage
- Boost Reconnect Voltage
- Low Voltage Disconnect
- Low Voltage Reconnect
- Under Voltage Warning
- Over Voltage Disconnect
- Charging Limit Voltage
## Paramètres timing
- durée boost
- durée equalize
## Température
- compensation température
## Divers
- activation equalization
- protections
Le code doit être pensé pour pouvoir ajouter facilement d'autres registres plus tard.
---
# Interface web
## Contraintes UI
L'interface doit rester légère.
Utiliser :
- formulaire simple ;
- sections claires ;
- groupes logiques ;
- unités visibles (V, Ah, min, etc.) ;
- valeurs actuelles visibles ;
- boutons :
- Lire
- Écrire
- Restaurer
- Exporter
- Importer
---
# Sécurité importante
## Très important
Aucune écriture ne doit être envoyée automatiquement.
L'utilisateur doit :
1. modifier les valeurs ;
2. cliquer explicitement sur "Écrire".
Ajouter une confirmation avant écriture.
Exemple :
"Confirmer l'écriture des paramètres vers le régulateur EPEVER ?"
---
# Validation des valeurs
Ajouter une validation stricte côté ESP32.
Exemples :
- Float Voltage :
- min 13.0V
- max 14.5V
- Boost Voltage :
- min 13.5V
- max 15V
- capacité batterie :
- valeur positive uniquement
Empêcher toute valeur incohérente.
---
# Gestion des erreurs
Le système doit être robuste.
## Cas à gérer
- câble RS485 débranché ;
- timeout Modbus ;
- CRC invalide ;
- registre inaccessible ;
- écriture refusée ;
- données invalides.
L'interface doit afficher :
- erreur claire ;
- statut de communication ;
- dernière synchronisation ;
- dernière erreur.
---
# Sauvegarde locale
Ajouter une sauvegarde locale de configuration.
## Objectif
Pouvoir :
- restaurer rapidement les paramètres ;
- sauvegarder avant modification ;
- exporter/importer.
Format conseillé :
- JSON.
Exemple :
```json
{
"boost_voltage": 14.2,
"float_voltage": 13.6,
"battery_capacity": 100
}
```
---
# Architecture logicielle
## Important
Ne pas mélanger :
- lecture temps réel ;
- logique métier ;
- écriture configuration ;
- interface web.
Créer une couche dédiée.
Exemple :
- epever_runtime.cpp
- epever_config.cpp
- epever_registers.h
- epever_web.cpp
---
# Gestion des registres
Créer une abstraction claire.
Exemple :
```cpp
struct EpeverRegisterConfig {
uint16_t register_id;
const char* name;
float scale;
float min_value;
float max_value;
bool writable;
};
```
Objectif :
- simplifier maintenance ;
- éviter duplication ;
- permettre génération automatique UI plus tard.
---
# Compatibilité future
Prévoir :
- autres modèles EPEVER ;
- plusieurs batteries ;
- profils batterie ;
- presets ;
- mode expert.
---
# Fonctionnalités futures possibles
Le code doit être pensé pour permettre plus tard :
- profils GEL/AGM/Lithium ;
- détection automatique type batterie ;
- historique des changements ;
- rollback ;
- sauvegarde cloud/Home Assistant ;
- synchronisation MQTT ;
- règles automatiques basées sur configuration ;
- assistant de configuration.
- sur la page web des explication pour les parametre permettrons un comprehension pour un novice
---
# Contraintes importantes
## Le projet ne doit pas être cassé
Avant toute modification :
- analyser l'architecture existante ;
- comprendre les flux actuels ;
- éviter les régressions.
Ne pas réécrire brutalement le système existant.
L'amélioration doit être progressive et propre.
---
# Priorité de développement
Ordre conseillé :
1. abstraction registres ;
2. lecture paramètres ;
3. affichage web ;
4. validation ;
5. écriture ;
6. sauvegarde JSON ;
7. import/export ;
8. gestion erreurs avancée.
---
# Important
Avant de coder :
- analyser le code actuel ;
- identifier les modules déjà existants ;
- proposer un plan d'intégration ;
- identifier les risques techniques ;
- proposer une architecture propre.
Le but est d'améliorer l'application existante, pas de repartir de zéro.
+1076
View File
File diff suppressed because it is too large Load Diff
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

+20
View File
@@ -0,0 +1,20 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect width="32" height="32" rx="6" fill="#1a1a2e"/>
<!-- soleil -->
<circle cx="16" cy="13" r="5" fill="#fdcb6e"/>
<!-- rayons -->
<g stroke="#fdcb6e" stroke-width="2" stroke-linecap="round">
<line x1="16" y1="4" x2="16" y2="6"/>
<line x1="16" y1="20" x2="16" y2="22"/>
<line x1="7" y1="13" x2="9" y2="13"/>
<line x1="23" y1="13" x2="25" y2="13"/>
<line x1="9.5" y1="6.5" x2="11" y2="8"/>
<line x1="21" y1="18" x2="22.5" y2="19.5"/>
<line x1="22.5" y1="6.5" x2="21" y2="8"/>
<line x1="9.5" y1="19.5" x2="11" y2="18"/>
</g>
<!-- batterie -->
<rect x="9" y="23" width="14" height="6" rx="1.5" fill="none" stroke="#00b894" stroke-width="1.5"/>
<rect x="23" y="25" width="2" height="2" rx="0.5" fill="#00b894"/>
<rect x="10.5" y="24.5" width="8" height="3" rx="1" fill="#00b894"/>
</svg>

After

Width:  |  Height:  |  Size: 912 B

+822
View File
@@ -0,0 +1,822 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>KC868 Solaire</title>
<link rel="icon" type="image/svg+xml" href="favicon.svg">
<link rel="stylesheet" href="style.css">
</head>
<body>
<header>
<div class="header-title">
<h1>⚡ Contrôleur Solaire</h1>
<span id="header-clock" class="header-clock">--</span>
</div>
<span id="rs485-badge" class="badge badge-err">RS485 --</span>
</header>
<nav>
<!-- Dashboard : grille 2×2 -->
<button class="tab active" title="Dashboard" onclick="afficherOnglet('dashboard', this)">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/>
<rect x="3" y="14" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/>
</svg>
</button>
<!-- Règles : liste à puces -->
<button class="tab" title="Règles" onclick="afficherOnglet('regles', this)">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
<line x1="9" y1="6" x2="20" y2="6"/><line x1="9" y1="12" x2="20" y2="12"/><line x1="9" y1="18" x2="20" y2="18"/>
<circle cx="4.5" cy="6" r="1.5" fill="currentColor" stroke="none"/>
<circle cx="4.5" cy="12" r="1.5" fill="currentColor" stroke="none"/>
<circle cx="4.5" cy="18" r="1.5" fill="currentColor" stroke="none"/>
</svg>
</button>
<!-- Config : engrenage -->
<button class="tab" title="Configuration" onclick="afficherOnglet('config', this)">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="3"/>
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/>
</svg>
</button>
<!-- Historique : courbe -->
<button class="tab" title="Historique" onclick="afficherOnglet('historique', this)">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/>
</svg>
</button>
<!-- EPEVER Config : curseurs -->
<button class="tab" title="Config EPEVER" onclick="afficherOnglet('epever-config', this)">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="4" y1="21" x2="4" y2="14"/><line x1="4" y1="10" x2="4" y2="3"/>
<line x1="12" y1="21" x2="12" y2="12"/><line x1="12" y1="8" x2="12" y2="3"/>
<line x1="20" y1="21" x2="20" y2="16"/><line x1="20" y1="12" x2="20" y2="3"/>
<line x1="1" y1="14" x2="7" y2="14"/><line x1="9" y1="8" x2="15" y2="8"/>
<line x1="17" y1="16" x2="23" y2="16"/>
</svg>
</button>
<!-- Debug : terminal -->
<button class="tab" title="Debug" onclick="afficherOnglet('debug', this)">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="4 17 10 11 4 5"/>
<line x1="12" y1="19" x2="20" y2="19"/>
</svg>
</button>
</nav>
<main>
<!-- Dashboard -->
<section id="dashboard" class="onglet actif">
<div class="dash-section">Relais <span class="dash-hint">appui 1,1s = toggle + save</span></div>
<div class="grille">
<div class="carte" id="carte-relay1">
<div class="etiquette" id="label-relay1">Relais 1</div>
<div class="valeur" id="relay1-etat">--</div>
</div>
<div class="carte" id="carte-relay2">
<div class="etiquette" id="label-relay2">Relais 2</div>
<div class="valeur" id="relay2-etat">--</div>
</div>
</div>
<div class="dash-section">Entrées</div>
<div class="grille">
<div class="carte">
<div class="etiquette" id="label-di1">Entrée 1</div>
<div class="valeur" id="di1-etat">--</div>
</div>
<div class="carte">
<div class="etiquette" id="label-di2">Entrée 2</div>
<div class="valeur" id="di2-etat">--</div>
</div>
</div>
<div class="dash-section">Solaire</div>
<div class="grille grille-3">
<div class="carte">
<div class="etiquette">Tension PV</div>
<div class="valeur" id="pv">-- V</div>
</div>
<div class="carte">
<div class="etiquette">Courant PV</div>
<div class="valeur" id="pvCurrent">-- A</div>
</div>
<div class="carte" id="carte-sun">
<div class="etiquette">Ensoleillement</div>
<div class="valeur" id="sun">--</div>
</div>
</div>
<div class="grille">
<div class="carte">
<div class="etiquette">Horloge EPEVER</div>
<div class="valeur valeur-compacte" id="epeverTime">--</div>
</div>
<div class="carte">
<div class="etiquette">RTC EPEVER</div>
<div class="valeur" id="epeverClockOk">--</div>
</div>
</div>
<div class="dash-section">Batterie</div>
<div class="grille">
<div class="carte">
<div class="etiquette">Tension</div>
<div class="valeur" id="battery">-- V</div>
</div>
<div class="carte">
<div class="etiquette">SOC</div>
<div class="valeur" id="batSOC">-- %</div>
</div>
<div class="carte">
<div class="etiquette">Statut</div>
<div class="valeur" id="batStatut">--</div>
</div>
<div class="carte">
<div class="etiquette">Température</div>
<div class="valeur" id="batTemp">-- °C</div>
</div>
</div>
<div class="dash-section">Sortie 12V EPEVER</div>
<div class="grille grille-3">
<div class="carte">
<div class="etiquette">Tension load</div>
<div class="valeur" id="loadVoltage">-- V</div>
</div>
<div class="carte">
<div class="etiquette">Courant load</div>
<div class="valeur" id="loadCurrent">-- A</div>
</div>
<div class="carte">
<div class="etiquette">Puissance load</div>
<div class="valeur" id="loadPower">-- W</div>
</div>
</div>
<div class="dash-section">Énergie</div>
<div class="grille">
<div class="carte">
<div class="etiquette">Prod. jour</div>
<div class="valeur" id="energieGenJour">-- kWh</div>
</div>
<div class="carte">
<div class="etiquette">Conso. jour</div>
<div class="valeur" id="energieConJour">-- kWh</div>
</div>
<div class="carte">
<div class="etiquette">Prod. total</div>
<div class="valeur" id="energieGenTotal">-- kWh</div>
</div>
<div class="carte">
<div class="etiquette">Conso. total</div>
<div class="valeur" id="energieConTotal">-- kWh</div>
</div>
</div>
</section>
<!-- Règles -->
<section id="regles" class="onglet">
<p class="aide">Chaque règle surveille des conditions (ensoleillement, tension batterie) et commande automatiquement un relais. Un délai optionnel évite les basculements intempestifs. Les règles s'appliquent en parallèle de la commande manuelle.</p>
<!-- Liste des règles -->
<div id="liste-regles"></div>
<!-- Formulaire ajout -->
<div class="regle-form">
<div class="form-titre">Ajouter une règle</div>
<div class="form-section-label">Déclencheur</div>
<div class="form-ligne">
<label>Soleil</label>
<select id="f-sun">
<option value="">Ignoré</option>
<option value="true">Jour</option>
<option value="false">Nuit</option>
</select>
</div>
<div class="form-ligne">
<label>Entrée DI1</label>
<select id="f-di1">
<option value="">Ignoré</option>
<option value="true">Fermé (ON)</option>
<option value="false">Ouvert (OFF)</option>
</select>
</div>
<div class="form-ligne">
<label>Entrée DI2</label>
<select id="f-di2">
<option value="">Ignoré</option>
<option value="true">Fermé (ON)</option>
<option value="false">Ouvert (OFF)</option>
</select>
</div>
<div class="form-section-label">Condition</div>
<div class="form-ligne">
<label>Batt. min (V)</label>
<input type="number" id="f-batmin" step="0.1" min="0" max="30" placeholder="0 = ignoré">
</div>
<div class="form-ligne">
<label>Batt. max (V)</label>
<input type="number" id="f-batmax" step="0.1" min="0" max="30" placeholder="0 = ignoré">
</div>
<div class="form-ligne">
<label>PV min (V)</label>
<input type="number" id="f-pvmin" step="0.1" min="0" max="200" placeholder="0 = ignoré">
</div>
<div class="form-ligne">
<label>PV max (V)</label>
<input type="number" id="f-pvmax" step="0.1" min="0" max="200" placeholder="0 = ignoré">
</div>
<div class="form-section-label">Action</div>
<div class="form-ligne">
<label>Relais</label>
<select id="f-relay">
<option value="1">Relais 1</option>
<option value="2">Relais 2</option>
</select>
</div>
<div class="form-ligne">
<label>État</label>
<select id="f-state">
<option value="true">ON</option>
<option value="false">OFF</option>
</select>
</div>
<div class="form-ligne">
<label>Délai (s)</label>
<input type="number" id="f-delay" min="0" value="0" placeholder="0 = immédiat">
</div>
<div class="form-ligne">
<label>Hystérésis (V)</label>
<input type="number" id="f-hysteresis" step="0.1" min="0" max="5" value="0" placeholder="0 = désactivé">
</div>
<button class="btn btn-primaire btn-plein" onclick="ajouterRegle()">Ajouter</button>
</div>
</section>
<!-- Config / Paramètres -->
<section id="config" class="onglet">
<p class="aide">Commande manuelle des relais, noms personnalisables, sleep, OTA et redémarrage. Un appui maintenu sur une carte relais du dashboard bascule et sauvegarde l'état.</p>
<div class="regle-form">
<div class="form-titre">Commande manuelle</div>
<div class="ligne-commande relay-row">
<span class="label-cmd"><span id="cmd-label-r1">Relais 1</span> <span id="led-r1" class="led led-off"></span></span>
<button id="btn-r1-on" class="btn btn-vert btn-dim" onclick="relay(1,'on')">ON</button>
<button id="btn-r1-off" class="btn btn-rouge btn-glow-rouge" onclick="relay(1,'off')">OFF</button>
</div>
<div class="ligne-commande relay-row">
<span class="label-cmd"><span id="cmd-label-r2">Relais 2</span> <span id="led-r2" class="led led-off"></span></span>
<button id="btn-r2-on" class="btn btn-vert btn-dim" onclick="relay(2,'on')">ON</button>
<button id="btn-r2-off" class="btn btn-rouge btn-glow-rouge" onclick="relay(2,'off')">OFF</button>
</div>
</div>
<div class="regle-form">
<div class="form-titre">Noms des relais et entrées</div>
<div class="form-ligne">
<label>Relais 1</label>
<input type="text" id="c-n-relay1" maxlength="20" placeholder="Relais 1">
</div>
<div class="form-ligne">
<label>Relais 2</label>
<input type="text" id="c-n-relay2" maxlength="20" placeholder="Relais 2">
</div>
<div class="form-ligne">
<label>Entrée 1</label>
<input type="text" id="c-n-di1" maxlength="20" placeholder="Entrée 1">
</div>
<div class="form-ligne">
<label>Entrée 2</label>
<input type="text" id="c-n-di2" maxlength="20" placeholder="Entrée 2">
</div>
<button class="btn btn-primaire btn-plein" onclick="sauvegarderNoms()">Enregistrer et recharger</button>
</div>
<div class="regle-form">
<div class="form-titre">Mode économie d'énergie (sleep)</div>
<p class="aide">En mode sleep, l'ESP32 s'éteint entre deux cycles de mesure pour réduire la consommation. Il se réveille périodiquement, lit les données Modbus, évalue les règles, puis se rendort si la tension PV est inférieure au seuil (nuit détectée). En journée (PV &gt; seuil), il reste actif en permanence.</p>
<div class="form-ligne">
<label>Activé</label>
<select id="c-sleep-actif">
<option value="false">Non</option>
<option value="true">Oui</option>
</select>
</div>
<div class="form-ligne">
<label>Réveil (min)</label>
<input type="number" id="c-sleep-intervalle" min="1" max="120" value="10">
</div>
<div class="form-ligne">
<label>Seuil PV (V)</label>
<input type="number" id="c-sleep-seuil" step="0.5" min="0" max="10" value="2.0">
</div>
<button class="btn btn-primaire btn-plein" onclick="sauvegarderSleep()">Enregistrer</button>
</div>
<div class="regle-form">
<div class="form-titre">Interface</div>
<div class="form-ligne">
<label>Rafraîchissement (s)</label>
<input type="number" id="c-refresh" min="1" max="60" step="1" value="1">
</div>
<div class="form-ligne">
<label>Appui long (ms)</label>
<input type="number" id="c-longpress2" min="200" max="3000" step="100" value="500">
</div>
<button class="btn btn-primaire btn-plein" onclick="sauvegarderInterface()">Enregistrer et recharger</button>
</div>
<div class="regle-form">
<div class="form-titre">Intervalles Modbus</div>
<p class="aide">En <strong>mode soleil</strong> (PV actif), les données sont lues fréquemment. En <strong>mode veille</strong> (nuit / PV absent), l'intervalle est plus long pour économiser l'énergie.</p>
<div class="form-ligne">
<label>Mode soleil (s)</label>
<input type="number" id="c-mb-jour" min="1" max="60" step="1" value="5">
</div>
<div class="form-ligne">
<label>Mode veille (s)</label>
<input type="number" id="c-mb-nuit" min="5" max="300" step="5" value="30">
</div>
<button class="btn btn-primaire btn-plein" onclick="sauvegarderModbus()">Enregistrer</button>
</div>
<div class="regle-form">
<div class="form-titre">Horloge EPEVER</div>
<p class="aide">L'ESP32 cale son horloge sur l'EPEVER au boot puis toutes les 6h. Utilise ce réglage si l'heure du MPPT est décalée.</p>
<div class="form-ligne">
<label>Date/heure</label>
<input type="datetime-local" id="c-epever-time" step="1">
</div>
<button class="btn btn-primaire btn-plein" onclick="remplirHeureNavigateur()">Utiliser l'heure du navigateur</button>
<button class="btn btn-vert btn-plein" onclick="sauvegarderHeureEpever()">Régler l'EPEVER</button>
</div>
<div class="regle-form">
<div class="form-titre">Connexion WiFi</div>
<div id="wifi-status-bar" class="ec-statusbar">Chargement…</div>
<div class="form-section-label">Point d'accès (AP)</div>
<div class="form-ligne">
<label>SSID AP</label>
<span id="wifi-ap-ssid" class="wifi-val">--</span>
</div>
<div class="form-ligne">
<label>IP AP</label>
<span id="wifi-ap-ip" class="wifi-val">192.168.4.1</span>
</div>
<div class="form-ligne">
<label>Clients</label>
<span id="wifi-ap-clients" class="wifi-val">--</span>
</div>
<div class="form-section-label" style="margin-top:0.6rem">Nom réseau local (mDNS)</div>
<div class="form-ligne">
<label>Adresse .local</label>
<div class="ec-field-unit">
<input type="text" id="wifi-mdns-host" maxlength="63" placeholder="pv" autocomplete="off">
<span class="ec-unit">.local</span>
</div>
</div>
<button class="btn btn-primaire btn-plein" onclick="sauvegarderMdns()">Enregistrer le nom</button>
<div class="form-section-label" style="margin-top:0.6rem">Rejoindre un réseau WiFi</div>
<div class="form-ligne">
<label>Réseau</label>
<div class="wifi-ssid-row">
<input type="text" id="wifi-sta-ssid" placeholder="SSID du réseau" autocomplete="off">
<button class="btn btn-sm" id="wifi-btn-scan" onclick="scannerWifi()">Scanner</button>
</div>
</div>
<div id="wifi-scan-list" class="wifi-scan-list hidden"></div>
<div class="form-ligne">
<label>Mot de passe</label>
<input type="password" id="wifi-sta-pass" placeholder="Mot de passe" autocomplete="new-password">
</div>
<div class="wifi-actions">
<button class="btn btn-vert" onclick="connecterWifi()">Connecter</button>
<button class="btn btn-rouge" id="wifi-btn-oublier" onclick="oublierWifi()">Oublier</button>
</div>
<div id="wifi-sta-info" class="hidden">
<div class="form-section-label" style="margin-top:0.6rem">Connecté au réseau</div>
<div class="form-ligne">
<label>SSID</label>
<span id="wifi-sta-ssid-cur" class="wifi-val wifi-connected">--</span>
</div>
<div class="form-ligne">
<label>IP locale</label>
<span id="wifi-sta-ip" class="wifi-val wifi-connected">--</span>
</div>
<div class="form-ligne">
<label>Signal</label>
<span id="wifi-sta-rssi" class="wifi-val">--</span>
</div>
</div>
</div>
<div class="regle-form">
<div class="form-titre">VPN WireGuard</div>
<p class="aide">Tunnel chiffré vers votre serveur WireGuard. Nécessite une connexion WiFi (mode STA). Désactivé par défaut — la configuration locale reste accessible même si le VPN est coupé.</p>
<div id="wg-status-bar" class="ec-statusbar">Chargement…</div>
<div class="form-ligne">
<label>Activé</label>
<select id="wg-enabled">
<option value="false">Non</option>
<option value="true">Oui</option>
</select>
</div>
<div class="form-ligne">
<label>Clé privée ESP32</label>
<input type="password" id="wg-privkey" maxlength="64" placeholder="Base64 44 car." autocomplete="new-password">
</div>
<div class="form-ligne">
<label>Clé publique serveur</label>
<input type="text" id="wg-pubkey" maxlength="64" placeholder="Base64 44 car." autocomplete="off">
</div>
<div class="form-ligne">
<label>Clé pré-partagée <span class="ec-aide" title="Optionnel mais recommandé. Doit correspondre au PresharedKey du serveur."></span></label>
<input type="password" id="wg-psk" maxlength="64" placeholder="Optionnel" autocomplete="new-password">
</div>
<div class="form-ligne">
<label>Endpoint (serveur)</label>
<input type="text" id="wg-endpoint" maxlength="64" placeholder="mon.domaine.org" autocomplete="off">
</div>
<div class="form-ligne">
<label>Port</label>
<input type="number" id="wg-port" min="1" max="65535" value="51820">
</div>
<div class="form-ligne">
<label>IP locale WG</label>
<div class="ec-field-unit">
<input type="text" id="wg-localip" maxlength="18" placeholder="10.8.0.x" autocomplete="off">
<span class="ec-unit">/24</span>
</div>
</div>
<div class="form-ligne">
<label>Keepalive <span class="ec-aide" title="Maintien NAT actif. 25s recommandé pour accès distant depuis le serveur."></span></label>
<div class="ec-field-unit">
<input type="number" id="wg-keepalive" min="0" max="300" value="25">
<span class="ec-unit">s</span>
</div>
</div>
<button class="btn btn-primaire btn-plein" onclick="sauvegarderWireGuard()">Enregistrer et appliquer</button>
</div>
<div class="regle-form" style="margin-top:0.75rem">
<div class="form-titre">Mise à jour firmware (OTA)</div>
<p class="ota-info">Identifiants&nbsp;: aucun requis</p>
<a href="/update" class="btn btn-primaire btn-plein">Ouvrir l'interface OTA</a>
</div>
<div class="regle-form" style="margin-top:0.75rem">
<div class="form-titre">Exporter les données</div>
<p class="aide">Historique basse résolution : jusqu'à 30h de mesures (pas 5 min). Fichier CSV importable dans Excel, LibreOffice ou Google Sheets.</p>
<a href="/api/history/csv" download="historique.csv" class="btn btn-primaire btn-plein">Télécharger l'historique (CSV)</a>
</div>
<div class="regle-form" style="margin-top:0.75rem">
<div class="form-titre">Système</div>
<button class="btn btn-rouge btn-plein" onclick="rebootESP()">Redémarrer l'ESP32</button>
</div>
<img src="board.jpg" alt="KC868-A2 board" class="board-img">
<div class="regle-form rs485-info">
<div class="form-titre">Raccordement RS485 — Epever Tracer 4210N</div>
<p class="aide">Le contrôleur Epever utilise un connecteur <strong>RJ45 8P8C</strong> pour la communication RS485 (Modbus RTU, <strong>115200 bps</strong>, 8N1). Les signaux A et B sont doublés (pins 3&amp;4 = B, pins 5&amp;6 = A).<br>⚠ Ne jamais connecter les pins 1&amp;2 (+7.5V) au KC868-A2.</p>
<pre class="rs485-schema">
Epever 4210N — RJ45 vue de face (languette vers le bas)
╔══════════════════════════════════╗
║ ┌──────────────────────────┐ ║
║ │ ╷ ╷ ╷ ╷ ╷ ╷ ╷ ╷ │ ║
║ │ 1 2 3 4 5 6 7 8 │ ║
║ └──────────────────────────┘ ║
╚══════════════════════════════════╝
│ │ │ │ │ │ │ │
GRI ORA NOI ROU VER JAU BLE MAR
+7V +7V B B A+ A+ GND GND
⚠️ ⚠️
│ │ │
(au choix, ex: ROU / JAU / BLE)
│ │ │
▼ ▼ ▼
KC868-A2 : B A+ GND
</pre>
<table class="rs485-table">
<thead><tr><th>Pin</th><th>Couleur</th><th>Signal</th><th>KC868-A2</th></tr></thead>
<tbody>
<tr><td>1</td><td><span class="fil" style="background:#888">Gris</span></td><td>+7.5V ⚠</td><td>Ne pas connecter</td></tr>
<tr><td>2</td><td><span class="fil" style="background:#f80">Orange</span></td><td>+7.5V ⚠</td><td>Ne pas connecter</td></tr>
<tr><td>3</td><td><span class="fil" style="background:#222">Noir</span></td><td>RS-485-B</td><td rowspan="2">B</td></tr>
<tr><td>4</td><td><span class="fil" style="background:#e00">Rouge</span></td><td>RS-485-B</td></tr>
<tr><td>5</td><td><span class="fil" style="background:#0a0">Vert</span></td><td>RS-485-A</td><td rowspan="2">A+</td></tr>
<tr><td>6</td><td><span class="fil" style="background:#cc0;color:#333">Jaune</span></td><td>RS-485-A</td></tr>
<tr><td>7</td><td><span class="fil" style="background:#00c">Bleu</span></td><td>GND</td><td rowspan="2">GND (optionnel)</td></tr>
<tr><td>8</td><td><span class="fil" style="background:#6b3a2a">Marron</span></td><td>GND</td></tr>
</tbody>
</table>
<p class="aide" style="margin-top:0.5rem">⚠ Le port RS485 de l'Epever n'est pas isolé. Un module d'isolation RS485 est recommandé pour éviter les boucles de masse.</p>
<p class="aide">🔋 <strong>Alimentation KC868-A2</strong> : nécessite <strong>12V DC</strong> — brancher directement sur la batterie du système solaire. Ne pas utiliser les pins 1&amp;2 du RJ45 (+7.5V, courant trop faible).</p>
</div>
<div class="regle-form rs485-info">
<div class="form-titre">Raccordement des relais</div>
<p class="aide">Chaque relais dispose de 3 bornes : <strong>COM</strong> (commun), <strong>NO</strong> (normalement ouvert) et <strong>NC</strong> (normalement fermé). Capacité max : <strong>10A / 250V AC</strong>.</p>
<pre class="rs485-schema">
┌──────────────────────────────────────────────┐
│ RELAIS HORS TENSION (OFF) │
│ │
│ COM ────● ○ NO (circuit ouvert) │
│ COM ────●───● NC (circuit fermé) │
└──────────────────────────────────────────────┘
┌──────────────────────────────────────────────┐
│ RELAIS ALIMENTÉ (ON) │
│ │
│ COM ────●───● NO (circuit fermé) │
│ COM ────● ○ NC (circuit ouvert) │
└──────────────────────────────────────────────┘
</pre>
<table class="rs485-table">
<thead><tr><th>Contact</th><th>Relais OFF</th><th>Relais ON</th><th>Usage typique</th></tr></thead>
<tbody>
<tr><td>NO</td><td>Ouvert</td><td>Fermé</td><td>Charge OFF par défaut</td></tr>
<tr><td>NC</td><td>Fermé</td><td>Ouvert</td><td>Charge ON par défaut</td></tr>
</tbody>
</table>
<div class="form-titre" style="margin-top:1rem">Exemple : commande d'une lampe 230V</div>
<p class="aide">⚡ Travaux sur le 230V : couper le disjoncteur avant toute intervention. La tension 230V est dangereuse et potentiellement mortelle.</p>
<pre class="rs485-schema">
Utilisation du contact NO (lampe éteinte par défaut)
Tableau Bornier relais KC868-A2 Lampe
électrique ┌───────────────────────────┐
│ │
Phase (L) ───►│ COM NO ►───┼──── Lampe ──┐
│ NC │ │
└───────────────────────────┘ │
Neutre (N) ──────────────────────────────────────────────┘
Relais OFF → NO ouvert → lampe ÉTEINTE
Relais ON → NO fermé → lampe ALLUMÉE
Utilisation du contact NC (lampe allumée par défaut)
Tableau Bornier relais KC868-A2 Lampe
électrique ┌───────────────────────────┐
│ │
Phase (L) ───►│ COM NC ►───┼──── Lampe ──┐
│ NO │ │
└───────────────────────────┘ │
Neutre (N) ──────────────────────────────────────────────┘
Relais OFF → NC fermé → lampe ALLUMÉE
Relais ON → NC ouvert → lampe ÉTEINTE
</pre>
</div>
</section>
<!-- Historique -->
<section id="historique" class="onglet">
<p class="aide">Mode <strong>4h</strong> : un point par minute (RAM uniquement). Mode <strong>30h</strong> : moyenne sur 5 min, sauvegardée toutes les heures sur le système de fichiers.</p>
<div class="hist-toggle">
<button id="btn-hires" class="btn btn-primaire active-mode" onclick="setHistMode('hires')">4h</button>
<button id="btn-lores" class="btn" onclick="setHistMode('lores')">30h</button>
<button class="btn" onclick="chargerHistorique()"></button>
</div>
<div id="hist-debug" class="hist-debug">Historique en attente...</div>
<div id="hist-last" class="hist-debug">Derniers points en attente...</div>
<div class="graphe-conteneur">
<div class="graphe-titre">Tension batterie (V)</div>
<canvas id="chart-battery" width="600" height="150"></canvas>
</div>
<div class="graphe-conteneur">
<div class="graphe-titre">Tension PV (V)</div>
<canvas id="chart-pv" width="600" height="150"></canvas>
</div>
<div class="graphe-conteneur">
<div class="graphe-titre">Puissance load (W)</div>
<canvas id="chart-load" width="600" height="150"></canvas>
</div>
<div class="graphe-conteneur">
<div class="graphe-titre">SOC batterie (%)</div>
<canvas id="chart-soc" width="600" height="150"></canvas>
</div>
</section>
<!-- Config EPEVER -->
<section id="epever-config" class="onglet">
<p class="aide">Lecture et modification des paramètres de charge du régulateur EPEVER. Cliquer <strong>Lire</strong> pour synchroniser, modifier les valeurs, puis <strong>Écrire</strong> pour appliquer. Une confirmation est demandée avant toute écriture.</p>
<div class="ec-statusbar" id="ec-status">En attente — cliquer Lire pour synchroniser</div>
<div class="regle-form">
<div class="form-titre">Actions</div>
<div class="ec-actions">
<button class="btn btn-primaire" onclick="lireConfigEpever()">Lire</button>
<button class="btn btn-vert" onclick="sauvegarderConfigEpever()">Sauvegarder</button>
<button class="btn" onclick="restaurerConfigEpever()">Restaurer</button>
<button class="btn btn-sm" onclick="exporterConfigEpever()">Exporter</button>
<label class="btn btn-sm ec-import-label">Importer
<input type="file" id="ec-import-file" accept=".json" style="display:none" onchange="importerConfigEpever(this)">
</label>
</div>
</div>
<!-- Batterie -->
<div class="regle-form">
<div class="form-titre">Batterie</div>
<div class="form-ligne">
<label>Type <span class="ec-aide" title="0=Perso, 1=Scellée (AGM), 2=GEL, 3=Liquide, 4=Lithium. Détermine les algorithmes de charge."></span></label>
<select id="ec-battery_type" class="ec-field">
<option value="0">Perso (User)</option>
<option value="1">Scellée / AGM</option>
<option value="2">GEL</option>
<option value="3">Liquide (Flooded)</option>
<option value="4">Lithium</option>
</select>
</div>
<div class="form-ligne">
<label>Capacité <span class="ec-aide" title="Capacité nominale en Ah. Influence les calculs de protection."></span></label>
<div class="ec-field-unit">
<input type="number" id="ec-battery_capacity" class="ec-field" min="1" max="9999" step="1">
<span class="ec-unit">Ah</span>
</div>
</div>
<div class="form-ligne">
<label>Tension système <span class="ec-aide" title="0=Auto détecté, 1=12V, 2=24V. Laisser Auto si non sûr."></span></label>
<select id="ec-rated_voltage" class="ec-field">
<option value="0">Auto</option>
<option value="1">12V</option>
<option value="2">24V</option>
</select>
</div>
<div class="form-ligne">
<label>Comp. température <span class="ec-aide" title="Correction de tension selon la température. Typique plomb-acide : -3. Saisir 0 si inconnu."></span></label>
<div class="ec-field-unit">
<input type="number" id="ec-temp_compensation" class="ec-field" min="-9" max="0" step="1">
<span class="ec-unit">mV/°C/2V</span>
</div>
</div>
</div>
<!-- Seuils de charge -->
<div class="regle-form">
<div class="form-titre">Seuils de charge</div>
<div class="form-ligne">
<label>Float <span class="ec-aide" title="Tension de maintien après charge complète. Typique 13.6V. Trop haute = usure prématurée."></span></label>
<div class="ec-field-unit">
<input type="number" id="ec-float_voltage" class="ec-field" min="13.0" max="14.5" step="0.01">
<span class="ec-unit">V</span>
</div>
</div>
<div class="form-ligne">
<label>Boost <span class="ec-aide" title="Tension d'absorption (charge rapide). Typique 14.4V plomb-acide."></span></label>
<div class="ec-field-unit">
<input type="number" id="ec-boost_voltage" class="ec-field" min="13.0" max="15.5" step="0.01">
<span class="ec-unit">V</span>
</div>
</div>
<div class="form-ligne">
<label>Égalisation <span class="ec-aide" title="Tension des cycles de désulfatation périodiques. Typique 14.6V."></span></label>
<div class="ec-field-unit">
<input type="number" id="ec-equalize_voltage" class="ec-field" min="13.0" max="16.0" step="0.01">
<span class="ec-unit">V</span>
</div>
</div>
<div class="form-ligne">
<label>Limite charge <span class="ec-aide" title="Tension maximale autorisée pendant la charge."></span></label>
<div class="ec-field-unit">
<input type="number" id="ec-charging_limit" class="ec-field" min="13.0" max="16.0" step="0.01">
<span class="ec-unit">V</span>
</div>
</div>
<div class="form-ligne">
<label>Déconnexion survoltage <span class="ec-aide" title="Coupure de protection si la tension dépasse ce seuil."></span></label>
<div class="ec-field-unit">
<input type="number" id="ec-high_volt_disconnect" class="ec-field" min="13.0" max="17.0" step="0.01">
<span class="ec-unit">V</span>
</div>
</div>
<div class="form-ligne">
<label>Reconnexion survoltage <span class="ec-aide" title="La charge reprend quand la tension descend sous ce seuil après une coupure survoltage."></span></label>
<div class="ec-field-unit">
<input type="number" id="ec-overvolt_reconnect" class="ec-field" min="13.0" max="16.0" step="0.01">
<span class="ec-unit">V</span>
</div>
</div>
<div class="form-ligne">
<label>Reconnexion boost <span class="ec-aide" title="Si la tension descend sous ce seuil, la phase boost reprend automatiquement."></span></label>
<div class="ec-field-unit">
<input type="number" id="ec-boost_reconnect" class="ec-field" min="11.0" max="14.0" step="0.01">
<span class="ec-unit">V</span>
</div>
</div>
</div>
<!-- Seuils de décharge / protection -->
<div class="regle-form">
<div class="form-titre">Protection décharge</div>
<div class="form-ligne">
<label>Alerte basse tension <span class="ec-aide" title="Déclenche une alarme sans couper. Signal préventif."></span></label>
<div class="ec-field-unit">
<input type="number" id="ec-undervolt_warning" class="ec-field" min="10.0" max="13.0" step="0.01">
<span class="ec-unit">V</span>
</div>
</div>
<div class="form-ligne">
<label>Déconnexion basse tension <span class="ec-aide" title="La sortie de charge est coupée sous cette tension pour protéger la batterie."></span></label>
<div class="ec-field-unit">
<input type="number" id="ec-low_disconnect" class="ec-field" min="9.0" max="13.0" step="0.01">
<span class="ec-unit">V</span>
</div>
</div>
<div class="form-ligne">
<label>Reconnexion basse tension <span class="ec-aide" title="La sortie de charge est réactivée quand la tension remonte au-dessus de ce seuil."></span></label>
<div class="ec-field-unit">
<input type="number" id="ec-low_reconnect" class="ec-field" min="10.0" max="13.5" step="0.01">
<span class="ec-unit">V</span>
</div>
</div>
<div class="form-ligne">
<label>Limite décharge <span class="ec-aide" title="Tension minimale absolue. Ne jamais descendre en dessous."></span></label>
<div class="ec-field-unit">
<input type="number" id="ec-discharge_limit" class="ec-field" min="9.0" max="13.0" step="0.01">
<span class="ec-unit">V</span>
</div>
</div>
<div class="form-ligne">
<label>SOC déconnexion <span class="ec-aide" title="Seuil de charge minimum (%) avant coupure de la sortie."></span></label>
<div class="ec-field-unit">
<input type="number" id="ec-bat_discharge_soc" class="ec-field" min="20" max="100" step="1">
<span class="ec-unit">%</span>
</div>
</div>
</div>
<!-- Timing -->
<div class="regle-form">
<div class="form-titre">Timing</div>
<div class="form-ligne">
<label>Durée boost <span class="ec-aide" title="Durée maximale de la phase d'absorption en minutes."></span></label>
<div class="ec-field-unit">
<input type="number" id="ec-boost_duration" class="ec-field" min="10" max="180" step="1">
<span class="ec-unit">min</span>
</div>
</div>
<div class="form-ligne">
<label>Durée égalisation <span class="ec-aide" title="Durée du cycle d'égalisation en minutes. 0 = désactivé."></span></label>
<div class="ec-field-unit">
<input type="number" id="ec-equalize_duration" class="ec-field" min="0" max="180" step="1">
<span class="ec-unit">min</span>
</div>
</div>
</div>
<button class="btn btn-rouge btn-plein ec-btn-write" onclick="ecrireConfigEpever()">
Écrire vers l'EPEVER
</button>
</section>
<!-- Debug -->
<section id="debug" class="onglet">
<div class="debug-actions">
<button class="btn btn-primaire" onclick="chargerDebug()">Rafraîchir</button>
<button class="btn btn-rouge" onclick="viderDebug()">Vider</button>
</div>
<div class="debug-meta" id="debug-meta">Journal en attente</div>
<pre id="debug-console" class="debug-console">Chargement...</pre>
</section>
</main>
<div id="sun-modal" class="modal hidden">
<div class="modal-box">
<div class="modal-title">Changements Jour/Nuit</div>
<div id="sun-history-list" class="modal-list">Chargement...</div>
<button class="btn btn-primaire btn-plein" onclick="fermerSunPopup()">Fermer</button>
</div>
</div>
<footer id="pied-page">En attente de données…</footer>
<script src="app.js"></script>
</body>
</html>
+1
View File
@@ -0,0 +1 @@
[]
+595
View File
@@ -0,0 +1,595 @@
:root {
--bg: #1a1a2e;
--surface: #16213e;
--carte: #0f3460;
--accent: #e94560;
--vert: #00b894;
--rouge: #d63031;
--texte: #eaeaea;
--muted: #a0aec0;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
background: var(--bg);
color: var(--texte);
font-family: system-ui, -apple-system, sans-serif;
min-height: 100vh;
display: flex;
flex-direction: column;
}
/* --- En-tête --- */
header {
background: var(--surface);
padding: 0.6rem 0.9rem;
display: flex;
align-items: center;
justify-content: space-between;
border-bottom: 1px solid var(--carte);
}
header h1 { font-size: 0.95rem; font-weight: 700; }
.header-title {
min-width: 0;
}
.header-clock {
display: block;
margin-top: 0.1rem;
color: var(--muted);
font-family: "Courier New", monospace;
font-size: 0.68rem;
}
/* --- Badges --- */
.badge {
padding: 0.2rem 0.7rem;
border-radius: 999px;
font-size: 0.7rem;
font-weight: 700;
letter-spacing: 0.03em;
}
.badge-ok { background: var(--vert); color: #000; }
.badge-err { background: var(--rouge); color: #fff; }
/* --- Navigation --- */
nav {
display: flex;
background: var(--surface);
border-bottom: 1px solid var(--carte);
}
.tab {
flex: 1;
padding: 0.55rem 0;
background: none;
border: none;
border-bottom: 2px solid transparent;
color: var(--muted);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
min-width: 0;
transition: color 0.15s, border-color 0.15s;
}
.tab svg {
width: 20px;
height: 20px;
flex-shrink: 0;
pointer-events: none;
}
.tab.active {
color: var(--accent);
border-bottom-color: var(--accent);
}
/* --- Contenu principal --- */
main { flex: 1; padding: 0.65rem; }
.onglet { display: none; }
.onglet.actif { display: block; }
/* --- Dashboard --- */
.grille {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 0.45rem;
}
.grille-3 { grid-template-columns: repeat(3, 1fr); }
.carte {
background: var(--carte);
border-radius: 0.55rem;
padding: 0.45rem 0.35rem;
text-align: center;
border: 2px solid transparent;
transition: border-color 0.2s, transform 0.1s;
}
.carte-on { border-color: var(--vert); }
/* Long press feedback — user-select uniquement sur les cartes relais */
#carte-relay1, #carte-relay2 {
cursor: default;
user-select: none;
-webkit-user-select: none;
}
.press-hold {
border-color: var(--accent) !important;
transform: scale(0.95);
transition: transform 0.1s, border-color 0.1s !important;
}
@keyframes flash-save {
0% { background: var(--accent); }
100% { background: var(--carte); }
}
.press-done { animation: flash-save 0.5s ease-out forwards; }
.etiquette {
font-size: 0.58rem;
color: var(--muted);
text-transform: uppercase;
letter-spacing: 0.04em;
margin-bottom: 0.18rem;
}
.valeur {
font-size: 1.05rem;
font-weight: 700;
}
.valeur-compacte {
font-size: 0.82rem;
overflow-wrap: anywhere;
}
.val-on { color: var(--vert); }
.val-off { color: var(--muted); }
.dash-section {
font-size: 0.6rem;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--accent);
font-weight: 700;
padding: 0.3rem 0 0.15rem;
}
.dash-hint {
font-weight: 400;
color: var(--muted);
text-transform: none;
letter-spacing: 0;
font-size: 0.56rem;
}
/* --- Commandes --- */
.ligne-commande {
background: var(--surface);
border-radius: 0.65rem;
padding: 0.7rem 0.75rem;
margin-bottom: 0.5rem;
display: flex;
align-items: center;
gap: 0.5rem;
}
.label-cmd {
flex: 1;
font-weight: 500;
font-size: 0.9rem;
min-width: 0;
}
/* --- Boutons --- */
.btn {
padding: 0.55rem 1rem;
border: none;
border-radius: 0.5rem;
font-size: 0.85rem;
font-weight: 600;
cursor: pointer;
background: var(--carte);
color: var(--texte);
white-space: nowrap;
transition: opacity 0.15s, box-shadow 0.15s;
touch-action: manipulation;
}
.btn:active { opacity: 0.75; }
.btn-actif { background: var(--accent); color: #fff; }
.btn-vert { background: var(--vert); color: #000; }
.btn-rouge { background: var(--rouge); color: #fff; }
.btn-dim { opacity: 0.3; }
.btn-glow-vert { box-shadow: 0 0 10px var(--vert); }
.btn-glow-rouge { box-shadow: 0 0 10px var(--rouge); }
/* --- Voyant LED relais --- */
.led {
display: inline-block;
width: 12px; height: 12px;
border-radius: 50%;
vertical-align: middle;
margin-left: 6px;
transition: background 0.2s, box-shadow 0.2s;
}
.led-on { background: var(--vert); box-shadow: 0 0 7px var(--vert); }
.led-off { background: #444; box-shadow: none; }
.btn-primaire {
display: inline-block;
text-decoration: none;
background: var(--accent);
color: #fff;
margin-top: 1.2rem;
padding: 0.6rem 1.5rem;
border-radius: 0.5rem;
font-weight: 600;
}
/* --- Règles --- */
.regle-item {
background: var(--surface);
border-radius: 0.75rem;
padding: 0.75rem 1rem;
margin-bottom: 0.6rem;
display: flex;
align-items: center;
gap: 0.6rem;
}
.regle-desc { flex: 1; font-size: 0.85rem; line-height: 1.5; }
.regle-id { font-size: 0.7rem; color: var(--muted); }
.regle-desactivee { opacity: 0.45; }
.regle-form {
background: var(--surface);
border-radius: 0.75rem;
padding: 1rem;
margin-top: 1rem;
}
.form-titre {
font-weight: 600;
margin-bottom: 0.75rem;
font-size: 0.9rem;
}
.form-ligne {
display: flex;
align-items: center;
margin-bottom: 0.5rem;
gap: 0.5rem;
}
.form-ligne label {
width: 90px;
font-size: 0.8rem;
color: var(--muted);
flex-shrink: 0;
}
.form-ligne select,
.form-ligne input {
flex: 1;
background: var(--carte);
border: none;
border-radius: 0.4rem;
padding: 0.4rem 0.6rem;
color: var(--texte);
font-size: 0.85rem;
}
.btn-plein { display: block; width: 100%; margin-top: 0.75rem; text-align: center; }
.btn-sm { padding: 0.3rem 0.7rem; font-size: 0.75rem; }
.form-section-label {
font-size: 0.65rem;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--accent);
font-weight: 700;
margin: 0.8rem 0 0.35rem;
border-bottom: 1px solid var(--carte);
padding-bottom: 0.25rem;
}
/* --- Toast notification --- */
#toast {
position: fixed;
bottom: 1.5rem;
left: 50%;
transform: translateX(-50%) translateY(1rem);
background: var(--rouge);
color: #fff;
padding: 0.55rem 1.2rem;
border-radius: 2rem;
font-size: 0.85rem;
font-weight: 600;
opacity: 0;
pointer-events: none;
transition: opacity 0.2s, transform 0.2s;
white-space: nowrap;
z-index: 999;
}
#toast.visible {
opacity: 1;
transform: translateX(-50%) translateY(0);
}
/* --- Modales --- */
.modal {
position: fixed;
inset: 0;
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
padding: 1rem;
background: rgba(0, 0, 0, 0.65);
}
.modal.hidden { display: none; }
.modal-box {
width: 100%;
max-width: 420px;
background: var(--surface);
border: 1px solid var(--carte);
border-radius: 0.75rem;
padding: 1rem;
}
.modal-title {
font-weight: 700;
margin-bottom: 0.75rem;
}
.modal-list {
display: grid;
gap: 0.4rem;
}
.modal-row {
display: flex;
justify-content: space-between;
gap: 0.75rem;
background: var(--carte);
border-radius: 0.45rem;
padding: 0.55rem 0.65rem;
font-size: 0.82rem;
}
.modal-row span {
color: var(--muted);
font-family: "Courier New", monospace;
}
.modal-empty {
color: var(--muted);
font-size: 0.85rem;
}
/* --- Lignes relais désactivées en mode Auto --- */
.row-disabled { opacity: 0.4; pointer-events: none; }
/* --- Textes d'aide onglets --- */
.aide {
font-size: 0.78rem;
color: var(--muted);
line-height: 1.55;
background: var(--surface);
border-left: 3px solid var(--accent);
border-radius: 0 0.5rem 0.5rem 0;
padding: 0.55rem 0.75rem;
margin-bottom: 0.75rem;
}
.aide strong { color: var(--texte); }
/* --- Board image --- */
.board-img {
display: block;
width: 100%;
max-width: 700px;
height: auto;
margin: 0.75rem auto 0;
border-radius: 0.75rem;
border: 1px solid var(--carte);
}
.ota-info {
font-size: 0.8rem;
color: var(--muted);
margin-bottom: 0.5rem;
}
/* --- Historique / Graphes --- */
.hist-toggle {
display: flex;
gap: 0.5rem;
margin-bottom: 0.75rem;
}
.hist-toggle .btn { flex: 1; }
.active-mode { background: var(--accent) !important; color: #fff !important; opacity: 1 !important; }
.hist-debug {
color: var(--muted);
background: var(--surface);
border: 1px solid var(--carte);
border-radius: 0.5rem;
padding: 0.45rem 0.6rem;
margin-bottom: 0.75rem;
font-family: "Courier New", monospace;
font-size: 0.68rem;
line-height: 1.35;
}
.graphe-conteneur {
background: var(--surface);
border-radius: 0.75rem;
padding: 0.75rem;
margin-bottom: 0.75rem;
overflow: hidden;
}
.graphe-titre {
font-size: 0.75rem;
color: var(--muted);
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: 0.5rem;
}
canvas {
display: block;
width: 100%;
height: auto;
border-radius: 0.4rem;
}
/* --- Debug console --- */
.debug-actions {
display: flex;
gap: 0.5rem;
margin-bottom: 0.6rem;
}
.debug-actions .btn {
flex: 1;
margin-top: 0;
}
.debug-meta {
color: var(--muted);
font-size: 0.75rem;
margin-bottom: 0.45rem;
}
.debug-console {
width: 100%;
min-height: 60vh;
max-height: 68vh;
overflow: auto;
background: #070b12;
color: #d7f7df;
border: 1px solid var(--carte);
border-radius: 0.55rem;
padding: 0.75rem;
font-family: "Courier New", monospace;
font-size: 0.72rem;
line-height: 1.45;
white-space: pre-wrap;
word-break: break-word;
}
/* --- WiFi info --- */
.wifi-val { font-family: monospace; font-size: 0.9rem; color: var(--texte); }
/* --- RS485 wiring info --- */
.rs485-schema {
font-family: 'Courier New', monospace;
font-size: 0.7rem;
line-height: 1.4;
color: var(--texte);
background: var(--fond);
border-radius: 0.4rem;
padding: 0.6rem 0.75rem;
overflow-x: auto;
white-space: pre;
margin: 0.5rem 0;
}
.rs485-table {
width: 100%;
border-collapse: collapse;
font-size: 0.8rem;
margin: 0.5rem 0;
}
.rs485-table th, .rs485-table td {
padding: 0.35rem 0.5rem;
border: 1px solid var(--carte);
text-align: left;
}
.rs485-table th { background: var(--fond); color: var(--muted); }
.fil {
display: inline-block;
padding: 0.1rem 0.45rem;
border-radius: 0.3rem;
font-size: 0.75rem;
font-weight: 600;
color: #fff;
}
/* --- WiFi scan --- */
.wifi-ssid-row {
display: flex;
gap: 0.4rem;
flex: 1;
}
.wifi-ssid-row input { flex: 1; min-width: 0; }
.wifi-actions {
display: flex;
gap: 0.4rem;
margin-top: 0.5rem;
}
.wifi-actions .btn { flex: 1; }
.wifi-scan-list {
background: var(--carte);
border-radius: 0.4rem;
margin: 0.25rem 0 0.5rem;
max-height: 12rem;
overflow-y: auto;
}
.wifi-scan-list.hidden { display: none; }
.wifi-scan-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.45rem 0.65rem;
cursor: pointer;
border-bottom: 1px solid var(--surface);
font-size: 0.82rem;
transition: background 0.1s;
}
.wifi-scan-item:last-child { border-bottom: none; }
.wifi-scan-item:hover { background: #1a3a6e; }
.wifi-scan-wait { color: var(--muted); cursor: default; justify-content: center; }
.wifi-scan-wait:hover { background: none; }
.wifi-scan-ssid { font-weight: 500; }
.wifi-scan-rssi { font-size: 0.72rem; color: var(--muted); white-space: nowrap; margin-left: 0.5rem; }
.wifi-val { color: var(--texte); font-size: 0.85rem; }
.wifi-connected { color: var(--vert); font-weight: 600; }
/* --- Onglet Config EPEVER --- */
.ec-statusbar {
background: var(--carte);
border-radius: 0.45rem;
padding: 0.45rem 0.7rem;
font-size: 0.78rem;
color: var(--muted);
margin-bottom: 0.65rem;
border-left: 3px solid var(--muted);
}
.ec-statusbar.ec-ok { color: var(--vert); border-left-color: var(--vert); }
.ec-statusbar.ec-err { color: var(--rouge); border-left-color: var(--rouge); }
.ec-actions {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
}
.ec-actions .btn { flex: 1; min-width: 4.5rem; text-align: center; }
.ec-field-unit {
display: flex;
align-items: center;
gap: 0.3rem;
flex: 1;
}
.ec-field-unit .ec-field { flex: 1; min-width: 0; }
.ec-unit {
font-size: 0.7rem;
color: var(--muted);
white-space: nowrap;
}
.ec-aide {
display: inline-block;
font-size: 0.7rem;
color: var(--muted);
cursor: help;
margin-left: 0.2rem;
vertical-align: middle;
}
.ec-import-label { cursor: pointer; }
.ec-btn-write {
margin-top: 0.5rem;
font-weight: 700;
letter-spacing: 0.02em;
}
/* --- Pied de page --- */
footer {
text-align: center;
padding: 0.45rem;
font-size: 0.7rem;
color: var(--muted);
background: var(--surface);
border-top: 1px solid var(--carte);
}
+174
View File
@@ -0,0 +1,174 @@
# Debug RS485 — Historique des corrections
Ce document trace les problèmes rencontrés avec la communication RS485 Modbus et les modifications apportées pour la faire fonctionner.
---
## Contexte matériel
| Paramètre | Valeur |
|-----------|--------|
| Baudrate réel Epever | **115 200** (et non 9 600 par défaut) |
| UART ESP32 | Serial2 |
| RX GPIO | 35 (input-only) |
| TX GPIO | 32 |
| Adresse esclave | 1 |
| Mode | RTU, 8N1, half-duplex |
> Le baudrate est le premier point de blocage : l'Epever Tracer 4210N peut être configuré à des vitesses non standards. La valeur 115 200 a été découverte grâce à la sonde de boot (`probeRegistreBatterie`).
---
## Abandon de la librairie ModbusRTU — passage en mode "brut"
### Ancienne approche (callbacks, morte mais encore dans le fichier)
Le code utilisait la librairie `ModbusRTU` avec une chaîne de callbacks asynchrones :
```
gererModbus()
└─ mb.readIreg(0x3100, cbPV)
└─ cbPV → mb.readIreg(0x310C, cbLoad)
└─ cbLoad → mb.readIreg(0x311A, cbSOC)
└─ cbSOC → mb.readIreg(0x3200, cbStatus)
└─ cbStatus → mb.readIreg(0x3300, cbEnergie)
└─ cbEnergie → mb.readIsts(0x200C, cbJourNuit)
└─ cbJourNuit → finaliserLecture()
```
**Problèmes :**
- La lib gérait mal le timing RS485 half-duplex à 115 200 bauds, générant des trames corrompues ou des timeouts
- Aucun vidage du buffer RX entre requêtes → les octets résiduels polluaient les trames suivantes
- Pas de diagnostic au boot pour vérifier la communication avant d'entrer dans la boucle
Les callbacks `cbPV`, `cbLoad`, `cbSOC`, `cbStatus`, `cbEnergie`, `cbJourNuit` sont **toujours présents dans le fichier mais ne sont plus appelés**. Seuls `mb.begin()` et `mb.master()` restent actifs (initialisation du Serial2 interne à la lib).
### Nouvelle approche (Serial2 direct, synchrone)
`gererModbus()` appelle `effectuerLectureBruteEpever()` qui effectue toutes les lectures de façon séquentielle via Serial2 direct + CRC Modbus calculé manuellement :
```
effectuerLectureBruteEpever()
1. lireRegistresBruts(0x3100, 8) → PV + batterie [FC04, fatal]
2. lireRegistresBruts(0x310C, 5) → load + température [FC04, fatal]
3. lireRegistresBruts(0x311A, 1) → SOC % [FC04, fatal]
4. lireRegistresBruts(0x3200, 2) → statut batterie [FC04, fatal]
5. lireHorlogeEpever() → RTC Epever [FC03, non-fatal]
6. lireRegistresBruts(0x3302, 18) → énergie kWh [FC04, non-fatal]
7. lireEntreesDiscretesBrut(0x200C)→ jour/nuit [FC02, non-fatal]
```
---
## Corrections des registres
### Énergie kWh (base décalée)
| | Ancienne version | Nouvelle version |
|-|-----------------|-----------------|
| Base de lecture | 0x3300 | **0x3302** |
| Énergie générée jour | offset 0 (0x3300/01) | offset 2 (0x3304/05) |
| Énergie générée total | offset 6 (0x3306/07) | offset 8 (0x330A/0B) |
| Énergie consommée jour | offset 8 (0x3308/09) | offset 10 (0x330C/0D) |
| Énergie consommée total | offset 14 (0x330E/0F) | offset 16 (0x3312/13) |
Source de référence : `MODBUS-Protocol-v25.pdf` (tableau des registres input 0x3302 à 0x3313).
### Puissance sortie de charge (32 bits)
L'ancien callback lisait `bufLoad[2] * 0.01f` (16 bits uniquement, registre 0x310E seul).
La puissance est en réalité sur **32 bits** (0x310E = word L, 0x310F = word H) :
```cpp
// Avant
state.loadPower = bufLoad[2] * 0.01f;
// Après
state.loadPower = u32x100(load, 2); // (reg[3] << 16 | reg[2]) * 0.01f
```
### Détection jour/nuit (logique hybride)
Le registre FC02 `0x200C` retourne `1 = Nuit, 0 = Jour`. Mais si l'Epever renvoie nuit alors que le panneau produit clairement (PV > 2 V), l'état est incohérent côté UI.
```cpp
// Avant (simple inversion)
state.sun = !bufJourNuit[0];
// Après (hybride : registre + tension PV)
state.sun = !nuit || state.pv > 2.0f;
```
---
## Mécanismes de robustesse ajoutés
### `viderRx()` — purge systématique du buffer avant chaque trame
Entre deux requêtes Modbus, des octets parasites peuvent s'accumuler dans le FIFO Serial2 (echos, bruit, réponse tardive). Sans purge, ces octets polluent la réponse suivante et génèrent des CRC invalides.
```cpp
static void viderRx(const char *raison) {
// lit et logue tous les octets résiduels présents avant l'envoi
}
```
Appelé en tête de chaque fonction de lecture brute.
### `probeRegistreBatterie()` — sonde de boot RS485
Au démarrage, avant d'entrer dans la boucle normale, le code envoie une trame Modbus manuelle FC04 sur le registre 0x3104 (tension batterie) et vérifie :
- réception d'octets
- adresse esclave correcte
- CRC valide
- valeur de tension cohérente
Si le baudrate principal échoue, la sonde retente à 9 600 et 115 200.
### CRC Modbus calculé et vérifié manuellement
```cpp
static uint16_t crc16Modbus(const uint8_t *buf, size_t len);
```
Vérifié sur chaque trame reçue avant d'extraire les valeurs. En cas d'échec CRC, la trame est rejetée, l'erreur est comptée, et le cycle suivant repart proprement.
### Lectures non-fatales (énergie, RTC, jour/nuit)
Certaines lectures peuvent légitimement échouer (registres optionnels, timing serré) sans invalider les données essentielles. Le cycle continue et conserve les dernières valeurs valides :
- Énergie (0x3302) : non-fatal, conserve le dernier kWh connu
- Jour/nuit (FC02 0x200C) : non-fatal, fallback sur `state.pv > 2.0f`
- Horloge Epever (0x9013) : non-fatal, `epeverClockOk = false`
### Synchronisation RTC ESP32 depuis l'Epever
```cpp
static void calerHorlogeEspDepuisEpever();
```
Après lecture des holding registers 0x90130x9015 (secondes/minutes/heures/jour/mois/année), l'heure système ESP32 est calée via `settimeofday()`. Rafraîchissement toutes les 6h (`INTERVALLE_SYNC_RTC`).
---
## Fonctions FC couvertes
| Fonction Modbus | Usage |
|----------------|-------|
| FC02 (Read Discrete Inputs) | Registre 0x200C — état jour/nuit |
| FC03 (Read Holding Registers) | Registres 0x90130x9015 — horloge RTC |
| FC04 (Read Input Registers) | PV, batterie, load, SOC, statut, énergie |
| FC16 (Write Multiple Holding Registers) | Réglage horloge RTC Epever |
---
## Résumé des points de blocage résolus
| Problème | Cause | Correction |
|---------|-------|-----------|
| Aucune réponse RS485 | Baudrate 9600 au lieu de 115200 | `MODBUS_BAUDRATE 115200` + sonde boot |
| Trames corrompues | Buffer RX pollué entre requêtes | `viderRx()` systématique |
| Timeouts aléatoires | Lib ModbusRTU inadaptée au timing | Passage en Serial2 direct |
| Énergie kWh à zéro | Mauvaise base de registre (0x3300 vs 0x3302) | Correction offsets selon PDF |
| Puissance load erronée | Lecture 16 bits au lieu de 32 bits | `u32x100()` sur 0x310E/0F |
| État soleil incohérent | FC02 seul, pas de fallback PV | Logique hybride `!nuit \|\| pv > 2V` |
+96
View File
@@ -0,0 +1,96 @@
ARG DEBIAN_FRONTEND=noninteractive
ARG QEMU_TAG=esp-develop-9.2.2-20260417
ARG QEMU_ARCHIVE=qemu-xtensa-softmmu-esp_develop_9.2.2_20260417-x86_64-linux-gnu.tar.xz
# =============================================================================
# Stage 1 — extract ESP32 ROM blobs from the official pre-built package
# (these binary blobs are not built from source; we reuse them as-is)
# =============================================================================
FROM ubuntu:22.04 AS rom-extractor
ARG QEMU_TAG QEMU_ARCHIVE DEBIAN_FRONTEND
RUN apt-get update && apt-get install -y --no-install-recommends \
wget ca-certificates xz-utils \
&& rm -rf /var/lib/apt/lists/*
RUN wget -qO /tmp/qemu.tar.xz \
"https://github.com/espressif/qemu/releases/download/${QEMU_TAG}/${QEMU_ARCHIVE}" \
&& mkdir -p /tmp/rom \
&& tar -xJf /tmp/qemu.tar.xz -C /tmp/rom --strip-components=1 \
&& ls /tmp/rom/share/qemu/esp32*.bin
# =============================================================================
# Stage 2 — build patched QEMU from Espressif source
# Adds a silent stub for WiFi modem registers (0x60033C00) so the firmware
# does not crash with LoadStorePIFAddrError on first WiFi register access.
# =============================================================================
FROM ubuntu:22.04 AS qemu-builder
ARG QEMU_TAG DEBIAN_FRONTEND
RUN apt-get update && apt-get install -y --no-install-recommends \
git python3 python3-pip python3-tomli ninja-build pkg-config \
libglib2.0-dev libpixman-1-dev libslirp-dev libfdt-dev \
zlib1g-dev libpng-dev libgcrypt20-dev build-essential flex bison \
&& rm -rf /var/lib/apt/lists/*
# QEMU 9.x requires meson >= 1.1.0 — Ubuntu 22.04 ships an older version
RUN pip3 install --quiet 'meson>=1.5'
# Shallow clone of the exact release tag
RUN git clone --depth=1 --branch "${QEMU_TAG}" \
https://github.com/espressif/qemu.git /qemu
WORKDIR /qemu
# Inject WiFi modem stub into hw/xtensa/esp32.c
COPY wifi_stub_patch.py /tmp/
RUN python3 /tmp/wifi_stub_patch.py
# Configure and build — xtensa only, no UI, no docs
RUN ./configure \
--target-list=xtensa-softmmu \
--disable-docs \
--disable-gtk \
--disable-sdl \
--disable-vnc \
--disable-curses \
--disable-opengl \
--disable-virglrenderer \
--disable-spice \
--disable-dbus-display \
--disable-guest-agent \
--disable-capstone \
--disable-libudev \
--disable-libusb \
--disable-usb-redir \
--audio-drv-list= \
--enable-slirp \
--enable-fdt \
&& ninja -C build qemu-system-xtensa
# =============================================================================
# Stage 3 — final runtime image
# =============================================================================
FROM ubuntu:22.04
ARG DEBIAN_FRONTEND
RUN apt-get update && apt-get install -y --no-install-recommends \
python3 python3-pip \
libglib2.0-0 libpixman-1-0 libslirp0 libfdt1 libpng16-16 \
&& rm -rf /var/lib/apt/lists/*
# ROM blobs from the official pre-built package
RUN mkdir -p /usr/local/share/qemu
COPY --from=rom-extractor /tmp/rom/share/qemu/esp32*.bin /usr/local/share/qemu/
# Patched QEMU binary (built from source with WiFi stub)
COPY --from=qemu-builder /qemu/build/qemu-system-xtensa /usr/local/bin/qemu-system-xtensa
RUN chmod +x /usr/local/bin/qemu-system-xtensa
RUN pip3 install --quiet esptool
WORKDIR /emulator
COPY modbus_stub.py server.py entrypoint.sh ./
COPY ui/ ui/
RUN chmod +x entrypoint.sh
# 8888 = UI debug 3 volets 10080 = webserver ESP32
EXPOSE 8888 10080
ENTRYPOINT ["/emulator/entrypoint.sh"]
+126
View File
@@ -0,0 +1,126 @@
# Émulateur QEMU ESP32 — KC868-A2
Émulation du firmware sur QEMU ESP32 (fork Espressif, GPL).
Interface de débogage 3 volets : GPIO / terminal série / webserver ESP32.
## Architecture
```
Docker
├── qemu-system-xtensa (ESP32 firmware + WiFi + réseau)
│ UART0 → stdout (logs Serial.print → terminal)
│ UART2 → TCP:1235 (Modbus RTU ← stub Python)
│ NIC → slirp (hostfwd port 10080 → ESP32:80)
├── modbus_stub.py (émulateur Modbus RTU slave Epever)
│ Se connecte à TCP:1235, répond aux FC04 avec données simulées
└── server.py (interface web débogage, port 8888)
/ → UI 3 volets
/serial → SSE flux UART0
/api/* → proxy vers webserver ESP32 (port 10080)
```
## Prérequis
- Docker + Docker Compose
- Firmware compilé avec PlatformIO : `pio run`
## Lancement
### Option 1 — Serveur de simulation (recommandé)
Sert les vrais fichiers `data/` + simule tous les `/api/*` en mémoire.
L'état des relais, règles et config sleep sont modifiables via l'interface.
L'historique s'alimente toutes les 5 secondes (= 5 min en temps réel).
```bash
# Sans Docker (Python 3 requis) :
cd emulator && python3 sim.py
# Avec Docker :
cd emulator && docker compose up sim
```
Accès : **http://localhost:8087**
---
### Option 2 — Émulateur QEMU (boot séquence + terminal série)
Exécute le vrai binaire compilé. Montre le boot ESP32 et les logs Serial.
Le firmware crashe au démarrage de WiFi (hardware non émulé) — normal.
```bash
# 1. Compiler le firmware
pio run && pio run -t buildfs
# 2. Copier les binaires
cp .pio/build/kc868_a2/*.bin emulator/firmware/
# 3. Démarrer
cd emulator && docker compose up --build emulator
```
Accès :
- **Interface de débogage** : http://localhost:8888
- **WebServer ESP32** : http://localhost:10080 (si WiFi démarre)
## Mise à jour de la version QEMU
Si le téléchargement échoue, vérifier la dernière version disponible sur :
https://github.com/espressif/qemu/releases
Modifier `QEMU_TAG` et `QEMU_ARCHIVE` dans le `Dockerfile`.
## Correctif registres WiFi (LoadStorePIFAddrError)
Le QEMU Espressif standard n'émule pas les registres matériels WiFi modem
(`0x60033C00`). Sans correctif, le firmware crash en boucle avec
`LoadStorePIFAddrError` dès l'initialisation WiFi.
**Solution** : le `Dockerfile` utilise un build multi-étapes :
1. **`rom-extractor`** — télécharge le binaire pré-compilé, extrait les ROM blobs ESP32 (fichiers binaires propriétaires)
2. **`qemu-builder`** — clone le source Espressif QEMU, applique `wifi_stub_patch.py`, compile uniquement la cible xtensa
3. **Image finale** — ROM blobs de l'étape 1 + binaire patché de l'étape 2
`wifi_stub_patch.py` injecte un appel `create_unimplemented_device()` dans
`hw/xtensa/esp32.c` qui mappe silencieusement la plage `0x60033C000x60043BFF`
(64 Ko, WiFi MAC + baseband) : toutes les lectures retournent 0, les écritures
sont ignorées, plus de fault CPU.
> **Note** : le build initial prend 1530 min (compilation QEMU). Docker met
> les layers en cache — les rebuilds suivants sont instantanés.
## Limites connues
| Fonctionnalité | État |
|---|---|
| WiFi AP mode (softAP) | Registres stubés (pas de WiFi réel) — le firmware démarre |
| Webserver ESP32 (port 80) | Accessible via hostfwd → port 10080 si WiFi s'initialise |
| Modbus RS485 | Émulé par `modbus_stub.py` (données sinusoïdales) |
| GPIO physiques (DI1/DI2) | Non émulés — toujours à 0 |
| Deep sleep | Non supporté dans QEMU |
| OTA | Non testé |
## Modbus simulé
Le stub `modbus_stub.py` répond aux lectures FC04 des registres Epever Tracer 4210N :
| Registre | Valeur simulée |
|---|---|
| 0x3100 PV tension | ~18.72 V (variation sinusoïdale) |
| 0x3101 PV courant | ~4.20 A |
| 0x3104 Batterie | ~13.45 V |
| 0x310E Load | 26.80 W |
| 0x311A SOC | 75 % |
| 0x3200 Statut | Float charge |
| 0x200C Jour/Nuit | Jour |
## Arrêt
```bash
docker compose down
```
Pour quitter la console QEMU (dans le terminal) : `Ctrl+A` puis `X`.
+32
View File
@@ -0,0 +1,32 @@
services:
# --- Simulation web (recommandé) ---
# Sert les vrais fichiers data/ + simule tous les /api/* en mémoire
# Lancement : docker compose up sim
sim:
image: python:3.11-slim
working_dir: /emulator
command: python3 sim.py
ports:
- "8087:8080"
volumes:
- .:/emulator:ro
- ../data:/data:ro
environment:
- SIM_PORT=8080
# Lancement standalone sans Docker : cd emulator && python3 sim.py
# --- Émulateur QEMU (boot séquence + terminal série) ---
# Lancement : docker compose up emulator
emulator:
build: .
ports:
- "10080:10080"
- "8888:8888"
volumes:
# Binaires compilés (copier avec : cp ../.pio/build/kc868_a2/*.bin firmware/)
- ./firmware:/firmware:ro
environment:
- FIRMWARE_DIR=/firmware
stdin_open: true
tty: true
+52
View File
@@ -0,0 +1,52 @@
#!/usr/bin/env bash
set -euo pipefail
FIRM=${FIRMWARE_DIR:-/firmware}
echo "=== Préparation image flash ==="
MERGE_ARGS=(
--chip esp32 merge_bin
-o /tmp/flash.bin
--flash_mode dio
--flash_freq 40m
--flash_size 4MB
0x1000 "$FIRM/bootloader.bin"
0x8000 "$FIRM/partitions.bin"
0x10000 "$FIRM/firmware.bin"
)
# Inclure LittleFS si disponible (lancer "pio run -t buildfs" pour le générer)
if [ -f "$FIRM/littlefs.bin" ]; then
echo " → LittleFS inclus (0x290000)"
MERGE_ARGS+=(0x290000 "$FIRM/littlefs.bin")
else
echo " ⚠ littlefs.bin absent — webserver sans fichiers statiques"
echo " Lancer : pio run -t buildfs"
fi
MERGE_ARGS+=(--fill-flash-size 4MB)
python3 -m esptool "${MERGE_ARGS[@]}"
echo " flash.bin prêt ($(du -sh /tmp/flash.bin | cut -f1))"
echo "=== Démarrage stub Modbus RTU ==="
python3 /emulator/modbus_stub.py &
echo "=== Démarrage serveur UI ==="
python3 /emulator/server.py &
echo "=== Lancement QEMU ESP32 ==="
echo " WebServer ESP32 → http://localhost:10080"
echo " UI debug → http://localhost:8888"
echo ""
# UART0 → stdio (Serial debug)
# UART1 → null
# UART2 → TCP server port 1235 (stub Modbus se connecte ici)
exec qemu-system-xtensa \
-nographic \
-M esp32 \
-drive file=/tmp/flash.bin,if=mtd,format=raw \
-nic user,model=open_eth,hostfwd=tcp::10080-:80 \
-serial mon:stdio \
-serial null \
-serial tcp::1235,server,nowait \
2>&1 | tee /tmp/serial.log
+138
View File
@@ -0,0 +1,138 @@
#!/usr/bin/env python3
"""
Simulateur Modbus RTU émule les registres de l'Epever Tracer 4210N.
Se connecte au serveur TCP exposé par QEMU pour UART2 (port 1235).
Répond aux requêtes FC04 (Read Input Registers) avec des valeurs simulées
qui varient dans le temps pour reproduire un comportement réaliste.
"""
import math
import socket
import time
QEMU_HOST = '127.0.0.1'
QEMU_PORT = 1235
RECONNECT_DELAY = 2 # secondes
# ---------------------------------------------------------------------------
# CRC16 Modbus
# ---------------------------------------------------------------------------
def crc16(data: bytes) -> int:
crc = 0xFFFF
for b in data:
crc ^= b
for _ in range(8):
crc = (crc >> 1) ^ 0xA001 if crc & 1 else crc >> 1
return crc
# ---------------------------------------------------------------------------
# Registres simulés Epever Tracer 4210N
# ---------------------------------------------------------------------------
def get_reg(addr: int) -> int:
"""Retourne la valeur simulée d'un registre, avec variation sinusoïdale."""
t = time.time()
# --- PV (0x3100..0x3107) ---
if addr == 0x3100: # Tension PV × 100
return max(0, 1872 + int(50 * math.sin(t / 60)))
if addr == 0x3101: # Courant PV × 100
return max(0, 420 + int(30 * abs(math.sin(t / 45))))
if addr == 0x3104: # Tension batterie × 100
return 1345 + int(20 * math.sin(t / 120))
# --- Load + température batterie (0x310C..0x3110) ---
if addr == 0x310C: return 1340 # Tension load × 100 = 13.40 V
if addr == 0x310D: return 200 # Courant load × 100 = 2.00 A
if addr == 0x310E: return 2680 # Puissance load × 100 = 26.80 W
if addr == 0x3110: return 2500 # Temp. batterie × 100 = 25.00 °C
# --- SOC (0x311A) ---
if addr == 0x311A: return 75 # SOC = 75 %
# --- Statut charge (0x3200) ---
if addr == 0x3200: return 0x0004 # bits 3-2 = 01 → charge float
# --- Énergie (0x3300..0x3307) — registres 32 bits (Low/High) ---
if addr == 0x3300: return 150 # Prod. aujourd'hui low = 1.50 kWh
if addr == 0x3301: return 0
if addr == 0x3302: return 12000 # Prod. totale low = 120.00 kWh
if addr == 0x3303: return 0
if addr == 0x3304: return 80 # Conso. aujourd'hui low = 0.80 kWh
if addr == 0x3305: return 0
if addr == 0x3306: return 8500 # Conso. totale low = 85.00 kWh
if addr == 0x3307: return 0
# --- Jour/Nuit (0x200C) ---
if addr == 0x200C: return 0x0008 # Bit 3 = 1 → charge active (jour)
return 0
# ---------------------------------------------------------------------------
# Construction de la réponse FC04
# ---------------------------------------------------------------------------
def build_fc04_response(slave: int, start: int, count: int) -> bytes:
values = [get_reg(start + i) for i in range(count)]
payload = bytes([slave, 0x04, count * 2])
payload += b''.join(v.to_bytes(2, 'big') for v in values)
crc = crc16(payload)
return payload + bytes([crc & 0xFF, crc >> 8])
# ---------------------------------------------------------------------------
# Gestion d'une connexion QEMU
# ---------------------------------------------------------------------------
def handle(sock: socket.socket) -> None:
print('[Modbus] ✓ Connecté à QEMU UART2', flush=True)
buf = bytearray()
try:
while True:
chunk = sock.recv(256)
if not chunk:
break
buf.extend(chunk)
# Trame RTU FC04 : exactement 8 octets
while len(buf) >= 8:
slave, fc = buf[0], buf[1]
start = (buf[2] << 8) | buf[3]
count = (buf[4] << 8) | buf[5]
crc_rx = buf[6] | (buf[7] << 8)
if crc16(bytes(buf[:6])) == crc_rx and fc == 0x04:
resp = build_fc04_response(slave, start, count)
sock.sendall(resp)
print(f'[Modbus] FC04 0x{start:04X} × {count} reg → envoyé', flush=True)
del buf[:8]
else:
# Octet parasite — décaler
del buf[:1]
except (ConnectionResetError, BrokenPipeError, OSError):
pass
print('[Modbus] Déconnecté', flush=True)
# ---------------------------------------------------------------------------
# Boucle principale : reconnexion automatique
# ---------------------------------------------------------------------------
def main() -> None:
print(f'[Modbus] Attente de QEMU UART2 sur tcp://{QEMU_HOST}:{QEMU_PORT}', flush=True)
while True:
try:
with socket.create_connection((QEMU_HOST, QEMU_PORT), timeout=60) as sock:
handle(sock)
except (ConnectionRefusedError, OSError) as exc:
print(f'[Modbus] {exc} — retry dans {RECONNECT_DELAY}s', flush=True)
time.sleep(RECONNECT_DELAY)
if __name__ == '__main__':
main()
+111
View File
@@ -0,0 +1,111 @@
#!/usr/bin/env python3
"""
Serveur HTTP pour l'interface de débogage 3 volets.
Port 8888 :
GET / page HTML 3 volets
GET /serial SSE (Server-Sent Events) du port série QEMU
GET /api/* proxy transparent vers le webserver ESP32 (port 10080)
POST /api/* idem
"""
import http.server
import socketserver
import time
import urllib.request
import urllib.error
from urllib.parse import urlparse
ESP_URL = 'http://127.0.0.1:10080'
SERIAL_LOG = '/tmp/serial.log'
PORT = 8888
class Handler(http.server.BaseHTTPRequestHandler):
def do_GET(self):
if self.path in ('/', '/index.html'):
self._serve_file('/emulator/ui/index.html', 'text/html; charset=utf-8')
elif self.path == '/serial':
self._sse_serial()
elif self.path.startswith('/api/'):
self._proxy('GET')
else:
self.send_error(404)
def do_POST(self):
if self.path.startswith('/api/'):
self._proxy('POST')
else:
self.send_error(404)
# ------------------------------------------------------------------
def _serve_file(self, path, content_type):
try:
with open(path, 'rb') as f:
data = f.read()
self.send_response(200)
self.send_header('Content-Type', content_type)
self.send_header('Content-Length', str(len(data)))
self.end_headers()
self.wfile.write(data)
except FileNotFoundError:
self.send_error(404)
def _sse_serial(self):
self.send_response(200)
self.send_header('Content-Type', 'text/event-stream')
self.send_header('Cache-Control', 'no-cache')
self.send_header('Access-Control-Allow-Origin', '*')
self.end_headers()
try:
# Ouvrir le fichier log et se positionner à la fin
with open(SERIAL_LOG, 'r', errors='replace') as f:
f.seek(0, 2)
while True:
line = f.readline()
if line:
msg = line.rstrip().replace('\n', ' ')
self.wfile.write(f'data: {msg}\n\n'.encode())
self.wfile.flush()
else:
time.sleep(0.1)
except (BrokenPipeError, ConnectionResetError):
pass
except FileNotFoundError:
# Log pas encore créé — attendre
time.sleep(1)
def _proxy(self, method):
target = ESP_URL + self.path
try:
length = int(self.headers.get('Content-Length', 0))
body = self.rfile.read(length) if length else None
req = urllib.request.Request(target, data=body, method=method)
if body:
req.add_header('Content-Type', self.headers.get('Content-Type', 'application/json'))
with urllib.request.urlopen(req, timeout=3) as resp:
data = resp.read()
self.send_response(resp.status)
self.send_header('Content-Type', resp.headers.get('Content-Type', 'application/json'))
self.send_header('Content-Length', str(len(data)))
self.send_header('Access-Control-Allow-Origin', '*')
self.end_headers()
self.wfile.write(data)
except urllib.error.URLError:
self.send_response(503)
self.send_header('Content-Type', 'application/json')
self.end_headers()
self.wfile.write(b'{"error":"ESP32 hors ligne"}')
def log_message(self, *_):
pass # supprimer les logs d'accès
if __name__ == '__main__':
with socketserver.ThreadingTCPServer(('', PORT), Handler) as httpd:
httpd.allow_reuse_address = True
print(f'[UI] Interface de débogage sur http://0.0.0.0:{PORT}', flush=True)
httpd.serve_forever()
+246
View File
@@ -0,0 +1,246 @@
#!/usr/bin/env python3
"""
Serveur de simulation KC868-A2 port 8080.
Sert les fichiers statiques depuis ../data/ et implémente tous les
endpoints /api/* avec un état en mémoire qui varie dans le temps.
Lancement : python3 sim.py
Accès : http://localhost:8080
"""
import http.server
import json
import math
import os
import socketserver
import threading
import time
from urllib.parse import urlparse, parse_qs
# Chemin vers les fichiers web du projet
DATA_DIR = os.path.join(os.path.dirname(__file__), '..', 'data')
PORT = int(os.environ.get('SIM_PORT', 8080))
MIME = {
'.html': 'text/html; charset=utf-8',
'.js': 'application/javascript',
'.css': 'text/css',
'.json': 'application/json',
'.ico': 'image/x-icon',
}
# ---------------------------------------------------------------------------
# État simulé — modifiable via les endpoints POST
# ---------------------------------------------------------------------------
_lock = threading.Lock()
state = {
'pv': 18.72, 'pvCurrent': 4.20,
'battery': 13.45, 'batSOC': 75,
'batTemperature': 25.0, 'batStatut': 1,
'batSousVoltage': False, 'batSurVoltage': False,
'loadVoltage': 13.40, 'loadCurrent': 2.00, 'loadPower': 26.80,
'energieGenJour': 1.50, 'energieGenTotal': 120.00,
'energieConJour': 0.80, 'energieConTotal': 85.00,
'sun': True, 'relay1': False, 'relay2': False,
'di1': False, 'di2': False,
'autoMode': True, 'rs485_ok': True, 'last_update': 0,
}
rules = []
next_id = [1]
sleep_cfg = {'actif': False, 'intervalle': 600, 'seuil': 2.0}
# Historique circulaire (échantillonnage toutes les 5s en simulation)
history = {'b': [], 'p': [], 'l': [], 's': []}
MAX_HIST = 288
def _update():
"""Mise à jour périodique de l'état et de l'historique."""
while True:
t = time.time()
with _lock:
state['pv'] = round(18.72 + 0.8 * math.sin(t / 60), 2)
state['pvCurrent'] = round(max(0, 4.20 + 0.4 * math.sin(t / 45)), 2)
state['battery'] = round(13.45 + 0.3 * math.sin(t / 120), 2)
state['loadPower'] = round(max(0, 26.80 + 3.0 * math.sin(t / 30)), 1)
state['batSOC'] = min(100, max(0, int(75 + 5 * math.sin(t / 180))))
state['sun'] = (int(t / 30) % 2) == 0 # alterne jour/nuit toutes les 30s
state['rs485_ok'] = True
state['last_update'] = int(t * 1000)
# Historique — 1 point toutes les 5s (= 5 min en temps réel)
h = history
if len(h['b']) >= MAX_HIST:
for k in h: h[k].pop(0)
h['b'].append(round(state['battery'], 2))
h['p'].append(round(state['pv'], 2))
h['l'].append(round(state['loadPower'], 1))
h['s'].append(state['batSOC'])
time.sleep(5)
threading.Thread(target=_update, daemon=True).start()
# ---------------------------------------------------------------------------
# Handler HTTP
# ---------------------------------------------------------------------------
class Handler(http.server.BaseHTTPRequestHandler):
def do_OPTIONS(self):
self._cors(200)
def do_GET(self):
p = urlparse(self.path)
if p.path == '/api/state':
with _lock: self._json(state)
elif p.path == '/api/rules':
with _lock: self._json(rules)
elif p.path == '/api/sleep':
with _lock: self._json(sleep_cfg)
elif p.path == '/api/history':
with _lock:
out = {'n': len(history['b'])}
out.update(history)
self._json(out)
else:
self._static(p.path)
def do_POST(self):
p = urlparse(self.path)
qs = parse_qs(p.query)
body = self._read_body()
# --- Relais ---
if p.path.startswith('/api/relay/'):
parts = p.path.split('/') # ['','api','relay','1','on']
n, cmd = int(parts[3]), parts[4]
key = f'relay{n}'
with _lock:
state[key] = (cmd == 'on')
self._json({'ok': True})
# --- Mode ---
elif p.path == '/api/mode/auto':
with _lock: state['autoMode'] = True
self._json({'ok': True})
elif p.path == '/api/mode/manuel':
with _lock: state['autoMode'] = False
self._json({'ok': True})
# --- Règles ---
elif p.path == '/api/rules' and body:
try:
r = json.loads(body)
with _lock:
r['id'] = next_id[0]; next_id[0] += 1
rules.append(r)
self._json({'ok': True}, 201)
except Exception:
self._json({'ok': False}, 400)
elif p.path == '/api/rules/toggle':
rid = int(qs.get('id', [0])[0])
with _lock:
found = next((r for r in rules if r['id'] == rid), None)
if found: found['enabled'] = not found['enabled']
self._json({'ok': bool(found)}, 200 if found else 404)
elif p.path == '/api/rules/delete':
rid = int(qs.get('id', [0])[0])
with _lock:
before = len(rules)
rules[:] = [r for r in rules if r['id'] != rid]
ok = len(rules) < before
self._json({'ok': ok}, 200 if ok else 404)
# --- Sleep ---
elif p.path == '/api/sleep' and body:
try:
cfg = json.loads(body)
with _lock:
sleep_cfg.update({
'actif': bool(cfg.get('actif', sleep_cfg['actif'])),
'intervalle': int(cfg.get('intervalle', sleep_cfg['intervalle'])),
'seuil': float(cfg.get('seuil', sleep_cfg['seuil'])),
})
self._json({'ok': True})
except Exception:
self._json({'ok': False}, 400)
else:
self.send_error(404)
# ------------------------------------------------------------------
def _static(self, path):
if path in ('', '/'):
path = '/index.html'
filepath = os.path.normpath(os.path.join(DATA_DIR, path.lstrip('/')))
# Sécurité : rester dans DATA_DIR
if not filepath.startswith(os.path.realpath(DATA_DIR)):
self.send_error(403); return
if not os.path.isfile(filepath):
self.send_error(404); return
ext = os.path.splitext(filepath)[1]
with open(filepath, 'rb') as f:
data = f.read()
self.send_response(200)
self.send_header('Content-Type', MIME.get(ext, 'application/octet-stream'))
self.send_header('Content-Length', str(len(data)))
self._cors_headers()
self.end_headers()
self.wfile.write(data)
def _json(self, obj, code=200):
data = json.dumps(obj).encode()
self.send_response(code)
self.send_header('Content-Type', 'application/json')
self.send_header('Content-Length', str(len(data)))
self._cors_headers()
self.end_headers()
self.wfile.write(data)
def _cors(self, code):
self.send_response(code)
self._cors_headers()
self.end_headers()
def _cors_headers(self):
self.send_header('Access-Control-Allow-Origin', '*')
self.send_header('Access-Control-Allow-Methods', 'GET, POST, OPTIONS')
self.send_header('Access-Control-Allow-Headers', 'Content-Type')
def _read_body(self):
length = int(self.headers.get('Content-Length', 0))
return self.rfile.read(length).decode() if length else ''
def log_message(self, fmt, *args):
print(f'[Sim] {self.address_string()} {fmt % args}', flush=True)
# ---------------------------------------------------------------------------
if __name__ == '__main__':
data_real = os.path.realpath(DATA_DIR)
if not os.path.isdir(data_real):
print(f'[Sim] ERREUR : dossier data introuvable : {data_real}')
raise SystemExit(1)
with socketserver.ThreadingTCPServer(('', PORT), Handler) as httpd:
httpd.allow_reuse_address = True
print(f'[Sim] Serveur de simulation sur http://localhost:{PORT}')
print(f'[Sim] Fichiers web depuis : {data_real}')
print(f'[Sim] Historique : 1 point / 5s (288 pts max = ~24 min simulées)')
httpd.serve_forever()
+332
View File
@@ -0,0 +1,332 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<title>KC868-A2 — Émulateur QEMU</title>
<style>
:root {
--bg: #0d1117;
--surface: #161b22;
--border: #30363d;
--accent: #e94560;
--vert: #00b894;
--rouge: #d63031;
--jaune: #fdcb6e;
--bleu: #74b9ff;
--texte: #e6edf3;
--muted: #8b949e;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
background: var(--bg);
color: var(--texte);
font-family: 'Segoe UI', system-ui, sans-serif;
display: flex;
flex-direction: column;
height: 100vh;
overflow: hidden;
}
/* === En-tête === */
header {
background: var(--surface);
border-bottom: 1px solid var(--border);
padding: 0.5rem 1rem;
display: flex;
align-items: center;
gap: 1rem;
font-size: 0.85rem;
flex-shrink: 0;
}
header h1 { font-size: 0.95rem; font-weight: 600; }
.badge {
padding: 0.15rem 0.6rem;
border-radius: 999px;
font-size: 0.7rem;
font-weight: 700;
}
.badge-ok { background: var(--vert); color: #000; }
.badge-err { background: var(--rouge); color: #fff; }
.badge-warn{ background: var(--jaune); color: #000; }
.spacer { flex: 1; }
/* === Layout 3 volets === */
.layout {
display: flex;
flex: 1;
overflow: hidden;
}
/* Volet gauche — état GPIO */
.panel-gpio {
width: 220px;
flex-shrink: 0;
background: var(--surface);
border-right: 1px solid var(--border);
overflow-y: auto;
padding: 0.75rem;
}
.section {
font-size: 0.65rem;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--muted);
margin: 0.75rem 0 0.4rem;
}
.section:first-child { margin-top: 0; }
.gpio-row {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.35rem;
font-size: 0.8rem;
}
.gpio-label { color: var(--muted); }
.gpio-val { font-weight: 600; font-family: monospace; }
.on { color: var(--vert); }
.off { color: var(--muted); }
.err { color: var(--rouge); }
.num { color: var(--bleu); }
/* Volet droit — webserver + serial */
.panel-right {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
iframe {
flex: 1;
border: none;
background: #1a1a2e;
}
/* Terminal série */
.serial-bar {
height: 36px;
background: var(--surface);
border-top: 1px solid var(--border);
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
padding: 0 0.75rem;
font-size: 0.7rem;
color: var(--muted);
flex-shrink: 0;
gap: 0.5rem;
cursor: pointer;
user-select: none;
}
.serial-bar:hover { color: var(--texte); }
.terminal {
height: 180px;
background: #0a0c10;
overflow-y: auto;
padding: 0.4rem 0.75rem;
font-family: 'Consolas', 'Monaco', monospace;
font-size: 0.72rem;
flex-shrink: 0;
transition: height 0.2s;
}
.terminal.collapsed { height: 0; padding: 0; }
.log-line { color: #b2bec3; white-space: pre-wrap; line-height: 1.45; }
.log-line.warn { color: var(--jaune); }
.log-line.err { color: var(--rouge); }
.log-line.ok { color: var(--vert); }
</style>
</head>
<body>
<header>
<h1>⚡ KC868-A2 — Émulateur QEMU ESP32</h1>
<span id="badge-esp" class="badge badge-err">ESP32 démarrage…</span>
<span id="badge-mb" class="badge badge-err">Modbus --</span>
<span class="spacer"></span>
<span style="color:var(--muted);font-size:0.7rem">
WebServer : <a href="http://localhost:10080" target="_blank"
style="color:var(--bleu)">localhost:10080</a>
</span>
</header>
<div class="layout">
<!-- Volet gauche : état système -->
<div class="panel-gpio">
<div class="section">RS485 / Modbus</div>
<div class="gpio-row">
<span class="gpio-label">État</span>
<span class="gpio-val" id="g-rs485">--</span>
</div>
<div class="section">Relais</div>
<div class="gpio-row">
<span class="gpio-label">GPIO15 Relay 1</span>
<span class="gpio-val" id="g-r1">--</span>
</div>
<div class="gpio-row">
<span class="gpio-label">GPIO2 Relay 2</span>
<span class="gpio-val" id="g-r2">--</span>
</div>
<div class="section">Entrées numériques</div>
<div class="gpio-row">
<span class="gpio-label">GPIO36 DI1</span>
<span class="gpio-val" id="g-di1">--</span>
</div>
<div class="gpio-row">
<span class="gpio-label">GPIO39 DI2</span>
<span class="gpio-val" id="g-di2">--</span>
</div>
<div class="section">Mode</div>
<div class="gpio-row">
<span class="gpio-label">Contrôle</span>
<span class="gpio-val" id="g-mode">--</span>
</div>
<div class="section">Solaire</div>
<div class="gpio-row">
<span class="gpio-label">Ensoleillement</span>
<span class="gpio-val" id="g-sun">--</span>
</div>
<div class="gpio-row">
<span class="gpio-label">Batterie</span>
<span class="gpio-val num" id="g-bat">--</span>
</div>
<div class="gpio-row">
<span class="gpio-label">Tension PV</span>
<span class="gpio-val num" id="g-pv">--</span>
</div>
<div class="gpio-row">
<span class="gpio-label">Courant PV</span>
<span class="gpio-val num" id="g-pvc">--</span>
</div>
<div class="gpio-row">
<span class="gpio-label">SOC</span>
<span class="gpio-val num" id="g-soc">--</span>
</div>
<div class="gpio-row">
<span class="gpio-label">Temp. bat.</span>
<span class="gpio-val num" id="g-temp">--</span>
</div>
<div class="gpio-row">
<span class="gpio-label">Statut charge</span>
<span class="gpio-val" id="g-stat">--</span>
</div>
<div class="section">Load</div>
<div class="gpio-row">
<span class="gpio-label">Puissance</span>
<span class="gpio-val num" id="g-load">--</span>
</div>
<div class="section">Énergie</div>
<div class="gpio-row">
<span class="gpio-label">Prod. jour</span>
<span class="gpio-val num" id="g-egenj">--</span>
</div>
<div class="gpio-row">
<span class="gpio-label">Conso. jour</span>
<span class="gpio-val num" id="g-econj">--</span>
</div>
</div>
<!-- Volet droit : iframe webserver + terminal série -->
<div class="panel-right">
<iframe id="esp-frame" src="http://localhost:10080" title="WebServer ESP32"></iframe>
<div class="serial-bar" onclick="toggleTerminal()">
<span>▼ Terminal série (UART0)</span>
<span id="serial-count" style="margin-left:auto;font-family:monospace">0 lignes</span>
</div>
<div class="terminal" id="terminal"></div>
</div>
</div>
<script>
'use strict';
// --- Polling état ESP32 ---
async function pollState() {
try {
const d = await (await fetch('/api/state', { signal: AbortSignal.timeout(2000) })).json();
badge('badge-esp', d.rs485_ok ? 'ESP32 OK' : 'ESP32 WiFi', d.rs485_ok ? 'ok' : 'warn');
badge('badge-mb', d.rs485_ok ? 'Modbus OK' : 'Modbus ERR', d.rs485_ok ? 'ok' : 'err');
gpio('g-rs485', d.rs485_ok ? '● OK' : '○ ERR', d.rs485_ok ? 'on' : 'err');
gpio('g-r1', d.relay1 ? '● ON' : '○ OFF', d.relay1 ? 'on' : 'off');
gpio('g-r2', d.relay2 ? '● ON' : '○ OFF', d.relay2 ? 'on' : 'off');
gpio('g-di1', d.di1 ? '● APP' : '○ REL', d.di1 ? 'on' : 'off');
gpio('g-di2', d.di2 ? '● APP' : '○ REL', d.di2 ? 'on' : 'off');
gpio('g-mode', d.autoMode ? 'Auto' : 'Manuel', 'num');
gpio('g-sun', d.sun ? '☀ Jour' : '🌙 Nuit', d.sun ? 'on' : 'off');
num('g-bat', d.battery.toFixed(2) + ' V');
num('g-pv', d.pv.toFixed(2) + ' V');
num('g-pvc', d.pvCurrent.toFixed(2) + ' A');
num('g-soc', d.batSOC + ' %');
num('g-temp', d.batTemperature.toFixed(1) + ' °C');
num('g-load', d.loadPower.toFixed(1) + ' W');
num('g-egenj',d.energieGenJour.toFixed(2) + ' kWh');
num('g-econj',d.energieConJour.toFixed(2) + ' kWh');
const statuts = ['Arrêt', 'Float', 'Boost', 'Égalisation'];
gpio('g-stat', statuts[d.batStatut] || '--', 'num');
} catch {
badge('badge-esp', 'ESP32 hors ligne', 'err');
gpio('g-rs485', '○ ERR', 'err');
}
}
function badge(id, txt, type) {
const el = document.getElementById(id);
el.textContent = txt;
el.className = 'badge badge-' + type;
}
function gpio(id, txt, cls) {
const el = document.getElementById(id);
el.textContent = txt;
el.className = 'gpio-val ' + cls;
}
function num(id, txt) {
const el = document.getElementById(id);
el.textContent = txt;
el.className = 'gpio-val num';
}
// --- Terminal série via SSE ---
let lineCount = 0;
const terminal = document.getElementById('terminal');
const evtSrc = new EventSource('/serial');
evtSrc.onmessage = e => {
const line = document.createElement('div');
line.className = 'log-line'
+ (e.data.match(/err|error|erreur|fail/i) ? ' err' : '')
+ (e.data.match(/warn|timeout|hors ligne/i) ? ' warn' : '')
+ (e.data.match(/ok|prêt|ready|démarr/i) ? ' ok' : '');
line.textContent = e.data;
terminal.appendChild(line);
lineCount++;
document.getElementById('serial-count').textContent = lineCount + ' lignes';
// Garder max 1000 lignes
while (terminal.children.length > 1000) terminal.removeChild(terminal.firstChild);
terminal.scrollTop = terminal.scrollHeight;
};
// --- Toggle terminal ---
function toggleTerminal() {
terminal.classList.toggle('collapsed');
}
// --- Démarrage ---
pollState();
setInterval(pollState, 3000);
</script>
</body>
</html>
+152
View File
@@ -0,0 +1,152 @@
#!/usr/bin/env python3
"""
Patch ESP32 QEMU source to fix WiFi/RF peripheral register stubs.
Two changes only no new files, no meson.build modifications:
1. hw/misc/unimp.c change unimp_read to return 0xFFFFFFFF instead of 0.
The ESP32 WiFi library busy-loops on hardware-ready bits. With the
default return-0 those loops never exit and the watchdog fires.
0xFFFF... sets all bits, so most active-high "ready/done" flags pass.
2. hw/xtensa/esp32.c add create_unimplemented_device() calls for all
unmapped WiFi/RF peripheral registers that cause LoadStorePIFAddrError.
"""
import re
import sys
ESP32_C = 'hw/xtensa/esp32.c'
UNIMP_C = 'hw/misc/unimp.c'
# ── Stubs to register ─────────────────────────────────────────────────────────
STUB_PREFIX = 'rfstub.' # unimp_read returns 0xFF for names starting with this
STUBS = [
# (device-name, base-addr, size)
# Prefix "rfstub." distinguishes our WiFi stubs from other unimplemented
# devices — unimp_read returns 0xFF only for that prefix.
# AHB bus ─────────────────────────────────────────────────────────────────
(f'{STUB_PREFIX}wifi_modem', 0x60033C00, 0x10000),
# Data-bus (0x3FF00000) ───────────────────────────────────────────────────
(f'{STUB_PREFIX}fe2', 0x3FF45000, 0x1000),
(f'{STUB_PREFIX}fe', 0x3FF46000, 0x1000), # crash @ 0x3FF460A0
(f'{STUB_PREFIX}bt_bb', 0x3FF51000, 0x2000), # <=0x2000 avoids I2C_EXT0
(f'{STUB_PREFIX}5c', 0x3FF5C000, 0x1000), # crash @ 0x3FF5C01C
(f'{STUB_PREFIX}5d', 0x3FF5D000, 0x1000),
(f'{STUB_PREFIX}nrx', 0x3FF5E000, 0x1000), # 0x3FF5F000=TG0 untouched
(f'{STUB_PREFIX}62', 0x3FF62000, 0x1000),
(f'{STUB_PREFIX}63', 0x3FF63000, 0x1000),
(f'{STUB_PREFIX}68', 0x3FF68000, 0x1000),
(f'{STUB_PREFIX}6a', 0x3FF6A000, 0x1000),
(f'{STUB_PREFIX}6b', 0x3FF6B000, 0x1000),
(f'{STUB_PREFIX}6c', 0x3FF6C000, 0x1000),
# 0x3FF6E000 = UART2 — leave for Modbus
]
# ─────────────────────────────────────────────────────────────────────────────
# Step 1 — patch unimp_read to return 0xFFFFFFFF
# ─────────────────────────────────────────────────────────────────────────────
def patch_unimp(path):
try:
with open(path) as f:
src = f.read()
except FileNotFoundError:
print(f' ERROR: {path} not found', file=sys.stderr)
sys.exit(1)
if '~(uint64_t)0' in src or '0xffffffff' in src.lower() and 'unimp_read' in src:
print(f' skip {path} — already patched')
return
# Replace "return 0;" inside unimp_read with a prefix-checked return:
# devices named "rfstub.*" return 0xFFFFFFFF, all others return 0.
replacement = (
'return strncmp(s->name, "rfstub.", 7) == 0 ? ~(uint64_t)0 : 0;'
)
new_src = re.sub(
r'(static uint64_t unimp_read\b[^}]+?\breturn\s+)0(;)',
lambda m: m.group(0).replace('return 0;', replacement),
src,
count=1,
flags=re.DOTALL,
)
# Also ensure <string.h> is included for strncmp
if '#include <string.h>' not in new_src and 'strncmp' in new_src:
new_src = new_src.replace(
'#include "qemu/osdep.h"\n',
'#include "qemu/osdep.h"\n#include <string.h>\n',
1,
)
if new_src == src:
print(f' WARNING: pattern not found in {path}, skipping', file=sys.stderr)
return
with open(path, 'w') as f:
f.write(new_src)
print(f' patched {path}: unimp_read returns 0xFFFF for rfstub.* only')
# ─────────────────────────────────────────────────────────────────────────────
# Step 2 — register stubs in ESP32 machine
# ─────────────────────────────────────────────────────────────────────────────
def patch_esp32(path):
try:
with open(path) as f:
src = f.read()
except FileNotFoundError:
print(f' ERROR: {path} not found', file=sys.stderr)
sys.exit(1)
# Ensure hw/misc/unimp.h is included
if 'hw/misc/unimp.h' not in src:
src = src.replace(
'#include "qemu/osdep.h"\n',
'#include "qemu/osdep.h"\n#include "hw/misc/unimp.h"\n',
1,
)
print(' + added #include "hw/misc/unimp.h"')
changed = False
for name, addr, size in STUBS:
hex_addr = f'0x{addr:08X}'
if hex_addr in src or hex_addr.lower() in src:
print(f' skip {name}: already present')
continue
stub_line = (
f' create_unimplemented_device("{name}", {hex_addr}, 0x{size:X});\n'
)
m = list(re.finditer(r'create_unimplemented_device\([^;]+;\n', src))
if m:
pos = m[-1].end()
src = src[:pos] + stub_line + src[pos:]
else:
pos = src.rfind('\n}')
src = src[:pos] + '\n' + stub_line + src[pos:]
print(f' + stub {name} @ {hex_addr}')
changed = True
if changed:
with open(path, 'w') as f:
f.write(src)
print(f' wrote {path}')
else:
print(f' no changes to {path}')
# ─────────────────────────────────────────────────────────────────────────────
if __name__ == '__main__':
print('Step 1: patch unimp_read to return 0xFFFFFFFF')
patch_unimp(UNIMP_C)
print('Step 2: register WiFi/RF stubs in ESP32 machine')
patch_esp32(ESP32_C)
print('Patch complete.')
+37
View File
@@ -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
+4
View File
@@ -0,0 +1,4 @@
#pragma once
void initBoutons();
void gererBoutons();
+33
View File
@@ -0,0 +1,33 @@
#pragma once
#include <IPAddress.h>
// --- WiFi point d'accès ---
#define WIFI_SSID "kc868-a2"
#define WIFI_PASSWORD "soleil12" // mot de passe WiFi AP
#define WIFI_IP IPAddress(192, 168, 4, 1)
#define WIFI_GATEWAY IPAddress(192, 168, 4, 1)
#define WIFI_SUBNET IPAddress(255, 255, 255, 0)
// --- GPIO ---
#define PIN_RELAY1 15
#define PIN_RELAY2 2 // pin de strapping boot — doit être HIGH au démarrage
#define PIN_RS485_TX 32
#define PIN_RS485_RX 35 // input only
#define PIN_DI1 36 // input only
#define PIN_DI2 39 // input only
// --- OTA ---
#define OTA_USER "admin"
#define OTA_PASSWORD "solar123"
// --- Modbus ---
#define MODBUS_ADRESSE 1 // adresse esclave Epever
#define MODBUS_BAUDRATE 115200 // baudrate principal de l'Epever
#define TIMEOUT_MODBUS 3000 // timeout réponse (ms) — doit être > délai interne lib (1s)
#define MODBUS_DEBUG_BOOT 1 // sonde RS485 détaillée au démarrage
#define MODBUS_DEBUG_RX_MAX 64 // octets max affichés en cas d'erreur
// --- Intervalles (ms) ---
#define INTERVALLE_MODBUS 5000
#define INTERVALLE_REGLES 1000
#define DEBOUNCE_BOUTON 50
+7
View File
@@ -0,0 +1,7 @@
#pragma once
#include <Arduino.h>
void debugLogLine(const char *message);
void debugLogf(const char *format, ...);
void getDebugLogJson(String &out);
void clearDebugLog();
+33
View File
@@ -0,0 +1,33 @@
#pragma once
#include <stdint.h>
#include <Arduino.h>
#include <ArduinoJson.h>
struct EpeverRegDef {
uint16_t reg;
const char *key;
float scale; // raw→float : 0.01 pour les tensions, 1.0 pour les entiers
float valMin;
float valMax;
bool writable;
const char *unit;
const char *label;
const char *aide;
};
extern const EpeverRegDef EPEVER_REGS[];
extern const uint8_t EPEVER_REGS_COUNT;
// Blocs de registres consécutifs
#define EPEVER_BLOC1_REG 0x9000u
#define EPEVER_BLOC1_QTY 14u
#define EPEVER_BLOC2_REG 0x906Bu
#define EPEVER_BLOC2_QTY 4u
void initConfigEpever();
bool lireConfigEpever();
bool ecrireConfigEpever(JsonObject obj, String &erreur);
bool sauvegarderConfigJson();
bool restaurerConfigJson(String &erreur);
void getConfigJson(String &out);
void getConfigSauvegardeJson(String &out);
+9
View File
@@ -0,0 +1,9 @@
#pragma once
#include <Arduino.h>
void initHistorique();
void gererHistorique();
void getHistoriqueJson(String &out); // lores : 30h, point toutes les 5 min
void getHistoriqueHiresJson(String &out); // hires : 4h, point toutes les 1 min
void getHistoriqueCsv(String &out); // export CSV lores pour téléchargement
void getHistoriqueStatusJson(String &out); // debug compteurs internes
+16
View File
@@ -0,0 +1,16 @@
#pragma once
#include <stdint.h>
void initModbus();
void gererModbus();
void setIntervallesModbus(uint32_t jour_ms, uint32_t nuit_ms);
void getIntervallesModbus(uint32_t &jour_ms, uint32_t &nuit_ms);
bool reglerHorlogeEpever(uint16_t annee, uint8_t mois, uint8_t jour,
uint8_t heure, uint8_t minute, uint8_t seconde);
// Accès Modbus partagé (pour epever_config.cpp)
bool isModbusBusy();
bool modbusAcquire();
void modbusRelease();
bool modbusLireHolding(uint16_t reg, uint16_t qty, uint16_t *dest, uint16_t timeoutMs);
bool modbusEcrireHolding(uint16_t reg, uint16_t qty, const uint16_t *vals, uint16_t timeoutMs);
+4
View File
@@ -0,0 +1,4 @@
#pragma once
void demarrerOTA();
void gererOTA();
+10
View File
@@ -0,0 +1,10 @@
#pragma once
#include <ArduinoJson.h>
void initRegles();
void gererRegles();
void reglesToJson(JsonArray arr);
bool ajouterRegle(JsonObject obj);
bool supprimerRegle(int id);
bool toggleRegle(int id);
+18
View File
@@ -0,0 +1,18 @@
#pragma once
#include <Arduino.h>
// Appelé au tout début de setup() — entre en deep sleep si réveil timer + nuit
void verifierEtDormirSiNuit();
// Appelé après montage de LittleFS
void chargerConfigSleep();
// Restaure les relais depuis la mémoire RTC après un réveil
void restaurerRelais();
// Appelé dans loop()
void gererSleep();
// API REST
void getSleepConfigJson(String &out);
bool setSleepConfig(bool actif, uint32_t intervalle, float seuil);
+61
View File
@@ -0,0 +1,61 @@
#pragma once
struct SystemState {
// --- PV ---
float pv = 0.0f; // Tension PV (V)
float pvCurrent = 0.0f; // Courant PV (A)
// --- Batterie ---
float battery = 0.0f; // Tension (V)
float batTemperature = 0.0f; // Température (°C)
uint8_t batSOC = 0; // Charge restante (%)
uint8_t batStatut = 0; // 0=arrêt 1=float 2=boost 3=égalisation
bool batSousVoltage = false;
bool batSurVoltage = false;
// --- Sortie de charge (load) ---
float loadVoltage = 0.0f; // Tension (V)
float loadCurrent = 0.0f; // Courant (A)
float loadPower = 0.0f; // Puissance (W)
// --- Énergie (kWh, calculées par l'Epever) ---
float energieGenJour = 0.0f; // Générée aujourd'hui
float energieGenTotal = 0.0f; // Générée total
float energieConJour = 0.0f; // Consommée aujourd'hui
float energieConTotal = 0.0f; // Consommée total
// --- Ensoleillement ---
bool sun = false; // true = jour
bool sunHistoryValid = false;
uint8_t sunHistoryCount = 0;
uint8_t sunHistoryHead = 0;
bool sunHistoryState[5] = {};
char sunHistoryTime[5][20] = {};
// --- Horloge interne Epever ---
bool epeverClockOk = false;
uint8_t epeverSecond = 0;
uint8_t epeverMinute = 0;
uint8_t epeverHour = 0;
uint8_t epeverDay = 0;
uint8_t epeverMonth = 0;
uint16_t epeverYear = 0;
bool espClockOk = false;
// --- Relais ---
bool relay1 = false;
bool relay2 = false;
// --- Boutons DI ---
bool di1 = false;
bool di2 = false;
// --- Mode ---
bool autoMode = true; // true = automatique, false = manuel
// --- Santé RS485 ---
bool rs485_ok = false;
unsigned long last_update = 0;
};
extern SystemState state;
+7
View File
@@ -0,0 +1,7 @@
#pragma once
#include <ESPAsyncWebServer.h>
extern AsyncWebServer server;
void demarrerWebserveur();
void restaurerRelaisNVS();
+11
View File
@@ -0,0 +1,11 @@
#pragma once
#include <Arduino.h>
void demarrerWifi();
void gererWifi(); // boucle principale (DNS + reconnexion STA)
void connecterWifiSTA(const char *ssid, const char *pass); // sauvegarde NVS + connexion
void deconnecterWifiSTA(); // efface NVS + déconnecte STA
void getWifiStatusJson(String &out); // statut AP + STA en JSON
void scannerReseauxJson(String &out); // scan synchrone → JSON
String getMdnsHostname(); // hostname mDNS courant
bool setMdnsHostname(const char *nom); // change hostname + sauvegarde NVS
+9
View File
@@ -0,0 +1,9 @@
#pragma once
#include <Arduino.h>
void initWireGuard();
void gererWireGuard();
void getWireGuardJson(String &out);
bool setWireGuardConfig(bool enabled, const char* privKey, const char* pubKey,
const char* psk, const char* endpoint, uint16_t port,
const char* localIP, uint16_t keepalive);
+46
View File
@@ -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 <Foo.h>
#include <Bar.h>
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
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 872 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 176 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 937 KiB

+270
View File
@@ -0,0 +1,270 @@
# Plan de développement — Configuration EPEVER
Basé sur l'analyse de `consigne_amelioration_epever_config_md.md` et du code existant.
---
## État actuel du projet (base de travail)
| Module | Fichier | État |
|--------|---------|------|
| Modbus RTU (lecture brute) | `modbus_epever.cpp` | ✅ Fonctionnel à 115 200 bauds |
| FC03 holding read | `lireHoldingBruts()` | ✅ Utilisé pour RTC |
| FC16 holding write | `ecrireHoldingMultiplesBrut()` | ✅ Utilisé pour RTC |
| Webserver REST | `webserver.cpp` | ✅ Pattern AsyncCallbackJsonWebHandler établi |
| Persistance LittleFS | `rules.cpp``/rules.json` | ✅ Patron JSON réutilisable |
| Persistance NVS | `relais`, `noms`, `modbus`, `sleep` | ✅ Patron Preferences établi |
| UI web (onglets) | `data/index.html` + `app.js` | ✅ Architecture multi-onglets existante |
**Infrastructure à réutiliser sans modification** : `lireHoldingBruts()`, `ecrireHoldingMultiplesBrut()`, `lectureEnCours`, `AsyncCallbackJsonWebHandler`, pattern LittleFS JSON.
---
## Registres Epever cibles (Holding FC03 / FC16)
Deux blocs consécutifs lisibles en une seule trame chacun :
### Bloc 1 — 0x9000 à 0x900D (14 registres, facteur ÷100 sauf 0x9000/9001)
| Registre | Clé JSON | Unité | Facteur | Min | Max | Écriture |
|----------|----------|-------|---------|-----|-----|---------|
| 0x9000 | `battery_type` | — | ×1 | 0 | 4 | ✅ (0=User,1=Sealed,2=GEL,3=Flooded,4=Li) |
| 0x9001 | `battery_capacity` | Ah | ×1 | 1 | 9999 | ✅ |
| 0x9002 | `temp_compensation` | mV/°C/2V | ×1 | -9 | 0 | ✅ |
| 0x9003 | `overvoltage_disconnect` | V | ÷100 | 13.0 | 16.0 | ✅ |
| 0x9004 | `charging_limit` | V | ÷100 | 13.0 | 15.5 | ✅ |
| 0x9005 | `overvoltage_reconnect` | V | ÷100 | 13.0 | 16.0 | ✅ |
| 0x9006 | `equalize_voltage` | V | ÷100 | 13.0 | 15.5 | ✅ |
| 0x9007 | `boost_voltage` | V | ÷100 | 13.5 | 15.0 | ✅ |
| 0x9008 | `float_voltage` | V | ÷100 | 13.0 | 14.5 | ✅ |
| 0x9009 | `boost_reconnect` | V | ÷100 | 11.0 | 13.5 | ✅ |
| 0x900A | `low_reconnect` | V | ÷100 | 10.0 | 13.5 | ✅ |
| 0x900B | `undervolt_warning` | V | ÷100 | 10.0 | 13.0 | ✅ |
| 0x900C | `low_disconnect` | V | ÷100 | 9.0 | 13.0 | ✅ |
| 0x900D | `discharge_limit` | V | ÷100 | 9.0 | 13.0 | ✅ |
### Bloc 2 — 0x906B à 0x906E (4 registres)
| Registre | Clé JSON | Unité | Facteur | Min | Max | Écriture |
|----------|----------|-------|---------|-----|-----|---------|
| 0x906B | `rated_voltage` | — | ×1 | 0 | 2 | ✅ (0=auto,1=12V,2=24V) |
| 0x906C | `equalize_duration` | min | ×1 | 0 | 180 | ✅ |
| 0x906D | `boost_duration` | min | ×1 | 10 | 180 | ✅ |
| 0x906E | `bat_discharge_soc` | % | ×1 | 20 | 100 | ✅ |
---
## Architecture cible — nouveaux fichiers uniquement
```
src/
epever_config.cpp ← NOUVEAU : logique lecture/écriture/persistance config
include/
epever_config.h ← NOUVEAU : struct EpeverRegDef, API publique
data/
epever_config.json ← NOUVEAU : sauvegarde locale LittleFS (généré à l'usage)
```
**Fichiers modifiés** (ajouts uniquement, pas de refactoring) :
| Fichier | Modification |
|---------|-------------|
| `src/webserver.cpp` | +6 routes API `/api/epever/config*` |
| `src/main.cpp` | +1 appel `initConfigEpever()` dans `setup()` |
| `src/modbus_epever.cpp` | +1 fonction `isModbusBusy()` pour exposer `lectureEnCours` |
| `include/modbus_epever.h` | +1 déclaration `isModbusBusy()` |
| `data/index.html` | +1 onglet "Config EPEVER" |
| `data/app.js` | +section JS onglet config |
| `data/style.css` | +styles mineurs onglet config |
**Fichiers non touchés** : `state.h`, `rules.cpp`, `historique.cpp`, `sleep.cpp`, `wifi_ap.cpp`, `buttons.cpp`, `ota.cpp`.
---
## Étapes de développement
### Étape 1 — Abstraction registres (backend)
Créer `include/epever_config.h` et `src/epever_config.cpp` :
```cpp
struct EpeverRegDef {
uint16_t reg;
const char *key;
float scale;
float valMin;
float valMax;
bool writable;
const char *unit;
const char *label;
};
```
Table statique des ~18 registres des deux blocs.
Pas d'effet sur le comportement du système — compilation et link uniquement.
**Test** : `pio run` — vérifier que la compilation passe.
---
### Étape 2 — Lecture config Epever (backend)
Dans `epever_config.cpp` :
```cpp
bool lireConfigEpever(); // lit les deux blocs, populate configCache
void getConfigJson(String &out); // sérialise + métadonnées (lastRead, rs485_ok, erreur)
```
Utilise `lireHoldingBruts()` existant. Même garde que `reglerHorlogeEpever()` :
```cpp
if (isModbusBusy()) return false;
```
Cache en RAM (`float configCache[18]` + `bool configValide`).
Dans `webserver.cpp` :
```
GET /api/epever/config → lireConfigEpever() + getConfigJson()
```
**Test** : appel depuis navigateur, vérifier JSON avec valeurs cohérentes de l'Epever.
---
### Étape 3 — Validation + écriture (backend)
Dans `epever_config.cpp` :
```cpp
bool validerConfig(JsonObject obj, String &erreur);
bool ecrireConfigEpever(JsonObject obj); // valide puis FC16 par bloc
```
Règles de validation :
- plage min/max par registre (table)
- cohérence inter-registres : `float_voltage < boost_voltage < equalize_voltage < overvoltage_disconnect`
- `low_disconnect < low_reconnect < undervolt_warning`
Écriture en deux passes (bloc 0x9000, puis bloc 0x906B) avec vérification lecture-retour.
Dans `webserver.cpp` :
```
POST /api/epever/config → body JSON → validerConfig() → ecrireConfigEpever()
```
Réponse : `{"ok": true}` ou `{"ok": false, "erreur": "Float > Boost"}`.
**Test** : modifier Float Voltage de 0.1V depuis Postman/curl, relire pour confirmer.
---
### Étape 4 — Sauvegarde LittleFS (backend)
Dans `epever_config.cpp` :
```cpp
bool sauvegarderConfigJson(); // écrit /epever_config.json depuis configCache
bool restaurerConfigJson(); // lit /epever_config.json → écriture Epever
bool exporterConfigJson(String &out);
bool importerConfigJson(const String &json);
```
Format fichier identique à l'API (même structure JSON).
Nouvelles routes :
```
GET /api/epever/config/saved → lire LittleFS
POST /api/epever/config/save → sauvegarderConfigJson()
POST /api/epever/config/restore → restaurerConfigJson() → ecrireConfigEpever()
```
**Test** : sauvegarder, modifier une valeur sur l'Epever, restaurer, relire.
---
### Étape 5 — Interface web (frontend)
Nouvel onglet "Config EPEVER" dans `index.html` + section dans `app.js`.
**Structure UI** :
```
┌─────────────────────────────────────────────────┐
│ [Lire depuis Epever] [Sauvegarder] [Restaurer]│
│ Statut : ✅ Synchronisé — 09:14:22 │
├───────────────────────┬─────────────────────────┤
│ 🔋 Batterie │ ⚡ Tensions de charge │
│ Type [Sealed ▼] │ Boost [14.4 V] │
│ Capacité [100 Ah] │ Float [13.6 V] │
│ Tension [auto ▼] │ Equalize [14.6 V] │
├───────────────────────┼─────────────────────────┤
│ ⏱ Timing │ 🌡 Température │
│ Durée boost [120 min] │ Compensation [-3 mV/°C] │
│ Durée égal. [60 min] │ │
└───────────────────────┴─────────────────────────┘
[✏️ Écrire vers Epever] [⬇ Exporter JSON] [⬆ Importer]
```
Comportements :
- **Lire** : GET `/api/epever/config`, remplit les champs
- **Écrire** : confirmation modale → POST `/api/epever/config`
- **Sauvegarder** : POST `/api/epever/config/save`
- **Restaurer** : confirmation modale → POST `/api/epever/config/restore`
- **Exporter** : GET `/api/epever/config/saved`, `Blob` + `<a download>`
- **Importer** : `<input type="file">` → POST `/api/epever/config`
Aide intégrée (novice) : chaque paramètre a un `title` HTML + icône `` avec explication en français courte.
Exemples :
> **Tension Float** : tension de maintien après charge complète. Une valeur trop haute fatigue la batterie ; trop basse ne la charge pas suffisamment. Typique : 13.513.8V pour plomb.
**Test** : vérifier sur mobile (WiFi AP), formulaire responsive, confirmation avant écriture.
---
### Étape 6 — Gestion des erreurs UI
- Champ en rouge si valeur hors plage (validation JS temps réel)
- Alerte si cohérence inter-registres violée avant même d'écrire
- Toast de succès/erreur après écriture (3s)
- Indicateur `rs485_ok` dans le status bar de l'onglet
- Affichage de la dernière erreur Modbus si `rs485_ok = false`
---
## Contraintes de non-régression
| Risque | Mitigation |
|--------|-----------|
| Conflit accès Serial2 | `isModbusBusy()` — même garde que `reglerHorlogeEpever()` |
| Saturation LittleFS | `/epever_config.json` ≈ 500 octets — négligeable |
| Timeout HTTP long | `lireHoldingBruts()` sur 2 blocs ≈ 200ms — acceptable |
| Écriture valeur dangereuse | Double validation : JS (UX) + C++ (sécurité) |
| Régression onglets existants | Ajouts dans app.js isolés dans une section dédiée |
**Dead code connu à ne pas toucher** : les callbacks `cbPV`, `cbLoad`, `cbSOC`, `cbStatus`, `cbEnergie`, `cbJourNuit` dans `modbus_epever.cpp` sont inactifs mais laissés en place pour éviter le risque de régression.
---
## Ordre d'implémentation recommandé
```
Étape 1 : EpeverRegDef + table registres (~1h) → pio run
Étape 2 : lireConfigEpever + GET /api/epever/config (~2h) → test JSON navigateur
Étape 3 : validation + POST /api/epever/config (~2h) → test curl
Étape 4 : LittleFS save/restore (~1h) → test navigateur
Étape 5 : UI onglet complet (~3h) → test mobile
Étape 6 : gestion erreurs UI + aide novice (~1h) → test cas d'erreur
```
Total estimé : **~10h** de développement incrémental, chaque étape testable indépendamment.
---
## Fonctionnalités futures (hors scope immédiat)
- Profils batterie prédéfinis : GEL, AGM, Sealed, Lithium (presets JSON)
- Rollback automatique si la tension batterie tombe anormalement après écriture
- Historique des modifications de config (date + delta)
- Synchronisation MQTT / Home Assistant
- Mode expert : affichage de tous les registres bruts
+32
View File
@@ -0,0 +1,32 @@
; PlatformIO Project Configuration File
; https://docs.platformio.org/page/projectconf.html
[common]
platform = espressif32
board = esp32dev
framework = arduino
monitor_speed = 115200
board_build.filesystem = littlefs
lib_deps =
bblanchon/ArduinoJson @ ^7.0.0
https://github.com/me-no-dev/AsyncTCP.git
https://github.com/me-no-dev/ESPAsyncWebServer.git
ayushsharma82/ElegantOTA @ ^3.1.0
emelianov/modbus-esp8266 @ ^4.1.0
; --- Cible physique KC868-A2 ---
[env:kc868_a2]
extends = common
build_flags = -D ELEGANTOTA_USE_ASYNC_WEBSERVER=1
lib_deps =
${common.lib_deps}
https://github.com/ciniml/WireGuard-ESP32-Arduino.git
; --- Build QEMU : WiFi/sleep désactivés, Modbus + règles actifs ---
; Compiler : pio run -e qemu
; Copier : cp .pio/build/qemu/firmware.bin emulator/firmware/
[env:qemu]
extends = common
build_flags =
-D ELEGANTOTA_USE_ASYNC_WEBSERVER=1
-D QEMU_BUILD=1
+509
View File
@@ -0,0 +1,509 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0">
<title>KC868 Solaire — Prévisualisation</title>
<style>
/* === Données simulées — pas de connexion à la carte requise === */
:root {
--bg: #1a1a2e;
--surface: #16213e;
--carte: #0f3460;
--accent: #e94560;
--vert: #00b894;
--rouge: #d63031;
--texte: #eaeaea;
--muted: #a0aec0;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
background: var(--bg);
color: var(--texte);
font-family: system-ui, -apple-system, sans-serif;
min-height: 100vh;
display: flex;
flex-direction: column;
max-width: 480px;
margin: 0 auto;
}
header {
background: var(--surface);
padding: 0.9rem 1rem;
display: flex;
align-items: center;
justify-content: space-between;
border-bottom: 1px solid var(--carte);
}
header h1 { font-size: 1.05rem; font-weight: 700; }
.badge {
padding: 0.2rem 0.7rem;
border-radius: 999px;
font-size: 0.7rem;
font-weight: 700;
letter-spacing: 0.03em;
}
.badge-ok { background: var(--vert); color: #000; }
.badge-err { background: var(--rouge); color: #fff; }
nav {
display: flex;
background: var(--surface);
border-bottom: 1px solid var(--carte);
overflow-x: auto;
}
.tab {
flex: 1;
min-width: 60px;
padding: 0.7rem 0.2rem;
background: none;
border: none;
border-bottom: 2px solid transparent;
color: var(--muted);
font-size: 0.8rem;
font-weight: 500;
cursor: pointer;
white-space: nowrap;
transition: color 0.15s, border-color 0.15s;
}
.tab.active {
color: var(--texte);
border-bottom-color: var(--accent);
}
main { flex: 1; padding: 1rem; }
.onglet { display: none; }
.onglet.actif { display: block; }
/* Dashboard */
.grille {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 0.75rem;
}
.carte {
background: var(--carte);
border-radius: 0.75rem;
padding: 1rem 0.75rem;
text-align: center;
}
.etiquette {
font-size: 0.7rem;
color: var(--muted);
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: 0.4rem;
}
.valeur { font-size: 1.4rem; font-weight: 700; }
.val-on { color: var(--vert); }
.val-off { color: var(--muted); }
/* Commandes */
.ligne-commande {
background: var(--surface);
border-radius: 0.75rem;
padding: 0.9rem 1rem;
margin-bottom: 0.75rem;
display: flex;
align-items: center;
gap: 0.6rem;
}
.label-cmd { flex: 1; font-weight: 500; font-size: 0.95rem; }
/* Boutons */
.btn {
padding: 0.45rem 1.1rem;
border: none;
border-radius: 0.5rem;
font-size: 0.85rem;
font-weight: 600;
cursor: pointer;
background: var(--carte);
color: var(--texte);
}
.btn-actif { background: var(--accent); color: #fff; }
.btn-vert { background: var(--vert); color: #000; }
.btn-rouge { background: var(--rouge); color: #fff; }
.btn-primaire {
display: inline-block;
text-decoration: none;
background: var(--accent);
color: #fff;
margin-top: 1.2rem;
padding: 0.6rem 1.5rem;
border-radius: 0.5rem;
font-weight: 600;
}
.btn-plein { display: block; width: 100%; margin-top: 0.75rem; text-align: center; }
.btn-sm { padding: 0.3rem 0.7rem; font-size: 0.75rem; }
/* Règles */
.regle-item {
background: var(--surface);
border-radius: 0.75rem;
padding: 0.75rem 1rem;
margin-bottom: 0.6rem;
display: flex;
align-items: center;
gap: 0.6rem;
}
.regle-desc { flex: 1; font-size: 0.85rem; line-height: 1.5; }
.regle-id { font-size: 0.7rem; color: var(--muted); }
.regle-desactivee { opacity: 0.45; }
.regle-form {
background: var(--surface);
border-radius: 0.75rem;
padding: 1rem;
margin-top: 1rem;
}
.form-titre { font-weight: 600; margin-bottom: 0.75rem; font-size: 0.9rem; }
.form-ligne { display: flex; align-items: center; margin-bottom: 0.5rem; gap: 0.5rem; }
.form-ligne label { width: 90px; font-size: 0.8rem; color: var(--muted); flex-shrink: 0; }
.form-ligne select,
.form-ligne input {
flex: 1;
background: var(--carte);
border: none;
border-radius: 0.4rem;
padding: 0.4rem 0.6rem;
color: var(--texte);
font-size: 0.85rem;
}
/* Firmware */
.firmware-bloc {
text-align: center;
padding: 2.5rem 1rem;
color: var(--muted);
line-height: 1.7;
}
.info-ota { margin-top: 0.5rem; font-size: 0.85rem; }
/* Historique */
.graphe-conteneur {
background: var(--surface);
border-radius: 0.75rem;
padding: 0.75rem;
margin-bottom: 0.75rem;
overflow: hidden;
}
.graphe-titre {
font-size: 0.75rem;
color: var(--muted);
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: 0.5rem;
}
canvas { display: block; width: 100%; height: auto; border-radius: 0.4rem; }
footer {
text-align: center;
padding: 0.45rem;
font-size: 0.7rem;
color: var(--muted);
background: var(--surface);
border-top: 1px solid var(--carte);
}
.preview-banner {
background: var(--accent);
color: #fff;
text-align: center;
font-size: 0.75rem;
padding: 0.3rem;
font-weight: 600;
letter-spacing: 0.05em;
}
</style>
</head>
<body>
<div class="preview-banner">PRÉVISUALISATION — données simulées</div>
<header>
<h1>⚡ Contrôleur Solaire</h1>
<span class="badge badge-ok">RS485 OK</span>
</header>
<nav>
<button class="tab active" onclick="afficherOnglet('dashboard', this)">Dashboard</button>
<button class="tab" onclick="afficherOnglet('commandes', this)">Commandes</button>
<button class="tab" onclick="afficherOnglet('regles', this)">Règles</button>
<button class="tab" onclick="afficherOnglet('config', this)">Config</button>
<button class="tab" onclick="afficherOnglet('historique', this)">Historique</button>
<button class="tab" onclick="afficherOnglet('firmware', this)">Firmware</button>
</nav>
<main>
<!-- Dashboard -->
<section id="dashboard" class="onglet actif">
<div class="grille">
<div class="carte">
<div class="etiquette">Batterie</div>
<div class="valeur">13.45 V</div>
</div>
<div class="carte">
<div class="etiquette">Tension PV</div>
<div class="valeur">18.72 V</div>
</div>
<div class="carte">
<div class="etiquette">Courant PV</div>
<div class="valeur">4.20 A</div>
</div>
<div class="carte">
<div class="etiquette">Ensoleillement</div>
<div class="valeur">☀ Jour</div>
</div>
<div class="carte">
<div class="etiquette">Relais 1</div>
<div class="valeur val-on">● ON</div>
</div>
<div class="carte">
<div class="etiquette">Relais 2</div>
<div class="valeur val-off">○ OFF</div>
</div>
<div class="carte">
<div class="etiquette">DI1 (mode)</div>
<div class="valeur val-off">○ Relâché</div>
</div>
<div class="carte">
<div class="etiquette">DI2 (relais)</div>
<div class="valeur val-off">○ Relâché</div>
</div>
</div>
</section>
<!-- Commandes -->
<section id="commandes" class="onglet">
<div class="ligne-commande">
<span class="label-cmd">Mode</span>
<button class="btn btn-actif">Auto</button>
<button class="btn">Manuel</button>
</div>
<div class="ligne-commande">
<span class="label-cmd">Relais 1</span>
<button class="btn btn-vert">ON</button>
<button class="btn btn-rouge">OFF</button>
</div>
<div class="ligne-commande">
<span class="label-cmd">Relais 2</span>
<button class="btn btn-vert">ON</button>
<button class="btn btn-rouge">OFF</button>
</div>
</section>
<!-- Règles -->
<section id="regles" class="onglet">
<div class="regle-item">
<div class="regle-desc">
<div class="regle-id">Règle #1</div>
☀ Jour + Bat ≥ 13V → Relais 1 <strong>ON</strong>
</div>
<button class="btn btn-sm">OFF</button>
<button class="btn btn-sm btn-rouge"></button>
</div>
<div class="regle-item regle-desactivee">
<div class="regle-desc">
<div class="regle-id">Règle #2 (désactivée)</div>
🌙 Nuit → Relais 2 <strong>OFF</strong> (délai 3600s)
</div>
<button class="btn btn-sm btn-actif">ON</button>
<button class="btn btn-sm btn-rouge"></button>
</div>
<div class="regle-form">
<div class="form-titre">Ajouter une règle</div>
<div class="form-ligne">
<label>Soleil</label>
<select>
<option>Ignoré</option>
<option selected>Jour</option>
<option>Nuit</option>
</select>
</div>
<div class="form-ligne">
<label>Batt. min (V)</label>
<input type="number" placeholder="0 = ignoré" value="13">
</div>
<div class="form-ligne">
<label>Batt. max (V)</label>
<input type="number" placeholder="0 = ignoré" value="0">
</div>
<div class="form-ligne">
<label>Relais</label>
<select><option>Relais 1</option><option>Relais 2</option></select>
</div>
<div class="form-ligne">
<label>Action</label>
<select><option>ON</option><option>OFF</option></select>
</div>
<div class="form-ligne">
<label>Délai (s)</label>
<input type="number" value="0">
</div>
<button class="btn btn-primaire btn-plein">Ajouter</button>
</div>
</section>
<!-- Config -->
<section id="config" class="onglet">
<div class="regle-form">
<div class="form-titre">Mode économie d'énergie (sleep)</div>
<div class="form-ligne">
<label>Activé</label>
<select><option>Non</option><option>Oui</option></select>
</div>
<div class="form-ligne">
<label>Réveil (min)</label>
<input type="number" value="10" min="1" max="120">
</div>
<div class="form-ligne">
<label>Seuil PV (V)</label>
<input type="number" value="2.0" step="0.5">
</div>
<button class="btn btn-primaire btn-plein">Enregistrer</button>
</div>
</section>
<!-- Historique -->
<section id="historique" class="onglet">
<div class="graphe-conteneur">
<div class="graphe-titre">Tension batterie (V)</div>
<canvas id="chart-battery" width="600" height="150"></canvas>
</div>
<div class="graphe-conteneur">
<div class="graphe-titre">Tension PV (V)</div>
<canvas id="chart-pv" width="600" height="150"></canvas>
</div>
<div class="graphe-conteneur">
<div class="graphe-titre">Puissance load (W)</div>
<canvas id="chart-load" width="600" height="150"></canvas>
</div>
<div class="graphe-conteneur">
<div class="graphe-titre">SOC batterie (%)</div>
<canvas id="chart-soc" width="600" height="150"></canvas>
</div>
</section>
<!-- Firmware -->
<section id="firmware" class="onglet">
<div class="firmware-bloc">
<p>Mise à jour du firmware via l'interface OTA.</p>
<p class="info-ota">Identifiants : <strong>admin / solar123</strong></p>
<a href="#" class="btn btn-primaire">Ouvrir l'interface OTA</a>
</div>
</section>
</main>
<footer>Mise à jour : 14:32:07</footer>
<script>
function afficherOnglet(nom, bouton) {
document.querySelectorAll('.onglet').forEach(s => s.classList.remove('actif'));
document.querySelectorAll('.tab').forEach(b => b.classList.remove('active'));
document.getElementById(nom).classList.add('actif');
bouton.classList.add('active');
if (nom === 'historique') afficherHistoriqueSimule();
}
// Simulation de 72 points (6h de données à 5 min)
function genSimule(base, amp, n) {
return Array.from({ length: n }, (_, i) =>
+(base + amp * Math.sin(i / n * Math.PI * 3) + (Math.random() - 0.5) * amp * 0.3).toFixed(2)
);
}
function afficherHistoriqueSimule() {
const n = 72;
const bat = genSimule(13.2, 0.6, n);
const pv = genSimule(14.0, 8.0, n);
const load = genSimule(25, 15.0, n);
const soc = genSimule(72, 8.0, n).map(v => Math.min(100, Math.max(0, Math.round(v))));
dessinerGraphe(document.getElementById('chart-battery'), bat, '#00b894', 'V');
dessinerGraphe(document.getElementById('chart-pv'), pv, '#e94560', 'V');
dessinerGraphe(document.getElementById('chart-load'), load, '#fdcb6e', 'W');
dessinerGraphe(document.getElementById('chart-soc'), soc, '#74b9ff', '%');
}
function dessinerGraphe(canvas, data, couleur, unite) {
const ctx = canvas.getContext('2d');
const W = canvas.width, H = canvas.height;
const pad = { top: 12, right: 8, bottom: 28, left: 38 };
const w = W - pad.left - pad.right;
const h = H - pad.top - pad.bottom;
ctx.clearRect(0, 0, W, H);
if (!data || data.length < 2) {
ctx.fillStyle = '#a0aec0';
ctx.font = '14px system-ui';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText('Pas encore de données (5 min)', W / 2, H / 2);
return;
}
const min = Math.min(...data);
const max = Math.max(...data);
const range = max - min || 1;
for (let i = 0; i <= 4; i++) {
const y = pad.top + (h / 4) * i;
ctx.strokeStyle = '#0f3460';
ctx.lineWidth = 1;
ctx.beginPath(); ctx.moveTo(pad.left, y); ctx.lineTo(pad.left + w, y); ctx.stroke();
const val = max - (range / 4) * i;
ctx.fillStyle = '#a0aec0';
ctx.font = '10px system-ui';
ctx.textAlign = 'right';
ctx.textBaseline = 'middle';
ctx.fillText(val.toFixed(1) + unite, pad.left - 4, y);
}
ctx.strokeStyle = couleur;
ctx.lineWidth = 2;
ctx.lineJoin = 'round';
ctx.beginPath();
data.forEach((v, i) => {
const x = pad.left + (i / (data.length - 1)) * w;
const y = pad.top + h - ((v - min) / range) * h;
i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
});
ctx.stroke();
ctx.lineTo(pad.left + w, pad.top + h);
ctx.lineTo(pad.left, pad.top + h);
ctx.closePath();
ctx.fillStyle = couleur + '22';
ctx.fill();
const now = Date.now();
const step = Math.max(1, Math.floor(data.length / 5));
ctx.fillStyle = '#a0aec0';
ctx.font = '10px system-ui';
ctx.textAlign = 'center';
ctx.textBaseline = 'alphabetic';
for (let i = 0; i < data.length; i += step) {
const x = pad.left + (i / (data.length - 1)) * w;
const t = new Date(now - (data.length - 1 - i) * 5 * 60 * 1000);
ctx.fillText(
t.toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' }),
x, H - 5
);
}
}
</script>
</body>
</html>
+49
View File
@@ -0,0 +1,49 @@
#include <Arduino.h>
#include "config.h"
#include "state.h"
// Suivi anti-rebond pour un bouton
struct Bouton {
uint8_t pin;
bool lectureRaw; // dernière lecture brute
bool etatConfirme; // état validé après anti-rebond
unsigned long tChangement;
};
static Bouton di1 = { PIN_DI1, HIGH, HIGH, 0 };
static Bouton di2 = { PIN_DI2, HIGH, HIGH, 0 };
// Retourne true une seule fois au moment où l'appui est confirmé (front descendant)
static bool detecterAppui(Bouton &b) {
bool lecture = digitalRead(b.pin);
if (lecture != b.lectureRaw) {
b.lectureRaw = lecture;
b.tChangement = millis();
}
if ((millis() - b.tChangement) >= DEBOUNCE_BOUTON && lecture != b.etatConfirme) {
b.etatConfirme = lecture;
return (b.etatConfirme == LOW); // LOW = contact fermé = appui
}
return false;
}
void initBoutons() {
// GPIO36 et GPIO39 sont input-only — pas de pull-up interne possible sur ESP32
pinMode(PIN_DI1, INPUT);
pinMode(PIN_DI2, INPUT);
Serial.println("Boutons DI1/DI2 initialisés");
}
void gererBoutons() {
bool appui1 = detecterAppui(di1);
bool appui2 = detecterAppui(di2);
if (appui1) Serial.println("[DI] DI1 appui détecté");
if (appui2) Serial.println("[DI] DI2 appui détecté");
// Mise à jour état pour l'interface web et les règles
state.di1 = (di1.etatConfirme == LOW);
state.di2 = (di2.etatConfirme == LOW);
}
+79
View File
@@ -0,0 +1,79 @@
#include <Arduino.h>
#include <stdarg.h>
#include "debug_log.h"
static const uint8_t NB_LIGNES = 80;
static const uint8_t TAILLE_LIGNE = 160;
static char lignes[NB_LIGNES][TAILLE_LIGNE];
static uint32_t horodatage[NB_LIGNES];
static uint8_t prochaineLigne = 0;
static uint8_t nbLignes = 0;
static uint32_t compteur = 0;
static void appendJsonString(String &out, const char *s) {
out += '"';
while (*s) {
char c = *s++;
switch (c) {
case '\\': out += "\\\\"; break;
case '"': out += "\\\""; break;
case '\n': out += "\\n"; break;
case '\r': break;
case '\t': out += "\\t"; break;
default:
if ((uint8_t)c < 0x20) out += ' ';
else out += c;
break;
}
}
out += '"';
}
void debugLogLine(const char *message) {
if (!message) return;
snprintf(lignes[prochaineLigne], TAILLE_LIGNE, "%s", message);
horodatage[prochaineLigne] = millis();
prochaineLigne = (prochaineLigne + 1) % NB_LIGNES;
if (nbLignes < NB_LIGNES) nbLignes++;
compteur++;
}
void debugLogf(const char *format, ...) {
char buffer[TAILLE_LIGNE];
va_list args;
va_start(args, format);
vsnprintf(buffer, sizeof(buffer), format, args);
va_end(args);
Serial.println(buffer);
debugLogLine(buffer);
}
void getDebugLogJson(String &out) {
out.reserve(NB_LIGNES * 96);
out = "{\"count\":";
out += compteur;
out += ",\"lines\":[";
for (uint8_t i = 0; i < nbLignes; i++) {
uint8_t idx = (prochaineLigne + NB_LIGNES - nbLignes + i) % NB_LIGNES;
if (i) out += ',';
out += "{\"t\":";
out += horodatage[idx];
out += ",\"m\":";
appendJsonString(out, lignes[idx]);
out += '}';
}
out += "]}";
}
void clearDebugLog() {
prochaineLigne = 0;
nbLignes = 0;
compteur = 0;
debugLogf("[DEBUG] Journal vidé");
}
+333
View File
@@ -0,0 +1,333 @@
#include <LittleFS.h>
#include <ArduinoJson.h>
#include <Arduino.h>
#include "epever_config.h"
#include "modbus_epever.h"
#include "debug_log.h"
#define FICHIER_CONFIG "/epever_config.json"
// ---------------------------------------------------------------------------
// Table des registres de configuration
// ---------------------------------------------------------------------------
const EpeverRegDef EPEVER_REGS[] = {
// reg key scale min max wr unit label aide
{ 0x9000, "battery_type", 1.0f, 0, 4, true, "", "Type batterie", "0=Perso, 1=Scellée (AGM), 2=GEL, 3=Liquide, 4=Lithium" },
{ 0x9001, "battery_capacity", 1.0f, 1, 9999, true, "Ah", "Capacité", "Capacité nominale en Ah. Influence les seuils de protection." },
{ 0x9002, "temp_compensation", 1.0f, -9, 0, true, "mV/°C/2V", "Compensation température", "Ajuste la tension de charge selon la température. Typique : -3 pour plomb-acide. Laisser 0 si inconnu." },
{ 0x9003, "high_volt_disconnect", 0.01f, 13.0f, 17.0f, true, "V", "Déconnexion survoltage", "Tension à laquelle le régulateur coupe pour protéger la batterie contre la surcharge." },
{ 0x9004, "charging_limit", 0.01f, 13.0f, 16.0f, true, "V", "Limite de charge", "Tension maximale autorisée pendant la charge." },
{ 0x9005, "overvolt_reconnect", 0.01f, 13.0f, 16.0f, true, "V", "Reconnexion survoltage", "En dessous de cette tension, la charge reprend après une coupure survoltage." },
{ 0x9006, "equalize_voltage", 0.01f, 13.0f, 16.0f, true, "V", "Tension égalisation", "Tension appliquée lors des cycles d'égalisation (désulfatation). Typique : 14.6V." },
{ 0x9007, "boost_voltage", 0.01f, 13.0f, 15.5f, true, "V", "Tension boost", "Tension d'absorption (charge rapide). Typique : 14.4V pour plomb-acide." },
{ 0x9008, "float_voltage", 0.01f, 13.0f, 14.5f, true, "V", "Tension float", "Tension de maintien après charge complète. Typique : 13.6V. Trop haute = usure prématurée." },
{ 0x9009, "boost_reconnect", 0.01f, 11.0f, 14.0f, true, "V", "Reconnexion boost", "Si la tension tombe sous ce seuil, la charge boost reprend automatiquement." },
{ 0x900A, "low_reconnect", 0.01f, 10.0f, 13.5f, true, "V", "Reconnexion basse tension", "Tension à laquelle la sortie de charge est réactivée après une coupure." },
{ 0x900B, "undervolt_warning", 0.01f, 10.0f, 13.0f, true, "V", "Alerte basse tension", "Déclenche une alarme sans couper. Prévenez avant que la batterie soit trop déchargée." },
{ 0x900C, "low_disconnect", 0.01f, 9.0f, 13.0f, true, "V", "Déconnexion basse tension", "La sortie charge est coupée sous cette tension pour protéger la batterie." },
{ 0x900D, "discharge_limit", 0.01f, 9.0f, 13.0f, true, "V", "Limite de décharge", "Tension minimale absolue. Ne jamais descendre en dessous." },
{ 0x906B, "rated_voltage", 1.0f, 0, 2, true, "", "Tension nominale système", "0=Auto détecté, 1=Système 12V, 2=Système 24V. Laisser Auto si possible." },
{ 0x906C, "equalize_duration", 1.0f, 0, 180, true, "min", "Durée égalisation", "Durée du cycle d'égalisation en minutes (0=désactivé)." },
{ 0x906D, "boost_duration", 1.0f, 10, 180, true, "min", "Durée boost", "Durée maximale de la phase d'absorption en minutes." },
{ 0x906E, "bat_discharge_soc", 1.0f, 20, 100, true, "%", "SOC déconnexion", "Seuil de charge minimum (%) avant coupure de la sortie." },
};
const uint8_t EPEVER_REGS_COUNT = sizeof(EPEVER_REGS) / sizeof(EPEVER_REGS[0]);
// ---------------------------------------------------------------------------
// Cache en RAM
// ---------------------------------------------------------------------------
static float configCache[18];
static bool configValide = false;
static unsigned long tDerniereSync = 0;
static String derniereErreur;
// ---------------------------------------------------------------------------
// Helpers internes
// ---------------------------------------------------------------------------
static int8_t indexPourReg(uint16_t reg) {
for (uint8_t i = 0; i < EPEVER_REGS_COUNT; i++) {
if (EPEVER_REGS[i].reg == reg) return (int8_t)i;
}
return -1;
}
static bool estBloc1(uint16_t reg) { return reg >= 0x9000u && reg <= 0x900Du; }
static bool estBloc2(uint16_t reg) { return reg >= 0x906Bu && reg <= 0x906Eu; }
// ---------------------------------------------------------------------------
// Init
// ---------------------------------------------------------------------------
void initConfigEpever() {
debugLogf("[Config] initConfigEpever — %u registres", EPEVER_REGS_COUNT);
}
// ---------------------------------------------------------------------------
// Lecture depuis l'Epever
// ---------------------------------------------------------------------------
bool lireConfigEpever() {
if (!modbusAcquire()) {
derniereErreur = "Modbus occupé, réessayer dans quelques secondes";
debugLogf("[Config] Lecture refusée: %s", derniereErreur.c_str());
return false;
}
uint16_t raw1[EPEVER_BLOC1_QTY];
uint16_t raw2[EPEVER_BLOC2_QTY];
bool ok1 = modbusLireHolding(EPEVER_BLOC1_REG, EPEVER_BLOC1_QTY, raw1, 900);
if (!ok1) {
derniereErreur = "Erreur RS485 lecture bloc 0x9000 (tensions)";
debugLogf("[Config] %s", derniereErreur.c_str());
modbusRelease();
return false;
}
bool ok2 = modbusLireHolding(EPEVER_BLOC2_REG, EPEVER_BLOC2_QTY, raw2, 700);
if (!ok2) {
// Bloc 2 non fatal : on garde les valeurs du cache ou zéro
debugLogf("[Config] Bloc 0x906B indisponible — valeurs conservées");
}
modbusRelease();
// Décoder bloc 1 (registres 0x9000 → 0x900D)
for (uint8_t i = 0; i < EPEVER_BLOC1_QTY; i++) {
int8_t idx = indexPourReg(0x9000u + i);
if (idx < 0) continue;
const EpeverRegDef &d = EPEVER_REGS[idx];
if (d.scale == 1.0f && d.valMin < 0) {
// valeur signée (ex: temp_compensation)
configCache[idx] = (int16_t)raw1[i] * d.scale;
} else {
configCache[idx] = raw1[i] * d.scale;
}
}
// Décoder bloc 2 (registres 0x906B → 0x906E)
if (ok2) {
for (uint8_t i = 0; i < EPEVER_BLOC2_QTY; i++) {
int8_t idx = indexPourReg(0x906Bu + i);
if (idx < 0) continue;
configCache[idx] = raw2[i] * EPEVER_REGS[idx].scale;
}
}
configValide = true;
tDerniereSync = millis();
derniereErreur = "";
debugLogf("[Config] Lecture OK — Type:%d Capa:%dAh Float:%.2fV Boost:%.2fV",
(int)configCache[indexPourReg(0x9000)],
(int)configCache[indexPourReg(0x9001)],
configCache[indexPourReg(0x9008)],
configCache[indexPourReg(0x9007)]);
return true;
}
// ---------------------------------------------------------------------------
// Écriture vers l'Epever
// ---------------------------------------------------------------------------
bool ecrireConfigEpever(JsonObject obj, String &erreur) {
if (!configValide) {
erreur = "Lecture préalable requise — cliquer Lire d'abord";
return false;
}
if (!modbusAcquire()) {
erreur = "Modbus occupé, réessayer dans quelques secondes";
return false;
}
// Validation complète avant d'écrire quoi que ce soit
for (uint8_t i = 0; i < EPEVER_REGS_COUNT; i++) {
const EpeverRegDef &d = EPEVER_REGS[i];
if (!obj[d.key].is<float>() && !obj[d.key].is<int>()) continue;
float val = obj[d.key].as<float>();
if (val < d.valMin || val > d.valMax) {
erreur = String(d.label) + " hors plage [" + String(d.valMin, 2) + "" + String(d.valMax, 2) + "]";
modbusRelease();
return false;
}
}
// Vérification cohérence tensions clés
auto getVal = [&](const char *key) -> float {
if (obj[key].is<float>() || obj[key].is<int>()) return obj[key].as<float>();
int8_t idx = indexPourReg(0); // recalculé
for (uint8_t i = 0; i < EPEVER_REGS_COUNT; i++) {
if (strcmp(EPEVER_REGS[i].key, key) == 0) return configCache[i];
}
return 0.0f;
};
float floatV = getVal("float_voltage");
float boostV = getVal("boost_voltage");
float eqV = getVal("equalize_voltage");
float discoV = getVal("low_disconnect");
float reconV = getVal("low_reconnect");
if (floatV >= boostV) {
erreur = "Float Voltage doit être < Boost Voltage";
modbusRelease();
return false;
}
if (discoV >= reconV) {
erreur = "Déconnexion basse tension doit être < Reconnexion";
modbusRelease();
return false;
}
if (boostV > eqV && eqV > 0) {
erreur = "Boost Voltage doit être ≤ Equalize Voltage";
modbusRelease();
return false;
}
// Écriture registre par registre
bool ok = true;
for (uint8_t i = 0; i < EPEVER_REGS_COUNT; i++) {
const EpeverRegDef &d = EPEVER_REGS[i];
if (!d.writable) continue;
if (!obj[d.key].is<float>() && !obj[d.key].is<int>()) continue;
float val = obj[d.key].as<float>();
uint16_t raw;
if (d.scale == 1.0f && d.valMin < 0) {
raw = (uint16_t)(int16_t)round(val); // signé (temp_compensation)
} else if (d.scale < 1.0f) {
raw = (uint16_t)round(val / d.scale); // tension ÷ 0.01
} else {
raw = (uint16_t)round(val); // entier brut
}
if (!modbusEcrireHolding(d.reg, 1, &raw, 800)) {
erreur = String("Erreur RS485 écriture ") + d.label;
ok = false;
break;
}
configCache[i] = val;
delay(15);
}
modbusRelease();
if (ok) {
derniereErreur = "";
debugLogf("[Config] Écriture OK");
} else {
derniereErreur = erreur;
debugLogf("[Config] Écriture ERREUR: %s", erreur.c_str());
}
return ok;
}
// ---------------------------------------------------------------------------
// Persistance LittleFS
// ---------------------------------------------------------------------------
bool sauvegarderConfigJson() {
if (!configValide) {
debugLogf("[Config] Sauvegarde refusée: cache non valide");
return false;
}
JsonDocument doc;
JsonObject values = doc["values"].to<JsonObject>();
for (uint8_t i = 0; i < EPEVER_REGS_COUNT; i++) {
const EpeverRegDef &d = EPEVER_REGS[i];
if (d.scale < 1.0f) {
values[d.key] = roundf(configCache[i] * 100.0f) / 100.0f;
} else {
values[d.key] = (int)configCache[i];
}
}
File f = LittleFS.open(FICHIER_CONFIG, "w");
if (!f) {
debugLogf("[Config] Impossible d'ouvrir %s en écriture", FICHIER_CONFIG);
return false;
}
serializeJson(doc, f);
f.close();
debugLogf("[Config] Sauvegardé dans %s", FICHIER_CONFIG);
return true;
}
bool restaurerConfigJson(String &erreur) {
if (!LittleFS.exists(FICHIER_CONFIG)) {
erreur = "Aucune sauvegarde trouvée";
return false;
}
File f = LittleFS.open(FICHIER_CONFIG, "r");
if (!f) { erreur = "Impossible de lire la sauvegarde"; return false; }
JsonDocument doc;
DeserializationError e = deserializeJson(doc, f);
f.close();
if (e) { erreur = "JSON invalide dans la sauvegarde"; return false; }
JsonObject values = doc["values"].as<JsonObject>();
if (values.isNull()) { erreur = "Format sauvegarde invalide"; return false; }
return ecrireConfigEpever(values, erreur);
}
// ---------------------------------------------------------------------------
// Sérialisation JSON pour les API
// ---------------------------------------------------------------------------
void getConfigJson(String &out) {
JsonDocument doc;
doc["ok"] = configValide;
doc["valide"] = configValide;
doc["uptime"] = millis();
if (!derniereErreur.isEmpty()) doc["erreur"] = derniereErreur;
JsonObject values = doc["values"].to<JsonObject>();
if (configValide) {
for (uint8_t i = 0; i < EPEVER_REGS_COUNT; i++) {
const EpeverRegDef &d = EPEVER_REGS[i];
if (d.scale < 1.0f) {
values[d.key] = roundf(configCache[i] * 100.0f) / 100.0f;
} else {
values[d.key] = (int)configCache[i];
}
}
}
// Schéma (labels + unités + aide + plages) pour génération UI
JsonArray schema = doc["schema"].to<JsonArray>();
for (uint8_t i = 0; i < EPEVER_REGS_COUNT; i++) {
const EpeverRegDef &d = EPEVER_REGS[i];
JsonObject r = schema.add<JsonObject>();
r["key"] = d.key;
r["label"] = d.label;
r["unit"] = d.unit;
r["aide"] = d.aide;
r["min"] = d.valMin;
r["max"] = d.valMax;
r["writable"] = d.writable;
r["scale"] = d.scale;
}
serializeJson(doc, out);
}
void getConfigSauvegardeJson(String &out) {
if (!LittleFS.exists(FICHIER_CONFIG)) {
out = "{\"ok\":false,\"erreur\":\"Aucune sauvegarde\"}";
return;
}
File f = LittleFS.open(FICHIER_CONFIG, "r");
if (!f) { out = "{\"ok\":false,\"erreur\":\"Lecture impossible\"}"; return; }
out = f.readString();
f.close();
}
+262
View File
@@ -0,0 +1,262 @@
#include "historique.h"
#include "state.h"
#include "debug_log.h"
#include <LittleFS.h>
// ─── Dimensions ───────────────────────────────────────────────────────────────
#define HIRES_MAX 240 // 4h × 60 min, une mesure/min
#define LORES_MAX 312 // 26h × 12 pts/h (5 min)
#define FICHIER_HIST "/hist.bin"
// ─── Format binaire du fichier (13 octets/entrée) ────────────────────────────
struct __attribute__((packed)) HistEntry {
float bat;
float pv;
float load;
uint8_t soc;
};
// ─── Buffers haute résolution (RAM uniquement) ───────────────────────────────
static float hrBat[HIRES_MAX], hrPV[HIRES_MAX], hrLoad[HIRES_MAX];
static uint8_t hrSOC[HIRES_MAX];
static uint16_t hrTete = 0, hrN = 0;
// ─── Buffers basse résolution (RAM + fichier) ────────────────────────────────
static float lrBat[LORES_MAX], lrPV[LORES_MAX], lrLoad[LORES_MAX];
static uint8_t lrSOC[LORES_MAX];
static uint16_t lrTete = 0, lrN = 0;
// ─── Accumulateurs pour la moyenne 5 min ─────────────────────────────────────
static float accBat = 0, accPV = 0, accLoad = 0;
static uint16_t accSOC = 0, accN = 0;
static uint8_t lrDepuisHr = 0; // pts lores ajoutés depuis la dernière sauvegarde
// ─── Timers ───────────────────────────────────────────────────────────────────
static unsigned long tDernMin = 0;
static unsigned long tDern5Min = 0;
static unsigned long tDernHr = 0;
static bool premierPointAjoute = false;
// ─── Index ordonné dans un ring buffer ───────────────────────────────────────
static inline uint16_t hrIdx(uint16_t i) {
return (uint16_t)((hrTete - hrN + i + HIRES_MAX) % HIRES_MAX);
}
static inline uint16_t lrIdx(uint16_t i) {
return (uint16_t)((lrTete - lrN + i + LORES_MAX) % LORES_MAX);
}
// ─── Ajout d'un point hires ──────────────────────────────────────────────────
static void pushHires() {
hrBat[hrTete] = state.battery;
hrPV[hrTete] = state.pv;
hrLoad[hrTete] = state.loadPower;
hrSOC[hrTete] = state.batSOC;
hrTete = (hrTete + 1) % HIRES_MAX;
if (hrN < HIRES_MAX) hrN++;
accBat += state.battery;
accPV += state.pv;
accLoad += state.loadPower;
accSOC += state.batSOC;
accN++;
debugLogf("[HIST] Point hires #%u — bat=%.2fV pv=%.2fV load=%.1fW soc=%u",
hrN, state.battery, state.pv, state.loadPower, state.batSOC);
}
// ─── Flush de la moyenne 5 min vers lores ────────────────────────────────────
static void pushLores() {
if (accN == 0) return;
lrBat[lrTete] = accBat / accN;
lrPV[lrTete] = accPV / accN;
lrLoad[lrTete] = accLoad / accN;
lrSOC[lrTete] = (uint8_t)(accSOC / accN);
lrTete = (lrTete + 1) % LORES_MAX;
if (lrN < LORES_MAX) lrN++;
lrDepuisHr++;
debugLogf("[HIST] Point lores #%u — moyennes sur %u pt(s): bat=%.2fV pv=%.2fV load=%.1fW soc=%u",
lrN, accN, lrBat[(lrTete + LORES_MAX - 1) % LORES_MAX],
lrPV[(lrTete + LORES_MAX - 1) % LORES_MAX],
lrLoad[(lrTete + LORES_MAX - 1) % LORES_MAX],
lrSOC[(lrTete + LORES_MAX - 1) % LORES_MAX]);
accBat = accPV = accLoad = 0;
accSOC = 0; accN = 0;
}
// ─── Sauvegarde horaire vers /hist.bin ───────────────────────────────────────
static void sauvegarderHeure() {
if (lrDepuisHr == 0) return;
// Lire les entrées existantes (max LORES_MAX)
HistEntry buf[LORES_MAX];
uint16_t existant = 0;
File fr = LittleFS.open(FICHIER_HIST, "r");
if (fr) {
existant = (uint16_t)min((size_t)(fr.size() / sizeof(HistEntry)),
(size_t)LORES_MAX);
fr.read((uint8_t*)buf, existant * sizeof(HistEntry));
fr.close();
}
// Ajouter les lrDepuisHr nouvelles entrées depuis le ring lores
uint16_t debut = (lrN >= lrDepuisHr) ? (lrN - lrDepuisHr) : 0;
for (uint16_t i = debut; i < lrN; i++) {
if (existant >= LORES_MAX) {
memmove(buf, buf + 1, (existant - 1) * sizeof(HistEntry));
existant--;
}
uint16_t idx = lrIdx(i);
buf[existant++] = { lrBat[idx], lrPV[idx], lrLoad[idx], lrSOC[idx] };
}
// Réécrire le fichier
File fw = LittleFS.open(FICHIER_HIST, "w");
if (fw) {
fw.write((uint8_t*)buf, existant * sizeof(HistEntry));
fw.close();
Serial.printf("[HIST] Sauvegarde %d pts → %d total (%dB)\n",
lrDepuisHr, existant,
(int)(existant * sizeof(HistEntry)));
} else {
Serial.println("[HIST] Erreur écriture hist.bin");
}
lrDepuisHr = 0;
}
// ─── Chargement du fichier au démarrage ──────────────────────────────────────
static void chargerFichier() {
File f = LittleFS.open(FICHIER_HIST, "r");
if (!f) { Serial.println("[HIST] Aucun fichier — démarrage à zéro"); return; }
uint16_t n = (uint16_t)(f.size() / sizeof(HistEntry));
if (n > LORES_MAX) n = LORES_MAX;
Serial.printf("[HIST] Chargement de %d pts depuis hist.bin\n", n);
HistEntry e;
for (uint16_t i = 0; i < n; i++) {
f.read((uint8_t*)&e, sizeof(e));
lrBat[lrTete] = e.bat;
lrPV[lrTete] = e.pv;
lrLoad[lrTete] = e.load;
lrSOC[lrTete] = e.soc;
lrTete = (lrTete + 1) % LORES_MAX;
if (lrN < LORES_MAX) lrN++;
}
f.close();
}
// ─── Sérialisation JSON générique ────────────────────────────────────────────
static void serJson(String &out, const char *mode, uint16_t n,
uint16_t step_s,
float *bat, float *pv, float *load, uint8_t *soc,
uint16_t maxBuf,
uint16_t (*idxFn)(uint16_t)) {
char tmp[12];
out.reserve(n * 26 + 80);
out = "{\"mode\":\""; out += mode;
out += "\",\"step\":"; out += step_s;
out += ",\"n\":"; out += n;
out += ",\"b\":[";
for (uint16_t i = 0; i < n; i++) {
if (i) out += ',';
snprintf(tmp, sizeof(tmp), "%.2f", bat[idxFn(i)]);
out += tmp;
}
out += "],\"p\":[";
for (uint16_t i = 0; i < n; i++) {
if (i) out += ',';
snprintf(tmp, sizeof(tmp), "%.2f", pv[idxFn(i)]);
out += tmp;
}
out += "],\"l\":[";
for (uint16_t i = 0; i < n; i++) {
if (i) out += ',';
snprintf(tmp, sizeof(tmp), "%.1f", load[idxFn(i)]);
out += tmp;
}
out += "],\"s\":[";
for (uint16_t i = 0; i < n; i++) {
if (i) out += ',';
out += soc[idxFn(i)];
}
out += "]}";
(void)maxBuf;
}
// ─── API publique ─────────────────────────────────────────────────────────────
void initHistorique() {
hrTete = hrN = lrTete = lrN = 0;
accBat = accPV = accLoad = 0;
accSOC = accN = lrDepuisHr = 0;
premierPointAjoute = false;
unsigned long t = millis();
tDernMin = tDern5Min = tDernHr = t;
chargerFichier();
debugLogf("[HIST] Init — hires=%u lores=%u", hrN, lrN);
}
void gererHistorique() {
if (!state.rs485_ok) return;
unsigned long maintenant = millis();
if (!premierPointAjoute) {
premierPointAjoute = true;
tDernMin = maintenant;
pushHires();
} else if (maintenant - tDernMin >= 60000UL) {
tDernMin = maintenant;
pushHires();
}
if (maintenant - tDern5Min >= 300000UL) {
tDern5Min = maintenant;
pushLores();
}
if (maintenant - tDernHr >= 3600000UL) {
tDernHr = maintenant;
sauvegarderHeure();
}
}
// Lores : jusqu'à 30h, résolution 5 min
void getHistoriqueJson(String &out) {
serJson(out, "lores", lrN, 300,
lrBat, lrPV, lrLoad, lrSOC, LORES_MAX, lrIdx);
}
// Hires : jusqu'à 4h, résolution 1 min
void getHistoriqueHiresJson(String &out) {
serJson(out, "hires", hrN, 60,
hrBat, hrPV, hrLoad, hrSOC, HIRES_MAX, hrIdx);
}
// Export CSV lores (30h, pas 5 min) — temps relatif en minutes depuis maintenant
void getHistoriqueCsv(String &out) {
out.reserve(lrN * 32 + 64);
out = "temps_min,batterie_V,pv_V,charge_W,soc_pct\r\n";
char tmp[48];
for (uint16_t i = 0; i < lrN; i++) {
uint16_t idx = lrIdx(i);
int32_t min = -((int32_t)(lrN - 1 - i) * 5);
snprintf(tmp, sizeof(tmp), "%ld,%.2f,%.2f,%.1f,%d\r\n",
(long)min, lrBat[idx], lrPV[idx], lrLoad[idx], lrSOC[idx]);
out += tmp;
}
}
void getHistoriqueStatusJson(String &out) {
out = "{";
out += "\"hires_n\":"; out += hrN;
out += ",\"hires_max\":"; out += HIRES_MAX;
out += ",\"lores_n\":"; out += lrN;
out += ",\"lores_max\":"; out += LORES_MAX;
out += ",\"acc_n\":"; out += accN;
out += ",\"rs485_ok\":"; out += state.rs485_ok ? "true" : "false";
out += ",\"last_update\":"; out += state.last_update;
out += ",\"millis\":"; out += millis();
out += ",\"next_hires_ms\":";
unsigned long now = millis();
out += (now - tDernMin >= 60000UL) ? 0 : (60000UL - (now - tDernMin));
out += ",\"next_lores_ms\":";
out += (now - tDern5Min >= 300000UL) ? 0 : (300000UL - (now - tDern5Min));
out += "}";
}
+59
View File
@@ -0,0 +1,59 @@
#include <Arduino.h>
#include "config.h"
#include "state.h"
#include "wifi_ap.h"
#include "webserver.h"
#include "ota.h"
#include "buttons.h"
#include "modbus_epever.h"
#include "rules.h"
#include "sleep.h"
#include "historique.h"
#include "debug_log.h"
#include "epever_config.h"
#include "wireguard_vpn.h"
// Instance globale partagée entre tous les modules
SystemState state;
void setup() {
Serial.begin(115200);
Serial.println("\n==============================");
Serial.println(" KC868-A2 Contrôleur solaire");
Serial.printf (" Reset reason : %d\n", (int)esp_reset_reason());
Serial.println("==============================");
debugLogf("Boot KC868-A2 — reset reason %d", (int)esp_reset_reason());
// Réveil timer : vérification rapide — peut retourner en deep sleep ici
verifierEtDormirSiNuit();
// Init GPIO relais
pinMode(PIN_RELAY1, OUTPUT);
pinMode(PIN_RELAY2, OUTPUT);
restaurerRelaisNVS(); // restaure depuis NVS (survit au power-off)
demarrerWifi();
demarrerWebserveur(); // monte LittleFS
demarrerOTA();
initBoutons();
initModbus();
chargerConfigSleep(); // après montage LittleFS
restaurerRelais(); // restaure l'état relais si réveil depuis deep sleep
initRegles();
initHistorique();
initConfigEpever();
initWireGuard();
debugLogf("Système prêt.");
}
void loop() {
gererWifi();
gererWireGuard();
gererOTA();
gererBoutons();
gererModbus();
gererRegles();
gererHistorique();
gererSleep();
}
+757
View File
@@ -0,0 +1,757 @@
#include <ModbusRTU.h>
#include <Arduino.h>
#include <Preferences.h>
#include <time.h>
#include <sys/time.h>
#include "config.h"
#include "state.h"
#include "debug_log.h"
static ModbusRTU mb;
static uint32_t intervalleJour = INTERVALLE_MODBUS; // ms — mode soleil
static uint32_t intervalleNuit = 30000UL; // ms — mode veille
static uint32_t intervalCourant() {
if (state.last_update == 0) return intervalleJour; // première lecture toujours rapide
return state.sun ? intervalleJour : intervalleNuit;
}
void setIntervallesModbus(uint32_t jour_ms, uint32_t nuit_ms) {
intervalleJour = jour_ms;
intervalleNuit = nuit_ms;
Preferences p; p.begin("modbus", false);
p.putUInt("jour", jour_ms);
p.putUInt("nuit", nuit_ms);
p.end();
Serial.printf("[Modbus] Intervalles — jour:%ums nuit:%ums\n", jour_ms, nuit_ms);
}
void getIntervallesModbus(uint32_t &jour_ms, uint32_t &nuit_ms) {
jour_ms = intervalleJour;
nuit_ms = intervalleNuit;
}
// Buffers de réception — un par groupe de registres
static uint16_t bufPV[8]; // 0x3100..0x3107 : PV, batterie, courant/puissance charge
static uint16_t bufLoad[5]; // 0x310C..0x3110 : load + température batterie
static uint16_t bufSOC[1]; // 0x311A : SOC %
static uint16_t bufStatus[2]; // 0x3200 : Battery status | 0x3201 : Charging status
static uint16_t bufEnergie[16]; // 0x3304..0x3313 : kWh consommés/générés
static bool bufJourNuit[1]; // 0x200C : jour/nuit (FC02 discrete input)
static unsigned long tDerniereLecture = 0;
static unsigned long tDebutRequete = 0;
static bool lectureEnCours = false;
static uint32_t nbLecturesOK = 0;
static uint32_t nbErreurs = 0;
static uint8_t derniereErreur = 0;
static const char *derniereEtape = "aucune";
static unsigned long tDerniereSyncRtc = 0;
static bool rtcSyncedOnce = false;
static const unsigned long INTERVALLE_SYNC_RTC = 21600000UL; // 6h
static bool dernierSun = false;
static bool dernierSunValide = false;
static void finaliserLecture();
static uint16_t crc16Modbus(const uint8_t *buf, size_t len) {
uint16_t crc = 0xFFFF;
for (size_t i = 0; i < len; i++) {
crc ^= buf[i];
for (uint8_t bit = 0; bit < 8; bit++) {
crc = (crc & 1) ? (crc >> 1) ^ 0xA001 : crc >> 1;
}
}
return crc;
}
static void dumpHex(const char *prefix, const uint8_t *buf, size_t len) {
String ligne;
ligne.reserve(40 + len * 3);
ligne += prefix;
ligne += " (";
ligne += (unsigned)len;
ligne += " octets):";
for (size_t i = 0; i < len; i++) {
char hex[4];
snprintf(hex, sizeof(hex), " %02X", buf[i]);
ligne += hex;
}
Serial.println(ligne);
debugLogLine(ligne.c_str());
}
static void viderRx(const char *raison) {
uint8_t buf[MODBUS_DEBUG_RX_MAX];
size_t n = 0;
while (Serial2.available() && n < sizeof(buf)) {
buf[n++] = (uint8_t)Serial2.read();
}
while (Serial2.available()) Serial2.read();
if (n > 0) {
debugLogf("[Modbus][debug] RX vidé avant %s", raison);
dumpHex("[Modbus][debug] octets parasites", buf, n);
}
}
static bool probeRegistreBatterie(uint32_t baudrate) {
#if MODBUS_DEBUG_BOOT
debugLogf("[Modbus][probe] Test direct 0x3104 à %u bauds, esclave %d",
baudrate, MODBUS_ADRESSE);
Serial2.end();
delay(20);
Serial2.begin(baudrate, SERIAL_8N1, PIN_RS485_RX, PIN_RS485_TX);
delay(50);
viderRx("probe");
uint8_t req[8] = {
MODBUS_ADRESSE,
0x04,
0x31, 0x04,
0x00, 0x01,
0x00, 0x00
};
uint16_t crc = crc16Modbus(req, 6);
req[6] = crc & 0xFF;
req[7] = crc >> 8;
dumpHex("[Modbus][probe] TX", req, sizeof(req));
Serial2.write(req, sizeof(req));
Serial2.flush();
uint8_t resp[MODBUS_DEBUG_RX_MAX];
size_t n = 0;
unsigned long t0 = millis();
unsigned long dernierOctet = t0;
while ((millis() - t0) < 700 && n < sizeof(resp)) {
while (Serial2.available() && n < sizeof(resp)) {
resp[n++] = (uint8_t)Serial2.read();
dernierOctet = millis();
}
if (n >= 7 && (millis() - dernierOctet) > 20) break;
delay(1);
}
if (n == 0) {
debugLogf("[Modbus][probe] Aucun octet reçu à %u bauds", baudrate);
debugLogf("[Modbus][probe] Causes probables: A/B inversés, GND absent, mauvais baudrate, mauvais ID, Epever non alimenté.");
return false;
}
dumpHex("[Modbus][probe] RX", resp, n);
if (n < 5) {
debugLogf("[Modbus][probe] Réponse trop courte: bruit RS485 ou baudrate incorrect probable.");
return false;
}
if (resp[0] != MODBUS_ADRESSE) {
debugLogf("[Modbus][probe] Adresse inattendue: reçu %u, attendu %u",
resp[0], MODBUS_ADRESSE);
return false;
}
if (resp[1] & 0x80) {
debugLogf("[Modbus][probe] Exception Modbus fonction 0x%02X code 0x%02X",
resp[1], n > 2 ? resp[2] : 0);
return false;
}
if (resp[1] != 0x04 || resp[2] != 0x02 || n < 7) {
debugLogf("[Modbus][probe] Format inattendu pour lecture input register 0x3104.");
return false;
}
uint16_t crcCalc = crc16Modbus(resp, 5);
uint16_t crcRx = (uint16_t)resp[5] | ((uint16_t)resp[6] << 8);
if (crcCalc != crcRx) {
debugLogf("[Modbus][probe] CRC invalide: calcul 0x%04X, reçu 0x%04X", crcCalc, crcRx);
return false;
}
uint16_t brut = ((uint16_t)resp[3] << 8) | resp[4];
debugLogf("[Modbus][probe] OK à %u bauds: batterie %.2f V (brut 0x%04X)",
baudrate, brut * 0.01f, brut);
return true;
#else
(void)baudrate;
return false;
#endif
}
static bool lireRegistresBrutsFc(uint8_t fonction, uint16_t registre, uint16_t quantite,
uint16_t *dest, uint16_t timeoutMs, bool logSucces = false) {
if (quantite == 0 || quantite > 24) return false;
viderRx("lecture brute");
uint8_t req[8] = {
MODBUS_ADRESSE,
fonction,
(uint8_t)(registre >> 8), (uint8_t)(registre & 0xFF),
(uint8_t)(quantite >> 8), (uint8_t)(quantite & 0xFF),
0x00, 0x00
};
uint16_t crc = crc16Modbus(req, 6);
req[6] = crc & 0xFF;
req[7] = crc >> 8;
Serial2.write(req, sizeof(req));
Serial2.flush();
const size_t attendu = 5 + (size_t)quantite * 2;
uint8_t resp[MODBUS_DEBUG_RX_MAX];
size_t n = 0;
unsigned long t0 = millis();
unsigned long dernierOctet = t0;
while ((millis() - t0) < timeoutMs && n < sizeof(resp)) {
while (Serial2.available() && n < sizeof(resp)) {
resp[n++] = (uint8_t)Serial2.read();
dernierOctet = millis();
}
if (n >= attendu && (millis() - dernierOctet) > 5) break;
delay(1);
}
if (n == 0) {
nbErreurs++;
derniereErreur = 0xE0;
derniereEtape = "Brut";
debugLogf("[Modbus][brut] Timeout FC%02u registre 0x%04X, aucun octet reçu", fonction, registre);
dumpHex("[Modbus][brut] TX", req, sizeof(req));
return false;
}
if (n < attendu) {
nbErreurs++;
derniereErreur = 0xE2;
derniereEtape = "Brut court";
debugLogf("[Modbus][brut] Réponse courte FC%02u registre 0x%04X: reçu %u, attendu %u",
fonction, registre, (unsigned)n, (unsigned)attendu);
dumpHex("[Modbus][brut] TX", req, sizeof(req));
dumpHex("[Modbus][brut] RX", resp, n);
return false;
}
uint16_t crcCalc = crc16Modbus(resp, attendu - 2);
uint16_t crcRx = (uint16_t)resp[attendu - 2] | ((uint16_t)resp[attendu - 1] << 8);
if (crcCalc != crcRx) {
nbErreurs++;
derniereErreur = 0xE1;
derniereEtape = "Brut CRC";
debugLogf("[Modbus][brut] CRC invalide FC%02u registre 0x%04X: calcul 0x%04X, reçu 0x%04X",
fonction, registre, crcCalc, crcRx);
dumpHex("[Modbus][brut] TX", req, sizeof(req));
dumpHex("[Modbus][brut] RX", resp, n);
return false;
}
if (resp[0] != MODBUS_ADRESSE || resp[1] != fonction || resp[2] != quantite * 2) {
nbErreurs++;
derniereErreur = resp[1];
derniereEtape = "Brut format";
debugLogf("[Modbus][brut] Format inattendu FC%02u registre 0x%04X", fonction, registre);
dumpHex("[Modbus][brut] TX", req, sizeof(req));
dumpHex("[Modbus][brut] RX", resp, n);
return false;
}
for (uint16_t i = 0; i < quantite; i++) {
dest[i] = ((uint16_t)resp[3 + i * 2] << 8) | resp[4 + i * 2];
}
if (logSucces) {
debugLogf("[Modbus][brut] OK FC%02u registre 0x%04X x%u", fonction, registre, quantite);
}
return true;
}
static bool lireRegistresBruts(uint16_t registre, uint16_t quantite, uint16_t *dest,
uint16_t timeoutMs, bool logSucces = false) {
return lireRegistresBrutsFc(0x04, registre, quantite, dest, timeoutMs, logSucces);
}
static bool lireHoldingBruts(uint16_t registre, uint16_t quantite, uint16_t *dest,
uint16_t timeoutMs, bool logSucces = false) {
return lireRegistresBrutsFc(0x03, registre, quantite, dest, timeoutMs, logSucces);
}
static bool ecrireHoldingMultiplesBrut(uint16_t registre, uint16_t quantite,
const uint16_t *valeurs, uint16_t timeoutMs) {
if (quantite == 0 || quantite > 12) return false;
viderRx("écriture holding brute");
uint8_t req[MODBUS_DEBUG_RX_MAX];
size_t len = 7 + (size_t)quantite * 2 + 2;
if (len > sizeof(req)) return false;
req[0] = MODBUS_ADRESSE;
req[1] = 0x10;
req[2] = registre >> 8;
req[3] = registre & 0xFF;
req[4] = quantite >> 8;
req[5] = quantite & 0xFF;
req[6] = quantite * 2;
for (uint16_t i = 0; i < quantite; i++) {
req[7 + i * 2] = valeurs[i] >> 8;
req[8 + i * 2] = valeurs[i] & 0xFF;
}
uint16_t crc = crc16Modbus(req, len - 2);
req[len - 2] = crc & 0xFF;
req[len - 1] = crc >> 8;
dumpHex("[Modbus][write] TX", req, len);
Serial2.write(req, len);
Serial2.flush();
uint8_t resp[8];
size_t n = 0;
unsigned long t0 = millis();
while ((millis() - t0) < timeoutMs && n < sizeof(resp)) {
while (Serial2.available() && n < sizeof(resp)) resp[n++] = (uint8_t)Serial2.read();
if (n >= 8) break;
delay(1);
}
if (n < 8) {
debugLogf("[Modbus][write] Réponse courte FC16 registre 0x%04X: %u octets", registre, (unsigned)n);
if (n) dumpHex("[Modbus][write] RX", resp, n);
return false;
}
uint16_t crcCalc = crc16Modbus(resp, 6);
uint16_t crcRx = (uint16_t)resp[6] | ((uint16_t)resp[7] << 8);
bool ok = resp[0] == MODBUS_ADRESSE && resp[1] == 0x10 &&
resp[2] == (registre >> 8) && resp[3] == (registre & 0xFF) &&
resp[4] == (quantite >> 8) && resp[5] == (quantite & 0xFF) &&
crcCalc == crcRx;
dumpHex("[Modbus][write] RX", resp, n);
debugLogf("[Modbus][write] FC16 0x%04X x%u -> %s", registre, quantite, ok ? "OK" : "ERREUR");
return ok;
}
static bool lireEntreesDiscretesBrut(uint16_t adresse, bool *dest, uint16_t timeoutMs) {
viderRx("lecture discrete brute");
uint8_t req[8] = {
MODBUS_ADRESSE,
0x02,
(uint8_t)(adresse >> 8), (uint8_t)(adresse & 0xFF),
0x00, 0x01,
0x00, 0x00
};
uint16_t crc = crc16Modbus(req, 6);
req[6] = crc & 0xFF;
req[7] = crc >> 8;
Serial2.write(req, sizeof(req));
Serial2.flush();
uint8_t resp[8];
size_t n = 0;
unsigned long t0 = millis();
while ((millis() - t0) < timeoutMs && n < 6) {
while (Serial2.available() && n < sizeof(resp)) resp[n++] = (uint8_t)Serial2.read();
if (n >= 6) break;
delay(1);
}
if (n < 6) {
debugLogf("[Modbus][brut] Jour/nuit 0x%04X ignoré: pas de réponse FC02", adresse);
return false;
}
uint16_t crcCalc = crc16Modbus(resp, 4);
uint16_t crcRx = (uint16_t)resp[4] | ((uint16_t)resp[5] << 8);
if (resp[0] != MODBUS_ADRESSE || resp[1] != 0x02 || resp[2] != 1 || crcCalc != crcRx) {
debugLogf("[Modbus][brut] Jour/nuit 0x%04X ignoré: format/CRC invalide", adresse);
dumpHex("[Modbus][brut] RX FC02", resp, n);
return false;
}
*dest = (resp[3] & 0x01) != 0;
debugLogf("[Modbus][brut] FC02 0x%04X = %u", adresse, *dest ? 1 : 0);
return true;
}
static float u32x100(const uint16_t *reg, uint8_t indexL) {
// Le PDF EPEVER indique les 32 bits en deux registres: L puis H.
return (((uint32_t)reg[indexL + 1] << 16) | reg[indexL]) * 0.01f;
}
static void calerHorlogeEspDepuisEpever() {
struct tm tmRtc = {};
tmRtc.tm_year = state.epeverYear - 1900;
tmRtc.tm_mon = state.epeverMonth - 1;
tmRtc.tm_mday = state.epeverDay;
tmRtc.tm_hour = state.epeverHour;
tmRtc.tm_min = state.epeverMinute;
tmRtc.tm_sec = state.epeverSecond;
time_t epoch = mktime(&tmRtc);
if (epoch <= 0) {
state.espClockOk = false;
debugLogf("[RTC] Impossible de convertir l'heure Epever en epoch");
return;
}
timeval tv = { epoch, 0 };
settimeofday(&tv, nullptr);
state.espClockOk = true;
debugLogf("[RTC] Horloge ESP32 calée depuis Epever");
}
static void lireHorlogeEpever(bool force) {
unsigned long maintenant = millis();
if (!force && rtcSyncedOnce && (maintenant - tDerniereSyncRtc) < INTERVALLE_SYNC_RTC) return;
uint16_t rtc[3];
if (!lireHoldingBruts(0x9013, 3, rtc, 700, true)) {
state.epeverClockOk = false;
debugLogf("[Modbus][rtc] Horloge Epever indisponible");
return;
}
state.epeverSecond = rtc[0] & 0xFF;
state.epeverMinute = (rtc[0] >> 8) & 0xFF;
state.epeverHour = rtc[1] & 0xFF;
state.epeverDay = (rtc[1] >> 8) & 0xFF;
state.epeverMonth = rtc[2] & 0xFF;
state.epeverYear = 2000 + ((rtc[2] >> 8) & 0xFF);
bool valide = state.epeverSecond < 60 && state.epeverMinute < 60 &&
state.epeverHour < 24 && state.epeverDay >= 1 &&
state.epeverDay <= 31 && state.epeverMonth >= 1 &&
state.epeverMonth <= 12;
state.epeverClockOk = valide;
debugLogf("[Modbus][rtc] %04u-%02u-%02u %02u:%02u:%02u (%s)",
state.epeverYear, state.epeverMonth, state.epeverDay,
state.epeverHour, state.epeverMinute, state.epeverSecond,
valide ? "OK" : "invalide");
if (valide) {
tDerniereSyncRtc = maintenant;
rtcSyncedOnce = true;
calerHorlogeEspDepuisEpever();
}
}
static void enregistrerChangementSoleil(bool nouveauSun) {
if (!dernierSunValide) {
dernierSun = nouveauSun;
dernierSunValide = true;
return;
}
if (nouveauSun == dernierSun) return;
dernierSun = nouveauSun;
uint8_t idx = state.sunHistoryHead;
state.sunHistoryState[idx] = nouveauSun;
if (state.espClockOk) {
time_t now = time(nullptr);
struct tm tmNow;
localtime_r(&now, &tmNow);
snprintf(state.sunHistoryTime[idx], sizeof(state.sunHistoryTime[idx]),
"%04d-%02d-%02d %02d:%02d:%02d",
tmNow.tm_year + 1900, tmNow.tm_mon + 1, tmNow.tm_mday,
tmNow.tm_hour, tmNow.tm_min, tmNow.tm_sec);
} else if (state.epeverClockOk) {
snprintf(state.sunHistoryTime[idx], sizeof(state.sunHistoryTime[idx]),
"%04u-%02u-%02u %02u:%02u:%02u",
state.epeverYear, state.epeverMonth, state.epeverDay,
state.epeverHour, state.epeverMinute, state.epeverSecond);
} else {
snprintf(state.sunHistoryTime[idx], sizeof(state.sunHistoryTime[idx]),
"uptime %lus", millis() / 1000);
}
state.sunHistoryHead = (state.sunHistoryHead + 1) % 5;
if (state.sunHistoryCount < 5) state.sunHistoryCount++;
state.sunHistoryValid = true;
debugLogf("[SUN] Changement état -> %s à %s",
nouveauSun ? "JOUR" : "NUIT", state.sunHistoryTime[idx]);
}
static bool effectuerLectureBruteEpever() {
uint16_t pv[8];
uint16_t load[5];
uint16_t soc[1];
uint16_t status[2];
uint16_t energie[18];
bool nuit = false;
debugLogf("[Modbus][brut] Début cycle lecture Epever");
if (!lireRegistresBruts(0x3100, 8, pv, 700, true)) return false;
if (!lireRegistresBruts(0x310C, 5, load, 700, true)) return false;
if (!lireRegistresBruts(0x311A, 1, soc, 700, true)) return false;
if (!lireRegistresBruts(0x3200, 2, status, 700, true)) return false;
lireHorlogeEpever(false);
if (lireRegistresBruts(0x3302, 18, energie, 900, false)) {
// Base 0x3302 selon MODBUS-Protocol-v25.pdf:
// 0x3304/05 conso jour, 0x330A/0B conso totale,
// 0x330C/0D production jour, 0x3312/13 production totale.
state.energieConJour = u32x100(energie, 2);
state.energieConTotal = u32x100(energie, 8);
state.energieGenJour = u32x100(energie, 10);
state.energieGenTotal = u32x100(energie, 16);
debugLogf("[Modbus][brut] Energie: genJ=%.2fkWh consoJ=%.2fkWh genTot=%.2fkWh consoTot=%.2fkWh",
state.energieGenJour, state.energieConJour,
state.energieGenTotal, state.energieConTotal);
} else {
debugLogf("[Modbus][brut] Energie ignorée, les valeurs précédentes sont conservées");
}
state.pv = pv[0] * 0.01f;
state.pvCurrent = pv[1] * 0.01f;
state.battery = pv[4] * 0.01f;
state.loadVoltage = load[0] * 0.01f;
state.loadCurrent = load[1] * 0.01f;
state.loadPower = u32x100(load, 2); // 0x310E/0x310F L/H
state.batTemperature = (int16_t)load[4] * 0.01f;
state.batSOC = (uint8_t)constrain((int)soc[0], 0, 100);
uint8_t batVoltStatus = status[0] & 0x0F;
state.batSousVoltage = (batVoltStatus == 2);
state.batSurVoltage = (batVoltStatus == 1);
state.batStatut = (status[1] >> 2) & 0x03;
if (lireEntreesDiscretesBrut(0x200C, &nuit, 500)) {
// Le registre officiel dit 1=Nuit, 0=Jour. Si le PV est clairement
// présent, on force jour pour éviter un état incohérent côté UI.
state.sun = !nuit || state.pv > 2.0f;
} else {
state.sun = state.pv > 2.0f;
}
enregistrerChangementSoleil(state.sun);
debugLogf("[Modbus][brut] Bruts: 3110(temp)=0x%04X 311A(SOC)=%u 3200=0x%04X 3201=0x%04X sun=%u",
load[4], soc[0], status[0], status[1], state.sun ? 1 : 0);
finaliserLecture();
return true;
}
bool reglerHorlogeEpever(uint16_t annee, uint8_t mois, uint8_t jour,
uint8_t heure, uint8_t minute, uint8_t seconde) {
if (lectureEnCours) {
debugLogf("[Modbus][rtc] Réglage refusé: cycle lecture en cours");
return false;
}
if (annee < 2000 || annee > 2099 || mois < 1 || mois > 12 || jour < 1 || jour > 31 ||
heure > 23 || minute > 59 || seconde > 59) {
debugLogf("[Modbus][rtc] Réglage refusé: date/heure invalide");
return false;
}
uint16_t regs[3];
regs[0] = ((uint16_t)minute << 8) | seconde; // 0x9013
regs[1] = ((uint16_t)jour << 8) | heure; // 0x9014
regs[2] = ((uint16_t)(annee - 2000) << 8) | mois; // 0x9015
lectureEnCours = true;
bool ok = ecrireHoldingMultiplesBrut(0x9013, 3, regs, 1000);
lectureEnCours = false;
if (ok) {
rtcSyncedOnce = false;
lireHorlogeEpever(true);
}
return ok;
}
static void debugBootModbus() {
#if MODBUS_DEBUG_BOOT
debugLogf("--- Diagnostic RS485 boot ---");
debugLogf(" UART ESP32 : Serial2");
debugLogf(" RX GPIO : %d", PIN_RS485_RX);
debugLogf(" TX GPIO : %d", PIN_RS485_TX);
debugLogf(" Adresse Epever : %d", MODBUS_ADRESSE);
debugLogf(" Baud principal : %u", (uint32_t)MODBUS_BAUDRATE);
debugLogf(" Trame test : FC04 registre 0x3104 tension batterie");
debugLogf(" Rappel câblage : Epever A/D+ vers KC868 A, Epever B/D- vers KC868 B, GND recommandé");
bool okPrincipal = probeRegistreBatterie(MODBUS_BAUDRATE);
if (!okPrincipal && MODBUS_BAUDRATE != 9600) probeRegistreBatterie(9600);
if (!okPrincipal && MODBUS_BAUDRATE != 115200) probeRegistreBatterie(115200);
debugLogf("--- Fin diagnostic RS485 boot ---");
#endif
}
// Fin de chaîne — appelé après la dernière lecture
static void finaliserLecture() {
state.rs485_ok = true;
state.last_update = millis();
lectureEnCours = false;
nbLecturesOK++;
debugLogf("Modbus OK #%u — Bat:%.2fV %d%% PV:%.2fV %.2fA Load:%.1fW %s",
nbLecturesOK,
state.battery, state.batSOC, state.pv, state.pvCurrent,
state.loadPower, state.sun ? "JOUR" : "NUIT");
}
static const char* codeModbus(uint8_t code) {
switch (code) {
case 0x01: return "Fonction non supportée";
case 0x02: return "Adresse registre invalide";
case 0x03: return "Valeur invalide";
case 0x04: return "Erreur matérielle esclave";
case 0xE0: return "Timeout (pas de réponse)";
case 0xE1: return "CRC invalide";
case 0xE2: return "Exception générale";
default: return "Inconnu";
}
}
static void erreurLecture(const char *etape, uint8_t code) {
state.rs485_ok = false;
lectureEnCours = false;
nbErreurs++;
derniereErreur = code;
derniereEtape = etape;
debugLogf("Modbus ERREUR #%u [%s] code=0x%02X (%s), ok=%u, uptime=%lums",
nbErreurs, etape, code, codeModbus(code), nbLecturesOK, millis());
debugLogf("[Modbus][aide] Si timeout: vérifier A/B, GND, baudrate, ID=%d, RJ45 Epever non branché sur Ethernet.",
MODBUS_ADRESSE);
}
// Chaîne de lectures : PV → Load → SOC → Status → Energie → JourNuit → fin
static bool cbJourNuit(Modbus::ResultCode ev, uint16_t, void*) {
if (ev == Modbus::EX_SUCCESS) {
// 0x200C FC02 : bit D0 = 1 → Nuit, 0 → Jour
state.sun = !bufJourNuit[0];
} else {
Serial.printf("Modbus [JourNuit] : 0x%02X — ignoré\n", ev);
}
finaliserLecture(); // non-fatal : on finalise dans tous les cas
return true;
}
static bool cbEnergie(Modbus::ResultCode ev, uint16_t, void*) {
if (ev == Modbus::EX_SUCCESS) {
// Lecture depuis 0x3300, registres 32 bits little-endian (L word first)
state.energieGenJour = ((uint32_t)bufEnergie[1] << 16 | bufEnergie[0]) * 0.01f; // 0x3300-01
state.energieGenTotal = ((uint32_t)bufEnergie[7] << 16 | bufEnergie[6]) * 0.01f; // 0x3306-07
state.energieConJour = ((uint32_t)bufEnergie[9] << 16 | bufEnergie[8]) * 0.01f; // 0x3308-09
state.energieConTotal = ((uint32_t)bufEnergie[15] << 16 | bufEnergie[14]) * 0.01f; // 0x330E-0F
tDebutRequete = millis();
mb.readIsts(MODBUS_ADRESSE, 0x200C, bufJourNuit, 1, cbJourNuit); // FC02 discrete input
} else {
Serial.printf("Modbus [Energie] : 0x%02X — ignoré\n", ev);
finaliserLecture(); // non-fatal
}
return true;
}
static bool cbStatus(Modbus::ResultCode ev, uint16_t, void*) {
if (ev != Modbus::EX_SUCCESS) { erreurLecture("Status", ev); return true; }
// 0x3200 D3-D0 : 00=Normal, 01=Over voltage, 02=Under voltage, 03=Over discharge
uint8_t batVoltStatus = bufStatus[0] & 0x0F;
state.batSousVoltage = (batVoltStatus == 2);
state.batSurVoltage = (batVoltStatus == 1);
// 0x3201 D3-D2 : 00=No charge, 01=Float, 02=Boost, 03=Equalization
state.batStatut = (bufStatus[1] >> 2) & 0x03;
tDebutRequete = millis();
mb.readIreg(MODBUS_ADRESSE, 0x3300, bufEnergie, 16, cbEnergie);
return true;
}
static bool cbSOC(Modbus::ResultCode ev, uint16_t, void*) {
if (ev != Modbus::EX_SUCCESS) { erreurLecture("SOC", ev); return true; }
state.batSOC = (uint8_t)bufSOC[0];
tDebutRequete = millis();
mb.readIreg(MODBUS_ADRESSE, 0x3200, bufStatus, 2, cbStatus); // 0x3200 + 0x3201
return true;
}
static bool cbLoad(Modbus::ResultCode ev, uint16_t, void*) {
if (ev != Modbus::EX_SUCCESS) { erreurLecture("Load", ev); return true; }
state.loadVoltage = bufLoad[0] * 0.01f; // 0x310C
state.loadCurrent = bufLoad[1] * 0.01f; // 0x310D
state.loadPower = bufLoad[2] * 0.01f; // 0x310E
// bufLoad[3] = 0x310F réservé
state.batTemperature = (int16_t)bufLoad[4] * 0.01f; // 0x3110 signé
tDebutRequete = millis();
mb.readIreg(MODBUS_ADRESSE, 0x311A, bufSOC, 1, cbSOC);
return true;
}
static bool cbPV(Modbus::ResultCode ev, uint16_t, void*) {
if (ev != Modbus::EX_SUCCESS) { erreurLecture("PV", ev); return true; }
state.pv = bufPV[0] * 0.01f; // 0x3100 Tension PV
state.pvCurrent = bufPV[1] * 0.01f; // 0x3101 Courant PV
state.battery = bufPV[4] * 0.01f; // 0x3104 Tension batterie
tDebutRequete = millis();
mb.readIreg(MODBUS_ADRESSE, 0x310C, bufLoad, 5, cbLoad);
return true;
}
void initModbus() {
Preferences p; p.begin("modbus", true);
intervalleJour = p.getUInt("jour", INTERVALLE_MODBUS);
intervalleNuit = p.getUInt("nuit", 30000UL);
p.end();
debugLogf("--- Modbus init ---");
debugLogf(" Adresse esclave : %d", MODBUS_ADRESSE);
debugLogf(" Baud rate : %u", (uint32_t)MODBUS_BAUDRATE);
debugLogf(" TX GPIO : %d", PIN_RS485_TX);
debugLogf(" RX GPIO : %d", PIN_RS485_RX);
debugLogf(" Timeout requête : %d ms", TIMEOUT_MODBUS);
debugLogf(" Intervalle jour : %u ms", intervalleJour);
debugLogf(" Intervalle nuit : %u ms", intervalleNuit);
debugBootModbus();
Serial2.end();
delay(20);
Serial2.begin(MODBUS_BAUDRATE, SERIAL_8N1, PIN_RS485_RX, PIN_RS485_TX);
viderRx("démarrage ModbusRTU");
mb.begin(&Serial2);
mb.master();
debugLogf(" → Serial2 + Modbus master démarrés");
debugLogf("-------------------");
}
// ---------------------------------------------------------------------------
// API partagée pour epever_config.cpp
// ---------------------------------------------------------------------------
bool isModbusBusy() { return lectureEnCours; }
bool modbusAcquire() { if (lectureEnCours) return false; lectureEnCours = true; return true; }
void modbusRelease() { lectureEnCours = false; }
bool modbusLireHolding(uint16_t reg, uint16_t qty, uint16_t *dest, uint16_t timeoutMs) {
return lireHoldingBruts(reg, qty, dest, timeoutMs);
}
bool modbusEcrireHolding(uint16_t reg, uint16_t qty, const uint16_t *vals, uint16_t timeoutMs) {
return ecrireHoldingMultiplesBrut(reg, qty, vals, timeoutMs);
}
void gererModbus() {
unsigned long maintenant = millis();
if (!lectureEnCours && (maintenant - tDerniereLecture) >= intervalCourant()) {
tDerniereLecture = maintenant;
tDebutRequete = maintenant;
lectureEnCours = true;
debugLogf("[Modbus] Début lecture brute — uptime=%lus, baud=%u, ID=%d, erreurs=%u, dernière=%s/0x%02X",
maintenant / 1000, (uint32_t)MODBUS_BAUDRATE, MODBUS_ADRESSE,
nbErreurs, derniereEtape, derniereErreur);
if (!effectuerLectureBruteEpever()) {
state.rs485_ok = false;
lectureEnCours = false;
debugLogf("[Modbus][brut] Cycle échoué — dernière=%s/0x%02X, erreurs=%u",
derniereEtape, derniereErreur, nbErreurs);
}
}
}
+13
View File
@@ -0,0 +1,13 @@
#include <ElegantOTA.h>
#include "config.h"
#include "webserver.h"
void demarrerOTA() {
ElegantOTA.begin(&server);
Serial.println("OTA disponible sur http://192.168.4.1/update (sans authentification)");
}
// Doit être appelé dans loop() pour que l'OTA async fonctionne
void gererOTA() {
ElegantOTA.loop();
}
+220
View File
@@ -0,0 +1,220 @@
#include <LittleFS.h>
#include <ArduinoJson.h>
#include <Arduino.h>
#include "config.h"
#include "state.h"
#include "rules.h"
#define MAX_REGLES 20
#define FICHIER_REGLES "/rules.json"
struct Regle {
int id;
bool enabled;
// Déclencheurs
int8_t sun; // -1=ignoré, 0=nuit requis, 1=jour requis
int8_t di1; // -1=ignoré, 0=ouvert requis, 1=fermé requis
int8_t di2; // -1=ignoré, 0=ouvert requis, 1=fermé requis
// Conditions
float batteryMin; // seuil min batterie en V (0 = ignoré)
float batteryMax; // seuil max batterie en V (0 = ignoré)
float pvMin; // seuil min PV en V (0 = ignoré)
float pvMax; // seuil max PV en V (0 = ignoré)
// Action
uint8_t relay; // 1 ou 2
bool etat; // true=ON, false=OFF
uint32_t delai; // délai avant action (secondes)
float hysteresis; // bande morte en V
// État runtime — non persisté
bool delaiEnCours;
unsigned long tDebutDelai;
bool estActif;
};
static Regle regles[MAX_REGLES];
static int nbRegles = 0;
static unsigned long tDerniereEval = 0;
// --- Persistance ---
static int8_t parseTri(JsonObject &obj, const char *key) {
if (!obj[key].is<bool>()) return -1;
return obj[key].as<bool>() ? 1 : 0;
}
static void jsonVersRegle(JsonObject obj, Regle &r) {
r.enabled = obj["enabled"] | true;
r.sun = parseTri(obj, "sun");
r.di1 = parseTri(obj, "di1");
r.di2 = parseTri(obj, "di2");
r.batteryMin = obj["battery_min"] | 0.0f;
r.batteryMax = obj["battery_max"] | 0.0f;
r.pvMin = obj["pv_min"] | 0.0f;
r.pvMax = obj["pv_max"] | 0.0f;
r.relay = obj["relay"] | 1;
r.etat = obj["state"] | false;
r.delai = obj["delay"] | 0u;
r.hysteresis = obj["hysteresis"] | 0.0f;
r.delaiEnCours = false;
r.tDebutDelai = 0;
r.estActif = false;
}
static void chargerRegles() {
nbRegles = 0;
if (!LittleFS.exists(FICHIER_REGLES)) {
Serial.println("rules.json absent — aucune règle chargée");
return;
}
File f = LittleFS.open(FICHIER_REGLES, "r");
if (!f) { Serial.println("Erreur ouverture rules.json"); return; }
JsonDocument doc;
if (deserializeJson(doc, f)) {
Serial.println("Erreur parsing rules.json");
f.close();
return;
}
f.close();
for (JsonObject obj : doc.as<JsonArray>()) {
if (nbRegles >= MAX_REGLES) break;
regles[nbRegles].id = obj["id"] | (nbRegles + 1);
jsonVersRegle(obj, regles[nbRegles]);
nbRegles++;
}
Serial.printf("%d règle(s) chargée(s)\n", nbRegles);
}
static bool sauvegarderRegles() {
File f = LittleFS.open(FICHIER_REGLES, "w");
if (!f) { Serial.println("Erreur écriture rules.json"); return false; }
JsonDocument doc;
JsonArray arr = doc.to<JsonArray>();
reglesToJson(arr);
serializeJson(doc, f);
f.close();
return true;
}
// --- Logique d'évaluation ---
// Hystérésis : quand la règle est active, les seuils sont relâchés d'une
// bande `hysteresis` pour éviter les oscillations autour du point de consigne.
static bool conditionsSatisfaites(const Regle &r) {
// Seuils batterie avec hystérésis si la règle était déjà active
float batMinEff = (r.hysteresis > 0 && r.estActif) ? r.batteryMin - r.hysteresis : r.batteryMin;
float batMaxEff = (r.hysteresis > 0 && r.estActif) ? r.batteryMax + r.hysteresis : r.batteryMax;
float pvMinEff = (r.hysteresis > 0 && r.estActif) ? r.pvMin - r.hysteresis : r.pvMin;
float pvMaxEff = (r.hysteresis > 0 && r.estActif) ? r.pvMax + r.hysteresis : r.pvMax;
// Déclencheurs
if (r.sun >= 0 && (bool)(r.sun == 1) != state.sun) return false;
if (r.di1 >= 0 && (bool)(r.di1 == 1) != state.di1) return false;
if (r.di2 >= 0 && (bool)(r.di2 == 1) != state.di2) return false;
// Conditions
if (r.batteryMin > 0 && state.battery < batMinEff) return false;
if (r.batteryMax > 0 && state.battery > batMaxEff) return false;
if (r.pvMin > 0 && state.pv < pvMinEff) return false;
if (r.pvMax > 0 && state.pv > pvMaxEff) return false;
return true;
}
static void appliquerAction(const Regle &r) {
if (r.relay == 1) {
state.relay1 = r.etat;
digitalWrite(PIN_RELAY1, r.etat ? HIGH : LOW);
} else if (r.relay == 2) {
state.relay2 = r.etat;
digitalWrite(PIN_RELAY2, r.etat ? HIGH : LOW);
}
Serial.printf("Règle %d appliquée — relais %d : %s\n", r.id, r.relay, r.etat ? "ON" : "OFF");
}
// --- API publique ---
void initRegles() {
chargerRegles();
}
void gererRegles() {
unsigned long maintenant = millis();
if (maintenant - tDerniereEval < INTERVALLE_REGLES) return;
tDerniereEval = maintenant;
for (int i = 0; i < nbRegles; i++) {
Regle &r = regles[i];
if (!r.enabled) continue;
if (conditionsSatisfaites(r)) {
r.estActif = true;
if (r.delai == 0) {
appliquerAction(r);
} else if (!r.delaiEnCours) {
r.delaiEnCours = true;
r.tDebutDelai = maintenant;
Serial.printf("Règle %d — délai %ds démarré\n", r.id, r.delai);
} else if (maintenant - r.tDebutDelai >= (unsigned long)r.delai * 1000UL) {
appliquerAction(r);
r.delaiEnCours = false;
}
} else {
r.estActif = false;
if (r.delaiEnCours) {
r.delaiEnCours = false;
Serial.printf("Règle %d — conditions perdues, délai annulé\n", r.id);
}
}
}
}
void reglesToJson(JsonArray arr) {
for (int i = 0; i < nbRegles; i++) {
const Regle &r = regles[i];
JsonObject obj = arr.add<JsonObject>();
obj["id"] = r.id;
obj["enabled"] = r.enabled;
if (r.sun >= 0) obj["sun"] = (bool)(r.sun == 1);
if (r.di1 >= 0) obj["di1"] = (bool)(r.di1 == 1);
if (r.di2 >= 0) obj["di2"] = (bool)(r.di2 == 1);
if (r.batteryMin > 0) obj["battery_min"] = r.batteryMin;
if (r.batteryMax > 0) obj["battery_max"] = r.batteryMax;
if (r.pvMin > 0) obj["pv_min"] = r.pvMin;
if (r.pvMax > 0) obj["pv_max"] = r.pvMax;
obj["relay"] = r.relay;
obj["state"] = r.etat;
obj["delay"] = r.delai;
if (r.hysteresis > 0) obj["hysteresis"] = r.hysteresis;
}
}
bool ajouterRegle(JsonObject obj) {
if (nbRegles >= MAX_REGLES) return false;
int maxId = 0;
for (int i = 0; i < nbRegles; i++) if (regles[i].id > maxId) maxId = regles[i].id;
regles[nbRegles].id = maxId + 1;
jsonVersRegle(obj, regles[nbRegles]);
nbRegles++;
return sauvegarderRegles();
}
bool supprimerRegle(int id) {
for (int i = 0; i < nbRegles; i++) {
if (regles[i].id != id) continue;
for (int j = i; j < nbRegles - 1; j++) regles[j] = regles[j + 1];
nbRegles--;
return sauvegarderRegles();
}
return false;
}
bool toggleRegle(int id) {
for (int i = 0; i < nbRegles; i++) {
if (regles[i].id != id) continue;
regles[i].enabled = !regles[i].enabled;
return sauvegarderRegles();
}
return false;
}
+155
View File
@@ -0,0 +1,155 @@
#include <WiFi.h>
#include <LittleFS.h>
#include <ArduinoJson.h>
#include <Arduino.h>
#include <esp_sleep.h>
#include "config.h"
#include "state.h"
// Persisté en mémoire RTC — survit au deep sleep, perdu au power-off complet
RTC_DATA_ATTR static bool rtcSleepActif = false; // désactivé par défaut
RTC_DATA_ATTR static uint32_t rtcIntervalle = 600; // secondes entre réveil
RTC_DATA_ATTR static float rtcSeuilSoleil = 2.0f; // V PV minimum = jour
RTC_DATA_ATTR static bool rtcRelay1 = false; // état relais sauvegardé
RTC_DATA_ATTR static bool rtcRelay2 = false;
// Runtime
static bool enModeNuit = false;
static unsigned long tDebutNuit = 0;
#define TEMPO_CONFIRMATION_NUIT 60000UL // 60s de nuit confirmée avant de dormir
#define FICHIER_SLEEP "/sleep.json"
// --- Utilitaires ---
static uint16_t crc16Modbus(const uint8_t *buf, int len) {
uint16_t crc = 0xFFFF;
for (int i = 0; i < len; i++) {
crc ^= buf[i];
for (int b = 0; b < 8; b++)
crc = (crc & 1) ? (crc >> 1) ^ 0xA001 : crc >> 1;
}
return crc;
}
// Lecture synchrone de la tension PV via Modbus RTU brut
// Utilisé uniquement au réveil, avant que le serveur web soit démarré
static float lirePVSync() {
uint8_t req[8] = { MODBUS_ADRESSE, 0x04, 0x31, 0x00, 0x00, 0x01, 0x00, 0x00 };
uint16_t crc = crc16Modbus(req, 6);
req[6] = crc & 0xFF;
req[7] = crc >> 8;
while (Serial2.available()) Serial2.read(); // vider buffer résiduel
Serial2.write(req, 8);
Serial2.flush();
unsigned long t = millis();
while (Serial2.available() < 7 && millis() - t < 300);
if (Serial2.available() < 7) return -1.0f;
uint8_t resp[7];
Serial2.readBytes(resp, 7);
if (resp[0] != MODBUS_ADRESSE || resp[1] != 0x04 || resp[2] != 2) return -1.0f;
return ((resp[3] << 8) | resp[4]) * 0.01f;
}
static void entrerEnDeepSleep() {
Serial.printf("Deep sleep — réveil dans %ds\n", rtcIntervalle);
Serial.flush();
WiFi.mode(WIFI_OFF);
delay(50);
esp_sleep_enable_timer_wakeup((uint64_t)rtcIntervalle * 1000000ULL);
esp_deep_sleep_start();
// Ne revient jamais ici
}
// --- API publique ---
void verifierEtDormirSiNuit() {
if (esp_sleep_get_wakeup_cause() != ESP_SLEEP_WAKEUP_TIMER) return;
if (!rtcSleepActif) return;
Serial.println("Réveil timer — vérification ensoleillement...");
Serial2.begin(9600, SERIAL_8N1, PIN_RS485_RX, PIN_RS485_TX);
delay(50);
float pv = lirePVSync();
Serial2.end();
Serial.printf("PV = %.2fV (seuil %.1fV)\n", pv, rtcSeuilSoleil);
if (pv >= 0.0f && pv < rtcSeuilSoleil) {
Serial.println("Toujours nuit → re-sleep");
entrerEnDeepSleep(); // ne revient pas
}
Serial.println("Jour détecté → démarrage complet");
}
void restaurerRelais() {
if (esp_sleep_get_wakeup_cause() != ESP_SLEEP_WAKEUP_TIMER) return;
state.relay1 = rtcRelay1;
state.relay2 = rtcRelay2;
digitalWrite(PIN_RELAY1, rtcRelay1 ? HIGH : LOW);
digitalWrite(PIN_RELAY2, rtcRelay2 ? HIGH : LOW);
Serial.printf("Relais restaurés — R1:%d R2:%d\n", rtcRelay1, rtcRelay2);
}
void chargerConfigSleep() {
if (!LittleFS.exists(FICHIER_SLEEP)) return;
File f = LittleFS.open(FICHIER_SLEEP, "r");
if (!f) return;
JsonDocument doc;
if (!deserializeJson(doc, f)) {
rtcSleepActif = doc["actif"] | false;
rtcIntervalle = doc["intervalle"] | 600u;
rtcSeuilSoleil = doc["seuil"] | 2.0f;
}
f.close();
Serial.printf("Sleep config — actif:%d intervalle:%ds seuil:%.1fV\n",
rtcSleepActif, rtcIntervalle, rtcSeuilSoleil);
}
void gererSleep() {
if (!rtcSleepActif) return;
if (!state.rs485_ok) return; // pas de données fiables — ne pas dormir
unsigned long maintenant = millis();
if (!state.sun) {
if (!enModeNuit) {
enModeNuit = true;
tDebutNuit = maintenant;
Serial.printf("Nuit — sleep dans %lus si confirmé\n", TEMPO_CONFIRMATION_NUIT / 1000);
return;
}
if (maintenant - tDebutNuit < TEMPO_CONFIRMATION_NUIT) return;
rtcRelay1 = state.relay1; // sauvegarder état relais en RTC
rtcRelay2 = state.relay2;
entrerEnDeepSleep(); // ne revient pas
} else {
enModeNuit = false;
}
}
void getSleepConfigJson(String &out) {
JsonDocument doc;
doc["actif"] = rtcSleepActif;
doc["intervalle"] = rtcIntervalle;
doc["seuil"] = rtcSeuilSoleil;
serializeJson(doc, out);
}
bool setSleepConfig(bool actif, uint32_t intervalle, float seuil) {
rtcSleepActif = actif;
rtcIntervalle = intervalle;
rtcSeuilSoleil = seuil;
File f = LittleFS.open(FICHIER_SLEEP, "w");
if (!f) return false;
JsonDocument doc;
doc["actif"] = actif;
doc["intervalle"] = intervalle;
doc["seuil"] = seuil;
serializeJson(doc, f);
f.close();
return true;
}
+512
View File
@@ -0,0 +1,512 @@
#include <ESPAsyncWebServer.h>
#include <AsyncJson.h>
#include <LittleFS.h>
#include <ArduinoJson.h>
#include <Preferences.h>
#include <time.h>
#include "config.h"
#include "state.h"
#include "webserver.h"
#include "rules.h"
#include "sleep.h"
#include "historique.h"
#include "modbus_epever.h"
#include "epever_config.h"
#include "wifi_ap.h"
#include "wireguard_vpn.h"
#include "debug_log.h"
AsyncWebServer server(80);
// --- Persistance relais (NVS — survit au power-off) ---
static void sauvegarderRelaisNVS() {
Preferences prefs;
prefs.begin("relais", false);
prefs.putBool("r1", state.relay1);
prefs.putBool("r2", state.relay2);
prefs.end();
Serial.printf("[NVS] Relais sauvegardés — R1:%d R2:%d\n", state.relay1, state.relay2);
}
void restaurerRelaisNVS() {
Preferences prefs;
prefs.begin("relais", true);
state.relay1 = prefs.getBool("r1", false);
state.relay2 = prefs.getBool("r2", false);
prefs.end();
digitalWrite(PIN_RELAY1, state.relay1 ? HIGH : LOW);
digitalWrite(PIN_RELAY2, state.relay2 ? HIGH : LOW);
Serial.printf("[NVS] Relais restaurés — R1:%d R2:%d\n", state.relay1, state.relay2);
}
// Sérialise l'état système en JSON et répond à la requête
static void envoyerEtat(AsyncWebServerRequest *request) {
JsonDocument doc;
// PV
doc["pv"] = state.pv;
doc["pvCurrent"] = state.pvCurrent;
// Batterie
doc["battery"] = state.battery;
doc["batSOC"] = state.batSOC;
doc["batTemperature"] = state.batTemperature;
doc["batStatut"] = state.batStatut;
doc["batSousVoltage"] = state.batSousVoltage;
doc["batSurVoltage"] = state.batSurVoltage;
// Load
doc["loadVoltage"] = state.loadVoltage;
doc["loadCurrent"] = state.loadCurrent;
doc["loadPower"] = state.loadPower;
// Énergie kWh
doc["energieGenJour"] = state.energieGenJour;
doc["energieGenTotal"] = state.energieGenTotal;
doc["energieConJour"] = state.energieConJour;
doc["energieConTotal"] = state.energieConTotal;
// Général
doc["sun"] = state.sun;
doc["espClockOk"] = state.espClockOk;
if (state.espClockOk) {
time_t now = time(nullptr);
struct tm tmNow;
localtime_r(&now, &tmNow);
char espTime[20];
snprintf(espTime, sizeof(espTime), "%04d-%02d-%02d %02d:%02d:%02d",
tmNow.tm_year + 1900, tmNow.tm_mon + 1, tmNow.tm_mday,
tmNow.tm_hour, tmNow.tm_min, tmNow.tm_sec);
doc["espTime"] = espTime;
} else {
doc["espTime"] = "--";
}
doc["epeverClockOk"] = state.epeverClockOk;
if (state.epeverClockOk) {
char rtc[20];
snprintf(rtc, sizeof(rtc), "%04u-%02u-%02u %02u:%02u:%02u",
state.epeverYear, state.epeverMonth, state.epeverDay,
state.epeverHour, state.epeverMinute, state.epeverSecond);
doc["epeverTime"] = rtc;
} else {
doc["epeverTime"] = "--";
}
doc["relay1"] = state.relay1;
doc["relay2"] = state.relay2;
doc["di1"] = state.di1;
doc["di2"] = state.di2;
doc["autoMode"] = state.autoMode;
doc["rs485_ok"] = state.rs485_ok;
doc["last_update"] = state.last_update;
String json;
serializeJson(doc, json);
request->send(200, "application/json", json);
}
// Commande un relais et met à jour l'état global
static void commanderRelais(AsyncWebServerRequest *request, int relais, bool etat) {
if (relais == 1) {
state.relay1 = etat;
digitalWrite(PIN_RELAY1, etat ? HIGH : LOW);
} else if (relais == 2) {
state.relay2 = etat;
digitalWrite(PIN_RELAY2, etat ? HIGH : LOW);
}
Serial.printf("[WEB] Relais %d → %s (client %s)\n",
relais, etat ? "ON" : "OFF",
request->client()->remoteIP().toString().c_str());
request->send(200, "application/json", "{\"ok\":true}");
}
void demarrerWebserveur() {
if (!LittleFS.begin(true)) {
Serial.println("Erreur : impossible de monter LittleFS");
return;
}
Serial.println("LittleFS monté");
// --- API REST (définie avant le handler statique) ---
server.on("/api/state", HTTP_GET, envoyerEtat);
server.on("/api/debug/logs", HTTP_GET, [](AsyncWebServerRequest *r) {
String json;
getDebugLogJson(json);
r->send(200, "application/json", json);
});
server.on("/api/debug/clear", HTTP_POST, [](AsyncWebServerRequest *r) {
clearDebugLog();
r->send(200, "application/json", "{\"ok\":true}");
});
server.on("/api/sun/history", HTTP_GET, [](AsyncWebServerRequest *r) {
JsonDocument doc;
JsonArray arr = doc["changes"].to<JsonArray>();
for (uint8_t i = 0; i < state.sunHistoryCount; i++) {
uint8_t idx = (state.sunHistoryHead + 5 - state.sunHistoryCount + i) % 5;
JsonObject item = arr.add<JsonObject>();
item["sun"] = state.sunHistoryState[idx];
item["label"] = state.sunHistoryState[idx] ? "Jour" : "Nuit";
item["time"] = state.sunHistoryTime[idx];
}
String json;
serializeJson(doc, json);
r->send(200, "application/json", json);
});
server.on("/api/relay/1/on", HTTP_POST, [](AsyncWebServerRequest *r){ commanderRelais(r, 1, true); });
server.on("/api/relay/1/off", HTTP_POST, [](AsyncWebServerRequest *r){ commanderRelais(r, 1, false); });
server.on("/api/relay/2/on", HTTP_POST, [](AsyncWebServerRequest *r){ commanderRelais(r, 2, true); });
server.on("/api/relay/2/off", HTTP_POST, [](AsyncWebServerRequest *r){ commanderRelais(r, 2, false); });
// Toggle + sauvegarde NVS (appui long dashboard)
server.on("/api/relay/1/toggle", HTTP_POST, [](AsyncWebServerRequest *r){
state.relay1 = !state.relay1;
digitalWrite(PIN_RELAY1, state.relay1 ? HIGH : LOW);
sauvegarderRelaisNVS();
Serial.printf("[WEB] Relais 1 toggle → %s (NVS sauvegardé)\n", state.relay1 ? "ON" : "OFF");
r->send(200, "application/json", "{\"ok\":true}");
});
server.on("/api/relay/2/toggle", HTTP_POST, [](AsyncWebServerRequest *r){
state.relay2 = !state.relay2;
digitalWrite(PIN_RELAY2, state.relay2 ? HIGH : LOW);
sauvegarderRelaisNVS();
Serial.printf("[WEB] Relais 2 toggle → %s (NVS sauvegardé)\n", state.relay2 ? "ON" : "OFF");
r->send(200, "application/json", "{\"ok\":true}");
});
server.on("/api/reboot", HTTP_POST, [](AsyncWebServerRequest *r){
Serial.println("[WEB] Reboot demandé");
r->send(200, "application/json", "{\"ok\":true}");
delay(200);
ESP.restart();
});
auto *handlerEpeverTime = new AsyncCallbackJsonWebHandler("/api/epever/time",
[](AsyncWebServerRequest *r, JsonVariant &json) {
JsonObject obj = json.as<JsonObject>();
uint16_t year = obj["year"] | 0;
uint8_t month = obj["month"] | 0;
uint8_t day = obj["day"] | 0;
uint8_t hour = obj["hour"] | 0;
uint8_t minute = obj["minute"] | 0;
uint8_t second = obj["second"] | 0;
bool ok = reglerHorlogeEpever(year, month, day, hour, minute, second);
r->send(ok ? 200 : 409, "application/json", ok ? "{\"ok\":true}" : "{\"ok\":false}");
});
server.addHandler(handlerEpeverTime);
// --- API règles ---
server.on("/api/rules", HTTP_GET, [](AsyncWebServerRequest *r) {
JsonDocument doc;
reglesToJson(doc.to<JsonArray>());
String json;
serializeJson(doc, json);
r->send(200, "application/json", json);
});
server.on("/api/rules/toggle", HTTP_POST, [](AsyncWebServerRequest *r) {
if (!r->hasParam("id")) { r->send(400); return; }
int id = r->getParam("id")->value().toInt();
bool ok = toggleRegle(id);
Serial.printf("[WEB] Règle %d toggle → %s\n", id, ok ? "ok" : "introuvable");
r->send(ok ? 200 : 404, "application/json", "{\"ok\":true}");
});
server.on("/api/rules/delete", HTTP_POST, [](AsyncWebServerRequest *r) {
if (!r->hasParam("id")) { r->send(400); return; }
int id = r->getParam("id")->value().toInt();
bool ok = supprimerRegle(id);
Serial.printf("[WEB] Règle %d supprimée → %s\n", id, ok ? "ok" : "introuvable");
r->send(ok ? 200 : 404, "application/json", "{\"ok\":true}");
});
// Ajout de règle — corps JSON
auto *handlerRegle = new AsyncCallbackJsonWebHandler("/api/rules",
[](AsyncWebServerRequest *r, JsonVariant &json) {
bool ok = ajouterRegle(json.as<JsonObject>());
Serial.printf("[WEB] Ajout règle → %s\n", ok ? "ok" : "erreur");
r->send(ok ? 201 : 500, "application/json", ok ? "{\"ok\":true}" : "{\"ok\":false}");
});
server.addHandler(handlerRegle);
// --- API historique ---
server.on("/api/history", HTTP_GET, [](AsyncWebServerRequest *r) {
String json;
getHistoriqueJson(json); // lores : 30h, 5 min
r->send(200, "application/json", json);
});
server.on("/api/history/hires", HTTP_GET, [](AsyncWebServerRequest *r) {
String json;
getHistoriqueHiresJson(json); // hires : 4h, 1 min
r->send(200, "application/json", json);
});
server.on("/api/history/status", HTTP_GET, [](AsyncWebServerRequest *r) {
String json;
getHistoriqueStatusJson(json);
r->send(200, "application/json", json);
});
server.on("/api/history/csv", HTTP_GET, [](AsyncWebServerRequest *r) {
String csv;
getHistoriqueCsv(csv);
AsyncWebServerResponse *resp = r->beginResponse(200, "text/csv", csv);
resp->addHeader("Content-Disposition", "attachment; filename=\"historique.csv\"");
r->send(resp);
});
// --- API noms (relais / entrées) ---
server.on("/api/names", HTTP_GET, [](AsyncWebServerRequest *r) {
Preferences p; p.begin("noms", true);
JsonDocument doc;
doc["relay1"] = p.getString("r1", "Relais 1");
doc["relay2"] = p.getString("r2", "Relais 2");
doc["di1"] = p.getString("d1", "Entrée 1");
doc["di2"] = p.getString("d2", "Entrée 2");
p.end();
String json; serializeJson(doc, json);
r->send(200, "application/json", json);
});
auto *handlerNoms = new AsyncCallbackJsonWebHandler("/api/names",
[](AsyncWebServerRequest *r, JsonVariant &json) {
JsonObject obj = json.as<JsonObject>();
Preferences p; p.begin("noms", false);
if (obj["relay1"].is<const char*>()) p.putString("r1", obj["relay1"].as<const char*>());
if (obj["relay2"].is<const char*>()) p.putString("r2", obj["relay2"].as<const char*>());
if (obj["di1"].is<const char*>()) p.putString("d1", obj["di1"].as<const char*>());
if (obj["di2"].is<const char*>()) p.putString("d2", obj["di2"].as<const char*>());
p.end();
Serial.println("[NVS] Noms sauvegardés");
r->send(200, "application/json", "{\"ok\":true}");
});
server.addHandler(handlerNoms);
// --- API sleep / config ---
server.on("/api/sleep", HTTP_GET, [](AsyncWebServerRequest *r) {
String json;
getSleepConfigJson(json);
r->send(200, "application/json", json);
});
auto *handlerSleep = new AsyncCallbackJsonWebHandler("/api/sleep",
[](AsyncWebServerRequest *r, JsonVariant &json) {
JsonObject obj = json.as<JsonObject>();
bool actif = obj["actif"] | false;
uint32_t inv = obj["intervalle"] | 600u;
float seuil = obj["seuil"] | 2.0f;
bool ok = setSleepConfig(actif, inv, seuil);
Serial.printf("[WEB] Sleep config — actif:%d intervalle:%ds seuil:%.1fV → %s\n",
actif, inv, seuil, ok ? "ok" : "erreur");
r->send(ok ? 200 : 500, "application/json", ok ? "{\"ok\":true}" : "{\"ok\":false}");
});
server.addHandler(handlerSleep);
// --- API configuration EPEVER ---
// GET /api/epever/config → lecture depuis l'Epever + retour JSON
server.on("/api/epever/config", HTTP_GET, [](AsyncWebServerRequest *r) {
String json;
bool ok = lireConfigEpever();
if (!ok) {
r->send(503, "application/json", "{\"ok\":false,\"erreur\":\"RS485 indisponible ou Modbus occupe\"}");
return;
}
getConfigJson(json);
r->send(200, "application/json", json);
});
// POST /api/epever/config → écriture vers l'Epever
auto *handlerConfigWrite = new AsyncCallbackJsonWebHandler("/api/epever/config",
[](AsyncWebServerRequest *r, JsonVariant &json) {
JsonObject obj = json.as<JsonObject>();
String erreur;
bool ok = ecrireConfigEpever(obj, erreur);
if (ok) {
r->send(200, "application/json", "{\"ok\":true}");
} else {
String body = "{\"ok\":false,\"erreur\":\"" + erreur + "\"}";
r->send(409, "application/json", body);
}
});
server.addHandler(handlerConfigWrite);
// GET /api/epever/config/saved → lecture sauvegarde LittleFS
server.on("/api/epever/config/saved", HTTP_GET, [](AsyncWebServerRequest *r) {
String json;
getConfigSauvegardeJson(json);
r->send(200, "application/json", json);
});
// POST /api/epever/config/save → sauvegarde cache → LittleFS
server.on("/api/epever/config/save", HTTP_POST, [](AsyncWebServerRequest *r) {
bool ok = sauvegarderConfigJson();
r->send(ok ? 200 : 500, "application/json",
ok ? "{\"ok\":true}" : "{\"ok\":false,\"erreur\":\"Cache vide ou LittleFS inaccessible\"}");
});
// POST /api/epever/config/restore → LittleFS → Epever
server.on("/api/epever/config/restore", HTTP_POST, [](AsyncWebServerRequest *r) {
String erreur;
bool ok = restaurerConfigJson(erreur);
if (ok) {
r->send(200, "application/json", "{\"ok\":true}");
} else {
String body = "{\"ok\":false,\"erreur\":\"" + erreur + "\"}";
r->send(409, "application/json", body);
}
});
// --- API intervalles Modbus ---
server.on("/api/modbus", HTTP_GET, [](AsyncWebServerRequest *r) {
uint32_t jour, nuit;
getIntervallesModbus(jour, nuit);
JsonDocument doc;
doc["jour"] = jour;
doc["nuit"] = nuit;
String json; serializeJson(doc, json);
r->send(200, "application/json", json);
});
auto *handlerModbus = new AsyncCallbackJsonWebHandler("/api/modbus",
[](AsyncWebServerRequest *r, JsonVariant &json) {
JsonObject obj = json.as<JsonObject>();
uint32_t jour = obj["jour"] | 5000u;
uint32_t nuit = obj["nuit"] | 30000u;
jour = constrain(jour, 1000u, 60000u);
nuit = constrain(nuit, 5000u, 300000u);
setIntervallesModbus(jour, nuit);
r->send(200, "application/json", "{\"ok\":true}");
});
server.addHandler(handlerModbus);
// --- API WiFi ---
// Statut complet AP + STA
server.on("/api/wifi/status", HTTP_GET, [](AsyncWebServerRequest *r) {
String json;
getWifiStatusJson(json);
r->send(200, "application/json", json);
});
// Scan réseaux (synchrone ~3-5s)
server.on("/api/wifi/scan", HTTP_GET, [](AsyncWebServerRequest *r) {
String json;
scannerReseauxJson(json);
r->send(200, "application/json", json);
});
// Connexion à un réseau WiFi existant
auto *handlerWifiSta = new AsyncCallbackJsonWebHandler("/api/wifi/sta",
[](AsyncWebServerRequest *r, JsonVariant &json) {
JsonObject obj = json.as<JsonObject>();
const char *ssid = obj["ssid"] | "";
const char *pass = obj["pass"] | "";
if (!ssid || strlen(ssid) == 0) {
r->send(400, "application/json", "{\"ok\":false,\"erreur\":\"SSID manquant\"}");
return;
}
if (strlen(ssid) > 32 || strlen(pass) > 64) {
r->send(400, "application/json", "{\"ok\":false,\"erreur\":\"SSID ou mot de passe trop long\"}");
return;
}
connecterWifiSTA(ssid, pass);
r->send(200, "application/json", "{\"ok\":true}");
});
server.addHandler(handlerWifiSta);
// Oublier le réseau STA (retour AP seul)
server.on("/api/wifi/sta/disconnect", HTTP_POST, [](AsyncWebServerRequest *r) {
deconnecterWifiSTA();
r->send(200, "application/json", "{\"ok\":true}");
});
// Hostname mDNS
server.on("/api/mdns", HTTP_GET, [](AsyncWebServerRequest *r) {
JsonDocument doc;
doc["hostname"] = getMdnsHostname();
String json; serializeJson(doc, json);
r->send(200, "application/json", json);
});
auto *handlerMdns = new AsyncCallbackJsonWebHandler("/api/mdns",
[](AsyncWebServerRequest *r, JsonVariant &json) {
const char *nom = json["hostname"] | "";
bool ok = setMdnsHostname(nom);
r->send(ok ? 200 : 400, "application/json",
ok ? "{\"ok\":true}" : "{\"ok\":false,\"erreur\":\"Nom invalide (alphanum + tirets, 1-63 car.)\"}");
});
server.addHandler(handlerMdns);
// --- API WireGuard ---
server.on("/api/wireguard", HTTP_GET, [](AsyncWebServerRequest *r) {
String json;
getWireGuardJson(json);
r->send(200, "application/json", json);
});
auto *handlerWg = new AsyncCallbackJsonWebHandler("/api/wireguard",
[](AsyncWebServerRequest *r, JsonVariant &json) {
JsonObject obj = json.as<JsonObject>();
bool enabled = obj["enabled"] | false;
const char* prv = obj["privkey"] | "";
const char* pub = obj["pubkey"] | "";
const char* psk = obj["psk"] | "";
const char* ep = obj["endpoint"] | "";
uint16_t port = obj["port"] | 51820u;
const char* ip = obj["localip"] | "";
uint16_t ka = obj["keepalive"] | 25u;
if (!prv[0] || !pub[0] || !ep[0] || !ip[0]) {
r->send(400, "application/json", "{\"ok\":false,\"erreur\":\"Champs obligatoires manquants\"}");
return;
}
bool ok = setWireGuardConfig(enabled, prv, pub, psk, ep, port, ip, ka);
r->send(ok ? 200 : 500, "application/json", ok ? "{\"ok\":true}" : "{\"ok\":false}");
});
server.addHandler(handlerWg);
// Compat : retourne info AP (SSID + IP)
server.on("/api/wifi", HTTP_GET, [](AsyncWebServerRequest *r) {
JsonDocument doc;
doc["ssid"] = WIFI_SSID;
doc["password"] = WIFI_PASSWORD;
String json; serializeJson(doc, json);
r->send(200, "application/json", json);
});
// --- Captive portal --- iOS, Android, Windows détectent l'absence d'internet
// et ouvrent automatiquement le navigateur sur notre page principale.
auto redirect = [](AsyncWebServerRequest *r) {
r->redirect("http://192.168.4.1/");
};
// iOS / macOS
server.on("/hotspot-detect.html", HTTP_GET, redirect);
server.on("/library/test/success.html", HTTP_GET, redirect);
server.on("/canonical.html", HTTP_GET, redirect);
// Android
server.on("/generate_204", HTTP_GET, redirect);
server.on("/gen_204", HTTP_GET, redirect);
server.on("/connecttest.txt", HTTP_GET, redirect);
// Windows
server.on("/ncsi.txt", HTTP_GET, redirect);
server.on("/redirect", HTTP_GET, redirect);
server.on("/success.txt", HTTP_GET, redirect);
// --- Fichiers statiques depuis LittleFS ---
server.serveStatic("/", LittleFS, "/").setDefaultFile("index.html");
server.onNotFound([](AsyncWebServerRequest *r){
// Tout GET inconnu → portail captif (navigateur s'ouvre sur la page d'accueil)
if (r->method() == HTTP_GET) {
r->redirect("http://192.168.4.1/");
} else {
r->send(404, "text/plain", "Non trouvé");
}
});
server.begin();
Serial.println("Serveur web démarré sur http://192.168.4.1");
}
+283
View File
@@ -0,0 +1,283 @@
#include <WiFi.h>
#include <DNSServer.h>
#include <ESPmDNS.h>
#include <Preferences.h>
#include <ArduinoJson.h>
#include <Arduino.h>
#include "config.h"
#include "wifi_ap.h"
static DNSServer dnsServer;
// --- mDNS ---
static String mdnsHostname = "pv"; // default → pv.local
static void chargerMdnsNVS() {
Preferences p;
p.begin("mdns", true);
mdnsHostname = p.getString("host", "pv");
p.end();
// nettoyer : uniquement alphanum + tirets, lowercase
String clean;
for (char c : mdnsHostname) {
c = tolower(c);
if (isalnum(c) || c == '-') clean += c;
}
if (clean.isEmpty()) clean = "pv";
mdnsHostname = clean;
}
static void demarrerMdns() {
if (MDNS.begin(mdnsHostname.c_str())) {
MDNS.addService("http", "tcp", 80);
Serial.printf("[mDNS] Accessible sur http://%s.local\n", mdnsHostname.c_str());
} else {
Serial.println("[mDNS] Échec démarrage");
}
}
// --- État STA ---
static bool staConfiguree = false;
static bool staConnectee = false;
static String staSSID;
static String staPass;
static unsigned long tDerniereTentative = 0;
static const unsigned long RECONNECT_INTERVAL_MS = 30000UL;
// ---------------------------------------------------------------------------
// NVS helpers
// ---------------------------------------------------------------------------
static void chargerCredentialsNVS() {
Preferences p;
p.begin("wifi_sta", true);
staSSID = p.getString("ssid", "");
staPass = p.getString("pass", "");
p.end();
staConfiguree = staSSID.length() > 0;
}
static void sauvegarderCredentialsNVS(const char *ssid, const char *pass) {
Preferences p;
p.begin("wifi_sta", false);
p.putString("ssid", ssid);
p.putString("pass", pass);
p.end();
}
static void effacerCredentialsNVS() {
Preferences p;
p.begin("wifi_sta", false);
p.remove("ssid");
p.remove("pass");
p.end();
}
// ---------------------------------------------------------------------------
// Événements WiFi
// ---------------------------------------------------------------------------
static void onWifiEvent(WiFiEvent_t event, WiFiEventInfo_t info) {
switch (event) {
case ARDUINO_EVENT_WIFI_AP_STACONNECTED:
Serial.printf("[WiFi][AP] Client connecté — MAC %02X:%02X:%02X:%02X:%02X:%02X clients:%d\n",
info.wifi_ap_staconnected.mac[0], info.wifi_ap_staconnected.mac[1],
info.wifi_ap_staconnected.mac[2], info.wifi_ap_staconnected.mac[3],
info.wifi_ap_staconnected.mac[4], info.wifi_ap_staconnected.mac[5],
WiFi.softAPgetStationNum());
break;
case ARDUINO_EVENT_WIFI_AP_STADISCONNECTED:
Serial.printf("[WiFi][AP] Client déconnecté — clients restants:%d\n",
WiFi.softAPgetStationNum());
break;
case ARDUINO_EVENT_WIFI_STA_GOT_IP:
staConnectee = true;
Serial.printf("[WiFi][STA] Connecté — SSID:%s IP:%s RSSI:%d dBm\n",
staSSID.c_str(),
WiFi.localIP().toString().c_str(),
WiFi.RSSI());
break;
case ARDUINO_EVENT_WIFI_STA_DISCONNECTED:
if (staConnectee) {
staConnectee = false;
tDerniereTentative = millis();
Serial.printf("[WiFi][STA] Déconnecté de %s — nouvelle tentative dans %lus\n",
staSSID.c_str(), RECONNECT_INTERVAL_MS / 1000);
}
break;
default:
break;
}
}
// ---------------------------------------------------------------------------
// Démarrage
// ---------------------------------------------------------------------------
void demarrerWifi() {
chargerMdnsNVS();
WiFi.onEvent(onWifiEvent);
// AP + STA simultanés — l'AP est toujours actif comme filet de sécurité
WiFi.mode(WIFI_AP_STA);
WiFi.softAPConfig(WIFI_IP, WIFI_GATEWAY, WIFI_SUBNET);
if (strlen(WIFI_PASSWORD) > 0) {
WiFi.softAP(WIFI_SSID, WIFI_PASSWORD);
} else {
WiFi.softAP(WIFI_SSID);
}
Serial.printf("[WiFi][AP] Démarré — SSID:%s IP:%s\n",
WIFI_SSID, WiFi.softAPIP().toString().c_str());
// Captive portal — tous les noms DNS → 192.168.4.1 pour les clients AP
dnsServer.setErrorReplyCode(DNSReplyCode::NoError);
dnsServer.start(53, "*", WIFI_IP);
demarrerMdns();
// Tentative de connexion STA si credentials sauvegardés
chargerCredentialsNVS();
if (staConfiguree) {
Serial.printf("[WiFi][STA] Tentative de connexion à %s…\n", staSSID.c_str());
WiFi.begin(staSSID.c_str(), staPass.c_str());
tDerniereTentative = millis();
}
}
// ---------------------------------------------------------------------------
// Boucle principale (remplace traiterDNS)
// ---------------------------------------------------------------------------
void gererWifi() {
dnsServer.processNextRequest();
// Reconnexion automatique STA si configuré et déconnecté
if (staConfiguree && !staConnectee) {
unsigned long maintenant = millis();
if ((maintenant - tDerniereTentative) >= RECONNECT_INTERVAL_MS) {
tDerniereTentative = maintenant;
Serial.printf("[WiFi][STA] Reconnexion à %s…\n", staSSID.c_str());
WiFi.disconnect(false);
WiFi.begin(staSSID.c_str(), staPass.c_str());
}
}
}
// ---------------------------------------------------------------------------
// API publique — connexion / déconnexion
// ---------------------------------------------------------------------------
void connecterWifiSTA(const char *ssid, const char *pass) {
staSSID = ssid;
staPass = pass;
staConfiguree = true;
staConnectee = false;
tDerniereTentative = millis();
sauvegarderCredentialsNVS(ssid, pass);
WiFi.disconnect(false);
delay(100);
WiFi.begin(ssid, pass);
Serial.printf("[WiFi][STA] Connexion lancée vers %s\n", ssid);
}
void deconnecterWifiSTA() {
staConfiguree = false;
staConnectee = false;
staSSID = "";
staPass = "";
effacerCredentialsNVS();
WiFi.disconnect(true);
Serial.println("[WiFi][STA] Credentials effacés, STA déconnectée");
}
// ---------------------------------------------------------------------------
// API publique — scan réseaux
// ---------------------------------------------------------------------------
void scannerReseauxJson(String &out) {
int n = WiFi.scanNetworks(false, false); // synchrone, pas de réseaux cachés
JsonDocument doc;
doc["ok"] = (n >= 0);
if (n < 0) {
doc["erreur"] = "Scan échoué";
serializeJson(doc, out);
return;
}
JsonArray arr = doc["networks"].to<JsonArray>();
for (int i = 0; i < n; i++) {
String s = WiFi.SSID(i);
if (s.isEmpty()) continue; // ignorer réseaux cachés
JsonObject net = arr.add<JsonObject>();
net["ssid"] = s;
net["rssi"] = WiFi.RSSI(i);
net["secured"] = (WiFi.encryptionType(i) != WIFI_AUTH_OPEN);
}
WiFi.scanDelete();
serializeJson(doc, out);
}
// ---------------------------------------------------------------------------
// API publique — statut JSON
// ---------------------------------------------------------------------------
// ---------------------------------------------------------------------------
// API publique — mDNS
// ---------------------------------------------------------------------------
String getMdnsHostname() {
return mdnsHostname;
}
bool setMdnsHostname(const char *nom) {
String clean;
for (const char *p = nom; *p; p++) {
char c = tolower(*p);
if (isalnum(c) || c == '-') clean += c;
}
if (clean.isEmpty() || clean.length() > 63) return false;
mdnsHostname = clean;
Preferences prefs;
prefs.begin("mdns", false);
prefs.putString("host", mdnsHostname);
prefs.end();
MDNS.end();
demarrerMdns();
return true;
}
void getWifiStatusJson(String &out) {
JsonDocument doc;
// AP
JsonObject ap = doc["ap"].to<JsonObject>();
ap["ssid"] = WIFI_SSID;
ap["ip"] = WiFi.softAPIP().toString();
ap["clients"] = WiFi.softAPgetStationNum();
// STA
JsonObject sta = doc["sta"].to<JsonObject>();
sta["configured"] = staConfiguree;
sta["connected"] = staConnectee;
sta["ssid"] = staSSID;
sta["ip"] = staConnectee ? WiFi.localIP().toString() : String("");
sta["rssi"] = staConnectee ? WiFi.RSSI() : 0;
doc["mode"] = staConnectee ? "AP+STA" : (staConfiguree ? "AP (STA tentative)" : "AP");
serializeJson(doc, out);
}
+181
View File
@@ -0,0 +1,181 @@
#include "wireguard_vpn.h"
#ifndef QEMU_BUILD
#include <WireGuard-ESP32.h>
#include <WiFi.h>
#include <Preferences.h>
#include <ArduinoJson.h>
static WireGuard wg;
static bool wgActif = false;
static bool wgConnecte = false;
static String wgPrivKey;
static String wgPubKey;
static String wgPsk;
static String wgEndpoint;
static uint16_t wgPort = 51820;
static String wgLocalIP;
static uint16_t wgKeepalive = 25;
static unsigned long tDerniereConnexion = 0;
static const unsigned long WG_RETRY_MS = 60000UL;
// Valeurs initiales — à renseigner via l'interface web (onglet Config > VPN WireGuard)
static const char* WG_DEFAULT_PRIVKEY = "";
static const char* WG_DEFAULT_PUBKEY = "";
static const char* WG_DEFAULT_PSK = "";
static const char* WG_DEFAULT_ENDPOINT = "";
static const uint16_t WG_DEFAULT_PORT = 51820;
static const char* WG_DEFAULT_LOCALIP = "";
static const uint16_t WG_DEFAULT_KEEPALIVE = 25;
static void chargerWgNVS() {
Preferences p;
p.begin("wireguard", true);
wgActif = p.getBool("enabled", false);
wgPrivKey = p.getString("privkey", "");
wgPubKey = p.getString("pubkey", "");
wgPsk = p.getString("psk", "");
wgEndpoint = p.getString("endpoint", "");
wgPort = p.getUShort("port", 51820);
wgLocalIP = p.getString("localip", "");
wgKeepalive = p.getUShort("keepalive", 25);
p.end();
}
static void ecrireDefauts() {
Preferences p;
p.begin("wireguard", false);
p.putBool("enabled", false);
p.putString("privkey", WG_DEFAULT_PRIVKEY);
p.putString("pubkey", WG_DEFAULT_PUBKEY);
p.putString("psk", WG_DEFAULT_PSK);
p.putString("endpoint", WG_DEFAULT_ENDPOINT);
p.putUShort("port", WG_DEFAULT_PORT);
p.putString("localip", WG_DEFAULT_LOCALIP);
p.putUShort("keepalive", WG_DEFAULT_KEEPALIVE);
p.end();
}
static void connecterWg() {
if (wgPrivKey.isEmpty() || wgPubKey.isEmpty() || wgEndpoint.isEmpty() || wgLocalIP.isEmpty()) {
Serial.println("[WireGuard] Config incomplète — connexion annulée");
return;
}
// Extraire l'IP (supprimer le préfixe /24 si présent)
String ipStr = wgLocalIP;
int slash = ipStr.indexOf('/');
if (slash > 0) ipStr = ipStr.substring(0, slash);
IPAddress ip;
if (!ip.fromString(ipStr)) {
Serial.printf("[WireGuard] IP locale invalide : %s\n", wgLocalIP.c_str());
return;
}
Serial.printf("[WireGuard] Connexion → %s:%u IP locale: %s\n",
wgEndpoint.c_str(), wgPort, ipStr.c_str());
bool ok = wg.begin(ip, wgPrivKey.c_str(), wgEndpoint.c_str(),
wgPubKey.c_str(), wgPort);
if (ok) {
wgConnecte = true;
Serial.println("[WireGuard] Tunnel établi");
} else {
wgConnecte = false;
tDerniereConnexion = millis();
Serial.println("[WireGuard] Échec connexion — nouvelle tentative dans 60s");
}
}
void initWireGuard() {
// Premier boot : écrire les défauts issus de kc868-a2.conf si NVS vide
{
Preferences p;
p.begin("wireguard", true);
bool existe = p.isKey("privkey");
p.end();
if (!existe) {
Serial.println("[WireGuard] Initialisation NVS avec les défauts du .conf");
ecrireDefauts();
}
}
chargerWgNVS();
Serial.printf("[WireGuard] %s — endpoint: %s:%u IP: %s\n",
wgActif ? "Activé" : "Désactivé",
wgEndpoint.c_str(), wgPort, wgLocalIP.c_str());
}
void gererWireGuard() {
if (!wgActif) return;
bool staOk = (WiFi.status() == WL_CONNECTED);
if (staOk && !wgConnecte) {
unsigned long maintenant = millis();
if (tDerniereConnexion == 0 || (maintenant - tDerniereConnexion) >= WG_RETRY_MS) {
tDerniereConnexion = maintenant;
connecterWg();
}
} else if (!staOk && wgConnecte) {
wg.end();
wgConnecte = false;
tDerniereConnexion = 0;
Serial.println("[WireGuard] STA perdue — tunnel coupé");
}
}
bool setWireGuardConfig(bool enabled, const char* privKey, const char* pubKey,
const char* psk, const char* endpoint, uint16_t port,
const char* localIP, uint16_t keepalive) {
Preferences p;
p.begin("wireguard", false);
p.putBool("enabled", enabled);
p.putString("privkey", privKey);
p.putString("pubkey", pubKey);
p.putString("psk", psk);
p.putString("endpoint", endpoint);
p.putUShort("port", port);
p.putString("localip", localIP);
p.putUShort("keepalive", keepalive);
p.end();
// Déconnecter le tunnel en cours si actif
if (wgConnecte) {
wg.end();
wgConnecte = false;
}
tDerniereConnexion = 0;
chargerWgNVS();
Serial.printf("[WireGuard] Config mise à jour — %s\n", wgActif ? "activé" : "désactivé");
return true;
}
void getWireGuardJson(String &out) {
JsonDocument doc;
doc["enabled"] = wgActif;
doc["connected"] = wgConnecte;
doc["privkey"] = wgPrivKey;
doc["pubkey"] = wgPubKey;
doc["psk"] = wgPsk;
doc["endpoint"] = wgEndpoint;
doc["port"] = wgPort;
doc["localip"] = wgLocalIP;
doc["keepalive"] = wgKeepalive;
serializeJson(doc, out);
}
#else
// --- Stubs QEMU ---
void initWireGuard() { Serial.println("[WireGuard] Désactivé (build QEMU)"); }
void gererWireGuard() {}
void getWireGuardJson(String &out) { out = "{\"enabled\":false,\"connected\":false}"; }
bool setWireGuardConfig(bool, const char*, const char*, const char*,
const char*, uint16_t, const char*, uint16_t) { return false; }
#endif
+11
View File
@@ -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