Una cortadora láser CO2 sin aire asistido en la boquilla puede incendiarse o romper la lente en segundos. El extractor de humos es igual de crítico. La controladora Ruida no vigila ninguna de las dos cosas. PakuLaser es un ESP32-C3 que monitoriza presión de aire, humo y llama cada 200ms y, si algo supera los umbrales, para todo de forma segura — sin perder el punto de trabajo.
01 — El problema: la láser sin red de seguridad
Una láser CO2 tiene dos periféricos de seguridad críticos que la controladora Ruida no supervisa: el compresor de aire y el extractor de humos.
El aire asistido sale por la boquilla enfocadora durante el corte. Si el compresor falla o la presión baja, los residuos de la combustión entran en la lente y la destruyen, y en materiales inflamables la probabilidad de incendio sube porque ya no hay flujo de aire apagando las brasas.
El extractor evacúa los humos y partículas. Si para a mitad de un trabajo largo, los humos se acumulan en el habitáculo. Con ciertos materiales eso también supone riesgo de incendio.
La alternativa obvia sería un pedal de hombre muerto — si no lo pisas, la máquina para. Pero en trabajos de 20 minutos no es viable. La solución es un controlador autónomo que monitoriza los sensores y para todo si algo va mal, de forma que la Ruida guarda la posición exacta del trabajo y puede retomarlo desde donde lo dejó.
02 — Los tres sensores y sus umbrales
El sistema monitoriza tres magnitudes, cada una con su umbral configurable:
- Humo y gas (MQ-2). El ADC lee AOUT cada 200ms. Si el valor supera
th_humo(2500 por defecto), dispara alarma. El MQ-2 da lecturas erróneas mientras el filamento calienta — los primeros 60 segundos desde el arranque, el sensor se ignora: para que el filamento estabilice y también para dar tiempo al operador a colocar y ajustar el sensor antes de que empiece a vigilar. - Llama (IR analógico). Lógica invertida: cuando el sensor detecta llama, la salida AOUT baja. La alarma dispara cuando
adc_llama < th_llama(umbral 500 por defecto). Siempre activo, independientemente del modo. - Presión de aire. Sensor analógico 0-5V en la salida del compresor. Si la presión baja de
th_presion(1000 por defecto) mientras la máquina está en marcha, alarma de aire. Cuando la máquina está parada, el sensor se ignora — sin compresor encendido, la presión baja es normal.
Los tres sensores trabajan a 5V pero el ADC del ESP32-C3 es de 3.3V. Cada AOUT lleva un divisor resistivo: 2.2kΩ en serie y 3.3kΩ a GND, que reduce el rango 0-5V a 0-3V con margen suficiente.
03 — El truco del relé agua: parar la Ruida sin perder el trabajo
La controladora Ruida tiene una entrada de protección de refrigeración, pensada para detectar que el agua del tubo láser está circulando. Si esa entrada falla, la Ruida para el trabajo de forma ordenada: guarda la posición exacta y permite retomarlo desde el mismo punto, exactamente como si hubieras pulsado pausa.
Es exactamente el comportamiento que se necesita en una parada de emergencia. Cortar la corriente bruscamente pierde el trabajo; decirle a la Ruida que "no hay agua" la para limpiamente.
La entrada de protección de la Ruida trabaja a 24V. Conectar un GPIO del ESP32 directamente a esa línea lo destruiría, de ahí el relé pequeño del esquema: sus contactos van en serie con la línea de 24V de la Ruida, y su bobina la pilota el ESP32.
La bobina del relé se conduce con un truco de open-drain por software. Los módulos de relé de 5V llevan una resistencia de pull-up en su pin IN conectada a su propio VCC. Si el GPIO del ESP32 está en modo INPUT (alta impedancia), esa pull-up mantiene IN a 5V → relé desactivado → contactos en reposo → Ruida ve "agua OK". Cuando el GPIO pasa a OUTPUT + LOW, arrastra IN a GND → relé activado → contactos conmutan → Ruida ve "sin agua" → para limpiamente guardando la posición.
Por qué open-drain y no OUTPUT HIGH: el GPIO del ESP32 da 3.3V, y muchos módulos de relé de 5V no garantizan activación con menos de 4V en IN. Con OUTPUT LOW el ESP32 solo necesita arrastrar a GND — para lo que 3.3V son más que suficientes — y el propio módulo usa su VCC de 5V para el pull-up.
// Parar la Ruida: activar relé → contactos conmutan → Ruida sin "agua" pinMode(PIN_AGUA, OUTPUT); digitalWrite(PIN_AGUA, LOW); // Dejar pasar: alta impedancia → pull-up del módulo relé → relé desactivado pinMode(PIN_AGUA, INPUT);
04 — La PCB: diseño en KiCad, fresada en CNC
El diseño en KiCad 9 integra en una sola placa: la fuente SHB-5W1 (220V → 5V + 12V), el interruptor de encendido, el portafusibles, la clema de entrada 220V, los conectores JST para los tres sensores y las señales de control de los tres relés, y los seis divisores resistivos. El ESP32-C3 SuperMini va en su hueco con sus pines accesibles.
Los relés de potencia (30A) van en sus módulos independientes en una base de fenólico separada, para mantener la electrónica de potencia alejada de la lógica.
El G-code para el fresado se generó con FlatCAM a partir del Gerber de KiCad.
05 — Snubbers en los relés de potencia
El extractor y el compresor son cargas inductivas — motores. Cuando el relé abre el circuito, la bobina del motor genera un pico de tensión inversa que puede superar varios centenares de voltios en fracciones de microsegundo. Sin protección, ese pico degrada los contactos del relé con el tiempo y genera interferencias que pueden reiniciar el ESP32 o corromper lecturas ADC.
La solución es un snubber RC en paralelo con los contactos de cada relé de potencia: 100Ω en serie con un condensador X2 de 100nF. El condensador X2 es obligatorio — va sobre red, necesita la certificación de seguridad correspondiente.
Cuando el relé abre, la corriente del pico inductivo fluye por el snubber en lugar de arcing en los contactos. La resistencia limita la corriente de carga del condensador y amortigua la oscilación.
El relé pequeño de la señal de agua no necesita snubber — no conmuta carga inductiva, solo la línea de señal de 24V de la Ruida.
06 — Web embebida, modos y calibración sin recompilar
El ESP32-C3 arranca en modo dual: conecta a la red doméstica (STA) y levanta simultáneamente un punto de acceso propio (PAKU_CONFIG) accesible directamente si la red falla o no está configurada. La IP del AP es siempre 192.168.4.1.
La interfaz web usa WebSocket para actualizaciones en tiempo real: valores ADC de los tres sensores, estado de cada relé y modo activo, todo a 5Hz. Sin polling, sin recargas.
Cuatro modos de operación:
- LASER: enciende extractor + compresor y activa la señal de agua. Modo normal de trabajo con la láser.
- JWEI: enciende solo el compresor y activa la señal de agua, sin extractor. Para otros equipos del taller que usan el mismo compresor.
- PARADA TOTAL: apaga todo y limpia la alarma si estaba activa. El único modo que puede sacarte de un estado de alarma.
- AGUA (manual): conmuta solo la señal de agua sin tocar el compresor ni el extractor. Sirve para pausar el trabajo en la Ruida sin apagar los periféricos — la máquina queda en espera pero el compresor sigue corriendo para no perder presión.
Los tres umbrales de alarma son ajustables desde la web sin recompilar ni resubir firmware. Se guardan en la NVS del ESP32 (Preferences) y sobreviven a reinicios y cortes de luz. Lo mismo para WiFi y MQTT.
07 — El código
Librerías: ESPAsyncWebServer + AsyncTCP para la web con WebSocket, AsyncMQTT_ESP32 para MQTT sin bloquear el loop, ArduinoJson para serializar el estado y parsear comandos, Preferences para la config persistente en NVS.
Librerías: ESPAsyncWebServer + AsyncTCP, AsyncMQTT_ESP32, ArduinoJson, Preferences. La lógica de sensores corre cada 200ms. El MQ-2 se ignora los primeros 60 segundos: para que el filamento estabilice y para dar margen al operador a colocar el sensor antes de que empiece a vigilar:
#include <Arduino.h>
#include <WiFi.h>
#include <AsyncTCP.h>
#include <ESPAsyncWebServer.h>
#include <Preferences.h>
#include <AsyncMQTT_ESP32.h>
#include <ArduinoJson.h>
#include "esp_wifi.h"
#include "webpage.h"
// ==========================================
// CONFIGURACIÓN
// ==========================================
const char* AP_NAME_CFG = "PAKU_CONFIG";
const char* AP_PASS_CFG = "tu-password-ap"; // cambiar antes de flashear
// Pines (ESP32-C3 SuperMini)
const int PIN_MQ2 = 0; // AOUT sensor humo/gas
const int PIN_LLAMA = 1; // AOUT sensor llama (lógica invertida)
const int PIN_PRESION = 2; // AOUT sensor presión 0-5V (con divisor)
const int PIN_EXTRACTOR = 6; // Relé extractor (activo LOW)
const int PIN_COMPRESOR = 7; // Relé compresor (activo LOW)
const int PIN_AGUA = 8; // Relé agua Ruida (open-drain por software)
// ==========================================
// VARIABLES GLOBALES
// ==========================================
Preferences preferences;
AsyncWebServer servidorWeb(80);
AsyncWebSocket ws("/ws");
AsyncMqttClient mqttCliente;
TimerHandle_t mqttReconnectTimer;
char wifi_ssid[40] = "";
char wifi_pass[40] = "";
char mqtt_server[40] = "tu-broker-mqtt";
int mqtt_port = 1883;
char mqtt_user[40] = "";
char mqtt_pass[40] = "";
int th_humo = 2500;
int th_llama = 500;
int th_presion = 1000;
String currentMode = "paro";
String alarmReason = "";
bool alarmActive = false;
bool r_ext = false, r_comp = false, r_agua = false;
int adc_humo = 0, adc_llama = 0, adc_presion = 0;
volatile bool updateOutputNeeded = false;
volatile bool publishStateNeeded = false;
bool shouldRestart = false;
const char* TOPIC_CMD = "casa/pakulaser/cmd";
const char* TOPIC_STATE = "casa/pakulaser/state";
// ==========================================
// LÓGICA DE CONTROL
// ==========================================
void triggerAlarm(String r) {
if (alarmActive) return;
alarmActive = true;
alarmReason = r;
Serial.println("\n!!! ALARMA: " + r + " !!!");
currentMode = "ALARMA";
r_ext = false; r_comp = false; r_agua = false;
updateOutputNeeded = true;
}
void checkSensors() {
adc_humo = analogRead(PIN_MQ2);
adc_llama = analogRead(PIN_LLAMA);
adc_presion = analogRead(PIN_PRESION);
// MQ-2: ignorar los primeros 60s
// El filamento necesita calentarse, y da margen al operador
// para colocar el sensor antes de que empiece a vigilar.
if (millis() > 60000) {
if (adc_humo > th_humo) triggerAlarm("HUMO");
}
// Llama: lógica invertida — bajo AOUT = llama detectada
if (adc_llama < th_llama) triggerAlarm("FUEGO");
// Presión: solo si la máquina está en marcha
if (currentMode != "paro" && !alarmActive && adc_presion < th_presion)
triggerAlarm("AIRE");
}
void setMode(String m) {
if (alarmActive && m != "paro") {
Serial.println("Comando denegado: ALARMA ACTIVA");
return;
}
Serial.print("MODO: "); Serial.println(m);
if (m == "paro") {
currentMode = "paro";
r_ext = false; r_comp = false; r_agua = false;
alarmActive = false;
}
else if (m == "enciendeLaser") {
checkSensors();
if (alarmActive) return;
currentMode = "laser";
r_ext = true; r_comp = true; r_agua = true;
}
else if (m == "enciendeJwei") {
checkSensors();
if (alarmActive) return;
currentMode = "jwei";
r_ext = false; r_comp = true; r_agua = true;
}
else if (m == "toggleAgua") {
if (alarmActive) return;
r_agua = !r_agua;
}
updateOutputNeeded = true;
}
// ==========================================
// MQTT
// ==========================================
void connectMqtt() {
if (WiFi.status() == WL_CONNECTED) mqttCliente.connect();
}
void onMqttMsg(char* topic, char* payload,
AsyncMqttClientMessageProperties p,
size_t len, size_t i, size_t t) {
JsonDocument doc;
DeserializationError error = deserializeJson(doc, payload, len);
if (!error) {
String type = doc["type"];
if (type == "cmd")
setMode(doc["command"].as<String>());
else if (type == "config") {
if (doc["humo"].is<int>()) th_humo = doc["humo"];
if (doc["llama"].is<int>()) th_llama = doc["llama"];
if (doc["presion"].is<int>()) th_presion = doc["presion"];
preferences.begin("pakulaser", false);
preferences.putInt("th_humo", th_humo);
preferences.putInt("th_llama", th_llama);
preferences.putInt("th_presion", th_presion);
preferences.end();
publishStateNeeded = true;
}
} else {
String msg;
for (size_t k = 0; k < len; k++) msg += (char)payload[k];
setMode(msg);
}
}
// ==========================================
// SETUP
// ==========================================
void setup() {
// Pines de relé a estado seguro antes de nada
pinMode(PIN_EXTRACTOR, OUTPUT); digitalWrite(PIN_EXTRACTOR, HIGH);
pinMode(PIN_COMPRESOR, OUTPUT); digitalWrite(PIN_COMPRESOR, HIGH);
pinMode(PIN_AGUA, INPUT); // open-drain: INPUT = relé desactivado
Serial.begin(115200);
delay(1000);
Serial.println("--- PAKULASER ARRANQUE ---");
// WiFi dual: AP siempre activo + STA si hay credenciales
WiFi.mode(WIFI_AP_STA);
WiFi.setTxPower(WIFI_POWER_19_5dBm);
esp_wifi_set_protocol(WIFI_IF_STA, WIFI_PROTOCOL_11B | WIFI_PROTOCOL_11G);
WiFi.softAP(AP_NAME_CFG, AP_PASS_CFG);
Serial.print("AP: "); Serial.println(WiFi.softAPIP());
// Sensores analógicos
pinMode(PIN_MQ2, INPUT);
pinMode(PIN_LLAMA, INPUT);
pinMode(PIN_PRESION, INPUT);
// Cargar configuración de NVS
preferences.begin("pakulaser", false);
String s = preferences.getString("ssid", "");
String p = preferences.getString("pass", "");
String m = preferences.getString("mqtt_srv", "tu-broker-mqtt");
mqtt_port = preferences.getInt("mqtt_port", 1883);
String mu = preferences.getString("mqtt_user", "");
String mp = preferences.getString("mqtt_pass", "");
s.toCharArray(wifi_ssid, 40);
p.toCharArray(wifi_pass, 40);
m.toCharArray(mqtt_server, 40);
mu.toCharArray(mqtt_user, 40);
mp.toCharArray(mqtt_pass, 40);
th_humo = preferences.getInt("th_humo", 2500);
th_llama = preferences.getInt("th_llama", 500);
th_presion = preferences.getInt("th_presion", 1000);
preferences.end();
if (strlen(wifi_ssid) > 0) {
Serial.print("Conectando a: "); Serial.println(wifi_ssid);
WiFi.begin(wifi_ssid, wifi_pass);
}
// WebSocket: envía configuración actual al conectar
ws.onEvent([](AsyncWebSocket* server, AsyncWebSocketClient* client,
AwsEventType type, void* arg, uint8_t* data, size_t len) {
if (type == WS_EVT_CONNECT) {
publishStateNeeded = true;
JsonDocument docCfg;
docCfg["type"] = "settings";
docCfg["ssid"] = wifi_ssid;
docCfg["pass"] = wifi_pass;
docCfg["mqtt_s"] = mqtt_server;
docCfg["mqtt_p"] = mqtt_port;
docCfg["mqtt_u"] = mqtt_user;
docCfg["mqtt_pw"] = mqtt_pass;
docCfg["th_humo"] = th_humo;
docCfg["th_llama"] = th_llama;
docCfg["th_presion"] = th_presion;
String out; serializeJson(docCfg, out);
client->text(out);
}
else if (type == WS_EVT_DATA) {
JsonDocument d;
deserializeJson(d, (char*)data, len);
if (d["type"] == "cmd") setMode(d["command"].as<String>());
}
});
servidorWeb.addHandler(&ws);
servidorWeb.on("/", HTTP_GET, [](AsyncWebServerRequest* req) {
req->send_P(200, "text/html", index_html);
});
servidorWeb.on("/saveconfig", HTTP_POST, [](AsyncWebServerRequest* req) {
if (req->hasParam("ssid", true)) {
preferences.begin("pakulaser", false);
preferences.putString("ssid", req->getParam("ssid", true)->value());
preferences.putString("pass", req->getParam("pass", true)->value());
preferences.putString("mqtt_srv", req->getParam("server", true)->value());
if (req->hasParam("port", true))
preferences.putInt("mqtt_port", req->getParam("port", true)->value().toInt());
if (req->hasParam("user", true))
preferences.putString("mqtt_user", req->getParam("user", true)->value());
if (req->hasParam("mqtt_pass", true))
preferences.putString("mqtt_pass", req->getParam("mqtt_pass",true)->value());
preferences.end();
}
req->send(200, "text/plain", "OK. Reiniciando...");
shouldRestart = true;
});
servidorWeb.on("/savecal", HTTP_POST, [](AsyncWebServerRequest* req) {
if (req->hasParam("th_humo", true)) {
th_humo = req->getParam("th_humo", true)->value().toInt();
th_llama = req->getParam("th_llama", true)->value().toInt();
th_presion = req->getParam("th_presion", true)->value().toInt();
preferences.begin("pakulaser", false);
preferences.putInt("th_humo", th_humo);
preferences.putInt("th_llama", th_llama);
preferences.putInt("th_presion", th_presion);
preferences.end();
publishStateNeeded = true;
}
req->redirect("/");
});
servidorWeb.begin();
// MQTT con reconexión por timer FreeRTOS (no bloquea el loop)
mqttReconnectTimer = xTimerCreate(
"mqttTimer", pdMS_TO_TICKS(5000), pdFALSE, (void*)0,
reinterpret_cast<TimerCallbackFunction_t>(connectMqtt));
mqttCliente.onConnect([](bool s) {
Serial.println("MQTT conectado");
mqttCliente.subscribe(TOPIC_CMD, 1);
});
mqttCliente.onMessage(onMqttMsg);
mqttCliente.setServer(mqtt_server, mqtt_port);
if (strlen(mqtt_user) > 0) mqttCliente.setCredentials(mqtt_user, mqtt_pass);
connectMqtt();
}
// ==========================================
// LOOP
// ==========================================
unsigned long t_sens = 0;
void loop() {
ws.cleanupClients();
// Reconexión MQTT si es necesario
if (WiFi.status() == WL_CONNECTED && !mqttCliente.connected()) {
if (xTimerIsTimerActive(mqttReconnectTimer) == pdFALSE)
xTimerStart(mqttReconnectTimer, 0);
}
// Lectura de sensores cada 200ms
if (millis() - t_sens > 200) {
t_sens = millis();
checkSensors();
publishStateNeeded = true;
}
// Actualizar salidas hardware
if (updateOutputNeeded) {
updateOutputNeeded = false;
// Relés estándar: LOW = activado, HIGH = reposo
digitalWrite(PIN_EXTRACTOR, r_ext ? LOW : HIGH);
digitalWrite(PIN_COMPRESOR, r_comp ? LOW : HIGH);
// Relé agua: open-drain por software
// INPUT → alta impedancia → pull-up módulo → relé OFF → Ruida "agua OK"
// OUTPUT → arrastra a GND → relé ON → Ruida "sin agua" → para
if (r_agua) {
pinMode(PIN_AGUA, OUTPUT);
digitalWrite(PIN_AGUA, LOW);
} else {
pinMode(PIN_AGUA, INPUT);
}
Serial.printf("OUT: Ext:%d Cmp:%d Agua:%d\n", r_ext, r_comp, r_agua);
publishStateNeeded = true;
}
// Publicar estado por WebSocket y MQTT
if (publishStateNeeded) {
publishStateNeeded = false;
JsonDocument doc;
doc["type"] = "status";
doc["mode"] = currentMode;
doc["alarm_active"] = alarmActive;
doc["alarm_reason"] = alarmReason;
doc["adc_humo"] = adc_humo;
doc["adc_llama"] = adc_llama;
doc["adc_presion"] = adc_presion;
doc["al_humo"] = (adc_humo > th_humo);
doc["al_llama"] = (adc_llama < th_llama);
doc["al_presion"] = (!alarmActive && currentMode != "paro"
&& adc_presion < th_presion);
doc["r_ext"] = r_ext;
doc["r_comp"] = r_comp;
doc["r_agua"] = r_agua;
String out; serializeJson(doc, out);
ws.textAll(out);
if (WiFi.status() == WL_CONNECTED && mqttCliente.connected())
mqttCliente.publish(TOPIC_STATE, 0, true, out.c_str());
}
if (shouldRestart) { delay(1000); ESP.restart(); }
}
webpage.h — la interfaz web embebida (HTML + CSS + WebSocket en PROGMEM):
const char index_html[] PROGMEM = R"rawliteral(
<!DOCTYPE HTML>
<html lang="es">
<head>
<title>PakuLaser</title>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
body { font-family: sans-serif; background-color: #f4f4f9; color: #333;
margin: 0; padding: 10px; }
.header { background: #fff; padding: 15px; border-radius: 8px;
border-bottom: 4px solid #007bff; margin-bottom: 15px;
text-align: center; }
h1 { margin: 0; color: #007bff; font-size: 1.5rem; }
.status-line { margin-top: 5px; font-weight: bold; font-size: 0.9rem; }
.card { background: #fff; padding: 15px; border-radius: 8px;
margin-bottom: 15px; box-shadow: 0 2px 5px rgba(0,0,0,0.1); }
h2 { border-bottom: 1px solid #eee; padding-bottom: 5px;
color: #555; font-size: 1.1rem; }
.row { display: flex; justify-content: space-between;
padding: 8px 0; border-bottom: 1px dotted #ccc; }
.val { font-family: monospace; font-weight: bold; font-size: 1.1rem; }
.danger { color: #d32f2f; } .safe { color: #388e3c; }
.btn-grid { display: grid; grid-template-columns: 1fr 1fr;
gap: 10px; margin-top: 15px; }
button { padding: 12px; border: none; border-radius: 5px;
font-weight: bold; cursor: pointer; color: white; }
.btn-laser { background-color: #007bff; }
.btn-jwei { background-color: #6f42c1; }
.btn-stop { background-color: #dc3545; grid-column: span 2; }
.btn-agua { background-color: #009688; grid-column: span 2;
margin-top: 5px; }
.btn-save { background-color: #28a745; width: 100%; margin-top: 10px; }
.active-mode { outline: 3px solid #333; }
.agua-on { background-color: #004d40; border: 2px solid #009688; }
input { width: 100%; padding: 8px; margin: 5px 0 10px;
border: 1px solid #ccc; border-radius: 4px;
box-sizing: border-box; }
label { font-size: 0.85rem; font-weight: bold; color: #666; }
.online { color: green; } .offline { color: red; }
</style>
</head>
<body>
<div class="header">
<h1>PAKU LASER</h1>
<div class="status-line">
WS: <span id="ws-state" class="offline">...</span> |
<span id="mode-display">---</span>
</div>
</div>
<div class="card">
<h2>Sensores</h2>
<div class="row"><span>Humo</span>
<span id="v-humo" class="val">---</span></div>
<div class="row"><span>Llama (invertido)</span>
<span id="v-llama" class="val">---</span></div>
<div class="row"><span>Presión</span>
<span id="v-presion" class="val">---</span></div>
<div style="text-align:center;font-size:0.8rem;margin-top:5px;color:#777">
R: Ext[<span id="r-ext"></span>]
Cmp[<span id="r-comp"></span>]
Agua[<span id="r-agua"></span>]
</div>
</div>
<div class="card">
<h2>Control</h2>
<div class="btn-grid">
<button id="b-laser" class="btn-laser"
onclick="cmd('enciendeLaser')">LASER</button>
<button id="b-jwei" class="btn-jwei"
onclick="cmd('enciendeJwei')">JWEI</button>
<button id="b-agua" class="btn-agua"
onclick="cmd('toggleAgua')">AGUA / PAUSA (Manual)</button>
<button class="btn-stop"
onclick="cmd('paro')">PARADA TOTAL</button>
</div>
</div>
<div class="card">
<h2>Calibración</h2>
<form action="/savecal" method="POST">
<label>Max Humo ( > Alarma)</label>
<input type="number" id="i_th_humo" name="th_humo">
<label>Min Llama ( < Alarma)</label>
<input type="number" id="i_th_llama" name="th_llama">
<label>Min Presión ( < Alarma)</label>
<input type="number" id="i_th_presion" name="th_presion">
<button type="submit" class="btn-laser">GUARDAR UMBRALES</button>
</form>
</div>
<div class="card">
<h2>Red WiFi / MQTT</h2>
<form action="/saveconfig" method="POST">
<label>SSID</label>
<input type="text" id="i_ssid" name="ssid">
<label>Password</label>
<input type="password" id="i_pass" name="pass">
<hr>
<label>Broker MQTT</label>
<input type="text" id="i_server" name="server">
<label>Puerto</label>
<input type="number" id="i_port" name="port">
<label>User</label>
<input type="text" id="i_user" name="user">
<label>Pass</label>
<input type="password" id="i_mqtt_pass" name="mqtt_pass">
<button type="submit" class="btn-save">GUARDAR RED</button>
</form>
</div>
<script>
var gateway = `ws://${window.location.hostname}/ws`;
var ws;
function initWS() {
ws = new WebSocket(gateway);
ws.onopen = function() {
document.getElementById('ws-state').innerText = "ONLINE";
document.getElementById('ws-state').className = "online";
};
ws.onclose = function() {
document.getElementById('ws-state').innerText = "OFFLINE";
document.getElementById('ws-state').className = "offline";
setTimeout(initWS, 2000);
};
ws.onmessage = function(e) {
var d = JSON.parse(e.data);
if (d.type == 'status') {
updateVal('v-humo', d.adc_humo, d.al_humo);
updateVal('v-llama', d.adc_llama, d.al_llama);
updateVal('v-presion', d.adc_presion, d.al_presion);
var md = document.getElementById('mode-display');
if (d.alarm_active) {
md.innerText = "¡ALARMA: " + d.alarm_reason + "!";
md.style.color = "red";
} else {
md.innerText = d.mode.toUpperCase();
md.style.color = "blue";
}
document.getElementById('r-ext').innerText = d.r_ext ? "1" : "0";
document.getElementById('r-comp').innerText = d.r_comp ? "1" : "0";
document.getElementById('r-agua').innerText = d.r_agua ? "1" : "0";
document.getElementById('b-laser').className =
d.mode == 'laser' ? "btn-laser active-mode" : "btn-laser";
document.getElementById('b-jwei').className =
d.mode == 'jwei' ? "btn-jwei active-mode" : "btn-jwei";
var bAgua = document.getElementById('b-agua');
if (d.r_agua) {
bAgua.className = "btn-agua agua-on";
bAgua.innerText = "AGUA: ON (Trabajando)";
} else {
bAgua.className = "btn-agua";
bAgua.innerText = "AGUA: OFF (Pausa)";
}
}
if (d.type == 'settings') {
document.getElementById('i_th_humo').value = d.th_humo;
document.getElementById('i_th_llama').value = d.th_llama;
document.getElementById('i_th_presion').value = d.th_presion;
document.getElementById('i_ssid').value = d.ssid;
document.getElementById('i_pass').value = d.pass;
document.getElementById('i_server').value = d.mqtt_s;
document.getElementById('i_port').value = d.mqtt_p;
document.getElementById('i_user').value = d.mqtt_u;
document.getElementById('i_mqtt_pass').value = d.mqtt_pw;
}
};
}
function updateVal(id, val, isAlarm) {
var el = document.getElementById(id);
el.innerText = val;
el.className = isAlarm ? "val danger" : "val safe";
}
function cmd(c) { ws.send(JSON.stringify({type:'cmd', command:c})); }
window.onload = initWS;
</script>
</body>
</html>
)rawliteral";