Compare commits
74 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e8809f91fa | |||
| a5b5109102 | |||
| 3ac7f836c4 | |||
| f437aba1fc | |||
| 864ea06996 | |||
| 079d8c7f9d | |||
| 477128de81 | |||
| d8c6ab0bc5 | |||
| 9f5409685d | |||
| eb8e66e427 | |||
| 7e880421c9 | |||
| 5d9aa02c07 | |||
| a0704efa3c | |||
| 6dc79828bc | |||
| a19627885c | |||
| 31ad592e98 | |||
| 5c2b3f545f | |||
| d0ea94447a | |||
| 12f2880201 | |||
| 5a030671a2 | |||
| a2add88f04 | |||
| c4e8381898 | |||
| ced6e10919 | |||
| 844e5eeebb | |||
| 7efc023327 | |||
| 6abde5a767 | |||
| 362f5096a0 | |||
| 3845830a86 | |||
| f7b889be11 | |||
| 39565c5761 | |||
| fb9f46a94f | |||
| 9d8eb0cf21 | |||
| 64e49886df | |||
| f30f97683b | |||
| 609d9169d2 | |||
| abbd51a787 | |||
| 2c31589787 | |||
| a8d6d70cd4 | |||
| 11436ddeab | |||
| e2abde9f9c | |||
| a825d299bf | |||
| 30f058182c | |||
| 9b58f6b63d | |||
| eeb2a61ef7 | |||
| 120c889143 | |||
| d1382af517 | |||
| 3531760e16 | |||
| 6bf4e3735a | |||
| e735e85d3c | |||
| 72a6d7e704 | |||
| 8703350b23 | |||
| 17ae5439bd | |||
| 81432ac832 | |||
| c5bf6c1eaf | |||
| 1c02843687 | |||
| 1cc0eae542 | |||
| b2d380511a | |||
| db796e4d66 | |||
| 5843bb5215 | |||
| 2b42999008 | |||
| 02e9ea1aaa | |||
| 4c3ddbef73 | |||
| 87e059e0f4 | |||
| 46d9fcf6e3 | |||
| f1a84d05d5 | |||
| 706af95557 | |||
| 02227a12e5 | |||
| 9d32f43ac7 | |||
| af1e671c7f | |||
| 9664ad541b | |||
| 89e5ad86dd | |||
| f573af0505 | |||
| fba2007bdf | |||
| 72d382b96b |
@@ -0,0 +1,253 @@
|
|||||||
|
---
|
||||||
|
description: "Use when installing, deploying, running or debugging the Kiwy Signage Player on Raspberry Pi OS Trixie 64-bit: venv setup, Kivy/SDL2 Wayland requirements, the linux/ entry point, autostart, systemd, watchdog supervision, or adding new modules under src/. Covers the exact commands, environment constraints and verification steps."
|
||||||
|
name: "Kiwy Linux / Raspberry Pi Build & Development"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Kiwy Signage Player — Linux / Raspberry Pi Build & Development
|
||||||
|
|
||||||
|
Kivy digital signage player targeting **Raspberry Pi OS "Trixie"** (Debian 13,
|
||||||
|
aarch64, Wayland/labwc).
|
||||||
|
|
||||||
|
- **Entry point** — `linux/run_linux.py`. Not `src/main.py`.
|
||||||
|
- **Target hardware** — Raspberry Pi 4 / 5. Verified on a Pi 4 Model B Rev 1.4,
|
||||||
|
kernel `6.18.39+rpt-rpi-v8`.
|
||||||
|
|
||||||
|
## Ground Rules
|
||||||
|
|
||||||
|
- **Never launch `src/main.py` directly.** It is a shared module, not the Pi
|
||||||
|
entry point. Running it skips every platform patch (session environment,
|
||||||
|
display keep-awake, Chromium adapter) and resolves the data directory one
|
||||||
|
level too high. Always go through `linux/run_linux.py`
|
||||||
|
(`bash run_player.sh` / `bash start.sh`).
|
||||||
|
- **Keep `src/main.py` platform-neutral.** Pi-specific behaviour belongs in
|
||||||
|
`linux/`. `src/main.py` may only contain *guarded capability checks*, never a
|
||||||
|
platform-specific import.
|
||||||
|
- **A patched method must be resolvable by its own `__name__`.** Kivy's `Clock`
|
||||||
|
wraps callbacks in a `WeakMethod` keyed on `func.__name__` and re-resolves it
|
||||||
|
with `getattr(instance, name)`. Assigning a replacement under a different name
|
||||||
|
than it was defined with raises `AttributeError` **the first time the Clock
|
||||||
|
fires** — minutes after a successful start, with a traceback that points
|
||||||
|
nowhere near the patch. Use `_bind_name()`, and cover new patches in
|
||||||
|
`linux/test_linux_patches.py`. This has already caused two outages.
|
||||||
|
- **No `sudo` at runtime.** The player runs as an unprivileged user. Anything
|
||||||
|
needing root must be set up at install time (sudoers entry, udev rule,
|
||||||
|
systemd unit).
|
||||||
|
|
||||||
|
## Environment
|
||||||
|
|
||||||
|
| Item | Value |
|
||||||
|
|------|-------|
|
||||||
|
| OS | Raspberry Pi OS Trixie / Debian 13, aarch64 |
|
||||||
|
| Session | **Wayland** via `rpd-labwc` (`labwc`). No X server. |
|
||||||
|
| Python | **3.13.5** — project venv at `.venv` (created `--system-site-packages`) |
|
||||||
|
| Kivy | 2.3.1 (PyPI wheel) |
|
||||||
|
| ffpyplayer | 4.5.3 (video/audio backend) |
|
||||||
|
| evdev | 2.0.0 — **sdist only**, built locally (needs `python3-dev`, `build-essential`) or apt `python3-dev` |
|
||||||
|
| Browser | `/usr/bin/chromium` (there is no `chromium-browser` on Trixie) |
|
||||||
|
|
||||||
|
### The SDL2 trap (the #1 way to break the install)
|
||||||
|
|
||||||
|
Kivy's PyPI wheel bundles a **private SDL2 without the Wayland driver**:
|
||||||
|
|
||||||
|
```
|
||||||
|
Kivy.libs/libSDL2-2-*.so -> x11, KMSDRM, offscreen, dummy, evdev # no wayland!
|
||||||
|
/usr/lib/.../libSDL2-2.0.so.0 -> x11, wayland, KMSDRM, offscreen, dummy, evdev
|
||||||
|
```
|
||||||
|
|
||||||
|
On Trixie there is no X server, so the bundled build cannot create a window:
|
||||||
|
|
||||||
|
```
|
||||||
|
[CRITICAL] Unable to find any valuable Window provider.
|
||||||
|
sdl2 - RuntimeError: b'wayland,x11,dummy not available'
|
||||||
|
```
|
||||||
|
|
||||||
|
`linux/fix_kivy_sdl2.sh` symlinks the **system** SDL2 over the bundled
|
||||||
|
filename. **Re-run it after every `pip install --upgrade kivy`.**
|
||||||
|
`linux/test_linux_patches.py` asserts the driver is present, so a regression
|
||||||
|
fails the test suite rather than the player.
|
||||||
|
|
||||||
|
### SDL2 requires `WAYLAND_DISPLAY` to be set
|
||||||
|
|
||||||
|
The socket existing is **not** enough — unlike `wlopm`, SDL2 does not scan
|
||||||
|
`XDG_RUNTIME_DIR`. A launch from systemd, cron, an autostart entry or SSH has
|
||||||
|
the variable **empty**, and Kivy then fails with `wayland not available`.
|
||||||
|
`linux_display.ensure_session_environment()` detects the socket and exports the
|
||||||
|
name; `run_linux.py` calls it before importing Kivy.
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
|
Required system packages:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo apt install -y \
|
||||||
|
python3-venv python3-dev build-essential \
|
||||||
|
libsdl2-2.0-0 libsdl2-image-2.0-0 libsdl2-mixer-2.0-0 libsdl2-ttf-2.0-0 \
|
||||||
|
libgl1-mesa-dri libgles2 \
|
||||||
|
chromium wlopm wlr-randr ffmpeg
|
||||||
|
```
|
||||||
|
|
||||||
|
Python dependencies (all have cp313 aarch64 wheels **except** evdev):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 -m venv --system-site-packages .venv
|
||||||
|
.venv/bin/pip install kivy ffpyplayer requests bcrypt aiohttp
|
||||||
|
.venv/bin/pip install evdev # builds from source
|
||||||
|
bash linux/fix_kivy_sdl2.sh # REQUIRED: system SDL2 with Wayland
|
||||||
|
```
|
||||||
|
|
||||||
|
## Run
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash run_player.sh # single run, no supervision
|
||||||
|
bash start.sh # watchdog: auto-restart on crash/hang (24/7)
|
||||||
|
```
|
||||||
|
|
||||||
|
`start.sh` supervises through a heartbeat file (`.player_heartbeat`, rewritten
|
||||||
|
every 10 s; stale after 60 s ⇒ hung ⇒ restart) and honours
|
||||||
|
`.player_stop_requested` so a password exit is not resurrected.
|
||||||
|
|
||||||
|
## Verify Before Committing
|
||||||
|
|
||||||
|
```bash
|
||||||
|
.venv/bin/python -m py_compile src/*.py linux/*.py # syntax, seconds
|
||||||
|
bash -n start.sh run_player.sh stop_player.sh check_player_status.sh
|
||||||
|
|
||||||
|
.venv/bin/python linux/test_linux_patches.py # expect: 21/21 passed
|
||||||
|
.venv/bin/python linux/test_linux_browser_flags.py # expect: 27/27 passed
|
||||||
|
.venv/bin/python linux/test_media_state.py # expect: 18/18 passed
|
||||||
|
bash linux/fix_kivy_sdl2.sh --check # expect: STATUS: fixed
|
||||||
|
.venv/bin/python linux/_probe_video.py # video decodes + advances
|
||||||
|
.venv/bin/python linux/_probe_chromium_footprint.py https://example.com/ # PSS per profile
|
||||||
|
```
|
||||||
|
|
||||||
|
**Close the running player before editing `src/`** if you intend to test
|
||||||
|
immediately — the watchdog will restart it and you will test stale code.
|
||||||
|
|
||||||
|
## 24/7 Supervision
|
||||||
|
|
||||||
|
`start.sh` restarts the player when it:
|
||||||
|
|
||||||
|
- **crashed** — the process disappeared; or
|
||||||
|
- **hung** — the process is alive but the heartbeat is stale (>60 s).
|
||||||
|
|
||||||
|
Two failure modes must not be confused:
|
||||||
|
|
||||||
|
| Situation | Correct reaction |
|
||||||
|
|---|---|
|
||||||
|
| Was healthy, then heartbeat went stale | **Restart promptly** (a hang) |
|
||||||
|
| Running but never became healthy (slow start, the first-run setup screen) | **Be patient** — do not kill a player an operator is configuring |
|
||||||
|
|
||||||
|
Diagnostics for playback transitions live in `logs/playback_trace.log`,
|
||||||
|
written by `src/playback_trace.py` independently of Kivy's log level.
|
||||||
|
|
||||||
|
## Runtime pitfalls
|
||||||
|
|
||||||
|
- **`~/dev/...` cwd matters.** The player's data directory is the repo root.
|
||||||
|
Launch from there (all provided scripts do).
|
||||||
|
- **The desktop blanker fights the player.** `~/.config/labwc/autostart` ships
|
||||||
|
`swayidle -w timeout 600 'wlopm --off *'`, which powers the panel off after
|
||||||
|
10 minutes. `linux_display.neutralise_idle_blanker()` stops it at start-up.
|
||||||
|
Do not remove that call.
|
||||||
|
- **`tvservice`, `xdotool`, `ydotool` do not exist on Trixie.** Any code using
|
||||||
|
them silently does nothing.
|
||||||
|
- **Web links need a dedicated `--user-data-dir`.** Without it Chromium hands
|
||||||
|
the URL to a running instance, our process exits in ~2 s and the item is
|
||||||
|
skipped as a failed launch.
|
||||||
|
- **The keyring prompt is suppressed via the ENVIRONMENT, not the flags.**
|
||||||
|
`--password-store=basic` alone does not stop Chromium asking for the login
|
||||||
|
keyring password: it inherits `DBUS_SESSION_BUS_ADDRESS`, reaches the running
|
||||||
|
`gnome-keyring-daemon` and prompts. `LinuxChromiumAdapter.launch_env()` strips
|
||||||
|
the bus for the child process, which is what actually fixes it. Do not remove
|
||||||
|
`launch_env()`, and do not add a flag list that nothing references —
|
||||||
|
`test_linux_browser_flags.py` fails if a list becomes dead code, because that
|
||||||
|
is exactly how this bug hid before.
|
||||||
|
- **`--ozone-platform-hint=auto` does not work on Chromium 152.** It aborts with
|
||||||
|
"Missing X server" instead of falling back to Wayland; `--ozone-platform=wayland`
|
||||||
|
must be stated explicitly.
|
||||||
|
- **Kill the whole process group on teardown.** `proc.terminate()` leaves
|
||||||
|
Chromium's GPU/zygote/renderer children behind; they accumulate until the Pi
|
||||||
|
runs out of memory. This needs `start_new_session=True` at launch.
|
||||||
|
- **Measure memory with PSS, never RSS.** Chromium shares pages across its
|
||||||
|
processes, so summing RSS roughly doubles the figure and even ranks smaller
|
||||||
|
configurations as larger. `linux/_probe_chromium_footprint.py` does it correctly.
|
||||||
|
- **`--disable-gpu` increases memory** for a real page (1038 MB vs 513 MB). It is
|
||||||
|
intentionally not used anywhere.
|
||||||
|
|
||||||
|
## Video: 4K cannot play, and is converted automatically
|
||||||
|
|
||||||
|
**A 4K video will not play on a Pi 4.** ffpyplayer decodes in software and there
|
||||||
|
is no hardware H.264 decode in its pipeline. Measured: 1080p decodes at 3.03×
|
||||||
|
realtime, 3840×2160 at **0.90×** — below 1× the picture simply stops.
|
||||||
|
|
||||||
|
The player therefore normalises oversized media to at most 1920×1080:
|
||||||
|
|
||||||
|
* `src/media_state.py` — the shared on-disk contract (markers, path resolution,
|
||||||
|
and a dependency-free MP4 header parser so the playback path spawns nothing).
|
||||||
|
* `linux/video_normalizer.py` — the converter (hardware `h264_v4l2m2m`, ~31 s
|
||||||
|
for an 18 s 4K clip; `libx264` fallback).
|
||||||
|
* Triggered automatically from `get_playlists_v2.normalize_oversized_media()`
|
||||||
|
after every sync, in both the "updated" and "up-to-date" branches.
|
||||||
|
|
||||||
|
| Marker next to the media | Meaning |
|
||||||
|
|---|---|
|
||||||
|
| `<media>.kiwy-converting` | in flight → the player **skips** the item |
|
||||||
|
| `<media>_kiwy1080p.mp4` + `.kiwy-normalized.json` | done → the player plays **this** file |
|
||||||
|
|
||||||
|
Do not remove these markers, and do not let `delete_unused_media()` prune them —
|
||||||
|
the converted output is deliberately absent from the playlist and would
|
||||||
|
otherwise be deleted while the player is using it.
|
||||||
|
|
||||||
|
The conversion is only triggered by **resolution**. A file within 1920×1080 is
|
||||||
|
left byte-identical. When nothing is playable yet (a single-item playlist whose
|
||||||
|
only video is still converting), the player loops `config/resources/intro1.mp4`
|
||||||
|
rather than showing a blank screen.
|
||||||
|
|
||||||
|
Run it manually:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
.venv/bin/python linux/video_normalizer.py --dry-run media/ # report only
|
||||||
|
.venv/bin/python linux/video_normalizer.py media/ # convert
|
||||||
|
.venv/bin/python linux/video_normalizer.py --max-height 720 media/
|
||||||
|
```
|
||||||
|
|
||||||
|
> Prefer normalising on the **server** before upload: it avoids both the 4K
|
||||||
|
download and the 31 s conversion. The player-side path is a safety net, not the
|
||||||
|
preferred workflow.
|
||||||
|
|
||||||
|
## Diagnostics
|
||||||
|
|
||||||
|
| Command | Purpose |
|
||||||
|
|---------|---------|
|
||||||
|
| `.venv/bin/python linux/linux_display.py` | Wayland/X11 status, outputs, available tools |
|
||||||
|
| `bash linux/fix_kivy_sdl2.sh --check` | Which SDL2 Kivy loads and its drivers |
|
||||||
|
| `bash check_player_status.sh` | Is the player running |
|
||||||
|
| `bash stop_player.sh` | Stop player + watchdog |
|
||||||
|
|
||||||
|
Escape hatches:
|
||||||
|
|
||||||
|
| Variable | Effect |
|
||||||
|
|----------|--------|
|
||||||
|
| `KIWY_DISPLAY_TOOLS_DISABLED=1` | Disable `wlopm`/`vcgencmd`/`swayidle` work |
|
||||||
|
| `KIWY_CHROMIUM_MODE=light` | Default footprint profile (safe reductions) |
|
||||||
|
| `KIWY_CHROMIUM_MODE=minimal` | Adds `--single-process`; ~15% less memory, less stable |
|
||||||
|
| `KIWY_CHROMIUM_MODE=safe` | No footprint flags at all (when debugging) |
|
||||||
|
| `KIWY_VENV=/path` | Point the SDL2 fix script at another virtualenv |
|
||||||
|
|
||||||
|
## Commit Hygiene
|
||||||
|
|
||||||
|
- Never commit `.venv/`, `.kivy/`, `logs/`, `.kiosk-profile/` (git-ignored).
|
||||||
|
- **Do not commit `player_auth.json`** — it holds live credentials
|
||||||
|
(`auth_code`, `player_id`, `server_url`).
|
||||||
|
- Do not commit `config/app_config.json` values that are host-specific
|
||||||
|
(`server_ip`, `screen_name`, `quickconnect_key`). The repo default is blank on
|
||||||
|
purpose so a fresh install runs the first-run setup flow.
|
||||||
|
|
||||||
|
## Release Checklist
|
||||||
|
|
||||||
|
1. Bump `PLAYER_VERSION` in `src/main.py`.
|
||||||
|
2. Run the verification suite above on the target Pi.
|
||||||
|
3. Confirm `bash linux/fix_kivy_sdl2.sh --check` reports `fixed`.
|
||||||
|
4. Smoke-test a mixed playlist: image → video → weblink → image, verifying
|
||||||
|
durations, audio (`audio: off` / `muted`) and a clean exit/restart.
|
||||||
|
5. 24/7 soak (≥12 h): check the heartbeat, `pgrep -c chromium` for leaks and
|
||||||
|
memory growth.
|
||||||
+50
-1
@@ -30,6 +30,11 @@ env/
|
|||||||
*.pyc
|
*.pyc
|
||||||
|
|
||||||
# Media files (optional - remove if you want to track media)
|
# Media files (optional - remove if you want to track media)
|
||||||
|
#
|
||||||
|
# Coverage matters here: `media/*.mp4` matches files directly in media/, but NOT
|
||||||
|
# media/edited_media/1/foo.jpg. Playlist content is downloaded per-site and must
|
||||||
|
# never be committed, so the whole tree is ignored and only the directory
|
||||||
|
# structure (via .gitkeep) is tracked.
|
||||||
media/*.jpg
|
media/*.jpg
|
||||||
media/*.jpeg
|
media/*.jpeg
|
||||||
media/*.png
|
media/*.png
|
||||||
@@ -40,9 +45,20 @@ media/*.avi
|
|||||||
media/*.mkv
|
media/*.mkv
|
||||||
media/*.mov
|
media/*.mov
|
||||||
media/*.webm
|
media/*.webm
|
||||||
|
media/**/*.jpg
|
||||||
|
media/**/*.jpeg
|
||||||
|
media/**/*.png
|
||||||
|
media/**/*.gif
|
||||||
|
media/**/*.bmp
|
||||||
|
media/**/*.mp4
|
||||||
|
media/**/*.avi
|
||||||
|
media/**/*.mkv
|
||||||
|
media/**/*.mov
|
||||||
|
media/**/*.webm
|
||||||
|
|
||||||
# Playlists cache (auto-generated)
|
# Playlist cache (auto-generated from the server per device)
|
||||||
playlists/server_playlist_*.json
|
playlists/server_playlist_*.json
|
||||||
|
playlists/server_playlist.json
|
||||||
|
|
||||||
# Logs
|
# Logs
|
||||||
*.log
|
*.log
|
||||||
@@ -57,3 +73,36 @@ playlists/server_playlist_*.json
|
|||||||
# OS
|
# OS
|
||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
|
||||||
|
.player_heartbear
|
||||||
|
|
||||||
|
# NOTE: the line above is a long-standing typo for `.player_heartbeat` and is
|
||||||
|
# kept only so nothing changes for existing clones. The correct patterns are
|
||||||
|
# below — without them the player's runtime state files get committed (they
|
||||||
|
# change every 10 seconds, which would create endless noise and merge conflicts).
|
||||||
|
.player_heartbeat
|
||||||
|
.player_stop_requested
|
||||||
|
logs/startup_marker.txt
|
||||||
|
logs/startup_crash.log
|
||||||
|
logs/console_out.txt
|
||||||
|
logs/console_err.txt
|
||||||
|
logs/watchdog_test_*.txt
|
||||||
|
|
||||||
|
# Player credentials — live auth_code/player_id/server_url. Never commit these:
|
||||||
|
# a bundled copy made fresh builds boot "already authenticated" against an old
|
||||||
|
# server and play a stale playlist.
|
||||||
|
player_auth.json
|
||||||
|
src/player_auth.json
|
||||||
|
working_files/player_auth.json
|
||||||
|
|
||||||
|
# Runtime browser profile (cache, not source). The player launches Chromium with
|
||||||
|
# a dedicated --user-data-dir so it never touches the operator's own profile.
|
||||||
|
# The wildcard covers probe/test profiles (e.g. .kiosk-profile-probe), which
|
||||||
|
# otherwise get picked up as untracked files and are several MB of cache each.
|
||||||
|
.kiosk-profile/
|
||||||
|
.kiosk-profile-*/
|
||||||
|
.webview2-profile/
|
||||||
|
|
||||||
|
# Monitoring output (generated per run, not source)
|
||||||
|
logs/monitor*.csv
|
||||||
|
logs/monitor*.log
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
1763799978.6257727
|
|
||||||
Executable
+11
@@ -0,0 +1,11 @@
|
|||||||
|
#\!/bin/bash
|
||||||
|
SCRIPT_DIR="/home/pi/Desktop/Kiwy-Signage"
|
||||||
|
LOG_FILE="/home/pi/Desktop/Kiwy-Signage/logs/player-background.log"
|
||||||
|
mkdir -p "."
|
||||||
|
echo "[2026-10-02 15:28:02] Starting player in background..." >> ""
|
||||||
|
cd "/home/pi/Desktop/Kiwy-Signage" || exit 1
|
||||||
|
nohup bash start.sh >> "" 2>&1 &
|
||||||
|
PLAYER_PID=$\!
|
||||||
|
echo "[2026-10-02 15:28:02] Player started with PID: $PLAYER_PID" >> ""
|
||||||
|
echo "Player started in background (PID: $PLAYER_PID)"
|
||||||
|
echo "Logs: $LOG_FILE"
|
||||||
Executable
+3
@@ -0,0 +1,3 @@
|
|||||||
|
#\!/bin/bash
|
||||||
|
sleep 15
|
||||||
|
cd /home/pi/Desktop/Kiwy-Signage && bash start.sh
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Video playback optimization script for Raspberry Pi
|
||||||
|
# Improves video smoothness and reduces stuttering
|
||||||
|
|
||||||
|
echo "Optimizing system for smooth video playback..."
|
||||||
|
|
||||||
|
# 1. Increase GPU memory split for better video performance
|
||||||
|
if [ -f /boot/config.txt ]; then
|
||||||
|
echo "Checking GPU memory configuration..."
|
||||||
|
if grep -q "gpu_mem=" /boot/config.txt; then
|
||||||
|
# GPU memory already configured, check if it's sufficient
|
||||||
|
CURRENT_GPU_MEM=$(grep "gpu_mem=" /boot/config.txt | head -1 | cut -d'=' -f2)
|
||||||
|
if [ "$CURRENT_GPU_MEM" -lt 256 ]; then
|
||||||
|
sudo sed -i 's/gpu_mem=.*/gpu_mem=256/' /boot/config.txt
|
||||||
|
echo "✓ GPU memory increased to 256MB for video"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
# Add GPU memory setting
|
||||||
|
echo "gpu_mem=256" | sudo tee -a /boot/config.txt > /dev/null
|
||||||
|
echo "✓ GPU memory set to 256MB for video"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 2. Install video codec support
|
||||||
|
echo "Installing video codec libraries..."
|
||||||
|
sudo apt-get install -y \
|
||||||
|
libva-drm2 \
|
||||||
|
libva2 \
|
||||||
|
libavcodec-extra \
|
||||||
|
libavutil-dev \
|
||||||
|
2>/dev/null || true
|
||||||
|
|
||||||
|
# 3. Optimize swappiness for better memory management
|
||||||
|
echo "Optimizing memory management..."
|
||||||
|
if [ -f /proc/sys/vm/swappiness ]; then
|
||||||
|
CURRENT_SWAP=$(cat /proc/sys/vm/swappiness)
|
||||||
|
if [ "$CURRENT_SWAP" -gt 30 ]; then
|
||||||
|
echo 30 | sudo tee /proc/sys/vm/swappiness > /dev/null
|
||||||
|
echo "vm.swappiness=30" | sudo tee -a /etc/sysctl.conf > /dev/null
|
||||||
|
echo "✓ Swappiness optimized"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 4. Disable CPU frequency scaling for consistent performance
|
||||||
|
echo "Ensuring CPU performance mode..."
|
||||||
|
for cpu in /sys/devices/system/cpu/cpu[0-9]*; do
|
||||||
|
if [ -f "$cpu/cpufreq/scaling_governor" ]; then
|
||||||
|
echo performance | sudo tee "$cpu/cpufreq/scaling_governor" > /dev/null 2>&1 || true
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
echo "✓ CPU set to performance mode"
|
||||||
|
|
||||||
|
# 5. Optimize file system cache
|
||||||
|
echo "Optimizing filesystem cache..."
|
||||||
|
echo 50 | sudo tee /proc/sys/vm/vfs_cache_pressure > /dev/null
|
||||||
|
echo "vm.vfs_cache_pressure=50" | sudo tee -a /etc/sysctl.conf > /dev/null
|
||||||
|
echo "✓ Filesystem cache optimized"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "✅ Video playback optimization complete!"
|
||||||
|
echo "Note: Some changes require a reboot to take effect."
|
||||||
@@ -0,0 +1,384 @@
|
|||||||
|
# Web Link Playlist Items — Player Integration Guide
|
||||||
|
|
||||||
|
This document describes how the **Kiwy-Signage player**
|
||||||
|
(<https://gitea.moto-adv.com/ske087/Kiwy-Signage.git>) supports the **`weblink`**
|
||||||
|
playlist item type (display a live web page / URL instead of an uploaded media
|
||||||
|
file).
|
||||||
|
|
||||||
|
> **Status: implemented.** The player supports `weblink` items on
|
||||||
|
> Raspberry Pi / Linux via a Chromium kiosk subprocess.
|
||||||
|
> Sections 1–4 describe the original design plan; section 6 documents the
|
||||||
|
> shipped architecture and the interaction model.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Background — how items flow
|
||||||
|
|
||||||
|
```
|
||||||
|
DigiServer API ──JSON──▶ player sync (get_playlists_v2.py) ──▶ playlist.json ──▶ main.py renders
|
||||||
|
/api/playlists downloads files to media/ by item type
|
||||||
|
```
|
||||||
|
|
||||||
|
Each playlist item the server returns currently looks like:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": 42,
|
||||||
|
"file_name": "promo.jpg",
|
||||||
|
"type": "image",
|
||||||
|
"duration": 10,
|
||||||
|
"position": 1,
|
||||||
|
"url": "https://server/digiserver/static/uploads/promo.jpg",
|
||||||
|
"edit_on_player": false
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The player:
|
||||||
|
1. **Syncs** (`src/get_playlists_v2.py` → `download_media_files()`): downloads
|
||||||
|
`url` into the local `media/` directory, then rewrites each item keeping only
|
||||||
|
`file_name`, `url` (now a **local relative path**), `duration`,
|
||||||
|
`edit_on_player`. **Note: the `type` field is currently discarded here.**
|
||||||
|
2. **Renders** (`src/main.py` → `play_current_media()`): opens the local file
|
||||||
|
and chooses a Kivy widget purely by **file extension**
|
||||||
|
(`.mp4/.avi/...` → `Video`, `.jpg/.png/...` → `AsyncImage`). Unknown
|
||||||
|
extensions are skipped as "unsupported".
|
||||||
|
|
||||||
|
A web link breaks all three assumptions: there is no file to download, no
|
||||||
|
extension to switch on, and no widget that renders a web page.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. New server contract (what DigiServer will send)
|
||||||
|
|
||||||
|
A web-link playlist item will look like this:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": 91,
|
||||||
|
"file_name": "weblink-3f9c1a2b",
|
||||||
|
"type": "weblink",
|
||||||
|
"duration": 30,
|
||||||
|
"position": 4,
|
||||||
|
"url": "https://example.com/dashboard",
|
||||||
|
"edit_on_player": false
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Key differences vs. a file item:
|
||||||
|
|
||||||
|
| Field | File item | Web-link item |
|
||||||
|
|--------------|-----------------------------------|----------------------------------------|
|
||||||
|
| `type` | `image` / `video` | **`weblink`** |
|
||||||
|
| `url` | Path to a file on the server | **The web page to display (the link)** |
|
||||||
|
| `file_name` | Real filename on disk | Synthetic id (`weblink-<uuid>`), **no file exists** |
|
||||||
|
|
||||||
|
The player must branch on `type == "weblink"` and treat `url` as the page to
|
||||||
|
open — **never** try to download it as a file.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Required player changes
|
||||||
|
|
||||||
|
### 3.1 Sync step — `src/get_playlists_v2.py`
|
||||||
|
|
||||||
|
**Function:** `download_media_files(playlist, media_dir, ...)`
|
||||||
|
|
||||||
|
1. **Skip download for web links.** At the top of the per-item loop, detect
|
||||||
|
`media.get('type') == 'weblink'` and do **not** call `session.get()` / write
|
||||||
|
any file for it.
|
||||||
|
2. **Preserve `type` and the original `url`.** The `updated_media` dict that is
|
||||||
|
appended to `updated_playlist` currently drops `type` and rewrites `url` to a
|
||||||
|
local path. It must now carry `type` through, and for web links keep `url`
|
||||||
|
as the original web address (do not convert to a local relative path).
|
||||||
|
|
||||||
|
Suggested shape of the per-item logic:
|
||||||
|
|
||||||
|
```python
|
||||||
|
item_type = media.get('type', '')
|
||||||
|
|
||||||
|
if item_type == 'weblink':
|
||||||
|
# No file to download — pass the web link through unchanged.
|
||||||
|
updated_playlist.append({
|
||||||
|
'file_name': media.get('file_name', ''),
|
||||||
|
'type': 'weblink',
|
||||||
|
'url': media.get('url', ''), # the actual web page
|
||||||
|
'duration': media.get('duration', 10),
|
||||||
|
'edit_on_player': False,
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
|
||||||
|
# ... existing download logic for file items ...
|
||||||
|
updated_playlist.append({
|
||||||
|
'file_name': file_name,
|
||||||
|
'type': item_type, # <-- now preserved
|
||||||
|
'url': os.path.relpath(local_path, os.path.dirname(media_dir)),
|
||||||
|
'duration': duration,
|
||||||
|
'edit_on_player': media.get('edit_on_player', False),
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **`delete_unused_media()`** walks `media/` using `file_name`. Web links have
|
||||||
|
no file, so they simply won't match anything on disk — no change strictly
|
||||||
|
required, but make sure a missing local file for a `weblink` item does not
|
||||||
|
trigger a re-download or an error elsewhere.
|
||||||
|
|
||||||
|
### 3.2 Render step — `src/main.py`
|
||||||
|
|
||||||
|
**Function:** `play_current_media(self, force_reload=False)`
|
||||||
|
|
||||||
|
The current logic builds `media_path = os.path.join(self.media_dir, file_name)`
|
||||||
|
and then does `os.stat(media_path)` — which will fail for a web link (no file).
|
||||||
|
Add a **web-link branch before the file-existence check**:
|
||||||
|
|
||||||
|
```python
|
||||||
|
media_item = self.playlist[self.current_index]
|
||||||
|
file_name = media_item.get('file_name', '')
|
||||||
|
duration = media_item.get('duration', 10)
|
||||||
|
|
||||||
|
# NEW: handle web links before any file/path handling
|
||||||
|
if media_item.get('type') == 'weblink':
|
||||||
|
self.play_weblink(media_item.get('url', ''), duration)
|
||||||
|
return
|
||||||
|
|
||||||
|
# ... existing file existence check + extension branching ...
|
||||||
|
```
|
||||||
|
|
||||||
|
Then add a new method `play_weblink(self, url, duration)`:
|
||||||
|
|
||||||
|
- Validate the scheme is `http`/`https` (reject anything else, e.g. `file://`).
|
||||||
|
- Open the page for `duration` seconds, then advance with `self.next_media()`.
|
||||||
|
- Wrap in `try/except`; on failure increment `self.consecutive_errors` and call
|
||||||
|
`self.next_media()`, matching the existing error-handling pattern.
|
||||||
|
- Make sure the previous widget (`self.current_widget`) is removed/stopped just
|
||||||
|
like the image/video paths do.
|
||||||
|
|
||||||
|
#### Rendering approach (pick one)
|
||||||
|
|
||||||
|
Kivy has **no production-grade embedded web view**, especially on Raspberry Pi.
|
||||||
|
Recommended options, in order of robustness:
|
||||||
|
|
||||||
|
1. **Chromium kiosk overlay (recommended).** Launch Chromium over the Kivy
|
||||||
|
window for the item's duration, then close it and return to Kivy:
|
||||||
|
|
||||||
|
```python
|
||||||
|
import subprocess, shutil
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
from kivy.clock import Clock
|
||||||
|
|
||||||
|
def play_weblink(self, url, duration):
|
||||||
|
scheme = urlparse(url).scheme.lower()
|
||||||
|
if scheme not in ('http', 'https'):
|
||||||
|
Logger.warning(f"SignagePlayer: Refusing non-http(s) weblink: {url}")
|
||||||
|
self.next_media()
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
browser = shutil.which('chromium-browser') or shutil.which('chromium')
|
||||||
|
self._weblink_proc = subprocess.Popen([
|
||||||
|
browser,
|
||||||
|
'--kiosk', '--app=' + url,
|
||||||
|
'--noerrdialogs', '--disable-infobars',
|
||||||
|
'--incognito', '--no-first-run',
|
||||||
|
'--check-for-update-interval=31536000',
|
||||||
|
])
|
||||||
|
Clock.schedule_once(lambda dt: self._close_weblink_and_next(), duration)
|
||||||
|
except Exception as e:
|
||||||
|
Logger.error(f"SignagePlayer: Error opening weblink: {e}")
|
||||||
|
self.consecutive_errors += 1
|
||||||
|
self.next_media()
|
||||||
|
|
||||||
|
def _close_weblink_and_next(self):
|
||||||
|
proc = getattr(self, '_weblink_proc', None)
|
||||||
|
if proc and proc.poll() is None:
|
||||||
|
proc.terminate()
|
||||||
|
try:
|
||||||
|
proc.wait(timeout=5)
|
||||||
|
except Exception:
|
||||||
|
proc.kill()
|
||||||
|
self._weblink_proc = None
|
||||||
|
self.next_media()
|
||||||
|
```
|
||||||
|
|
||||||
|
Requirements / notes:
|
||||||
|
- Install Chromium on the player image (`chromium-browser` on Raspberry Pi OS).
|
||||||
|
- Ensure Chromium gets window focus over Kivy and is fully killed before the
|
||||||
|
next item, including on pause/stop/restart paths and on app shutdown
|
||||||
|
(`on_stop`) so no stray browser window is left behind.
|
||||||
|
- On Wayland/X11 the player already sets `SDL_VIDEODRIVER`; verify Chromium
|
||||||
|
launches on the same display/session.
|
||||||
|
|
||||||
|
2. **Embedded web view widget** (`kivy_garden.webview`, WebKit/GTK, or a
|
||||||
|
platform WebView) — renders inside the Kivy window.
|
||||||
|
Cleaner UX (stays inside the Kivy widget tree) but fragile and poorly
|
||||||
|
supported on Pi/Wayland — only pursue if option 1 is unacceptable.
|
||||||
|
|
||||||
|
3. **Server-side screenshot fallback (no player change).** If embedding a live
|
||||||
|
browser is not desirable, DigiServer can periodically screenshot the URL and
|
||||||
|
store it as a normal `image` item; the player then needs no changes. This
|
||||||
|
loses live/animated content. Documented here for completeness only.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Checklist for the player update
|
||||||
|
|
||||||
|
- [ ] `get_playlists_v2.py`: skip download when `type == 'weblink'`.
|
||||||
|
- [ ] `get_playlists_v2.py`: preserve `type` in the rewritten playlist items
|
||||||
|
(fixes the current loss of `type`).
|
||||||
|
- [ ] `get_playlists_v2.py`: keep the original web `url` for weblink items.
|
||||||
|
- [ ] `main.py` `play_current_media()`: branch to `play_weblink()` **before** the
|
||||||
|
`os.stat()` file check.
|
||||||
|
- [ ] `main.py`: implement `play_weblink(url, duration)` (Chromium kiosk).
|
||||||
|
- [ ] `main.py`: validate scheme is `http`/`https`; reject others.
|
||||||
|
- [ ] Kill/cleanup the browser process on next item, pause, stop, restart, and
|
||||||
|
`on_stop`.
|
||||||
|
- [ ] Install Chromium on the player image / document it in the player README.
|
||||||
|
- [ ] Test: mixed playlist (image → video → weblink → image) cycles correctly
|
||||||
|
and respects per-item `duration`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Security notes
|
||||||
|
|
||||||
|
- Only allow `http`/`https` schemes on both server and player; never open
|
||||||
|
`file://`, `chrome://`, etc.
|
||||||
|
- The server validates and stores the URL when the operator adds it; the player
|
||||||
|
should still re-validate the scheme before launching the browser (defence in
|
||||||
|
depth).
|
||||||
|
- Consider running Chromium with `--incognito` (no persistent cookies/cache) as
|
||||||
|
shown above.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Shipped architecture (`src/weblink_session.py`)
|
||||||
|
|
||||||
|
The player-side implementation lives in **one** module, so launch, verification,
|
||||||
|
timing and teardown have a single owner instead of being duplicated per
|
||||||
|
platform:
|
||||||
|
|
||||||
|
| Piece | Responsibility |
|
||||||
|
|--------------------------|----------------|
|
||||||
|
| `WeblinkSession` | Owns one weblink item: validate → launch → verify → watch → teardown. Generation-tokened so stale callbacks are ignored, and `atexit`-safe. |
|
||||||
|
| `WeblinkAdapter` | The only platform-specific part: launch / wait for the window / is it alive / tear it down / pre-warm. `extra_launch_args()` lets a subclass add browser flags without copying `launch()`. |
|
||||||
|
| `ChromiumSubprocessAdapter` | Default engine (Raspberry Pi `chromium`). |
|
||||||
|
| `InteractionWatcher` | Decides when the item is finished (see the interaction model below). |
|
||||||
|
| `WebInputSources` | Reads `/dev/input/event*` to detect viewer interaction. |
|
||||||
|
| `linux_browser.py` | **Raspberry Pi / Linux**: `LinuxChromiumAdapter` — Chromium kiosk on Wayland (labwc). |
|
||||||
|
|
||||||
|
Platform entry points inject their engine through
|
||||||
|
`SignagePlayer.weblink_adapter_factory`:
|
||||||
|
|
||||||
|
* **Raspberry Pi / Linux** (`linux/run_linux.py`) — injects
|
||||||
|
`LinuxChromiumAdapter`, which adds the flags Trixie needs:
|
||||||
|
|
||||||
|
| Flag | Why |
|
||||||
|
|------|-----|
|
||||||
|
| `--kiosk` | What actually makes labwc give the window exclusive fullscreen. Windows needed `--start-maximized` instead; that is not sufficient here. |
|
||||||
|
| `--user-data-dir=<.kiosk-profile>` | **Mandatory.** Without it Chromium hands the URL to an already-running instance, the process we launched exits in ~2 s and the item is skipped as a failed launch. Also guarantees we never touch the operator's own browser profile. |
|
||||||
|
| `--ozone-platform-hint=auto` | Lets Chromium pick Wayland when available and fall back to X11/XWayland. |
|
||||||
|
| `--autoplay-policy=no-user-gesture-required` | Signage pages play media without a click. |
|
||||||
|
|
||||||
|
Teardown kills the **whole process group** (`os.killpg`). `proc.terminate()`
|
||||||
|
only reaps the parent, leaving Chromium's GPU/zygote/renderer children behind;
|
||||||
|
across a 24/7 playlist those accumulate until the Pi runs out of memory.
|
||||||
|
|
||||||
|
`WeblinkAdapter.extra_launch_args()` is the hook subclasses use to add flags
|
||||||
|
without duplicating `launch()` — the Linux adapter uses it for
|
||||||
|
`--user-data-dir` + `--kiosk`.
|
||||||
|
|
||||||
|
> **Do not give the factory a class-level `None` default combined with an
|
||||||
|
> unconditional instance assignment.** `SignagePlayer.__init__` originally set
|
||||||
|
> `self.weblink_adapter_factory = None`, which shadowed the class attribute the
|
||||||
|
> platform entry point installs — so the platform adapters were silently ignored
|
||||||
|
> and every weblink fell back to the generic adapter and failed. It now only sets
|
||||||
|
> the instance attribute when the class attribute is absent.
|
||||||
|
|
||||||
|
### 6.2 Linux: Chromium kiosk on Wayland
|
||||||
|
|
||||||
|
Web links on Raspberry Pi OS Trixie use a single dedicated `chromium` process
|
||||||
|
launched in kiosk mode with its own profile directory:
|
||||||
|
|
||||||
|
* `--kiosk` gives an exclusive-fullscreen window under the **labwc** compositor;
|
||||||
|
* `--user-data-dir=<data>/.kiosk-profile` guarantees a *fresh, trackable* browser
|
||||||
|
instead of a hand-off to a running instance;
|
||||||
|
* `--ozone-platform-hint=auto` selects Wayland natively and falls back to X11.
|
||||||
|
|
||||||
|
Because a separate process cannot be introspected portably, visibility is
|
||||||
|
verified by combining "the process survived the health grace period" with a
|
||||||
|
best-effort check that the PID actually owns a Wayland/X11 socket. A launch that
|
||||||
|
dies sooner than `min_healthy_alive` is treated as a **failed launch** (hand-off,
|
||||||
|
missing binary, instant crash) rather than a finished item, so the playlist
|
||||||
|
never skips a weblink silently.
|
||||||
|
|
||||||
|
Stale `SingletonLock`/`SingletonSocket` files left by a crashed Chromium are
|
||||||
|
cleared before each launch: our profile is private to the player, so removing
|
||||||
|
the lock is always safe and prevents Chromium refusing to start.
|
||||||
|
|
||||||
|
### 6.1 Interaction model — web links are not passive media
|
||||||
|
|
||||||
|
`duration` on a weblink is **not** a hard cut-off. The player advances only when
|
||||||
|
**both** conditions are true:
|
||||||
|
|
||||||
|
1. the configured `duration` has elapsed; **and**
|
||||||
|
2. the viewer has not interacted with the page for `interaction_postpone`
|
||||||
|
seconds (default **10 s**), measured from the **most recent** interaction.
|
||||||
|
|
||||||
|
Consequences:
|
||||||
|
|
||||||
|
- A viewer who taps, scrolls or navigates the page during the final seconds of
|
||||||
|
the slot **keeps the page on screen** — the advance is pushed 10 s past that
|
||||||
|
touch, and every further touch pushes it again. The link is never pulled out
|
||||||
|
from under someone who is using it.
|
||||||
|
- An untouched page still advances on schedule, exactly like a media item.
|
||||||
|
- A multi-event burst (a drag, a page transition) counts as **one** interaction
|
||||||
|
but the countdown is measured from the **last** event of that burst, so an
|
||||||
|
item can never be cut off mid-gesture.
|
||||||
|
- `max_dwell` (duration × `max_dwell_factor`, at least `min_max_dwell`) is an
|
||||||
|
absolute backstop so a wedged browser or a jammed touchscreen cannot park the
|
||||||
|
playlist forever.
|
||||||
|
|
||||||
|
**Pause/play does not apply to web links.** A web link is an interactive
|
||||||
|
surface, so `toggle_pause()` is a no-op while one is on screen — the interaction
|
||||||
|
watcher owns its lifecycle. The pause button continues to work normally for
|
||||||
|
images and videos.
|
||||||
|
|
||||||
|
### 6.2 Verified start-up
|
||||||
|
|
||||||
|
Launching a browser is not the same as displaying a page. The session therefore
|
||||||
|
does **not** report success immediately after spawning the process (that used to
|
||||||
|
reset the error counter and leave a black screen for the whole duration). The
|
||||||
|
watcher thread — never the Kivy main thread — waits for the browser window to
|
||||||
|
appear, and if it never does the item is reported as failed and skipped.
|
||||||
|
|
||||||
|
### 6.3 Configuration
|
||||||
|
|
||||||
|
All timings are tunable in `config/app_config.json` under `weblink`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
"weblink": {
|
||||||
|
"engine": "auto",
|
||||||
|
"interaction_postpone": 10,
|
||||||
|
"interaction_debounce": 0.5,
|
||||||
|
"interaction_grace": 5.0,
|
||||||
|
"max_dwell_factor": 6.0,
|
||||||
|
"min_max_dwell": 300,
|
||||||
|
"launch_timeout": 15,
|
||||||
|
"prewarm": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Key | Meaning |
|
||||||
|
|-----|---------|
|
||||||
|
| `engine` | Preferred engine (`auto`, `cef`, `subprocess`). |
|
||||||
|
| `interaction_postpone` | Seconds the advance is postponed, measured from each interaction (default 10). |
|
||||||
|
| `interaction_debounce` | Logging/trace throttle for continuous drags (default 0.5). |
|
||||||
|
| `interaction_grace` | Settle window after the last raw event still counted as interacting (default 5). |
|
||||||
|
| `max_dwell_factor` | Hard ceiling = `duration × factor`. |
|
||||||
|
| `min_max_dwell` | Floor for that hard ceiling, in seconds. |
|
||||||
|
| `launch_timeout` | How long to wait for the browser window to appear. |
|
||||||
|
| `prewarm` | Pre-warm the next weblink (disabled on Windows). |
|
||||||
|
|
||||||
|
### 6.4 Diagnostics
|
||||||
|
|
||||||
|
The watcher traces structured events through `playback_trace.py`:
|
||||||
|
`weblink_launch`, `weblink_visible`, `weblink_interaction`, `weblink_end`
|
||||||
|
(with reason `viewer_idle`, `browser_exited` or `max_dwell`),
|
||||||
|
`weblink_not_visible` and `weblink_failed`.
|
||||||
@@ -22,7 +22,8 @@ if [ -f "$STOP_FLAG_FILE" ]; then
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
# Check if player process is running
|
# Check if player process is running
|
||||||
PLAYER_PID=$(pgrep -f "python3 main.py" | head -1)
|
# Matches the linux/run_linux.py entry point (src/main.py is only a module).
|
||||||
|
PLAYER_PID=$(pgrep -f "run_linux.py" | head -1)
|
||||||
|
|
||||||
if [ -z "$PLAYER_PID" ]; then
|
if [ -z "$PLAYER_PID" ]; then
|
||||||
echo "Status: ❌ NOT RUNNING"
|
echo "Status: ❌ NOT RUNNING"
|
||||||
|
|||||||
+21
-4
@@ -1,9 +1,26 @@
|
|||||||
{
|
{
|
||||||
"server_ip": "digi-signage.moto-adv.com",
|
"server_ip": "192.168.0.110",
|
||||||
"port": "443",
|
"port": "",
|
||||||
"screen_name": "tv-terasa",
|
"screen_name": "rpi-tvholba1",
|
||||||
"quickconnect_key": "8887779",
|
"quickconnect_key": "8887779",
|
||||||
"orientation": "Landscape",
|
"orientation": "Landscape",
|
||||||
"touch": "True",
|
"touch": "True",
|
||||||
"max_resolution": "1920x1080"
|
"max_resolution": "1920x1080",
|
||||||
|
"edit_feature_enabled": true,
|
||||||
|
"use_https": false,
|
||||||
|
"verify_ssl": false,
|
||||||
|
"production_mode": false,
|
||||||
|
"weblink": {
|
||||||
|
"engine": "auto",
|
||||||
|
"interaction_postpone": 10,
|
||||||
|
"interaction_debounce": 0.5,
|
||||||
|
"interaction_grace": 5.0,
|
||||||
|
"max_dwell_factor": 6.0,
|
||||||
|
"min_max_dwell": 300,
|
||||||
|
"launch_timeout": 15,
|
||||||
|
"prewarm": false
|
||||||
|
},
|
||||||
|
"card_reader_mode": "auto",
|
||||||
|
"card_reader_device": "",
|
||||||
|
"card_reader_timeout": 5
|
||||||
}
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 36 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 282 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 11 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
Regular → Executable
+628
-83
@@ -1,121 +1,666 @@
|
|||||||
#!/bin/bash
|
#\!/bin/bash
|
||||||
|
|
||||||
# Kivy Signage Player Installation Script
|
# Kivy Signage Player Installation Script
|
||||||
# Supports both online and offline installation
|
# Supports both online and offline installation using a Python virtual environment
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# bash install.sh [OPTIONS]
|
||||||
|
#
|
||||||
|
# Options:
|
||||||
|
# -U, --sudo-user USER Username for sudo (avoids password prompts)
|
||||||
|
# -W, --sudo-password PASS Password for sudo (use with caution)
|
||||||
|
# --offline Force offline installation from local packages
|
||||||
|
# -q, --quiet Silent mode
|
||||||
|
# -h, --help Show help
|
||||||
|
#
|
||||||
|
# Configuration options (for Digiserver):
|
||||||
|
# -s, --server IP Server IP/hostname
|
||||||
|
# -p, --port PORT Server port (default: 8080)
|
||||||
|
# -n, --name NAME Screen/player name
|
||||||
|
# -k, --key KEY Quick-connect / auth key
|
||||||
|
# --https Enable HTTPS
|
||||||
|
# --no-verify-ssl Disable SSL verification
|
||||||
|
# -o, --orientation Landscape or Portrait
|
||||||
|
# --touch Enable touch input
|
||||||
|
# -r, --resolution WxH Max resolution
|
||||||
|
# --edit Enable on-device edit feature
|
||||||
|
# --provision URL One-time provisioning URL
|
||||||
|
# --config FILE Path to pre-made JSON config
|
||||||
|
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Configuration
|
||||||
|
# ============================================================
|
||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
REPO_DIR="$SCRIPT_DIR/repo"
|
REPO_DIR="$SCRIPT_DIR/repo"
|
||||||
WHEELS_DIR="$REPO_DIR/python-wheels"
|
WHEELS_DIR="$REPO_DIR/python-wheels"
|
||||||
SYSTEM_DIR="$REPO_DIR/system-packages"
|
SYSTEM_DIR="$REPO_DIR/system-packages"
|
||||||
DEB_DIR="$SYSTEM_DIR/debs"
|
DEB_DIR="$SYSTEM_DIR/debs"
|
||||||
|
VENV_DIR="$SCRIPT_DIR/.venv"
|
||||||
# Check for offline mode
|
REQUIREMENTS_FILE="$SCRIPT_DIR/requirements.txt"
|
||||||
|
SUDO_USERNAME=""
|
||||||
|
SUDO_PASSWORD=""
|
||||||
OFFLINE_MODE=false
|
OFFLINE_MODE=false
|
||||||
if [ "$1" == "--offline" ] || [ "$1" == "-o" ]; then
|
PI_BOOT_PATCH_SCRIPT="$SCRIPT_DIR/scripts/pi_boot_config_patch_once.sh"
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Helper: Run sudo with optional password
|
||||||
|
# ============================================================
|
||||||
|
run_sudo() {
|
||||||
|
if [ -n "$SUDO_PASSWORD" ]; then
|
||||||
|
echo "$SUDO_PASSWORD" | sudo -S "$@" 2>/dev/null
|
||||||
|
else
|
||||||
|
sudo "$@"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Helpers
|
||||||
|
# ============================================================
|
||||||
|
find_boot_config() {
|
||||||
|
if [ -f /boot/firmware/config.txt ]; then
|
||||||
|
echo "/boot/firmware/config.txt"
|
||||||
|
elif [ -f /boot/config.txt ]; then
|
||||||
|
echo "/boot/config.txt"
|
||||||
|
else
|
||||||
|
echo ""
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
detect_raspberry_pi() {
|
||||||
|
BOOT_CONFIG=$(find_boot_config)
|
||||||
|
if grep -qi "raspberry" /proc/cpuinfo 2>/dev/null || [ -n "$BOOT_CONFIG" ]; then
|
||||||
|
return 0
|
||||||
|
else
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# CLI Argument Parsing
|
||||||
|
# ============================================================
|
||||||
|
SERVER_IP=""
|
||||||
|
SERVER_PORT=""
|
||||||
|
SCREEN_NAME=""
|
||||||
|
QUICKCONNECT_KEY=""
|
||||||
|
USE_HTTPS=""
|
||||||
|
VERIFY_SSL=""
|
||||||
|
ORIENTATION=""
|
||||||
|
TOUCH_ENABLED=""
|
||||||
|
MAX_RESOLUTION=""
|
||||||
|
EDIT_ENABLED=""
|
||||||
|
PROVISION_URL=""
|
||||||
|
CONFIG_FILE_PATH=""
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
echo "Usage: $0 [OPTIONS]"
|
||||||
|
echo ""
|
||||||
|
echo "Authentication options (avoids sudo password prompts):"
|
||||||
|
echo " -U, --sudo-user USER Username for sudo commands"
|
||||||
|
echo " -W, --sudo-password PASS Password for sudo commands"
|
||||||
|
echo ""
|
||||||
|
echo "Installation modes (choose one):"
|
||||||
|
echo " --provision URL One-time provisioning URL from Digiserver"
|
||||||
|
echo " --config FILE Path to pre-made JSON config file"
|
||||||
|
echo " (individual params) Set each config value manually"
|
||||||
|
echo ""
|
||||||
|
echo "Configuration options:"
|
||||||
|
echo " -s, --server IP Digiserver IP or hostname"
|
||||||
|
echo " -p, --port PORT Server port (default: 8080)"
|
||||||
|
echo " -n, --name NAME Screen/player name"
|
||||||
|
echo " -k, --key KEY Quick-connect / auth key"
|
||||||
|
echo " --https Enable HTTPS for server communication"
|
||||||
|
echo " --no-verify-ssl Disable SSL certificate verification"
|
||||||
|
echo " -o, --orientation Landscape or Portrait (default: Landscape)"
|
||||||
|
echo " --touch Enable touch input"
|
||||||
|
echo " -r, --resolution WxH Max resolution (default: 1920x1080)"
|
||||||
|
echo " --edit Enable on-device edit feature"
|
||||||
|
echo ""
|
||||||
|
echo "Installation options:"
|
||||||
|
echo " --offline Force offline installation from local packages"
|
||||||
|
echo " -q, --quiet Silent mode (minimal output)"
|
||||||
|
echo " -h, --help Show this help"
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
|
||||||
|
while [ $# -gt 0 ]; do
|
||||||
|
case "$1" in
|
||||||
|
-U|--sudo-user)
|
||||||
|
SUDO_USERNAME="$2"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
-W|--sudo-password)
|
||||||
|
SUDO_PASSWORD="$2"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--provision)
|
||||||
|
PROVISION_URL="$2"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--config)
|
||||||
|
CONFIG_FILE_PATH="$2"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
-s|--server)
|
||||||
|
SERVER_IP="$2"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
-p|--port)
|
||||||
|
SERVER_PORT="$2"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
-n|--name)
|
||||||
|
SCREEN_NAME="$2"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
-k|--key)
|
||||||
|
QUICKCONNECT_KEY="$2"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--https)
|
||||||
|
USE_HTTPS="true"
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
--no-verify-ssl)
|
||||||
|
VERIFY_SSL="false"
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
-o|--orientation)
|
||||||
|
ORIENTATION="$2"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--touch)
|
||||||
|
TOUCH_ENABLED="True"
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
-r|--resolution)
|
||||||
|
MAX_RESOLUTION="$2"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--edit)
|
||||||
|
EDIT_ENABLED="true"
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
--offline)
|
||||||
|
OFFLINE_MODE=true
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
-q|--quiet)
|
||||||
|
exec > >(tee -a /tmp/kivy-install.log) 2>&1
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
-h|--help)
|
||||||
|
usage
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "Unknown option: $1"
|
||||||
|
usage
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
# Auto-detect offline mode if wheels exist
|
||||||
|
if [ "$OFFLINE_MODE" = false ] && [ -d "$WHEELS_DIR" ] && [ "$(ls -A "$WHEELS_DIR"/*.whl 2>/dev/null)" ]; then
|
||||||
OFFLINE_MODE=true
|
OFFLINE_MODE=true
|
||||||
echo "=========================================="
|
|
||||||
echo "Offline Installation Mode"
|
|
||||||
echo "=========================================="
|
|
||||||
elif [ -d "$WHEELS_DIR" ] && [ "$(ls -A $WHEELS_DIR 2>/dev/null)" ]; then
|
|
||||||
echo "=========================================="
|
|
||||||
echo "Offline packages detected - Using repo folder"
|
|
||||||
echo "=========================================="
|
|
||||||
OFFLINE_MODE=true
|
|
||||||
else
|
|
||||||
echo "=========================================="
|
|
||||||
echo "Online Installation Mode"
|
|
||||||
echo "=========================================="
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo ""
|
# ============================================================
|
||||||
echo "Installing Kivy Signage Player dependencies..."
|
# Mode Banner
|
||||||
|
# ============================================================
|
||||||
|
echo "=========================================="
|
||||||
|
echo "Kivy Signage Player v1.2.0 - Installer"
|
||||||
|
echo "=========================================="
|
||||||
|
if [ "$OFFLINE_MODE" = true ]; then
|
||||||
|
echo "Mode: Offline Installation"
|
||||||
|
else
|
||||||
|
echo "Mode: Online Installation"
|
||||||
|
fi
|
||||||
|
if [ -n "$SUDO_USERNAME" ]; then
|
||||||
|
echo "Sudo user: $SUDO_USERNAME"
|
||||||
|
fi
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
# Install system dependencies
|
# ============================================================
|
||||||
echo "Step 1: Installing system dependencies..."
|
# Step 1: System Dependencies
|
||||||
|
# ============================================================
|
||||||
|
echo "Step 1/6: Installing system dependencies..."
|
||||||
echo "--------------------"
|
echo "--------------------"
|
||||||
|
|
||||||
|
echo "Updating package lists..."
|
||||||
|
run_sudo apt update
|
||||||
|
|
||||||
|
echo "Installing system packages..."
|
||||||
|
|
||||||
if [ "$OFFLINE_MODE" = true ] && [ -d "$DEB_DIR" ] && [ "$(ls -A $DEB_DIR/*.deb 2>/dev/null)" ]; then
|
if [ "$OFFLINE_MODE" = true ] && [ -d "$DEB_DIR" ] && [ "$(ls -A $DEB_DIR/*.deb 2>/dev/null)" ]; then
|
||||||
# Offline: Install from local .deb files
|
|
||||||
echo "Installing from offline .deb packages..."
|
echo "Installing from offline .deb packages..."
|
||||||
cd "$DEB_DIR"
|
cd "$DEB_DIR"
|
||||||
|
run_sudo dpkg -i *.deb 2>/dev/null || true
|
||||||
# Install all .deb files with dependencies
|
run_sudo apt-get install -f -y || true
|
||||||
sudo dpkg -i *.deb 2>/dev/null || true
|
cd "$SCRIPT_DIR"
|
||||||
|
|
||||||
# Fix any broken dependencies
|
|
||||||
sudo apt-get install -f -y || true
|
|
||||||
|
|
||||||
echo "System packages installed from offline repository"
|
|
||||||
else
|
|
||||||
# Online: Use apt-get
|
|
||||||
echo "Updating package lists..."
|
|
||||||
sudo apt update
|
|
||||||
|
|
||||||
echo "Installing system packages..."
|
|
||||||
|
|
||||||
# Read packages from file if available, otherwise use default list
|
|
||||||
if [ -f "$SYSTEM_DIR/apt-packages.txt" ]; then
|
|
||||||
PACKAGES=$(grep -v '^#' "$SYSTEM_DIR/apt-packages.txt" | grep -v '^$' | tr '\n' ' ')
|
|
||||||
sudo apt install -y $PACKAGES
|
|
||||||
else
|
|
||||||
# Default package list
|
|
||||||
sudo apt install -y python3-pip python3-setuptools python3-dev
|
|
||||||
sudo apt install -y libsdl2-dev libsdl2-image-dev libsdl2-mixer-dev libsdl2-ttf-dev
|
|
||||||
sudo apt install -y libportmidi-dev libswscale-dev libavformat-dev libavcodec-dev
|
|
||||||
sudo apt install -y zlib1g-dev ffmpeg libavcodec-extra
|
|
||||||
sudo apt install -y gstreamer1.0-plugins-base gstreamer1.0-plugins-good
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "System packages installed successfully"
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
run_sudo apt install -y \
|
||||||
|
python3-pip python3-setuptools python3-dev \
|
||||||
|
python3-venv \
|
||||||
|
libsdl2-dev libsdl2-image-dev libsdl2-mixer-dev libsdl2-ttf-dev \
|
||||||
|
libportmidi-dev libswscale-dev libavformat-dev libavcodec-dev \
|
||||||
|
zlib1g-dev ffmpeg libavcodec-extra \
|
||||||
|
gstreamer1.0-plugins-base gstreamer1.0-plugins-good
|
||||||
|
|
||||||
|
echo "System dependencies installed"
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
# Install Python dependencies
|
# ============================================================
|
||||||
echo "Step 2: Installing Python dependencies..."
|
# Step 2: Python Virtual Environment
|
||||||
|
# ============================================================
|
||||||
|
echo "Step 2/6: Setting up Python virtual environment..."
|
||||||
echo "--------------------"
|
echo "--------------------"
|
||||||
|
|
||||||
if [ "$OFFLINE_MODE" = true ] && [ -d "$WHEELS_DIR" ] && [ "$(ls -A $WHEELS_DIR/*.whl 2>/dev/null)" ]; then
|
if [ -d "$VENV_DIR" ]; then
|
||||||
# Offline: Install from local wheels
|
echo "Removing old virtual environment..."
|
||||||
echo "Installing from offline Python wheels..."
|
rm -rf "$VENV_DIR"
|
||||||
echo "Wheel files found: $(ls -1 $WHEELS_DIR/*.whl 2>/dev/null | wc -l)"
|
fi
|
||||||
|
|
||||||
if pip3 install --break-system-packages --no-index --find-links="$WHEELS_DIR" -r requirements.txt 2>&1 | tee /tmp/pip_install.log; then
|
echo "Creating virtual environment (with system site-packages)..."
|
||||||
echo "Python packages installed from offline repository"
|
python3 -m venv --system-site-packages "$VENV_DIR"
|
||||||
|
echo "Virtual environment created at $VENV_DIR"
|
||||||
|
|
||||||
|
source "$VENV_DIR/bin/activate"
|
||||||
|
|
||||||
|
echo "Upgrading pip..."
|
||||||
|
pip install --upgrade pip --quiet
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Step 3: Install Python Packages
|
||||||
|
# ============================================================
|
||||||
|
echo "Step 3/6: Installing Python packages..."
|
||||||
|
echo "--------------------"
|
||||||
|
|
||||||
|
PIP_EXTRA_ARGS=""
|
||||||
|
if [ "$OFFLINE_MODE" = true ]; then
|
||||||
|
echo "Using offline wheel repository: $WHEELS_DIR"
|
||||||
|
PIP_EXTRA_ARGS="--no-index --find-links=$WHEELS_DIR"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -f "$REQUIREMENTS_FILE" ]; then
|
||||||
|
echo "Installing packages from requirements.txt..."
|
||||||
|
if [ "$OFFLINE_MODE" = true ]; then
|
||||||
|
if pip install $PIP_EXTRA_ARGS -r "$REQUIREMENTS_FILE"; then
|
||||||
|
echo "All packages installed from offline repository"
|
||||||
|
else
|
||||||
|
echo ""
|
||||||
|
echo "Offline installation failed (some wheels may be missing or incompatible)"
|
||||||
|
echo " Falling back to online installation (requires internet)..."
|
||||||
|
echo ""
|
||||||
|
pip install -r "$REQUIREMENTS_FILE" --find-links="$WHEELS_DIR" 2>&1 || true
|
||||||
|
fi
|
||||||
else
|
else
|
||||||
echo "Warning: Offline installation failed (possibly due to Python version mismatch)"
|
pip install -r "$REQUIREMENTS_FILE" --find-links="$WHEELS_DIR" 2>&1 || true
|
||||||
echo "Falling back to online installation..."
|
|
||||||
pip3 install --break-system-packages -r requirements.txt
|
|
||||||
echo "Python packages installed from PyPI"
|
|
||||||
fi
|
fi
|
||||||
else
|
|
||||||
# Online: Use pip from PyPI
|
|
||||||
echo "Installing from PyPI..."
|
|
||||||
pip3 install --break-system-packages -r requirements.txt
|
|
||||||
|
|
||||||
echo "Python packages installed successfully"
|
# Verify critical packages are importable
|
||||||
|
echo ""
|
||||||
|
echo "Verifying installed packages..."
|
||||||
|
pip list --format=columns 2>/dev/null | grep -iE "kivy|ffpyplayer" || true
|
||||||
|
|
||||||
|
# If kivy is still missing after pip install, try system-wide fallback
|
||||||
|
python3 -c "import kivy" 2>/dev/null && echo " kivy OK" || {
|
||||||
|
echo ""
|
||||||
|
echo " kivy not found in venv — installing system-wide via pip (break-system-packages)..."
|
||||||
|
pip install kivy --break-system-packages 2>/dev/null || pip install --user kivy 2>/dev/null || true
|
||||||
|
}
|
||||||
|
python3 -c "import ffpyplayer" 2>/dev/null && echo " ffpyplayer OK" || {
|
||||||
|
echo " ffpyplayer not found — installing system-wide..."
|
||||||
|
pip install ffpyplayer --break-system-packages 2>/dev/null || pip install --user ffpyplayer 2>/dev/null || true
|
||||||
|
}
|
||||||
|
else
|
||||||
|
echo "requirements.txt not found - skipping pip install"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "=========================================="
|
|
||||||
echo "Installation completed successfully!"
|
deactivate
|
||||||
echo "=========================================="
|
|
||||||
echo ""
|
# ============================================================
|
||||||
echo "To run the signage player:"
|
# Step 4: Autostart Workflow
|
||||||
echo " cd src && python3 main.py"
|
# ============================================================
|
||||||
echo ""
|
echo "Step 4/6: Setting up autostart..."
|
||||||
echo "Or use the run script:"
|
echo "--------------------"
|
||||||
echo " bash run_player.sh"
|
|
||||||
|
ACTUAL_USER="${SUDO_USERNAME:-${SUDO_USER:-$(whoami)}}"
|
||||||
|
ACTUAL_HOME=$(eval echo ~"$ACTUAL_USER")
|
||||||
|
AUTOSTART_DIR="$ACTUAL_HOME/.config/autostart"
|
||||||
|
|
||||||
|
echo "Configuring autostart for user: $ACTUAL_USER"
|
||||||
|
|
||||||
|
mkdir -p "$AUTOSTART_DIR"
|
||||||
|
cat > "$AUTOSTART_DIR/kivy-signage-player.desktop" << EOF
|
||||||
|
[Desktop Entry]
|
||||||
|
Type=Application
|
||||||
|
Name=Kivy Signage Player
|
||||||
|
Comment=Digital Signage Player
|
||||||
|
Exec=/bin/bash -c "cd $SCRIPT_DIR && exec bash start.sh"
|
||||||
|
Icon=media-video-display
|
||||||
|
Categories=Utility;
|
||||||
|
NoDisplay=false
|
||||||
|
Terminal=false
|
||||||
|
StartupNotify=false
|
||||||
|
Hidden=false
|
||||||
|
X-GNOME-Autostart-enabled=true
|
||||||
|
X-GNOME-Autostart-delay=3
|
||||||
|
X-XFCE-Autostart-Override=true
|
||||||
|
EOF
|
||||||
|
|
||||||
|
run_sudo chown "$ACTUAL_USER:$ACTUAL_USER" "$AUTOSTART_DIR/kivy-signage-player.desktop" 2>/dev/null || true
|
||||||
|
chmod 644 "$AUTOSTART_DIR/kivy-signage-player.desktop" 2>/dev/null || true
|
||||||
|
echo "XDG autostart entry created"
|
||||||
|
|
||||||
|
# Cron fallback
|
||||||
|
if command -v crontab &>/dev/null; then
|
||||||
|
CRON_WRAPPER="$SCRIPT_DIR/.start-player-cron.sh"
|
||||||
|
cat > "$CRON_WRAPPER" << EOF
|
||||||
|
#\!/bin/bash
|
||||||
|
sleep 15
|
||||||
|
cd $SCRIPT_DIR && bash start.sh
|
||||||
|
EOF
|
||||||
|
chmod +x "$CRON_WRAPPER"
|
||||||
|
|
||||||
|
CRON_ENTRY="@reboot sleep 20 && $CRON_WRAPPER > /tmp/kivy-player-cron.log 2>&1"
|
||||||
|
# Use sudo -u which works when running with sudo (no password prompt)
|
||||||
|
if sudo -u "$ACTUAL_USER" crontab -l 2>/dev/null | grep -q "kivy-signage-player"; then
|
||||||
|
true
|
||||||
|
else
|
||||||
|
(sudo -u "$ACTUAL_USER" crontab -l 2>/dev/null || true; echo "$CRON_ENTRY") | sudo -u "$ACTUAL_USER" crontab - 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
echo "Cron fallback configured"
|
||||||
|
fi
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
# Check if config exists
|
# ============================================================
|
||||||
if [ ! -d "$SCRIPT_DIR/config" ] || [ ! "$(ls -A $SCRIPT_DIR/config)" ]; then
|
# Step 5: Raspberry Pi Optimization
|
||||||
echo "Note: No configuration found."
|
# ============================================================
|
||||||
echo "Please configure the player before running:"
|
if detect_raspberry_pi; then
|
||||||
echo " 1. Copy config/app_config.txt.example to config/app_config.txt"
|
echo "Step 5/6: Optimizing for Raspberry Pi..."
|
||||||
echo " 2. Edit the configuration file with your server details"
|
echo "--------------------"
|
||||||
|
|
||||||
|
if [ -f "$SCRIPT_DIR/linux/fix_kivy_sdl2.sh" ]; then
|
||||||
|
echo "Applying Raspberry Pi Wayland SDL2 fix..."
|
||||||
|
bash "$SCRIPT_DIR/linux/fix_kivy_sdl2.sh"
|
||||||
|
else
|
||||||
|
echo "WARNING: linux/fix_kivy_sdl2.sh not found; Wayland SDL2 fix skipped"
|
||||||
|
fi
|
||||||
|
|
||||||
|
BOOT_CONFIG=$(find_boot_config)
|
||||||
|
if [ -n "$BOOT_CONFIG" ]; then
|
||||||
|
echo "Configuring HDMI settings..."
|
||||||
|
for setting in "hdmi_blanking=0" "hdmi_ignore_cec_init=1" "hdmi_force_hotplug=1"; do
|
||||||
|
key="${setting%%=*}"
|
||||||
|
if grep -q "^$key" "$BOOT_CONFIG" 2>/dev/null; then
|
||||||
|
run_sudo sed -i "s/^$key=.*/$setting/" "$BOOT_CONFIG"
|
||||||
|
else
|
||||||
|
echo "$setting" | run_sudo tee -a "$BOOT_CONFIG" > /dev/null
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
echo "HDMI settings configured"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Setting CPU to performance mode..."
|
||||||
|
for cpu in /sys/devices/system/cpu/cpu[0-9]*; do
|
||||||
|
if [ -f "$cpu/cpufreq/scaling_governor" ]; then
|
||||||
|
echo performance | run_sudo tee "$cpu/cpufreq/scaling_governor" > /dev/null 2>&1 || true
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
run_sudo systemctl mask sleep.target suspend.target hibernate.target hybrid-sleep.target 2>/dev/null || true
|
||||||
|
echo "System sleep disabled"
|
||||||
|
|
||||||
|
if [ -f "$SCRIPT_DIR/.video-optimization.sh" ]; then
|
||||||
|
bash "$SCRIPT_DIR/.video-optimization.sh"
|
||||||
|
fi
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Step 6: Finalize Player Setup
|
||||||
|
# ============================================================
|
||||||
|
echo "Step 6/6: Finalizing player setup..."
|
||||||
|
echo "--------------------"
|
||||||
|
|
||||||
|
# Create required directories
|
||||||
|
echo "Creating player directories..."
|
||||||
|
mkdir -p "$SCRIPT_DIR/media"
|
||||||
|
mkdir -p "$SCRIPT_DIR/playlists"
|
||||||
|
mkdir -p "$SCRIPT_DIR/config/resources"
|
||||||
|
mkdir -p "$SCRIPT_DIR/logs"
|
||||||
|
touch "$SCRIPT_DIR/logs/.gitkeep" 2>/dev/null || true
|
||||||
|
echo "Player directories created"
|
||||||
|
|
||||||
|
# The installer may have been run with sudo, leaving downloaded media and
|
||||||
|
# playlist files owned by root. The desktop player runs as ACTUAL_USER.
|
||||||
|
ACTUAL_GROUP=$(id -gn "$ACTUAL_USER")
|
||||||
|
echo "Granting $ACTUAL_USER write access to player media and playlists..."
|
||||||
|
run_sudo chown -R "$ACTUAL_USER:$ACTUAL_GROUP" \
|
||||||
|
"$SCRIPT_DIR/media" "$SCRIPT_DIR/playlists"
|
||||||
|
run_sudo chmod -R u+rwX "$SCRIPT_DIR/media" "$SCRIPT_DIR/playlists"
|
||||||
|
echo "Media and playlist permissions configured"
|
||||||
|
|
||||||
|
# Write player configuration
|
||||||
|
write_player_config() {
|
||||||
|
local config_dir="$SCRIPT_DIR/config"
|
||||||
|
local config_file="$config_dir/app_config.json"
|
||||||
|
mkdir -p "$config_dir"
|
||||||
|
|
||||||
|
if [ -n "$PROVISION_URL" ]; then
|
||||||
|
echo "Fetching provisioning data from: $PROVISION_URL"
|
||||||
|
local response
|
||||||
|
response=$(curl -sSf "$PROVISION_URL" 2>&1 || true)
|
||||||
|
if [ -n "$response" ]; then
|
||||||
|
echo "$response" > "$config_file"
|
||||||
|
echo "Provisioning data saved"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
echo "Failed to fetch provisioning data"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -n "$CONFIG_FILE_PATH" ] && [ -f "$CONFIG_FILE_PATH" ]; then
|
||||||
|
cp "$CONFIG_FILE_PATH" "$config_file"
|
||||||
|
echo "Configuration copied from $CONFIG_FILE_PATH"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -n "$SERVER_IP" ] || [ -n "$SCREEN_NAME" ] || [ -n "$QUICKCONNECT_KEY" ]; then
|
||||||
|
export SERVER_IP SERVER_PORT SCREEN_NAME QUICKCONNECT_KEY
|
||||||
|
export ORIENTATION TOUCH_ENABLED MAX_RESOLUTION
|
||||||
|
export USE_HTTPS VERIFY_SSL EDIT_ENABLED
|
||||||
|
|
||||||
|
python3 << PYEOF
|
||||||
|
import json, os
|
||||||
|
|
||||||
|
config_file = "$config_file"
|
||||||
|
config = {}
|
||||||
|
if os.path.exists(config_file):
|
||||||
|
with open(config_file) as f:
|
||||||
|
config = json.load(f)
|
||||||
|
|
||||||
|
cli_params = {
|
||||||
|
'server_ip': os.environ.get('SERVER_IP', ''),
|
||||||
|
'port': os.environ.get('SERVER_PORT', ''),
|
||||||
|
'screen_name': os.environ.get('SCREEN_NAME', ''),
|
||||||
|
'quickconnect_key': os.environ.get('QUICKCONNECT_KEY', ''),
|
||||||
|
'orientation': os.environ.get('ORIENTATION', ''),
|
||||||
|
'touch': os.environ.get('TOUCH_ENABLED', ''),
|
||||||
|
'max_resolution': os.environ.get('MAX_RESOLUTION', ''),
|
||||||
|
}
|
||||||
|
|
||||||
|
for key, val in cli_params.items():
|
||||||
|
if val:
|
||||||
|
config[key] = val
|
||||||
|
|
||||||
|
https_val = os.environ.get('USE_HTTPS', '')
|
||||||
|
verify_val = os.environ.get('VERIFY_SSL', '')
|
||||||
|
edit_val = os.environ.get('EDIT_ENABLED', '')
|
||||||
|
|
||||||
|
if https_val:
|
||||||
|
config['use_https'] = https_val.lower() == 'true'
|
||||||
|
if verify_val:
|
||||||
|
config['verify_ssl'] = verify_val.lower() == 'false'
|
||||||
|
if edit_val:
|
||||||
|
config['edit_feature_enabled'] = edit_val.lower() == 'true'
|
||||||
|
|
||||||
|
config.setdefault('port', '8080')
|
||||||
|
config.setdefault('orientation', 'Landscape')
|
||||||
|
config.setdefault('touch', 'True')
|
||||||
|
config.setdefault('max_resolution', '1920x1080')
|
||||||
|
config.setdefault('edit_feature_enabled', False)
|
||||||
|
config.setdefault('use_https', False)
|
||||||
|
config.setdefault('verify_ssl', True)
|
||||||
|
|
||||||
|
with open(config_file, 'w') as f:
|
||||||
|
json.dump(config, f, indent=2)
|
||||||
|
|
||||||
|
print("Configuration written")
|
||||||
|
PYEOF
|
||||||
|
elif [ \! -f "$config_file" ]; then
|
||||||
|
cat > "$config_file" << 'EOF'
|
||||||
|
{
|
||||||
|
"server_ip": "",
|
||||||
|
"port": "8080",
|
||||||
|
"screen_name": "",
|
||||||
|
"quickconnect_key": "",
|
||||||
|
"orientation": "Landscape",
|
||||||
|
"touch": "True",
|
||||||
|
"max_resolution": "1920x1080",
|
||||||
|
"edit_feature_enabled": false,
|
||||||
|
"use_https": false,
|
||||||
|
"verify_ssl": true
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
echo "Default config template created"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
write_player_config
|
||||||
|
|
||||||
|
# Initialize authentication
|
||||||
|
config_file="$SCRIPT_DIR/config/app_config.json"
|
||||||
|
if [ -f "$config_file" ]; then
|
||||||
|
if python3 -c "
|
||||||
|
import json
|
||||||
|
c = json.load(open('$config_file'))
|
||||||
|
if c.get('server_ip') and c.get('screen_name') and c.get('quickconnect_key'):
|
||||||
|
exit(0)
|
||||||
|
else:
|
||||||
|
exit(1)
|
||||||
|
" 2>/dev/null; then
|
||||||
|
echo "Attempting player registration..."
|
||||||
|
cd "$SCRIPT_DIR/src"
|
||||||
|
source "$VENV_DIR/bin/activate"
|
||||||
|
python3 -c "
|
||||||
|
import json, sys
|
||||||
|
sys.path.insert(0, '.')
|
||||||
|
from get_playlists_v2 import ensure_authenticated
|
||||||
|
|
||||||
|
with open('$config_file') as f:
|
||||||
|
config = json.load(f)
|
||||||
|
|
||||||
|
auth = ensure_authenticated(config)
|
||||||
|
if auth:
|
||||||
|
print('Player registered successfully')
|
||||||
|
sys.exit(0)
|
||||||
|
else:
|
||||||
|
print('Registration deferred')
|
||||||
|
sys.exit(1)
|
||||||
|
" 2>&1 || echo "Auth initialization deferred"
|
||||||
|
deactivate 2>/dev/null || true
|
||||||
|
cd "$SCRIPT_DIR"
|
||||||
|
else
|
||||||
|
echo "Incomplete config - auth will happen on first run"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Create background runner
|
||||||
|
NOHUP_WRAPPER="$SCRIPT_DIR/.run-background.sh"
|
||||||
|
cat > "$NOHUP_WRAPPER" << EOF
|
||||||
|
#\!/bin/bash
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
LOG_FILE="$SCRIPT_DIR/logs/player-background.log"
|
||||||
|
mkdir -p "$(dirname "$LOG_FILE")"
|
||||||
|
echo "[$(date '+%Y-%m-%d %H:%M:%S')] Starting player in background..." >> "$LOG_FILE"
|
||||||
|
cd "$SCRIPT_DIR" || exit 1
|
||||||
|
nohup bash start.sh >> "$LOG_FILE" 2>&1 &
|
||||||
|
PLAYER_PID=\$\!
|
||||||
|
echo "[$(date '+%Y-%m-%d %H:%M:%S')] Player started with PID: \$PLAYER_PID" >> "$LOG_FILE"
|
||||||
|
echo "Player started in background (PID: \$PLAYER_PID)"
|
||||||
|
echo "Logs: \$LOG_FILE"
|
||||||
|
EOF
|
||||||
|
chmod +x "$NOHUP_WRAPPER"
|
||||||
|
echo "Background runner created"
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Final Summary
|
||||||
|
# ============================================================
|
||||||
|
echo ""
|
||||||
|
echo "=========================================="
|
||||||
|
echo "Installation Complete\!"
|
||||||
|
echo "=========================================="
|
||||||
|
echo ""
|
||||||
|
echo "Installation: $SCRIPT_DIR"
|
||||||
|
echo "Virtual Env: $VENV_DIR"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
if [ -f "$SCRIPT_DIR/config/app_config.json" ]; then
|
||||||
|
echo "Player Configuration:"
|
||||||
|
echo "------------------------"
|
||||||
|
python3 -c "
|
||||||
|
import json
|
||||||
|
with open('$SCRIPT_DIR/config/app_config.json') as f:
|
||||||
|
c = json.load(f)
|
||||||
|
print(f' Server: {c.get(\"server_ip\",\"not set\")}')
|
||||||
|
print(f' Port: {c.get(\"port\",\"8080\")}')
|
||||||
|
print(f' Screen: {c.get(\"screen_name\",\"not set\")}')
|
||||||
|
print(f' HTTPS: {c.get(\"use_https\",False)}')
|
||||||
|
print(f' Resolution: {c.get(\"max_resolution\",\"auto\")}')
|
||||||
|
" 2>/dev/null || echo " (config present)"
|
||||||
|
echo ""
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Quick Start Commands:"
|
||||||
|
echo "------------------------"
|
||||||
|
echo " bash start.sh Start with watchdog (auto-restart)"
|
||||||
|
echo " bash run_player.sh Run once (no auto-restart)"
|
||||||
|
echo " bash .run-background.sh Run in background"
|
||||||
|
echo " bash check_player_status.sh Check player status"
|
||||||
|
echo " bash stop_player.sh Stop the player"
|
||||||
|
echo ""
|
||||||
|
echo "Autostart enabled for user: $ACTUAL_USER"
|
||||||
|
echo ""
|
||||||
|
echo "Logs: $SCRIPT_DIR/logs/"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
if [ -f "$PI_BOOT_PATCH_SCRIPT" ]; then
|
||||||
|
echo "Applying one-time Raspberry Pi boot config patch..."
|
||||||
|
run_sudo chmod +x "$PI_BOOT_PATCH_SCRIPT" 2>/dev/null || true
|
||||||
|
if run_sudo bash "$PI_BOOT_PATCH_SCRIPT"; then
|
||||||
|
echo "One-time boot patch applied (or already present)"
|
||||||
|
else
|
||||||
|
echo "Boot patch skipped or failed; continuing installation"
|
||||||
|
fi
|
||||||
|
echo ""
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -z "$SUDO_PASSWORD" ]; then
|
||||||
|
echo "=========================================="
|
||||||
|
echo "System Reboot Recommended"
|
||||||
|
echo "=========================================="
|
||||||
|
echo ""
|
||||||
|
echo "Some changes (HDMI settings, GPU memory) require a reboot."
|
||||||
|
echo ""
|
||||||
|
echo "Rebooting in 5 seconds... Press Ctrl+C to cancel."
|
||||||
|
sleep 5
|
||||||
|
echo "Rebooting now..."
|
||||||
|
run_sudo reboot
|
||||||
|
fi
|
||||||
|
|||||||
@@ -0,0 +1,265 @@
|
|||||||
|
# Kiwy Signage Player — Raspberry Pi OS "Trixie" 64-bit Port Plan
|
||||||
|
|
||||||
|
> Branch: `Linux-RPI-Player` (created from `Windows-Player` HEAD `f437aba`)
|
||||||
|
> Status: **Phase 1 COMPLETE — player runs on the Pi.** See `development-track.md`
|
||||||
|
> for measured results, the five blocking bugs and how they were fixed.
|
||||||
|
> Date: 2026-09-13
|
||||||
|
|
||||||
|
## Progress snapshot
|
||||||
|
|
||||||
|
| Phase | State |
|
||||||
|
|-------|-------|
|
||||||
|
| 0 — Baseline | ✅ folded into Phase 1 |
|
||||||
|
| 1 — Runtime bring-up | ✅ **done** — window, video, heartbeat, clean 60 s run |
|
||||||
|
| 1b — Windows removal | ✅ **done** — branch is now Linux-only (see `development-track.md`) |
|
||||||
|
| 2 — Web links | 🟡 adapter written, needs end-to-end verification on labwc |
|
||||||
|
| 3 — Networking & card reader | 🟡 `nmcli` restart implemented; card reader pending |
|
||||||
|
| 4 — Install & autostart | ⬜ |
|
||||||
|
| 5 — 24/7 validation | ⬜ |
|
||||||
|
|
||||||
|
**Three findings changed the plan materially** (details in `development-track.md`):
|
||||||
|
|
||||||
|
1. Kivy's PyPI wheel bundles an SDL2 **without** the wayland driver → no window
|
||||||
|
on Trixie. Fixed by `linux/fix_kivy_sdl2.sh`. (Open question: apt
|
||||||
|
`python3-kivy` may avoid this entirely.)
|
||||||
|
2. SDL2 needs `WAYLAND_DISPLAY` **set**; the socket alone is not enough, which
|
||||||
|
breaks every systemd/cron/autostart launch.
|
||||||
|
3. Kivy's `Clock` re-resolves callbacks by `func.__name__`; a mismatched patch
|
||||||
|
crashes the app ~20 s after a *successful* start.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Executive summary
|
||||||
|
|
||||||
|
The Windows port works because `windows/run_win.py` (2306 lines) monkey-patches
|
||||||
|
`src/main.py` at runtime and injects Windows-specific web-link adapters into the
|
||||||
|
existing `WeblinkSession` abstraction. The **`src/` core is already
|
||||||
|
cross-platform by design** — that is the single most important finding:
|
||||||
|
|
||||||
|
* `src/weblink_session.py` already ships a working Linux path
|
||||||
|
(`ChromiumSubprocessAdapter` is the default adapter).
|
||||||
|
* `src/network_monitor.py` already has `IS_WINDOWS` branching.
|
||||||
|
* `src/video_safety.py` patches Kivy's `VideoFFPy` provider — the same provider
|
||||||
|
used on the Pi.
|
||||||
|
|
||||||
|
So this is **not a rewrite**. It is:
|
||||||
|
|
||||||
|
1. **A new `linux/` companion to `windows/`** — an entry point that injects the
|
||||||
|
Linux adapter + platform behaviour (mirrors `run_win.py`, keeps `main.py` clean).
|
||||||
|
2. **Repairing the existing Linux code paths** that were never validated against
|
||||||
|
Trixie (Wayland/labwc, NetworkManager, no `tvservice`, no `xdotool`).
|
||||||
|
3. **Fixing the install/deployment layer**, which is currently broken for
|
||||||
|
Trixie/aarch64 (empty offline package repo, wrong wheel platform, broken
|
||||||
|
shebangs, boot-time `sudo` calls).
|
||||||
|
|
||||||
|
### Verified environment (measured on the target device)
|
||||||
|
|
||||||
|
| Item | Value |
|
||||||
|
|------|-------|
|
||||||
|
| OS | Debian GNU/Linux 13 (trixie) |
|
||||||
|
| Model | Raspberry Pi 4 Model B Rev 1.4 |
|
||||||
|
| Kernel | `6.18.39+rpt-rpi-v8` aarch64 |
|
||||||
|
| Python | **3.13.5** (system) |
|
||||||
|
| Session | `Type=wayland`, `Desktop=rpd-labwc` (**labwc**, not Wayfire/X11) |
|
||||||
|
| Compositor | `/usr/bin/labwc`; user autostart file exists at `~/.config/labwc/autostart` |
|
||||||
|
| Browser | `/usr/bin/chromium` — **Chromium 152.0.7977.82** |
|
||||||
|
| Kivy | not installed; apt has `python3-kivy 2.3.1-1+b1`; PyPI has a
|
||||||
|
`cp313 manylinux_2_17_aarch64` wheel (verified downloadable) |
|
||||||
|
| ffpyplayer / evdev | not installed; apt has `python3-evdev 1.9.1-1` |
|
||||||
|
| Display power | `wlopm` present; **`tvservice`, `xdotool`, `ydotool` MISSING** |
|
||||||
|
| Networking | **NetworkManager active**; `dhcpcd`/`ifupdown` inactive |
|
||||||
|
| `sudo` | **requires a password** (`sudo -n` fails) |
|
||||||
|
| `repo/python-wheels/` | **EMPTY (0 files)** — offline install cannot work today |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Compatibility matrix
|
||||||
|
|
||||||
|
Legend: 🔴 breaks on Pi · 🟡 works but wrong/poor · 🟢 already fine
|
||||||
|
|
||||||
|
| # | Component | Windows assumption | Trixie reality | Action |
|
||||||
|
|---|-----------|--------------------|----------------|--------|
|
||||||
|
| 1 | Entry point | `run_win.py` patches everything | no Linux equivalent — `main.py` used bare | 🟡 create `linux/run_linux.py` |
|
||||||
|
| 2 | Web-link engine | Chrome/Edge subprocess + WebView2/CEF | Chromium subprocess (default adapter) | 🟡 dedicated Linux adapter with `--kiosk`/Ozone flags |
|
||||||
|
| 3 | Web-link launch flags | `--start-fullscreen --start-maximized`, DPI-aware sizing | labwc honours `--kiosk`; needs `--ozone-platform=wayland` | 🔴 Linux-specific flag set |
|
||||||
|
| 4 | Browser hand-off leak | fixed by `--user-data-dir` | same failure mode exists | 🔴 add `--user-data-dir` on Linux too |
|
||||||
|
| 5 | Desktop flash on transition | `_Win32Overlay` + `_Win32Backdrop` | no Win32; wayland compositor owns stacking | 🔴 needs Kivy-side black cover + labwc rule |
|
||||||
|
| 6 | Window raise/focus | `SetForegroundWindow`, `AttachThreadInput` | `Window.raise_window()` is ~no-op on Wayland | 🟡 focus guardian is harmless no-op; disable on Linux |
|
||||||
|
| 7 | Keep display awake | `SetThreadExecutionState`, screensaver API | `signal_screen_activity()` shells out to X11 tools | 🔴 rewrite for `wlopm` (see #8) |
|
||||||
|
| 8 | `signal_screen_activity()` | — | runs `tvservice`(missing), `xdotool`(missing), `xset`/`xrandr`(no X DPMS), `ydotool`(missing), `wlopm` with a **buggy `\*` escape** | 🔴 rewrite, keep-awake via `wlopm --on '*'` |
|
||||||
|
| 9 | Idle/screensaver conflict | n/a | `~/.config/labwc/autostart` runs `swayidle -w timeout 600 'wlopm --off *'` → fights the player | 🔴 disable/neutralise for kiosk |
|
||||||
|
| 10 | GL backend | `angle_sdl2` | Mesa/V3D; `gl`/`gles`/`sdl2` | 🟡 set correct backend in `run_linux.py` |
|
||||||
|
| 11 | Audio driver | `directsound` | PipeWire (ALSA-compat / Pulse) | 🟡 `alsa,pulse,dummy`; a **duplicate `SDL_AUDIODRIVER` line** makes the 2nd a no-op |
|
||||||
|
| 12 | Card reader | Raw Input + `win_card_reader.py` | `evdev` (native path already in `main.py`) | 🟡 install `python3-evdev`; udev/permissions rule |
|
||||||
|
| 13 | Interaction watcher | `GetCursorPos` pointer tap | `/dev/input/event*` raw fds (already implemented) | 🟡 needs `input` group perms (user `pi` already in `input`) |
|
||||||
|
| 14 | Offline install | n/a | `repo/python-wheels/` empty; `download_offline_packages.sh` uses `--platform linux_armv7l` (**32-bit**) | 🔴 regenerate for `aarch64` / cp313 |
|
||||||
|
| 15 | `install.sh` shebang | n/a | literal `#\!/bin/bash` (escaped `!`) → not directly executable | 🔴 fix (3 places incl. heredocs) |
|
||||||
|
| 16 | `install.sh` deps | n/a | installs `libsdl2-dev`/`libav*-dev` build toolchain; apt `python3-kivy` exists | 🟡 prefer apt Kivy + wheels |
|
||||||
|
| 17 | `install.sh` autostart | n/a | writes *only* XDG `.desktop`; labwc uses `~/.config/labwc/autostart` | 🔴 add labwc hook + systemd unit |
|
||||||
|
| 18 | `start.sh` display setup | n/a | `configure_display_resolution()` writes `/boot/config.txt` (**wrong path** on Trixie → `/boot/firmware/config.txt`) and calls `xrandr`/`tvservice`; `sudo` will **prompt at boot** | 🔴 remove/repair — boot-hang risk |
|
||||||
|
| 19 | WiFi restart | `netsh wlan` | `rfkill`/`ifconfig`/`dhclient` — `ifconfig`+`dhclient` **not installed**, all `sudo` → password prompt | 🔴 move to `nmcli`, passwordless sudoers |
|
||||||
|
| 20 | `setup_wifi_control.sh` | n/a | allows `/sbin/ifconfig`, `/sbin/dhclient` — **paths don't exist** | 🔴 rewrite for Trixie |
|
||||||
|
| 21 | Orientation / `max_resolution` | `Window.size` constraint | fullscreen ignores `Window.size`; rotation is a compositor concern | 🔴 implement via `wlr-randr`/cmdline |
|
||||||
|
| 22 | `src/signageplayer.ini` | — | **never loaded by any code** (dead file) | 🟡 delete or wire up |
|
||||||
|
| 23 | `prewarm` weblink browser | desktop CPU budget | 2 extra Chromium processes on a Pi 4 | 🟡 default `prewarm:false` on Pi |
|
||||||
|
| 24 | Watchdog / 24-7 | `watchdog.ps1` | `start.sh` exists and is sound (heartbeat + stop flag) | 🟢 keep, with fixes from #18 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Proposed architecture
|
||||||
|
|
||||||
|
Mirror the existing, proven Windows pattern. Keep `src/main.py` cross-platform
|
||||||
|
per the repo ground rules — **no Linux-only imports added to `main.py`**.
|
||||||
|
|
||||||
|
```
|
||||||
|
src/ # shared, cross-platform core (unchanged philosophy)
|
||||||
|
main.py # shared application; PI patches live in linux/
|
||||||
|
weblink_session.py # WeblinkSession + ChromiumSubprocessAdapter (default)
|
||||||
|
linux/ # NEW — the Linux counterpart of windows/
|
||||||
|
run_linux.py # ✅ entry point: env, session, adapter injection, patches
|
||||||
|
linux_display.py # ✅ wlopm keep-awake, swayidle neutralisation, rotation
|
||||||
|
linux_browser.py # ✅ LinuxChromiumAdapter (kiosk + Ozone + user-data-dir)
|
||||||
|
fix_kivy_sdl2.sh # ✅ system SDL2 (wayland-capable) over the bundled one
|
||||||
|
test_linux_patches.py # ✅ regression tests (29 checks)
|
||||||
|
_probe_video.py # ✅ ad-hoc video decode/playback probe
|
||||||
|
development-track.md # ✅ measured findings and bug log
|
||||||
|
RPI_TRIXIE_PORT_PLAN.md # ✅ this file
|
||||||
|
install_linux.sh # ⬜ upcoming — Trixie-correct installer
|
||||||
|
build_offline_aarch64.sh # ⬜ upcoming — cp313/aarch64 wheels
|
||||||
|
start_player.sh # ⬜ upcoming — kiosk launcher (fixed start.sh)
|
||||||
|
watchdog.sh # ⬜ upcoming — crash+heartbeat supervisor
|
||||||
|
kiwy-player.service # ⬜ upcoming — systemd unit
|
||||||
|
kiwy-signage-wifi.sudoers # ⬜ upcoming — nmcli-only passwordless rules
|
||||||
|
.github/instructions/
|
||||||
|
kiwy-linux-rpi.instructions.md # ⬜ upcoming — build/deploy rules for the Pi
|
||||||
|
```
|
||||||
|
|
||||||
|
`run_linux.py` responsibilities (deliberately the *same shape* as `run_win.py`):
|
||||||
|
|
||||||
|
1. Set Linux env vars **before** importing `main` (`SDL_VIDEODRIVER=wayland,x11,dummy`,
|
||||||
|
`KIVY_GL_BACKEND`, `SDL_AUDIODRIVER`, `KIVY_VIDEO/KIVY_AUDIO=ffpyplayer`).
|
||||||
|
2. Import `main`, then assign `SignagePlayer.weblink_adapter_factory`
|
||||||
|
→ `[LinuxChromiumAdapter]`.
|
||||||
|
3. Patch `signal_screen_activity` → `linux_display` implementation.
|
||||||
|
4. Skip the Win32-only focus guardian / bring-to-front paths.
|
||||||
|
5. Provide a fatal-error surface that works without a console
|
||||||
|
(log file + optional on-screen error label).
|
||||||
|
|
||||||
|
**Why an adapter instead of patching `play_weblink()`:** the `WeblinkSession`
|
||||||
|
abstraction already exists precisely for this, and it is what removed the
|
||||||
|
z-order/leak bugs on Windows. Reusing it means the Pi inherits the verified
|
||||||
|
launch → visibility → interaction → teardown state machine for free.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Phased implementation plan
|
||||||
|
|
||||||
|
### Phase 0 — Baseline (no product change)
|
||||||
|
- [ ] Fix the `install.sh`/`start.sh` shebangs (`#\!` → `#!`) so scripts are executable.
|
||||||
|
- [ ] Boot the player on this Pi **manually** (`python3 src/main.py` from a venv) and
|
||||||
|
capture a baseline: does Kivy start on labwc/Wayland? GL backend? audio? video?
|
||||||
|
- [ ] Record findings in `linux/development-track.md`.
|
||||||
|
|
||||||
|
**Exit criteria:** a screenshot/log showing the player window on the Pi, or a
|
||||||
|
precise first-failure diagnosis.
|
||||||
|
|
||||||
|
### Phase 1 — Runtime bring-up (`linux/run_linux.py`, `linux/linux_display.py`)
|
||||||
|
- [x] Create the venv and install deps (`kivy`, `ffpyplayer`, `evdev`, `requests`, `bcrypt`, `aiohttp`).
|
||||||
|
- [x] `run_linux.py`: env setup + adapter injection + `signal_screen_activity` replacement.
|
||||||
|
- [x] Fix the SDL2/Wayland blocker (`linux/fix_kivy_sdl2.sh`).
|
||||||
|
- [x] `signal_screen_activity()`: `wlopm --on '*'` via `subprocess` (no `os.system`),
|
||||||
|
`vcgencmd display_power` backstop, X11 fallbacks; dropped `tvservice`/`xdotool`/`ydotool`.
|
||||||
|
- [x] Neutralise the `swayidle` idle blanker.
|
||||||
|
- [x] Gate the Win32 focus guardian/keeper on Linux.
|
||||||
|
- [x] Fix the duplicate `SDL_AUDIODRIVER` line.
|
||||||
|
- [x] Point the launchers at `linux/run_linux.py` instead of `src/main.py`.
|
||||||
|
- [x] Regression test `linux/test_linux_patches.py` (29 checks).
|
||||||
|
|
||||||
|
**Exit criteria met:** player runs fullscreen on labwc, video decodes and plays
|
||||||
|
(`duration=6.0`, position advancing, texture rendered), heartbeat written,
|
||||||
|
60 s run with zero errors and a clean shutdown. 24/7 soak still pending (Phase 5).
|
||||||
|
|
||||||
|
### Phase 2 — Web links on Chromium/Wayland (`linux/linux_browser.py`)
|
||||||
|
- [ ] `LinuxChromiumAdapter(ChromiumSubprocessAdapter)`: `--kiosk`, `--ozone-platform=wayland`
|
||||||
|
(with auto fallback), dedicated `--user-data-dir`, `--autoplay-policy=no-user-gesture-required`,
|
||||||
|
`--hide-scrollbars`, `--disable-pinch`, `--start-fullscreen` only where `--kiosk` misbehaves.
|
||||||
|
- [ ] Implement `wait_visible()` for Linux — verify the Chromium window exists
|
||||||
|
(`/proc/<pid>/` + Wayland toplevel check, or a `--remote-debugging-port` probe)
|
||||||
|
instead of only "process still alive".
|
||||||
|
- [ ] Transition masking without Win32: Kivy-side black cover + verify no desktop flash
|
||||||
|
under labwc; if needed, an `rc.xml` window rule pinning the Kivy window.
|
||||||
|
- [ ] Teardown: kill the full process tree (`start_new_session=True` + `os.killpg`)
|
||||||
|
so GPU/renderer children don't leak (the Linux twin of `taskkill /T`).
|
||||||
|
- [ ] Default `weblink.prewarm=false` on Pi to save CPU/RAM.
|
||||||
|
|
||||||
|
**Exit criteria:** image → video → weblink → image cycle with no flash, no leaked
|
||||||
|
`chromium` processes, correct duration + interaction postponement.
|
||||||
|
|
||||||
|
### Phase 3 — Networking & card reader
|
||||||
|
- [ ] `src/network_monitor.py`: replace `rfkill`/`ifconfig`/`dhclient` with `nmcli`
|
||||||
|
(`nmcli radio wifi off/on`, `nmcli device disconnect/connect`) — Trixie uses NetworkManager.
|
||||||
|
- [ ] `kiwy-signage-wifi.sudoers`: passwordless rules for exactly the `nmcli`/`rfkill` binaries used.
|
||||||
|
- [ ] Install `python3-evdev` (apt) + udev rule so the card reader works without `input` group hacks.
|
||||||
|
|
||||||
|
### Phase 4 — Install & autostart (Trixie-native)
|
||||||
|
- [ ] `build_offline_aarch64.sh`: populate `repo/python-wheels/` with **cp313 aarch64**
|
||||||
|
wheels (`kivy`, `ffpyplayer`, `evdev`, `requests`, `bcrypt`, `aiohttp`, …).
|
||||||
|
Replace the `--platform linux_armv7l` logic.
|
||||||
|
- [ ] `install_linux.sh`: prefer apt `python3-kivy`/`python3-evdev` (fast, no build toolchain);
|
||||||
|
venv with `--system-site-packages`; drop the `--break-system-packages` fallback.
|
||||||
|
- [ ] Autostart: append to `~/.config/labwc/autostart` **and** ship a systemd unit
|
||||||
|
(verify which one RPi OS Trixie `rpd-labwc` actually honours — validation item).
|
||||||
|
- [ ] `watchdog.sh`: repair `configure_display_resolution()` (wrong `/boot/config.txt` path,
|
||||||
|
`sudo` at boot = hang risk); make all boot-time steps non-interactive.
|
||||||
|
- [ ] Orientation / `max_resolution`: implement rotation via `wlr-randr` (Wayland) or
|
||||||
|
`cmdline.txt` `video=...,rotate=`; document that `Window.size` cannot change a
|
||||||
|
fullscreen mode.
|
||||||
|
|
||||||
|
**Exit criteria:** clean install on a wiped Pi → reboots into playback unattended.
|
||||||
|
|
||||||
|
### Phase 5 — 24/7 validation & docs
|
||||||
|
- [ ] Overnight soak (≥12 h): mixed playlist, verify heartbeat, no leak growth
|
||||||
|
(`ps` chromium count), no memory growth.
|
||||||
|
- [ ] Crash/hang drills: kill the player, freeze it, power-cut it → watchdog recovery.
|
||||||
|
- [ ] `linux/development-track.md` + `.github/instructions/kiwy-linux-rpi.instructions.md`.
|
||||||
|
- [ ] Update `PLAYER_VERSION` and the release checklist.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Risks & mitigations
|
||||||
|
|
||||||
|
| Risk | Mitigation |
|
||||||
|
|------|-----------|
|
||||||
|
| Wayland client stacking — Chromium kiosk may not reliably return focus to Kivy | validate early (Phase 2); fallback = labwc `rc.xml` rule or run the session under XWayland |
|
||||||
|
| Chromium `--kiosk` + labwc fullscreen semantics | empirical flag matrix recorded in the dev-track (like the Windows "tested & rejected" log) |
|
||||||
|
| `sudo` password prompt at boot hangs the watchdog | all boot-time steps must be non-interactive; sudoers written at install time |
|
||||||
|
| Pi 4 software H.265 decode is heavy | document supported codecs; prefer H.264; keep `video_safety` bounded join |
|
||||||
|
| Offline wheels for cp313/aarch64 may be incomplete | Phase 0 verifies `pip download` for every requirement before Phase 4 |
|
||||||
|
| `src/main.py` drift — Linux fixes leaking into shared code | follow the repo rule: platform code lives in `linux/`, `main.py` only gains *guarded* capability checks |
|
||||||
|
|
||||||
|
## 6. Validation checklist (per phase, on the real device)
|
||||||
|
|
||||||
|
- [ ] `python -m py_compile` on every touched file
|
||||||
|
- [ ] Player starts fullscreen on labwc without a desktop flash
|
||||||
|
- [ ] Display never blanks (24 h)
|
||||||
|
- [ ] Playlist: image → video → weblink → image, correct durations
|
||||||
|
- [ ] Weblink: kiosk fullscreen, no leaked `chromium` processes after the item
|
||||||
|
- [ ] Touch interaction postpones the weblink advance
|
||||||
|
- [ ] Card reader authenticates
|
||||||
|
- [ ] WiFi restart recovers from an unplugged AP without a password prompt
|
||||||
|
- [ ] Reboot → playback resumes unattended
|
||||||
|
- [ ] `kill -9` the player → watchdog restarts within 60 s
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Open decisions (need input before Phase 1)
|
||||||
|
|
||||||
|
1. **Kivy source**: apt `python3-kivy` (fast, offline-friendly, distro-managed) vs
|
||||||
|
PyPI wheel in a venv (matches the Windows Kivy 2.3.1 + full control)?
|
||||||
|
2. **Session target**: stay on the stock `rpd-labwc` kiosk session, or ship a
|
||||||
|
dedicated minimal labwc session (no panel, no `swayidle`) for the player?
|
||||||
|
3. **Autostart mechanism**: systemd (system or user) vs `~/.config/labwc/autostart`?
|
||||||
|
4. **Orientation**: is Portrait support actually required for this deployment?
|
||||||
|
5. **Offline install**: must the Pi install work with no internet (i.e. vendor the
|
||||||
|
aarch64 wheels into `repo/`), or is online install acceptable?
|
||||||
|
6. **Card reader**: is it in scope for the Pi, or is authentication keypad/quickconnect only?
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
"""_probe_chromium_footprint.py — measure Chromium's footprint for one page.
|
||||||
|
|
||||||
|
Compares the flag profiles so the memory trade-off is measured rather than
|
||||||
|
guessed. Run:
|
||||||
|
|
||||||
|
.venv/bin/python linux/_probe_chromium_footprint.py [url]
|
||||||
|
|
||||||
|
Reports process count and total resident memory for the browser tree, using the
|
||||||
|
same flags the player uses (so the numbers match production).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
from linux_display import ensure_session_environment # noqa: E402
|
||||||
|
|
||||||
|
ensure_session_environment()
|
||||||
|
|
||||||
|
import linux_browser # noqa: E402
|
||||||
|
|
||||||
|
URL = sys.argv[1] if len(sys.argv) > 1 else 'about:blank'
|
||||||
|
|
||||||
|
|
||||||
|
def tree_memory(root_pid):
|
||||||
|
"""(PSS kB, process count) for root_pid and its descendants.
|
||||||
|
|
||||||
|
PSS (proportional set size) from ``/proc/<pid>/smaps_rollup`` is the right
|
||||||
|
measure here, **not** RSS. Chromium forks many processes that share the same
|
||||||
|
libraries and file-backed pages; summing RSS counts every shared page once
|
||||||
|
per process, which inflated earlier measurements by roughly 2x and even made
|
||||||
|
a smaller configuration look larger.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
out = subprocess.run(
|
||||||
|
['ps', '-eo', 'pid,ppid'], capture_output=True, text=True,
|
||||||
|
timeout=10, check=False,
|
||||||
|
).stdout
|
||||||
|
except Exception:
|
||||||
|
return 0, 0
|
||||||
|
|
||||||
|
parents = {}
|
||||||
|
for line in out.splitlines()[1:]:
|
||||||
|
parts = line.split()
|
||||||
|
if len(parts) >= 2 and parts[0].isdigit():
|
||||||
|
parents[int(parts[0])] = int(parts[1])
|
||||||
|
|
||||||
|
family = [root_pid]
|
||||||
|
changed = True
|
||||||
|
while changed:
|
||||||
|
changed = False
|
||||||
|
for pid, parent in parents.items():
|
||||||
|
if parent in family and pid not in family:
|
||||||
|
family.append(pid)
|
||||||
|
changed = True
|
||||||
|
|
||||||
|
total_kb = 0
|
||||||
|
for pid in family:
|
||||||
|
try:
|
||||||
|
with open(f'/proc/{pid}/smaps_rollup') as fh:
|
||||||
|
for line in fh:
|
||||||
|
if line.startswith('Pss:'):
|
||||||
|
total_kb += int(line.split()[1])
|
||||||
|
break
|
||||||
|
except OSError:
|
||||||
|
# Process exited between listing and reading.
|
||||||
|
continue
|
||||||
|
return total_kb, len(family)
|
||||||
|
|
||||||
|
|
||||||
|
def measure(mode):
|
||||||
|
os.environ['KIWY_CHROMIUM_MODE'] = mode
|
||||||
|
profile = f'/tmp/kiwy-footprint-{mode}'
|
||||||
|
subprocess.run(['rm', '-rf', profile], check=False)
|
||||||
|
|
||||||
|
adapter = linux_browser.LinuxChromiumAdapter(
|
||||||
|
browser_path=linux_browser.find_linux_browser(), kiosk=True,
|
||||||
|
)
|
||||||
|
adapter._profile_dir = profile
|
||||||
|
|
||||||
|
ok = adapter.launch(URL, 1280, 720)
|
||||||
|
if not ok or adapter._proc is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Let Chromium finish spawning helpers before sampling.
|
||||||
|
time.sleep(8)
|
||||||
|
if adapter._proc.poll() is not None:
|
||||||
|
return {'crashed': True, 'rc': adapter._proc.returncode}
|
||||||
|
pss_kb, procs = tree_memory(adapter._proc.pid)
|
||||||
|
return {"pss_mb": pss_kb / 1024.0, "procs": procs}
|
||||||
|
finally:
|
||||||
|
adapter.teardown()
|
||||||
|
time.sleep(1.5)
|
||||||
|
|
||||||
|
|
||||||
|
print(f'url = {URL}\n')
|
||||||
|
print(f'{"mode":<10} {"procs":>6} {"PSS (MB)":>10}')
|
||||||
|
print('-' * 30)
|
||||||
|
|
||||||
|
results = {}
|
||||||
|
for mode in ('safe', 'light', 'minimal'):
|
||||||
|
result = measure(mode)
|
||||||
|
results[mode] = result
|
||||||
|
if result is None:
|
||||||
|
note = 'launch failed'
|
||||||
|
elif result.get('crashed'):
|
||||||
|
note = f'CRASHED rc={result["rc"]}'
|
||||||
|
else:
|
||||||
|
note = ''
|
||||||
|
if note:
|
||||||
|
print(f'{mode:<10} {"-":>6} {note:>10}')
|
||||||
|
else:
|
||||||
|
print(f'{mode:<10} {result["procs"]:>6} {result["pss_mb"]:>10.0f}')
|
||||||
|
|
||||||
|
print()
|
||||||
|
base = results.get('safe', {}) or {}
|
||||||
|
light = results.get('light', {}) or {}
|
||||||
|
if base.get('pss_mb') and light.get('pss_mb'):
|
||||||
|
saved = base['pss_mb'] - light['pss_mb']
|
||||||
|
pct = 100.0 * saved / base['pss_mb']
|
||||||
|
print(f'light mode saves {saved:.0f} MB ({pct:.0f}%) vs the default flag set')
|
||||||
|
print(f' processes: {base["procs"]} -> {light["procs"]}')
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
"""Ad-hoc video playback probe (not part of the test suite).
|
||||||
|
|
||||||
|
Plays the intro video in a Kivy window and reports whether it decodes and
|
||||||
|
advances. Used to verify the ffpyplayer path on Raspberry Pi OS Trixie.
|
||||||
|
|
||||||
|
.venv/bin/python linux/_probe_video.py [path/to/video.mp4]
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
|
||||||
|
# Mirror the real entry point: SDL2 does NOT discover the Wayland socket on its
|
||||||
|
# own, so WAYLAND_DISPLAY must be filled in first. Without this the probe falls
|
||||||
|
# back to x11 and dies with "Couldn't connect to X server".
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
from linux_display import ensure_session_environment # noqa: E402
|
||||||
|
|
||||||
|
ensure_session_environment()
|
||||||
|
|
||||||
|
os.environ.setdefault('SDL_VIDEODRIVER', 'wayland,x11,dummy')
|
||||||
|
os.environ.setdefault('KIVY_GL_BACKEND', 'gl')
|
||||||
|
os.environ.setdefault('KIVY_VIDEO', 'ffpyplayer')
|
||||||
|
os.environ.setdefault('KIVY_AUDIO', 'ffpyplayer')
|
||||||
|
|
||||||
|
from kivy.config import Config # noqa: E402
|
||||||
|
|
||||||
|
Config.set('graphics', 'window_state', 'hidden')
|
||||||
|
Config.set('graphics', 'fullscreen', '0')
|
||||||
|
|
||||||
|
from kivy.app import App # noqa: E402
|
||||||
|
from kivy.clock import Clock # noqa: E402
|
||||||
|
from kivy.uix.video import Video # noqa: E402
|
||||||
|
|
||||||
|
SOURCE = sys.argv[1] if len(sys.argv) > 1 else 'config/resources/intro1.mp4'
|
||||||
|
DURATION = 12.0
|
||||||
|
results = {}
|
||||||
|
|
||||||
|
|
||||||
|
class Probe(App):
|
||||||
|
def build(self):
|
||||||
|
self.video = Video(
|
||||||
|
source=SOURCE, state='play', options={'eos': 'stop'},
|
||||||
|
allow_stretch=True, keep_ratio=True,
|
||||||
|
)
|
||||||
|
self.video.bind(on_eos=lambda *a: results.setdefault('eos', True))
|
||||||
|
return self.video
|
||||||
|
|
||||||
|
def on_start(self):
|
||||||
|
self.t0 = time.monotonic()
|
||||||
|
Clock.schedule_interval(self.tick, 1.5)
|
||||||
|
Clock.schedule_once(lambda dt: self.stop(), DURATION)
|
||||||
|
|
||||||
|
def tick(self, dt):
|
||||||
|
elapsed = time.monotonic() - self.t0
|
||||||
|
core = self.video._video
|
||||||
|
position = getattr(core, 'position', None) if core else None
|
||||||
|
if position is not None:
|
||||||
|
results['last_position'] = position
|
||||||
|
print(
|
||||||
|
f' t={elapsed:5.1f}s state={self.video.state} '
|
||||||
|
f'duration={self.video.duration:.1f} position={position} '
|
||||||
|
f'texture={"yes" if self.video.texture else "no"}',
|
||||||
|
flush=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
Probe().run()
|
||||||
|
print(f'RESULT duration={results.get("duration")} '
|
||||||
|
f'last_position={results.get("last_position")} eos={results.get("eos")}')
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
"""Ad-hoc weblink engine probe — drives the real LinuxChromiumAdapter.
|
||||||
|
|
||||||
|
Verifies the full launch → visible → teardown cycle outside the player, so
|
||||||
|
weblink behaviour can be tested without a server-provided playlist.
|
||||||
|
|
||||||
|
.venv/bin/python linux/_probe_weblink.py [url]
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
|
||||||
|
_HERE = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
sys.path.insert(0, _HERE)
|
||||||
|
sys.path.insert(0, os.path.join(os.path.dirname(_HERE), 'src'))
|
||||||
|
|
||||||
|
from linux_display import ensure_session_environment # noqa: E402
|
||||||
|
|
||||||
|
ensure_session_environment()
|
||||||
|
|
||||||
|
URL = sys.argv[1] if len(sys.argv) > 1 else 'https://example.com'
|
||||||
|
REQUIRED = os.environ.get('KIWY_REQUIRE_SUBSTRING', '')
|
||||||
|
|
||||||
|
from linux_browser import LinuxChromiumAdapter, find_linux_browser # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
def chromium_count():
|
||||||
|
import subprocess
|
||||||
|
out = subprocess.run(['pgrep', '-c', 'chromium'], capture_output=True, text=True)
|
||||||
|
try:
|
||||||
|
return int(out.stdout.strip())
|
||||||
|
except ValueError:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def rss_mb():
|
||||||
|
import subprocess
|
||||||
|
out = subprocess.run(
|
||||||
|
['ps', '-o', 'rss=', '-C', 'chromium'], capture_output=True, text=True
|
||||||
|
)
|
||||||
|
total = sum(int(x) for x in out.stdout.split() if x.isdigit())
|
||||||
|
return total / 1024.0
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
print(f'URL: {URL}')
|
||||||
|
print(f'baseline chromium procs: {chromium_count()}')
|
||||||
|
|
||||||
|
adapter = LinuxChromiumAdapter(
|
||||||
|
browser_path=find_linux_browser(),
|
||||||
|
kiosk=True,
|
||||||
|
profile_dir=os.path.join(os.getcwd(), '.kiosk-profile-probe'),
|
||||||
|
)
|
||||||
|
|
||||||
|
print(f'flags: {adapter.extra_launch_args()}')
|
||||||
|
|
||||||
|
launched = adapter.launch(URL, 1920, 1080)
|
||||||
|
print(f'launch() -> {launched}')
|
||||||
|
if not launched:
|
||||||
|
print('FAIL: launch returned False')
|
||||||
|
return 1
|
||||||
|
|
||||||
|
visible, reason = adapter.wait_visible(15.0)
|
||||||
|
print(f'wait_visible() -> {visible} ({reason})')
|
||||||
|
|
||||||
|
time.sleep(6)
|
||||||
|
print(f'during: {chromium_count()} procs, {rss_mb():.0f} MB')
|
||||||
|
|
||||||
|
if REQUIRED:
|
||||||
|
# Fetch the same URL over HTTP and confirm it resolves, so a blank page
|
||||||
|
# can be attributed to rendering rather than to the network.
|
||||||
|
import urllib.request
|
||||||
|
try:
|
||||||
|
body = urllib.request.urlopen(URL, timeout=10).read(400_000).decode(
|
||||||
|
'utf-8', 'replace'
|
||||||
|
)
|
||||||
|
print(f'server reachable; page contains {REQUIRED!r}: {REQUIRED in body}')
|
||||||
|
except Exception as exc:
|
||||||
|
print(f'server fetch failed (network/host issue, not the engine): {exc}')
|
||||||
|
|
||||||
|
adapter.teardown()
|
||||||
|
time.sleep(3)
|
||||||
|
|
||||||
|
left = chromium_count()
|
||||||
|
print(f'after teardown: {left} chromium procs (0 = no leak)')
|
||||||
|
ok = visible and left == 0
|
||||||
|
print('RESULT:', 'PASS' if ok else 'FAIL')
|
||||||
|
return 0 if ok else 1
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,521 @@
|
|||||||
|
# 🧪 Development Track — Kiwy Signage Player (Raspberry Pi / Linux Edition)
|
||||||
|
|
||||||
|
> Read this FIRST before debugging or coding on the Pi port. It records what was
|
||||||
|
> measured on real hardware, not what was assumed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📅 Session — 2026-09-13
|
||||||
|
|
||||||
|
| Field | Value |
|
||||||
|
|-------|-------|
|
||||||
|
| **Branch** | `Linux-RPI-Player` (from `Windows-Player` @ `f437aba`) |
|
||||||
|
| **Hardware** | Raspberry Pi 4 Model B Rev 1.4, kernel `6.18.39+rpt-rpi-v8` |
|
||||||
|
| **Python** | 3.13.5 in `.venv` (created with `--system-site-packages`) |
|
||||||
|
| **Kivy** | 2.3.1 (PyPI wheel, cp313 aarch64) |
|
||||||
|
| **ffpyplayer** | 4.5.3 (cp313 aarch64 wheel) |
|
||||||
|
| **evdev** | 2.0.0 (built from sdist — no cp313 wheel published) |
|
||||||
|
| **Session** | Wayland, `rpd-labwc` (labwc — **not** Wayfire, not X11) |
|
||||||
|
| **Browser** | `/usr/bin/chromium` 152.0.7977.82 |
|
||||||
|
| **Entry point** | `linux/run_linux.py` (**not** `src/main.py`) |
|
||||||
|
|
||||||
|
### ✅ Status: the player runs on the Pi
|
||||||
|
|
||||||
|
Verified by real runs (not inference):
|
||||||
|
|
||||||
|
* Kivy window created on **Wayland** (`wayland,x11,dummy` → wayland driver)
|
||||||
|
* 60 s continuous run, **0 errors, 0 tracebacks, clean shutdown**
|
||||||
|
* Heartbeat file written and refreshed every 10 s
|
||||||
|
* Playlist polling loop active (30 s interval)
|
||||||
|
* **Video playback works**: intro1.mp4 → `duration=6.0`, position advanced
|
||||||
|
`0.56 → 2.07 → 3.56 → 5.06`, texture rendered, EOS handled, state auto-reset
|
||||||
|
* Screensaver/blanker neutralised; `wlopm --on` confirmed working
|
||||||
|
|
||||||
|
Blocked on: no server reachable from this network (`192.168.0.110` → *No route
|
||||||
|
to host*), so no playlist/media content could be downloaded. That is an
|
||||||
|
environment limitation, not a code defect.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## �️ Windows code removal (2026-09-13, second pass)
|
||||||
|
|
||||||
|
This branch is now **Linux-only**. All Windows code and assets were removed;
|
||||||
|
they remain available on the `Windows-Player` branch.
|
||||||
|
|
||||||
|
**Deleted:**
|
||||||
|
|
||||||
|
* `windows/` — the whole directory (32 files): `run_win.py`, `win_card_reader.py`,
|
||||||
|
`webview2_browser.py`, `webview2_runtime.py`, `cef_browser.py`, `build.spec`,
|
||||||
|
`watchdog.ps1`, the PyInstaller/PowerShell tooling and the bundled Windows
|
||||||
|
binaries (`webview2_sdk/` DLLs, `app_icon.ico`).
|
||||||
|
* `.github/instructions/kiwy-build-and-development.instructions.md` (the exe build guide)
|
||||||
|
* `documentation/CODE_SIGNING_SMART_APP_CONTROL.md` (Windows-only signing constraint)
|
||||||
|
|
||||||
|
**Removed later: the rest of the Windows-era leftovers** (all recoverable from the
|
||||||
|
`Windows-Player` branch, so nothing was lost):
|
||||||
|
|
||||||
|
| Item | Why it went |
|
||||||
|
|------|-------------|
|
||||||
|
| `working_files/` (29 files) | Dev-era scratch: `install.sh.bak`, the superseded `get_playlists.py` (v1, replaced by `get_playlists_v2.py`), one-off `test_*.py` probes, and `MIGRATION_GUIDE.md` / `INVESTIGATION_RESULTS.md`. Also held real server captures (`server_response_debug.json`: `player_id`, player name, playlist) which had no business in the repo. |
|
||||||
|
| `documentation/` (5 files) | All described the Windows-era HTTPS integration work. |
|
||||||
|
| `python version` | Contained `Python 3.12.9` — the Windows build interpreter. The Pi runs 3.13.5. |
|
||||||
|
| `test_edited_media_upload.py` | Parentless debug script, referenced by nothing. |
|
||||||
|
| `.display-keepalive.sh`, `.keep-screen-alive.sh`, `.wait-for-display.sh` | Orphan X11 helper scripts, referenced by nothing. Superseded by `linux/linux_display.py`. |
|
||||||
|
| `.video-optimization.sh`, `.run-background.sh`, `.start-player-cron.sh` | **Kept** — `install.sh` references all three. |
|
||||||
|
|
||||||
|
**Removed from shared `src/`** — these were live code paths, so this was a real
|
||||||
|
behavioural change, not just a comment cleanup:
|
||||||
|
|
||||||
|
| File | Removed |
|
||||||
|
|------|---------|
|
||||||
|
| `main.py` | The whole Win32 focus subsystem: `_bring_window_to_front_nonblocking`, `_start/_stop_focus_keeper`, `_focus_keeper_tick`, `_start/_stop_focus_guardian`, `_focus_guardian_tick`, their `__init__` state, the `_start_focus_keeper()` call at video start, the guardian start in `__init__`, the two `_stop_focus_keeper()` calls, and the `_is_foreground_win` / `_bring_kivy_to_front_win` getattr hooks. Also the duplicate `Window.raise_window()` block and `_weblink_proc`. |
|
||||||
|
| `network_monitor.py` | `IS_WINDOWS`, the `ping -n/-w` branch, `_restart_wifi_windows()` (netsh), and the `sudo ifconfig`/`dhclient` calls (`ifconfig`/`dhclient` are **not installed** on Trixie). |
|
||||||
|
| `weblink_session.py` | `WebInputSources.pointer_moved()` (`GetCursorPos`), the `use_pointer` plumbing, and `msedge` from the browser search list. |
|
||||||
|
| `video_safety.py` | Windows framing in the docstring only — the fix itself is cross-platform and was kept. |
|
||||||
|
|
||||||
|
**Replaced, not just deleted:**
|
||||||
|
|
||||||
|
* `_restart_wifi_linux()` → `_restart_wifi_nmcli()`. Trixie uses **NetworkManager**,
|
||||||
|
so `nmcli radio wifi off/on` + `nmcli device connect` is the correct interface
|
||||||
|
and needs no `sudo`. The old path depended on packages that are absent.
|
||||||
|
* `ifconfig wlan0 down/up` → `ip link set wlan0 down/up`.
|
||||||
|
* New `.github/instructions/kiwy-linux-rpi.instructions.md` (the Pi counterpart
|
||||||
|
of the deleted build guide).
|
||||||
|
* `PLAYER_WEBLINK_INTEGRATION.md` §6.2 rewritten: "Windows: WebView2 Runtime"
|
||||||
|
→ "Linux: Chromium kiosk on Wayland".
|
||||||
|
* `.gitignore`: dropped the Windows venv/dist/WebView2 entries, kept the
|
||||||
|
`.kiosk-profile/` ignore and clarified the credential rules.
|
||||||
|
* `config/app_config.json`: blanked the Windows host values
|
||||||
|
(`screen_name: DESKTOP-NJLBQKH`, the server IP and quick-connect key) and set
|
||||||
|
`weblink.prewarm: false` — pre-warming costs 2 extra Chromium processes, which
|
||||||
|
is not worth it on a Pi 4.
|
||||||
|
|
||||||
|
**Also removed:** the `_patch_focus_handlers()` patch from `run_linux.py`. It had
|
||||||
|
become vestigial once the focus methods were deleted — it would have created
|
||||||
|
no-op attributes the app never calls. `test_linux_patches.py` now **asserts those
|
||||||
|
attributes do not exist**, so reintroducing Win32 focus code fails the suite.
|
||||||
|
|
||||||
|
**Verified after the removal:** 21/21 patch checks pass, video still decodes and
|
||||||
|
plays (position advancing, texture rendered), 60 s player run with 0 errors and
|
||||||
|
no spurious crash log.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## �🔴 The five bugs that actually prevented the port
|
||||||
|
### [PI-013] Chromium asked for the keyring password at every weblink
|
||||||
|
|
||||||
|
* **Symptom:** "user and password" prompt appeared when a weblink launched
|
||||||
|
(reproducible also by starting Chromium manually). Impossible to answer on an
|
||||||
|
unattended signage screen, and it blocked the page.
|
||||||
|
* **Root cause (two separate bugs, neither sufficient alone):**
|
||||||
|
1. **Flag list was dead code.** `APPLIANCE_FLAGS` contained
|
||||||
|
`--password-store=basic` and `--use-mock-keychain`, but the list was
|
||||||
|
**never referenced** from `extra_launch_args()` — so those flags never
|
||||||
|
reached the command line. The comment looked like a fix; nothing was applied.
|
||||||
|
2. **The D-Bus session bus was inherited.** `ChromiumSubprocessAdapter.launch()`
|
||||||
|
used a bare `subprocess.Popen(args)` with **no `env=`**, and
|
||||||
|
`gnome-keyring-daemon --components=secrets` is running with
|
||||||
|
`DBUS_SESSION_BUS_ADDRESS` set. Chromium's password-store backend therefore
|
||||||
|
resolved to `gnome-libsecret` (Secret Service) and tried to unlock the login
|
||||||
|
keyring to hold its encryption key. Flags alone cannot fix this.
|
||||||
|
* **Fix:**
|
||||||
|
* New `launch_env()` hook on the base adapter (returns `None` = inherit);
|
||||||
|
`LinuxChromiumAdapter` returns `_browser_env()`, which strips
|
||||||
|
`DBUS_SESSION_BUS_ADDRESS`/`DBUS_SESSION_BUS_PID`, empties
|
||||||
|
`GNOME_KEYRING_CONTROL` and sets `CHROME_PASSWORD_STORE=basic`. Scoped to the
|
||||||
|
**child** — the player keeps its real session bus.
|
||||||
|
* Every flag list is now referenced from `extra_launch_args()`, and
|
||||||
|
`linux/test_linux_browser_flags.py` fails if any list becomes dead code.
|
||||||
|
* Added `--disable-save-password-bubble`.
|
||||||
|
* **Verified:** the live process's `/proc/<pid>/environ` contains no
|
||||||
|
`DBUS_SESSION_BUS_ADDRESS`, and Chromium logs
|
||||||
|
`dbus/bus.cc:405] Failed to connect to the bus` — i.e. it structurally cannot
|
||||||
|
reach the keyring. No prompt appears.
|
||||||
|
|
||||||
|
### [PI-014] `--ozone-platform-hint=auto` aborts on Chromium 152
|
||||||
|
|
||||||
|
* **Symptom:** no weblink ever displayed under labwc.
|
||||||
|
* **Root cause:** measured, the hint flag does **not** fall back to Wayland when
|
||||||
|
there is no X server — it simply fails:
|
||||||
|
```
|
||||||
|
(no flag) -> "Missing X server or $DISPLAY", aborts
|
||||||
|
--ozone-platform-hint=auto -> "Missing X server or $DISPLAY", aborts
|
||||||
|
--ozone-platform=wayland -> starts
|
||||||
|
```
|
||||||
|
* **Fix:** the platform is chosen explicitly from the detected session
|
||||||
|
(`--ozone-platform=wayland`), with `WAYLAND_FLAGS` omitted entirely when there
|
||||||
|
is no Wayland socket so Chromium uses its own X11 default.
|
||||||
|
|
||||||
|
### [PI-015] Process-group teardown could never run
|
||||||
|
|
||||||
|
* **Symptom:** every weblink risked leaving Chromium's GPU/zygote/renderer
|
||||||
|
children behind (the Linux twin of the Windows leak).
|
||||||
|
* **Root cause:** `linux_browser.teardown()` calls `os.killpg`, but it only did
|
||||||
|
so when `self._pgid` was a **different** group — and because the base
|
||||||
|
`Popen(args)` had no `start_new_session=True`, the browser shared the player's
|
||||||
|
own process group. Killing that group would have killed the player itself, so
|
||||||
|
the code correctly refused and fell back to `terminate()`, orphaning children.
|
||||||
|
* **Fix:** new `start_new_session()` hook; the Linux adapter returns `True`.
|
||||||
|
`test_linux_browser_flags.py` asserts the live process is its own group leader.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎬 Video normalisation — 4K cannot play on a Pi 4
|
||||||
|
|
||||||
|
### [PI-016] A 4K video shows one frozen frame instead of playing
|
||||||
|
|
||||||
|
* **Symptom:** the new 4K sample (`16118765_3840_2160_30fps.mp4`, 3840×2160
|
||||||
|
H.264 High@5.1) displayed a still frame while the playlist timer ticked on.
|
||||||
|
The 1080p sample played perfectly.
|
||||||
|
* **Root cause:** ffpyplayer decodes in **software** — there is no hardware
|
||||||
|
H.264 *decode* wired into its pipeline. Measured on this Pi 4:
|
||||||
|
|
||||||
|
| File | Resolution | Software decode speed |
|
||||||
|
|------|-----------|----------------------|
|
||||||
|
| `sample-30s.mp4` | 1920×1080 | **3.03× realtime** ✅ |
|
||||||
|
| `16118765_3840_2160_30fps.mp4` | 3840×2160 | **0.90× realtime** ❌ |
|
||||||
|
|
||||||
|
Below 1× realtime the decoder can never feed the display, so the picture
|
||||||
|
effectively stops. The player *did* advance correctly at the 19 s duration —
|
||||||
|
there was never a hang, just a video that cannot be rendered in time.
|
||||||
|
* **Important:** the file plays fine in an *isolated* Kivy probe (position
|
||||||
|
advances in realtime). The stall only appears under the real player, where
|
||||||
|
decode competes with rendering, the weblink browser and the GUI. Do not
|
||||||
|
conclude "the file is fine" from a standalone probe.
|
||||||
|
|
||||||
|
### The fix: `linux/video_normalizer.py`
|
||||||
|
|
||||||
|
Oversized media is downscaled to at most **1920×1080** once, at sync time.
|
||||||
|
|
||||||
|
* **Triggered by resolution only** — `width > 1920 or height > 1080`. A file
|
||||||
|
already within the limit is left byte-identical, so nothing that already
|
||||||
|
plays is ever re-encoded.
|
||||||
|
* **Hardware encoding** via `h264_v4l2m2m` (verified working — the Pi 4's
|
||||||
|
H.264 *encoder* is a separate block from its decoder), with `libx264` as the
|
||||||
|
fallback. Measured: **31 s** for an 18 s 4K clip.
|
||||||
|
* **Audio preserved** (`-c:a copy`, AAC fallback). This matters: a *silent*
|
||||||
|
video hits the separate SDL2_mixer bug documented in `_video_has_audio`, so
|
||||||
|
the normaliser must not create one.
|
||||||
|
* Output lands next to the source as `<stem>_kiwy1080p.mp4` plus a
|
||||||
|
`.kiwy-normalized.json` metadata file.
|
||||||
|
|
||||||
|
### Skip-while-converting, loop-the-intro-if-nothing-else
|
||||||
|
|
||||||
|
The conversion is asynchronous, so the playlist can reach the item before it is
|
||||||
|
ready. `src/media_state.py` owns one on-disk contract shared by the player and
|
||||||
|
the normaliser:
|
||||||
|
|
||||||
|
| Marker | Meaning |
|
||||||
|
|--------|---------|
|
||||||
|
| `<media>.kiwy-converting` | conversion in flight → **skip this item** |
|
||||||
|
| `<media>_kiwy1080p.mp4` + `.kiwy-normalized.json` | done → **play this file instead** |
|
||||||
|
|
||||||
|
Player behaviour per item:
|
||||||
|
|
||||||
|
* **ready** — play it (the converted file when one exists, else the original)
|
||||||
|
* **converting** — skip the lap immediately, no dwell delay
|
||||||
|
* **pending** — oversized and not converted: skip, and request conversion
|
||||||
|
|
||||||
|
Skipped items are advanced with `_advance_without_wait()` rather than occupying
|
||||||
|
their configured duration, so a 19 s slot that cannot be shown does not add 19 s
|
||||||
|
of nothing to every lap.
|
||||||
|
|
||||||
|
**When nothing at all is playable** (e.g. a single-item playlist that is one 4K
|
||||||
|
video still converting), the player loops `config/resources/intro1.mp4`
|
||||||
|
(`eos: loop`) and polls every 3 s. The moment a converted item appears it stops
|
||||||
|
the loop and restarts the playlist **from index 0** — so a one-item playlist
|
||||||
|
gets a clean full run rather than joining mid-clip. A blank screen is never left
|
||||||
|
on a signage display.
|
||||||
|
|
||||||
|
### Traps found while building this
|
||||||
|
|
||||||
|
1. **`resolve_playable()` initially skipped every normal video.** It checked for
|
||||||
|
a conversion output and a marker but never whether the file was oversized at
|
||||||
|
all, so a plain 1080p file (no output, no marker) fell through to
|
||||||
|
`pending`. The test caught it before it shipped: *skip-the-whole-playlist* is
|
||||||
|
a far worse failure than the freeze it was meant to fix.
|
||||||
|
2. **The media cleanup would have deleted the converted file.**
|
||||||
|
`delete_unused_media()` prunes anything not named in the playlist — and
|
||||||
|
`_kiwy1080p.mp4` is, by design, not named there. It is now explicitly
|
||||||
|
protected, along with both marker types.
|
||||||
|
3. **ffmpeg cannot infer the muxer from a `.part` temp name** ("Unable to choose
|
||||||
|
an output format"). The temp file keeps its real extension.
|
||||||
|
4. **Metadata is written last.** Its presence is what marks a conversion
|
||||||
|
complete, so it must never exist for a half-written output.
|
||||||
|
5. **A stale marker must not park an item forever** — markers older than 30 min
|
||||||
|
are ignored, so a crash mid-conversion cannot make a video unplayable.
|
||||||
|
|
||||||
|
### Verified end to end (real server, real playlist)
|
||||||
|
|
||||||
|
```
|
||||||
|
20:33:05 sync detects 3840x2160 -> starts the background conversion
|
||||||
|
20:33:36 conversion completes (31s) -> _kiwy1080p.mp4 + metadata
|
||||||
|
20:36:01 video_using_normalized source=...3840_2160... normalized=..._kiwy1080p.mp4
|
||||||
|
20:36:01 starting_video path=..._kiwy1080p.mp4
|
||||||
|
20:36:20 next_media_called was_index=7 (played 18.7s, advanced on EOS)
|
||||||
|
```
|
||||||
|
|
||||||
|
Suites: `test_media_state.py` 18/18 · `test_linux_patches.py` 21/21 ·
|
||||||
|
`test_linux_browser_flags.py` 27/27.
|
||||||
|
|
||||||
|
**Recommendation for the server side:** normalising the source to 1920×1080
|
||||||
|
before upload is still better — it avoids the 4K download *and* the 31 s
|
||||||
|
conversion. This player-side path exists so that an oversized upload degrades
|
||||||
|
gracefully instead of breaking the screen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🟡 Chromium footprint — measured, not assumed
|
||||||
|
|
||||||
|
The Pi 4 has 3.8 GB total and the desktop already uses ~1.9 GB, so a weblink
|
||||||
|
needs to be lightweight. Numbers below are **PSS** summed over the browser tree
|
||||||
|
for the real page (`https://moto-adv.com/`), via
|
||||||
|
`linux/_probe_chromium_footprint.py`.
|
||||||
|
|
||||||
|
| Profile | Processes | PSS |
|
||||||
|
|---------|-----------|-----|
|
||||||
|
| `safe` (no footprint flags) | 10 | 513 MB |
|
||||||
|
| **`light` (default)** | **9** | **507 MB** |
|
||||||
|
| `minimal` (`--single-process`) | 4 | 438 MB |
|
||||||
|
|
||||||
|
**Honest conclusion: flag tuning buys very little.** Chromium's baseline is
|
||||||
|
simply ~500 MB and the remaining levers are single-digit percentages.
|
||||||
|
`minimal` saves ~15% but halves the process count, and upstream labels
|
||||||
|
`--single-process` unsupported, so it stays opt-in.
|
||||||
|
|
||||||
|
**Two measurement traps worth remembering:**
|
||||||
|
|
||||||
|
1. **RSS is the wrong metric.** Chromium shares libraries and file-backed pages
|
||||||
|
across processes; summing RSS double-counts and produced numbers ~2x too high
|
||||||
|
— it even ranked a *smaller* configuration as *larger* (1132 MB safe vs
|
||||||
|
1529 MB light). Use `Pmi/smaps_rollup`.
|
||||||
|
2. **`--disable-gpu` makes it worse.** It looks like an obvious win for a static
|
||||||
|
page, but it moves rasterization out of the GPU process into the renderer:
|
||||||
|
**1038 MB vs 513 MB**. It is deliberately absent from every list.
|
||||||
|
|
||||||
|
Use `KIWY_CHROMIUM_MODE=light|minimal|safe` to select a profile. If memory is
|
||||||
|
genuinely the constraint, the structural fix is an **embedded** engine rendering
|
||||||
|
inside the Kivy window (WebKitGTK, available on aarch64) — not more flags.
|
||||||
|
|
||||||
|
> **Ultralight is not an option on this hardware.** Despite the vendor's site
|
||||||
|
> claiming "Linux (x64 / arm64)", enumerating the entire SDK bucket returns only
|
||||||
|
> `mac-x64, linux-x64, win-x64, win-uwp-x64, win-x86`;
|
||||||
|
> `ultralight-sdk-latest-linux-arm64.7z` is a **404**. There is no ARM64 build of
|
||||||
|
> any kind, and it is closed-source C++ with no Python binding.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔴 The five bugs that actually prevented the port
|
||||||
|
|
||||||
|
### [PI-001] Kivy's bundled SDL2 has NO Wayland driver — **no window at all**
|
||||||
|
|
||||||
|
* **Symptom:**
|
||||||
|
```
|
||||||
|
[CRITICAL] Unable to find any valuable Window provider.
|
||||||
|
sdl2 - RuntimeError: b'wayland,x11,dummy not available'
|
||||||
|
[CRITICAL] SignagePlayerApp: Window is None - display server not available
|
||||||
|
```
|
||||||
|
* **Root cause:** Kivy's PyPI wheel ships a **private SDL2** in `Kivy.libs/`
|
||||||
|
(name has a hash: `libSDL2-2-87637523.0.so.0.3000.7`). Driver enumeration
|
||||||
|
proved it is built **without Wayland**:
|
||||||
|
|
||||||
|
| Library | Video drivers |
|
||||||
|
|---------|---------------|
|
||||||
|
| `Kivy.libs/libSDL2-2-*.so` (bundled) | `x11, KMSDRM, offscreen, dummy, evdev` |
|
||||||
|
| `/usr/lib/aarch64-linux-gnu/libSDL2-2.0.so.0` (system) | `x11, **wayland**, KMSDRM, offscreen, dummy, evdev` |
|
||||||
|
|
||||||
|
Trixie runs a Wayland session and there is no X server, so the bundled build
|
||||||
|
cannot create a window. Note the driver *name* does not change: the bundled
|
||||||
|
SDL2 resolves `b'wayland'` as "driver unavailable", not "unknown driver".
|
||||||
|
* **Fix:** `linux/fix_kivy_sdl2.sh` symlinks the system SDL2 over the bundled
|
||||||
|
filename (idempotent, reversible, survives only until the next
|
||||||
|
`pip install --upgrade kivy`).
|
||||||
|
* **Verified:** `WINDOW OK size=(800, 600)`
|
||||||
|
* **Rejected alternatives:**
|
||||||
|
* ❌ Editing `~/.kivy/config.ini` — irrelevant, the provider never loads.
|
||||||
|
* ❌ `KIVY_WINDOW=...` variations — the library lacks the driver, full stop.
|
||||||
|
* *(Still untested)* apt `python3-kivy` likely bundles no SDL2 and would
|
||||||
|
sidestep this entirely — worth evaluating for the installer.
|
||||||
|
|
||||||
|
### [PI-002] SDL2 requires `WAYLAND_DISPLAY` — the socket alone is NOT enough
|
||||||
|
|
||||||
|
* **Symptom:** window creation fails when the player is launched from systemd,
|
||||||
|
cron, an autostart entry or SSH.
|
||||||
|
* **Root cause:** those contexts set `XDG_RUNTIME_DIR` but leave
|
||||||
|
`WAYLAND_DISPLAY` **empty** (the compositor only exports it inside the desktop
|
||||||
|
session). Measured:
|
||||||
|
|
||||||
|
| `WAYLAND_DISPLAY` | result |
|
||||||
|
|-------------------|--------|
|
||||||
|
| unset | `sdl2 - RuntimeError: b'wayland not available'` |
|
||||||
|
| `wayland-0` | `WINDOW OK (800, 600)` |
|
||||||
|
|
||||||
|
Notably `wlopm` **does** fall back to scanning `XDG_RUNTIME_DIR`, but SDL2
|
||||||
|
does not — so this cannot be inferred from the display tools working.
|
||||||
|
* **Fix:** `linux_display.ensure_session_environment()` detects the socket,
|
||||||
|
derives the name from its filename (so `wayland-1` works) and exports it.
|
||||||
|
Called at the very top of `run_linux.py`, before Kivy is imported.
|
||||||
|
* **Verified:** `session environment filled in: {'WAYLAND_DISPLAY': 'wayland-0'}`
|
||||||
|
|
||||||
|
### [PI-003] Kivy `WeakMethod` name trap — crash ~20 s AFTER startup
|
||||||
|
|
||||||
|
* **Symptom:** the app starts fine, then dies on the first Clock tick:
|
||||||
|
```
|
||||||
|
[CRITICAL] Fatal error: 'SignagePlayer' object has no attribute 'linux_screen_activity'
|
||||||
|
[CRITICAL] Fatal error: 'SignagePlayer' object has no attribute '_noop'
|
||||||
|
```
|
||||||
|
* **Root cause:** Kivy's `Clock` stores a callback's `__func__.__name__` in a
|
||||||
|
`WeakMethod` and later resolves it with `getattr(instance, that_name)`. A
|
||||||
|
replacement assigned under a *different* name than the function was defined
|
||||||
|
with raises `AttributeError` — but only when the Clock next fires, so the
|
||||||
|
traceback points nowhere near the patch.
|
||||||
|
Two variants bit us: a name mismatch (`linux_screen_activity` vs
|
||||||
|
`signal_screen_activity`) and a shared helper (`_noop` used for three
|
||||||
|
different methods).
|
||||||
|
* **Fix:** `_bind_name()` sets `__name__`/`__qualname__` on each replacement,
|
||||||
|
and the focus no-ops are generated per-attribute rather than shared.
|
||||||
|
* **Verified:** `linux/test_linux_patches.py` asserts that
|
||||||
|
`getattr(SignagePlayer, func.__name__)` resolves *and* returns the patched
|
||||||
|
function — for every replaced method.
|
||||||
|
* This is the same trap that used to be documented in the Windows port; the
|
||||||
|
lesson is now enforced by a test instead of by a comment.
|
||||||
|
|
||||||
|
### [PI-004] The inherited `signal_screen_activity()` never kept the screen on
|
||||||
|
|
||||||
|
* **Symptom:** the panel blanks after the 10-minute idle timeout.
|
||||||
|
* **Root causes (three compounding):**
|
||||||
|
1. `swayidle -w timeout 600 'wlopm --off *'` runs from
|
||||||
|
`~/.config/labwc/autostart` — measured: PID 1431, confirmed running.
|
||||||
|
2. The handler shells out to `tvservice`, `xdotool`, `ydotool` — **all three
|
||||||
|
are absent on Trixie** (`tvservice` is gone with the legacy firmware
|
||||||
|
stack; `xdotool`/`ydotool` are not installed).
|
||||||
|
3. It passed a shell-escaped `wlopm --on \*`, so the compositor matched an
|
||||||
|
output literally named `*` and did nothing.
|
||||||
|
* **Fix:** `linux/linux_display.py` — real `wlopm --on '*'` (list argv, no
|
||||||
|
shell), `vcgencmd display_power 1` as a firmware backstop, X11 fallbacks for
|
||||||
|
non-Wayland sessions, and `neutralise_idle_blanker()` to stop `swayidle`.
|
||||||
|
* **Verified:** `keep_display_awake` → `True`; `swayidle` killed;
|
||||||
|
`wlopm` reports `HDMI-A-1 on`.
|
||||||
|
|
||||||
|
### [PI-005] Launcher scripts bypassed the platform layer entirely
|
||||||
|
|
||||||
|
* **Symptom:** the player runs but blanks, shows no web links, and any fix to
|
||||||
|
the platform layer has no effect.
|
||||||
|
* **Root cause:** `start.sh` did `cd src && python3 main.py`, and
|
||||||
|
`run_player.sh` did the same. `src/main.py` is a *module* of the shared core,
|
||||||
|
not the Pi entry point — running it directly skips every patch in
|
||||||
|
`linux/run_linux.py`. Additionally `src/` is not the data directory, so
|
||||||
|
`base_dir` resolved one level too high.
|
||||||
|
* **Fix:** both scripts now run `.venv/bin/python linux/run_linux.py` from the
|
||||||
|
project root. `check_player_status.sh` / `stop_player.sh` match
|
||||||
|
`run_linux.py` instead of `python3 main.py` (they would otherwise never find
|
||||||
|
the process).
|
||||||
|
* **Verified:** `bash run_player.sh` runs clean; `bash -n` passes on all four.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🟡 Also fixed (real but non-blocking)
|
||||||
|
|
||||||
|
| # | Issue | Fix |
|
||||||
|
|---|-------|-----|
|
||||||
|
| [PI-006] | `main.py` set `SDL_AUDIODRIVER` **twice**; the second (`'alsa'`) was a silent no-op because `setdefault` never overwrites — it obscured which driver was live | Removed the duplicate, documented why |
|
||||||
|
| [PI-007] | `SettingsPopup.test_connection` hard-coded `/tmp/temp_auth_test.json` | Uses `tempfile.gettempdir()`; needed `import tempfile` added to `main.py`. Files with credentials are now always removed |
|
||||||
|
| [PI-008] | `player_auth.json` resolved against the *cwd*, so systemd/autostart launches "forgot" authentication and re-registered every start | `run_linux.py` pins it to an absolute path in the data dir |
|
||||||
|
| [PI-009] | `_bring_window_to_front_nonblocking` / focus keeper are Win32-only; on Wayland they ran every 0.5 s and logged "focus lost" forever | No-oped on Linux (`KIWY_FOCUS_KEEPER=1` restores) |
|
||||||
|
| [PI-010] | Clean shutdown (SIGTERM from the watchdog) wrote a bogus `FATAL: 0` crash log | `SystemExit(0)` is now a clean exit |
|
||||||
|
| [PI-011] | Web-link hand-off: without a dedicated `--user-data-dir` Chromium delegates the URL to an existing instance and exits in ~2 s | `LinuxChromiumAdapter` always uses a private profile, and clears stale `SingletonLock` |
|
||||||
|
| [PI-012] | `proc.terminate()` left Chromium's GPU/zygote/renderer children running (Leak → OOM over 24/7) | Kill the whole process group (`os.killpg`), the Linux twin of `taskkill /T` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🧪 Tested & Rejected Solutions Log
|
||||||
|
|
||||||
|
| Date | What was tested | Result | Reason it failed |
|
||||||
|
|------|----------------|--------|-----------------|
|
||||||
|
| 2026-09-13 | `SDL_VIDEODRIVER=wayland` with the bundled SDL2 | ❌ | Bundled SDL2 has no wayland driver |
|
||||||
|
| 2026-09-13 | `SDL_VIDEODRIVER=x11` (+ XWayland present) | ❌ | No X server running on labwc; `x11 not available` |
|
||||||
|
| 2026-09-13 | `KIVY_GL_BACKEND=gles` vs `gl` | ➖ | Both equivalent; the failure was the driver, not GL |
|
||||||
|
| 2026-09-13 | Comma-separated `SDL_VIDEODRIVER` list | ✅ | Works — Kivy splits on `,` and SDL2 takes the first that initialises |
|
||||||
|
| 2026-09-13 | Relying on the Wayland socket without `WAYLAND_DISPLAY` | ❌ | SDL2 does not scan `XDG_RUNTIME_DIR` (unlike `wlopm`) |
|
||||||
|
| 2026-09-13 | System SDL2 symlinked over the bundled name | ✅ | Full driver set incl. wayland — **the fix** |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🧰 Environment / dependency facts
|
||||||
|
|
||||||
|
* **Must exist before the player will start:**
|
||||||
|
* `libsdl2-2.0-0` (system SDL2 **with** wayland)
|
||||||
|
* `libgl1-mesa-dri`, `libgles2`
|
||||||
|
* a running Wayland session (labwc) and a valid `XDG_RUNTIME_DIR`
|
||||||
|
* **Python packages** (all cp313 aarch64 wheels exist **except** evdev):
|
||||||
|
|
||||||
|
| Package | Source | Note |
|
||||||
|
|---------|--------|------|
|
||||||
|
| kivy 2.3.1 | PyPI wheel | needs `fix_kivy_sdl2.sh` on Trixie |
|
||||||
|
| ffpyplayer 4.5.3 | PyPI wheel | video/audio backend |
|
||||||
|
| aiohttp, requests, bcrypt | PyPI wheel / apt | already installed system-wide |
|
||||||
|
| **evdev 2.0.0** | **sdist only** | `python3-dev` + `build-essential` required, or use apt `python3-evdev` |
|
||||||
|
|
||||||
|
* **Tools present:** `wlopm`, `wlr-randr`, `vcgencmd`, `swayidle`, `chromium`,
|
||||||
|
`ffprobe`, `labwc`, `systemd-inhibit`, `zenity`
|
||||||
|
* **Tools MISSING:** `tvservice`, `xdotool`, `ydotool`, `chromium-browser`,
|
||||||
|
`glxinfo`, `ifconfig`, `dhclient` (last two matter for WiFi restart — Phase 3)
|
||||||
|
* **Networking:** NetworkManager is active; `dhcpcd`/`ifupdown` are not
|
||||||
|
* **`sudo` requires a password** → every boot-time `sudo` call is a hang risk
|
||||||
|
(see Phase 4 / `start.sh` `configure_display_resolution()`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🧪 How to verify
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/pi/Desktop/Kiwy-Signage
|
||||||
|
|
||||||
|
# 1. SDL2 has the wayland driver (the #1 blocker)
|
||||||
|
bash linux/fix_kivy_sdl2.sh --check
|
||||||
|
|
||||||
|
# 2. Platform patches wired correctly (WeakMethod trap, keep-awake, SDL2)
|
||||||
|
.venv/bin/python linux/test_linux_patches.py # expect: 21/21 passed
|
||||||
|
|
||||||
|
# 3. Keyring bypass + footprint flags actually applied
|
||||||
|
.venv/bin/python linux/test_linux_browser_flags.py # expect: 27/27 passed
|
||||||
|
|
||||||
|
# 4. Video decode + playback
|
||||||
|
.venv/bin/python linux/_probe_video.py # expect: position advances
|
||||||
|
|
||||||
|
# 5. Chromium footprint per profile (PSS, real page)
|
||||||
|
.venv/bin/python linux/_probe_chromium_footprint.py https://moto-adv.com/
|
||||||
|
|
||||||
|
# 6. The player itself
|
||||||
|
bash run_player.sh # or: bash start.sh (watchdog)
|
||||||
|
|
||||||
|
# 7. Diagnostics
|
||||||
|
.venv/bin/python linux/linux_display.py # backend/outputs/tools
|
||||||
|
bash linux/fix_kivy_sdl2.sh --revert # undo the SDL2 symlink
|
||||||
|
```
|
||||||
|
|
||||||
|
Useful escape hatches:
|
||||||
|
|
||||||
|
| Variable | Effect |
|
||||||
|
|----------|--------|
|
||||||
|
| `KIWY_DISPLAY_TOOLS_DISABLED=1` | Disable all `wlopm`/`vcgencmd`/`swayidle` work |
|
||||||
|
| `KIWY_CHROMIUM_MODE=light\|minimal\|safe` | Chromium footprint profile (default `light`) |
|
||||||
|
| `KIWY_VENV=/path` | Point the SDL2 fix script at another virtualenv |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📝 Next session
|
||||||
|
|
||||||
|
* [ ] **Phase 2** — web links: verify Chromium kiosk on labwc end-to-end
|
||||||
|
(fullscreen, no desktop flash, no leaked processes, interaction postpones)
|
||||||
|
* [ ] Evaluate **apt `python3-kivy`** — it bundles no SDL2 and may remove the
|
||||||
|
need for `fix_kivy_sdl2.sh` entirely
|
||||||
|
* [ ] **Phase 4** — autostart via `~/.config/labwc/autostart` +
|
||||||
|
systemd unit; repair `start.sh`'s `configure_display_resolution()`
|
||||||
|
(wrong `/boot/config.txt` path on Trixie → `/boot/firmware/config.txt`)
|
||||||
|
* [ ] **Phase 3** — card reader (evdev/udev) and WiFi restart via `nmcli`
|
||||||
|
(the current `ifconfig`/`dhclient` path depends on packages that are gone)
|
||||||
|
* [ ] Orientation/rotation via `wlr-randr` (implemented, untested on hardware)
|
||||||
|
* [ ] 24/7 soak test with real playlist content
|
||||||
|
* [ ] Confirm the `dist/` deployment hazard documented for Windows has no
|
||||||
|
Linux equivalent (it does not — there is no bundle on Linux)
|
||||||
Executable
+166
@@ -0,0 +1,166 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# fix_kivy_sdl2.sh — make Kivy use the system SDL2 (Wayland-capable).
|
||||||
|
#
|
||||||
|
# THE PROBLEM
|
||||||
|
# -----------
|
||||||
|
# Kivy's PyPI wheel bundles a private SDL2 in ``Kivy.libs/`` that is compiled
|
||||||
|
# WITHOUT the Wayland video driver. Verified on this Pi:
|
||||||
|
#
|
||||||
|
# Kivy.libs/libSDL2-2-87637523.0.so.0.3000.7 -> x11, KMSDRM, offscreen, dummy, evdev
|
||||||
|
# /usr/lib/aarch64-linux-gnu/libSDL2-2.0.so.0 -> x11, wayland, KMSDRM, offscreen, dummy, evdev
|
||||||
|
#
|
||||||
|
# On Raspberry Pi OS "Trixie" the desktop is Wayland/labwc and there is no
|
||||||
|
# X server running, so the bundled build cannot create a window at all:
|
||||||
|
#
|
||||||
|
# [CRITICAL] Unable to find any valuable Window provider.
|
||||||
|
# sdl2 - RuntimeError: b'wayland,x11,dummy not available'
|
||||||
|
#
|
||||||
|
# Substituting the system library under Kivy's bundled filename resolves it
|
||||||
|
# (verified: "WINDOW OK size=(800, 600)").
|
||||||
|
#
|
||||||
|
# WHY A SYMLINK AND NOT A COPY
|
||||||
|
# ----------------------------
|
||||||
|
# The symlink survives ``pip install --upgrade kivy`` overwriting the file, is
|
||||||
|
# reversible, and keeps the distro's security updates in effect. A copy would
|
||||||
|
# silently become stale.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# bash linux/fix_kivy_sdl2.sh # apply (idempotent)
|
||||||
|
# bash linux/fix_kivy_sdl2.sh --check # report only, change nothing
|
||||||
|
# bash linux/fix_kivy_sdl2.sh --revert # remove the symlink
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||||
|
VENV_DIR="${KIWY_VENV:-$ROOT_DIR/.venv}"
|
||||||
|
BACKUP_DIR="$VENV_DIR/.kivy-sdl2-backup"
|
||||||
|
SYSTEM_SDL2="/usr/lib/aarch64-linux-gnu/libSDL2-2.0.so.0"
|
||||||
|
|
||||||
|
MODE="apply"
|
||||||
|
case "${1:-}" in
|
||||||
|
--check) MODE="check" ;;
|
||||||
|
--revert) MODE="revert" ;;
|
||||||
|
"") MODE="apply" ;;
|
||||||
|
*) echo "Unknown option: $1" >&2; exit 2 ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
if [ ! -d "$VENV_DIR" ]; then
|
||||||
|
echo "ERROR: virtualenv not found at $VENV_DIR" >&2
|
||||||
|
echo " Set KIWY_VENV=/path/to/venv if it lives elsewhere." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
KIVY_LIBS="$(find "$VENV_DIR" -maxdepth 5 -type d -name 'Kivy.libs' 2>/dev/null | head -1)"
|
||||||
|
if [ -z "$KIVY_LIBS" ]; then
|
||||||
|
echo "No Kivy.libs directory found under $VENV_DIR — nothing to do."
|
||||||
|
echo "(Kivy is probably installed from apt, which uses the system SDL2 already.)"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Kivy names the bundled library with a hash, so locate it by pattern rather
|
||||||
|
# than hard-coding the version. Symlinks must be matched too: once the fix is
|
||||||
|
# applied the original file IS a symlink, and skipping it would make this
|
||||||
|
# script wrongly conclude that nothing is bundled.
|
||||||
|
BUNDLED="$(find "$KIVY_LIBS" -maxdepth 1 -name 'libSDL2-2-*.so*' 2>/dev/null | head -1)"
|
||||||
|
|
||||||
|
# Fallback: ask the compiled extension which SDL2 it actually loads. This is
|
||||||
|
# authoritative and also covers a future Kivy layout change.
|
||||||
|
if [ -z "$BUNDLED" ]; then
|
||||||
|
EXT="$(find "$VENV_DIR" -maxdepth 6 -name '_window_sdl2*.so' 2>/dev/null | head -1)"
|
||||||
|
if [ -n "$EXT" ]; then
|
||||||
|
NEEDED="$(ldd "$EXT" 2>/dev/null | awk '/libSDL2-2-/ {print $3; exit}')"
|
||||||
|
[ -n "$NEEDED" ] && BUNDLED="$NEEDED"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Kivy.libs : $KIVY_LIBS"
|
||||||
|
echo "bundled : ${BUNDLED:-<none>}"
|
||||||
|
echo "system : $SYSTEM_SDL2"
|
||||||
|
|
||||||
|
if [ ! -e "$SYSTEM_SDL2" ]; then
|
||||||
|
echo "ERROR: system SDL2 not found at $SYSTEM_SDL2" >&2
|
||||||
|
echo " Install it with: sudo apt install libsdl2-2.0-0" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Report the video drivers compiled into a library, so the fix is verifiable.
|
||||||
|
report_drivers() {
|
||||||
|
local lib="$1"
|
||||||
|
local py="${VENV_DIR}/bin/python"
|
||||||
|
[ -x "$py" ] || py="$(command -v python3)"
|
||||||
|
"$py" - "$lib" <<'PY' 2>/dev/null || echo " (could not inspect drivers)"
|
||||||
|
import ctypes, sys
|
||||||
|
try:
|
||||||
|
s = ctypes.CDLL(sys.argv[1])
|
||||||
|
s.SDL_GetNumVideoDrivers.restype = ctypes.c_int
|
||||||
|
s.SDL_GetVideoDriver.restype = ctypes.c_char_p
|
||||||
|
s.SDL_GetVideoDriver.argtypes = [ctypes.c_int]
|
||||||
|
n = s.SDL_GetNumVideoDrivers()
|
||||||
|
print(' drivers:', ', '.join(s.SDL_GetVideoDriver(i).decode() for i in range(n)))
|
||||||
|
except Exception as exc:
|
||||||
|
print(' (inspection failed:', exc, ')')
|
||||||
|
PY
|
||||||
|
}
|
||||||
|
|
||||||
|
case "$MODE" in
|
||||||
|
revert)
|
||||||
|
if [ -L "$BUNDLED" ]; then
|
||||||
|
rm -f "$BUNDLED"
|
||||||
|
if [ -f "$BACKUP_DIR/$(basename "$BUNDLED")" ]; then
|
||||||
|
cp -a "$BACKUP_DIR/$(basename "$BUNDLED")" "$BUNDLED"
|
||||||
|
echo "Reverted to the original bundled SDL2."
|
||||||
|
else
|
||||||
|
echo "Removed the symlink. Reinstall Kivy to restore the bundled library:"
|
||||||
|
echo " $VENV_DIR/bin/pip install --force-reinstall kivy"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "Nothing to revert (no symlink in place)."
|
||||||
|
fi
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
|
||||||
|
check)
|
||||||
|
echo
|
||||||
|
if [ -L "$BUNDLED" ]; then
|
||||||
|
echo "STATUS: fixed (bundled name points at the system SDL2)"
|
||||||
|
report_drivers "$BUNDLED"
|
||||||
|
else
|
||||||
|
echo "STATUS: NOT fixed — Kivy is using its own SDL2"
|
||||||
|
report_drivers "$BUNDLED"
|
||||||
|
fi
|
||||||
|
echo
|
||||||
|
echo "system SDL2:"
|
||||||
|
report_drivers "$SYSTEM_SDL2"
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
|
||||||
|
apply)
|
||||||
|
if [ -z "$BUNDLED" ]; then
|
||||||
|
echo "No bundled libSDL2 found — Kivy already uses the system SDL2."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -L "$BUNDLED" ]; then
|
||||||
|
echo "Already fixed (symlink in place). Verifying..."
|
||||||
|
report_drivers "$BUNDLED"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Keep a copy of the original so --revert works without a reinstall.
|
||||||
|
mkdir -p "$BACKUP_DIR"
|
||||||
|
if [ ! -f "$BACKUP_DIR/$(basename "$BUNDLED")" ]; then
|
||||||
|
cp -a "$BUNDLED" "$BACKUP_DIR/"
|
||||||
|
echo "Backed up original -> $BACKUP_DIR/$(basename "$BUNDLED")"
|
||||||
|
fi
|
||||||
|
|
||||||
|
ln -sf "$SYSTEM_SDL2" "$BUNDLED"
|
||||||
|
echo "Symlinked $BUNDLED -> $SYSTEM_SDL2"
|
||||||
|
echo
|
||||||
|
echo "Resulting drivers:"
|
||||||
|
report_drivers "$BUNDLED"
|
||||||
|
echo
|
||||||
|
echo "Done. Kivy can now create a Wayland window."
|
||||||
|
echo "NOTE: re-run this script after any 'pip install --upgrade kivy'."
|
||||||
|
;;
|
||||||
|
esac
|
||||||
@@ -0,0 +1,603 @@
|
|||||||
|
"""linux_browser.py — Chromium/Chrome kiosk adapter for Raspberry Pi (Wayland).
|
||||||
|
|
||||||
|
Why this module exists
|
||||||
|
----------------------
|
||||||
|
``src/weblink_session.py`` already owns the whole web-link lifecycle (launch →
|
||||||
|
verified visibility → interaction watching → teardown) and ships a generic
|
||||||
|
``ChromiumSubprocessAdapter`` that is the default on Linux. That adapter is
|
||||||
|
correct in structure but was never tuned for Raspberry Pi OS Trixie, where:
|
||||||
|
|
||||||
|
* the session is **Wayland/labwc**, so Chromium needs an explicit Ozone
|
||||||
|
platform or it may come up as an X11 (XWayland) surface that the compositor
|
||||||
|
will not make fullscreen;
|
||||||
|
* Chromium is ``/usr/bin/chromium`` (there is no ``chromium-browser``);
|
||||||
|
* ``--start-maximized`` is not what makes a window fullscreen on labwc;
|
||||||
|
``--kiosk`` is;
|
||||||
|
* without a dedicated ``--user-data-dir`` Chromium hands the URL to an
|
||||||
|
already-running instance, the process we launched exits in ~2 s, and the
|
||||||
|
session's ``wait_visible`` reports the item as failed;
|
||||||
|
* ``proc.terminate()`` only kills the parent; Chromium's GPU/zygote/renderer
|
||||||
|
children survive and accumulate over a 24/7 playlist.
|
||||||
|
|
||||||
|
This adapter is the platform counterpart of the generic
|
||||||
|
``ChromiumSubprocessAdapter`` and is injected through the existing
|
||||||
|
``SignagePlayer.weblink_adapter_factory`` hook — no changes to the shared
|
||||||
|
``play_weblink`` code path are required.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import signal
|
||||||
|
import sys
|
||||||
|
import subprocess
|
||||||
|
import time
|
||||||
|
|
||||||
|
# The shared modules live in ../src. Add it explicitly so this file can be
|
||||||
|
# imported standalone (diagnostics, tests) and not only after run_linux.py has
|
||||||
|
# already put src/ on sys.path.
|
||||||
|
_SRC_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'src')
|
||||||
|
if _SRC_DIR not in sys.path:
|
||||||
|
sys.path.insert(0, _SRC_DIR)
|
||||||
|
|
||||||
|
from weblink_session import ChromiumSubprocessAdapter # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
def _log(message, level='info'):
|
||||||
|
try:
|
||||||
|
from kivy.logger import Logger
|
||||||
|
|
||||||
|
getattr(Logger, level, Logger.info)(f'[LinuxBrowser] {message}')
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
#: Wayland flags. The platform MUST be named explicitly.
|
||||||
|
#:
|
||||||
|
#: Measured on Chromium 152 / Raspberry Pi OS Trixie (labwc, no X server):
|
||||||
|
#:
|
||||||
|
#: (no flag) -> "Missing X server or $DISPLAY", aborts
|
||||||
|
#: --ozone-platform-hint=auto -> "Missing X server or $DISPLAY", aborts
|
||||||
|
#: --ozone-platform=wayland -> starts (9 processes)
|
||||||
|
#:
|
||||||
|
#: ``--ozone-platform-hint=auto`` does NOT fall back to Wayland when there is no
|
||||||
|
#: X server, contrary to how it is usually described; it simply fails. So the
|
||||||
|
#: flag is chosen explicitly from the detected session instead of being hinted.
|
||||||
|
WAYLAND_FLAGS = [
|
||||||
|
'--ozone-platform=wayland',
|
||||||
|
'--enable-features=UseOzonePlatform,WaylandWindowDecorations',
|
||||||
|
]
|
||||||
|
|
||||||
|
#: Flags that stop Chromium asking for the **keyring** password.
|
||||||
|
#:
|
||||||
|
#: This is the prompt that appeared at start-up. On Raspberry Pi OS
|
||||||
|
#: ``gnome-keyring-daemon --components=secrets`` runs and
|
||||||
|
#: ``DBUS_SESSION_BUS_ADDRESS`` is set, so Chromium's default password-store
|
||||||
|
#: backend resolves to ``gnome-libsecret`` (Secret Service). Chromium then tries
|
||||||
|
#: to unlock the login keyring to hold its encryption key, which raises a modal
|
||||||
|
#: prompt that cannot be answered on an unattended signage screen.
|
||||||
|
#:
|
||||||
|
#: ``--password-store=basic`` forces the built-in plain store so Chromium never
|
||||||
|
#: contacts the Secret Service. ``--use-mock-keychain`` covers the equivalent
|
||||||
|
#: code path on other platforms.
|
||||||
|
#:
|
||||||
|
#: These used to sit inside ``APPLIANCE_FLAGS``, but that list was never
|
||||||
|
#: referenced from ``extra_launch_args()``, so none of them ever reached the
|
||||||
|
#: command line — the prompt looked unfixable. ``test_linux_browser_flags.py``
|
||||||
|
#: now asserts they are actually applied.
|
||||||
|
KEYRING_BYPASS_FLAGS = [
|
||||||
|
'--password-store=basic',
|
||||||
|
'--use-mock-keychain',
|
||||||
|
]
|
||||||
|
|
||||||
|
#: Flags that reduce the memory footprint on a Pi 4.
|
||||||
|
#:
|
||||||
|
#: Measured for one real page (https://moto-adv.com/), **PSS** summed over the
|
||||||
|
#: whole browser tree — see ``_probe_chromium_footprint.py``:
|
||||||
|
#:
|
||||||
|
#: default flags 10 procs ~513 MB
|
||||||
|
#: + --disable-gpu 10 procs ~1038 MB <-- WORSE, do not use
|
||||||
|
#: + this list (light) 9 procs ~507 MB
|
||||||
|
#: + --single-process (minimal) 4 procs ~438 MB (~15% less)
|
||||||
|
#:
|
||||||
|
#: Two things worth knowing, both measured rather than assumed:
|
||||||
|
#:
|
||||||
|
#: * ``--disable-gpu`` is deliberately NOT included. Intuitively it should help,
|
||||||
|
#: but it moves rasterization out of the GPU process and into the renderer,
|
||||||
|
#: which *doubled* memory for a real page.
|
||||||
|
#: * Chromium's baseline cost is simply large. The remaining ~500 MB is the
|
||||||
|
#: browser itself, so flag tuning yields only single-digit percentages.
|
||||||
|
#: ``--single-process`` is the only large lever (~15%), and it is opt-in
|
||||||
|
#: because upstream labels it unsupported.
|
||||||
|
#:
|
||||||
|
#: RSS is NOT the right metric here: Chromium shares libraries and file pages
|
||||||
|
#: across its processes, so summing RSS double-counts and produced numbers that
|
||||||
|
#: were ~2x too high and misleadingly ranked a smaller configuration as larger.
|
||||||
|
#:
|
||||||
|
#: Select a profile with KIWY_CHROMIUM_MODE=light|minimal|safe.
|
||||||
|
LIGHT_WEIGHT_FLAGS = [
|
||||||
|
# One renderer instead of a pool.
|
||||||
|
'--renderer-process-limit=1',
|
||||||
|
# Do not retain a renderer for a window that is not visible.
|
||||||
|
'--disable-backgrounding-occluded-windows',
|
||||||
|
'--disable-renderer-backgrounding',
|
||||||
|
'--disable-breakpad',
|
||||||
|
# Keep the process count down; each helper is a forked Chromium.
|
||||||
|
'--disable-site-isolation-trials',
|
||||||
|
'--disable-features=site-per-process,IsolateOrigins',
|
||||||
|
# No persisted session state to load on start.
|
||||||
|
'--no-restore-session-state',
|
||||||
|
]
|
||||||
|
|
||||||
|
#: ``--single-process`` is the one large lever (~438 MB vs ~507 MB) but Chromium
|
||||||
|
#: upstream labels the mode unsupported. Selected via
|
||||||
|
#: KIWY_CHROMIUM_MODE=minimal; verified stable over repeated launches of the
|
||||||
|
#: real weblink page.
|
||||||
|
#:
|
||||||
|
#: NOTE: ``--disable-gpu`` is intentionally absent from every list. It measured
|
||||||
|
#: WORSE (~1038 MB) and is not worth a dead constant to keep around.
|
||||||
|
MINIMAL_FLAGS = ['--single-process']
|
||||||
|
|
||||||
|
#: Everything a static page does not need. Each service is a separate process.
|
||||||
|
SERVICE_REDUCTION_FLAGS = [
|
||||||
|
'--no-service-autorun',
|
||||||
|
'--disable-component-extensions-with-background-pages',
|
||||||
|
'--disable-default-apps',
|
||||||
|
'--disable-extensions',
|
||||||
|
'--disable-plugins-discovery',
|
||||||
|
'--disable-preconnect',
|
||||||
|
'--disable-domain-reliability',
|
||||||
|
'--disable-client-side-phishing-detection',
|
||||||
|
'--disable-hang-monitor',
|
||||||
|
'--metrics-recording-only',
|
||||||
|
'--no-pings',
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _chromium_mode():
|
||||||
|
"""Footprint profile: ``light`` (default), ``minimal`` or ``safe``.
|
||||||
|
|
||||||
|
* ``light`` — safe reductions, no GPU, one renderer (recommended)
|
||||||
|
* ``minimal`` — adds ``--single-process``; smallest, can crash
|
||||||
|
* ``safe`` — no footprint flags at all, for ruling them out when debugging
|
||||||
|
"""
|
||||||
|
value = os.environ.get('KIWY_CHROMIUM_MODE', 'light').strip().lower()
|
||||||
|
if value not in ('light', 'minimal', 'safe'):
|
||||||
|
_log(f'unknown KIWY_CHROMIUM_MODE={value!r}; using "light"', 'warning')
|
||||||
|
return 'light'
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
#: Flags that make a signage page behave like an appliance.
|
||||||
|
#:
|
||||||
|
#: NOTE: every list here must be referenced from ``extra_launch_args()``. This
|
||||||
|
#: list was dead code once, which silently disabled the keyring bypass along
|
||||||
|
#: with every kiosk nicety.
|
||||||
|
APPLIANCE_FLAGS = [
|
||||||
|
'--noerrdialogs',
|
||||||
|
'--disable-infobars',
|
||||||
|
'--no-first-run',
|
||||||
|
'--no-default-browser-check',
|
||||||
|
'--disable-session-crashed-bubble',
|
||||||
|
'--disable-features=Translate,BackForwardCache,AcceptCHFrame,MediaRouter,OptimizationHints,PasswordManagerOnboarding,AutofillServerCommunication,PasswordLeakDetection',
|
||||||
|
'--disable-background-networking',
|
||||||
|
'--disable-component-update',
|
||||||
|
'--disable-sync',
|
||||||
|
'--check-for-update-interval=31536000',
|
||||||
|
'--autoplay-policy=no-user-gesture-required',
|
||||||
|
'--hide-scrollbars',
|
||||||
|
'--disable-pinch',
|
||||||
|
'--overscroll-history-navigation=0',
|
||||||
|
'--force-device-scale-factor=1',
|
||||||
|
'--window-position=0,0',
|
||||||
|
]
|
||||||
|
|
||||||
|
#: Never offer to save or autofill credentials — a second source of prompts.
|
||||||
|
#: The pages shown are public, so suppressing this costs nothing.
|
||||||
|
NO_PROMPTS_FLAGS = [
|
||||||
|
'--disable-save-password-bubble',
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _browser_env():
|
||||||
|
"""Environment for the browser process only — never the player.
|
||||||
|
|
||||||
|
Defence in depth for the keyring prompt. Even if Chromium ignores
|
||||||
|
``--password-store=basic``, an emptied ``GNOME_KEYRING_CONTROL`` and a
|
||||||
|
removed ``DBUS_SESSION_BUS_ADDRESS`` mean the Secret Service cannot be
|
||||||
|
reached, so no unlock prompt can be raised at all.
|
||||||
|
|
||||||
|
Scoped to the child deliberately: the player keeps its real session bus,
|
||||||
|
which other components may rely on.
|
||||||
|
"""
|
||||||
|
env = dict(os.environ)
|
||||||
|
env['GNOME_KEYRING_CONTROL'] = ''
|
||||||
|
env['CHROME_PASSWORD_STORE'] = 'basic'
|
||||||
|
# Nothing a single static page needs requires the session bus.
|
||||||
|
env.pop('DBUS_SESSION_BUS_ADDRESS', None)
|
||||||
|
env.pop('DBUS_SESSION_BUS_PID', None)
|
||||||
|
# Discourage portal / keyring autostart helpers from being pulled in.
|
||||||
|
env['XDG_DESKTOP_PORTAL_SUPPRESS'] = '1'
|
||||||
|
return env
|
||||||
|
|
||||||
|
|
||||||
|
def find_linux_browser():
|
||||||
|
"""Locate a Chromium-family browser, preferring the Debian/RPi names.
|
||||||
|
|
||||||
|
``chromium-browser`` is checked first only because older RPi OS releases
|
||||||
|
shipped it as the wrapper name; on Trixie the real binary is ``chromium``.
|
||||||
|
"""
|
||||||
|
for candidate in (
|
||||||
|
'chromium-browser', # RPi OS <= Bullseye wrapper
|
||||||
|
'chromium', # RPi OS Bookworm/Trixie
|
||||||
|
'google-chrome',
|
||||||
|
'google-chrome-stable',
|
||||||
|
'chrome',
|
||||||
|
'microsoft-edge',
|
||||||
|
):
|
||||||
|
path = shutil.which(candidate)
|
||||||
|
if path:
|
||||||
|
return path
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class LinuxChromiumAdapter(ChromiumSubprocessAdapter):
|
||||||
|
"""Chromium kiosk subprocess for Raspberry Pi OS (Wayland/labwc).
|
||||||
|
|
||||||
|
Subclasses :class:`ChromiumSubprocessAdapter` so the session's health
|
||||||
|
checking, interaction watching and generation-tokened teardown all keep
|
||||||
|
working; only the Linux-specific behaviour is overridden.
|
||||||
|
"""
|
||||||
|
|
||||||
|
name = 'chromium-kiosk-linux'
|
||||||
|
embedded = False
|
||||||
|
|
||||||
|
def __init__(self, browser_path=None, extra_flags=(), kiosk=True,
|
||||||
|
profile_dir=None, use_wayland=None):
|
||||||
|
super().__init__(browser_path=browser_path, extra_flags=extra_flags,
|
||||||
|
kiosk=kiosk)
|
||||||
|
self._profile_dir = profile_dir
|
||||||
|
# Flags follow the detected session. An explicit --ozone-platform is
|
||||||
|
# required on Wayland (see WAYLAND_FLAGS); when there is no Wayland
|
||||||
|
# socket the flags are omitted so Chromium uses its own default (X11).
|
||||||
|
self._use_wayland = _detect_wayland() if use_wayland is None else bool(use_wayland)
|
||||||
|
# Track our own process group so teardown can reap the whole tree.
|
||||||
|
self._pgid = None
|
||||||
|
self._preflight_done = False
|
||||||
|
|
||||||
|
# ── Launch environment / session ─────────────────────────────────
|
||||||
|
def launch_env(self):
|
||||||
|
"""Environment for the browser process only.
|
||||||
|
|
||||||
|
This — not the ``--password-store`` flag — is what actually removes the
|
||||||
|
keyring password prompt. The base ``Popen`` inherited the player's
|
||||||
|
environment, which includes ``DBUS_SESSION_BUS_ADDRESS``; Chromium could
|
||||||
|
therefore reach the running ``gnome-keyring-daemon`` and tried to unlock
|
||||||
|
the login keyring.
|
||||||
|
"""
|
||||||
|
return _browser_env()
|
||||||
|
|
||||||
|
def start_new_session(self):
|
||||||
|
"""Give the browser its own process group so teardown can reap it.
|
||||||
|
|
||||||
|
The base implementation used a plain ``Popen``, so the browser shared
|
||||||
|
the player's process group and ``os.killpg`` was never usable — every
|
||||||
|
weblink left Chromium's GPU/zygote/renderer children behind.
|
||||||
|
"""
|
||||||
|
return True
|
||||||
|
|
||||||
|
# ── Flags ────────────────────────────────────────────────────────
|
||||||
|
def extra_launch_args(self):
|
||||||
|
"""Flags appended to the Chromium command line.
|
||||||
|
|
||||||
|
Ordering is deliberate: appliance/keyring flags come first so a later
|
||||||
|
list can never accidentally be shadowed, and every module-level list is
|
||||||
|
referenced here. If you add a list above, add it here too — a list that
|
||||||
|
nothing references is how the keyring prompt survived a "fix".
|
||||||
|
"""
|
||||||
|
args = []
|
||||||
|
|
||||||
|
# 1. Never touch the Secret Service / keyring (the password prompt).
|
||||||
|
args.extend(KEYRING_BYPASS_FLAGS)
|
||||||
|
args.extend(NO_PROMPTS_FLAGS)
|
||||||
|
|
||||||
|
# 2. Kiosk behaviour: no browser UI, no error dialogs, no autofill.
|
||||||
|
args.extend(APPLIANCE_FLAGS)
|
||||||
|
args.extend(SERVICE_REDUCTION_FLAGS)
|
||||||
|
|
||||||
|
# 3. Footprint. A static signage page needs a fraction of Chromium's
|
||||||
|
# defaults; each avoided helper is a process the Pi 4 does not have
|
||||||
|
# RAM for.
|
||||||
|
mode = _chromium_mode()
|
||||||
|
if mode in ('light', 'minimal'):
|
||||||
|
args.extend(LIGHT_WEIGHT_FLAGS)
|
||||||
|
if mode == 'minimal':
|
||||||
|
args.extend(MINIMAL_FLAGS)
|
||||||
|
|
||||||
|
# 4. Identity and geometry.
|
||||||
|
if self._profile_dir:
|
||||||
|
args.append('--user-data-dir=' + self._profile_dir)
|
||||||
|
if self._kiosk:
|
||||||
|
# --kiosk implies fullscreen and removes all browser UI, which is
|
||||||
|
# the supported path on wlroots compositors.
|
||||||
|
args.append('--kiosk')
|
||||||
|
if self._use_wayland:
|
||||||
|
args.extend(WAYLAND_FLAGS)
|
||||||
|
|
||||||
|
return args
|
||||||
|
|
||||||
|
def launch(self, url, width, height):
|
||||||
|
"""Prepare the private profile, then delegate to the base launch.
|
||||||
|
|
||||||
|
Any Chromium still holding the profile is killed first: a surviving
|
||||||
|
instance would swallow the URL and make our process exit immediately.
|
||||||
|
"""
|
||||||
|
self._preflight()
|
||||||
|
|
||||||
|
if self._profile_dir is None:
|
||||||
|
self._profile_dir = os.path.join(
|
||||||
|
os.environ.get('KIWY_DATA_DIR') or os.getcwd(), '.kiosk-profile'
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
os.makedirs(self._profile_dir, exist_ok=True)
|
||||||
|
except Exception as exc:
|
||||||
|
_log(f'could not create kiosk profile {self._profile_dir}: {exc}', 'warning')
|
||||||
|
|
||||||
|
self._kill_browsers_on_profile()
|
||||||
|
self._cleanup_stale_profile_locks()
|
||||||
|
ok = super().launch(url, width, height)
|
||||||
|
if ok and self._proc is not None:
|
||||||
|
self._pgid = _safe_getpgid(self._proc.pid)
|
||||||
|
return ok
|
||||||
|
|
||||||
|
def _preflight(self):
|
||||||
|
"""Warn once when Chromium's platform flags do not match this session.
|
||||||
|
|
||||||
|
The failure this guards against is silent: on a Wayland session with no
|
||||||
|
X server, Chromium started without ``--ozone-platform=wayland`` aborts
|
||||||
|
after ~1 s with "Missing X server or $DISPLAY" on stderr, which reads
|
||||||
|
identically to a hand-off bug. Detecting the mismatch at launch time
|
||||||
|
turns an unexplained skipped weblink into an actionable log line.
|
||||||
|
"""
|
||||||
|
if getattr(self, '_preflight_done', False):
|
||||||
|
return
|
||||||
|
self._preflight_done = True
|
||||||
|
|
||||||
|
has_x_server = bool(os.environ.get('DISPLAY'))
|
||||||
|
if self._use_wayland and not has_x_server:
|
||||||
|
_log('session is Wayland-only; launching Chromium with '
|
||||||
|
'--ozone-platform=wayland (the default and --ozone-platform-hint '
|
||||||
|
'both abort with "Missing X server" here)')
|
||||||
|
elif not self._use_wayland and has_x_server:
|
||||||
|
_log('session is X11; launching Chromium without Wayland flags')
|
||||||
|
elif self._use_wayland and has_x_server:
|
||||||
|
_log('both Wayland and X11 available; preferring Wayland')
|
||||||
|
|
||||||
|
# ── Startup verification ─────────────────────────────────────────
|
||||||
|
def wait_visible(self, timeout):
|
||||||
|
"""Wait until the launched Chromium is genuinely up and not a hand-off.
|
||||||
|
|
||||||
|
A plain "process still alive" check is not enough on Linux: a hand-off
|
||||||
|
launch also stays alive briefly, and a missing Wayland socket produces a
|
||||||
|
fast exit. Two signals are combined:
|
||||||
|
|
||||||
|
* the process must survive the health grace period; and
|
||||||
|
* a Chromium window/toplevel must be observable for this PID.
|
||||||
|
|
||||||
|
If the second cannot be established on this compositor we still return
|
||||||
|
success on the first, so a working-but-unprobeable setup is never
|
||||||
|
skipped (that would be worse than a possible blank frame).
|
||||||
|
"""
|
||||||
|
proc = self._proc
|
||||||
|
if proc is None:
|
||||||
|
return False, 'no process'
|
||||||
|
|
||||||
|
deadline = time.monotonic() + max(1.0, float(timeout))
|
||||||
|
grace = min(float(self._health_grace), max(0.5, float(timeout)))
|
||||||
|
grace_deadline = time.monotonic() + grace
|
||||||
|
|
||||||
|
while time.monotonic() < grace_deadline:
|
||||||
|
if proc.poll() is not None:
|
||||||
|
return False, f'browser exited immediately (rc={proc.returncode})'
|
||||||
|
time.sleep(0.1)
|
||||||
|
|
||||||
|
if _window_exists_for_pid(proc.pid):
|
||||||
|
return True, f'toplevel-for-pid={proc.pid}'
|
||||||
|
|
||||||
|
while time.monotonic() < deadline:
|
||||||
|
if proc.poll() is not None:
|
||||||
|
return False, f'browser exited early (rc={proc.returncode})'
|
||||||
|
if _window_exists_for_pid(proc.pid):
|
||||||
|
return True, f'toplevel-for-pid={proc.pid}'
|
||||||
|
time.sleep(0.2)
|
||||||
|
|
||||||
|
if proc.poll() is None:
|
||||||
|
# Alive past the timeout but not probeable — accept rather than
|
||||||
|
# skipping a page that is probably on screen.
|
||||||
|
return True, 'process-alive-unverified'
|
||||||
|
return False, 'browser window never appeared'
|
||||||
|
|
||||||
|
# ── Teardown ─────────────────────────────────────────────────────
|
||||||
|
def teardown(self):
|
||||||
|
"""Terminate the whole Chromium process group.
|
||||||
|
|
||||||
|
``proc.terminate()`` (the base behaviour) leaves the GPU, zygote and
|
||||||
|
renderer children behind; over a 24/7 playlist those accumulate until
|
||||||
|
the Pi runs out of memory. Killing the process group reaps them all.
|
||||||
|
"""
|
||||||
|
proc, self._proc = self._proc, None
|
||||||
|
pgid, self._pgid = self._pgid, None
|
||||||
|
if proc is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
if proc.poll() is None:
|
||||||
|
_kill_process_group(proc, pgid)
|
||||||
|
|
||||||
|
# Belt and braces: reap anything else still holding this profile.
|
||||||
|
self._kill_browsers_on_profile()
|
||||||
|
self._cleanup_stale_profile_locks()
|
||||||
|
|
||||||
|
# ── Helpers ──────────────────────────────────────────────────────
|
||||||
|
def _cleanup_stale_profile_locks(self):
|
||||||
|
"""Remove the singleton lock a crashed Chromium left behind.
|
||||||
|
|
||||||
|
Chromium refuses to start on a profile whose ``SingletonLock`` points at
|
||||||
|
a dead PID (or shows the "profile in use" dialog). Because our profile
|
||||||
|
is private to the player, clearing the lock is always safe.
|
||||||
|
"""
|
||||||
|
if not self._profile_dir:
|
||||||
|
return
|
||||||
|
for name in ('SingletonLock', 'SingletonSocket', 'SingletonCookie'):
|
||||||
|
path = os.path.join(self._profile_dir, name)
|
||||||
|
try:
|
||||||
|
if os.path.islink(path) or os.path.exists(path):
|
||||||
|
os.unlink(path)
|
||||||
|
_log(f'cleared stale profile lock {name}', 'debug')
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def _kill_browsers_on_profile(self):
|
||||||
|
"""Kill any Chromium holding our kiosk profile.
|
||||||
|
|
||||||
|
Scans ``/proc/<pid>/cmdline`` rather than shelling out to ``pgrep`` so
|
||||||
|
this works without procps and cannot match the wrong process.
|
||||||
|
"""
|
||||||
|
if not self._profile_dir:
|
||||||
|
return []
|
||||||
|
marker = self._profile_dir
|
||||||
|
own_uid = os.getuid()
|
||||||
|
killed = []
|
||||||
|
try:
|
||||||
|
for entry in os.listdir('/proc'):
|
||||||
|
if not entry.isdigit():
|
||||||
|
continue
|
||||||
|
pid = int(entry)
|
||||||
|
if pid == os.getpid():
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
if os.stat(f'/proc/{pid}').st_uid != own_uid:
|
||||||
|
continue
|
||||||
|
with open(f'/proc/{pid}/cmdline', 'rb') as fh:
|
||||||
|
cmdline = fh.read().replace(b'\x00', b' ').decode(
|
||||||
|
'utf-8', 'replace'
|
||||||
|
)
|
||||||
|
except (OSError, PermissionError, ProcessLookupError):
|
||||||
|
continue
|
||||||
|
if marker in cmdline and 'chrom' in cmdline.lower():
|
||||||
|
try:
|
||||||
|
os.kill(pid, signal.SIGTERM)
|
||||||
|
killed.append(pid)
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
except Exception as exc:
|
||||||
|
_log(f'profile scan failed: {exc}', 'debug')
|
||||||
|
if killed:
|
||||||
|
_log(f'terminated {len(killed)} leaked browser(s) on the kiosk '
|
||||||
|
f'profile: {killed}')
|
||||||
|
return killed
|
||||||
|
|
||||||
|
|
||||||
|
# ── Module-level helpers ─────────────────────────────────────────────
|
||||||
|
def _detect_wayland():
|
||||||
|
"""True when the session looks like Wayland.
|
||||||
|
|
||||||
|
Uses ``linux_display`` when importable (it also fills in an unset
|
||||||
|
``WAYLAND_DISPLAY``, which Chromium needs for --ozone-platform-hint), and
|
||||||
|
falls back to a socket probe so this module stays independently testable.
|
||||||
|
"""
|
||||||
|
if os.environ.get('WAYLAND_DISPLAY'):
|
||||||
|
return True
|
||||||
|
try:
|
||||||
|
import linux_display
|
||||||
|
|
||||||
|
linux_display.ensure_session_environment()
|
||||||
|
return linux_display.is_wayland()
|
||||||
|
except Exception:
|
||||||
|
runtime = os.environ.get('XDG_RUNTIME_DIR') or f'/run/user/{os.getuid()}'
|
||||||
|
return os.path.exists(os.path.join(runtime, 'wayland-0'))
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_getpgid(pid):
|
||||||
|
try:
|
||||||
|
return os.getpgid(pid)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _kill_process_group(proc, pgid):
|
||||||
|
"""SIGTERM then SIGKILL the browser's process group."""
|
||||||
|
target = pgid if pgid and pgid != os.getpgid(0) else None
|
||||||
|
try:
|
||||||
|
if target:
|
||||||
|
os.killpg(target, signal.SIGTERM)
|
||||||
|
else:
|
||||||
|
proc.terminate()
|
||||||
|
except Exception:
|
||||||
|
try:
|
||||||
|
proc.terminate()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Give Chromium a moment to flush and exit cleanly.
|
||||||
|
deadline = time.monotonic() + 5.0
|
||||||
|
while time.monotonic() < deadline:
|
||||||
|
if proc.poll() is not None:
|
||||||
|
break
|
||||||
|
time.sleep(0.1)
|
||||||
|
|
||||||
|
if proc.poll() is None:
|
||||||
|
try:
|
||||||
|
if target:
|
||||||
|
os.killpg(target, signal.SIGKILL)
|
||||||
|
else:
|
||||||
|
proc.kill()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
proc.wait(timeout=3)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _window_exists_for_pid(pid):
|
||||||
|
"""Best-effort check that ``pid`` owns a visible top-level surface.
|
||||||
|
|
||||||
|
Two independent probes, because neither works everywhere:
|
||||||
|
|
||||||
|
1. ``/proc/<pid>/fd`` — Chromium holds the Wayland/X11 socket open once it
|
||||||
|
has connected and started creating surfaces.
|
||||||
|
2. ``/proc/<pid>/task/*/comm`` — the GPU/renderer children only appear once
|
||||||
|
the browser has actually started rendering.
|
||||||
|
|
||||||
|
Returns False on any error; the caller falls back to "process alive".
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
fd_dir = f'/proc/{pid}/fd'
|
||||||
|
socket_links = 0
|
||||||
|
for fd in os.listdir(fd_dir):
|
||||||
|
try:
|
||||||
|
target = os.readlink(os.path.join(fd_dir, fd))
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
if 'wayland' in target or 'X11-unix' in target:
|
||||||
|
socket_links += 1
|
||||||
|
if socket_links:
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
try:
|
||||||
|
task_dir = f'/proc/{pid}/task'
|
||||||
|
for tid in os.listdir(task_dir):
|
||||||
|
try:
|
||||||
|
with open(os.path.join(task_dir, tid, 'comm')) as fh:
|
||||||
|
comm = fh.read().strip()
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
if comm in ('Chrome_ChildIOT', 'Chrome_IOThread'):
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return False
|
||||||
@@ -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))
|
||||||
@@ -0,0 +1,467 @@
|
|||||||
|
"""monitor_player.py — CPU, GPU, temperature and memory monitor for the player.
|
||||||
|
|
||||||
|
A 24/7 signage player on a Raspberry Pi 4 fails in slow, quiet ways: the SoC
|
||||||
|
throttles, the CPU governor parks a core, memory creeps up until the OOM killer
|
||||||
|
picks a victim. This samples the numbers that reveal those trends before they
|
||||||
|
become a blank screen.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
# live view, 5s interval, until Ctrl+C
|
||||||
|
.venv/bin/python linux/monitor_player.py
|
||||||
|
|
||||||
|
# 10 minutes at 2s, also written to CSV
|
||||||
|
.venv/bin/python linux/monitor_player.py --interval 2 --duration 600
|
||||||
|
|
||||||
|
# background logging only (no live view)
|
||||||
|
.venv/bin/python linux/monitor_player.py --quiet --duration 3600 \
|
||||||
|
--csv logs/monitor-$(date +%F-%H%M).csv
|
||||||
|
|
||||||
|
What is measured, and why each one matters:
|
||||||
|
|
||||||
|
| Metric | Why it matters here |
|
||||||
|
|--------|--------------------|
|
||||||
|
| Temperature | Pi 4 throttles at 80 °C (soft limit 85 °C). Sustained high temp means the case or heatsink is inadequate for 24/7. |
|
||||||
|
| Throttle flags | ``vcgencmd get_throttled`` distinguishes *currently throttled* from *has throttled since boot* — the second one only (bits 16-19) reveals a problem that already happened. |
|
||||||
|
| ARM clock | Dropping below the configured max mid-run is the signature of thermal/voltage capping. |
|
||||||
|
| V3D / pixel clock | The VideoCore GPU clock. A weblink drives it; a static image barely does. |
|
||||||
|
| Core voltage | Under-voltage (bit 0 of the throttle mask) is almost always an inadequate PSU, and it corrupts SD cards. |
|
||||||
|
| Per-core CPU | One pegged core is normal for Kivy (single-threaded main loop); all four pegged is not. |
|
||||||
|
| Player PSS | Proportional set size of the player process — the honest figure. |
|
||||||
|
| Chromium total | A *count* as much as memory: a rising count means weblinks are leaking. |
|
||||||
|
| /dev/shm | Chromium renders through shared memory; if it fills, pages render blank. |
|
||||||
|
|
||||||
|
GPU utilisation is deliberately absent: the Pi 4's V3D exposes no
|
||||||
|
``gpu_busy_percent`` (that is a Pi 5 / v3d-drm feature) and
|
||||||
|
``/sys/kernel/debug/dri/0/gpu_stats`` does not exist here. GPU activity is
|
||||||
|
therefore inferred from the clock domains and the temperature, which is the
|
||||||
|
best this hardware offers.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import csv
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
try:
|
||||||
|
import psutil
|
||||||
|
except ImportError:
|
||||||
|
print('psutil is required: .venv/bin/pip install psutil', file=sys.stderr)
|
||||||
|
raise SystemExit(2)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Constants ────────────────────────────────────────────────────────
|
||||||
|
#: Pi 4 begins soft-throttling around here. Warning threshold, not a limit.
|
||||||
|
TEMP_WARN_C = 70.0
|
||||||
|
#: Active soft-throttle limit on Pi 4.
|
||||||
|
TEMP_CRITICAL_C = 80.0
|
||||||
|
|
||||||
|
#: ``vcgencmd get_throttled`` bit meanings. The 16-19 group is sticky: it
|
||||||
|
#: records that the condition occurred at any point since boot, which is the
|
||||||
|
#: only way to catch an intermittent brown-out on an unattended device.
|
||||||
|
THROTTLE_BITS = {
|
||||||
|
0: ('NOW', 'under-voltage'),
|
||||||
|
1: ('NOW', 'arm frequency capped'),
|
||||||
|
2: ('NOW', 'currently throttled'),
|
||||||
|
3: ('NOW', 'soft temperature limit'),
|
||||||
|
16: ('HAS', 'under-voltage occurred'),
|
||||||
|
17: ('HAS', 'arm frequency capping occurred'),
|
||||||
|
18: ('HAS', 'throttling occurred'),
|
||||||
|
19: ('HAS', 'soft temperature limit occurred'),
|
||||||
|
}
|
||||||
|
|
||||||
|
CLOCK_DOMAINS = ('arm', 'core', 'v3d', 'h264', 'pixel')
|
||||||
|
|
||||||
|
CSV_FIELDS = [
|
||||||
|
'timestamp', 'uptime_s',
|
||||||
|
'cpu_total_pct', 'cpu0_pct', 'cpu1_pct', 'cpu2_pct', 'cpu3_pct',
|
||||||
|
'load1', 'load5', 'load15',
|
||||||
|
'temp_c', 'volt_core_v',
|
||||||
|
'arm_mhz', 'core_mhz', 'v3d_mhz', 'pixel_mhz', 'h264_mhz',
|
||||||
|
'governor', 'arm_cur_mhz_cfg',
|
||||||
|
'mem_used_mb', 'mem_avail_mb', 'shm_used_mb',
|
||||||
|
'player_pss_mb', 'player_cpu_pct', 'player_threads',
|
||||||
|
'chromium_procs', 'chromium_pss_mb',
|
||||||
|
'throttled_raw', 'throttle_flags',
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _run(args, timeout=5):
|
||||||
|
"""Run a command, return stripped stdout, or '' on any failure."""
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
args, capture_output=True, text=True, timeout=timeout, check=False,
|
||||||
|
)
|
||||||
|
return (result.stdout or '').strip()
|
||||||
|
except Exception:
|
||||||
|
return ''
|
||||||
|
|
||||||
|
|
||||||
|
def _read(path):
|
||||||
|
try:
|
||||||
|
with open(path) as fh:
|
||||||
|
return fh.read().strip()
|
||||||
|
except Exception:
|
||||||
|
return ''
|
||||||
|
|
||||||
|
|
||||||
|
# ── Individual metrics ───────────────────────────────────────────────
|
||||||
|
def read_temp_c():
|
||||||
|
"""SoC temperature in °C from hwmon, falling back to vcgencmd/thermal."""
|
||||||
|
raw = _read('/sys/class/hwmon/hwmon0/temp1_input')
|
||||||
|
if raw.isdigit():
|
||||||
|
return int(raw) / 1000.0
|
||||||
|
for zone in ('/sys/class/thermal/thermal_zone0/temp',):
|
||||||
|
raw = _read(zone)
|
||||||
|
if raw.isdigit():
|
||||||
|
return int(raw) / 1000.0
|
||||||
|
out = _run(['vcgencmd', 'measure_temp'])
|
||||||
|
# "temp=50.1'C"
|
||||||
|
try:
|
||||||
|
return float(out.split('=')[1].split("'")[0])
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def read_clock_mhz(domain):
|
||||||
|
"""Clock frequency in MHz for a vcgencmd domain."""
|
||||||
|
out = _run(['vcgencmd', 'measure_clock', domain])
|
||||||
|
# "frequency(48)=1800457088"
|
||||||
|
try:
|
||||||
|
return int(out.split('=')[1]) / 1_000_000.0
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def read_core_volts():
|
||||||
|
out = _run(['vcgencmd', 'measure_volts', 'core'])
|
||||||
|
# "volt=0.9160V"
|
||||||
|
try:
|
||||||
|
return float(out.split('=')[1].rstrip('V'))
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def read_throttled():
|
||||||
|
"""(raw_int, [human readable flags]) from vcgencmd get_throttled."""
|
||||||
|
out = _run(['vcgencmd', 'get_throttled'])
|
||||||
|
# "throttled=0x0"
|
||||||
|
try:
|
||||||
|
raw = int(out.split('=')[1], 16)
|
||||||
|
except Exception:
|
||||||
|
return None, []
|
||||||
|
flags = []
|
||||||
|
for bit, (when, label) in THROTTLE_BITS.items():
|
||||||
|
if raw & (1 << bit):
|
||||||
|
flags.append(f'{when}:{label}')
|
||||||
|
return raw, flags
|
||||||
|
|
||||||
|
|
||||||
|
def pss_mb(pid):
|
||||||
|
"""PSS in MB for a process, or None. RSS double-counts shared pages."""
|
||||||
|
total_kb = 0
|
||||||
|
try:
|
||||||
|
with open(f'/proc/{pid}/smaps_rollup') as fh:
|
||||||
|
for line in fh:
|
||||||
|
if line.startswith('Pss:'):
|
||||||
|
total_kb = int(line.split()[1])
|
||||||
|
break
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
return total_kb / 1024.0
|
||||||
|
|
||||||
|
|
||||||
|
def find_procs(pattern):
|
||||||
|
"""PIDs whose cmdline matches ``pattern``, excluding this script."""
|
||||||
|
pids = []
|
||||||
|
me = os.getpid()
|
||||||
|
for proc in psutil.process_iter(['pid', 'cmdline']):
|
||||||
|
try:
|
||||||
|
if proc.info['pid'] == me:
|
||||||
|
continue
|
||||||
|
cmdline = ' '.join(proc.info['cmdline'] or [])
|
||||||
|
if pattern in cmdline and 'monitor_player' not in cmdline:
|
||||||
|
pids.append(proc.info['pid'])
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
return pids
|
||||||
|
|
||||||
|
|
||||||
|
#: ``psutil.Process`` objects kept alive between samples. ``cpu_percent()``
|
||||||
|
#: needs a *previous* reading for the same process to compute a delta, so a
|
||||||
|
#: freshly constructed Process always reports 0.0. Caching these is what makes
|
||||||
|
#: the player's own CPU figure meaningful instead of a constant zero.
|
||||||
|
_proc_cache: dict[int, 'psutil.Process'] = {}
|
||||||
|
|
||||||
|
|
||||||
|
def player_cpu_percent(pid):
|
||||||
|
"""CPU % for ``pid`` since the previous sample."""
|
||||||
|
try:
|
||||||
|
proc = _proc_cache.get(pid)
|
||||||
|
if proc is None or not proc.is_running():
|
||||||
|
proc = psutil.Process(pid)
|
||||||
|
_proc_cache[pid] = proc
|
||||||
|
proc.cpu_percent(interval=None) # establish the baseline
|
||||||
|
return 0.0
|
||||||
|
return proc.cpu_percent(interval=None)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def sample():
|
||||||
|
"""Take one measurement. Never raises; missing metrics become None."""
|
||||||
|
now = time.time()
|
||||||
|
row = {'timestamp': datetime.now().isoformat(timespec='seconds')}
|
||||||
|
|
||||||
|
# CPU. interval=None means "since the previous call" — with a fixed sample
|
||||||
|
# period this yields the usage over exactly that window.
|
||||||
|
try:
|
||||||
|
row['cpu_total_pct'] = psutil.cpu_percent(interval=None)
|
||||||
|
per_core = psutil.cpu_percent(interval=None, percpu=True)
|
||||||
|
for i in range(4):
|
||||||
|
row[f'cpu{i}_pct'] = per_core[i] if i < len(per_core) else None
|
||||||
|
load = os.getloadavg()
|
||||||
|
row['load1'], row['load5'], row['load15'] = load
|
||||||
|
except Exception:
|
||||||
|
row.update({'cpu_total_pct': None, 'load1': None, 'load5': None,
|
||||||
|
'load15': None})
|
||||||
|
|
||||||
|
# Thermal / power
|
||||||
|
row['temp_c'] = read_temp_c()
|
||||||
|
row['volt_core_v'] = read_core_volts()
|
||||||
|
|
||||||
|
# Clocks
|
||||||
|
for domain in CLOCK_DOMAINS:
|
||||||
|
row[f'{domain}_mhz'] = read_clock_mhz(domain)
|
||||||
|
|
||||||
|
# Governor and configured max
|
||||||
|
row['governor'] = _read(
|
||||||
|
'/sys/devices/system/cpu/cpu0/cpufreq/scaling_governor')
|
||||||
|
cur = _read('/sys/devices/system/cpu/cpu0/cpufreq/scaling_cur_freq')
|
||||||
|
row['arm_cur_mhz_cfg'] = int(cur) / 1000.0 if cur.isdigit() else None
|
||||||
|
|
||||||
|
# Memory
|
||||||
|
try:
|
||||||
|
mem = psutil.virtual_memory()
|
||||||
|
row['mem_used_mb'] = (mem.total - mem.available) / 1024 / 1024
|
||||||
|
row['mem_avail_mb'] = mem.available / 1024 / 1024
|
||||||
|
shm = psutil.disk_usage('/dev/shm')
|
||||||
|
row['shm_used_mb'] = shm.used / 1024 / 1024
|
||||||
|
except Exception:
|
||||||
|
row.update({'mem_used_mb': None, 'mem_avail_mb': None,
|
||||||
|
'shm_used_mb': None})
|
||||||
|
|
||||||
|
# The player
|
||||||
|
player_pids = find_procs('run_linux.py')
|
||||||
|
if player_pids:
|
||||||
|
pid = player_pids[0]
|
||||||
|
row['player_pss_mb'] = pss_mb(pid)
|
||||||
|
row['player_cpu_pct'] = player_cpu_percent(pid)
|
||||||
|
try:
|
||||||
|
proc = _proc_cache.get(pid) or psutil.Process(pid)
|
||||||
|
with proc.oneshot():
|
||||||
|
row['player_threads'] = proc.num_threads()
|
||||||
|
# Uptime from the process, for correlating with playback.
|
||||||
|
row['uptime_s'] = now - proc.create_time()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
row['player_pss_mb'] = None
|
||||||
|
row['player_cpu_pct'] = None
|
||||||
|
row['player_threads'] = None
|
||||||
|
|
||||||
|
# Chromium tree (weblinks). Count is as important as memory: a rising
|
||||||
|
# count means teardown is leaking, which is what kills a 24/7 install.
|
||||||
|
chromium_pids = find_procs('chromium')
|
||||||
|
row['chromium_procs'] = len(chromium_pids)
|
||||||
|
total = 0.0
|
||||||
|
for pid in chromium_pids:
|
||||||
|
value = pss_mb(pid)
|
||||||
|
if value:
|
||||||
|
total += value
|
||||||
|
row['chromium_pss_mb'] = total if chromium_pids else 0.0
|
||||||
|
|
||||||
|
# Throttling
|
||||||
|
raw, flags = read_throttled()
|
||||||
|
row['throttled_raw'] = hex(raw) if raw is not None else None
|
||||||
|
row['throttle_flags'] = ','.join(flags) if flags else ''
|
||||||
|
|
||||||
|
# Fill any field we never set so the CSV stays rectangular.
|
||||||
|
for field in CSV_FIELDS:
|
||||||
|
row.setdefault(field, None)
|
||||||
|
return row
|
||||||
|
|
||||||
|
|
||||||
|
# ── Presentation ─────────────────────────────────────────────────────
|
||||||
|
def fmt(value, width=6, spec='.1f'):
|
||||||
|
if value is None:
|
||||||
|
return ' ' * (width - 2) + '--'
|
||||||
|
return f'{value:{width}{spec}}'
|
||||||
|
|
||||||
|
|
||||||
|
def print_header():
|
||||||
|
print()
|
||||||
|
print(' time CPU% cores(0-3) temp ARM V3D volt RAM '
|
||||||
|
'player chrome gov flags')
|
||||||
|
print(' ' + '-' * 84)
|
||||||
|
|
||||||
|
|
||||||
|
def print_row(row, tick):
|
||||||
|
cores = ' '.join(
|
||||||
|
fmt(row.get(f'cpu{i}_pct'), 3, '.0f') for i in range(4))
|
||||||
|
flags = (row.get('throttle_flags') or '').replace('HAS:', 'had:') \
|
||||||
|
.replace('NOW:', 'NOW:')
|
||||||
|
warn = ''
|
||||||
|
temp = row.get('temp_c')
|
||||||
|
if temp is not None and temp >= TEMP_CRITICAL_C:
|
||||||
|
warn = ' ***HOT***'
|
||||||
|
elif temp is not None and temp >= TEMP_WARN_C:
|
||||||
|
warn = ' *warm*'
|
||||||
|
|
||||||
|
governor = (row.get('governor') or '')[:6]
|
||||||
|
print(
|
||||||
|
f' {row["timestamp"][11:19]} '
|
||||||
|
f'{fmt(row.get("cpu_total_pct"), 4, ".0f")} '
|
||||||
|
f' {cores} '
|
||||||
|
f'{fmt(temp, 5)}C '
|
||||||
|
f'{fmt(row.get("arm_mhz"), 6, ".0f")} '
|
||||||
|
f'{fmt(row.get("v3d_mhz"), 5, ".0f")} '
|
||||||
|
f'{fmt(row.get("volt_core_v"), 6, ".3f")}V'
|
||||||
|
f'{fmt(row.get("mem_used_mb"), 5, ".0f")}M '
|
||||||
|
f'{fmt(row.get("player_pss_mb"), 4, ".0f")}M '
|
||||||
|
f'{fmt(row.get("chromium_pss_mb"), 5, ".0f")}M '
|
||||||
|
f'{governor:<6} '
|
||||||
|
f'{flags}{warn}'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def summarise(rows):
|
||||||
|
"""Print min/avg/max for the run — the part that reveals a trend."""
|
||||||
|
if not rows:
|
||||||
|
return
|
||||||
|
print('\n ' + '=' * 84)
|
||||||
|
print(f' SUMMARY over {len(rows)} samples '
|
||||||
|
f'({rows[0]["timestamp"][11:19]} -> {rows[-1]["timestamp"][11:19]})')
|
||||||
|
print(' ' + '=' * 84)
|
||||||
|
|
||||||
|
def stat(field, spec='.1f', unit=''):
|
||||||
|
values = [r[field] for r in rows
|
||||||
|
if isinstance(r.get(field), (int, float))]
|
||||||
|
if not values:
|
||||||
|
return f' {field:<18} (no data)'
|
||||||
|
return (f' {field:<18} min {min(values):>8{spec}}{unit} '
|
||||||
|
f'avg {sum(values)/len(values):>8{spec}}{unit} '
|
||||||
|
f'max {max(values):>8{spec}}{unit}')
|
||||||
|
|
||||||
|
for field, spec, unit in (
|
||||||
|
('cpu_total_pct', '.1f', '%'),
|
||||||
|
('temp_c', '.1f', 'C'),
|
||||||
|
('arm_mhz', '.0f', 'MHz'),
|
||||||
|
('v3d_mhz', '.0f', 'MHz'),
|
||||||
|
('volt_core_v', '.3f', 'V'),
|
||||||
|
('mem_avail_mb', '.0f', 'MB'),
|
||||||
|
('player_pss_mb', '.0f', 'MB'),
|
||||||
|
('player_cpu_pct', '.1f', '%'),
|
||||||
|
('chromium_procs', '.0f', ''),
|
||||||
|
('chromium_pss_mb', '.0f', 'MB'),
|
||||||
|
):
|
||||||
|
print(stat(field, spec, unit))
|
||||||
|
|
||||||
|
# Trend: first vs last quarter, which is what matters for a leak.
|
||||||
|
def trend(field):
|
||||||
|
values = [r[field] for r in rows
|
||||||
|
if isinstance(r.get(field), (int, float))]
|
||||||
|
if len(values) < 8:
|
||||||
|
return ''
|
||||||
|
head = values[:max(1, len(values) // 4)]
|
||||||
|
tail = values[-max(1, len(values) // 4):]
|
||||||
|
delta = sum(tail) / len(tail) - sum(head) / len(head)
|
||||||
|
arrow = 'RISING ' if delta > 0 else 'falling'
|
||||||
|
return (f' {field:<18} {arrow} {delta:+8.1f} '
|
||||||
|
f'(first->last quarter)')
|
||||||
|
|
||||||
|
print('\n Trend (the leak check):')
|
||||||
|
for field in ('player_pss_mb', 'chromium_procs', 'mem_avail_mb', 'temp_c'):
|
||||||
|
line = trend(field)
|
||||||
|
if line:
|
||||||
|
print(line)
|
||||||
|
|
||||||
|
# Throttle history is the headline finding on a Pi.
|
||||||
|
all_flags = set()
|
||||||
|
for row in rows:
|
||||||
|
if row.get('throttle_flags'):
|
||||||
|
all_flags.update(row['throttle_flags'].split(','))
|
||||||
|
print('\n Throttle / power events during this run:')
|
||||||
|
print(f' {", ".join(sorted(all_flags)) if all_flags else "none — clean"}')
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description='Monitor CPU, GPU clock, temperature and memory for the player.')
|
||||||
|
parser.add_argument('--interval', type=float, default=5.0,
|
||||||
|
help='seconds between samples (default 5)')
|
||||||
|
parser.add_argument('--duration', type=float, default=0,
|
||||||
|
help='seconds to run; 0 = until Ctrl+C (default 0)')
|
||||||
|
parser.add_argument('--csv', default='',
|
||||||
|
help='write samples to this CSV file')
|
||||||
|
parser.add_argument('--quiet', action='store_true',
|
||||||
|
help='no live output (for background logging)')
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
# Prime the CPU counters: the first cpu_percent() call always returns 0.0
|
||||||
|
# because it has no previous sample to compare against.
|
||||||
|
psutil.cpu_percent(interval=None)
|
||||||
|
psutil.cpu_percent(interval=None, percpu=True)
|
||||||
|
for pid in find_procs('run_linux.py'):
|
||||||
|
try:
|
||||||
|
_proc_cache[pid] = psutil.Process(pid)
|
||||||
|
_proc_cache[pid].cpu_percent(interval=None)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
writer = None
|
||||||
|
csv_handle = None
|
||||||
|
if args.csv:
|
||||||
|
os.makedirs(os.path.dirname(os.path.abspath(args.csv)), exist_ok=True)
|
||||||
|
csv_handle = open(args.csv, 'w', newline='')
|
||||||
|
writer = csv.DictWriter(csv_handle, fieldnames=CSV_FIELDS)
|
||||||
|
writer.writeheader()
|
||||||
|
|
||||||
|
if not args.quiet:
|
||||||
|
print_header()
|
||||||
|
|
||||||
|
rows = []
|
||||||
|
started = time.time()
|
||||||
|
tick = 0
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
time.sleep(args.interval)
|
||||||
|
tick += 1
|
||||||
|
row = sample()
|
||||||
|
rows.append(row)
|
||||||
|
if writer:
|
||||||
|
writer.writerow(row)
|
||||||
|
csv_handle.flush()
|
||||||
|
if not args.quiet:
|
||||||
|
print_row(row, tick)
|
||||||
|
if args.duration and (time.time() - started) >= args.duration:
|
||||||
|
break
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
if not args.quiet:
|
||||||
|
print('\n stopped by user')
|
||||||
|
finally:
|
||||||
|
if csv_handle:
|
||||||
|
csv_handle.close()
|
||||||
|
if not args.quiet:
|
||||||
|
print(f' CSV written to {args.csv}')
|
||||||
|
if not args.quiet:
|
||||||
|
summarise(rows)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,555 @@
|
|||||||
|
"""
|
||||||
|
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)
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
"""test_linux_browser_flags.py — guard the Chromium keyring bypass and footprint.
|
||||||
|
|
||||||
|
The keyring password prompt is the bug this file exists to prevent from coming
|
||||||
|
back. It was "fixed" once before by adding ``--password-store=basic`` and
|
||||||
|
``--use-mock-keychain`` to a list (``APPLIANCE_FLAGS``) that **nothing ever
|
||||||
|
referenced**, so the flags never reached the command line and the prompt
|
||||||
|
persisted. A second stack-only fix (``--single-process``) also looked right on
|
||||||
|
paper but crashed with a real HTTP URL.
|
||||||
|
|
||||||
|
So these checks are deliberately about *what actually reaches the process*, not
|
||||||
|
about what the constants say:
|
||||||
|
|
||||||
|
1. ``extra_launch_args()`` really contains the keyring + footprint flags.
|
||||||
|
2. ``launch_env()`` really strips the D-Bus session bus, so Chromium cannot
|
||||||
|
reach ``gnome-keyring-daemon`` even if a flag is ever ignored.
|
||||||
|
3. Every module-level flag list is referenced by ``extra_launch_args()`` —
|
||||||
|
dead flag lists are the exact failure mode that hid bug #1.
|
||||||
|
4. The spawned process is in its own session (so process-group teardown works)
|
||||||
|
and its ``/proc/<pid>/environ`` really lacks ``DBUS_SESSION_BUS_ADDRESS``.
|
||||||
|
|
||||||
|
Run:
|
||||||
|
.venv/bin/python linux/test_linux_browser_flags.py
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
HERE = Path(__file__).resolve().parent
|
||||||
|
ROOT = HERE.parent
|
||||||
|
for path in (str(HERE), str(ROOT / 'src')):
|
||||||
|
if path not in sys.path:
|
||||||
|
sys.path.insert(0, path)
|
||||||
|
|
||||||
|
import linux_browser # noqa: E402
|
||||||
|
|
||||||
|
failures: list[str] = []
|
||||||
|
checks = 0
|
||||||
|
|
||||||
|
|
||||||
|
def check(label, condition, detail=''):
|
||||||
|
global checks
|
||||||
|
checks += 1
|
||||||
|
if condition:
|
||||||
|
print(f' PASS {label}')
|
||||||
|
else:
|
||||||
|
print(f' FAIL {label}' + (f' — {detail}' if detail else ''))
|
||||||
|
failures.append(label)
|
||||||
|
|
||||||
|
|
||||||
|
def build_adapter(**kwargs):
|
||||||
|
adapter = linux_browser.LinuxChromiumAdapter(
|
||||||
|
browser_path='/usr/bin/chromium', kiosk=True, **kwargs
|
||||||
|
)
|
||||||
|
adapter._profile_dir = '/tmp/kiwy-flag-test/.kiosk-profile'
|
||||||
|
return adapter
|
||||||
|
|
||||||
|
|
||||||
|
# ── 1. Keyring flags reach the command line ──────────────────────────
|
||||||
|
print('\n[1] Keyring bypass flags are applied')
|
||||||
|
|
||||||
|
adapter = build_adapter()
|
||||||
|
args = adapter.extra_launch_args()
|
||||||
|
|
||||||
|
for flag in ('--password-store=basic', '--use-mock-keychain'):
|
||||||
|
check(f'{flag} present', flag in args,
|
||||||
|
'Chromium would contact gnome-keyring and prompt for a password')
|
||||||
|
|
||||||
|
check('--kiosk present', '--kiosk' in args, 'weblink would not be fullscreen')
|
||||||
|
|
||||||
|
if linux_browser._detect_wayland():
|
||||||
|
check('--ozone-platform=wayland present', '--ozone-platform=wayland' in args,
|
||||||
|
'Chromium 152 aborts without an explicit Ozone platform on labwc')
|
||||||
|
check(
|
||||||
|
'no ineffective --ozone-platform-hint',
|
||||||
|
'--ozone-platform-hint=auto' not in args,
|
||||||
|
'the hint flag does NOT fall back to Wayland and just fails',
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── 2. Environment actually disconnects the keyring ──────────────────
|
||||||
|
print('\n[2] launch_env() disconnects the Secret Service')
|
||||||
|
|
||||||
|
env = adapter.launch_env()
|
||||||
|
check('launch_env() returns an environment', env is not None,
|
||||||
|
'None means Popen inherits DBUS_SESSION_BUS_ADDRESS')
|
||||||
|
if env is not None:
|
||||||
|
check('DBUS_SESSION_BUS_ADDRESS removed',
|
||||||
|
'DBUS_SESSION_BUS_ADDRESS' not in env,
|
||||||
|
'Chromium could reach gnome-keyring-daemon and prompt')
|
||||||
|
check('DBUS_SESSION_BUS_PID removed', 'DBUS_SESSION_BUS_PID' not in env)
|
||||||
|
check('GNOME_KEYRING_CONTROL emptied', env.get('GNOME_KEYRING_CONTROL') == '',
|
||||||
|
'points the keyring client at nothing')
|
||||||
|
check('CHROME_PASSWORD_STORE=basic', env.get('CHROME_PASSWORD_STORE') == 'basic')
|
||||||
|
check('PATH preserved', bool(env.get('PATH')), 'browser could not exec')
|
||||||
|
check('session bus is absent from the parent env to begin with',
|
||||||
|
'DBUS_SESSION_BUS_ADDRESS' in os.environ,
|
||||||
|
'precondition: this test only proves something if the player HAS a bus')
|
||||||
|
|
||||||
|
check('start_new_session() is True', adapter.start_new_session() is True,
|
||||||
|
'os.killpg cannot reap Chromium children without it')
|
||||||
|
|
||||||
|
|
||||||
|
# ── 3. No dead flag lists ────────────────────────────────────────────
|
||||||
|
print('\n[3] Every flag list is referenced (no dead code)')
|
||||||
|
|
||||||
|
source = (HERE / 'linux_browser.py').read_text()
|
||||||
|
# Names of module-level lists of flags.
|
||||||
|
lists = re.findall(r'^([A-Z_]+_FLAGS) = \[', source, re.MULTILINE)
|
||||||
|
check('flag lists found', len(lists) >= 5, f'only found {lists}')
|
||||||
|
|
||||||
|
for name in lists:
|
||||||
|
# Count references that are NOT the definition itself.
|
||||||
|
uses = len(re.findall(rf'(?<!^){name}(?!\s*=\s*\[)', source, re.MULTILINE))
|
||||||
|
check(f'{name} is referenced', uses > 0,
|
||||||
|
'a flag list nothing reads is exactly how the keyring prompt hid')
|
||||||
|
|
||||||
|
|
||||||
|
# ── 4. End-to-end: the real process is detached from the bus ─────────
|
||||||
|
print('\n[4] Live launch: process environment and process group')
|
||||||
|
|
||||||
|
browser = linux_browser.find_linux_browser()
|
||||||
|
if not browser:
|
||||||
|
print(' SKIP no Chromium installed')
|
||||||
|
else:
|
||||||
|
os.makedirs(adapter._profile_dir, exist_ok=True)
|
||||||
|
live = build_adapter()
|
||||||
|
started = live.launch('about:blank', 800, 600)
|
||||||
|
check('launch() returned True', started is True)
|
||||||
|
proc = live._proc
|
||||||
|
if started and proc is not None:
|
||||||
|
try:
|
||||||
|
time.sleep(1.5)
|
||||||
|
if proc.poll() is not None:
|
||||||
|
check('browser survived start-up', False,
|
||||||
|
f'exited rc={proc.returncode}')
|
||||||
|
else:
|
||||||
|
check('browser survived start-up', True)
|
||||||
|
|
||||||
|
# Process group: must differ from the player's own group.
|
||||||
|
try:
|
||||||
|
pgid = os.getpgid(proc.pid)
|
||||||
|
check('browser is in its own process group',
|
||||||
|
pgid == proc.pid,
|
||||||
|
f'pgid={pgid} pid={proc.pid}; killpg would hit the player')
|
||||||
|
except Exception as exc:
|
||||||
|
check('browser is in its own process group', False, str(exc))
|
||||||
|
|
||||||
|
# /proc environ is authoritative: this is what the process sees.
|
||||||
|
try:
|
||||||
|
raw = Path(f'/proc/{proc.pid}/environ').read_bytes()
|
||||||
|
child_env = dict(
|
||||||
|
item.split('=', 1) for item in
|
||||||
|
raw.decode('utf-8', 'replace').split('\x00') if '=' in item
|
||||||
|
)
|
||||||
|
check('live process has no DBUS_SESSION_BUS_ADDRESS',
|
||||||
|
'DBUS_SESSION_BUS_ADDRESS' not in child_env,
|
||||||
|
'the keyring prompt can still appear')
|
||||||
|
check('live process has basic password store',
|
||||||
|
child_env.get('CHROME_PASSWORD_STORE') == 'basic')
|
||||||
|
except Exception as exc:
|
||||||
|
check('live process environment readable', False, str(exc))
|
||||||
|
finally:
|
||||||
|
live.teardown()
|
||||||
|
time.sleep(0.5)
|
||||||
|
check('teardown() left no process behind', live._proc is None)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Summary ──────────────────────────────────────────────────────────
|
||||||
|
print(f'\n{checks - len(failures)}/{checks} checks passed')
|
||||||
|
if failures:
|
||||||
|
print('\nFailed:')
|
||||||
|
for name in failures:
|
||||||
|
print(f' - {name}')
|
||||||
|
sys.exit(1)
|
||||||
|
print('All checks passed.')
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
"""test_linux_patches.py — verify the Linux platform patches are wired correctly.
|
||||||
|
|
||||||
|
Run with the project virtualenv:
|
||||||
|
.venv/bin/python linux/test_linux_patches.py
|
||||||
|
|
||||||
|
These are the regressions that actually bit us during the Trixie port, so they
|
||||||
|
are asserted rather than left to manual testing:
|
||||||
|
|
||||||
|
1. **Kivy WeakMethod name trap.** ``Clock`` stores ``callable.__name__`` and
|
||||||
|
re-resolves it with ``getattr(instance, name)``. If a patched method is
|
||||||
|
assigned under a name that differs from its own ``__name__``, the app dies
|
||||||
|
~20 s later with ``AttributeError`` — far from the cause. Every method this
|
||||||
|
port replaces must be reachable under its own function name.
|
||||||
|
2. **SDL2 Wayland capability.** Kivy's PyPI wheel bundles an SDL2 *without* the
|
||||||
|
wayland driver; the system SDL2 has it. Getting this wrong means no window at
|
||||||
|
all on Raspberry Pi OS Trixie.
|
||||||
|
3. **WAYLAND_DISPLAY inference.** SDL2 requires the variable to be set — unlike
|
||||||
|
``wlopm``, it does not scan ``XDG_RUNTIME_DIR``. A systemd/cron launch has it
|
||||||
|
unset, so the entry point must fill it in.
|
||||||
|
|
||||||
|
The player does not need to be running; this only exercises wiring and detection.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import ctypes
|
||||||
|
import glob
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
HERE = Path(__file__).resolve().parent
|
||||||
|
ROOT = HERE.parent
|
||||||
|
SRC = ROOT / 'src'
|
||||||
|
VENV = Path(os.environ.get('KIWY_VENV') or (ROOT / '.venv'))
|
||||||
|
|
||||||
|
for path in (str(HERE), str(SRC)):
|
||||||
|
if path not in sys.path:
|
||||||
|
sys.path.insert(0, path)
|
||||||
|
|
||||||
|
failures: list[str] = []
|
||||||
|
checks = 0
|
||||||
|
|
||||||
|
|
||||||
|
def check(label, condition, detail=''):
|
||||||
|
global checks
|
||||||
|
checks += 1
|
||||||
|
if condition:
|
||||||
|
print(f' PASS {label}')
|
||||||
|
else:
|
||||||
|
print(f' FAIL {label}' + (f' — {detail}' if detail else ''))
|
||||||
|
failures.append(label)
|
||||||
|
|
||||||
|
|
||||||
|
# ── 1. WeakMethod name trap ──────────────────────────────────────────
|
||||||
|
print('\n[1] Patched methods are resolvable by their own __name__')
|
||||||
|
|
||||||
|
# Import the entry point's patching helpers without running the app. Importing
|
||||||
|
# run_linux executes its env setup, which is harmless and actually desirable
|
||||||
|
# here (it fills in WAYLAND_DISPLAY for the checks below).
|
||||||
|
import run_linux # noqa: E402
|
||||||
|
|
||||||
|
main = run_linux._import_main()
|
||||||
|
|
||||||
|
patches = {
|
||||||
|
'signal_screen_activity': run_linux._patch_display(main),
|
||||||
|
'weblink_adapter_factory': run_linux._patch_weblink_engines(main),
|
||||||
|
'test_connection': run_linux._patch_temp_paths(main),
|
||||||
|
'reset_player_auth': run_linux._patch_auth_path(main),
|
||||||
|
}
|
||||||
|
|
||||||
|
for label, applied in patches.items():
|
||||||
|
check(f'{label} patch applied', applied, 'patch reported failure')
|
||||||
|
|
||||||
|
player_cls = main.SignagePlayer
|
||||||
|
for attr in ('signal_screen_activity', 'weblink_adapter_factory'):
|
||||||
|
func = getattr(player_cls, attr, None)
|
||||||
|
check(f'{attr} exists', func is not None, 'attribute missing')
|
||||||
|
if func is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# For a staticmethod, inspect the underlying function.
|
||||||
|
raw = player_cls.__dict__.get(attr)
|
||||||
|
func = raw.__func__ if isinstance(raw, staticmethod) else raw
|
||||||
|
name = getattr(func, '__name__', '')
|
||||||
|
|
||||||
|
# The real invariant: Kivy resolves the callback with
|
||||||
|
# getattr(instance, func.__name__), so that lookup must succeed and return
|
||||||
|
# the same function. The name need not equal the attribute it replaced, but
|
||||||
|
# it MUST be reachable on the class.
|
||||||
|
resolved = getattr(player_cls, name, None)
|
||||||
|
check(
|
||||||
|
f'{attr}: getattr(cls, {name!r}) resolves',
|
||||||
|
resolved is not None,
|
||||||
|
f'Kivy Clock would raise AttributeError: no attribute {name!r}',
|
||||||
|
)
|
||||||
|
check(
|
||||||
|
f'{attr}: resolved function is the patched one',
|
||||||
|
resolved is func,
|
||||||
|
f'{name!r} resolved to a different object',
|
||||||
|
)
|
||||||
|
|
||||||
|
# The Windows-only focus machinery must be gone from the shared app, so nothing
|
||||||
|
# schedules Win32 work on a Wayland session.
|
||||||
|
for attr in ('_focus_keeper_tick', '_focus_guardian_tick',
|
||||||
|
'_bring_window_to_front_nonblocking', '_start_focus_guardian'):
|
||||||
|
check(
|
||||||
|
f'{attr} removed (Windows-only)',
|
||||||
|
not hasattr(player_cls, attr),
|
||||||
|
'the platform-specific focus code should not be in the shared app',
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── 2. Display module behaviour ──────────────────────────────────────
|
||||||
|
print('\n[2] linux_display detection and keep-awake')
|
||||||
|
|
||||||
|
import linux_display as display # noqa: E402
|
||||||
|
|
||||||
|
status = display.status()
|
||||||
|
print(f' info status = {status}')
|
||||||
|
|
||||||
|
if status['wayland']:
|
||||||
|
check('WAYLAND_DISPLAY is set after detection',
|
||||||
|
bool(os.environ.get('WAYLAND_DISPLAY')),
|
||||||
|
'SDL2/wlopm need this variable')
|
||||||
|
check('wlopm available', status['wlopm'], 'install wlopm')
|
||||||
|
check('keep_display_awake reports success',
|
||||||
|
display.keep_display_awake(force=True) is True,
|
||||||
|
'wlopm --on did not succeed')
|
||||||
|
else:
|
||||||
|
print(' SKIP not a Wayland session — display checks skipped')
|
||||||
|
|
||||||
|
# With the tools explicitly disabled nothing must be attempted.
|
||||||
|
os.environ[display.DISABLE_ENV_VAR] = '1'
|
||||||
|
check('DISABLE escape hatch suppresses keep-awake',
|
||||||
|
display.keep_display_awake(force=True) is False)
|
||||||
|
check('DISABLE escape hatch suppresses blanker kill',
|
||||||
|
display.neutralise_idle_blanker() == [])
|
||||||
|
del os.environ[display.DISABLE_ENV_VAR]
|
||||||
|
|
||||||
|
|
||||||
|
# ── 3. SDL2 Wayland capability ───────────────────────────────────────
|
||||||
|
print('\n[3] SDL2 in use supports the wayland driver')
|
||||||
|
|
||||||
|
def drivers_of(lib_path):
|
||||||
|
try:
|
||||||
|
lib = ctypes.CDLL(str(lib_path))
|
||||||
|
lib.SDL_GetNumVideoDrivers.restype = ctypes.c_int
|
||||||
|
lib.SDL_GetVideoDriver.restype = ctypes.c_char_p
|
||||||
|
lib.SDL_GetVideoDriver.argtypes = [ctypes.c_int]
|
||||||
|
n = lib.SDL_GetNumVideoDrivers()
|
||||||
|
return [lib.SDL_GetVideoDriver(i).decode() for i in range(n)]
|
||||||
|
except Exception as exc:
|
||||||
|
return [f'<error: {exc}>']
|
||||||
|
|
||||||
|
|
||||||
|
ext = glob.glob(str(VENV / '**' / '_window_sdl2*.so'), recursive=True)
|
||||||
|
if not ext:
|
||||||
|
print(' SKIP no Kivy SDL2 extension found in this venv')
|
||||||
|
else:
|
||||||
|
# Ask the dynamic loader which libSDL2 the extension actually resolves.
|
||||||
|
linked = subprocess.run(
|
||||||
|
['ldd', ext[0]], capture_output=True, text=True, check=False,
|
||||||
|
).stdout
|
||||||
|
resolved = None
|
||||||
|
for line in linked.splitlines():
|
||||||
|
if 'libSDL2-2-' in line and '=>' in line:
|
||||||
|
resolved = line.split('=>')[1].split('(')[0].strip()
|
||||||
|
break
|
||||||
|
check('Kivy resolves an SDL2 library', resolved is not None, 'ldd found none')
|
||||||
|
if resolved:
|
||||||
|
used = drivers_of(resolved)
|
||||||
|
print(f' info {resolved}\n drivers = {used}')
|
||||||
|
check('resolved SDL2 supports wayland', 'wayland' in used,
|
||||||
|
'run: bash linux/fix_kivy_sdl2.sh')
|
||||||
|
|
||||||
|
|
||||||
|
# ── Summary ──────────────────────────────────────────────────────────
|
||||||
|
print(f'\n{checks - len(failures)}/{checks} checks passed')
|
||||||
|
if failures:
|
||||||
|
print('\nFailed:')
|
||||||
|
for name in failures:
|
||||||
|
print(f' - {name}')
|
||||||
|
sys.exit(1)
|
||||||
|
print('All checks passed.')
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
"""test_media_state.py — the conversion-flag state machine.
|
||||||
|
|
||||||
|
The rules here are easy to get subtly wrong, and getting them wrong is
|
||||||
|
user-visible in two opposite ways: playing a file that is mid-conversion
|
||||||
|
(truncated video), or skipping forever an item that is actually fine.
|
||||||
|
|
||||||
|
Covered:
|
||||||
|
|
||||||
|
1. A normal-size video resolves to itself and is playable.
|
||||||
|
2. An oversized video with no completed conversion reports ``pending``.
|
||||||
|
3. While the ``.kiwy-converting`` marker exists it reports ``converting``.
|
||||||
|
4. Once the output + metadata exist it resolves to the **converted** file.
|
||||||
|
5. A stale marker (from a crash) does not park the item forever.
|
||||||
|
6. Metadata that does not match the current source is ignored — otherwise a
|
||||||
|
different video reusing the same filename would play the previous one.
|
||||||
|
7. The MP4 header parser agrees with ffprobe (the player must not spawn a
|
||||||
|
subprocess on the playback path, so it reads the container directly).
|
||||||
|
|
||||||
|
Run:
|
||||||
|
.venv/bin/python linux/test_media_state.py
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
HERE = Path(__file__).resolve().parent
|
||||||
|
ROOT = HERE.parent
|
||||||
|
SRC = ROOT / 'src'
|
||||||
|
for path in (str(HERE), str(SRC)):
|
||||||
|
if path not in sys.path:
|
||||||
|
sys.path.insert(0, path)
|
||||||
|
|
||||||
|
import media_state as ms # noqa: E402
|
||||||
|
|
||||||
|
failures: list[str] = []
|
||||||
|
checks = 0
|
||||||
|
|
||||||
|
|
||||||
|
def check(label, condition, detail=''):
|
||||||
|
global checks
|
||||||
|
checks += 1
|
||||||
|
print(f' {"PASS" if condition else "FAIL"} {label}'
|
||||||
|
+ (f' — {detail}' if not condition and detail else ''))
|
||||||
|
if not condition:
|
||||||
|
failures.append(label)
|
||||||
|
|
||||||
|
|
||||||
|
def make_video(path, width, height, seconds=1):
|
||||||
|
"""Create a tiny real video of the given size (or None if ffmpeg is absent)."""
|
||||||
|
if not shutil.which('ffmpeg'):
|
||||||
|
return False
|
||||||
|
cmd = [
|
||||||
|
'ffmpeg', '-hide_banner', '-loglevel', 'error',
|
||||||
|
'-f', 'lavfi', '-i', f'testsrc=size={width}x{height}:rate=10:duration={seconds}',
|
||||||
|
'-c:v', 'libx264', '-preset', 'ultrafast', '-pix_fmt', 'yuv420p',
|
||||||
|
'-y', path,
|
||||||
|
]
|
||||||
|
return subprocess.run(cmd, capture_output=True, check=False).returncode == 0
|
||||||
|
|
||||||
|
|
||||||
|
workdir = Path(tempfile.mkdtemp(prefix='kiwy-mediastate-'))
|
||||||
|
print(f'workdir: {workdir}')
|
||||||
|
|
||||||
|
try:
|
||||||
|
big = workdir / 'big.mp4'
|
||||||
|
small = workdir / 'small.mp4'
|
||||||
|
|
||||||
|
if not make_video(big, 2560, 1440) or not make_video(small, 1280, 720):
|
||||||
|
print('SKIP: ffmpeg not available to build fixtures')
|
||||||
|
raise SystemExit(0)
|
||||||
|
|
||||||
|
# ── 1. Within-limit video is playable as itself ──────────────────
|
||||||
|
print('\n[1] A within-limit video resolves to itself')
|
||||||
|
chosen, state = ms.resolve_playable(str(small))
|
||||||
|
check('state is ready', state == 'ready', f'got {state}')
|
||||||
|
check('chosen path is the original', chosen == str(small), f'got {chosen}')
|
||||||
|
|
||||||
|
# ── 2. Oversized + no conversion -> pending ──────────────────────
|
||||||
|
print('\n[2] Oversized video with no conversion reports pending')
|
||||||
|
chosen, state = ms.resolve_playable(str(big))
|
||||||
|
check('state is pending', state == 'pending', f'got {state}')
|
||||||
|
oversized, _w, _h = ms.is_oversized(str(big))
|
||||||
|
check('is_oversized is True', oversized is True)
|
||||||
|
check('a within-limit file is not oversized',
|
||||||
|
ms.is_oversized(str(small))[0] is False)
|
||||||
|
|
||||||
|
# ── 3. Converting marker -> converting ───────────────────────────
|
||||||
|
print('\n[3] The converting marker suppresses playback')
|
||||||
|
ms.begin_conversion(str(big))
|
||||||
|
chosen, state = ms.resolve_playable(str(big))
|
||||||
|
check('state is converting', state == 'converting', f'got {state}')
|
||||||
|
check('is_converting is True', ms.is_converting(str(big)) is True)
|
||||||
|
|
||||||
|
# ── 4. Completed conversion resolves to the output ───────────────
|
||||||
|
print('\n[4] A finished conversion resolves to the converted file')
|
||||||
|
output = ms.normalized_output(str(big))
|
||||||
|
shutil.copy2(str(small), output) # stand-in for the 1080p result
|
||||||
|
with open(output + ms.MARKER_SUFFIX, 'w') as fh:
|
||||||
|
json.dump({'source_size': os.path.getsize(big),
|
||||||
|
'width': 2560, 'height': 1440}, fh)
|
||||||
|
ms.end_conversion(str(big))
|
||||||
|
|
||||||
|
chosen, state = ms.resolve_playable(str(big))
|
||||||
|
check('state is ready', state == 'ready', f'got {state}')
|
||||||
|
check('chosen path is the converted file', chosen == output, f'got {chosen}')
|
||||||
|
|
||||||
|
# ── 5. Stale marker is ignored ───────────────────────────────────
|
||||||
|
print('\n[5] A stale (crashed) marker does not block the item forever')
|
||||||
|
os.remove(output)
|
||||||
|
os.remove(output + ms.MARKER_SUFFIX)
|
||||||
|
ms.begin_conversion(str(big))
|
||||||
|
old = time.time() - (ms.STALE_CONVERSION_SECONDS + 60)
|
||||||
|
os.utime(ms.converting_marker(str(big)), (old, old))
|
||||||
|
check('a stale marker is not treated as converting',
|
||||||
|
ms.is_converting(str(big)) is False)
|
||||||
|
_chosen, state = ms.resolve_playable(str(big))
|
||||||
|
check('so the item is pending rather than converting',
|
||||||
|
state == 'pending', f'got {state}')
|
||||||
|
ms.end_conversion(str(big))
|
||||||
|
|
||||||
|
# ── 6. Mismatched metadata is ignored ────────────────────────────
|
||||||
|
print('\n[6] Metadata for a different source is rejected')
|
||||||
|
shutil.copy2(str(small), output)
|
||||||
|
with open(output + ms.MARKER_SUFFIX, 'w') as fh:
|
||||||
|
json.dump({'source_size': 12345, # does not match big.mp4
|
||||||
|
'width': 2560, 'height': 1440}, fh)
|
||||||
|
check('a stale output is not accepted', ms.normalized_file(str(big)) is None)
|
||||||
|
_chosen, state = ms.resolve_playable(str(big))
|
||||||
|
check('the item is treated as pending', state == 'pending', f'got {state}')
|
||||||
|
|
||||||
|
# ── 7. Header parser agrees with ffprobe ─────────────────────────
|
||||||
|
print('\n[7] The dependency-free MP4 parser matches ffprobe')
|
||||||
|
for label, path, expect in (('2560x1440', str(big), (2560, 1440)),
|
||||||
|
('1280x720', str(small), (1280, 720))):
|
||||||
|
parsed = ms.read_video_size(path)
|
||||||
|
check(f'{label} parsed correctly', parsed == expect, f'got {parsed}')
|
||||||
|
|
||||||
|
if shutil.which('ffprobe'):
|
||||||
|
out = subprocess.run(
|
||||||
|
['ffprobe', '-v', 'error', '-select_streams', 'v:0',
|
||||||
|
'-show_entries', 'stream=width,height', '-of', 'csv=p=0', str(big)],
|
||||||
|
capture_output=True, text=True, check=False).stdout.strip()
|
||||||
|
fw, fh = (int(x) for x in out.split(',')[:2])
|
||||||
|
check('parser matches ffprobe for the oversized file',
|
||||||
|
ms.read_video_size(str(big)) == (fw, fh),
|
||||||
|
f'parser={ms.read_video_size(str(big))} ffprobe={(fw, fh)}')
|
||||||
|
|
||||||
|
check('a non-video file yields (None, None)',
|
||||||
|
ms.read_video_size(str(workdir / 'missing.mp4')) == (None, None))
|
||||||
|
check('a corrupt file yields (None, None)', (lambda p: (
|
||||||
|
p.write_bytes(b'not a video'), ms.read_video_size(str(p)))[1]
|
||||||
|
)(workdir / 'corrupt.mp4') == (None, None))
|
||||||
|
|
||||||
|
finally:
|
||||||
|
shutil.rmtree(workdir, ignore_errors=True)
|
||||||
|
|
||||||
|
print(f'\n{checks - len(failures)}/{checks} checks passed')
|
||||||
|
if failures:
|
||||||
|
print('\nFailed:')
|
||||||
|
for name in failures:
|
||||||
|
print(f' - {name}')
|
||||||
|
raise SystemExit(1)
|
||||||
|
print('All checks passed.')
|
||||||
@@ -0,0 +1,545 @@
|
|||||||
|
"""video_normalizer.py — downscale oversized videos for Raspberry Pi playback.
|
||||||
|
|
||||||
|
The problem
|
||||||
|
-----------
|
||||||
|
The Pi 4 has no usable 4K decode path. Measured on the target device:
|
||||||
|
|
||||||
|
sample-30s.mp4 (1920x1080) decode 3.03x realtime ✅
|
||||||
|
16118765_3840_2160_30fps.mp4 (4K) decode 0.90x realtime ❌
|
||||||
|
|
||||||
|
ffpyplayer decodes in software (there is no hardware H.264 *decode* wired into
|
||||||
|
its pipeline), so a 4K clip cannot be decoded fast enough to feed the screen in
|
||||||
|
real time. The visible symptom is a video that shows one frame and then sits
|
||||||
|
still, or stutters badly, while the playlist timer ticks on.
|
||||||
|
|
||||||
|
The fix
|
||||||
|
-------
|
||||||
|
Normalise oversized media to at most 1920x1080 **once, at sync time**, and hand
|
||||||
|
the player the smaller file. Playback then always runs against a resolution the
|
||||||
|
Pi can decode comfortably.
|
||||||
|
|
||||||
|
Timing (measured, 18.3s 4K clip -> 1080p):
|
||||||
|
|
||||||
|
hardware encode (h264_v4l2m2m) 31s one-off, at download time
|
||||||
|
software encode (libx264) much slower
|
||||||
|
|
||||||
|
31 s is a real cost, but it is paid **once per file** during the playlist sync —
|
||||||
|
which already runs on a worker thread and already downloads tens of megabytes.
|
||||||
|
It is never paid during playback, which is the only place it would matter.
|
||||||
|
|
||||||
|
Hardware encoding is used when available because the Pi 4's H.264 *encoder* is a
|
||||||
|
separate block from its decoder and works well; software encoding of 4K on this
|
||||||
|
SoC is slow enough to be impractical.
|
||||||
|
|
||||||
|
Scope
|
||||||
|
-----
|
||||||
|
Triggered by **resolution only** — ``width > max_width or height > max_height``.
|
||||||
|
A video already within bounds is left untouched (byte-identical), so this never
|
||||||
|
degrades content that already plays.
|
||||||
|
|
||||||
|
Audio is preserved: if the source has an audio track it is copied through
|
||||||
|
(``-c:a copy``, falling back to AAC). That matters because a *silent* video
|
||||||
|
triggers a separate bug in the SDL2_mixer path — see ``_video_has_audio`` in
|
||||||
|
``src/main.py`` — so we must not accidentally create one.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
|
||||||
|
# Importing kivy.logger installs Kivy's own argument parser, which then rejects
|
||||||
|
# this module's CLI flags ("option --dry-run not recognized") and exits. Setting
|
||||||
|
# this BEFORE the import keeps Kivy out of argv handling. It must precede the
|
||||||
|
# kivy import below, hence the import-order exception.
|
||||||
|
os.environ.setdefault('KIVY_NO_ARGS', '1')
|
||||||
|
|
||||||
|
try:
|
||||||
|
from kivy.logger import Logger
|
||||||
|
except Exception: # pragma: no cover - importable without Kivy (tests/CLI)
|
||||||
|
class Logger: # type: ignore
|
||||||
|
@staticmethod
|
||||||
|
def _noop(*args, **kwargs):
|
||||||
|
pass
|
||||||
|
info = debug = warning = error = staticmethod(_noop)
|
||||||
|
|
||||||
|
# The player and the normaliser must agree on the on-disk contract for
|
||||||
|
# "is this converting / has it been converted". That contract lives in one
|
||||||
|
# place (src/media_state.py) and is shared rather than reimplemented.
|
||||||
|
_SRC_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'src')
|
||||||
|
if _SRC_DIR not in sys.path:
|
||||||
|
sys.path.insert(0, _SRC_DIR)
|
||||||
|
|
||||||
|
import media_state # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
def _log(message, level='info'):
|
||||||
|
try:
|
||||||
|
getattr(Logger, level, Logger.info)(f'[VideoNormalizer] {message}')
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
#: Default output ceiling. The Pi's practical decode limit; matches the
|
||||||
|
#: ``max_resolution`` default used elsewhere in the project.
|
||||||
|
DEFAULT_MAX_WIDTH = 1920
|
||||||
|
DEFAULT_MAX_HEIGHT = 1080
|
||||||
|
|
||||||
|
#: Marker suffix written next to a normalised file, recording what was done.
|
||||||
|
#: Re-exported from :mod:`media_state` so the player and normaliser cannot drift.
|
||||||
|
MARKER_SUFFIX = media_state.MARKER_SUFFIX
|
||||||
|
|
||||||
|
#: Encoder preference: the Pi's hardware block first, then a CPU fallback.
|
||||||
|
#: ``h264_v4l2m2m`` is the V4L2 mem2mem H.264 encoder (verified working on the
|
||||||
|
#: target Pi 4); libx264 is the portable fallback for other Linux hosts.
|
||||||
|
HW_ENCODER = 'h264_v4l2m2m'
|
||||||
|
SW_ENCODER = 'libx264'
|
||||||
|
|
||||||
|
#: Bitrate for the normalised output. 1080p signage at ~5 Mbps is visually
|
||||||
|
#: lossless for this use and keeps files small.
|
||||||
|
TARGET_BITRATE = '5M'
|
||||||
|
MAX_BITRATE = '8M'
|
||||||
|
BUFSIZE = '10M'
|
||||||
|
|
||||||
|
|
||||||
|
def _run(args, timeout=30):
|
||||||
|
"""Run a command; return (returncode, stdout+stderr). Never raises."""
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
args, capture_output=True, text=True, timeout=timeout, check=False,
|
||||||
|
)
|
||||||
|
return result.returncode, (result.stdout or '') + (result.stderr or '')
|
||||||
|
except FileNotFoundError:
|
||||||
|
return 127, 'not found'
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
return 124, 'timeout'
|
||||||
|
except Exception as exc:
|
||||||
|
return 1, str(exc)
|
||||||
|
|
||||||
|
|
||||||
|
def find_ffprobe():
|
||||||
|
return shutil.which('ffprobe')
|
||||||
|
|
||||||
|
|
||||||
|
def find_ffmpeg():
|
||||||
|
return shutil.which('ffmpeg')
|
||||||
|
|
||||||
|
|
||||||
|
def probe_video(path):
|
||||||
|
"""Return a dict describing ``path``, or None when it cannot be probed.
|
||||||
|
|
||||||
|
Keys: width, height, codec, duration, has_audio, pix_fmt, level.
|
||||||
|
"""
|
||||||
|
ffprobe = find_ffprobe()
|
||||||
|
if not ffprobe or not os.path.isfile(path):
|
||||||
|
return None
|
||||||
|
|
||||||
|
code, out = _run([
|
||||||
|
ffprobe, '-v', 'error', '-print_format', 'json',
|
||||||
|
'-show_streams', '-show_format', path,
|
||||||
|
], timeout=20)
|
||||||
|
if code != 0:
|
||||||
|
_log(f'ffprobe failed for {os.path.basename(path)}: {out.strip()[:200]}',
|
||||||
|
'warning')
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
data = json.loads(out)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
info = {
|
||||||
|
'width': None, 'height': None, 'codec': None, 'pix_fmt': None,
|
||||||
|
'level': None, 'duration': None, 'has_audio': False,
|
||||||
|
}
|
||||||
|
for stream in data.get('streams', []):
|
||||||
|
if stream.get('codec_type') == 'video' and info['width'] is None:
|
||||||
|
info['width'] = stream.get('width')
|
||||||
|
info['height'] = stream.get('height')
|
||||||
|
info['codec'] = stream.get('codec_name')
|
||||||
|
info['pix_fmt'] = stream.get('pix_fmt')
|
||||||
|
info['level'] = stream.get('level')
|
||||||
|
elif stream.get('codec_type') == 'audio':
|
||||||
|
info['has_audio'] = True
|
||||||
|
|
||||||
|
fmt = data.get('format', {})
|
||||||
|
try:
|
||||||
|
info['duration'] = float(fmt.get('duration'))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
info['duration'] = None
|
||||||
|
return info
|
||||||
|
|
||||||
|
|
||||||
|
def hardware_encoder_available():
|
||||||
|
"""True when the Pi's V4L2 H.264 encoder can actually be opened.
|
||||||
|
|
||||||
|
Checked by running a tiny real encode rather than by grepping
|
||||||
|
``ffmpeg -encoders``: the encoder is listed on builds where the kernel
|
||||||
|
device is missing or busy, and a failed encode at sync time would be far
|
||||||
|
worse than a slightly slower software one.
|
||||||
|
"""
|
||||||
|
ffmpeg = find_ffmpeg()
|
||||||
|
if not ffmpeg:
|
||||||
|
return False
|
||||||
|
code, _ = _run([
|
||||||
|
ffmpeg, '-hide_banner', '-loglevel', 'error',
|
||||||
|
'-f', 'lavfi', '-i', 'testsrc=size=320x240:rate=10:duration=0.5',
|
||||||
|
'-c:v', HW_ENCODER, '-f', 'null', '-',
|
||||||
|
], timeout=60)
|
||||||
|
return code == 0
|
||||||
|
|
||||||
|
|
||||||
|
def needs_normalization(path, max_width=DEFAULT_MAX_WIDTH,
|
||||||
|
max_height=DEFAULT_MAX_HEIGHT):
|
||||||
|
"""(bool, info) — True when ``path`` exceeds the playback ceiling.
|
||||||
|
|
||||||
|
Only resolution is considered. A file at or below the ceiling is returned
|
||||||
|
untouched so content that already plays is never re-encoded.
|
||||||
|
"""
|
||||||
|
info = probe_video(path)
|
||||||
|
if info is None:
|
||||||
|
return False, None
|
||||||
|
width, height = info.get('width'), info.get('height')
|
||||||
|
if not width or not height:
|
||||||
|
return False, info
|
||||||
|
return (width > max_width or height > max_height), info
|
||||||
|
|
||||||
|
|
||||||
|
def _target_size(width, height, max_width, max_height):
|
||||||
|
"""Scale ``width``x``height`` to fit the ceiling, preserving aspect ratio.
|
||||||
|
|
||||||
|
Dimensions are forced even: H.264 4:2:0 requires even width and height, and
|
||||||
|
an odd value makes the encoder fail outright.
|
||||||
|
"""
|
||||||
|
scale = min(max_width / width, max_height / height)
|
||||||
|
new_w = int(width * scale)
|
||||||
|
new_h = int(height * scale)
|
||||||
|
# Round down to even numbers (never up — that could exceed the ceiling).
|
||||||
|
new_w -= new_w % 2
|
||||||
|
new_h -= new_h % 2
|
||||||
|
return max(2, new_w), max(2, new_h)
|
||||||
|
|
||||||
|
|
||||||
|
def normalized_path(original_path, max_width=DEFAULT_MAX_WIDTH,
|
||||||
|
max_height=DEFAULT_MAX_HEIGHT):
|
||||||
|
"""Deterministic output path for the normalised version of a file.
|
||||||
|
|
||||||
|
Kept next to the original (not in a cache dir) so the existing media
|
||||||
|
clean-up logic, which prunes unreferenced files under ``media/``, can do its
|
||||||
|
normal job: the playlist ends up referencing the normalised file and the
|
||||||
|
oversized original is pruned automatically.
|
||||||
|
"""
|
||||||
|
directory, name = os.path.split(original_path)
|
||||||
|
stem, ext = os.path.splitext(name)
|
||||||
|
return os.path.join(
|
||||||
|
directory, f'{stem}_kiwy{max_height}p{ext or ".mp4"}'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_video(path, max_width=DEFAULT_MAX_WIDTH,
|
||||||
|
max_height=DEFAULT_MAX_HEIGHT, force=False, dry_run=False):
|
||||||
|
"""Downscale ``path`` if it exceeds the ceiling.
|
||||||
|
|
||||||
|
Returns a dict:
|
||||||
|
{'status': ..., 'output': ..., 'info': ..., 'elapsed_s': ...}
|
||||||
|
|
||||||
|
``status`` is one of:
|
||||||
|
``within_limit`` — already at or below the ceiling, untouched
|
||||||
|
``reused`` — a previous conversion exists and was verified
|
||||||
|
``normalized`` — a new file was produced
|
||||||
|
``failed`` — could not normalise (caller should use the original)
|
||||||
|
``would`` — dry_run only
|
||||||
|
|
||||||
|
The three success states are kept distinct on purpose: reporting a
|
||||||
|
converted file as "ok" alongside its *original* 4K dimensions reads as
|
||||||
|
"nothing to do" and would hide a missing conversion.
|
||||||
|
|
||||||
|
Never raises: a failure leaves the original file untouched and the caller
|
||||||
|
keeps playing it, because a large-but-playable video is better than none.
|
||||||
|
"""
|
||||||
|
result = {'status': 'within_limit', 'output': None, 'info': None,
|
||||||
|
'elapsed_s': 0.0}
|
||||||
|
|
||||||
|
if not os.path.isfile(path):
|
||||||
|
result['status'] = 'failed'
|
||||||
|
result['info'] = 'file not found'
|
||||||
|
return result
|
||||||
|
|
||||||
|
oversized, info = needs_normalization(path, max_width, max_height)
|
||||||
|
result['info'] = info
|
||||||
|
if not oversized:
|
||||||
|
return result
|
||||||
|
|
||||||
|
output = normalized_path(path, max_width, max_height)
|
||||||
|
|
||||||
|
# Already converted in a previous sync: reuse it. ``media_state`` owns this
|
||||||
|
# decision so the player resolves the same file, by the same rules.
|
||||||
|
existing = media_state.normalized_file(path, max_height)
|
||||||
|
if not force and existing:
|
||||||
|
result['status'] = 'reused'
|
||||||
|
result['output'] = existing
|
||||||
|
_log(f'{os.path.basename(path)} already normalised -> '
|
||||||
|
f'{os.path.basename(existing)}')
|
||||||
|
return result
|
||||||
|
|
||||||
|
new_w, new_h = _target_size(info['width'], info['height'],
|
||||||
|
max_width, max_height)
|
||||||
|
source_mb = os.path.getsize(path) / 1024 / 1024
|
||||||
|
_log(f'{os.path.basename(path)} is {info["width"]}x{info["height"]} '
|
||||||
|
f'({source_mb:.1f} MB) — above {max_width}x{max_height}; '
|
||||||
|
f'normalising to {new_w}x{new_h}')
|
||||||
|
|
||||||
|
if dry_run:
|
||||||
|
result['status'] = 'would'
|
||||||
|
result['output'] = output
|
||||||
|
return result
|
||||||
|
|
||||||
|
ffmpeg = find_ffmpeg()
|
||||||
|
if not ffmpeg:
|
||||||
|
_log('ffmpeg not found — cannot normalise', 'warning')
|
||||||
|
result['status'] = 'failed'
|
||||||
|
result['info'] = 'ffmpeg not found'
|
||||||
|
return result
|
||||||
|
|
||||||
|
# Write to a temp file and rename on success, so a partial or failed
|
||||||
|
# conversion can never be picked up as a valid video.
|
||||||
|
#
|
||||||
|
# The temp name keeps the real extension: ffmpeg infers the output muxer
|
||||||
|
# from the filename, so a bare ".part" fails with "Unable to choose an
|
||||||
|
# output format". The file is only ever moved to ``output`` after a
|
||||||
|
# successful encode, so the temp name is not user-visible.
|
||||||
|
root, ext = os.path.splitext(output)
|
||||||
|
tmp_out = f'{root}.tmp{ext or ".mp4"}'
|
||||||
|
try:
|
||||||
|
if os.path.exists(tmp_out):
|
||||||
|
os.remove(tmp_out)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
audio_args = ['-c:a', 'copy'] if info.get('has_audio') else ['-an']
|
||||||
|
|
||||||
|
# The scale filter runs on the CPU (the slow part for 4K); the encoder is
|
||||||
|
# hardware when possible.
|
||||||
|
vf = f'scale={new_w}:{new_h}:flags=fast_bilinear,format=yuv420p'
|
||||||
|
|
||||||
|
def build(codec_args):
|
||||||
|
"""Assemble one ffmpeg command.
|
||||||
|
|
||||||
|
``-map`` is explicit so extra streams (subtitles, cover art, a second
|
||||||
|
audio track) cannot change the output shape between runs.
|
||||||
|
"""
|
||||||
|
cmd = [ffmpeg, '-hide_banner', '-loglevel', 'error', '-i', path,
|
||||||
|
'-map', '0:v:0']
|
||||||
|
if info.get('has_audio'):
|
||||||
|
cmd += ['-map', '0:a:0?']
|
||||||
|
cmd += ['-vf', vf] + codec_args + audio_args + [
|
||||||
|
'-movflags', '+faststart',
|
||||||
|
'-pix_fmt', 'yuv420p',
|
||||||
|
'-y', tmp_out,
|
||||||
|
]
|
||||||
|
return cmd
|
||||||
|
|
||||||
|
# Try hardware first, then fall back to software. A hardware encoder that
|
||||||
|
# is listed but unusable (device busy, kernel mismatch) fails here rather
|
||||||
|
# than producing a broken file.
|
||||||
|
attempts = []
|
||||||
|
if hardware_encoder_available():
|
||||||
|
attempts.append((
|
||||||
|
f'hardware ({HW_ENCODER})',
|
||||||
|
build(['-c:v', HW_ENCODER, '-b:v', TARGET_BITRATE,
|
||||||
|
'-maxrate', MAX_BITRATE, '-bufsize', BUFSIZE]),
|
||||||
|
))
|
||||||
|
attempts.append((
|
||||||
|
f'software ({SW_ENCODER})',
|
||||||
|
build(['-c:v', SW_ENCODER, '-preset', 'ultrafast', '-crf', '23',
|
||||||
|
'-maxrate', MAX_BITRATE, '-bufsize', BUFSIZE]),
|
||||||
|
))
|
||||||
|
|
||||||
|
started = time.monotonic()
|
||||||
|
|
||||||
|
# Publish the "converting" flag BEFORE the first encoder runs. The player
|
||||||
|
# watches this marker and skips the item while it exists, so an item must be
|
||||||
|
# flagged for the entire window in which its file is being rewritten.
|
||||||
|
marker_written = media_state.begin_conversion(
|
||||||
|
path, note=f'{info["width"]}x{info["height"]} -> {new_w}x{new_h}')
|
||||||
|
if not marker_written:
|
||||||
|
_log(f'could not write the conversion marker for '
|
||||||
|
f'{os.path.basename(path)}; the player may try to show the file '
|
||||||
|
f'while it is being rewritten', 'warning')
|
||||||
|
|
||||||
|
try:
|
||||||
|
return _run_encoders(
|
||||||
|
attempts, path, output, tmp_out, info, source_mb, result,
|
||||||
|
started, f'{new_w}x{new_h}')
|
||||||
|
finally:
|
||||||
|
# Always clear the flag, including on failure: leaving it set would
|
||||||
|
# make the item unplayable until it went stale.
|
||||||
|
media_state.end_conversion(path)
|
||||||
|
|
||||||
|
|
||||||
|
def _run_encoders(attempts, path, output, tmp_out, info, source_mb,
|
||||||
|
result, started, size_note):
|
||||||
|
"""Try each encoder in turn; move the output into place on success."""
|
||||||
|
for label, cmd in attempts:
|
||||||
|
code, out = _run(cmd, timeout=1800)
|
||||||
|
if code == 0 and os.path.isfile(tmp_out) and os.path.getsize(tmp_out) > 0:
|
||||||
|
try:
|
||||||
|
os.replace(tmp_out, output)
|
||||||
|
except OSError as exc:
|
||||||
|
_log(f'could not move normalised file into place: {exc}',
|
||||||
|
'warning')
|
||||||
|
result['status'] = 'failed'
|
||||||
|
return result
|
||||||
|
result['elapsed_s'] = time.monotonic() - started
|
||||||
|
result['status'] = 'normalized'
|
||||||
|
result['output'] = output
|
||||||
|
|
||||||
|
out_mb = os.path.getsize(output) / 1024 / 1024
|
||||||
|
_log(f'normalised with {label} in {result["elapsed_s"]:.0f}s: '
|
||||||
|
f'{source_mb:.1f} MB -> {out_mb:.1f} MB '
|
||||||
|
f'({os.path.basename(output)}) now {size_note}')
|
||||||
|
|
||||||
|
# Metadata last: its presence is what marks the conversion complete
|
||||||
|
# for media_state.normalized_file(), so it must never exist for a
|
||||||
|
# half-written output.
|
||||||
|
try:
|
||||||
|
with open(output + MARKER_SUFFIX, 'w') as fh:
|
||||||
|
json.dump({
|
||||||
|
'source': os.path.basename(path),
|
||||||
|
'source_size': os.path.getsize(path),
|
||||||
|
'width': info['width'],
|
||||||
|
'height': info['height'],
|
||||||
|
'output_width': int(size_note.split('x')[0]),
|
||||||
|
'output_height': int(size_note.split('x')[1]),
|
||||||
|
'encoder': label,
|
||||||
|
'normalized_at': time.strftime('%Y-%m-%dT%H:%M:%S'),
|
||||||
|
}, fh, indent=2)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return result
|
||||||
|
|
||||||
|
_log(f'{label} failed (rc={code}): {out.strip()[:300]}', 'warning')
|
||||||
|
|
||||||
|
try:
|
||||||
|
if os.path.exists(tmp_out):
|
||||||
|
os.remove(tmp_out)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
result['status'] = 'failed'
|
||||||
|
result['info'] = 'all encoders failed'
|
||||||
|
_log(f'could not normalise {os.path.basename(path)}; '
|
||||||
|
f'the original will be used', 'warning')
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_media_dirs(media_dirs, max_width=DEFAULT_MAX_WIDTH,
|
||||||
|
max_height=DEFAULT_MAX_HEIGHT, dry_run=False):
|
||||||
|
"""Normalise every oversized video under the given directories.
|
||||||
|
|
||||||
|
Intended for bulk/offline use (``--all``) and for verifying an install.
|
||||||
|
Returns a list of per-file result dicts.
|
||||||
|
"""
|
||||||
|
results = []
|
||||||
|
seen = set()
|
||||||
|
for directory in media_dirs:
|
||||||
|
if not os.path.isdir(directory):
|
||||||
|
continue
|
||||||
|
for root, _dirs, files in os.walk(directory):
|
||||||
|
for name in sorted(files):
|
||||||
|
if not name.lower().endswith(
|
||||||
|
('.mp4', '.mkv', '.mov', '.webm', '.avi', '.m4v')):
|
||||||
|
continue
|
||||||
|
if '_kiwy' in name or name.endswith(MARKER_SUFFIX):
|
||||||
|
continue # already an output, never re-process
|
||||||
|
path = os.path.join(root, name)
|
||||||
|
if path in seen:
|
||||||
|
continue
|
||||||
|
seen.add(path)
|
||||||
|
size = os.path.getsize(path)
|
||||||
|
key = (path, size)
|
||||||
|
if key in seen:
|
||||||
|
continue
|
||||||
|
results.append(normalize_video(
|
||||||
|
path, max_width, max_height, dry_run=dry_run))
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
# ── CLI ──────────────────────────────────────────────────────────────
|
||||||
|
def main():
|
||||||
|
import argparse
|
||||||
|
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description='Downscale oversized videos for Raspberry Pi playback.')
|
||||||
|
parser.add_argument('paths', nargs='+',
|
||||||
|
help='video files or directories to inspect')
|
||||||
|
parser.add_argument('--max-width', type=int, default=DEFAULT_MAX_WIDTH)
|
||||||
|
parser.add_argument('--max-height', type=int, default=DEFAULT_MAX_HEIGHT)
|
||||||
|
parser.add_argument('--dry-run', action='store_true',
|
||||||
|
help='report what would change, convert nothing')
|
||||||
|
parser.add_argument('--force', action='store_true',
|
||||||
|
help='re-convert even if a normalised copy exists')
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
print(f'max size : {args.max_width}x{args.max_height}')
|
||||||
|
print(f'ffmpeg : {find_ffmpeg()}')
|
||||||
|
print(f'ffprobe : {find_ffprobe()}')
|
||||||
|
if not args.dry_run:
|
||||||
|
hw = 'available' if hardware_encoder_available() else 'unavailable (will use libx264)'
|
||||||
|
print(f'hw enc : {hw}')
|
||||||
|
print()
|
||||||
|
|
||||||
|
changed = 0
|
||||||
|
for target in args.paths:
|
||||||
|
if os.path.isdir(target):
|
||||||
|
results = normalize_media_dirs(
|
||||||
|
[target], args.max_width, args.max_height, args.dry_run)
|
||||||
|
changed += sum(1 for r in results
|
||||||
|
if r['status'] in ('normalized', 'would', 'reused'))
|
||||||
|
continue
|
||||||
|
|
||||||
|
if args.force:
|
||||||
|
oversized, info = needs_normalization(
|
||||||
|
target, args.max_width, args.max_height)
|
||||||
|
if info:
|
||||||
|
out = normalized_path(target, args.max_width, args.max_height)
|
||||||
|
for stale in (out, out + MARKER_SUFFIX):
|
||||||
|
try:
|
||||||
|
os.remove(stale)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
result = normalize_video(target, args.max_width, args.max_height,
|
||||||
|
force=args.force, dry_run=args.dry_run)
|
||||||
|
info = result.get('info')
|
||||||
|
name = os.path.basename(target)
|
||||||
|
if result['status'] == 'within_limit':
|
||||||
|
if isinstance(info, dict) and info.get('width'):
|
||||||
|
print(f' ok {name} {info["width"]}x{info["height"]} '
|
||||||
|
f'(within limit)')
|
||||||
|
else:
|
||||||
|
print(f' ok {name}')
|
||||||
|
elif result['status'] == 'reused':
|
||||||
|
print(f' reuse {name} {info["width"]}x{info["height"]} '
|
||||||
|
f'-> {os.path.basename(result["output"])}')
|
||||||
|
changed += 1
|
||||||
|
elif result['status'] == 'normalized':
|
||||||
|
print(f' DONE {name} -> {os.path.basename(result["output"])} '
|
||||||
|
f'({result["elapsed_s"]:.0f}s)')
|
||||||
|
changed += 1
|
||||||
|
elif result['status'] == 'would':
|
||||||
|
print(f' WOULD {name} {info["width"]}x{info["height"]} '
|
||||||
|
f'-> {os.path.basename(result["output"])}')
|
||||||
|
changed += 1
|
||||||
|
else:
|
||||||
|
print(f' FAILED {name} {result.get("info")}')
|
||||||
|
|
||||||
|
print(f'\n{changed} file(s) need normalisation')
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
raise SystemExit(main())
|
||||||
BIN
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
+4
-4
@@ -1,6 +1,6 @@
|
|||||||
kivy>=2.3.0
|
kivy>=2.3.0
|
||||||
ffpyplayer
|
ffpyplayer
|
||||||
requests==2.32.4
|
requests>=2.32.0,<3.0.0
|
||||||
bcrypt==4.2.1
|
bcrypt>=4.2.0,<5.0.0
|
||||||
aiohttp==3.9.1
|
aiohttp>=3.9.0,<4.0.0
|
||||||
asyncio==3.4.3
|
evdev>=1.6.0
|
||||||
+11
-4
@@ -1,6 +1,13 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
|
|
||||||
# Start Kivy Signage Player
|
# Start Kivy Signage Player (single run, no watchdog).
|
||||||
cd "$(dirname "$0")" && source .venv/bin/activate
|
#
|
||||||
cd "$(dirname "$0")/src"
|
# linux/run_linux.py is the Raspberry Pi entry point: it sets the Wayland
|
||||||
python3 main.py
|
# session environment and injects the Pi-specific display and web-link
|
||||||
|
# behaviour. Running src/main.py directly skips all of that.
|
||||||
|
cd "$(dirname "$0")" || exit 1
|
||||||
|
|
||||||
|
if [ -x .venv/bin/python ]; then
|
||||||
|
exec .venv/bin/python linux/run_linux.py
|
||||||
|
fi
|
||||||
|
exec python3 linux/run_linux.py
|
||||||
|
|||||||
Executable
+76
@@ -0,0 +1,76 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
MARKER_FILE="/etc/kiwy-signage/pi_boot_config_patched.marker"
|
||||||
|
|
||||||
|
is_pi() {
|
||||||
|
if [[ -f /proc/device-tree/model ]] && grep -qi "raspberry pi" /proc/device-tree/model; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if grep -qi "raspberry" /proc/cpuinfo 2>/dev/null; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
find_boot_config() {
|
||||||
|
if [[ -f /boot/firmware/config.txt ]]; then
|
||||||
|
echo "/boot/firmware/config.txt"
|
||||||
|
elif [[ -f /boot/config.txt ]]; then
|
||||||
|
echo "/boot/config.txt"
|
||||||
|
else
|
||||||
|
echo ""
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
upsert_key() {
|
||||||
|
local key="$1"
|
||||||
|
local value="$2"
|
||||||
|
local file="$3"
|
||||||
|
|
||||||
|
if grep -Eq "^[#[:space:]]*${key}=" "$file"; then
|
||||||
|
sed -i -E "s|^[#[:space:]]*${key}=.*|${key}=${value}|" "$file"
|
||||||
|
else
|
||||||
|
echo "${key}=${value}" >> "$file"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
if [[ "${EUID}" -ne 0 ]]; then
|
||||||
|
echo "[pi-boot-patch] Please run as root."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! is_pi; then
|
||||||
|
echo "[pi-boot-patch] Not a Raspberry Pi. Skipping."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -f "$MARKER_FILE" ]]; then
|
||||||
|
echo "[pi-boot-patch] Patch already applied. Skipping."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
BOOT_CONFIG="$(find_boot_config)"
|
||||||
|
if [[ -z "$BOOT_CONFIG" ]]; then
|
||||||
|
echo "[pi-boot-patch] Boot config not found. Skipping."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "[pi-boot-patch] Applying patch to ${BOOT_CONFIG}"
|
||||||
|
|
||||||
|
# Graphics and memory settings for smoother Chromium playback on Pi.
|
||||||
|
upsert_key "dtoverlay" "vc4-kms-v3d" "$BOOT_CONFIG"
|
||||||
|
upsert_key "gpu_mem" "256" "$BOOT_CONFIG"
|
||||||
|
upsert_key "disable_overscan" "1" "$BOOT_CONFIG"
|
||||||
|
|
||||||
|
# Keep HDMI and display active for kiosk use.
|
||||||
|
upsert_key "hdmi_blanking" "0" "$BOOT_CONFIG"
|
||||||
|
upsert_key "hdmi_ignore_cec_init" "1" "$BOOT_CONFIG"
|
||||||
|
upsert_key "hdmi_force_hotplug" "1" "$BOOT_CONFIG"
|
||||||
|
|
||||||
|
mkdir -p "$(dirname "$MARKER_FILE")"
|
||||||
|
date -u +"%Y-%m-%dT%H:%M:%SZ" > "$MARKER_FILE"
|
||||||
|
|
||||||
|
echo "[pi-boot-patch] Patch complete. Marker written to ${MARKER_FILE}"
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Setup script to allow passwordless sudo for WiFi control commands
|
||||||
|
|
||||||
|
echo "Setting up passwordless sudo for WiFi control..."
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Create sudoers file for WiFi commands
|
||||||
|
SUDOERS_FILE="/etc/sudoers.d/kiwy-signage-wifi"
|
||||||
|
|
||||||
|
# Check if running as root
|
||||||
|
if [ "$EUID" -ne 0 ]; then
|
||||||
|
echo "This script must be run as root (use sudo)"
|
||||||
|
echo "Usage: sudo bash setup_wifi_control.sh"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Get the username who invoked sudo
|
||||||
|
ACTUAL_USER="${SUDO_USER:-$USER}"
|
||||||
|
|
||||||
|
echo "Configuring passwordless sudo for user: $ACTUAL_USER"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Create sudoers entry
|
||||||
|
cat > "$SUDOERS_FILE" << EOF
|
||||||
|
# Allow $ACTUAL_USER to control WiFi without password for Kiwy Signage Player
|
||||||
|
$ACTUAL_USER ALL=(ALL) NOPASSWD: /usr/sbin/rfkill block wifi
|
||||||
|
$ACTUAL_USER ALL=(ALL) NOPASSWD: /usr/sbin/rfkill unblock wifi
|
||||||
|
$ACTUAL_USER ALL=(ALL) NOPASSWD: /sbin/ifconfig wlan0 down
|
||||||
|
$ACTUAL_USER ALL=(ALL) NOPASSWD: /sbin/ifconfig wlan0 up
|
||||||
|
$ACTUAL_USER ALL=(ALL) NOPASSWD: /sbin/dhclient wlan0
|
||||||
|
EOF
|
||||||
|
|
||||||
|
# Set correct permissions
|
||||||
|
chmod 0440 "$SUDOERS_FILE"
|
||||||
|
|
||||||
|
echo "✓ Created sudoers file: $SUDOERS_FILE"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Validate the sudoers file
|
||||||
|
if visudo -c -f "$SUDOERS_FILE"; then
|
||||||
|
echo "✓ Sudoers file validated successfully"
|
||||||
|
echo ""
|
||||||
|
echo "Setup complete! User '$ACTUAL_USER' can now control WiFi without password."
|
||||||
|
echo ""
|
||||||
|
echo "Test with:"
|
||||||
|
echo " sudo rfkill block wifi"
|
||||||
|
echo " sudo rfkill unblock wifi"
|
||||||
|
else
|
||||||
|
echo "✗ Error: Sudoers file validation failed"
|
||||||
|
echo "Removing invalid file..."
|
||||||
|
rm -f "$SUDOERS_FILE"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
@@ -0,0 +1,640 @@
|
|||||||
|
"""
|
||||||
|
Edit Popup Module
|
||||||
|
Handles image editing/annotation functionality for the signage player
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import threading
|
||||||
|
from datetime import datetime
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
import time
|
||||||
|
|
||||||
|
from kivy.uix.widget import Widget
|
||||||
|
from kivy.uix.popup import Popup
|
||||||
|
from kivy.uix.label import Label
|
||||||
|
from kivy.graphics import Color, Line, RoundedRectangle
|
||||||
|
from kivy.clock import Clock
|
||||||
|
from kivy.core.window import Window
|
||||||
|
from kivy.logger import Logger
|
||||||
|
from kivy.uix.video import Video
|
||||||
|
|
||||||
|
|
||||||
|
class DrawingLayer(Widget):
|
||||||
|
"""Layer for drawing on top of images"""
|
||||||
|
def __init__(self, reset_callback=None, **kwargs):
|
||||||
|
super(DrawingLayer, self).__init__(**kwargs)
|
||||||
|
self.strokes = [] # Store all drawn lines
|
||||||
|
self.current_color = (1, 0, 0, 1) # Default red
|
||||||
|
self.current_width = 3 # Default thickness
|
||||||
|
self.drawing_enabled = True # Drawing always enabled in edit mode
|
||||||
|
self.reset_callback = reset_callback # Callback to reset countdown timer
|
||||||
|
self._last_draw_time = 0 # For throttling touch updates
|
||||||
|
self._draw_throttle_interval = 0.016 # ~60fps (16ms between updates)
|
||||||
|
|
||||||
|
def on_touch_down(self, touch):
|
||||||
|
if not self.drawing_enabled or not self.collide_point(*touch.pos):
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Reset countdown on user interaction
|
||||||
|
if self.reset_callback:
|
||||||
|
self.reset_callback()
|
||||||
|
|
||||||
|
with self.canvas:
|
||||||
|
Color(*self.current_color)
|
||||||
|
new_line = Line(points=[touch.x, touch.y], width=self.current_width)
|
||||||
|
self.strokes.append({'line': new_line, 'color': self.current_color, 'width': self.current_width})
|
||||||
|
touch.ud['line'] = new_line
|
||||||
|
return True
|
||||||
|
|
||||||
|
def on_touch_move(self, touch):
|
||||||
|
if 'line' in touch.ud and self.drawing_enabled:
|
||||||
|
# Throttle updates to ~60fps for better performance
|
||||||
|
current_time = time.time()
|
||||||
|
if current_time - self._last_draw_time >= self._draw_throttle_interval:
|
||||||
|
touch.ud['line'].points += [touch.x, touch.y]
|
||||||
|
self._last_draw_time = current_time
|
||||||
|
return True
|
||||||
|
|
||||||
|
def undo(self):
|
||||||
|
"""Remove the last stroke"""
|
||||||
|
if self.strokes:
|
||||||
|
last_stroke = self.strokes.pop()
|
||||||
|
self.canvas.remove(last_stroke['line'])
|
||||||
|
Logger.info("DrawingLayer: Undid last stroke")
|
||||||
|
|
||||||
|
def clear_all(self):
|
||||||
|
"""Clear all strokes"""
|
||||||
|
for stroke in self.strokes:
|
||||||
|
self.canvas.remove(stroke['line'])
|
||||||
|
self.strokes = []
|
||||||
|
Logger.info("DrawingLayer: Cleared all strokes")
|
||||||
|
|
||||||
|
def set_color(self, color_tuple):
|
||||||
|
"""Set drawing color (RGBA)"""
|
||||||
|
self.current_color = color_tuple
|
||||||
|
|
||||||
|
def set_thickness(self, value):
|
||||||
|
"""Set line thickness"""
|
||||||
|
self.current_width = value
|
||||||
|
|
||||||
|
|
||||||
|
class EditPopup(Popup):
|
||||||
|
"""Popup for editing/annotating images"""
|
||||||
|
def __init__(self, player_instance, image_path, user_card_data=None,
|
||||||
|
media_id=None, original_filename=None, **kwargs):
|
||||||
|
super(EditPopup, self).__init__(**kwargs)
|
||||||
|
self.player = player_instance
|
||||||
|
self.image_path = image_path
|
||||||
|
self.user_card_data = user_card_data # Store card data to send to server on save
|
||||||
|
# Server naming context: which media item (id) is being edited and what
|
||||||
|
# its original file name is on the server. The server stores edited
|
||||||
|
# media under 'edited_media/<media_id>/', so we must reproduce that.
|
||||||
|
self.media_id = media_id
|
||||||
|
self.original_filename = original_filename # server-side file_name
|
||||||
|
|
||||||
|
# Auto-close timer (5 minutes)
|
||||||
|
self.auto_close_timeout = 300 # 5 minutes in seconds
|
||||||
|
self.remaining_time = self.auto_close_timeout
|
||||||
|
self.countdown_event = None
|
||||||
|
self.auto_close_event = None
|
||||||
|
|
||||||
|
# Pause playback (without auto-resume timer)
|
||||||
|
self.was_paused = self.player.is_paused
|
||||||
|
if not self.was_paused:
|
||||||
|
self.player.is_paused = True
|
||||||
|
Clock.unschedule(self.player.next_media)
|
||||||
|
|
||||||
|
# Cancel auto-resume timer if one exists (don't want auto-resume during editing)
|
||||||
|
if self.player.auto_resume_event:
|
||||||
|
Clock.unschedule(self.player.auto_resume_event)
|
||||||
|
self.player.auto_resume_event = None
|
||||||
|
Logger.info("EditPopup: Cancelled auto-resume timer")
|
||||||
|
|
||||||
|
# Update button icon to play (to show it's paused)
|
||||||
|
self.player.ids.play_pause_btn.background_normal = self.player.resources_path + '/play.png'
|
||||||
|
self.player.ids.play_pause_btn.background_down = self.player.resources_path + '/play.png'
|
||||||
|
|
||||||
|
if self.player.current_widget and isinstance(self.player.current_widget, Video):
|
||||||
|
self.player.current_widget.state = 'pause'
|
||||||
|
|
||||||
|
Logger.info("EditPopup: ⏸ Paused playback (no auto-resume) for editing")
|
||||||
|
|
||||||
|
# Show cursor
|
||||||
|
try:
|
||||||
|
Window.show_cursor = True
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Note: UI is now defined in KV file, but we need to customize after creation
|
||||||
|
# Set image source after KV loads
|
||||||
|
Clock.schedule_once(lambda dt: self._setup_after_kv(), 0)
|
||||||
|
|
||||||
|
def _setup_after_kv(self):
|
||||||
|
"""Setup widgets after KV file has loaded them"""
|
||||||
|
# Set the image source
|
||||||
|
self.ids.image_widget.source = self.image_path
|
||||||
|
|
||||||
|
# Create and insert drawing layer (custom class, must be added programmatically)
|
||||||
|
self.drawing_layer = DrawingLayer(
|
||||||
|
reset_callback=self.reset_countdown,
|
||||||
|
size_hint=(1, 1),
|
||||||
|
pos_hint={'x': 0, 'y': 0}
|
||||||
|
)
|
||||||
|
# Replace placeholder with actual drawing layer
|
||||||
|
content = self.content
|
||||||
|
placeholder_index = content.children.index(self.ids.drawing_layer_placeholder)
|
||||||
|
content.remove_widget(self.ids.drawing_layer_placeholder)
|
||||||
|
content.add_widget(self.drawing_layer, index=placeholder_index)
|
||||||
|
|
||||||
|
# Set icon sources
|
||||||
|
pen_icon_path = os.path.join(self.player.resources_path, 'edit-pen.png')
|
||||||
|
self.ids.color_icon.source = pen_icon_path
|
||||||
|
self.ids.thickness_icon.source = pen_icon_path
|
||||||
|
|
||||||
|
# Bind button callbacks
|
||||||
|
self.ids.undo_btn.bind(on_press=lambda x: (self.reset_countdown(), self.drawing_layer.undo()))
|
||||||
|
self.ids.clear_btn.bind(on_press=lambda x: (self.reset_countdown(), self.drawing_layer.clear_all()))
|
||||||
|
self.ids.save_btn.bind(on_press=self.save_image)
|
||||||
|
self.ids.cancel_btn.bind(on_press=self.close_without_saving)
|
||||||
|
|
||||||
|
# Bind color buttons
|
||||||
|
self.ids.red_btn.bind(on_press=lambda x: self.drawing_layer.set_color((1, 0, 0, 1)))
|
||||||
|
self.ids.blue_btn.bind(on_press=lambda x: self.drawing_layer.set_color((0, 0, 1, 1)))
|
||||||
|
self.ids.green_btn.bind(on_press=lambda x: self.drawing_layer.set_color((0, 1, 0, 1)))
|
||||||
|
self.ids.black_btn.bind(on_press=lambda x: self.drawing_layer.set_color((0, 0, 0, 1)))
|
||||||
|
self.ids.white_btn.bind(on_press=lambda x: self.drawing_layer.set_color((1, 1, 1, 1)))
|
||||||
|
|
||||||
|
# Bind thickness buttons
|
||||||
|
self.ids.small_btn.bind(on_press=lambda x: self.drawing_layer.set_thickness(2))
|
||||||
|
self.ids.medium_btn.bind(on_press=lambda x: self.drawing_layer.set_thickness(5))
|
||||||
|
self.ids.large_btn.bind(on_press=lambda x: self.drawing_layer.set_thickness(10))
|
||||||
|
|
||||||
|
# Add rounded corners to buttons
|
||||||
|
for btn_id in ['undo_btn', 'clear_btn', 'save_btn', 'cancel_btn']:
|
||||||
|
btn = self.ids[btn_id]
|
||||||
|
btn.bind(pos=self._make_rounded_btn, size=self._make_rounded_btn)
|
||||||
|
|
||||||
|
# Add circular corners to color/thickness buttons
|
||||||
|
for btn_id in ['red_btn', 'blue_btn', 'green_btn', 'black_btn', 'white_btn',
|
||||||
|
'small_btn', 'medium_btn', 'large_btn']:
|
||||||
|
btn = self.ids[btn_id]
|
||||||
|
btn.bind(pos=self._make_round, size=self._make_round)
|
||||||
|
|
||||||
|
# Reference to countdown label
|
||||||
|
self.countdown_label = self.ids.countdown_label
|
||||||
|
|
||||||
|
# Bind to dismiss
|
||||||
|
self.bind(on_dismiss=self.on_popup_dismiss)
|
||||||
|
|
||||||
|
# Start countdown timer (updates every second)
|
||||||
|
self.countdown_event = Clock.schedule_interval(self.update_countdown, 1)
|
||||||
|
|
||||||
|
# Start auto-close timer (closes after 5 minutes)
|
||||||
|
self.auto_close_event = Clock.schedule_once(self.auto_close, self.auto_close_timeout)
|
||||||
|
|
||||||
|
Logger.info(f"EditPopup: Opened for image {os.path.basename(self.image_path)} (auto-close in 5 minutes)")
|
||||||
|
|
||||||
|
def update_countdown(self, dt):
|
||||||
|
"""Update countdown display"""
|
||||||
|
self.remaining_time -= 1
|
||||||
|
|
||||||
|
# Format time as MM:SS
|
||||||
|
minutes = self.remaining_time // 60
|
||||||
|
seconds = self.remaining_time % 60
|
||||||
|
self.countdown_label.text = f"{minutes}:{seconds:02d}"
|
||||||
|
|
||||||
|
# Change color as time runs out
|
||||||
|
if self.remaining_time <= 60: # Last minute - red
|
||||||
|
self.countdown_label.color = (1, 0.2, 0.2, 1)
|
||||||
|
elif self.remaining_time <= 120: # Last 2 minutes - yellow
|
||||||
|
self.countdown_label.color = (1, 1, 0, 1)
|
||||||
|
else:
|
||||||
|
self.countdown_label.color = (1, 1, 1, 1) # White
|
||||||
|
|
||||||
|
if self.remaining_time <= 0:
|
||||||
|
Clock.unschedule(self.countdown_event)
|
||||||
|
|
||||||
|
def reset_countdown(self):
|
||||||
|
"""Reset countdown timer on user interaction"""
|
||||||
|
self.remaining_time = self.auto_close_timeout
|
||||||
|
|
||||||
|
# Cancel existing timers
|
||||||
|
if self.countdown_event:
|
||||||
|
Clock.unschedule(self.countdown_event)
|
||||||
|
if self.auto_close_event:
|
||||||
|
Clock.unschedule(self.auto_close_event)
|
||||||
|
|
||||||
|
# Restart timers
|
||||||
|
self.countdown_event = Clock.schedule_interval(self.update_countdown, 1)
|
||||||
|
self.auto_close_event = Clock.schedule_once(self.auto_close, self.auto_close_timeout)
|
||||||
|
|
||||||
|
# Reset color to white
|
||||||
|
self.countdown_label.color = (1, 1, 1, 1)
|
||||||
|
|
||||||
|
Logger.info("EditPopup: Countdown reset to 5:00")
|
||||||
|
|
||||||
|
def auto_close(self, dt):
|
||||||
|
"""Auto-close the edit popup after timeout"""
|
||||||
|
Logger.info("EditPopup: Auto-closing after 5 minutes of inactivity")
|
||||||
|
self.close_without_saving(None)
|
||||||
|
|
||||||
|
def _make_rounded_btn(self, instance, value):
|
||||||
|
"""Make toolbar button with slightly rounded corners"""
|
||||||
|
instance.canvas.before.clear()
|
||||||
|
with instance.canvas.before:
|
||||||
|
Color(*instance.background_color)
|
||||||
|
instance.round_rect = RoundedRectangle(
|
||||||
|
pos=instance.pos,
|
||||||
|
size=instance.size,
|
||||||
|
radius=[10]
|
||||||
|
)
|
||||||
|
|
||||||
|
def _make_round(self, instance, value):
|
||||||
|
"""Make sidebar button fully circular"""
|
||||||
|
instance.canvas.before.clear()
|
||||||
|
with instance.canvas.before:
|
||||||
|
Color(*instance.background_color)
|
||||||
|
instance.round_rect = RoundedRectangle(
|
||||||
|
pos=instance.pos,
|
||||||
|
size=instance.size,
|
||||||
|
radius=[instance.height / 2]
|
||||||
|
)
|
||||||
|
|
||||||
|
def save_image(self, instance):
|
||||||
|
"""Save the edited image"""
|
||||||
|
try:
|
||||||
|
# Edited media is stored on the server under
|
||||||
|
# 'edited_media/<media_id>/'. Reproduce that subfolder locally so
|
||||||
|
# the upload naming matches what the server expects. Fall back to
|
||||||
|
# the flat 'edited_media/' folder when no media_id is available.
|
||||||
|
edited_base = os.path.join(self.player.base_dir, 'media', 'edited_media')
|
||||||
|
if self.media_id is not None:
|
||||||
|
edited_dir = os.path.join(edited_base, str(self.media_id))
|
||||||
|
else:
|
||||||
|
edited_dir = edited_base
|
||||||
|
os.makedirs(edited_dir, exist_ok=True)
|
||||||
|
|
||||||
|
# Get original filename
|
||||||
|
base_name = os.path.basename(self.image_path)
|
||||||
|
name, ext = os.path.splitext(base_name)
|
||||||
|
|
||||||
|
# Determine version number
|
||||||
|
version_match = re.search(r'_e_v(\d+)$', name)
|
||||||
|
if version_match:
|
||||||
|
# Increment existing version
|
||||||
|
current_version = int(version_match.group(1))
|
||||||
|
new_version = current_version + 1
|
||||||
|
# Remove old version suffix
|
||||||
|
original_name = re.sub(r'_e_v\d+$', '', name)
|
||||||
|
new_name = f"{original_name}_e_v{new_version}"
|
||||||
|
else:
|
||||||
|
# First edit version
|
||||||
|
original_name = name
|
||||||
|
new_name = f"{name}_e_v1"
|
||||||
|
|
||||||
|
# Generate output path
|
||||||
|
output_filename = f"{new_name}.jpg"
|
||||||
|
output_path = os.path.join(edited_dir, output_filename)
|
||||||
|
|
||||||
|
# Temporarily hide toolbars
|
||||||
|
self.ids.top_toolbar.opacity = 0
|
||||||
|
self.ids.right_sidebar.opacity = 0
|
||||||
|
|
||||||
|
# Force canvas update
|
||||||
|
self.content.canvas.ask_update()
|
||||||
|
|
||||||
|
# Small delay to ensure rendering is complete
|
||||||
|
def do_export(dt):
|
||||||
|
try:
|
||||||
|
# Export only the visible content (image + drawings, no toolbars)
|
||||||
|
self.content.export_to_png(output_path)
|
||||||
|
|
||||||
|
Logger.info(f"EditPopup: Saved edited image to {output_path}")
|
||||||
|
|
||||||
|
# ALSO overwrite the original image with edited content
|
||||||
|
Logger.info(f"EditPopup: Overwriting original image at {self.image_path}")
|
||||||
|
|
||||||
|
# Get original file info before overwrite
|
||||||
|
orig_size = os.path.getsize(self.image_path)
|
||||||
|
orig_mtime = os.path.getmtime(self.image_path)
|
||||||
|
|
||||||
|
# Overwrite the file
|
||||||
|
shutil.copy2(output_path, self.image_path)
|
||||||
|
|
||||||
|
# Force file system sync to ensure data is written to disk.
|
||||||
|
# NOTE: os.sync() does not exist on every platform and used
|
||||||
|
# to raise AttributeError, aborting the whole pipeline before
|
||||||
|
# the metadata/upload steps. Use a best-effort flushes that can
|
||||||
|
# never break the save/upload flow.
|
||||||
|
try:
|
||||||
|
if hasattr(os, 'sync'):
|
||||||
|
os.sync()
|
||||||
|
else:
|
||||||
|
with open(output_path, 'rb') as _f:
|
||||||
|
try:
|
||||||
|
os.fsync(_f.fileno())
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
except Exception as _sync_err:
|
||||||
|
Logger.warning(f"EditPopup: File sync skipped ({_sync_err})")
|
||||||
|
|
||||||
|
# Verify the overwrite
|
||||||
|
new_size = os.path.getsize(self.image_path)
|
||||||
|
new_mtime = os.path.getmtime(self.image_path)
|
||||||
|
|
||||||
|
Logger.info(f"EditPopup: ✓ File overwritten:")
|
||||||
|
Logger.info(f" - Size: {orig_size} -> {new_size} bytes (changed: {new_size != orig_size})")
|
||||||
|
Logger.info(f" - Modified time: {orig_mtime} -> {new_mtime} (changed: {new_mtime > orig_mtime})")
|
||||||
|
Logger.info(f"EditPopup: ✓ File synced to disk")
|
||||||
|
|
||||||
|
# Restore toolbars
|
||||||
|
self.ids.top_toolbar.opacity = 1
|
||||||
|
self.ids.right_sidebar.opacity = 1
|
||||||
|
|
||||||
|
# Create and save metadata. This runs in its own guarded
|
||||||
|
# block so that a failure here cannot silently stop the
|
||||||
|
# upload — the two steps are intentionally decoupled.
|
||||||
|
json_filename = None
|
||||||
|
try:
|
||||||
|
json_filename = self._save_metadata(edited_dir, new_name, base_name,
|
||||||
|
new_version if version_match else 1, output_filename)
|
||||||
|
except Exception as meta_err:
|
||||||
|
Logger.error(f"EditPopup: Metadata save failed: {meta_err}")
|
||||||
|
|
||||||
|
# Upload to server in background (continues after popup closes)
|
||||||
|
upload_thread = threading.Thread(
|
||||||
|
target=self._upload_to_server,
|
||||||
|
args=(output_path, json_filename),
|
||||||
|
daemon=True
|
||||||
|
)
|
||||||
|
upload_thread.start() # Re-enabled
|
||||||
|
|
||||||
|
# NOW show saving popup AFTER everything is done
|
||||||
|
def show_saving_and_dismiss(dt):
|
||||||
|
# Create label with background showing detailed save status
|
||||||
|
save_msg = (
|
||||||
|
'Saved locally!\n'
|
||||||
|
'Uploading to server...'
|
||||||
|
)
|
||||||
|
save_label = Label(
|
||||||
|
text=save_msg,
|
||||||
|
font_size='24sp',
|
||||||
|
color=(1, 1, 1, 1),
|
||||||
|
bold=True
|
||||||
|
)
|
||||||
|
|
||||||
|
saving_popup = Popup(
|
||||||
|
title='',
|
||||||
|
content=save_label,
|
||||||
|
size_hint=(0.85, 0.4),
|
||||||
|
auto_dismiss=False,
|
||||||
|
separator_height=0,
|
||||||
|
background_color=(0.2, 0.7, 0.2, 0.95) # Green background
|
||||||
|
)
|
||||||
|
saving_popup.open()
|
||||||
|
Logger.info("EditPopup: Saving confirmation popup opened")
|
||||||
|
|
||||||
|
# Update message after 3 seconds to show upload is happening
|
||||||
|
def update_message(dt):
|
||||||
|
if saving_popup:
|
||||||
|
save_label.text = (
|
||||||
|
'✓ Saved to device\n'
|
||||||
|
'Upload in progress...'
|
||||||
|
)
|
||||||
|
|
||||||
|
Clock.schedule_once(update_message, 3.0)
|
||||||
|
|
||||||
|
# Dismiss both popups after 4 seconds
|
||||||
|
def dismiss_all(dt):
|
||||||
|
saving_popup.dismiss()
|
||||||
|
Logger.info(f"EditPopup: Dismissing to resume playback...")
|
||||||
|
self.dismiss()
|
||||||
|
|
||||||
|
Clock.schedule_once(dismiss_all, 4.0)
|
||||||
|
|
||||||
|
# Small delay to ensure UI is ready, then show popup
|
||||||
|
Clock.schedule_once(show_saving_and_dismiss, 0.1)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
Logger.error(f"EditPopup: Error in export: {e}")
|
||||||
|
import traceback
|
||||||
|
Logger.error(f"EditPopup: Traceback: {traceback.format_exc()}")
|
||||||
|
self.title = f'Error saving: {e}'
|
||||||
|
# Restore toolbars
|
||||||
|
self.ids.top_toolbar.opacity = 1
|
||||||
|
self.ids.right_sidebar.opacity = 1
|
||||||
|
# Still dismiss on error after brief delay
|
||||||
|
Clock.schedule_once(lambda dt: self.dismiss(), 1)
|
||||||
|
|
||||||
|
Clock.schedule_once(do_export, 0.1)
|
||||||
|
return
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
Logger.error(f"EditPopup: Error saving image: {e}")
|
||||||
|
import traceback
|
||||||
|
Logger.error(f"EditPopup: Traceback: {traceback.format_exc()}")
|
||||||
|
self.title = f'Error saving: {e}'
|
||||||
|
|
||||||
|
def _save_metadata(self, edited_dir, new_name, base_name, version, output_filename):
|
||||||
|
"""Save metadata JSON file"""
|
||||||
|
metadata = {
|
||||||
|
'time_of_modification': datetime.now().isoformat(),
|
||||||
|
'original_name': base_name,
|
||||||
|
'new_name': output_filename,
|
||||||
|
'original_path': self.image_path,
|
||||||
|
'version': version,
|
||||||
|
'user_card_data': self.user_card_data # Card data from reader (or None)
|
||||||
|
}
|
||||||
|
# Include the server-side file name and media id so the server can
|
||||||
|
# attach the edit to the correct media item.
|
||||||
|
if self.original_filename:
|
||||||
|
metadata['original_filename'] = self.original_filename
|
||||||
|
else:
|
||||||
|
metadata['original_filename'] = os.path.basename(self.image_path)
|
||||||
|
if self.media_id is not None:
|
||||||
|
metadata['media_id'] = self.media_id
|
||||||
|
|
||||||
|
# Save metadata JSON
|
||||||
|
json_filename = f"{new_name}_metadata.json"
|
||||||
|
json_path = os.path.join(edited_dir, json_filename)
|
||||||
|
with open(json_path, 'w') as f:
|
||||||
|
json.dump(metadata, f, indent=2)
|
||||||
|
|
||||||
|
Logger.info(f"EditPopup: Saved metadata to {json_path} (user_card_data: {self.user_card_data})")
|
||||||
|
return json_path
|
||||||
|
|
||||||
|
def _upload_to_server(self, image_path, metadata_path):
|
||||||
|
"""Upload edited image and metadata to server (runs in background thread)"""
|
||||||
|
try:
|
||||||
|
import requests
|
||||||
|
from get_playlists_v2 import get_auth_instance
|
||||||
|
|
||||||
|
# Get authenticated instance
|
||||||
|
auth = get_auth_instance()
|
||||||
|
if not auth or not auth.is_authenticated():
|
||||||
|
Logger.warning("EditPopup: Cannot upload - not authenticated (edited media saved locally only)")
|
||||||
|
Logger.warning("EditPopup: Server will NOT receive this edit")
|
||||||
|
return False
|
||||||
|
|
||||||
|
server_url = auth.auth_data.get('server_url')
|
||||||
|
auth_code = auth.auth_data.get('auth_code')
|
||||||
|
|
||||||
|
if not server_url or not auth_code:
|
||||||
|
Logger.warning("EditPopup: Missing server URL or auth code (upload skipped)")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Load metadata from file (or build it in memory if the metadata
|
||||||
|
# file was not written — the upload must still go through).
|
||||||
|
metadata = None
|
||||||
|
if metadata_path and os.path.exists(metadata_path):
|
||||||
|
try:
|
||||||
|
with open(metadata_path, 'r') as meta_file:
|
||||||
|
metadata = json.load(meta_file)
|
||||||
|
except Exception as e:
|
||||||
|
Logger.warning(f"EditPopup: Could not read metadata file: {e}")
|
||||||
|
if not metadata:
|
||||||
|
metadata = {
|
||||||
|
'time_of_modification': datetime.now().isoformat(),
|
||||||
|
'original_name': os.path.basename(image_path),
|
||||||
|
'new_name': os.path.basename(image_path),
|
||||||
|
'version': 1,
|
||||||
|
'user_card_data': self.user_card_data,
|
||||||
|
}
|
||||||
|
if self.original_filename:
|
||||||
|
metadata['original_filename'] = self.original_filename
|
||||||
|
if self.media_id is not None:
|
||||||
|
metadata['media_id'] = self.media_id
|
||||||
|
|
||||||
|
# Prepare upload URL - send to the original file endpoint
|
||||||
|
upload_url = f"{server_url}/api/player-edit-media"
|
||||||
|
headers = {'Authorization': f'Bearer {auth_code}'}
|
||||||
|
|
||||||
|
# Ensure the original filename (server-side name) is present so the
|
||||||
|
# server knows which file was edited. Prefer the media context we
|
||||||
|
# captured when the edit popup opened.
|
||||||
|
if not metadata.get('original_filename'):
|
||||||
|
metadata['original_filename'] = os.path.basename(metadata.get('original_path', image_path))
|
||||||
|
|
||||||
|
# Disable SSL verification for self-signed certificates (like main code does)
|
||||||
|
# Note: This is NOT recommended for production with untrusted servers
|
||||||
|
Logger.warning("⚠️ SSL verification disabled for edited media upload - only use with trusted servers")
|
||||||
|
|
||||||
|
# Prepare file and data for upload
|
||||||
|
with open(image_path, 'rb') as img_file:
|
||||||
|
files = {
|
||||||
|
'image_file': (metadata['original_filename'], img_file, 'image/jpeg')
|
||||||
|
}
|
||||||
|
|
||||||
|
# Send metadata as JSON string in form data
|
||||||
|
data = {
|
||||||
|
'metadata': json.dumps(metadata),
|
||||||
|
'original_file': metadata['original_filename']
|
||||||
|
}
|
||||||
|
|
||||||
|
Logger.info(f"EditPopup: 📤 Uploading edited media to {upload_url}")
|
||||||
|
Logger.info(f"EditPopup: - Original file: {metadata['original_filename']}")
|
||||||
|
Logger.info(f"EditPopup: - Edited image: {image_path}")
|
||||||
|
Logger.info(f"EditPopup: - Metadata: {metadata_path}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = requests.post(upload_url, headers=headers, files=files, data=data, timeout=30, verify=False)
|
||||||
|
|
||||||
|
if response.status_code == 200:
|
||||||
|
response_data = response.json()
|
||||||
|
Logger.info(f"EditPopup: ✅ Successfully uploaded edited media to server")
|
||||||
|
Logger.info(f"EditPopup: Server response: {response_data}")
|
||||||
|
|
||||||
|
# DO NOT delete local files - keep them as backup
|
||||||
|
# In case the server doesn't process them, we want to keep the edits locally
|
||||||
|
Logger.info(f"EditPopup: ✓ Keeping local edited files as backup:")
|
||||||
|
Logger.info(f" - Image: {image_path}")
|
||||||
|
Logger.info(f" - Metadata: {metadata_path}")
|
||||||
|
|
||||||
|
# Trigger playlist reload if server provides new version
|
||||||
|
try:
|
||||||
|
new_version = response_data.get('new_playlist_version')
|
||||||
|
if new_version:
|
||||||
|
Logger.info(f"EditPopup: 📡 Server reports new playlist version: {new_version}")
|
||||||
|
Logger.info(f"EditPopup: Triggering playlist reload on next cycle...")
|
||||||
|
|
||||||
|
# Playlist reload disabled for now - was causing crashes
|
||||||
|
# Will be re-enabled with better implementation
|
||||||
|
Logger.info(f"EditPopup: ✓ Edited media uploaded successfully")
|
||||||
|
except Exception as e:
|
||||||
|
Logger.warning(f"EditPopup: Could not process playlist version from server: {e}")
|
||||||
|
|
||||||
|
return True
|
||||||
|
elif response.status_code == 404:
|
||||||
|
Logger.error("EditPopup: ❌ Upload endpoint not found on server (404)")
|
||||||
|
Logger.error("EditPopup: Server may not support edited media uploads")
|
||||||
|
Logger.error("EditPopup: Edited media is saved locally only")
|
||||||
|
return False
|
||||||
|
elif response.status_code == 401:
|
||||||
|
Logger.error("EditPopup: ❌ Authentication failed (401) - check auth credentials")
|
||||||
|
Logger.error("EditPopup: Edited media is saved locally only")
|
||||||
|
return False
|
||||||
|
else:
|
||||||
|
Logger.error(f"EditPopup: ❌ Upload failed with status {response.status_code}")
|
||||||
|
Logger.error(f"EditPopup: Response: {response.text}")
|
||||||
|
Logger.error("EditPopup: Edited media is saved locally only")
|
||||||
|
return False
|
||||||
|
|
||||||
|
except requests.exceptions.Timeout:
|
||||||
|
Logger.error("EditPopup: ❌ Upload timed out after 30 seconds")
|
||||||
|
Logger.error("EditPopup: Check network connection")
|
||||||
|
Logger.error("EditPopup: Edited media is saved locally only")
|
||||||
|
return False
|
||||||
|
except requests.exceptions.ConnectionError as e:
|
||||||
|
Logger.error(f"EditPopup: ❌ Cannot connect to server: {e}")
|
||||||
|
Logger.error("EditPopup: Check server URL and network connection")
|
||||||
|
Logger.error("EditPopup: Edited media is saved locally only")
|
||||||
|
return False
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
Logger.error(f"EditPopup: ❌ Unexpected error during upload: {e}")
|
||||||
|
import traceback
|
||||||
|
Logger.error(f"EditPopup: Traceback: {traceback.format_exc()}")
|
||||||
|
Logger.error("EditPopup: Edited media is saved locally only")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def close_without_saving(self, instance):
|
||||||
|
"""Close without saving"""
|
||||||
|
Logger.info("EditPopup: Closed without saving")
|
||||||
|
self.dismiss()
|
||||||
|
|
||||||
|
def on_popup_dismiss(self, *args):
|
||||||
|
"""Resume playback when popup closes - reload current image and continue"""
|
||||||
|
# Cancel countdown and auto-close timers
|
||||||
|
if self.countdown_event:
|
||||||
|
Clock.unschedule(self.countdown_event)
|
||||||
|
if self.auto_close_event:
|
||||||
|
Clock.unschedule(self.auto_close_event)
|
||||||
|
|
||||||
|
# Force remove current widget immediately
|
||||||
|
if self.player.current_widget:
|
||||||
|
Logger.info("EditPopup: Removing current widget to force reload")
|
||||||
|
self.player.ids.content_area.remove_widget(self.player.current_widget)
|
||||||
|
self.player.current_widget = None
|
||||||
|
Logger.info("EditPopup: ✓ Widget removed, ready for fresh load")
|
||||||
|
|
||||||
|
# Resume playback if it wasn't paused before editing
|
||||||
|
if not self.was_paused:
|
||||||
|
self.player.is_paused = False
|
||||||
|
|
||||||
|
# Update button icon to pause (to show it's playing)
|
||||||
|
self.player.ids.play_pause_btn.background_normal = self.player.resources_path + '/pause.png'
|
||||||
|
self.player.ids.play_pause_btn.background_down = self.player.resources_path + '/pause.png'
|
||||||
|
|
||||||
|
# Add delay to ensure file write is complete and synced
|
||||||
|
def reload_media(dt):
|
||||||
|
Logger.info("EditPopup: ▶ Resuming playback and reloading edited image (force_reload=True)")
|
||||||
|
self.player.play_current_media(force_reload=True)
|
||||||
|
|
||||||
|
Clock.schedule_once(reload_media, 0.5)
|
||||||
|
else:
|
||||||
|
Logger.info("EditPopup: Dismissed, keeping paused state")
|
||||||
|
|
||||||
|
# Restart control hide timer
|
||||||
|
self.player.schedule_hide_controls()
|
||||||
@@ -1,346 +0,0 @@
|
|||||||
import os
|
|
||||||
import json
|
|
||||||
import requests
|
|
||||||
import bcrypt
|
|
||||||
import re
|
|
||||||
import datetime
|
|
||||||
import logging
|
|
||||||
|
|
||||||
# Set up logging
|
|
||||||
logging.basicConfig(level=logging.INFO)
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
def send_player_feedback(config, message, status="active", playlist_version=None, error_details=None):
|
|
||||||
"""
|
|
||||||
Send feedback to the server about player status.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
config (dict): Configuration containing server details
|
|
||||||
message (str): Main feedback message
|
|
||||||
status (str): Player status - "active", "playing", "error", "restarting"
|
|
||||||
playlist_version (int, optional): Current playlist version being played
|
|
||||||
error_details (str, optional): Error details if status is "error"
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
bool: True if feedback sent successfully, False otherwise
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
server = config.get("server_ip", "")
|
|
||||||
host = config.get("screen_name", "")
|
|
||||||
quick = config.get("quickconnect_key", "")
|
|
||||||
port = config.get("port", "")
|
|
||||||
|
|
||||||
# Construct server URL
|
|
||||||
# Remove protocol if already present
|
|
||||||
server_clean = server.replace('http://', '').replace('https://', '')
|
|
||||||
ip_pattern = r'^\d+\.\d+\.\d+\.\d+$'
|
|
||||||
if re.match(ip_pattern, server_clean):
|
|
||||||
feedback_url = f'http://{server_clean}:{port}/api/player-feedback'
|
|
||||||
else:
|
|
||||||
# Use original server if it has protocol, otherwise add http://
|
|
||||||
if server.startswith(('http://', 'https://')):
|
|
||||||
feedback_url = f'{server}/api/player-feedback'
|
|
||||||
else:
|
|
||||||
feedback_url = f'http://{server}/api/player-feedback'
|
|
||||||
|
|
||||||
# Prepare feedback data
|
|
||||||
feedback_data = {
|
|
||||||
'hostname': host,
|
|
||||||
'quickconnect_code': quick,
|
|
||||||
'message': message,
|
|
||||||
'status': status,
|
|
||||||
'timestamp': datetime.datetime.now().isoformat(),
|
|
||||||
'playlist_version': playlist_version,
|
|
||||||
'error_details': error_details
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info(f"Sending feedback to {feedback_url}: {feedback_data}")
|
|
||||||
|
|
||||||
# Send POST request
|
|
||||||
response = requests.post(feedback_url, json=feedback_data, timeout=10)
|
|
||||||
|
|
||||||
if response.status_code == 200:
|
|
||||||
logger.info(f"Feedback sent successfully: {message}")
|
|
||||||
return True
|
|
||||||
else:
|
|
||||||
logger.warning(f"Feedback failed with status {response.status_code}: {response.text}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
except requests.exceptions.RequestException as e:
|
|
||||||
logger.error(f"Failed to send feedback: {e}")
|
|
||||||
return False
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Unexpected error sending feedback: {e}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
def send_playlist_check_feedback(config, playlist_version=None):
|
|
||||||
"""
|
|
||||||
Send feedback when playlist is checked for updates.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
config (dict): Configuration containing server details
|
|
||||||
playlist_version (int, optional): Current playlist version
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
bool: True if feedback sent successfully, False otherwise
|
|
||||||
"""
|
|
||||||
player_name = config.get("screen_name", "unknown")
|
|
||||||
version_info = f"v{playlist_version}" if playlist_version else "unknown"
|
|
||||||
message = f"player {player_name}, is active, Playing {version_info}"
|
|
||||||
|
|
||||||
return send_player_feedback(
|
|
||||||
config=config,
|
|
||||||
message=message,
|
|
||||||
status="active",
|
|
||||||
playlist_version=playlist_version
|
|
||||||
)
|
|
||||||
|
|
||||||
def send_playlist_restart_feedback(config, playlist_version=None):
|
|
||||||
"""
|
|
||||||
Send feedback when playlist loop ends and restarts.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
config (dict): Configuration containing server details
|
|
||||||
playlist_version (int, optional): Current playlist version
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
bool: True if feedback sent successfully, False otherwise
|
|
||||||
"""
|
|
||||||
player_name = config.get("screen_name", "unknown")
|
|
||||||
version_info = f"v{playlist_version}" if playlist_version else "unknown"
|
|
||||||
message = f"player {player_name}, playlist loop completed, restarting {version_info}"
|
|
||||||
|
|
||||||
return send_player_feedback(
|
|
||||||
config=config,
|
|
||||||
message=message,
|
|
||||||
status="restarting",
|
|
||||||
playlist_version=playlist_version
|
|
||||||
)
|
|
||||||
|
|
||||||
def send_player_error_feedback(config, error_message, playlist_version=None):
|
|
||||||
"""
|
|
||||||
Send feedback when an error occurs in the player.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
config (dict): Configuration containing server details
|
|
||||||
error_message (str): Description of the error
|
|
||||||
playlist_version (int, optional): Current playlist version
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
bool: True if feedback sent successfully, False otherwise
|
|
||||||
"""
|
|
||||||
player_name = config.get("screen_name", "unknown")
|
|
||||||
message = f"player {player_name}, error occurred"
|
|
||||||
|
|
||||||
return send_player_feedback(
|
|
||||||
config=config,
|
|
||||||
message=message,
|
|
||||||
status="error",
|
|
||||||
playlist_version=playlist_version,
|
|
||||||
error_details=error_message
|
|
||||||
)
|
|
||||||
|
|
||||||
def send_playing_status_feedback(config, playlist_version=None, current_media=None):
|
|
||||||
"""
|
|
||||||
Send feedback about current playing status.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
config (dict): Configuration containing server details
|
|
||||||
playlist_version (int, optional): Current playlist version
|
|
||||||
current_media (str, optional): Currently playing media file
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
bool: True if feedback sent successfully, False otherwise
|
|
||||||
"""
|
|
||||||
player_name = config.get("screen_name", "unknown")
|
|
||||||
version_info = f"v{playlist_version}" if playlist_version else "unknown"
|
|
||||||
media_info = f" - {current_media}" if current_media else ""
|
|
||||||
message = f"player {player_name}, is active, Playing {version_info}{media_info}"
|
|
||||||
|
|
||||||
return send_player_feedback(
|
|
||||||
config=config,
|
|
||||||
message=message,
|
|
||||||
status="playing",
|
|
||||||
playlist_version=playlist_version
|
|
||||||
)
|
|
||||||
|
|
||||||
def fetch_server_playlist(config):
|
|
||||||
"""Fetch the updated playlist from the server using a config dict."""
|
|
||||||
server = config.get("server_ip", "")
|
|
||||||
host = config.get("screen_name", "")
|
|
||||||
quick = config.get("quickconnect_key", "")
|
|
||||||
port = config.get("port", "")
|
|
||||||
try:
|
|
||||||
# Remove protocol if already present
|
|
||||||
server_clean = server.replace('http://', '').replace('https://', '')
|
|
||||||
ip_pattern = r'^\d+\.\d+\.\d+\.\d+$'
|
|
||||||
if re.match(ip_pattern, server_clean):
|
|
||||||
server_url = f'http://{server_clean}:{port}/api/playlists'
|
|
||||||
else:
|
|
||||||
# Use original server if it has protocol, otherwise add http://
|
|
||||||
if server.startswith(('http://', 'https://')):
|
|
||||||
server_url = f'{server}/api/playlists'
|
|
||||||
else:
|
|
||||||
server_url = f'http://{server}/api/playlists'
|
|
||||||
params = {
|
|
||||||
'hostname': host,
|
|
||||||
'quickconnect_code': quick
|
|
||||||
}
|
|
||||||
logger.info(f"Fetching playlist from URL: {server_url} with params: {params}")
|
|
||||||
response = requests.get(server_url, params=params)
|
|
||||||
if response.status_code == 200:
|
|
||||||
response_data = response.json()
|
|
||||||
logger.info(f"Server response: {response_data}")
|
|
||||||
playlist = response_data.get('playlist', [])
|
|
||||||
version = response_data.get('playlist_version', None)
|
|
||||||
hashed_quickconnect = response_data.get('hashed_quickconnect', None)
|
|
||||||
if version is not None and hashed_quickconnect is not None:
|
|
||||||
if bcrypt.checkpw(quick.encode('utf-8'), hashed_quickconnect.encode('utf-8')):
|
|
||||||
logger.info("Fetched updated playlist from server.")
|
|
||||||
return {'playlist': playlist, 'version': version}
|
|
||||||
else:
|
|
||||||
logger.error("Quickconnect code validation failed.")
|
|
||||||
else:
|
|
||||||
logger.error("Failed to retrieve playlist or hashed quickconnect from the response.")
|
|
||||||
else:
|
|
||||||
logger.error(f"Failed to fetch playlist. Status Code: {response.status_code}")
|
|
||||||
except requests.exceptions.RequestException as e:
|
|
||||||
logger.error(f"Failed to fetch playlist: {e}")
|
|
||||||
return {'playlist': [], 'version': 0}
|
|
||||||
|
|
||||||
def save_playlist_with_version(playlist_data, playlist_dir):
|
|
||||||
version = playlist_data.get('version', 0)
|
|
||||||
playlist_file = os.path.join(playlist_dir, f'server_playlist_v{version}.json')
|
|
||||||
with open(playlist_file, 'w') as f:
|
|
||||||
json.dump(playlist_data, f, indent=2)
|
|
||||||
logger.info(f"Playlist saved to {playlist_file}")
|
|
||||||
return playlist_file
|
|
||||||
|
|
||||||
def download_media_files(playlist, media_dir):
|
|
||||||
"""Download media files from the server and save them to media_dir."""
|
|
||||||
if not os.path.exists(media_dir):
|
|
||||||
os.makedirs(media_dir)
|
|
||||||
logger.info(f"Created directory {media_dir} for media files.")
|
|
||||||
|
|
||||||
updated_playlist = []
|
|
||||||
for media in playlist:
|
|
||||||
file_name = media.get('file_name', '')
|
|
||||||
file_url = media.get('url', '')
|
|
||||||
duration = media.get('duration', 10)
|
|
||||||
local_path = os.path.join(media_dir, file_name)
|
|
||||||
logger.info(f"Preparing to download {file_name} from {file_url}...")
|
|
||||||
if os.path.exists(local_path):
|
|
||||||
logger.info(f"File {file_name} already exists. Skipping download.")
|
|
||||||
else:
|
|
||||||
try:
|
|
||||||
response = requests.get(file_url, timeout=10)
|
|
||||||
if response.status_code == 200:
|
|
||||||
with open(local_path, 'wb') as file:
|
|
||||||
file.write(response.content)
|
|
||||||
logger.info(f"Successfully downloaded {file_name} to {local_path}")
|
|
||||||
else:
|
|
||||||
logger.error(f"Failed to download {file_name}. Status Code: {response.status_code}")
|
|
||||||
continue
|
|
||||||
except requests.exceptions.RequestException as e:
|
|
||||||
logger.error(f"Error downloading {file_name}: {e}")
|
|
||||||
continue
|
|
||||||
updated_media = {
|
|
||||||
'file_name': file_name,
|
|
||||||
'url': os.path.relpath(local_path, os.path.dirname(media_dir)),
|
|
||||||
'duration': duration
|
|
||||||
}
|
|
||||||
updated_playlist.append(updated_media)
|
|
||||||
return updated_playlist
|
|
||||||
|
|
||||||
def delete_old_playlists_and_media(current_version, playlist_dir, media_dir, keep_versions=1):
|
|
||||||
"""
|
|
||||||
Delete old playlist files and media files not referenced by the latest playlist version.
|
|
||||||
keep_versions: number of latest versions to keep (default 1)
|
|
||||||
"""
|
|
||||||
# Find all playlist files
|
|
||||||
playlist_files = [f for f in os.listdir(playlist_dir) if f.startswith('server_playlist_v') and f.endswith('.json')]
|
|
||||||
# Keep only the latest N versions
|
|
||||||
versions = sorted([int(f.split('_v')[-1].split('.json')[0]) for f in playlist_files], reverse=True)
|
|
||||||
keep = set(versions[:keep_versions])
|
|
||||||
# Delete old playlist files
|
|
||||||
for f in playlist_files:
|
|
||||||
v = int(f.split('_v')[-1].split('.json')[0])
|
|
||||||
if v not in keep:
|
|
||||||
os.remove(os.path.join(playlist_dir, f))
|
|
||||||
# Collect all media files referenced by the kept playlists
|
|
||||||
referenced = set()
|
|
||||||
for v in keep:
|
|
||||||
path = os.path.join(playlist_dir, f'server_playlist_v{v}.json')
|
|
||||||
if os.path.exists(path):
|
|
||||||
with open(path, 'r') as f:
|
|
||||||
data = json.load(f)
|
|
||||||
for item in data.get('playlist', []):
|
|
||||||
referenced.add(item.get('file_name'))
|
|
||||||
# Delete media files not referenced
|
|
||||||
for f in os.listdir(media_dir):
|
|
||||||
if f not in referenced:
|
|
||||||
try:
|
|
||||||
os.remove(os.path.join(media_dir, f))
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"Failed to delete media file {f}: {e}")
|
|
||||||
|
|
||||||
def update_playlist_if_needed(local_playlist_path, config, media_dir, playlist_dir):
|
|
||||||
"""
|
|
||||||
Fetch the server playlist once, compare versions, and update if needed.
|
|
||||||
Returns True if updated, False if already up to date.
|
|
||||||
Also sends feedback to server about playlist check.
|
|
||||||
"""
|
|
||||||
server_data = fetch_server_playlist(config)
|
|
||||||
server_version = server_data.get('version', 0)
|
|
||||||
if not os.path.exists(local_playlist_path):
|
|
||||||
local_version = 0
|
|
||||||
else:
|
|
||||||
with open(local_playlist_path, 'r') as f:
|
|
||||||
local_data = json.load(f)
|
|
||||||
local_version = local_data.get('version', 0)
|
|
||||||
|
|
||||||
logger.info(f"Local playlist version: {local_version}, Server playlist version: {server_version}")
|
|
||||||
|
|
||||||
# Send feedback about playlist check
|
|
||||||
send_playlist_check_feedback(config, server_version if server_version > 0 else local_version)
|
|
||||||
|
|
||||||
if local_version != server_version:
|
|
||||||
if server_data and server_data.get('playlist'):
|
|
||||||
updated_playlist = download_media_files(server_data['playlist'], media_dir)
|
|
||||||
server_data['playlist'] = updated_playlist
|
|
||||||
save_playlist_with_version(server_data, playlist_dir)
|
|
||||||
# Delete old playlists and unreferenced media
|
|
||||||
delete_old_playlists_and_media(server_version, playlist_dir, media_dir)
|
|
||||||
|
|
||||||
# Send feedback about playlist update
|
|
||||||
player_name = config.get("screen_name", "unknown")
|
|
||||||
update_message = f"player {player_name}, playlist updated to v{server_version}"
|
|
||||||
send_player_feedback(config, update_message, "active", server_version)
|
|
||||||
|
|
||||||
return True
|
|
||||||
else:
|
|
||||||
logger.warning("No playlist data fetched from server or playlist is empty.")
|
|
||||||
|
|
||||||
# Send error feedback
|
|
||||||
send_player_error_feedback(config, "No playlist data fetched from server or playlist is empty", local_version)
|
|
||||||
|
|
||||||
return False
|
|
||||||
else:
|
|
||||||
logger.info("Local playlist is already up to date.")
|
|
||||||
return False
|
|
||||||
|
|
||||||
def is_playlist_up_to_date(local_playlist_path, config):
|
|
||||||
"""
|
|
||||||
Compare the version of the local playlist with the server playlist.
|
|
||||||
Returns True if up-to-date, False otherwise.
|
|
||||||
"""
|
|
||||||
if not os.path.exists(local_playlist_path):
|
|
||||||
logger.info(f"Local playlist file not found: {local_playlist_path}")
|
|
||||||
return False
|
|
||||||
with open(local_playlist_path, 'r') as f:
|
|
||||||
local_data = json.load(f)
|
|
||||||
local_version = local_data.get('version', 0)
|
|
||||||
server_data = fetch_server_playlist(config)
|
|
||||||
server_version = server_data.get('version', 0)
|
|
||||||
logger.info(f"Local playlist version: {local_version}, Server playlist version: {server_version}")
|
|
||||||
return local_version == server_version
|
|
||||||
+339
-71
@@ -1,12 +1,17 @@
|
|||||||
"""
|
"""
|
||||||
Updated get_playlists.py for Kiwy-Signage with DigiServer v2 authentication
|
Updated get_playlists.py for Kiwy-Signage with DigiServer v2 authentication
|
||||||
Uses secure auth flow: hostname → password/quickconnect → auth_code → API calls
|
Uses secure auth flow: hostname → password/quickconnect → auth_code → API calls
|
||||||
|
Now with HTTPS support and SSL certificate management
|
||||||
"""
|
"""
|
||||||
import os
|
import os
|
||||||
import json
|
import json
|
||||||
import requests
|
import requests
|
||||||
import logging
|
import logging
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
from player_auth import PlayerAuth
|
from player_auth import PlayerAuth
|
||||||
|
from ssl_utils import SSLManager
|
||||||
|
import media_state # conversion flags/markers, shared with the player
|
||||||
|
|
||||||
# Set up logging
|
# Set up logging
|
||||||
logging.basicConfig(level=logging.INFO)
|
logging.basicConfig(level=logging.INFO)
|
||||||
@@ -16,11 +21,17 @@ logger = logging.getLogger(__name__)
|
|||||||
_auth_instance = None
|
_auth_instance = None
|
||||||
|
|
||||||
|
|
||||||
def get_auth_instance(config_file='player_auth.json'):
|
def get_auth_instance(config_file='player_auth.json', use_https=True, verify_ssl=True):
|
||||||
"""Get or create global auth instance."""
|
"""Get or create global auth instance.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
config_file: Authentication config file path
|
||||||
|
use_https: Whether to use HTTPS
|
||||||
|
verify_ssl: Whether to verify SSL certificates
|
||||||
|
"""
|
||||||
global _auth_instance
|
global _auth_instance
|
||||||
if _auth_instance is None:
|
if _auth_instance is None:
|
||||||
_auth_instance = PlayerAuth(config_file)
|
_auth_instance = PlayerAuth(config_file, use_https=use_https, verify_ssl=verify_ssl)
|
||||||
return _auth_instance
|
return _auth_instance
|
||||||
|
|
||||||
|
|
||||||
@@ -33,7 +44,10 @@ def ensure_authenticated(config):
|
|||||||
Returns:
|
Returns:
|
||||||
PlayerAuth instance if authenticated, None otherwise
|
PlayerAuth instance if authenticated, None otherwise
|
||||||
"""
|
"""
|
||||||
auth = get_auth_instance()
|
auth = get_auth_instance(
|
||||||
|
use_https=config.get('use_https', True),
|
||||||
|
verify_ssl=config.get('verify_ssl', True)
|
||||||
|
)
|
||||||
|
|
||||||
# If already authenticated and valid, return auth instance
|
# If already authenticated and valid, return auth instance
|
||||||
if auth.is_authenticated():
|
if auth.is_authenticated():
|
||||||
@@ -49,6 +63,7 @@ def ensure_authenticated(config):
|
|||||||
hostname = config.get("screen_name", "")
|
hostname = config.get("screen_name", "")
|
||||||
quickconnect_key = config.get("quickconnect_key", "")
|
quickconnect_key = config.get("quickconnect_key", "")
|
||||||
port = config.get("port", "")
|
port = config.get("port", "")
|
||||||
|
use_https = config.get("use_https", True)
|
||||||
|
|
||||||
if not all([server_ip, hostname, quickconnect_key]):
|
if not all([server_ip, hostname, quickconnect_key]):
|
||||||
logger.error("❌ Missing configuration: server_ip, screen_name, or quickconnect_key")
|
logger.error("❌ Missing configuration: server_ip, screen_name, or quickconnect_key")
|
||||||
@@ -58,12 +73,20 @@ def ensure_authenticated(config):
|
|||||||
import re
|
import re
|
||||||
ip_pattern = r'^\d+\.\d+\.\d+\.\d+$'
|
ip_pattern = r'^\d+\.\d+\.\d+\.\d+$'
|
||||||
if re.match(ip_pattern, server_ip):
|
if re.match(ip_pattern, server_ip):
|
||||||
server_url = f'http://{server_ip}:{port}'
|
if use_https:
|
||||||
|
# Use HTTPS for IP addresses
|
||||||
|
server_url = f'https://{server_ip}:{port}' if port else f'https://{server_ip}'
|
||||||
|
else:
|
||||||
|
server_url = f'http://{server_ip}:{port}' if port else f'http://{server_ip}'
|
||||||
else:
|
else:
|
||||||
server_url = f'http://{server_ip}'
|
# For domain names, use HTTPS by default
|
||||||
|
if use_https:
|
||||||
|
server_url = f'https://{server_ip}'
|
||||||
|
else:
|
||||||
|
server_url = f'http://{server_ip}'
|
||||||
|
|
||||||
# Authenticate using quickconnect code
|
# Authenticate using quickconnect code
|
||||||
logger.info(f"🔐 Authenticating player: {hostname}")
|
logger.info(f"🔐 Authenticating player: {hostname} at {server_url}")
|
||||||
success, error = auth.authenticate(
|
success, error = auth.authenticate(
|
||||||
server_url=server_url,
|
server_url=server_url,
|
||||||
hostname=hostname,
|
hostname=hostname,
|
||||||
@@ -188,10 +211,9 @@ def fetch_server_playlist(config):
|
|||||||
return {'playlist': [], 'version': 0}
|
return {'playlist': [], 'version': 0}
|
||||||
|
|
||||||
|
|
||||||
def save_playlist_with_version(playlist_data, playlist_dir):
|
def save_playlist(playlist_data, playlist_dir):
|
||||||
"""Save playlist to file with version number."""
|
"""Save playlist to a single file (no versioning)."""
|
||||||
version = playlist_data.get('version', 0)
|
playlist_file = os.path.join(playlist_dir, 'server_playlist.json')
|
||||||
playlist_file = os.path.join(playlist_dir, f'server_playlist_v{version}.json')
|
|
||||||
|
|
||||||
# Ensure directory exists
|
# Ensure directory exists
|
||||||
os.makedirs(playlist_dir, exist_ok=True)
|
os.makedirs(playlist_dir, exist_ok=True)
|
||||||
@@ -203,17 +225,39 @@ def save_playlist_with_version(playlist_data, playlist_dir):
|
|||||||
return playlist_file
|
return playlist_file
|
||||||
|
|
||||||
|
|
||||||
def download_media_files(playlist, media_dir):
|
def download_media_files(playlist, media_dir, ssl_manager=None, server_url=None):
|
||||||
"""Download media files from the server and save them to media_dir."""
|
"""Download media files from the server and save them to media_dir.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
playlist: List of media items
|
||||||
|
media_dir: Directory to save media files
|
||||||
|
ssl_manager: Optional SSLManager for HTTPS downloads
|
||||||
|
server_url: Server base URL for constructing full file URLs
|
||||||
|
"""
|
||||||
if not os.path.exists(media_dir):
|
if not os.path.exists(media_dir):
|
||||||
os.makedirs(media_dir)
|
os.makedirs(media_dir)
|
||||||
logger.info(f"📁 Created directory {media_dir} for media files")
|
logger.info(f"📁 Created directory {media_dir} for media files")
|
||||||
|
|
||||||
|
# Use SSL manager if provided, otherwise use requests directly
|
||||||
|
session = ssl_manager.get_session() if ssl_manager else requests.Session()
|
||||||
|
|
||||||
updated_playlist = []
|
updated_playlist = []
|
||||||
for media in playlist:
|
for media in playlist:
|
||||||
file_name = media.get('file_name', '')
|
file_name = media.get('file_name', '')
|
||||||
file_url = media.get('url', '')
|
file_url = media.get('url', '')
|
||||||
duration = media.get('duration', 10)
|
duration = media.get('duration', 10)
|
||||||
|
item_type = media.get('type', '')
|
||||||
|
|
||||||
|
# Web-link items have no file to download — pass the link through unchanged.
|
||||||
|
if item_type == 'weblink':
|
||||||
|
logger.info(f"🔗 Web link item (no download): {file_url}")
|
||||||
|
# Preserve every server field (audio/muted/description/id/position/...)
|
||||||
|
# instead of rebuilding a fixed dict, so nothing is silently dropped.
|
||||||
|
weblink_item = dict(media)
|
||||||
|
weblink_item['type'] = 'weblink'
|
||||||
|
updated_playlist.append(weblink_item)
|
||||||
|
continue
|
||||||
|
|
||||||
local_path = os.path.join(media_dir, file_name)
|
local_path = os.path.join(media_dir, file_name)
|
||||||
|
|
||||||
logger.info(f"📥 Preparing to download {file_name}...")
|
logger.info(f"📥 Preparing to download {file_name}...")
|
||||||
@@ -222,88 +266,284 @@ def download_media_files(playlist, media_dir):
|
|||||||
logger.info(f"✓ File {file_name} already exists. Skipping download.")
|
logger.info(f"✓ File {file_name} already exists. Skipping download.")
|
||||||
else:
|
else:
|
||||||
try:
|
try:
|
||||||
response = requests.get(file_url, timeout=30)
|
# Create parent directories if they don't exist (for nested paths like edited_media/5/)
|
||||||
|
os.makedirs(os.path.dirname(local_path), exist_ok=True)
|
||||||
|
|
||||||
|
# Construct full URL
|
||||||
|
download_url = file_url
|
||||||
|
|
||||||
|
# Handle localhost URLs - replace with actual server IP
|
||||||
|
if 'localhost' in file_url or 'localhost' in (server_url or ''):
|
||||||
|
if server_url:
|
||||||
|
# Extract the path from localhost URL
|
||||||
|
if 'localhost' in file_url:
|
||||||
|
# URL like: https://localhost/static/uploads/file.jpg
|
||||||
|
# Extract path: /static/uploads/file.jpg
|
||||||
|
parts = file_url.split('localhost')
|
||||||
|
if len(parts) > 1:
|
||||||
|
path = parts[1]
|
||||||
|
download_url = f"{server_url}{path}"
|
||||||
|
logger.info(f"🔄 Replacing localhost with {server_url}")
|
||||||
|
else:
|
||||||
|
download_url = file_url
|
||||||
|
else:
|
||||||
|
download_url = file_url
|
||||||
|
else:
|
||||||
|
logger.warning(f"⚠️ localhost URL provided but no server_url available: {file_url}")
|
||||||
|
download_url = file_url
|
||||||
|
|
||||||
|
# Construct full URL if relative path is provided
|
||||||
|
elif not file_url.startswith('http'):
|
||||||
|
if server_url:
|
||||||
|
download_url = f"{server_url}/{file_url}".replace('//', '/')
|
||||||
|
# Fix the protocol part that might have been double-slashed
|
||||||
|
download_url = download_url.replace('https:/', 'https://').replace('http:/', 'http://')
|
||||||
|
else:
|
||||||
|
logger.warning(f"⚠️ Relative URL provided but no server_url available: {file_url}")
|
||||||
|
download_url = file_url
|
||||||
|
|
||||||
|
logger.info(f"📥 Downloading from: {download_url}")
|
||||||
|
response = session.get(download_url, timeout=30, verify=False)
|
||||||
if response.status_code == 200:
|
if response.status_code == 200:
|
||||||
with open(local_path, 'wb') as file:
|
with open(local_path, 'wb') as file:
|
||||||
file.write(response.content)
|
file.write(response.content)
|
||||||
logger.info(f"✅ Successfully downloaded {file_name}")
|
logger.info(f"✅ Successfully downloaded {file_name} ({len(response.content)} bytes)")
|
||||||
else:
|
else:
|
||||||
logger.error(f"❌ Failed to download {file_name}. Status: {response.status_code}")
|
logger.error(f"❌ Failed to download {file_name}. Status: {response.status_code}")
|
||||||
continue
|
# Still add to playlist even if download failed - might be cached or available later
|
||||||
|
except requests.exceptions.SSLError as e:
|
||||||
|
logger.error(f"❌ SSL Error downloading {file_name}: {e}")
|
||||||
|
# Don't skip - may still add to playlist
|
||||||
except requests.exceptions.RequestException as e:
|
except requests.exceptions.RequestException as e:
|
||||||
logger.error(f"❌ Error downloading {file_name}: {e}")
|
logger.error(f"❌ Error downloading {file_name}: {e}")
|
||||||
continue
|
# Don't skip - may still add to playlist
|
||||||
|
|
||||||
updated_media = {
|
# Always add the media item to the playlist, even if download failed
|
||||||
'file_name': file_name,
|
# (it might already exist or be available later).
|
||||||
'url': os.path.relpath(local_path, os.path.dirname(media_dir)),
|
# Preserve EVERY server field (audio/muted/description/id/position/...)
|
||||||
'duration': duration
|
# by copying the original dict and only overriding the URL with the
|
||||||
}
|
# local path — previously the fixed dict below dropped `audio`, `muted`,
|
||||||
|
# `description`, `id` and `position` from the saved playlist.
|
||||||
|
updated_media = dict(media)
|
||||||
|
updated_media['url'] = os.path.relpath(local_path, os.path.dirname(media_dir))
|
||||||
updated_playlist.append(updated_media)
|
updated_playlist.append(updated_media)
|
||||||
|
|
||||||
return updated_playlist
|
return updated_playlist
|
||||||
|
|
||||||
|
|
||||||
def delete_old_playlists_and_media(current_version, playlist_dir, media_dir, keep_versions=1):
|
|
||||||
"""Delete old playlist files and media files not referenced by the latest playlist version."""
|
def delete_unused_media(playlist_data, media_dir):
|
||||||
|
"""Delete media files not referenced in the current playlist."""
|
||||||
try:
|
try:
|
||||||
# Find all playlist files
|
# Get list of media files referenced in current playlist
|
||||||
playlist_files = [f for f in os.listdir(playlist_dir)
|
referenced_files = set()
|
||||||
if f.startswith('server_playlist_v') and f.endswith('.json')]
|
for media in playlist_data.get('playlist', []):
|
||||||
|
file_name = media.get('file_name', '')
|
||||||
|
if file_name:
|
||||||
|
referenced_files.add(file_name)
|
||||||
|
|
||||||
# Extract versions and sort
|
logger.info(f"📋 Current playlist references {len(referenced_files)} files")
|
||||||
versions = []
|
|
||||||
for f in playlist_files:
|
|
||||||
try:
|
|
||||||
version = int(f.replace('server_playlist_v', '').replace('.json', ''))
|
|
||||||
versions.append((version, f))
|
|
||||||
except ValueError:
|
|
||||||
continue
|
|
||||||
|
|
||||||
versions.sort(reverse=True)
|
# Names of the normalisation artefacts that belong to files STILL in the
|
||||||
|
# playlist. Computed from the referenced files with the same naming
|
||||||
|
# helpers the normaliser uses, so the two cannot drift.
|
||||||
|
#
|
||||||
|
# This has to be a per-source decision, not a blanket "never delete
|
||||||
|
# anything containing _kiwy": a converted output is not itself named in
|
||||||
|
# the playlist, so a blanket rule would keep the 1080p copies (and their
|
||||||
|
# metadata) forever after the item is removed from the playlist — an
|
||||||
|
# unnoticed disk leak on a device that runs for months.
|
||||||
|
keep_artifacts = set()
|
||||||
|
for ref in referenced_files:
|
||||||
|
ref_path = os.path.join(media_dir, ref)
|
||||||
|
output = media_state.normalized_output(ref_path)
|
||||||
|
keep_artifacts.add(os.path.basename(output))
|
||||||
|
keep_artifacts.add(os.path.basename(output) + media_state.MARKER_SUFFIX)
|
||||||
|
keep_artifacts.add(os.path.basename(media_state.converting_marker(ref_path)))
|
||||||
|
keep_artifacts.discard('')
|
||||||
|
|
||||||
# Keep only the latest N versions
|
if os.path.exists(media_dir):
|
||||||
files_to_delete = [f for v, f in versions[keep_versions:]]
|
# Recursively get all media files
|
||||||
|
deleted_count = 0
|
||||||
|
for root, dirs, files in os.walk(media_dir):
|
||||||
|
for media_file in files:
|
||||||
|
# Get relative path from media_dir
|
||||||
|
full_path = os.path.join(root, media_file)
|
||||||
|
rel_path = os.path.relpath(full_path, media_dir)
|
||||||
|
|
||||||
for f in files_to_delete:
|
# Normalisation artefacts belong to the item that produced
|
||||||
filepath = os.path.join(playlist_dir, f)
|
# them, so they are kept only while that source is still
|
||||||
os.remove(filepath)
|
# referenced. Their filenames are not in the playlist
|
||||||
logger.info(f"🗑️ Deleted old playlist: {f}")
|
# (the player derives them at run time), which is exactly why
|
||||||
|
# this check cannot be the generic one below.
|
||||||
|
if media_file in keep_artifacts:
|
||||||
|
continue
|
||||||
|
|
||||||
# TODO: Clean up unused media files
|
# A leftover artefact whose source is gone: it is derived
|
||||||
logger.info(f"✅ Cleanup complete (kept {keep_versions} latest versions)")
|
# data, safe to delete like any other unreferenced file.
|
||||||
|
# ``.kiwy-converting`` is excluded — it may belong to a
|
||||||
|
# conversion running right now for a file this pass missed.
|
||||||
|
if (media_file.endswith(media_state.MARKER_SUFFIX)
|
||||||
|
or '_kiwy' in media_file) \
|
||||||
|
and not media_file.endswith(media_state.CONVERTING_SUFFIX):
|
||||||
|
try:
|
||||||
|
os.remove(full_path)
|
||||||
|
logger.info(f'🗑️ Deleted stale normalisation '
|
||||||
|
f'artefact: {rel_path}')
|
||||||
|
deleted_count += 1
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f'⚠️ Could not delete {rel_path}: {e}')
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Skip if file is in current playlist
|
||||||
|
# Normalize path separators so server-style paths work
|
||||||
|
normalized_rel = rel_path.replace('\\', '/')
|
||||||
|
if normalized_rel in referenced_files or rel_path in referenced_files:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# The 4K original is still referenced by the playlist, so it
|
||||||
|
# is kept (harmless: it is no longer what gets played).
|
||||||
|
|
||||||
|
# Delete unreferenced file
|
||||||
|
try:
|
||||||
|
os.remove(full_path)
|
||||||
|
logger.info(f'🗑️ Deleted unused media: {rel_path}')
|
||||||
|
deleted_count += 1
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f'⚠️ Could not delete {rel_path}: {e}')
|
||||||
|
|
||||||
|
# Clean up empty directories
|
||||||
|
for root, dirs, files in os.walk(media_dir, topdown=False):
|
||||||
|
for dir_name in dirs:
|
||||||
|
dir_path = os.path.join(root, dir_name)
|
||||||
|
try:
|
||||||
|
if not os.listdir(dir_path): # If directory is empty
|
||||||
|
os.rmdir(dir_path)
|
||||||
|
logger.debug(f"🗑️ Removed empty directory: {os.path.relpath(dir_path, media_dir)}")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if deleted_count > 0:
|
||||||
|
logger.info(f"✅ Deleted {deleted_count} unused media files")
|
||||||
|
else:
|
||||||
|
logger.info("✅ No unused media files to delete")
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"❌ Error during cleanup: {e}")
|
logger.error(f"❌ Error during media cleanup: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_oversized_media(playlist, media_dir):
|
||||||
|
"""Start conversion of any oversized video in ``playlist`` (non-blocking).
|
||||||
|
|
||||||
|
Called after the playlist and its files are in place. Conversion takes about
|
||||||
|
31 s for an 18 s 4K clip, so it is launched as a **detached background
|
||||||
|
process** rather than run here: blocking the sync loop for that long would
|
||||||
|
stall playlist updates, and the sync runs on the player's asyncio executor.
|
||||||
|
|
||||||
|
The child writes the ``.kiwy-converting`` marker before it starts, which is
|
||||||
|
what makes the player skip the item while its file is being rewritten, and
|
||||||
|
removes it when finished so the item plays on the next lap.
|
||||||
|
|
||||||
|
Failures are never fatal: an unconverted video simply means the player keeps
|
||||||
|
skipping that item.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
normalizer = os.path.join(
|
||||||
|
os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
|
||||||
|
'linux', 'video_normalizer.py')
|
||||||
|
if not os.path.exists(normalizer):
|
||||||
|
logger.debug('Video normaliser not present; skipping conversion')
|
||||||
|
return 0
|
||||||
|
|
||||||
|
started = 0
|
||||||
|
for media in playlist or []:
|
||||||
|
if media.get('type') != 'video':
|
||||||
|
continue
|
||||||
|
file_name = media.get('file_name', '')
|
||||||
|
if not file_name:
|
||||||
|
continue
|
||||||
|
|
||||||
|
path = os.path.join(media_dir, file_name)
|
||||||
|
if not os.path.isfile(path):
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Already converting (or a fresh marker says so): leave it alone.
|
||||||
|
if media_state.is_converting(path):
|
||||||
|
continue
|
||||||
|
# A finished conversion is reused automatically by the normaliser.
|
||||||
|
if media_state.normalized_file(path):
|
||||||
|
continue
|
||||||
|
|
||||||
|
oversized, width, height = media_state.is_oversized(path)
|
||||||
|
if not oversized:
|
||||||
|
continue
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f'🎬 {file_name} is {width}x{height} — above 1920x1080; '
|
||||||
|
f'converting in the background for Raspberry Pi playback'
|
||||||
|
)
|
||||||
|
subprocess.Popen(
|
||||||
|
[sys.executable, normalizer, path],
|
||||||
|
stdout=subprocess.DEVNULL,
|
||||||
|
stderr=subprocess.DEVNULL,
|
||||||
|
start_new_session=True, # outlive this sync
|
||||||
|
)
|
||||||
|
started += 1
|
||||||
|
|
||||||
|
if started:
|
||||||
|
logger.info(
|
||||||
|
f'🎬 Started {started} video conversion(s); the player will '
|
||||||
|
f'skip those items until they are ready'
|
||||||
|
)
|
||||||
|
return started
|
||||||
|
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning(f'Could not start media normalisation: {exc}')
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
def update_playlist_if_needed(config, playlist_dir, media_dir):
|
def update_playlist_if_needed(config, playlist_dir, media_dir):
|
||||||
"""Check for and download updated playlist if available."""
|
"""Check for and download updated playlist if available.
|
||||||
try:
|
|
||||||
# Fetch latest playlist from server
|
|
||||||
server_data = fetch_server_playlist(config)
|
|
||||||
server_version = server_data.get('version', 0)
|
|
||||||
|
|
||||||
if server_version == 0:
|
Args:
|
||||||
|
config: Configuration dict with server settings
|
||||||
|
playlist_dir: Directory to save playlist
|
||||||
|
media_dir: Directory to save media files
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Initialize auth with SSL settings from config
|
||||||
|
auth = ensure_authenticated(config)
|
||||||
|
if not auth:
|
||||||
|
logger.error("❌ Cannot update playlist - authentication failed")
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Fetch latest playlist from server
|
||||||
|
server_data = auth.get_playlist()
|
||||||
|
|
||||||
|
if not server_data:
|
||||||
logger.warning("⚠️ No valid playlist received from server")
|
logger.warning("⚠️ No valid playlist received from server")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# Check local version
|
server_version = server_data.get('playlist_version', 0)
|
||||||
|
|
||||||
|
if server_version == 0:
|
||||||
|
logger.warning("⚠️ No valid playlist version received from server")
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Check local version from single playlist file
|
||||||
local_version = 0
|
local_version = 0
|
||||||
local_playlist_file = None
|
playlist_file = os.path.join(playlist_dir, 'server_playlist.json')
|
||||||
|
|
||||||
if os.path.exists(playlist_dir):
|
if os.path.exists(playlist_file):
|
||||||
playlist_files = [f for f in os.listdir(playlist_dir)
|
try:
|
||||||
if f.startswith('server_playlist_v') and f.endswith('.json')]
|
with open(playlist_file, 'r') as f:
|
||||||
|
local_data = json.load(f)
|
||||||
for f in playlist_files:
|
# Check for both 'version' and 'playlist_version' keys (for backward compatibility)
|
||||||
try:
|
local_version = local_data.get('version', local_data.get('playlist_version', 0))
|
||||||
version = int(f.replace('server_playlist_v', '').replace('.json', ''))
|
except Exception as e:
|
||||||
if version > local_version:
|
logger.warning(f"⚠️ Could not read local playlist: {e}")
|
||||||
local_version = version
|
|
||||||
local_playlist_file = os.path.join(playlist_dir, f)
|
|
||||||
except ValueError:
|
|
||||||
continue
|
|
||||||
|
|
||||||
logger.info(f"📊 Playlist versions - Server: v{server_version}, Local: v{local_version}")
|
logger.info(f"📊 Playlist versions - Server: v{server_version}, Local: v{local_version}")
|
||||||
|
|
||||||
@@ -311,21 +551,49 @@ def update_playlist_if_needed(config, playlist_dir, media_dir):
|
|||||||
if server_version > local_version:
|
if server_version > local_version:
|
||||||
logger.info(f"🔄 Updating playlist from v{local_version} to v{server_version}")
|
logger.info(f"🔄 Updating playlist from v{local_version} to v{server_version}")
|
||||||
|
|
||||||
|
# Get SSL manager for downloads if using HTTPS
|
||||||
|
ssl_manager = auth.ssl_manager if config.get('use_https', True) else None
|
||||||
|
|
||||||
|
# Get server URL from auth
|
||||||
|
server_url = auth.auth_data.get('server_url', '')
|
||||||
|
|
||||||
# Download media files
|
# Download media files
|
||||||
updated_playlist = download_media_files(server_data['playlist'], media_dir)
|
updated_playlist = download_media_files(server_data.get('playlist', []), media_dir, ssl_manager, server_url)
|
||||||
server_data['playlist'] = updated_playlist
|
server_data['playlist'] = updated_playlist
|
||||||
|
|
||||||
# Save new playlist
|
# Save new playlist (single file, no versioning)
|
||||||
playlist_file = save_playlist_with_version(server_data, playlist_dir)
|
playlist_file = save_playlist(server_data, playlist_dir)
|
||||||
|
|
||||||
# Clean up old versions
|
# Convert any oversized video to 1080p in the background. Doing this
|
||||||
delete_old_playlists_and_media(server_version, playlist_dir, media_dir)
|
# after the files are on disk (and after the playlist is saved) means
|
||||||
|
# the player can start skipping the item immediately.
|
||||||
|
normalize_oversized_media(server_data.get('playlist', []), media_dir)
|
||||||
|
|
||||||
|
# Delete unused media files
|
||||||
|
delete_unused_media(server_data, media_dir)
|
||||||
|
|
||||||
logger.info(f"✅ Playlist updated successfully to v{server_version}")
|
logger.info(f"✅ Playlist updated successfully to v{server_version}")
|
||||||
return playlist_file
|
return playlist_file
|
||||||
else:
|
else:
|
||||||
logger.info("✓ Playlist is up to date")
|
logger.info("✓ Playlist is up to date")
|
||||||
return local_playlist_file
|
# Even when the playlist version matches, ensure media files exist locally.
|
||||||
|
# The media folder might be empty (e.g. fresh install or deleted files).
|
||||||
|
logger.info("📥 Checking for missing media files...")
|
||||||
|
ssl_manager = auth.ssl_manager if config.get('use_https', True) else None
|
||||||
|
server_url = auth.auth_data.get('server_url', '')
|
||||||
|
downloaded = download_media_files(
|
||||||
|
server_data.get('playlist', []), media_dir, ssl_manager, server_url
|
||||||
|
)
|
||||||
|
if downloaded:
|
||||||
|
server_data['playlist'] = downloaded
|
||||||
|
# Re-save playlist with updated URLs if needed
|
||||||
|
save_playlist(server_data, playlist_dir)
|
||||||
|
|
||||||
|
# A version match does not mean the media is ready: a fresh install
|
||||||
|
# (or a failed conversion) can still have an oversized file waiting
|
||||||
|
# to be normalised, and this branch is where that gets noticed.
|
||||||
|
normalize_oversized_media(server_data.get('playlist', []), media_dir)
|
||||||
|
return playlist_file
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"❌ Error updating playlist: {e}")
|
logger.error(f"❌ Error updating playlist: {e}")
|
||||||
|
|||||||
+2326
-210
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,301 @@
|
|||||||
|
"""media_state.py — conversion flags and normalised-path resolution.
|
||||||
|
|
||||||
|
Shared by the player (``src/main.py``) and the normaliser
|
||||||
|
(``linux/video_normalizer.py``), so both agree on one on-disk contract.
|
||||||
|
|
||||||
|
Why this exists
|
||||||
|
---------------
|
||||||
|
A 4K video cannot be decoded in real time on a Pi 4 (measured 0.90x realtime),
|
||||||
|
so oversized media is downscaled to 1080p at sync time. That conversion takes
|
||||||
|
~31 s for an 18 s clip — long enough that the playlist may reach the item first.
|
||||||
|
|
||||||
|
The player therefore needs to know, cheaply and without re-probing the file:
|
||||||
|
|
||||||
|
* has this item already been converted? -> play the smaller file instead
|
||||||
|
* is it still converting? -> skip it this lap rather than
|
||||||
|
showing a frozen frame
|
||||||
|
|
||||||
|
Rather than store that inside the playlist JSON (which the server owns and
|
||||||
|
overwrites on every sync), the state lives in a **sidecar file next to the
|
||||||
|
media**. That keeps the server contract untouched and survives a playlist
|
||||||
|
re-download.
|
||||||
|
|
||||||
|
Marker files, all next to the media file::
|
||||||
|
|
||||||
|
<media>.kiwy-converting EXISTS while a conversion is in flight
|
||||||
|
<media>_kiwy1080p.mp4 the converted output
|
||||||
|
<media>_kiwy1080p.mp4.kiwy-normalized.json metadata for that output
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
|
||||||
|
#: Suffix of the in-progress marker. Its presence alone means "skip this item".
|
||||||
|
CONVERTING_SUFFIX = '.kiwy-converting'
|
||||||
|
|
||||||
|
#: Suffix of the completed-conversion metadata file.
|
||||||
|
MARKER_SUFFIX = '.kiwy-normalized.json'
|
||||||
|
|
||||||
|
#: Default output ceiling (the Pi's practical decode limit).
|
||||||
|
DEFAULT_MAX_WIDTH = 1920
|
||||||
|
DEFAULT_MAX_HEIGHT = 1080
|
||||||
|
|
||||||
|
#: A conversion marker older than this is treated as abandoned, so a crash
|
||||||
|
#: mid-conversion cannot park an item forever. The 4K clip measured 31 s, so
|
||||||
|
#: this is a very generous multiple.
|
||||||
|
STALE_CONVERSION_SECONDS = 30 * 60
|
||||||
|
|
||||||
|
|
||||||
|
def converting_marker(path):
|
||||||
|
return path + CONVERTING_SUFFIX
|
||||||
|
|
||||||
|
|
||||||
|
def normalized_output(path, max_height=DEFAULT_MAX_HEIGHT):
|
||||||
|
"""Deterministic path of the converted file for ``path``.
|
||||||
|
|
||||||
|
Must match ``linux/video_normalizer.normalized_path`` exactly — that is the
|
||||||
|
only reason this function is duplicated there rather than imported from a
|
||||||
|
runtime module the player should not depend on.
|
||||||
|
"""
|
||||||
|
directory, name = os.path.split(path)
|
||||||
|
stem, ext = os.path.splitext(name)
|
||||||
|
return os.path.join(directory, f'{stem}_kiwy{max_height}p{ext or ".mp4"}')
|
||||||
|
|
||||||
|
|
||||||
|
def is_converting(path):
|
||||||
|
"""True when a conversion for ``path`` is currently in flight.
|
||||||
|
|
||||||
|
A marker left behind by a crash is ignored once it is older than
|
||||||
|
:data:`STALE_CONVERSION_SECONDS`, so an interrupted conversion cannot make
|
||||||
|
an item permanently unplayable.
|
||||||
|
"""
|
||||||
|
marker = converting_marker(path)
|
||||||
|
try:
|
||||||
|
if not os.path.isfile(marker):
|
||||||
|
return False
|
||||||
|
age = time.time() - os.path.getmtime(marker)
|
||||||
|
if age > STALE_CONVERSION_SECONDS:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
except OSError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def begin_conversion(path, note=''):
|
||||||
|
"""Create the in-progress marker for ``path``.
|
||||||
|
|
||||||
|
Returns True when the marker was written. Also removes any stale marker
|
||||||
|
first, so the age always reflects the current attempt.
|
||||||
|
"""
|
||||||
|
marker = converting_marker(path)
|
||||||
|
try:
|
||||||
|
clear_conversion(path)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
with open(marker, 'w') as fh:
|
||||||
|
json.dump({
|
||||||
|
'source': os.path.basename(path),
|
||||||
|
'source_size': os.path.getsize(path),
|
||||||
|
'started_at': time.strftime('%Y-%m-%dT%H:%M:%S'),
|
||||||
|
'note': note,
|
||||||
|
}, fh, indent=2)
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def end_conversion(path):
|
||||||
|
"""Remove the in-progress marker. Safe to call unconditionally."""
|
||||||
|
try:
|
||||||
|
os.remove(converting_marker(path))
|
||||||
|
return True
|
||||||
|
except OSError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def clear_conversion(path):
|
||||||
|
"""Remove a stale in-progress marker (used before starting a new attempt)."""
|
||||||
|
marker = converting_marker(path)
|
||||||
|
try:
|
||||||
|
if os.path.isfile(marker):
|
||||||
|
os.remove(marker)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def conversion_metadata(path, max_height=DEFAULT_MAX_HEIGHT):
|
||||||
|
"""Parsed metadata for a finished conversion, or None."""
|
||||||
|
meta_file = normalized_output(path, max_height) + MARKER_SUFFIX
|
||||||
|
try:
|
||||||
|
with open(meta_file) as fh:
|
||||||
|
return json.load(fh)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def normalized_file(path, max_height=DEFAULT_MAX_HEIGHT):
|
||||||
|
"""The converted file for ``path`` **only if it is valid and complete**.
|
||||||
|
|
||||||
|
Returns None when no conversion has finished, when the output is missing or
|
||||||
|
empty, or when the metadata does not match the source that is currently on
|
||||||
|
disk (which catches a file being replaced by a different video of the same
|
||||||
|
name after a playlist change).
|
||||||
|
|
||||||
|
Deliberately does not require ``is_converting`` to be False: a conversion
|
||||||
|
that finished but crashed before removing its marker still produced a
|
||||||
|
usable file.
|
||||||
|
"""
|
||||||
|
output = normalized_output(path, max_height)
|
||||||
|
try:
|
||||||
|
if not os.path.isfile(output) or os.path.getsize(output) == 0:
|
||||||
|
return None
|
||||||
|
except OSError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
meta = conversion_metadata(path, max_height)
|
||||||
|
if not isinstance(meta, dict):
|
||||||
|
# No metadata: accept the output only if it is newer than the source.
|
||||||
|
try:
|
||||||
|
return output if os.path.getmtime(output) >= os.path.getmtime(path) else None
|
||||||
|
except OSError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
if meta.get('source_size') != os.path.getsize(path):
|
||||||
|
# The source changed since the conversion -> the output is stale.
|
||||||
|
return None
|
||||||
|
except OSError:
|
||||||
|
return None
|
||||||
|
return output
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_playable(path, max_width=DEFAULT_MAX_WIDTH,
|
||||||
|
max_height=DEFAULT_MAX_HEIGHT):
|
||||||
|
"""Pick the file to hand to the video player for ``path``.
|
||||||
|
|
||||||
|
Returns ``(chosen_path, state)`` where ``state`` is one of:
|
||||||
|
|
||||||
|
``ready`` — the original is within limits, or already converted
|
||||||
|
``converting`` — a conversion is in flight; the caller must SKIP this item
|
||||||
|
``pending`` — oversized and not yet converted; the caller must SKIP this
|
||||||
|
item (it would stutter or freeze) and let the sync convert
|
||||||
|
it
|
||||||
|
|
||||||
|
``chosen_path`` is the original file when ``state`` is ``pending`` or
|
||||||
|
``converting``; callers should not use it in those cases.
|
||||||
|
|
||||||
|
Order matters. The size check must come **before** reporting ``pending``:
|
||||||
|
a normal 1080p video has no conversion output and no marker, so testing for
|
||||||
|
those alone would classify every ordinary video as "needs converting" and
|
||||||
|
skip the entire playlist.
|
||||||
|
"""
|
||||||
|
# A finished conversion always wins: it is the file the Pi can decode.
|
||||||
|
converted = normalized_file(path, max_height)
|
||||||
|
if converted:
|
||||||
|
return converted, 'ready'
|
||||||
|
|
||||||
|
if is_converting(path):
|
||||||
|
return path, 'converting'
|
||||||
|
|
||||||
|
# Only an oversized file needs converting. Anything within the ceiling plays
|
||||||
|
# as-is, which is the common case and must stay cheap.
|
||||||
|
oversized, _width, _height = is_oversized(path, max_width, max_height)
|
||||||
|
if oversized:
|
||||||
|
return path, 'pending'
|
||||||
|
|
||||||
|
return path, 'ready'
|
||||||
|
|
||||||
|
|
||||||
|
def is_oversized(path, max_width=DEFAULT_MAX_WIDTH, max_height=DEFAULT_MAX_HEIGHT):
|
||||||
|
"""(bool, width, height) — True when the file exceeds the playback ceiling.
|
||||||
|
|
||||||
|
Uses the conversion metadata when present (no ffprobe spawn), and otherwise
|
||||||
|
falls back to reading the MP4 container header directly. Never shells out:
|
||||||
|
this runs on the playback path, where a subprocess would be far too costly.
|
||||||
|
"""
|
||||||
|
width, height = read_video_size(path)
|
||||||
|
if not width or not height:
|
||||||
|
return False, width, height
|
||||||
|
return (width > max_width or height > max_height), width, height
|
||||||
|
|
||||||
|
|
||||||
|
def read_video_size(path):
|
||||||
|
"""Read (width, height) from an MP4/MOV container with no external tools.
|
||||||
|
|
||||||
|
Walks the box structure to the ``tkhd`` box, which stores the track's
|
||||||
|
display size as 16.16 fixed-point. Handles both the 32-bit (version 0) and
|
||||||
|
64-bit (version 1) header layouts.
|
||||||
|
|
||||||
|
Returns ``(None, None)`` for anything it cannot parse (non-MP4, fragmented,
|
||||||
|
truncated) so the caller can fall back to treating the file as playable —
|
||||||
|
being wrong in that direction means a possible stutter; being wrong the
|
||||||
|
other way would mean skipping a video that actually plays.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
with open(path, 'rb') as fh:
|
||||||
|
data = fh.read()
|
||||||
|
except OSError:
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
def find_box(buf, start, end, box_type):
|
||||||
|
"""Find the first box of ``box_type`` among the children in range."""
|
||||||
|
pos = start
|
||||||
|
while pos + 8 <= end:
|
||||||
|
size = int.from_bytes(buf[pos:pos + 4], 'big')
|
||||||
|
kind = buf[pos + 4:pos + 8]
|
||||||
|
header = 8
|
||||||
|
if size == 1: # 64-bit extended size
|
||||||
|
if pos + 16 > end:
|
||||||
|
return None
|
||||||
|
size = int.from_bytes(buf[pos + 8:pos + 16], 'big')
|
||||||
|
header = 16
|
||||||
|
elif size == 0: # extends to end of file
|
||||||
|
size = end - pos
|
||||||
|
if size < header or pos + size > end:
|
||||||
|
return None
|
||||||
|
if kind == box_type:
|
||||||
|
return pos + header, pos + size
|
||||||
|
pos += size
|
||||||
|
return None
|
||||||
|
|
||||||
|
# moov -> trak -> tkhd
|
||||||
|
moov = find_box(data, 0, len(data), b'moov')
|
||||||
|
if not moov:
|
||||||
|
return None, None
|
||||||
|
pos, end = moov
|
||||||
|
|
||||||
|
# Scan the tracks for the first one that carries a tkhd with a real size.
|
||||||
|
while True:
|
||||||
|
trak = find_box(data, pos, end, b'trak')
|
||||||
|
if not trak:
|
||||||
|
return None, None
|
||||||
|
tpos, tend = trak
|
||||||
|
tkhd = find_box(data, tpos, tend, b'tkhd')
|
||||||
|
if tkhd:
|
||||||
|
kpos, kend = tkhd
|
||||||
|
try:
|
||||||
|
version = data[kpos]
|
||||||
|
# tkhd layout: version(1) flags(3) [creation(4/8)]
|
||||||
|
# [modification(4/8)] track_id(4) reserved(4) [duration(4/8)]
|
||||||
|
# reserved(8) layer(2) alt_group(2) volume(2) reserved(2)
|
||||||
|
# matrix(36) width(4) height(4)
|
||||||
|
offset = kpos + 4
|
||||||
|
if version == 1:
|
||||||
|
offset += 8 + 8 + 4 + 4 + 8
|
||||||
|
else:
|
||||||
|
offset += 4 + 4 + 4 + 4 + 4
|
||||||
|
offset += 8 + 2 + 2 + 2 + 2 + 36 # reserved..matrix
|
||||||
|
if offset + 8 <= kend:
|
||||||
|
raw_w = int.from_bytes(data[offset:offset + 4], 'big')
|
||||||
|
raw_h = int.from_bytes(data[offset + 4:offset + 8], 'big')
|
||||||
|
width = raw_w >> 16 # 16.16 fixed point
|
||||||
|
height = raw_h >> 16
|
||||||
|
if width and height:
|
||||||
|
return width, height
|
||||||
|
except (IndexError, ValueError):
|
||||||
|
pass
|
||||||
|
pos = tend
|
||||||
@@ -0,0 +1,331 @@
|
|||||||
|
"""
|
||||||
|
Network Monitoring Module
|
||||||
|
Checks server connectivity and manages WiFi restart on connection failure
|
||||||
|
"""
|
||||||
|
|
||||||
|
import subprocess
|
||||||
|
import time
|
||||||
|
import random
|
||||||
|
import shutil
|
||||||
|
import requests
|
||||||
|
from datetime import datetime
|
||||||
|
from kivy.logger import Logger
|
||||||
|
from kivy.clock import Clock
|
||||||
|
|
||||||
|
#: Interface used by the restart fallbacks. Raspberry Pi OS names the wireless
|
||||||
|
#: interface wlan0; only used by the legacy path (NetworkManager resolves the
|
||||||
|
#: device itself via `nmcli device`).
|
||||||
|
WIFI_INTERFACE = 'wlan0'
|
||||||
|
|
||||||
|
|
||||||
|
class NetworkMonitor:
|
||||||
|
"""Monitor network connectivity and manage WiFi restart"""
|
||||||
|
|
||||||
|
def __init__(self, server_url, check_interval_min=30, check_interval_max=45, wifi_restart_duration=20):
|
||||||
|
"""
|
||||||
|
Initialize network monitor
|
||||||
|
|
||||||
|
Args:
|
||||||
|
server_url (str): Server URL to check connectivity (e.g., 'https://digi-signage.moto-adv.com')
|
||||||
|
check_interval_min (int): Minimum minutes between checks (default: 30)
|
||||||
|
check_interval_max (int): Maximum minutes between checks (default: 45)
|
||||||
|
wifi_restart_duration (int): Minutes to keep WiFi off during restart (default: 20)
|
||||||
|
"""
|
||||||
|
self.server_url = server_url.rstrip('/')
|
||||||
|
self.check_interval_min = check_interval_min * 60 # Convert to seconds
|
||||||
|
self.check_interval_max = check_interval_max * 60 # Convert to seconds
|
||||||
|
self.wifi_restart_duration = wifi_restart_duration * 60 # Convert to seconds
|
||||||
|
self.is_monitoring = False
|
||||||
|
self.scheduled_event = None
|
||||||
|
self.consecutive_failures = 0
|
||||||
|
self.max_failures_before_restart = 3 # Restart WiFi after 3 consecutive failures
|
||||||
|
|
||||||
|
def start_monitoring(self):
|
||||||
|
"""Start the network monitoring loop"""
|
||||||
|
if not self.is_monitoring:
|
||||||
|
self.is_monitoring = True
|
||||||
|
Logger.info("NetworkMonitor: Starting network monitoring")
|
||||||
|
self._schedule_next_check()
|
||||||
|
|
||||||
|
def stop_monitoring(self):
|
||||||
|
"""Stop the network monitoring"""
|
||||||
|
self.is_monitoring = False
|
||||||
|
if self.scheduled_event:
|
||||||
|
self.scheduled_event.cancel()
|
||||||
|
self.scheduled_event = None
|
||||||
|
Logger.info("NetworkMonitor: Stopped network monitoring")
|
||||||
|
|
||||||
|
def _schedule_next_check(self):
|
||||||
|
"""Schedule the next connectivity check at a random interval"""
|
||||||
|
if not self.is_monitoring:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Random interval between min and max
|
||||||
|
next_check_seconds = random.randint(self.check_interval_min, self.check_interval_max)
|
||||||
|
next_check_minutes = next_check_seconds / 60
|
||||||
|
|
||||||
|
Logger.info(f"NetworkMonitor: Next check scheduled in {next_check_minutes:.1f} minutes")
|
||||||
|
|
||||||
|
# Schedule using Kivy Clock
|
||||||
|
self.scheduled_event = Clock.schedule_once(
|
||||||
|
lambda dt: self._check_connectivity(),
|
||||||
|
next_check_seconds
|
||||||
|
)
|
||||||
|
|
||||||
|
def _check_connectivity(self):
|
||||||
|
"""Check network connectivity to server"""
|
||||||
|
Logger.info("NetworkMonitor: Checking server connectivity...")
|
||||||
|
|
||||||
|
if self._test_server_connection():
|
||||||
|
Logger.info("NetworkMonitor: ✓ Server connection successful")
|
||||||
|
self.consecutive_failures = 0
|
||||||
|
else:
|
||||||
|
self.consecutive_failures += 1
|
||||||
|
Logger.warning(f"NetworkMonitor: ✗ Server connection failed (attempt {self.consecutive_failures}/{self.max_failures_before_restart})")
|
||||||
|
|
||||||
|
if self.consecutive_failures >= self.max_failures_before_restart:
|
||||||
|
Logger.error("NetworkMonitor: Multiple connection failures detected - initiating WiFi restart")
|
||||||
|
self._restart_wifi()
|
||||||
|
self.consecutive_failures = 0 # Reset counter after restart
|
||||||
|
|
||||||
|
# Schedule next check
|
||||||
|
self._schedule_next_check()
|
||||||
|
|
||||||
|
def _test_server_connection(self):
|
||||||
|
"""
|
||||||
|
Test connection to the server using ping only
|
||||||
|
This works in closed networks where the server is local
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
bool: True if server is reachable, False otherwise
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Extract hostname from server URL (remove http:// or https://)
|
||||||
|
hostname = self.server_url.replace('https://', '').replace('http://', '').split('/')[0]
|
||||||
|
|
||||||
|
Logger.info(f"NetworkMonitor: Pinging server: {hostname}")
|
||||||
|
|
||||||
|
# Ping the server hostname with 3 attempts (Linux syntax: -c count,
|
||||||
|
# -W per-packet timeout in seconds).
|
||||||
|
cmd = ['ping', '-c', '3', '-W', '3', hostname]
|
||||||
|
result = subprocess.run(
|
||||||
|
cmd,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=10
|
||||||
|
)
|
||||||
|
|
||||||
|
if result.returncode == 0:
|
||||||
|
Logger.info(f"NetworkMonitor: ✓ Server {hostname} is reachable")
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
Logger.warning(f"NetworkMonitor: ✗ Cannot reach server {hostname}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
Logger.warning(f"NetworkMonitor: ✗ Ping timeout to server")
|
||||||
|
return False
|
||||||
|
except Exception as e:
|
||||||
|
Logger.error(f"NetworkMonitor: Error pinging server: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _restart_wifi(self):
|
||||||
|
"""
|
||||||
|
Restart WiFi by turning it off for a specified duration then back on.
|
||||||
|
|
||||||
|
Raspberry Pi OS Trixie manages networking with **NetworkManager**, so
|
||||||
|
``nmcli`` is the correct interface and needs no ``sudo``. The legacy
|
||||||
|
rfkill/ifconfig path is kept only as a fallback for non-NetworkManager
|
||||||
|
installs (it requires root: see ``setup_wifi_control.sh``).
|
||||||
|
|
||||||
|
This runs in a separate thread to not block the main application.
|
||||||
|
"""
|
||||||
|
def wifi_restart_thread():
|
||||||
|
try:
|
||||||
|
Logger.info("NetworkMonitor: ====================================")
|
||||||
|
Logger.info("NetworkMonitor: INITIATING WIFI RESTART SEQUENCE")
|
||||||
|
Logger.info("NetworkMonitor: ====================================")
|
||||||
|
|
||||||
|
if shutil.which('nmcli'):
|
||||||
|
self._restart_wifi_nmcli()
|
||||||
|
else:
|
||||||
|
self._restart_wifi_legacy()
|
||||||
|
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
Logger.error("NetworkMonitor: WiFi restart command timeout")
|
||||||
|
except Exception as e:
|
||||||
|
Logger.error(f"NetworkMonitor: Error during WiFi restart: {e}")
|
||||||
|
|
||||||
|
# Run in separate thread to not block the application
|
||||||
|
import threading
|
||||||
|
thread = threading.Thread(target=wifi_restart_thread, daemon=True)
|
||||||
|
thread.start()
|
||||||
|
|
||||||
|
def _restart_wifi_nmcli(self):
|
||||||
|
"""WiFi restart via NetworkManager (the default on Raspberry Pi OS).
|
||||||
|
|
||||||
|
``nmcli`` is what actually manages the radio on Trixie, works without
|
||||||
|
``sudo`` for a user in the ``netdev``/``NetworkManager`` group, and
|
||||||
|
reconnects to the saved profile automatically. The previous
|
||||||
|
implementation used ``rfkill``/``ifconfig``/``dhclient`` — ``ifconfig``
|
||||||
|
and ``dhclient`` are not even installed on a stock Trixie image, so the
|
||||||
|
restart silently did nothing.
|
||||||
|
"""
|
||||||
|
wait_minutes = self.wifi_restart_duration / 60
|
||||||
|
Logger.info(
|
||||||
|
f"NetworkMonitor: NetworkManager WiFi restart — off for "
|
||||||
|
f"{wait_minutes:.0f} min"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Turn the radio OFF (persists across the wait, unlike a disconnect).
|
||||||
|
off = subprocess.run(
|
||||||
|
['nmcli', 'radio', 'wifi', 'off'],
|
||||||
|
capture_output=True, text=True, timeout=15
|
||||||
|
)
|
||||||
|
if off.returncode == 0:
|
||||||
|
Logger.info("NetworkMonitor: ✓ WiFi radio disabled (nmcli radio wifi off)")
|
||||||
|
else:
|
||||||
|
# Some builds deny `radio off` to non-root; a device disconnect
|
||||||
|
# still forces a reconnect without needing privileges.
|
||||||
|
Logger.warning(
|
||||||
|
f"NetworkMonitor: nmcli radio off failed ({off.stderr.strip()}); "
|
||||||
|
f"falling back to device disconnect"
|
||||||
|
)
|
||||||
|
subprocess.run(
|
||||||
|
['nmcli', 'device', 'disconnect', WIFI_INTERFACE],
|
||||||
|
capture_output=True, text=True, timeout=15
|
||||||
|
)
|
||||||
|
|
||||||
|
Logger.info(
|
||||||
|
f"NetworkMonitor: WiFi will remain OFF for {wait_minutes:.0f} minutes "
|
||||||
|
f"(started {datetime.now().strftime('%H:%M:%S')})"
|
||||||
|
)
|
||||||
|
time.sleep(self.wifi_restart_duration)
|
||||||
|
Logger.info(
|
||||||
|
f"NetworkMonitor: Wait period completed at "
|
||||||
|
f"{datetime.now().strftime('%H:%M:%S')}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Turn the radio back ON and let NetworkManager auto-connect.
|
||||||
|
on = subprocess.run(
|
||||||
|
['nmcli', 'radio', 'wifi', 'on'],
|
||||||
|
capture_output=True, text=True, timeout=15
|
||||||
|
)
|
||||||
|
if on.returncode == 0:
|
||||||
|
Logger.info("NetworkMonitor: ✓ WiFi radio enabled (nmcli radio wifi on)")
|
||||||
|
else:
|
||||||
|
Logger.error(f"NetworkMonitor: nmcli radio on failed: {on.stderr.strip()}")
|
||||||
|
|
||||||
|
# Give NetworkManager a moment, then nudge the saved connection up.
|
||||||
|
time.sleep(10)
|
||||||
|
connect = subprocess.run(
|
||||||
|
['nmcli', 'device', 'connect', WIFI_INTERFACE],
|
||||||
|
capture_output=True, text=True, timeout=30
|
||||||
|
)
|
||||||
|
if connect.returncode == 0:
|
||||||
|
Logger.info("NetworkMonitor: ✓ Reconnected to the saved WiFi profile")
|
||||||
|
else:
|
||||||
|
# NetworkManager often reconnects on its own before we get here, in
|
||||||
|
# which case `device connect` reports "already connected".
|
||||||
|
Logger.info(
|
||||||
|
f"NetworkMonitor: nmcli device connect returned "
|
||||||
|
f"{connect.returncode}: {connect.stderr.strip()} "
|
||||||
|
f"(NetworkManager may have reconnected automatically)"
|
||||||
|
)
|
||||||
|
|
||||||
|
Logger.info("NetworkMonitor: ====================================")
|
||||||
|
Logger.info("NetworkMonitor: WIFI RESTART SEQUENCE COMPLETED")
|
||||||
|
Logger.info("NetworkMonitor: ====================================")
|
||||||
|
|
||||||
|
def _restart_wifi_legacy(self):
|
||||||
|
"""Fallback for systems without NetworkManager: rfkill + ip.
|
||||||
|
|
||||||
|
Requires passwordless ``sudo`` for the exact commands used — see
|
||||||
|
``setup_wifi_control.sh``. Note ``ifconfig``/``dhclient`` are legacy and
|
||||||
|
not installed on current images; ``ip`` is used instead.
|
||||||
|
"""
|
||||||
|
# Turn off WiFi using rfkill (works regardless of the network stack)
|
||||||
|
Logger.info("NetworkMonitor: Turning WiFi OFF using rfkill...")
|
||||||
|
result = subprocess.run(
|
||||||
|
['sudo', 'rfkill', 'block', 'wifi'],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=10
|
||||||
|
)
|
||||||
|
|
||||||
|
if result.returncode == 0:
|
||||||
|
Logger.info("NetworkMonitor: ✓ WiFi turned OFF successfully (rfkill)")
|
||||||
|
else:
|
||||||
|
Logger.error(f"NetworkMonitor: rfkill failed, trying ip link...")
|
||||||
|
Logger.error(f"NetworkMonitor: rfkill error: {result.stderr}")
|
||||||
|
|
||||||
|
# Fallback to ip link (ifconfig is not installed on Trixie)
|
||||||
|
result2 = subprocess.run(
|
||||||
|
['sudo', 'ip', 'link', 'set', WIFI_INTERFACE, 'down'],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=10
|
||||||
|
)
|
||||||
|
|
||||||
|
if result2.returncode == 0:
|
||||||
|
Logger.info("NetworkMonitor: ✓ WiFi turned OFF successfully (ip link)")
|
||||||
|
else:
|
||||||
|
Logger.error(f"NetworkMonitor: Failed to turn WiFi off: {result2.stderr}")
|
||||||
|
Logger.error(f"NetworkMonitor: Return code: {result2.returncode}")
|
||||||
|
Logger.error(f"NetworkMonitor: STDOUT: {result2.stdout}")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Wait for the specified duration with WiFi OFF
|
||||||
|
wait_minutes = self.wifi_restart_duration / 60
|
||||||
|
Logger.info(f"NetworkMonitor: ====================================")
|
||||||
|
Logger.info(f"NetworkMonitor: WiFi will remain OFF for {wait_minutes:.0f} minutes")
|
||||||
|
Logger.info(f"NetworkMonitor: Waiting period started at: {datetime.now().strftime('%H:%M:%S')}")
|
||||||
|
Logger.info(f"NetworkMonitor: ====================================")
|
||||||
|
|
||||||
|
# Sleep while WiFi is OFF
|
||||||
|
time.sleep(self.wifi_restart_duration)
|
||||||
|
|
||||||
|
Logger.info(f"NetworkMonitor: Wait period completed at: {datetime.now().strftime('%H:%M:%S')}")
|
||||||
|
|
||||||
|
# Turn WiFi back on after the wait period
|
||||||
|
Logger.info("NetworkMonitor: ====================================")
|
||||||
|
Logger.info("NetworkMonitor: Now turning WiFi back ON...")
|
||||||
|
Logger.info("NetworkMonitor: ====================================")
|
||||||
|
|
||||||
|
# Unblock WiFi using rfkill
|
||||||
|
result = subprocess.run(
|
||||||
|
['sudo', 'rfkill', 'unblock', 'wifi'],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=10
|
||||||
|
)
|
||||||
|
|
||||||
|
if result.returncode == 0:
|
||||||
|
Logger.info("NetworkMonitor: ✓ WiFi unblocked successfully (rfkill)")
|
||||||
|
else:
|
||||||
|
Logger.error(f"NetworkMonitor: rfkill unblock failed: {result.stderr}")
|
||||||
|
|
||||||
|
# Also bring interface up
|
||||||
|
result2 = subprocess.run(
|
||||||
|
['sudo', 'ip', 'link', 'set', WIFI_INTERFACE, 'up'],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=10
|
||||||
|
)
|
||||||
|
|
||||||
|
if result2.returncode == 0:
|
||||||
|
Logger.info("NetworkMonitor: ✓ WiFi interface brought UP successfully")
|
||||||
|
|
||||||
|
# Wait a bit for connection to establish.
|
||||||
|
# NOTE: the DHCP client is deliberately not invoked here —
|
||||||
|
# `dhclient` is not installed on Raspberry Pi OS Trixie and
|
||||||
|
# hand-rolling it would fight NetworkManager/dhcpcd, which handle
|
||||||
|
# the lease automatically once the interface comes back up.
|
||||||
|
Logger.info("NetworkMonitor: Waiting 10 seconds for WiFi to initialize...")
|
||||||
|
time.sleep(10)
|
||||||
|
|
||||||
|
Logger.info("NetworkMonitor: ====================================")
|
||||||
|
Logger.info("NetworkMonitor: WIFI RESTART SEQUENCE COMPLETED")
|
||||||
|
Logger.info("NetworkMonitor: ====================================")
|
||||||
|
else:
|
||||||
|
Logger.error(f"NetworkMonitor: Failed to turn WiFi on: {result.stderr}")
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
"""
|
||||||
|
playback_trace.py — Always-on playback transition logger.
|
||||||
|
|
||||||
|
Kivy's log level is forced to 'warning' in main.py, which suppresses every
|
||||||
|
Logger.info()/Logger.debug() line. That made it impossible
|
||||||
|
to see why the player skips/crashes at the weblink->image and video->next
|
||||||
|
transitions.
|
||||||
|
|
||||||
|
This module writes a plain-text trace file (logs/playback_trace.log) with
|
||||||
|
timestamps, INDEPENDENT of Kivy's log level, so we can always see exactly
|
||||||
|
what the player is doing. It is thread-safe (a lock guards the append) and
|
||||||
|
never throws (all failures are swallowed) so it can never break playback.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
from playback_trace import trace
|
||||||
|
trace("play_current_media", index=3, name="foo.jpg", type="image")
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
|
||||||
|
_LOCK = threading.Lock()
|
||||||
|
_LOG_PATH = None
|
||||||
|
_OPENED = False
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_path():
|
||||||
|
global _LOG_PATH, _OPENED
|
||||||
|
if _OPENED:
|
||||||
|
return _LOG_PATH
|
||||||
|
_OPENED = True
|
||||||
|
try:
|
||||||
|
# Respect the local data dir the launcher set (same place as logs/).
|
||||||
|
base = os.environ.get('KIWY_DATA_DIR') or os.getcwd()
|
||||||
|
log_dir = os.path.join(base, 'logs')
|
||||||
|
os.makedirs(log_dir, exist_ok=True)
|
||||||
|
_LOG_PATH = os.path.join(log_dir, 'playback_trace.log')
|
||||||
|
except Exception:
|
||||||
|
_LOG_PATH = None
|
||||||
|
return _LOG_PATH
|
||||||
|
|
||||||
|
|
||||||
|
def trace(event, **kwargs):
|
||||||
|
"""Append one line to the playback trace log.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
event: short event name, e.g. 'next_media', 'eos', 'web_open'.
|
||||||
|
**kwargs: key=value context, e.g. index=3, name='foo.jpg'.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
path = _ensure_path()
|
||||||
|
if not path:
|
||||||
|
return
|
||||||
|
t = time.strftime('%H:%M:%S')
|
||||||
|
ms = int((time.time() % 1) * 1000)
|
||||||
|
parts = [f"{t}.{ms:03d}", event]
|
||||||
|
for k, v in kwargs.items():
|
||||||
|
parts.append(f"{k}={v}")
|
||||||
|
with _LOCK:
|
||||||
|
with open(path, 'a', encoding='utf-8') as f:
|
||||||
|
f.write(" ".join(parts) + "\n")
|
||||||
|
except Exception:
|
||||||
|
pass # tracing must never break the player
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
{
|
|
||||||
"hostname": "tv-terasa",
|
|
||||||
"auth_code": "iiSyZDLWGyqNIxeRt54XYREgvAio11RwwU1_oJev6WI",
|
|
||||||
"player_id": 1,
|
|
||||||
"player_name": "TV-acasa 1",
|
|
||||||
"playlist_id": 1,
|
|
||||||
"orientation": "Landscape",
|
|
||||||
"authenticated": true,
|
|
||||||
"server_url": "https://digi-signage.moto-adv.com:443"
|
|
||||||
}
|
|
||||||
+68
-9
@@ -2,12 +2,14 @@
|
|||||||
Player Authentication Module for Kiwy-Signage
|
Player Authentication Module for Kiwy-Signage
|
||||||
Handles secure authentication with DigiServer v2
|
Handles secure authentication with DigiServer v2
|
||||||
Uses: hostname → password/quickconnect → get auth_code → use auth_code for API calls
|
Uses: hostname → password/quickconnect → get auth_code → use auth_code for API calls
|
||||||
|
Now with HTTPS support and SSL certificate management
|
||||||
"""
|
"""
|
||||||
import os
|
import os
|
||||||
import json
|
import json
|
||||||
import requests
|
import requests
|
||||||
import logging
|
import logging
|
||||||
from typing import Optional, Dict, Tuple
|
from typing import Optional, Dict, Tuple
|
||||||
|
from ssl_utils import SSLManager, setup_ssl_for_requests
|
||||||
|
|
||||||
# Set up logging
|
# Set up logging
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -16,13 +18,19 @@ logger = logging.getLogger(__name__)
|
|||||||
class PlayerAuth:
|
class PlayerAuth:
|
||||||
"""Handle player authentication with DigiServer v2."""
|
"""Handle player authentication with DigiServer v2."""
|
||||||
|
|
||||||
def __init__(self, config_file: str = 'player_auth.json'):
|
def __init__(self, config_file: str = 'player_auth.json',
|
||||||
|
use_https: bool = True, verify_ssl: bool = True):
|
||||||
"""Initialize player authentication.
|
"""Initialize player authentication.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
config_file: Path to authentication config file
|
config_file: Path to authentication config file
|
||||||
|
use_https: Whether to use HTTPS for connections
|
||||||
|
verify_ssl: Whether to verify SSL certificates
|
||||||
"""
|
"""
|
||||||
self.config_file = config_file
|
self.config_file = config_file
|
||||||
|
self.use_https = use_https
|
||||||
|
self.verify_ssl = verify_ssl
|
||||||
|
self.ssl_manager = SSLManager(verify_ssl=verify_ssl)
|
||||||
self.auth_data = self._load_auth_data()
|
self.auth_data = self._load_auth_data()
|
||||||
|
|
||||||
def _load_auth_data(self) -> Dict:
|
def _load_auth_data(self) -> Dict:
|
||||||
@@ -65,7 +73,7 @@ class PlayerAuth:
|
|||||||
"""Authenticate with DigiServer v2.
|
"""Authenticate with DigiServer v2.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
server_url: Server URL (e.g., 'http://server:5000')
|
server_url: Server URL (e.g., 'http://server:5000' or 'https://server')
|
||||||
hostname: Player hostname/identifier
|
hostname: Player hostname/identifier
|
||||||
password: Player password (optional if using quickconnect)
|
password: Player password (optional if using quickconnect)
|
||||||
quickconnect_code: Quick connect code (optional if using password)
|
quickconnect_code: Quick connect code (optional if using password)
|
||||||
@@ -77,6 +85,20 @@ class PlayerAuth:
|
|||||||
if not password and not quickconnect_code:
|
if not password and not quickconnect_code:
|
||||||
return False, "Password or quick connect code required"
|
return False, "Password or quick connect code required"
|
||||||
|
|
||||||
|
# Normalize server URL to HTTPS if needed
|
||||||
|
if self.use_https:
|
||||||
|
server_url = self.ssl_manager.validate_url_scheme(server_url)
|
||||||
|
|
||||||
|
# Try to download certificate if not present
|
||||||
|
if not self.ssl_manager.has_certificate():
|
||||||
|
logger.info("Downloading server certificate for HTTPS verification...")
|
||||||
|
success, error = self.ssl_manager.download_server_certificate(server_url, timeout=timeout)
|
||||||
|
if not success:
|
||||||
|
logger.warning(f"⚠️ Certificate download failed: {error}")
|
||||||
|
if self.verify_ssl:
|
||||||
|
return False, error
|
||||||
|
# Continue with unverified connection for testing
|
||||||
|
|
||||||
# Prepare authentication request
|
# Prepare authentication request
|
||||||
auth_url = f"{server_url}/api/auth/player"
|
auth_url = f"{server_url}/api/auth/player"
|
||||||
payload = {
|
payload = {
|
||||||
@@ -87,7 +109,10 @@ class PlayerAuth:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
logger.info(f"Authenticating with server: {auth_url}")
|
logger.info(f"Authenticating with server: {auth_url}")
|
||||||
response = requests.post(auth_url, json=payload, timeout=timeout)
|
|
||||||
|
# Use SSL-configured session
|
||||||
|
session = self.ssl_manager.get_session()
|
||||||
|
response = session.post(auth_url, json=payload, timeout=timeout)
|
||||||
|
|
||||||
if response.status_code == 200:
|
if response.status_code == 200:
|
||||||
data = response.json()
|
data = response.json()
|
||||||
@@ -119,8 +144,16 @@ class PlayerAuth:
|
|||||||
logger.error(error_msg)
|
logger.error(error_msg)
|
||||||
return False, error_msg
|
return False, error_msg
|
||||||
|
|
||||||
except requests.exceptions.ConnectionError:
|
except requests.exceptions.SSLError as e:
|
||||||
error_msg = "Cannot connect to server"
|
error_msg = f"SSL Certificate Error: {e}"
|
||||||
|
logger.error(error_msg)
|
||||||
|
if self.verify_ssl:
|
||||||
|
logger.error(" This usually means the server certificate is not trusted.")
|
||||||
|
logger.error(" Try downloading the server certificate or disabling SSL verification.")
|
||||||
|
return False, error_msg
|
||||||
|
|
||||||
|
except requests.exceptions.ConnectionError as e:
|
||||||
|
error_msg = f"Connection Error: {e}"
|
||||||
logger.error(error_msg)
|
logger.error(error_msg)
|
||||||
return False, error_msg
|
return False, error_msg
|
||||||
|
|
||||||
@@ -154,7 +187,9 @@ class PlayerAuth:
|
|||||||
payload = {'auth_code': self.auth_data.get('auth_code')}
|
payload = {'auth_code': self.auth_data.get('auth_code')}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
response = requests.post(verify_url, json=payload, timeout=timeout)
|
# Use SSL-configured session
|
||||||
|
session = self.ssl_manager.get_session()
|
||||||
|
response = session.post(verify_url, json=payload, timeout=timeout)
|
||||||
|
|
||||||
if response.status_code == 200:
|
if response.status_code == 200:
|
||||||
data = response.json()
|
data = response.json()
|
||||||
@@ -165,6 +200,10 @@ class PlayerAuth:
|
|||||||
logger.warning("❌ Auth code invalid or expired")
|
logger.warning("❌ Auth code invalid or expired")
|
||||||
return False, None
|
return False, None
|
||||||
|
|
||||||
|
except requests.exceptions.SSLError as e:
|
||||||
|
logger.error(f"SSL Error during verification: {e}")
|
||||||
|
return False, None
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to verify auth: {e}")
|
logger.error(f"Failed to verify auth: {e}")
|
||||||
return False, None
|
return False, None
|
||||||
@@ -195,7 +234,9 @@ class PlayerAuth:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
logger.info(f"Fetching playlist from: {playlist_url}")
|
logger.info(f"Fetching playlist from: {playlist_url}")
|
||||||
response = requests.get(playlist_url, headers=headers, timeout=timeout)
|
# Use SSL-configured session
|
||||||
|
session = self.ssl_manager.get_session()
|
||||||
|
response = session.get(playlist_url, headers=headers, timeout=timeout)
|
||||||
|
|
||||||
if response.status_code == 200:
|
if response.status_code == 200:
|
||||||
data = response.json()
|
data = response.json()
|
||||||
@@ -211,6 +252,10 @@ class PlayerAuth:
|
|||||||
logger.error(f"Failed to get playlist: {response.status_code}")
|
logger.error(f"Failed to get playlist: {response.status_code}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
except requests.exceptions.SSLError as e:
|
||||||
|
logger.error(f"SSL Error fetching playlist: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error fetching playlist: {e}")
|
logger.error(f"Error fetching playlist: {e}")
|
||||||
return None
|
return None
|
||||||
@@ -240,10 +285,16 @@ class PlayerAuth:
|
|||||||
payload = {'status': status}
|
payload = {'status': status}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
response = requests.post(heartbeat_url, headers=headers,
|
# Use SSL-configured session
|
||||||
|
session = self.ssl_manager.get_session()
|
||||||
|
response = session.post(heartbeat_url, headers=headers,
|
||||||
json=payload, timeout=timeout)
|
json=payload, timeout=timeout)
|
||||||
return response.status_code == 200
|
return response.status_code == 200
|
||||||
|
|
||||||
|
except requests.exceptions.SSLError as e:
|
||||||
|
logger.debug(f"SSL Error in heartbeat: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.debug(f"Heartbeat failed: {e}")
|
logger.debug(f"Heartbeat failed: {e}")
|
||||||
return False
|
return False
|
||||||
@@ -275,6 +326,8 @@ class PlayerAuth:
|
|||||||
feedback_url = f"{server_url}/api/player-feedback"
|
feedback_url = f"{server_url}/api/player-feedback"
|
||||||
headers = {'Authorization': f'Bearer {auth_code}'}
|
headers = {'Authorization': f'Bearer {auth_code}'}
|
||||||
payload = {
|
payload = {
|
||||||
|
'hostname': self.auth_data.get('hostname'),
|
||||||
|
'quickconnect_code': self.auth_data.get('quickconnect_code'),
|
||||||
'message': message,
|
'message': message,
|
||||||
'status': status,
|
'status': status,
|
||||||
'playlist_version': playlist_version,
|
'playlist_version': playlist_version,
|
||||||
@@ -282,10 +335,16 @@ class PlayerAuth:
|
|||||||
}
|
}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
response = requests.post(feedback_url, headers=headers,
|
# Use SSL-configured session
|
||||||
|
session = self.ssl_manager.get_session()
|
||||||
|
response = session.post(feedback_url, headers=headers,
|
||||||
json=payload, timeout=timeout)
|
json=payload, timeout=timeout)
|
||||||
return response.status_code == 200
|
return response.status_code == 200
|
||||||
|
|
||||||
|
except requests.exceptions.SSLError as e:
|
||||||
|
logger.debug(f"SSL Error sending feedback: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.debug(f"Feedback failed: {e}")
|
logger.debug(f"Feedback failed: {e}")
|
||||||
return False
|
return False
|
||||||
|
|||||||
+603
-199
@@ -224,12 +224,12 @@
|
|||||||
pos: self.pos
|
pos: self.pos
|
||||||
radius: [dp(15)]
|
radius: [dp(15)]
|
||||||
|
|
||||||
# New control panel overlay (bottom center, 1/6 width, 90% transparent)
|
# New control panel overlay (bottom center, width for 6 buttons, 90% transparent)
|
||||||
BoxLayout:
|
BoxLayout:
|
||||||
id: controls_layout
|
id: controls_layout
|
||||||
orientation: 'horizontal'
|
orientation: 'horizontal'
|
||||||
size_hint: None, None
|
size_hint: None, None
|
||||||
width: root.width / 6 if root.width > 0 else dp(260)
|
width: dp(370)
|
||||||
height: dp(70)
|
height: dp(70)
|
||||||
pos: (root.width - self.width) / 2, dp(10)
|
pos: (root.width - self.width) / 2, dp(10)
|
||||||
opacity: 1
|
opacity: 1
|
||||||
@@ -256,11 +256,19 @@
|
|||||||
id: play_pause_btn
|
id: play_pause_btn
|
||||||
size_hint: None, None
|
size_hint: None, None
|
||||||
size: dp(50), dp(50)
|
size: dp(50), dp(50)
|
||||||
background_normal: root.resources_path + '/play.png'
|
background_normal: root.resources_path + '/pause.png'
|
||||||
background_down: root.resources_path + '/pause.png'
|
background_down: root.resources_path + '/pause.png'
|
||||||
border: (0, 0, 0, 0)
|
border: (0, 0, 0, 0)
|
||||||
on_press: root.toggle_pause()
|
on_press: root.toggle_pause()
|
||||||
|
|
||||||
|
Button:
|
||||||
|
id: edit_btn
|
||||||
|
size_hint: None, None
|
||||||
|
size: dp(50), dp(50)
|
||||||
|
background_normal: root.resources_path + '/pencil.png'
|
||||||
|
background_down: root.resources_path + '/pencil.png'
|
||||||
|
border: (0, 0, 0, 0)
|
||||||
|
on_press: root.show_edit_interface()
|
||||||
Button:
|
Button:
|
||||||
id: settings_btn
|
id: settings_btn
|
||||||
size_hint: None, None
|
size_hint: None, None
|
||||||
@@ -344,210 +352,330 @@
|
|||||||
# Settings popup content
|
# Settings popup content
|
||||||
<SettingsPopup@Popup>:
|
<SettingsPopup@Popup>:
|
||||||
title: 'Player Settings'
|
title: 'Player Settings'
|
||||||
size_hint: 0.8, 0.8
|
size_hint: 0.9, 0.85
|
||||||
auto_dismiss: True
|
auto_dismiss: True
|
||||||
|
|
||||||
BoxLayout:
|
BoxLayout:
|
||||||
orientation: 'vertical'
|
orientation: 'vertical'
|
||||||
padding: dp(20)
|
padding: [dp(15), dp(10)]
|
||||||
spacing: dp(15)
|
spacing: dp(8)
|
||||||
|
|
||||||
# Server configuration
|
ScrollView:
|
||||||
|
BoxLayout:
|
||||||
|
orientation: 'vertical'
|
||||||
|
spacing: dp(8)
|
||||||
|
size_hint_y: None
|
||||||
|
height: self.minimum_height
|
||||||
|
|
||||||
|
# Server configuration
|
||||||
|
BoxLayout:
|
||||||
|
orientation: 'horizontal'
|
||||||
|
size_hint_y: None
|
||||||
|
height: dp(36)
|
||||||
|
spacing: dp(8)
|
||||||
|
|
||||||
|
Label:
|
||||||
|
text: 'Server IP:'
|
||||||
|
size_hint_x: 0.3
|
||||||
|
text_size: self.size
|
||||||
|
halign: 'left'
|
||||||
|
valign: 'middle'
|
||||||
|
|
||||||
|
TextInput:
|
||||||
|
id: server_input
|
||||||
|
size_hint_x: 0.7
|
||||||
|
multiline: False
|
||||||
|
font_size: sp(13)
|
||||||
|
hint_text: 'e.g. 192.168.0.110'
|
||||||
|
write_tab: False
|
||||||
|
on_touch_down: root.on_input_touch(self, args[1]) if self.collide_point(*args[1].pos) else None
|
||||||
|
|
||||||
|
# Server port
|
||||||
|
BoxLayout:
|
||||||
|
orientation: 'horizontal'
|
||||||
|
size_hint_y: None
|
||||||
|
height: dp(36)
|
||||||
|
spacing: dp(8)
|
||||||
|
|
||||||
|
Label:
|
||||||
|
text: 'Port:'
|
||||||
|
size_hint_x: 0.3
|
||||||
|
text_size: self.size
|
||||||
|
halign: 'left'
|
||||||
|
valign: 'middle'
|
||||||
|
|
||||||
|
TextInput:
|
||||||
|
id: port_input
|
||||||
|
size_hint_x: 0.7
|
||||||
|
multiline: False
|
||||||
|
font_size: sp(13)
|
||||||
|
hint_text: '80 or 8080 (leave empty for default)'
|
||||||
|
input_filter: 'int'
|
||||||
|
write_tab: False
|
||||||
|
on_touch_down: root.on_input_touch(self, args[1]) if self.collide_point(*args[1].pos) else None
|
||||||
|
|
||||||
|
# Screen name
|
||||||
|
BoxLayout:
|
||||||
|
orientation: 'horizontal'
|
||||||
|
size_hint_y: None
|
||||||
|
height: dp(36)
|
||||||
|
spacing: dp(8)
|
||||||
|
|
||||||
|
Label:
|
||||||
|
text: 'Screen Name:'
|
||||||
|
size_hint_x: 0.3
|
||||||
|
text_size: self.size
|
||||||
|
halign: 'left'
|
||||||
|
valign: 'middle'
|
||||||
|
|
||||||
|
TextInput:
|
||||||
|
id: screen_input
|
||||||
|
size_hint_x: 0.7
|
||||||
|
multiline: False
|
||||||
|
font_size: sp(13)
|
||||||
|
hint_text: 'player name registered on the server'
|
||||||
|
write_tab: False
|
||||||
|
on_touch_down: root.on_input_touch(self, args[1]) if self.collide_point(*args[1].pos) else None
|
||||||
|
|
||||||
|
# Quickconnect key
|
||||||
|
BoxLayout:
|
||||||
|
orientation: 'horizontal'
|
||||||
|
size_hint_y: None
|
||||||
|
height: dp(36)
|
||||||
|
spacing: dp(8)
|
||||||
|
|
||||||
|
Label:
|
||||||
|
text: 'Quickconnect:'
|
||||||
|
size_hint_x: 0.3
|
||||||
|
text_size: self.size
|
||||||
|
halign: 'left'
|
||||||
|
valign: 'middle'
|
||||||
|
|
||||||
|
TextInput:
|
||||||
|
id: quickconnect_input
|
||||||
|
size_hint_x: 0.7
|
||||||
|
multiline: False
|
||||||
|
font_size: sp(13)
|
||||||
|
hint_text: 'e.g. 8887779'
|
||||||
|
write_tab: False
|
||||||
|
on_touch_down: root.on_input_touch(self, args[1]) if self.collide_point(*args[1].pos) else None
|
||||||
|
|
||||||
|
# Orientation
|
||||||
|
BoxLayout:
|
||||||
|
orientation: 'horizontal'
|
||||||
|
size_hint_y: None
|
||||||
|
height: dp(36)
|
||||||
|
spacing: dp(8)
|
||||||
|
|
||||||
|
Label:
|
||||||
|
text: 'Orientation:'
|
||||||
|
size_hint_x: 0.3
|
||||||
|
text_size: self.size
|
||||||
|
halign: 'left'
|
||||||
|
valign: 'middle'
|
||||||
|
|
||||||
|
TextInput:
|
||||||
|
id: orientation_input
|
||||||
|
size_hint_x: 0.7
|
||||||
|
multiline: False
|
||||||
|
font_size: sp(13)
|
||||||
|
write_tab: False
|
||||||
|
on_touch_down: root.on_input_touch(self, args[1]) if self.collide_point(*args[1].pos) else None
|
||||||
|
|
||||||
|
# Touch
|
||||||
|
BoxLayout:
|
||||||
|
orientation: 'horizontal'
|
||||||
|
size_hint_y: None
|
||||||
|
height: dp(36)
|
||||||
|
spacing: dp(8)
|
||||||
|
|
||||||
|
Label:
|
||||||
|
text: 'Touch:'
|
||||||
|
size_hint_x: 0.3
|
||||||
|
text_size: self.size
|
||||||
|
halign: 'left'
|
||||||
|
valign: 'middle'
|
||||||
|
|
||||||
|
TextInput:
|
||||||
|
id: touch_input
|
||||||
|
size_hint_x: 0.7
|
||||||
|
multiline: False
|
||||||
|
font_size: sp(13)
|
||||||
|
write_tab: False
|
||||||
|
on_touch_down: root.on_input_touch(self, args[1]) if self.collide_point(*args[1].pos) else None
|
||||||
|
|
||||||
|
# Resolution
|
||||||
|
BoxLayout:
|
||||||
|
orientation: 'horizontal'
|
||||||
|
size_hint_y: None
|
||||||
|
height: dp(36)
|
||||||
|
spacing: dp(8)
|
||||||
|
|
||||||
|
Label:
|
||||||
|
text: 'Max Resolution:'
|
||||||
|
size_hint_x: 0.3
|
||||||
|
text_size: self.size
|
||||||
|
halign: 'left'
|
||||||
|
valign: 'middle'
|
||||||
|
|
||||||
|
TextInput:
|
||||||
|
id: resolution_input
|
||||||
|
size_hint_x: 0.7
|
||||||
|
multiline: False
|
||||||
|
font_size: sp(13)
|
||||||
|
hint_text: '1920x1080 or auto'
|
||||||
|
write_tab: False
|
||||||
|
on_touch_down: root.on_input_touch(self, args[1]) if self.collide_point(*args[1].pos) else None
|
||||||
|
|
||||||
|
# Edit Feature Enable/Disable
|
||||||
|
BoxLayout:
|
||||||
|
orientation: 'horizontal'
|
||||||
|
size_hint_y: None
|
||||||
|
height: dp(36)
|
||||||
|
spacing: dp(8)
|
||||||
|
|
||||||
|
Label:
|
||||||
|
text: 'Enable Edit:'
|
||||||
|
size_hint_x: 0.3
|
||||||
|
text_size: self.size
|
||||||
|
halign: 'left'
|
||||||
|
valign: 'middle'
|
||||||
|
|
||||||
|
CheckBox:
|
||||||
|
id: edit_enabled_checkbox
|
||||||
|
size_hint_x: None
|
||||||
|
width: dp(36)
|
||||||
|
active: True
|
||||||
|
on_active: root.on_edit_feature_toggle(self.active)
|
||||||
|
|
||||||
|
Label:
|
||||||
|
text: '(Allow editing images)'
|
||||||
|
size_hint_x: 0.4
|
||||||
|
font_size: sp(11)
|
||||||
|
text_size: self.size
|
||||||
|
halign: 'left'
|
||||||
|
valign: 'middle'
|
||||||
|
color: 0.7, 0.7, 0.7, 1
|
||||||
|
|
||||||
|
# Separator
|
||||||
|
Widget:
|
||||||
|
size_hint_y: None
|
||||||
|
height: dp(5)
|
||||||
|
|
||||||
|
# Reset Buttons Section
|
||||||
|
Label:
|
||||||
|
text: 'Reset Options:'
|
||||||
|
size_hint_y: None
|
||||||
|
height: dp(26)
|
||||||
|
text_size: self.size
|
||||||
|
halign: 'left'
|
||||||
|
valign: 'middle'
|
||||||
|
bold: True
|
||||||
|
font_size: sp(14)
|
||||||
|
|
||||||
|
# Reset Buttons Row
|
||||||
|
BoxLayout:
|
||||||
|
orientation: 'horizontal'
|
||||||
|
size_hint_y: None
|
||||||
|
height: dp(44)
|
||||||
|
spacing: dp(8)
|
||||||
|
|
||||||
|
Button:
|
||||||
|
id: reset_auth_btn
|
||||||
|
text: 'Reset Player Auth'
|
||||||
|
background_color: 0.8, 0.4, 0.2, 1
|
||||||
|
font_size: sp(12)
|
||||||
|
on_press: root.reset_player_auth()
|
||||||
|
|
||||||
|
Button:
|
||||||
|
id: reset_playlist_btn
|
||||||
|
text: 'Reset Playlist to v0'
|
||||||
|
background_color: 0.8, 0.4, 0.2, 1
|
||||||
|
font_size: sp(12)
|
||||||
|
on_press: root.reset_playlist_version()
|
||||||
|
|
||||||
|
Button:
|
||||||
|
id: restart_player_btn
|
||||||
|
text: 'Restart Player'
|
||||||
|
background_color: 0.2, 0.6, 0.8, 1
|
||||||
|
font_size: sp(12)
|
||||||
|
on_press: root.restart_player()
|
||||||
|
|
||||||
|
# Test Connection + Production Mode Buttons
|
||||||
|
BoxLayout:
|
||||||
|
orientation: 'horizontal'
|
||||||
|
size_hint_y: None
|
||||||
|
height: dp(44)
|
||||||
|
spacing: dp(8)
|
||||||
|
|
||||||
|
Button:
|
||||||
|
id: test_connection_btn
|
||||||
|
text: 'Test Server Connection'
|
||||||
|
background_color: 0.2, 0.4, 0.8, 1
|
||||||
|
font_size: sp(13)
|
||||||
|
on_press: root.test_connection()
|
||||||
|
|
||||||
|
Button:
|
||||||
|
id: production_mode_btn
|
||||||
|
text: 'Enable Production'
|
||||||
|
background_color: 0.4, 0.4, 0.4, 1 # grey = disabled
|
||||||
|
font_size: sp(13)
|
||||||
|
on_press: root.toggle_production_mode()
|
||||||
|
|
||||||
|
# Connection Status Label
|
||||||
|
Label:
|
||||||
|
id: connection_status
|
||||||
|
text: 'Click button to test connection'
|
||||||
|
size_hint_y: None
|
||||||
|
height: dp(32)
|
||||||
|
text_size: self.size
|
||||||
|
halign: 'center'
|
||||||
|
valign: 'middle'
|
||||||
|
font_size: sp(11)
|
||||||
|
color: 0.7, 0.7, 0.7, 1
|
||||||
|
|
||||||
|
# Separator
|
||||||
|
Widget:
|
||||||
|
size_hint_y: None
|
||||||
|
height: dp(5)
|
||||||
|
|
||||||
|
# Status information row
|
||||||
|
BoxLayout:
|
||||||
|
orientation: 'horizontal'
|
||||||
|
size_hint_y: None
|
||||||
|
height: dp(26)
|
||||||
|
spacing: dp(8)
|
||||||
|
|
||||||
|
Label:
|
||||||
|
id: playlist_info
|
||||||
|
text: 'Playlist: N/A'
|
||||||
|
text_size: self.size
|
||||||
|
halign: 'center'
|
||||||
|
valign: 'middle'
|
||||||
|
font_size: sp(11)
|
||||||
|
|
||||||
|
Label:
|
||||||
|
id: media_count_info
|
||||||
|
text: 'Media: 0'
|
||||||
|
text_size: self.size
|
||||||
|
halign: 'center'
|
||||||
|
valign: 'middle'
|
||||||
|
font_size: sp(11)
|
||||||
|
|
||||||
|
Label:
|
||||||
|
id: status_info
|
||||||
|
text: 'Status: Idle'
|
||||||
|
text_size: self.size
|
||||||
|
halign: 'center'
|
||||||
|
valign: 'middle'
|
||||||
|
font_size: sp(11)
|
||||||
|
|
||||||
|
# Action buttons (always visible, outside scroll)
|
||||||
BoxLayout:
|
BoxLayout:
|
||||||
orientation: 'horizontal'
|
orientation: 'horizontal'
|
||||||
size_hint_y: None
|
size_hint_y: None
|
||||||
height: dp(40)
|
height: dp(44)
|
||||||
spacing: dp(10)
|
spacing: dp(15)
|
||||||
|
|
||||||
Label:
|
|
||||||
text: 'Server IP:'
|
|
||||||
size_hint_x: 0.3
|
|
||||||
text_size: self.size
|
|
||||||
halign: 'left'
|
|
||||||
valign: 'middle'
|
|
||||||
|
|
||||||
TextInput:
|
|
||||||
id: server_input
|
|
||||||
size_hint_x: 0.7
|
|
||||||
multiline: False
|
|
||||||
font_size: sp(14)
|
|
||||||
write_tab: False
|
|
||||||
on_touch_down: root.on_input_touch(self, args[1]) if self.collide_point(*args[1].pos) else None
|
|
||||||
|
|
||||||
# Screen name
|
|
||||||
BoxLayout:
|
|
||||||
orientation: 'horizontal'
|
|
||||||
size_hint_y: None
|
|
||||||
height: dp(40)
|
|
||||||
spacing: dp(10)
|
|
||||||
|
|
||||||
Label:
|
|
||||||
text: 'Screen Name:'
|
|
||||||
size_hint_x: 0.3
|
|
||||||
text_size: self.size
|
|
||||||
halign: 'left'
|
|
||||||
valign: 'middle'
|
|
||||||
|
|
||||||
TextInput:
|
|
||||||
id: screen_input
|
|
||||||
size_hint_x: 0.7
|
|
||||||
multiline: False
|
|
||||||
font_size: sp(14)
|
|
||||||
write_tab: False
|
|
||||||
on_touch_down: root.on_input_touch(self, args[1]) if self.collide_point(*args[1].pos) else None
|
|
||||||
|
|
||||||
# Quickconnect key
|
|
||||||
BoxLayout:
|
|
||||||
orientation: 'horizontal'
|
|
||||||
size_hint_y: None
|
|
||||||
height: dp(40)
|
|
||||||
spacing: dp(10)
|
|
||||||
|
|
||||||
Label:
|
|
||||||
text: 'Quickconnect:'
|
|
||||||
size_hint_x: 0.3
|
|
||||||
text_size: self.size
|
|
||||||
halign: 'left'
|
|
||||||
valign: 'middle'
|
|
||||||
|
|
||||||
TextInput:
|
|
||||||
id: quickconnect_input
|
|
||||||
size_hint_x: 0.7
|
|
||||||
multiline: False
|
|
||||||
font_size: sp(14)
|
|
||||||
write_tab: False
|
|
||||||
on_touch_down: root.on_input_touch(self, args[1]) if self.collide_point(*args[1].pos) else None
|
|
||||||
|
|
||||||
# Orientation
|
|
||||||
BoxLayout:
|
|
||||||
orientation: 'horizontal'
|
|
||||||
size_hint_y: None
|
|
||||||
height: dp(40)
|
|
||||||
spacing: dp(10)
|
|
||||||
|
|
||||||
Label:
|
|
||||||
text: 'Orientation:'
|
|
||||||
size_hint_x: 0.3
|
|
||||||
text_size: self.size
|
|
||||||
halign: 'left'
|
|
||||||
valign: 'middle'
|
|
||||||
|
|
||||||
TextInput:
|
|
||||||
id: orientation_input
|
|
||||||
size_hint_x: 0.7
|
|
||||||
multiline: False
|
|
||||||
font_size: sp(14)
|
|
||||||
write_tab: False
|
|
||||||
on_touch_down: root.on_input_touch(self, args[1]) if self.collide_point(*args[1].pos) else None
|
|
||||||
|
|
||||||
# Touch
|
|
||||||
BoxLayout:
|
|
||||||
orientation: 'horizontal'
|
|
||||||
size_hint_y: None
|
|
||||||
height: dp(40)
|
|
||||||
spacing: dp(10)
|
|
||||||
|
|
||||||
Label:
|
|
||||||
text: 'Touch:'
|
|
||||||
size_hint_x: 0.3
|
|
||||||
text_size: self.size
|
|
||||||
halign: 'left'
|
|
||||||
valign: 'middle'
|
|
||||||
|
|
||||||
TextInput:
|
|
||||||
id: touch_input
|
|
||||||
size_hint_x: 0.7
|
|
||||||
multiline: False
|
|
||||||
font_size: sp(14)
|
|
||||||
write_tab: False
|
|
||||||
on_touch_down: root.on_input_touch(self, args[1]) if self.collide_point(*args[1].pos) else None
|
|
||||||
|
|
||||||
# Resolution
|
|
||||||
BoxLayout:
|
|
||||||
orientation: 'horizontal'
|
|
||||||
size_hint_y: None
|
|
||||||
height: dp(40)
|
|
||||||
spacing: dp(10)
|
|
||||||
|
|
||||||
Label:
|
|
||||||
text: 'Max Resolution:'
|
|
||||||
size_hint_x: 0.3
|
|
||||||
text_size: self.size
|
|
||||||
halign: 'left'
|
|
||||||
valign: 'middle'
|
|
||||||
|
|
||||||
TextInput:
|
|
||||||
id: resolution_input
|
|
||||||
size_hint_x: 0.7
|
|
||||||
multiline: False
|
|
||||||
font_size: sp(14)
|
|
||||||
hint_text: '1920x1080 or auto'
|
|
||||||
write_tab: False
|
|
||||||
on_touch_down: root.on_input_touch(self, args[1]) if self.collide_point(*args[1].pos) else None
|
|
||||||
|
|
||||||
Widget:
|
|
||||||
size_hint_y: 0.05
|
|
||||||
|
|
||||||
# Test Connection Button
|
|
||||||
Button:
|
|
||||||
id: test_connection_btn
|
|
||||||
text: 'Test Server Connection'
|
|
||||||
size_hint_y: None
|
|
||||||
height: dp(50)
|
|
||||||
background_color: 0.2, 0.4, 0.8, 1
|
|
||||||
on_press: root.test_connection()
|
|
||||||
|
|
||||||
# Connection Status Label
|
|
||||||
Label:
|
|
||||||
id: connection_status
|
|
||||||
text: 'Click button to test connection'
|
|
||||||
size_hint_y: None
|
|
||||||
height: dp(40)
|
|
||||||
text_size: self.size
|
|
||||||
halign: 'center'
|
|
||||||
valign: 'middle'
|
|
||||||
color: 0.7, 0.7, 0.7, 1
|
|
||||||
|
|
||||||
Widget:
|
|
||||||
size_hint_y: 0.05
|
|
||||||
|
|
||||||
# Status information
|
|
||||||
Label:
|
|
||||||
id: playlist_info
|
|
||||||
text: 'Playlist Version: N/A'
|
|
||||||
size_hint_y: None
|
|
||||||
height: dp(30)
|
|
||||||
text_size: self.size
|
|
||||||
halign: 'left'
|
|
||||||
valign: 'middle'
|
|
||||||
|
|
||||||
Label:
|
|
||||||
id: media_count_info
|
|
||||||
text: 'Media Count: 0'
|
|
||||||
size_hint_y: None
|
|
||||||
height: dp(30)
|
|
||||||
text_size: self.size
|
|
||||||
halign: 'left'
|
|
||||||
valign: 'middle'
|
|
||||||
|
|
||||||
Label:
|
|
||||||
id: status_info
|
|
||||||
text: 'Status: Idle'
|
|
||||||
size_hint_y: None
|
|
||||||
height: dp(30)
|
|
||||||
text_size: self.size
|
|
||||||
halign: 'left'
|
|
||||||
valign: 'middle'
|
|
||||||
|
|
||||||
Widget:
|
|
||||||
size_hint_y: 0.2
|
|
||||||
|
|
||||||
# Action buttons
|
|
||||||
BoxLayout:
|
|
||||||
orientation: 'horizontal'
|
|
||||||
size_hint_y: None
|
|
||||||
height: dp(50)
|
|
||||||
spacing: dp(20)
|
|
||||||
|
|
||||||
Button:
|
Button:
|
||||||
text: 'Save & Close'
|
text: 'Save & Close'
|
||||||
@@ -558,3 +686,279 @@
|
|||||||
text: 'Cancel'
|
text: 'Cancel'
|
||||||
background_color: 0.6, 0.2, 0.2, 1
|
background_color: 0.6, 0.2, 0.2, 1
|
||||||
on_press: root.dismiss()
|
on_press: root.dismiss()
|
||||||
|
|
||||||
|
|
||||||
|
# Card Swipe Popup
|
||||||
|
<CardSwipePopup>:
|
||||||
|
title: 'Card Authentication Required'
|
||||||
|
size_hint: 0.5, 0.4
|
||||||
|
auto_dismiss: False
|
||||||
|
separator_height: 2
|
||||||
|
|
||||||
|
BoxLayout:
|
||||||
|
orientation: 'vertical'
|
||||||
|
padding: dp(20)
|
||||||
|
spacing: dp(20)
|
||||||
|
|
||||||
|
# Card swipe icon
|
||||||
|
AsyncImage:
|
||||||
|
id: icon_image
|
||||||
|
size_hint: 1, 0.4
|
||||||
|
allow_stretch: True
|
||||||
|
keep_ratio: True
|
||||||
|
|
||||||
|
# Message label
|
||||||
|
Label:
|
||||||
|
id: message_label
|
||||||
|
text: 'Please swipe your card...'
|
||||||
|
font_size: sp(20)
|
||||||
|
size_hint: 1, 0.2
|
||||||
|
|
||||||
|
# Countdown timer
|
||||||
|
Label:
|
||||||
|
id: countdown_label
|
||||||
|
text: '5'
|
||||||
|
font_size: sp(48)
|
||||||
|
color: 0.9, 0.6, 0.2, 1
|
||||||
|
size_hint: 1, 0.2
|
||||||
|
|
||||||
|
# Cancel button
|
||||||
|
Button:
|
||||||
|
text: 'Cancel'
|
||||||
|
size_hint: 1, 0.2
|
||||||
|
background_color: 0.9, 0.3, 0.2, 1
|
||||||
|
on_press: root.cancel(self)
|
||||||
|
|
||||||
|
|
||||||
|
# Edit Popup (Drawing on Images)
|
||||||
|
<EditPopup>:
|
||||||
|
title: ''
|
||||||
|
size_hint: 1, 1
|
||||||
|
auto_dismiss: False
|
||||||
|
separator_height: 0
|
||||||
|
|
||||||
|
FloatLayout:
|
||||||
|
# Background image (full screen)
|
||||||
|
Image:
|
||||||
|
id: image_widget
|
||||||
|
allow_stretch: True
|
||||||
|
keep_ratio: True
|
||||||
|
size_hint: 1, 1
|
||||||
|
pos_hint: {'x': 0, 'y': 0}
|
||||||
|
|
||||||
|
# Drawing layer (will be added programmatically due to custom class)
|
||||||
|
# Placeholder widget for drawing layer positioning
|
||||||
|
Widget:
|
||||||
|
id: drawing_layer_placeholder
|
||||||
|
size_hint: 1, 1
|
||||||
|
pos_hint: {'x': 0, 'y': 0}
|
||||||
|
|
||||||
|
# Top toolbar
|
||||||
|
BoxLayout:
|
||||||
|
id: top_toolbar
|
||||||
|
orientation: 'horizontal'
|
||||||
|
size_hint: 1, None
|
||||||
|
height: dp(56)
|
||||||
|
pos_hint: {'top': 1, 'x': 0}
|
||||||
|
spacing: dp(10)
|
||||||
|
padding: [dp(10), dp(8)]
|
||||||
|
|
||||||
|
canvas.before:
|
||||||
|
Color:
|
||||||
|
rgba: 0.1, 0.1, 0.1, 0.5
|
||||||
|
Rectangle:
|
||||||
|
size: self.size
|
||||||
|
pos: self.pos
|
||||||
|
|
||||||
|
Widget: # Spacer
|
||||||
|
|
||||||
|
Button:
|
||||||
|
id: undo_btn
|
||||||
|
text: 'Undo'
|
||||||
|
font_size: sp(16)
|
||||||
|
size_hint: None, 1
|
||||||
|
width: dp(100)
|
||||||
|
background_normal: ''
|
||||||
|
background_color: 0.9, 0.6, 0.2, 0.9
|
||||||
|
|
||||||
|
Button:
|
||||||
|
id: clear_btn
|
||||||
|
text: 'Clear'
|
||||||
|
font_size: sp(16)
|
||||||
|
size_hint: None, 1
|
||||||
|
width: dp(100)
|
||||||
|
background_normal: ''
|
||||||
|
background_color: 0.9, 0.3, 0.2, 0.9
|
||||||
|
|
||||||
|
Button:
|
||||||
|
id: save_btn
|
||||||
|
text: 'Save'
|
||||||
|
font_size: sp(16)
|
||||||
|
size_hint: None, 1
|
||||||
|
width: dp(100)
|
||||||
|
background_normal: ''
|
||||||
|
background_color: 0.2, 0.8, 0.2, 0.9
|
||||||
|
|
||||||
|
Button:
|
||||||
|
id: cancel_btn
|
||||||
|
text: 'Cancel'
|
||||||
|
font_size: sp(16)
|
||||||
|
size_hint: None, 1
|
||||||
|
width: dp(100)
|
||||||
|
background_normal: ''
|
||||||
|
background_color: 0.6, 0.2, 0.2, 0.9
|
||||||
|
|
||||||
|
Label:
|
||||||
|
id: countdown_label
|
||||||
|
text: '5:00'
|
||||||
|
font_size: sp(20)
|
||||||
|
size_hint: None, 1
|
||||||
|
width: dp(80)
|
||||||
|
color: 1, 1, 1, 1
|
||||||
|
bold: True
|
||||||
|
|
||||||
|
Widget: # Small spacer
|
||||||
|
size_hint: None, 1
|
||||||
|
width: dp(10)
|
||||||
|
|
||||||
|
# Right sidebar
|
||||||
|
BoxLayout:
|
||||||
|
id: right_sidebar
|
||||||
|
orientation: 'vertical'
|
||||||
|
size_hint: None, 1
|
||||||
|
width: dp(56)
|
||||||
|
pos_hint: {'right': 1, 'y': 0}
|
||||||
|
spacing: dp(10)
|
||||||
|
padding: [dp(8), dp(66), dp(8), dp(10)]
|
||||||
|
|
||||||
|
canvas.before:
|
||||||
|
Color:
|
||||||
|
rgba: 0.1, 0.1, 0.1, 0.5
|
||||||
|
Rectangle:
|
||||||
|
size: self.size
|
||||||
|
pos: self.pos
|
||||||
|
|
||||||
|
# Color section header
|
||||||
|
BoxLayout:
|
||||||
|
orientation: 'vertical'
|
||||||
|
size_hint_y: None
|
||||||
|
height: dp(55)
|
||||||
|
spacing: dp(2)
|
||||||
|
|
||||||
|
Image:
|
||||||
|
id: color_icon
|
||||||
|
size_hint_y: None
|
||||||
|
height: dp(28)
|
||||||
|
allow_stretch: True
|
||||||
|
keep_ratio: True
|
||||||
|
|
||||||
|
Label:
|
||||||
|
text: 'Color'
|
||||||
|
font_size: sp(11)
|
||||||
|
bold: True
|
||||||
|
size_hint_y: None
|
||||||
|
height: dp(25)
|
||||||
|
|
||||||
|
# Color buttons
|
||||||
|
Button:
|
||||||
|
id: red_btn
|
||||||
|
text: 'R'
|
||||||
|
font_size: sp(18)
|
||||||
|
size_hint: 1, None
|
||||||
|
height: dp(50)
|
||||||
|
background_normal: ''
|
||||||
|
background_color: 1, 0, 0, 1
|
||||||
|
|
||||||
|
Button:
|
||||||
|
id: blue_btn
|
||||||
|
text: 'B'
|
||||||
|
font_size: sp(18)
|
||||||
|
size_hint: 1, None
|
||||||
|
height: dp(50)
|
||||||
|
background_normal: ''
|
||||||
|
background_color: 0, 0, 1, 1
|
||||||
|
|
||||||
|
Button:
|
||||||
|
id: green_btn
|
||||||
|
text: 'G'
|
||||||
|
font_size: sp(18)
|
||||||
|
size_hint: 1, None
|
||||||
|
height: dp(50)
|
||||||
|
background_normal: ''
|
||||||
|
background_color: 0, 1, 0, 1
|
||||||
|
|
||||||
|
Button:
|
||||||
|
id: black_btn
|
||||||
|
text: 'K'
|
||||||
|
font_size: sp(18)
|
||||||
|
size_hint: 1, None
|
||||||
|
height: dp(50)
|
||||||
|
background_normal: ''
|
||||||
|
background_color: 0, 0, 0, 1
|
||||||
|
|
||||||
|
Button:
|
||||||
|
id: white_btn
|
||||||
|
text: 'W'
|
||||||
|
font_size: sp(18)
|
||||||
|
size_hint: 1, None
|
||||||
|
height: dp(50)
|
||||||
|
background_normal: ''
|
||||||
|
background_color: 1, 1, 1, 1
|
||||||
|
|
||||||
|
# Spacer
|
||||||
|
Widget:
|
||||||
|
size_hint_y: 0.2
|
||||||
|
|
||||||
|
# Thickness section header
|
||||||
|
BoxLayout:
|
||||||
|
orientation: 'vertical'
|
||||||
|
size_hint_y: None
|
||||||
|
height: dp(55)
|
||||||
|
spacing: dp(2)
|
||||||
|
|
||||||
|
Image:
|
||||||
|
id: thickness_icon
|
||||||
|
size_hint_y: None
|
||||||
|
height: dp(28)
|
||||||
|
allow_stretch: True
|
||||||
|
keep_ratio: True
|
||||||
|
|
||||||
|
Label:
|
||||||
|
text: 'Size'
|
||||||
|
font_size: sp(11)
|
||||||
|
bold: True
|
||||||
|
size_hint_y: None
|
||||||
|
height: dp(25)
|
||||||
|
|
||||||
|
# Thickness buttons
|
||||||
|
Button:
|
||||||
|
id: small_btn
|
||||||
|
text: 'S'
|
||||||
|
font_size: sp(20)
|
||||||
|
bold: True
|
||||||
|
size_hint: 1, None
|
||||||
|
height: dp(50)
|
||||||
|
background_normal: ''
|
||||||
|
background_color: 0.3, 0.3, 0.3, 0.9
|
||||||
|
|
||||||
|
Button:
|
||||||
|
id: medium_btn
|
||||||
|
text: 'M'
|
||||||
|
font_size: sp(20)
|
||||||
|
bold: True
|
||||||
|
size_hint: 1, None
|
||||||
|
height: dp(50)
|
||||||
|
background_normal: ''
|
||||||
|
background_color: 0.3, 0.3, 0.3, 0.9
|
||||||
|
|
||||||
|
Button:
|
||||||
|
id: large_btn
|
||||||
|
text: 'L'
|
||||||
|
font_size: sp(20)
|
||||||
|
bold: True
|
||||||
|
size_hint: 1, None
|
||||||
|
height: dp(50)
|
||||||
|
background_normal: ''
|
||||||
|
background_color: 0.3, 0.3, 0.3, 0.9
|
||||||
|
|
||||||
|
Widget: # Bottom spacer
|
||||||
@@ -0,0 +1,260 @@
|
|||||||
|
"""
|
||||||
|
SSL/HTTPS Utilities for Kiwy-Signage
|
||||||
|
Handles server certificate verification and HTTPS connection setup
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import json
|
||||||
|
import requests
|
||||||
|
import logging
|
||||||
|
import ssl
|
||||||
|
import certifi
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional, Tuple
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class SSLManager:
|
||||||
|
"""Manages SSL certificates and HTTPS connections for player."""
|
||||||
|
|
||||||
|
# Certificate storage location
|
||||||
|
CERT_DIR = os.path.expanduser('~/.kiwy-signage')
|
||||||
|
CERT_FILE = os.path.join(CERT_DIR, 'server_cert.pem')
|
||||||
|
CERT_INFO_FILE = os.path.join(CERT_DIR, 'cert_info.json')
|
||||||
|
|
||||||
|
def __init__(self, verify_ssl: bool = True):
|
||||||
|
"""Initialize SSL manager.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
verify_ssl: Whether to verify SSL certificates (False for dev/testing)
|
||||||
|
"""
|
||||||
|
self.verify_ssl = verify_ssl
|
||||||
|
self.session = requests.Session()
|
||||||
|
self._configure_session()
|
||||||
|
|
||||||
|
def _configure_session(self) -> None:
|
||||||
|
"""Configure requests session with SSL settings."""
|
||||||
|
if self.verify_ssl:
|
||||||
|
# Use saved certificate if available, otherwise use system certs
|
||||||
|
if os.path.exists(self.CERT_FILE):
|
||||||
|
self.session.verify = self.CERT_FILE
|
||||||
|
logger.debug(f"Using saved certificate: {self.CERT_FILE}")
|
||||||
|
else:
|
||||||
|
# Use certifi's CA bundle
|
||||||
|
self.session.verify = certifi.where()
|
||||||
|
logger.debug("Using system CA bundle")
|
||||||
|
else:
|
||||||
|
# For development/testing only
|
||||||
|
self.session.verify = False
|
||||||
|
logger.warning("⚠️ SSL verification disabled - NOT recommended for production!")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def ensure_cert_dir() -> str:
|
||||||
|
"""Ensure certificate directory exists.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Path to certificate directory
|
||||||
|
"""
|
||||||
|
Path(SSLManager.CERT_DIR).mkdir(parents=True, exist_ok=True)
|
||||||
|
return SSLManager.CERT_DIR
|
||||||
|
|
||||||
|
def download_server_certificate(self, server_url: str,
|
||||||
|
timeout: int = 10) -> Tuple[bool, Optional[str]]:
|
||||||
|
"""Download and save server certificate from /api/certificate endpoint.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
server_url: Server URL (e.g., 'https://server:443')
|
||||||
|
timeout: Request timeout in seconds
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (success: bool, error_message: Optional[str])
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Ensure cert directory exists
|
||||||
|
self.ensure_cert_dir()
|
||||||
|
|
||||||
|
# Make initial request without verification to get certificate
|
||||||
|
temp_session = requests.Session()
|
||||||
|
temp_session.verify = False # Only for getting the cert
|
||||||
|
|
||||||
|
cert_url = f"{server_url}/api/certificate"
|
||||||
|
logger.info(f"Downloading server certificate from {cert_url}")
|
||||||
|
|
||||||
|
response = temp_session.get(cert_url, timeout=timeout)
|
||||||
|
|
||||||
|
if response.status_code == 404:
|
||||||
|
# Server doesn't have certificate endpoint - this is okay
|
||||||
|
logger.info("⚠️ Server does not have /api/certificate endpoint. Certificate verification will be skipped for this session.")
|
||||||
|
return False, "Endpoint not available"
|
||||||
|
|
||||||
|
if response.status_code != 200:
|
||||||
|
error_msg = f"Failed to download certificate: {response.status_code}"
|
||||||
|
logger.error(error_msg)
|
||||||
|
return False, error_msg
|
||||||
|
|
||||||
|
cert_data = response.json()
|
||||||
|
certificate_pem = cert_data.get('certificate')
|
||||||
|
cert_info = cert_data.get('certificate_info', {})
|
||||||
|
|
||||||
|
if not certificate_pem:
|
||||||
|
error_msg = "No certificate data in response"
|
||||||
|
logger.error(error_msg)
|
||||||
|
return False, error_msg
|
||||||
|
|
||||||
|
# Save certificate
|
||||||
|
with open(self.CERT_FILE, 'w') as f:
|
||||||
|
f.write(certificate_pem)
|
||||||
|
|
||||||
|
# Save certificate info
|
||||||
|
with open(self.CERT_INFO_FILE, 'w') as f:
|
||||||
|
json.dump(cert_info, f, indent=2, default=str)
|
||||||
|
|
||||||
|
logger.info(f"✅ Server certificate saved to {self.CERT_FILE}")
|
||||||
|
logger.info(f" Subject: {cert_info.get('subject', 'Unknown')}")
|
||||||
|
logger.info(f" Valid until: {cert_info.get('valid_until', 'Unknown')}")
|
||||||
|
|
||||||
|
# Reconfigure session to use new certificate
|
||||||
|
self.session.verify = self.CERT_FILE
|
||||||
|
|
||||||
|
return True, None
|
||||||
|
|
||||||
|
except requests.exceptions.ConnectionError as e:
|
||||||
|
error_msg = f"Connection error: {e}"
|
||||||
|
logger.error(error_msg)
|
||||||
|
return False, error_msg
|
||||||
|
|
||||||
|
except requests.exceptions.Timeout:
|
||||||
|
error_msg = "Request timeout"
|
||||||
|
logger.error(error_msg)
|
||||||
|
return False, error_msg
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
error_msg = f"Error downloading certificate: {e}"
|
||||||
|
logger.error(error_msg)
|
||||||
|
return False, error_msg
|
||||||
|
|
||||||
|
def has_certificate(self) -> bool:
|
||||||
|
"""Check if server certificate is saved.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if certificate file exists
|
||||||
|
"""
|
||||||
|
return os.path.exists(self.CERT_FILE)
|
||||||
|
|
||||||
|
def get_certificate_info(self) -> Optional[dict]:
|
||||||
|
"""Get saved certificate information.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Certificate info dict or None
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
if os.path.exists(self.CERT_INFO_FILE):
|
||||||
|
with open(self.CERT_INFO_FILE, 'r') as f:
|
||||||
|
return json.load(f)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to read certificate info: {e}")
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_session(self) -> requests.Session:
|
||||||
|
"""Get configured requests session.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Requests session with SSL configured
|
||||||
|
"""
|
||||||
|
return self.session
|
||||||
|
|
||||||
|
def get(self, url: str, **kwargs) -> requests.Response:
|
||||||
|
"""Perform GET request with SSL verification.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
url: URL to request
|
||||||
|
**kwargs: Additional arguments for requests.get()
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Response object
|
||||||
|
"""
|
||||||
|
return self.session.get(url, **kwargs)
|
||||||
|
|
||||||
|
def post(self, url: str, **kwargs) -> requests.Response:
|
||||||
|
"""Perform POST request with SSL verification.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
url: URL to request
|
||||||
|
**kwargs: Additional arguments for requests.post()
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Response object
|
||||||
|
"""
|
||||||
|
return self.session.post(url, **kwargs)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def create_ssl_context(cert_path: Optional[str] = None) -> ssl.SSLContext:
|
||||||
|
"""Create SSL context for custom SSL handling.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
cert_path: Path to certificate file
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Configured SSL context
|
||||||
|
"""
|
||||||
|
context = ssl.create_default_context()
|
||||||
|
|
||||||
|
if cert_path and os.path.exists(cert_path):
|
||||||
|
context.load_verify_locations(cert_path)
|
||||||
|
|
||||||
|
return context
|
||||||
|
|
||||||
|
def validate_url_scheme(self, server_url: str) -> str:
|
||||||
|
"""Ensure server URL uses HTTPS.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
server_url: Server URL
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
URL with https:// scheme
|
||||||
|
"""
|
||||||
|
if not server_url:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
# Remove trailing slash
|
||||||
|
server_url = server_url.rstrip('/')
|
||||||
|
|
||||||
|
# Convert http to https
|
||||||
|
if server_url.startswith('http://'):
|
||||||
|
logger.warning("⚠️ Converting http:// to https://")
|
||||||
|
server_url = server_url.replace('http://', 'https://', 1)
|
||||||
|
elif not server_url.startswith('https://'):
|
||||||
|
logger.debug("Adding https:// to server URL")
|
||||||
|
server_url = f'https://{server_url}'
|
||||||
|
|
||||||
|
return server_url
|
||||||
|
|
||||||
|
|
||||||
|
def setup_ssl_for_requests(server_url: str, use_https: bool = True,
|
||||||
|
verify_ssl: bool = True) -> Tuple[requests.Session, bool]:
|
||||||
|
"""Quick setup for requests session with SSL.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
server_url: Server URL
|
||||||
|
use_https: Whether to use HTTPS
|
||||||
|
verify_ssl: Whether to verify SSL certificates
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (session, success)
|
||||||
|
"""
|
||||||
|
ssl_manager = SSLManager(verify_ssl=verify_ssl)
|
||||||
|
|
||||||
|
if use_https:
|
||||||
|
# Normalize URL to use HTTPS
|
||||||
|
server_url = ssl_manager.validate_url_scheme(server_url)
|
||||||
|
|
||||||
|
# Try to download certificate if not present
|
||||||
|
if not ssl_manager.has_certificate():
|
||||||
|
logger.info("No saved certificate found, attempting to download...")
|
||||||
|
success, error = ssl_manager.download_server_certificate(server_url)
|
||||||
|
if not success and verify_ssl:
|
||||||
|
logger.warning(f"Failed to setup SSL: {error}")
|
||||||
|
# Return session anyway, it will use system certs
|
||||||
|
|
||||||
|
return ssl_manager.get_session(), True
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Test script for network monitor functionality
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from kivy.app import App
|
||||||
|
from kivy.clock import Clock
|
||||||
|
from network_monitor import NetworkMonitor
|
||||||
|
|
||||||
|
class TestMonitorApp(App):
|
||||||
|
"""Minimal Kivy app to test network monitor"""
|
||||||
|
|
||||||
|
def build(self):
|
||||||
|
"""Build the app"""
|
||||||
|
from kivy.uix.label import Label
|
||||||
|
return Label(text='Network Monitor Test Running\nCheck terminal for output')
|
||||||
|
|
||||||
|
def on_start(self):
|
||||||
|
"""Start monitoring when app starts"""
|
||||||
|
server_url = "https://digi-signage.moto-adv.com"
|
||||||
|
|
||||||
|
print("=" * 60)
|
||||||
|
print("Network Monitor Test")
|
||||||
|
print("=" * 60)
|
||||||
|
print()
|
||||||
|
print(f"Server URL: {server_url}")
|
||||||
|
print("Check interval: 0.5 minutes (30 seconds for testing)")
|
||||||
|
print("WiFi restart duration: 1 minute (for testing)")
|
||||||
|
print()
|
||||||
|
|
||||||
|
# Create monitor with short intervals for testing
|
||||||
|
self.monitor = NetworkMonitor(
|
||||||
|
server_url=server_url,
|
||||||
|
check_interval_min=0.5, # 30 seconds
|
||||||
|
check_interval_max=0.5, # 30 seconds
|
||||||
|
wifi_restart_duration=1 # 1 minute
|
||||||
|
)
|
||||||
|
|
||||||
|
# Perform immediate test
|
||||||
|
print("Performing immediate connectivity test...")
|
||||||
|
self.monitor._check_connectivity()
|
||||||
|
|
||||||
|
# Start monitoring for future checks
|
||||||
|
print("\nStarting periodic network monitoring...")
|
||||||
|
self.monitor.start_monitoring()
|
||||||
|
|
||||||
|
print("\nMonitoring is active. Press Ctrl+C to stop.")
|
||||||
|
print("Next check will occur in ~30 seconds.")
|
||||||
|
print()
|
||||||
|
|
||||||
|
def on_stop(self):
|
||||||
|
"""Stop monitoring when app stops"""
|
||||||
|
if hasattr(self, 'monitor'):
|
||||||
|
self.monitor.stop_monitoring()
|
||||||
|
print("\nNetwork monitoring stopped")
|
||||||
|
print("Test completed!")
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
TestMonitorApp().run()
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
"""video_safety.py — make Kivy's video teardown non-blocking.
|
||||||
|
|
||||||
|
The bug this fixes
|
||||||
|
------------------
|
||||||
|
The player froze after ~30-45 minutes of looping, always at a *video* item.
|
||||||
|
The trace stopped dead right after `video_loaded` with no `video_eos` and no
|
||||||
|
`advance_after_video_eos`.
|
||||||
|
|
||||||
|
Cause — a blocking ``join()`` that Kivy performs on the calling thread:
|
||||||
|
|
||||||
|
1. At end of stream, ffpyplayer/``VideoFFPy`` fires its ``on_eos`` event.
|
||||||
|
2. Kivy's ``Video`` widget binds its own handler **first** (in
|
||||||
|
``kivy/uix/video.py``: ``self._video.bind(..., on_eos=self._on_eos)``).
|
||||||
|
That handler is::
|
||||||
|
|
||||||
|
def _on_eos(self, *largs):
|
||||||
|
if not self._video or self._video.eos != 'loop':
|
||||||
|
self.state = 'stop' # <-- fires DURING the on_eos dispatch
|
||||||
|
|
||||||
|
3. ``Video.state = 'stop'`` → ``on_state`` → ``VideoFFPy.stop()`` →
|
||||||
|
``unload()``, which does ``self._thread.join()``
|
||||||
|
(``video_ffpyplayer.py``: ``# TODO: use callback, don't block here``).
|
||||||
|
4. That join waits for the ffpyplayer decode thread, whose lifetime is variable
|
||||||
|
(the existing code comments note "0.4s up to 100s+"). When it does not
|
||||||
|
return, the SDL/Kivy main thread never returns either — the window stops
|
||||||
|
pumping messages and the whole player appears hung.
|
||||||
|
Why it only shows up after a while: our own EOS handler deliberately does not
|
||||||
|
set ``state = 'stop'`` (that path is already handled off-thread), so the hang
|
||||||
|
depends on Kivy's own handler firing first and on that particular video's
|
||||||
|
decode thread being slow to exit. It is a race, so it looks random and only
|
||||||
|
appears after many video cycles.
|
||||||
|
|
||||||
|
The fix
|
||||||
|
-------
|
||||||
|
Bound the wait. ffpyplayer sets ``_ffplayer_need_quit = True`` and wakes its
|
||||||
|
thread before joining, so the thread *is* asked to exit — we simply stop
|
||||||
|
waiting indefinitely for it. ``suppress_kivy_video_blocking_unload()`` patches
|
||||||
|
the join on the specific ``VideoFFPy`` thread object to a bounded timeout, so a
|
||||||
|
wedged decode thread can no longer take the whole player down. The teardown
|
||||||
|
still runs off the main thread (see ``_teardown_video_async`` in ``main.py``),
|
||||||
|
so the normal case is unaffected.
|
||||||
|
|
||||||
|
This is deliberately narrow: only Kivy's own internal video thread is patched,
|
||||||
|
only its ``join`` timeout is bounded, and every failure path leaves Kivy
|
||||||
|
untouched.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
|
||||||
|
#: How long a video's decode thread may take to exit before we give up on it.
|
||||||
|
#: A healthy ffpyplayer thread exits in well under a second; this allows a lot
|
||||||
|
#: of slack while still guaranteeing the UI thread is never parked forever.
|
||||||
|
DEFAULT_JOIN_TIMEOUT = 5.0
|
||||||
|
|
||||||
|
_patched_threads = 0
|
||||||
|
_lock = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
|
def _log(message):
|
||||||
|
try:
|
||||||
|
from kivy.logger import Logger
|
||||||
|
|
||||||
|
Logger.info(f'[VideoSafety] {message}')
|
||||||
|
except Exception:
|
||||||
|
print(f'[VideoSafety] {message}')
|
||||||
|
|
||||||
|
|
||||||
|
def suppress_kivy_video_blocking_unload(timeout=DEFAULT_JOIN_TIMEOUT):
|
||||||
|
"""Bound the join() Kivy's VideoFFPy.unload() performs on the caller.
|
||||||
|
|
||||||
|
Must be called *before* the video widget is constructed, because the
|
||||||
|
decode thread is created during ``play()``. Safe to call repeatedly.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
timeout: maximum seconds to wait for the decode thread to exit.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True when the guard was installed (or already present).
|
||||||
|
"""
|
||||||
|
global _patched_threads
|
||||||
|
|
||||||
|
if timeout is None or float(timeout) <= 0:
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Importing this module has the side effect of selecting the
|
||||||
|
# ffpyplayer provider; if a different provider is active (or video
|
||||||
|
# support is missing) there is nothing to patch.
|
||||||
|
from kivy.core.video import Video as CoreVideo
|
||||||
|
|
||||||
|
if CoreVideo is None:
|
||||||
|
return False
|
||||||
|
except Exception as exc:
|
||||||
|
_log(f'video provider unavailable, nothing to patch ({exc})')
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
from kivy.core.video import Video
|
||||||
|
except Exception:
|
||||||
|
Video = None
|
||||||
|
|
||||||
|
if Video is None:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# VideoFFPy resolves via the current provider. Import it directly so we
|
||||||
|
# patch the right class even if the provider changes later.
|
||||||
|
try:
|
||||||
|
from kivy.core.video import video_ffpyplayer as _vfp
|
||||||
|
except Exception as exc:
|
||||||
|
_log(f'ffpyplayer provider not active, nothing to patch ({exc})')
|
||||||
|
return False
|
||||||
|
|
||||||
|
provider = getattr(_vfp, 'VideoFFPy', None)
|
||||||
|
if provider is None:
|
||||||
|
return False
|
||||||
|
|
||||||
|
with _lock:
|
||||||
|
if getattr(provider, '_kiwy_bounded_join', False):
|
||||||
|
return True
|
||||||
|
|
||||||
|
original_play = provider.play
|
||||||
|
if getattr(provider, '_kiwy_original_play', None) is None:
|
||||||
|
provider._kiwy_original_play = original_play
|
||||||
|
|
||||||
|
def play(self, *args, **kwargs):
|
||||||
|
result = provider._kiwy_original_play(self, *args, **kwargs)
|
||||||
|
_bound_thread_join(self, timeout)
|
||||||
|
return result
|
||||||
|
|
||||||
|
provider.play = play
|
||||||
|
provider._kiwy_bounded_join = True
|
||||||
|
_patched_threads += 1
|
||||||
|
|
||||||
|
_log(f'Kivy video unload join() bounded to {float(timeout):.1f}s')
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def _bound_thread_join(provider_instance, timeout):
|
||||||
|
"""Replace the provider's internal thread join with a bounded one.
|
||||||
|
|
||||||
|
ffpyplayer has already been told to quit (``_ffplayer_need_quit = True``)
|
||||||
|
and its thread woken before ``unload()`` joins, so bounding the wait does
|
||||||
|
not leak work — it only stops an unresponsive decode thread from freezing
|
||||||
|
the whole application.
|
||||||
|
"""
|
||||||
|
thread = getattr(provider_instance, '_thread', None)
|
||||||
|
if thread is None:
|
||||||
|
return
|
||||||
|
if getattr(thread, '_kiwy_bounded_join', False):
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
real_join = thread.join
|
||||||
|
except Exception:
|
||||||
|
return
|
||||||
|
|
||||||
|
def bounded_join(join_timeout=None):
|
||||||
|
"""Join with a hard upper bound; never block the caller forever."""
|
||||||
|
limit = float(timeout if join_timeout is None else min(join_timeout, timeout))
|
||||||
|
started = time.monotonic()
|
||||||
|
try:
|
||||||
|
real_join(timeout=limit)
|
||||||
|
except TypeError:
|
||||||
|
# Some Python builds dislike an explicit keyword here.
|
||||||
|
try:
|
||||||
|
real_join(limit)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
if thread.is_alive():
|
||||||
|
_log(
|
||||||
|
f'video decode thread still alive {time.monotonic() - started:.1f}s '
|
||||||
|
'after being asked to quit - continuing without it'
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
thread.join = bounded_join
|
||||||
|
thread._kiwy_bounded_join = True
|
||||||
|
except Exception as exc:
|
||||||
|
_log(f'could not bound video thread join ({exc})')
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -14,11 +14,165 @@ HEARTBEAT_FILE="$SCRIPT_DIR/.player_heartbeat"
|
|||||||
STOP_FLAG_FILE="$SCRIPT_DIR/.player_stop_requested"
|
STOP_FLAG_FILE="$SCRIPT_DIR/.player_stop_requested"
|
||||||
LOG_FILE="$SCRIPT_DIR/player_watchdog.log"
|
LOG_FILE="$SCRIPT_DIR/player_watchdog.log"
|
||||||
|
|
||||||
# Function to log messages
|
# Ensure log file is writable
|
||||||
|
if [ ! -w "$(dirname "$LOG_FILE")" ]; then
|
||||||
|
LOG_FILE="/tmp/kivy-player-watchdog.log"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Function to log messages (MUST be defined before use)
|
||||||
log_message() {
|
log_message() {
|
||||||
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1" | tee -a "$LOG_FILE"
|
local msg="[$(date '+%Y-%m-%d %H:%M:%S')] $1"
|
||||||
|
echo "$msg"
|
||||||
|
# Try to write to log file, ignore errors if permission denied
|
||||||
|
echo "$msg" >> "$LOG_FILE" 2>/dev/null || true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Load user's environment from systemd user session (most reliable method)
|
||||||
|
# This ensures we get the proper DISPLAY/WAYLAND_DISPLAY and session variables
|
||||||
|
load_user_environment() {
|
||||||
|
# Try to get environment from active user session via systemctl
|
||||||
|
local user_env
|
||||||
|
if command -v systemctl &>/dev/null; then
|
||||||
|
user_env=$(systemctl --user show-environment 2>/dev/null)
|
||||||
|
if [ -n "$user_env" ]; then
|
||||||
|
# Extract display-related variables without subshell to preserve exports
|
||||||
|
while IFS='=' read -r key value; do
|
||||||
|
case "$key" in
|
||||||
|
DISPLAY|WAYLAND_DISPLAY|XDG_RUNTIME_DIR|DBUS_SESSION_BUS_ADDRESS)
|
||||||
|
export "$key=$value"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done <<< "$user_env"
|
||||||
|
|
||||||
|
log_message "Loaded user environment from systemctl --user"
|
||||||
|
|
||||||
|
# Verify we got valid display
|
||||||
|
if [ -n "$DISPLAY" ] || [ -n "$WAYLAND_DISPLAY" ]; then
|
||||||
|
log_message "Display environment ready: DISPLAY='$DISPLAY' WAYLAND_DISPLAY='$WAYLAND_DISPLAY'"
|
||||||
|
return 0
|
||||||
|
else
|
||||||
|
log_message "Systemctl didn't provide display variables, trying fallback..."
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Fallback: detect display manually
|
||||||
|
log_message "Falling back to manual display detection..."
|
||||||
|
|
||||||
|
if [ -z "$DISPLAY" ] && [ -z "$WAYLAND_DISPLAY" ]; then
|
||||||
|
# Try to detect Wayland display
|
||||||
|
if [ -S "/run/user/$(id -u)/wayland-0" ]; then
|
||||||
|
export WAYLAND_DISPLAY=wayland-0
|
||||||
|
log_message "Detected Wayland display: $WAYLAND_DISPLAY"
|
||||||
|
# Try to detect X11 display
|
||||||
|
elif [ -S "/tmp/.X11-unix/X0" ]; then
|
||||||
|
export DISPLAY=:0
|
||||||
|
log_message "Detected X11 display: $DISPLAY"
|
||||||
|
else
|
||||||
|
# Wait for display to come up (useful for systemd or delayed starts)
|
||||||
|
log_message "Waiting for display server to be ready (up to 30 seconds)..."
|
||||||
|
for i in {1..30}; do
|
||||||
|
sleep 1
|
||||||
|
if [ -S "/run/user/$(id -u)/wayland-0" ] 2>/dev/null; then
|
||||||
|
export WAYLAND_DISPLAY=wayland-0
|
||||||
|
log_message "Display server detected on attempt $i: Wayland"
|
||||||
|
return 0
|
||||||
|
elif [ -S "/tmp/.X11-unix/X0" ] 2>/dev/null; then
|
||||||
|
export DISPLAY=:0
|
||||||
|
log_message "Display server detected on attempt $i: X11"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Verify we have a display now
|
||||||
|
if [ -z "$DISPLAY" ] && [ -z "$WAYLAND_DISPLAY" ]; then
|
||||||
|
log_message "WARNING: No display server detected. This may cause graphics issues."
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
log_message "Display environment ready: DISPLAY=$DISPLAY WAYLAND_DISPLAY=$WAYLAND_DISPLAY"
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
# Load the user environment early
|
||||||
|
load_user_environment
|
||||||
|
|
||||||
|
# Function to configure display output to Full HD (1920x1080)
|
||||||
|
configure_display_resolution() {
|
||||||
|
log_message "🖥️ Configuring display output to Full HD (1920x1080)..."
|
||||||
|
|
||||||
|
# Method 1: Try xrandr (works on X11 systems)
|
||||||
|
if command -v xrandr &>/dev/null && [ -n "$DISPLAY" ]; then
|
||||||
|
log_message "Attempting to set resolution via xrandr..."
|
||||||
|
|
||||||
|
# Get the primary display
|
||||||
|
primary_display=$(xrandr --query 2>/dev/null | grep " connected" | grep "primary\|preferred" | head -n 1 | awk '{print $1}')
|
||||||
|
|
||||||
|
if [ -z "$primary_display" ]; then
|
||||||
|
primary_display=$(xrandr --query 2>/dev/null | grep " connected" | head -n 1 | awk '{print $1}')
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -n "$primary_display" ]; then
|
||||||
|
# Set output to 1920x1080 @ 60Hz
|
||||||
|
xrandr --output "$primary_display" --mode 1920x1080 --rate 60 2>/dev/null
|
||||||
|
if [ $? -eq 0 ]; then
|
||||||
|
log_message "✓ Display resolution set to 1920x1080 via xrandr"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Method 2: Try tvservice (native Raspberry Pi tool)
|
||||||
|
if command -v tvservice &>/dev/null; then
|
||||||
|
log_message "Attempting to set resolution via tvservice (RPi native)..."
|
||||||
|
|
||||||
|
# Get current HDMI status
|
||||||
|
hdmi_mode=$(tvservice -s 2>/dev/null | grep -oP 'DMT mode \K\d+')
|
||||||
|
|
||||||
|
# Set to HDMI mode 16 (1920x1080 @ 60Hz) - standard Full HD
|
||||||
|
tvservice -e "DMT 16" 2>/dev/null
|
||||||
|
if [ $? -eq 0 ]; then
|
||||||
|
log_message "✓ Display resolution set to 1920x1080 via tvservice (DMT mode 16)"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Method 3: Configure via /boot/config.txt (persistent RPi config)
|
||||||
|
if [ -f "/boot/config.txt" ] && command -v sudo &>/dev/null; then
|
||||||
|
log_message "Checking /boot/config.txt for display settings..."
|
||||||
|
|
||||||
|
# These settings ensure Full HD output on large displays
|
||||||
|
if ! grep -q "hdmi_group=2" /boot/config.txt; then
|
||||||
|
log_message "Adding HDMI display configuration to /boot/config.txt..."
|
||||||
|
# Backup config first
|
||||||
|
sudo cp /boot/config.txt /boot/config.txt.backup.$(date +%s)
|
||||||
|
|
||||||
|
# Add Full HD configuration
|
||||||
|
echo "" | sudo tee -a /boot/config.txt > /dev/null
|
||||||
|
echo "# Kiwy Signage Player - Full HD Display Configuration" | sudo tee -a /boot/config.txt > /dev/null
|
||||||
|
echo "hdmi_group=2" | sudo tee -a /boot/config.txt > /dev/null # DMT (monitor timings)
|
||||||
|
echo "hdmi_mode=16" | sudo tee -a /boot/config.txt > /dev/null # 1920x1080 @ 60Hz
|
||||||
|
echo "hdmi_drive=2" | sudo tee -a /boot/config.txt > /dev/null # Normal HDMI mode
|
||||||
|
echo "disable_overscan=1" | sudo tee -a /boot/config.txt > /dev/null # Disable overscan
|
||||||
|
echo "framebuffer_width=1920" | sudo tee -a /boot/config.txt > /dev/null
|
||||||
|
echo "framebuffer_height=1080" | sudo tee -a /boot/config.txt > /dev/null
|
||||||
|
|
||||||
|
log_message "✓ HDMI configuration added to /boot/config.txt"
|
||||||
|
log_message "⚠️ Display settings require reboot to take effect"
|
||||||
|
return 0
|
||||||
|
else
|
||||||
|
log_message "✓ /boot/config.txt already configured for Full HD"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
log_message "Display configuration completed (1920x1080 Full HD target)"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Configure display before starting player
|
||||||
|
configure_display_resolution
|
||||||
|
|
||||||
# Function to check if player is healthy
|
# Function to check if player is healthy
|
||||||
check_health() {
|
check_health() {
|
||||||
# Check if heartbeat file exists and is recent (within last 60 seconds)
|
# Check if heartbeat file exists and is recent (within last 60 seconds)
|
||||||
@@ -64,19 +218,6 @@ rm -f "$STOP_FLAG_FILE"
|
|||||||
# Change to the project directory
|
# Change to the project directory
|
||||||
cd "$SCRIPT_DIR"
|
cd "$SCRIPT_DIR"
|
||||||
|
|
||||||
# Check if virtual environment exists
|
|
||||||
if [ -d ".venv" ]; then
|
|
||||||
log_message "✓ Virtual environment found"
|
|
||||||
source .venv/bin/activate
|
|
||||||
else
|
|
||||||
log_message "⚠️ Creating virtual environment..."
|
|
||||||
python3 -m venv .venv
|
|
||||||
source .venv/bin/activate
|
|
||||||
log_message "📦 Installing dependencies..."
|
|
||||||
pip3 install -r requirements.txt
|
|
||||||
log_message "✓ Virtual environment ready"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Check if configuration exists
|
# Check if configuration exists
|
||||||
if [ ! -f "config/app_config.json" ]; then
|
if [ ! -f "config/app_config.json" ]; then
|
||||||
log_message "⚠️ WARNING: Configuration file not found!"
|
log_message "⚠️ WARNING: Configuration file not found!"
|
||||||
@@ -96,9 +237,21 @@ while true; do
|
|||||||
# Clean old heartbeat
|
# Clean old heartbeat
|
||||||
rm -f "$HEARTBEAT_FILE"
|
rm -f "$HEARTBEAT_FILE"
|
||||||
|
|
||||||
# Start the player
|
# Activate virtual environment if it exists
|
||||||
cd "$SCRIPT_DIR/src"
|
if [ -f "$SCRIPT_DIR/.venv/bin/activate" ]; then
|
||||||
python3 main.py &
|
source "$SCRIPT_DIR/.venv/bin/activate"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Start the player.
|
||||||
|
#
|
||||||
|
# NOTE: the entry point is linux/run_linux.py, NOT src/main.py. Running
|
||||||
|
# main.py directly skips the Raspberry Pi platform patches (Wayland session
|
||||||
|
# environment, display keep-awake, Chromium kiosk adapter), which is why
|
||||||
|
# the player used to blank after 10 minutes and fail to show web links.
|
||||||
|
# Run from the project root so the data directory is the repo root and the
|
||||||
|
# relative config/media/playlists paths resolve as the app expects.
|
||||||
|
cd "$SCRIPT_DIR"
|
||||||
|
python3 linux/run_linux.py &
|
||||||
PLAYER_PID=$!
|
PLAYER_PID=$!
|
||||||
|
|
||||||
log_message "Player PID: $PLAYER_PID"
|
log_message "Player PID: $PLAYER_PID"
|
||||||
@@ -138,8 +291,8 @@ while true; do
|
|||||||
log_message "⏳ Waiting ${RESTART_DELAY}s before restart..."
|
log_message "⏳ Waiting ${RESTART_DELAY}s before restart..."
|
||||||
sleep $RESTART_DELAY
|
sleep $RESTART_DELAY
|
||||||
|
|
||||||
# Cleanup any zombie processes
|
# Ensure old player process is gone before restarting
|
||||||
pkill -9 -f "python3 main.py" 2>/dev/null
|
kill -9 $PLAYER_PID 2>/dev/null
|
||||||
|
|
||||||
done
|
done
|
||||||
|
|
||||||
@@ -147,6 +300,3 @@ log_message ""
|
|||||||
log_message "=========================================="
|
log_message "=========================================="
|
||||||
log_message "Watchdog stopped"
|
log_message "Watchdog stopped"
|
||||||
log_message "=========================================="
|
log_message "=========================================="
|
||||||
|
|
||||||
# Deactivate virtual environment (this line is never reached in watchdog mode)
|
|
||||||
deactivate
|
|
||||||
|
|||||||
+4
-2
@@ -15,15 +15,17 @@ echo "Stopping watchdog..."
|
|||||||
pkill -f "bash.*start.sh"
|
pkill -f "bash.*start.sh"
|
||||||
|
|
||||||
# Kill player
|
# Kill player
|
||||||
|
# NOTE: match the linux/run_linux.py entry point, not "python3 main.py" — the
|
||||||
|
# player is started as `python3 linux/run_linux.py` from the project root.
|
||||||
echo "Stopping player..."
|
echo "Stopping player..."
|
||||||
pkill -f "python3 main.py"
|
pkill -f "run_linux.py"
|
||||||
|
|
||||||
# Give processes time to exit gracefully
|
# Give processes time to exit gracefully
|
||||||
sleep 2
|
sleep 2
|
||||||
|
|
||||||
# Force kill if still running
|
# Force kill if still running
|
||||||
pkill -9 -f "bash.*start.sh" 2>/dev/null
|
pkill -9 -f "bash.*start.sh" 2>/dev/null
|
||||||
pkill -9 -f "python3 main.py" 2>/dev/null
|
pkill -9 -f "run_linux.py" 2>/dev/null
|
||||||
|
|
||||||
# Clean up heartbeat and stop flag files
|
# Clean up heartbeat and stop flag files
|
||||||
rm -f "$SCRIPT_DIR/.player_heartbeat"
|
rm -f "$SCRIPT_DIR/.player_heartbeat"
|
||||||
|
|||||||
@@ -1,211 +0,0 @@
|
|||||||
# Debugging Media File Skips - Guide
|
|
||||||
|
|
||||||
## Summary
|
|
||||||
Your playlist has been analyzed and all 3 media files are present and valid:
|
|
||||||
- ✅ music.jpg (36,481 bytes) - IMAGE - 15s
|
|
||||||
- ✅ 130414-746934884.mp4 (6,474,921 bytes) - VIDEO - 23s
|
|
||||||
- ✅ IMG_0386.jpeg (592,162 bytes) - IMAGE - 15s
|
|
||||||
|
|
||||||
## Enhanced Logging Added
|
|
||||||
The application has been updated with detailed logging to track:
|
|
||||||
- When each media file starts playing
|
|
||||||
- File path validation
|
|
||||||
- File size and existence checks
|
|
||||||
- Media type detection
|
|
||||||
- Widget creation steps
|
|
||||||
- Scheduling of next media
|
|
||||||
- Any errors or skips
|
|
||||||
|
|
||||||
## How to See Detailed Logs
|
|
||||||
|
|
||||||
### Method 1: Run with log output
|
|
||||||
```bash
|
|
||||||
cd /home/pi/Desktop/Kiwy-Signage
|
|
||||||
source .venv/bin/activate
|
|
||||||
cd src
|
|
||||||
python3 main.py 2>&1 | tee playback.log
|
|
||||||
```
|
|
||||||
|
|
||||||
### Method 2: Check Kivy logs location
|
|
||||||
Kivy logs are typically stored in:
|
|
||||||
- Linux: `~/.kivy/logs/`
|
|
||||||
- Check with: `ls -lth ~/.kivy/logs/ | head`
|
|
||||||
|
|
||||||
## Common Reasons Media Files Get Skipped
|
|
||||||
|
|
||||||
### 1. **File Not Found**
|
|
||||||
**Symptom**: Log shows "❌ Media file not found"
|
|
||||||
**Cause**: File doesn't exist at expected path
|
|
||||||
**Solution**: Run diagnostic tool
|
|
||||||
```bash
|
|
||||||
python3 diagnose_playlist.py
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. **Unsupported File Type**
|
|
||||||
**Symptom**: Log shows "❌ Unsupported media type"
|
|
||||||
**Supported formats**:
|
|
||||||
- Videos: .mp4, .avi, .mkv, .mov, .webm
|
|
||||||
- Images: .jpg, .jpeg, .png, .bmp, .gif
|
|
||||||
**Solution**: Convert files or check extension
|
|
||||||
|
|
||||||
### 3. **Video Codec Issues**
|
|
||||||
**Symptom**: Video file exists but doesn't play
|
|
||||||
**Cause**: Video codec not supported by ffpyplayer
|
|
||||||
**Check**: Look for error in logs about codec
|
|
||||||
**Solution**: Re-encode video with H.264 codec:
|
|
||||||
```bash
|
|
||||||
ffmpeg -i input.mp4 -c:v libx264 -preset fast -crf 23 output.mp4
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4. **Corrupted Media Files**
|
|
||||||
**Symptom**: File exists but throws error when loading
|
|
||||||
**Check**: Try playing file with external player
|
|
||||||
```bash
|
|
||||||
# For images
|
|
||||||
feh media/music.jpg
|
|
||||||
|
|
||||||
# For videos
|
|
||||||
vlc media/130414-746934884.mp4
|
|
||||||
# or
|
|
||||||
ffplay media/130414-746934884.mp4
|
|
||||||
```
|
|
||||||
|
|
||||||
### 5. **Memory/Performance Issues**
|
|
||||||
**Symptom**: First few files play, then skipping increases
|
|
||||||
**Cause**: Memory leak or performance degradation
|
|
||||||
**Check**: Look for "consecutive_errors" in logs
|
|
||||||
**Solution**:
|
|
||||||
- Reduce resolution setting in settings popup
|
|
||||||
- Optimize video files (lower bitrate/resolution)
|
|
||||||
|
|
||||||
### 6. **Timing Issues**
|
|
||||||
**Symptom**: Files play too fast or skip immediately
|
|
||||||
**Cause**: Duration set too low or scheduler issues
|
|
||||||
**Check**: Verify durations in playlist.json
|
|
||||||
**Current durations**: 15s (images), 23s (video)
|
|
||||||
|
|
||||||
### 7. **Permission Issues**
|
|
||||||
**Symptom**: "Permission denied" in logs
|
|
||||||
**Check**: File permissions
|
|
||||||
```bash
|
|
||||||
ls -la media/
|
|
||||||
```
|
|
||||||
**Solution**: Fix permissions
|
|
||||||
```bash
|
|
||||||
chmod 644 media/*
|
|
||||||
```
|
|
||||||
|
|
||||||
## What to Look For in Logs
|
|
||||||
|
|
||||||
### Successful Playback Pattern:
|
|
||||||
```
|
|
||||||
SignagePlayer: ===== Playing item 1/3 =====
|
|
||||||
SignagePlayer: File: music.jpg
|
|
||||||
SignagePlayer: Duration: 15s
|
|
||||||
SignagePlayer: Full path: /path/to/media/music.jpg
|
|
||||||
SignagePlayer: ✓ File exists (size: 36,481 bytes)
|
|
||||||
SignagePlayer: Extension: .jpg
|
|
||||||
SignagePlayer: Media type: IMAGE
|
|
||||||
SignagePlayer: Creating AsyncImage widget...
|
|
||||||
SignagePlayer: Adding image widget to content area...
|
|
||||||
SignagePlayer: Scheduled next media in 15s
|
|
||||||
SignagePlayer: ✓ Image displayed successfully
|
|
||||||
SignagePlayer: ✓ Media started successfully (consecutive_errors reset to 0)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Skip Pattern (File Not Found):
|
|
||||||
```
|
|
||||||
SignagePlayer: ===== Playing item 2/3 =====
|
|
||||||
SignagePlayer: File: missing.mp4
|
|
||||||
SignagePlayer: Full path: /path/to/media/missing.mp4
|
|
||||||
SignagePlayer: ❌ Media file not found: /path/to/media/missing.mp4
|
|
||||||
SignagePlayer: Skipping to next media...
|
|
||||||
SignagePlayer: Transitioning to next media (was index 1)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Video Loading Error:
|
|
||||||
```
|
|
||||||
SignagePlayer: Loading video file.mp4 for 23s
|
|
||||||
SignagePlayer: Video provider: ffpyplayer
|
|
||||||
[ERROR ] [Video ] Error reading video
|
|
||||||
[ERROR ] SignagePlayer: Error playing video: ...
|
|
||||||
```
|
|
||||||
|
|
||||||
## Testing Tools Provided
|
|
||||||
|
|
||||||
### 1. Diagnostic Tool
|
|
||||||
```bash
|
|
||||||
python3 diagnose_playlist.py
|
|
||||||
```
|
|
||||||
Checks:
|
|
||||||
- Playlist file exists and is valid
|
|
||||||
- All media files exist
|
|
||||||
- File types are supported
|
|
||||||
- No case sensitivity issues
|
|
||||||
|
|
||||||
### 2. Playback Simulation
|
|
||||||
```bash
|
|
||||||
python3 test_playback_logging.py
|
|
||||||
```
|
|
||||||
Simulates the playback sequence without running the GUI
|
|
||||||
|
|
||||||
## Monitoring Live Playback
|
|
||||||
|
|
||||||
To see live logs while the app is running:
|
|
||||||
```bash
|
|
||||||
# Terminal 1: Start the app
|
|
||||||
./run_player.sh
|
|
||||||
|
|
||||||
# Terminal 2: Monitor logs
|
|
||||||
tail -f ~/.kivy/logs/kivy_*.txt
|
|
||||||
```
|
|
||||||
|
|
||||||
## Quick Fixes to Try
|
|
||||||
|
|
||||||
### 1. Clear any stuck state
|
|
||||||
```bash
|
|
||||||
rm -f src/*.pyc
|
|
||||||
rm -rf src/__pycache__
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Test with simpler playlist
|
|
||||||
Create `playlists/test_playlist_v9.json`:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"playlist": [
|
|
||||||
{
|
|
||||||
"file_name": "music.jpg",
|
|
||||||
"url": "media/music.jpg",
|
|
||||||
"duration": 5
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"version": 9
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. Check video compatibility
|
|
||||||
```bash
|
|
||||||
# Install ffmpeg tools if not present
|
|
||||||
sudo apt-get install ffmpeg
|
|
||||||
|
|
||||||
# Check video info
|
|
||||||
ffprobe media/130414-746934884.mp4
|
|
||||||
```
|
|
||||||
|
|
||||||
## Getting Help
|
|
||||||
|
|
||||||
When reporting issues, please provide:
|
|
||||||
1. Output from `python3 diagnose_playlist.py`
|
|
||||||
2. Last 100 lines of Kivy log file
|
|
||||||
3. Any error messages from console
|
|
||||||
4. What you observe (which files skip? pattern?)
|
|
||||||
|
|
||||||
## Next Steps
|
|
||||||
|
|
||||||
1. **Run the app** and observe the console output
|
|
||||||
2. **Check logs** for error patterns
|
|
||||||
3. **Run diagnostic** if files are skipping
|
|
||||||
4. **Test individual files** with external players if needed
|
|
||||||
5. **Re-encode videos** if codec issues found
|
|
||||||
|
|
||||||
The enhanced logging will now tell you exactly why each file is being skipped!
|
|
||||||
@@ -1,263 +0,0 @@
|
|||||||
# ✅ Kiwy-Signage Authentication Implementation Complete
|
|
||||||
|
|
||||||
## What Was Implemented
|
|
||||||
|
|
||||||
The Kiwy-Signage player now supports **secure authentication** with DigiServer v2 using the flow:
|
|
||||||
|
|
||||||
**hostname → password/quickconnect → get auth_code → use auth_code for API calls**
|
|
||||||
|
|
||||||
## Files Created
|
|
||||||
|
|
||||||
### 1. **Kiwy-Signage/src/player_auth.py**
|
|
||||||
- Complete authentication module
|
|
||||||
- Handles authentication, token storage, API calls
|
|
||||||
- Methods:
|
|
||||||
- `authenticate()` - Initial authentication with server
|
|
||||||
- `verify_auth()` - Verify saved auth code
|
|
||||||
- `get_playlist()` - Fetch playlist using auth code
|
|
||||||
- `send_heartbeat()` - Send status updates
|
|
||||||
- `send_feedback()` - Send player feedback
|
|
||||||
- `clear_auth()` - Clear saved credentials
|
|
||||||
|
|
||||||
### 2. **Kiwy-Signage/src/get_playlists_v2.py**
|
|
||||||
- Updated playlist management
|
|
||||||
- Uses new authentication system
|
|
||||||
- Backward compatible with existing code
|
|
||||||
- Functions:
|
|
||||||
- `ensure_authenticated()` - Auto-authenticate if needed
|
|
||||||
- `fetch_server_playlist()` - Get playlist via authenticated API
|
|
||||||
- `send_player_feedback()` - Send feedback with auth
|
|
||||||
- All existing functions updated to use auth
|
|
||||||
|
|
||||||
### 3. **Kiwy-Signage/test_authentication.py**
|
|
||||||
- Test script to verify authentication
|
|
||||||
- Run before updating main.py
|
|
||||||
- Tests:
|
|
||||||
- Server connectivity
|
|
||||||
- Authentication flow
|
|
||||||
- Playlist fetch
|
|
||||||
- Heartbeat sending
|
|
||||||
|
|
||||||
### 4. **Kiwy-Signage/MIGRATION_GUIDE.md**
|
|
||||||
- Complete migration instructions
|
|
||||||
- Troubleshooting guide
|
|
||||||
- Configuration examples
|
|
||||||
- Rollback procedures
|
|
||||||
|
|
||||||
### 5. **digiserver-v2/player_auth_module.py**
|
|
||||||
- Standalone authentication module
|
|
||||||
- Can be used in any Python project
|
|
||||||
- Same functionality as Kiwy-Signage version
|
|
||||||
|
|
||||||
### 6. **digiserver-v2/PLAYER_AUTH.md**
|
|
||||||
- Complete API documentation
|
|
||||||
- Authentication endpoint specs
|
|
||||||
- Configuration file formats
|
|
||||||
- Security considerations
|
|
||||||
|
|
||||||
## Testing Steps
|
|
||||||
|
|
||||||
### 1. Test Authentication (Recommended First)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd /home/pi/Desktop/Kiwy-Signage
|
|
||||||
python3 test_authentication.py
|
|
||||||
```
|
|
||||||
|
|
||||||
**Expected output:**
|
|
||||||
```
|
|
||||||
✅ Server is healthy (version: 2.0.0)
|
|
||||||
🔐 Authenticating with server...
|
|
||||||
✅ Authentication successful!
|
|
||||||
Player: Demo Player
|
|
||||||
📋 Testing playlist fetch...
|
|
||||||
✅ Playlist received!
|
|
||||||
💓 Testing heartbeat...
|
|
||||||
✅ Heartbeat sent successfully
|
|
||||||
✅ All tests passed! Player is ready to use.
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Update Main Player App
|
|
||||||
|
|
||||||
In `Kiwy-Signage/src/main.py`, change:
|
|
||||||
|
|
||||||
```python
|
|
||||||
# OLD:
|
|
||||||
from get_playlists import update_playlist_if_needed, ...
|
|
||||||
|
|
||||||
# NEW:
|
|
||||||
from get_playlists_v2 import update_playlist_if_needed, ...
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. Run Player
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd /home/pi/Desktop/Kiwy-Signage/src
|
|
||||||
python3 main.py
|
|
||||||
```
|
|
||||||
|
|
||||||
## Configuration
|
|
||||||
|
|
||||||
### No Changes Needed!
|
|
||||||
|
|
||||||
Your existing `app_config.txt` works as-is:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"server_ip": "192.168.1.100",
|
|
||||||
"port": "5000",
|
|
||||||
"screen_name": "player-001",
|
|
||||||
"quickconnect_key": "QUICK123",
|
|
||||||
...
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Authentication Storage
|
|
||||||
|
|
||||||
Auto-created at `src/player_auth.json`:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"hostname": "player-001",
|
|
||||||
"auth_code": "rrX4JtM99e4e6ni0VCsuIstjTVQQqILXeRmGu_Ek2Ks",
|
|
||||||
"player_id": 1,
|
|
||||||
"player_name": "Demo Player",
|
|
||||||
"group_id": 5,
|
|
||||||
"orientation": "Landscape",
|
|
||||||
"authenticated": true,
|
|
||||||
"server_url": "http://192.168.1.100:5000"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## DigiServer v2 Setup
|
|
||||||
|
|
||||||
### 1. Create Player
|
|
||||||
|
|
||||||
Via Web UI (http://your-server:5000):
|
|
||||||
1. Login as admin
|
|
||||||
2. Go to Players → Add Player
|
|
||||||
3. Fill in:
|
|
||||||
- **Name**: Display name
|
|
||||||
- **Hostname**: player-001 (must match `screen_name` in config)
|
|
||||||
- **Password**: (optional, use quickconnect instead)
|
|
||||||
- **Quick Connect Code**: QUICK123 (must match `quickconnect_key`)
|
|
||||||
- **Orientation**: Landscape/Portrait
|
|
||||||
|
|
||||||
### 2. Test API Manually
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Test authentication
|
|
||||||
curl -X POST http://your-server:5000/api/auth/player \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{
|
|
||||||
"hostname": "player-001",
|
|
||||||
"quickconnect_code": "QUICK123"
|
|
||||||
}'
|
|
||||||
|
|
||||||
# Expected response:
|
|
||||||
{
|
|
||||||
"success": true,
|
|
||||||
"player_id": 1,
|
|
||||||
"player_name": "Demo Player",
|
|
||||||
"auth_code": "rrX4JtM99e4e6ni0VCsuIstjTVQQqILXeRmGu_Ek2Ks",
|
|
||||||
...
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Security Features
|
|
||||||
|
|
||||||
✅ **Auth Code Storage**: Saved locally, not transmitted after initial auth
|
|
||||||
✅ **Bcrypt Hashing**: Passwords and quickconnect codes hashed in database
|
|
||||||
✅ **Token-Based**: Auth codes are 32-byte URL-safe tokens
|
|
||||||
✅ **Rate Limiting**: Authentication endpoint limited to 10 requests/minute
|
|
||||||
✅ **Session Management**: Server tracks player sessions and status
|
|
||||||
|
|
||||||
## Advantages Over Old System
|
|
||||||
|
|
||||||
### Old System (v1)
|
|
||||||
```
|
|
||||||
Player → [hostname + quickconnect on EVERY request] → Server
|
|
||||||
↓
|
|
||||||
Bcrypt verification on every API call (slow)
|
|
||||||
```
|
|
||||||
|
|
||||||
### New System (v2)
|
|
||||||
```
|
|
||||||
Player → [hostname + quickconnect ONCE] → Server
|
|
||||||
↓
|
|
||||||
Returns auth_code
|
|
||||||
↓
|
|
||||||
Player → [auth_code for all subsequent requests] → Server
|
|
||||||
↓
|
|
||||||
Fast token validation
|
|
||||||
```
|
|
||||||
|
|
||||||
**Benefits:**
|
|
||||||
- 🚀 **10x faster API calls** (no bcrypt on every request)
|
|
||||||
- 🔒 **More secure** (credentials only sent once)
|
|
||||||
- 📊 **Better tracking** (server knows player sessions)
|
|
||||||
- 🔄 **Easier management** (can revoke auth codes)
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
### Authentication Fails
|
|
||||||
|
|
||||||
**Check:**
|
|
||||||
1. Player exists in DigiServer v2 with matching hostname
|
|
||||||
2. Quickconnect code matches exactly (case-sensitive)
|
|
||||||
3. Server is accessible: `curl http://server:5000/api/health`
|
|
||||||
|
|
||||||
### Auth Code Expired
|
|
||||||
|
|
||||||
**Solution:**
|
|
||||||
```bash
|
|
||||||
rm /home/pi/Desktop/Kiwy-Signage/src/player_auth.json
|
|
||||||
# Restart player - will auto-authenticate
|
|
||||||
```
|
|
||||||
|
|
||||||
### Old get_playlists.py Issues
|
|
||||||
|
|
||||||
**Keep both files:**
|
|
||||||
- `get_playlists.py` - Original (for DigiServer v1)
|
|
||||||
- `get_playlists_v2.py` - New (for DigiServer v2)
|
|
||||||
|
|
||||||
Can switch between them by changing import in `main.py`.
|
|
||||||
|
|
||||||
## Next Steps
|
|
||||||
|
|
||||||
1. ✅ **Test authentication** with `test_authentication.py`
|
|
||||||
2. ✅ **Update main.py** to use `get_playlists_v2`
|
|
||||||
3. ✅ **Run player** and verify playlist loading
|
|
||||||
4. ✅ **Monitor logs** for first 24 hours
|
|
||||||
5. ✅ **Update other players** one at a time
|
|
||||||
|
|
||||||
## Files Summary
|
|
||||||
|
|
||||||
```
|
|
||||||
Kiwy-Signage/
|
|
||||||
├── src/
|
|
||||||
│ ├── player_auth.py # ✨ NEW: Authentication module
|
|
||||||
│ ├── get_playlists_v2.py # ✨ NEW: Updated playlist fetcher
|
|
||||||
│ ├── get_playlists.py # OLD: Keep for v1 compatibility
|
|
||||||
│ ├── main.py # Update import to use v2
|
|
||||||
│ └── player_auth.json # ✨ AUTO-CREATED: Auth storage
|
|
||||||
├── test_authentication.py # ✨ NEW: Test script
|
|
||||||
├── MIGRATION_GUIDE.md # ✨ NEW: Migration docs
|
|
||||||
└── resources/
|
|
||||||
└── app_config.txt # Existing config (no changes needed)
|
|
||||||
|
|
||||||
digiserver-v2/
|
|
||||||
├── app/
|
|
||||||
│ ├── models/player.py # ✨ UPDATED: Added auth methods
|
|
||||||
│ └── blueprints/api.py # ✨ UPDATED: Added auth endpoints
|
|
||||||
├── player_auth_module.py # ✨ NEW: Standalone module
|
|
||||||
├── player_config_template.ini # ✨ NEW: Config template
|
|
||||||
├── PLAYER_AUTH.md # ✨ NEW: API documentation
|
|
||||||
└── reinit_db.sh # ✨ NEW: Database recreation script
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🎉 Implementation Complete!
|
|
||||||
|
|
||||||
The Kiwy-Signage player authentication system is now compatible with DigiServer v2 using secure token-based authentication. Test with `test_authentication.py` before deploying to production.
|
|
||||||
@@ -1,182 +0,0 @@
|
|||||||
# Investigation Results: Media File Skipping
|
|
||||||
|
|
||||||
## Diagnostic Summary
|
|
||||||
✅ **All 3 media files are present and valid:**
|
|
||||||
- music.jpg (36,481 bytes) - IMAGE
|
|
||||||
- 130414-746934884.mp4 (6,474,921 bytes) - VIDEO (H.264, 1920x1080, compatible)
|
|
||||||
- IMG_0386.jpeg (592,162 bytes) - IMAGE
|
|
||||||
|
|
||||||
✅ **No file system issues found:**
|
|
||||||
- All files exist
|
|
||||||
- Correct permissions
|
|
||||||
- No case sensitivity problems
|
|
||||||
- Supported file types
|
|
||||||
|
|
||||||
✅ **Video codec is compatible:**
|
|
||||||
- H.264 codec (fully supported by ffpyplayer)
|
|
||||||
- 1920x1080 @ 29.97fps
|
|
||||||
- Reasonable bitrate (2.3 Mbps)
|
|
||||||
|
|
||||||
## Potential Root Causes Identified
|
|
||||||
|
|
||||||
### 1. **Video Widget Not Properly Stopping** (Most Likely)
|
|
||||||
When transitioning from video to the next media, the video widget may not be properly stopped before removal. This could cause:
|
|
||||||
- The video to continue playing in background
|
|
||||||
- Race conditions with scheduling
|
|
||||||
- Next media appearing to "skip"
|
|
||||||
|
|
||||||
**Location**: `play_current_media()` line 417-420
|
|
||||||
```python
|
|
||||||
if self.current_widget:
|
|
||||||
self.ids.content_area.remove_widget(self.current_widget)
|
|
||||||
self.current_widget = None
|
|
||||||
```
|
|
||||||
|
|
||||||
**Fix**: Stop video before removing widget
|
|
||||||
|
|
||||||
### 2. **Multiple Scheduled Events**
|
|
||||||
The `Clock.schedule_once(self.next_media, duration)` could be called multiple times if widget loading triggers multiple events.
|
|
||||||
|
|
||||||
**Location**: Lines 510, 548
|
|
||||||
|
|
||||||
**Fix**: Add `Clock.unschedule()` before scheduling
|
|
||||||
|
|
||||||
### 3. **Video Loading Callback Issues**
|
|
||||||
The video `loaded` callback might not fire or might fire multiple times, causing state confusion.
|
|
||||||
|
|
||||||
**Location**: `_on_video_loaded()` line 516
|
|
||||||
|
|
||||||
### 4. **Pause State Not Properly Checked**
|
|
||||||
If the player gets paused/unpaused during media transition, scheduling could get confused.
|
|
||||||
|
|
||||||
**Location**: `next_media()` line 551
|
|
||||||
|
|
||||||
## What Enhanced Logging Will Show
|
|
||||||
|
|
||||||
With the new logging, you'll see patterns like:
|
|
||||||
|
|
||||||
### If Videos Are Being Skipped:
|
|
||||||
```
|
|
||||||
===== Playing item 2/3 =====
|
|
||||||
File: 130414-746934884.mp4
|
|
||||||
Extension: .mp4
|
|
||||||
Media type: VIDEO
|
|
||||||
Loading video...
|
|
||||||
Creating Video widget...
|
|
||||||
[SHORT PAUSE OR ERROR]
|
|
||||||
Transitioning to next media (was index 1)
|
|
||||||
===== Playing item 3/3 =====
|
|
||||||
```
|
|
||||||
|
|
||||||
### If Duration Is Too Short:
|
|
||||||
```
|
|
||||||
Creating Video widget...
|
|
||||||
Scheduled next media in 23s
|
|
||||||
[Only 1-2 seconds pass]
|
|
||||||
Transitioning to next media
|
|
||||||
```
|
|
||||||
|
|
||||||
## Recommended Fixes
|
|
||||||
|
|
||||||
I've added comprehensive logging. Here are additional fixes to try:
|
|
||||||
|
|
||||||
### Fix 1: Properly Stop Video Widget Before Removal
|
|
||||||
Add this to `play_current_media()` before removing widget:
|
|
||||||
|
|
||||||
```python
|
|
||||||
# Remove previous media widget
|
|
||||||
if self.current_widget:
|
|
||||||
# Stop video if it's playing
|
|
||||||
if isinstance(self.current_widget, Video):
|
|
||||||
self.current_widget.state = 'stop'
|
|
||||||
self.current_widget.unload()
|
|
||||||
self.ids.content_area.remove_widget(self.current_widget)
|
|
||||||
self.current_widget = None
|
|
||||||
```
|
|
||||||
|
|
||||||
### Fix 2: Ensure Scheduled Events Don't Overlap
|
|
||||||
Modify scheduling in both `play_video()` and `play_image()`:
|
|
||||||
|
|
||||||
```python
|
|
||||||
# Unschedule any pending transitions before scheduling new one
|
|
||||||
Clock.unschedule(self.next_media)
|
|
||||||
Clock.schedule_once(self.next_media, duration)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Fix 3: Add Video State Monitoring
|
|
||||||
Track when video actually starts playing vs when widget is created.
|
|
||||||
|
|
||||||
## How to Test
|
|
||||||
|
|
||||||
### 1. Run with Enhanced Logging
|
|
||||||
```bash
|
|
||||||
cd /home/pi/Desktop/Kiwy-Signage
|
|
||||||
source .venv/bin/activate
|
|
||||||
cd src
|
|
||||||
python3 main.py 2>&1 | tee ../playback_debug.log
|
|
||||||
```
|
|
||||||
|
|
||||||
Watch the console output. You should see:
|
|
||||||
- Each media file being loaded
|
|
||||||
- Timing information
|
|
||||||
- Any errors or skips
|
|
||||||
|
|
||||||
### 2. Check Timing
|
|
||||||
If media skips, check the log for timing:
|
|
||||||
- Does "Scheduled next media in Xs" appear?
|
|
||||||
- How long until "Transitioning to next media" appears?
|
|
||||||
- Is it immediate (< 1 second) = scheduling bug
|
|
||||||
- Is it after full duration = normal operation
|
|
||||||
|
|
||||||
### 3. Look for Error Patterns
|
|
||||||
Search the log for:
|
|
||||||
```bash
|
|
||||||
grep "❌" playback_debug.log
|
|
||||||
grep "Error" playback_debug.log
|
|
||||||
grep "consecutive_errors" playback_debug.log
|
|
||||||
```
|
|
||||||
|
|
||||||
## Quick Test Scenario
|
|
||||||
|
|
||||||
Create a test with just one file to isolate the issue:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"playlist": [
|
|
||||||
{
|
|
||||||
"file_name": "music.jpg",
|
|
||||||
"url": "media/music.jpg",
|
|
||||||
"duration": 10
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"version": 99
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
If this single image repeats correctly every 10s, the issue is with video playback or transitions.
|
|
||||||
|
|
||||||
## What to Report
|
|
||||||
|
|
||||||
When you run the app, please capture:
|
|
||||||
|
|
||||||
1. **Console output** - especially the pattern around skipped files
|
|
||||||
2. **Which files skip?** - Is it always videos? Always after videos?
|
|
||||||
3. **Timing** - Do files play for full duration before skipping?
|
|
||||||
4. **Pattern** - First loop OK then skips? Always skips certain file?
|
|
||||||
|
|
||||||
## Tools Created
|
|
||||||
|
|
||||||
1. **diagnose_playlist.py** - Check file system issues
|
|
||||||
2. **test_playback_logging.py** - Simulate playback logic
|
|
||||||
3. **check_video_codecs.py** - Verify video compatibility
|
|
||||||
4. **Enhanced main.py** - Detailed logging throughout
|
|
||||||
|
|
||||||
## Next Actions
|
|
||||||
|
|
||||||
1. ✅ Run `diagnose_playlist.py` - **PASSED**
|
|
||||||
2. ✅ Run `check_video_codecs.py` - **PASSED**
|
|
||||||
3. ⏳ Run app with logging and observe pattern
|
|
||||||
4. ⏳ Apply video widget fixes if needed
|
|
||||||
5. ⏳ Report findings for further diagnosis
|
|
||||||
|
|
||||||
The enhanced logging will pinpoint exactly where and why files are being skipped!
|
|
||||||
@@ -1,276 +0,0 @@
|
|||||||
# Kiwy-Signage Player Migration Guide
|
|
||||||
## Updating to DigiServer v2 Authentication
|
|
||||||
|
|
||||||
This guide explains how to update your Kiwy-Signage player to use the new secure authentication system with DigiServer v2.
|
|
||||||
|
|
||||||
## What Changed?
|
|
||||||
|
|
||||||
### Old System (v1)
|
|
||||||
- Direct API calls with hostname + quickconnect code on every request
|
|
||||||
- No persistent authentication
|
|
||||||
- Credentials sent with every API call
|
|
||||||
|
|
||||||
### New System (v2)
|
|
||||||
- **Step 1**: Authenticate once with hostname + password/quickconnect
|
|
||||||
- **Step 2**: Receive and save auth_code
|
|
||||||
- **Step 3**: Use auth_code for all subsequent API calls
|
|
||||||
- **Benefits**: More secure, faster, supports session management
|
|
||||||
|
|
||||||
## Migration Steps
|
|
||||||
|
|
||||||
### 1. Copy New Files
|
|
||||||
|
|
||||||
Copy the authentication modules to your Kiwy-Signage project:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd /home/pi/Desktop/Kiwy-Signage/src/
|
|
||||||
|
|
||||||
# New files are already created:
|
|
||||||
# - player_auth.py (authentication module)
|
|
||||||
# - get_playlists_v2.py (updated playlist fetcher)
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Update main.py Imports
|
|
||||||
|
|
||||||
In `main.py`, replace the old import:
|
|
||||||
|
|
||||||
```python
|
|
||||||
# OLD:
|
|
||||||
from get_playlists import (
|
|
||||||
update_playlist_if_needed,
|
|
||||||
send_playing_status_feedback,
|
|
||||||
send_playlist_restart_feedback,
|
|
||||||
send_player_error_feedback
|
|
||||||
)
|
|
||||||
|
|
||||||
# NEW:
|
|
||||||
from get_playlists_v2 import (
|
|
||||||
update_playlist_if_needed,
|
|
||||||
send_playing_status_feedback,
|
|
||||||
send_playlist_restart_feedback,
|
|
||||||
send_player_error_feedback
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. Add Authentication on Startup
|
|
||||||
|
|
||||||
In `main.py`, add authentication check in the `SignagePlayer` class:
|
|
||||||
|
|
||||||
```python
|
|
||||||
def build(self):
|
|
||||||
"""Build the application UI"""
|
|
||||||
# Load configuration
|
|
||||||
self.config = self.load_config()
|
|
||||||
|
|
||||||
# NEW: Authenticate with server
|
|
||||||
from player_auth import PlayerAuth
|
|
||||||
auth = PlayerAuth()
|
|
||||||
|
|
||||||
if not auth.is_authenticated():
|
|
||||||
Logger.info("First time setup - authenticating...")
|
|
||||||
from get_playlists_v2 import ensure_authenticated
|
|
||||||
if not ensure_authenticated(self.config):
|
|
||||||
Logger.error("❌ Failed to authenticate with server!")
|
|
||||||
# Show error popup or retry
|
|
||||||
else:
|
|
||||||
Logger.info(f"✅ Authenticated as: {auth.get_player_name()}")
|
|
||||||
|
|
||||||
# Continue with normal startup...
|
|
||||||
return SignagePlayerWidget(config=self.config)
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4. Update Server Configuration
|
|
||||||
|
|
||||||
Your existing `app_config.txt` works as-is! The new system uses the same fields:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"server_ip": "your-server-ip",
|
|
||||||
"port": "5000",
|
|
||||||
"screen_name": "player-001",
|
|
||||||
"quickconnect_key": "QUICK123",
|
|
||||||
...
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Note**: `screen_name` is now used as `hostname` for authentication.
|
|
||||||
|
|
||||||
### 5. Testing
|
|
||||||
|
|
||||||
1. **Stop the old player**:
|
|
||||||
```bash
|
|
||||||
pkill -f main.py
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **Delete old authentication data** (first time only):
|
|
||||||
```bash
|
|
||||||
rm -f /home/pi/Desktop/Kiwy-Signage/src/player_auth.json
|
|
||||||
```
|
|
||||||
|
|
||||||
3. **Start the updated player**:
|
|
||||||
```bash
|
|
||||||
cd /home/pi/Desktop/Kiwy-Signage/src/
|
|
||||||
python3 main.py
|
|
||||||
```
|
|
||||||
|
|
||||||
4. **Check logs for authentication**:
|
|
||||||
- Look for: `✅ Authentication successful`
|
|
||||||
- Or: `❌ Authentication failed: [error message]`
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
### Authentication Fails
|
|
||||||
|
|
||||||
**Problem**: `❌ Authentication failed: Invalid credentials`
|
|
||||||
|
|
||||||
**Solution**:
|
|
||||||
1. Verify player exists in DigiServer v2:
|
|
||||||
- Login to http://your-server:5000
|
|
||||||
- Go to Players → check if hostname exists
|
|
||||||
|
|
||||||
2. Verify quickconnect code:
|
|
||||||
- In DigiServer, check player's Quick Connect Code
|
|
||||||
- Update `app_config.txt` with correct code
|
|
||||||
|
|
||||||
3. Check server URL:
|
|
||||||
```python
|
|
||||||
# Test connection
|
|
||||||
import requests
|
|
||||||
response = requests.get('http://your-server:5000/api/health')
|
|
||||||
print(response.json()) # Should show: {'status': 'healthy'}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Auth Code Expired
|
|
||||||
|
|
||||||
**Problem**: Player was working, now shows auth errors
|
|
||||||
|
|
||||||
**Solution**:
|
|
||||||
```bash
|
|
||||||
# Clear saved auth and re-authenticate
|
|
||||||
rm /home/pi/Desktop/Kiwy-Signage/src/player_auth.json
|
|
||||||
# Restart player - will auto-authenticate
|
|
||||||
```
|
|
||||||
|
|
||||||
### Can't Connect to Server
|
|
||||||
|
|
||||||
**Problem**: `Cannot connect to server`
|
|
||||||
|
|
||||||
**Solution**:
|
|
||||||
1. Check server is running:
|
|
||||||
```bash
|
|
||||||
curl http://your-server:5000/api/health
|
|
||||||
```
|
|
||||||
|
|
||||||
2. Check network connectivity:
|
|
||||||
```bash
|
|
||||||
ping your-server-ip
|
|
||||||
```
|
|
||||||
|
|
||||||
3. Verify server URL in `app_config.txt`
|
|
||||||
|
|
||||||
## Configuration Files
|
|
||||||
|
|
||||||
### player_auth.json (auto-created)
|
|
||||||
|
|
||||||
This file stores the authentication token:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"hostname": "player-001",
|
|
||||||
"auth_code": "rrX4JtM99e4e6ni0VCsuIstjTVQQqILXeRmGu_Ek2Ks",
|
|
||||||
"player_id": 1,
|
|
||||||
"player_name": "Demo Player",
|
|
||||||
"group_id": 5,
|
|
||||||
"orientation": "Landscape",
|
|
||||||
"authenticated": true,
|
|
||||||
"server_url": "http://your-server:5000"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Important**: Keep this file secure! It contains your player's access token.
|
|
||||||
|
|
||||||
## Advanced: Custom Authentication
|
|
||||||
|
|
||||||
If you need custom authentication logic:
|
|
||||||
|
|
||||||
```python
|
|
||||||
from player_auth import PlayerAuth
|
|
||||||
|
|
||||||
# Initialize
|
|
||||||
auth = PlayerAuth(config_file='custom_auth.json')
|
|
||||||
|
|
||||||
# Authenticate with password instead of quickconnect
|
|
||||||
success, error = auth.authenticate(
|
|
||||||
server_url='http://your-server:5000',
|
|
||||||
hostname='player-001',
|
|
||||||
password='your_secure_password' # Use password instead
|
|
||||||
)
|
|
||||||
|
|
||||||
if success:
|
|
||||||
print(f"✅ Authenticated as: {auth.get_player_name()}")
|
|
||||||
|
|
||||||
# Get playlist
|
|
||||||
playlist_data = auth.get_playlist()
|
|
||||||
|
|
||||||
# Send heartbeat
|
|
||||||
auth.send_heartbeat(status='playing')
|
|
||||||
|
|
||||||
# Send feedback
|
|
||||||
auth.send_feedback(
|
|
||||||
message="Playing video.mp4",
|
|
||||||
status="playing",
|
|
||||||
playlist_version=5
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
print(f"❌ Failed: {error}")
|
|
||||||
```
|
|
||||||
|
|
||||||
## Rollback to Old System
|
|
||||||
|
|
||||||
If you need to rollback:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd /home/pi/Desktop/Kiwy-Signage/src/
|
|
||||||
|
|
||||||
# Rename new files
|
|
||||||
mv get_playlists_v2.py get_playlists_v2.py.backup
|
|
||||||
mv player_auth.py player_auth.py.backup
|
|
||||||
|
|
||||||
# Use old get_playlists.py (keep as-is)
|
|
||||||
# Old system will continue working with DigiServer v1
|
|
||||||
```
|
|
||||||
|
|
||||||
## Benefits of New System
|
|
||||||
|
|
||||||
✅ **More Secure**: Auth tokens instead of passwords in every request
|
|
||||||
✅ **Better Performance**: No bcrypt verification on every API call
|
|
||||||
✅ **Session Management**: Server tracks player sessions
|
|
||||||
✅ **Easier Debugging**: Auth failures vs API failures are separate
|
|
||||||
✅ **Future-Proof**: Ready for token refresh, expiration, etc.
|
|
||||||
|
|
||||||
## Next Steps
|
|
||||||
|
|
||||||
Once migration is complete:
|
|
||||||
|
|
||||||
1. **Monitor player logs** for first 24 hours
|
|
||||||
2. **Verify playlist updates** are working
|
|
||||||
3. **Check feedback** is being received in DigiServer
|
|
||||||
4. **Update other players** one at a time
|
|
||||||
|
|
||||||
## Support
|
|
||||||
|
|
||||||
If you encounter issues:
|
|
||||||
|
|
||||||
1. **Check player logs**: `tail -f player.log`
|
|
||||||
2. **Check server logs**: DigiServer v2 logs in `instance/logs/`
|
|
||||||
3. **Test API manually**:
|
|
||||||
```bash
|
|
||||||
# Test authentication
|
|
||||||
curl -X POST http://your-server:5000/api/auth/player \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{"hostname":"player-001","quickconnect_code":"QUICK123"}'
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**Migration completed!** Your Kiwy-Signage player now uses secure authentication with DigiServer v2. 🎉
|
|
||||||
@@ -1,158 +0,0 @@
|
|||||||
# Offline Installation Guide
|
|
||||||
|
|
||||||
This guide explains how to set up and use offline installation for the Kiwy Signage Player.
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
The offline installation system allows you to install the signage player on devices without internet access by pre-downloading all necessary packages.
|
|
||||||
|
|
||||||
## Directory Structure
|
|
||||||
|
|
||||||
```
|
|
||||||
Kiwy-Signage/
|
|
||||||
├── repo/ # Offline packages repository
|
|
||||||
│ ├── python-wheels/ # Python packages (.whl files)
|
|
||||||
│ ├── system-packages/ # System package information
|
|
||||||
│ │ ├── apt-packages.txt # List of required apt packages
|
|
||||||
│ │ └── debs/ # Downloaded .deb files (optional)
|
|
||||||
│ └── README.md
|
|
||||||
├── download_offline_packages.sh # Download Python packages
|
|
||||||
├── download_deb_packages.sh # Download system .deb packages
|
|
||||||
└── install.sh # Smart installer (online/offline)
|
|
||||||
```
|
|
||||||
|
|
||||||
## Setup for Offline Installation
|
|
||||||
|
|
||||||
### Step 1: Prepare on a Connected System
|
|
||||||
|
|
||||||
On a system with internet access (preferably Raspberry Pi OS):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Clone the repository
|
|
||||||
git clone <repository-url>
|
|
||||||
cd Kiwy-Signage
|
|
||||||
|
|
||||||
# Download Python packages
|
|
||||||
bash download_offline_packages.sh
|
|
||||||
|
|
||||||
# (Optional) Download system .deb packages
|
|
||||||
bash download_deb_packages.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
This will populate the `repo/` folder with all necessary packages.
|
|
||||||
|
|
||||||
### Step 2: Transfer to Offline System
|
|
||||||
|
|
||||||
Copy the entire `Kiwy-Signage` directory to your offline system:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Using USB drive
|
|
||||||
cp -r Kiwy-Signage /media/usb/
|
|
||||||
|
|
||||||
# Or create a tarball
|
|
||||||
tar -czf kiwy-signage-offline.tar.gz Kiwy-Signage/
|
|
||||||
|
|
||||||
# On target system, extract:
|
|
||||||
tar -xzf kiwy-signage-offline.tar.gz
|
|
||||||
cd Kiwy-Signage
|
|
||||||
```
|
|
||||||
|
|
||||||
### Step 3: Install on Offline System
|
|
||||||
|
|
||||||
The installer automatically detects offline packages:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Automatic detection
|
|
||||||
bash install.sh
|
|
||||||
|
|
||||||
# Or explicitly specify offline mode
|
|
||||||
bash install.sh --offline
|
|
||||||
```
|
|
||||||
|
|
||||||
## Package Information
|
|
||||||
|
|
||||||
### Python Packages (requirements.txt)
|
|
||||||
|
|
||||||
- **kivy==2.1.0** - UI framework
|
|
||||||
- **requests==2.32.4** - HTTP library
|
|
||||||
- **bcrypt==4.2.1** - Password hashing
|
|
||||||
- **aiohttp==3.9.1** - Async HTTP client
|
|
||||||
- **asyncio==3.4.3** - Async I/O framework
|
|
||||||
|
|
||||||
### System Packages (APT)
|
|
||||||
|
|
||||||
See `repo/system-packages/apt-packages.txt` for complete list:
|
|
||||||
- Python development tools
|
|
||||||
- SDL2 libraries (video/audio)
|
|
||||||
- FFmpeg and codecs
|
|
||||||
- GStreamer plugins
|
|
||||||
- Build dependencies
|
|
||||||
|
|
||||||
## Online Installation
|
|
||||||
|
|
||||||
If you have internet access, simply run:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
bash install.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
The installer will automatically download and install all packages from the internet.
|
|
||||||
|
|
||||||
## Updating Offline Packages
|
|
||||||
|
|
||||||
To update the offline package cache:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# On a connected system
|
|
||||||
bash download_offline_packages.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
This will download the latest versions of all packages.
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
### Problem: Missing Dependencies
|
|
||||||
|
|
||||||
If installation fails due to missing dependencies:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Download .deb packages with dependencies
|
|
||||||
bash download_deb_packages.sh
|
|
||||||
|
|
||||||
# Install with dependency resolution
|
|
||||||
sudo apt install -f
|
|
||||||
```
|
|
||||||
|
|
||||||
### Problem: Wheel Not Found
|
|
||||||
|
|
||||||
If a specific Python package wheel is not found:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Download specific package
|
|
||||||
pip3 download <package-name> -d repo/python-wheels/
|
|
||||||
```
|
|
||||||
|
|
||||||
### Problem: Architecture Mismatch
|
|
||||||
|
|
||||||
Ensure packages are downloaded on the same architecture (ARM for Raspberry Pi):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Verify architecture
|
|
||||||
uname -m # Should show: armv7l or aarch64
|
|
||||||
|
|
||||||
# Force ARM downloads
|
|
||||||
pip3 download -r requirements.txt -d repo/python-wheels/ --platform linux_armv7l
|
|
||||||
```
|
|
||||||
|
|
||||||
## Storage Requirements
|
|
||||||
|
|
||||||
- **Python wheels**: ~50-100 MB
|
|
||||||
- **System .deb packages**: ~200-500 MB (if downloaded)
|
|
||||||
- **Total**: ~250-600 MB
|
|
||||||
|
|
||||||
## Notes
|
|
||||||
|
|
||||||
- The `repo/` folder is designed to be portable
|
|
||||||
- Downloaded packages are excluded from git (see `.gitignore`)
|
|
||||||
- The installer supports both online and offline modes seamlessly
|
|
||||||
- System packages list is maintained in `repo/system-packages/apt-packages.txt`
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
# Offline Installation Quick Start
|
|
||||||
|
|
||||||
## For Connected System (Preparation)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 1. Download Python packages (required)
|
|
||||||
bash download_offline_packages.sh
|
|
||||||
|
|
||||||
# 2. Download system .deb packages (optional, for fully offline)
|
|
||||||
bash download_deb_packages.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
## For Offline System (Installation)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# The installer auto-detects offline packages
|
|
||||||
bash install.sh
|
|
||||||
|
|
||||||
# Or explicitly use offline mode
|
|
||||||
bash install.sh --offline
|
|
||||||
```
|
|
||||||
|
|
||||||
## What's Included
|
|
||||||
|
|
||||||
### Python Packages (18 wheels)
|
|
||||||
✅ Kivy 2.1.0
|
|
||||||
✅ Requests 2.32.4
|
|
||||||
✅ Bcrypt 4.2.1
|
|
||||||
✅ Aiohttp 3.9.1 (async HTTP)
|
|
||||||
✅ Asyncio 3.4.3 (async framework)
|
|
||||||
✅ All dependencies
|
|
||||||
|
|
||||||
### System Packages
|
|
||||||
📋 See `repo/system-packages/apt-packages.txt`
|
|
||||||
|
|
||||||
## File Size
|
|
||||||
- Python wheels: ~50 MB
|
|
||||||
- System packages: ~200-500 MB (if .deb downloaded)
|
|
||||||
|
|
||||||
## See Also
|
|
||||||
- **OFFLINE_INSTALLATION.md** - Complete guide
|
|
||||||
- **repo/README.md** - Repository structure
|
|
||||||
@@ -1,190 +0,0 @@
|
|||||||
# 🚀 Quick Start Guide - Player Authentication
|
|
||||||
|
|
||||||
## For DigiServer Admin
|
|
||||||
|
|
||||||
### 1. Create Player in DigiServer v2
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Login to web interface
|
|
||||||
http://your-server:5000
|
|
||||||
|
|
||||||
# Navigate to: Players → Add Player
|
|
||||||
Name: Office Player
|
|
||||||
Hostname: office-player-001 # Must be unique
|
|
||||||
Location: Main Office
|
|
||||||
Password: [leave empty if using quickconnect]
|
|
||||||
Quick Connect Code: OFFICE123 # Easy pairing code
|
|
||||||
Orientation: Landscape
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Distribute Credentials to Player
|
|
||||||
|
|
||||||
Give the player administrator:
|
|
||||||
- **Server URL**: `http://your-server:5000`
|
|
||||||
- **Hostname**: `office-player-001`
|
|
||||||
- **Quick Connect Code**: `OFFICE123`
|
|
||||||
|
|
||||||
## For Player Setup
|
|
||||||
|
|
||||||
### 1. Update app_config.txt
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"server_ip": "your-server-ip",
|
|
||||||
"port": "5000",
|
|
||||||
"screen_name": "office-player-001",
|
|
||||||
"quickconnect_key": "OFFICE123",
|
|
||||||
...
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Test Authentication
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd /home/pi/Desktop/Kiwy-Signage
|
|
||||||
python3 test_authentication.py
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. Update Player Code (One-Time)
|
|
||||||
|
|
||||||
In `src/main.py`, line ~34, change:
|
|
||||||
|
|
||||||
```python
|
|
||||||
from get_playlists_v2 import ( # Changed from get_playlists
|
|
||||||
update_playlist_if_needed,
|
|
||||||
send_playing_status_feedback,
|
|
||||||
send_playlist_restart_feedback,
|
|
||||||
send_player_error_feedback
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4. Run Player
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd /home/pi/Desktop/Kiwy-Signage/src
|
|
||||||
python3 main.py
|
|
||||||
```
|
|
||||||
|
|
||||||
## Authentication Flow
|
|
||||||
|
|
||||||
```
|
|
||||||
┌─────────┐ ┌────────────┐
|
|
||||||
│ Player │ │ DigiServer │
|
|
||||||
└────┬────┘ └─────┬──────┘
|
|
||||||
│ │
|
|
||||||
│ POST /api/auth/player │
|
|
||||||
│ {hostname, quickconnect} │
|
|
||||||
├──────────────────────────────>│
|
|
||||||
│ │
|
|
||||||
│ 200 OK │
|
|
||||||
│ {auth_code, player_id, ...} │
|
|
||||||
│<──────────────────────────────┤
|
|
||||||
│ │
|
|
||||||
│ Save auth_code locally │
|
|
||||||
├──────────────────┐ │
|
|
||||||
│ │ │
|
|
||||||
│<─────────────────┘ │
|
|
||||||
│ │
|
|
||||||
│ GET /api/playlists/{id} │
|
|
||||||
│ Header: Bearer {auth_code} │
|
|
||||||
├──────────────────────────────>│
|
|
||||||
│ │
|
|
||||||
│ 200 OK │
|
|
||||||
│ {playlist, version} │
|
|
||||||
│<──────────────────────────────┤
|
|
||||||
│ │
|
|
||||||
```
|
|
||||||
|
|
||||||
## Files to Know
|
|
||||||
|
|
||||||
### Player Side (Kiwy-Signage)
|
|
||||||
|
|
||||||
```
|
|
||||||
src/
|
|
||||||
├── player_auth.json # Auto-created, stores auth_code
|
|
||||||
├── player_auth.py # Authentication module
|
|
||||||
├── get_playlists_v2.py # Updated playlist fetcher
|
|
||||||
└── app_config.txt # Your existing config
|
|
||||||
```
|
|
||||||
|
|
||||||
### Server Side (DigiServer v2)
|
|
||||||
|
|
||||||
```
|
|
||||||
app/
|
|
||||||
├── models/player.py # Player model with auth methods
|
|
||||||
└── blueprints/api.py # Authentication endpoints
|
|
||||||
|
|
||||||
API Endpoints:
|
|
||||||
- POST /api/auth/player # Authenticate and get token
|
|
||||||
- POST /api/auth/verify # Verify token validity
|
|
||||||
- GET /api/playlists/{id} # Get playlist (requires auth)
|
|
||||||
- POST /api/players/{id}/heartbeat # Send status (requires auth)
|
|
||||||
```
|
|
||||||
|
|
||||||
## Common Commands
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Test authentication
|
|
||||||
./test_authentication.py
|
|
||||||
|
|
||||||
# Clear saved auth (re-authenticate)
|
|
||||||
rm src/player_auth.json
|
|
||||||
|
|
||||||
# Check server health
|
|
||||||
curl http://your-server:5000/api/health
|
|
||||||
|
|
||||||
# Manual authentication test
|
|
||||||
curl -X POST http://your-server:5000/api/auth/player \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{"hostname":"player-001","quickconnect_code":"QUICK123"}'
|
|
||||||
|
|
||||||
# View player logs
|
|
||||||
tail -f player.log
|
|
||||||
|
|
||||||
# View server logs (if running Flask dev server)
|
|
||||||
# Logs appear in terminal where server is running
|
|
||||||
```
|
|
||||||
|
|
||||||
## Troubleshooting One-Liners
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Authentication fails → Check player exists
|
|
||||||
curl http://your-server:5000/api/health
|
|
||||||
|
|
||||||
# Auth expired → Clear and retry
|
|
||||||
rm src/player_auth.json && python3 main.py
|
|
||||||
|
|
||||||
# Can't connect → Test network
|
|
||||||
ping your-server-ip
|
|
||||||
|
|
||||||
# Wrong quickconnect → Check in DigiServer web UI
|
|
||||||
# Go to: Players → [Your Player] → Edit → View Quick Connect Code
|
|
||||||
```
|
|
||||||
|
|
||||||
## Security Notes
|
|
||||||
|
|
||||||
- ✅ Auth code saved in `player_auth.json` (keep secure!)
|
|
||||||
- ✅ Quickconnect code hashed with bcrypt in database
|
|
||||||
- ✅ Auth endpoints rate-limited (10 req/min)
|
|
||||||
- ✅ Auth codes are 32-byte secure tokens
|
|
||||||
- ⚠️ Use HTTPS in production!
|
|
||||||
- ⚠️ Rotate quickconnect codes periodically
|
|
||||||
|
|
||||||
## Quick Wins
|
|
||||||
|
|
||||||
### Before (Old System)
|
|
||||||
- Every API call = send hostname + quickconnect
|
|
||||||
- Server runs bcrypt check on every request
|
|
||||||
- Slow response times
|
|
||||||
- No session tracking
|
|
||||||
|
|
||||||
### After (New System)
|
|
||||||
- Authenticate once = get auth_code
|
|
||||||
- All subsequent calls use auth_code
|
|
||||||
- 10x faster API responses
|
|
||||||
- Server tracks player sessions
|
|
||||||
- Can revoke access instantly
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**Ready to go!** 🎉 Test with `./test_authentication.py` then start your player!
|
|
||||||
@@ -1,152 +0,0 @@
|
|||||||
# Kivy Signage Player
|
|
||||||
|
|
||||||
A modern digital signage player built with Kivy framework that displays content from DigiServer playlists.
|
|
||||||
|
|
||||||
## Features
|
|
||||||
|
|
||||||
- **Cross-platform**: Runs on Linux, Windows, and macOS
|
|
||||||
- **Modern UI**: Built with Kivy framework for smooth graphics and animations
|
|
||||||
- **Multiple Media Types**: Supports images (JPG, PNG, GIF, BMP) and videos (MP4, AVI, MKV, MOV, WEBM)
|
|
||||||
- **Server Integration**: Fetches playlists from DigiServer with automatic updates
|
|
||||||
- **Player Feedback**: Reports status and playback information to server
|
|
||||||
- **Fullscreen Display**: Optimized for digital signage displays
|
|
||||||
- **Touch Controls**: Mouse/touch-activated control panel
|
|
||||||
- **Auto-restart**: Continuous playlist looping
|
|
||||||
- **Error Handling**: Robust error handling with server feedback
|
|
||||||
|
|
||||||
## Installation
|
|
||||||
|
|
||||||
1. **Install system dependencies:**
|
|
||||||
```bash
|
|
||||||
chmod +x install.sh
|
|
||||||
./install.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **Configure the player:**
|
|
||||||
Edit `config/app_config.json` with your server details:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"server_ip": "your-server-ip",
|
|
||||||
"port": "5000",
|
|
||||||
"screen_name": "your-player-name",
|
|
||||||
"quickconnect_key": "your-quickconnect-code"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Usage
|
|
||||||
|
|
||||||
### Recommended: Using Start Script (with Virtual Environment)
|
|
||||||
```bash
|
|
||||||
chmod +x start.sh
|
|
||||||
./start.sh
|
|
||||||
```
|
|
||||||
This script automatically:
|
|
||||||
- Activates the Python virtual environment
|
|
||||||
- Checks for configuration
|
|
||||||
- Starts the player
|
|
||||||
|
|
||||||
### Alternative: Using Run Script
|
|
||||||
```bash
|
|
||||||
chmod +x run_player.sh
|
|
||||||
./run_player.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
### Manual Start
|
|
||||||
```bash
|
|
||||||
# With virtual environment
|
|
||||||
source .venv/bin/activate
|
|
||||||
cd src
|
|
||||||
python3 main.py
|
|
||||||
|
|
||||||
# Without virtual environment
|
|
||||||
cd src
|
|
||||||
python3 main.py
|
|
||||||
```
|
|
||||||
|
|
||||||
## Controls
|
|
||||||
|
|
||||||
- **Mouse/Touch Movement**: Shows control panel for 3 seconds
|
|
||||||
- **Previous (⏮)**: Go to previous media item
|
|
||||||
- **Pause/Play (⏸/▶)**: Toggle playback
|
|
||||||
- **Next (⏭)**: Skip to next media item
|
|
||||||
- **Settings (⚙)**: View player configuration and status
|
|
||||||
- **Exit (⏻)**: Close the application
|
|
||||||
|
|
||||||
## Directory Structure
|
|
||||||
|
|
||||||
```
|
|
||||||
Kiwi-signage/
|
|
||||||
├── src/
|
|
||||||
│ ├── main.py # Main Kivy application
|
|
||||||
│ └── get_playlists.py # Playlist management and server communication
|
|
||||||
├── config/
|
|
||||||
│ └── app_config.json # Player configuration
|
|
||||||
├── media/ # Downloaded media files (auto-generated)
|
|
||||||
├── playlists/ # Playlist cache (auto-generated)
|
|
||||||
├── requirements.txt # Python dependencies
|
|
||||||
├── install.sh # Installation script
|
|
||||||
├── run_player.sh # Run script
|
|
||||||
└── README.md # This file
|
|
||||||
```
|
|
||||||
|
|
||||||
## Configuration Options
|
|
||||||
|
|
||||||
### app_config.json
|
|
||||||
- `server_ip`: IP address or domain of DigiServer
|
|
||||||
- `port`: Port number of DigiServer (default: 5000)
|
|
||||||
- `screen_name`: Unique identifier for this player
|
|
||||||
- `quickconnect_key`: Authentication key for server access
|
|
||||||
|
|
||||||
## Features Comparison with Tkinter Player
|
|
||||||
|
|
||||||
| Feature | Kivy Player | Tkinter Player |
|
|
||||||
|---------|-------------|----------------|
|
|
||||||
| Cross-platform | ✅ Better | ✅ Good |
|
|
||||||
| Modern UI | ✅ Excellent | ❌ Basic |
|
|
||||||
| Touch Support | ✅ Native | ❌ Limited |
|
|
||||||
| Video Playback | ✅ Built-in | ✅ VLC Required |
|
|
||||||
| Performance | ✅ GPU Accelerated | ❌ CPU Only |
|
|
||||||
| Animations | ✅ Smooth | ❌ None |
|
|
||||||
| Mobile Ready | ✅ Yes | ❌ No |
|
|
||||||
|
|
||||||
## Server Integration
|
|
||||||
|
|
||||||
The player communicates with DigiServer via REST API:
|
|
||||||
|
|
||||||
- **Playlist Fetch**: `GET /api/playlists`
|
|
||||||
- **Player Feedback**: `POST /api/player-feedback`
|
|
||||||
|
|
||||||
Status updates sent to server:
|
|
||||||
- Playlist check and update notifications
|
|
||||||
- Current playback status
|
|
||||||
- Error reports
|
|
||||||
- Playlist restart notifications
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
### Installation Issues
|
|
||||||
- Make sure system dependencies are installed: `./install.sh`
|
|
||||||
- For ARM devices (Raspberry Pi), ensure proper SDL2 libraries
|
|
||||||
|
|
||||||
### Playback Issues
|
|
||||||
- Check media file formats are supported
|
|
||||||
- Verify network connection to DigiServer
|
|
||||||
- Check player configuration in settings
|
|
||||||
|
|
||||||
### Server Connection
|
|
||||||
- Verify server IP and port in configuration
|
|
||||||
- Check quickconnect key is correct
|
|
||||||
- Ensure DigiServer is running and accessible
|
|
||||||
|
|
||||||
## Development
|
|
||||||
|
|
||||||
Based on the proven architecture of the tkinter signage player with modern Kivy enhancements:
|
|
||||||
|
|
||||||
- **Playlist Management**: Inherited from `get_playlists.py`
|
|
||||||
- **Media Playback**: Kivy's built-in Video and AsyncImage widgets
|
|
||||||
- **Server Communication**: REST API calls with feedback system
|
|
||||||
- **Error Handling**: Comprehensive exception handling with server reporting
|
|
||||||
|
|
||||||
## License
|
|
||||||
|
|
||||||
This project is part of the DigiServer digital signage system.
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""Analyze what's happening with the playlist download."""
|
|
||||||
|
|
||||||
import json
|
|
||||||
|
|
||||||
# Check the saved playlist
|
|
||||||
playlist_file = 'playlists/server_playlist_v8.json'
|
|
||||||
print("=" * 80)
|
|
||||||
print("SAVED PLAYLIST ANALYSIS")
|
|
||||||
print("=" * 80)
|
|
||||||
|
|
||||||
with open(playlist_file, 'r') as f:
|
|
||||||
data = json.load(f)
|
|
||||||
|
|
||||||
print(f"\nVersion: {data.get('version', 'N/A')}")
|
|
||||||
print(f"Items in playlist: {len(data.get('playlist', []))}")
|
|
||||||
|
|
||||||
print("\nPlaylist items:")
|
|
||||||
for idx, item in enumerate(data.get('playlist', []), 1):
|
|
||||||
print(f"\n{idx}. File: {item.get('file_name', 'N/A')}")
|
|
||||||
print(f" URL: {item.get('url', 'N/A')}")
|
|
||||||
print(f" Duration: {item.get('duration', 'N/A')}s")
|
|
||||||
|
|
||||||
print("\n" + "=" * 80)
|
|
||||||
print("\n⚠️ ISSUE: Server has 5 files, but only 3 are saved!")
|
|
||||||
print("\nPossible reasons:")
|
|
||||||
print("1. Server sent only 3 files")
|
|
||||||
print("2. 2 files failed to download and were skipped")
|
|
||||||
print("3. Download function has a bug")
|
|
||||||
print("\nThe download_media_files() function in get_playlists_v2.py:")
|
|
||||||
print("- Downloads from the 'url' field in the playlist")
|
|
||||||
print("- If download fails, it SKIPS the file (continues)")
|
|
||||||
print("- Only successfully downloaded files are added to updated_playlist")
|
|
||||||
print("\nThis means 2 files likely had invalid URLs or download errors!")
|
|
||||||
print("=" * 80)
|
|
||||||
@@ -1,123 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
Check video files for codec compatibility with ffpyplayer
|
|
||||||
"""
|
|
||||||
import os
|
|
||||||
import subprocess
|
|
||||||
import json
|
|
||||||
|
|
||||||
def check_video_codec(video_path):
|
|
||||||
"""Check video codec using ffprobe"""
|
|
||||||
try:
|
|
||||||
cmd = [
|
|
||||||
'ffprobe',
|
|
||||||
'-v', 'quiet',
|
|
||||||
'-print_format', 'json',
|
|
||||||
'-show_format',
|
|
||||||
'-show_streams',
|
|
||||||
video_path
|
|
||||||
]
|
|
||||||
|
|
||||||
result = subprocess.run(cmd, capture_output=True, text=True)
|
|
||||||
|
|
||||||
if result.returncode != 0:
|
|
||||||
return None, "ffprobe failed"
|
|
||||||
|
|
||||||
data = json.loads(result.stdout)
|
|
||||||
|
|
||||||
video_streams = [s for s in data.get('streams', []) if s.get('codec_type') == 'video']
|
|
||||||
audio_streams = [s for s in data.get('streams', []) if s.get('codec_type') == 'audio']
|
|
||||||
|
|
||||||
if not video_streams:
|
|
||||||
return None, "No video stream found"
|
|
||||||
|
|
||||||
video_stream = video_streams[0]
|
|
||||||
|
|
||||||
info = {
|
|
||||||
'codec': video_stream.get('codec_name', 'unknown'),
|
|
||||||
'codec_long': video_stream.get('codec_long_name', 'unknown'),
|
|
||||||
'width': video_stream.get('width', 0),
|
|
||||||
'height': video_stream.get('height', 0),
|
|
||||||
'fps': eval(video_stream.get('r_frame_rate', '0/1')),
|
|
||||||
'duration': float(data.get('format', {}).get('duration', 0)),
|
|
||||||
'bitrate': int(data.get('format', {}).get('bit_rate', 0)),
|
|
||||||
'audio_codec': audio_streams[0].get('codec_name', 'none') if audio_streams else 'none',
|
|
||||||
'size': int(data.get('format', {}).get('size', 0))
|
|
||||||
}
|
|
||||||
|
|
||||||
return info, None
|
|
||||||
|
|
||||||
except FileNotFoundError:
|
|
||||||
return None, "ffprobe not installed (run: sudo apt-get install ffmpeg)"
|
|
||||||
except Exception as e:
|
|
||||||
return None, str(e)
|
|
||||||
|
|
||||||
def main():
|
|
||||||
base_dir = os.path.dirname(os.path.abspath(__file__))
|
|
||||||
media_dir = os.path.join(base_dir, 'media')
|
|
||||||
|
|
||||||
print("=" * 80)
|
|
||||||
print("VIDEO CODEC COMPATIBILITY CHECKER")
|
|
||||||
print("=" * 80)
|
|
||||||
|
|
||||||
# Supported codecs by ffpyplayer
|
|
||||||
supported_codecs = ['h264', 'h265', 'hevc', 'vp8', 'vp9', 'mpeg4']
|
|
||||||
|
|
||||||
# Find video files
|
|
||||||
video_extensions = ['.mp4', '.avi', '.mkv', '.mov', '.webm']
|
|
||||||
video_files = []
|
|
||||||
|
|
||||||
if os.path.exists(media_dir):
|
|
||||||
for filename in os.listdir(media_dir):
|
|
||||||
ext = os.path.splitext(filename)[1].lower()
|
|
||||||
if ext in video_extensions:
|
|
||||||
video_files.append(filename)
|
|
||||||
|
|
||||||
if not video_files:
|
|
||||||
print("\n✓ No video files found in media directory")
|
|
||||||
return
|
|
||||||
|
|
||||||
print(f"\nFound {len(video_files)} video file(s):\n")
|
|
||||||
|
|
||||||
for filename in video_files:
|
|
||||||
video_path = os.path.join(media_dir, filename)
|
|
||||||
print(f"📹 {filename}")
|
|
||||||
print(f" Path: {video_path}")
|
|
||||||
|
|
||||||
info, error = check_video_codec(video_path)
|
|
||||||
|
|
||||||
if error:
|
|
||||||
print(f" ❌ ERROR: {error}")
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Display video info
|
|
||||||
print(f" Video Codec: {info['codec']} ({info['codec_long']})")
|
|
||||||
print(f" Resolution: {info['width']}x{info['height']}")
|
|
||||||
print(f" Frame Rate: {info['fps']:.2f} fps")
|
|
||||||
print(f" Duration: {info['duration']:.1f}s")
|
|
||||||
print(f" Bitrate: {info['bitrate'] / 1000:.0f} kbps")
|
|
||||||
print(f" Audio Codec: {info['audio_codec']}")
|
|
||||||
print(f" File Size: {info['size'] / (1024*1024):.2f} MB")
|
|
||||||
|
|
||||||
# Check compatibility
|
|
||||||
if info['codec'] in supported_codecs:
|
|
||||||
print(f" ✅ COMPATIBLE - Codec '{info['codec']}' is supported by ffpyplayer")
|
|
||||||
else:
|
|
||||||
print(f" ⚠️ WARNING - Codec '{info['codec']}' may not be supported")
|
|
||||||
print(f" Supported codecs: {', '.join(supported_codecs)}")
|
|
||||||
print(f" Consider re-encoding to H.264:")
|
|
||||||
print(f" ffmpeg -i \"{filename}\" -c:v libx264 -preset fast -crf 23 \"{os.path.splitext(filename)[0]}_h264.mp4\"")
|
|
||||||
|
|
||||||
# Performance warnings
|
|
||||||
if info['width'] > 1920 or info['height'] > 1080:
|
|
||||||
print(f" ⚠️ High resolution ({info['width']}x{info['height']}) may cause performance issues")
|
|
||||||
print(f" Consider downscaling to 1920x1080 or lower")
|
|
||||||
|
|
||||||
if info['bitrate'] > 5000000: # 5 Mbps
|
|
||||||
print(f" ⚠️ High bitrate ({info['bitrate'] / 1000000:.1f} Mbps) may cause playback issues")
|
|
||||||
print(f" Consider reducing bitrate to 2-4 Mbps")
|
|
||||||
|
|
||||||
print()
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
|
||||||
main()
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
# Video Conversion Script for Raspberry Pi Signage Player
|
|
||||||
# Converts videos to optimal settings: 1080p @ 30fps, H.264 codec
|
|
||||||
|
|
||||||
if [ $# -eq 0 ]; then
|
|
||||||
echo "Usage: $0 <input_video> [output_video]"
|
|
||||||
echo "Example: $0 input.mp4 output.mp4"
|
|
||||||
echo ""
|
|
||||||
echo "This script converts videos to Raspberry Pi-friendly settings:"
|
|
||||||
echo " - Resolution: Max 1920x1080"
|
|
||||||
echo " - Frame rate: 30 fps"
|
|
||||||
echo " - Codec: H.264"
|
|
||||||
echo " - Bitrate: ~5-8 Mbps"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
INPUT_VIDEO="$1"
|
|
||||||
OUTPUT_VIDEO="${2:-converted_$(basename "$INPUT_VIDEO")}"
|
|
||||||
|
|
||||||
if [ ! -f "$INPUT_VIDEO" ]; then
|
|
||||||
echo "Error: Input file '$INPUT_VIDEO' not found!"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "Converting video for Raspberry Pi playback..."
|
|
||||||
echo "Input: $INPUT_VIDEO"
|
|
||||||
echo "Output: $OUTPUT_VIDEO"
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
# Convert video with optimal settings for Raspberry Pi
|
|
||||||
ffmpeg -i "$INPUT_VIDEO" \
|
|
||||||
-c:v libx264 \
|
|
||||||
-preset medium \
|
|
||||||
-crf 23 \
|
|
||||||
-maxrate 8M \
|
|
||||||
-bufsize 12M \
|
|
||||||
-vf "scale='min(1920,iw)':'min(1080,ih)':force_original_aspect_ratio=decrease,fps=30" \
|
|
||||||
-r 30 \
|
|
||||||
-c:a aac \
|
|
||||||
-b:a 128k \
|
|
||||||
-movflags +faststart \
|
|
||||||
-y \
|
|
||||||
"$OUTPUT_VIDEO"
|
|
||||||
|
|
||||||
if [ $? -eq 0 ]; then
|
|
||||||
echo ""
|
|
||||||
echo "✓ Conversion completed successfully!"
|
|
||||||
echo "Original: $(du -h "$INPUT_VIDEO" | cut -f1)"
|
|
||||||
echo "Converted: $(du -h "$OUTPUT_VIDEO" | cut -f1)"
|
|
||||||
echo ""
|
|
||||||
echo "You can now use '$OUTPUT_VIDEO' in your signage player."
|
|
||||||
else
|
|
||||||
echo ""
|
|
||||||
echo "✗ Conversion failed! Make sure ffmpeg is installed:"
|
|
||||||
echo " sudo apt-get install ffmpeg"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
@@ -1,167 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
Diagnostic script to check why media files might be skipped
|
|
||||||
"""
|
|
||||||
import os
|
|
||||||
import json
|
|
||||||
|
|
||||||
# Paths
|
|
||||||
base_dir = os.path.dirname(os.path.abspath(__file__))
|
|
||||||
media_dir = os.path.join(base_dir, 'media')
|
|
||||||
playlists_dir = os.path.join(base_dir, 'playlists')
|
|
||||||
|
|
||||||
# Supported extensions
|
|
||||||
VIDEO_EXTENSIONS = ['.mp4', '.avi', '.mkv', '.mov', '.webm']
|
|
||||||
IMAGE_EXTENSIONS = ['.jpg', '.jpeg', '.png', '.bmp', '.gif']
|
|
||||||
SUPPORTED_EXTENSIONS = VIDEO_EXTENSIONS + IMAGE_EXTENSIONS
|
|
||||||
|
|
||||||
def check_playlist():
|
|
||||||
"""Check playlist for issues"""
|
|
||||||
print("=" * 80)
|
|
||||||
print("PLAYLIST DIAGNOSTIC TOOL")
|
|
||||||
print("=" * 80)
|
|
||||||
|
|
||||||
# Find latest playlist file
|
|
||||||
playlist_files = [f for f in os.listdir(playlists_dir)
|
|
||||||
if f.startswith('server_playlist_v') and f.endswith('.json')]
|
|
||||||
|
|
||||||
if not playlist_files:
|
|
||||||
print("\n❌ ERROR: No playlist files found!")
|
|
||||||
return
|
|
||||||
|
|
||||||
# Sort by version and get latest
|
|
||||||
versions = [(int(f.split('_v')[-1].split('.json')[0]), f) for f in playlist_files]
|
|
||||||
versions.sort(reverse=True)
|
|
||||||
latest_file = versions[0][1]
|
|
||||||
playlist_path = os.path.join(playlists_dir, latest_file)
|
|
||||||
|
|
||||||
print(f"\n📋 Latest Playlist: {latest_file}")
|
|
||||||
print(f" Path: {playlist_path}")
|
|
||||||
|
|
||||||
# Load playlist
|
|
||||||
try:
|
|
||||||
with open(playlist_path, 'r') as f:
|
|
||||||
data = json.load(f)
|
|
||||||
|
|
||||||
playlist = data.get('playlist', [])
|
|
||||||
version = data.get('version', 0)
|
|
||||||
|
|
||||||
print(f" Version: {version}")
|
|
||||||
print(f" Total items: {len(playlist)}")
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
print(f"\n❌ ERROR loading playlist: {e}")
|
|
||||||
return
|
|
||||||
|
|
||||||
# Check media directory
|
|
||||||
print(f"\n📁 Media Directory: {media_dir}")
|
|
||||||
if not os.path.exists(media_dir):
|
|
||||||
print(" ❌ ERROR: Media directory doesn't exist!")
|
|
||||||
return
|
|
||||||
|
|
||||||
media_files = os.listdir(media_dir)
|
|
||||||
print(f" Files found: {len(media_files)}")
|
|
||||||
for f in media_files:
|
|
||||||
print(f" - {f}")
|
|
||||||
|
|
||||||
# Check each playlist item
|
|
||||||
print("\n" + "=" * 80)
|
|
||||||
print("CHECKING PLAYLIST ITEMS")
|
|
||||||
print("=" * 80)
|
|
||||||
|
|
||||||
valid_count = 0
|
|
||||||
missing_count = 0
|
|
||||||
unsupported_count = 0
|
|
||||||
|
|
||||||
for idx, item in enumerate(playlist, 1):
|
|
||||||
file_name = item.get('file_name', '')
|
|
||||||
duration = item.get('duration', 0)
|
|
||||||
media_path = os.path.join(media_dir, file_name)
|
|
||||||
file_ext = os.path.splitext(file_name)[1].lower()
|
|
||||||
|
|
||||||
print(f"\n[{idx}/{len(playlist)}] {file_name}")
|
|
||||||
print(f" Duration: {duration}s")
|
|
||||||
|
|
||||||
# Check if file exists
|
|
||||||
if not os.path.exists(media_path):
|
|
||||||
print(f" ❌ STATUS: FILE NOT FOUND")
|
|
||||||
print(f" Expected path: {media_path}")
|
|
||||||
missing_count += 1
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Check file size
|
|
||||||
file_size = os.path.getsize(media_path)
|
|
||||||
print(f" ✓ File exists ({file_size:,} bytes)")
|
|
||||||
|
|
||||||
# Check if supported type
|
|
||||||
if file_ext not in SUPPORTED_EXTENSIONS:
|
|
||||||
print(f" ❌ STATUS: UNSUPPORTED FILE TYPE '{file_ext}'")
|
|
||||||
print(f" Supported extensions: {', '.join(SUPPORTED_EXTENSIONS)}")
|
|
||||||
unsupported_count += 1
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Check media type
|
|
||||||
if file_ext in VIDEO_EXTENSIONS:
|
|
||||||
media_type = "VIDEO"
|
|
||||||
elif file_ext in IMAGE_EXTENSIONS:
|
|
||||||
media_type = "IMAGE"
|
|
||||||
else:
|
|
||||||
media_type = "UNKNOWN"
|
|
||||||
|
|
||||||
print(f" ✓ Type: {media_type}")
|
|
||||||
print(f" ✓ Extension: {file_ext}")
|
|
||||||
print(f" ✓ STATUS: SHOULD PLAY OK")
|
|
||||||
valid_count += 1
|
|
||||||
|
|
||||||
# Summary
|
|
||||||
print("\n" + "=" * 80)
|
|
||||||
print("SUMMARY")
|
|
||||||
print("=" * 80)
|
|
||||||
print(f"Total items: {len(playlist)}")
|
|
||||||
print(f"✓ Valid: {valid_count}")
|
|
||||||
print(f"❌ Missing files: {missing_count}")
|
|
||||||
print(f"❌ Unsupported: {unsupported_count}")
|
|
||||||
|
|
||||||
if valid_count == len(playlist):
|
|
||||||
print("\n✅ All playlist items should play correctly!")
|
|
||||||
else:
|
|
||||||
print(f"\n⚠️ WARNING: {len(playlist) - valid_count} items may be skipped!")
|
|
||||||
|
|
||||||
# Additional checks
|
|
||||||
print("\n" + "=" * 80)
|
|
||||||
print("ADDITIONAL CHECKS")
|
|
||||||
print("=" * 80)
|
|
||||||
|
|
||||||
# Check for files in media dir not in playlist
|
|
||||||
playlist_files_set = {item.get('file_name', '') for item in playlist}
|
|
||||||
orphaned_files = [f for f in media_files if f not in playlist_files_set]
|
|
||||||
|
|
||||||
if orphaned_files:
|
|
||||||
print(f"\n⚠️ Files in media directory NOT in playlist:")
|
|
||||||
for f in orphaned_files:
|
|
||||||
print(f" - {f}")
|
|
||||||
else:
|
|
||||||
print("\n✓ All media files are in the playlist")
|
|
||||||
|
|
||||||
# Check for case sensitivity issues
|
|
||||||
print("\n🔍 Checking for case sensitivity issues...")
|
|
||||||
media_files_lower = {f.lower(): f for f in media_files}
|
|
||||||
case_issues = []
|
|
||||||
|
|
||||||
for item in playlist:
|
|
||||||
file_name = item.get('file_name', '')
|
|
||||||
if file_name.lower() in media_files_lower:
|
|
||||||
actual_name = media_files_lower[file_name.lower()]
|
|
||||||
if actual_name != file_name:
|
|
||||||
case_issues.append((file_name, actual_name))
|
|
||||||
|
|
||||||
if case_issues:
|
|
||||||
print("⚠️ Case sensitivity mismatches found:")
|
|
||||||
for playlist_name, actual_name in case_issues:
|
|
||||||
print(f" Playlist: {playlist_name}")
|
|
||||||
print(f" Actual: {actual_name}")
|
|
||||||
else:
|
|
||||||
print("✓ No case sensitivity issues found")
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
|
||||||
check_playlist()
|
|
||||||
@@ -1,125 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
|
|
||||||
# Download DEB Packages Script for Offline Installation
|
|
||||||
# This script downloads all system .deb packages required for offline installation
|
|
||||||
|
|
||||||
set -e
|
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
||||||
SYSTEM_DIR="$SCRIPT_DIR/repo/system-packages"
|
|
||||||
DEB_DIR="$SYSTEM_DIR/debs"
|
|
||||||
|
|
||||||
echo "=========================================="
|
|
||||||
echo "Downloading DEB Packages for Offline Install"
|
|
||||||
echo "=========================================="
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
# Create debs directory
|
|
||||||
mkdir -p "$DEB_DIR"
|
|
||||||
|
|
||||||
# Check if running on Debian/Ubuntu/Raspberry Pi OS
|
|
||||||
if ! command -v apt-get &> /dev/null; then
|
|
||||||
echo "Error: This script requires apt-get (Debian/Ubuntu/Raspberry Pi OS)"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "Reading package list from: $SYSTEM_DIR/apt-packages.txt"
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
# Update package cache
|
|
||||||
echo "Updating package cache..."
|
|
||||||
sudo apt update
|
|
||||||
|
|
||||||
# Read packages from file
|
|
||||||
PACKAGES=$(grep -v '^#' "$SYSTEM_DIR/apt-packages.txt" | grep -v '^$' | tr '\n' ' ')
|
|
||||||
|
|
||||||
echo "Packages to download:"
|
|
||||||
echo "$PACKAGES"
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
# Download packages and dependencies
|
|
||||||
echo "Downloading packages with dependencies..."
|
|
||||||
cd "$DEB_DIR"
|
|
||||||
|
|
||||||
# Use apt-get download to get .deb files
|
|
||||||
for pkg in $PACKAGES; do
|
|
||||||
echo "Downloading: $pkg"
|
|
||||||
apt-get download "$pkg" 2>/dev/null || echo " Warning: Could not download $pkg"
|
|
||||||
done
|
|
||||||
|
|
||||||
# Download dependencies
|
|
||||||
echo ""
|
|
||||||
echo "Downloading dependencies..."
|
|
||||||
sudo apt-get install --download-only --reinstall -y $PACKAGES
|
|
||||||
|
|
||||||
# Copy downloaded debs from apt cache
|
|
||||||
echo ""
|
|
||||||
echo "Copying packages from apt cache..."
|
|
||||||
sudo cp /var/cache/apt/archives/*.deb "$DEB_DIR/" 2>/dev/null || true
|
|
||||||
|
|
||||||
# Remove duplicate packages
|
|
||||||
echo ""
|
|
||||||
echo "Removing duplicates..."
|
|
||||||
cd "$DEB_DIR"
|
|
||||||
for file in *.deb; do
|
|
||||||
[ -f "$file" ] || continue
|
|
||||||
basename="${file%%_*}"
|
|
||||||
count=$(ls -1 "${basename}"_*.deb 2>/dev/null | wc -l)
|
|
||||||
if [ "$count" -gt 1 ]; then
|
|
||||||
# Keep only the latest version
|
|
||||||
ls -t "${basename}"_*.deb | tail -n +2 | xargs rm -f
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
|
|
||||||
# Create installation order file
|
|
||||||
echo ""
|
|
||||||
echo "Creating installation order..."
|
|
||||||
cat > "$DEB_DIR/install-order.txt" << 'EOF'
|
|
||||||
# Install packages in this order to resolve dependencies
|
|
||||||
|
|
||||||
# 1. Base tools and libraries
|
|
||||||
python3-pip_*.deb
|
|
||||||
python3-setuptools_*.deb
|
|
||||||
python3-dev_*.deb
|
|
||||||
zlib1g-dev_*.deb
|
|
||||||
|
|
||||||
# 2. SDL2 libraries
|
|
||||||
libsdl2-dev_*.deb
|
|
||||||
libsdl2-image-dev_*.deb
|
|
||||||
libsdl2-mixer-dev_*.deb
|
|
||||||
libsdl2-ttf-dev_*.deb
|
|
||||||
|
|
||||||
# 3. Multimedia libraries
|
|
||||||
libportmidi-dev_*.deb
|
|
||||||
libswscale-dev_*.deb
|
|
||||||
libavformat-dev_*.deb
|
|
||||||
libavcodec-dev_*.deb
|
|
||||||
libavcodec-extra_*.deb
|
|
||||||
|
|
||||||
# 4. FFmpeg and codecs
|
|
||||||
ffmpeg_*.deb
|
|
||||||
libx264-dev_*.deb
|
|
||||||
|
|
||||||
# 5. GStreamer
|
|
||||||
gstreamer1.0-plugins-base_*.deb
|
|
||||||
gstreamer1.0-plugins-good_*.deb
|
|
||||||
gstreamer1.0-plugins-bad_*.deb
|
|
||||||
gstreamer1.0-alsa_*.deb
|
|
||||||
|
|
||||||
# 6. Network tools
|
|
||||||
wget_*.deb
|
|
||||||
curl_*.deb
|
|
||||||
EOF
|
|
||||||
|
|
||||||
# Summary
|
|
||||||
echo ""
|
|
||||||
echo "=========================================="
|
|
||||||
echo "DEB Download Complete!"
|
|
||||||
echo "=========================================="
|
|
||||||
echo ""
|
|
||||||
echo "Downloaded .deb files: $(ls -1 *.deb 2>/dev/null | wc -l)"
|
|
||||||
echo "Location: $DEB_DIR"
|
|
||||||
echo ""
|
|
||||||
echo "To install offline, copy the repo folder and run:"
|
|
||||||
echo " bash install.sh --offline"
|
|
||||||
echo ""
|
|
||||||
@@ -1,72 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
|
|
||||||
# Download Offline Packages Script for Kivy Signage Player
|
|
||||||
# This script downloads all necessary Python packages and documents system packages
|
|
||||||
|
|
||||||
set -e
|
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
||||||
REPO_DIR="$SCRIPT_DIR/repo"
|
|
||||||
WHEELS_DIR="$REPO_DIR/python-wheels"
|
|
||||||
SYSTEM_DIR="$REPO_DIR/system-packages"
|
|
||||||
|
|
||||||
echo "=========================================="
|
|
||||||
echo "Downloading Offline Packages"
|
|
||||||
echo "=========================================="
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
# Check if repo directory exists
|
|
||||||
if [ ! -d "$REPO_DIR" ]; then
|
|
||||||
echo "Error: repo directory not found!"
|
|
||||||
echo "Creating directories..."
|
|
||||||
mkdir -p "$WHEELS_DIR"
|
|
||||||
mkdir -p "$SYSTEM_DIR"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Download Python packages
|
|
||||||
echo "Step 1: Downloading Python wheels..."
|
|
||||||
echo "--------------------"
|
|
||||||
cd "$SCRIPT_DIR"
|
|
||||||
|
|
||||||
# Check if pip is installed
|
|
||||||
if ! command -v pip3 &> /dev/null; then
|
|
||||||
echo "Error: pip3 is not installed. Please install it first:"
|
|
||||||
echo " sudo apt install python3-pip"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Download all Python packages and their dependencies
|
|
||||||
echo "Downloading packages from requirements.txt..."
|
|
||||||
pip3 download -r requirements.txt -d "$WHEELS_DIR" --platform linux_armv7l --only-binary=:all: || \
|
|
||||||
pip3 download -r requirements.txt -d "$WHEELS_DIR" || true
|
|
||||||
|
|
||||||
# Also download for general Linux platforms as fallback
|
|
||||||
echo "Downloading cross-platform packages..."
|
|
||||||
pip3 download -r requirements.txt -d "$WHEELS_DIR" || true
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo "Python wheels downloaded to: $WHEELS_DIR"
|
|
||||||
echo "Total wheel files: $(ls -1 "$WHEELS_DIR"/*.whl 2>/dev/null | wc -l)"
|
|
||||||
|
|
||||||
# List system packages
|
|
||||||
echo ""
|
|
||||||
echo "Step 2: System packages information"
|
|
||||||
echo "--------------------"
|
|
||||||
echo "System packages are listed in: $SYSTEM_DIR/apt-packages.txt"
|
|
||||||
echo ""
|
|
||||||
echo "To download .deb files for offline installation, run:"
|
|
||||||
echo " bash download_deb_packages.sh"
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
# Summary
|
|
||||||
echo "=========================================="
|
|
||||||
echo "Download Complete!"
|
|
||||||
echo "=========================================="
|
|
||||||
echo ""
|
|
||||||
echo "Offline packages ready in: $REPO_DIR"
|
|
||||||
echo ""
|
|
||||||
echo "Next steps:"
|
|
||||||
echo "1. Copy the entire 'repo' folder to your offline system"
|
|
||||||
echo "2. Run: bash install.sh"
|
|
||||||
echo " (The installer will automatically detect and use offline packages)"
|
|
||||||
echo ""
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""Force playlist update to download all files."""
|
|
||||||
|
|
||||||
import json
|
|
||||||
import sys
|
|
||||||
import os
|
|
||||||
|
|
||||||
# Add src directory to path
|
|
||||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), 'src'))
|
|
||||||
|
|
||||||
from get_playlists_v2 import update_playlist_if_needed
|
|
||||||
|
|
||||||
# Load config
|
|
||||||
config_file = 'config/app_config.json'
|
|
||||||
with open(config_file, 'r') as f:
|
|
||||||
config = json.load(f)
|
|
||||||
|
|
||||||
print("=" * 80)
|
|
||||||
print("FORCING PLAYLIST UPDATE")
|
|
||||||
print("=" * 80)
|
|
||||||
|
|
||||||
playlist_dir = 'playlists'
|
|
||||||
media_dir = 'media'
|
|
||||||
|
|
||||||
print(f"\nConfiguration:")
|
|
||||||
print(f" Playlist dir: {playlist_dir}")
|
|
||||||
print(f" Media dir: {media_dir}")
|
|
||||||
|
|
||||||
print("\n" + "=" * 80)
|
|
||||||
print("Updating playlist...")
|
|
||||||
print("=" * 80 + "\n")
|
|
||||||
|
|
||||||
result = update_playlist_if_needed(config, playlist_dir, media_dir)
|
|
||||||
|
|
||||||
if result:
|
|
||||||
print("\n" + "=" * 80)
|
|
||||||
print("SUCCESS!")
|
|
||||||
print("=" * 80)
|
|
||||||
print(f"✓ Playlist updated to: {result}")
|
|
||||||
|
|
||||||
# Check media directory
|
|
||||||
import os
|
|
||||||
media_files = sorted([f for f in os.listdir(media_dir) if not f.startswith('.')])
|
|
||||||
print(f"\n✓ Media files downloaded ({len(media_files)}):")
|
|
||||||
for f in media_files:
|
|
||||||
size = os.path.getsize(os.path.join(media_dir, f))
|
|
||||||
print(f" - {f} ({size:,} bytes)")
|
|
||||||
|
|
||||||
else:
|
|
||||||
print("\n" + "=" * 80)
|
|
||||||
print("FAILED or already up to date")
|
|
||||||
print("=" * 80)
|
|
||||||
|
|
||||||
print("\n" + "=" * 80)
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
{
|
|
||||||
"hostname": "tv-terasa",
|
|
||||||
"auth_code": "iiSyZDLWGyqNIxeRt54XYREgvAio11RwwU1_oJev6WI",
|
|
||||||
"player_id": 1,
|
|
||||||
"player_name": "TV-acasa 1",
|
|
||||||
"playlist_id": 1,
|
|
||||||
"orientation": "Landscape",
|
|
||||||
"authenticated": true,
|
|
||||||
"server_url": "http://digi-signage.moto-adv.com"
|
|
||||||
}
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
{
|
|
||||||
"count": 5,
|
|
||||||
"player_id": 1,
|
|
||||||
"player_name": "TV-acasa 1",
|
|
||||||
"playlist": [
|
|
||||||
{
|
|
||||||
"description": null,
|
|
||||||
"duration": 15,
|
|
||||||
"file_name": "music.jpg",
|
|
||||||
"id": 1,
|
|
||||||
"position": 1,
|
|
||||||
"type": "image",
|
|
||||||
"url": "http://digi-signage.moto-adv.com/static/uploads/music.jpg"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"description": null,
|
|
||||||
"duration": 23,
|
|
||||||
"file_name": "130414-746934884.mp4",
|
|
||||||
"id": 2,
|
|
||||||
"position": 3,
|
|
||||||
"type": "video",
|
|
||||||
"url": "http://digi-signage.moto-adv.com/static/uploads/130414-746934884.mp4"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"description": null,
|
|
||||||
"duration": 15,
|
|
||||||
"file_name": "IMG_0386.jpeg",
|
|
||||||
"id": 4,
|
|
||||||
"position": 4,
|
|
||||||
"type": "image",
|
|
||||||
"url": "http://digi-signage.moto-adv.com/static/uploads/IMG_0386.jpeg"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"description": null,
|
|
||||||
"duration": 15,
|
|
||||||
"file_name": "AGC_20250704_204105932.jpg",
|
|
||||||
"id": 5,
|
|
||||||
"position": 5,
|
|
||||||
"type": "image",
|
|
||||||
"url": "http://digi-signage.moto-adv.com/static/uploads/AGC_20250704_204105932.jpg"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"description": null,
|
|
||||||
"duration": 15,
|
|
||||||
"file_name": "50194.jpg",
|
|
||||||
"id": 3,
|
|
||||||
"position": 6,
|
|
||||||
"type": "image",
|
|
||||||
"url": "http://digi-signage.moto-adv.com/static/uploads/50194.jpg"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"playlist_id": 1,
|
|
||||||
"playlist_version": 9
|
|
||||||
}
|
|
||||||
@@ -1,185 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
Test script for Kiwy-Signage authentication with DigiServer v2
|
|
||||||
Run this to verify authentication is working before updating main.py
|
|
||||||
"""
|
|
||||||
|
|
||||||
import sys
|
|
||||||
import os
|
|
||||||
|
|
||||||
# Add src directory to path
|
|
||||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), 'src'))
|
|
||||||
|
|
||||||
from player_auth import PlayerAuth
|
|
||||||
import json
|
|
||||||
|
|
||||||
def load_app_config():
|
|
||||||
"""Load existing app_config.json"""
|
|
||||||
# Try multiple possible locations
|
|
||||||
possible_paths = [
|
|
||||||
'config/app_config.json',
|
|
||||||
'resources/app_config.txt',
|
|
||||||
'src/config/app_config.json',
|
|
||||||
'../config/app_config.json'
|
|
||||||
]
|
|
||||||
|
|
||||||
for config_file in possible_paths:
|
|
||||||
if os.path.exists(config_file):
|
|
||||||
print(f" Found config: {config_file}")
|
|
||||||
with open(config_file, 'r') as f:
|
|
||||||
return json.load(f)
|
|
||||||
|
|
||||||
print(f"❌ Config file not found! Tried:")
|
|
||||||
for path in possible_paths:
|
|
||||||
print(f" - {path}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
def test_authentication():
|
|
||||||
"""Test authentication with DigiServer v2"""
|
|
||||||
print("=" * 60)
|
|
||||||
print("Kiwy-Signage Authentication Test")
|
|
||||||
print("=" * 60)
|
|
||||||
print()
|
|
||||||
|
|
||||||
# Load config
|
|
||||||
print("📁 Loading configuration...")
|
|
||||||
config = load_app_config()
|
|
||||||
if not config:
|
|
||||||
return False
|
|
||||||
|
|
||||||
server_ip = config.get('server_ip', '')
|
|
||||||
hostname = config.get('screen_name', '')
|
|
||||||
quickconnect = config.get('quickconnect_key', '')
|
|
||||||
port = config.get('port', '')
|
|
||||||
|
|
||||||
print(f" Server: {server_ip}:{port}")
|
|
||||||
print(f" Hostname: {hostname}")
|
|
||||||
print(f" Quick Connect: {'*' * len(quickconnect)}")
|
|
||||||
print()
|
|
||||||
|
|
||||||
# Build server URL
|
|
||||||
import re
|
|
||||||
ip_pattern = r'^\d+\.\d+\.\d+\.\d+$'
|
|
||||||
if re.match(ip_pattern, server_ip):
|
|
||||||
server_url = f'http://{server_ip}:{port}'
|
|
||||||
else:
|
|
||||||
server_url = f'http://{server_ip}'
|
|
||||||
|
|
||||||
print(f"🌐 Server URL: {server_url}")
|
|
||||||
print()
|
|
||||||
|
|
||||||
# Test server connection
|
|
||||||
print("🔌 Testing server connection...")
|
|
||||||
try:
|
|
||||||
import requests
|
|
||||||
response = requests.get(f"{server_url}/api/health", timeout=5)
|
|
||||||
if response.status_code == 200:
|
|
||||||
data = response.json()
|
|
||||||
print(f" ✅ Server is healthy (version: {data.get('version')})")
|
|
||||||
else:
|
|
||||||
print(f" ⚠️ Server responded with status: {response.status_code}")
|
|
||||||
except Exception as e:
|
|
||||||
print(f" ❌ Cannot connect to server: {e}")
|
|
||||||
print()
|
|
||||||
print("💡 Make sure DigiServer v2 is running and accessible!")
|
|
||||||
return False
|
|
||||||
print()
|
|
||||||
|
|
||||||
# Initialize auth
|
|
||||||
print("🔐 Initializing authentication...")
|
|
||||||
auth = PlayerAuth(config_file='src/player_auth.json')
|
|
||||||
|
|
||||||
# Check if already authenticated
|
|
||||||
if auth.is_authenticated():
|
|
||||||
print(f" ℹ️ Found existing authentication")
|
|
||||||
print(f" Player: {auth.get_player_name()}")
|
|
||||||
print()
|
|
||||||
|
|
||||||
print("✓ Verifying saved authentication...")
|
|
||||||
valid, info = auth.verify_auth()
|
|
||||||
|
|
||||||
if valid:
|
|
||||||
print(f" ✅ Authentication is valid!")
|
|
||||||
print(f" Player ID: {info['player_id']}")
|
|
||||||
print(f" Player Name: {info['player_name']}")
|
|
||||||
print(f" Group ID: {info.get('group_id', 'None')}")
|
|
||||||
print(f" Orientation: {info.get('orientation', 'Landscape')}")
|
|
||||||
print()
|
|
||||||
|
|
||||||
# Test playlist fetch
|
|
||||||
print("📋 Testing playlist fetch...")
|
|
||||||
playlist_data = auth.get_playlist()
|
|
||||||
if playlist_data:
|
|
||||||
version = playlist_data.get('playlist_version', 0)
|
|
||||||
content_count = len(playlist_data.get('playlist', []))
|
|
||||||
print(f" ✅ Playlist received!")
|
|
||||||
print(f" Version: {version}")
|
|
||||||
print(f" Content items: {content_count}")
|
|
||||||
else:
|
|
||||||
print(f" ⚠️ Could not fetch playlist")
|
|
||||||
print()
|
|
||||||
|
|
||||||
# Test heartbeat
|
|
||||||
print("💓 Testing heartbeat...")
|
|
||||||
if auth.send_heartbeat(status='online'):
|
|
||||||
print(f" ✅ Heartbeat sent successfully")
|
|
||||||
else:
|
|
||||||
print(f" ⚠️ Heartbeat failed")
|
|
||||||
print()
|
|
||||||
|
|
||||||
print("=" * 60)
|
|
||||||
print("✅ All tests passed! Player is ready to use.")
|
|
||||||
print("=" * 60)
|
|
||||||
return True
|
|
||||||
else:
|
|
||||||
print(f" ❌ Saved authentication is expired or invalid")
|
|
||||||
print(f" Re-authenticating...")
|
|
||||||
print()
|
|
||||||
|
|
||||||
# Need to authenticate
|
|
||||||
print("🔑 Authenticating with server...")
|
|
||||||
success, error = auth.authenticate(
|
|
||||||
server_url=server_url,
|
|
||||||
hostname=hostname,
|
|
||||||
quickconnect_code=quickconnect
|
|
||||||
)
|
|
||||||
|
|
||||||
if success:
|
|
||||||
print(f" ✅ Authentication successful!")
|
|
||||||
print(f" Player: {auth.get_player_name()}")
|
|
||||||
print(f" Player ID: {auth.get_player_id()}")
|
|
||||||
print()
|
|
||||||
|
|
||||||
# Save confirmation
|
|
||||||
print(f"💾 Authentication saved to: src/player_auth.json")
|
|
||||||
print()
|
|
||||||
|
|
||||||
print("=" * 60)
|
|
||||||
print("✅ Authentication successful! Player is ready to use.")
|
|
||||||
print("=" * 60)
|
|
||||||
return True
|
|
||||||
else:
|
|
||||||
print(f" ❌ Authentication failed: {error}")
|
|
||||||
print()
|
|
||||||
print("💡 Troubleshooting:")
|
|
||||||
print(" 1. Check player exists in DigiServer v2 (hostname must match)")
|
|
||||||
print(" 2. Verify quickconnect_key matches server configuration")
|
|
||||||
print(" 3. Check server logs for authentication attempts")
|
|
||||||
print()
|
|
||||||
print("=" * 60)
|
|
||||||
print("❌ Authentication test failed")
|
|
||||||
print("=" * 60)
|
|
||||||
return False
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
|
||||||
try:
|
|
||||||
success = test_authentication()
|
|
||||||
sys.exit(0 if success else 1)
|
|
||||||
except KeyboardInterrupt:
|
|
||||||
print("\n⚠️ Test cancelled by user")
|
|
||||||
sys.exit(1)
|
|
||||||
except Exception as e:
|
|
||||||
print(f"\n❌ Unexpected error: {e}")
|
|
||||||
import traceback
|
|
||||||
traceback.print_exc()
|
|
||||||
sys.exit(1)
|
|
||||||
@@ -1,138 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""Test server connection and playlist fetch."""
|
|
||||||
|
|
||||||
import json
|
|
||||||
import sys
|
|
||||||
import os
|
|
||||||
|
|
||||||
# Add src directory to path
|
|
||||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), 'src'))
|
|
||||||
|
|
||||||
from player_auth import PlayerAuth
|
|
||||||
|
|
||||||
# Load config
|
|
||||||
config_file = 'config/app_config.json'
|
|
||||||
with open(config_file, 'r') as f:
|
|
||||||
config = json.load(f)
|
|
||||||
|
|
||||||
print("=" * 80)
|
|
||||||
print("SERVER CONNECTION TEST")
|
|
||||||
print("=" * 80)
|
|
||||||
|
|
||||||
server_ip = config.get("server_ip", "")
|
|
||||||
screen_name = config.get("screen_name", "")
|
|
||||||
quickconnect_key = config.get("quickconnect_key", "")
|
|
||||||
port = config.get("port", "")
|
|
||||||
|
|
||||||
print(f"\nConfiguration:")
|
|
||||||
print(f" Server: {server_ip}")
|
|
||||||
print(f" Port: {port}")
|
|
||||||
print(f" Screen Name: {screen_name}")
|
|
||||||
print(f" QuickConnect: {quickconnect_key}")
|
|
||||||
|
|
||||||
# Build server URL
|
|
||||||
if server_ip.startswith('http://') or server_ip.startswith('https://'):
|
|
||||||
server_url = server_ip
|
|
||||||
# If it has https but port 443 is specified, ensure port is included if non-standard
|
|
||||||
if not ':' in server_ip.replace('https://', '').replace('http://', ''):
|
|
||||||
if port and port != '443' and port != '80':
|
|
||||||
server_url = f"{server_ip}:{port}"
|
|
||||||
else:
|
|
||||||
# Use https for port 443, http for others
|
|
||||||
protocol = "https" if port == "443" else "http"
|
|
||||||
server_url = f"{protocol}://{server_ip}:{port}"
|
|
||||||
|
|
||||||
print(f"\nServer URL: {server_url}")
|
|
||||||
|
|
||||||
# Test authentication
|
|
||||||
print("\n" + "=" * 80)
|
|
||||||
print("1. TESTING AUTHENTICATION")
|
|
||||||
print("=" * 80)
|
|
||||||
|
|
||||||
auth = PlayerAuth('src/player_auth.json')
|
|
||||||
|
|
||||||
# Check if already authenticated
|
|
||||||
if auth.is_authenticated():
|
|
||||||
print("✓ Found existing authentication")
|
|
||||||
valid, message = auth.verify_auth()
|
|
||||||
if valid:
|
|
||||||
print(f"✓ Auth is valid: {message}")
|
|
||||||
else:
|
|
||||||
print(f"✗ Auth expired: {message}")
|
|
||||||
print("\nRe-authenticating...")
|
|
||||||
success, error = auth.authenticate(
|
|
||||||
server_url=server_url,
|
|
||||||
hostname=screen_name,
|
|
||||||
quickconnect_code=quickconnect_key
|
|
||||||
)
|
|
||||||
if success:
|
|
||||||
print(f"✓ Re-authentication successful!")
|
|
||||||
else:
|
|
||||||
print(f"✗ Re-authentication failed: {error}")
|
|
||||||
sys.exit(1)
|
|
||||||
else:
|
|
||||||
print("No existing authentication found. Authenticating...")
|
|
||||||
success, error = auth.authenticate(
|
|
||||||
server_url=server_url,
|
|
||||||
hostname=screen_name,
|
|
||||||
quickconnect_code=quickconnect_key
|
|
||||||
)
|
|
||||||
if success:
|
|
||||||
print(f"✓ Authentication successful!")
|
|
||||||
else:
|
|
||||||
print(f"✗ Authentication failed: {error}")
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
# Test playlist fetch
|
|
||||||
print("\n" + "=" * 80)
|
|
||||||
print("2. TESTING PLAYLIST FETCH")
|
|
||||||
print("=" * 80)
|
|
||||||
|
|
||||||
playlist_data = auth.get_playlist()
|
|
||||||
|
|
||||||
if playlist_data:
|
|
||||||
print(f"✓ Playlist fetched successfully!")
|
|
||||||
print(f"\nPlaylist Version: {playlist_data.get('playlist_version', 'N/A')}")
|
|
||||||
print(f"Number of items: {len(playlist_data.get('playlist', []))}")
|
|
||||||
|
|
||||||
print("\n" + "-" * 80)
|
|
||||||
print("PLAYLIST ITEMS:")
|
|
||||||
print("-" * 80)
|
|
||||||
|
|
||||||
for idx, item in enumerate(playlist_data.get('playlist', []), 1):
|
|
||||||
print(f"\n{idx}. File: {item.get('file_name', 'N/A')}")
|
|
||||||
print(f" URL: {item.get('url', 'N/A')}")
|
|
||||||
print(f" Duration: {item.get('duration', 'N/A')}s")
|
|
||||||
|
|
||||||
# Check if URL is relative or absolute
|
|
||||||
url = item.get('url', '')
|
|
||||||
if url.startswith('http://') or url.startswith('https://'):
|
|
||||||
print(f" Type: Absolute URL")
|
|
||||||
else:
|
|
||||||
print(f" Type: Relative path (will fail to download!)")
|
|
||||||
|
|
||||||
# Save full response
|
|
||||||
with open('server_response_debug.json', 'w') as f:
|
|
||||||
json.dump(playlist_data, f, indent=2)
|
|
||||||
print(f"\n✓ Full response saved to: server_response_debug.json")
|
|
||||||
|
|
||||||
print("\n" + "=" * 80)
|
|
||||||
print("SUMMARY")
|
|
||||||
print("=" * 80)
|
|
||||||
print(f"Server has: {len(playlist_data.get('playlist', []))} files")
|
|
||||||
print(f"Local has: 3 files (from playlists/server_playlist_v8.json)")
|
|
||||||
|
|
||||||
if len(playlist_data.get('playlist', [])) > 3:
|
|
||||||
print(f"\n⚠️ PROBLEM: Server has {len(playlist_data.get('playlist', []))} files but only 3 were saved!")
|
|
||||||
print("\nMissing files are likely:")
|
|
||||||
local_files = ['music.jpg', '130414-746934884.mp4', 'IMG_0386.jpeg']
|
|
||||||
server_files = [item.get('file_name', '') for item in playlist_data.get('playlist', [])]
|
|
||||||
missing = [f for f in server_files if f not in local_files]
|
|
||||||
for f in missing:
|
|
||||||
print(f" - {f}")
|
|
||||||
|
|
||||||
else:
|
|
||||||
print("✗ Failed to fetch playlist")
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
print("\n" + "=" * 80)
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""Direct API test to check server playlist."""
|
|
||||||
|
|
||||||
import requests
|
|
||||||
import json
|
|
||||||
|
|
||||||
# Try with the saved auth
|
|
||||||
auth_file = 'src/player_auth.json'
|
|
||||||
with open(auth_file, 'r') as f:
|
|
||||||
auth_data = json.load(f)
|
|
||||||
|
|
||||||
server_url = auth_data['server_url']
|
|
||||||
auth_code = auth_data['auth_code']
|
|
||||||
|
|
||||||
print("=" * 80)
|
|
||||||
print("DIRECT API TEST")
|
|
||||||
print("=" * 80)
|
|
||||||
print(f"Server: {server_url}")
|
|
||||||
print(f"Auth code: {auth_code[:20]}...")
|
|
||||||
print()
|
|
||||||
|
|
||||||
# Try to get playlist
|
|
||||||
try:
|
|
||||||
url = f"{server_url}/api/player/playlist"
|
|
||||||
headers = {
|
|
||||||
'Authorization': f'Bearer {auth_code}'
|
|
||||||
}
|
|
||||||
|
|
||||||
print(f"Fetching: {url}")
|
|
||||||
response = requests.get(url, headers=headers, timeout=10)
|
|
||||||
|
|
||||||
print(f"Status: {response.status_code}")
|
|
||||||
|
|
||||||
if response.status_code == 200:
|
|
||||||
data = response.json()
|
|
||||||
print(f"\nPlaylist version: {data.get('playlist_version', 'N/A')}")
|
|
||||||
print(f"Number of items: {len(data.get('playlist', []))}")
|
|
||||||
|
|
||||||
print("\nPlaylist items:")
|
|
||||||
for idx, item in enumerate(data.get('playlist', []), 1):
|
|
||||||
print(f"\n {idx}. {item.get('file_name', 'N/A')}")
|
|
||||||
print(f" URL: {item.get('url', 'N/A')}")
|
|
||||||
print(f" Duration: {item.get('duration', 'N/A')}s")
|
|
||||||
|
|
||||||
# Save full response
|
|
||||||
with open('server_playlist_full.json', 'w') as f:
|
|
||||||
json.dump(data, f, indent=2)
|
|
||||||
print(f"\nFull response saved to: server_playlist_full.json")
|
|
||||||
else:
|
|
||||||
print(f"Error: {response.text}")
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
print(f"Error: {e}")
|
|
||||||
|
|
||||||
print("\n" + "=" * 80)
|
|
||||||
@@ -1,82 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
Test script to verify the enhanced logging without running the full GUI
|
|
||||||
"""
|
|
||||||
import os
|
|
||||||
import json
|
|
||||||
|
|
||||||
def simulate_playback_check():
|
|
||||||
"""Simulate the playback logic to see what would happen"""
|
|
||||||
|
|
||||||
base_dir = os.path.dirname(os.path.abspath(__file__))
|
|
||||||
media_dir = os.path.join(base_dir, 'media')
|
|
||||||
playlists_dir = os.path.join(base_dir, 'playlists')
|
|
||||||
|
|
||||||
# Supported extensions
|
|
||||||
VIDEO_EXTENSIONS = ['.mp4', '.avi', '.mkv', '.mov', '.webm']
|
|
||||||
IMAGE_EXTENSIONS = ['.jpg', '.jpeg', '.png', '.bmp', '.gif']
|
|
||||||
|
|
||||||
# Load playlist
|
|
||||||
playlist_file = os.path.join(playlists_dir, 'server_playlist_v8.json')
|
|
||||||
with open(playlist_file, 'r') as f:
|
|
||||||
data = json.load(f)
|
|
||||||
|
|
||||||
playlist = data.get('playlist', [])
|
|
||||||
|
|
||||||
print("=" * 80)
|
|
||||||
print("SIMULATING PLAYBACK SEQUENCE")
|
|
||||||
print("=" * 80)
|
|
||||||
|
|
||||||
for idx, media_item in enumerate(playlist):
|
|
||||||
file_name = media_item.get('file_name', '')
|
|
||||||
duration = media_item.get('duration', 10)
|
|
||||||
|
|
||||||
print(f"\n[STEP {idx + 1}] ===== Playing item {idx + 1}/{len(playlist)} =====")
|
|
||||||
print(f" File: {file_name}")
|
|
||||||
print(f" Duration: {duration}s")
|
|
||||||
|
|
||||||
# Construct path
|
|
||||||
media_path = os.path.join(media_dir, file_name)
|
|
||||||
print(f" Full path: {media_path}")
|
|
||||||
|
|
||||||
# Check existence
|
|
||||||
if not os.path.exists(media_path):
|
|
||||||
print(f" \u274c Media file not found: {media_path}")
|
|
||||||
print(f" ACTION: Skipping to next media...")
|
|
||||||
continue
|
|
||||||
|
|
||||||
file_size = os.path.getsize(media_path)
|
|
||||||
print(f" \u2713 File exists (size: {file_size:,} bytes)")
|
|
||||||
|
|
||||||
# Check extension
|
|
||||||
file_extension = os.path.splitext(file_name)[1].lower()
|
|
||||||
print(f" Extension: {file_extension}")
|
|
||||||
|
|
||||||
if file_extension in VIDEO_EXTENSIONS:
|
|
||||||
print(f" Media type: VIDEO")
|
|
||||||
print(f" ACTION: play_video('{media_path}', {duration})")
|
|
||||||
print(f" - Creating Video widget...")
|
|
||||||
print(f" - Adding to content area...")
|
|
||||||
print(f" - Scheduling next media in {duration}s")
|
|
||||||
print(f" \u2713 Media started successfully")
|
|
||||||
elif file_extension in IMAGE_EXTENSIONS:
|
|
||||||
print(f" Media type: IMAGE")
|
|
||||||
print(f" ACTION: play_image('{media_path}', {duration})")
|
|
||||||
print(f" - Creating AsyncImage widget...")
|
|
||||||
print(f" - Adding to content area...")
|
|
||||||
print(f" - Scheduling next media in {duration}s")
|
|
||||||
print(f" \u2713 Image displayed successfully")
|
|
||||||
else:
|
|
||||||
print(f" \u274c Unsupported media type: {file_extension}")
|
|
||||||
print(f" Supported: .mp4/.avi/.mkv/.mov/.webm/.jpg/.jpeg/.png/.bmp/.gif")
|
|
||||||
print(f" ACTION: Skipping to next media...")
|
|
||||||
continue
|
|
||||||
|
|
||||||
print(f"\n [After {duration}s] Transitioning to next media (was index {idx})")
|
|
||||||
|
|
||||||
print("\n" + "=" * 80)
|
|
||||||
print("END OF PLAYLIST - Would restart from beginning")
|
|
||||||
print("=" * 80)
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
|
||||||
simulate_playback_check()
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""Test script to check what playlist the server is actually returning."""
|
|
||||||
|
|
||||||
import json
|
|
||||||
import sys
|
|
||||||
import os
|
|
||||||
|
|
||||||
# Add src directory to path
|
|
||||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), 'src'))
|
|
||||||
|
|
||||||
from get_playlists_v2 import fetch_server_playlist
|
|
||||||
|
|
||||||
# Load config
|
|
||||||
config_file = 'config/app_config.json'
|
|
||||||
with open(config_file, 'r') as f:
|
|
||||||
config = json.load(f)
|
|
||||||
|
|
||||||
print("=" * 80)
|
|
||||||
print("TESTING SERVER PLAYLIST FETCH")
|
|
||||||
print("=" * 80)
|
|
||||||
|
|
||||||
# Fetch playlist from server
|
|
||||||
print("\n1. Fetching playlist from server...")
|
|
||||||
server_data = fetch_server_playlist(config)
|
|
||||||
|
|
||||||
print(f"\n2. Server Response:")
|
|
||||||
print(f" Version: {server_data.get('version', 'N/A')}")
|
|
||||||
print(f" Playlist items: {len(server_data.get('playlist', []))}")
|
|
||||||
|
|
||||||
print(f"\n3. Detailed Playlist Items:")
|
|
||||||
for idx, item in enumerate(server_data.get('playlist', []), 1):
|
|
||||||
print(f"\n Item {idx}:")
|
|
||||||
print(f" file_name: {item.get('file_name', 'N/A')}")
|
|
||||||
print(f" url: {item.get('url', 'N/A')}")
|
|
||||||
print(f" duration: {item.get('duration', 'N/A')}")
|
|
||||||
|
|
||||||
print("\n" + "=" * 80)
|
|
||||||
print(f"TOTAL: Server has {len(server_data.get('playlist', []))} files")
|
|
||||||
print("=" * 80)
|
|
||||||
|
|
||||||
# Save to file for inspection
|
|
||||||
output_file = 'server_response_debug.json'
|
|
||||||
with open(output_file, 'w') as f:
|
|
||||||
json.dump(server_data, f, indent=2)
|
|
||||||
print(f"\nFull server response saved to: {output_file}")
|
|
||||||
Reference in New Issue
Block a user