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
+425
View File
@@ -0,0 +1,425 @@
"""linux_display.py — display power, keep-awake and rotation for Raspberry Pi OS.
Why this module exists
----------------------
On Raspberry Pi OS "Trixie" the desktop session is **Wayland/labwc**, and an
idle blanker is installed and running by default::
swayidle -w timeout 600 'wlopm --off *' resume 'wlopm --on *'
A signage player must never blank, so that line fights the player for control of
the output. The historical implementation in ``main.py``
(``signal_screen_activity``) shells out to X11 tools that no longer exist on
Trixie (``xdotool``, ``tvservice``, ``ydotool`` are all absent) and uses a
mis-escaped ``wlopm --on \\*`` argument, so it kept the screen awake on none of
the current installs.
This module replaces that logic with the commands Trixie actually provides:
* ``wlopm`` — Wayland output power management (present, works)
* ``vcgencmd display_power`` — Raspberry Pi firmware-level display power
* ``wlr-randr`` — output configuration, used for rotation (present)
All work is best-effort and non-fatal: running the player over SSH, or on a
desktop without a compositor, must never crash or spam the log.
The platform entry point (``linux/run_linux.py``) installs
:func:`linux_screen_activity` onto ``SignagePlayer`` before playback starts.
"""
from __future__ import annotations
import os
import shutil
import subprocess
import time
# ── Tunables ─────────────────────────────────────────────────────────
#: Don't re-issue the keep-awake commands more often than this (seconds).
#: ``signal_screen_activity`` is called on a 20 s Kivy interval; the commands
#: are idempotent but spawning processes on a Pi is not free.
MIN_REASSERT_INTERVAL = 5.0
#: Set to "1" to disable every display command (diagnostics / desktop testing).
DISABLE_ENV_VAR = 'KIWY_DISPLAY_TOOLS_DISABLED'
#: Output name used when the compositor does not report one.
DEFAULT_OUTPUT = 'HDMI-A-1'
_state = {
'last_awake_at': 0.0,
'blinker_pids': [],
'logged_backend': False,
'warned_no_backend': False,
}
def _log(message, level='info'):
"""Log through Kivy when available, else print. Never raises."""
try:
from kivy.logger import Logger
getattr(Logger, level, Logger.info)(f'[Display] {message}')
except Exception:
try:
if level in ('error', 'warning'):
print(f'[Display] {message}')
except Exception:
pass
# ── Environment detection ────────────────────────────────────────────
def is_wayland():
"""True when a Wayland compositor socket is reachable.
Deliberately based on the socket, not on ``WAYLAND_DISPLAY``: the variable
is empty in exactly the launch contexts this module exists to fix (systemd,
cron, SSH), where the socket is nevertheless present and usable.
"""
return wayland_socket_path() is not None
def is_x11():
"""True when an X11 display is reachable (and Wayland is not)."""
return bool(os.environ.get('DISPLAY')) and not is_wayland()
def tools_disabled():
"""Honour the operator escape hatch."""
return os.environ.get(DISABLE_ENV_VAR, '').strip().lower() in ('1', 'true', 'yes')
# ── Command helper ───────────────────────────────────────────────────
def wayland_socket_path():
"""Absolute path of the Wayland socket, or None."""
runtime = os.environ.get('XDG_RUNTIME_DIR') or f'/run/user/{os.getuid()}'
display = os.environ.get('WAYLAND_DISPLAY') or ''
if display:
# Absolute names are used as-is; relative names live in XDG_RUNTIME_DIR.
return display if os.path.isabs(display) else os.path.join(runtime, display)
candidate = os.path.join(runtime, 'wayland-0')
return candidate if os.path.exists(candidate) else None
def ensure_session_environment():
"""Fill in the display environment for our child processes.
**This is the bug that made keep-awake silently useless.** A process started
by ``systemd``, a cron wrapper or an SSH session has ``XDG_RUNTIME_DIR`` set
but ``WAYLAND_DISPLAY`` *empty* — the compositor only exports it inside the
desktop session. ``wlopm`` then fails with::
ERROR: WAYLAND_DISPLAY is not set.
and exits 1, so the panel blanks after the idle timeout. Detecting the
socket and exporting the name (derived from its filename, so an unusual
``wayland-1`` still works) makes the tools function regardless of how the
player was launched.
Only ever *sets* values that are missing, so an operator-provided value is
never overridden. Returns a dict of what was changed (for logging).
"""
changed = {}
runtime = os.environ.get('XDG_RUNTIME_DIR')
if not runtime:
fallback = f'/run/user/{os.getuid()}'
if os.path.isdir(fallback):
os.environ['XDG_RUNTIME_DIR'] = fallback
changed['XDG_RUNTIME_DIR'] = fallback
runtime = fallback
if not os.environ.get('WAYLAND_DISPLAY') and runtime:
try:
# Prefer wayland-0, else the lowest-numbered socket present.
candidates = sorted(
name for name in os.listdir(runtime)
if name.startswith('wayland-') and not name.endswith('.lock')
)
if candidates:
os.environ['WAYLAND_DISPLAY'] = candidates[0]
changed['WAYLAND_DISPLAY'] = candidates[0]
except Exception:
pass
return changed
def _run(args, timeout=5.0):
"""Run a command quietly. Returns (returncode, stdout) — never raises.
``stderr`` is discarded: ``wlopm``/``wlr-randr`` are chatty about compositor
details we do not act on, and the player's log is noise-sensitive.
"""
ensure_session_environment()
try:
result = subprocess.run(
args,
stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL,
stdin=subprocess.DEVNULL,
timeout=timeout,
text=True,
check=False,
)
return result.returncode, (result.stdout or '').strip()
except FileNotFoundError:
return 127, ''
except subprocess.TimeoutExpired:
_log(f'{args[0]} timed out after {timeout}s', 'debug')
return 124, ''
except Exception as exc: # pragma: no cover - defensive
_log(f'{args[0]} failed: {exc}', 'debug')
return 1, ''
def _which(name):
try:
return shutil.which(name)
except Exception:
return None
# ── Output discovery ─────────────────────────────────────────────────
def list_outputs():
"""Return the connected Wayland output names (best effort).
Uses ``wlopm`` (no arguments lists ``<name> <on|off>`` per line), falling
back to ``wlr-randr`` so rotation works even where ``wlopm`` is missing.
"""
if not is_wayland():
return []
code, out = _run(['wlopm'])
if code == 0 and out:
names = []
for line in out.splitlines():
parts = line.split()
if parts:
names.append(parts[0])
if names:
return names
code, out = _run(['wlr-randr'])
if code == 0 and out:
names = []
for line in out.splitlines():
# Output lines are unindented: "HDMI-A-1 \"...\""
if line and not line[0].isspace():
names.append(line.split()[0])
return names
return []
def _target_output():
"""The output to address, or ``'*'`` so the compositor expands it.
``wlopm`` expands ``'*'`` itself, which is more robust than us guessing the
connector name (HDMI-A-1 / HDMI-A-2 / DSI-1 differ per board and port).
"""
return '*'
# ── Keep-awake ───────────────────────────────────────────────────────
def keep_display_awake(force=False):
"""Turn the display back on. Cheap, idempotent, rate-limited.
Returns True when something was actually issued.
"""
if tools_disabled():
return False
now = time.monotonic()
if not force and (now - _state['last_awake_at']) < MIN_REASSERT_INTERVAL:
return False
_state['last_awake_at'] = now
issued = False
failures = []
if is_wayland() and _which('wlopm'):
# NOTE: the argument must stay the literal '*' — the shell must not
# expand it (we pass a list, so no shell is involved) and wlopm does
# the matching. The old code passed a backslash-escaped '\*' through
# os.system(), so the compositor matched an output literally named '*'
# and nothing happened.
ensure_session_environment()
code, out = _run(['wlopm', '--on', _target_output()])
issued = issued or code == 0
if code != 0:
failures.append(f'wlopm --on {_target_output()} rc={code} {out}'.strip())
elif is_x11():
if _which('xset'):
code, _ = _run(['xset', 's', 'reset'])
issued = issued or code == 0
_run(['xset', 'dpms', 'force', 'on'])
if _which('xdotool'):
# Nudge the pointer by a pixel and back — invisible, but enough to
# reset the X idle counter on compositors without a Wayland path.
code, _ = _run(['xdotool', 'mousemove_relative', '1', '1'])
if code == 0:
_run(['xdotool', 'mousemove_relative', '-1', '-1'])
issued = True
# Firmware-level backstop: covers a blanked HDMI signal even when the
# compositor never reported the output as off.
if _which('vcgencmd'):
_run(['vcgencmd', 'display_power', '1'])
if failures and not _state['warned_no_backend']:
_state['warned_no_backend'] = True
_log(
'keep-awake command failed: ' + '; '.join(failures)
+ f' (WAYLAND_DISPLAY={os.environ.get("WAYLAND_DISPLAY")!r})',
'warning',
)
elif not issued and not _state['warned_no_backend']:
_state['warned_no_backend'] = True
_log(
'No usable display backend found (no Wayland socket and no X11 '
'display) — keep-awake is inactive.',
'warning',
)
return issued
def neutralise_idle_blanker():
"""Stop the desktop idle blanker from turning the panel off.
On Raspberry Pi OS Trixie ``~/.config/labwc/autostart`` starts::
swayidle -w timeout 600 'wlopm --off *' resume 'wlopm --on *'
That is exactly the behaviour a signage player must override, so the
``swayidle`` process is terminated once at start-up and re-checked
periodically (in case the session restarts it).
Only processes owned by the current user are touched, and a failure is
never fatal. Set ``KIWY_DISPLAY_TOOLS_DISABLED=1`` to opt out.
"""
if tools_disabled() or not _which('swayidle'):
return []
try:
result = subprocess.run(
['pgrep', '-x', 'swayidle'],
stdout=subprocess.PIPE, stderr=subprocess.DEVNULL,
text=True, timeout=5, check=False,
)
pids = [int(p) for p in (result.stdout or '').split() if p.strip().isdigit()]
except Exception:
return []
own_uid = os.getuid()
killed = []
for pid in pids:
try:
# Only our own processes: swayidle is per-session and killing
# another user's would be a surprise.
if os.stat(f'/proc/{pid}').st_uid != own_uid:
continue
os.kill(pid, 15) # SIGTERM
killed.append(pid)
except (ProcessLookupError, PermissionError, FileNotFoundError):
continue
except Exception:
continue
if killed:
_log(f'Stopped the idle blanker (swayidle pids: {killed}) — the display '
f'will stay on. Disable with {DISABLE_ENV_VAR}=1.')
return killed
def _log_backend_once():
if _state['logged_backend']:
return
_state['logged_backend'] = True
backend = 'wayland' if is_wayland() else ('x11' if is_x11() else 'none')
_log(f'backend={backend} outputs={list_outputs() or "unknown"} '
f'WAYLAND_DISPLAY={os.environ.get("WAYLAND_DISPLAY") or "(unset)"} '
f'wlopm={bool(_which("wlopm"))} vcgencmd={bool(_which("vcgencmd"))}')
# ── SignagePlayer replacement ────────────────────────────────────────
def linux_screen_activity(self, dt):
"""Drop-in replacement for ``SignagePlayer.signal_screen_activity``.
Bound to a 20 s Kivy interval, so it must be cheap and must never raise.
"""
try:
_log_backend_once()
keep_display_awake()
# Cheap re-check: the blanker may have been restarted by the session,
# e.g. after a compositor reload.
if (time.monotonic() - _state.get('last_blinker_check', 0.0)) > 60.0:
_state['last_blinker_check'] = time.monotonic()
neutralise_idle_blanker()
except Exception as exc: # pragma: no cover - must never break the Clock
_log(f'screen activity signal failed (non-fatal): {exc}', 'debug')
# ── Orientation / resolution (Wayland-native) ────────────────────────
#: Logical rotation for each supported orientation value from app_config.json.
ORIENTATION_TRANSFORMS = {
'landscape': 'normal',
'portrait': '90',
'portrait-inverted': '270',
'landscape-inverted': '180',
}
def apply_orientation(orientation):
"""Rotate the display to match the configured orientation.
``Window.size`` cannot rotate a fullscreen Wayland surface, so the rotation
has to happen at the output level. ``wlr-randr`` is the tool that works on
labwc; the call is skipped silently when it is unavailable.
Returns True when a transform was applied.
"""
if tools_disabled():
return False
key = str(orientation or '').strip().lower()
transform = ORIENTATION_TRANSFORMS.get(key)
if not transform or transform == 'normal':
return False
if not is_wayland() or not _which('wlr-randr'):
_log(f'Orientation "{orientation}" requested but wlr-randr/Wayland is '
f'unavailable — leaving the output unrotated.', 'warning')
return False
outputs = list_outputs() or [DEFAULT_OUTPUT]
applied = False
for name in outputs:
code, _ = _run(['wlr-randr', '--output', name, '--transform', transform])
applied = applied or code == 0
if applied:
_log(f'Applied orientation "{orientation}" (transform={transform}) to '
f'{outputs}')
return applied
def status():
"""Diagnostic snapshot (used by tests and the startup banner)."""
return {
'wayland': is_wayland(),
'wayland_socket': wayland_socket_path(),
'wayland_display': os.environ.get('WAYLAND_DISPLAY') or '(unset)',
'x11': is_x11(),
'disabled': tools_disabled(),
'outputs': list_outputs(),
'wlopm': bool(_which('wlopm')),
'wlr_randr': bool(_which('wlr-randr')),
'vcgencmd': bool(_which('vcgencmd')),
}
if __name__ == '__main__': # pragma: no cover - manual diagnostics
import json
import sys
if len(sys.argv) > 1 and sys.argv[1] == '--awake':
print('awake issued:', keep_display_awake(force=True))
print('blinker killed:', neutralise_idle_blanker())
print(json.dumps(status(), indent=2))