Browse Source

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.
maziggy 2 months ago
parent
commit
4c7141f738
4 changed files with 267 additions and 32 deletions
  1. 1 0
      CHANGELOG.md
  2. 219 0
      spoolbuddy/install/generate_splash.py
  3. 47 32
      spoolbuddy/install/install.sh
  4. BIN
      spoolbuddy/install/splash.png

+ 1 - 0
CHANGELOG.md

@@ -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 - 0
spoolbuddy/install/generate_splash.py

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

+ 47 - 32
spoolbuddy/install/install.sh

@@ -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
-EOF
+    # 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
 
-    # 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);
+[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
 
-    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 &
 

BIN
spoolbuddy/install/splash.png