Merge pull request #12 from torlando-tech/feature/splash-screen

Add boot splash screen with Pyxis constellation logo
This commit is contained in:
Torlando
2026-03-04 18:38:43 -05:00
committed by GitHub
8 changed files with 204 additions and 12 deletions

1
.gitignore vendored
View File

@@ -2,3 +2,4 @@
.vscode/
*.pyc
docs/flasher/firmware/
lib/tdeck_ui/Hardware/TDeck/SplashImage.h

View File

@@ -1,3 +1,7 @@
<p align="center">
<img src="pyxis-icon.svg" width="200" alt="Pyxis logo">
</p>
# 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)

101
generate_splash.py Normal file
View File

@@ -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 <Arduino.h>\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)")

View File

@@ -9,6 +9,10 @@
#include "Log.h"
#include <esp_heap_caps.h>
#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;

View File

@@ -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;

View File

@@ -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

24
pyxis-icon.svg Normal file
View File

@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="utf-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<defs>
<radialGradient id="starGlow">
<stop offset="0" style="stop-color:#FFFFFF;stop-opacity:0.8"/>
<stop offset="0.5" style="stop-color:#FFFFFF;stop-opacity:0.3"/>
<stop offset="1" style="stop-color:#FFFFFF;stop-opacity:0"/>
</radialGradient>
</defs>
<circle cx="256" cy="256" r="256" fill="#1D1A1E"/>
<text x="256" y="80" text-anchor="middle" font-family="sans-serif" font-size="64" font-weight="bold" letter-spacing="12" fill="#FFFFFF" opacity="0.85">PYXIS</text>
<g transform="translate(256, 276) scale(0.8) translate(-256, -256)">
<g transform="matrix(1, 0, 0, 1, 17.99218, -6.964715)">
<path stroke="#FFFFFF" stroke-width="4.5" fill="none" stroke-linecap="round" opacity="0.6" d="M 245.377 322.844 L 181.759 90.3"/>
<path stroke="#FFFFFF" stroke-width="4.5" fill="none" stroke-linecap="round" opacity="0.6" d="M 273.819 413.359 L 245.377 322.844"/>
<circle cx="181.759" cy="90.3" r="20" fill="url(#starGlow)"/>
<circle cx="181.759" cy="90.3" r="8" fill="#FFFFFF"/>
<circle cx="245.377" cy="322.844" r="26" fill="url(#starGlow)"/>
<circle cx="245.377" cy="322.844" r="10" fill="#FFFFFF"/>
<circle cx="273.819" cy="413.359" r="22" fill="url(#starGlow)"/>
<circle cx="273.819" cy="413.359" r="9" fill="#FFFFFF"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

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