Files
Kiwy-Signage/linux/run_linux.py
ske087 3ac7f836c4 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.
2026-09-13 21:57:49 +03:00

556 lines
23 KiB
Python

"""
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)