Deploy rearchitected MusicMouse backend + web frontend

The app grew a python-backend/ and a web/ frontend, so pi_musicmouse no
longer matches it. Rework the role:

- Install a pinned uv and build the venv with Python 3.13; Raspbian
  Bookworm's python3 is 3.11 and the backend uses PEP 695 syntax.
- pip install python-backend/ instead of a requirements.txt, and take
  the systemd unit straight from the checkout so it can't drift.
- Build web/ on the control machine (no npm on the Pi) and rsync
  web/dist over. Chain the checkout -> install -> build -> sync -> restart
  steps through handlers so a run with no repo change does nothing.
- Per-host config files (config-<host>.yml); the schema now differs
  between a host with a real mouse and a display-only one.
- Version to deploy is per host (musicmouse_version), no default.

Add pi_kiosk: autologin user running startx with Firefox in kiosk mode
and no window manager, for a Pi with a monitor attached. Enabled by
mediapi_has_monitor, defaulting off.

Turn musicdolphin into a display-only instance: serial and audio
simulated, driven entirely through the kiosk page.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-19 17:58:51 +02:00
parent 2443a75871
commit 9b1d01115b
19 changed files with 337 additions and 33 deletions

View File

@@ -5,3 +5,4 @@ mediapi_has_dhtsensor: false
mediapi_disable_onboard_bluetooth: false mediapi_disable_onboard_bluetooth: false
mediapi_install_bluetooth_monitor: false mediapi_install_bluetooth_monitor: false
mediapi_install_kidsmusic: false mediapi_install_kidsmusic: false
mediapi_has_monitor: false

View File

@@ -10,3 +10,9 @@ main_user: root
mediapi_has_hifiberry_amp: true mediapi_has_hifiberry_amp: true
mediapi_ir_control: irserver mediapi_ir_control: irserver
mediapi_has_dhtsensor: true 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
mediapi_has_monitor: true
musicmouse_version: rearchitect-backend

View File

@@ -10,3 +10,4 @@ main_user: root
mediapi_has_hifiberry_amp: true mediapi_has_hifiberry_amp: true
mediapi_install_bluetooth_monitor: true mediapi_install_bluetooth_monitor: true
mediapi_install_kidsmusic: true mediapi_install_kidsmusic: true
musicmouse_version: rearchitect-backend

View File

@@ -9,6 +9,8 @@
when: mediapi_has_hifiberry_amp when: mediapi_has_hifiberry_amp
- role: pi_musicmouse - role: pi_musicmouse
when: mediapi_install_kidsmusic when: mediapi_install_kidsmusic
- role: pi_kiosk
when: mediapi_has_monitor
- pi_squeezelite_custom - pi_squeezelite_custom
- pi_shairport - pi_shairport
- role: pi_irserver - role: pi_irserver

18
roles/pi_kiosk/README.md Normal file
View File

@@ -0,0 +1,18 @@
# pi_kiosk
Turns a Pi with a monitor attached into a full-screen Firefox kiosk display, with no
window manager: an autologin console user runs `startx`, whose `.xinitrc` launches
Firefox in `--kiosk` mode pointed at `pi_kiosk_url` and relaunches it if it ever exits.
If X itself crashes, `.bash_profile` restarts it.
Vars:
- `pi_kiosk_user` (default `kiosk`) - the account that autologs into tty1.
- `pi_kiosk_url` (default `http://localhost:8080`) - what Firefox shows.
Meant to run alongside `pi_musicmouse` on the same host when it's serving the page
locally (the default `pi_kiosk_url`), gated in `mediapis.yml` by `mediapi_has_monitor`.
niri was considered instead of plain X/no-WM, but isn't packaged for Debian Bookworm
yet (no official apt package, only third-party `.deb`s or a from-source Rust build) -
revisit once it is.

View File

@@ -0,0 +1,3 @@
---
pi_kiosk_user: "kiosk"
pi_kiosk_url: "http://localhost:8080"

View File

@@ -0,0 +1,29 @@
{
"policies": {
"DisableAppUpdate": true,
"DisableFirefoxStudies": true,
"DisableProfileImport": true,
"DisableTelemetry": true,
"DontCheckDefaultBrowser": true,
"NoDefaultBookmarks": true,
"OverrideFirstRunPage": "",
"OverridePostUpdatePage": "",
"PasswordManagerEnabled": false,
"UserMessaging": {
"WhatsNew": false,
"ExtensionRecommendations": false,
"FeatureRecommendations": false,
"Locked": true
},
"Preferences": {
"browser.sessionstore.resume_from_crash": {
"Value": false,
"Status": "locked"
},
"browser.tabs.warnOnClose": {
"Value": false,
"Status": "locked"
}
}
}
}

View File

