Files
notes-techniques/notes/technologie/HX711 ESP32 - balance connectée WiFi - code.cpp
T
cedricAbonnel 7a30649787 Projet balance connectée WiFi : HX711 + ESP32
- Datasheet HX711 complété et corrigé (brochage, timing, plages de gain, broche RATE)
- Comparatif VL53L1X vs VL6180X
- Fiche projet balance WiFi : câblage, alimentation, calibration web, dépannage
- Code ESP32 : AP WiFi, portail captif, SSE temps réel, calibration depuis la page web (NVS)
- Annexe installation Arduino IDE Windows 10 (pilote CP2102, support ESP32, bibliothèques)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 18:23:29 +02:00

339 lines
10 KiB
C++

/*
Balance connectée WiFi — ESP32 + HX711
----------------------------------------
- L'ESP32 crée un point d'accès WiFi (mode AP)
- Un portail captif redirige automatiquement le smartphone vers la page de pesée
- La page se met à jour en temps réel via Server-Sent Events (SSE)
- La calibration se fait depuis la page web, le facteur est sauvegardé en NVS
Bibliothèques requises :
- HX711 (bogde)
- ESPAsyncWebServer (me-no-dev)
- AsyncTCP (me-no-dev)
- Preferences (intégrée ESP32)
Câblage HX711 :
DOUT → GPIO 4
SCK → GPIO 5
VCC → 3V3 (ou 5V si module compatible)
GND → GND
*/
#include <WiFi.h>
#include <DNSServer.h>
#include <ESPAsyncWebServer.h>
#include <Preferences.h>
#include "HX711.h"
// --- Pins HX711 ---
static const int PIN_DOUT = 4;
static const int PIN_SCK = 5;
// --- WiFi AP ---
static const char* AP_SSID = "Balance-ESP32";
static const char* AP_PASS = ""; // réseau ouvert
// --- Objets globaux ---
HX711 scale;
DNSServer dnsServer;
AsyncWebServer server(80);
AsyncEventSource events("/events");
Preferences prefs;
float calibrationFactor = 1.0f; // chargé depuis NVS au démarrage
// --- Flags inter-tâches (handler HTTP → loop) ---
// AsyncWebServer tourne dans une tâche séparée ; les opérations HX711
// doivent rester dans loop() pour éviter les conflits de timing.
volatile bool pendingTare = false;
volatile bool pendingCalib = false;
volatile float pendingCalibMass = 0.0f;
// --- Page HTML embarquée ---
static const char HTML[] PROGMEM = R"rawliteral(
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Balance</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, Arial, sans-serif;
background: #f5f5f5;
padding: 20px;
min-height: 100vh;
}
.card {
background: white;
border-radius: 20px;
padding: 40px 30px 32px;
box-shadow: 0 4px 24px rgba(0,0,0,0.09);
max-width: 400px;
margin: 0 auto 20px;
text-align: center;
}
h1 { color: #555; font-size: 1.1em; font-weight: 500; margin-bottom: 28px; }
h2 { color: #666; font-size: 1em; font-weight: 600; margin-bottom: 18px; text-align: left; }
.weight { font-size: 5em; font-weight: 700; color: #1a1a2e; line-height: 1; }
.unit { font-size: 1.3em; color: #888; margin-top: 8px; }
.dot {
display: inline-block; width: 10px; height: 10px;
border-radius: 50%; background: #ccc;
margin-right: 6px; vertical-align: middle;
transition: background 0.3s;
}
.dot.live { background: #27ae60; }
.status { font-size: 0.82em; color: #aaa; margin-top: 24px; }
/* Calibration */
.steps { text-align: left; }
.step { margin-bottom: 20px; }
.step p { font-size: 0.9em; color: #555; margin-bottom: 8px; }
.row { display: flex; gap: 8px; align-items: center; }
input[type=number] {
flex: 1; padding: 10px 12px; border: 1px solid #ddd;
border-radius: 10px; font-size: 1em; outline: none;
}
input[type=number]:focus { border-color: #3498db; }
button {
padding: 10px 18px; border: none; border-radius: 10px;
font-size: 0.95em; font-weight: 600; cursor: pointer;
transition: opacity 0.2s;
}
button:active { opacity: 0.7; }
.btn-tare { background: #ecf0f1; color: #555; }
.btn-calib { background: #3498db; color: white; }
.fb {
font-size: 0.82em; margin-top: 6px; min-height: 1.2em;
color: #27ae60;
}
.fb.err { color: #e74c3c; }
.factor { font-size: 0.8em; color: #bbb; margin-top: 10px; text-align: right; }
</style>
</head>
<body>
<!-- Affichage poids -->
<div class="card">
<h1>Balance connectée</h1>
<div class="weight" id="w">—</div>
<div class="unit">grammes</div>
<div class="status">
<span class="dot" id="dot"></span>
<span id="status">Connexion</span>
</div>
</div>
<!-- Calibration -->
<div class="card">
<h2>Calibration</h2>
<div class="steps">
<div class="step">
<p> Vider la balance, puis mettre à zéro :</p>
<button class="btn-tare" onclick="doTare()">Tare (zéro)</button>
<div class="fb" id="fb-tare"></div>
</div>
<div class="step">
<p> Poser une masse connue et entrer sa valeur :</p>
<div class="row">
<input type="number" id="mass" placeholder="Masse en grammes" min="1" step="any">
<button class="btn-calib" onclick="doCalib()">Calibrer</button>
</div>
<div class="fb" id="fb-calib"></div>
</div>
</div>
<div class="factor" id="factor"></div>
</div>
<script>
const wEl = document.getElementById('w');
const dotEl = document.getElementById('dot');
const statEl = document.getElementById('status');
const fbTare = document.getElementById('fb-tare');
const fbCalib = document.getElementById('fb-calib');
const factorEl = document.getElementById('factor');
// --- SSE : mise à jour temps réel ---
const src = new EventSource('/events');
src.addEventListener('weight', e => {
wEl.textContent = parseFloat(e.data).toFixed(1);
dotEl.classList.add('live');
statEl.textContent = 'Mis à jour ' + new Date().toLocaleTimeString();
});
src.addEventListener('info', e => {
const msg = e.data;
if (msg === 'tare_ok') {
fbTare.className = 'fb';
fbTare.textContent = ' Tare effectuée';
} else if (msg.startsWith('calib_ok:')) {
const f = parseFloat(msg.split(':')[1]);
fbCalib.className = 'fb';
fbCalib.textContent = ' Calibration sauvegardée';
factorEl.textContent = 'Facteur : ' + f.toFixed(2);
}
});
src.onopen = () => { dotEl.classList.add('live'); statEl.textContent = 'Connecté'; };
src.onerror = () => { dotEl.classList.remove('live'); statEl.textContent = 'Reconnexion'; };
// --- Actions calibration ---
function doTare() {
fbTare.className = 'fb';
fbTare.textContent = 'En cours';
fetch('/tare', { method: 'POST' }).catch(() => {
fbTare.className = 'fb err';
fbTare.textContent = 'Erreur réseau';
});
}
function doCalib() {
const mass = parseFloat(document.getElementById('mass').value);
if (!mass || mass <= 0) {
fbCalib.className = 'fb err';
fbCalib.textContent = 'Entrer une masse valide';
return;
}
fbCalib.className = 'fb';
fbCalib.textContent = 'En cours';
const body = new URLSearchParams({ mass: mass.toString() });
fetch('/calibrate', { method: 'POST', body }).catch(() => {
fbCalib.className = 'fb err';
fbCalib.textContent = 'Erreur réseau';
});
}
</script>
</body>
</html>
)rawliteral";
// --- Portail captif ---
static void handleCaptiveRedirect(AsyncWebServerRequest* req) {
req->redirect("http://192.168.4.1/");
}
void setup() {
Serial.begin(115200);
delay(200);
// --- Chargement facteur de calibration depuis NVS ---
prefs.begin("balance", true); // lecture seule
calibrationFactor = prefs.getFloat("calib", 1.0f);
prefs.end();
Serial.printf("Facteur de calibration : %.2f%s\n",
calibrationFactor,
calibrationFactor == 1.0f ? " (défaut — calibration requise)" : " (chargé)");
// --- HX711 ---
scale.begin(PIN_DOUT, PIN_SCK);
if (!scale.is_ready()) {
Serial.println("HX711 non détecté — vérifier câblage.");
}
scale.set_scale();
scale.tare(20);
scale.set_scale(calibrationFactor);
Serial.println("HX711 prêt.");
// --- WiFi AP ---
WiFi.mode(WIFI_AP);
WiFi.softAP(AP_SSID, AP_PASS);
Serial.printf("AP démarré — SSID : %s — IP : %s\n", AP_SSID,
WiFi.softAPIP().toString().c_str());
// --- DNS captif ---
dnsServer.start(53, "*", WiFi.softAPIP());
// --- Routes HTTP ---
server.on("/", HTTP_GET, [](AsyncWebServerRequest* req) {
req->send_P(200, "text/html", HTML);
});
// Tare
server.on("/tare", HTTP_POST, [](AsyncWebServerRequest* req) {
pendingTare = true;
req->send(200, "text/plain", "OK");
});
// Calibration
server.on("/calibrate", HTTP_POST, [](AsyncWebServerRequest* req) {
if (req->hasParam("mass", true)) {
float m = req->getParam("mass", true)->value().toFloat();
if (m > 0.0f) {
pendingCalibMass = m;
pendingCalib = true;
req->send(200, "text/plain", "OK");
} else {
req->send(400, "text/plain", "Masse invalide");
}
} else {
req->send(400, "text/plain", "Parametre mass manquant");
}
});
// Portail captif Android / iOS
server.on("/generate_204", HTTP_GET, handleCaptiveRedirect);
server.on("/connecttest.txt", HTTP_GET, handleCaptiveRedirect);
server.on("/redirect", HTTP_GET, handleCaptiveRedirect);
server.on("/hotspot-detect.html", HTTP_GET, handleCaptiveRedirect);
server.on("/library/test/success.html", HTTP_GET, handleCaptiveRedirect);
server.onNotFound(handleCaptiveRedirect);
// --- SSE ---
events.onConnect([](AsyncEventSourceClient* client) {
client->send("0.0", "weight", millis(), 500);
});
server.addHandler(&events);
server.begin();
Serial.println("Serveur web démarré.");
}
void loop() {
dnsServer.processNextRequest();
// --- Tare ---
if (pendingTare) {
pendingTare = false;
scale.set_scale(); // remet le facteur à 1 avant la tare
scale.tare(20);
scale.set_scale(calibrationFactor);
events.send("tare_ok", "info", millis());
Serial.println("Tare effectuée.");
}
// --- Calibration ---
if (pendingCalib) {
pendingCalib = false;
scale.set_scale(1.0f); // facteur neutre pour lire la valeur brute
float raw = scale.get_value(20);
float factor = raw / pendingCalibMass;
calibrationFactor = factor;
scale.set_scale(calibrationFactor);
prefs.begin("balance", false); // écriture
prefs.putFloat("calib", calibrationFactor);
prefs.end();
Serial.printf("Calibration : brut=%.0f masse=%.1fg facteur=%.2f\n",
raw, pendingCalibMass, calibrationFactor);
String msg = "calib_ok:" + String(calibrationFactor, 2);
events.send(msg.c_str(), "info", millis());
}
// --- Lecture et diffusion du poids ---
if (scale.is_ready()) {
float grams = scale.get_units(5);
events.send(String(grams, 1).c_str(), "weight", millis());
Serial.printf("Poids : %.1f g\n", grams);
}
delay(500);
}