From c0125847012b43a04749c4abb99f53f14673e186 Mon Sep 17 00:00:00 2001 From: Shane Jordan Date: Sun, 29 Mar 2026 15:33:22 -0500 Subject: [PATCH 01/12] feat: TFT display support (WROOM, 240x240 ST7789, LovyanGFX) Add TFTManager with FreeRTOS task, full-screen 8-bit sprite for flicker-free rendering, spool graphic with filament color fill, weight bar, tag type icons, breathing animation for low spool, and screen timeout. WROOM pin mapping on VSPI (pins 22/23 freed from LCD). Mutually exclusive with LCD I2C on WROOM. New build target: esp32dev_tft. Regular builds unaffected. --- include/BoardPins.h | 9 + platformio.ini | 7 + src/TFTConfig.h | 150 +++++++++++ src/TFTManager.cpp | 632 ++++++++++++++++++++++++++++++++++++++++++++ src/TFTManager.h | 121 +++++++++ src/main.cpp | 30 +++ 6 files changed, 949 insertions(+) create mode 100644 src/TFTConfig.h create mode 100644 src/TFTManager.cpp create mode 100644 src/TFTManager.h diff --git a/include/BoardPins.h b/include/BoardPins.h index c8fd65f..cd7f144 100644 --- a/include/BoardPins.h +++ b/include/BoardPins.h @@ -70,4 +70,13 @@ #define PIN_KEYPAD_COL1 19 #define PIN_KEYPAD_COL2 21 #define PIN_KEYPAD_COL3 5 + // TFT SPI display (ENABLE_TFT — mutually exclusive with LCD I2C) + // Uses VSPI. Pins 23/22 freed from LCD when TFT replaces it. + #define PIN_TFT_MOSI 23 // VSPI MOSI (shared with LCD SDA when LCD enabled) + #define PIN_TFT_SCLK 22 // VSPI SCLK (shared with LCD SCL when LCD enabled) + #define PIN_TFT_MISO -1 // Not needed for write-only TFT + #define PIN_TFT_CS 2 // Free GPIO + #define PIN_TFT_DC 0 // Free GPIO (boot strapping pin, safe after boot) + #define PIN_TFT_RST -1 // Software reset via LovyanGFX + #define PIN_TFT_BL -1 // No backlight control (always on), or pick a free pin #endif diff --git a/platformio.ini b/platformio.ini index a97e61d..1697d1a 100644 --- a/platformio.ini +++ b/platformio.ini @@ -26,10 +26,17 @@ lib_deps = adafruit/Adafruit NeoPixel adafruit/Adafruit PN532@1.3.4 chris--a/Keypad@^3.1.1 + lovyan03/LovyanGFX@^1.1.16 [env:esp32dev] board = esp32dev +[env:esp32dev_tft] +board = esp32dev +build_flags = + ${env.build_flags} + -DENABLE_TFT=1 + [env:esp32s3zero] board = esp32-s3-devkitc-1 board_build.cdc_on_boot = 1 diff --git a/src/TFTConfig.h b/src/TFTConfig.h new file mode 100644 index 0000000..c9ae0e6 --- /dev/null +++ b/src/TFTConfig.h @@ -0,0 +1,150 @@ +#pragma once + +#ifdef ENABLE_TFT + +// TFT and LCD are mutually exclusive on WROOM (shared GPIO 22/23) +#if defined(ENABLE_LCD) && ENABLE_LCD == 1 && !defined(BOARD_ESP32_S3) + #error "ENABLE_TFT and ENABLE_LCD cannot both be enabled on WROOM (GPIO 22/23 conflict)" +#endif + +#include +#include "BoardPins.h" + +// --------------------------------------------------------------------------- +// LovyanGFX display config — one class per board, selected at compile time. +// +// Tested target: 240x240 ST7789 (common cheap round/square TFT). +// To use a different driver (ILI9341, GC9A01, etc.) change the _panel_instance +// type and adjust _width/_height below. Everything else stays the same. +// +// SPI bus used: +// WROOM — VSPI (bus 3): pins 18/23 freed from LCD when ENABLE_TFT replaces LCD +// S3-Zero — SPI2 (FSPI): pins 13/15/16 (PN5180 is on separate SPI instance) +// --------------------------------------------------------------------------- + +#if defined(BOARD_ESP32_S3) + +class LGFX : public lgfx::LGFX_Device { + lgfx::Panel_ST7789 _panel_instance; + lgfx::Bus_SPI _bus_instance; + lgfx::Light_PWM _light_instance; + +public: + LGFX() { + // SPI bus + { + auto cfg = _bus_instance.config(); + cfg.spi_host = SPI2_HOST; // FSPI on S3 + cfg.spi_mode = 0; + cfg.freq_write = 40000000; + cfg.freq_read = 16000000; + cfg.pin_sclk = PIN_TFT_SCLK; + cfg.pin_mosi = PIN_TFT_MOSI; + cfg.pin_miso = PIN_TFT_MISO; // -1 if not connected + cfg.pin_dc = PIN_TFT_DC; + _bus_instance.config(cfg); + _panel_instance.setBus(&_bus_instance); + } + + // Panel + { + auto cfg = _panel_instance.config(); + cfg.pin_cs = PIN_TFT_CS; + cfg.pin_rst = PIN_TFT_RST; + cfg.pin_busy = -1; + cfg.memory_width = 240; + cfg.memory_height = 240; + cfg.panel_width = 240; + cfg.panel_height = 240; + cfg.offset_x = 0; + cfg.offset_y = 0; + cfg.offset_rotation = 0; + cfg.dummy_read_pixel = 8; + cfg.dummy_read_bits = 1; + cfg.readable = false; + cfg.invert = true; // ST7789 typically needs invert=true + cfg.rgb_order = false; + cfg.dlen_16bit = false; + cfg.bus_shared = false; + _panel_instance.config(cfg); + } + + // Backlight + { + auto cfg = _light_instance.config(); + cfg.pin_bl = PIN_TFT_BL; + cfg.invert = false; + cfg.freq = 44100; + cfg.pwm_channel = 7; + _light_instance.config(cfg); + _panel_instance.setLight(&_light_instance); + } + + setPanel(&_panel_instance); + } +}; + +#else // WROOM + +class LGFX : public lgfx::LGFX_Device { + lgfx::Panel_ST7789 _panel_instance; + lgfx::Bus_SPI _bus_instance; + lgfx::Light_PWM _light_instance; + +public: + LGFX() { + // SPI bus — VSPI (bus 3), freed from LCD when TFT enabled + { + auto cfg = _bus_instance.config(); + cfg.spi_host = VSPI_HOST; + cfg.spi_mode = 0; + cfg.freq_write = 40000000; + cfg.freq_read = 16000000; + cfg.pin_sclk = PIN_TFT_SCLK; + cfg.pin_mosi = PIN_TFT_MOSI; + cfg.pin_miso = PIN_TFT_MISO; + cfg.pin_dc = PIN_TFT_DC; + _bus_instance.config(cfg); + _panel_instance.setBus(&_bus_instance); + } + + // Panel + { + auto cfg = _panel_instance.config(); + cfg.pin_cs = PIN_TFT_CS; + cfg.pin_rst = PIN_TFT_RST; + cfg.pin_busy = -1; + cfg.memory_width = 240; + cfg.memory_height = 240; + cfg.panel_width = 240; + cfg.panel_height = 240; + cfg.offset_x = 0; + cfg.offset_y = 0; + cfg.offset_rotation = 0; + cfg.dummy_read_pixel = 8; + cfg.dummy_read_bits = 1; + cfg.readable = false; + cfg.invert = true; + cfg.rgb_order = false; + cfg.dlen_16bit = false; + cfg.bus_shared = false; + _panel_instance.config(cfg); + } + + // Backlight + { + auto cfg = _light_instance.config(); + cfg.pin_bl = PIN_TFT_BL; + cfg.invert = false; + cfg.freq = 44100; + cfg.pwm_channel = 7; + _light_instance.config(cfg); + _panel_instance.setLight(&_light_instance); + } + + setPanel(&_panel_instance); + } +}; + +#endif // BOARD_ESP32_S3 +#endif // ENABLE_TFT diff --git a/src/TFTManager.cpp b/src/TFTManager.cpp new file mode 100644 index 0000000..f9e899d --- /dev/null +++ b/src/TFTManager.cpp @@ -0,0 +1,632 @@ +#ifdef ENABLE_TFT + +#include "TFTManager.h" +#include + +// --------------------------------------------------------------------------- +// Tag type icon bitmaps — 32x32 monochrome, stored in flash. +// Each is a const uint8_t array. 1 = foreground pixel, 0 = background. +// These are minimal symbolic icons — replace with your own designs. +// +// Format: row-major, 1 bit per pixel packed into bytes (32 bytes per row, +// but we use a simple 32x32 byte array here for clarity at the cost of +// ~1KB per icon. Fine for 5 icons = ~5KB flash.) +// --------------------------------------------------------------------------- + +// Generic NFC icon (concentric arcs suggesting radio waves) +static const uint8_t ICON_NFC_PLAIN[32][32] PROGMEM = { + {0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0}, + {0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0}, + {0,0,0,0,0,0,0,0,0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0,0,0,0,0,0,0,0,0}, + {0,0,0,0,0,0,0,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,0,0,0,0,0,0,0}, + {0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0}, + {0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0}, + {0,0,0,0,1,0,0,0,0,0,1,1,1,1,1,1,1,1,1,1,1,0,0,0,0,0,0,1,0,0,0,0}, + {0,0,0,0,1,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,1,0,0,0,0}, + {0,0,0,0,1,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,1,0,0,0,0}, + {0,0,0,0,1,0,0,1,0,0,0,0,1,1,1,1,1,1,1,0,0,0,0,1,0,0,0,1,0,0,0,0}, + {0,0,0,0,1,0,0,1,0,0,0,1,0,0,0,0,0,0,0,1,0,0,0,1,0,0,0,1,0,0,0,0}, + {0,0,0,0,1,0,0,1,0,0,1,0,0,0,0,0,0,0,0,0,1,0,0,1,0,0,0,1,0,0,0,0}, + {0,0,0,0,1,0,0,1,0,0,1,0,0,0,1,1,1,0,0,0,1,0,0,1,0,0,0,1,0,0,0,0}, + {0,0,0,0,1,0,0,1,0,0,1,0,0,1,1,0,1,1,0,0,1,0,0,1,0,0,0,1,0,0,0,0}, + {0,0,0,0,1,0,0,1,0,0,1,0,0,1,0,1,1,0,0,0,1,0,0,1,0,0,0,1,0,0,0,0}, + {0,0,0,0,1,0,0,1,0,0,1,0,0,0,1,1,1,0,0,0,1,0,0,1,0,0,0,1,0,0,0,0}, + {0,0,0,0,1,0,0,1,0,0,1,0,0,0,0,0,0,0,0,0,1,0,0,1,0,0,0,1,0,0,0,0}, + {0,0,0,0,1,0,0,1,0,0,0,1,0,0,0,0,0,0,0,1,0,0,0,1,0,0,0,1,0,0,0,0}, + {0,0,0,0,1,0,0,1,0,0,0,0,1,1,1,1,1,1,1,0,0,0,0,1,0,0,0,1,0,0,0,0}, + {0,0,0,0,1,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,1,0,0,0,0}, + {0,0,0,0,1,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,1,0,0,0,0}, + {0,0,0,0,1,0,0,0,0,0,1,1,1,1,1,1,1,1,1,1,1,0,0,0,0,0,0,1,0,0,0,0}, + {0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0}, + {0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0}, + {0,0,0,0,0,0,0,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,0,0,0,0,0,0,0}, + {0,0,0,0,0,0,0,0,0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0,0,0,0,0,0,0,0,0}, + {0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0}, + {0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0}, + {0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0}, + {0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0}, + {0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0}, + {0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0}, +}; + +// OpenPrintTag icon: "OPT" text in a rounded box +// (placeholder — same structure, you can replace with a proper design) +static const uint8_t ICON_OPENPRINTTAG[32][32] PROGMEM = { + {0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0}, + {0,0,0,0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0,0,0,0}, + {0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0}, + {0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0}, + {0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0}, + {0,1,0,0,0,1,1,1,0,0,0,1,0,0,1,0,0,1,1,1,0,0,0,0,0,0,0,0,0,0,1,0}, + {0,1,0,0,1,0,0,0,1,0,0,1,0,0,1,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0}, + {0,1,0,0,1,0,0,0,1,0,0,1,0,0,1,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0}, + {0,1,0,0,1,0,0,0,1,0,0,1,1,1,1,0,1,0,1,1,0,0,0,0,0,0,0,0,0,0,1,0}, + {0,1,0,0,1,0,0,0,1,0,0,1,0,1,0,0,1,0,0,0,1,0,0,0,0,0,0,0,0,0,1,0}, + {0,1,0,0,1,0,0,0,1,0,0,1,0,0,1,0,1,0,0,0,1,0,0,0,0,0,0,0,0,0,1,0}, + {0,1,0,0,0,1,1,1,0,0,0,1,0,0,1,0,0,1,1,1,0,0,0,0,0,0,0,0,0,0,1,0}, + {0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0}, + {0,1,0,0,0,0,0,0,0,1,1,0,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0}, + {0,1,0,0,0,0,0,0,0,1,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0}, + {0,1,0,0,0,0,0,0,0,1,0,0,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0}, + {0,1,0,0,0,0,0,0,0,1,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0}, + {0,1,0,0,0,0,0,0,0,1,1,0,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0}, + {0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0}, + {0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0}, + {0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0}, + {0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0}, + {0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0}, + {0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0}, + {0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0}, + {0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0}, + {0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0}, + {0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0}, + {0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0}, + {0,0,0,0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0,0,0,0}, + {0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0}, + {0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0}, +}; + +// Reuse NFC_PLAIN icon for TigerTag, OpenTag3D, Bambu for now. +// Replace with distinct designs as desired. +#define ICON_TIGERTAG ICON_NFC_PLAIN +#define ICON_OPENTAG3D ICON_NFC_PLAIN +#define ICON_BAMBU ICON_NFC_PLAIN + +// --------------------------------------------------------------------------- +// Color constants +// --------------------------------------------------------------------------- +static const uint32_t COLOR_BG = 0x000000; +static const uint32_t COLOR_HEADER_BG = 0x1A1A2E; +static const uint32_t COLOR_TEXT = 0xFFFFFF; +static const uint32_t COLOR_SUBTEXT = 0xAAAAAA; +static const uint32_t COLOR_SPOOL_RIM = 0x444444; +static const uint32_t COLOR_SPOOL_HUB = 0x222222; +static const uint32_t COLOR_BAR_BG = 0x333333; +static const uint32_t COLOR_BAR_FG = 0x00CC66; +static const uint32_t COLOR_BAR_LOW = 0xFF4444; +static const uint32_t COLOR_ACCENT = 0x4FC3F7; + +// --------------------------------------------------------------------------- +// Constructor +// --------------------------------------------------------------------------- +TFTManager::TFTManager() + : _sprite(&_tft), + _messageQueue(nullptr), + _taskHandle(nullptr), + _screenTimeoutMs(DEFAULT_SCREEN_TIMEOUT_MS), + _lastActivityMs(0), + _screenOff(false), + _stateMux(portMUX_INITIALIZER_UNLOCKED), + _breathBrightness(255), + _breathDirection(-1), + _lastBreathMs(0), + _isBreathing(false), + _breathColor(0xFFFFFF) {} + +// --------------------------------------------------------------------------- +// begin / startTask +// --------------------------------------------------------------------------- +void TFTManager::begin() { + _tft.init(); + _tft.setRotation(0); + _tft.fillScreen(COLOR_BG); + + _sprite.setColorDepth(8); // 8-bit = 57.6KB vs 115KB at 16-bit + _sprite.createSprite(_tft.width(), _tft.height()); + + _messageQueue = xQueueCreate(4, sizeof(TFTMessage)); + _lastActivityMs = millis(); +} + +void TFTManager::startTask() { + xTaskCreatePinnedToCore( + taskFunc, + "TFTTask", + 8192, // TFT + sprite rendering needs more stack than LCD + this, + 1, + &_taskHandle, + 0 // Core 0, same as LCDTask + ); + Serial.println("TFTManager: Task started on core 0"); +} + +// --------------------------------------------------------------------------- +// Public show* methods — called from main task +// --------------------------------------------------------------------------- +void TFTManager::showBoot(const char* version) { + TFTMessage msg{}; + msg.state = TFTState::Boot; + snprintf(msg.statusText, sizeof(msg.statusText), "%s", version); + xQueueSend(_messageQueue, &msg, 0); +} + +void TFTManager::showWifiConnecting() { + TFTMessage msg{}; + msg.state = TFTState::WifiConnecting; + snprintf(msg.statusText, sizeof(msg.statusText), "Connecting..."); + xQueueSend(_messageQueue, &msg, 0); +} + +void TFTManager::showWifiConnected(const char* ip) { + TFTMessage msg{}; + msg.state = TFTState::WifiConnecting; + snprintf(msg.statusText, sizeof(msg.statusText), "%s", ip); + xQueueSend(_messageQueue, &msg, 0); +} + +void TFTManager::showReady() { + TFTMessage msg{}; + msg.state = TFTState::Ready; + xQueueSend(_messageQueue, &msg, 0); +} + +void TFTManager::showSpoolScanned(const TFTSpoolData& spool) { + TFTMessage msg{}; + msg.state = TFTState::SpoolScanned; + msg.spool = spool; + xQueueSend(_messageQueue, &msg, 0); +} + +void TFTManager::showWriting(const char* tagFormat) { + TFTMessage msg{}; + msg.state = TFTState::Writing; + snprintf(msg.statusText, sizeof(msg.statusText), "%s", tagFormat); + xQueueSend(_messageQueue, &msg, 0); +} + +void TFTManager::showWriteResult(bool success, const char* tagFormat) { + TFTMessage msg{}; + msg.state = TFTState::WriteResult; + msg.writeSuccess = success; + snprintf(msg.statusText, sizeof(msg.statusText), "%s", tagFormat); + xQueueSend(_messageQueue, &msg, 0); +} + +void TFTManager::showKeypadEntry(const char* toolNumber) { + TFTMessage msg{}; + msg.state = TFTState::KeypadEntry; + snprintf(msg.statusText, sizeof(msg.statusText), "%s", toolNumber); + xQueueSend(_messageQueue, &msg, 0); +} + +void TFTManager::showError(const char* errMsg) { + TFTMessage msg{}; + msg.state = TFTState::Error; + snprintf(msg.statusText, sizeof(msg.statusText), "%s", errMsg); + xQueueSend(_messageQueue, &msg, 0); +} + +void TFTManager::setScreenTimeoutMs(uint32_t timeoutMs) { + taskENTER_CRITICAL(&_stateMux); + _screenTimeoutMs = timeoutMs; + _lastActivityMs = millis(); + bool wasOff = _screenOff; + _screenOff = false; + taskEXIT_CRITICAL(&_stateMux); + if (wasOff) { + _tft.setBrightness(255); + } +} + +// --------------------------------------------------------------------------- +// Task loop +// --------------------------------------------------------------------------- +void TFTManager::taskFunc(void* param) { + TFTManager* self = static_cast(param); + self->taskLoop(); +} + +void TFTManager::taskLoop() { + while (true) { + processQueue(); + vTaskDelay(pdMS_TO_TICKS(20)); + } +} + +void TFTManager::processQueue() { + TFTMessage msg; + if (xQueueReceive(_messageQueue, &msg, 0) == pdTRUE) { + taskENTER_CRITICAL(&_stateMux); + _lastActivityMs = millis(); + bool wasOff = _screenOff; + _screenOff = false; + _isBreathing = false; + taskEXIT_CRITICAL(&_stateMux); + + if (wasOff) { + _tft.setBrightness(255); + } + + switch (msg.state) { + case TFTState::Boot: + renderBoot(msg.statusText); + break; + case TFTState::WifiConnecting: + renderStatus("WiFi", msg.statusText); + break; + case TFTState::Ready: + renderReady(); + break; + case TFTState::SpoolScanned: + _isBreathing = (msg.spool.remainingWeight > 0 && + msg.spool.remainingWeight <= 100.0f); + if (_isBreathing) { + _breathColor = hexToRgb(msg.spool.colorHex); + _breathBrightness = 255; + _breathDirection = -1; + } + renderSpoolScanned(msg.spool); + break; + case TFTState::Writing: + renderStatus("Writing...", msg.statusText); + break; + case TFTState::WriteResult: + renderWriteResult(msg.writeSuccess, msg.statusText); + break; + case TFTState::KeypadEntry: + renderKeypadEntry(msg.statusText); + break; + case TFTState::Error: + renderStatus("Error", msg.statusText); + break; + } + } + + // Breathing animation tick + if (_isBreathing && (millis() - _lastBreathMs >= BREATH_STEP_MS)) { + _lastBreathMs = millis(); + _breathBrightness += _breathDirection * 3; + if (_breathBrightness <= 30) _breathDirection = 1; + if (_breathBrightness >= 255) _breathDirection = -1; + _breathBrightness = constrain(_breathBrightness, 30, 255); + _tft.setBrightness(_breathBrightness); + } + + // Screen timeout + uint32_t timeoutMs; + unsigned long lastActivity; + bool screenOff; + taskENTER_CRITICAL(&_stateMux); + timeoutMs = _screenTimeoutMs; + lastActivity = _lastActivityMs; + screenOff = _screenOff; + taskEXIT_CRITICAL(&_stateMux); + + if (!screenOff && timeoutMs > 0 && (millis() - lastActivity >= timeoutMs)) { + _tft.setBrightness(0); + taskENTER_CRITICAL(&_stateMux); + _screenOff = true; + taskEXIT_CRITICAL(&_stateMux); + } +} + +// --------------------------------------------------------------------------- +// Rendering +// --------------------------------------------------------------------------- + +void TFTManager::renderBoot(const char* version) { + _sprite.fillScreen(COLOR_BG); + + // Centered logo text + _sprite.setTextColor(COLOR_ACCENT); + _sprite.setTextSize(3); + _sprite.setTextDatum(MC_DATUM); + _sprite.drawString("SpoolSense", _tft.width() / 2, _tft.height() / 2 - 20); + + _sprite.setTextColor(COLOR_SUBTEXT); + _sprite.setTextSize(1); + _sprite.drawString(version, _tft.width() / 2, _tft.height() / 2 + 20); + + _sprite.pushSprite(0, 0); +} + +void TFTManager::renderReady() { + _sprite.fillScreen(COLOR_BG); + + // Header bar + _sprite.fillRect(0, 0, _tft.width(), 28, COLOR_HEADER_BG); + _sprite.setTextColor(COLOR_ACCENT); + _sprite.setTextSize(1); + _sprite.setTextDatum(MC_DATUM); + _sprite.drawString("SpoolSense", _tft.width() / 2, 14); + + // Idle spool — grey fill + int cx = _tft.width() / 2; + int cy = _tft.height() / 2 + 10; + drawSpool(cx, cy, 70, 28, COLOR_SPOOL_RIM); + + // Prompt + _sprite.setTextColor(COLOR_SUBTEXT); + _sprite.setTextSize(1); + _sprite.setTextDatum(MC_DATUM); + _sprite.drawString("Tap a spool to scan", cx, _tft.height() - 16); + + _sprite.pushSprite(0, 0); +} + +void TFTManager::renderSpoolScanned(const TFTSpoolData& spool) { + _sprite.fillScreen(COLOR_BG); + + int W = _tft.width(); // 240 + int H = _tft.height(); // 240 + + // Header bar + _sprite.fillRect(0, 0, W, 28, COLOR_HEADER_BG); + + // Tag type icon top-left in header + drawTagIcon(spool.tagType, 4, 2); + + // "SpoolSense" in header + _sprite.setTextColor(COLOR_ACCENT); + _sprite.setTextSize(1); + _sprite.setTextDatum(ML_DATUM); + _sprite.drawString("SpoolSense", 30, 14); + + // ---- Spool graphic ---- + uint32_t fillColor = hexToRgb(spool.colorHex); + int cx = W / 2; + int cy = 110; + drawSpool(cx, cy, 68, 26, fillColor); + + // ---- Text area ---- + int textY = 185; + _sprite.setTextDatum(MC_DATUM); + + // Brand + material on one line + char brandMat[48]; + snprintf(brandMat, sizeof(brandMat), "%s %s", spool.brand, spool.material); + _sprite.setTextColor(COLOR_SUBTEXT); + _sprite.setTextSize(1); + _sprite.drawString(brandMat, cx, textY); + + // Filament name + _sprite.setTextColor(COLOR_TEXT); + _sprite.setTextSize(1); + _sprite.drawString(spool.name, cx, textY + 14); + + // Weight bar + if (spool.totalWeight > 0) { + drawWeightBar(20, textY + 28, W - 40, 8, + spool.remainingWeight, spool.totalWeight); + + // Weight text + char weightStr[32]; + snprintf(weightStr, sizeof(weightStr), "%.0fg / %.0fg", + spool.remainingWeight, spool.totalWeight); + _sprite.setTextColor(COLOR_SUBTEXT); + _sprite.setTextSize(1); + _sprite.setTextDatum(MC_DATUM); + _sprite.drawString(weightStr, cx, textY + 44); + } + + _sprite.pushSprite(0, 0); +} + +void TFTManager::renderStatus(const char* line1, const char* line2) { + _sprite.fillScreen(COLOR_BG); + + _sprite.fillRect(0, 0, _tft.width(), 28, COLOR_HEADER_BG); + _sprite.setTextColor(COLOR_ACCENT); + _sprite.setTextSize(1); + _sprite.setTextDatum(MC_DATUM); + _sprite.drawString("SpoolSense", _tft.width() / 2, 14); + + int cy = _tft.height() / 2; + _sprite.setTextColor(COLOR_TEXT); + _sprite.setTextSize(2); + _sprite.setTextDatum(MC_DATUM); + _sprite.drawString(line1, _tft.width() / 2, line2 ? cy - 12 : cy); + + if (line2) { + _sprite.setTextColor(COLOR_SUBTEXT); + _sprite.setTextSize(1); + _sprite.drawString(line2, _tft.width() / 2, cy + 12); + } + + _sprite.pushSprite(0, 0); +} + +void TFTManager::renderWriteResult(bool success, const char* tagFormat) { + _sprite.fillScreen(COLOR_BG); + _sprite.fillRect(0, 0, _tft.width(), 28, COLOR_HEADER_BG); + _sprite.setTextColor(COLOR_ACCENT); + _sprite.setTextSize(1); + _sprite.setTextDatum(MC_DATUM); + _sprite.drawString("SpoolSense", _tft.width() / 2, 14); + + int cx = _tft.width() / 2; + int cy = _tft.height() / 2; + + if (success) { + // Green checkmark circle + _sprite.fillCircle(cx, cy - 20, 22, 0x00CC66); + _sprite.setTextColor(COLOR_BG); + _sprite.setTextSize(3); + _sprite.setTextDatum(MC_DATUM); + _sprite.drawString("OK", cx, cy - 20); + _sprite.setTextColor(COLOR_TEXT); + _sprite.setTextSize(1); + _sprite.drawString("Write OK", cx, cy + 12); + _sprite.setTextColor(COLOR_SUBTEXT); + _sprite.drawString(tagFormat, cx, cy + 26); + } else { + _sprite.fillCircle(cx, cy - 20, 22, 0xFF4444); + _sprite.setTextColor(COLOR_BG); + _sprite.setTextSize(3); + _sprite.setTextDatum(MC_DATUM); + _sprite.drawString("X", cx, cy - 20); + _sprite.setTextColor(COLOR_TEXT); + _sprite.setTextSize(1); + _sprite.drawString("Write failed", cx, cy + 12); + _sprite.setTextColor(COLOR_SUBTEXT); + _sprite.drawString(tagFormat, cx, cy + 26); + } + + _sprite.pushSprite(0, 0); +} + +void TFTManager::renderKeypadEntry(const char* toolNumber) { + _sprite.fillScreen(COLOR_BG); + _sprite.fillRect(0, 0, _tft.width(), 28, COLOR_HEADER_BG); + _sprite.setTextColor(COLOR_ACCENT); + _sprite.setTextSize(1); + _sprite.setTextDatum(MC_DATUM); + _sprite.drawString("SpoolSense", _tft.width() / 2, 14); + + int cx = _tft.width() / 2; + _sprite.setTextColor(COLOR_SUBTEXT); + _sprite.setTextSize(1); + _sprite.drawString("Assign to tool:", cx, 100); + + _sprite.setTextColor(COLOR_TEXT); + _sprite.setTextSize(4); + _sprite.drawString(toolNumber, cx, 130); + + _sprite.setTextColor(COLOR_SUBTEXT); + _sprite.setTextSize(1); + _sprite.drawString("Press # to confirm", cx, 185); + + _sprite.pushSprite(0, 0); +} + +// --------------------------------------------------------------------------- +// Drawing helpers +// --------------------------------------------------------------------------- + +void TFTManager::drawSpool(int cx, int cy, int outerR, int innerR, uint32_t fillColor) { + // Shadow + _sprite.fillCircle(cx + 3, cy + 3, outerR, 0x111111); + + // Outer rim (dark grey ring) + _sprite.fillCircle(cx, cy, outerR, COLOR_SPOOL_RIM); + + // Filament fill area (between rim and hub) + // We fill the full inner area with filament color, then draw spokes over it + _sprite.fillCircle(cx, cy, outerR - 5, fillColor); + + // Hub (inner dark circle) + _sprite.fillCircle(cx, cy, innerR, COLOR_SPOOL_HUB); + + // Spokes — 6 evenly spaced + for (int i = 0; i < 6; i++) { + float angle = i * (M_PI / 3.0f); + int x1 = cx + (int)(cos(angle) * (innerR - 2)); + int y1 = cy + (int)(sin(angle) * (innerR - 2)); + int x2 = cx + (int)(cos(angle) * (outerR - 8)); + int y2 = cy + (int)(sin(angle) * (outerR - 8)); + _sprite.drawLine(x1, y1, x2, y2, COLOR_SPOOL_RIM); + _sprite.drawLine(x1+1, y1, x2+1, y2, COLOR_SPOOL_RIM); // 2px wide + } + + // Rim outline + _sprite.drawCircle(cx, cy, outerR, 0x666666); + _sprite.drawCircle(cx, cy, outerR - 1, 0x555555); + + // Hub outline + _sprite.drawCircle(cx, cy, innerR, 0x555555); + + // Hub centre dot + _sprite.fillCircle(cx, cy, 4, 0x888888); +} + +void TFTManager::drawWeightBar(int x, int y, int w, int h, + float remaining, float total) { + float ratio = (total > 0) ? (remaining / total) : 0.0f; + ratio = constrain(ratio, 0.0f, 1.0f); + int filled = (int)(w * ratio); + + uint32_t barColor = (ratio <= 0.1f) ? COLOR_BAR_LOW : COLOR_BAR_FG; + + // Background + _sprite.fillRoundRect(x, y, w, h, h / 2, COLOR_BAR_BG); + // Fill + if (filled > 0) { + _sprite.fillRoundRect(x, y, filled, h, h / 2, barColor); + } + // Outline + _sprite.drawRoundRect(x, y, w, h, h / 2, 0x555555); +} + +void TFTManager::drawTagIcon(uint8_t tagType, int x, int y) { + const uint8_t (*icon)[32] = nullptr; + uint32_t fgColor = COLOR_ACCENT; + + switch (tagType) { + case TAG_TYPE_OPENPRINTTAG: + icon = ICON_OPENPRINTTAG; + fgColor = 0x00BFFF; + break; + case TAG_TYPE_TIGERTAG: + icon = ICON_TIGERTAG; + fgColor = 0xFF8C00; + break; + case TAG_TYPE_OPENTAG3D: + icon = ICON_OPENTAG3D; + fgColor = 0x00CC66; + break; + case TAG_TYPE_BAMBU: + icon = ICON_BAMBU; + fgColor = 0x1DB954; + break; + case TAG_TYPE_NFC_PLAIN: + default: + icon = ICON_NFC_PLAIN; + fgColor = COLOR_SUBTEXT; + break; + } + + if (!icon) return; + + // Draw 32x32 bitmap from PROGMEM — scale to 22x22 by skipping every ~3rd pixel + for (int row = 0; row < 22; row++) { + int srcRow = (row * 32) / 22; + for (int col = 0; col < 22; col++) { + int srcCol = (col * 32) / 22; + uint8_t pixel = pgm_read_byte(&icon[srcRow][srcCol]); + if (pixel) { + _sprite.drawPixel(x + col, y + row, fgColor); + } + } + } +} + +uint32_t TFTManager::hexToRgb(const char* hex) { + if (!hex || strlen(hex) < 6) return 0xCCCCCC; + char buf[7]; + // Strip leading # if present + const char* h = (hex[0] == '#') ? hex + 1 : hex; + strncpy(buf, h, 6); + buf[6] = '\0'; + unsigned long val = strtoul(buf, nullptr, 16); + return (uint32_t)val; +} + +uint32_t TFTManager::dimColor(uint32_t color, uint8_t brightness) { + uint8_t r = ((color >> 16) & 0xFF) * brightness / 255; + uint8_t g = ((color >> 8) & 0xFF) * brightness / 255; + uint8_t b = ((color) & 0xFF) * brightness / 255; + return ((uint32_t)r << 16) | ((uint32_t)g << 8) | b; +} + +#endif // ENABLE_TFT diff --git a/src/TFTManager.h b/src/TFTManager.h new file mode 100644 index 0000000..0df57c4 --- /dev/null +++ b/src/TFTManager.h @@ -0,0 +1,121 @@ +#pragma once + +#ifdef ENABLE_TFT + +#include +#include +#include "BoardPins.h" +#include "TFTConfig.h" + +// --------------------------------------------------------------------------- +// TFTSpoolData — the data the TFT needs after a scan +// --------------------------------------------------------------------------- +struct TFTSpoolData { + char brand[32]; // e.g. "eSUN" + char material[16]; // e.g. "PLA" + char name[48]; // e.g. "eSUN PLA+ White" + char colorHex[8]; // e.g. "FFFFFF" (no leading #) + float remainingWeight; // grams remaining + float totalWeight; // grams total (for % bar) + uint8_t tagType; // TAG_TYPE_* constants below +}; + +// Tag type constants — one icon per type +#define TAG_TYPE_UNKNOWN 0 +#define TAG_TYPE_OPENPRINTTAG 1 +#define TAG_TYPE_TIGERTAG 2 +#define TAG_TYPE_OPENTAG3D 3 +#define TAG_TYPE_BAMBU 4 +#define TAG_TYPE_NFC_PLAIN 5 + +// --------------------------------------------------------------------------- +// Display states +// --------------------------------------------------------------------------- +enum class TFTState { + Boot, + WifiConnecting, + Ready, + SpoolScanned, + Writing, + WriteResult, + KeypadEntry, + Error +}; + +// --------------------------------------------------------------------------- +// Internal message queued to the TFT task +// --------------------------------------------------------------------------- +struct TFTMessage { + TFTState state; + TFTSpoolData spool; // valid when state == SpoolScanned + char statusText[48]; // used for Boot/Ready/Error/Writing/KeypadEntry + bool writeSuccess; // used for WriteResult +}; + +// --------------------------------------------------------------------------- +// TFTManager +// --------------------------------------------------------------------------- +class TFTManager { +public: + TFTManager(); + + void begin(); + void startTask(); + + // --- Call these from the main task, same callers that call LCDManager --- + void showBoot(const char* version); + void showWifiConnecting(); + void showWifiConnected(const char* ip); + void showReady(); + void showSpoolScanned(const TFTSpoolData& spool); + void showWriting(const char* tagFormat); + void showWriteResult(bool success, const char* tagFormat); + void showKeypadEntry(const char* toolNumber); + void showError(const char* msg); + + void setScreenTimeoutMs(uint32_t timeoutMs); + +private: + static void taskFunc(void* param); + void taskLoop(); + void processQueue(); + + // --- Rendering --- + void renderBoot(const char* version); + void renderReady(); + void renderSpoolScanned(const TFTSpoolData& spool); + void renderStatus(const char* line1, const char* line2 = nullptr); + void renderWriteResult(bool success, const char* tagFormat); + void renderKeypadEntry(const char* toolNumber); + + // --- Drawing helpers --- + void drawSpool(int cx, int cy, int outerR, int innerR, uint32_t fillColor); + void drawWeightBar(int x, int y, int w, int h, float remaining, float total); + void drawTagIcon(uint8_t tagType, int x, int y); + uint32_t hexToRgb(const char* hex); + uint32_t dimColor(uint32_t color, uint8_t brightness); // for low-spool breathing + + LGFX _tft; + LGFX_Sprite _sprite; // full-screen sprite for flicker-free rendering + + QueueHandle_t _messageQueue; + TaskHandle_t _taskHandle; + + uint32_t _screenTimeoutMs; + unsigned long _lastActivityMs; + bool _screenOff; + + portMUX_TYPE _stateMux; + + // Breathing animation state (low spool) + uint8_t _breathBrightness; + int8_t _breathDirection; + unsigned long _lastBreathMs; + bool _isBreathing; + uint32_t _breathColor; + + static constexpr uint32_t DEFAULT_SCREEN_TIMEOUT_MS = 30000; + static constexpr uint32_t BREATH_STEP_MS = 20; +}; + +#endif // ENABLE_TFT diff --git a/src/main.cpp b/src/main.cpp index 8da59a8..d506a88 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -9,6 +9,9 @@ #include "SpoolmanManager.h" #include "HomeAssistantManager.h" #include "LCDManager.h" +#ifdef ENABLE_TFT +#include "TFTManager.h" +#endif #include "LEDManager.h" #include "WebServerManager.h" #include "PrinterManager.h" @@ -32,6 +35,11 @@ static PrusaLinkStrategy prusaLinkStrategy; // Always declared; only initialized if isLcdEnabled() at runtime LCDManager lcdManager(0x27, 16, 2); +#ifdef ENABLE_TFT +// TFT display — replaces LCD when enabled +TFTManager tftManager; +#endif + // Always declared; only initialized if isLedEnabled() at runtime LEDManager ledManager; @@ -55,9 +63,13 @@ void startAPMode() { Serial.printf("AP started: %s @ 192.168.4.1\n", g_apSSID); auto& config = ConfigurationManager::getInstance(); +#ifdef ENABLE_TFT + tftManager.showError(g_apSSID); +#else if (config.isLcdEnabled()) { lcdManager.updateScreen(g_apSSID, "Go to 192.168.4.1"); } +#endif if (config.isLedEnabled()) { ledManager.showWifiFailed(); // yellow/warning state } @@ -75,9 +87,13 @@ void initWiFi() { Serial.print("Connecting to WiFi: "); Serial.println(config.getWiFiSSID()); +#ifdef ENABLE_TFT + tftManager.showWifiConnecting(); +#else if (config.isLcdEnabled()) { lcdManager.updateScreen("Connecting WiFi", ""); } +#endif WiFi.begin(config.getWiFiSSID(), config.getWiFiPassword()); @@ -93,9 +109,13 @@ void initWiFi() { Serial.print("WiFi connected! IP: "); Serial.println(WiFi.localIP()); +#ifdef ENABLE_TFT + tftManager.showWifiConnected(WiFi.localIP().toString().c_str()); +#else if (config.isLcdEnabled()) { lcdManager.updateScreen("WiFi OK", WiFi.localIP().toString().c_str()); } +#endif if (config.isLedEnabled()) { ledManager.showWifiConnected(); @@ -106,9 +126,11 @@ void initWiFi() { struct tm timeinfo; if (!getLocalTime(&timeinfo)) { Serial.println("Failed to obtain time"); +#ifndef ENABLE_TFT if (config.isLcdEnabled()) { lcdManager.updateScreen("NTP FAILED", ""); } +#endif } else { Serial.println("Time obtained"); } @@ -141,6 +163,13 @@ void setup() { ledManager.showBooting(); } +#ifdef ENABLE_TFT + // TFT display — replaces LCD + tftManager.begin(); + tftManager.startTask(); + tftManager.showBoot(FIRMWARE_VERSION); + Serial.println("TFT initialized"); +#else if (config.isLcdEnabled()) { // Initialize I2C with custom pins for LCD Wire.begin(PIN_LCD_SDA, PIN_LCD_SCL); @@ -153,6 +182,7 @@ void setup() { lcdManager.setScreenTimeoutMs(config.getLcdTimeoutMs()); } +#endif if (config.isKeypadEnabled()) { InputManager::getInstance().begin(); From f6b46f3c1009d3f5bc03e84f305e279d913b1315 Mon Sep 17 00:00:00 2001 From: Shane Jordan Date: Sun, 29 Mar 2026 15:43:36 -0500 Subject: [PATCH 02/12] feat: TFT display as runtime NVS option (like LCD, LED, keypad) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove ENABLE_TFT compile-time guards — LovyanGFX always compiled in. TFT enabled via tft_on NVS key and web config toggle. Mutually exclusive with LCD at runtime (shared GPIO 22/23 on WROOM). 8-bit color depth sprite (57.6KB) instead of 16-bit (115KB) to preserve heap headroom. Both esp32dev and esp32s3zero compile clean. --- include/BoardPins.h | 8 ++++++ platformio.ini | 6 ----- src/ConfigHTML.h | 9 +++++++ src/ConfigurationManager.cpp | 11 +++++++++ src/ConfigurationManager.h | 3 +++ src/TFTConfig.h | 8 ------ src/TFTManager.cpp | 4 --- src/TFTManager.h | 4 --- src/WebServerManager.cpp | 2 ++ src/main.cpp | 48 +++++++++++++----------------------- 10 files changed, 50 insertions(+), 53 deletions(-) diff --git a/include/BoardPins.h b/include/BoardPins.h index cd7f144..8294637 100644 --- a/include/BoardPins.h +++ b/include/BoardPins.h @@ -38,6 +38,14 @@ #define PIN_KEYPAD_COL1 17 #define PIN_KEYPAD_COL2 18 #define PIN_KEYPAD_COL3 42 + // TFT SPI display (ENABLE_TFT — S3 pins, not yet validated on hardware) + #define PIN_TFT_MOSI 13 + #define PIN_TFT_SCLK 15 + #define PIN_TFT_MISO -1 + #define PIN_TFT_CS 16 + #define PIN_TFT_DC 3 + #define PIN_TFT_RST -1 + #define PIN_TFT_BL -1 #else // --- ESP32-WROOM-32 pin mapping (default) --- // PN5180 SPI diff --git a/platformio.ini b/platformio.ini index 1697d1a..21df388 100644 --- a/platformio.ini +++ b/platformio.ini @@ -31,12 +31,6 @@ lib_deps = [env:esp32dev] board = esp32dev -[env:esp32dev_tft] -board = esp32dev -build_flags = - ${env.build_flags} - -DENABLE_TFT=1 - [env:esp32s3zero] board = esp32-s3-devkitc-1 board_build.cdc_on_boot = 1 diff --git a/src/ConfigHTML.h b/src/ConfigHTML.h index a88975a..25a389f 100644 --- a/src/ConfigHTML.h +++ b/src/ConfigHTML.h @@ -192,6 +192,13 @@ const char CONFIG_HTML[] PROGMEM = R"rawliteral( +
+ TFT Display (ST7789 240x240) + +
NFC Reader + +
- - -
- -
- - -
- -
- - + +
@@ -213,13 +203,25 @@ const char UID_REGISTRATION_HTML[] PROGMEM = R"rawliteral( // Auto-fill temps and density from material selection var nfcFieldMap = { - minPrintTemp: 'min_print_temp', maxPrintTemp: 'max_print_temp', - minBedTemp: 'min_bed_temp', maxBedTemp: 'max_bed_temp', density: 'density' }; - trackAutoFill(['min_print_temp','max_print_temp','min_bed_temp','max_bed_temp','density']); + trackAutoFill(['extruder_temp','bed_temp','density']); materialTypeEl.addEventListener('input', function() { autoFillMaterialData(materialTypeEl.value, nfcFieldMap); + // Average min/max temps into single fields + var m = lookupMaterial(materialTypeEl.value); + if (m) { + var et = document.getElementById('extruder_temp'); + if (et && et.dataset.autoFilled !== 'false' && m.minPrintTemp && m.maxPrintTemp) { + et.value = Math.round((m.minPrintTemp + m.maxPrintTemp) / 2); + et.dataset.autoFilled = 'true'; + } + var bt = document.getElementById('bed_temp'); + if (bt && bt.dataset.autoFilled !== 'false' && m.minBedTemp && m.maxBedTemp) { + bt.value = Math.round((m.minBedTemp + m.maxBedTemp) / 2); + bt.dataset.autoFilled = 'true'; + } + } }); loadMaterialDb().then(function(db) { var dl = document.getElementById('material-list'); @@ -352,10 +354,8 @@ const char UID_REGISTRATION_HTML[] PROGMEM = R"rawliteral( remaining_g: remainingWeight, density: readPositiveNumber('density') || 0, diameter_mm: readPositiveNumber('diameter_mm') || 1.75, - min_print_temp: readPositiveNumber('min_print_temp') || 0, - max_print_temp: readPositiveNumber('max_print_temp') || 0, - min_bed_temp: readPositiveNumber('min_bed_temp') || 0, - max_bed_temp: readPositiveNumber('max_bed_temp') || 0 + extruder_temp: readPositiveNumber('extruder_temp') || 0, + bed_temp: readPositiveNumber('bed_temp') || 0 }; setResult('Registering in Spoolman\u2026', ''); diff --git a/src/WebServerManager.cpp b/src/WebServerManager.cpp index 9f88a91..0518cd6 100644 --- a/src/WebServerManager.cpp +++ b/src/WebServerManager.cpp @@ -248,10 +248,8 @@ void WebServerManager::handleApiRegisterUid() { float remainingWeight = doc["remaining_g"] | 0.0f; float density = doc["density"] | 0.0f; float diameter = doc["diameter_mm"] | 1.75f; - int minPrintTemp = doc["min_print_temp"] | 0; - int maxPrintTemp = doc["max_print_temp"] | 0; - int minBedTemp = doc["min_bed_temp"] | 0; - int maxBedTemp = doc["max_bed_temp"] | 0; + int extruderTemp = doc["extruder_temp"] | 0; + int bedTemp = doc["bed_temp"] | 0; if (strlen(uid) == 0) { sendError(400, "UID is required"); @@ -323,6 +321,8 @@ void WebServerManager::handleApiRegisterUid() { filBody["density"] = density > 0 ? density : 1.24f; filBody["diameter"] = diameter > 0 ? diameter : 1.75f; if (strlen(color) > 0) filBody["color_hex"] = color; + if (extruderTemp > 0) filBody["settings_extruder_temp"] = extruderTemp; + if (bedTemp > 0) filBody["settings_bed_temp"] = bedTemp; String filJson; serializeJson(filBody, filJson); From e78bc3048f4fb1247082103916ef04b6b4173535 Mon Sep 17 00:00:00 2001 From: Shane Jordan Date: Sun, 29 Mar 2026 19:08:51 -0500 Subject: [PATCH 10/12] fix: NFC+ weight bar + temp registration on TFT - Pass initial_weight_g through SpoolmanSyncedPayload so NFC+ tags show the weight bar on TFT (was always 0, hiding the bar) - NFC+ registration: single extruder/bed temp fields (averaged from material DB min/max), written to Spoolman filament settings --- src/ApplicationManager.cpp | 2 +- src/ApplicationManager.h | 1 + src/SpoolmanManager.cpp | 1 + 3 files changed, 3 insertions(+), 1 deletion(-) diff --git a/src/ApplicationManager.cpp b/src/ApplicationManager.cpp index 27d209a..4c846b2 100644 --- a/src/ApplicationManager.cpp +++ b/src/ApplicationManager.cpp @@ -692,7 +692,7 @@ void ApplicationManager::handleSpoolmanSynced(const AppMessage& msg) { if (colorSrc[0] == '#') colorSrc++; strncpy(spool.colorHex, colorSrc, sizeof(spool.colorHex) - 1); spool.remainingWeight = kgRemaining * 1000.0f; - spool.totalWeight = 0; + spool.totalWeight = msg.payload.spoolmanSynced.initial_weight_g; spool.tagType = 5; display_->showSpool(spool); } else if (msg.payload.spoolmanSynced.success) { diff --git a/src/ApplicationManager.h b/src/ApplicationManager.h index ede8b57..9f1ddf5 100644 --- a/src/ApplicationManager.h +++ b/src/ApplicationManager.h @@ -82,6 +82,7 @@ struct SpoolmanSyncedPayload { char spool_id[17]; bool success; float kg_remaining; // Remaining weight for LCD display + float initial_weight_g; // Initial spool weight (for weight bar display) int32_t spoolman_id; // Resolved Spoolman spool ID (-1 if unknown) char material_name[32]; // Carried from sync request — avoids re-reading NFC state char manufacturer[64]; // Vendor name (populated for UID lookups) diff --git a/src/SpoolmanManager.cpp b/src/SpoolmanManager.cpp index 7f49e04..43f5deb 100644 --- a/src/SpoolmanManager.cpp +++ b/src/SpoolmanManager.cpp @@ -1128,6 +1128,7 @@ void SpoolmanManager::taskLoop() { msg.payload.spoolmanSynced.success = found; msg.payload.spoolmanSynced.spoolman_id = found ? details.spoolman_id : -1; msg.payload.spoolmanSynced.kg_remaining = found ? details.remaining_weight_g / 1000.0f : 0.0f; + msg.payload.spoolmanSynced.initial_weight_g = found ? details.initial_weight_g : 0.0f; strncpy(msg.payload.spoolmanSynced.material_name, found ? details.material_type : "", sizeof(msg.payload.spoolmanSynced.material_name) - 1); From 847722792e91f9c65c3a64625500856896fc1d69 Mon Sep 17 00:00:00 2001 From: Shane Jordan Date: Sun, 29 Mar 2026 19:20:33 -0500 Subject: [PATCH 11/12] =?UTF-8?q?Remove=20CLAUDE.md=20from=20tracking=20?= =?UTF-8?q?=E2=80=94=20should=20stay=20gitignored?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CLAUDE.md | 167 ------------------------------------------------------ 1 file changed, 167 deletions(-) delete mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index af7bbf8..0000000 --- a/CLAUDE.md +++ /dev/null @@ -1,167 +0,0 @@ -# Overview: -ESP32 / ESP32-S3 Arduino project built with PlatformIO. -Primary current hardware target is ESP32 DevKit / WROOM (typically 4MB flash). -PN5180 NFC scanner firmware with OpenPrintTag support today and planned generic NFC tag support. The firmware is intended to function as a standalone NFC scanner for spool management systems such as SpoolSense rather than direct printer integrations. -16x2 I2C LCD support is optional. -All config is compile-time via UserConfig.h. OTA firmware updates via web UI. - -# Guidelines: -Be concise in responses -Run compile checks before handing off: `pio run -e esp32s3zero` (primary target) and `pio run -e esp32dev` (secondary). Do NOT flash — only compile. -Consider thread safety for all changes -Add new files to source inventory (one-line) -Run tests after changes: ./scripts/run_all_tests.sh -Avoid adding more heap allocations - the device is low on memory. -Use StaticJsonDocument document, even though it is deprecated. -Use LSP plugins when searching for C/C++ and TypeScript identifiers. -All user-editable configuration must live in include/UserConfig.h. -Do not add hardcoded credentials or environment-specific settings anywhere else in the codebase. - -# Project goals: -- Rename project to `spoolsense_scanner` and remove legacy `openprinttag_scanner` naming throughout the codebase, docs, BLE strings, and UI. -- Keep OpenPrintTag support as the first fully supported tag format. -- Add support for standard NFC tags such as NTAG215 for simple UID-based Spoolman / SpoolSense workflows. These tags are not OpenPrintTag and should be handled as a separate path. -- Keep LCD support optional as a first-class build/profile feature. -- Add and stabilize ESP32-S3 support. -- Long-term stretch goal: support OpenTag3D as an additional tag format later. - -# Architecture summary: -main.cpp: Initializes all managers, starts FreeRTOS tasks -ApplicationManager: Central state machine + message bus, receives events (print start, spool scan, etc.) via queue and coordinates responses. -NFC Stack: NFCManager -> Hardware NFC adapter (PN5180 today) -> tag protocol handler -> openprinttag_lib (for OpenPrintTag) or generic tag handler -Spool Sync: ApplicationManager triggers sync -> SpoolmanManager queues request -> SpoolmanManager task -> HTTP requests to SpoolSense / Spoolman style APIs. -Configuration: Compile-time via UserConfig.h -> DeviceConfig -> ConfigurationManager. BLE (BluetoothManager) handles only spool tag operations at runtime. Long-term direction is a broader multi-format scanner architecture for SpoolSense. - -# Source Inventory -OpenPrintTag Library -lib/openprinttag/cbor.h / cbor_native.c — Minimal CBOR implementation used by the OpenPrintTag encoder/decoder and native tests -lib/openprinttag/openprinttag_lib.c / .h — Encode/decode filament data (CBOR, NDEF) -lib/openprinttag/openprinttag_pn532.h — Example HAL adapter for PN532-style 4‑byte page NFC implementations (pn532-esp-idf) -lib/openprinttag/openprinttag_adafruit_pn532.h — HAL adapter for Adafruit_PN532 Arduino library - -PN5180 Driver -lib/PN5180/Debug.cpp / .h — Hex/debug helpers -lib/PN5180/PN5180.cpp / .h — Core driver, SPI + register control -lib/PN5180/PN5180ISO15693.cpp / .h — ISO15693 protocol implementation -lib/PN5180/PN5180ISO14443.cpp / .h — ISO14443A detection (Type A activate + anticollision, NTAG215 UID) — Copyright 2019 Dirk Carstensen, LGPL-2.1 - -Board / Config -include/BoardPins.h — Board-conditional pin definitions (#define), auto-selected via BOARD_ESP32_S3 from UserConfig.h - -Application Core -src/main.cpp — Entry point, task startup -src/ApplicationManager.cpp / .h — Central state machine + event queue -src/ConfigurationManager.cpp / .h — Device config (loaded from UserConfig.h at boot via DeviceConfig) -src/DeviceConfig.cpp / .h — Compile-time config struct populated from UserConfig.h defines - -NFC -src/NFCManager.cpp / .h — NFC scan/read/write task and primary tag detection/handling entry point -src/HardwareNFCConnection.cpp / .h — PN5180 hardware adapter (ISO15693 + ISO14443A page read/write) -src/HardwareNFCConnectionPN532.cpp / .h — PN532 hardware adapter (ISO14443A only, Adafruit_PN532) -src/NFCConnectionI.h — NFC hardware interface -src/NFCTypes.h — Detected spool state structs (TagKind: OpenPrintTag, GenericUidTag, OpenTag3D, TigerTag, BlankTag) -src/NFCWriteTypes.h — Write queue types/enums (includes WRITE_TIGERTAG with 40-byte payload) -src/TigerTagParser.cpp / .h — TigerTag NTAG213 binary parser with embedded material/brand lookup tables - - -Spool Sync -src/SpoolmanManager.cpp / .h — Spoolman API sync + queue worker - -Home Assistant -src/HomeAssistantManager.cpp / .h — MQTT client task, publish/subscribe, HA discovery - -UI / UX -src/DisplayI.h — Display interface (showText, showSpool, showKeypad, showWriteResult) — implemented by LCDManager and TFTManager -src/LCDManager.cpp / .h — I2C LCD task + status updates, implements DisplayI -src/LCDDisplayLogic.h — Shared LCD message merge/timing rules -src/TFTManager.cpp / .h — ST7789 240x240 TFT display via LovyanGFX, implements DisplayI, 8-bit color sprite rendering -src/TFTConfig.h — LovyanGFX hardware config per board (SPI bus, pins, panel settings) -src/WebServerManager.cpp / .h — HTTP server (port 80, mDNS spoolsense.local); multi-page UI + API endpoints + OTA upload -src/LandingHTML.h — Landing page PROGMEM served at GET / -src/ReaderHTML.h — Tag reader page PROGMEM served at GET /reader -src/TagWriterHTML.h — OpenPrintTag writer page PROGMEM served at GET /writer/openprinttag -src/TigerTagWriterHTML.h — TigerTag writer page PROGMEM served at GET /writer/tigertag -src/UpdateHTML.h — Firmware update page PROGMEM served at GET /update (auto-check GitHub + manual upload) -src/ConfigHTML.h — Device configuration page PROGMEM served at GET /config (WiFi, MQTT, Spoolman, hardware) -src/SharedCSS.h — Shared CSS PROGMEM served at GET /css/shared.css -src/SharedJS.h — Shared JS PROGMEM served at GET /js/shared.js -src/OpenPrintTagLogo.h — OpenPrintTag logo PNG served at GET /img/openprinttag.png -src/TigerTagLogo.h — TigerTag logo PNG served at GET /img/tigertag.png -docs/writer-ui-plan.md — Tag writer UI redesign plan - -Utilities -src/ConversionUtils.cpp / .h — Shared data format conversion utilities (material types, colors, density defaults) -src/InputManager.cpp / .h — Optional 3x4 matrix keypad driver; polls for key presses and enqueues KEYPAD_DIGIT/CONFIRM/CANCEL messages (compiled only when ENABLE_KEYPAD=1) - -Tests -OpenPrintTag -test/test_openprinttag.c — CBOR + NDEF unit tests (mock HAL) - -Native Fakes / Stubs -test/native/FakeLCDManager.h — In-memory LCD -test/native/StubApplicationManager.h — Message capture stub -test/native/StubNFCConnection.h — Simulated NFC tags -test/native/NativePlatform.cpp — Stub Serial - -Native Tests -test/native/test_app_flow.cpp — App state transitions -test/native/test_lcd_manager.cpp — LCD message merge timing behavior -test/native/test_nfc_read.cpp — NFC read behavior -test/native/test_raw_write.cpp — Raw binary write to NFC tag -test/native/TestableApplicationManager.h — Queue bypass harness -test/native/TestNFCManager.h — Write queue tracker -test/native/test_helpers.h — Factories + assertions - -Integration Tests -test/integration/ha.cpp — Native standalone MQTT/HA connectivity + discovery/state publisher -test/integration/Makefile — Build/run helper for local HA integration probe - -Integration / HIL Test Harness -test/integration/http_server.py — Test orchestrator + mock spool management APIs + SSE server -test/integration/mock_spoolman.py — Mock Spoolman API state controller -test/integration/scenarios/base.py — BaseTestScenario with BLE bridge helpers -test/integration/scenarios/test_format_spool.py — Format spool test -test/integration/scenarios/test_set_filament.py — Set filament weight test -test/integration/scenarios/test_set_filament_profile.py — Set filament type/manufacturer test -test/integration/scenarios/test_print_e2e.py — End-to-end print simulation test -test/integration/scenarios/test_print_30_percent.py — Canceled print at 30% integration test -test/integration/scenarios/test_print_100x.py — 100x print endurance test (excluded from run-all) -test/integration/scenarios/test_recent_spools.py — Swap spool A/B and verify recently seen spool history -test/integration/scenarios/test_spoolman_sync.py — Spoolman sync verification test -test/integration/scenarios/test_color_update.py — Color field update and verification test -test/integration/scenarios/test_spool_swap_during_print.py — Mid-print spool swap edge case test -test/integration/scenarios/test_zero_weight_handling.py — Zero weight boundary and clamping test -test/integration/scenarios/test_printer_api_errors.py — PrusaLink API error resilience test -test/integration/scenarios/test_print_progress_edge_cases.py — Print progress edge cases (0%, 100%, dwell) -test/integration/scenarios/test_automation_mode_controlled.py — HA-controlled mode (no auto-deduction) test -test/integration/scenarios/test_job_disappeared_deduction.py — Job disappeared (204) bgcode fallback deduction test -test/integration/scenarios/test_real_tag.py — Raw binary write from fixture and 100g deduction verification -test/integration/scenarios/test_write_spoolman_spool.py — Write Spoolman spool test (Mode A API fetch, Mode B direct data) -test/integration/site/index.html — Web Bluetooth test runner UI -test/integration/requirements.txt — Python dependencies for integration tests (paho-mqtt) -test/integration/mqtt_config.json — MQTT broker configuration for event-driven test waits - -# Notes for future direction -- Treat OpenPrintTag, generic UID tags, and future formats such as OpenTag3D as separate handler paths. -- Prefer protocol/tag detection first, then route to the correct parser/handler. -- Reuse `lib/openprinttag/*` as the OpenPrintTag engine, while evolving the surrounding scanner firmware under the `spoolsense_scanner` identity. - -- Direct printer integrations (PrusaLink, OctoPrint) are intentionally out of scope for spoolsense_scanner and should not be expanded further in this project. - -# gstack -Use the /browse skill from gstack for all web browsing. Never use mcp__claude-in-chrome__* tools. - -Available skills: -- /plan-ceo-review -- /plan-eng-review -- /plan-design-review -- /design-consultation -- /review -- /ship -- /browse -- /qa -- /qa-only -- /qa-design-review -- /setup-browser-cookies -- /retro -- /document-release From 6c9a7021ced691721c857e921633060d218c9abf Mon Sep 17 00:00:00 2001 From: Shane Jordan Date: Sun, 29 Mar 2026 19:21:25 -0500 Subject: [PATCH 12/12] =?UTF-8?q?fix:=20CodeRabbit=20findings=20=E2=80=94?= =?UTF-8?q?=20TFT=20error=20handling,=20LCD=20mutual=20exclusion,=20pin=20?= =?UTF-8?q?comment?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - TFTManager::begin() checks createSprite and xQueueCreate return values - Auto-disable LCD when TFT enabled in config POST handler (shared GPIO 22/23) - Fix comment: pins 22/23, not 18/23 --- src/TFTConfig.h | 2 +- src/TFTManager.cpp | 7 ++++++- src/WebServerManager.cpp | 4 ++++ 3 files changed, 11 insertions(+), 2 deletions(-) diff --git a/src/TFTConfig.h b/src/TFTConfig.h index 8491048..634c113 100644 --- a/src/TFTConfig.h +++ b/src/TFTConfig.h @@ -11,7 +11,7 @@ // type and adjust _width/_height below. Everything else stays the same. // // SPI bus used: -// WROOM — VSPI (bus 3): pins 18/23 freed from LCD when ENABLE_TFT replaces LCD +// WROOM — VSPI (bus 3): pins 22/23 freed from LCD when TFT replaces LCD // S3-Zero — SPI2 (FSPI): pins 13/15/16 (PN5180 is on separate SPI instance) // --------------------------------------------------------------------------- diff --git a/src/TFTManager.cpp b/src/TFTManager.cpp index c13ab87..181addb 100644 --- a/src/TFTManager.cpp +++ b/src/TFTManager.cpp @@ -132,9 +132,14 @@ void TFTManager::begin() { _tft.fillScreen(COLOR_BG); _sprite.setColorDepth(8); // 8-bit = 57.6KB vs 115KB at 16-bit - _sprite.createSprite(_tft.width(), _tft.height()); + if (!_sprite.createSprite(_tft.width(), _tft.height())) { + Serial.println("TFTManager: WARNING — sprite allocation failed (low heap)"); + } _messageQueue = xQueueCreate(4, sizeof(TFTMessage)); + if (_messageQueue == nullptr) { + Serial.println("TFTManager: WARNING — queue creation failed"); + } _lastActivityMs = millis(); } diff --git a/src/WebServerManager.cpp b/src/WebServerManager.cpp index 0518cd6..57db953 100644 --- a/src/WebServerManager.cpp +++ b/src/WebServerManager.cpp @@ -556,6 +556,10 @@ void WebServerManager::handleApiPostConfig() { update.led_enabled = doc["led_enabled"] | (uint8_t)0; update.keypad_enabled = doc["keypad_enabled"] | (uint8_t)0; update.tft_enabled = doc["tft_enabled"] | (uint8_t)0; + // TFT and LCD share GPIO 22/23 on WROOM — auto-disable LCD when TFT enabled + if (update.tft_enabled && update.lcd_enabled) { + update.lcd_enabled = 0; + } strncpy(update.moonraker_url, doc["moonraker_url"] | "", sizeof(update.moonraker_url) - 1); update.prusalink_on = doc["prusalink_on"] | (uint8_t)0; strncpy(update.prusalink_url, doc["prusalink_url"] | "", sizeof(update.prusalink_url) - 1);