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
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 @@
+
+
\ 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();