Skip to content

Commit 3de4cb7

Browse files
committed
feat(lva-peripheral): add 03-stage-lva-2mic-peripheral image
New pi-gen stage that bakes the unmerged genericJE/linux-voice-assistant feat/peripheral-led-light-entity branch onto a 2-Mic HAT v2 + PiCompose image, alongside the ReSpeaker 2-Mic peripheral container from the same branch's examples/ReSpeaker 2mic HAT/ directory. The branch sits on top of upstream PR #266 (omaramin-2000:leds-and-buttons-events) and adds the Light entity, Rainbow effect, and peripheral-startup-wait — none of which are in the published ghcr.io/ohf-voice/linux-voice-assistant image yet, so both containers build from cloned source at first boot. What's wired: - /compose/lva-peripheral/ deploys two services in one compose project so HA enumeration waits until the Light entity is registered: LVA started with --peripheral-startup-wait 8, peripheral with restart loop on a 3 s reconnect. - Dockerfile.local for LVA fixes upstream's `COPY ../wakewords/` path (which escapes the build context); the upstream Dockerfile is left in lva-src/ untouched as a reference. - SPI overlay enabled and `pi` added to spi/gpio groups so the peripheral container's `group_add: [spi, gpio]` resolves to host GIDs that grant device access. - lva-peripheral-gpiochip-fix.service rewrites the compose's /dev/gpiochip0 to /dev/gpiochip4 on Pi 5 (one-shot, before picompose.service) so the same compose file works on Pi 3 / 4 / Zero 2 W and Pi 5. - LVA commit SHA pinned at /compose/lva-peripheral/LVA_COMMIT. CI: - build-2michat-v2-lva-peripheral target wired into build-all.yml with enable-rpi-imager-snippet: true, so the release ships with a Pi Imager JSON pointing directly at the .img.xz. README in the stage covers post-flash verification (containers, audio, peripheral API, HA discovery), log paths, and the manual smoke-test exercises that drove the branch.
1 parent 2feaa06 commit 3de4cb7

10 files changed

Lines changed: 501 additions & 0 deletions

File tree

.github/workflows/build-all.yml

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,20 @@ jobs:
6060
compression-level: 6
6161
custom-hostname: picompose
6262

