Files
Kiwy-Signage/linux/development-track.md
T
ske087 3ac7f836c4 Port the player to Raspberry Pi OS Trixie 64-bit (Linux-only branch)
Replaces the Windows port with a Raspberry Pi / Linux implementation on
Raspberry Pi OS "Trixie" (Debian 13, aarch64, Wayland/labwc). The Windows
code is removed here but preserved on the Windows-Player branch.

Entry point
-----------
linux/run_linux.py replaces windows/run_win.py. src/main.py stays
platform-neutral; all Pi-specific behaviour is injected from linux/.

Five bugs that prevented the port (all measured on real hardware)
----------------------------------------------------------------
1. Kivy's PyPI wheel bundles an SDL2 built WITHOUT the wayland driver, so
   no window could be created (Trixie has no X server). linux/fix_kivy_sdl2.sh
   symlinks the system SDL2 over the bundled filename.
2. SDL2 requires WAYLAND_DISPLAY to be *set* - the socket alone is not enough,
   unlike wlopm. This broke every systemd/cron/autostart launch.
   linux_display.ensure_session_environment() detects and exports it.
3. Kivy's Clock resolves callbacks via func.__name__; a patch assigned under a
   different name crashed the player ~20s after a successful start.
4. The inherited signal_screen_activity() shelled out to tvservice, xdotool and
   ydotool - none exist on Trixie - and mis-escaped 'wlopm --on \*', so the
   display blanked after 10 minutes.
5. The launchers ran src/main.py directly, bypassing every platform patch and
   resolving the data directory one level too high.

Web links
---------
- --ozone-platform-hint=auto does NOT fall back to Wayland on Chromium 152; it
  aborts. The platform is now chosen explicitly.
- The keyring password prompt is suppressed via the ENVIRONMENT, not the flags:
  launch_env() strips DBUS_SESSION_BUS_ADDRESS for the child so Chromium cannot
  reach gnome-keyring-daemon.
- Teardown kills the whole process group (needs start_new_session=True);
  previously it silently fell back to terminate() and orphaned children.

Video normalisation
-------------------
A 4K video cannot play on a Pi 4: ffpyplayer decodes in software, measured at
0.90x realtime (1080p is 3.03x). Oversized media is downscaled to 1920x1080 at
sync time using the hardware h264_v4l2m2m encoder (~31s for an 18s clip),
triggered by resolution only so already-playable files are untouched.

src/media_state.py owns the shared on-disk contract: a .kiwy-converting marker
makes the player skip the item while it is being rebuilt, then the converted
file is played instead. If nothing is playable at all (a single-item playlist
whose only video is converting), the player loops the intro video rather than
leaving a blank screen.

Also fixed
----------
- network_monitor: replaced netsh/ifconfig/dhclient with nmcli (Trixie uses
  NetworkManager; ifconfig and dhclient are not even installed).
- Removed the Windows-only focus keeper/guardian from main.py.
- main.py: duplicate SDL_AUDIODRIVER setdefault (a silent no-op); Settings
  "Test connection" now uses tempfile.gettempdir().
- config/app_config.json: credentials blanked so a fresh clone runs the
  first-run setup flow.

Verification
------------
linux/test_media_state.py 18/18, test_linux_patches.py 21/21,
test_linux_browser_flags.py 27/27. Verified live against a real DigiServer:
image -> weblink -> image -> video with correct durations, zero leaked Chromium
processes, and no throttling over a 10 minute monitored run.
2026-09-13 21:57:49 +03:00

510 lines
26 KiB
Markdown
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 🧪 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)