diff --git a/group_vars/mediapis.yml b/group_vars/mediapis.yml index b71e1e0..7910589 100644 --- a/group_vars/mediapis.yml +++ b/group_vars/mediapis.yml @@ -5,3 +5,4 @@ mediapi_has_dhtsensor: false mediapi_disable_onboard_bluetooth: false mediapi_install_bluetooth_monitor: false mediapi_install_kidsmusic: false +mediapi_has_monitor: false diff --git a/host_vars/musicdolphin.yml b/host_vars/musicdolphin.yml index 026d967..f3b2e52 100644 --- a/host_vars/musicdolphin.yml +++ b/host_vars/musicdolphin.yml @@ -10,3 +10,9 @@ main_user: root mediapi_has_hifiberry_amp: true mediapi_ir_control: irserver 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 diff --git a/host_vars/musicmouse.yml b/host_vars/musicmouse.yml index 64ebb20..4c1226e 100644 --- a/host_vars/musicmouse.yml +++ b/host_vars/musicmouse.yml @@ -10,3 +10,4 @@ main_user: root mediapi_has_hifiberry_amp: true mediapi_install_bluetooth_monitor: true mediapi_install_kidsmusic: true +musicmouse_version: rearchitect-backend diff --git a/mediapis.yml b/mediapis.yml index 36f81f0..11cd217 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_kiosk + when: mediapi_has_monitor - pi_squeezelite_custom - pi_shairport - role: pi_irserver diff --git a/roles/pi_kiosk/README.md b/roles/pi_kiosk/README.md new file mode 100644 index 0000000..8a284ca --- /dev/null +++ b/roles/pi_kiosk/README.md @@ -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. diff --git a/roles/pi_kiosk/defaults/main.yml b/roles/pi_kiosk/defaults/main.yml new file mode 100644 index 0000000..750db2b --- /dev/null +++ b/roles/pi_kiosk/defaults/main.yml @@ -0,0 +1,3 @@ +--- +pi_kiosk_user: "kiosk" +pi_kiosk_url: "http://localhost:8080" diff --git a/roles/pi_kiosk/files/policies.json b/roles/pi_kiosk/files/policies.json new file mode 100644 index 0000000..3012740 --- /dev/null +++ b/roles/pi_kiosk/files/policies.json @@ -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" + } + } + } +} diff --git a/roles/pi_kiosk/handlers/main.yml b/roles/pi_kiosk/handlers/main.yml new file mode 100644 index 0000000..6ed99e1 --- /dev/null +++ b/roles/pi_kiosk/handlers/main.yml @@ -0,0 +1,6 @@ +--- +- name: Reload systemd and restart getty + ansible.builtin.systemd: + name: "getty@tty1" + state: restarted + daemon_reload: "yes" diff --git a/roles/pi_kiosk/tasks/main.yml b/roles/pi_kiosk/tasks/main.yml new file mode 100644 index 0000000..42929ec --- /dev/null +++ b/roles/pi_kiosk/tasks/main.yml @@ -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" diff --git a/roles/pi_kiosk/templates/autologin.conf.j2 b/roles/pi_kiosk/templates/autologin.conf.j2 new file mode 100644 index 0000000..4847af2 --- /dev/null +++ b/roles/pi_kiosk/templates/autologin.conf.j2 @@ -0,0 +1,3 @@ +[Service] +ExecStart= +ExecStart=-/sbin/agetty --autologin {{ pi_kiosk_user }} --noclear %I $TERM diff --git a/roles/pi_kiosk/templates/bash_profile.j2 b/roles/pi_kiosk/templates/bash_profile.j2 new file mode 100644 index 0000000..4231aea --- /dev/null +++ b/roles/pi_kiosk/templates/bash_profile.j2 @@ -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 diff --git a/roles/pi_kiosk/templates/xinitrc.j2 b/roles/pi_kiosk/templates/xinitrc.j2 new file mode 100644 index 0000000..d55c182 --- /dev/null +++ b/roles/pi_kiosk/templates/xinitrc.j2 @@ -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 diff --git a/roles/pi_musicmouse/README.md b/roles/pi_musicmouse/README.md index f00b787..d33f43b 100644 --- a/roles/pi_musicmouse/README.md +++ b/roles/pi_musicmouse/README.md @@ -1,4 +1,20 @@ # pi_musicmouse -Deploys the "Musicmouse" application: checks out its git repo, creates a -Python virtualenv, sets up a media directory, and installs its config file. +Deploys the MusicMouse backend (`python-backend/`) and its built web frontend. + +- 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-.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). diff --git a/roles/pi_musicmouse/defaults/main.yml b/roles/pi_musicmouse/defaults/main.yml new file mode 100644 index 0000000..20fb085 --- /dev/null +++ b/roles/pi_musicmouse/defaults/main.yml @@ -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" diff --git a/roles/pi_musicmouse/files/config-musicdolphin.yml b/roles/pi_musicmouse/files/config-musicdolphin.yml new file mode 100644 index 0000000..a9b4efd --- /dev/null +++ b/roles/pi_musicmouse/files/config-musicdolphin.yml @@ -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"] diff --git a/roles/pi_musicmouse/files/config.yml b/roles/pi_musicmouse/files/config-musicmouse.yml similarity index 72% rename from roles/pi_musicmouse/files/config.yml rename to roles/pi_musicmouse/files/config-musicmouse.yml index b5b348b..b20126b 100644 --- a/roles/pi_musicmouse/files/config.yml +++ b/roles/pi_musicmouse/files/config-musicmouse.yml @@ -1,17 +1,26 @@ --- general: + library: + root: /media/musicmouse + + serial_port: "/dev/ttyUSB0" alsa_device: softvol_effects mqtt: user: musicmouse password: KNLEFLZF94yA6Zhj141 server: homeassistant - hass_url: https://ha.bauer.tech - hass_token: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiI4N2ExMzM2ZmQ4ZWQ0ZDgzOWZhMjU3NmZjYTg1NWQ1ZiIsImlhdCI6MTcwNDAyOTUyOSwiZXhwIjoyMDE5Mzg5NTI5fQ.vx9L5bTSey98uc8TwodShaEXSMr-hjXugPsNviR_fEw" # noqa: yaml[line-length] + web: + 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 volume_increment: 5 min_volume: 23 max_volume: 70 - serial_port: "/dev/ttyUSB0" figures: elefant: diff --git a/roles/pi_musicmouse/files/musicmouse.service b/roles/pi_musicmouse/files/musicmouse.service deleted file mode 100644 index a09758c..0000000 --- a/roles/pi_musicmouse/files/musicmouse.service +++ /dev/null @@ -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 \ No newline at end of file diff --git a/roles/pi_musicmouse/handlers/main.yml b/roles/pi_musicmouse/handlers/main.yml new file mode 100644 index 0000000..1ffa7ab --- /dev/null +++ b/roles/pi_musicmouse/handlers/main.yml @@ -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" diff --git a/roles/pi_musicmouse/tasks/main.yml b/roles/pi_musicmouse/tasks/main.yml index 40f920a..91406d7 100644 --- a/roles/pi_musicmouse/tasks/main.yml +++ b/roles/pi_musicmouse/tasks/main.yml @@ -2,54 +2,111 @@ - name: Packages ansible.builtin.apt: name: - - python3 - - python3-pip - - python3-vlc # also in venv - but this installs vlc + deps + - git + - libvlc5 # runtime lib python-vlc (installed into the venv by uv) binds against + - vlc-plugin-base + - alsa-utils - 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 ansible.builtin.git: - repo: "ssh://git@git.bauer.tech:2222/martin/musicmouse.git" - dest: "/opt/musicmouse" - version: "release/1.1" + repo: "{{ pi_musicmouse_repo }}" + dest: /opt/musicmouse + version: "{{ musicmouse_version }}" accept_hostkey: true -- name: Create and update virtual env - ansible.builtin.pip: - requirements: /opt/musicmouse/espmusicmouse/host_driver/requirements.txt - virtualenv: /opt/musicmouse_venv - virtualenv_command: "/usr/bin/python3 -m venv" + force: true + notify: Reinstall musicmouse backend + +- name: Create virtualenv with Python {{ pi_musicmouse_python_version }} + 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 ansible.builtin.file: path: /media/musicmouse state: directory mode: "0755" + - name: Install config file ansible.builtin.copy: - src: config.yml + src: "{{ pi_musicmouse_config_file }}" dest: /media/musicmouse/config.yml 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: - src: musicmouse.service - dest: /etc/systemd/system/ + src: /opt/musicmouse/python-backend/musicmouse.service + dest: /etc/systemd/system/musicmouse.service mode: "0644" + remote_src: true + notify: Restart musicmouse + - name: Add script to autostart and start now ansible.builtin.systemd: name: musicmouse - state: restarted + state: started enabled: "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 ansible.builtin.copy: src: smb.conf dest: /etc/samba/ mode: "0644" + - name: Restart samba ansible.builtin.systemd: name: smbd state: restarted enabled: "yes" + daemon_reload: "yes" # manual steps: # - set samba passwords with smbpasswd # - 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