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.
This commit is contained in:
ske087
2026-09-13 21:57:49 +03:00
parent f437aba1fc
commit 3ac7f836c4
68 changed files with 5604 additions and 9326 deletions
+265
View File
@@ -0,0 +1,265 @@
# Kiwy Signage Player — Raspberry Pi OS "Trixie" 64-bit Port Plan
> Branch: `Linux-RPI-Player` (created from `Windows-Player` HEAD `f437aba`)
> Status: **Phase 1 COMPLETE — player runs on the Pi.** See `development-track.md`
> for measured results, the five blocking bugs and how they were fixed.
> Date: 2026-09-13
## Progress snapshot
| Phase | State |
|-------|-------|
| 0 — Baseline | ✅ folded into Phase 1 |
| 1 — Runtime bring-up | ✅ **done** — window, video, heartbeat, clean 60 s run |
| 1b — Windows removal | ✅ **done** — branch is now Linux-only (see `development-track.md`) |
| 2 — Web links | 🟡 adapter written, needs end-to-end verification on labwc |
| 3 — Networking & card reader | 🟡 `nmcli` restart implemented; card reader pending |
| 4 — Install & autostart | ⬜ |
| 5 — 24/7 validation | ⬜ |
**Three findings changed the plan materially** (details in `development-track.md`):
1. Kivy's PyPI wheel bundles an SDL2 **without** the wayland driver → no window
on Trixie. Fixed by `linux/fix_kivy_sdl2.sh`. (Open question: apt
`python3-kivy` may avoid this entirely.)
2. SDL2 needs `WAYLAND_DISPLAY` **set**; the socket alone is not enough, which
breaks every systemd/cron/autostart launch.
3. Kivy's `Clock` re-resolves callbacks by `func.__name__`; a mismatched patch
crashes the app ~20 s after a *successful* start.
---
## 1. Executive summary
The Windows port works because `windows/run_win.py` (2306 lines) monkey-patches
`src/main.py` at runtime and injects Windows-specific web-link adapters into the
existing `WeblinkSession` abstraction. The **`src/` core is already
cross-platform by design** — that is the single most important finding:
* `src/weblink_session.py` already ships a working Linux path
(`ChromiumSubprocessAdapter` is the default adapter).
* `src/network_monitor.py` already has `IS_WINDOWS` branching.
* `src/video_safety.py` patches Kivy's `VideoFFPy` provider — the same provider
used on the Pi.
So this is **not a rewrite**. It is:
1. **A new `linux/` companion to `windows/`** — an entry point that injects the
Linux adapter + platform behaviour (mirrors `run_win.py`, keeps `main.py` clean).
2. **Repairing the existing Linux code paths** that were never validated against
Trixie (Wayland/labwc, NetworkManager, no `tvservice`, no `xdotool`).
3. **Fixing the install/deployment layer**, which is currently broken for
Trixie/aarch64 (empty offline package repo, wrong wheel platform, broken
shebangs, boot-time `sudo` calls).
### Verified environment (measured on the target device)
| Item | Value |
|------|-------|
| OS | Debian GNU/Linux 13 (trixie) |
| Model | Raspberry Pi 4 Model B Rev 1.4 |
| Kernel | `6.18.39+rpt-rpi-v8` aarch64 |
| Python | **3.13.5** (system) |
| Session | `Type=wayland`, `Desktop=rpd-labwc` (**labwc**, not Wayfire/X11) |
| Compositor | `/usr/bin/labwc`; user autostart file exists at `~/.config/labwc/autostart` |
| Browser | `/usr/bin/chromium`**Chromium 152.0.7977.82** |
| Kivy | not installed; apt has `python3-kivy 2.3.1-1+b1`; PyPI has a
`cp313 manylinux_2_17_aarch64` wheel (verified downloadable) |
| ffpyplayer / evdev | not installed; apt has `python3-evdev 1.9.1-1` |
| Display power | `wlopm` present; **`tvservice`, `xdotool`, `ydotool` MISSING** |
| Networking | **NetworkManager active**; `dhcpcd`/`ifupdown` inactive |
| `sudo` | **requires a password** (`sudo -n` fails) |
| `repo/python-wheels/` | **EMPTY (0 files)** — offline install cannot work today |
---
## 2. Compatibility matrix
Legend: 🔴 breaks on Pi · 🟡 works but wrong/poor · 🟢 already fine
| # | Component | Windows assumption | Trixie reality | Action |
|---|-----------|--------------------|----------------|--------|
| 1 | Entry point | `run_win.py` patches everything | no Linux equivalent — `main.py` used bare | 🟡 create `linux/run_linux.py` |
| 2 | Web-link engine | Chrome/Edge subprocess + WebView2/CEF | Chromium subprocess (default adapter) | 🟡 dedicated Linux adapter with `--kiosk`/Ozone flags |
| 3 | Web-link launch flags | `--start-fullscreen --start-maximized`, DPI-aware sizing | labwc honours `--kiosk`; needs `--ozone-platform=wayland` | 🔴 Linux-specific flag set |
| 4 | Browser hand-off leak | fixed by `--user-data-dir` | same failure mode exists | 🔴 add `--user-data-dir` on Linux too |
| 5 | Desktop flash on transition | `_Win32Overlay` + `_Win32Backdrop` | no Win32; wayland compositor owns stacking | 🔴 needs Kivy-side black cover + labwc rule |
| 6 | Window raise/focus | `SetForegroundWindow`, `AttachThreadInput` | `Window.raise_window()` is ~no-op on Wayland | 🟡 focus guardian is harmless no-op; disable on Linux |
| 7 | Keep display awake | `SetThreadExecutionState`, screensaver API | `signal_screen_activity()` shells out to X11 tools | 🔴 rewrite for `wlopm` (see #8) |
| 8 | `signal_screen_activity()` | — | runs `tvservice`(missing), `xdotool`(missing), `xset`/`xrandr`(no X DPMS), `ydotool`(missing), `wlopm` with a **buggy `\*` escape** | 🔴 rewrite, keep-awake via `wlopm --on '*'` |
| 9 | Idle/screensaver conflict | n/a | `~/.config/labwc/autostart` runs `swayidle -w timeout 600 'wlopm --off *'` → fights the player | 🔴 disable/neutralise for kiosk |
| 10 | GL backend | `angle_sdl2` | Mesa/V3D; `gl`/`gles`/`sdl2` | 🟡 set correct backend in `run_linux.py` |
| 11 | Audio driver | `directsound` | PipeWire (ALSA-compat / Pulse) | 🟡 `alsa,pulse,dummy`; a **duplicate `SDL_AUDIODRIVER` line** makes the 2nd a no-op |
| 12 | Card reader | Raw Input + `win_card_reader.py` | `evdev` (native path already in `main.py`) | 🟡 install `python3-evdev`; udev/permissions rule |
| 13 | Interaction watcher | `GetCursorPos` pointer tap | `/dev/input/event*` raw fds (already implemented) | 🟡 needs `input` group perms (user `pi` already in `input`) |
| 14 | Offline install | n/a | `repo/python-wheels/` empty; `download_offline_packages.sh` uses `--platform linux_armv7l` (**32-bit**) | 🔴 regenerate for `aarch64` / cp313 |
| 15 | `install.sh` shebang | n/a | literal `#\!/bin/bash` (escaped `!`) → not directly executable | 🔴 fix (3 places incl. heredocs) |
| 16 | `install.sh` deps | n/a | installs `libsdl2-dev`/`libav*-dev` build toolchain; apt `python3-kivy` exists | 🟡 prefer apt Kivy + wheels |
| 17 | `install.sh` autostart | n/a | writes *only* XDG `.desktop`; labwc uses `~/.config/labwc/autostart` | 🔴 add labwc hook + systemd unit |
| 18 | `start.sh` display setup | n/a | `configure_display_resolution()` writes `/boot/config.txt` (**wrong path** on Trixie → `/boot/firmware/config.txt`) and calls `xrandr`/`tvservice`; `sudo` will **prompt at boot** | 🔴 remove/repair — boot-hang risk |
| 19 | WiFi restart | `netsh wlan` | `rfkill`/`ifconfig`/`dhclient``ifconfig`+`dhclient` **not installed**, all `sudo` → password prompt | 🔴 move to `nmcli`, passwordless sudoers |
| 20 | `setup_wifi_control.sh` | n/a | allows `/sbin/ifconfig`, `/sbin/dhclient`**paths don't exist** | 🔴 rewrite for Trixie |
| 21 | Orientation / `max_resolution` | `Window.size` constraint | fullscreen ignores `Window.size`; rotation is a compositor concern | 🔴 implement via `wlr-randr`/cmdline |
| 22 | `src/signageplayer.ini` | — | **never loaded by any code** (dead file) | 🟡 delete or wire up |
| 23 | `prewarm` weblink browser | desktop CPU budget | 2 extra Chromium processes on a Pi 4 | 🟡 default `prewarm:false` on Pi |
| 24 | Watchdog / 24-7 | `watchdog.ps1` | `start.sh` exists and is sound (heartbeat + stop flag) | 🟢 keep, with fixes from #18 |
---
## 3. Proposed architecture
Mirror the existing, proven Windows pattern. Keep `src/main.py` cross-platform
per the repo ground rules — **no Linux-only imports added to `main.py`**.
```
src/ # shared, cross-platform core (unchanged philosophy)
main.py # shared application; PI patches live in linux/
weblink_session.py # WeblinkSession + ChromiumSubprocessAdapter (default)
linux/ # NEW — the Linux counterpart of windows/
run_linux.py # ✅ entry point: env, session, adapter injection, patches
linux_display.py # ✅ wlopm keep-awake, swayidle neutralisation, rotation
linux_browser.py # ✅ LinuxChromiumAdapter (kiosk + Ozone + user-data-dir)
fix_kivy_sdl2.sh # ✅ system SDL2 (wayland-capable) over the bundled one
test_linux_patches.py # ✅ regression tests (29 checks)
_probe_video.py # ✅ ad-hoc video decode/playback probe
development-track.md # ✅ measured findings and bug log
RPI_TRIXIE_PORT_PLAN.md # ✅ this file
install_linux.sh # ⬜ upcoming — Trixie-correct installer
build_offline_aarch64.sh # ⬜ upcoming — cp313/aarch64 wheels
start_player.sh # ⬜ upcoming — kiosk launcher (fixed start.sh)
watchdog.sh # ⬜ upcoming — crash+heartbeat supervisor
kiwy-player.service # ⬜ upcoming — systemd unit
kiwy-signage-wifi.sudoers # ⬜ upcoming — nmcli-only passwordless rules
.github/instructions/
kiwy-linux-rpi.instructions.md # ⬜ upcoming — build/deploy rules for the Pi
```
`run_linux.py` responsibilities (deliberately the *same shape* as `run_win.py`):
1. Set Linux env vars **before** importing `main` (`SDL_VIDEODRIVER=wayland,x11,dummy`,
`KIVY_GL_BACKEND`, `SDL_AUDIODRIVER`, `KIVY_VIDEO/KIVY_AUDIO=ffpyplayer`).
2. Import `main`, then assign `SignagePlayer.weblink_adapter_factory`
`[LinuxChromiumAdapter]`.
3. Patch `signal_screen_activity``linux_display` implementation.
4. Skip the Win32-only focus guardian / bring-to-front paths.
5. Provide a fatal-error surface that works without a console
(log file + optional on-screen error label).
**Why an adapter instead of patching `play_weblink()`:** the `WeblinkSession`
abstraction already exists precisely for this, and it is what removed the
z-order/leak bugs on Windows. Reusing it means the Pi inherits the verified
launch → visibility → interaction → teardown state machine for free.
---
## 4. Phased implementation plan
### Phase 0 — Baseline (no product change)
- [ ] Fix the `install.sh`/`start.sh` shebangs (`#\!``#!`) so scripts are executable.
- [ ] Boot the player on this Pi **manually** (`python3 src/main.py` from a venv) and
capture a baseline: does Kivy start on labwc/Wayland? GL backend? audio? video?
- [ ] Record findings in `linux/development-track.md`.
**Exit criteria:** a screenshot/log showing the player window on the Pi, or a
precise first-failure diagnosis.
### Phase 1 — Runtime bring-up (`linux/run_linux.py`, `linux/linux_display.py`)
- [x] Create the venv and install deps (`kivy`, `ffpyplayer`, `evdev`, `requests`, `bcrypt`, `aiohttp`).
- [x] `run_linux.py`: env setup + adapter injection + `signal_screen_activity` replacement.
- [x] Fix the SDL2/Wayland blocker (`linux/fix_kivy_sdl2.sh`).
- [x] `signal_screen_activity()`: `wlopm --on '*'` via `subprocess` (no `os.system`),
`vcgencmd display_power` backstop, X11 fallbacks; dropped `tvservice`/`xdotool`/`ydotool`.
- [x] Neutralise the `swayidle` idle blanker.
- [x] Gate the Win32 focus guardian/keeper on Linux.
- [x] Fix the duplicate `SDL_AUDIODRIVER` line.
- [x] Point the launchers at `linux/run_linux.py` instead of `src/main.py`.
- [x] Regression test `linux/test_linux_patches.py` (29 checks).
**Exit criteria met:** player runs fullscreen on labwc, video decodes and plays
(`duration=6.0`, position advancing, texture rendered), heartbeat written,
60 s run with zero errors and a clean shutdown. 24/7 soak still pending (Phase 5).
### Phase 2 — Web links on Chromium/Wayland (`linux/linux_browser.py`)
- [ ] `LinuxChromiumAdapter(ChromiumSubprocessAdapter)`: `--kiosk`, `--ozone-platform=wayland`
(with auto fallback), dedicated `--user-data-dir`, `--autoplay-policy=no-user-gesture-required`,
`--hide-scrollbars`, `--disable-pinch`, `--start-fullscreen` only where `--kiosk` misbehaves.
- [ ] Implement `wait_visible()` for Linux — verify the Chromium window exists
(`/proc/<pid>/` + Wayland toplevel check, or a `--remote-debugging-port` probe)
instead of only "process still alive".
- [ ] Transition masking without Win32: Kivy-side black cover + verify no desktop flash
under labwc; if needed, an `rc.xml` window rule pinning the Kivy window.
- [ ] Teardown: kill the full process tree (`start_new_session=True` + `os.killpg`)
so GPU/renderer children don't leak (the Linux twin of `taskkill /T`).
- [ ] Default `weblink.prewarm=false` on Pi to save CPU/RAM.
**Exit criteria:** image → video → weblink → image cycle with no flash, no leaked
`chromium` processes, correct duration + interaction postponement.
### Phase 3 — Networking & card reader
- [ ] `src/network_monitor.py`: replace `rfkill`/`ifconfig`/`dhclient` with `nmcli`
(`nmcli radio wifi off/on`, `nmcli device disconnect/connect`) — Trixie uses NetworkManager.
- [ ] `kiwy-signage-wifi.sudoers`: passwordless rules for exactly the `nmcli`/`rfkill` binaries used.
- [ ] Install `python3-evdev` (apt) + udev rule so the card reader works without `input` group hacks.
### Phase 4 — Install & autostart (Trixie-native)
- [ ] `build_offline_aarch64.sh`: populate `repo/python-wheels/` with **cp313 aarch64**
wheels (`kivy`, `ffpyplayer`, `evdev`, `requests`, `bcrypt`, `aiohttp`, …).
Replace the `--platform linux_armv7l` logic.
- [ ] `install_linux.sh`: prefer apt `python3-kivy`/`python3-evdev` (fast, no build toolchain);
venv with `--system-site-packages`; drop the `--break-system-packages` fallback.
- [ ] Autostart: append to `~/.config/labwc/autostart` **and** ship a systemd unit
(verify which one RPi OS Trixie `rpd-labwc` actually honours — validation item).
- [ ] `watchdog.sh`: repair `configure_display_resolution()` (wrong `/boot/config.txt` path,
`sudo` at boot = hang risk); make all boot-time steps non-interactive.
- [ ] Orientation / `max_resolution`: implement rotation via `wlr-randr` (Wayland) or
`cmdline.txt` `video=...,rotate=`; document that `Window.size` cannot change a
fullscreen mode.
**Exit criteria:** clean install on a wiped Pi → reboots into playback unattended.
### Phase 5 — 24/7 validation & docs
- [ ] Overnight soak (≥12 h): mixed playlist, verify heartbeat, no leak growth
(`ps` chromium count), no memory growth.
- [ ] Crash/hang drills: kill the player, freeze it, power-cut it → watchdog recovery.
- [ ] `linux/development-track.md` + `.github/instructions/kiwy-linux-rpi.instructions.md`.
- [ ] Update `PLAYER_VERSION` and the release checklist.
---
## 5. Risks & mitigations
| Risk | Mitigation |
|------|-----------|
| Wayland client stacking — Chromium kiosk may not reliably return focus to Kivy | validate early (Phase 2); fallback = labwc `rc.xml` rule or run the session under XWayland |
| Chromium `--kiosk` + labwc fullscreen semantics | empirical flag matrix recorded in the dev-track (like the Windows "tested & rejected" log) |
| `sudo` password prompt at boot hangs the watchdog | all boot-time steps must be non-interactive; sudoers written at install time |
| Pi 4 software H.265 decode is heavy | document supported codecs; prefer H.264; keep `video_safety` bounded join |
| Offline wheels for cp313/aarch64 may be incomplete | Phase 0 verifies `pip download` for every requirement before Phase 4 |
| `src/main.py` drift — Linux fixes leaking into shared code | follow the repo rule: platform code lives in `linux/`, `main.py` only gains *guarded* capability checks |
## 6. Validation checklist (per phase, on the real device)
- [ ] `python -m py_compile` on every touched file
- [ ] Player starts fullscreen on labwc without a desktop flash
- [ ] Display never blanks (24 h)
- [ ] Playlist: image → video → weblink → image, correct durations
- [ ] Weblink: kiosk fullscreen, no leaked `chromium` processes after the item
- [ ] Touch interaction postpones the weblink advance
- [ ] Card reader authenticates
- [ ] WiFi restart recovers from an unplugged AP without a password prompt
- [ ] Reboot → playback resumes unattended
- [ ] `kill -9` the player → watchdog restarts within 60 s
---
## 7. Open decisions (need input before Phase 1)
1. **Kivy source**: apt `python3-kivy` (fast, offline-friendly, distro-managed) vs
PyPI wheel in a venv (matches the Windows Kivy 2.3.1 + full control)?
2. **Session target**: stay on the stock `rpd-labwc` kiosk session, or ship a
dedicated minimal labwc session (no panel, no `swayidle`) for the player?
3. **Autostart mechanism**: systemd (system or user) vs `~/.config/labwc/autostart`?
4. **Orientation**: is Portrait support actually required for this deployment?
5. **Offline install**: must the Pi install work with no internet (i.e. vendor the
aarch64 wheels into `repo/`), or is online install acceptable?
6. **Card reader**: is it in scope for the Pi, or is authentication keypad/quickconnect only?
+126
View File
@@ -0,0 +1,126 @@
"""_probe_chromium_footprint.py — measure Chromium's footprint for one page.
Compares the flag profiles so the memory trade-off is measured rather than
guessed. Run:
.venv/bin/python linux/_probe_chromium_footprint.py [url]
Reports process count and total resident memory for the browser tree, using the
same flags the player uses (so the numbers match production).
"""
import os
import subprocess
import sys
import time
from pathlib import Path
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from linux_display import ensure_session_environment # noqa: E402
ensure_session_environment()
import linux_browser # noqa: E402
URL = sys.argv[1] if len(sys.argv) > 1 else 'about:blank'
def tree_memory(root_pid):
"""(PSS kB, process count) for root_pid and its descendants.
PSS (proportional set size) from ``/proc/<pid>/smaps_rollup`` is the right
measure here, **not** RSS. Chromium forks many processes that share the same
libraries and file-backed pages; summing RSS counts every shared page once
per process, which inflated earlier measurements by roughly 2x and even made
a smaller configuration look larger.
"""
try:
out = subprocess.run(
['ps', '-eo', 'pid,ppid'], capture_output=True, text=True,
timeout=10, check=False,
).stdout
except Exception:
return 0, 0
parents = {}
for line in out.splitlines()[1:]:
parts = line.split()
if len(parts) >= 2 and parts[0].isdigit():
parents[int(parts[0])] = int(parts[1])
family = [root_pid]
changed = True
while changed:
changed = False
for pid, parent in parents.items():
if parent in family and pid not in family:
family.append(pid)
changed = True
total_kb = 0
for pid in family:
try:
with open(f'/proc/{pid}/smaps_rollup') as fh:
for line in fh:
if line.startswith('Pss:'):
total_kb += int(line.split()[1])
break
except OSError:
# Process exited between listing and reading.
continue
return total_kb, len(family)
def measure(mode):
os.environ['KIWY_CHROMIUM_MODE'] = mode
profile = f'/tmp/kiwy-footprint-{mode}'
subprocess.run(['rm', '-rf', profile], check=False)
adapter = linux_browser.LinuxChromiumAdapter(
browser_path=linux_browser.find_linux_browser(), kiosk=True,
)
adapter._profile_dir = profile
ok = adapter.launch(URL, 1280, 720)
if not ok or adapter._proc is None:
return None
try:
# Let Chromium finish spawning helpers before sampling.
time.sleep(8)
if adapter._proc.poll() is not None:
return {'crashed': True, 'rc': adapter._proc.returncode}
pss_kb, procs = tree_memory(adapter._proc.pid)
return {"pss_mb": pss_kb / 1024.0, "procs": procs}
finally:
adapter.teardown()
time.sleep(1.5)
print(f'url = {URL}\n')
print(f'{"mode":<10} {"procs":>6} {"PSS (MB)":>10}')
print('-' * 30)
results = {}
for mode in ('safe', 'light', 'minimal'):
result = measure(mode)
results[mode] = result
if result is None:
note = 'launch failed'
elif result.get('crashed'):
note = f'CRASHED rc={result["rc"]}'
else:
note = ''
if note:
print(f'{mode:<10} {"-":>6} {note:>10}')
else:
print(f'{mode:<10} {result["procs"]:>6} {result["pss_mb"]:>10.0f}')
print()
base = results.get('safe', {}) or {}
light = results.get('light', {}) or {}
if base.get('pss_mb') and light.get('pss_mb'):
saved = base['pss_mb'] - light['pss_mb']
pct = 100.0 * saved / base['pss_mb']
print(f'light mode saves {saved:.0f} MB ({pct:.0f}%) vs the default flag set')
print(f' processes: {base["procs"]} -> {light["procs"]}')
+70
View File
@@ -0,0 +1,70 @@
"""Ad-hoc video playback probe (not part of the test suite).
Plays the intro video in a Kivy window and reports whether it decodes and
advances. Used to verify the ffpyplayer path on Raspberry Pi OS Trixie.
.venv/bin/python linux/_probe_video.py [path/to/video.mp4]
"""
import os
import sys
import time
# Mirror the real entry point: SDL2 does NOT discover the Wayland socket on its
# own, so WAYLAND_DISPLAY must be filled in first. Without this the probe falls
# back to x11 and dies with "Couldn't connect to X server".
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from linux_display import ensure_session_environment # noqa: E402
ensure_session_environment()
os.environ.setdefault('SDL_VIDEODRIVER', 'wayland,x11,dummy')
os.environ.setdefault('KIVY_GL_BACKEND', 'gl')
os.environ.setdefault('KIVY_VIDEO', 'ffpyplayer')
os.environ.setdefault('KIVY_AUDIO', 'ffpyplayer')
from kivy.config import Config # noqa: E402
Config.set('graphics', 'window_state', 'hidden')
Config.set('graphics', 'fullscreen', '0')
from kivy.app import App # noqa: E402
from kivy.clock import Clock # noqa: E402
from kivy.uix.video import Video # noqa: E402
SOURCE = sys.argv[1] if len(sys.argv) > 1 else 'config/resources/intro1.mp4'
DURATION = 12.0
results = {}
class Probe(App):
def build(self):
self.video = Video(
source=SOURCE, state='play', options={'eos': 'stop'},
allow_stretch=True, keep_ratio=True,
)
self.video.bind(on_eos=lambda *a: results.setdefault('eos', True))
return self.video
def on_start(self):
self.t0 = time.monotonic()
Clock.schedule_interval(self.tick, 1.5)
Clock.schedule_once(lambda dt: self.stop(), DURATION)
def tick(self, dt):
elapsed = time.monotonic() - self.t0
core = self.video._video
position = getattr(core, 'position', None) if core else None
if position is not None:
results['last_position'] = position
print(
f' t={elapsed:5.1f}s state={self.video.state} '
f'duration={self.video.duration:.1f} position={position} '
f'texture={"yes" if self.video.texture else "no"}',
flush=True,
)
Probe().run()
print(f'RESULT duration={results.get("duration")} '
f'last_position={results.get("last_position")} eos={results.get("eos")}')
+92
View File
@@ -0,0 +1,92 @@
"""Ad-hoc weblink engine probe — drives the real LinuxChromiumAdapter.
Verifies the full launch → visible → teardown cycle outside the player, so
weblink behaviour can be tested without a server-provided playlist.
.venv/bin/python linux/_probe_weblink.py [url]
"""
import os
import sys
import time
_HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, _HERE)
sys.path.insert(0, os.path.join(os.path.dirname(_HERE), 'src'))
from linux_display import ensure_session_environment # noqa: E402
ensure_session_environment()
URL = sys.argv[1] if len(sys.argv) > 1 else 'https://example.com'
REQUIRED = os.environ.get('KIWY_REQUIRE_SUBSTRING', '')
from linux_browser import LinuxChromiumAdapter, find_linux_browser # noqa: E402
def chromium_count():
import subprocess
out = subprocess.run(['pgrep', '-c', 'chromium'], capture_output=True, text=True)
try:
return int(out.stdout.strip())
except ValueError:
return 0
def rss_mb():
import subprocess
out = subprocess.run(
['ps', '-o', 'rss=', '-C', 'chromium'], capture_output=True, text=True
)
total = sum(int(x) for x in out.stdout.split() if x.isdigit())
return total / 1024.0
def main():
print(f'URL: {URL}')
print(f'baseline chromium procs: {chromium_count()}')
adapter = LinuxChromiumAdapter(
browser_path=find_linux_browser(),
kiosk=True,
profile_dir=os.path.join(os.getcwd(), '.kiosk-profile-probe'),
)
print(f'flags: {adapter.extra_launch_args()}')
launched = adapter.launch(URL, 1920, 1080)
print(f'launch() -> {launched}')
if not launched:
print('FAIL: launch returned False')
return 1
visible, reason = adapter.wait_visible(15.0)
print(f'wait_visible() -> {visible} ({reason})')
time.sleep(6)
print(f'during: {chromium_count()} procs, {rss_mb():.0f} MB')
if REQUIRED:
# Fetch the same URL over HTTP and confirm it resolves, so a blank page
# can be attributed to rendering rather than to the network.
import urllib.request
try:
body = urllib.request.urlopen(URL, timeout=10).read(400_000).decode(
'utf-8', 'replace'
)
print(f'server reachable; page contains {REQUIRED!r}: {REQUIRED in body}')
except Exception as exc:
print(f'server fetch failed (network/host issue, not the engine): {exc}')
adapter.teardown()
time.sleep(3)
left = chromium_count()
print(f'after teardown: {left} chromium procs (0 = no leak)')
ok = visible and left == 0
print('RESULT:', 'PASS' if ok else 'FAIL')
return 0 if ok else 1
if __name__ == '__main__':
sys.exit(main())
+509
View File
@@ -0,0 +1,509 @@
# 🧪 Development Track — Kiwy Signage Player (Raspberry Pi / Linux Edition)
> Read this FIRST before debugging or coding on the Pi port. It records what was
> measured on real hardware, not what was assumed.
---
## 📅 Session — 2026-09-13
| Field | Value |
|-------|-------|
| **Branch** | `Linux-RPI-Player` (from `Windows-Player` @ `f437aba`) |
| **Hardware** | Raspberry Pi 4 Model B Rev 1.4, kernel `6.18.39+rpt-rpi-v8` |
| **Python** | 3.13.5 in `.venv` (created with `--system-site-packages`) |
| **Kivy** | 2.3.1 (PyPI wheel, cp313 aarch64) |
| **ffpyplayer** | 4.5.3 (cp313 aarch64 wheel) |
| **evdev** | 2.0.0 (built from sdist — no cp313 wheel published) |
| **Session** | Wayland, `rpd-labwc` (labwc — **not** Wayfire, not X11) |
| **Browser** | `/usr/bin/chromium` 152.0.7977.82 |
| **Entry point** | `linux/run_linux.py` (**not** `src/main.py`) |
### ✅ Status: the player runs on the Pi
Verified by real runs (not inference):
* Kivy window created on **Wayland** (`wayland,x11,dummy` → wayland driver)
* 60 s continuous run, **0 errors, 0 tracebacks, clean shutdown**
* Heartbeat file written and refreshed every 10 s
* Playlist polling loop active (30 s interval)
* **Video playback works**: intro1.mp4 → `duration=6.0`, position advanced
`0.56 → 2.07 → 3.56 → 5.06`, texture rendered, EOS handled, state auto-reset
* Screensaver/blanker neutralised; `wlopm --on` confirmed working
Blocked on: no server reachable from this network (`192.168.0.110` → *No route
to host*), so no playlist/media content could be downloaded. That is an
environment limitation, not a code defect.
---
## Windows code removal (2026-09-13, second pass)
This branch is now **Linux-only**. All Windows code and assets were removed;
they remain available on the `Windows-Player` branch.
**Deleted:**
* `windows/` — the whole directory (32 files): `run_win.py`, `win_card_reader.py`,
`webview2_browser.py`, `webview2_runtime.py`, `cef_browser.py`, `build.spec`,
`watchdog.ps1`, the PyInstaller/PowerShell tooling and the bundled Windows
binaries (`webview2_sdk/` DLLs, `app_icon.ico`).
* `.github/instructions/kiwy-build-and-development.instructions.md` (the exe build guide)
* `documentation/CODE_SIGNING_SMART_APP_CONTROL.md` (Windows-only signing constraint)
**Removed from shared `src/`** — these were live code paths, so this was a real
behavioural change, not just a comment cleanup:
| File | Removed |
|------|---------|
| `main.py` | The whole Win32 focus subsystem: `_bring_window_to_front_nonblocking`, `_start/_stop_focus_keeper`, `_focus_keeper_tick`, `_start/_stop_focus_guardian`, `_focus_guardian_tick`, their `__init__` state, the `_start_focus_keeper()` call at video start, the guardian start in `__init__`, the two `_stop_focus_keeper()` calls, and the `_is_foreground_win` / `_bring_kivy_to_front_win` getattr hooks. Also the duplicate `Window.raise_window()` block and `_weblink_proc`. |
| `network_monitor.py` | `IS_WINDOWS`, the `ping -n/-w` branch, `_restart_wifi_windows()` (netsh), and the `sudo ifconfig`/`dhclient` calls (`ifconfig`/`dhclient` are **not installed** on Trixie). |
| `weblink_session.py` | `WebInputSources.pointer_moved()` (`GetCursorPos`), the `use_pointer` plumbing, and `msedge` from the browser search list. |
| `video_safety.py` | Windows framing in the docstring only — the fix itself is cross-platform and was kept. |
**Replaced, not just deleted:**
* `_restart_wifi_linux()``_restart_wifi_nmcli()`. Trixie uses **NetworkManager**,
so `nmcli radio wifi off/on` + `nmcli device connect` is the correct interface
and needs no `sudo`. The old path depended on packages that are absent.
* `ifconfig wlan0 down/up``ip link set wlan0 down/up`.
* New `.github/instructions/kiwy-linux-rpi.instructions.md` (the Pi counterpart
of the deleted build guide).
* `PLAYER_WEBLINK_INTEGRATION.md` §6.2 rewritten: "Windows: WebView2 Runtime"
→ "Linux: Chromium kiosk on Wayland".
* `.gitignore`: dropped the Windows venv/dist/WebView2 entries, kept the
`.kiosk-profile/` ignore and clarified the credential rules.
* `config/app_config.json`: blanked the Windows host values
(`screen_name: DESKTOP-NJLBQKH`, the server IP and quick-connect key) and set
`weblink.prewarm: false` — pre-warming costs 2 extra Chromium processes, which
is not worth it on a Pi 4.
**Also removed:** the `_patch_focus_handlers()` patch from `run_linux.py`. It had
become vestigial once the focus methods were deleted — it would have created
no-op attributes the app never calls. `test_linux_patches.py` now **asserts those
attributes do not exist**, so reintroducing Win32 focus code fails the suite.
**Verified after the removal:** 21/21 patch checks pass, video still decodes and
plays (position advancing, texture rendered), 60 s player run with 0 errors and
no spurious crash log.
---
## 🔴 The five bugs that actually prevented the port
### [PI-013] Chromium asked for the keyring password at every weblink
* **Symptom:** "user and password" prompt appeared when a weblink launched
(reproducible also by starting Chromium manually). Impossible to answer on an
unattended signage screen, and it blocked the page.
* **Root cause (two separate bugs, neither sufficient alone):**
1. **Flag list was dead code.** `APPLIANCE_FLAGS` contained
`--password-store=basic` and `--use-mock-keychain`, but the list was
**never referenced** from `extra_launch_args()` — so those flags never
reached the command line. The comment looked like a fix; nothing was applied.
2. **The D-Bus session bus was inherited.** `ChromiumSubprocessAdapter.launch()`
used a bare `subprocess.Popen(args)` with **no `env=`**, and
`gnome-keyring-daemon --components=secrets` is running with
`DBUS_SESSION_BUS_ADDRESS` set. Chromium's password-store backend therefore
resolved to `gnome-libsecret` (Secret Service) and tried to unlock the login
keyring to hold its encryption key. Flags alone cannot fix this.
* **Fix:**
* New `launch_env()` hook on the base adapter (returns `None` = inherit);
`LinuxChromiumAdapter` returns `_browser_env()`, which strips
`DBUS_SESSION_BUS_ADDRESS`/`DBUS_SESSION_BUS_PID`, empties
`GNOME_KEYRING_CONTROL` and sets `CHROME_PASSWORD_STORE=basic`. Scoped to the
**child** — the player keeps its real session bus.
* Every flag list is now referenced from `extra_launch_args()`, and
`linux/test_linux_browser_flags.py` fails if any list becomes dead code.
* Added `--disable-save-password-bubble`.
* **Verified:** the live process's `/proc/<pid>/environ` contains no
`DBUS_SESSION_BUS_ADDRESS`, and Chromium logs
`dbus/bus.cc:405] Failed to connect to the bus` — i.e. it structurally cannot
reach the keyring. No prompt appears.
### [PI-014] `--ozone-platform-hint=auto` aborts on Chromium 152
* **Symptom:** no weblink ever displayed under labwc.
* **Root cause:** measured, the hint flag does **not** fall back to Wayland when
there is no X server — it simply fails:
```
(no flag) -> "Missing X server or $DISPLAY", aborts
--ozone-platform-hint=auto -> "Missing X server or $DISPLAY", aborts
--ozone-platform=wayland -> starts
```
* **Fix:** the platform is chosen explicitly from the detected session
(`--ozone-platform=wayland`), with `WAYLAND_FLAGS` omitted entirely when there
is no Wayland socket so Chromium uses its own X11 default.
### [PI-015] Process-group teardown could never run
* **Symptom:** every weblink risked leaving Chromium's GPU/zygote/renderer
children behind (the Linux twin of the Windows leak).
* **Root cause:** `linux_browser.teardown()` calls `os.killpg`, but it only did
so when `self._pgid` was a **different** group — and because the base
`Popen(args)` had no `start_new_session=True`, the browser shared the player's
own process group. Killing that group would have killed the player itself, so
the code correctly refused and fell back to `terminate()`, orphaning children.
* **Fix:** new `start_new_session()` hook; the Linux adapter returns `True`.
`test_linux_browser_flags.py` asserts the live process is its own group leader.
---
## 🎬 Video normalisation — 4K cannot play on a Pi 4
### [PI-016] A 4K video shows one frozen frame instead of playing
* **Symptom:** the new 4K sample (`16118765_3840_2160_30fps.mp4`, 3840×2160
H.264 High@5.1) displayed a still frame while the playlist timer ticked on.
The 1080p sample played perfectly.
* **Root cause:** ffpyplayer decodes in **software** — there is no hardware
H.264 *decode* wired into its pipeline. Measured on this Pi 4:
| File | Resolution | Software decode speed |
|------|-----------|----------------------|
| `sample-30s.mp4` | 1920×1080 | **3.03× realtime** ✅ |
| `16118765_3840_2160_30fps.mp4` | 3840×2160 | **0.90× realtime** ❌ |
Below 1× realtime the decoder can never feed the display, so the picture
effectively stops. The player *did* advance correctly at the 19 s duration —
there was never a hang, just a video that cannot be rendered in time.
* **Important:** the file plays fine in an *isolated* Kivy probe (position
advances in realtime). The stall only appears under the real player, where
decode competes with rendering, the weblink browser and the GUI. Do not
conclude "the file is fine" from a standalone probe.
### The fix: `linux/video_normalizer.py`
Oversized media is downscaled to at most **1920×1080** once, at sync time.
* **Triggered by resolution only** — `width > 1920 or height > 1080`. A file
already within the limit is left byte-identical, so nothing that already
plays is ever re-encoded.
* **Hardware encoding** via `h264_v4l2m2m` (verified working — the Pi 4's
H.264 *encoder* is a separate block from its decoder), with `libx264` as the
fallback. Measured: **31 s** for an 18 s 4K clip.
* **Audio preserved** (`-c:a copy`, AAC fallback). This matters: a *silent*
video hits the separate SDL2_mixer bug documented in `_video_has_audio`, so
the normaliser must not create one.
* Output lands next to the source as `<stem>_kiwy1080p.mp4` plus a
`.kiwy-normalized.json` metadata file.
### Skip-while-converting, loop-the-intro-if-nothing-else
The conversion is asynchronous, so the playlist can reach the item before it is
ready. `src/media_state.py` owns one on-disk contract shared by the player and
the normaliser:
| Marker | Meaning |
|--------|---------|
| `<media>.kiwy-converting` | conversion in flight → **skip this item** |
| `<media>_kiwy1080p.mp4` + `.kiwy-normalized.json` | done → **play this file instead** |
Player behaviour per item:
* **ready** — play it (the converted file when one exists, else the original)
* **converting** — skip the lap immediately, no dwell delay
* **pending** — oversized and not converted: skip, and request conversion
Skipped items are advanced with `_advance_without_wait()` rather than occupying
their configured duration, so a 19 s slot that cannot be shown does not add 19 s
of nothing to every lap.
**When nothing at all is playable** (e.g. a single-item playlist that is one 4K
video still converting), the player loops `config/resources/intro1.mp4`
(`eos: loop`) and polls every 3 s. The moment a converted item appears it stops
the loop and restarts the playlist **from index 0** — so a one-item playlist
gets a clean full run rather than joining mid-clip. A blank screen is never left
on a signage display.
### Traps found while building this
1. **`resolve_playable()` initially skipped every normal video.** It checked for
a conversion output and a marker but never whether the file was oversized at
all, so a plain 1080p file (no output, no marker) fell through to
`pending`. The test caught it before it shipped: *skip-the-whole-playlist* is
a far worse failure than the freeze it was meant to fix.
2. **The media cleanup would have deleted the converted file.**
`delete_unused_media()` prunes anything not named in the playlist — and
`_kiwy1080p.mp4` is, by design, not named there. It is now explicitly
protected, along with both marker types.
3. **ffmpeg cannot infer the muxer from a `.part` temp name** ("Unable to choose
an output format"). The temp file keeps its real extension.
4. **Metadata is written last.** Its presence is what marks a conversion
complete, so it must never exist for a half-written output.
5. **A stale marker must not park an item forever** — markers older than 30 min
are ignored, so a crash mid-conversion cannot make a video unplayable.
### Verified end to end (real server, real playlist)
```
20:33:05 sync detects 3840x2160 -> starts the background conversion
20:33:36 conversion completes (31s) -> _kiwy1080p.mp4 + metadata
20:36:01 video_using_normalized source=...3840_2160... normalized=..._kiwy1080p.mp4
20:36:01 starting_video path=..._kiwy1080p.mp4
20:36:20 next_media_called was_index=7 (played 18.7s, advanced on EOS)
```
Suites: `test_media_state.py` 18/18 · `test_linux_patches.py` 21/21 ·
`test_linux_browser_flags.py` 27/27.
**Recommendation for the server side:** normalising the source to 1920×1080
before upload is still better — it avoids the 4K download *and* the 31 s
conversion. This player-side path exists so that an oversized upload degrades
gracefully instead of breaking the screen.
---
## 🟡 Chromium footprint — measured, not assumed
The Pi 4 has 3.8 GB total and the desktop already uses ~1.9 GB, so a weblink
needs to be lightweight. Numbers below are **PSS** summed over the browser tree
for the real page (`https://moto-adv.com/`), via
`linux/_probe_chromium_footprint.py`.
| Profile | Processes | PSS |
|---------|-----------|-----|
| `safe` (no footprint flags) | 10 | 513 MB |
| **`light` (default)** | **9** | **507 MB** |
| `minimal` (`--single-process`) | 4 | 438 MB |
**Honest conclusion: flag tuning buys very little.** Chromium's baseline is
simply ~500 MB and the remaining levers are single-digit percentages.
`minimal` saves ~15% but halves the process count, and upstream labels
`--single-process` unsupported, so it stays opt-in.
**Two measurement traps worth remembering:**
1. **RSS is the wrong metric.** Chromium shares libraries and file-backed pages
across processes; summing RSS double-counts and produced numbers ~2x too high
— it even ranked a *smaller* configuration as *larger* (1132 MB safe vs
1529 MB light). Use `Pmi/smaps_rollup`.
2. **`--disable-gpu` makes it worse.** It looks like an obvious win for a static
page, but it moves rasterization out of the GPU process into the renderer:
**1038 MB vs 513 MB**. It is deliberately absent from every list.
Use `KIWY_CHROMIUM_MODE=light|minimal|safe` to select a profile. If memory is
genuinely the constraint, the structural fix is an **embedded** engine rendering
inside the Kivy window (WebKitGTK, available on aarch64) — not more flags.
> **Ultralight is not an option on this hardware.** Despite the vendor's site
> claiming "Linux (x64 / arm64)", enumerating the entire SDK bucket returns only
> `mac-x64, linux-x64, win-x64, win-uwp-x64, win-x86`;
> `ultralight-sdk-latest-linux-arm64.7z` is a **404**. There is no ARM64 build of
> any kind, and it is closed-source C++ with no Python binding.
---
## 🔴 The five bugs that actually prevented the port
### [PI-001] Kivy's bundled SDL2 has NO Wayland driver — **no window at all**
* **Symptom:**
```
[CRITICAL] Unable to find any valuable Window provider.
sdl2 - RuntimeError: b'wayland,x11,dummy not available'
[CRITICAL] SignagePlayerApp: Window is None - display server not available
```
* **Root cause:** Kivy's PyPI wheel ships a **private SDL2** in `Kivy.libs/`
(name has a hash: `libSDL2-2-87637523.0.so.0.3000.7`). Driver enumeration
proved it is built **without Wayland**:
| Library | Video drivers |
|---------|---------------|
| `Kivy.libs/libSDL2-2-*.so` (bundled) | `x11, KMSDRM, offscreen, dummy, evdev` |
| `/usr/lib/aarch64-linux-gnu/libSDL2-2.0.so.0` (system) | `x11, **wayland**, KMSDRM, offscreen, dummy, evdev` |
Trixie runs a Wayland session and there is no X server, so the bundled build
cannot create a window. Note the driver *name* does not change: the bundled
SDL2 resolves `b'wayland'` as "driver unavailable", not "unknown driver".
* **Fix:** `linux/fix_kivy_sdl2.sh` symlinks the system SDL2 over the bundled
filename (idempotent, reversible, survives only until the next
`pip install --upgrade kivy`).
* **Verified:** `WINDOW OK size=(800, 600)`
* **Rejected alternatives:**
* ❌ Editing `~/.kivy/config.ini` — irrelevant, the provider never loads.
* ❌ `KIVY_WINDOW=...` variations — the library lacks the driver, full stop.
* *(Still untested)* apt `python3-kivy` likely bundles no SDL2 and would
sidestep this entirely — worth evaluating for the installer.
### [PI-002] SDL2 requires `WAYLAND_DISPLAY` — the socket alone is NOT enough
* **Symptom:** window creation fails when the player is launched from systemd,
cron, an autostart entry or SSH.
* **Root cause:** those contexts set `XDG_RUNTIME_DIR` but leave
`WAYLAND_DISPLAY` **empty** (the compositor only exports it inside the desktop
session). Measured:
| `WAYLAND_DISPLAY` | result |
|-------------------|--------|
| unset | `sdl2 - RuntimeError: b'wayland not available'` |
| `wayland-0` | `WINDOW OK (800, 600)` |
Notably `wlopm` **does** fall back to scanning `XDG_RUNTIME_DIR`, but SDL2
does not — so this cannot be inferred from the display tools working.
* **Fix:** `linux_display.ensure_session_environment()` detects the socket,
derives the name from its filename (so `wayland-1` works) and exports it.
Called at the very top of `run_linux.py`, before Kivy is imported.
* **Verified:** `session environment filled in: {'WAYLAND_DISPLAY': 'wayland-0'}`
### [PI-003] Kivy `WeakMethod` name trap — crash ~20 s AFTER startup
* **Symptom:** the app starts fine, then dies on the first Clock tick:
```
[CRITICAL] Fatal error: 'SignagePlayer' object has no attribute 'linux_screen_activity'
[CRITICAL] Fatal error: 'SignagePlayer' object has no attribute '_noop'
```
* **Root cause:** Kivy's `Clock` stores a callback's `__func__.__name__` in a
`WeakMethod` and later resolves it with `getattr(instance, that_name)`. A
replacement assigned under a *different* name than the function was defined
with raises `AttributeError` — but only when the Clock next fires, so the
traceback points nowhere near the patch.
Two variants bit us: a name mismatch (`linux_screen_activity` vs
`signal_screen_activity`) and a shared helper (`_noop` used for three
different methods).
* **Fix:** `_bind_name()` sets `__name__`/`__qualname__` on each replacement,
and the focus no-ops are generated per-attribute rather than shared.
* **Verified:** `linux/test_linux_patches.py` asserts that
`getattr(SignagePlayer, func.__name__)` resolves *and* returns the patched
function — for every replaced method.
* This is the same trap that used to be documented in the Windows port; the
lesson is now enforced by a test instead of by a comment.
### [PI-004] The inherited `signal_screen_activity()` never kept the screen on
* **Symptom:** the panel blanks after the 10-minute idle timeout.
* **Root causes (three compounding):**
1. `swayidle -w timeout 600 'wlopm --off *'` runs from
`~/.config/labwc/autostart` — measured: PID 1431, confirmed running.
2. The handler shells out to `tvservice`, `xdotool`, `ydotool` — **all three
are absent on Trixie** (`tvservice` is gone with the legacy firmware
stack; `xdotool`/`ydotool` are not installed).
3. It passed a shell-escaped `wlopm --on \*`, so the compositor matched an
output literally named `*` and did nothing.
* **Fix:** `linux/linux_display.py` — real `wlopm --on '*'` (list argv, no
shell), `vcgencmd display_power 1` as a firmware backstop, X11 fallbacks for
non-Wayland sessions, and `neutralise_idle_blanker()` to stop `swayidle`.
* **Verified:** `keep_display_awake` → `True`; `swayidle` killed;
`wlopm` reports `HDMI-A-1 on`.
### [PI-005] Launcher scripts bypassed the platform layer entirely
* **Symptom:** the player runs but blanks, shows no web links, and any fix to
the platform layer has no effect.
* **Root cause:** `start.sh` did `cd src && python3 main.py`, and
`run_player.sh` did the same. `src/main.py` is a *module* of the shared core,
not the Pi entry point — running it directly skips every patch in
`linux/run_linux.py`. Additionally `src/` is not the data directory, so
`base_dir` resolved one level too high.
* **Fix:** both scripts now run `.venv/bin/python linux/run_linux.py` from the
project root. `check_player_status.sh` / `stop_player.sh` match
`run_linux.py` instead of `python3 main.py` (they would otherwise never find
the process).
* **Verified:** `bash run_player.sh` runs clean; `bash -n` passes on all four.
---
## 🟡 Also fixed (real but non-blocking)
| # | Issue | Fix |
|---|-------|-----|
| [PI-006] | `main.py` set `SDL_AUDIODRIVER` **twice**; the second (`'alsa'`) was a silent no-op because `setdefault` never overwrites — it obscured which driver was live | Removed the duplicate, documented why |
| [PI-007] | `SettingsPopup.test_connection` hard-coded `/tmp/temp_auth_test.json` | Uses `tempfile.gettempdir()`; needed `import tempfile` added to `main.py`. Files with credentials are now always removed |
| [PI-008] | `player_auth.json` resolved against the *cwd*, so systemd/autostart launches "forgot" authentication and re-registered every start | `run_linux.py` pins it to an absolute path in the data dir |
| [PI-009] | `_bring_window_to_front_nonblocking` / focus keeper are Win32-only; on Wayland they ran every 0.5 s and logged "focus lost" forever | No-oped on Linux (`KIWY_FOCUS_KEEPER=1` restores) |
| [PI-010] | Clean shutdown (SIGTERM from the watchdog) wrote a bogus `FATAL: 0` crash log | `SystemExit(0)` is now a clean exit |
| [PI-011] | Web-link hand-off: without a dedicated `--user-data-dir` Chromium delegates the URL to an existing instance and exits in ~2 s | `LinuxChromiumAdapter` always uses a private profile, and clears stale `SingletonLock` |
| [PI-012] | `proc.terminate()` left Chromium's GPU/zygote/renderer children running (Leak → OOM over 24/7) | Kill the whole process group (`os.killpg`), the Linux twin of `taskkill /T` |
---
## 🧪 Tested & Rejected Solutions Log
| Date | What was tested | Result | Reason it failed |
|------|----------------|--------|-----------------|
| 2026-09-13 | `SDL_VIDEODRIVER=wayland` with the bundled SDL2 | ❌ | Bundled SDL2 has no wayland driver |
| 2026-09-13 | `SDL_VIDEODRIVER=x11` (+ XWayland present) | ❌ | No X server running on labwc; `x11 not available` |
| 2026-09-13 | `KIVY_GL_BACKEND=gles` vs `gl` | | Both equivalent; the failure was the driver, not GL |
| 2026-09-13 | Comma-separated `SDL_VIDEODRIVER` list | ✅ | Works — Kivy splits on `,` and SDL2 takes the first that initialises |
| 2026-09-13 | Relying on the Wayland socket without `WAYLAND_DISPLAY` | ❌ | SDL2 does not scan `XDG_RUNTIME_DIR` (unlike `wlopm`) |
| 2026-09-13 | System SDL2 symlinked over the bundled name | ✅ | Full driver set incl. wayland — **the fix** |
---
## 🧰 Environment / dependency facts
* **Must exist before the player will start:**
* `libsdl2-2.0-0` (system SDL2 **with** wayland)
* `libgl1-mesa-dri`, `libgles2`
* a running Wayland session (labwc) and a valid `XDG_RUNTIME_DIR`
* **Python packages** (all cp313 aarch64 wheels exist **except** evdev):
| Package | Source | Note |
|---------|--------|------|
| kivy 2.3.1 | PyPI wheel | needs `fix_kivy_sdl2.sh` on Trixie |
| ffpyplayer 4.5.3 | PyPI wheel | video/audio backend |
| aiohttp, requests, bcrypt | PyPI wheel / apt | already installed system-wide |
| **evdev 2.0.0** | **sdist only** | `python3-dev` + `build-essential` required, or use apt `python3-evdev` |
* **Tools present:** `wlopm`, `wlr-randr`, `vcgencmd`, `swayidle`, `chromium`,
`ffprobe`, `labwc`, `systemd-inhibit`, `zenity`
* **Tools MISSING:** `tvservice`, `xdotool`, `ydotool`, `chromium-browser`,
`glxinfo`, `ifconfig`, `dhclient` (last two matter for WiFi restart — Phase 3)
* **Networking:** NetworkManager is active; `dhcpcd`/`ifupdown` are not
* **`sudo` requires a password** → every boot-time `sudo` call is a hang risk
(see Phase 4 / `start.sh` `configure_display_resolution()`)
---
## 🧪 How to verify
```bash
cd /home/pi/Desktop/Kiwy-Signage
# 1. SDL2 has the wayland driver (the #1 blocker)
bash linux/fix_kivy_sdl2.sh --check
# 2. Platform patches wired correctly (WeakMethod trap, keep-awake, SDL2)
.venv/bin/python linux/test_linux_patches.py # expect: 21/21 passed
# 3. Keyring bypass + footprint flags actually applied
.venv/bin/python linux/test_linux_browser_flags.py # expect: 27/27 passed
# 4. Video decode + playback
.venv/bin/python linux/_probe_video.py # expect: position advances
# 5. Chromium footprint per profile (PSS, real page)
.venv/bin/python linux/_probe_chromium_footprint.py https://moto-adv.com/
# 6. The player itself
bash run_player.sh # or: bash start.sh (watchdog)
# 7. Diagnostics
.venv/bin/python linux/linux_display.py # backend/outputs/tools
bash linux/fix_kivy_sdl2.sh --revert # undo the SDL2 symlink
```
Useful escape hatches:
| Variable | Effect |
|----------|--------|
| `KIWY_DISPLAY_TOOLS_DISABLED=1` | Disable all `wlopm`/`vcgencmd`/`swayidle` work |
| `KIWY_CHROMIUM_MODE=light\|minimal\|safe` | Chromium footprint profile (default `light`) |
| `KIWY_VENV=/path` | Point the SDL2 fix script at another virtualenv |
---
## 📝 Next session
* [ ] **Phase 2** — web links: verify Chromium kiosk on labwc end-to-end
(fullscreen, no desktop flash, no leaked processes, interaction postpones)
* [ ] Evaluate **apt `python3-kivy`** — it bundles no SDL2 and may remove the
need for `fix_kivy_sdl2.sh` entirely
* [ ] **Phase 4** — autostart via `~/.config/labwc/autostart` +
systemd unit; repair `start.sh`'s `configure_display_resolution()`
(wrong `/boot/config.txt` path on Trixie → `/boot/firmware/config.txt`)
* [ ] **Phase 3** — card reader (evdev/udev) and WiFi restart via `nmcli`
(the current `ifconfig`/`dhclient` path depends on packages that are gone)
* [ ] Orientation/rotation via `wlr-randr` (implemented, untested on hardware)
* [ ] 24/7 soak test with real playlist content
* [ ] Confirm the `dist/` deployment hazard documented for Windows has no
Linux equivalent (it does not — there is no bundle on Linux)
+166
View File
@@ -0,0 +1,166 @@
#!/usr/bin/env bash
# fix_kivy_sdl2.sh — make Kivy use the system SDL2 (Wayland-capable).
#
# THE PROBLEM
# -----------
# Kivy's PyPI wheel bundles a private SDL2 in ``Kivy.libs/`` that is compiled
# WITHOUT the Wayland video driver. Verified on this Pi:
#
# Kivy.libs/libSDL2-2-87637523.0.so.0.3000.7 -> x11, KMSDRM, offscreen, dummy, evdev
# /usr/lib/aarch64-linux-gnu/libSDL2-2.0.so.0 -> x11, wayland, KMSDRM, offscreen, dummy, evdev
#
# On Raspberry Pi OS "Trixie" the desktop is Wayland/labwc and there is no
# X server running, so the bundled build cannot create a window at all:
#
# [CRITICAL] Unable to find any valuable Window provider.
# sdl2 - RuntimeError: b'wayland,x11,dummy not available'
#
# Substituting the system library under Kivy's bundled filename resolves it
# (verified: "WINDOW OK size=(800, 600)").
#
# WHY A SYMLINK AND NOT A COPY
# ----------------------------
# The symlink survives ``pip install --upgrade kivy`` overwriting the file, is
# reversible, and keeps the distro's security updates in effect. A copy would
# silently become stale.
#
# Usage:
# bash linux/fix_kivy_sdl2.sh # apply (idempotent)
# bash linux/fix_kivy_sdl2.sh --check # report only, change nothing
# bash linux/fix_kivy_sdl2.sh --revert # remove the symlink
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
VENV_DIR="${KIWY_VENV:-$ROOT_DIR/.venv}"
BACKUP_DIR="$VENV_DIR/.kivy-sdl2-backup"
SYSTEM_SDL2="/usr/lib/aarch64-linux-gnu/libSDL2-2.0.so.0"
MODE="apply"
case "${1:-}" in
--check) MODE="check" ;;
--revert) MODE="revert" ;;
"") MODE="apply" ;;
*) echo "Unknown option: $1" >&2; exit 2 ;;
esac
if [ ! -d "$VENV_DIR" ]; then
echo "ERROR: virtualenv not found at $VENV_DIR" >&2
echo " Set KIWY_VENV=/path/to/venv if it lives elsewhere." >&2
exit 1
fi
KIVY_LIBS="$(find "$VENV_DIR" -maxdepth 5 -type d -name 'Kivy.libs' 2>/dev/null | head -1)"
if [ -z "$KIVY_LIBS" ]; then
echo "No Kivy.libs directory found under $VENV_DIR — nothing to do."
echo "(Kivy is probably installed from apt, which uses the system SDL2 already.)"
exit 0
fi
# Kivy names the bundled library with a hash, so locate it by pattern rather
# than hard-coding the version. Symlinks must be matched too: once the fix is
# applied the original file IS a symlink, and skipping it would make this
# script wrongly conclude that nothing is bundled.
BUNDLED="$(find "$KIVY_LIBS" -maxdepth 1 -name 'libSDL2-2-*.so*' 2>/dev/null | head -1)"
# Fallback: ask the compiled extension which SDL2 it actually loads. This is
# authoritative and also covers a future Kivy layout change.
if [ -z "$BUNDLED" ]; then
EXT="$(find "$VENV_DIR" -maxdepth 6 -name '_window_sdl2*.so' 2>/dev/null | head -1)"
if [ -n "$EXT" ]; then
NEEDED="$(ldd "$EXT" 2>/dev/null | awk '/libSDL2-2-/ {print $3; exit}')"
[ -n "$NEEDED" ] && BUNDLED="$NEEDED"
fi
fi
echo "Kivy.libs : $KIVY_LIBS"
echo "bundled : ${BUNDLED:-<none>}"
echo "system : $SYSTEM_SDL2"
if [ ! -e "$SYSTEM_SDL2" ]; then
echo "ERROR: system SDL2 not found at $SYSTEM_SDL2" >&2
echo " Install it with: sudo apt install libsdl2-2.0-0" >&2
exit 1
fi
# Report the video drivers compiled into a library, so the fix is verifiable.
report_drivers() {
local lib="$1"
local py="${VENV_DIR}/bin/python"
[ -x "$py" ] || py="$(command -v python3)"
"$py" - "$lib" <<'PY' 2>/dev/null || echo " (could not inspect drivers)"
import ctypes, sys
try:
s = ctypes.CDLL(sys.argv[1])
s.SDL_GetNumVideoDrivers.restype = ctypes.c_int
s.SDL_GetVideoDriver.restype = ctypes.c_char_p
s.SDL_GetVideoDriver.argtypes = [ctypes.c_int]
n = s.SDL_GetNumVideoDrivers()
print(' drivers:', ', '.join(s.SDL_GetVideoDriver(i).decode() for i in range(n)))
except Exception as exc:
print(' (inspection failed:', exc, ')')
PY
}
case "$MODE" in
revert)
if [ -L "$BUNDLED" ]; then
rm -f "$BUNDLED"
if [ -f "$BACKUP_DIR/$(basename "$BUNDLED")" ]; then
cp -a "$BACKUP_DIR/$(basename "$BUNDLED")" "$BUNDLED"
echo "Reverted to the original bundled SDL2."
else
echo "Removed the symlink. Reinstall Kivy to restore the bundled library:"
echo " $VENV_DIR/bin/pip install --force-reinstall kivy"
fi
else
echo "Nothing to revert (no symlink in place)."
fi
exit 0
;;
check)
echo
if [ -L "$BUNDLED" ]; then
echo "STATUS: fixed (bundled name points at the system SDL2)"
report_drivers "$BUNDLED"
else
echo "STATUS: NOT fixed — Kivy is using its own SDL2"
report_drivers "$BUNDLED"
fi
echo
echo "system SDL2:"
report_drivers "$SYSTEM_SDL2"
exit 0
;;
apply)
if [ -z "$BUNDLED" ]; then
echo "No bundled libSDL2 found — Kivy already uses the system SDL2."
exit 0
fi
if [ -L "$BUNDLED" ]; then
echo "Already fixed (symlink in place). Verifying..."
report_drivers "$BUNDLED"
exit 0
fi
# Keep a copy of the original so --revert works without a reinstall.
mkdir -p "$BACKUP_DIR"
if [ ! -f "$BACKUP_DIR/$(basename "$BUNDLED")" ]; then
cp -a "$BUNDLED" "$BACKUP_DIR/"
echo "Backed up original -> $BACKUP_DIR/$(basename "$BUNDLED")"
fi
ln -sf "$SYSTEM_SDL2" "$BUNDLED"
echo "Symlinked $BUNDLED -> $SYSTEM_SDL2"
echo
echo "Resulting drivers:"
report_drivers "$BUNDLED"
echo
echo "Done. Kivy can now create a Wayland window."
echo "NOTE: re-run this script after any 'pip install --upgrade kivy'."
;;
esac
+603
View File
@@ -0,0 +1,603 @@
"""linux_browser.py — Chromium/Chrome kiosk adapter for Raspberry Pi (Wayland).
Why this module exists
----------------------
``src/weblink_session.py`` already owns the whole web-link lifecycle (launch →
verified visibility → interaction watching → teardown) and ships a generic
``ChromiumSubprocessAdapter`` that is the default on Linux. That adapter is
correct in structure but was never tuned for Raspberry Pi OS Trixie, where:
* the session is **Wayland/labwc**, so Chromium needs an explicit Ozone
platform or it may come up as an X11 (XWayland) surface that the compositor
will not make fullscreen;
* Chromium is ``/usr/bin/chromium`` (there is no ``chromium-browser``);
* ``--start-maximized`` is not what makes a window fullscreen on labwc;
``--kiosk`` is;
* without a dedicated ``--user-data-dir`` Chromium hands the URL to an
already-running instance, the process we launched exits in ~2 s, and the
session's ``wait_visible`` reports the item as failed;
* ``proc.terminate()`` only kills the parent; Chromium's GPU/zygote/renderer
children survive and accumulate over a 24/7 playlist.
This adapter is the platform counterpart of the generic
``ChromiumSubprocessAdapter`` and is injected through the existing
``SignagePlayer.weblink_adapter_factory`` hook — no changes to the shared
``play_weblink`` code path are required.
"""
from __future__ import annotations
import os
import shutil
import signal
import sys
import subprocess
import time
# The shared modules live in ../src. Add it explicitly so this file can be
# imported standalone (diagnostics, tests) and not only after run_linux.py has
# already put src/ on sys.path.
_SRC_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'src')
if _SRC_DIR not in sys.path:
sys.path.insert(0, _SRC_DIR)
from weblink_session import ChromiumSubprocessAdapter # noqa: E402
def _log(message, level='info'):
try:
from kivy.logger import Logger
getattr(Logger, level, Logger.info)(f'[LinuxBrowser] {message}')
except Exception:
pass
#: Wayland flags. The platform MUST be named explicitly.
#:
#: Measured on Chromium 152 / Raspberry Pi OS Trixie (labwc, no X server):
#:
#: (no flag) -> "Missing X server or $DISPLAY", aborts
#: --ozone-platform-hint=auto -> "Missing X server or $DISPLAY", aborts
#: --ozone-platform=wayland -> starts (9 processes)
#:
#: ``--ozone-platform-hint=auto`` does NOT fall back to Wayland when there is no
#: X server, contrary to how it is usually described; it simply fails. So the
#: flag is chosen explicitly from the detected session instead of being hinted.
WAYLAND_FLAGS = [
'--ozone-platform=wayland',
'--enable-features=UseOzonePlatform,WaylandWindowDecorations',
]
#: Flags that stop Chromium asking for the **keyring** password.
#:
#: This is the prompt that appeared at start-up. On Raspberry Pi OS
#: ``gnome-keyring-daemon --components=secrets`` runs and
#: ``DBUS_SESSION_BUS_ADDRESS`` is set, so Chromium's default password-store
#: backend resolves to ``gnome-libsecret`` (Secret Service). Chromium then tries
#: to unlock the login keyring to hold its encryption key, which raises a modal
#: prompt that cannot be answered on an unattended signage screen.
#:
#: ``--password-store=basic`` forces the built-in plain store so Chromium never
#: contacts the Secret Service. ``--use-mock-keychain`` covers the equivalent
#: code path on other platforms.
#:
#: These used to sit inside ``APPLIANCE_FLAGS``, but that list was never
#: referenced from ``extra_launch_args()``, so none of them ever reached the
#: command line — the prompt looked unfixable. ``test_linux_browser_flags.py``
#: now asserts they are actually applied.
KEYRING_BYPASS_FLAGS = [
'--password-store=basic',
'--use-mock-keychain',
]
#: Flags that reduce the memory footprint on a Pi 4.
#:
#: Measured for one real page (https://moto-adv.com/), **PSS** summed over the
#: whole browser tree — see ``_probe_chromium_footprint.py``:
#:
#: default flags 10 procs ~513 MB
#: + --disable-gpu 10 procs ~1038 MB <-- WORSE, do not use
#: + this list (light) 9 procs ~507 MB
#: + --single-process (minimal) 4 procs ~438 MB (~15% less)
#:
#: Two things worth knowing, both measured rather than assumed:
#:
#: * ``--disable-gpu`` is deliberately NOT included. Intuitively it should help,
#: but it moves rasterization out of the GPU process and into the renderer,
#: which *doubled* memory for a real page.
#: * Chromium's baseline cost is simply large. The remaining ~500 MB is the
#: browser itself, so flag tuning yields only single-digit percentages.
#: ``--single-process`` is the only large lever (~15%), and it is opt-in
#: because upstream labels it unsupported.
#:
#: RSS is NOT the right metric here: Chromium shares libraries and file pages
#: across its processes, so summing RSS double-counts and produced numbers that
#: were ~2x too high and misleadingly ranked a smaller configuration as larger.
#:
#: Select a profile with KIWY_CHROMIUM_MODE=light|minimal|safe.
LIGHT_WEIGHT_FLAGS = [
# One renderer instead of a pool.
'--renderer-process-limit=1',
# Do not retain a renderer for a window that is not visible.
'--disable-backgrounding-occluded-windows',
'--disable-renderer-backgrounding',
'--disable-breakpad',
# Keep the process count down; each helper is a forked Chromium.
'--disable-site-isolation-trials',
'--disable-features=site-per-process,IsolateOrigins',
# No persisted session state to load on start.
'--no-restore-session-state',
]
#: ``--single-process`` is the one large lever (~438 MB vs ~507 MB) but Chromium
#: upstream labels the mode unsupported. Selected via
#: KIWY_CHROMIUM_MODE=minimal; verified stable over repeated launches of the
#: real weblink page.
#:
#: NOTE: ``--disable-gpu`` is intentionally absent from every list. It measured
#: WORSE (~1038 MB) and is not worth a dead constant to keep around.
MINIMAL_FLAGS = ['--single-process']
#: Everything a static page does not need. Each service is a separate process.
SERVICE_REDUCTION_FLAGS = [
'--no-service-autorun',
'--disable-component-extensions-with-background-pages',
'--disable-default-apps',
'--disable-extensions',
'--disable-plugins-discovery',
'--disable-preconnect',
'--disable-domain-reliability',
'--disable-client-side-phishing-detection',
'--disable-hang-monitor',
'--metrics-recording-only',
'--no-pings',
]
def _chromium_mode():
"""Footprint profile: ``light`` (default), ``minimal`` or ``safe``.
* ``light`` — safe reductions, no GPU, one renderer (recommended)
* ``minimal`` — adds ``--single-process``; smallest, can crash
* ``safe`` — no footprint flags at all, for ruling them out when debugging
"""
value = os.environ.get('KIWY_CHROMIUM_MODE', 'light').strip().lower()
if value not in ('light', 'minimal', 'safe'):
_log(f'unknown KIWY_CHROMIUM_MODE={value!r}; using "light"', 'warning')
return 'light'
return value
#: Flags that make a signage page behave like an appliance.
#:
#: NOTE: every list here must be referenced from ``extra_launch_args()``. This
#: list was dead code once, which silently disabled the keyring bypass along
#: with every kiosk nicety.
APPLIANCE_FLAGS = [
'--noerrdialogs',
'--disable-infobars',
'--no-first-run',
'--no-default-browser-check',
'--disable-session-crashed-bubble',
'--disable-features=Translate,BackForwardCache,AcceptCHFrame,MediaRouter,OptimizationHints,PasswordManagerOnboarding,AutofillServerCommunication,PasswordLeakDetection',
'--disable-background-networking',
'--disable-component-update',
'--disable-sync',
'--check-for-update-interval=31536000',
'--autoplay-policy=no-user-gesture-required',
'--hide-scrollbars',
'--disable-pinch',
'--overscroll-history-navigation=0',
'--force-device-scale-factor=1',
'--window-position=0,0',
]
#: Never offer to save or autofill credentials — a second source of prompts.
#: The pages shown are public, so suppressing this costs nothing.
NO_PROMPTS_FLAGS = [
'--disable-save-password-bubble',
]
def _browser_env():
"""Environment for the browser process only — never the player.
Defence in depth for the keyring prompt. Even if Chromium ignores
``--password-store=basic``, an emptied ``GNOME_KEYRING_CONTROL`` and a
removed ``DBUS_SESSION_BUS_ADDRESS`` mean the Secret Service cannot be
reached, so no unlock prompt can be raised at all.
Scoped to the child deliberately: the player keeps its real session bus,
which other components may rely on.
"""
env = dict(os.environ)
env['GNOME_KEYRING_CONTROL'] = ''
env['CHROME_PASSWORD_STORE'] = 'basic'
# Nothing a single static page needs requires the session bus.
env.pop('DBUS_SESSION_BUS_ADDRESS', None)
env.pop('DBUS_SESSION_BUS_PID', None)
# Discourage portal / keyring autostart helpers from being pulled in.
env['XDG_DESKTOP_PORTAL_SUPPRESS'] = '1'
return env
def find_linux_browser():
"""Locate a Chromium-family browser, preferring the Debian/RPi names.
``chromium-browser`` is checked first only because older RPi OS releases
shipped it as the wrapper name; on Trixie the real binary is ``chromium``.
"""
for candidate in (
'chromium-browser', # RPi OS <= Bullseye wrapper
'chromium', # RPi OS Bookworm/Trixie
'google-chrome',
'google-chrome-stable',
'chrome',
'microsoft-edge',
):
path = shutil.which(candidate)
if path:
return path
return None
class LinuxChromiumAdapter(ChromiumSubprocessAdapter):
"""Chromium kiosk subprocess for Raspberry Pi OS (Wayland/labwc).
Subclasses :class:`ChromiumSubprocessAdapter` so the session's health
checking, interaction watching and generation-tokened teardown all keep
working; only the Linux-specific behaviour is overridden.
"""
name = 'chromium-kiosk-linux'
embedded = False
def __init__(self, browser_path=None, extra_flags=(), kiosk=True,
profile_dir=None, use_wayland=None):
super().__init__(browser_path=browser_path, extra_flags=extra_flags,
kiosk=kiosk)
self._profile_dir = profile_dir
# Flags follow the detected session. An explicit --ozone-platform is
# required on Wayland (see WAYLAND_FLAGS); when there is no Wayland
# socket the flags are omitted so Chromium uses its own default (X11).
self._use_wayland = _detect_wayland() if use_wayland is None else bool(use_wayland)
# Track our own process group so teardown can reap the whole tree.
self._pgid = None
self._preflight_done = False
# ── Launch environment / session ─────────────────────────────────
def launch_env(self):
"""Environment for the browser process only.
This — not the ``--password-store`` flag — is what actually removes the
keyring password prompt. The base ``Popen`` inherited the player's
environment, which includes ``DBUS_SESSION_BUS_ADDRESS``; Chromium could
therefore reach the running ``gnome-keyring-daemon`` and tried to unlock
the login keyring.
"""
return _browser_env()
def start_new_session(self):
"""Give the browser its own process group so teardown can reap it.
The base implementation used a plain ``Popen``, so the browser shared
the player's process group and ``os.killpg`` was never usable — every
weblink left Chromium's GPU/zygote/renderer children behind.
"""
return True
# ── Flags ────────────────────────────────────────────────────────
def extra_launch_args(self):
"""Flags appended to the Chromium command line.
Ordering is deliberate: appliance/keyring flags come first so a later
list can never accidentally be shadowed, and every module-level list is
referenced here. If you add a list above, add it here too — a list that
nothing references is how the keyring prompt survived a "fix".
"""
args = []
# 1. Never touch the Secret Service / keyring (the password prompt).
args.extend(KEYRING_BYPASS_FLAGS)
args.extend(NO_PROMPTS_FLAGS)
# 2. Kiosk behaviour: no browser UI, no error dialogs, no autofill.
args.extend(APPLIANCE_FLAGS)
args.extend(SERVICE_REDUCTION_FLAGS)
# 3. Footprint. A static signage page needs a fraction of Chromium's
# defaults; each avoided helper is a process the Pi 4 does not have
# RAM for.
mode = _chromium_mode()
if mode in ('light', 'minimal'):
args.extend(LIGHT_WEIGHT_FLAGS)
if mode == 'minimal':
args.extend(MINIMAL_FLAGS)
# 4. Identity and geometry.
if self._profile_dir:
args.append('--user-data-dir=' + self._profile_dir)
if self._kiosk:
# --kiosk implies fullscreen and removes all browser UI, which is
# the supported path on wlroots compositors.
args.append('--kiosk')
if self._use_wayland:
args.extend(WAYLAND_FLAGS)
return args
def launch(self, url, width, height):
"""Prepare the private profile, then delegate to the base launch.
Any Chromium still holding the profile is killed first: a surviving
instance would swallow the URL and make our process exit immediately.
"""
self._preflight()
if self._profile_dir is None:
self._profile_dir = os.path.join(
os.environ.get('KIWY_DATA_DIR') or os.getcwd(), '.kiosk-profile'
)
try:
os.makedirs(self._profile_dir, exist_ok=True)
except Exception as exc:
_log(f'could not create kiosk profile {self._profile_dir}: {exc}', 'warning')
self._kill_browsers_on_profile()
self._cleanup_stale_profile_locks()
ok = super().launch(url, width, height)
if ok and self._proc is not None:
self._pgid = _safe_getpgid(self._proc.pid)
return ok
def _preflight(self):
"""Warn once when Chromium's platform flags do not match this session.
The failure this guards against is silent: on a Wayland session with no
X server, Chromium started without ``--ozone-platform=wayland`` aborts
after ~1 s with "Missing X server or $DISPLAY" on stderr, which reads
identically to a hand-off bug. Detecting the mismatch at launch time
turns an unexplained skipped weblink into an actionable log line.
"""
if getattr(self, '_preflight_done', False):
return
self._preflight_done = True
has_x_server = bool(os.environ.get('DISPLAY'))
if self._use_wayland and not has_x_server:
_log('session is Wayland-only; launching Chromium with '
'--ozone-platform=wayland (the default and --ozone-platform-hint '
'both abort with "Missing X server" here)')
elif not self._use_wayland and has_x_server:
_log('session is X11; launching Chromium without Wayland flags')
elif self._use_wayland and has_x_server:
_log('both Wayland and X11 available; preferring Wayland')
# ── Startup verification ─────────────────────────────────────────
def wait_visible(self, timeout):
"""Wait until the launched Chromium is genuinely up and not a hand-off.
A plain "process still alive" check is not enough on Linux: a hand-off
launch also stays alive briefly, and a missing Wayland socket produces a
fast exit. Two signals are combined:
* the process must survive the health grace period; and
* a Chromium window/toplevel must be observable for this PID.
If the second cannot be established on this compositor we still return
success on the first, so a working-but-unprobeable setup is never
skipped (that would be worse than a possible blank frame).
"""
proc = self._proc
if proc is None:
return False, 'no process'
deadline = time.monotonic() + max(1.0, float(timeout))
grace = min(float(self._health_grace), max(0.5, float(timeout)))
grace_deadline = time.monotonic() + grace
while time.monotonic() < grace_deadline:
if proc.poll() is not None:
return False, f'browser exited immediately (rc={proc.returncode})'
time.sleep(0.1)
if _window_exists_for_pid(proc.pid):
return True, f'toplevel-for-pid={proc.pid}'
while time.monotonic() < deadline:
if proc.poll() is not None:
return False, f'browser exited early (rc={proc.returncode})'
if _window_exists_for_pid(proc.pid):
return True, f'toplevel-for-pid={proc.pid}'
time.sleep(0.2)
if proc.poll() is None:
# Alive past the timeout but not probeable — accept rather than
# skipping a page that is probably on screen.
return True, 'process-alive-unverified'
return False, 'browser window never appeared'
# ── Teardown ─────────────────────────────────────────────────────
def teardown(self):
"""Terminate the whole Chromium process group.
``proc.terminate()`` (the base behaviour) leaves the GPU, zygote and
renderer children behind; over a 24/7 playlist those accumulate until
the Pi runs out of memory. Killing the process group reaps them all.
"""
proc, self._proc = self._proc, None
pgid, self._pgid = self._pgid, None
if proc is None:
return
if proc.poll() is None:
_kill_process_group(proc, pgid)
# Belt and braces: reap anything else still holding this profile.
self._kill_browsers_on_profile()
self._cleanup_stale_profile_locks()
# ── Helpers ──────────────────────────────────────────────────────
def _cleanup_stale_profile_locks(self):
"""Remove the singleton lock a crashed Chromium left behind.
Chromium refuses to start on a profile whose ``SingletonLock`` points at
a dead PID (or shows the "profile in use" dialog). Because our profile
is private to the player, clearing the lock is always safe.
"""
if not self._profile_dir:
return
for name in ('SingletonLock', 'SingletonSocket', 'SingletonCookie'):
path = os.path.join(self._profile_dir, name)
try:
if os.path.islink(path) or os.path.exists(path):
os.unlink(path)
_log(f'cleared stale profile lock {name}', 'debug')
except Exception:
pass
def _kill_browsers_on_profile(self):
"""Kill any Chromium holding our kiosk profile.
Scans ``/proc/<pid>/cmdline`` rather than shelling out to ``pgrep`` so
this works without procps and cannot match the wrong process.
"""
if not self._profile_dir:
return []
marker = self._profile_dir
own_uid = os.getuid()
killed = []
try:
for entry in os.listdir('/proc'):
if not entry.isdigit():
continue
pid = int(entry)
if pid == os.getpid():
continue
try:
if os.stat(f'/proc/{pid}').st_uid != own_uid:
continue
with open(f'/proc/{pid}/cmdline', 'rb') as fh:
cmdline = fh.read().replace(b'\x00', b' ').decode(
'utf-8', 'replace'
)
except (OSError, PermissionError, ProcessLookupError):
continue
if marker in cmdline and 'chrom' in cmdline.lower():
try:
os.kill(pid, signal.SIGTERM)
killed.append(pid)
except Exception:
continue
except Exception as exc:
_log(f'profile scan failed: {exc}', 'debug')
if killed:
_log(f'terminated {len(killed)} leaked browser(s) on the kiosk '
f'profile: {killed}')
return killed
# ── Module-level helpers ─────────────────────────────────────────────
def _detect_wayland():
"""True when the session looks like Wayland.
Uses ``linux_display`` when importable (it also fills in an unset
``WAYLAND_DISPLAY``, which Chromium needs for --ozone-platform-hint), and
falls back to a socket probe so this module stays independently testable.
"""
if os.environ.get('WAYLAND_DISPLAY'):
return True
try:
import linux_display
linux_display.ensure_session_environment()
return linux_display.is_wayland()
except Exception:
runtime = os.environ.get('XDG_RUNTIME_DIR') or f'/run/user/{os.getuid()}'
return os.path.exists(os.path.join(runtime, 'wayland-0'))
def _safe_getpgid(pid):
try:
return os.getpgid(pid)
except Exception:
return None
def _kill_process_group(proc, pgid):
"""SIGTERM then SIGKILL the browser's process group."""
target = pgid if pgid and pgid != os.getpgid(0) else None
try:
if target:
os.killpg(target, signal.SIGTERM)
else:
proc.terminate()
except Exception:
try:
proc.terminate()
except Exception:
pass
# Give Chromium a moment to flush and exit cleanly.
deadline = time.monotonic() + 5.0
while time.monotonic() < deadline:
if proc.poll() is not None:
break
time.sleep(0.1)
if proc.poll() is None:
try:
if target:
os.killpg(target, signal.SIGKILL)
else:
proc.kill()
except Exception:
pass
try:
proc.wait(timeout=3)
except Exception:
pass
def _window_exists_for_pid(pid):
"""Best-effort check that ``pid`` owns a visible top-level surface.
Two independent probes, because neither works everywhere:
1. ``/proc/<pid>/fd`` — Chromium holds the Wayland/X11 socket open once it
has connected and started creating surfaces.
2. ``/proc/<pid>/task/*/comm`` — the GPU/renderer children only appear once
the browser has actually started rendering.
Returns False on any error; the caller falls back to "process alive".
"""
try:
fd_dir = f'/proc/{pid}/fd'
socket_links = 0
for fd in os.listdir(fd_dir):
try:
target = os.readlink(os.path.join(fd_dir, fd))
except OSError:
continue
if 'wayland' in target or 'X11-unix' in target:
socket_links += 1
if socket_links:
return True
except Exception:
pass
try:
task_dir = f'/proc/{pid}/task'
for tid in os.listdir(task_dir):
try:
with open(os.path.join(task_dir, tid, 'comm')) as fh:
comm = fh.read().strip()
except OSError:
continue
if comm in ('Chrome_ChildIOT', 'Chrome_IOThread'):
return True
except Exception:
pass
return False
+425
View File
@@ -0,0 +1,425 @@
"""linux_display.py — display power, keep-awake and rotation for Raspberry Pi OS.
Why this module exists
----------------------
On Raspberry Pi OS "Trixie" the desktop session is **Wayland/labwc**, and an
idle blanker is installed and running by default::
swayidle -w timeout 600 'wlopm --off *' resume 'wlopm --on *'
A signage player must never blank, so that line fights the player for control of
the output. The historical implementation in ``main.py``
(``signal_screen_activity``) shells out to X11 tools that no longer exist on
Trixie (``xdotool``, ``tvservice``, ``ydotool`` are all absent) and uses a
mis-escaped ``wlopm --on \\*`` argument, so it kept the screen awake on none of
the current installs.
This module replaces that logic with the commands Trixie actually provides:
* ``wlopm`` — Wayland output power management (present, works)
* ``vcgencmd display_power`` — Raspberry Pi firmware-level display power
* ``wlr-randr`` — output configuration, used for rotation (present)
All work is best-effort and non-fatal: running the player over SSH, or on a
desktop without a compositor, must never crash or spam the log.
The platform entry point (``linux/run_linux.py``) installs
:func:`linux_screen_activity` onto ``SignagePlayer`` before playback starts.
"""
from __future__ import annotations
import os
import shutil
import subprocess
import time
# ── Tunables ─────────────────────────────────────────────────────────
#: Don't re-issue the keep-awake commands more often than this (seconds).
#: ``signal_screen_activity`` is called on a 20 s Kivy interval; the commands
#: are idempotent but spawning processes on a Pi is not free.
MIN_REASSERT_INTERVAL = 5.0
#: Set to "1" to disable every display command (diagnostics / desktop testing).
DISABLE_ENV_VAR = 'KIWY_DISPLAY_TOOLS_DISABLED'
#: Output name used when the compositor does not report one.
DEFAULT_OUTPUT = 'HDMI-A-1'
_state = {
'last_awake_at': 0.0,
'blinker_pids': [],
'logged_backend': False,
'warned_no_backend': False,
}
def _log(message, level='info'):
"""Log through Kivy when available, else print. Never raises."""
try:
from kivy.logger import Logger
getattr(Logger, level, Logger.info)(f'[Display] {message}')
except Exception:
try:
if level in ('error', 'warning'):
print(f'[Display] {message}')
except Exception:
pass
# ── Environment detection ────────────────────────────────────────────
def is_wayland():
"""True when a Wayland compositor socket is reachable.
Deliberately based on the socket, not on ``WAYLAND_DISPLAY``: the variable
is empty in exactly the launch contexts this module exists to fix (systemd,
cron, SSH), where the socket is nevertheless present and usable.
"""
return wayland_socket_path() is not None
def is_x11():
"""True when an X11 display is reachable (and Wayland is not)."""
return bool(os.environ.get('DISPLAY')) and not is_wayland()
def tools_disabled():
"""Honour the operator escape hatch."""
return os.environ.get(DISABLE_ENV_VAR, '').strip().lower() in ('1', 'true', 'yes')
# ── Command helper ───────────────────────────────────────────────────
def wayland_socket_path():
"""Absolute path of the Wayland socket, or None."""
runtime = os.environ.get('XDG_RUNTIME_DIR') or f'/run/user/{os.getuid()}'
display = os.environ.get('WAYLAND_DISPLAY') or ''
if display:
# Absolute names are used as-is; relative names live in XDG_RUNTIME_DIR.
return display if os.path.isabs(display) else os.path.join(runtime, display)
candidate = os.path.join(runtime, 'wayland-0')
return candidate if os.path.exists(candidate) else None
def ensure_session_environment():
"""Fill in the display environment for our child processes.
**This is the bug that made keep-awake silently useless.** A process started
by ``systemd``, a cron wrapper or an SSH session has ``XDG_RUNTIME_DIR`` set
but ``WAYLAND_DISPLAY`` *empty* — the compositor only exports it inside the
desktop session. ``wlopm`` then fails with::
ERROR: WAYLAND_DISPLAY is not set.
and exits 1, so the panel blanks after the idle timeout. Detecting the
socket and exporting the name (derived from its filename, so an unusual
``wayland-1`` still works) makes the tools function regardless of how the
player was launched.
Only ever *sets* values that are missing, so an operator-provided value is
never overridden. Returns a dict of what was changed (for logging).
"""
changed = {}
runtime = os.environ.get('XDG_RUNTIME_DIR')
if not runtime:
fallback = f'/run/user/{os.getuid()}'
if os.path.isdir(fallback):
os.environ['XDG_RUNTIME_DIR'] = fallback
changed['XDG_RUNTIME_DIR'] = fallback
runtime = fallback
if not os.environ.get('WAYLAND_DISPLAY') and runtime:
try:
# Prefer wayland-0, else the lowest-numbered socket present.
candidates = sorted(
name for name in os.listdir(runtime)
if name.startswith('wayland-') and not name.endswith('.lock')
)
if candidates:
os.environ['WAYLAND_DISPLAY'] = candidates[0]
changed['WAYLAND_DISPLAY'] = candidates[0]
except Exception:
pass
return changed
def _run(args, timeout=5.0):
"""Run a command quietly. Returns (returncode, stdout) — never raises.
``stderr`` is discarded: ``wlopm``/``wlr-randr`` are chatty about compositor
details we do not act on, and the player's log is noise-sensitive.
"""
ensure_session_environment()
try:
result = subprocess.run(
args,
stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL,
stdin=subprocess.DEVNULL,
timeout=timeout,
text=True,
check=False,
)
return result.returncode, (result.stdout or '').strip()
except FileNotFoundError:
return 127, ''
except subprocess.TimeoutExpired:
_log(f'{args[0]} timed out after {timeout}s', 'debug')
return 124, ''
except Exception as exc: # pragma: no cover - defensive
_log(f'{args[0]} failed: {exc}', 'debug')
return 1, ''
def _which(name):
try:
return shutil.which(name)
except Exception:
return None
# ── Output discovery ─────────────────────────────────────────────────
def list_outputs():
"""Return the connected Wayland output names (best effort).
Uses ``wlopm`` (no arguments lists ``<name> <on|off>`` per line), falling
back to ``wlr-randr`` so rotation works even where ``wlopm`` is missing.
"""
if not is_wayland():
return []
code, out = _run(['wlopm'])
if code == 0 and out:
names = []
for line in out.splitlines():
parts = line.split()
if parts:
names.append(parts[0])
if names:
return names
code, out = _run(['wlr-randr'])
if code == 0 and out:
names = []
for line in out.splitlines():
# Output lines are unindented: "HDMI-A-1 \"...\""
if line and not line[0].isspace():
names.append(line.split()[0])
return names
return []
def _target_output():
"""The output to address, or ``'*'`` so the compositor expands it.
``wlopm`` expands ``'*'`` itself, which is more robust than us guessing the
connector name (HDMI-A-1 / HDMI-A-2 / DSI-1 differ per board and port).
"""
return '*'
# ── Keep-awake ───────────────────────────────────────────────────────
def keep_display_awake(force=False):
"""Turn the display back on. Cheap, idempotent, rate-limited.
Returns True when something was actually issued.
"""
if tools_disabled():
return False
now = time.monotonic()
if not force and (now - _state['last_awake_at']) < MIN_REASSERT_INTERVAL:
return False
_state['last_awake_at'] = now
issued = False
failures = []
if is_wayland() and _which('wlopm'):
# NOTE: the argument must stay the literal '*' — the shell must not
# expand it (we pass a list, so no shell is involved) and wlopm does
# the matching. The old code passed a backslash-escaped '\*' through
# os.system(), so the compositor matched an output literally named '*'
# and nothing happened.
ensure_session_environment()
code, out = _run(['wlopm', '--on', _target_output()])
issued = issued or code == 0
if code != 0:
failures.append(f'wlopm --on {_target_output()} rc={code} {out}'.strip())
elif is_x11():
if _which('xset'):
code, _ = _run(['xset', 's', 'reset'])
issued = issued or code == 0
_run(['xset', 'dpms', 'force', 'on'])
if _which('xdotool'):
# Nudge the pointer by a pixel and back — invisible, but enough to
# reset the X idle counter on compositors without a Wayland path.
code, _ = _run(['xdotool', 'mousemove_relative', '1', '1'])
if code == 0:
_run(['xdotool', 'mousemove_relative', '-1', '-1'])
issued = True
# Firmware-level backstop: covers a blanked HDMI signal even when the
# compositor never reported the output as off.
if _which('vcgencmd'):
_run(['vcgencmd', 'display_power', '1'])
if failures and not _state['warned_no_backend']:
_state['warned_no_backend'] = True
_log(
'keep-awake command failed: ' + '; '.join(failures)
+ f' (WAYLAND_DISPLAY={os.environ.get("WAYLAND_DISPLAY")!r})',
'warning',
)
elif not issued and not _state['warned_no_backend']:
_state['warned_no_backend'] = True
_log(
'No usable display backend found (no Wayland socket and no X11 '
'display) — keep-awake is inactive.',
'warning',
)
return issued
def neutralise_idle_blanker():
"""Stop the desktop idle blanker from turning the panel off.
On Raspberry Pi OS Trixie ``~/.config/labwc/autostart`` starts::
swayidle -w timeout 600 'wlopm --off *' resume 'wlopm --on *'
That is exactly the behaviour a signage player must override, so the
``swayidle`` process is terminated once at start-up and re-checked
periodically (in case the session restarts it).
Only processes owned by the current user are touched, and a failure is
never fatal. Set ``KIWY_DISPLAY_TOOLS_DISABLED=1`` to opt out.
"""
if tools_disabled() or not _which('swayidle'):
return []
try:
result = subprocess.run(
['pgrep', '-x', 'swayidle'],
stdout=subprocess.PIPE, stderr=subprocess.DEVNULL,
text=True, timeout=5, check=False,
)
pids = [int(p) for p in (result.stdout or '').split() if p.strip().isdigit()]
except Exception:
return []
own_uid = os.getuid()
killed = []
for pid in pids:
try:
# Only our own processes: swayidle is per-session and killing
# another user's would be a surprise.
if os.stat(f'/proc/{pid}').st_uid != own_uid:
continue
os.kill(pid, 15) # SIGTERM
killed.append(pid)
except (ProcessLookupError, PermissionError, FileNotFoundError):
continue
except Exception:
continue
if killed:
_log(f'Stopped the idle blanker (swayidle pids: {killed}) — the display '
f'will stay on. Disable with {DISABLE_ENV_VAR}=1.')
return killed
def _log_backend_once():
if _state['logged_backend']:
return
_state['logged_backend'] = True
backend = 'wayland' if is_wayland() else ('x11' if is_x11() else 'none')
_log(f'backend={backend} outputs={list_outputs() or "unknown"} '
f'WAYLAND_DISPLAY={os.environ.get("WAYLAND_DISPLAY") or "(unset)"} '
f'wlopm={bool(_which("wlopm"))} vcgencmd={bool(_which("vcgencmd"))}')
# ── SignagePlayer replacement ────────────────────────────────────────
def linux_screen_activity(self, dt):
"""Drop-in replacement for ``SignagePlayer.signal_screen_activity``.
Bound to a 20 s Kivy interval, so it must be cheap and must never raise.
"""
try:
_log_backend_once()
keep_display_awake()
# Cheap re-check: the blanker may have been restarted by the session,
# e.g. after a compositor reload.
if (time.monotonic() - _state.get('last_blinker_check', 0.0)) > 60.0:
_state['last_blinker_check'] = time.monotonic()
neutralise_idle_blanker()
except Exception as exc: # pragma: no cover - must never break the Clock
_log(f'screen activity signal failed (non-fatal): {exc}', 'debug')
# ── Orientation / resolution (Wayland-native) ────────────────────────
#: Logical rotation for each supported orientation value from app_config.json.
ORIENTATION_TRANSFORMS = {
'landscape': 'normal',
'portrait': '90',
'portrait-inverted': '270',
'landscape-inverted': '180',
}
def apply_orientation(orientation):
"""Rotate the display to match the configured orientation.
``Window.size`` cannot rotate a fullscreen Wayland surface, so the rotation
has to happen at the output level. ``wlr-randr`` is the tool that works on
labwc; the call is skipped silently when it is unavailable.
Returns True when a transform was applied.
"""
if tools_disabled():
return False
key = str(orientation or '').strip().lower()
transform = ORIENTATION_TRANSFORMS.get(key)
if not transform or transform == 'normal':
return False
if not is_wayland() or not _which('wlr-randr'):
_log(f'Orientation "{orientation}" requested but wlr-randr/Wayland is '
f'unavailable — leaving the output unrotated.', 'warning')
return False
outputs = list_outputs() or [DEFAULT_OUTPUT]
applied = False
for name in outputs:
code, _ = _run(['wlr-randr', '--output', name, '--transform', transform])
applied = applied or code == 0
if applied:
_log(f'Applied orientation "{orientation}" (transform={transform}) to '
f'{outputs}')
return applied
def status():
"""Diagnostic snapshot (used by tests and the startup banner)."""
return {
'wayland': is_wayland(),
'wayland_socket': wayland_socket_path(),
'wayland_display': os.environ.get('WAYLAND_DISPLAY') or '(unset)',
'x11': is_x11(),
'disabled': tools_disabled(),
'outputs': list_outputs(),
'wlopm': bool(_which('wlopm')),
'wlr_randr': bool(_which('wlr-randr')),
'vcgencmd': bool(_which('vcgencmd')),
}
if __name__ == '__main__': # pragma: no cover - manual diagnostics
import json
import sys
if len(sys.argv) > 1 and sys.argv[1] == '--awake':
print('awake issued:', keep_display_awake(force=True))
print('blinker killed:', neutralise_idle_blanker())
print(json.dumps(status(), indent=2))
+467
View File
@@ -0,0 +1,467 @@
"""monitor_player.py — CPU, GPU, temperature and memory monitor for the player.
A 24/7 signage player on a Raspberry Pi 4 fails in slow, quiet ways: the SoC
throttles, the CPU governor parks a core, memory creeps up until the OOM killer
picks a victim. This samples the numbers that reveal those trends before they
become a blank screen.
Usage:
# live view, 5s interval, until Ctrl+C
.venv/bin/python linux/monitor_player.py
# 10 minutes at 2s, also written to CSV
.venv/bin/python linux/monitor_player.py --interval 2 --duration 600
# background logging only (no live view)
.venv/bin/python linux/monitor_player.py --quiet --duration 3600 \
--csv logs/monitor-$(date +%F-%H%M).csv
What is measured, and why each one matters:
| Metric | Why it matters here |
|--------|--------------------|
| Temperature | Pi 4 throttles at 80 °C (soft limit 85 °C). Sustained high temp means the case or heatsink is inadequate for 24/7. |
| Throttle flags | ``vcgencmd get_throttled`` distinguishes *currently throttled* from *has throttled since boot* — the second one only (bits 16-19) reveals a problem that already happened. |
| ARM clock | Dropping below the configured max mid-run is the signature of thermal/voltage capping. |
| V3D / pixel clock | The VideoCore GPU clock. A weblink drives it; a static image barely does. |
| Core voltage | Under-voltage (bit 0 of the throttle mask) is almost always an inadequate PSU, and it corrupts SD cards. |
| Per-core CPU | One pegged core is normal for Kivy (single-threaded main loop); all four pegged is not. |
| Player PSS | Proportional set size of the player process — the honest figure. |
| Chromium total | A *count* as much as memory: a rising count means weblinks are leaking. |
| /dev/shm | Chromium renders through shared memory; if it fills, pages render blank. |
GPU utilisation is deliberately absent: the Pi 4's V3D exposes no
``gpu_busy_percent`` (that is a Pi 5 / v3d-drm feature) and
``/sys/kernel/debug/dri/0/gpu_stats`` does not exist here. GPU activity is
therefore inferred from the clock domains and the temperature, which is the
best this hardware offers.
"""
from __future__ import annotations
import argparse
import csv
import os
import subprocess
import sys
import time
from datetime import datetime
try:
import psutil
except ImportError:
print('psutil is required: .venv/bin/pip install psutil', file=sys.stderr)
raise SystemExit(2)
# ── Constants ────────────────────────────────────────────────────────
#: Pi 4 begins soft-throttling around here. Warning threshold, not a limit.
TEMP_WARN_C = 70.0
#: Active soft-throttle limit on Pi 4.
TEMP_CRITICAL_C = 80.0
#: ``vcgencmd get_throttled`` bit meanings. The 16-19 group is sticky: it
#: records that the condition occurred at any point since boot, which is the
#: only way to catch an intermittent brown-out on an unattended device.
THROTTLE_BITS = {
0: ('NOW', 'under-voltage'),
1: ('NOW', 'arm frequency capped'),
2: ('NOW', 'currently throttled'),
3: ('NOW', 'soft temperature limit'),
16: ('HAS', 'under-voltage occurred'),
17: ('HAS', 'arm frequency capping occurred'),
18: ('HAS', 'throttling occurred'),
19: ('HAS', 'soft temperature limit occurred'),
}
CLOCK_DOMAINS = ('arm', 'core', 'v3d', 'h264', 'pixel')
CSV_FIELDS = [
'timestamp', 'uptime_s',
'cpu_total_pct', 'cpu0_pct', 'cpu1_pct', 'cpu2_pct', 'cpu3_pct',
'load1', 'load5', 'load15',
'temp_c', 'volt_core_v',
'arm_mhz', 'core_mhz', 'v3d_mhz', 'pixel_mhz', 'h264_mhz',
'governor', 'arm_cur_mhz_cfg',
'mem_used_mb', 'mem_avail_mb', 'shm_used_mb',
'player_pss_mb', 'player_cpu_pct', 'player_threads',
'chromium_procs', 'chromium_pss_mb',
'throttled_raw', 'throttle_flags',
]
def _run(args, timeout=5):
"""Run a command, return stripped stdout, or '' on any failure."""
try:
result = subprocess.run(
args, capture_output=True, text=True, timeout=timeout, check=False,
)
return (result.stdout or '').strip()
except Exception:
return ''
def _read(path):
try:
with open(path) as fh:
return fh.read().strip()
except Exception:
return ''
# ── Individual metrics ───────────────────────────────────────────────
def read_temp_c():
"""SoC temperature in °C from hwmon, falling back to vcgencmd/thermal."""
raw = _read('/sys/class/hwmon/hwmon0/temp1_input')
if raw.isdigit():
return int(raw) / 1000.0
for zone in ('/sys/class/thermal/thermal_zone0/temp',):
raw = _read(zone)
if raw.isdigit():
return int(raw) / 1000.0
out = _run(['vcgencmd', 'measure_temp'])
# "temp=50.1'C"
try:
return float(out.split('=')[1].split("'")[0])
except Exception:
return None
def read_clock_mhz(domain):
"""Clock frequency in MHz for a vcgencmd domain."""
out = _run(['vcgencmd', 'measure_clock', domain])
# "frequency(48)=1800457088"
try:
return int(out.split('=')[1]) / 1_000_000.0
except Exception:
return None
def read_core_volts():
out = _run(['vcgencmd', 'measure_volts', 'core'])
# "volt=0.9160V"
try:
return float(out.split('=')[1].rstrip('V'))
except Exception:
return None
def read_throttled():
"""(raw_int, [human readable flags]) from vcgencmd get_throttled."""
out = _run(['vcgencmd', 'get_throttled'])
# "throttled=0x0"
try:
raw = int(out.split('=')[1], 16)
except Exception:
return None, []
flags = []
for bit, (when, label) in THROTTLE_BITS.items():
if raw & (1 << bit):
flags.append(f'{when}:{label}')
return raw, flags
def pss_mb(pid):
"""PSS in MB for a process, or None. RSS double-counts shared pages."""
total_kb = 0
try:
with open(f'/proc/{pid}/smaps_rollup') as fh:
for line in fh:
if line.startswith('Pss:'):
total_kb = int(line.split()[1])
break
except Exception:
return None
return total_kb / 1024.0
def find_procs(pattern):
"""PIDs whose cmdline matches ``pattern``, excluding this script."""
pids = []
me = os.getpid()
for proc in psutil.process_iter(['pid', 'cmdline']):
try:
if proc.info['pid'] == me:
continue
cmdline = ' '.join(proc.info['cmdline'] or [])
if pattern in cmdline and 'monitor_player' not in cmdline:
pids.append(proc.info['pid'])
except Exception:
continue
return pids
#: ``psutil.Process`` objects kept alive between samples. ``cpu_percent()``
#: needs a *previous* reading for the same process to compute a delta, so a
#: freshly constructed Process always reports 0.0. Caching these is what makes
#: the player's own CPU figure meaningful instead of a constant zero.
_proc_cache: dict[int, 'psutil.Process'] = {}
def player_cpu_percent(pid):
"""CPU % for ``pid`` since the previous sample."""
try:
proc = _proc_cache.get(pid)
if proc is None or not proc.is_running():
proc = psutil.Process(pid)
_proc_cache[pid] = proc
proc.cpu_percent(interval=None) # establish the baseline
return 0.0
return proc.cpu_percent(interval=None)
except Exception:
return None
def sample():
"""Take one measurement. Never raises; missing metrics become None."""
now = time.time()
row = {'timestamp': datetime.now().isoformat(timespec='seconds')}
# CPU. interval=None means "since the previous call" — with a fixed sample
# period this yields the usage over exactly that window.
try:
row['cpu_total_pct'] = psutil.cpu_percent(interval=None)
per_core = psutil.cpu_percent(interval=None, percpu=True)
for i in range(4):
row[f'cpu{i}_pct'] = per_core[i] if i < len(per_core) else None
load = os.getloadavg()
row['load1'], row['load5'], row['load15'] = load
except Exception:
row.update({'cpu_total_pct': None, 'load1': None, 'load5': None,
'load15': None})
# Thermal / power
row['temp_c'] = read_temp_c()
row['volt_core_v'] = read_core_volts()
# Clocks
for domain in CLOCK_DOMAINS:
row[f'{domain}_mhz'] = read_clock_mhz(domain)
# Governor and configured max
row['governor'] = _read(
'/sys/devices/system/cpu/cpu0/cpufreq/scaling_governor')
cur = _read('/sys/devices/system/cpu/cpu0/cpufreq/scaling_cur_freq')
row['arm_cur_mhz_cfg'] = int(cur) / 1000.0 if cur.isdigit() else None
# Memory
try:
mem = psutil.virtual_memory()
row['mem_used_mb'] = (mem.total - mem.available) / 1024 / 1024
row['mem_avail_mb'] = mem.available / 1024 / 1024
shm = psutil.disk_usage('/dev/shm')
row['shm_used_mb'] = shm.used / 1024 / 1024
except Exception:
row.update({'mem_used_mb': None, 'mem_avail_mb': None,
'shm_used_mb': None})
# The player
player_pids = find_procs('run_linux.py')
if player_pids:
pid = player_pids[0]
row['player_pss_mb'] = pss_mb(pid)
row['player_cpu_pct'] = player_cpu_percent(pid)
try:
proc = _proc_cache.get(pid) or psutil.Process(pid)
with proc.oneshot():
row['player_threads'] = proc.num_threads()
# Uptime from the process, for correlating with playback.
row['uptime_s'] = now - proc.create_time()
except Exception:
pass
else:
row['player_pss_mb'] = None
row['player_cpu_pct'] = None
row['player_threads'] = None
# Chromium tree (weblinks). Count is as important as memory: a rising
# count means teardown is leaking, which is what kills a 24/7 install.
chromium_pids = find_procs('chromium')
row['chromium_procs'] = len(chromium_pids)
total = 0.0
for pid in chromium_pids:
value = pss_mb(pid)
if value:
total += value
row['chromium_pss_mb'] = total if chromium_pids else 0.0
# Throttling
raw, flags = read_throttled()
row['throttled_raw'] = hex(raw) if raw is not None else None
row['throttle_flags'] = ','.join(flags) if flags else ''
# Fill any field we never set so the CSV stays rectangular.
for field in CSV_FIELDS:
row.setdefault(field, None)
return row
# ── Presentation ─────────────────────────────────────────────────────
def fmt(value, width=6, spec='.1f'):
if value is None:
return ' ' * (width - 2) + '--'
return f'{value:{width}{spec}}'
def print_header():
print()
print(' time CPU% cores(0-3) temp ARM V3D volt RAM '
'player chrome gov flags')
print(' ' + '-' * 84)
def print_row(row, tick):
cores = ' '.join(
fmt(row.get(f'cpu{i}_pct'), 3, '.0f') for i in range(4))
flags = (row.get('throttle_flags') or '').replace('HAS:', 'had:') \
.replace('NOW:', 'NOW:')
warn = ''
temp = row.get('temp_c')
if temp is not None and temp >= TEMP_CRITICAL_C:
warn = ' ***HOT***'
elif temp is not None and temp >= TEMP_WARN_C:
warn = ' *warm*'
governor = (row.get('governor') or '')[:6]
print(
f' {row["timestamp"][11:19]} '
f'{fmt(row.get("cpu_total_pct"), 4, ".0f")} '
f' {cores} '
f'{fmt(temp, 5)}C '
f'{fmt(row.get("arm_mhz"), 6, ".0f")} '
f'{fmt(row.get("v3d_mhz"), 5, ".0f")} '
f'{fmt(row.get("volt_core_v"), 6, ".3f")}V'
f'{fmt(row.get("mem_used_mb"), 5, ".0f")}M '
f'{fmt(row.get("player_pss_mb"), 4, ".0f")}M '
f'{fmt(row.get("chromium_pss_mb"), 5, ".0f")}M '
f'{governor:<6} '
f'{flags}{warn}'
)
def summarise(rows):
"""Print min/avg/max for the run — the part that reveals a trend."""
if not rows:
return
print('\n ' + '=' * 84)
print(f' SUMMARY over {len(rows)} samples '
f'({rows[0]["timestamp"][11:19]} -> {rows[-1]["timestamp"][11:19]})')
print(' ' + '=' * 84)
def stat(field, spec='.1f', unit=''):
values = [r[field] for r in rows
if isinstance(r.get(field), (int, float))]
if not values:
return f' {field:<18} (no data)'
return (f' {field:<18} min {min(values):>8{spec}}{unit} '
f'avg {sum(values)/len(values):>8{spec}}{unit} '
f'max {max(values):>8{spec}}{unit}')
for field, spec, unit in (
('cpu_total_pct', '.1f', '%'),
('temp_c', '.1f', 'C'),
('arm_mhz', '.0f', 'MHz'),
('v3d_mhz', '.0f', 'MHz'),
('volt_core_v', '.3f', 'V'),
('mem_avail_mb', '.0f', 'MB'),
('player_pss_mb', '.0f', 'MB'),
('player_cpu_pct', '.1f', '%'),
('chromium_procs', '.0f', ''),
('chromium_pss_mb', '.0f', 'MB'),
):
print(stat(field, spec, unit))
# Trend: first vs last quarter, which is what matters for a leak.
def trend(field):
values = [r[field] for r in rows
if isinstance(r.get(field), (int, float))]
if len(values) < 8:
return ''
head = values[:max(1, len(values) // 4)]
tail = values[-max(1, len(values) // 4):]
delta = sum(tail) / len(tail) - sum(head) / len(head)
arrow = 'RISING ' if delta > 0 else 'falling'
return (f' {field:<18} {arrow} {delta:+8.1f} '
f'(first->last quarter)')
print('\n Trend (the leak check):')
for field in ('player_pss_mb', 'chromium_procs', 'mem_avail_mb', 'temp_c'):
line = trend(field)
if line:
print(line)
# Throttle history is the headline finding on a Pi.
all_flags = set()
for row in rows:
if row.get('throttle_flags'):
all_flags.update(row['throttle_flags'].split(','))
print('\n Throttle / power events during this run:')
print(f' {", ".join(sorted(all_flags)) if all_flags else "none — clean"}')
def main():
parser = argparse.ArgumentParser(
description='Monitor CPU, GPU clock, temperature and memory for the player.')
parser.add_argument('--interval', type=float, default=5.0,
help='seconds between samples (default 5)')
parser.add_argument('--duration', type=float, default=0,
help='seconds to run; 0 = until Ctrl+C (default 0)')
parser.add_argument('--csv', default='',
help='write samples to this CSV file')
parser.add_argument('--quiet', action='store_true',
help='no live output (for background logging)')
args = parser.parse_args()
# Prime the CPU counters: the first cpu_percent() call always returns 0.0
# because it has no previous sample to compare against.
psutil.cpu_percent(interval=None)
psutil.cpu_percent(interval=None, percpu=True)
for pid in find_procs('run_linux.py'):
try:
_proc_cache[pid] = psutil.Process(pid)
_proc_cache[pid].cpu_percent(interval=None)
except Exception:
pass
writer = None
csv_handle = None
if args.csv:
os.makedirs(os.path.dirname(os.path.abspath(args.csv)), exist_ok=True)
csv_handle = open(args.csv, 'w', newline='')
writer = csv.DictWriter(csv_handle, fieldnames=CSV_FIELDS)
writer.writeheader()
if not args.quiet:
print_header()
rows = []
started = time.time()
tick = 0
try:
while True:
time.sleep(args.interval)
tick += 1
row = sample()
rows.append(row)
if writer:
writer.writerow(row)
csv_handle.flush()
if not args.quiet:
print_row(row, tick)
if args.duration and (time.time() - started) >= args.duration:
break
except KeyboardInterrupt:
if not args.quiet:
print('\n stopped by user')
finally:
if csv_handle:
csv_handle.close()
if not args.quiet:
print(f' CSV written to {args.csv}')
if not args.quiet:
summarise(rows)
return 0
if __name__ == '__main__':
raise SystemExit(main())
+555
View File
@@ -0,0 +1,555 @@
"""
Kiwy Signage Player — Linux / Raspberry Pi entry point
------------------------------------------------------
This module prepares a Pi-correct environment, imports the shared application,
then injects the platform-specific behaviour. ``src/main.py`` itself stays
cross-platform and holds no platform patches.
Usage:
python3 linux/run_linux.py # development / manual run
bash linux/start_player.sh # supervised run (watchdog)
Target: Raspberry Pi OS "Trixie" 64-bit (Debian 13, aarch64, labwc/Wayland).
What this file is responsible for
---------------------------------
1. Environment, set *before* Kivy is imported (video/audio/GL/input backends).
2. ``sys.path`` so the shared modules in ``src/`` import cleanly.
3. Injecting the Linux web-link adapter through the existing
``SignagePlayer.weblink_adapter_factory`` hook.
4. Replacing ``signal_screen_activity`` with the Wayland-aware implementation
in ``linux_display.py`` (the inherited one calls X11 tools absent on Trixie).
5. Pointing ``player_auth.json`` at the player's data directory.
6. A fatal-error surface that works without a console.
"""
import json
import logging
import os
import platform
import sys
import traceback
from pathlib import Path
# =====================================================================
# 0. Resolve directories
# =====================================================================
# Layout: <root>/linux/run_linux.py -> <root> is the data directory and
# <root>/src holds the shared modules.
_HERE = Path(__file__).resolve().parent
ROOT_DIR = _HERE.parent
SRC_DIR = ROOT_DIR / 'src'
LOG_DIR = ROOT_DIR / 'logs'
# The shared modules (main, weblink_session, player_auth, ...) live in src/.
if str(SRC_DIR) not in sys.path:
sys.path.insert(0, str(SRC_DIR))
DATA_DIR = str(ROOT_DIR)
LOG_DIR.mkdir(parents=True, exist_ok=True)
# Consumed by src/playback_trace.py and our own helpers, so trace/log files land
# next to the player rather than in whatever directory it happened to start in.
os.environ.setdefault('KIWY_DATA_DIR', DATA_DIR)
def _early_log(message):
"""Log before Kivy's Logger exists (and mirror to a file)."""
line = f'[run_linux] {message}'
print(line, flush=True)
try:
with open(LOG_DIR / 'startup.log', 'a') as fh:
from datetime import datetime
fh.write(f"{datetime.now().isoformat(timespec='seconds')} {line}\n")
except Exception:
pass
def _show_error(message, details=''):
"""Report a fatal start-up failure where an operator can actually see it.
On a Pi kiosk there is no console and no dialog framework yet (Kivy failed),
so the text is written to a log AND printed. If a ``zenity``-style dialog is
available it is used as a bonus, never as a requirement.
"""
_early_log(f'FATAL: {message}')
if details:
_early_log(details)
try:
crash_log = LOG_DIR / 'fatal_crash.log'
with open(crash_log, 'w') as fh:
fh.write(f'FATAL: {message}\n\n{details}\n')
except Exception:
pass
try:
import shutil
import subprocess
if shutil.which('zenity') and os.environ.get('WAYLAND_DISPLAY'):
subprocess.Popen(
['zenity', '--error', '--width=520',
'--title=Kiwy Signage Player',
'--text=' + f'{message}\n\nSee logs/fatal_crash.log'],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
)
except Exception:
pass
# =====================================================================
# 1. Environment — must be set BEFORE Kivy is imported
# =====================================================================
# main.py uses os.environ.setdefault(), so values set here win. Anything the
# operator exports explicitly is left untouched (setdefault semantics).
def _configure_environment():
env = os.environ
# ── Session environment FIRST ───────────────────────────────────
# A launch from systemd, cron or SSH has XDG_RUNTIME_DIR set but
# WAYLAND_DISPLAY *empty* — the compositor only exports it inside the
# desktop session. SDL2 does NOT scan XDG_RUNTIME_DIR on its own: with the
# variable unset it fails with "wayland not available" even though the
# socket exists. Detecting and exporting it here is what makes the player
# start under systemd/autostart at all.
try:
from linux_display import ensure_session_environment
changed = ensure_session_environment()
if changed:
_early_log(f'session environment filled in: {changed}')
except Exception as exc:
_early_log(f'session environment detection skipped: {exc}')
# ── Video output ────────────────────────────────────────────────
# Raspberry Pi OS Trixie runs a Wayland (labwc) session. A comma-separated
# list is valid: Kivy splits it and SDL2 picks the first driver that
# initialises, so this also covers XWayland ('x11') and headless ('dummy').
env.setdefault('SDL_VIDEODRIVER', 'wayland,x11,dummy')
# ── Audio ───────────────────────────────────────────────────────
# Trixie ships PipeWire, which exposes an ALSA compatibility layer and a
# PulseAudio-compatible socket. NOTE: main.py sets SDL_AUDIODRIVER twice
# (once via setdefault at import, once with a hard setdefault later), so
# the value chosen here is the one that sticks.
env.setdefault('SDL_AUDIODRIVER', 'alsa,pulse,dummy')
# ── Kivy window / GL ────────────────────────────────────────────
env.setdefault('KIVY_WINDOW', 'sdl2')
# Pi 4/5 use Mesa + V3D. 'gl' is the safe default; operators on odd stacks
# can export KIVY_GL_BACKEND=gles/sdl2 to change it.
env.setdefault('KIVY_GL_BACKEND', 'gl')
env.setdefault('KIVY_INPUTPROVIDERS', 'wayland,x11,probesysfs,hidinput,mtdev')
# ── Media (ffpyplayer, hardware-friendly) ───────────────────────
env.setdefault('KIVY_VIDEO', 'ffpyplayer')
env.setdefault('KIVY_AUDIO', 'ffpyplayer')
env.setdefault('FFPYPLAYER_CODECS', 'h264,h265,vp9,vp8')
# Pi 4 has 4 cores; more threads than that hurts more than it helps.
env.setdefault('FFMPEG_THREADS', '2')
env.setdefault('LIBPLAYER_BUFFER', '1048576')
# ── Misc ────────────────────────────────────────────────────────
env.setdefault('SDL_VIDEO_ALLOW_SCREENSAVER', '0')
# Kivy's own home; keeping it inside the project avoids surprises when the
# player is started by systemd with a different HOME.
env.setdefault('KIVY_HOME', str(ROOT_DIR / '.kivy'))
_configure_environment()
# =====================================================================
# 2. Logging
# =====================================================================
def _configure_logging():
"""Route Kivy/root logging to logs/ as well as stderr.
journald already captures stderr when started by systemd, but a plain
``runner`` launch (or a labwc autostart) would otherwise lose it.
"""
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s %(levelname).1s %(name)s: %(message)s',
)
try:
from logging.handlers import RotatingFileHandler
handler = RotatingFileHandler(
LOG_DIR / 'player.log', maxBytes=2 * 1024 * 1024, backupCount=2
)
handler.setFormatter(
logging.Formatter('%(asctime)s %(levelname).1s %(name)s: %(message)s')
)
logging.getLogger().addHandler(handler)
except Exception as exc:
_early_log(f'file logging unavailable: {exc}')
_configure_logging()
# =====================================================================
# 3. Import the shared application (env is already Pi-correct)
# =====================================================================
def _bind_name(func, name):
"""Give ``func`` the name Kivy will use when it resolves the callback later.
Kivy's ``Clock`` wraps callbacks in a ``WeakMethod`` keyed on
``func.__name__`` and re-resolves them with ``getattr(instance, name)``. A
replacement assigned under a different name than it was defined with
therefore raises ``AttributeError`` the first time the Clock fires — after
a delay, far from the cause. Renaming the function keeps the two in sync.
"""
try:
func.__name__ = name
func.__qualname__ = name
except Exception:
pass
return func
def _import_main():
"""Import ``src/main.py``, translating import failures into clear advice."""
try:
import main as signage_main # noqa: WPS433 - deliberate late import
return signage_main
except SystemExit as exc:
# Kivy raises SystemExit(1) when no window provider can be created.
raise RuntimeError(
f'Kivy could not create a window (SystemExit {exc.code}).\n'
'This usually means the display backends are missing.\n'
'Try: sudo apt install libsdl2-2.0-0 libgl1-mesa-dri\n'
f'Backend: SDL_VIDEODRIVER={os.environ.get("SDL_VIDEODRIVER")} '
f'KIVY_GL_BACKEND={os.environ.get("KIVY_GL_BACKEND")} '
f'WAYLAND_DISPLAY={os.environ.get("WAYLAND_DISPLAY")} '
f'DISPLAY={os.environ.get("DISPLAY")}'
) from exc
# =====================================================================
# 4. Platform patches
# =====================================================================
def _reassert_graphics_config():
"""Re-apply fullscreen/window config after main.py has run its own.
``main.py`` sets ``graphics.fullscreen = 0`` and ``window_state = maximized``
at import time. On the Pi the window must be a true fullscreen surface, so
the values are re-asserted here — after main's module body, before
``App.run()`` creates the window.
"""
try:
from kivy.config import Config
Config.set('graphics', 'fullscreen', '1')
Config.set('graphics', 'borderless', '1')
Config.set('graphics', 'resizable', '0')
Config.set('graphics', 'multisampling', '0')
Config.set('graphics', 'fast_rgba', '1')
Config.set('graphics', 'maxfps', '60')
Config.set('kivy', 'exit_on_escape', '0')
except Exception as exc:
_early_log(f'graphics config re-assert failed (non-fatal): {exc}')
def _patch_display(signage_main):
"""Install the Wayland-aware screen-activity handler.
``main.py``'s inherited implementation shells out to ``tvservice``,
``xdotool`` and ``ydotool`` — none of which exist on Trixie — and passes a
mis-escaped ``wlopm --on \\*``. It therefore never prevents blanking.
NOTE: the replacement is registered under **every** name Kivy might use to
resolve the callback. Kivy's ``Clock`` stores ``func.__name__`` in a
WeakMethod and later does ``getattr(instance, that_name)``, so the attribute
name must match the function's own ``__name__`` exactly — otherwise the
Clock raises ``AttributeError`` the first time it fires (measured: the
player died 20 s in, on the first ``signal_screen_activity`` tick, with
``'SignagePlayer' object has no attribute 'linux_screen_activity'``).
"""
try:
from linux_display import linux_screen_activity
except ImportError as exc:
_early_log(f'linux_display unavailable, keeping built-in handler: {exc}')
return False
# Own name first: this is what Kivy's WeakMethod will look up.
setattr(signage_main.SignagePlayer, 'linux_screen_activity', linux_screen_activity)
# Then the attribute the app actually schedules.
signage_main.SignagePlayer.signal_screen_activity = linux_screen_activity
return True
def _patch_weblink_engines(signage_main):
"""Inject the Raspberry Pi Chromium adapter.
``WeblinkSession`` owns launch/visibility/interaction/teardown; the adapter
is the only platform-specific part. Injecting it via the class-level
``weblink_adapter_factory`` hook keeps ``main.py`` free of platform code.
"""
try:
from linux_browser import LinuxChromiumAdapter, find_linux_browser
except ImportError as exc:
_early_log(f'linux_browser unavailable, using the generic adapter: {exc}')
return False
from kivy.logger import Logger
browser = find_linux_browser()
def _linux_weblink_adapter_factory(player):
adapters = []
if browser:
adapters.append(LinuxChromiumAdapter(browser_path=browser, kiosk=True))
else:
Logger.warning(
'SignagePlayer: no Chromium/Chrome found — web links will be '
'skipped. Install with: sudo apt install chromium'
)
return adapters
signage_main.SignagePlayer.weblink_adapter_factory = staticmethod(
_bind_name(_linux_weblink_adapter_factory, 'weblink_adapter_factory')
)
Logger.info(
f'SignagePlayer: web-link engine -> '
f'{"chromium-kiosk-linux (" + browser + ")" if browser else "none found"}'
)
return True
def _patch_temp_paths(signage_main):
"""Make the Settings "Test connection" use a portable temp path.
``main.py`` hard-codes ``/tmp/temp_auth_test.json``. That usually works on
Linux, but the file can be left behind with live credentials and breaks
outright when ``/tmp`` is private (systemd ``PrivateTmp``) or read-only.
"""
try:
import tempfile
import threading
from kivy.clock import Clock
from player_auth import PlayerAuth
except Exception as exc:
_early_log(f'temp-path patch skipped: {exc}')
return False
def _linux_test_connection(self):
"""Copy of the original flow, using ``tempfile.gettempdir()``."""
self.ids.connection_status.text = 'Testing connection...'
self.ids.connection_status.color = (1, 0.7, 0, 1)
def run_test():
temp_file = os.path.join(tempfile.gettempdir(), 'temp_auth_test.json')
try:
server_ip = self.ids.server_input.text.strip()
screen_name = self.ids.screen_input.text.strip()
quickconnect = self.ids.quickconnect_input.text.strip()
port = self.ids.port_input.text.strip() or self.player.config.get('port', '')
use_https = self.player.config.get('use_https', True)
verify_ssl = self.player.config.get('verify_ssl', True)
if not all([server_ip, screen_name, quickconnect]):
Clock.schedule_once(
lambda dt: self.update_connection_status('Error: Fill all fields', False)
)
return
if server_ip.startswith(('http://', 'https://')):
server_url = server_ip
if ':' not in server_ip.replace('https://', '').replace('http://', ''):
if port and port not in ('443', '80'):
server_url = f'{server_ip}:{port}'
else:
protocol = 'https' if use_https else 'http'
if ':' in server_ip:
server_url = f'{protocol}://{server_ip}'
else:
server_url = f'{protocol}://{server_ip}:{port}' if port else f'{protocol}://{server_ip}'
auth = PlayerAuth(temp_file, use_https=use_https, verify_ssl=verify_ssl)
success, error = auth.authenticate(
server_url=server_url, hostname=screen_name,
quickconnect_code=quickconnect,
)
if success:
player_name = auth.get_player_name()
Clock.schedule_once(
lambda dt: self.update_connection_status(f'✓ Connected: {player_name}', True)
)
else:
Clock.schedule_once(
lambda dt: self.update_connection_status(f'✗ Failed: {error}', False)
)
except Exception as exc:
Clock.schedule_once(
lambda dt: self.update_connection_status(f'✗ Error: {exc}', False)
)
finally:
# Never leave a file containing credentials behind.
try:
if os.path.exists(temp_file):
os.remove(temp_file)
except Exception:
pass
threading.Thread(target=run_test, daemon=True).start()
signage_main.SettingsPopup.test_connection = _bind_name(
_linux_test_connection, 'test_connection'
)
return True
def _patch_auth_path(signage_main):
"""Keep ``player_auth.json`` in the player's data directory.
``player_auth.py`` defaults to the relative path ``player_auth.json``, which
resolves against the *current working directory*. Started by systemd,
labwc-autostart or a cron wrapper, that cwd differs — so the player would
"forget" its authentication and re-register on every launch. Pinning it to
an absolute path in the data dir removes that class of bug.
"""
try:
import player_auth as player_auth_module
except Exception as exc:
_early_log(f'auth-path patch skipped: {exc}')
return False
local_auth = os.path.join(DATA_DIR, 'player_auth.json')
original_init = player_auth_module.PlayerAuth.__init__
def _linux_auth_init(self, config_file='player_auth.json',
use_https=True, verify_ssl=True):
try:
if not os.path.isabs(config_file):
config_file = os.path.join(DATA_DIR, os.path.basename(config_file))
except Exception:
config_file = local_auth
original_init(self, config_file, use_https=use_https, verify_ssl=verify_ssl)
player_auth_module.PlayerAuth.__init__ = _bind_name(_linux_auth_init, '__init__')
_early_log(f'player auth file -> {local_auth}')
# get_playlists_v2 may already hold a globally cached auth instance created
# with the old relative path; drop it so the redirect applies.
try:
import get_playlists_v2 as gp
if getattr(gp, '_auth_instance', None) is not None:
gp._auth_instance = None
except Exception:
pass
# Point the "Reset auth" button at the real file.
def _linux_reset_player_auth(self):
try:
if os.path.exists(local_auth):
os.remove(local_auth)
signage_main.Logger.info(f'SettingsPopup: Deleted auth file: {local_auth}')
self._show_temp_message(
'✓ Authentication reset - will reauthenticate on restart',
(0, 1, 0, 1),
)
except Exception as exc:
signage_main.Logger.error(f'SettingsPopup: Failed to reset auth: {exc}')
signage_main.SettingsPopup.reset_player_auth = _bind_name(
_linux_reset_player_auth, 'reset_player_auth'
)
return True
def _patch_startup_hooks(signage_main):
"""Apply orientation and stop the idle blanker once the app is up."""
original_on_start = signage_main.SignagePlayerApp.on_start
def _linux_on_start(self):
original_on_start(self)
try:
from linux_display import (
apply_orientation, keep_display_awake, neutralise_idle_blanker,
status,
)
_early_log(f'display backend: {json.dumps(status())}')
# The desktop ships an idle blanker that powers the panel off after
# 10 minutes; a signage player must win that contest.
neutralise_idle_blanker()
keep_display_awake(force=True)
root = getattr(self, 'root', None)
orientation = ''
if root is not None:
orientation = (getattr(root, 'config', {}) or {}).get('orientation', '')
if orientation:
apply_orientation(orientation)
except Exception as exc:
_early_log(f'startup display hook failed (non-fatal): {exc}')
signage_main.SignagePlayerApp.on_start = _bind_name(_linux_on_start, 'on_start')
# =====================================================================
# 5. Run
# =====================================================================
def main():
from kivy.logger import Logger
Logger.info('=' * 78)
Logger.info('Kiwy Signage Player — Raspberry Pi / Linux Edition')
Logger.info(f'Python: {sys.version.split()[0]}')
Logger.info(f'Platform: {platform.platform()}')
Logger.info(f'Machine: {platform.machine()}')
Logger.info(f'Data dir: {DATA_DIR}')
Logger.info(f'Session: WAYLAND_DISPLAY={os.environ.get("WAYLAND_DISPLAY")} '
f'DISPLAY={os.environ.get("DISPLAY")}')
Logger.info('=' * 78)
signage_main = _import_main()
_reassert_graphics_config()
patched = {
'display': _patch_display(signage_main),
'weblink': _patch_weblink_engines(signage_main),
'temp': _patch_temp_paths(signage_main),
'auth': _patch_auth_path(signage_main),
}
_patch_startup_hooks(signage_main)
Logger.info(f'SignagePlayer: platform patches -> {patched}')
try:
signage_main.SignagePlayerApp().run()
except KeyboardInterrupt:
Logger.info('Application stopped by user (Ctrl+C)')
except SystemExit as exc:
_show_error(
f'Kivy exited: {exc}',
'Kivy could not create a window. Check the display backends.\n'
f'SDL_VIDEODRIVER={os.environ.get("SDL_VIDEODRIVER")}\n'
f'KIVY_GL_BACKEND={os.environ.get("KIVY_GL_BACKEND")}',
)
return 1
except Exception as exc:
Logger.critical(f'Fatal error: {exc}')
Logger.exception('Full traceback:')
_show_error(str(exc), traceback.format_exc())
return 1
finally:
Logger.info('Application shutdown complete')
return 0
if __name__ == '__main__':
exit_code = 0
try:
exit_code = main()
except SystemExit as exc:
# A clean shutdown (SIGTERM from the watchdog, window closed after the
# password exit) arrives here as SystemExit(0). Reporting that as a
# fatal error wrote a bogus "FATAL: 0" crash log on every normal stop.
code = exc.code
exit_code = code if isinstance(code, int) else (0 if code is None else 1)
if exit_code:
_show_error(f'exited with code {exit_code}', traceback.format_exc())
except BaseException as exc: # includes import-time failures
_show_error(str(exc), traceback.format_exc())
raise
sys.exit(exit_code)
+180
View File
@@ -0,0 +1,180 @@
"""test_linux_browser_flags.py — guard the Chromium keyring bypass and footprint.
The keyring password prompt is the bug this file exists to prevent from coming
back. It was "fixed" once before by adding ``--password-store=basic`` and
``--use-mock-keychain`` to a list (``APPLIANCE_FLAGS``) that **nothing ever
referenced**, so the flags never reached the command line and the prompt
persisted. A second stack-only fix (``--single-process``) also looked right on
paper but crashed with a real HTTP URL.
So these checks are deliberately about *what actually reaches the process*, not
about what the constants say:
1. ``extra_launch_args()`` really contains the keyring + footprint flags.
2. ``launch_env()`` really strips the D-Bus session bus, so Chromium cannot
reach ``gnome-keyring-daemon`` even if a flag is ever ignored.
3. Every module-level flag list is referenced by ``extra_launch_args()`` —
dead flag lists are the exact failure mode that hid bug #1.
4. The spawned process is in its own session (so process-group teardown works)
and its ``/proc/<pid>/environ`` really lacks ``DBUS_SESSION_BUS_ADDRESS``.
Run:
.venv/bin/python linux/test_linux_browser_flags.py
"""
from __future__ import annotations
import os
import re
import sys
import time
from pathlib import Path
HERE = Path(__file__).resolve().parent
ROOT = HERE.parent
for path in (str(HERE), str(ROOT / 'src')):
if path not in sys.path:
sys.path.insert(0, path)
import linux_browser # noqa: E402
failures: list[str] = []
checks = 0
def check(label, condition, detail=''):
global checks
checks += 1
if condition:
print(f' PASS {label}')
else:
print(f' FAIL {label}' + (f'{detail}' if detail else ''))
failures.append(label)
def build_adapter(**kwargs):
adapter = linux_browser.LinuxChromiumAdapter(
browser_path='/usr/bin/chromium', kiosk=True, **kwargs
)
adapter._profile_dir = '/tmp/kiwy-flag-test/.kiosk-profile'
return adapter
# ── 1. Keyring flags reach the command line ──────────────────────────
print('\n[1] Keyring bypass flags are applied')
adapter = build_adapter()
args = adapter.extra_launch_args()
for flag in ('--password-store=basic', '--use-mock-keychain'):
check(f'{flag} present', flag in args,
'Chromium would contact gnome-keyring and prompt for a password')
check('--kiosk present', '--kiosk' in args, 'weblink would not be fullscreen')
if linux_browser._detect_wayland():
check('--ozone-platform=wayland present', '--ozone-platform=wayland' in args,
'Chromium 152 aborts without an explicit Ozone platform on labwc')
check(
'no ineffective --ozone-platform-hint',
'--ozone-platform-hint=auto' not in args,
'the hint flag does NOT fall back to Wayland and just fails',
)
# ── 2. Environment actually disconnects the keyring ──────────────────
print('\n[2] launch_env() disconnects the Secret Service')
env = adapter.launch_env()
check('launch_env() returns an environment', env is not None,
'None means Popen inherits DBUS_SESSION_BUS_ADDRESS')
if env is not None:
check('DBUS_SESSION_BUS_ADDRESS removed',
'DBUS_SESSION_BUS_ADDRESS' not in env,
'Chromium could reach gnome-keyring-daemon and prompt')
check('DBUS_SESSION_BUS_PID removed', 'DBUS_SESSION_BUS_PID' not in env)
check('GNOME_KEYRING_CONTROL emptied', env.get('GNOME_KEYRING_CONTROL') == '',
'points the keyring client at nothing')
check('CHROME_PASSWORD_STORE=basic', env.get('CHROME_PASSWORD_STORE') == 'basic')
check('PATH preserved', bool(env.get('PATH')), 'browser could not exec')
check('session bus is absent from the parent env to begin with',
'DBUS_SESSION_BUS_ADDRESS' in os.environ,
'precondition: this test only proves something if the player HAS a bus')
check('start_new_session() is True', adapter.start_new_session() is True,
'os.killpg cannot reap Chromium children without it')
# ── 3. No dead flag lists ────────────────────────────────────────────
print('\n[3] Every flag list is referenced (no dead code)')
source = (HERE / 'linux_browser.py').read_text()
# Names of module-level lists of flags.
lists = re.findall(r'^([A-Z_]+_FLAGS) = \[', source, re.MULTILINE)
check('flag lists found', len(lists) >= 5, f'only found {lists}')
for name in lists:
# Count references that are NOT the definition itself.
uses = len(re.findall(rf'(?<!^){name}(?!\s*=\s*\[)', source, re.MULTILINE))
check(f'{name} is referenced', uses > 0,
'a flag list nothing reads is exactly how the keyring prompt hid')
# ── 4. End-to-end: the real process is detached from the bus ─────────
print('\n[4] Live launch: process environment and process group')
browser = linux_browser.find_linux_browser()
if not browser:
print(' SKIP no Chromium installed')
else:
os.makedirs(adapter._profile_dir, exist_ok=True)
live = build_adapter()
started = live.launch('about:blank', 800, 600)
check('launch() returned True', started is True)
proc = live._proc
if started and proc is not None:
try:
time.sleep(1.5)
if proc.poll() is not None:
check('browser survived start-up', False,
f'exited rc={proc.returncode}')
else:
check('browser survived start-up', True)
# Process group: must differ from the player's own group.
try:
pgid = os.getpgid(proc.pid)
check('browser is in its own process group',
pgid == proc.pid,
f'pgid={pgid} pid={proc.pid}; killpg would hit the player')
except Exception as exc:
check('browser is in its own process group', False, str(exc))
# /proc environ is authoritative: this is what the process sees.
try:
raw = Path(f'/proc/{proc.pid}/environ').read_bytes()
child_env = dict(
item.split('=', 1) for item in
raw.decode('utf-8', 'replace').split('\x00') if '=' in item
)
check('live process has no DBUS_SESSION_BUS_ADDRESS',
'DBUS_SESSION_BUS_ADDRESS' not in child_env,
'the keyring prompt can still appear')
check('live process has basic password store',
child_env.get('CHROME_PASSWORD_STORE') == 'basic')
except Exception as exc:
check('live process environment readable', False, str(exc))
finally:
live.teardown()
time.sleep(0.5)
check('teardown() left no process behind', live._proc is None)
# ── Summary ──────────────────────────────────────────────────────────
print(f'\n{checks - len(failures)}/{checks} checks passed')
if failures:
print('\nFailed:')
for name in failures:
print(f' - {name}')
sys.exit(1)
print('All checks passed.')
+186
View File
@@ -0,0 +1,186 @@
"""test_linux_patches.py — verify the Linux platform patches are wired correctly.
Run with the project virtualenv:
.venv/bin/python linux/test_linux_patches.py
These are the regressions that actually bit us during the Trixie port, so they
are asserted rather than left to manual testing:
1. **Kivy WeakMethod name trap.** ``Clock`` stores ``callable.__name__`` and
re-resolves it with ``getattr(instance, name)``. If a patched method is
assigned under a name that differs from its own ``__name__``, the app dies
~20 s later with ``AttributeError`` — far from the cause. Every method this
port replaces must be reachable under its own function name.
2. **SDL2 Wayland capability.** Kivy's PyPI wheel bundles an SDL2 *without* the
wayland driver; the system SDL2 has it. Getting this wrong means no window at
all on Raspberry Pi OS Trixie.
3. **WAYLAND_DISPLAY inference.** SDL2 requires the variable to be set — unlike
``wlopm``, it does not scan ``XDG_RUNTIME_DIR``. A systemd/cron launch has it
unset, so the entry point must fill it in.
The player does not need to be running; this only exercises wiring and detection.
"""
from __future__ import annotations
import ctypes
import glob
import os
import subprocess
import sys
from pathlib import Path
HERE = Path(__file__).resolve().parent
ROOT = HERE.parent
SRC = ROOT / 'src'
VENV = Path(os.environ.get('KIWY_VENV') or (ROOT / '.venv'))
for path in (str(HERE), str(SRC)):
if path not in sys.path:
sys.path.insert(0, path)
failures: list[str] = []
checks = 0
def check(label, condition, detail=''):
global checks
checks += 1
if condition:
print(f' PASS {label}')
else:
print(f' FAIL {label}' + (f'{detail}' if detail else ''))
failures.append(label)
# ── 1. WeakMethod name trap ──────────────────────────────────────────
print('\n[1] Patched methods are resolvable by their own __name__')
# Import the entry point's patching helpers without running the app. Importing
# run_linux executes its env setup, which is harmless and actually desirable
# here (it fills in WAYLAND_DISPLAY for the checks below).
import run_linux # noqa: E402
main = run_linux._import_main()
patches = {
'signal_screen_activity': run_linux._patch_display(main),
'weblink_adapter_factory': run_linux._patch_weblink_engines(main),
'test_connection': run_linux._patch_temp_paths(main),
'reset_player_auth': run_linux._patch_auth_path(main),
}
for label, applied in patches.items():
check(f'{label} patch applied', applied, 'patch reported failure')
player_cls = main.SignagePlayer
for attr in ('signal_screen_activity', 'weblink_adapter_factory'):
func = getattr(player_cls, attr, None)
check(f'{attr} exists', func is not None, 'attribute missing')
if func is None:
continue
# For a staticmethod, inspect the underlying function.
raw = player_cls.__dict__.get(attr)
func = raw.__func__ if isinstance(raw, staticmethod) else raw
name = getattr(func, '__name__', '')
# The real invariant: Kivy resolves the callback with
# getattr(instance, func.__name__), so that lookup must succeed and return
# the same function. The name need not equal the attribute it replaced, but
# it MUST be reachable on the class.
resolved = getattr(player_cls, name, None)
check(
f'{attr}: getattr(cls, {name!r}) resolves',
resolved is not None,
f'Kivy Clock would raise AttributeError: no attribute {name!r}',
)
check(
f'{attr}: resolved function is the patched one',
resolved is func,
f'{name!r} resolved to a different object',
)
# The Windows-only focus machinery must be gone from the shared app, so nothing
# schedules Win32 work on a Wayland session.
for attr in ('_focus_keeper_tick', '_focus_guardian_tick',
'_bring_window_to_front_nonblocking', '_start_focus_guardian'):
check(
f'{attr} removed (Windows-only)',
not hasattr(player_cls, attr),
'the platform-specific focus code should not be in the shared app',
)
# ── 2. Display module behaviour ──────────────────────────────────────
print('\n[2] linux_display detection and keep-awake')
import linux_display as display # noqa: E402
status = display.status()
print(f' info status = {status}')
if status['wayland']:
check('WAYLAND_DISPLAY is set after detection',
bool(os.environ.get('WAYLAND_DISPLAY')),
'SDL2/wlopm need this variable')
check('wlopm available', status['wlopm'], 'install wlopm')
check('keep_display_awake reports success',
display.keep_display_awake(force=True) is True,
'wlopm --on did not succeed')
else:
print(' SKIP not a Wayland session — display checks skipped')
# With the tools explicitly disabled nothing must be attempted.
os.environ[display.DISABLE_ENV_VAR] = '1'
check('DISABLE escape hatch suppresses keep-awake',
display.keep_display_awake(force=True) is False)
check('DISABLE escape hatch suppresses blanker kill',
display.neutralise_idle_blanker() == [])
del os.environ[display.DISABLE_ENV_VAR]
# ── 3. SDL2 Wayland capability ───────────────────────────────────────
print('\n[3] SDL2 in use supports the wayland driver')
def drivers_of(lib_path):
try:
lib = ctypes.CDLL(str(lib_path))
lib.SDL_GetNumVideoDrivers.restype = ctypes.c_int
lib.SDL_GetVideoDriver.restype = ctypes.c_char_p
lib.SDL_GetVideoDriver.argtypes = [ctypes.c_int]
n = lib.SDL_GetNumVideoDrivers()
return [lib.SDL_GetVideoDriver(i).decode() for i in range(n)]
except Exception as exc:
return [f'<error: {exc}>']
ext = glob.glob(str(VENV / '**' / '_window_sdl2*.so'), recursive=True)
if not ext:
print(' SKIP no Kivy SDL2 extension found in this venv')
else:
# Ask the dynamic loader which libSDL2 the extension actually resolves.
linked = subprocess.run(
['ldd', ext[0]], capture_output=True, text=True, check=False,
).stdout
resolved = None
for line in linked.splitlines():
if 'libSDL2-2-' in line and '=>' in line:
resolved = line.split('=>')[1].split('(')[0].strip()
break
check('Kivy resolves an SDL2 library', resolved is not None, 'ldd found none')
if resolved:
used = drivers_of(resolved)
print(f' info {resolved}\n drivers = {used}')
check('resolved SDL2 supports wayland', 'wayland' in used,
'run: bash linux/fix_kivy_sdl2.sh')
# ── Summary ──────────────────────────────────────────────────────────
print(f'\n{checks - len(failures)}/{checks} checks passed')
if failures:
print('\nFailed:')
for name in failures:
print(f' - {name}')
sys.exit(1)
print('All checks passed.')
+171
View File
@@ -0,0 +1,171 @@
"""test_media_state.py — the conversion-flag state machine.
The rules here are easy to get subtly wrong, and getting them wrong is
user-visible in two opposite ways: playing a file that is mid-conversion
(truncated video), or skipping forever an item that is actually fine.
Covered:
1. A normal-size video resolves to itself and is playable.
2. An oversized video with no completed conversion reports ``pending``.
3. While the ``.kiwy-converting`` marker exists it reports ``converting``.
4. Once the output + metadata exist it resolves to the **converted** file.
5. A stale marker (from a crash) does not park the item forever.
6. Metadata that does not match the current source is ignored — otherwise a
different video reusing the same filename would play the previous one.
7. The MP4 header parser agrees with ffprobe (the player must not spawn a
subprocess on the playback path, so it reads the container directly).
Run:
.venv/bin/python linux/test_media_state.py
"""
from __future__ import annotations
import json
import os
import shutil
import subprocess
import sys
import tempfile
import time
from pathlib import Path
HERE = Path(__file__).resolve().parent
ROOT = HERE.parent
SRC = ROOT / 'src'
for path in (str(HERE), str(SRC)):
if path not in sys.path:
sys.path.insert(0, path)
import media_state as ms # noqa: E402
failures: list[str] = []
checks = 0
def check(label, condition, detail=''):
global checks
checks += 1
print(f' {"PASS" if condition else "FAIL"} {label}'
+ (f'{detail}' if not condition and detail else ''))
if not condition:
failures.append(label)
def make_video(path, width, height, seconds=1):
"""Create a tiny real video of the given size (or None if ffmpeg is absent)."""
if not shutil.which('ffmpeg'):
return False
cmd = [
'ffmpeg', '-hide_banner', '-loglevel', 'error',
'-f', 'lavfi', '-i', f'testsrc=size={width}x{height}:rate=10:duration={seconds}',
'-c:v', 'libx264', '-preset', 'ultrafast', '-pix_fmt', 'yuv420p',
'-y', path,
]
return subprocess.run(cmd, capture_output=True, check=False).returncode == 0
workdir = Path(tempfile.mkdtemp(prefix='kiwy-mediastate-'))
print(f'workdir: {workdir}')
try:
big = workdir / 'big.mp4'
small = workdir / 'small.mp4'
if not make_video(big, 2560, 1440) or not make_video(small, 1280, 720):
print('SKIP: ffmpeg not available to build fixtures')
raise SystemExit(0)
# ── 1. Within-limit video is playable as itself ──────────────────
print('\n[1] A within-limit video resolves to itself')
chosen, state = ms.resolve_playable(str(small))
check('state is ready', state == 'ready', f'got {state}')
check('chosen path is the original', chosen == str(small), f'got {chosen}')
# ── 2. Oversized + no conversion -> pending ──────────────────────
print('\n[2] Oversized video with no conversion reports pending')
chosen, state = ms.resolve_playable(str(big))
check('state is pending', state == 'pending', f'got {state}')
oversized, _w, _h = ms.is_oversized(str(big))
check('is_oversized is True', oversized is True)
check('a within-limit file is not oversized',
ms.is_oversized(str(small))[0] is False)
# ── 3. Converting marker -> converting ───────────────────────────
print('\n[3] The converting marker suppresses playback')
ms.begin_conversion(str(big))
chosen, state = ms.resolve_playable(str(big))
check('state is converting', state == 'converting', f'got {state}')
check('is_converting is True', ms.is_converting(str(big)) is True)
# ── 4. Completed conversion resolves to the output ───────────────
print('\n[4] A finished conversion resolves to the converted file')
output = ms.normalized_output(str(big))
shutil.copy2(str(small), output) # stand-in for the 1080p result
with open(output + ms.MARKER_SUFFIX, 'w') as fh:
json.dump({'source_size': os.path.getsize(big),
'width': 2560, 'height': 1440}, fh)
ms.end_conversion(str(big))
chosen, state = ms.resolve_playable(str(big))
check('state is ready', state == 'ready', f'got {state}')
check('chosen path is the converted file', chosen == output, f'got {chosen}')
# ── 5. Stale marker is ignored ───────────────────────────────────
print('\n[5] A stale (crashed) marker does not block the item forever')
os.remove(output)
os.remove(output + ms.MARKER_SUFFIX)
ms.begin_conversion(str(big))
old = time.time() - (ms.STALE_CONVERSION_SECONDS + 60)
os.utime(ms.converting_marker(str(big)), (old, old))
check('a stale marker is not treated as converting',
ms.is_converting(str(big)) is False)
_chosen, state = ms.resolve_playable(str(big))
check('so the item is pending rather than converting',
state == 'pending', f'got {state}')
ms.end_conversion(str(big))
# ── 6. Mismatched metadata is ignored ────────────────────────────
print('\n[6] Metadata for a different source is rejected')
shutil.copy2(str(small), output)
with open(output + ms.MARKER_SUFFIX, 'w') as fh:
json.dump({'source_size': 12345, # does not match big.mp4
'width': 2560, 'height': 1440}, fh)
check('a stale output is not accepted', ms.normalized_file(str(big)) is None)
_chosen, state = ms.resolve_playable(str(big))
check('the item is treated as pending', state == 'pending', f'got {state}')
# ── 7. Header parser agrees with ffprobe ─────────────────────────
print('\n[7] The dependency-free MP4 parser matches ffprobe')
for label, path, expect in (('2560x1440', str(big), (2560, 1440)),
('1280x720', str(small), (1280, 720))):
parsed = ms.read_video_size(path)
check(f'{label} parsed correctly', parsed == expect, f'got {parsed}')
if shutil.which('ffprobe'):
out = subprocess.run(
['ffprobe', '-v', 'error', '-select_streams', 'v:0',
'-show_entries', 'stream=width,height', '-of', 'csv=p=0', str(big)],
capture_output=True, text=True, check=False).stdout.strip()
fw, fh = (int(x) for x in out.split(',')[:2])
check('parser matches ffprobe for the oversized file',
ms.read_video_size(str(big)) == (fw, fh),
f'parser={ms.read_video_size(str(big))} ffprobe={(fw, fh)}')
check('a non-video file yields (None, None)',
ms.read_video_size(str(workdir / 'missing.mp4')) == (None, None))
check('a corrupt file yields (None, None)', (lambda p: (
p.write_bytes(b'not a video'), ms.read_video_size(str(p)))[1]
)(workdir / 'corrupt.mp4') == (None, None))
finally:
shutil.rmtree(workdir, ignore_errors=True)
print(f'\n{checks - len(failures)}/{checks} checks passed')
if failures:
print('\nFailed:')
for name in failures:
print(f' - {name}')
raise SystemExit(1)
print('All checks passed.')
+545
View File
@@ -0,0 +1,545 @@
"""video_normalizer.py — downscale oversized videos for Raspberry Pi playback.
The problem
-----------
The Pi 4 has no usable 4K decode path. Measured on the target device:
sample-30s.mp4 (1920x1080) decode 3.03x realtime ✅
16118765_3840_2160_30fps.mp4 (4K) decode 0.90x realtime ❌
ffpyplayer decodes in software (there is no hardware H.264 *decode* wired into
its pipeline), so a 4K clip cannot be decoded fast enough to feed the screen in
real time. The visible symptom is a video that shows one frame and then sits
still, or stutters badly, while the playlist timer ticks on.
The fix
-------
Normalise oversized media to at most 1920x1080 **once, at sync time**, and hand
the player the smaller file. Playback then always runs against a resolution the
Pi can decode comfortably.
Timing (measured, 18.3s 4K clip -> 1080p):
hardware encode (h264_v4l2m2m) 31s one-off, at download time
software encode (libx264) much slower
31 s is a real cost, but it is paid **once per file** during the playlist sync —
which already runs on a worker thread and already downloads tens of megabytes.
It is never paid during playback, which is the only place it would matter.
Hardware encoding is used when available because the Pi 4's H.264 *encoder* is a
separate block from its decoder and works well; software encoding of 4K on this
SoC is slow enough to be impractical.
Scope
-----
Triggered by **resolution only** — ``width > max_width or height > max_height``.
A video already within bounds is left untouched (byte-identical), so this never
degrades content that already plays.
Audio is preserved: if the source has an audio track it is copied through
(``-c:a copy``, falling back to AAC). That matters because a *silent* video
triggers a separate bug in the SDL2_mixer path — see ``_video_has_audio`` in
``src/main.py`` — so we must not accidentally create one.
"""
from __future__ import annotations
import json
import os
import shutil
import subprocess
import sys
import time
# Importing kivy.logger installs Kivy's own argument parser, which then rejects
# this module's CLI flags ("option --dry-run not recognized") and exits. Setting
# this BEFORE the import keeps Kivy out of argv handling. It must precede the
# kivy import below, hence the import-order exception.
os.environ.setdefault('KIVY_NO_ARGS', '1')
try:
from kivy.logger import Logger
except Exception: # pragma: no cover - importable without Kivy (tests/CLI)
class Logger: # type: ignore
@staticmethod
def _noop(*args, **kwargs):
pass
info = debug = warning = error = staticmethod(_noop)
# The player and the normaliser must agree on the on-disk contract for
# "is this converting / has it been converted". That contract lives in one
# place (src/media_state.py) and is shared rather than reimplemented.
_SRC_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'src')
if _SRC_DIR not in sys.path:
sys.path.insert(0, _SRC_DIR)
import media_state # noqa: E402
def _log(message, level='info'):
try:
getattr(Logger, level, Logger.info)(f'[VideoNormalizer] {message}')
except Exception:
pass
#: Default output ceiling. The Pi's practical decode limit; matches the
#: ``max_resolution`` default used elsewhere in the project.
DEFAULT_MAX_WIDTH = 1920
DEFAULT_MAX_HEIGHT = 1080
#: Marker suffix written next to a normalised file, recording what was done.
#: Re-exported from :mod:`media_state` so the player and normaliser cannot drift.
MARKER_SUFFIX = media_state.MARKER_SUFFIX
#: Encoder preference: the Pi's hardware block first, then a CPU fallback.
#: ``h264_v4l2m2m`` is the V4L2 mem2mem H.264 encoder (verified working on the
#: target Pi 4); libx264 is the portable fallback for other Linux hosts.
HW_ENCODER = 'h264_v4l2m2m'
SW_ENCODER = 'libx264'
#: Bitrate for the normalised output. 1080p signage at ~5 Mbps is visually
#: lossless for this use and keeps files small.
TARGET_BITRATE = '5M'
MAX_BITRATE = '8M'
BUFSIZE = '10M'
def _run(args, timeout=30):
"""Run a command; return (returncode, stdout+stderr). Never raises."""
try:
result = subprocess.run(
args, capture_output=True, text=True, timeout=timeout, check=False,
)
return result.returncode, (result.stdout or '') + (result.stderr or '')
except FileNotFoundError:
return 127, 'not found'
except subprocess.TimeoutExpired:
return 124, 'timeout'
except Exception as exc:
return 1, str(exc)
def find_ffprobe():
return shutil.which('ffprobe')
def find_ffmpeg():
return shutil.which('ffmpeg')
def probe_video(path):
"""Return a dict describing ``path``, or None when it cannot be probed.
Keys: width, height, codec, duration, has_audio, pix_fmt, level.
"""
ffprobe = find_ffprobe()
if not ffprobe or not os.path.isfile(path):
return None
code, out = _run([
ffprobe, '-v', 'error', '-print_format', 'json',
'-show_streams', '-show_format', path,
], timeout=20)
if code != 0:
_log(f'ffprobe failed for {os.path.basename(path)}: {out.strip()[:200]}',
'warning')
return None
try:
data = json.loads(out)
except Exception:
return None
info = {
'width': None, 'height': None, 'codec': None, 'pix_fmt': None,
'level': None, 'duration': None, 'has_audio': False,
}
for stream in data.get('streams', []):
if stream.get('codec_type') == 'video' and info['width'] is None:
info['width'] = stream.get('width')
info['height'] = stream.get('height')
info['codec'] = stream.get('codec_name')
info['pix_fmt'] = stream.get('pix_fmt')
info['level'] = stream.get('level')
elif stream.get('codec_type') == 'audio':
info['has_audio'] = True
fmt = data.get('format', {})
try:
info['duration'] = float(fmt.get('duration'))
except (TypeError, ValueError):
info['duration'] = None
return info
def hardware_encoder_available():
"""True when the Pi's V4L2 H.264 encoder can actually be opened.
Checked by running a tiny real encode rather than by grepping
``ffmpeg -encoders``: the encoder is listed on builds where the kernel
device is missing or busy, and a failed encode at sync time would be far
worse than a slightly slower software one.
"""
ffmpeg = find_ffmpeg()
if not ffmpeg:
return False
code, _ = _run([
ffmpeg, '-hide_banner', '-loglevel', 'error',
'-f', 'lavfi', '-i', 'testsrc=size=320x240:rate=10:duration=0.5',
'-c:v', HW_ENCODER, '-f', 'null', '-',
], timeout=60)
return code == 0
def needs_normalization(path, max_width=DEFAULT_MAX_WIDTH,
max_height=DEFAULT_MAX_HEIGHT):
"""(bool, info) — True when ``path`` exceeds the playback ceiling.
Only resolution is considered. A file at or below the ceiling is returned
untouched so content that already plays is never re-encoded.
"""
info = probe_video(path)
if info is None:
return False, None
width, height = info.get('width'), info.get('height')
if not width or not height:
return False, info
return (width > max_width or height > max_height), info
def _target_size(width, height, max_width, max_height):
"""Scale ``width``x``height`` to fit the ceiling, preserving aspect ratio.
Dimensions are forced even: H.264 4:2:0 requires even width and height, and
an odd value makes the encoder fail outright.
"""
scale = min(max_width / width, max_height / height)
new_w = int(width * scale)
new_h = int(height * scale)
# Round down to even numbers (never up — that could exceed the ceiling).
new_w -= new_w % 2
new_h -= new_h % 2
return max(2, new_w), max(2, new_h)
def normalized_path(original_path, max_width=DEFAULT_MAX_WIDTH,
max_height=DEFAULT_MAX_HEIGHT):
"""Deterministic output path for the normalised version of a file.
Kept next to the original (not in a cache dir) so the existing media
clean-up logic, which prunes unreferenced files under ``media/``, can do its
normal job: the playlist ends up referencing the normalised file and the
oversized original is pruned automatically.
"""
directory, name = os.path.split(original_path)
stem, ext = os.path.splitext(name)
return os.path.join(
directory, f'{stem}_kiwy{max_height}p{ext or ".mp4"}'
)
def normalize_video(path, max_width=DEFAULT_MAX_WIDTH,
max_height=DEFAULT_MAX_HEIGHT, force=False, dry_run=False):
"""Downscale ``path`` if it exceeds the ceiling.
Returns a dict:
{'status': ..., 'output': ..., 'info': ..., 'elapsed_s': ...}
``status`` is one of:
``within_limit`` — already at or below the ceiling, untouched
``reused`` — a previous conversion exists and was verified
``normalized`` — a new file was produced
``failed`` — could not normalise (caller should use the original)
``would`` — dry_run only
The three success states are kept distinct on purpose: reporting a
converted file as "ok" alongside its *original* 4K dimensions reads as
"nothing to do" and would hide a missing conversion.
Never raises: a failure leaves the original file untouched and the caller
keeps playing it, because a large-but-playable video is better than none.
"""
result = {'status': 'within_limit', 'output': None, 'info': None,
'elapsed_s': 0.0}
if not os.path.isfile(path):
result['status'] = 'failed'
result['info'] = 'file not found'
return result
oversized, info = needs_normalization(path, max_width, max_height)
result['info'] = info
if not oversized:
return result
output = normalized_path(path, max_width, max_height)
# Already converted in a previous sync: reuse it. ``media_state`` owns this
# decision so the player resolves the same file, by the same rules.
existing = media_state.normalized_file(path, max_height)
if not force and existing:
result['status'] = 'reused'
result['output'] = existing
_log(f'{os.path.basename(path)} already normalised -> '
f'{os.path.basename(existing)}')
return result
new_w, new_h = _target_size(info['width'], info['height'],
max_width, max_height)
source_mb = os.path.getsize(path) / 1024 / 1024
_log(f'{os.path.basename(path)} is {info["width"]}x{info["height"]} '
f'({source_mb:.1f} MB) — above {max_width}x{max_height}; '
f'normalising to {new_w}x{new_h}')
if dry_run:
result['status'] = 'would'
result['output'] = output
return result
ffmpeg = find_ffmpeg()
if not ffmpeg:
_log('ffmpeg not found — cannot normalise', 'warning')
result['status'] = 'failed'
result['info'] = 'ffmpeg not found'
return result
# Write to a temp file and rename on success, so a partial or failed
# conversion can never be picked up as a valid video.
#
# The temp name keeps the real extension: ffmpeg infers the output muxer
# from the filename, so a bare ".part" fails with "Unable to choose an
# output format". The file is only ever moved to ``output`` after a
# successful encode, so the temp name is not user-visible.
root, ext = os.path.splitext(output)
tmp_out = f'{root}.tmp{ext or ".mp4"}'
try:
if os.path.exists(tmp_out):
os.remove(tmp_out)
except OSError:
pass
audio_args = ['-c:a', 'copy'] if info.get('has_audio') else ['-an']
# The scale filter runs on the CPU (the slow part for 4K); the encoder is
# hardware when possible.
vf = f'scale={new_w}:{new_h}:flags=fast_bilinear,format=yuv420p'
def build(codec_args):
"""Assemble one ffmpeg command.
``-map`` is explicit so extra streams (subtitles, cover art, a second
audio track) cannot change the output shape between runs.
"""
cmd = [ffmpeg, '-hide_banner', '-loglevel', 'error', '-i', path,
'-map', '0:v:0']
if info.get('has_audio'):
cmd += ['-map', '0:a:0?']
cmd += ['-vf', vf] + codec_args + audio_args + [
'-movflags', '+faststart',
'-pix_fmt', 'yuv420p',
'-y', tmp_out,
]
return cmd
# Try hardware first, then fall back to software. A hardware encoder that
# is listed but unusable (device busy, kernel mismatch) fails here rather
# than producing a broken file.
attempts = []
if hardware_encoder_available():
attempts.append((
f'hardware ({HW_ENCODER})',
build(['-c:v', HW_ENCODER, '-b:v', TARGET_BITRATE,
'-maxrate', MAX_BITRATE, '-bufsize', BUFSIZE]),
))
attempts.append((
f'software ({SW_ENCODER})',
build(['-c:v', SW_ENCODER, '-preset', 'ultrafast', '-crf', '23',
'-maxrate', MAX_BITRATE, '-bufsize', BUFSIZE]),
))
started = time.monotonic()
# Publish the "converting" flag BEFORE the first encoder runs. The player
# watches this marker and skips the item while it exists, so an item must be
# flagged for the entire window in which its file is being rewritten.
marker_written = media_state.begin_conversion(
path, note=f'{info["width"]}x{info["height"]} -> {new_w}x{new_h}')
if not marker_written:
_log(f'could not write the conversion marker for '
f'{os.path.basename(path)}; the player may try to show the file '
f'while it is being rewritten', 'warning')
try:
return _run_encoders(
attempts, path, output, tmp_out, info, source_mb, result,
started, f'{new_w}x{new_h}')
finally:
# Always clear the flag, including on failure: leaving it set would
# make the item unplayable until it went stale.
media_state.end_conversion(path)
def _run_encoders(attempts, path, output, tmp_out, info, source_mb,
result, started, size_note):
"""Try each encoder in turn; move the output into place on success."""
for label, cmd in attempts:
code, out = _run(cmd, timeout=1800)
if code == 0 and os.path.isfile(tmp_out) and os.path.getsize(tmp_out) > 0:
try:
os.replace(tmp_out, output)
except OSError as exc:
_log(f'could not move normalised file into place: {exc}',
'warning')
result['status'] = 'failed'
return result
result['elapsed_s'] = time.monotonic() - started
result['status'] = 'normalized'
result['output'] = output
out_mb = os.path.getsize(output) / 1024 / 1024
_log(f'normalised with {label} in {result["elapsed_s"]:.0f}s: '
f'{source_mb:.1f} MB -> {out_mb:.1f} MB '
f'({os.path.basename(output)}) now {size_note}')
# Metadata last: its presence is what marks the conversion complete
# for media_state.normalized_file(), so it must never exist for a
# half-written output.
try:
with open(output + MARKER_SUFFIX, 'w') as fh:
json.dump({
'source': os.path.basename(path),
'source_size': os.path.getsize(path),
'width': info['width'],
'height': info['height'],
'output_width': int(size_note.split('x')[0]),
'output_height': int(size_note.split('x')[1]),
'encoder': label,
'normalized_at': time.strftime('%Y-%m-%dT%H:%M:%S'),
}, fh, indent=2)
except Exception:
pass
return result
_log(f'{label} failed (rc={code}): {out.strip()[:300]}', 'warning')
try:
if os.path.exists(tmp_out):
os.remove(tmp_out)
except OSError:
pass
result['status'] = 'failed'
result['info'] = 'all encoders failed'
_log(f'could not normalise {os.path.basename(path)}; '
f'the original will be used', 'warning')
return result
def normalize_media_dirs(media_dirs, max_width=DEFAULT_MAX_WIDTH,
max_height=DEFAULT_MAX_HEIGHT, dry_run=False):
"""Normalise every oversized video under the given directories.
Intended for bulk/offline use (``--all``) and for verifying an install.
Returns a list of per-file result dicts.
"""
results = []
seen = set()
for directory in media_dirs:
if not os.path.isdir(directory):
continue
for root, _dirs, files in os.walk(directory):
for name in sorted(files):
if not name.lower().endswith(
('.mp4', '.mkv', '.mov', '.webm', '.avi', '.m4v')):
continue
if '_kiwy' in name or name.endswith(MARKER_SUFFIX):
continue # already an output, never re-process
path = os.path.join(root, name)
if path in seen:
continue
seen.add(path)
size = os.path.getsize(path)
key = (path, size)
if key in seen:
continue
results.append(normalize_video(
path, max_width, max_height, dry_run=dry_run))
return results
# ── CLI ──────────────────────────────────────────────────────────────
def main():
import argparse
parser = argparse.ArgumentParser(
description='Downscale oversized videos for Raspberry Pi playback.')
parser.add_argument('paths', nargs='+',
help='video files or directories to inspect')
parser.add_argument('--max-width', type=int, default=DEFAULT_MAX_WIDTH)
parser.add_argument('--max-height', type=int, default=DEFAULT_MAX_HEIGHT)
parser.add_argument('--dry-run', action='store_true',
help='report what would change, convert nothing')
parser.add_argument('--force', action='store_true',
help='re-convert even if a normalised copy exists')
args = parser.parse_args()
print(f'max size : {args.max_width}x{args.max_height}')
print(f'ffmpeg : {find_ffmpeg()}')
print(f'ffprobe : {find_ffprobe()}')
if not args.dry_run:
hw = 'available' if hardware_encoder_available() else 'unavailable (will use libx264)'
print(f'hw enc : {hw}')
print()
changed = 0
for target in args.paths:
if os.path.isdir(target):
results = normalize_media_dirs(
[target], args.max_width, args.max_height, args.dry_run)
changed += sum(1 for r in results
if r['status'] in ('normalized', 'would', 'reused'))
continue
if args.force:
oversized, info = needs_normalization(
target, args.max_width, args.max_height)
if info:
out = normalized_path(target, args.max_width, args.max_height)
for stale in (out, out + MARKER_SUFFIX):
try:
os.remove(stale)
except OSError:
pass
result = normalize_video(target, args.max_width, args.max_height,
force=args.force, dry_run=args.dry_run)
info = result.get('info')
name = os.path.basename(target)
if result['status'] == 'within_limit':
if isinstance(info, dict) and info.get('width'):
print(f' ok {name} {info["width"]}x{info["height"]} '
f'(within limit)')
else:
print(f' ok {name}')
elif result['status'] == 'reused':
print(f' reuse {name} {info["width"]}x{info["height"]} '
f'-> {os.path.basename(result["output"])}')
changed += 1
elif result['status'] == 'normalized':
print(f' DONE {name} -> {os.path.basename(result["output"])} '
f'({result["elapsed_s"]:.0f}s)')
changed += 1
elif result['status'] == 'would':
print(f' WOULD {name} {info["width"]}x{info["height"]} '
f'-> {os.path.basename(result["output"])}')
changed += 1
else:
print(f' FAILED {name} {result.get("info")}')
print(f'\n{changed} file(s) need normalisation')
return 0
if __name__ == '__main__':
raise SystemExit(main())