Port the player to Raspberry Pi OS Trixie 64-bit (Linux-only branch)

Replaces the Windows port with a Raspberry Pi / Linux implementation on
Raspberry Pi OS "Trixie" (Debian 13, aarch64, Wayland/labwc). The Windows
code is removed here but preserved on the Windows-Player branch.

Entry point
-----------
linux/run_linux.py replaces windows/run_win.py. src/main.py stays
platform-neutral; all Pi-specific behaviour is injected from linux/.

Five bugs that prevented the port (all measured on real hardware)
----------------------------------------------------------------
1. Kivy's PyPI wheel bundles an SDL2 built WITHOUT the wayland driver, so
   no window could be created (Trixie has no X server). linux/fix_kivy_sdl2.sh
   symlinks the system SDL2 over the bundled filename.
2. SDL2 requires WAYLAND_DISPLAY to be *set* - the socket alone is not enough,
   unlike wlopm. This broke every systemd/cron/autostart launch.
   linux_display.ensure_session_environment() detects and exports it.
3. Kivy's Clock resolves callbacks via func.__name__; a patch assigned under a
   different name crashed the player ~20s after a successful start.
4. The inherited signal_screen_activity() shelled out to tvservice, xdotool and
   ydotool - none exist on Trixie - and mis-escaped 'wlopm --on \*', so the
   display blanked after 10 minutes.
5. The launchers ran src/main.py directly, bypassing every platform patch and
   resolving the data directory one level too high.

Web links
---------
- --ozone-platform-hint=auto does NOT fall back to Wayland on Chromium 152; it
  aborts. The platform is now chosen explicitly.
- The keyring password prompt is suppressed via the ENVIRONMENT, not the flags:
  launch_env() strips DBUS_SESSION_BUS_ADDRESS for the child so Chromium cannot
  reach gnome-keyring-daemon.
- Teardown kills the whole process group (needs start_new_session=True);
  previously it silently fell back to terminate() and orphaned children.

Video normalisation
-------------------
A 4K video cannot play on a Pi 4: ffpyplayer decodes in software, measured at
0.90x realtime (1080p is 3.03x). Oversized media is downscaled to 1920x1080 at
sync time using the hardware h264_v4l2m2m encoder (~31s for an 18s clip),
triggered by resolution only so already-playable files are untouched.

src/media_state.py owns the shared on-disk contract: a .kiwy-converting marker
makes the player skip the item while it is being rebuilt, then the converted
file is played instead. If nothing is playable at all (a single-item playlist
whose only video is converting), the player loops the intro video rather than
leaving a blank screen.

Also fixed
----------
- network_monitor: replaced netsh/ifconfig/dhclient with nmcli (Trixie uses
  NetworkManager; ifconfig and dhclient are not even installed).
- Removed the Windows-only focus keeper/guardian from main.py.
- main.py: duplicate SDL_AUDIODRIVER setdefault (a silent no-op); Settings
  "Test connection" now uses tempfile.gettempdir().
- config/app_config.json: credentials blanked so a fresh clone runs the
  first-run setup flow.

