3ac7f836c4
Replaces the Windows port with a Raspberry Pi / Linux implementation on Raspberry Pi OS "Trixie" (Debian 13, aarch64, Wayland/labwc). The Windows code is removed here but preserved on the Windows-Player branch. Entry point ----------- linux/run_linux.py replaces windows/run_win.py. src/main.py stays platform-neutral; all Pi-specific behaviour is injected from linux/. Five bugs that prevented the port (all measured on real hardware) ---------------------------------------------------------------- 1. Kivy's PyPI wheel bundles an SDL2 built WITHOUT the wayland driver, so no window could be created (Trixie has no X server). linux/fix_kivy_sdl2.sh symlinks the system SDL2 over the bundled filename. 2. SDL2 requires WAYLAND_DISPLAY to be *set* - the socket alone is not enough, unlike wlopm. This broke every systemd/cron/autostart launch. linux_display.ensure_session_environment() detects and exports it. 3. Kivy's Clock resolves callbacks via func.__name__; a patch assigned under a different name crashed the player ~20s after a successful start. 4. The inherited signal_screen_activity() shelled out to tvservice, xdotool and ydotool - none exist on Trixie - and mis-escaped 'wlopm --on \*', so the display blanked after 10 minutes. 5. The launchers ran src/main.py directly, bypassing every platform patch and resolving the data directory one level too high. Web links --------- - --ozone-platform-hint=auto does NOT fall back to Wayland on Chromium 152; it aborts. The platform is now chosen explicitly. - The keyring password prompt is suppressed via the ENVIRONMENT, not the flags: launch_env() strips DBUS_SESSION_BUS_ADDRESS for the child so Chromium cannot reach gnome-keyring-daemon. - Teardown kills the whole process group (needs start_new_session=True); previously it silently fell back to terminate() and orphaned children. Video normalisation ------------------- A 4K video cannot play on a Pi 4: ffpyplayer decodes in software, measured at 0.90x realtime (1080p is 3.03x). Oversized media is downscaled to 1920x1080 at sync time using the hardware h264_v4l2m2m encoder (~31s for an 18s clip), triggered by resolution only so already-playable files are untouched. src/media_state.py owns the shared on-disk contract: a .kiwy-converting marker makes the player skip the item while it is being rebuilt, then the converted file is played instead. If nothing is playable at all (a single-item playlist whose only video is converting), the player loops the intro video rather than leaving a blank screen. Also fixed ---------- - network_monitor: replaced netsh/ifconfig/dhclient with nmcli (Trixie uses NetworkManager; ifconfig and dhclient are not even installed). - Removed the Windows-only focus keeper/guardian from main.py. - main.py: duplicate SDL_AUDIODRIVER setdefault (a silent no-op); Settings "Test connection" now uses tempfile.gettempdir(). - config/app_config.json: credentials blanked so a fresh clone runs the first-run setup flow. Verification ------------ linux/test_media_state.py 18/18, test_linux_patches.py 21/21, test_linux_browser_flags.py 27/27. Verified live against a real DigiServer: image -> weblink -> image -> video with correct durations, zero leaked Chromium processes, and no throttling over a 10 minute monitored run.
510 lines
26 KiB
Markdown
510 lines
26 KiB
Markdown
# 🧪 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 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)
|