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
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.
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.
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:
| Offset | Archivo |
|---|---|
0x1000 | bootloader.bin |
0x8000 | partitions.bin |
0xe000 | boot_app0.bin |
0x10000 | app.bin |
El merged.bin de arriba ya incluye los cuatro juntos y se graba en 0x0.
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.maker3d_esp32.ino (descargado arriba).115200 baudios
para ver el device_id y el estado.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.
/*
* 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);
}