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,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.')
|
||||
Reference in New Issue
Block a user