Replace Plymouth with fbi for SpoolBuddy boot splash

Plymouth ran as a persistent daemon throughout boot, consuming memory
  and competing for framebuffer allocation. fbi writes pixels directly
  to the framebuffer and exits — zero ongoing resource cost.

  New splash image shows only the SpoolBuddy logo with baked-in glow,
  radial gradient, light rays, and vignette effects (66KB vs 205KB).
  Install script auto-removes Plymouth on existing installs.
This commit is contained in:
maziggy
2026-03-26 12:03:49 +01:00
parent fa705102b4
commit 4c7141f738
4 changed files with 268 additions and 33 deletions
+1
View File
@@ -25,6 +25,7 @@ All notable changes to Bambuddy will be documented in this file.
- **Removed Diagnostic Buttons from Write Tag Page** — Removed the "NFC Diag" and "Scale Diag" buttons from the NFC status panel on the Write Tag page. These diagnostics are accessible from the Settings page and don't belong on the tag writing flow.
- **SpoolBuddy Assign Spool Modal No Longer Clips Display** — The shared Assign Spool modal overflowed off-screen on the small SpoolBuddy touchscreen, hiding the footer buttons. Added scoped CSS in the SpoolBuddy AMS page that caps the modal at 90vh with a scrollable spool list, without affecting the main Bambuddy frontend.
- **SpoolBuddy System Tab** — Added a "System" tab to SpoolBuddy Settings showing live OS stats from the Raspberry Pi: CPU temperature, core count, load average, memory usage, disk usage, OS distro/kernel/architecture, Python version, and system uptime. Stats are collected by the daemon every heartbeat (10s) using stdlib-only reads from `/proc` and `/sys` — no additional dependencies required. Usage bars turn amber at 70% and red at 90%; CPU temperature is color-coded green/amber/red.
- **SpoolBuddy Boot Splash Overhaul** — Replaced Plymouth with fbi for a lighter, faster boot splash. Plymouth ran as a persistent daemon throughout boot, consuming memory and competing for framebuffer allocation. fbi writes pixels directly to the framebuffer and exits — zero ongoing resource cost. The new splash displays only the SpoolBuddy logo with polished glow effects, radial gradient background, light rays, and vignette (66KB vs 205KB). Plymouth is automatically removed on upgrade. A generator script (`generate_splash.py`) is included for easy customization.
### Fixed
- **SpoolBuddy Read Tag Diagnostic Fails on NTAG Tags** — The `read_tag.py` diagnostic script had five issues preventing NTAG reads: (1) SAK `0x04` (MIFARE Ultralight family) was rejected as "unsupported tag type" — now accepts both `0x00` and `0x04`. (2) `ntag_read_pages` had TX CRC off (should be on per NTAG spec), no Crypto1 clear, and no IDLE→TRANSCEIVE state reset. (3) The PN5180 enters an unrecoverable state after an NTAG READ command — added full GPIO hardware reset between each 4-page batch. (4) Reading past the end of smaller tags (MIFARE Ultralight has 16 pages vs NTAG's 44+) caused a hard failure — now returns partial data gracefully. (5) `ntag_write_page`/`ntag_write_pages` had the same stale CRC/state issues plus unreliable ACK checking and post-write verification — synced with daemon.
+219
View File
@@ -0,0 +1,219 @@
#!/usr/bin/env python3
"""Generate a polished SpoolBuddy boot splash image (1024x600).
Uses the SpoolBuddy logo with baked-in glow, radial gradient background,
subtle light rays, and vignette effects for a premium kiosk boot screen.
Usage:
python3 generate_splash.py [output_path]
Requires: Pillow (pip install Pillow)
"""
import math
import os
import sys
from PIL import Image, ImageDraw, ImageFilter
# --- Configuration ---
WIDTH, HEIGHT = 1024, 600
BG_CENTER = (30, 30, 30) # Slightly lighter center
BG_EDGE = (8, 8, 8) # Near-black edges
ACCENT = (0, 174, 66) # SpoolBuddy green (#00AE42)
ACCENT_GLOW = (0, 200, 75) # Slightly brighter for glow core
LOGO_SCALE = 0.50 # Scale logo to 50% of canvas width
GLOW_RADIUS = 80 # Gaussian blur radius for glow
VIGNETTE_STRENGTH = 0.55 # Edge darkening intensity
RAY_COUNT = 24 # Number of radial light rays
RAY_OPACITY = 12 # Subtle ray brightness (0-255)
def radial_gradient(size, center_color, edge_color):
"""Create a radial gradient from center to edges."""
w, h = size
img = Image.new("RGB", size)
pixels = img.load()
cx, cy = w // 2, h // 2
max_dist = math.sqrt(cx**2 + cy**2)
for y in range(h):
for x in range(w):
dist = math.sqrt((x - cx) ** 2 + (y - cy) ** 2)
t = min(dist / max_dist, 1.0)
# Ease-out curve for smoother falloff
t = t * t
r = int(center_color[0] + (edge_color[0] - center_color[0]) * t)
g = int(center_color[1] + (edge_color[1] - center_color[1]) * t)
b = int(center_color[2] + (edge_color[2] - center_color[2]) * t)
pixels[x, y] = (r, g, b)
return img
def create_light_rays(size, num_rays, opacity):
"""Create subtle radial light rays emanating from center."""
w, h = size
rays = Image.new("RGBA", size, (0, 0, 0, 0))
draw = ImageDraw.Draw(rays)
cx, cy = w // 2, h // 2
max_radius = int(math.sqrt(cx**2 + cy**2)) + 50
for i in range(num_rays):
angle = (2 * math.pi * i) / num_rays
# Vary ray width slightly for organic feel
half_width = math.radians(1.5 + (i % 3) * 0.5)
a1 = angle - half_width
a2 = angle + half_width
points = [
(cx, cy),
(cx + int(max_radius * math.cos(a1)), cy + int(max_radius * math.sin(a1))),
(cx + int(max_radius * math.cos(a2)), cy + int(max_radius * math.sin(a2))),
]
# Green-tinted rays
draw.polygon(points, fill=(ACCENT[0], ACCENT[1], ACCENT[2], opacity))
# Heavy blur to make rays soft and diffuse
rays = rays.filter(ImageFilter.GaussianBlur(radius=30))
return rays
def create_vignette(size, strength):
"""Create a vignette (edge darkening) mask."""
w, h = size
vignette = Image.new("L", size, 255)
pixels = vignette.load()
cx, cy = w / 2, h / 2
max_dist = math.sqrt(cx**2 + cy**2)
for y in range(h):
for x in range(w):
dist = math.sqrt((x - cx) ** 2 + (y - cy) ** 2)
t = dist / max_dist
# Ramp darkening from ~40% radius outward
fade = max(0, (t - 0.4) / 0.6)
fade = fade * fade # Quadratic ease
val = int(255 * (1 - fade * strength))
pixels[x, y] = max(0, val)
return vignette
def create_glow(logo_img, color, radius, intensity=1.5):
"""Create a colored glow effect from a logo's alpha channel."""
# Extract alpha as the glow shape
if logo_img.mode != "RGBA":
return Image.new("RGBA", logo_img.size, (0, 0, 0, 0))
alpha = logo_img.split()[3]
# Create colored version of the alpha shape
glow = Image.new("RGBA", logo_img.size, (0, 0, 0, 0))
glow_pixels = glow.load()
alpha_pixels = alpha.load()
for y in range(logo_img.height):
for x in range(logo_img.width):
a = alpha_pixels[x, y]
if a > 0:
boosted = min(255, int(a * intensity))
glow_pixels[x, y] = (color[0], color[1], color[2], boosted)
# Blur to create the glow spread
glow = glow.filter(ImageFilter.GaussianBlur(radius=radius))
return glow
def generate_splash(output_path):
"""Generate the final splash image."""
print(f"Generating {WIDTH}x{HEIGHT} splash image...")
# 1. Radial gradient background
print(" Creating radial gradient background...")
canvas = radial_gradient((WIDTH, HEIGHT), BG_CENTER, BG_EDGE)
# 2. Light rays
print(" Adding light rays...")
rays = create_light_rays((WIDTH, HEIGHT), RAY_COUNT, RAY_OPACITY)
canvas.paste(
Image.alpha_composite(
Image.new("RGBA", (WIDTH, HEIGHT), (0, 0, 0, 0)),
rays,
),
(0, 0),
rays,
)
# 3. Load and scale logo
print(" Loading SpoolBuddy logo...")
script_dir = os.path.dirname(os.path.abspath(__file__))
logo_paths = [
os.path.join(script_dir, "..", "..", "frontend", "public", "spoolbuddy_logo_dark.png"),
os.path.join(script_dir, "..", "..", "frontend", "public", "img", "spoolbuddy_logo_dark.png"),
]
logo = None
for p in logo_paths:
resolved = os.path.normpath(p)
if os.path.exists(resolved):
logo = Image.open(resolved).convert("RGBA")
print(f" Loaded logo from {resolved}")
break
if logo is None:
print(" ERROR: Could not find spoolbuddy_logo_dark.png")
sys.exit(1)
# Scale logo to target width
target_w = int(WIDTH * LOGO_SCALE)
scale = target_w / logo.width
target_h = int(logo.height * scale)
logo = logo.resize((target_w, target_h), Image.LANCZOS)
# Center position (shift up slightly for visual balance)
logo_x = (WIDTH - target_w) // 2
logo_y = (HEIGHT - target_h) // 2 - 10
# 4. Glow behind logo (two layers: wide diffuse + tight bright)
print(" Rendering glow effects...")
# Wide diffuse glow
glow_wide = create_glow(logo, ACCENT, radius=GLOW_RADIUS, intensity=1.2)
# Expand glow canvas to full size
glow_canvas = Image.new("RGBA", (WIDTH, HEIGHT), (0, 0, 0, 0))
glow_canvas.paste(glow_wide, (logo_x, logo_y), glow_wide)
canvas = Image.alpha_composite(canvas.convert("RGBA"), glow_canvas)
# Tighter brighter glow
glow_tight = create_glow(logo, ACCENT_GLOW, radius=GLOW_RADIUS // 3, intensity=0.8)
glow_canvas2 = Image.new("RGBA", (WIDTH, HEIGHT), (0, 0, 0, 0))
glow_canvas2.paste(glow_tight, (logo_x, logo_y), glow_tight)
canvas = Image.alpha_composite(canvas, glow_canvas2)
# 5. Composite logo on top
print(" Compositing logo...")
canvas.paste(logo, (logo_x, logo_y), logo)
# 6. Apply vignette
print(" Applying vignette...")
vignette = create_vignette((WIDTH, HEIGHT), VIGNETTE_STRENGTH)
canvas_rgb = canvas.convert("RGB")
# Multiply canvas by vignette mask
r, g, b = canvas_rgb.split()
r = Image.composite(r, Image.new("L", (WIDTH, HEIGHT), 0), vignette)
g = Image.composite(g, Image.new("L", (WIDTH, HEIGHT), 0), vignette)
b = Image.composite(b, Image.new("L", (WIDTH, HEIGHT), 0), vignette)
canvas = Image.merge("RGB", (r, g, b))
# 7. Save
canvas.save(output_path, "PNG", optimize=True)
file_size = os.path.getsize(output_path) / 1024
print(f" Saved to {output_path} ({file_size:.0f} KB)")
if __name__ == "__main__":
out = sys.argv[1] if len(sys.argv) > 1 else os.path.join(os.path.dirname(os.path.abspath(__file__)), "splash.png")
generate_splash(out)
+48 -33
View File
@@ -878,7 +878,7 @@ setup_kiosk() {
fi
# ── Install kiosk packages ────────────────────────────────────────────
run_with_progress "Installing kiosk packages" apt-get install -y labwc chromium plymouth wlr-randr
run_with_progress "Installing kiosk packages" apt-get install -y labwc chromium fbi wlr-randr
# ── config.txt tweaks ─────────────────────────────────────────────────
local boot_config="/boot/firmware/config.txt"
@@ -928,11 +928,17 @@ setup_kiosk() {
if [[ -f "$cmdline" ]]; then
info "Configuring $cmdline for kiosk..."
# Remove serial console (Plymouth needs tty-only console)
# Remove serial console (frees tty1 for splash and kiosk)
sed -i 's/console=serial0,[0-9]* //' "$cmdline"
# Add splash quiet loglevel=3 logo.nologo if missing
grep -q "splash" "$cmdline" || sed -i 's/$/ splash quiet loglevel=3 logo.nologo/' "$cmdline"
# Add quiet loglevel=3 logo.nologo if missing (suppress boot text)
grep -q "loglevel=3" "$cmdline" || sed -i 's/$/ quiet loglevel=3 logo.nologo/' "$cmdline"
# Hide kernel text cursor during boot
grep -q "vt.global_cursor_default=0" "$cmdline" || sed -i 's/$/ vt.global_cursor_default=0/' "$cmdline"
# Remove Plymouth "splash" keyword if present (fbi replaces Plymouth)
sed -i 's/ splash / /g; s/^splash //; s/ splash$//' "$cmdline"
# Add video mode if missing
grep -q "video=HDMI-A-1" "$cmdline" || sed -i 's/$/ video=HDMI-A-1:1024x600@60/' "$cmdline"
@@ -940,47 +946,53 @@ setup_kiosk() {
success "Kernel cmdline updated"
fi
# ── Plymouth splash theme ─────────────────────────────────────────────
info "Installing Plymouth boot splash..."
local theme_dir="/usr/share/plymouth/themes/spoolbuddy"
mkdir -p "$theme_dir"
# ── fbi boot splash ──────────────────────────────────────────────────
info "Installing boot splash (fbi)..."
local splash_dir="/usr/share/spoolbuddy"
mkdir -p "$splash_dir"
# Copy bundled splash image from the install directory
local script_dir
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
if [[ -f "$script_dir/splash.png" ]]; then
cp "$script_dir/splash.png" "$theme_dir/splash.png"
cp "$script_dir/splash.png" "$splash_dir/splash.png"
elif [[ -f "$INSTALL_PATH/spoolbuddy/install/splash.png" ]]; then
cp "$INSTALL_PATH/spoolbuddy/install/splash.png" "$theme_dir/splash.png"
cp "$INSTALL_PATH/spoolbuddy/install/splash.png" "$splash_dir/splash.png"
else
warn "splash.png not found — Plymouth splash will not display an image"
warn "splash.png not found — boot splash will not display an image"
fi
# Write .plymouth theme file
cat > "$theme_dir/spoolbuddy.plymouth" << 'EOF'
[Plymouth Theme]
Name=SpoolBuddy
Description=SpoolBuddy boot splash
ModuleName=script
# Remove Plymouth if present (replaced by fbi)
if dpkg -l plymouth &>/dev/null; then
info "Removing Plymouth (replaced by lightweight fbi splash)..."
apt-get remove -y --purge plymouth plymouth-themes 2>/dev/null || true
update-initramfs -u 2>/dev/null || true
fi
[script]
ImageDir=/usr/share/plymouth/themes/spoolbuddy
ScriptFile=/usr/share/plymouth/themes/spoolbuddy/spoolbuddy.script
# Create systemd service that shows splash image on tty1 during boot
cat > /etc/systemd/system/spoolbuddy-splash.service << 'EOF'
[Unit]
Description=SpoolBuddy Boot Splash
DefaultDependencies=no
After=local-fs.target
Before=getty@tty1.service
[Service]
Type=oneshot
RemainAfterExit=yes
ExecStart=/usr/bin/fbi -T 1 -a --noverbose --norandom /usr/share/spoolbuddy/splash.png
ExecStop=/usr/bin/killall -q fbi
StandardInput=tty
StandardOutput=tty
TTYPath=/dev/tty1
[Install]
WantedBy=sysinit.target
EOF
# Write .script theme file
cat > "$theme_dir/spoolbuddy.script" << 'EOF'
wallpaper_image = Image("splash.png");
screen_width = Window.GetWidth();
screen_height = Window.GetHeight();
resized_wallpaper_image = wallpaper_image.Scale(screen_width, screen_height);
wallpaper_sprite = Sprite(resized_wallpaper_image);
wallpaper_sprite.SetZ(-100);
EOF
plymouth-set-default-theme spoolbuddy
run_with_progress "Updating initramfs" update-initramfs -u
success "Plymouth splash installed"
systemctl daemon-reload
systemctl enable spoolbuddy-splash.service
success "Boot splash installed (fbi)"
# ── Auto-login on tty1 ────────────────────────────────────────────────
info "Configuring auto-login for $KIOSK_USER..."
@@ -1085,6 +1097,9 @@ EOF
# ── labwc autostart ───────────────────────────────────────────────────
cat > "$labwc_dir/autostart" << EOF
# Kill fbi boot splash now that the compositor is running
systemctl stop spoolbuddy-splash.service 2>/dev/null || killall -q fbi || true
# Force 1024x600 (panel doesn't advertise this natively)
wlr-randr --output HDMI-A-1 --custom-mode 1024x600@60 &
Binary file not shown.

Before

Width:  |  Height:  |  Size: 201 KiB

After

Width:  |  Height:  |  Size: 66 KiB