Firmware ESP32

Código para grabar en el módulo ESP32-WROOM-32 de un dispositivo maker3d. Con soporte de pantalla para la placa ESP32-3248S035C (TFT 3.5" + touch).

Firmware v1.91 · compilado 24/07/2026

⬇ Descargar binario compilado (.bin) Descargar código (.ino) 🔌 Esquema de conexionado (PDF) Ver código

Opción A — Flashear desde el navegador (más fácil) ⚡

Conectá el ESP32 por USB y grabalo con un clic, sin instalar nada. Requiere Chrome o Edge de escritorio (WebSerial no está en móviles ni en Firefox).

Se va a grabar la versión v1.91.

Tu navegador no soporta WebSerial. Abrí esta página en Chrome o Edge de escritorio. Se necesita una conexión segura (HTTPS).

Al tocar el botón, elegí el puerto USB serial del módulo. Si no aparece, mantené presionado BOOT en el ESP32 mientras conectás.

Opción B — Grabar el binario compilado con esptool

Ya está compilado y verificado. Necesitás solo un cable USB y una de estas herramientas.

Con esptool (multiplataforma). Poné el ESP32 en el puerto y ejecutá:

pip install esptool
esptool --chip esp32 --port /dev/ttyUSB0 --baud 921600 write_flash 0x0 maker3d_esp32.merged.bin

En Windows el puerto es COM3 (o el que corresponda); en Mac, /dev/tty.usbserial-XXXX.

Con la Flash Download Tool de Espressif (Windows) o cualquier flasher que pida offsets, usá los archivos individuales:

OffsetArchivo
0x1000bootloader.bin
0x8000partitions.bin
0xe000boot_app0.bin
0x10000app.bin

El merged.bin de arriba ya incluye los cuatro juntos y se graba en 0x0.

Opción C — Compilar con Arduino IDE

  1. Instalá el Arduino IDE y el soporte de placas esp32 de Espressif (Gestor de tarjetas → buscar “esp32”).
  2. Seleccioná la placa “ESP32 Dev Module” y el puerto COM del módulo.
  3. Importante: en Herramientas → Partition Scheme elegí “Huge APP (3MB No OTA/1MB SPIFFS)”. El firmware usa BLE + WiFi + TLS y no entra en la partición por defecto.
  4. Instalá la librería LovyanGFX desde el Gestor de Librerías (solo para la pantalla).
  5. Abrí maker3d_esp32.ino (descargado arriba).
  6. Presioná Subir. Al terminar, abrí el Monitor Serie a 115200 baudios para ver el device_id y el estado.

Hardware de la secadora 🌡️

El firmware maneja el ciclo real de secado: lee un sensor SHT31 (temperatura + humedad) y acciona el calefactor y el ventilador mediante relés, con control por histéresis, timer real y corte por sobre-temperatura. Si no hay sensor conectado, corre en modo demo.

Todo va por un bus I2C en GPIO21/GPIO22 (el único header libre de la placa): el sensor y un módulo de relés I2C (PCF8574). El esquema completo, con tabla de conexiones y notas de seguridad, está en el PDF.

Qué hace

Código fuente

/*
 * maker3d - Firmware para ESP32-WROOM-32 / placa con pantalla ESP32-3248S035C
 * ------------------------------------------------------------------
 * Aprovisionamiento por Bluetooth (BLE) + registro por WiFi, con interfaz en
 * pantalla TFT 3.5" (ST7796, 480x320) y touch capacitivo (GT911) de estética
 * Windows 11 / Fluent Design.
 *
 * Comportamiento:
 *  - De fábrica enciende BLE y queda "descubrible". La app maker3d lo encuentra
 *    por el Service UUID (firma de maker3d) y lee su chip id.
 *  - La app le envía por BLE: SSID, contraseña WiFi, un "claim" (ata el equipo a
 *    la cuenta) y un nombre.
 *  - Se conecta a WiFi y se registra en el servidor. Si lo logra, guarda en NVS,
 *    avisa "registered" y apaga el BLE. Si falla, vuelve a encender el BLE.
 *  - La pantalla muestra el estado con íconos y tipografía tipo Windows 11.
 *    Un toque despierta la pantalla; una pulsación larga (3s) vuelve a modo
 *    emparejamiento (BLE). El equipo se actualiza solo por OTA (HTTPS).
 *
 * Placa: "ESP32 Dev Module".  Partition Scheme: "Minimal SPIFFS (1.9MB APP con
 * OTA)" — hace falta la doble partición para el auto-update (ver firmware/compile.sh).
 * Librerías: LovyanGFX (pantalla) y NimBLE-Arduino (Bluetooth, más liviano que
 * bluedroid). Ambas configuradas inline.
 * Pines: placa Sunton ESP32-3248S035C (CYD 3.5"). En un WROOM sin display igual
 * corre (BLE/WiFi); la pantalla no muestra nada.
 */

#define LGFX_USE_V1
#include <LovyanGFX.hpp>
#include <WiFi.h>
#include <WiFiClientSecure.h>
#include <HTTPClient.h>
#include <Update.h>
#include <Preferences.h>
#include <NimBLEDevice.h>
#include <time.h>
#include <Wire.h>

// ---------- Pantalla (ESP32-3248S035C) ----------
class LGFX : public lgfx::LGFX_Device {
  lgfx::Panel_ST7796  _panel;
  lgfx::Bus_SPI       _bus;
  lgfx::Light_PWM     _light;
  lgfx::Touch_GT911   _touch;
public:
  LGFX(void) {
    { auto c = _bus.config();
      c.spi_host = SPI2_HOST; c.spi_mode = 0;
      // 27 MHz: frecuencia estable. 40 MHz produce parpadeo/ruido en esta placa
      // (sobre todo al redibujar), así que el máximo estable acá es 27 MHz.
      c.freq_write = 27000000; c.freq_read = 16000000;
      c.spi_3wire = false; c.use_lock = true; c.dma_channel = SPI_DMA_CH_AUTO;
      c.pin_sclk = 14; c.pin_mosi = 13; c.pin_miso = 12; c.pin_dc = 2;
      _bus.config(c); _panel.setBus(&_bus);
    }
    { auto c = _panel.config();
      c.pin_cs = 15; c.pin_rst = -1; c.pin_busy = -1;
      c.panel_width = 320; c.panel_height = 480;
      c.offset_x = 0; c.offset_y = 0;
      c.readable = true; c.invert = false; c.rgb_order = false;
      c.dlen_16bit = false; c.bus_shared = true;
      _panel.config(c);
    }
    { auto c = _light.config();
      c.pin_bl = 27; c.invert = false; c.freq = 44100; c.pwm_channel = 7;
      _light.config(c); _panel.setLight(&_light);
    }
    { auto c = _touch.config();
      c.x_min = 0; c.x_max = 319; c.y_min = 0; c.y_max = 479;
      // INT/RST en -1: el GT911 ya escanea desde el arranque; sin la secuencia de
      // reset de LovyanGFX (que en esta placa parece dejarlo mudo), lo leemos por
      // consulta I2C pura. Dirección 0x5D confirmada por el escaneo.
      c.pin_int = -1; c.pin_rst = -1; c.bus_shared = false; c.offset_rotation = 0;
      c.i2c_port = 1; c.i2c_addr = 0x5D; c.pin_sda = 33; c.pin_scl = 32; c.freq = 400000;
      _touch.config(c); _panel.setTouch(&_touch);
    }
    setPanel(&_panel);
  }
};
LGFX tft;
bool hasDisplay = false;

// El callback BLE (onWrite) corre en la tarea del stack BLE y también dibuja en
// la pantalla, mientras loop() dibuja el spinner desde la tarea principal. Sin
// exclusión mutua, dos tareas escriben el bus SPI a la vez y la imagen parpadea.
// Este mutex serializa TODO el dibujado.
SemaphoreHandle_t tftMux = nullptr;
struct TftLock {
  bool held;
  TftLock() : held(false) {
    if (tftMux) held = (xSemaphoreTake(tftMux, portMAX_DELAY) == pdTRUE);
  }
  ~TftLock() { if (held && tftMux) xSemaphoreGive(tftMux); }
};

// ---------- BLE UUIDs (deben coincidir con la app) ----------
#define SERVICE_UUID   "6c8f9a10-4b2c-4e6d-9f13-2a7b5c8d1e0f"
#define CHAR_DEVID     "6c8f9a11-4b2c-4e6d-9f13-2a7b5c8d1e0f"
#define CHAR_STATUS    "6c8f9a12-4b2c-4e6d-9f13-2a7b5c8d1e0f"
#define CHAR_CONFIG    "6c8f9a13-4b2c-4e6d-9f13-2a7b5c8d1e0f"

static const char* REGISTER_URL  = "https://maker3d.jrintercom.net/api/device_register.php";
static const char* HEARTBEAT_URL = "https://maker3d.jrintercom.net/api/device_heartbeat.php";
static const char* FW_VERSION    = "1.91";

Preferences prefs;
NimBLEServer*         bleServer  = nullptr;
NimBLECharacteristic* statusChar = nullptr;
bool bleActive = false;

String deviceId;
String gToken;
int    heartbeatFails = 0;
unsigned long lastBeat = 0;
int    gLastHttp = 0;   // último código HTTP (para detectar el 401 = equipo borrado).
int    unauthorizedCount = 0; // heartbeats seguidos con 401 (equipo borrado).

// Aprovisionamiento diferido: el callback BLE guarda los datos y marca este
// flag; loop() (tarea principal, con stack grande) hace el WiFi + HTTPS. Hacer
// TLS dentro del callback BLE desborda su stack chico y reinicia el ESP32.
volatile bool provisionPending = false;
String pSsid, pPass, pClaim, pName;

// OTA diferido: el heartbeat marca esto y loop() hace la descarga en una vuelta
// aparte (con la conexión del heartbeat ya cerrada, para no chocar por recursos).
volatile bool otaPending = false;
String otaUrl;

// Captura de pantalla diferida: el heartbeat pide una y loop() la sube.
// shotScreen (>=0) navega a esa pantalla antes de capturar (para verlas todas
// remotamente sin tocar el equipo).
volatile bool shotPending = false;
int           shotScreen  = -1;

// ---------- Idioma (bilingüe ES/PT) ----------
// 0 = Español (principal), 1 = Português. La fuente GFX no trae glifos acentuados,
// así que las cadenas PT van SIN acentos (ç, ã, á...) para que se vean bien.
uint8_t uiLang = 0;
const char* TR(const char* es, const char* pt);   // definición más abajo (para no
                                                    // mover el punto de auto-prototipos)

// ---------- Paleta Windows 11 (Fluent, dark) ----------
uint16_t COL_BG, COL_SURF, COL_TEXT, COL_MUTE, COL_ACCENT, COL_GREEN, COL_AMBER, COL_RED, COL_DIM;

// Layout
static const int ICON_CX = 240;
static const int ICON_CY = 116;
static const int ICON_R  = 46;
static const int RING_R0 = 52;
static const int RING_R1 = 57;

enum UiState { UI_INIT, UI_BLE, UI_CONNECTING, UI_REGISTERING, UI_REGISTERED, UI_ONLINE, UI_WIFI_FAIL, UI_REG_FAIL, UI_BADCFG, UI_UPDATING };
enum DryScreen { D_PANEL, D_PRESETS, D_ADJUST, D_DONE, D_SETTINGS, D_SCHEDULE }; // pantallas de la secadora
UiState  currentUi = UI_INIT;
uint16_t curIconColor;
int      spinAngle = 0;
unsigned long lastSpin = 0;

bool uiBusy(UiState s) { return s == UI_INIT || s == UI_CONNECTING || s == UI_REGISTERING || s == UI_UPDATING; }

// Traducción ES/PT (definición; ver declaración arriba).
const char* TR(const char* es, const char* pt) { return uiLang ? pt : es; }

// ---------- Ajustes de pantalla (persisten en NVS; el servidor los actualiza
// por heartbeat, y la app los cambia desde "Mis dispositivos") ----------
uint8_t  dispBacklight = 100;  // brillo 0..100 (%). 0 = apagado. Máximo por defecto.
bool     dispPowerSave = true; // apagar la pantalla tras dispSleepMin sin uso.
uint16_t dispSleepMin  = 5;    // minutos hasta apagar (si el ahorro está activo).
volatile bool pushBrightness = false; // el usuario cambió el brillo en la pantalla: avisar al server.

bool          screenOn     = true;  // estado actual del backlight (encendido/ahorro).
unsigned long lastActivity = 0;     // último toque o cambio de estado.
bool          appMode      = false; // true = corriendo la app de la secadora.

// ---- Salvapantalla "Nebulosa de polvo estelar" (partículas) ----
// En modo ahorro, en vez de apagar la pantalla, se muestra una constelación de
// nodos flotando lento con líneas casi invisibles entre los más cercanos.
#define SAVER_N     45
#define SAVER_MAXSEG 340
struct SNode { float x, y, vx, vy; };
SNode  sNodes[SAVER_N];
struct SSeg { int16_t x0, y0, x1, y1; uint16_t col; };
SSeg   sSeg[SAVER_MAXSEG];
int    sSegN = 0;
float  brandX = 240, brandY = 160, brandVX = 0.3f, brandVY = 0.2f;   // marca flotante
volatile bool saverActive = false;   // el salvapantalla se está mostrando
bool   saverPrimed = false;          // ya se inicializó esta sesión
bool   shotSaver = false;            // la próxima captura rasteriza el salvapantalla
uint16_t SV_DOT, SV_DOT2, SV_LINE_LO, SV_LINE_HI, SV_BRAND;

uint8_t pctTo255(uint8_t pct) { if (pct > 100) pct = 100; return (uint8_t)((uint16_t)pct * 255 / 100); }

void applyBacklight(bool on) {
  if (!hasDisplay) return;
  tft.setBrightness(on ? pctTo255(dispBacklight) : 0);
}

void drawDryer();       // fwd
void drawUi(UiState s); // fwd
// Toque o actividad: reinicia el temporizador de ahorro; sale del salvapantalla o
// enciende la pantalla si estaba apagada, y redibuja la UI.
void wakeScreen() {
  lastActivity = millis();
  bool wasSaver = saverActive;
  saverActive = false;
  if (!screenOn) { screenOn = true; applyBacklight(true); }
  if (wasSaver) { if (appMode) drawDryer(); else drawUi(currentUi); }  // restaurar UI
}

// Entra en salvapantalla si el ahorro está activo y pasó el tiempo sin uso.
void powerSaveTick() {
  if (!hasDisplay || !dispPowerSave || !screenOn || saverActive) return;
  if (dispBacklight == 0) return; // apagada por configuración: no hay salvapantalla
  if (millis() - lastActivity > (unsigned long)dispSleepMin * 60000UL) {
    saverPrimed = false;
    saverActive = true;   // el salvapantalla lo dibuja su tarea (saverTaskFn)
  }
}

// Mezcla dos colores RGB565 (t: 0=a, 1=b).
uint16_t lerp565(uint16_t a, uint16_t b, float t) {
  int ar=(a>>11)&0x1f, ag=(a>>5)&0x3f, ab=a&0x1f;
  int br=(b>>11)&0x1f, bg=(b>>5)&0x3f, bb=b&0x1f;
  int r=ar+(int)((br-ar)*t), g=ag+(int)((bg-ag)*t), bl=ab+(int)((bb-ab)*t);
  return (uint16_t)((r<<11)|(g<<5)|bl);
}

// Inicializa nodos y la marca con posición/velocidad al azar (sin dibujar; el
// primer fotograma limpia por bandas). Corre al activarse el salvapantalla.
void saverPrime() {
  for (int i=0;i<SAVER_N;i++) {
    sNodes[i].x = random(16, 464);
    sNodes[i].y = random(16, 304);
    sNodes[i].vx = (random(0,2)?1:-1) * (random(90,220)/100.0f);   // deriva más rápida
    sNodes[i].vy = (random(0,2)?1:-1) * (random(90,220)/100.0f);
  }
  brandX = random(160, 320); brandY = random(120, 200);
  brandVX = (random(0,2)?1:-1) * (random(80,160)/100.0f);
  brandVY = (random(0,2)?1:-1) * (random(80,160)/100.0f);
  sSegN = 0;
}
// El salvapantalla y su render por bandas se definen más abajo (usan DG).

// ---------- Utilidades ----------
String chipIdHex() {
  uint64_t mac = ESP.getEfuseMac();
  char buf[13];
  snprintf(buf, sizeof(buf), "%04X%08X", (uint16_t)(mac >> 32), (uint32_t)mac);
  return String(buf);
}

// ---------- Íconos vectoriales (estilo Fluent, trazo redondeado) ----------
void icoBluetooth(int cx, int cy, uint16_t col) {
  const float w = 3.4f; const int H = 22, W = 13;
  tft.drawWideLine(cx, cy - H, cx, cy + H, w, col);
  tft.drawWideLine(cx, cy - H, cx + W, cy - H / 2, w, col);
  tft.drawWideLine(cx + W, cy - H / 2, cx - W, cy + H / 2, w, col);
  tft.drawWideLine(cx - W, cy - H / 2, cx + W, cy + H / 2, w, col);
  tft.drawWideLine(cx + W, cy + H / 2, cx, cy + H, w, col);
}

void icoWifi(int cx, int cy, uint16_t col) {
  int by = cy + 16;
  tft.fillArc(cx, by, 30, 34, 220, 320, col);
  tft.fillArc(cx, by, 19, 23, 220, 320, col);
  tft.fillSmoothCircle(cx, by - 2, 4, col);
}

void icoCheck(int cx, int cy, uint16_t col) {
  const float w = 4.0f;
  tft.drawWideLine(cx - 17, cy + 1, cx - 5, cy + 13, w, col);
  tft.drawWideLine(cx - 5, cy + 13, cx + 19, cy - 13, w, col);
}

void icoWarn(int cx, int cy, uint16_t col) {
  const float w = 3.4f;
  int T = 24;
  tft.drawWideLine(cx, cy - T, cx - T, cy + T - 4, w, col);
  tft.drawWideLine(cx - T, cy + T - 4, cx + T, cy + T - 4, w, col);
  tft.drawWideLine(cx + T, cy + T - 4, cx, cy - T, w, col);
  tft.drawWideLine(cx, cy - 6, cx, cy + 7, w, col);
  tft.fillSmoothCircle(cx, cy + 15, 2, col);
}

void drawIcon(const char* name, uint16_t col) {
  if      (!strcmp(name, "bt"))    icoBluetooth(ICON_CX, ICON_CY, col);
  else if (!strcmp(name, "wifi"))  icoWifi(ICON_CX, ICON_CY, col);
  else if (!strcmp(name, "check")) icoCheck(ICON_CX, ICON_CY, col);
  else if (!strcmp(name, "warn"))  icoWarn(ICON_CX, ICON_CY, col);
  // "spin": sin ícono fijo, lo cubre el spinner
}

void drawWordmark() {
  tft.setFont(&fonts::FreeSansBold12pt7b);
  tft.setTextDatum(lgfx::textdatum_t::top_left);
  int x = 22, y = 18;
  tft.setTextColor(COL_TEXT, COL_BG);
  tft.drawString("Maker", x, y);
  x += tft.textWidth("Maker");
  tft.setTextColor(COL_ACCENT, COL_BG);
  tft.drawString("3D", x, y);

  // Versión del firmware, arriba a la derecha: así se sabe qué versión corre.
  tft.setFont(&fonts::FreeSans9pt7b);
  tft.setTextDatum(lgfx::textdatum_t::top_right);
  tft.setTextColor(COL_MUTE, COL_BG);
  tft.drawString((String("v") + FW_VERSION).c_str(), 458, 22);
}

void drawUi(UiState s) {
  currentUi = s;
  if (!hasDisplay) return;
  TftLock lock;

  const char* icon; const char* title; const char* sub; uint16_t color;
  switch (s) {
    case UI_INIT:        icon="spin";  title=TR("Iniciando","Iniciando");            sub="Maker3D";                                                    color=COL_ACCENT; break;
    case UI_BLE:         icon="bt";    title=TR("Listo para vincular","Pronto para vincular");  sub=TR("Abri la app Maker3D y agregalo","Abra o app Maker3D e adicione");   color=COL_ACCENT; break;
    case UI_CONNECTING:  icon="wifi";  title=TR("Conectando","Conectando");           sub=TR("Uniendose a la red WiFi","Conectando na rede WiFi");       color=COL_ACCENT; break;
    case UI_REGISTERING: icon="wifi";  title=TR("Registrando","Registrando");          sub=TR("Vinculando con tu cuenta","Vinculando com sua conta");     color=COL_ACCENT; break;
    case UI_REGISTERED:  icon="check"; title=TR("Listo","Pronto");                sub=TR("Dispositivo vinculado","Dispositivo vinculado");           color=COL_GREEN;  break;
    case UI_ONLINE:      icon="wifi";  title=TR("En linea","Online");             sub=TR("Conectado y funcionando","Conectado e funcionando");       color=COL_GREEN;  break;
    case UI_WIFI_FAIL:   icon="warn";  title=TR("Sin conexion WiFi","Sem conexao WiFi");    sub=TR("Toca la pantalla para reintentar","Toque a tela para tentar de novo"); color=COL_AMBER;  break;
    case UI_REG_FAIL:    icon="warn";  title=TR("No se pudo registrar","Nao foi possivel registrar"); sub=TR("Toca la pantalla para reintentar","Toque a tela para tentar de novo"); color=COL_RED;    break;
    case UI_BADCFG:      icon="warn";  title=TR("Datos invalidos","Dados invalidos");      sub=TR("Reintenta desde la app","Tente novamente pelo app");       color=COL_AMBER;  break;
    case UI_UPDATING:    icon="spin";  title=TR("Actualizando","Atualizando");         sub=TR("Descargando firmware nuevo...","Baixando novo firmware...");    color=COL_ACCENT; break;
    default:             icon="spin";  title="";                     sub="";                                 color=COL_ACCENT; break;
  }
  curIconColor = color;

  tft.fillScreen(COL_BG);
  drawWordmark();

  // Círculo "chip" detrás del ícono (superficie Fluent)
  tft.fillSmoothCircle(ICON_CX, ICON_CY, ICON_R, COL_SURF);
  if (uiBusy(s)) tft.fillArc(ICON_CX, ICON_CY, RING_R0, RING_R1, 0, 360, COL_DIM);
  drawIcon(icon, color);

  tft.setFont(&fonts::FreeSansBold18pt7b);
  tft.setTextDatum(lgfx::textdatum_t::middle_center);
  tft.setTextColor(COL_TEXT, COL_BG);
  tft.drawString(title, 240, 208);

  tft.setFont(&fonts::FreeSans12pt7b);
  tft.setTextColor(COL_MUTE, COL_BG);
  tft.drawString(sub, 240, 244);

  tft.drawFastHLine(40, 284, 400, COL_DIM);
  tft.fillSmoothCircle(60, 302, 4, color);
  tft.setFont(&fonts::FreeSans9pt7b);
  tft.setTextDatum(lgfx::textdatum_t::middle_left);
  tft.setTextColor(COL_MUTE, COL_BG);
  String idline = "ID  " + deviceId;
  tft.drawString(idline.c_str(), 74, 302);

  // Un cambio de estado cuenta como actividad: encendé la pantalla para que se
  // vea, y reiniciá el temporizador de ahorro.
  wakeScreen();
}

// Spinner animado (Fluent) para estados de trabajo
void spinnerTick() {
  if (!hasDisplay || appMode || !uiBusy(currentUi)) return; // en la app no va el spinner
  if (millis() - lastSpin < 60) return;
  lastSpin = millis();
  spinAngle = (spinAngle + 26) % 360;
  TftLock lock;
  tft.fillArc(ICON_CX, ICON_CY, RING_R0, RING_R1, 0, 360, COL_DIM);
  tft.fillArc(ICON_CX, ICON_CY, RING_R0, RING_R1, spinAngle, spinAngle + 100, curIconColor);
}

// ---------- Estado ----------
void screenStatus(const char* code) {
  if      (!strcmp(code, "connecting"))  drawUi(UI_CONNECTING);
  else if (!strcmp(code, "registering")) drawUi(UI_REGISTERING);
  else if (!strcmp(code, "registered"))  drawUi(UI_REGISTERED);
  else if (!strcmp(code, "wifi_fail"))   drawUi(UI_WIFI_FAIL);
  else if (!strcmp(code, "reg_fail"))    drawUi(UI_REG_FAIL);
  else if (!strcmp(code, "bad_config"))  drawUi(UI_BADCFG);
}

void setStatus(const char* s) {
  if (statusChar) {
    statusChar->setValue((uint8_t*)s, strlen(s));
    if (bleActive) statusChar->notify();
  }
  screenStatus(s);
}

String jsonStr(const String& body, const char* key) {
  String pat = String("\"") + key + "\":\"";
  int i = body.indexOf(pat);
  if (i < 0) return "";
  i += pat.length();
  int j = body.indexOf('"', i);
  if (j < 0) return "";
  return body.substring(i, j);
}

// Extrae un valor numérico ("key":123) del JSON; devuelve dflt si no está.
long jsonInt(const String& body, const char* key, long dflt) {
  String pat = String("\"") + key + "\":";
  int i = body.indexOf(pat);
  if (i < 0) return dflt;
  i += pat.length();
  while (i < (int)body.length() && body[i] == ' ') i++;
  long sign = 1;
  if (i < (int)body.length() && body[i] == '-') { sign = -1; i++; }
  long v = 0; bool any = false;
  while (i < (int)body.length() && body[i] >= '0' && body[i] <= '9') {
    v = v * 10 + (body[i] - '0'); i++; any = true;
  }
  return any ? sign * v : dflt;
}

// Aplica (y opcionalmente persiste) los ajustes de pantalla.
void applyDisplaySettings(uint8_t bl, bool ps, uint16_t sm, bool persist) {
  if (bl > 100) bl = 100;
  if (sm < 1)   sm = 1;
  bool changed = (bl != dispBacklight) || (ps != dispPowerSave) || (sm != dispSleepMin);
  dispBacklight = bl;
  dispPowerSave = ps;
  dispSleepMin  = sm;
  if (persist && changed) {
    prefs.putUChar("bl", dispBacklight);
    prefs.putBool("ps", dispPowerSave);
    prefs.putUShort("sm", dispSleepMin);
  }
  // Reflejar el cambio en el acto.
  if (!dispPowerSave) { screenOn = true; lastActivity = millis(); }
  if (screenOn) applyBacklight(true);
}

// ---------- WiFi ----------
bool connectWiFi(const String& ssid, const String& pass, uint32_t timeoutMs = 20000) {
  if (ssid.length() == 0) return false;
  WiFi.mode(WIFI_STA);
  // Máxima potencia de TX (mejor alcance/estabilidad). La alimentación ya no es
  // un problema, así que no hace falta limitarla.
  WiFi.setTxPower(WIFI_POWER_19_5dBm);
  WiFi.begin(ssid.c_str(), pass.c_str());
  uint32_t start = millis();
  while (WiFi.status() != WL_CONNECTED && millis() - start < timeoutMs) {
    spinnerTick();
    delay(120);
  }
  if (WiFi.status() == WL_CONNECTED) {
    // Reloj por NTP (zona Argentina, UTC-3, sin DST). La hora se muestra en la UI.
    configTzTime("<-03>3", "pool.ntp.org", "time.google.com", "time.cloudflare.com");
  }
  return WiFi.status() == WL_CONNECTED;
}

// Hora local "HH:MM" (o "--:--" si el NTP todavía no sincronizó).
String clockStr() {
  time_t now = time(nullptr);
  if (now < 1700000000) return "--:--";   // aún sin sincronizar
  struct tm t; localtime_r(&now, &t);
  char b[6]; strftime(b, sizeof(b), "%H:%M", &t);
  return String(b);
}

// ---------- Agenda (programar inicio/fin del secado por fecha y hora) ----------
bool schedEnabled = false;   // agenda activada
bool schedDone    = false;   // la ventana ya se ejecutó (one-shot)
bool schedInit    = false;   // ya se inicializaron los campos (desde NTP o NVS)
int  schStMon = 1, schStDay = 1, schStHr = 8,  schStMin = 0;   // inicio (mes/día/hora/min)
int  schEnMon = 1, schEnDay = 1, schEnHr = 14, schEnMin = 0;   // fin
unsigned long schedTickMs = 0;

// ---------- Agenda: helpers ----------
// ¿El reloj NTP ya sincronizó?
bool ntpReady() { return time(nullptr) >= 1700000000; }

// Epoch de un mes/día/hora/min usando el AÑO actual (zona local).
time_t schedEpoch(int mon, int day, int hr, int mn) {
  time_t now = time(nullptr);
  struct tm t; localtime_r(&now, &t);
  t.tm_mon = mon - 1; t.tm_mday = day; t.tm_hour = hr; t.tm_min = mn; t.tm_sec = 0;
  t.tm_isdst = -1;
  return mktime(&t);
}

void schedSave() {
  prefs.putBool("scEn", schedEnabled); prefs.putBool("scDn", schedDone);
  prefs.putBool("scIn", schedInit);
  prefs.putUChar("scSMo", schStMon); prefs.putUChar("scSDa", schStDay);
  prefs.putUChar("scSHr", schStHr);  prefs.putUChar("scSMi", schStMin);
  prefs.putUChar("scEMo", schEnMon); prefs.putUChar("scEDa", schEnDay);
  prefs.putUChar("scEHr", schEnHr);  prefs.putUChar("scEMi", schEnMin);
}

void schedLoad() {
  schedEnabled = prefs.getBool("scEn", false);
  schedDone    = prefs.getBool("scDn", false);
  schedInit    = prefs.getBool("scIn", false);
  schStMon = prefs.getUChar("scSMo", 1); schStDay = prefs.getUChar("scSDa", 1);
  schStHr  = prefs.getUChar("scSHr", 8); schStMin = prefs.getUChar("scSMi", 0);
  schEnMon = prefs.getUChar("scEMo", 1); schEnDay = prefs.getUChar("scEDa", 1);
  schEnHr  = prefs.getUChar("scEHr",14); schEnMin = prefs.getUChar("scEMi", 0);
}

// Inicializa los campos con valores razonables (inicio = próxima hora en punto,
// fin = +6 h) la primera vez, en cuanto haya hora NTP.
void schedEnsureInit() {
  if (schedInit || !ntpReady()) return;
  schedInit = true;
  time_t now = time(nullptr);
  struct tm s; localtime_r(&now, &s);
  schStMon = s.tm_mon + 1; schStDay = s.tm_mday; schStHr = (s.tm_hour + 1) % 24; schStMin = 0;
  time_t en = schedEpoch(schStMon, schStDay, schStHr, schStMin) + 6 * 3600;
  struct tm e; localtime_r(&en, &e);
  schEnMon = e.tm_mon + 1; schEnDay = e.tm_mday; schEnHr = e.tm_hour; schEnMin = 0;
  schedSave();
}

// Duración compacta "Xh Ym" (para el estado de la agenda).
String fmtDur(long s) {
  if (s < 0) s = 0;
  long h = s / 3600, m = (s % 3600) / 60;
  return h > 0 ? String(h) + "h " + String(m) + "m" : String(m) + "m";
}

// Ajusta un campo de fecha/hora con wrap. field: 0 día,1 mes,2 hora,3 min (paso 5).
void schAdjust(int field, int* v, int delta) {
  if      (field == 0) { *v += delta;     if (*v < 1) *v = 31; if (*v > 31) *v = 1; }
  else if (field == 1) { *v += delta;     if (*v < 1) *v = 12; if (*v > 12) *v = 1; }
  else if (field == 2) { *v += delta;     if (*v < 0) *v = 23; if (*v > 23) *v = 0; }
  else                 { *v += delta * 5; if (*v < 0) *v = 55; if (*v > 59) *v = 0; }
}

// Aplica la agenda enviada desde la app/web: "en,sMon,sDay,sHr,sMin,eMon,eDay,eHr,eMin".
void applySchedCmd(const String& s) {
  int vals[9], n = 0, start = 0;
  for (int i = 0; i <= (int)s.length() && n < 9; i++) {
    if (i == (int)s.length() || s[i] == ',') { vals[n++] = s.substring(start, i).toInt(); start = i + 1; }
  }
  if (n < 9) return;                       // formato inválido
  schedEnabled = vals[0] != 0;
  schStMon = vals[1]; schStDay = vals[2]; schStHr = vals[3]; schStMin = vals[4];
  schEnMon = vals[5]; schEnDay = vals[6]; schEnHr = vals[7]; schEnMin = vals[8];
  schedInit = true; schedDone = false;
  schedSave();
}

// ---------- Servidor ----------
bool httpPostJson(const char* url, const String& json, String& respOut) {
  WiFiClientSecure client;
  client.setInsecure();
  HTTPClient http;
  if (!http.begin(client, url)) { gLastHttp = -1000; return false; }
  http.addHeader("Content-Type", "application/json");
  int code = http.POST(json);
  respOut = http.getString();
  http.end();
  gLastHttp = code;
  return code == 200;
}

bool registerDevice(const String& claim, const String& name) {
  String body = String("{\"device_id\":\"") + deviceId +
                "\",\"claim\":\"" + claim +
                "\",\"name\":\"" + name +
                "\",\"fw\":\"" + FW_VERSION + "\"}";
  // Reintentos por si el TLS falla de forma transitoria.
  for (int attempt = 1; attempt <= 3; attempt++) {
    String resp;
    if (httpPostJson(REGISTER_URL, body, resp)) {
      String tok = jsonStr(resp, "device_token");
      if (tok.length() >= 10) { gToken = tok; return true; }
    }
    if (attempt < 3) delay(2000);
  }
  return false;
}

// ---------- OTA (actualización de firmware por HTTPS) ----------
// Pantalla base del OTA (título + barra vacía). Se dibuja una vez.
static const int OTA_BAR_X = 60, OTA_BAR_Y = 236, OTA_BAR_W = 360, OTA_BAR_H = 22;
void drawOtaScreen() {
  if (!hasDisplay) return;
  currentUi = UI_UPDATING;
  TftLock lock;
  tft.fillScreen(COL_BG);
  drawWordmark();
  tft.setFont(&fonts::FreeSansBold18pt7b);
  tft.setTextDatum(lgfx::textdatum_t::middle_center);
  tft.setTextColor(COL_TEXT, COL_BG);
  tft.drawString(TR("Actualizando","Atualizando"), 240, 120);
  tft.setFont(&fonts::FreeSans12pt7b);
  tft.setTextColor(COL_MUTE, COL_BG);
  tft.drawString(TR("Descargando e instalando","Baixando e instalando"), 240, 284);
  tft.drawRoundRect(OTA_BAR_X, OTA_BAR_Y, OTA_BAR_W, OTA_BAR_H, 6, COL_DIM);
}

// Actualiza solo el número y el relleno de la barra (sin redibujar todo).
void drawOtaProgress(int pct) {
  if (!hasDisplay) return;
  if (pct < 0) pct = 0; if (pct > 100) pct = 100;
  TftLock lock;
  tft.fillRect(150, 168, 180, 44, COL_BG); // limpiar zona del %
  tft.setFont(&fonts::FreeSansBold18pt7b);
  tft.setTextDatum(lgfx::textdatum_t::middle_center);
  tft.setTextColor(COL_ACCENT, COL_BG);
  tft.drawString(String(pct) + "%", 240, 190);
  int fillW = (OTA_BAR_W - 4) * pct / 100;
  tft.fillRoundRect(OTA_BAR_X + 2, OTA_BAR_Y + 2, fillW, OTA_BAR_H - 4, 4, COL_ACCENT);
}

// Descarga el binario con el mismo HTTPClient que el heartbeat (conecta bien por
// HTTPS) y lo escribe con Update, mostrando el progreso. Reinicia al terminar.
void doOtaUpdate(const String& url) {
  drawOtaScreen();
  drawOtaProgress(0);
  delay(1000); // dejar que la conexión previa (heartbeat) se cierre del todo
  for (int attempt = 1; attempt <= 2; attempt++) {
    WiFiClientSecure client;
    client.setInsecure();
    HTTPClient http;
    http.setTimeout(20000);
    if (!http.begin(client, url)) { delay(1500); continue; }
    int code = http.GET();
    if (code != HTTP_CODE_OK) { http.end(); delay(1500); continue; }
    int total = http.getSize();
    if (total <= 0) { http.end(); delay(1500); continue; }
    if (!Update.begin((size_t)total)) { http.end(); delay(1500); continue; }

    WiFiClient* stream = http.getStreamPtr();
    int done = 0, lastPct = -1;
    uint8_t buf[1460];
    unsigned long lastData = millis();
    while (done < total) {
      size_t avail = stream->available();
      if (avail) {
        int toRead = avail > sizeof(buf) ? sizeof(buf) : (int)avail;
        int r = stream->readBytes(buf, toRead);
        if (r > 0) {
          if (Update.write(buf, r) != (size_t)r) break;
          done += r;
          lastData = millis();
          int pct = (int)((long)done * 100 / total);
          if (pct != lastPct) { lastPct = pct; drawOtaProgress(pct); }
        }
      } else if (!client.connected()) {
        break;
      } else if (millis() - lastData > 15000) {
        break;
      } else {
        delay(1);
      }
    }
    http.end();

    if (done != total) { Update.abort(); delay(1500); continue; }
    if (!Update.end(true)) { delay(1500); continue; }
    drawOtaProgress(100);
    delay(500);
    ESP.restart(); // arranca con el firmware nuevo
  }
  drawUi(UI_ONLINE); // si falló, vuelve a "En línea" (el servidor reintenta luego)
}

// El equipo fue borrado del servidor (o su token dejó de valer): borra la
// provisión y reinicia. Al arrancar sin credenciales, setup() va directo a modo
// "descubrir" (BLE). Reiniciar es más limpio que apagar WiFi y prender BLE en
// caliente (esa transición de radio suele terminar en panic).
void factoryReset() {
  prefs.remove("ssid");
  prefs.remove("pass");
  prefs.remove("stoken");
  delay(300);
  ESP.restart();
}

String dryStateJson();   // definida junto al UI de la secadora
void   applyDryCmd(const String& c);

bool sendHeartbeat() {
  if (gToken.length() == 0) return false;
  String body = String("{\"device_id\":\"") + deviceId + "\",\"token\":\"" + gToken +
                "\",\"fw\":\"" + FW_VERSION + "\"," + dryStateJson();
  if (pushBrightness) body += ",\"setbl\":" + String(dispBacklight); // brillo cambiado en el equipo
  body += "}";
  String resp;
  bool ok = httpPostJson(HEARTBEAT_URL, body, resp);
  if (!ok && gLastHttp == 401) {
    // Ya no está autorizado: seguramente lo borraron. Con 2 seguidos, reset.
    if (++unauthorizedCount >= 2) factoryReset();
    return false;
  }
  if (ok) {
    unauthorizedCount = 0;
    pushBrightness = false;   // el server ya recibió el brillo local (si lo había)
    long bl = jsonInt(resp, "backlight",  dispBacklight);
    long ps = jsonInt(resp, "power_save", dispPowerSave ? 1 : 0);
    long sm = jsonInt(resp, "sleep_min",  dispSleepMin);
    if (bl < 0) bl = 0; if (bl > 100) bl = 100;
    if (sm < 1) sm = 1; if (sm > 240) sm = 240;
    applyDisplaySettings((uint8_t)bl, ps != 0, (uint16_t)sm, true);

    // ¿El servidor pidió actualizar el firmware? Lo hacemos en loop() (no acá),
    // para que la conexión de este heartbeat se cierre y libere sus recursos.
    if (jsonInt(resp, "ota", 0) == 1) {
      String url = jsonStr(resp, "ota_url");
      if (url.length() > 10) { otaUrl = url; otaPending = true; }
    }

    // ¿Pidió una captura de pantalla? Se sube en loop() (conexión ya cerrada).
    if (jsonInt(resp, "shot", 0) == 1) {
      shotPending = true;
      shotScreen  = jsonInt(resp, "scr", -1); // navega a esa pantalla si viene
    }

    // Comando remoto desde la app (pausar/iniciar/detener/ventilador/etc.).
    String cmd = jsonStr(resp, "cmd");
    if (cmd.length() > 0) applyDryCmd(cmd);
  }
  return ok;
}

// ---------- Persistencia ----------
void saveProvisioning(const String& ssid, const String& pass) {
  prefs.putString("ssid", ssid);
  prefs.putString("pass", pass);
  prefs.putString("stoken", gToken);
}

// ---------- BLE ----------
void stopBLE();

class ConfigCallback : public NimBLECharacteristicCallbacks {
  void onWrite(NimBLECharacteristic* c, NimBLEConnInfo& connInfo) override {
    String data = c->getValue().c_str();
    int a = data.indexOf('\n');
    int b = data.indexOf('\n', a + 1);
    int d = data.indexOf('\n', b + 1);
    if (a < 0 || b < 0) { setStatus("bad_config"); return; }
    // Solo parsear y guardar. NADA de WiFi/HTTPS acá: este callback corre en la
    // tarea del stack BLE (stack chico) y el handshake TLS lo desbordaría,
    // reiniciando el ESP32. loop() hace el trabajo pesado (ver doProvision()).
    pSsid  = data.substring(0, a);
    pPass  = data.substring(a + 1, b);
    pClaim = (d < 0) ? data.substring(b + 1) : data.substring(b + 1, d);
    pName  = (d < 0) ? "" : data.substring(d + 1);
    provisionPending = true;
  }
};

// Se ejecuta desde loop() (tarea principal). Aquí sí es seguro hacer WiFi + TLS.
void doProvision() {
  setStatus("connecting");
  if (!connectWiFi(pSsid, pPass)) { setStatus("wifi_fail"); return; }

  setStatus("registering");
  // Liberar el BLE ANTES del registro: el handshake TLS necesita bastante memoria
  // contigua y, con bluedroid (BLE) activo, falla con "http -1". Ya no hace falta
  // el BLE acá: la app confirma el registro consultando el servidor.
  stopBLE();
  delay(300);

  if (registerDevice(pClaim, pName)) {
    saveProvisioning(pSsid, pPass);
    setStatus("registered");
  } else {
    setStatus("reg_fail"); // en pantalla; tocar la pantalla reactiva el BLE
  }
}

void startBLE() {
  if (bleActive) return;
  String advName = "maker3d-" + deviceId.substring(deviceId.length() - 4);
  NimBLEDevice::init(advName.c_str());
  // Máxima potencia de radio BLE (+9 dBm) para el mejor alcance al vincular.
  NimBLEDevice::setPower(9);
  bleServer = NimBLEDevice::createServer();
  NimBLEService* svc = bleServer->createService(SERVICE_UUID);

  NimBLECharacteristic* dev = svc->createCharacteristic(CHAR_DEVID, NIMBLE_PROPERTY::READ);
  dev->setValue(deviceId.c_str());

  // NimBLE crea solo el descriptor CCCD (0x2902) para NOTIFY; no hace falta agregarlo.
  statusChar = svc->createCharacteristic(
      CHAR_STATUS, NIMBLE_PROPERTY::READ | NIMBLE_PROPERTY::NOTIFY);
  statusChar->setValue("idle");

  NimBLECharacteristic* cfg = svc->createCharacteristic(CHAR_CONFIG, NIMBLE_PROPERTY::WRITE);
  cfg->setCallbacks(new ConfigCallback());

  svc->start();
  NimBLEAdvertising* adv = NimBLEDevice::getAdvertising();
  adv->addServiceUUID(SERVICE_UUID);
  adv->enableScanResponse(true);
  NimBLEDevice::startAdvertising();
  bleActive = true;
  drawUi(UI_BLE);
}

void stopBLE() {
  if (!bleActive) return;
  NimBLEDevice::stopAdvertising();
  NimBLEDevice::deinit(true);
  bleActive = false;
  statusChar = nullptr;
}

// ============================================================================
//   UI SECADORA DE FILAMENTO  — diseño "Nocturne" (480x320, oscuro/blurple)
// ============================================================================

// ---- Paleta Nocturne (algunos son mezclas ya resueltas sobre el fondo) ----
uint16_t NC_BG, NC_RAIL, NC_SURF, NC_TEXT, NC_MUTE, NC_FAINT, NC_ACC, NC_ACCLT,
         NC_BORDER, NC_TRACK, NC_NAVACT, NC_SEL, NC_ONACC;
uint16_t NC_ZLO, NC_ZMD, NC_ZHI; // zonas de temperatura (azul/ámbar/rojo, ref.)
void initNocturne() {
  NC_ZLO = tft.color565(0x2b, 0x7f, 0xf6); // BAJA
  NC_ZMD = tft.color565(0xf5, 0xa6, 0x23); // MEDIA
  NC_ZHI = tft.color565(0xef, 0x44, 0x44); // ALTA
  NC_BG     = tft.color565(0x16, 0x18, 0x26); // fondo pantalla
  NC_RAIL   = tft.color565(0x12, 0x13, 0x1f); // rail nav
  NC_SURF   = tft.color565(0x23, 0x25, 0x32); // tarjetas
  NC_TEXT   = tft.color565(0xe9, 0xe9, 0xed); // texto
  NC_MUTE   = tft.color565(0x93, 0x97, 0xab); // texto atenuado
  NC_FAINT  = tft.color565(0x6b, 0x6f, 0x80); // texto muy tenue / iconos inactivos
  NC_ACC    = tft.color565(0xff, 0x57, 0x22); // acento
  NC_ACCLT  = tft.color565(0xff, 0x90, 0x57); // acento claro
  NC_ONACC  = tft.color565(0x16, 0x18, 0x26); // texto sobre acento
  NC_BORDER = tft.color565(0x27, 0x29, 0x36); // borde .08 sobre fondo
  NC_TRACK  = tft.color565(0x37, 0x39, 0x46); // track .1 sobre superficie
  NC_NAVACT = tft.color565(0x3a, 0x23, 0x20); // nav activo .16 acento sobre fondo
  NC_SEL    = tft.color565(0x3d, 0x2a, 0x26); // seleccionado .14 acento sobre superficie
  // Paleta del salvapantalla (fría y sobria, con brasas ámbar de la marca).
  SV_DOT     = tft.color565(0x92, 0xb8, 0xda); // nodo (azul-periwinkle suave)
  SV_DOT2    = tft.color565(0xff, 0x90, 0x57); // brasa (acento tenue)
  SV_LINE_HI = tft.color565(0x3a, 0x52, 0x72); // línea cercana (azul tenue)
  SV_LINE_LO = tft.color565(0x12, 0x1b, 0x2a); // línea lejana (casi invisible)
  SV_BRAND   = tft.color565(0xaa, 0xc2, 0xda); // marca (azul-blanco suave)
}

// ---- Destino de dibujo con offset (para capturar la pantalla) ----
// Todo el UI de la secadora dibuja a través de DG. En operación normal DG apunta
// a la pantalla (DOY=0). Para una captura, como esta placa no puede leer su
// framebuffer y no hay RAM para uno completo, se rasteriza la UI en un sprite
// "banda" (480 x N) recorriendo la pantalla: DST=&sprite y DOY=fila superior de
// la banda, así cada primitiva se corre -DOY y cae dentro del sprite.
LovyanGFX* DST = &tft;
int        DOY = 0;
int        DOX = 0;   // offset X (además de DOY): permite rasterizar una sub-región
                      // en un sprite chico y volcarla limpia (sin flash de borrado).
struct OffGfx {
  void fillRect(int x,int y,int w,int h,uint16_t c){ DST->fillRect(x-DOX,y-DOY,w,h,c); }
  void fillRoundRect(int x,int y,int w,int h,int r,uint16_t c){ DST->fillRoundRect(x-DOX,y-DOY,w,h,r,c); }
  void drawRoundRect(int x,int y,int w,int h,int r,uint16_t c){ DST->drawRoundRect(x-DOX,y-DOY,w,h,r,c); }
  void drawFastVLine(int x,int y,int h,uint16_t c){ DST->drawFastVLine(x-DOX,y-DOY,h,c); }
  void drawFastHLine(int x,int y,int w,uint16_t c){ DST->drawFastHLine(x-DOX,y-DOY,w,c); }
  void drawCircle(int x,int y,int r,uint16_t c){ DST->drawCircle(x-DOX,y-DOY,r,c); }
  void fillSmoothCircle(int x,int y,int r,uint16_t c){ DST->fillSmoothCircle(x-DOX,y-DOY,r,c); }
  void fillTriangle(int x1,int y1,int x2,int y2,int x3,int y3,uint16_t c){ DST->fillTriangle(x1-DOX,y1-DOY,x2-DOX,y2-DOY,x3-DOX,y3-DOY,c); }
  void drawWideLine(int x0,int y0,int x1,int y1,int w,uint16_t c){ DST->drawWideLine(x0-DOX,y0-DOY,x1-DOX,y1-DOY,w,c); }
  void fillArc(int x,int y,int r0,int r1,float a0,float a1,uint16_t c){ DST->fillArc(x-DOX,y-DOY,r0,r1,a0,a1,c); }
  void fillScreen(uint16_t c){ DST->fillScreen(c); }
  void setFont(const lgfx::IFont* f){ DST->setFont(f); }
  void setTextDatum(lgfx::textdatum_t d){ DST->setTextDatum(d); }
  void setTextColor(uint16_t c){ DST->setTextColor(c); }
  void drawString(const String& s,int x,int y){ DST->drawString(s,x-DOX,y-DOY); }
  int  textWidth(const String& s){ return DST->textWidth(s); }
};
OffGfx DG;

// ---- Estado de la secadora ----
DryScreen dryScreen   = D_PANEL;
String    dryFilament = "PETG";
int       dryTargetC  = 65;
long      dryTotalSec = 21600;  // 6 h
long      dryRemainSec= 8100;   // ~62 %
bool      dryRunning  = true;
float     dryCurTempC = 62;
float     dryCurHum   = 21;   // humedad interna (cámara)
float     dryExtHum   = 45;   // humedad externa (ambiente) — 2do sensor / demo
char      dryUnit     = 'C';  // 'C' o 'F' (afecta lecturas y steppers de temp)
int       fanMode     = 0;    // 0 Auto, 1 Encendido, 2 Off
bool      alarmOn     = true; // alarma al finalizar

unsigned long dryTick    = 0;
bool          dotOn      = true;  // parpadeo del punto de estado
float         fanAngle   = 0;     // ángulo de giro de las aspas (grados)

// ---- Presets de filamento (nombre, temp °C, duración en horas) ----
struct Preset { const char* name; int tempC; float hours; };
const Preset PRESETS[6] = {
  {"PLA",45,4}, {"PETG",65,6}, {"ABS",70,6}, {"Nylon",80,12}, {"TPU",50,5}, {"PVA",45,8}
};

int navOf(DryScreen s) { return (s == D_PANEL) ? 0 : (s == D_PRESETS) ? 1 : (s == D_ADJUST) ? 2 : (s == D_SCHEDULE) ? 3 : (s == D_SETTINGS) ? 4 : 0; }

// Aplica un preset por nombre (fija filamento, temperatura y duración).
void applyPresetByName(const String& name) {
  for (int i = 0; i < 6; i++) if (name == PRESETS[i].name) {
    dryFilament = PRESETS[i].name;
    dryTargetC  = PRESETS[i].tempC;
    dryTotalSec = (long)(PRESETS[i].hours * 3600);
    return;
  }
}

// Estado de la secadora, en JSON compacto, para que la app dibuje la misma
// pantalla que el ESP (se manda en cada heartbeat).
extern bool hasSensor;   // definidos en el módulo de hardware (más abajo)
extern bool heaterOn;
String dryStateJson() {
  return String("\"st\":{\"sc\":") + (int)dryScreen
       + ",\"fil\":\"" + dryFilament + "\""
       + ",\"tc\":"  + dryTargetC
       + ",\"tot\":" + dryTotalSec
       + ",\"rem\":" + dryRemainSec
       + ",\"run\":" + (dryRunning ? 1 : 0)
       + ",\"ct\":"  + String(dryCurTempC, 1)
       + ",\"ch\":"  + String(dryCurHum, 1)
       + ",\"eh\":"  + String(dryExtHum, 1)
       + ",\"un\":\"" + String(dryUnit) + "\""
       + ",\"fan\":" + fanMode
       + ",\"alm\":" + (alarmOn ? 1 : 0)
       + ",\"hs\":"  + (hasSensor ? 1 : 0)
       + ",\"heat\":" + (heaterOn ? 1 : 0)
       // Agenda (programación de inicio/fin)
       + ",\"sen\":" + (schedEnabled ? 1 : 0)
       + ",\"ssd\":" + schStDay + ",\"ssm\":" + schStMon + ",\"ssh\":" + schStHr + ",\"ssi\":" + schStMin
       + ",\"sed\":" + schEnDay + ",\"sem\":" + schEnMon + ",\"seh\":" + schEnHr + ",\"sei\":" + schEnMin
       + ",\"lang\":" + uiLang
       + "}";
}

// Aplica un comando remoto que llegó por heartbeat y redibuja.
void drawDryer();  // fwd
void applyDryCmd(const String& c) {
  bool ok = true;
  if      (c == "run1")  dryRunning = true;
  else if (c == "run0")  dryRunning = false;
  else if (c == "stop")  { dryRunning = false; dryRemainSec = dryTotalSec; dryScreen = D_PANEL; }
  else if (c == "start") { dryRunning = true; dryScreen = D_PANEL; if (dryRemainSec <= 0) dryRemainSec = dryTotalSec; }
  else if (c == "accept"){ dryRunning = false; dryRemainSec = dryTotalSec; dryCurHum = 21; dryCurTempC = 24; dryScreen = D_PANEL; }
  else if (c == "fan")   fanMode = (fanMode + 1) % 3;
  else if (c == "unitC") dryUnit = 'C';
  else if (c == "unitF") dryUnit = 'F';
  else if (c == "alarm") alarmOn = !alarmOn;
  else if (c == "tcup")  { if (dryTargetC < 90) dryTargetC++; }
  else if (c == "tcdn")  { if (dryTargetC > 35) dryTargetC--; }
  else if (c == "durup") { if (dryTotalSec < 86400) dryTotalSec += 1800; }
  else if (c == "durdn") { if (dryTotalSec > 1800)  dryTotalSec -= 1800; }
  else if (c.startsWith("sc:"))  dryScreen = (DryScreen)c.substring(3).toInt();
  else if (c.startsWith("fil:")) applyPresetByName(c.substring(4));
  else if (c.startsWith("sched:")) applySchedCmd(c.substring(6));  // agenda: en,sMon,sDay,sHr,sMin,eMon,eDay,eHr,eMin
  else if (c.startsWith("lang:")) { uiLang = c.substring(5).toInt() ? 1 : 0; prefs.putUChar("lang", uiLang); } // idioma ES/PT
  else ok = false;
  if (ok && appMode && !saverActive) drawDryer();   // no dibujar sobre el salvapantalla
  // Reporta el estado NUEVO enseguida: fuerza un heartbeat inmediato en la
  // próxima vuelta del loop (así la app/admin ven el cambio sin esperar 5-10 s).
  if (ok) lastBeat = 0;
}

// ---- Helpers de unidad y formato ----
int  toUnit(int c)       { return (dryUnit == 'F') ? (int)lround(c * 9.0 / 5.0 + 32) : c; }
const char* unitLetter() { return (dryUnit == 'F') ? "F" : "C"; }
String hoursStr(float h) { // "6 h" o "0.5 h"
  if (h == (int)h) return String((int)h) + " h";
  char b[12]; snprintf(b, sizeof(b), "%.1f h", h); return String(b);
}

// Declaraciones adelantadas (definidas más abajo, junto al resto del UI).
void txt(const lgfx::IFont* f, uint16_t col, int x, int y, const String& s, lgfx::textdatum_t d = lgfx::textdatum_t::top_left);
int  txtW(const lgfx::IFont* f, const String& s);
void deg(int x, int y, uint16_t c);

// ---- Botones con texto centrado ----
void btnFill(int x,int y,int w,int h,uint16_t bg,uint16_t fg,const lgfx::IFont* f,const String& s) {
  DG.fillRoundRect(x,y,w,h,10,bg);
  txt(f, fg, x + w/2, y + h/2, s, lgfx::textdatum_t::middle_center);
}
void btnOutline(int x,int y,int w,int h,uint16_t col,const lgfx::IFont* f,const String& s) {
  DG.drawRoundRect(x,y,w,h,10,col);
  txt(f, col, x + w/2, y + h/2, s, lgfx::textdatum_t::middle_center);
}

// ---- Iconos del rail (líneas estilo Phosphor, trazo 2) ----
void icPanel(int cx, int cy, uint16_t c) { // pulso
  DG.drawWideLine(cx-9,cy, cx-4,cy, 2, c);
  DG.drawWideLine(cx-4,cy, cx-1,cy-7, 2, c);
  DG.drawWideLine(cx-1,cy-7, cx+2,cy+7, 2, c);
  DG.drawWideLine(cx+2,cy+7, cx+5,cy, 2, c);
  DG.drawWideLine(cx+5,cy, cx+9,cy, 2, c);
}
void icSpool(int cx, int cy, uint16_t c) { // carrete
  DG.drawRoundRect(cx-9,cy-8,18,16,3,c);
  DG.drawFastVLine(cx-4,cy-8,16,c);
  DG.drawFastVLine(cx+4,cy-8,16,c);
}
void icSliders(int cx, int cy, uint16_t c) { // ajuste
  DG.drawFastHLine(cx-9,cy-5,18,c); DG.fillSmoothCircle(cx-2,cy-5,3,c);
  DG.drawFastHLine(cx-9,cy+5,18,c); DG.fillSmoothCircle(cx+3,cy+5,3,c);
}
void icGear(int cx, int cy, uint16_t c) { // ajustes (hexágono + círculo)
  DG.drawCircle(cx,cy,8,c); DG.drawCircle(cx,cy,3,c);
}
void icCalendar(int cx, int cy, uint16_t c) { // agenda (calendario)
  DG.drawRoundRect(cx-8, cy-6, 16, 14, 2, c);   // cuerpo
  DG.drawFastHLine(cx-8, cy-1, 16, c);           // separador del encabezado
  DG.drawFastVLine(cx-4, cy-9, 4, c);            // anillas
  DG.drawFastVLine(cx+4, cy-9, 4, c);
}
void icThermo(int cx, int cy, uint16_t c) { DG.drawCircle(cx,cy+5,3,c); DG.drawFastVLine(cx,cy-6,9,c); }
void icDrop(int cx, int cy, uint16_t c)   { DG.fillSmoothCircle(cx,cy+2,4,c); DG.fillTriangle(cx-3,cy+1,cx+3,cy+1,cx,cy-6,c); }
void icFan(int cx, int cy, uint16_t c)    { // aspa (ventilador)
  DG.fillSmoothCircle(cx,cy,2,c);
  DG.drawWideLine(cx,cy,cx-6,cy-4,2,c); DG.drawWideLine(cx,cy,cx+6,cy-4,2,c);
  DG.drawWideLine(cx,cy,cx-6,cy+4,2,c); DG.drawWideLine(cx,cy,cx+6,cy+4,2,c);
}

// ---- Rail de navegación (56 px) ----
// Filas del rail (5 ítems): Panel, Filamento, Ajuste, Agenda, Ajustes.
const int NAV_YS[5] = {50, 98, 146, 194, 242};
void drawNavRail() {
  DG.fillRect(0, 0, 56, 320, NC_RAIL);
  DG.drawFastVLine(55, 0, 320, NC_BORDER);
  DG.fillRoundRect(15, 14, 26, 26, 7, NC_ACC); // logo
  int active = navOf(dryScreen);
  for (int i = 0; i < 5; i++) {
    if (i == active) DG.fillRoundRect(8, NAV_YS[i], 40, 40, 10, NC_NAVACT);
    uint16_t ic = (i == active) ? NC_ACCLT : NC_FAINT;
    int cx = 28, cy = NAV_YS[i] + 20;
    if      (i == 0) icPanel(cx, cy, ic);
    else if (i == 1) icSpool(cx, cy, ic);
    else if (i == 2) icSliders(cx, cy, ic);
    else if (i == 3) icCalendar(cx, cy, ic);
    else             icGear(cx, cy, ic);
  }
}

// ---- Helpers ----
void txt(const lgfx::IFont* f, uint16_t col, int x, int y, const String& s, lgfx::textdatum_t d) {
  DG.setFont(f); DG.setTextDatum(d); DG.setTextColor(col); DG.drawString(s, x, y); // fondo transparente
}
int txtW(const lgfx::IFont* f, const String& s) { DG.setFont(f); return DG.textWidth(s); }
void deg(int x, int y, uint16_t c) { DG.drawCircle(x, y, 2, c); } // símbolo de grados (anillo)
void midDot(int x, int y, uint16_t c) { DG.fillSmoothCircle(x, y, 2, c); } // separador "·" (la fuente no lo trae)
String fmtTime(long s) {
  long h = s/3600, m = (s%3600)/60, sec = s%60; char b[12];
  if (h > 0) snprintf(b,sizeof(b),"%ld:%02ld:%02ld",h,m,sec); else snprintf(b,sizeof(b),"%ld:%02ld",m,sec);
  return String(b);
}

// Últimos valores ENTEROS dibujados (para no repintar si no cambiaron).
int pvLastTemp = -999, pvLastHum = -999, pvLastExt = -999;

// Vuelca una sub-región del panel de forma LIMPIA: la rasteriza en un sprite
// fuera de pantalla y la empuja de una sola vez, sin el "borrado a oscuro" que
// la SPI lenta muestra como parpadeo. what: 0 = velocímetro temp, 1 = ventilador.
void blitRegion(uint8_t what) {
  const int gx = (what == 0) ? 98 : 322;
  const int gy = (what == 0) ? 132 : 144;
  const int gw = (what == 0) ? 152 : 106;
  const int gh = (what == 0) ? 130 : 112;
  LGFX_Sprite spr(&tft);
  spr.setColorDepth(16);
  if (!spr.createSprite(gw, gh)) {          // sin RAM: método directo (con flash)
    DG.fillRect(gx, gy, gw, gh, NC_SURF);
    if (what == 0) drawTempGauge(170, 207, 44, dryCurTempC);
    else           drawFanIndicator(366, 198, 38, fanAngle, dryRunning);
    return;
  }
  LovyanGFX* pd = DST; int px = DOX, py = DOY;
  DST = &spr; DOX = gx; DOY = gy;
  spr.fillScreen(NC_SURF);
  if (what == 0) drawTempGauge(170, 207, 44, dryCurTempC);
  else           drawFanIndicator(366, 198, 38, fanAngle, dryRunning);
  DST = pd; DOX = px; DOY = py;
  spr.pushSprite(gx, gy);
  spr.deleteSprite();
}

// Tiempo restante + barra de progreso (cambian cada segundo).
void drawPanelTime() {
  DG.fillRect(352, 66, 104, 26, NC_SURF);
  txt(&fonts::FreeSansBold12pt7b, NC_TEXT, 352, 71, fmtTime(dryRemainSec));
  int pct = dryTotalSec > 0 ? (int)round((1.0 - (double)dryRemainSec/dryTotalSec) * 100) : 0;
  if (pct < 0) pct = 0; else if (pct > 100) pct = 100;   // no desbordar la barra
  DG.fillRect(352, 98, 112, 24, NC_SURF);
  DG.fillRoundRect(352, 102, 100, 5, 2, NC_TRACK);
  if (pct > 0) DG.fillRoundRect(352, 102, pct, 5, 2, NC_ACC);
  txt(&fonts::FreeSans9pt7b, NC_MUTE, 352, 110, String(pct) + "% completo");
}

// Tarjeta OBJETIVO: temperatura objetivo (el sensor ya está en el velocímetro),
// naranja y centrada en la tarjeta (cx=134, centro vertical y=94 entre título y fondo).
void drawTempTarget() {
  DG.fillRect(76, 66, 116, 56, NC_SURF);
  String num = String(toUnit(dryTargetC));
  int nw = txtW(&fonts::FreeSansBold18pt7b, num);
  int numCx = 130;
  txt(&fonts::FreeSansBold18pt7b, NC_ACCLT, numCx, 94, num, lgfx::textdatum_t::middle_center);
  deg(numCx + nw/2 + 6, 88, NC_ACCLT);
}
// Tarjeta HUMEDAD: humedad del sensor, naranja y centrada (cx=268, y=94).
void drawHumValue() {
  DG.fillRect(208, 66, 120, 56, NC_SURF);
  txt(&fonts::FreeSansBold18pt7b, NC_ACCLT, 268, 94, String((int)round(dryCurHum)) + "%", lgfx::textdatum_t::middle_center);
}

// ---- Refresco del Panel en el tick (SIN parpadeo): sólo repinta lo que cambió. ----
void drawPanelValues() {
  drawPanelTime();                                  // el reloj corre siempre
  int tI = (int)round(dryCurTempC);
  if (tI != pvLastTemp) { pvLastTemp = tI; blitRegion(0); }   // sensor -> sólo el velocímetro
  int hI = (int)round(dryCurHum);
  if (hI != pvLastHum) { pvLastHum = hI; drawHumValue(); }    // humedad de la tarjeta
}

// ---- Dibujo COMPLETO de los valores (al dibujar todo el panel de cero) ----
void drawPanelValuesFull() {
  drawTempTarget();
  drawHumValue();
  drawPanelTime();
  drawTempGauge(170, 207, 44, dryCurTempC);
  drawFanIndicator(366, 198, 38, fanAngle, dryRunning);
  pvLastTemp = (int)round(dryCurTempC);
  pvLastHum  = (int)round(dryCurHum);
  pvLastExt  = (int)round(dryExtHum);
}

// ---- Indicadores tipo velocímetro (semicírculo superior) ----
// Arco grueso por segmentos (independiente de la convención de fillArc).
void arcSeg(int cx, int cy, int R, float a0, float a1, int thick, uint16_t col) {
  const int N = 24; int px = 0, py = 0;
  for (int i = 0; i <= N; i++) {
    float a = (a0 + (a1 - a0) * i / N) * 3.14159265f / 180.0f;
    int x = cx + (int)(R * cosf(a)), y = cy + (int)(R * sinf(a));
    if (i > 0) DG.drawWideLine(px, py, x, y, thick, col);
    px = x; py = y;
  }
}
// Aguja del velocímetro (frac 0..1 -> ángulo 180..360).
void gaugeNeedle(int cx, int cy, int R, float frac, int thick, uint16_t col) {
  if (frac < 0) frac = 0; if (frac > 1) frac = 1;
  float a = (180 + frac * 180) * 3.14159265f / 180.0f;
  DG.drawWideLine(cx, cy, cx + (int)((R - 8) * cosf(a)), cy + (int)((R - 8) * sinf(a)), thick, col);
}
uint16_t tempZoneCol(float t) { return t < 40 ? NC_ZLO : t < 70 ? NC_ZMD : NC_ZHI; }

// Aguja cónica (triángulo) del velocímetro.
void needleTaper(int cx, int cy, int R, float frac, uint16_t col) {
  if (frac < 0) frac = 0; if (frac > 1) frac = 1;
  float a = (180 + frac * 180) * 3.14159265f / 180.0f;
  float dx = cosf(a), dy = sinf(a), pxn = -sinf(a), pyn = cosf(a), bw = 4.5f;
  int tx = cx + (int)((R - 3) * dx),  ty = cy + (int)((R - 3) * dy);
  int b1x = cx + (int)(bw * pxn), b1y = cy + (int)(bw * pyn);
  int b2x = cx - (int)(bw * pxn), b2y = cy - (int)(bw * pyn);
  DG.fillTriangle(tx, ty, b1x, b1y, b2x, b2y, col);
}

// Velocímetro de TEMPERATURA estilo referencia: 3 zonas de color, números
// 0..100, aguja cónica, hub con anillo y valor grande + subtítulo.
void drawTempGauge(int cx, int cy, int R, float t) {
  const int r0 = R - 5, r1 = R + 6;   // arco de 11 px centrado en R
  const float k = 1.8f;               // grados de arco por unidad (180°/100)
  DG.fillArc(cx, cy, r0, r1, 180,        180 + 39*k, NC_ZLO);
  DG.fillArc(cx, cy, r0, r1, 180 + 39*k, 180 + 70*k, NC_ZMD);
  DG.fillArc(cx, cy, r0, r1, 180 + 70*k, 360,        NC_ZHI);
  DG.fillSmoothCircle(cx - R, cy, 5, NC_ZLO);   // extremos redondeados
  DG.fillSmoothCircle(cx + R, cy, 5, NC_ZHI);
  for (int v = 0; v <= 100; v += 20) {          // números 0..100
    float a = (180 + v*k) * 3.14159265f / 180.0f;
    txt(&fonts::FreeSans9pt7b, NC_FAINT,
        cx + (int)((R + 19) * cosf(a)), cy + (int)((R + 19) * sinf(a)) - 6,
        String(v), lgfx::textdatum_t::top_center);
  }
  needleTaper(cx, cy, R + 2, t / 100.0f, NC_TEXT);
  DG.fillSmoothCircle(cx, cy, 7, NC_RAIL);
  DG.drawCircle(cx, cy, 7, NC_MUTE);
  DG.fillSmoothCircle(cx, cy, 3, NC_FAINT);
  // valor grande + unidad y subtítulo
  String num = String(toUnit((int)lroundf(t)));
  int valY = cy + 24, nw = txtW(&fonts::FreeSansBold18pt7b, num), grp = nw + 16;
  txt(&fonts::FreeSansBold18pt7b, NC_TEXT, cx - grp/2, valY - 13, num);
  deg(cx - grp/2 + nw + 7, valY - 6, NC_MUTE);
  txt(&fonts::FreeSans12pt7b, NC_MUTE, cx - grp/2 + nw + 12, valY - 9, String(unitLetter()));
  txt(&fonts::Font0, NC_FAINT, cx, valY + 20, TR("Temperatura actual","Temperatura atual"), lgfx::textdatum_t::top_center);
}

// Ventilador animado (reemplaza el cuadrante de humedad). Aspas girando + viento
// cuando la secadora anda; quieto y atenuado cuando está detenida.
void drawFanIndicator(int cx, int cy, int R, float angle, bool running) {
  txt(&fonts::FreeSans9pt7b, NC_MUTE, cx, 144, "Ventilador", lgfx::textdatum_t::top_center);
  uint16_t bladeCol = running ? NC_ACC : NC_FAINT;   // atenuado si está detenido
  DG.drawCircle(cx, cy, R, NC_TRACK);                // aro/guarda exterior
  for (int i = 0; i < 3; i++) {                       // 3 aspas (arcos)
    float base = angle + i * 120.0f;
    DG.fillArc(cx, cy, (int)(R * 0.30f), (int)(R * 0.86f), base - 34, base + 34, bladeCol);
  }
  DG.fillSmoothCircle(cx, cy, 6, NC_RAIL);           // cubo central
  DG.drawCircle(cx, cy, 6, NC_MUTE);
  DG.fillSmoothCircle(cx, cy, 2, NC_FAINT);
  if (running) {                                      // viento (sólo andando)
    for (int i = 0; i < 3; i++) {
      int wy = cy - 12 + i * 12;
      int ph = ((int)(angle / 30) + i) % 3;          // fase para "mover" el aire
      int x0 = cx + R + 6 + ph * 3;
      DG.drawWideLine(x0,      wy,     x0 + 10, wy,     2, NC_ACCLT);
      DG.drawWideLine(x0 + 10, wy,     x0 + 7,  wy - 3, 2, NC_ACCLT);
      DG.drawWideLine(x0 + 10, wy,     x0 + 7,  wy + 3, 2, NC_ACCLT);
    }
  }
  txt(&fonts::Font0, NC_FAINT, cx, cy + R + 8, running ? TR("Girando","Girando") : TR("Detenido","Parado"),
      lgfx::textdatum_t::top_center);
}

// ---- Pantalla PANEL (monitoreo) ----
void drawPanel() {
  uint16_t stCol = dryRunning ? NC_ACC : NC_MUTE;
  // Cabecera: pill de estado + filamento + hora. Tres estados: Secando (en
  // marcha), En pausa (frenado con tiempo a medias) y Detenido (tiempo completo).
  const char* stTxt = dryRunning ? TR("Secando","Secando")
                    : (dryRemainSec >= dryTotalSec ? TR("Detenido","Parado") : TR("En pausa","Em pausa"));
  int stW = 24 + txtW(&fonts::FreeSans9pt7b, stTxt) + 12;
  DG.drawRoundRect(70, 12, stW, 22, 11, stCol);
  DG.fillSmoothCircle(84, 23, 3, dotOn ? stCol : NC_BG);
  txt(&fonts::FreeSans9pt7b, stCol, 94, 23, stTxt, lgfx::textdatum_t::middle_left);
  txt(&fonts::FreeSans9pt7b, NC_MUTE, 188, 23, "Filamento", lgfx::textdatum_t::middle_left);
  int flw = txtW(&fonts::FreeSans9pt7b, "Filamento");
  txt(&fonts::FreeSans9pt7b, NC_TEXT, 188 + flw + 8, 23, dryFilament, lgfx::textdatum_t::middle_left);
  drawBrandClock();

  // 3 tarjetas (y=42, alto 84)
  DG.fillRoundRect(72,  42, 124, 84, 10, NC_SURF);
  DG.fillRoundRect(206, 42, 124, 84, 10, NC_SURF);
  DG.fillRoundRect(340, 42, 124, 84, 10, NC_SURF);
  // Objetivo: rótulo + termómetro juntos y centrados en la tarjeta (cx=134)
  { String ol = TR("Objetivo","Objetivo"); int olw = txtW(&fonts::FreeSans9pt7b, ol); int olx = 134 - (olw + 13)/2;
    txt(&fonts::FreeSans9pt7b, NC_MUTE, olx, 50, ol); icThermo(olx + olw + 9, 55, NC_MUTE); }
  // Humedad: rótulo + gota juntos y centrados (cx=268)
  { String hl = TR("Humedad","Umidade"); int hlw = txtW(&fonts::FreeSans9pt7b, hl); int hlx = 268 - (hlw + 15)/2;
    txt(&fonts::FreeSans9pt7b, NC_MUTE, hlx, 50, hl); icDrop(hlx + hlw + 10, 55, NC_MUTE); }
  txt(&fonts::FreeSans9pt7b, NC_MUTE, 352, 50, "Restante");

  // Tarjeta con 2 indicadores tipo velocímetro (temperatura y humedad).
  DG.fillRoundRect(72, 132, 392, 136, 10, NC_SURF);
  drawPanelValuesFull();   // valores + velocímetro + humedad (una sola pasada)

  // Barra de acciones (y=278, alto 40) — más abajo, usando el espacio libre
  const char* fanTxt = (fanMode == 0) ? TR("Ventilador auto","Ventilador auto") : (fanMode == 1) ? TR("Ventilador on","Ventilador ligado") : TR("Ventilador off","Ventilador desligado");
  DG.fillRoundRect(72, 278, 176, 40, 10, NC_SURF);
  icFan(92, 298, NC_MUTE);
  txt(&fonts::FreeSans9pt7b, NC_TEXT, 106, 298, fanTxt, lgfx::textdatum_t::middle_left);
  if (dryRunning) {
    btnOutline(282, 278, 84, 40, NC_ACC,  &fonts::FreeSans9pt7b, TR("Pausar","Pausar"));
    btnOutline(376, 278, 88, 40, NC_MUTE, &fonts::FreeSans9pt7b, TR("Detener","Parar"));
  } else {
    btnFill(334, 278, 130, 40, NC_ACC, NC_ONACC, &fonts::FreeSans12pt7b, "Iniciar");
  }
}

// ---- Pantallas 2-5 (por ahora, títulos navegables; se desarrollan luego) ----
// ---- Cabecera de pantalla (título + subtítulo opcional) ----
// Marca Maker3D + hora (NTP), arriba a la derecha, en todas las pantallas.
void drawBrandClock() {
  String ck = clockStr();
  txt(&fonts::FreeSans9pt7b, NC_MUTE, 466, 14, ck, lgfx::textdatum_t::top_right);
  int ckw = txtW(&fonts::FreeSans9pt7b, ck);
  txt(&fonts::FreeSans9pt7b, NC_ACCLT, 466 - ckw - 12, 14, "Maker3D", lgfx::textdatum_t::top_right);
}

void screenHead(const String& title, const String& sub) {
  txt(&fonts::FreeSansBold18pt7b, NC_TEXT, 72, 14, title);
  if (sub.length()) txt(&fonts::FreeSans9pt7b, NC_MUTE, 72, 46, sub);
  drawBrandClock();
}

// ---- Control segmentado °C/°F (dos celdas de 38x26) ----
void segCF(int x, int y) {
  const int w = 38, h = 26;
  bool cC = (dryUnit == 'C');
  DG.fillRoundRect(x,     y, w, h, 8, cC ? NC_ACC : NC_TRACK);
  txt(&fonts::FreeSans9pt7b, cC ? NC_ONACC : NC_MUTE, x + w/2, y + h/2, "C", lgfx::textdatum_t::middle_center);
  DG.fillRoundRect(x + w, y, w, h, 8, !cC ? NC_ACC : NC_TRACK);
  txt(&fonts::FreeSans9pt7b, !cC ? NC_ONACC : NC_MUTE, x + w + w/2, y + h/2, "F", lgfx::textdatum_t::middle_center);
}

// Segmentado de idioma ES / PT.
void segLang(int x, int y) {
  const int w = 38, h = 26;
  bool es = (uiLang == 0);
  DG.fillRoundRect(x,     y, w, h, 8, es ? NC_ACC : NC_TRACK);
  txt(&fonts::FreeSans9pt7b, es ? NC_ONACC : NC_MUTE, x + w/2, y + h/2, "ES", lgfx::textdatum_t::middle_center);
  DG.fillRoundRect(x + w, y, w, h, 8, !es ? NC_ACC : NC_TRACK);
  txt(&fonts::FreeSans9pt7b, !es ? NC_ONACC : NC_MUTE, x + w + w/2, y + h/2, "PT", lgfx::textdatum_t::middle_center);
}

// ---- Botón de stepper (– / +) de 40x40 ----
void stepBtn(int x, int y, bool plus) {
  if (plus) { DG.fillRoundRect(x, y, 40, 40, 10, NC_ACC);
              txt(&fonts::FreeSansBold18pt7b, NC_ONACC, x + 20, y + 18, "+", lgfx::textdatum_t::middle_center); }
  else      { DG.drawRoundRect(x, y, 40, 40, 10, NC_MUTE);
              txt(&fonts::FreeSansBold18pt7b, NC_MUTE, x + 20, y + 16, "-", lgfx::textdatum_t::middle_center); }
}

// ---- Interruptor (toggle) ----
void toggleSw(int x, int y, bool on) {
  const int w = 44, h = 22;
  DG.fillRoundRect(x, y, w, h, 11, on ? NC_ACC : NC_TRACK);
  DG.fillSmoothCircle(on ? x + w - 11 : x + 11, y + 11, 8, NC_TEXT);
}

// ---- 2. Filamento (selección de preset) ----
void drawPresets() {
  screenHead("Filamento", TR("Toca un filamento para ajustar el ciclo","Toque um filamento para ajustar o ciclo"));
  const int gx0 = 72, gy0 = 78, cw = 124, ch = 62, gapx = 10, gapy = 10;
  for (int i = 0; i < 6; i++) {
    int x = gx0 + (i % 3) * (cw + gapx);
    int y = gy0 + (i / 3) * (ch + gapy);
    bool sel = (dryFilament == PRESETS[i].name);
    DG.fillRoundRect(x, y, cw, ch, 12, sel ? NC_SEL : NC_SURF);
    if (sel) DG.drawRoundRect(x, y, cw, ch, 12, NC_ACC);
    txt(&fonts::FreeSans12pt7b, NC_TEXT, x + 14, y + 11, PRESETS[i].name);
    String t = String(toUnit(PRESETS[i].tempC));
    txt(&fonts::FreeSans9pt7b, NC_MUTE, x + 14, y + 38, t);
    int tw = txtW(&fonts::FreeSans9pt7b, t);
    deg(x + 14 + tw + 4, y + 42, NC_MUTE);
    txt(&fonts::FreeSans9pt7b, NC_MUTE, x + 14 + tw + 11, y + 38,
        String(unitLetter()) + "  " + hoursStr(PRESETS[i].hours));
  }
}

// ---- 3. Ajuste (temperatura + duración) ----
void drawAdjust() {
  screenHead("Ajustar ciclo", String("Filamento ") + dryFilament);
  const int y = 74, h = 150;
  // Tarjeta Temperatura
  DG.fillRoundRect(72, y, 188, h, 12, NC_SURF);
  txt(&fonts::FreeSans9pt7b, NC_MUTE, 88, y + 14, "Temp");
  segCF(72 + 188 - 92, y + 10);
  int sy = y + h - 62;
  stepBtn(88, sy, false);
  txt(&fonts::FreeSansBold18pt7b, NC_TEXT, 166, sy + 18, String(toUnit(dryTargetC)), lgfx::textdatum_t::middle_center);
  stepBtn(204, sy, true);
  // Tarjeta Duración
  DG.fillRoundRect(276, y, 188, h, 12, NC_SURF);
  txt(&fonts::FreeSans9pt7b, NC_MUTE, 292, y + 14, TR("Duracion","Duracao"));
  stepBtn(292, sy, false);
  txt(&fonts::FreeSansBold18pt7b, NC_TEXT, 370, sy + 18, hoursStr(dryTotalSec / 3600.0f), lgfx::textdatum_t::middle_center);
  stepBtn(408, sy, true);
  txt(&fonts::FreeSans9pt7b, NC_FAINT, 292, y + h - 16, TR("Pasos de 30 min","Passos de 30 min"));
  // Barra inferior: Volver + Iniciar secado
  btnOutline(72, 268, 120, 40, NC_MUTE, &fonts::FreeSans12pt7b, TR("Volver","Voltar"));
  btnFill(276, 268, 188, 40, NC_ACC, NC_ONACC, &fonts::FreeSans12pt7b, TR("Iniciar secado","Iniciar secagem"));
}

// ---- 4. Completado ----
void drawDone() {
  drawBrandClock();
  const int cx = 268;
  DG.fillSmoothCircle(cx, 98, 32, NC_SEL);
  DG.drawWideLine(cx - 14, 98, cx - 4, 109, 4, NC_ACC);
  DG.drawWideLine(cx - 4, 109, cx + 16, 84, 4, NC_ACC);
  txt(&fonts::FreeSansBold18pt7b, NC_TEXT, cx, 152, TR("Secado completado","Secagem concluida"), lgfx::textdatum_t::middle_center);
  String sum = dryFilament + TR(", humedad final ",", umidade final ") + String((int)round(dryCurHum)) + "%, " + hoursStr(dryTotalSec / 3600.0f);
  txt(&fonts::FreeSans9pt7b, NC_MUTE, cx, 184, sum, lgfx::textdatum_t::middle_center);
  btnFill(cx - 70, 222, 140, 42, NC_ACC, NC_ONACC, &fonts::FreeSans12pt7b, TR("Aceptar","Aceitar"));
}

// ---- 5. Ajustes (configuración) ----
void settingsRow(int y, const String& label) {
  DG.fillRoundRect(72, y, 392, 40, 10, NC_SURF);
  txt(&fonts::FreeSans12pt7b, NC_TEXT, 88, y + 20, label, lgfx::textdatum_t::middle_left);
}
// Slider de brillo (0..100) dentro de una fila de Ajustes. Track 250..408.
static const int BRI_X0 = 244, BRI_X1 = 392;
// El track representa 20..100 % (el mínimo desde el equipo es 20 %).
static const int BRI_MIN = 20;
void drawBrightness(int y) {
  int cy = y + 20, sw = BRI_X1 - BRI_X0;
  DG.fillRoundRect(BRI_X0, cy - 3, sw, 6, 3, NC_TRACK);
  int b = dispBacklight; if (b < BRI_MIN) b = BRI_MIN; if (b > 100) b = 100;
  int fw = sw * (b - BRI_MIN) / (100 - BRI_MIN);
  DG.fillRoundRect(BRI_X0, cy - 3, fw, 6, 3, NC_ACC);
  DG.fillSmoothCircle(BRI_X0 + fw, cy, 7, NC_TEXT);
  txt(&fonts::FreeSans9pt7b, NC_MUTE, 456, cy, String(dispBacklight) + "%", lgfx::textdatum_t::middle_right);
}
void drawSettings() {
  screenHead("Ajustes", "");
  int y = 54, step = 46;
  settingsRow(y, TR("Unidad de temperatura","Unidade de temperatura"));   segCF(392, y + 7);    y += step;
  settingsRow(y, TR("Brillo","Brilho"));       drawBrightness(y);                                y += step;
  settingsRow(y, "Ventilador");
  { const char* f = (fanMode == 0) ? TR("Auto","Auto") : (fanMode == 1) ? TR("Encendido","Ligado") : TR("Off","Off");
    btnOutline(360, y + 5, 90, 30, NC_ACC, &fonts::FreeSans9pt7b, f); }                          y += step;
  settingsRow(y, TR("Alarma al finalizar","Alarme ao terminar"));      toggleSw(408, y + 9, alarmOn);  y += step;
  settingsRow(y, "Idioma");                    segLang(392, y + 7);
  String fwl = String("Firmware ") + FW_VERSION;
  txt(&fonts::FreeSans9pt7b, NC_FAINT, 72, 298, fwl);
  int fwW = txtW(&fonts::FreeSans9pt7b, fwl);
  midDot(72 + fwW + 8, 305, NC_FAINT);
  txt(&fonts::FreeSans9pt7b, NC_FAINT, 72 + fwW + 16, 298, "Filadry 3.5\"");
}

// ---- 6. Agenda (programar inicio/fin del secado por fecha y hora) ----
const int SCH_XS[4] = {66, 168, 270, 372};   // columnas: día, mes, hora, min
const int SCH_FW    = 92;
const char* const SCH_LBL[4] = {"Dia", "Mes", "Hora", "Min"};

// Stepper compacto "−  valor  +" (el toque se rutea en dryerTouch).
void schStepper(int fx, int labelY, int sy, const char* label, int val) {
  txt(&fonts::FreeSans9pt7b, NC_MUTE, fx + SCH_FW/2, labelY, label, lgfx::textdatum_t::top_center);
  DG.drawRoundRect(fx, sy, 26, 30, 6, NC_MUTE);
  txt(&fonts::FreeSansBold12pt7b, NC_MUTE, fx + 13, sy + 14, "-", lgfx::textdatum_t::middle_center);
  char b[4]; snprintf(b, sizeof(b), "%02d", val);
  txt(&fonts::FreeSansBold12pt7b, NC_TEXT, fx + SCH_FW/2, sy + 14, String(b), lgfx::textdatum_t::middle_center);
  DG.drawRoundRect(fx + SCH_FW - 26, sy, 26, 30, 6, NC_ACC);
  txt(&fonts::FreeSansBold12pt7b, NC_ACC, fx + SCH_FW - 13, sy + 14, "+", lgfx::textdatum_t::middle_center);
}

String schedStatus() {
  if (!ntpReady()) return TR("Esperando hora (NTP)","Esperando hora (NTP)");
  time_t now = time(nullptr);
  time_t st = schedEpoch(schStMon, schStDay, schStHr, schStMin);
  time_t en = schedEpoch(schEnMon, schEnDay, schEnHr, schEnMin);
  if (en <= st)       return TR("El fin debe ser posterior","O fim deve ser posterior");
  if (!schedEnabled)  return TR("Agenda desactivada","Agenda desativada");
  if (schedDone)      return TR("Programacion cumplida","Programacao cumprida");
  if (now < st)       return String(TR("Inicia en ","Inicia em ")) + fmtDur(st - now);
  if (now < en)       return String(TR("En curso, termina en ","Em curso, termina em ")) + fmtDur(en - now);
  return TR("Ventana vencida","Janela vencida");
}

void drawSchedule() {
  screenHead("Agenda", "");
  schedEnsureInit();
  // Tarjeta Inicio
  DG.fillRoundRect(64, 44, 408, 84, 12, NC_SURF);
  txt(&fonts::FreeSans9pt7b, NC_ACCLT, 76, 50, "Inicio");
  int v1[4] = {schStDay, schStMon, schStHr, schStMin};
  for (int i = 0; i < 4; i++) schStepper(SCH_XS[i], 70, 88, SCH_LBL[i], v1[i]);
  // Tarjeta Fin
  DG.fillRoundRect(64, 136, 408, 84, 12, NC_SURF);
  txt(&fonts::FreeSans9pt7b, NC_ACCLT, 76, 142, TR("Fin","Fim"));
  int v2[4] = {schEnDay, schEnMon, schEnHr, schEnMin};
  for (int i = 0; i < 4; i++) schStepper(SCH_XS[i], 162, 180, SCH_LBL[i], v2[i]);
  // Barra inferior: activar + estado (se limpia por si cambió el texto)
  DG.fillRect(64, 246, 408, 30, NC_BG);
  toggleSw(72, 250, schedEnabled);
  txt(&fonts::FreeSans9pt7b, NC_TEXT, 126, 261, TR("Activar agenda","Ativar agenda"), lgfx::textdatum_t::middle_left);
  txt(&fonts::FreeSans9pt7b, NC_MUTE, 468, 261, schedStatus(), lgfx::textdatum_t::middle_right);
}

// Contenido de la pantalla actual (sin limpiar el fondo). Se usa tanto en vivo
// como al capturar (rasterizando en un sprite banda por banda).
void drawDryerBody() {
  drawNavRail();
  switch (dryScreen) {
    case D_PANEL:    drawPanel();    break;
    case D_PRESETS:  drawPresets();  break;
    case D_ADJUST:   drawAdjust();   break;
    case D_DONE:     drawDone();     break;
    case D_SETTINGS: drawSettings(); break;
    case D_SCHEDULE: drawSchedule(); break;
  }
}

// Marca "Maker3D" flotando (dibujada vía DG: respeta la banda del sprite).
void drawSaverBrand() {
  DG.setFont(&fonts::FreeSansBold18pt7b);
  DG.setTextDatum(lgfx::textdatum_t::middle_left);
  int w1 = DG.textWidth("Maker"), w2 = DG.textWidth("3D");
  int x0 = (int)brandX - (w1 + w2)/2, by = (int)brandY;
  DG.setTextColor(SV_BRAND); DG.drawString("Maker", x0, by);
  DG.setTextColor(SV_DOT2);  DG.drawString("3D", x0 + w1, by);
}

// Dibuja el salvapantalla COMPLETO (líneas + marca + puntos) con el DST actual.
// Se usa tanto en el render por bandas del propio salvapantalla como en la captura.
void drawSaverBody() {
  DG.fillScreen(0x0000);
  for (int i=0;i<sSegN;i++) DG.drawWideLine(sSeg[i].x0, sSeg[i].y0, sSeg[i].x1, sSeg[i].y1, 1, sSeg[i].col);
  drawSaverBrand();
  for (int i=0;i<SAVER_N;i++) DG.fillSmoothCircle((int)sNodes[i].x, (int)sNodes[i].y, 2, (i % 9 == 0) ? SV_DOT2 : SV_DOT);
}

// Un fotograma del salvapantalla, SIN parpadeo: mueve nodos/marca, calcula las
// líneas, y rasteriza toda la pantalla en un sprite banda por banda y la vuelca
// (doble buffer: no se ve el borrado intermedio). Corre bajo TftLock.
void saverFrame() {
  const int W=480, H=320, pad=14;
  const float TH=95.0f, TH2=TH*TH;
  // mover nodos (deriva lenta, rebote en bordes)
  for (int i=0;i<SAVER_N;i++) {
    sNodes[i].x += sNodes[i].vx; sNodes[i].y += sNodes[i].vy;
    if (sNodes[i].x < pad || sNodes[i].x > W-pad) { sNodes[i].vx = -sNodes[i].vx; sNodes[i].x = constrain(sNodes[i].x, (float)pad, (float)(W-pad)); }
    if (sNodes[i].y < pad || sNodes[i].y > H-pad) { sNodes[i].vy = -sNodes[i].vy; sNodes[i].y = constrain(sNodes[i].y, (float)pad, (float)(H-pad)); }
  }
  // mover la marca (flota en la zona central)
  brandX += brandVX; brandY += brandVY;
  if (brandX < 130 || brandX > 350) { brandVX = -brandVX; brandX = constrain(brandX, 130.0f, 350.0f); }
  if (brandY < 100 || brandY > 220) { brandVY = -brandVY; brandY = constrain(brandY, 100.0f, 220.0f); }
  // calcular líneas entre nodos cercanos (más cerca = un poco más brillante)
  sSegN = 0;
  for (int i=0;i<SAVER_N;i++) for (int j=i+1;j<SAVER_N;j++) {
    float dx=sNodes[i].x-sNodes[j].x, dy=sNodes[i].y-sNodes[j].y, d2=dx*dx+dy*dy;
    if (d2 < TH2 && sSegN < SAVER_MAXSEG) {
      uint16_t col = lerp565(SV_LINE_LO, SV_LINE_HI, 1.0f - sqrtf(d2)/TH);
      sSeg[sSegN++] = { (int16_t)sNodes[i].x,(int16_t)sNodes[i].y,(int16_t)sNodes[j].x,(int16_t)sNodes[j].y, col };
    }
  }
  // rasterizar por bandas a un sprite y volcar (sin flash)
  const int BAND = 24;   // 480*24*2 = 23 KB (convive con el TLS del heartbeat)
  LGFX_Sprite spr(&tft); spr.setColorDepth(16);
  if (!spr.createSprite(W, BAND)) return;   // heap ocupada: saltear este fotograma
  LovyanGFX* pd=DST; int px=DOX, py=DOY;
  for (int y0=0; y0<H; y0+=BAND) {
    int y1b = y0 + BAND;
    DST=&spr; DOX=0; DOY=y0;
    spr.fillScreen(0x0000);
    for (int i=0;i<sSegN;i++) {   // sólo las líneas que tocan esta banda
      int ymin = sSeg[i].y0 < sSeg[i].y1 ? sSeg[i].y0 : sSeg[i].y1;
      int ymax = sSeg[i].y0 > sSeg[i].y1 ? sSeg[i].y0 : sSeg[i].y1;
      if (ymax < y0 || ymin >= y1b) continue;
      DG.drawWideLine(sSeg[i].x0, sSeg[i].y0, sSeg[i].x1, sSeg[i].y1, 1, sSeg[i].col);
    }
    if ((int)brandY + 22 >= y0 && (int)brandY - 22 < y1b) drawSaverBrand();
    for (int i=0;i<SAVER_N;i++) {   // sólo los puntos de esta banda
      int dy=(int)sNodes[i].y;
      if (dy+3 < y0 || dy-3 >= y1b) continue;
      DG.fillSmoothCircle((int)sNodes[i].x, dy, 2, (i % 9 == 0) ? SV_DOT2 : SV_DOT);
    }
    DST=&tft; DOX=0; DOY=0;
    spr.pushSprite(0, y0);
  }
  DST=pd; DOX=px; DOY=py;
  spr.deleteSprite();
}

// Tarea del salvapantalla (~fps que permita la SPI) en su propio core.
void saverTaskFn(void*) {
  for (;;) {
    { TftLock lock;
      if (saverActive && hasDisplay) {
        if (!saverPrimed) { saverPrime(); saverPrimed = true; }
        saverFrame();
      }
    }
    vTaskDelay(pdMS_TO_TICKS(2));   // minimizar el hueco entre frames (mas fluido)
  }
}

// Dibuja la pantalla limpiando el fondo (al ENTRAR/cambiar de pantalla).
void drawDryer() {
  if (!hasDisplay) return;
  TftLock lock;
  DG.fillScreen(NC_BG);
  drawDryerBody();
}
// Redibuja SIN limpiar el fondo: para cambios de valor dentro de la misma
// pantalla. Cada elemento se pinta sobre sí mismo (las tarjetas se rellenan),
// así se evita el "flash" de borrar toda la pantalla a negro en cada toque.
void redrawDryer() {
  if (!hasDisplay) return;
  TftLock lock;
  drawDryerBody();
}

// ---------- Captura de pantalla ----------
// Esta placa no puede leer su framebuffer (MISO del LCD no cableado) y no hay
// PSRAM ni RAM para un framebuffer completo (300 KB). Solución: rasterizar la UI
// en un sprite "banda" (480 x BAND) recorriendo la pantalla de arriba a abajo, y
// subir cada banda cruda (RGB565) al servidor, que arma un PNG. El cuerpo HTTPS
// se escribe a mano para no bufferear toda la imagen.
void uploadScreenshot() {
  if (!hasDisplay) return;
  const int W = 480, H = 320, BAND = 16;   // 480*16*2 = 15360 bytes por banda

  LGFX_Sprite spr(&tft);
  spr.setColorDepth(16);
  if (!spr.createSprite(W, BAND)) return;   // sin RAM: se aborta silenciosamente

  WiFiClientSecure client;
  client.setInsecure();
  client.setTimeout(15000);
  if (!client.connect("maker3d.jrintercom.net", 443)) { spr.deleteSprite(); return; }

  const long bodyLen = 4L + (long)W * H * 2;
  String hdr = String("POST /api/screenshot.php?id=") + deviceId + "&token=" + gToken
             + " HTTP/1.1\r\nHost: maker3d.jrintercom.net\r\n"
             + "Content-Type: application/octet-stream\r\n"
             + "Content-Length: " + bodyLen + "\r\nConnection: close\r\n\r\n";
  client.print(hdr);

  uint8_t head[4] = { (uint8_t)(W >> 8), (uint8_t)(W & 0xFF),
                      (uint8_t)(H >> 8), (uint8_t)(H & 0xFF) };
  client.write(head, 4);

  for (int y0 = 0; y0 < H; y0 += BAND) {
    int rows = (y0 + BAND > H) ? (H - y0) : BAND;
    {
      TftLock lock;
      DST = &spr; DOY = y0;      // dibujar la banda [y0, y0+BAND) en el sprite
      if (shotSaver) { spr.fillScreen(0x0000); drawSaverBody(); }
      else           { spr.fillScreen(NC_BG);  drawDryerBody(); }
      DST = &tft; DOY = 0;       // restaurar destino en vivo
    }
    client.write((uint8_t*)spr.getBuffer(), (size_t)W * rows * 2);
  }
  spr.deleteSprite();

  unsigned long t0 = millis();
  while (client.connected() && millis() - t0 < 3000) {
    while (client.available()) client.read();
    delay(5);
  }
  client.stop();
}

// ============================================================================
//   HARDWARE REAL: sensor SHT31 + relés por PCF8574 (bus I2C en GPIO21/22)
// ----------------------------------------------------------------------------
//   En esta placa (ESP32-3248S035C) casi todos los GPIO están ocupados. El
//   único header libre expone GPIO21, GPIO22 y GPIO35 (35 solo entrada). Por eso
//   sensor y relés van por un bus I2C propio (SDA=21, SCL=22): el SHT31 (0x44) da
//   temperatura y humedad, y un PCF8574 (0x20) maneja los relés. Si no se detecta
//   el sensor al arrancar, se corre en modo DEMO (simulación) para que la
//   pantalla siga mostrando algo hasta conectar el hardware.
// ============================================================================
static const int     PIN_I2C_SDA   = 21;   // header de expansión (comparte INT del touch; con reintento)
static const int     PIN_I2C_SCL   = 22;   // header de expansión
static const uint8_t SHT31_ADDR    = 0x44;
static const uint8_t PCF8574_ADDR  = 0x20;
static const uint8_t RELAY_HEATER  = 0;    // P0 del PCF8574 -> IN1 del módulo de relés (calefactor)
static const uint8_t RELAY_FAN     = 1;    // P1 del PCF8574 -> IN2 (ventilador)

static const float TEMP_MAX_SAFE = 92.0f;  // corte duro por sobre-temperatura
static const float TEMP_HYST     = 1.0f;   // histéresis del calefactor (±1 °C)

bool     hasSensor   = false;   // sensor presente -> control real; si no, demo
bool     hasRelays   = false;   // PCF8574 presente
bool     heaterOn    = false;
uint8_t  pcfState    = 0xFF;    // relés apagados (módulos activos en BAJO)
int      sensorFail  = 0;       // lecturas fallidas seguidas
bool     overTempLock= false;   // enclavamiento por sobre-temperatura

// CRC-8 del SHT31 (polinomio 0x31) para descartar lecturas corruptas.
uint8_t sht31Crc(uint8_t b0, uint8_t b1) {
  uint8_t crc = 0xFF; uint8_t data[2] = {b0, b1};
  for (int i = 0; i < 2; i++) { crc ^= data[i];
    for (int b = 0; b < 8; b++) crc = (crc & 0x80) ? (crc << 1) ^ 0x31 : (crc << 1);
  }
  return crc;
}

bool pcfWrite(uint8_t v) {
  Wire.beginTransmission(PCF8574_ADDR);
  Wire.write(v);
  return Wire.endTransmission() == 0;
}
void relaySet(uint8_t bit, bool on) {
  if (on) pcfState &= ~(1 << bit); else pcfState |= (1 << bit); // activo en bajo
  if (hasRelays) pcfWrite(pcfState);
}
void setHeater(bool on) { heaterOn = on; relaySet(RELAY_HEATER, on); }
void setFan(bool on)    { relaySet(RELAY_FAN, on); }

bool sht31Read(float& t, float& h) {
  Wire.beginTransmission(SHT31_ADDR);
  Wire.write(0x24); Wire.write(0x00);         // medición single-shot, alta repetibilidad, sin clock-stretch
  if (Wire.endTransmission() != 0) return false;
  delay(20);
  if (Wire.requestFrom((int)SHT31_ADDR, 6) != 6) return false;
  uint8_t b[6]; for (int i = 0; i < 6; i++) b[i] = Wire.read();
  if (sht31Crc(b[0], b[1]) != b[2] || sht31Crc(b[3], b[4]) != b[5]) return false;
  uint16_t rawT = (b[0] << 8) | b[1], rawH = (b[3] << 8) | b[4];
  t = -45.0f + 175.0f * (rawT / 65535.0f);
  h = 100.0f * (rawH / 65535.0f);
  return true;
}

void dryerHwInit() {
  Wire.begin(PIN_I2C_SDA, PIN_I2C_SCL, 100000);
  // ¿Está el expansor de relés?
  Wire.beginTransmission(PCF8574_ADDR);
  hasRelays = (Wire.endTransmission() == 0);
  if (hasRelays) { pcfState = 0xFF; pcfWrite(pcfState); } // todo apagado
  // ¿Está el sensor? (dos intentos)
  float t, h;
  hasSensor = sht31Read(t, h) || sht31Read(t, h);
  if (hasSensor) { dryCurTempC = t; dryCurHum = h; }
}

// ---- Tick de la secadora (1 s): control real con hardware, o demo sin él ----
void dryerTick() {
  if (!appMode) return;
  if (millis() - dryTick < 1000) return;
  dryTick = millis();
  dotOn = !dotOn; // parpadeo del punto

  if (hasSensor) {
    // --- Modo REAL ---
    float t, h;
    if (sht31Read(t, h)) { dryCurTempC = t; dryCurHum = h; sensorFail = 0; }
    else                 { sensorFail++; }

    bool ciclo = dryRunning && dryScreen != D_DONE;
    if (ciclo && dryRemainSec > 0) dryRemainSec--;

    // Seguridad + control del calefactor por histéresis.
    if (dryCurTempC >= TEMP_MAX_SAFE) overTempLock = true;
    if (overTempLock && dryCurTempC < TEMP_MAX_SAFE - 5) overTempLock = false;
    bool wantHeat = ciclo && !overTempLock && sensorFail < 4;
    if (wantHeat) {
      if (dryCurTempC >= dryTargetC + TEMP_HYST) wantHeat = false;      // alcanzó objetivo
      else if (dryCurTempC <= dryTargetC - TEMP_HYST) wantHeat = true;  // por debajo -> calienta
      else wantHeat = heaterOn;                                         // zona muerta: mantiene
    }
    setHeater(wantHeat);

    // Ventilador: On=siempre, Off=nunca, Auto=mientras seca o calienta.
    bool wantFan = (fanMode == 1) || (fanMode == 0 && (heaterOn || ciclo));
    setFan(wantFan);

    if (ciclo && dryRemainSec == 0) {           // fin del ciclo
      dryRunning = false; dryScreen = D_DONE;
      setHeater(false);
      drawDryer(); return;
    }
  } else {
    // --- Modo DEMO (sin hardware conectado): simulación ---
    if (dryRunning && dryScreen != D_DONE) {
      if (dryRemainSec > 0) dryRemainSec--;
      dryCurTempC += (dryTargetC - dryCurTempC) * 0.05f;
      if (dryCurHum > 9) dryCurHum -= 0.02f;
      if (dryRemainSec == 0) { dryRunning = false; dryCurHum = 9; dryScreen = D_DONE; drawDryer(); return; }
    }
  }

  if (dryScreen == D_PANEL && !saverActive) {
    TftLock lock;
    tft.fillSmoothCircle(84, 23, 3, dotOn ? (dryRunning?NC_ACC:NC_MUTE) : NC_BG);
    drawPanelValues();
  }
}

// ---- Agenda: chequea cada 1 s si hay que auto-iniciar o auto-terminar ----
void scheduleTick() {
  if (!appMode) return;
  if (millis() - schedTickMs < 1000) return;
  schedTickMs = millis();
  schedEnsureInit();
  if (!schedEnabled || schedDone || !ntpReady()) return;
  time_t now = time(nullptr);
  time_t st = schedEpoch(schStMon, schStDay, schStHr, schStMin);
  time_t en = schedEpoch(schEnMon, schEnDay, schEnHr, schEnMin);
  if (en <= st) return;                          // rango inválido
  if (!dryRunning && now >= st && now < en) {    // auto-inicio
    dryTotalSec  = en - st;
    dryRemainSec = en - now;
    dryRunning   = true;
    dryScreen    = D_PANEL;
    drawDryer();
  } else if (now >= en) {                         // auto-fin
    if (dryRunning) { dryRunning = false; dryScreen = D_DONE; }
    schedDone = true; schedSave();
    drawDryer();
  }
}

// ---- Animación del ventilador en su PROPIA tarea (~12 fps) ----
// Corre en una tarea aparte para que la animación NO se frene cuando el loop
// principal se bloquea (p. ej. el heartbeat HTTPS de cada 5 s). El bus SPI se
// comparte con el loop mediante tftMux (TftLock), así no chocan. Sólo gira con la
// secadora en marcha y en el Panel; detenido se dibuja quieto en el redibujo.
volatile bool animSuspend = false;   // lo activan OTA/aprovisionamiento
void fanTaskFn(void*) {
  for (;;) {
    if (appMode && hasDisplay && !animSuspend && !saverActive && dryScreen == D_PANEL && dryRunning) {
      fanAngle += 15.0f;
      if (fanAngle >= 360.0f) fanAngle -= 360.0f;
      { TftLock lock; blitRegion(1); }   // vuelca el ventilador girado (limpio)
    }
    vTaskDelay(pdMS_TO_TICKS(80));
  }
}

// ---- Ruteo de toques ----
static bool inR(int x,int y,int x0,int y0,int w,int h){ return x>=x0 && x<x0+w && y>=y0 && y<y0+h; }

void dryerTouch(int x, int y) {
  if (x < 56) { // rail de navegación (5 ítems)
    for (int i=0;i<5;i++) if (y>=NAV_YS[i] && y<=NAV_YS[i]+40) {
      dryScreen = (i==0)?D_PANEL:(i==1)?D_PRESETS:(i==2)?D_ADJUST:(i==3)?D_SCHEDULE:D_SETTINGS;
      drawDryer(); return;
    }
    return;
  }
  switch (dryScreen) {
    case D_PANEL:
      if (inR(x,y, 72,278, 176,40)) { fanMode = (fanMode + 1) % 3; redrawDryer(); return; } // ventilador
      if (dryRunning) {
        if (inR(x,y, 282,278, 84,40)) { dryRunning = false; drawDryer(); return; }         // Pausar (mantiene el tiempo)
        if (inR(x,y, 376,278, 88,40)) { dryRunning = false; dryRemainSec = dryTotalSec; drawDryer(); return; } // Detener (reinicia el ciclo)
      } else if (inR(x,y, 334,278, 130,40)) { dryRunning = true; drawDryer(); return; }    // Iniciar
      return;

    case D_PRESETS: {
      const int gx0=72, gy0=78, cw=124, ch=62, gapx=10, gapy=10;
      for (int i=0;i<6;i++) {
        int cxp = gx0 + (i%3)*(cw+gapx), cyp = gy0 + (i/3)*(ch+gapy);
        if (inR(x,y, cxp,cyp, cw,ch)) {
          dryFilament = PRESETS[i].name;
          dryTargetC  = PRESETS[i].tempC;
          dryTotalSec = (long)(PRESETS[i].hours * 3600);
          dryScreen   = D_ADJUST;
          drawDryer(); return;
        }
      }
      return;
    }

    case D_ADJUST: {
      const int sy = 162;
      if (inR(x,y, 168,84, 38,26)) { dryUnit='C'; redrawDryer(); return; }   // segmentado C
      if (inR(x,y, 206,84, 38,26)) { dryUnit='F'; redrawDryer(); return; }   // segmentado F
      if (inR(x,y, 88,sy, 40,40))  { if (dryTargetC>35) dryTargetC--; redrawDryer(); return; }
      if (inR(x,y, 204,sy, 40,40)) { if (dryTargetC<90) dryTargetC++; redrawDryer(); return; }
      if (inR(x,y, 292,sy, 40,40)) { if (dryTotalSec>1800)  dryTotalSec-=1800; redrawDryer(); return; }
      if (inR(x,y, 408,sy, 40,40)) { if (dryTotalSec<86400) dryTotalSec+=1800; redrawDryer(); return; }
      if (inR(x,y, 72,268, 120,40))  { dryScreen = D_PRESETS; drawDryer(); return; }        // Volver
      if (inR(x,y, 276,268, 188,40)) { dryRemainSec = dryTotalSec; dryRunning = true;        // Iniciar secado
                                       dryScreen = D_PANEL; drawDryer(); return; }
      return;
    }

    case D_DONE:
      if (inR(x,y, 198,222, 140,42)) {   // Aceptar -> Panel en reposo, reset
        dryRunning = false; dryRemainSec = dryTotalSec; dryCurHum = 21; dryCurTempC = 24;
        dryScreen = D_PANEL; drawDryer();
      }
      return;

    case D_SETTINGS:
      if (inR(x,y, 392,61, 38,26)) { dryUnit='C'; redrawDryer(); return; }
      if (inR(x,y, 430,61, 38,26)) { dryUnit='F'; redrawDryer(); return; }
      // Brillo (slider en la fila y=100)
      if (y >= 100 && y <= 140 && x >= BRI_X0-10 && x <= BRI_X1+10) {
        float frac = (float)(x - BRI_X0) / (BRI_X1 - BRI_X0);
        if (frac < 0) frac = 0; if (frac > 1) frac = 1;
        int bl = BRI_MIN + (int)lroundf(frac * (100 - BRI_MIN));   // mínimo 20 %
        dispBacklight = (uint8_t)bl;
        applyBacklight(true);
        prefs.putUChar("bl", dispBacklight);
        pushBrightness = true;   // sincronizar al server
        redrawDryer(); return;
      }
      if (inR(x,y, 360,151, 90,30)) { fanMode = (fanMode + 1) % 3; redrawDryer(); return; }   // Ventilador
      if (inR(x,y, 408,201, 44,22)) { alarmOn = !alarmOn; redrawDryer(); return; }            // Alarma
      if (inR(x,y, 392,245, 38,26)) { uiLang = 0; prefs.putUChar("lang",0); drawDryer(); return; } // Idioma ES
      if (inR(x,y, 430,245, 38,26)) { uiLang = 1; prefs.putUChar("lang",1); drawDryer(); return; } // Idioma PT
      return;

    case D_SCHEDULE: {
      int* f1[4] = {&schStDay, &schStMon, &schStHr, &schStMin};   // fila Inicio (sy=88)
      int* f2[4] = {&schEnDay, &schEnMon, &schEnHr, &schEnMin};   // fila Fin (sy=180)
      for (int i = 0; i < 4; i++) {
        int fx = SCH_XS[i];
        for (int row = 0; row < 2; row++) {
          int sy = row == 0 ? 88 : 180;
          int* fp = row == 0 ? f1[i] : f2[i];
          int delta = inR(x,y, fx, sy, 26, 30) ? -1 : inR(x,y, fx+66, sy, 26, 30) ? +1 : 0;
          if (delta) { schAdjust(i, fp, delta); schedDone = false; schedSave(); redrawDryer(); return; }
        }
      }
      if (inR(x,y, 72, 250, 60, 24)) { schedEnabled = !schedEnabled; schedDone = false; schedSave(); redrawDryer(); return; } // activar
      return;
    }
  }
}

// ---------- setup / loop ----------
void setup() {
  Serial.begin(115200);
  delay(200);

  tftMux = xSemaphoreCreateMutex();
  // Tarea dedicada a la animación del ventilador (core 0, separado del loop en el
  // core 1) para que no se frene con los bloqueos del loop (heartbeat, etc.).
  xTaskCreatePinnedToCore(fanTaskFn, "fanAnim", 8192, nullptr, 1, nullptr, 0);
  randomSeed(esp_random());
  xTaskCreatePinnedToCore(saverTaskFn, "saver", 8192, nullptr, 1, nullptr, 0);   // salvapantalla

  hasDisplay = tft.init();
  if (hasDisplay) {
    tft.setRotation(1);
    // Arranca con el backlight en 0 para no dar un fogonazo; el brillo real se
    // aplica abajo, una vez leídos los ajustes de NVS (por defecto, al mínimo).
    tft.setBrightness(0);
    // Paleta de las pantallas iniciales, igual a la Nocturne de los menús
    // (fondo oscuro azulado, acento lava naranja).
    COL_BG     = tft.color565(0x16, 0x18, 0x26);
    COL_SURF   = tft.color565(0x23, 0x25, 0x32);
    COL_TEXT   = tft.color565(0xe9, 0xe9, 0xed);
    COL_MUTE   = tft.color565(0x93, 0x97, 0xab);
    COL_ACCENT = tft.color565(0xff, 0x57, 0x22);   // naranja lava (igual a los menús)
    COL_GREEN  = tft.color565(0x34, 0xd3, 0x99);
    COL_AMBER  = tft.color565(0xf5, 0xa6, 0x23);
    COL_RED    = tft.color565(0xef, 0x44, 0x44);
    COL_DIM    = tft.color565(0x37, 0x39, 0x46);
    initNocturne(); // paleta de la app de la secadora
    tft.fillScreen(COL_BG);
  }

  prefs.begin("maker3d", false);
  deviceId = chipIdHex();
  schedLoad();   // agenda guardada (programación de inicio/fin)
  uiLang = prefs.getUChar("lang", 0);   // idioma guardado (0 ES, 1 PT)

  // Sensor + relés (deja los relés apagados de entrada). Si no hay sensor, la
  // secadora corre en modo demo y no acciona nada.
  dryerHwInit();

  // Ajustes de pantalla guardados (por defecto: brillo máximo, ahorro ON, 5 min).
  dispBacklight = prefs.getUChar("bl", 100);
  dispPowerSave = prefs.getBool("ps", true);
  dispSleepMin  = prefs.getUShort("sm", 5);
  screenOn = true;
  lastActivity = millis();
  applyBacklight(true); // enciende al brillo configurado (mínimo por defecto)

  drawUi(UI_INIT);

  String ssid = prefs.getString("ssid", "");
  String pass = prefs.getString("pass", "");
  gToken      = prefs.getString("stoken", "");

  bool provisioned = (ssid.length() > 0 && gToken.length() > 0);
  if (provisioned && connectWiFi(ssid, pass) && sendHeartbeat()) {
    appMode = true;   // online: entra a la app de la secadora
    drawDryer();
  } else {
    startBLE();
  }
}

void loop() {
  // Aprovisionamiento pedido por BLE: se hace acá, en la tarea principal, para
  // no desbordar el stack del callback BLE con el handshake TLS.
  if (provisionPending) {
    provisionPending = false;
    saverActive = false;         // el aprovisionamiento dibuja su propia pantalla
    animSuspend = true;
    doProvision();
    animSuspend = false;
  }

  // OTA diferido: se ejecuta acá, con la conexión del heartbeat ya cerrada.
  if (otaPending) {
    otaPending = false;
    saverActive = false;         // el OTA dibuja su barra de progreso
    animSuspend = true;          // pausa la tarea del ventilador durante el OTA
    doOtaUpdate(otaUrl);
    animSuspend = false;
  }

  if (shotPending) {
    shotPending = false;
    animSuspend = true;          // libera heap/SPI para el TLS del upload
    if (shotScreen == 9) {       // capturar el salvapantalla (lo deja corriendo)
      shotSaver = true; shotScreen = -1;
    } else {
      saverActive = false;       // captura normal: salir del salvapantalla si estaba
      if (shotScreen >= 0 && shotScreen <= D_SCHEDULE) {
        dryScreen = (DryScreen)shotScreen; shotScreen = -1;
        drawDryer();
      }
    }
    uploadScreenshot();
    shotSaver = false;
    animSuspend = false;
  }

  spinnerTick();
  powerSaveTick();
  dryerTick();
  scheduleTick();

  if (hasDisplay) {
    static bool touchLatch = false;
    static unsigned long touchStart = 0;
    static bool longFired = false;
    int32_t tx, ty;
    if (tft.getTouch(&tx, &ty)) {
      if (!touchLatch) {
        touchLatch = true; touchStart = millis(); longFired = false;
        bool wasOff = !screenOn || saverActive;  // dormida o en salvapantalla
        wakeScreen(); // un toque despierta / sale del salvapantalla
        // Si ya estaba despierta y corriendo la app, el toque va a la secadora.
        if (!wasOff && appMode) dryerTouch(tx, ty);
      } else if (!longFired && !appMode && millis() - touchStart > 3000) {
        // Pulsación larga (3s) fuera de la app: entrar en emparejamiento (BLE).
        longFired = true;
        if (!bleActive) startBLE();
      }
    } else {
      touchLatch = false;
    }
  }

  if (!bleActive && gToken.length() > 0) {
    if (WiFi.status() != WL_CONNECTED) {
      String ssid = prefs.getString("ssid", "");
      String pass = prefs.getString("pass", "");
      if (!connectWiFi(ssid, pass)) heartbeatFails++;
    }
    // Heartbeat cada 5s: además de avisar "vivo", trae los ajustes de pantalla
    // desde el servidor, así los cambios de brillo/ahorro se aplican rápido.
    if (millis() - lastBeat > 5000) {
      lastBeat = millis();
      if (sendHeartbeat()) heartbeatFails = 0;
      else heartbeatFails++;
    }
    // Tras ~75s seguidos sin poder comunicarse, vuelve a modo emparejamiento.
    if (heartbeatFails >= 15) {
      heartbeatFails = 0;
      startBLE();
    }
  }
  delay(20);
}

← Volver a Maker3D