Files
Kiwy-Signage/.github/instructions/kiwy-linux-rpi.instructions.md
ske087 3ac7f836c4 Port the player to Raspberry Pi OS Trixie 64-bit (Linux-only branch)
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.
2026-09-13 21:57:49 +03:00

254 lines
11 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
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 |
|---|---|
| `<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:
```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.