commit 6f56a5fe6fe495dde39aaf269cb038f4f3a2adf1 Author: scayac Date: Sun Dec 7 14:31:52 2025 +0100 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..95e701e --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +.pio +.vscode +.git +data/conf.json diff --git a/data/app.js b/data/app.js new file mode 100644 index 0000000..121baec --- /dev/null +++ b/data/app.js @@ -0,0 +1,127 @@ +const btnPreheat = document.getElementById('validateChauffeManuelle'); +const btnProfil = document.getElementById('validateChauffeAuto'); + +// WebSocket pour affichage température en temps réel +function connectWS() { + const ws = new WebSocket(`ws://${window.location.hostname}/ws`); + ws.onopen = () => console.log('WebSocket connecté'); + ws.onclose = () => setTimeout(connectWS, 2000); + ws.onmessage = e => { + const tempDiv = document.getElementById('temp'); + const powerBar = document.getElementById('power-bar'); + const powerBarValue = document.getElementById('power-bar-value'); + try { + const data = JSON.parse(e.data); + if (tempDiv) tempDiv.textContent = `${data.temp} °C /${data.setpoint}°C`; + if (powerBar && powerBarValue && typeof data.output !== 'undefined') { + let pct = Math.max(0, Math.min(100, Math.round(data.output))); + powerBar.value = pct; + powerBarValue.textContent = pct + '%'; + } + } catch { + if (tempDiv) tempDiv.textContent = e.data; + } + }; +} + +connectWS(); + +// Fonction utilitaire pour modifier l'aspect d'un bouton +function updateButton(btn, isActive) { + if (isActive) { + btn.textContent = 'STOP'; + btn.className = 'stop'; + } else { + btn.textContent = 'VALIDER'; + btn.className = 'start'; + } +} + +// Initialisation : aucun mode actif +updateButton(btnPreheat, false); +updateButton(btnProfil, false); + +btnPreheat.onclick = function() { + if (btnPreheat.className === 'stop') { + // Désactive tout + updateButton(btnPreheat, false); + updateButton(btnProfil, false); + fetch('/action/stop', { method: 'POST' }); + return; + } + // Active le mode manuel + const temp = document.getElementById('preheat-gauge').value; + const params = new URLSearchParams(); + params.append('temp', temp); + fetch('/action/preheat', { + method: 'POST', + headers: {'Content-Type': 'application/x-www-form-urlencoded'}, + body: params.toString() + }).then(r => { + if (r.ok){ + updateButton(btnPreheat, true); + updateButton(btnProfil, false); + console.log('Préchauffage lancé à ' + temp + '°C'); + } + else console.log('Erreur serveur'); + }); +}; + +// Chauffe profil : bouton VALIDER +btnProfil.onclick = function() { + if (btnProfil.className === 'stop') { + // Désactive tout + updateButton(btnPreheat, false); + updateButton(btnProfil, false); + fetch('/action/stop', { method: 'POST' }); + return; + } + // Active le mode automatique + const preheatTemp = document.getElementById('preheat-temp').value; + const preheatTime = document.getElementById('preheat-time').value; + const soakTemp = document.getElementById('soak-temp').value; + const soakTime = document.getElementById('soak-time').value; + const reflowTemp = document.getElementById('reflow-temp').value; + const reflowTime = document.getElementById('reflow-time').value; + const params = new URLSearchParams(); + params.append('preheatTemp', preheatTemp); + params.append('preheatTime', preheatTime); + params.append('soakTemp', soakTemp); + params.append('soakTime', soakTime); + params.append('reflowTemp', reflowTemp); + params.append('reflowTime', reflowTime); + fetch('/action/auto', { + method: 'POST', + headers: {'Content-Type': 'application/x-www-form-urlencoded'}, + body: params.toString() + }).then(r => { + if (r.ok){ + updateButton(btnPreheat, false); + updateButton(btnProfil, true); + console.log('Profil lancé'); + } + else console.log('Erreur serveur'); + }); +}; + +// Jauge préchauffage : affichage dynamique +document.addEventListener('DOMContentLoaded', () => { + const gauge = document.getElementById('preheat-gauge'); + const gaugeValue = document.getElementById('preheat-gauge-value'); + const btnPreheat = document.getElementById('validateChauffeManuelle'); + if (gauge && gaugeValue) { + gauge.oninput = function() { + gaugeValue.textContent = this.value + '°C'; + // Si le mode manuel est actif, on met à jour le setpoint côté serveur + if (btnPreheat && btnPreheat.className === 'stop') { + const params = new URLSearchParams(); + params.append('temp', this.value); + fetch('/action/preheat', { + method: 'POST', + headers: {'Content-Type': 'application/x-www-form-urlencoded'}, + body: params.toString() + }); + } + }; + } +}); diff --git a/data/conf.html b/data/conf.html new file mode 100644 index 0000000..0a8a106 --- /dev/null +++ b/data/conf.html @@ -0,0 +1,37 @@ + + + + + + Reflow Oven CONFIG + + + +
+

