hardware 2025 · Mitigación de peligros de una máquina de corte láser

PakuLaser: controlador de seguridad para láser CO2 con ESP32

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ó.

PakuLaser ensamblado: placa de control con ESP32-C3 SuperMini y fuente SHB-5W1, junto a dos módulos de relés 30A Songle y un relé pequeño, montados en base de fenólico amarillo
El sistema completo: placa de control con el ESP32-C3 SuperMini y la fuente integrada SHB-5W1 (izquierda), módulos de relés 30A Songle para compresor y extractor (centro-derecha) y el relé pequeño para la señal de agua a la Ruida (arriba).

02 — Los tres sensores y sus umbrales

El sistema monitoriza tres magnitudes, cada una con su umbral configurable:

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.

PakuLaser — Diagrama de conexiones Fuente SHB-5W1 220V AC → 5V + 12V DC ESP32-C3 SuperMini OLED 0.42" · WiFi AP+STA ENTRADAS ANALÓGICAS GPIO0 ADC · humo (MQ-2) GPIO1 ADC · llama (invertido) GPIO2 ADC · presión aire SALIDAS DIGITALES GPIO6 OUT · extractor GPIO7 OUT · compresor GPIO8 OD · agua Ruida 5V GND MQ-2 Gas / Humo · 0-5V VCC=5V GND AOUT warmup 60 s Sensor Llama IR analógico · invertido VCC=5V GND AOUT bajo = fuego Presión Aire Analógico 0-5V VCC=5V GND AOUT caída → alarma AIRE Relé Extractor Songle 30A · activo LOW VCC=5V GND IN + snubber RC Relé Compresor Songle 30A · activo LOW VCC=5V GND IN + snubber RC Relé Agua (Ruida) Open-drain por software INPUT=OK · OUT+LOW=stop → protec. refrigeración Ruida ÷ 2.2k+3.3k ÷ 2.2k+3.3k ÷ 2.2k+3.3k
Esquema eléctrico del PakuLaser en KiCad: ESP32-C3 SuperMini, tres sensores con divisores resistivos, relés de compresor, extractor y señal agua, fuente SHB-5W1
Esquema completo en KiCad 9. Los tres sensores en la columna izquierda, cada uno con su divisor 2.2kΩ + 3.3kΩ. J1 = relé compresor, J2 = relé extractor, J5 = relé agua (señal a Ruida), J6 = sensor presión. La fuente SHB-5W1 en la esquina superior derecha.

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.

Layout de la PCB del PakuLaser en KiCad: placa principal con ESP32-C3 y fuente SHB-5W1, y placa auxiliar para sensores
Layout en KiCad. A la izquierda, la placa auxiliar para MQ-2 y sensor de llama. A la derecha, la placa principal con el ESP32-C3, la huella de la SHB-5W1, el interruptor, la clema 220V y todos los conectores de relés y sensores.

El G-code para el fresado se generó con FlatCAM a partir del Gerber de KiCad.

Trayectorias de fresado del PakuLaser en FlatCAM
Toolpaths en FlatCAM. La broca de aislamiento traza las pistas de cobre de la capa inferior.
CNC fresando las pistas de cobre de la PCB del PakuLaser sobre placa de cobre virgen
La CNC aislando las pistas. Misma metodología que el resto de PCBs de la bitácora.
PCB del PakuLaser soldada vista desde arriba: fuente SHB-5W1, interruptor, ESP32-C3 SuperMini y conectores JST etiquetados
La placa soldada. La fuente SHB-5W1 ocupa el hueco izquierdo. Conectores JST blancos etiquetados a rotulador: LLAMA, HUMO, PSI (presión), RELE AGUA, COMP, EXTRACTOR RELE.
Cara de cobre de la PCB del PakuLaser: pistas fresadas y soldaduras de componentes through-hole
La cara de cobre con las pistas fresadas en la CNC y las soldaduras de los componentes.
PCB del PakuLaser encendida: OLED del ESP32-C3 SuperMini mostrando la IP 192.168.4.1 en modo AP
Primer arranque. El OLED de 0.42" muestra la IP del punto de acceso (192.168.4.1). Suficiente para confirmar que el ESP32 está vivo y la red activa.

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.

Snubber RC en relés de potencia (extractor y compresor) L relé 30A M motor N 100 Ω 100 nF X2 El snubber absorbe el pico inductivo cuando el relé corta la alimentación del motor. Condensador X2: certificado para red eléctrica. Uno por cada relé de potencia.

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:

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.

PakuLaser montado y cableado: placa de control conectada a los módulos de relés 30A con cables de colores
El sistema cableado. Los cables van de los conectores JST de la placa de control a las entradas de señal de los módulos de relés. Tres señales de control (GPIO6, 7, 8) más 5V y GND comunes a todo.

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";