@@ -0,0 +1,6 @@
---
- name: Reload systemd and restart getty
ansible.builtin.systemd:
name: "getty@tty1"
state: restarted
daemon_reload: "yes"

View File

@@ -0,0 +1,59 @@
---
- name: Packages
ansible.builtin.apt:
name:
- xserver-xorg
- xinit
- x11-xserver-utils
- firefox-esr
- unclutter
cache_valid_time: 7200
- name: Create kiosk user
ansible.builtin.user:
name: "{{ pi_kiosk_user }}"
groups: [video, input, tty]
shell: /bin/bash
create_home: true
- name: Autologin the kiosk user on tty1
ansible.builtin.file:
path: /etc/systemd/system/getty@tty1.service.d
state: directory
mode: "0755"
- name: Install getty autologin override
ansible.builtin.template:
src: autologin.conf.j2
dest: /etc/systemd/system/getty@tty1.service.d/autologin.conf
mode: "0644"
notify: Reload systemd and restart getty
- name: Install .bash_profile (starts X on tty1 login)
ansible.builtin.template:
src: bash_profile.j2
dest: "/home/{{ pi_kiosk_user }}/.bash_profile"
owner: "{{ pi_kiosk_user }}"
group: "{{ pi_kiosk_user }}"
mode: "0644"
- name: Install .xinitrc (launches Firefox kiosk, no window manager)
ansible.builtin.template:
src: xinitrc.j2
dest: "/home/{{ pi_kiosk_user }}/.xinitrc"
owner: "{{ pi_kiosk_user }}"
group: "{{ pi_kiosk_user }}"
mode: "0755"
- name: Ensure firefox-esr policy directory exists
ansible.builtin.file:
path: /etc/firefox-esr
state: directory
mode: "0755"
# apt normally creates this; a backstop in case the package layout doesn't
- name: Install Firefox enterprise policy (suppress session-restore/update prompts)
ansible.builtin.copy:
src: policies.json
dest: /etc/firefox-esr/policies.json
mode: "0644"

View File

@@ -0,0 +1,3 @@
[Service]
ExecStart=
ExecStart=-/sbin/agetty --autologin {{ pi_kiosk_user }} --noclear %I $TERM

View File

@@ -0,0 +1,8 @@
# Managed by ansible (pi_kiosk role). Auto-starts the kiosk browser on the console
# this account autologs into - not meant for interactive use.
if [ -z "$DISPLAY" ] && [ "$(tty)" = "/dev/tty1" ]; then
while true; do
startx -- -nocursor
sleep 1
done
fi

View File

@@ -0,0 +1,15 @@
#!/bin/sh
# Managed by ansible (pi_kiosk role). No window manager on purpose - Firefox is the
# only X client, launched full-screen in kiosk mode; the loop is what takes the place
# of a WM's "keep something on screen" job if it crashes.
xset s off
xset -dpms
xset s noblank
unclutter -idle 0.5 -root &
while true; do
firefox --kiosk "{{ pi_kiosk_url }}"
sleep 2
done

View File