Configuration :

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ + + + + diff --git a/data/index.html b/data/index.html new file mode 100644 index 0000000..c61c680 --- /dev/null +++ b/data/index.html @@ -0,0 +1,47 @@ + + + + + + Reflow Oven + + + +
+
+
--.- °C/0°C
+
+ + + 0% +
+
+
+

Chauffe manuelle :

+
+ + 20°C +
+ +
+
+

Chauffe automatique :

+
+ + °C + s +
+
+ + °C + s +
+
+ + °C + s +
+ + + + diff --git a/data/style.css b/data/style.css new file mode 100644 index 0000000..f28353c --- /dev/null +++ b/data/style.css @@ -0,0 +1,71 @@ +body { + font-family: Arial, sans-serif; + background: #222222; + color: #eeeeee; + margin: 0; + padding: 0; +} +.container { + max-width: 400px; + margin: 2em auto; + background: #333333; + border-radius: 10px; + box-shadow: 0 0 10px #111111; + padding: 2em; +} +h1 { + text-align: center; +} +h4 { + margin: 0 0 0.5em 0; +} +.temp { + font-size: 2em; + text-align: center; + margin: 0.5em 0; +} +.data { + font-size: 1em; + text-align: center; + margin: 0.5em 0; +} +.phase { + padding: 0.8em; + border-radius: 5px; + background: #444444; + margin-bottom: 0.5em; +} +.actions { + text-align: center; + margin: 1.5em 0; +} +button.start { + font-size: 0.8em; + padding: 0.5em 0.5em; + border: none; + border-radius: 5px; + background: #4caf50; + color: #ffffff; + cursor: pointer; +} +button.stop { + font-size: 0.8em; + padding: 0.5em 0.5em; + border: none; + border-radius: 5px; + background: #f44336; + color: #ffffff; + cursor: pointer; +} +.row { + display: flex; + align-items: center; + gap: 1em; + padding: 0.5em 0.5em; +} +.row.active { + background: #1976d2; + color: #fff; + border-radius: 4px; + box-shadow: 0 0 6px #1976d2; +} \ No newline at end of file diff --git a/enclosure/ReflowOven-Capot.stl b/enclosure/ReflowOven-Capot.stl new file mode 100644 index 0000000..7c0cb53 Binary files /dev/null and b/enclosure/ReflowOven-Capot.stl differ diff --git a/enclosure/ReflowOven-Corps.stl b/enclosure/ReflowOven-Corps.stl new file mode 100644 index 0000000..46e95fe Binary files /dev/null and b/enclosure/ReflowOven-Corps.stl differ diff --git a/enclosure/ReflowOven.20251207-141003.FCBak b/enclosure/ReflowOven.20251207-141003.FCBak new file mode 100644 index 0000000..6114c81 Binary files /dev/null and b/enclosure/ReflowOven.20251207-141003.FCBak differ diff --git a/enclosure/ReflowOven.FCStd b/enclosure/ReflowOven.FCStd new file mode 100644 index 0000000..41b0762 Binary files /dev/null and b/enclosure/ReflowOven.FCStd differ diff --git a/include/README b/include/README new file mode 100644 index 0000000..49819c0 --- /dev/null +++ b/include/README @@ -0,0 +1,37 @@ + +This directory is intended for project header files. + +A header file is a file containing C declarations and macro definitions +to be shared between several project source files. You request the use of a +header file in your project source file (C, C++, etc) located in `src` folder +by including it, with the C preprocessing directive `#include'. + +```src/main.c + +#include "header.h" + +int main (void) +{ + ... +} +``` + +Including a header file produces the same results as copying the header file +into each source file that needs it. Such copying would be time-consuming +and error-prone. With a header file, the related declarations appear +in only one place. If they need to be changed, they can be changed in one +place, and programs that include the header file will automatically use the +new version when next recompiled. The header file eliminates the labor of +finding and changing all the copies as well as the risk that a failure to +find one copy will result in inconsistencies within a program. + +In C, the convention is to give header files names that end with `.h'. + +Read more about using header files in official GCC documentation: + +* Include Syntax +* Include Operation +* Once-Only Headers +* Computed Includes + +https://gcc.gnu.org/onlinedocs/cpp/Header-Files.html diff --git a/lib/Arduino-PID-Library b/lib/Arduino-PID-Library new file mode 160000 index 0000000..524a426 --- /dev/null +++ b/lib/Arduino-PID-Library @@ -0,0 +1 @@ +Subproject commit 524a4268fc01e6ea397e7fc5b5d820741e9b662f diff --git a/lib/MAX6675-library b/lib/MAX6675-library new file mode 160000 index 0000000..6a7d05d --- /dev/null +++ b/lib/MAX6675-library @@ -0,0 +1 @@ +Subproject commit 6a7d05d22769d7f48e486ee734e4ed0a4714ab02 diff --git a/platformio.ini b/platformio.ini new file mode 100644 index 0000000..1dce636 --- /dev/null +++ b/platformio.ini @@ -0,0 +1,24 @@ +; PlatformIO Project Configuration File +; +; Build options: build flags, source filter +; Upload options: custom upload port, speed and extra flags +; Library options: dependencies, extra library storages +; Advanced options: extra scripting +; +; Please visit documentation for the other options and examples +; https://docs.platformio.org/page/projectconf.html + +[env:esp12e] +platform = espressif8266 +board = esp12e +framework = arduino +monitor_speed = 115200 + +lib_deps = + ESP32Async/ESPAsyncWebServer + ESP32Async/ESPAsyncTCP + LittleFS@^0.1.0 + +; Partitionnement LittleFS (2MB pour fichiers, 1MB SPIFFS par défaut) +board_build.filesystem = littlefs +board_build.ldscript = eagle.flash.4m2m.ld diff --git a/src/main.cpp b/src/main.cpp new file mode 100644 index 0000000..e8e75de --- /dev/null +++ b/src/main.cpp @@ -0,0 +1,194 @@ +#include +#include +#include +#include +#include +#include + +// Wifi credentials +const char* ssid = ""; +const char* password = ""; +AsyncWebServer server(80); +AsyncWebSocket ws("/ws"); + +// SSR relay pin +const int SSR_PIN = 12; // D1 (GPIO5) par exemple + +// PID variables +double setpoint = 20, input = 0, output = 0; +const int PWM_PERIOD = 2000; +double Kp = 2, Ki = 5, Kd = 1; +PID myPID(&input, &output, &setpoint, Kp, Ki, Kd, DIRECT); + +// Variables globales pour le profil +int preheatTemp = 0, preheatTime = 0; +int soakTemp = 0, soakTime = 0; +int reflowTemp = 0, reflowTime = 0; +bool autoProfileActive = false; +unsigned long profileStartTime = 0; +int profileStep = 0; + +// Broches MAX6675 (exemple : SCK=14, CS=12, SO=13) +const int thermoSO = 13; +const int thermoCS = 4; +const int thermoSCK = 5; +MAX6675 thermocouple(thermoSCK, thermoCS, thermoSO); +float lastTemp = NAN; + +unsigned long lastSend = 0; +unsigned long pwmStart = 0; + +void setup() { + // Action arrêt (setpoint à 0°C) + server.on("/action/stop", HTTP_POST, [](AsyncWebServerRequest *request){ + setpoint = 0; + autoProfileActive = false; + profileStep = 0; + Serial.println("Arrêt demandé : setpoint PID à 0°C"); + request->send(200, "text/plain", "OK"); + }); + + // Action chauffe manuelle + server.on("/action/preheat", HTTP_POST, [](AsyncWebServerRequest *request){ + if (!request->hasArg("temp")) { + request->send(400, "text/plain", "Paramètre 'temp' manquant"); + return; + } + // Désactive le mode automatique si actif + if (autoProfileActive) { + autoProfileActive = false; + profileStep = 0; + Serial.println("Mode automatique désactivé (chauffe manuelle)"); + } + setpoint = request->arg("temp").toDouble(); + Serial.printf("Chauffe manuelle demandée : %.1f°C (setpoint PID fixé)\n", setpoint); + request->send(200, "text/plain", "OK"); + }); + + // Action chauffe auto + server.on("/action/auto", HTTP_POST, [](AsyncWebServerRequest *request){ + if (!request->hasArg("preheatTemp") || !request->hasArg("preheatTime") || + !request->hasArg("soakTemp") || !request->hasArg("soakTime") || + !request->hasArg("reflowTemp") || !request->hasArg("reflowTime")) { + request->send(400, "text/plain", "Paramètres manquants"); + return; + } + preheatTemp = request->arg("preheatTemp").toInt(); + preheatTime = request->arg("preheatTime").toInt(); + soakTemp = request->arg("soakTemp").toInt(); + soakTime = request->arg("soakTime").toInt(); + reflowTemp = request->arg("reflowTemp").toInt(); + reflowTime = request->arg("reflowTime").toInt(); + // Démarrage du profil automatique + autoProfileActive = true; + profileStep = 0; + profileStartTime = millis(); + setpoint = preheatTemp; + Serial.printf("Profil %s lancé : Preheat %d°C/%ds, Soak %d°C/%ds, Reflow %d°C/%ds\n", + request->arg("profile").c_str(), preheatTemp, preheatTime, soakTemp, soakTime, reflowTemp, reflowTime); + request->send(200, "text/plain", "OK"); + }); + + pinMode(SSR_PIN, OUTPUT); + digitalWrite(SSR_PIN, LOW); + myPID.SetMode(AUTOMATIC); + myPID.SetOutputLimits(0, PWM_PERIOD); // output = ms ON sur 2s + Serial.begin(115200); + delay(100); + + // Connexion à un réseau WiFi défini dans les paramètres + WiFi.mode(WIFI_STA); + WiFi.begin(ssid, password); + Serial.print("Connexion au WiFi : "); + Serial.println(ssid); + int wifiTimeout = 0; + while (WiFi.status() != WL_CONNECTED && wifiTimeout < 20) { + delay(500); + Serial.print("."); + wifiTimeout++; + } + if (WiFi.status() == WL_CONNECTED) { + Serial.print("Connecté, IP : "); + Serial.println(WiFi.localIP()); + } else { + Serial.println("Échec connexion WiFi"); + } + + // Initialisation LittleFS + if (!LittleFS.begin()) { + Serial.println("Erreur LittleFS"); + return; + } + Serial.println("LittleFS monté"); + + // Route pour servir les fichiers statiques (html, js, css) + server.serveStatic("/", LittleFS, "/").setDefaultFile("index.html"); + + // WebSocket + server.addHandler(&ws); + + // Démarrer le serveur web + server.begin(); + Serial.println("Serveur web démarré sur port 80"); +} + +void loop() { + + ws.cleanupClients(); + unsigned long now = millis(); + // Gestion du profil automatique + if (autoProfileActive) { + unsigned long elapsed = (millis() - profileStartTime) / 1000; + if (profileStep == 0) { // Preheat + setpoint = preheatTemp; + if (elapsed >= (unsigned long)preheatTime) { + profileStep = 1; + profileStartTime = millis(); + Serial.println("Début Soak"); + } + } else if (profileStep == 1) { // Soak + setpoint = soakTemp; + if (elapsed >= (unsigned long)soakTime) { + profileStep = 2; + profileStartTime = millis(); + Serial.println("Début Reflow"); + } + } else if (profileStep == 2) { // Reflow + setpoint = reflowTemp; + if (elapsed >= (unsigned long)reflowTime) { + profileStep = 3; + autoProfileActive = false; + Serial.println("Profil terminé"); + } + } + } + + // PID + myPID.Compute(); + // PWM logiciel SSR (2s) + if (now - pwmStart >= PWM_PERIOD) { + pwmStart = now; + } + if ((now - pwmStart) < (unsigned long)output) { + digitalWrite(SSR_PIN, HIGH); + } else { + digitalWrite(SSR_PIN, LOW); + } + // WebSocket température (1Hz) + if (now - lastSend > 1000) { + lastSend = now; + // Lecture température + float t = thermocouple.readCelsius(); + if (!isnan(t)) { + input = t; + } + int pct = 0; + if (output > 0) { + pct = (int)round((output * 100) / PWM_PERIOD); + if (pct > 100) pct = 100; + } + String json = String("{\"temp\":") + String(input, 1) + ",\"setpoint\":" + String(setpoint, 1) + ",\"output\":" + String(pct) + "}"; + Serial.println("Température: " + String(input, 1) + " °C | Setpoint: " + String(setpoint, 1) + " | Output PID: " + String(output) + " | Puissance: " + String(pct) + "%"); + ws.textAll(json); + } +} \ No newline at end of file diff --git a/src/main.cpp.saved b/src/main.cpp.saved new file mode 100644 index 0000000..49f3c86 --- /dev/null +++ b/src/main.cpp.saved @@ -0,0 +1,128 @@ +#include +#include +#include +#include +#include +#include + +// Wifi credentials +const char* ssid = "ReflowOven"; +const char* password = "12345678"; +AsyncWebServer server(80); +AsyncWebSocket ws("/ws"); + +// SSR relay pin +const int SSR_PIN = 5; // D1 (GPIO5) par exemple + +// PID variables +double setpoint = 150, input = 0, output = 0; +const int PWM_PERIOD = 2000; +double Kp = 2, Ki = 5, Kd = 1; +PID myPID(&input, &output, &setpoint, Kp, Ki, Kd, DIRECT); + +// Broches MAX6675 (exemple : SCK=14, CS=12, SO=13) +const int thermoSO = 13; +const int thermoCS = 12; +const int thermoSCK = 14; +MAX6675 thermocouple(thermoSCK, thermoCS, thermoSO); +float lastTemp = NAN; + +unsigned long lastSend = 0; +unsigned long pwmStart = 0; + +void setup() { + + // Action chauffe manuelle + server.on("/action/preheat", HTTP_POST, [](AsyncWebServerRequest *request){ + if (!request->hasArg("temp")) { + request->send(400, "text/plain", "Paramètre 'temp' manquant"); + return; + } + setpoint = request->arg("temp").toDouble(); + Serial.printf("Chauffe manuelle demandée : %.1f°C (setpoint PID fixé)\n", setpoint); + request->send(200, "text/plain", "OK"); + }); + + // Action chauffe profil + server.on("/action/profile", HTTP_POST, [](AsyncWebServerRequest *request){ + if (!request->hasArg("profile") || !request->hasArg("preheatTemp") || !request->hasArg("preheatTime") || + !request->hasArg("soakTemp") || !request->hasArg("soakTime") || + !request->hasArg("reflowTemp") || !request->hasArg("reflowTime")) { + request->send(400, "text/plain", "Paramètres manquants"); + return; + } + String profile = request->arg("profile"); + int preheatTemp = request->arg("preheatTemp").toInt(); + int preheatTime = request->arg("preheatTime").toInt(); + int soakTemp = request->arg("soakTemp").toInt(); + int soakTime = request->arg("soakTime").toInt(); + int reflowTemp = request->arg("reflowTemp").toInt(); + int reflowTime = request->arg("reflowTime").toInt(); + Serial.printf("Profil %s lancé : Preheat %d°C/%ds, Soak %d°C/%ds, Reflow %d°C/%ds\n", + profile.c_str(), preheatTemp, preheatTime, soakTemp, soakTime, reflowTemp, reflowTime); + request->send(200, "text/plain", "OK"); + }); + + pinMode(SSR_PIN, OUTPUT); + digitalWrite(SSR_PIN, LOW); + myPID.SetMode(AUTOMATIC); + myPID.SetOutputLimits(0, PWM_PERIOD); // output = ms ON sur 2s + Serial.begin(115200); + delay(100); + + // Configure ESP8266 as Access Point + WiFi.mode(WIFI_AP); + WiFi.softAP(ssid, password); + Serial.print("AP IP address: "); + Serial.println(WiFi.softAPIP()); + + // Initialisation LittleFS + if (!LittleFS.begin()) { + Serial.println("Erreur LittleFS"); + return; + } + Serial.println("LittleFS monté"); + + // Route pour servir les fichiers statiques (html, js, css) + server.serveStatic("/", LittleFS, "/").setDefaultFile("index.html"); + + // WebSocket + server.addHandler(&ws); + + // Démarrer le serveur web + server.begin(); + Serial.println("Serveur web démarré sur port 80"); +} + +void loop() { + + ws.cleanupClients(); + unsigned long now = millis(); + + + + // PID + myPID.Compute(); + // PWM logiciel SSR (2s) + if (now - pwmStart >= PWM_PERIOD) { + pwmStart = now; + } + if ((now - pwmStart) < (unsigned long)output) { + digitalWrite(SSR_PIN, HIGH); + } else { + digitalWrite(SSR_PIN, LOW); + } + // WebSocket température (1Hz) + if (now - lastSend > 1000) { + lastSend = now; + // Lecture température + float t = thermocouple.readCelsius(); + + if (!isnan(t)) { + input = t; + } + String msg = String(input, 1); + Serial.println("Température: " + msg + " °C"+" | Output PID: "+ String(output)); + ws.textAll(msg); + } +} \ No newline at end of file diff --git a/test/README b/test/README new file mode 100644 index 0000000..9b1e87b --- /dev/null +++ b/test/README @@ -0,0 +1,11 @@ + +This directory is intended for PlatformIO Test Runner and project tests. + +Unit Testing is a software testing method by which individual units of +source code, sets of one or more MCU program modules together with associated +control data, usage procedures, and operating procedures, are tested to +determine whether they are fit for use. Unit testing finds problems early +in the development cycle. + +More information about PlatformIO Unit Testing: +- https://docs.platformio.org/en/latest/advanced/unit-testing/index.html