63+
# 2MICHAT-v2 + LVA peripheral (smoke-test image for the unmerged
64+
# genericJE/linux-voice-assistant feat/peripheral-led-light-entity branch:
65+
# Light entity, Rainbow effect, ReSpeaker 2-Mic peripheral controller).
66+
# Emits a single-image rpi-imager.json alongside the .img.xz.
67+
build-2michat-v2-lva-peripheral:
68+
uses: ./.github/workflows/build-image-template.yml
69+
with:
70+
image-name: PiCompose_2MicHat-v2_LVA-Peripheral
71+
stage-list: stage0 stage1 stage2 ./01-stage-picompose ./02-stage-audiodriver-2michat-v2 ./03-stage-lva-2mic-peripheral ./04-stage-finish
72+
compression: xz
73+
compression-level: 6
74+
custom-hostname: picompose
75+
enable-rpi-imager-snippet: true
76+
6377
# RESPEAKER-LITE
6478
build-respeaker_lite:
6579
uses: ./.github/workflows/build-image-template.yml
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
#!/bin/bash -e
2+
3+
# Stage that drops the LVA + ReSpeaker 2-Mic peripheral compose project into
4+
# the rootfs at /compose/lva-peripheral/. Both images are built from cloned
5+
# source on first boot, so the LVA peripheral API and the ReSpeaker
6+
# controller line up against the same unmerged branch:
7+
#
8+
# genericJE/linux-voice-assistant : feat/peripheral-led-light-entity
9+
#
10+
# That branch is based on the in-flight upstream PR
11+
# omaramin-2000:leds-and-buttons-events (OHF-Voice/linux-voice-assistant#266)
12+
# plus the Light entity / Rainbow effect / peripheral-startup-wait commits.
13+
14+
LVA_REPO_URL="${LVA_REPO_URL:-https://github.com/genericJE/linux-voice-assistant.git}"
15+
LVA_BRANCH="${LVA_BRANCH:-feat/peripheral-led-light-entity}"
16+
17+
SRC="/tmp/lva-src-clone-$$"
18+
DEST="${ROOTFS_DIR}/compose/lva-peripheral"
19+
20+
rm -rf "$SRC"
21+
echo "Cloning $LVA_REPO_URL ($LVA_BRANCH)..."
22+
git clone --depth 1 --branch "$LVA_BRANCH" "$LVA_REPO_URL" "$SRC"
23+
24+
mkdir -p "$DEST/lva-src"
25+
26+
# Copy LVA source minus history/tests/docs/examples to keep the image small.
27+
# The peripheral example lives under examples/ — we pull it out separately
28+
# below, so excluding examples/ from the LVA build context is safe.
29+
(cd "$SRC" && tar \
30+
--exclude='.git' \
31+
--exclude='tests' \
32+
--exclude='docs' \
33+
--exclude='examples' \
34+
--exclude='*.egg-info' \
35+
--exclude='.venv' \
36+
--exclude='uv.lock' \
37+
-cf - .) | (cd "$DEST/lva-src" && tar -xf -)
38+
39+
# Peripheral controller (Dockerfile + compose.yml + respeaker_2mic_hat.py)
40+
# straight from the branch.
41+
mkdir -p "$DEST/peripheral"
42+
cp -r "$SRC/examples/ReSpeaker 2mic HAT/." "$DEST/peripheral/"
43+
44+
# Pin the LVA commit baked into this image so the user can confirm which
45+
# build they have on the running Pi.
46+
LVA_HASH="$(cd "$SRC" && git rev-parse HEAD)"
47+
echo "$LVA_HASH" > "$DEST/lva-src/version_githash.txt"
48+
echo "$LVA_HASH" > "$DEST/LVA_COMMIT"
49+
50+
# Compose project files we ship from this stage.
51+
install -v -m 644 files/lva-peripheral/docker-compose.yml "$DEST/docker-compose.yml"
52+
install -v -m 644 files/lva-peripheral/picompose.conf "$DEST/picompose.conf"
53+
install -v -m 644 files/lva-peripheral/.env "$DEST/.env"
54+
install -v -m 644 files/lva-peripheral/Dockerfile.lva "$DEST/lva-src/Dockerfile.local"
55+
56+
# Helper that rewrites /dev/gpiochip0 → /dev/gpiochip4 on Pi 5 (where the
57+
# 40-pin header sits on gpiochip4). Runs once before picompose deploys.
58+
install -v -D -m 755 files/lva-peripheral-gpiochip-fix.sh \
59+
"${ROOTFS_DIR}/usr/local/sbin/lva-peripheral-gpiochip-fix.sh"
60+
install -v -D -m 644 files/lva-peripheral-gpiochip-fix.service \
61+
"${ROOTFS_DIR}/etc/systemd/system/lva-peripheral-gpiochip-fix.service"
62+
63+
# Group membership + SPI overlay + enable the gpiochip-fix unit inside the chroot.
64+
on_chroot << 'EOF'
65+
getent group spi >/dev/null && usermod -aG spi pi || true
66+
getent group gpio >/dev/null && usermod -aG gpio pi || true
67+
68+
CONFIG=/boot/config.txt
69+
[ -f /boot/firmware/config.txt ] && CONFIG=/boot/firmware/config.txt
70+
[ -f /boot/firmware/usercfg.txt ] && CONFIG=/boot/firmware/usercfg.txt
71+
72+
sed -i -e 's:#dtparam=spi=on:dtparam=spi=on:g' "$CONFIG" || true
73+
grep -q "^dtparam=spi=on$" "$CONFIG" || echo "dtparam=spi=on" >> "$CONFIG"
74+
75+
systemctl daemon-reload
76+
systemctl enable lva-peripheral-gpiochip-fix.service
77+
EOF
78+
79+
rm -rf "$SRC"
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
[Unit]
2+
Description=Pick the correct /dev/gpiochip for the LVA peripheral compose project
3+
ConditionPathExists=/compose/lva-peripheral/docker-compose.yml
4+
After=local-fs.target
5+
Before=picompose.service
6+
7+
[Service]
8+
Type=oneshot
9+
ExecStart=/usr/local/sbin/lva-peripheral-gpiochip-fix.sh
10+
RemainAfterExit=yes
11+
12+
[Install]
13+
WantedBy=multi-user.target
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
#!/bin/bash
2+
set -e
3+
4+
# The compose file ships with /dev/gpiochip0 (Pi 3 / 4 / Zero 2 W). On Pi 5
5+
# the 40-pin GPIO header lives on /dev/gpiochip4 — mapping a non-existent
6+
# device through Docker fails the container start. Rewrite the compose
7+
# device line on the first boot we see a Pi 5, then drop a marker so we
8+
# never rewrite again (even if the user later edits the compose by hand).
9+
10+
COMPOSE=/compose/lva-peripheral/docker-compose.yml
11+
MARKER=/compose/lva-peripheral/.gpiochip-tuned
12+
13+
[ -f "$COMPOSE" ] || exit 0
14+
[ -f "$MARKER" ] && exit 0
15+
16+
MODEL="$(tr -d '\0' </proc/device-tree/model 2>/dev/null || true)"
17+
18+
case "$MODEL" in
19+
*"Raspberry Pi 5"*)
20+
echo "[lva-peripheral-gpiochip-fix] $MODEL — switching peripheral to /dev/gpiochip4"
21+
sed -i 's:/dev/gpiochip0:/dev/gpiochip4:g' "$COMPOSE"
22+
;;
23+
*)
24+
echo "[lva-peripheral-gpiochip-fix] $MODEL — keeping /dev/gpiochip0"
25+
;;
26+
esac
27+
28+
touch "$MARKER"
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
# LVA env vars. Override values by editing this file on the boot partition;
2+
# the compose project re-reads it on every `docker compose up`.
3+
4+
LVA_USER_ID="1000"
5+
LVA_USER_GROUP="1000"
6+
7+
LVA_XDG_RUNTIME_DIR="/run/user/1000"
8+
LVA_PULSE_SERVER="/run/user/1000/pulse/native"
9+
LVA_PULSE_COOKIE="/run/user/1000/pulse/cookie"
10+
11+
# Uncomment to pin specific ALSA/PulseAudio devices instead of letting LVA
12+
# fall back to the system default. Useful if multiple HATs are present.
13+
# AUDIO_INPUT_DEVICE="default"
14+
# AUDIO_OUTPUT_DEVICE="default"
15+
16+
# Friendly name for HA. Defaults to the hostname when empty.
17+
# CLIENT_NAME="respeaker-2mic-test"
18+
19+
# Set to 1 to enable verbose LVA debug logging.
20+
# ENABLE_DEBUG="1"
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
FROM python:3.12-slim-trixie
2+
3+
ENV LANG=C.UTF-8
4+
ENV DEBIAN_FRONTEND=noninteractive
5+
ENV PYTHONUNBUFFERED=1
6+
7+
LABEL \
8+
org.opencontainers.image.title="Linux-Voice-Assistant (peripheral-led-light-entity)" \
9+
org.opencontainers.image.description="LVA built from genericJE/linux-voice-assistant feat/peripheral-led-light-entity for the ReSpeaker 2-Mic HAT smoke test" \
10+
org.opencontainers.image.source="https://github.com/genericJE/linux-voice-assistant/tree/feat/peripheral-led-light-entity" \
11+
org.opencontainers.image.licenses="Apache-2.0"
12+
13+
# Mirrors upstream's runtime deps. avahi for zeroconf (HA discovery),
14+
# pulseaudio/pipewire/alsa for audio I/O, libmpv for python-mpv playback,
15+
# build-essential because pymicro-features ships sdist only.
16+
RUN apt-get update && \
17+
apt-get install --yes --no-install-recommends \
18+
avahi-utils \
19+
pulseaudio-utils \
20+
alsa-utils \
21+
pipewire-bin \
22+
pipewire-alsa \
23+
pipewire-pulse \
24+
build-essential \
25+
libmpv-dev \
26+
libasound2-plugins \
27+
ca-certificates \
28+
iproute2 \
29+
procps && \
30+
apt-get clean && \
31+
rm -rf /var/lib/apt/lists/*
32+
33+
WORKDIR /app
34+
35+
# Same layout as upstream's Dockerfile but with the wakewords/ COPY path
36+
# corrected (upstream's `COPY ../wakewords/` escapes the build context and
37+
# only works when CI sets up a non-default context).
38+
COPY script/ ./script/
39+
COPY pyproject.toml ./
40+
COPY setup.cfg ./
41+
COPY sounds/ ./sounds/
42+
COPY wakewords/ ./wakewords/
43+
COPY linux_voice_assistant/ ./linux_voice_assistant/
44+
COPY docker-entrypoint.sh ./
45+
COPY version.txt ./
46+
COPY version_githash.txt ./
47+
48+
RUN chmod +x docker-entrypoint.sh && \
49+
./script/setup
50+
51+
EXPOSE 6053 6055
52+
53+
ENTRYPOINT ["./docker-entrypoint.sh"]
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
---
2+
# Linux Voice Assistant + ReSpeaker 2-Mic HAT peripheral controller.
3+
#
4+
# Two services in one compose project so service ordering is local and HA
5+
# only enumerates the satellite's entities once both LVA and the peripheral
6+
# Light entity are registered. LVA is started with --peripheral-startup-wait 8
7+
# (see the `command:` override below) which gives the peripheral container
8+
# room to connect to ws://localhost:6055 before LVA accepts HA connections
9+
# on 6053.
10+
#
11+
# Both images are built locally from cloned source on first boot:
12+
# ./lva-src — genericJE/linux-voice-assistant feat/peripheral-led-light-entity
13+
# (Dockerfile.local, not the upstream Dockerfile)
14+
# ./peripheral — the same branch's examples/ReSpeaker 2mic HAT/ directory
15+
#
16+
# GPIO device note: /dev/gpiochip0 is mapped for Pi 3 / 4 / Zero 2 W.
17+
# On Pi 5 the 40-pin header sits on /dev/gpiochip4 instead — the
18+
# `lva-peripheral-gpiochip-fix.service` (ordered Before=picompose.service)
19+
# rewrites this file once on first boot if it detects a Pi 5.
20+
21+
services:
22+
fix-permissions:
23+
build:
24+
context: ./lva-src
25+
dockerfile: Dockerfile.local
26+
image: lva-peripheral-lva:local
27+
entrypoint: []
28+
command: "chown -R ${LVA_USER_ID:-1000}:${LVA_USER_GROUP:-1000} /app/local /app/configuration /app/wakewords/custom /app/sounds/custom"
29+
environment:
30+
- XDG_RUNTIME_DIR=${LVA_XDG_RUNTIME_DIR:-/run/user/1000}
31+
- PULSE_SERVER=${LVA_PULSE_SERVER:-/run/user/1000/pulse/native}
32+
- PULSE_COOKIE=${LVA_PULSE_COOKIE:-/run/user/1000/pulse/cookie}
33+
env_file: .env
34+
group_add:
35+
- audio
36+
volumes:
37+
- wakeword_data:/app/local
38+
- wakeword_custom:/app/wakewords/custom
39+
- configuration:/app/configuration
40+
- sounds_custom:/app/sounds/custom
41+
restart: "no"
42+
43+
linux-voice-assistant:
44+
container_name: linux-voice-assistant
45+
build:
46+
context: ./lva-src
47+
dockerfile: Dockerfile.local
48+
image: lva-peripheral-lva:local
49+
restart: unless-stopped
50+
network_mode: host
51+
user: "${LVA_USER_ID:-1000}:${LVA_USER_GROUP:-1000}"
52+
environment:
53+
- XDG_RUNTIME_DIR=${LVA_XDG_RUNTIME_DIR:-/run/user/1000}
54+
- PULSE_SERVER=${LVA_PULSE_SERVER:-/run/user/1000/pulse/native}
55+
- PULSE_COOKIE=${LVA_PULSE_COOKIE:-/run/user/1000/pulse/cookie}
56+
env_file: .env
57+
group_add:
58+
- audio
59+
cap_add:
60+
- SYS_NICE
61+
volumes:
62+
- wakeword_data:/app/local
63+
- wakeword_custom:/app/wakewords/custom
64+
- configuration:/app/configuration
65+
- sounds_custom:/app/sounds/custom
66+
- /etc/localtime:/etc/localtime:ro
67+
- /etc/timezone:/etc/timezone:ro
68+
- ${LVA_XDG_RUNTIME_DIR:-/run/user/1000}:${LVA_XDG_RUNTIME_DIR:-/run/user/1000}
69+
command:
70+
- "--peripheral-startup-wait"
71+
- "8"
72+
depends_on:
73+
- fix-permissions
74+
healthcheck:
75+
test: ["CMD", "pgrep", "-f", "linux_voice_assistant"]
76+
interval: 30s
77+
timeout: 5s
78+
retries: 3
79+
start_period: 90s
80+
81+
respeaker-peripheral:
82+
container_name: respeaker-peripheral
83+
build:
84+
context: ./peripheral
85+
image: lva-peripheral-respeaker:local
86+
restart: unless-stopped
87+
network_mode: host
88+
user: "${LVA_USER_ID:-1000}:${LVA_USER_GROUP:-1000}"
89+
devices:
90+
- /dev/spidev0.0:/dev/spidev0.0
91+
- /dev/gpiochip0:/dev/gpiochip0
92+
group_add:
93+
- gpio
94+
- spi
95+
environment:
96+
- PYTHONUNBUFFERED=1
97+
command:
98+
- "--host"
99+
- "localhost"
100+
- "--port"
101+
- "6055"
102+
depends_on:
103+
- linux-voice-assistant
104+
healthcheck:
105+
test: ["CMD", "pgrep", "-f", "respeaker_2mic_hat.py"]
106+
interval: 30s
107+
timeout: 5s
108+
retries: 3
109+
start_period: 15s
110+
logging:
111+
driver: "json-file"
112+
options:
113+
max-size: "10m"
114+
max-file: "3"
115+
116+
volumes:
117+
wakeword_data:
118+
wakeword_custom:
119+
configuration:
120+
sounds_custom:
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
# PiCompose deploy config for the LVA + ReSpeaker 2-Mic peripheral project.
2+
#
3+
# This stage is an unmerged-branch smoke test, not a production deploy.
4+
# We build locally on first boot from baked-in source (no pull) and stay
5+
# off the cron schedule to avoid trying to re-pull images we don't have
6+
# in any registry.
7+
8+
DISABLED=false
9+
10+
# Compose every boot. The build cache makes subsequent boots fast; this
11+
# just guarantees the containers are running after a power cycle.
12+
BOOT_ENABLED=true
13+
BOOT_IMAGE_PULL=false
14+
15+
# No CRON for this smoke-test project — there's no upstream image to pull
16+
# and the LVA branch source is baked into the image.
17+
CRON_ENABLED=false
18+
CRON_SCHEDULE="0 4 * * *"
19+
CRON_IMAGE_PULL=false

0 commit comments

Comments
 (0)