Replaces the Windows port with a Raspberry Pi / Linux implementation on Raspberry Pi OS "Trixie" (Debian 13, aarch64, Wayland/labwc). The Windows code is removed here but preserved on the Windows-Player branch. Entry point ----------- linux/run_linux.py replaces windows/run_win.py. src/main.py stays platform-neutral; all Pi-specific behaviour is injected from linux/. Five bugs that prevented the port (all measured on real hardware) ---------------------------------------------------------------- 1. Kivy's PyPI wheel bundles an SDL2 built WITHOUT the wayland driver, so no window could be created (Trixie has no X server). linux/fix_kivy_sdl2.sh symlinks the system SDL2 over the bundled filename. 2. SDL2 requires WAYLAND_DISPLAY to be *set* - the socket alone is not enough, unlike wlopm. This broke every systemd/cron/autostart launch. linux_display.ensure_session_environment() detects and exports it. 3. Kivy's Clock resolves callbacks via func.__name__; a patch assigned under a different name crashed the player ~20s after a successful start. 4. The inherited signal_screen_activity() shelled out to tvservice, xdotool and ydotool - none exist on Trixie - and mis-escaped 'wlopm --on \*', so the display blanked after 10 minutes. 5. The launchers ran src/main.py directly, bypassing every platform patch and resolving the data directory one level too high. Web links --------- - --ozone-platform-hint=auto does NOT fall back to Wayland on Chromium 152; it aborts. The platform is now chosen explicitly. - The keyring password prompt is suppressed via the ENVIRONMENT, not the flags: launch_env() strips DBUS_SESSION_BUS_ADDRESS for the child so Chromium cannot reach gnome-keyring-daemon. - Teardown kills the whole process group (needs start_new_session=True); previously it silently fell back to terminate() and orphaned children. Video normalisation ------------------- A 4K video cannot play on a Pi 4: ffpyplayer decodes in software, measured at 0.90x realtime (1080p is 3.03x). Oversized media is downscaled to 1920x1080 at sync time using the hardware h264_v4l2m2m encoder (~31s for an 18s clip), triggered by resolution only so already-playable files are untouched. src/media_state.py owns the shared on-disk contract: a .kiwy-converting marker makes the player skip the item while it is being rebuilt, then the converted file is played instead. If nothing is playable at all (a single-item playlist whose only video is converting), the player loops the intro video rather than leaving a blank screen. Also fixed ---------- - network_monitor: replaced netsh/ifconfig/dhclient with nmcli (Trixie uses NetworkManager; ifconfig and dhclient are not even installed). - Removed the Windows-only focus keeper/guardian from main.py. - main.py: duplicate SDL_AUDIODRIVER setdefault (a silent no-op); Settings "Test connection" now uses tempfile.gettempdir(). - config/app_config.json: credentials blanked so a fresh clone runs the first-run setup flow. Verification ------------ linux/test_media_state.py 18/18, test_linux_patches.py 21/21, test_linux_browser_flags.py 27/27. Verified live against a real DigiServer: image -> weblink -> image -> video with correct durations, zero leaked Chromium processes, and no throttling over a 10 minute monitored run.
11 KiB
description, name
| description | name |
|---|---|
| Use when installing, deploying, running or debugging the Kiwy Signage Player on Raspberry Pi OS Trixie 64-bit: venv setup, Kivy/SDL2 Wayland requirements, the linux/ entry point, autostart, systemd, watchdog supervision, or adding new modules under src/. Covers the exact commands, environment constraints and verification steps. | Kiwy Linux / Raspberry Pi Build & Development |
Kiwy Signage Player — Linux / Raspberry Pi Build & Development
Kivy digital signage player targeting Raspberry Pi OS "Trixie" (Debian 13, aarch64, Wayland/labwc).
- Entry point —
linux/run_linux.py. Notsrc/main.py. - Target hardware — Raspberry Pi 4 / 5. Verified on a Pi 4 Model B Rev 1.4,
kernel
6.18.39+rpt-rpi-v8.
Ground Rules
- Never launch
src/main.pydirectly. It is a shared module, not the Pi entry point. Running it skips every platform patch (session environment, display keep-awake, Chromium adapter) and resolves the data directory one level too high. Always go throughlinux/run_linux.py(bash run_player.sh/bash start.sh). - Keep
src/main.pyplatform-neutral. Pi-specific behaviour belongs inlinux/.src/main.pymay only contain guarded capability checks, never a platform-specific import. - A patched method must be resolvable by its own
__name__. Kivy'sClockwraps callbacks in aWeakMethodkeyed onfunc.__name__and re-resolves it withgetattr(instance, name). Assigning a replacement under a different name than it was defined with raisesAttributeErrorthe first time the Clock fires — minutes after a successful start, with a traceback that points nowhere near the patch. Use_bind_name(), and cover new patches inlinux/test_linux_patches.py. This has already caused two outages. - No
sudoat runtime. The player runs as an unprivileged user. Anything needing root must be set up at install time (sudoers entry, udev rule, systemd unit).
Environment
| Item | Value |
|---|---|
| OS | Raspberry Pi OS Trixie / Debian 13, aarch64 |
| Session | Wayland via rpd-labwc (labwc). No X server. |
| Python | 3.13.5 — project venv at .venv (created --system-site-packages) |
| Kivy | 2.3.1 (PyPI wheel) |
| ffpyplayer | 4.5.3 (video/audio backend) |
| evdev | 2.0.0 — sdist only, built locally (needs python3-dev, build-essential) or apt python3-dev |
| Browser | /usr/bin/chromium (there is no chromium-browser on Trixie) |
The SDL2 trap (the #1 way to break the install)
Kivy's PyPI wheel bundles a private SDL2 without the Wayland driver:
Kivy.libs/libSDL2-2-*.so -> x11, KMSDRM, offscreen, dummy, evdev # no wayland!
/usr/lib/.../libSDL2-2.0.so.0 -> x11, wayland, KMSDRM, offscreen, dummy, evdev
On Trixie there is no X server, so the bundled build cannot create a window:
[CRITICAL] Unable to find any valuable Window provider.
sdl2 - RuntimeError: b'wayland,x11,dummy not available'
linux/fix_kivy_sdl2.sh symlinks the system SDL2 over the bundled
filename. Re-run it after every pip install --upgrade kivy.
linux/test_linux_patches.py asserts the driver is present, so a regression
fails the test suite rather than the player.
SDL2 requires WAYLAND_DISPLAY to be set
The socket existing is not enough — unlike wlopm, SDL2 does not scan
XDG_RUNTIME_DIR. A launch from systemd, cron, an autostart entry or SSH has
the variable empty, and Kivy then fails with wayland not available.
linux_display.ensure_session_environment() detects the socket and exports the
name; run_linux.py calls it before importing Kivy.
Install
Required system packages:
sudo apt install -y \
python3-venv python3-dev build-essential \
libsdl2-2.0-0 libsdl2-image-2.0-0 libsdl2-mixer-2.0-0 libsdl2-ttf-2.0-0 \
libgl1-mesa-dri libgles2 \
chromium wlopm wlr-randr ffmpeg
Python dependencies (all have cp313 aarch64 wheels except evdev):
python3 -m venv --system-site-packages .venv
.venv/bin/pip install kivy ffpyplayer requests bcrypt aiohttp
.venv/bin/pip install evdev # builds from source
bash linux/fix_kivy_sdl2.sh # REQUIRED: system SDL2 with Wayland
Run
bash run_player.sh # single run, no supervision
bash start.sh # watchdog: auto-restart on crash/hang (24/7)
start.sh supervises through a heartbeat file (.player_heartbeat, rewritten
every 10 s; stale after 60 s ⇒ hung ⇒ restart) and honours
.player_stop_requested so a password exit is not resurrected.
Verify Before Committing
.venv/bin/python -m py_compile src/*.py linux/*.py # syntax, seconds
bash -n start.sh run_player.sh stop_player.sh check_player_status.sh
.venv/bin/python linux/test_linux_patches.py # expect: 21/21 passed
.venv/bin/python linux/test_linux_browser_flags.py # expect: 27/27 passed
.venv/bin/python linux/test_media_state.py # expect: 18/18 passed
bash linux/fix_kivy_sdl2.sh --check # expect: STATUS: fixed
.venv/bin/python linux/_probe_video.py # video decodes + advances
.venv/bin/python linux/_probe_chromium_footprint.py https://example.com/ # PSS per profile
Close the running player before editing src/ if you intend to test
immediately — the watchdog will restart it and you will test stale code.
24/7 Supervision
start.sh restarts the player when it:
- crashed — the process disappeared; or
- hung — the process is alive but the heartbeat is stale (>60 s).
Two failure modes must not be confused:
| Situation | Correct reaction |
|---|---|
| Was healthy, then heartbeat went stale | Restart promptly (a hang) |
| Running but never became healthy (slow start, the first-run setup screen) | Be patient — do not kill a player an operator is configuring |
Diagnostics for playback transitions live in logs/playback_trace.log,
written by src/playback_trace.py independently of Kivy's log level.
Runtime pitfalls
~/dev/...cwd matters. The player's data directory is the repo root. Launch from there (all provided scripts do).- The desktop blanker fights the player.
~/.config/labwc/autostartshipsswayidle -w timeout 600 'wlopm --off *', which powers the panel off after 10 minutes.linux_display.neutralise_idle_blanker()stops it at start-up. Do not remove that call. tvservice,xdotool,ydotooldo not exist on Trixie. Any code using them silently does nothing.- Web links need a dedicated
--user-data-dir. Without it Chromium hands the URL to a running instance, our process exits in ~2 s and the item is skipped as a failed launch. - The keyring prompt is suppressed via the ENVIRONMENT, not the flags.
--password-store=basicalone does not stop Chromium asking for the login keyring password: it inheritsDBUS_SESSION_BUS_ADDRESS, reaches the runninggnome-keyring-daemonand prompts.LinuxChromiumAdapter.launch_env()strips the bus for the child process, which is what actually fixes it. Do not removelaunch_env(), and do not add a flag list that nothing references —test_linux_browser_flags.pyfails if a list becomes dead code, because that is exactly how this bug hid before. --ozone-platform-hint=autodoes not work on Chromium 152. It aborts with "Missing X server" instead of falling back to Wayland;--ozone-platform=waylandmust be stated explicitly.- Kill the whole process group on teardown.
proc.terminate()leaves Chromium's GPU/zygote/renderer children behind; they accumulate until the Pi runs out of memory. This needsstart_new_session=Trueat launch. - Measure memory with PSS, never RSS. Chromium shares pages across its
processes, so summing RSS roughly doubles the figure and even ranks smaller
configurations as larger.
linux/_probe_chromium_footprint.pydoes it correctly. --disable-gpuincreases memory for a real page (1038 MB vs 513 MB). It is intentionally not used anywhere.
Video: 4K cannot play, and is converted automatically
A 4K video will not play on a Pi 4. ffpyplayer decodes in software and there is no hardware H.264 decode in its pipeline. Measured: 1080p decodes at 3.03× realtime, 3840×2160 at 0.90× — below 1× the picture simply stops.
The player therefore normalises oversized media to at most 1920×1080:
src/media_state.py— the shared on-disk contract (markers, path resolution, and a dependency-free MP4 header parser so the playback path spawns nothing).linux/video_normalizer.py— the converter (hardwareh264_v4l2m2m, ~31 s for an 18 s 4K clip;libx264fallback).- Triggered automatically from
get_playlists_v2.normalize_oversized_media()after every sync, in both the "updated" and "up-to-date" branches.
| Marker next to the media | Meaning |
|---|---|
<media>.kiwy-converting |
in flight → the player skips the item |
<media>_kiwy1080p.mp4 + .kiwy-normalized.json |
done → the player plays this file |
Do not remove these markers, and do not let delete_unused_media() prune them —
the converted output is deliberately absent from the playlist and would
otherwise be deleted while the player is using it.
The conversion is only triggered by resolution. A file within 1920×1080 is
left byte-identical. When nothing is playable yet (a single-item playlist whose
only video is still converting), the player loops config/resources/intro1.mp4
rather than showing a blank screen.
Run it manually:
.venv/bin/python linux/video_normalizer.py --dry-run media/ # report only
.venv/bin/python linux/video_normalizer.py media/ # convert
.venv/bin/python linux/video_normalizer.py --max-height 720 media/
Prefer normalising on the server before upload: it avoids both the 4K download and the 31 s conversion. The player-side path is a safety net, not the preferred workflow.
Diagnostics
| Command | Purpose |
|---|---|
.venv/bin/python linux/linux_display.py |
Wayland/X11 status, outputs, available tools |
bash linux/fix_kivy_sdl2.sh --check |
Which SDL2 Kivy loads and its drivers |
bash check_player_status.sh |
Is the player running |
bash stop_player.sh |
Stop player + watchdog |
Escape hatches:
| Variable | Effect |
|---|---|
KIWY_DISPLAY_TOOLS_DISABLED=1 |
Disable wlopm/vcgencmd/swayidle work |
KIWY_CHROMIUM_MODE=light |
Default footprint profile (safe reductions) |
KIWY_CHROMIUM_MODE=minimal |
Adds --single-process; ~15% less memory, less stable |
KIWY_CHROMIUM_MODE=safe |
No footprint flags at all (when debugging) |
KIWY_VENV=/path |
Point the SDL2 fix script at another virtualenv |
Commit Hygiene
- Never commit
.venv/,.kivy/,logs/,.kiosk-profile/(git-ignored). - Do not commit
player_auth.json— it holds live credentials (auth_code,player_id,server_url). - Do not commit
config/app_config.jsonvalues that are host-specific (server_ip,screen_name,quickconnect_key). The repo default is blank on purpose so a fresh install runs the first-run setup flow.
Release Checklist
- Bump
PLAYER_VERSIONinsrc/main.py. - Run the verification suite above on the target Pi.
- Confirm
bash linux/fix_kivy_sdl2.sh --checkreportsfixed. - Smoke-test a mixed playlist: image → video → weblink → image, verifying
durations, audio (
audio: off/muted) and a clean exit/restart. - 24/7 soak (≥12 h): check the heartbeat,
pgrep -c chromiumfor leaks and memory growth.