/* * 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 #include #include #include #include #include #include #include #include // ---------- 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> 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 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 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= 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=y0 && 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); }