""" 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: /linux/run_linux.py -> is the data directory and # /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)