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:
@@ -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?
|
||||
@@ -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"]}')
|
||||
@@ -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")}')
|
||||
@@ -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())
|
||||
@@ -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)
|
||||
Executable
+166
@@ -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
|
||||
@@ -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
|
||||
@@ -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))
|
||||
@@ -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())
|
||||
@@ -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)
|
||||
@@ -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.')
|
||||
@@ -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.')
|
||||
@@ -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.')
|
||||
@@ -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())
|
||||
Reference in New Issue
Block a user