@@ -1,4 +1,20 @@
# pi_musicmouse # pi_musicmouse
Deploys the "Musicmouse" application: checks out its git repo, creates a Deploys the MusicMouse backend (`python-backend/`) and its built web frontend.
Python virtualenv, sets up a media directory, and installs its config file.
- Checks out `musicmouse_version` (a tag or branch - set per host in `host_vars`, no
default) from `musicmouse_repo` to `/opt/musicmouse`.
- Installs a pinned `uv` release and uses it to create `/opt/musicmouse/.venv` with
Python `pi_musicmouse_python_version` (the backend needs 3.12+ for PEP 695 syntax;
Raspbian Bookworm's apt python3 is only 3.11) and `pip install` the backend into it.
- Builds the frontend (`web/`) on the *control machine* (delegated to `localhost`, not
the Pi - no Node/npm is installed on the Pi) and syncs `web/dist` over.
- Installs `/media/musicmouse/config.yml` from `files/config-<inventory_hostname>.yml`
(one file per host - the schema differs between a host with a real mouse attached and
a display-only one; see those files' own comments) and the systemd unit copied
straight from the checked-out repo's `python-backend/musicmouse.service`, so it can
never drift from what's actually in the app repo.
- Sets up a Samba share of `/media/musicmouse` for copying music onto the device.
Manual steps after a first deploy: set the Samba password with `smbpasswd`, and copy
music into `/media/musicmouse` (or via the Samba share).

View File

@@ -0,0 +1,15 @@
---
pi_musicmouse_repo: "ssh://git@git.bauer.tech:2222/martin/musicmouse.git"
# No default on purpose: which tag/branch to run is a per-host decision, set in host_vars.
# musicmouse_version: "rearchitect-backend"
pi_musicmouse_python_version: "3.13"
pi_musicmouse_uv_version: "0.12.10"
# Where to check out the repo on the *control machine* to build the frontend (npm isn't
# installed on the Pi - see README). Keyed by version so different hosts on different
# versions don't clobber each other's build.
pi_musicmouse_build_cache: "/tmp/musicmouse-build-cache"
# Which files/config-*.yml to install as this host's /media/musicmouse/config.yml.
pi_musicmouse_config_file: "config-{{ inventory_hostname }}.yml"

View File

@@ -0,0 +1,29 @@
---
# musicdolphin: display-only instance. No physical mouse attached (for
# now), so serial and audio are both "simulate" - the web front-end (shown locally in
# the pi_kiosk role's Firefox kiosk window, and reachable on the LAN for a tablet) is a
# complete way to drive the player on its own. `figures` still needs at least one entry
# (the schema requires it) even though nothing will ever scan a tag here.
general:
library:
root: /media/musicmouse
serial_port: "simulate"
alsa_device: "simulate"
web:
host: "0.0.0.0"
port: 8080
static_dir: /opt/musicmouse/web/dist
button_leds_brightness: 0.5
volume_increment: 5
min_volume: 0
max_volume: 100
figures:
# Placeholder - serial_port is "simulate" so no reader ever reports a tag id, but the
# schema requires at least one figure to be configured.
placeholder:
id: "0000000001"
colors: ["#ffffff", "#ffffff", "#000000", "#ffffff"]

View File

@@ -1,17 +1,26 @@
--- ---
general: general:
library:
root: /media/musicmouse
serial_port: "/dev/ttyUSB0"
alsa_device: softvol_effects alsa_device: softvol_effects
mqtt: mqtt:
user: musicmouse user: musicmouse
password: KNLEFLZF94yA6Zhj141 password: KNLEFLZF94yA6Zhj141
server: homeassistant server: homeassistant
hass_url: https://ha.bauer.tech web:
hass_token: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiI4N2ExMzM2ZmQ4ZWQ0ZDgzOWZhMjU3NmZjYTg1NWQ1ZiIsImlhdCI6MTcwNDAyOTUyOSwiZXhwIjoyMDE5Mzg5NTI5fQ.vx9L5bTSey98uc8TwodShaEXSMr-hjXugPsNviR_fEw" # noqa: yaml[line-length] host: "0.0.0.0"
port: 8080
static_dir: /opt/musicmouse/web/dist
# The old hass_url/hass_token keys don't exist in the current schema - HA
# integration now lives under general.ha (url, token, devices, scenes; needs at
# least one device or scene) - see config.yml.example. Add it back once picked.
button_leds_brightness: 0.5 button_leds_brightness: 0.5
volume_increment: 5 volume_increment: 5
min_volume: 23 min_volume: 23
max_volume: 70 max_volume: 70
serial_port: "/dev/ttyUSB0"
figures: figures:
elefant: elefant:

View File

@@ -1,11 +0,0 @@
[Unit]
Description=MusicMouse Player
After=network.target
[Service]
Type=simple
Restart=always
ExecStart=/opt/musicmouse_venv/bin/python /opt/musicmouse/espmusicmouse/host_driver/main.py /media/musicmouse/
[Install]
WantedBy=multi-user.target

View File

@@ -0,0 +1,38 @@
---
- name: Reinstall musicmouse backend
ansible.builtin.command:
cmd: "/usr/local/bin/uv pip install --python /opt/musicmouse/.venv/bin/python /opt/musicmouse/python-backend"
changed_when: true
notify: Restart musicmouse
- name: Build frontend
ansible.builtin.command:
cmd: npm ci
chdir: "{{ pi_musicmouse_build_cache }}/{{ musicmouse_version | regex_replace('/', '_') }}/web"
delegate_to: localhost
become: false
changed_when: true
notify: Compile frontend
- name: Compile frontend
ansible.builtin.command:
cmd: npm run build
chdir: "{{ pi_musicmouse_build_cache }}/{{ musicmouse_version | regex_replace('/', '_') }}/web"
delegate_to: localhost
become: false
changed_when: true
notify: Sync frontend
- name: Sync frontend
ansible.posix.synchronize:
src: "{{ pi_musicmouse_build_cache }}/{{ musicmouse_version | regex_replace('/', '_') }}/web/dist/"
dest: /opt/musicmouse/web/dist/
delete: true
notify: Restart musicmouse
- name: Restart musicmouse
ansible.builtin.systemd:
name: musicmouse
state: restarted
enabled: "yes"
daemon_reload: "yes"

View File

@@ -2,54 +2,111 @@
- name: Packages - name: Packages
ansible.builtin.apt: ansible.builtin.apt:
name: name:
- python3 - git
- python3-pip - libvlc5 # runtime lib python-vlc (installed into the venv by uv) binds against
- python3-vlc # also in venv - but this installs vlc + deps - vlc-plugin-base
- alsa-utils
- samba - samba
cache_valid_time: 7200
- name: Ensure uv install directory exists
ansible.builtin.file:
path: "/opt/uv-{{ pi_musicmouse_uv_version }}"
state: directory
mode: "0755"
- name: Download and extract uv {{ pi_musicmouse_uv_version }}
ansible.builtin.unarchive:
src: "https://github.com/astral-sh/uv/releases/download/{{ pi_musicmouse_uv_version }}/uv-aarch64-unknown-linux-gnu.tar.gz"
dest: "/opt/uv-{{ pi_musicmouse_uv_version }}"
remote_src: true
extra_opts: ["--strip-components=1"]
creates: "/opt/uv-{{ pi_musicmouse_uv_version }}/uv"
- name: Symlink uv/uvx into /usr/local/bin
ansible.builtin.file:
src: "/opt/uv-{{ pi_musicmouse_uv_version }}/{{ item }}"
dest: "/usr/local/bin/{{ item }}"
state: link
force: true
loop:
- uv
- uvx
- name: Checkout Musicmouse repo - name: Checkout Musicmouse repo
ansible.builtin.git: ansible.builtin.git:
repo: "ssh://git@git.bauer.tech:2222/martin/musicmouse.git" repo: "{{ pi_musicmouse_repo }}"
dest: "/opt/musicmouse" dest: /opt/musicmouse
version: "release/1.1" version: "{{ musicmouse_version }}"
accept_hostkey: true accept_hostkey: true
- name: Create and update virtual env force: true
ansible.builtin.pip: notify: Reinstall musicmouse backend
requirements: /opt/musicmouse/espmusicmouse/host_driver/requirements.txt
virtualenv: /opt/musicmouse_venv - name: Create virtualenv with Python {{ pi_musicmouse_python_version }}
virtualenv_command: "/usr/bin/python3 -m venv" ansible.builtin.command:
cmd: "/usr/local/bin/uv venv --python {{ pi_musicmouse_python_version }} /opt/musicmouse/.venv"
creates: /opt/musicmouse/.venv/bin/python
- name: Create media directory - name: Create media directory
ansible.builtin.file: ansible.builtin.file:
path: /media/musicmouse path: /media/musicmouse
state: directory state: directory
mode: "0755" mode: "0755"
- name: Install config file - name: Install config file
ansible.builtin.copy: ansible.builtin.copy:
src: config.yml src: "{{ pi_musicmouse_config_file }}"
dest: /media/musicmouse/config.yml dest: /media/musicmouse/config.yml
mode: "0644" mode: "0644"
- name: Install systemd service file notify: Restart musicmouse
- name: Install systemd service file (from the checked-out repo, not a copy kept here)
ansible.builtin.copy: ansible.builtin.copy:
src: musicmouse.service src: /opt/musicmouse/python-backend/musicmouse.service
dest: /etc/systemd/system/ dest: /etc/systemd/system/musicmouse.service
mode: "0644" mode: "0644"
remote_src: true
notify: Restart musicmouse
- name: Add script to autostart and start now - name: Add script to autostart and start now
ansible.builtin.systemd: ansible.builtin.systemd:
name: musicmouse name: musicmouse
state: restarted state: started
enabled: "yes" enabled: "yes"
daemon_reload: "yes" daemon_reload: "yes"
- name: Ensure local frontend build cache directory exists
ansible.builtin.file:
path: "{{ pi_musicmouse_build_cache }}/{{ musicmouse_version | regex_replace('/', '_') }}"
state: directory
mode: "0755"
delegate_to: localhost
become: false
- name: Checkout Musicmouse repo for frontend build (control machine)
ansible.builtin.git:
repo: "{{ pi_musicmouse_repo }}"
dest: "{{ pi_musicmouse_build_cache }}/{{ musicmouse_version | regex_replace('/', '_') }}"
version: "{{ musicmouse_version }}"
accept_hostkey: true
force: true
delegate_to: localhost
become: false
notify: Build frontend
- name: Samba setup - name: Samba setup
ansible.builtin.copy: ansible.builtin.copy:
src: smb.conf src: smb.conf
dest: /etc/samba/ dest: /etc/samba/
mode: "0644" mode: "0644"
- name: Restart samba - name: Restart samba
ansible.builtin.systemd: ansible.builtin.systemd:
name: smbd name: smbd
state: restarted state: restarted
enabled: "yes" enabled: "yes"
daemon_reload: "yes"
# manual steps: # manual steps:
# - set samba passwords with smbpasswd # - set samba passwords with smbpasswd
# - copy music into /media/musicmouse (or via samba share) # - copy music into /media/musicmouse (or via samba share)
# - upload host driver?
# - manual patching of hassclient necessary :( loop has to be passed in, to not use running_event_loop # - manual patching of hassclient necessary :( loop has to be passed in, to not use running_event_loop