diff --git a/.gitignore b/.gitignore index 873aa97..c6c6a74 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ .vscode/ *.pyc docs/flasher/firmware/ +lib/tdeck_ui/Hardware/TDeck/SplashImage.h diff --git a/README.md b/README.md index 8f0bb68..fadb29d 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,7 @@ +

+ Pyxis logo +

+ # Pyxis An LXMF and LXST client firmware for T-Deck, built on a [highly modified fork](https://github.com/torlando-tech/microReticulum/tree/feat/t-deck) of [microReticulum](https://github.com/attermann/microReticulum) diff --git a/generate_splash.py b/generate_splash.py new file mode 100644 index 0000000..f9899cd --- /dev/null +++ b/generate_splash.py @@ -0,0 +1,101 @@ +""" +PlatformIO pre-build script: Convert pyxis-icon.svg to RGB565 PROGMEM header. + +Generates lib/tdeck_ui/Hardware/TDeck/SplashImage.h containing a 320x240 +pixel splash image as a PROGMEM byte array. Skips regeneration if the +header is already newer than the source SVG. + +Requires: cairosvg, Pillow (gracefully skips if not installed) +""" + +Import("env") + +import os +import struct + +SPLASH_WIDTH = 320 +SPLASH_HEIGHT = 240 +SVG_FILE = "pyxis-icon.svg" +HEADER_FILE = os.path.join("lib", "tdeck_ui", "Hardware", "TDeck", "SplashImage.h") +BG_COLOR = (0x1D, 0x1A, 0x1E) # #1D1A1E — matches fill_screen in show_splash() + + +def rgb888_to_rgb565(r, g, b): + """Convert 8-bit RGB to 16-bit RGB565 (big-endian bytes).""" + rgb565 = ((r & 0xF8) << 8) | ((g & 0xFC) << 3) | (b >> 3) + return struct.pack(">H", rgb565) + + +def should_regenerate(svg_path, header_path): + """Return True if the header needs regeneration.""" + if not os.path.exists(header_path): + return True + return os.path.getmtime(svg_path) > os.path.getmtime(header_path) + + +project_dir = env.get("PROJECT_DIR", os.getcwd()) +svg_path = os.path.join(project_dir, SVG_FILE) +header_path = os.path.join(project_dir, HEADER_FILE) + +if not os.path.exists(svg_path): + print(f"[generate_splash] {SVG_FILE} not found, skipping splash generation") +elif not should_regenerate(svg_path, header_path): + print(f"[generate_splash] {HEADER_FILE} is up-to-date, skipping") +else: + try: + import cairosvg + from PIL import Image + import io + except ImportError as e: + print(f"[generate_splash] Missing dependency ({e}), skipping splash generation") + print(f"[generate_splash] Install with: pip install cairosvg Pillow") + else: + print(f"[generate_splash] Rendering {SVG_FILE} -> {HEADER_FILE} ({SPLASH_WIDTH}x{SPLASH_HEIGHT} RGB565)") + + # Render SVG to PNG — scale to fit height, center horizontally + png_data = cairosvg.svg2png( + url=svg_path, + output_width=SPLASH_HEIGHT, # Square SVG scaled to screen height + output_height=SPLASH_HEIGHT, + ) + + icon = Image.open(io.BytesIO(png_data)).convert("RGBA") + + # Composite onto full-screen background (handles transparency) + bg = Image.new("RGBA", (SPLASH_WIDTH, SPLASH_HEIGHT), BG_COLOR + (255,)) + x_offset = (SPLASH_WIDTH - icon.width) // 2 + bg.paste(icon, (x_offset, 0), icon) + img = bg.convert("RGB") + + # Convert to RGB565 big-endian byte array + pixels = list(img.getdata()) + rgb565_bytes = bytearray() + for r, g, b in pixels: + rgb565_bytes.extend(rgb888_to_rgb565(r, g, b)) + + # Write C header + os.makedirs(os.path.dirname(header_path), exist_ok=True) + + with open(header_path, "w") as f: + f.write("// AUTO-GENERATED by generate_splash.py — do not edit\n") + f.write("#ifndef SPLASH_IMAGE_H\n") + f.write("#define SPLASH_IMAGE_H\n\n") + f.write("#include \n\n") + f.write(f"#define SPLASH_WIDTH {SPLASH_WIDTH}\n") + f.write(f"#define SPLASH_HEIGHT {SPLASH_HEIGHT}\n") + f.write(f"#define HAS_SPLASH_IMAGE 1\n\n") + f.write(f"// {SPLASH_WIDTH}x{SPLASH_HEIGHT} RGB565 big-endian ({len(rgb565_bytes)} bytes)\n") + f.write("// WARNING: static linkage — include from exactly ONE translation unit\n") + f.write("static const uint8_t PROGMEM splash_image[] = {\n") + + # Write bytes, 16 per line + for i in range(0, len(rgb565_bytes), 16): + chunk = rgb565_bytes[i:i+16] + hex_str = ", ".join(f"0x{b:02X}" for b in chunk) + trailing = "," if i + 16 < len(rgb565_bytes) else "" + f.write(f" {hex_str}{trailing}\n") + + f.write("};\n\n") + f.write("#endif // SPLASH_IMAGE_H\n") + + print(f"[generate_splash] Generated {len(rgb565_bytes)} bytes ({SPLASH_WIDTH}x{SPLASH_HEIGHT} RGB565)") diff --git a/lib/tdeck_ui/Hardware/TDeck/Display.cpp b/lib/tdeck_ui/Hardware/TDeck/Display.cpp index 369bd07..8c463b7 100644 --- a/lib/tdeck_ui/Hardware/TDeck/Display.cpp +++ b/lib/tdeck_ui/Hardware/TDeck/Display.cpp @@ -9,6 +9,10 @@ #include "Log.h" #include +#if __has_include("SplashImage.h") +#include "SplashImage.h" +#endif + using namespace RNS; namespace Hardware { @@ -18,6 +22,7 @@ SPIClass* Display::_spi = nullptr; SemaphoreHandle_t Display::_spi_mutex = nullptr; uint8_t Display::_brightness = Disp::BACKLIGHT_DEFAULT; bool Display::_initialized = false; +bool Display::_hw_initialized = false; volatile uint32_t Display::_flush_count = 0; volatile uint32_t Display::_last_flush_ms = 0; uint32_t Display::_last_health_log_ms = 0; @@ -71,21 +76,23 @@ bool Display::init() { return false; } + _initialized = true; INFO("Display initialized successfully"); return true; } bool Display::init_hardware_only() { - if (_initialized) { + if (_hw_initialized) { return true; } INFO("Initializing display hardware"); - // Configure backlight PWM + // Configure backlight PWM — keep OFF until splash is rendered + // Use ledcWrite directly so _brightness retains the default value for show_splash() ledcSetup(Disp::BACKLIGHT_CHANNEL, Disp::BACKLIGHT_FREQ, Disp::BACKLIGHT_RESOLUTION); ledcAttachPin(Pin::DISPLAY_BACKLIGHT, Disp::BACKLIGHT_CHANNEL); - set_brightness(_brightness); + ledcWrite(Disp::BACKLIGHT_CHANNEL, 0); // Use global SPI (FSPI) — all peripherals (display, LoRa, SD card) must // share the same SPI peripheral to avoid GPIO matrix pin conflicts. @@ -105,7 +112,7 @@ bool Display::init_hardware_only() { // Initialize ST7789V registers init_registers(); - _initialized = true; + _hw_initialized = true; INFO(" Display hardware ready"); return true; } @@ -152,8 +159,8 @@ void Display::init_registers() { // DELAY RATIONALE: SPI command settling - allow display controller to process command before next delay(10); - // Clear screen to black - fill_screen(0x0000); + // Show splash image (or black screen if SplashImage.h not generated) + show_splash(); INFO(" ST7789V initialized"); } @@ -181,6 +188,41 @@ void Display::set_power(bool on) { if (_spi_mutex) xSemaphoreGive(_spi_mutex); } +void Display::show_splash() { +#ifdef HAS_SPLASH_IMAGE + // Splash image is full-screen (320x240), so offsets are 0 + static const uint16_t X_OFFSET = (Disp::WIDTH - SPLASH_WIDTH) / 2; + static const uint16_t Y_OFFSET = (Disp::HEIGHT - SPLASH_HEIGHT) / 2; + + set_addr_window(X_OFFSET, Y_OFFSET, + X_OFFSET + SPLASH_WIDTH - 1, + Y_OFFSET + SPLASH_HEIGHT - 1); + + begin_write(); + write_command(Command::RAMWR); + + // Stream PROGMEM data in 512-byte chunks to avoid large stack allocation + static const size_t CHUNK_SIZE = 512; + uint8_t buf[CHUNK_SIZE]; + size_t total = SPLASH_WIDTH * SPLASH_HEIGHT * 2; + + for (size_t offset = 0; offset < total; offset += CHUNK_SIZE) { + size_t len = (offset + CHUNK_SIZE <= total) ? CHUNK_SIZE : (total - offset); + memcpy_P(buf, splash_image + offset, len); + write_data(buf, len); + } + + end_write(); + INFO(" Splash image rendered"); +#else + // No splash image — fill with background color (#1D1A1E -> RGB565 0x18C3) + fill_screen(0x18C3); +#endif + + // Backlight on now that screen content is ready + set_brightness(_brightness); +} + void Display::fill_screen(uint16_t color) { if (_spi_mutex && xSemaphoreTake(_spi_mutex, pdMS_TO_TICKS(500)) != pdTRUE) { return; diff --git a/lib/tdeck_ui/Hardware/TDeck/Display.h b/lib/tdeck_ui/Hardware/TDeck/Display.h index 9dd0265..d44c1e7 100644 --- a/lib/tdeck_ui/Hardware/TDeck/Display.h +++ b/lib/tdeck_ui/Hardware/TDeck/Display.h @@ -87,6 +87,8 @@ public: static void lvgl_flush_cb(lv_disp_drv_t* drv, const lv_area_t* area, lv_color_t* color_p); private: + // Show boot splash image, called from init_hardware_only() + static void show_splash(); // SPI commands for ST7789V enum Command : uint8_t { NOP = 0x00, @@ -145,7 +147,8 @@ private: static SPIClass* _spi; static SemaphoreHandle_t _spi_mutex; static uint8_t _brightness; - static bool _initialized; + static bool _initialized; // Full init (hardware + LVGL) + static bool _hw_initialized; // Hardware-only init (SPI + registers) // Display health monitoring static volatile uint32_t _flush_count; diff --git a/platformio.ini b/platformio.ini index bce445e..a7f8778 100644 --- a/platformio.ini +++ b/platformio.ini @@ -1,6 +1,8 @@ ; T-Deck environment using Bluedroid BLE stack (fallback, uses more RAM) [env:tdeck-bluedroid] -extra_scripts = pre:version.py +extra_scripts = + pre:version.py + pre:generate_splash.py platform = espressif32 board = esp32-s3-devkitc-1 framework = arduino @@ -79,6 +81,7 @@ build_flags = [env:tdeck] extra_scripts = pre:version.py + pre:generate_splash.py pre:patch_nimble.py platform = espressif32 board = esp32-s3-devkitc-1 diff --git a/pyxis-icon.svg b/pyxis-icon.svg new file mode 100644 index 0000000..af724b7 --- /dev/null +++ b/pyxis-icon.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + PYXIS + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/main.cpp b/src/main.cpp index 440e27d..6a12bc2 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -638,10 +638,8 @@ void setup_hardware() { Wire.setClock(I2C::FREQUENCY); INFO("I2C initialized"); - // Initialize power - pinMode(Pin::POWER_EN, OUTPUT); - digitalWrite(Pin::POWER_EN, HIGH); - INFO("Power enabled"); + // Note: POWER_EN already set HIGH in setup() before display splash + INFO("Power enabled (early init)"); } void setup_lvgl_and_ui() { @@ -653,6 +651,11 @@ void setup_lvgl_and_ui() { while (1) delay(1000); } + // Match LVGL default screen background to splash color (#1D1A1E) + // so LVGL's first render doesn't flash over the boot splash + lv_obj_set_style_bg_color(lv_scr_act(), lv_color_hex(0x1D1A1E), 0); + lv_obj_set_style_bg_opa(lv_scr_act(), LV_OPA_COVER, 0); + INFO("LVGL initialized"); // Start LVGL on its own FreeRTOS task for responsive UI @@ -1198,6 +1201,17 @@ void setup() { INFO("╚══════════════════════════════════════╝"); INFO(""); + // Enable peripheral power rail before display init. + // Display needs ~120ms after power-on before accepting SPI commands + // (ST7789V power-on reset time). Without this delay, SWRESET is sent + // to an unpowered chip and silently lost. + pinMode(Pin::POWER_EN, OUTPUT); + digitalWrite(Pin::POWER_EN, HIGH); + delay(150); + + // Show boot splash ASAP — before any slow init (GPS, WiFi, SD, Reticulum). + Hardware::TDeck::Display::init_hardware_only(); + // Capture ESP reset reason early (before WiFi) — logged after WiFi init for UDP visibility esp_reset_reason_t _boot_reset_reason = esp_reset_reason();