--- description: "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." name: "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`. Not `src/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.py` directly.** 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 through `linux/run_linux.py` (`bash run_player.sh` / `bash start.sh`). - **Keep `src/main.py` platform-neutral.** Pi-specific behaviour belongs in `linux/`. `src/main.py` may only contain *guarded capability checks*, never a platform-specific import. - **A patched method must be resolvable by its own `__name__`.** Kivy's `Clock` wraps callbacks in a `WeakMethod` keyed on `func.__name__` and re-resolves it with `getattr(instance, name)`. Assigning a replacement under a different name than it was defined with raises `AttributeError` **the 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 in `linux/test_linux_patches.py`. This has already caused two outages. - **No `sudo` at 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: ```bash 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): ```bash 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 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 ```bash .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/autostart` ships `swayidle -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`, `ydotool` do 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=basic` alone does not stop Chromium asking for the login keyring password: it inherits `DBUS_SESSION_BUS_ADDRESS`, reaches the running `gnome-keyring-daemon` and prompts. `LinuxChromiumAdapter.launch_env()` strips the bus for the child process, which is what actually fixes it. Do not remove `launch_env()`, and do not add a flag list that nothing references — `test_linux_browser_flags.py` fails if a list becomes dead code, because that is exactly how this bug hid before. - **`--ozone-platform-hint=auto` does not work on Chromium 152.** It aborts with "Missing X server" instead of falling back to Wayland; `--ozone-platform=wayland` must 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 needs `start_new_session=True` at 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.py` does it correctly. - **`--disable-gpu` increases 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 (hardware `h264_v4l2m2m`, ~31 s for an 18 s 4K clip; `libx264` fallback). * 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 | |---|---| | `.kiwy-converting` | in flight → the player **skips** the item | | `_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: ```bash .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.json` values 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 1. Bump `PLAYER_VERSION` in `src/main.py`. 2. Run the verification suite above on the target Pi. 3. Confirm `bash linux/fix_kivy_sdl2.sh --check` reports `fixed`. 4. Smoke-test a mixed playlist: image → video → weblink → image, verifying durations, audio (`audio: off` / `muted`) and a clean exit/restart. 5. 24/7 soak (≥12 h): check the heartbeat, `pgrep -c chromium` for leaks and memory growth.