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.
556 lines
23 KiB
Python
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)
|