diff --git a/group_vars/mediapis.yml b/group_vars/mediapis.yml index f2e976b..850e446 100644 --- a/group_vars/mediapis.yml +++ b/group_vars/mediapis.yml @@ -5,6 +5,11 @@ mediapi_has_dhtsensor: false mediapi_disable_onboard_bluetooth: false mediapi_install_bluetooth_monitor: false mediapi_install_kidsmusic: false + +# The native Slint front-end, built on the device and installed beside the web UI. +# Needs mediapi_install_kidsmusic, which owns the checkout it builds from. Off by +# default: the build is ~40 minutes of the device's own CPU the first time. +mediapi_install_slint_frontend: false mediapi_has_monitor: false # No media pi runs its own Logitech Media Server. Every squeezelite in the fleet is diff --git a/host_vars/musicdolphin.yml b/host_vars/musicdolphin.yml index 09f6df4..2117b12 100644 --- a/host_vars/musicdolphin.yml +++ b/host_vars/musicdolphin.yml @@ -14,6 +14,9 @@ mediapi_has_dhtsensor: true # Display-only MusicMouse instance: no physical mouse attached, shown on the monitor # via pi_kiosk instead. See roles/pi_musicmouse/files/config-musicdolphin.yml. mediapi_install_kidsmusic: true +# The native front-end, for comparing against the browser on the same screen. Installed +# but not enabled - the device still boots into the kiosk. See roles/pi_slint_frontend. +mediapi_install_slint_frontend: true mediapi_has_monitor: true # Temporarily a debuggable desktop rather than a locked-down kiosk: Openbox root menu, # visible pointer, Firefox in a normal window. Set back to "kiosk" when done. diff --git a/mediapis.yml b/mediapis.yml index 8864f5a..0627a05 100644 --- a/mediapis.yml +++ b/mediapis.yml @@ -9,6 +9,8 @@ when: mediapi_has_hifiberry_amp - role: pi_musicmouse when: mediapi_install_kidsmusic + - role: pi_slint_frontend + when: mediapi_install_slint_frontend - role: pi_kiosk when: mediapi_has_monitor - pi_squeezelite_custom diff --git a/roles/pi_slint_frontend/README.md b/roles/pi_slint_frontend/README.md new file mode 100644 index 0000000..d5cb682 --- /dev/null +++ b/roles/pi_slint_frontend/README.md @@ -0,0 +1,44 @@ +# pi_slint_frontend + +Builds and installs `slint-frontend/` from the app repo - the native front-end that +exists because the React UI is sluggish on the kiosk. Installed *alongside* the web UI, +never instead of it, and the systemd unit is installed but not enabled: the device still +boots into the kiosk browser. + +```sh +systemctl start musikdelfin # takes over the screen +systemctl stop musikdelfin # back to the browser underneath +``` + +or over SSH, `DISPLAY=:0 /usr/local/bin/musikdelfin`. + +## Why this builds on the device + +`pi_musicmouse` builds the web front-end on the control machine and rsyncs `dist/`, +because npm output is portable and npm is not installed on the Pis. A Rust binary is +not portable, so this role has a harder choice. + +The Pis run **32-bit armhf Raspbian on a 64-bit kernel** - `uname -m` says `aarch64` +and `dpkg --print-architecture` says `armhf`, and the second one is the one that +matters. The target is `armv7-unknown-linux-gnueabihf`. Cross-compiling to it from the +control machine needs a sysroot, and the devices carry no `-dev` packages to build one +out of, so it would have to be reconstructed from the Debian archive - and then kept in +step with the devices' glibc forever. Spending the device's own CPU once is the smaller +liability. A rebuild only happens when the commit changes. + +The same fact rules out the Skia renderer: rust-skia publishes no prebuilt binaries for +armhf, so `--features kiosk` would mean compiling Skia from source on a Pi. This role +builds `--features desktop` - winit, FemtoVG and the software renderer - and runs as an +X client in the existing session rather than taking over KMS. + +`SLINT_BACKEND=winit-software musikdelfin` switches renderer without a rebuild, which +is the thing to try first if the GPU path misbehaves. The FemtoVG bug that makes +`kiosk` use Skia upstream (slint-ui/slint#11785) was reported on a Pi 5 with a 64-bit +userland, so it may well not apply here - but it is worth checking that album covers +actually appear before drawing conclusions about frame rate. + +## First run + +Roughly 40 minutes: 570 crates on two cores. Later runs are incremental and usually +seconds. The build is `nice`d and limited to two jobs, which bounds peak memory on +musicdolphin's 1.8 GB. diff --git a/roles/pi_slint_frontend/defaults/main.yml b/roles/pi_slint_frontend/defaults/main.yml new file mode 100644 index 0000000..41fd2d3 --- /dev/null +++ b/roles/pi_slint_frontend/defaults/main.yml @@ -0,0 +1,42 @@ +--- +# The native Slint front-end (slint-frontend/ in the app repo). Installed alongside the +# web UI, not instead of it: both talk to the same backend, so the two can be compared +# on the same screen. + +# Built on the device rather than on the control machine, which is the opposite of what +# pi_musicmouse does for the web front-end - and for a reason. The Pis run 32-bit armhf +# Raspbian on a 64-bit kernel, so the target is armv7-unknown-linux-gnueabihf. Cross +# compiling to it needs a sysroot, and the device has no -dev packages to build one +# from, so the choice is between reconstructing one from the Debian archive (and +# owning a glibc-version skew risk forever) or spending the device's own CPU once. +# A rebuild only happens when the commit changes - see the stamp below. +pi_slint_frontend_toolchain: stable + +# Bounds peak memory during the build. musicdolphin has 4 cores but only 1.8 GB. +pi_slint_frontend_jobs: 2 + +# winit + FemtoVG + the software renderer. Deliberately not Skia: rust-skia ships no +# prebuilt binaries for armhf, so `kiosk` would mean compiling Skia from source on a +# Pi, which will not finish in any reasonable time and would not fit on the disk. +pi_slint_frontend_features: desktop + +# Which backend the app is served by. Same machine as the kiosk browser uses. +pi_slint_frontend_server: "http://localhost:8080" + +# Where the checkout lives. Shared with pi_musicmouse, which owns the checkout itself. +pi_slint_frontend_root: /opt/musicmouse/slint-frontend + +# The user the kiosk's X session belongs to - the app has to run as them to reach the +# display. pi_kiosk owns this name; repeated here as a default so the role still works +# on a host where pi_kiosk has not run. +pi_slint_frontend_x_user: "{{ pi_kiosk_user | default('kiosk') }}" + +# The device is a kiosk, so the window takes the whole screen. Set false to get a +# normal window - useful while pi_kiosk_mode is "debug" and you want the browser and +# this side by side on the same display. +pi_slint_frontend_fullscreen: true + +# Slint's own frame-timing readout, for measuring rather than guessing. Empty to leave +# it off; "overlay,refresh_lazy" draws the numbers on screen and only counts frames +# that were actually rendered, which is the honest measure for a UI that idles. +pi_slint_frontend_debug_performance: "" diff --git a/roles/pi_slint_frontend/handlers/main.yml b/roles/pi_slint_frontend/handlers/main.yml new file mode 100644 index 0000000..c28484f --- /dev/null +++ b/roles/pi_slint_frontend/handlers/main.yml @@ -0,0 +1,4 @@ +--- +- name: Reload systemd + ansible.builtin.systemd: + daemon_reload: true diff --git a/roles/pi_slint_frontend/tasks/main.yml b/roles/pi_slint_frontend/tasks/main.yml new file mode 100644 index 0000000..397a2ae --- /dev/null +++ b/roles/pi_slint_frontend/tasks/main.yml @@ -0,0 +1,143 @@ +--- +- name: Packages needed to build the front-end + ansible.builtin.apt: + name: + - build-essential + - pkg-config + # The only C libraries the binary links: everything else - X11, Wayland, EGL, + # GL - is dlopen'd at runtime by winit and glutin, so it is not needed to build. + - libfontconfig1-dev + - libfreetype-dev + - curl + # Runtime, not build: winit and glutin dlopen all of these, so they never show + # up in `ldd` and the binary links happily without them - it then aborts on + # first run with "Library libxkbcommon-x11.so could not be loaded". + - libxkbcommon0 + - libxkbcommon-x11-0 + - libx11-xcb1 + - libxcb-xkb1 + - libxcursor1 + - libxi6 + - libxrandr2 + - libgl1 + - libegl1 + - libgles2 + # The design uses emoji as iconography - the shelf badges, the figurine marker, + # the scene sparkle. Raspbian ships no font that has them, so without these they + # render as empty boxes. Symbola is the monochrome fallback for the glyphs Noto + # does not cover. + - fonts-noto-color-emoji + - fonts-symbola + cache_valid_time: 7200 + +- name: Check for a rust toolchain + ansible.builtin.stat: + path: /root/.cargo/bin/cargo + register: pi_slint_frontend_cargo + +- name: Fetch the rustup installer + ansible.builtin.get_url: + url: https://sh.rustup.rs + dest: /tmp/rustup-init.sh + mode: "0755" + when: not pi_slint_frontend_cargo.stat.exists + +- name: Install the rust toolchain + # Debian 12 ships rustc 1.63, which is far too old for Slint, so this comes from + # rustup rather than apt. + ansible.builtin.command: + cmd: "/tmp/rustup-init.sh -y --profile minimal --default-toolchain {{ pi_slint_frontend_toolchain }}" + creates: /root/.cargo/bin/cargo + when: not pi_slint_frontend_cargo.stat.exists + +- name: Check the checkout has a slint-frontend + ansible.builtin.stat: + path: "{{ pi_slint_frontend_root }}/Cargo.toml" + register: pi_slint_frontend_manifest + when: not ansible_check_mode + +- name: Fail on a checkout without the front-end + ansible.builtin.fail: + msg: >- + {{ musicmouse_version }} has no slint-frontend/, so it predates the native + front-end. Point musicmouse_version at a branch that has it, or set + mediapi_install_slint_frontend to false for this host. + when: not ansible_check_mode and not pi_slint_frontend_manifest.stat.exists + +- name: Fail when the checkout's commit is unknown + # This role builds what pi_musicmouse checked out, and reuses its record of which + # commit that was. With mediapi_install_kidsmusic false that role never ran, so + # there is no checkout to build and no commit to stamp - and the stamp comparison + # below would compare against an empty string and rebuild on every run. + ansible.builtin.fail: + msg: >- + pi_slint_frontend needs pi_musicmouse to have run first - it builds the + slint-frontend/ inside the checkout that role owns. Set + mediapi_install_kidsmusic to true for this host, or + mediapi_install_slint_frontend to false. + when: not ansible_check_mode and pi_musicmouse_commit | length == 0 + +- name: Read which commit the binary was built from + # The same guard pi_musicmouse uses for the venv and the web dist, for the same + # reason: hanging a 40-minute build off "the checkout changed" strands the device + # when a run fails after the checkout, because the next run finds it current and + # rebuilds nothing. + ansible.builtin.command: + cmd: "cat {{ pi_slint_frontend_root }}/.built-commit" + register: pi_slint_frontend_stamp + changed_when: false + failed_when: false + +- name: Build the front-end + # First build takes roughly 40 minutes on a Pi 4 - 570 crates, two jobs. Later ones + # are incremental. `async` keeps the SSH connection from idling out underneath it. + ansible.builtin.command: + cmd: >- + nice -n 10 /root/.cargo/bin/cargo build --release + --no-default-features --features {{ pi_slint_frontend_features }} + -j {{ pi_slint_frontend_jobs }} + chdir: "{{ pi_slint_frontend_root }}" + environment: + # The committed release profile is tuned for a workstation. Thin LTO with + # codegen-units=1 is a memory risk on 1.8 GB and buys nothing that matters here. + CARGO_PROFILE_RELEASE_LTO: "false" + CARGO_PROFILE_RELEASE_CODEGEN_UNITS: "16" + PATH: "/root/.cargo/bin:{{ ansible_env.PATH }}" + async: 7200 + poll: 30 + changed_when: true + when: >- + not ansible_check_mode + and pi_slint_frontend_stamp.stdout | trim != pi_musicmouse_commit + +- name: Record which commit the binary was built from + ansible.builtin.copy: + content: "{{ pi_musicmouse_commit }}\n" + dest: "{{ pi_slint_frontend_root }}/.built-commit" + mode: "0644" + when: not ansible_check_mode + +- name: Find the X session user's home directory + ansible.builtin.getent: + database: passwd + key: "{{ pi_slint_frontend_x_user }}" + +- name: Note where the X cookie lives + ansible.builtin.set_fact: + pi_slint_frontend_x_home: "{{ ansible_facts.getent_passwd[pi_slint_frontend_x_user][4] }}" + +- name: Install the launcher + ansible.builtin.template: + src: musikdelfin.sh.j2 + dest: /usr/local/bin/musikdelfin + mode: "0755" + +- name: Install the systemd unit + # Installed but deliberately not enabled: this is the thing under test, and the + # kiosk browser is still what the device shows on boot. Start it by hand - + # `systemctl start musikdelfin` - or run /usr/local/bin/musikdelfin over SSH. + ansible.builtin.template: + src: musikdelfin.service.j2 + dest: /etc/systemd/system/musikdelfin.service + mode: "0644" + notify: Reload systemd diff --git a/roles/pi_slint_frontend/templates/musikdelfin.service.j2 b/roles/pi_slint_frontend/templates/musikdelfin.service.j2 new file mode 100644 index 0000000..7a4b4e5 --- /dev/null +++ b/roles/pi_slint_frontend/templates/musikdelfin.service.j2 @@ -0,0 +1,25 @@ +# {{ ansible_managed }} +# +# Installed, not enabled. The kiosk browser is still what this device shows on boot; +# this unit is here so the native front-end can be started and stopped the same way +# while it is being evaluated: +# +# systemctl start musikdelfin # takes over the screen +# systemctl stop musikdelfin # back to the browser underneath +[Unit] +Description=Musik Delfin (native Slint front-end) +After=musicmouse.service graphical.target +Wants=musicmouse.service + +[Service] +Type=simple +# Runs as the X session's own user, not root: the display belongs to them, and the +# cover cache should land in their home rather than root's. +User={{ pi_slint_frontend_x_user }} +ExecStart=/usr/local/bin/musikdelfin +Restart=no +StandardOutput=journal +StandardError=journal + +[Install] +WantedBy=graphical.target diff --git a/roles/pi_slint_frontend/templates/musikdelfin.sh.j2 b/roles/pi_slint_frontend/templates/musikdelfin.sh.j2 new file mode 100644 index 0000000..0c3699a --- /dev/null +++ b/roles/pi_slint_frontend/templates/musikdelfin.sh.j2 @@ -0,0 +1,40 @@ +#!/usr/bin/env bash +# {{ ansible_managed }} +# +# Run the native front-end on the kiosk's X display. The web UI stays where it is; +# both talk to the same backend, so they can be compared on the same screen. +# +# musikdelfin the display, as the kiosk uses it +# SLINT_BACKEND=winit-software musikdelfin no GPU, for comparison +# SLINT_FULLSCREEN=0 musikdelfin a normal window, beside the browser +# SLINT_DEBUG_PERFORMANCE=overlay,refresh_lazy musikdelfin frame timings on screen +set -eu + +# The kiosk's X session belongs to {{ pi_slint_frontend_x_user }}, and its cookie is +# the only way onto that display - without it the connection is refused before winit +# gets far enough to report anything more useful than "could not initialize backend". +export DISPLAY="${DISPLAY:-:0}" +export XAUTHORITY="${XAUTHORITY:-{{ pi_slint_frontend_x_home }}/.Xauthority}" + +# Nunito is the face the design is drawn in; without it Slint falls back to whatever +# fontconfig hands back, which changes how this looks but not whether it runs. +for candidate in \ + /usr/share/fonts/truetype/nunito/Nunito[wght].ttf \ + /usr/share/fonts/truetype/nunito/Nunito-Regular.ttf \ + {{ pi_slint_frontend_root }}/fonts/Nunito.ttf +do + if [ -f "$candidate" ]; then + export SLINT_DEFAULT_FONT="$candidate" + break + fi +done + +{% if pi_slint_frontend_fullscreen %} +export SLINT_FULLSCREEN="${SLINT_FULLSCREEN:-1}" +{% endif %} +{% if pi_slint_frontend_debug_performance %} +export SLINT_DEBUG_PERFORMANCE="${SLINT_DEBUG_PERFORMANCE:-{{ pi_slint_frontend_debug_performance }}}" +{% endif %} + +exec {{ pi_slint_frontend_root }}/target/release/musicmouse-slint \ + --server {{ pi_slint_frontend_server }} "$@"