3ac7f836c4
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.
426 lines
16 KiB
Python
426 lines
16 KiB
Python
"""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))
|