Verification
------------
linux/test_media_state.py 18/18, test_linux_patches.py 21/21,
test_linux_browser_flags.py 27/27. Verified live against a real DigiServer:
image -> weblink -> image -> video with correct durations, zero leaked Chromium
processes, and no throttling over a 10 minute monitored run.
This commit is contained in:
ske087
2026-09-13 21:57:49 +03:00
parent f437aba1fc
commit 3ac7f836c4
68 changed files with 5604 additions and 9326 deletions
+186
View File
@@ -0,0 +1,186 @@
"""test_linux_patches.py — verify the Linux platform patches are wired correctly.
Run with the project virtualenv:
.venv/bin/python linux/test_linux_patches.py
These are the regressions that actually bit us during the Trixie port, so they
are asserted rather than left to manual testing:
1. **Kivy WeakMethod name trap.** ``Clock`` stores ``callable.__name__`` and
re-resolves it with ``getattr(instance, name)``. If a patched method is
assigned under a name that differs from its own ``__name__``, the app dies
~20 s later with ``AttributeError`` — far from the cause. Every method this
port replaces must be reachable under its own function name.
2. **SDL2 Wayland capability.** Kivy's PyPI wheel bundles an SDL2 *without* the
wayland driver; the system SDL2 has it. Getting this wrong means no window at
all on Raspberry Pi OS Trixie.
3. **WAYLAND_DISPLAY inference.** SDL2 requires the variable to be set — unlike
``wlopm``, it does not scan ``XDG_RUNTIME_DIR``. A systemd/cron launch has it
unset, so the entry point must fill it in.
The player does not need to be running; this only exercises wiring and detection.
"""
from __future__ import annotations
import ctypes
import glob
import os
import subprocess
import sys
from pathlib import Path
HERE = Path(__file__).resolve().parent
ROOT = HERE.parent
SRC = ROOT / 'src'
VENV = Path(os.environ.get('KIWY_VENV') or (ROOT / '.venv'))
for path in (str(HERE), str(SRC)):
if path not in sys.path:
sys.path.insert(0, path)
failures: list[str] = []
checks = 0
def check(label, condition, detail=''):
global checks
checks += 1
if condition:
print(f' PASS {label}')
else:
print(f' FAIL {label}' + (f'{detail}' if detail else ''))
failures.append(label)
# ── 1. WeakMethod name trap ──────────────────────────────────────────
print('\n[1] Patched methods are resolvable by their own __name__')
# Import the entry point's patching helpers without running the app. Importing
# run_linux executes its env setup, which is harmless and actually desirable
# here (it fills in WAYLAND_DISPLAY for the checks below).
import run_linux # noqa: E402
main = run_linux._import_main()
patches = {
'signal_screen_activity': run_linux._patch_display(main),
'weblink_adapter_factory': run_linux._patch_weblink_engines(main),
'test_connection': run_linux._patch_temp_paths(main),
'reset_player_auth': run_linux._patch_auth_path(main),
}
for label, applied in patches.items():
check(f'{label} patch applied', applied, 'patch reported failure')
player_cls = main.SignagePlayer
for attr in ('signal_screen_activity', 'weblink_adapter_factory'):
func = getattr(player_cls, attr, None)
check(f'{attr} exists', func is not None, 'attribute missing')
if func is None:
continue
# For a staticmethod, inspect the underlying function.
raw = player_cls.__dict__.get(attr)
func = raw.__func__ if isinstance(raw, staticmethod) else raw
name = getattr(func, '__name__', '')
# The real invariant: Kivy resolves the callback with
# getattr(instance, func.__name__), so that lookup must succeed and return
# the same function. The name need not equal the attribute it replaced, but
# it MUST be reachable on the class.
resolved = getattr(player_cls, name, None)
check(
f'{attr}: getattr(cls, {name!r}) resolves',
resolved is not None,
f'Kivy Clock would raise AttributeError: no attribute {name!r}',
)
check(
f'{attr}: resolved function is the patched one',
resolved is func,
f'{name!r} resolved to a different object',
)
# The Windows-only focus machinery must be gone from the shared app, so nothing
# schedules Win32 work on a Wayland session.
for attr in ('_focus_keeper_tick', '_focus_guardian_tick',
'_bring_window_to_front_nonblocking', '_start_focus_guardian'):
check(
f'{attr} removed (Windows-only)',
not hasattr(player_cls, attr),
'the platform-specific focus code should not be in the shared app',
)
# ── 2. Display module behaviour ──────────────────────────────────────
print('\n[2] linux_display detection and keep-awake')
import linux_display as display # noqa: E402
status = display.status()
print(f' info status = {status}')
if status['wayland']:
check('WAYLAND_DISPLAY is set after detection',
bool(os.environ.get('WAYLAND_DISPLAY')),
'SDL2/wlopm need this variable')
check('wlopm available', status['wlopm'], 'install wlopm')
check('keep_display_awake reports success',
display.keep_display_awake(force=True) is True,
'wlopm --on did not succeed')
else:
print(' SKIP not a Wayland session — display checks skipped')
# With the tools explicitly disabled nothing must be attempted.
os.environ[display.DISABLE_ENV_VAR] = '1'
check('DISABLE escape hatch suppresses keep-awake',
display.keep_display_awake(force=True) is False)
check('DISABLE escape hatch suppresses blanker kill',
display.neutralise_idle_blanker() == [])
del os.environ[display.DISABLE_ENV_VAR]
# ── 3. SDL2 Wayland capability ───────────────────────────────────────
print('\n[3] SDL2 in use supports the wayland driver')
def drivers_of(lib_path):
try:
lib = ctypes.CDLL(str(lib_path))
lib.SDL_GetNumVideoDrivers.restype = ctypes.c_int
lib.SDL_GetVideoDriver.restype = ctypes.c_char_p
lib.SDL_GetVideoDriver.argtypes = [ctypes.c_int]
n = lib.SDL_GetNumVideoDrivers()
return [lib.SDL_GetVideoDriver(i).decode() for i in range(n)]
except Exception as exc:
return [f'<error: {exc}>']
ext = glob.glob(str(VENV / '**' / '_window_sdl2*.so'), recursive=True)
if not ext:
print(' SKIP no Kivy SDL2 extension found in this venv')
else:
# Ask the dynamic loader which libSDL2 the extension actually resolves.
linked = subprocess.run(
['ldd', ext[0]], capture_output=True, text=True, check=False,
).stdout
resolved = None
for line in linked.splitlines():
if 'libSDL2-2-' in line and '=>' in line:
resolved = line.split('=>')[1].split('(')[0].strip()
break
check('Kivy resolves an SDL2 library', resolved is not None, 'ldd found none')
if resolved:
used = drivers_of(resolved)
print(f' info {resolved}\n drivers = {used}')
check('resolved SDL2 supports wayland', 'wayland' in used,
'run: bash linux/fix_kivy_sdl2.sh')
# ── Summary ──────────────────────────────────────────────────────────
print(f'\n{checks - len(failures)}/{checks} checks passed')
if failures:
print('\nFailed:')
for name in failures:
print(f' - {name}')
sys.exit(1)
print('All checks passed.')