feat: add pi-gen SD image build with Gitea CI Add Docker-based pi-gen build for producing flashable Raspberry Pi OS images with Ti-Pote pre-installed. Includes Gitea Actions workflow that builds on the Act runner using docker cp (DooD-compatible), QEMU ARM emulation, and automatic Gitea release upload on tags. Also adds deploy.sh for dev SSH deployment, fixes sd-notify version, and updates .gitignore for build artifacts.

This commit is contained in:
ordinarthur 2026-04-13 22:06:00 +02:00
parent 6760759cb6
commit 272d225ca8
8 changed files with 408 additions and 54 deletions

View File

@ -0,0 +1,136 @@
name: Build Ti-Pote SD Image
on:
workflow_dispatch:
push:
tags:
- 'v*'
jobs:
build-image:
name: Build Raspberry Pi SD Image
runs-on: ubuntu-latest
timeout-minutes: 180
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 22
- name: Install pnpm
run: npm install -g pnpm@latest
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build robot-client
run: pnpm --filter @ti-pote/robot-client build
- name: Prepare pi-gen stage files
run: |
STAGE_FILES="tools/pi-gen-tipote/stage-tipote/01-install-tipote/files"
rm -rf "$STAGE_FILES"
mkdir -p "$STAGE_FILES"
cp -r apps/robot-client/dist "$STAGE_FILES/dist"
cp apps/robot-client/package.json "$STAGE_FILES/package.json"
cp apps/robot-client/deploy/tipote.service "$STAGE_FILES/tipote.service"
cp apps/robot-client/deploy/journald-tipote.conf "$STAGE_FILES/journald-tipote.conf"
- name: Register QEMU for ARM emulation
run: docker run --rm --privileged multiarch/qemu-user-static --reset -p yes
- name: Build pi-gen Docker image
run: docker build -t tipote-builder tools/pi-gen-tipote/
- name: Build SD image in Docker
run: |
# Run pi-gen build inside a privileged container
# Use docker cp (not volume mounts) because of DooD path mapping
docker run --privileged --name tipote-build tipote-builder
# Extract the output image
mkdir -p output
docker cp tipote-build:/output/. output/ 2>/dev/null || true
# Fallback: check pi-gen deploy dir directly
if [ -z "$(ls output/*.img* 2>/dev/null)" ]; then
docker cp tipote-build:/build/pi-gen/deploy/. output/ 2>/dev/null || true
fi
docker rm tipote-build
echo "=== Build output ==="
ls -lh output/ || echo "No output found"
- name: Determine version
id: version
run: |
if [[ "$GITHUB_REF" == refs/tags/v* ]]; then
VERSION="${GITHUB_REF#refs/tags/v}"
else
VERSION="dev-$(echo $GITHUB_SHA | cut -c1-7)"
fi
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
- name: Prepare final image
run: |
VERSION="${{ steps.version.outputs.version }}"
IMG_FILE=$(find output -name "*.img.xz" -o -name "*.img" 2>/dev/null | head -1)
if [ -z "$IMG_FILE" ]; then
echo "ERROR: No image found in output/"
ls -laR output/ || true
exit 1
fi
BASENAME="tipote-${VERSION}.img"
if [[ "$IMG_FILE" == *.img.xz ]]; then
mv "$IMG_FILE" "output/${BASENAME}.xz"
else
mv "$IMG_FILE" "output/$BASENAME"
xz -T0 -6 "output/$BASENAME"
fi
ls -lh output/
echo "IMAGE_FILE=${BASENAME}.xz" >> "$GITHUB_ENV"
- name: Upload to Gitea release
if: startsWith(github.ref, 'refs/tags/v')
run: |
VERSION="${{ steps.version.outputs.version }}"
GITEA_URL="${{ github.server_url }}"
REPO="${{ github.repository }}"
# Create release
curl -sf -X POST "${GITEA_URL}/api/v1/repos/${REPO}/releases" \
-H "Authorization: token ${{ secrets.REGISTRY_PASSWORD }}" \
-H "Content-Type: application/json" \
-d "{\"tag_name\": \"v${VERSION}\", \"name\": \"Ti-Pote v${VERSION}\", \"body\": \"SD image for Raspberry Pi Zero 2W\"}" \
-o /tmp/release.json
RELEASE_ID=$(jq -r '.id' /tmp/release.json)
echo "Release ID: $RELEASE_ID"
# Upload image as attachment
curl -sf -X POST "${GITEA_URL}/api/v1/repos/${REPO}/releases/${RELEASE_ID}/assets?name=${IMAGE_FILE}" \
-H "Authorization: token ${{ secrets.REGISTRY_PASSWORD }}" \
-F "attachment=@output/${IMAGE_FILE}" \
-o /tmp/asset.json
echo "Download URL: $(jq -r '.browser_download_url' /tmp/asset.json)"
- name: Summary (dev builds)
if: ${{ !startsWith(github.ref, 'refs/tags/v') }}
run: |
echo "=== SD Image built successfully ==="
echo "File: output/${IMAGE_FILE}"
ls -lh output/
echo ""
echo "To create a release, push a tag: git tag v0.1.0 && git push origin v0.1.0"

7
.gitignore vendored
View File

@ -5,6 +5,9 @@ node_modules/
dist/
build/
.next/
*.tsbuildinfo
vite.config.js
vite.config.d.ts
# Environment
.env
@ -48,3 +51,7 @@ pi-snapshot/
.pio/
# Pi-gen
tools/pi-gen-tipote/output/
tools/pi-gen-tipote/pi-gen/

View File

@ -27,7 +27,7 @@
"pino": "^9.6.0",
"pino-pretty": "^13.0.0",
"serialport": "^12.0.0",
"sd-notify": "^3.0.2"
"sd-notify": "^2.8.0"
},
"devDependencies": {
"typescript": "^5.8.3",

46
pnpm-lock.yaml generated
View File

@ -28,6 +28,9 @@ importers:
'@nestjs/core':
specifier: ^11.1.17
version: 11.1.17(@nestjs/common@11.1.17(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.17)(@nestjs/websockets@11.1.17)(reflect-metadata@0.2.2)(rxjs@7.8.2)
'@nestjs/event-emitter':
specifier: ^3.0.0
version: 3.0.1(@nestjs/common@11.1.17(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.17)
'@nestjs/jwt':
specifier: ^11.0.2
version: 11.0.2(@nestjs/common@11.1.17(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))
@ -210,6 +213,9 @@ importers:
pino-pretty:
specifier: ^13.0.0
version: 13.1.3
sd-notify:
specifier: ^2.8.0
version: 2.8.0
serialport:
specifier: ^12.0.0
version: 12.0.0
@ -239,6 +245,8 @@ importers:
specifier: ^3.2.1
version: 3.2.4(@types/node@22.19.15)(lightningcss@1.32.0)(terser@5.46.1)
apps/robot-client-pi: {}
apps/simulator:
dependencies:
react:
@ -1260,6 +1268,12 @@ packages:
'@nestjs/websockets':
optional: true
'@nestjs/event-emitter@3.0.1':
resolution: {integrity: sha512-0Ln/x+7xkU6AJFOcQI9tIhUMXVF7D5itiaQGOyJbXtlAfAIt8gzDdJm+Im7cFzKoWkiW5nCXCPh6GSvdQd/3Dw==}
peerDependencies:
'@nestjs/common': ^10.0.0 || ^11.0.0
'@nestjs/core': ^10.0.0 || ^11.0.0
'@nestjs/jwt@11.0.2':
resolution: {integrity: sha512-rK8aE/3/Ma45gAWfCksAXUNbOoSOUudU0Kn3rT39htPF7wsYXtKfjALKeKKJbFrIWbLjsbqfXX5bIJNvgBugGA==}
peerDependencies:
@ -2479,6 +2493,9 @@ packages:
resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==}
engines: {node: '>=8'}
bindings@1.5.0:
resolution: {integrity: sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==}
bl@4.1.0:
resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==}
@ -3055,6 +3072,9 @@ packages:
resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==}
engines: {node: '>=6'}
eventemitter2@6.4.9:
resolution: {integrity: sha512-JEPTiaOt9f04oa6NOkc4aH+nVp5I3wEjpHbIPqfgCdD5v5bUzy7xQqwcVO2aDQgOWhI28da57HksMrzK9HlRxg==}
events@3.3.0:
resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==}
engines: {node: '>=0.8.x'}
@ -3157,6 +3177,9 @@ packages:
resolution: {integrity: sha512-DLkUvGwep3poOV2wpzbHCOnSKGk1LzyXTv+aHFgN2VFl96wnp8YA9YjO2qPzg5PuL8q/SW9Pdi6WTkYOIh995w==}
engines: {node: '>=20'}
file-uri-to-path@1.0.0:
resolution: {integrity: sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==}
fill-range@7.1.1:
resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==}
engines: {node: '>=8'}
@ -4650,6 +4673,11 @@ packages:
resolution: {integrity: sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==}
engines: {node: '>= 10.13.0'}
sd-notify@2.8.0:
resolution: {integrity: sha512-e+D1v0Y6UzmqXcPlaTkHk1QMdqk36mF/jIYv5gwry/N2Tb8/UNnpfG6ktGLpeBOR6TCC5hPKgqA+0hTl9sm2tA==}
engines: {node: '>=8.0.0'}
os: [linux, darwin, win32]
section-matter@1.0.0:
resolution: {integrity: sha512-vfD3pmTzGpufjScBh50YHKzEu2lxBWhVEHsNGoEXmCmn2hKGfeNLYMzCJpe8cD7gqX7TJluOVpBkAequ6dgMmA==}
engines: {node: '>=4'}
@ -6598,6 +6626,12 @@ snapshots:
'@nestjs/platform-express': 11.1.17(@nestjs/common@11.1.17(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.17)
'@nestjs/websockets': 11.1.17(@nestjs/common@11.1.17(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.17)(@nestjs/platform-socket.io@11.1.17)(reflect-metadata@0.2.2)(rxjs@7.8.2)
'@nestjs/event-emitter@3.0.1(@nestjs/common@11.1.17(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.17)':
dependencies:
'@nestjs/common': 11.1.17(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)
'@nestjs/core': 11.1.17(@nestjs/common@11.1.17(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.17)(@nestjs/websockets@11.1.17)(reflect-metadata@0.2.2)(rxjs@7.8.2)
eventemitter2: 6.4.9
'@nestjs/jwt@11.0.2(@nestjs/common@11.1.17(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))':
dependencies:
'@nestjs/common': 11.1.17(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)
@ -7700,6 +7734,10 @@ snapshots:
binary-extensions@2.3.0: {}
bindings@1.5.0:
dependencies:
file-uri-to-path: 1.0.0
bl@4.1.0:
dependencies:
buffer: 5.7.1
@ -8307,6 +8345,8 @@ snapshots:
event-target-shim@5.0.1: {}
eventemitter2@6.4.9: {}
events@3.3.0: {}
eventsource-parser@3.0.6: {}
@ -8484,6 +8524,8 @@ snapshots:
transitivePeerDependencies:
- supports-color
file-uri-to-path@1.0.0: {}
fill-range@7.1.1:
dependencies:
to-regex-range: 5.0.1
@ -10077,6 +10119,10 @@ snapshots:
ajv-formats: 2.1.1(ajv@8.18.0)
ajv-keywords: 5.1.0(ajv@8.18.0)
sd-notify@2.8.0:
dependencies:
bindings: 1.5.0
section-matter@1.0.0:
dependencies:
extend-shallow: 2.0.1

View File

@ -0,0 +1,33 @@
FROM ubuntu:24.04
ENV DEBIAN_FRONTEND=noninteractive
# Install pi-gen dependencies
RUN apt-get update && apt-get install -y \
git coreutils quilt parted \
debootstrap zerofree zip dosfstools \
libarchive-tools libcap2-bin grep rsync xz-utils \
file binfmt-support qemu-user-static \
pigz arch-test \
curl bc xxd kmod \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /build
# Clone pi-gen
RUN git clone --depth 1 https://github.com/RPi-Distro/pi-gen.git /build/pi-gen
# Copy config and stage
COPY config /build/pi-gen/config
COPY stage-tipote/ /build/pi-gen/stage-tipote/
# Skip stage2 image export (we export from stage-tipote)
RUN touch /build/pi-gen/stage2/SKIP_IMAGES
# Patch pi-gen for Docker Desktop compatibility:
# 1. Remove setarch linux32 calls (fails on Docker Desktop)
# 2. Remove qemu-user-binfmt from dependency check (conflicts with qemu-user-static)
RUN find /build/pi-gen -name "*.sh" -exec sed -i 's/setarch linux32 //g' {} + 2>/dev/null || true \
&& sed -i '/qemu-user-binfmt/d' /build/pi-gen/depends
CMD ["bash", "-c", "cd /build/pi-gen && ./build.sh && mkdir -p /output && cp deploy/*.img.xz /output/ 2>/dev/null || cp deploy/*.img /output/ 2>/dev/null && ls -lh /output/"]

View File

@ -3,14 +3,15 @@ set -euo pipefail
# ──────────────────────────────────────────────────────
# Ti-Pote SD Image Builder
# Builds a flashable .img using pi-gen (Docker mode)
# Builds a flashable .img using pi-gen in a Docker container.
# Works on macOS (Docker Desktop) and Linux.
# ──────────────────────────────────────────────────────
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
ROBOT_CLIENT_DIR="$REPO_ROOT/apps/robot-client"
STAGE_FILES="$SCRIPT_DIR/stage-tipote/01-install-tipote/files"
PI_GEN_DIR="$SCRIPT_DIR/pi-gen"
OUTPUT_DIR="$SCRIPT_DIR/output"
echo "╔══════════════════════════════════════╗"
echo "║ Ti-Pote SD Image Builder ║"
@ -19,73 +20,59 @@ echo "╚═══════════════════════
# ── Step 1: Build robot-client ──
echo ""
echo "▸ Building robot-client..."
echo "▸ [1/3] Building robot-client..."
cd "$REPO_ROOT"
pnpm --filter @ti-pote/robot-client build
# ── Step 2: Prepare files for pi-gen stage ──
# ── Step 2: Prepare stage files ──
echo "▸ Preparing stage files..."
echo "▸ [2/3] Preparing stage files..."
rm -rf "$STAGE_FILES"
mkdir -p "$STAGE_FILES"
# Copy built dist
cp -r "$ROBOT_CLIENT_DIR/dist" "$STAGE_FILES/dist"
cp -r "$ROBOT_CLIENT_DIR/dist" "$STAGE_FILES/dist"
cp "$ROBOT_CLIENT_DIR/package.json" "$STAGE_FILES/package.json"
cp "$ROBOT_CLIENT_DIR/deploy/tipote.service" "$STAGE_FILES/tipote.service"
cp "$ROBOT_CLIENT_DIR/deploy/journald-tipote.conf" "$STAGE_FILES/journald-tipote.conf"
# Copy package.json (for npm install --omit=dev in chroot)
cp "$ROBOT_CLIENT_DIR/package.json" "$STAGE_FILES/package.json"
# ── Step 3: Build image in Docker ──
# Copy deploy configs
cp "$ROBOT_CLIENT_DIR/deploy/tipote.service" "$STAGE_FILES/tipote.service"
cp "$ROBOT_CLIENT_DIR/deploy/journald-tipote.conf" "$STAGE_FILES/journald-tipote.conf"
echo "▸ [3/3] Building SD image in Docker (this takes 30-60 min)..."
cd "$SCRIPT_DIR"
# ── Step 3: Clone or update pi-gen ──
# Register QEMU ARM interpreters in Docker Desktop's VM
echo " → Registering ARM emulation..."
docker run --rm --privileged multiarch/qemu-user-static --reset -p yes 2>/dev/null || true
if [ ! -d "$PI_GEN_DIR" ]; then
echo "▸ Cloning pi-gen..."
git clone --depth 1 https://github.com/RPi-Distro/pi-gen.git "$PI_GEN_DIR"
else
echo "▸ pi-gen already cloned"
fi
# Build the builder image (force amd64 — pi-gen cross-compiles to ARM from x86)
docker build --platform linux/amd64 -t tipote-builder .
# ── Step 4: Configure pi-gen ──
echo "▸ Configuring pi-gen..."
cp "$SCRIPT_DIR/config" "$PI_GEN_DIR/config"
# Link our custom stage
ln -sfn "$SCRIPT_DIR/stage-tipote" "$PI_GEN_DIR/stage-tipote"
# Skip stages 3-5 (desktop, apps — we only want Lite + our stage)
touch "$PI_GEN_DIR/stage3/SKIP" "$PI_GEN_DIR/stage4/SKIP" "$PI_GEN_DIR/stage5/SKIP"
touch "$PI_GEN_DIR/stage3/SKIP_IMAGES" "$PI_GEN_DIR/stage4/SKIP_IMAGES" "$PI_GEN_DIR/stage5/SKIP_IMAGES"
# Don't export images from stage2 (we export from stage-tipote)
touch "$PI_GEN_DIR/stage2/SKIP_IMAGES"
# ── Step 5: Build image ──
echo "▸ Building image (this will take a while)..."
cd "$PI_GEN_DIR"
./build-docker.sh
# ── Step 6: Copy result ──
OUTPUT_DIR="$SCRIPT_DIR/output"
# Run the build — mount output dir to retrieve the .img
mkdir -p "$OUTPUT_DIR"
docker run --rm --privileged \
--platform linux/amd64 \
-v /proc/sys/fs/binfmt_misc:/proc/sys/fs/binfmt_misc:rw \
-v "$OUTPUT_DIR:/output" \
tipote-builder
IMG_FILE=$(find "$PI_GEN_DIR/deploy" -name "*.img.xz" -type f | head -1)
# ── Done ──
IMG_FILE=$(find "$OUTPUT_DIR" -name "*.img*" -type f | head -1)
if [ -n "$IMG_FILE" ]; then
cp "$IMG_FILE" "$OUTPUT_DIR/"
BASENAME=$(basename "$IMG_FILE")
echo ""
echo "════════════════════════════════════════"
echo " Image ready: output/$BASENAME"
echo " Flash with: xzcat output/$BASENAME | sudo dd of=/dev/sdX bs=4M status=progress"
echo " Or use Balena Etcher"
SIZE=$(ls -lh "$IMG_FILE" | awk '{print $5}')
echo " Size: $SIZE"
echo ""
echo " Flash with:"
echo " xzcat output/$BASENAME | sudo dd of=/dev/sdX bs=4M status=progress"
echo " Or drag into Balena Etcher / Raspberry Pi Imager"
echo "════════════════════════════════════════"
else
echo "⚠ No image found in pi-gen/deploy/ — check build logs"
echo ""
echo "⚠ No image found — check Docker logs above"
exit 1
fi

View File

@ -9,7 +9,5 @@ KEYBOARD_LAYOUT="French"
TIMEZONE_DEFAULT=Europe/Paris
ENABLE_SSH=1
# Skip stages we don't need (desktop, X11, etc.)
SKIP_STAGE3=1
SKIP_STAGE4=1
SKIP_STAGE5=1
# Only run base stages + our custom stage (skip desktop/apps)
STAGE_LIST="stage0 stage1 stage2 stage-tipote"

147
tools/pi-gen-tipote/deploy.sh Executable file
View File

@ -0,0 +1,147 @@
#!/bin/bash
set -euo pipefail
# ──────────────────────────────────────────────────────
# Ti-Pote Remote Deployment Script
#
# Deploys Ti-Pote to a fresh Raspberry Pi OS Lite.
# Run from your dev machine (macOS/Linux).
#
# Prerequisites:
# 1. Flash Raspberry Pi OS Lite (64-bit, Debian trixie)
# using Raspberry Pi Imager
# 2. Enable SSH + set user "tipote" in Imager settings
# 3. Connect Pi to your local network (Ethernet or WiFi)
#
# Usage:
# ./deploy.sh <pi-ip-or-hostname>
# ./deploy.sh 192.168.1.124
# ./deploy.sh ti-pote.local
# ──────────────────────────────────────────────────────
if [ $# -lt 1 ]; then
echo "Usage: $0 <pi-host> [pi-user]"
echo " e.g. $0 192.168.1.124"
echo " e.g. $0 ti-pote.local tipote"
exit 1
fi
PI_HOST="$1"
PI_USER="${2:-tipote}"
SSH="ssh ${PI_USER}@${PI_HOST}"
SCP="scp -r"
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
ROBOT_CLIENT_DIR="$REPO_ROOT/apps/robot-client"
echo "╔══════════════════════════════════════╗"
echo "║ Ti-Pote Remote Deployment ║"
echo "╠══════════════════════════════════════╣"
echo "║ Target: ${PI_USER}@${PI_HOST}"
echo "╚══════════════════════════════════════╝"
# ── Step 1: Build robot-client ──
echo ""
echo "▸ [1/7] Building robot-client..."
cd "$REPO_ROOT"
pnpm --filter @ti-pote/robot-client build
# ── Step 2: Test SSH connectivity ──
echo "▸ [2/7] Testing SSH connection..."
$SSH "echo 'SSH OK — $(uname -m)$(cat /etc/os-release | grep PRETTY_NAME | cut -d= -f2)'" || {
echo "✗ Cannot reach ${PI_USER}@${PI_HOST} via SSH"
exit 1
}
# ── Step 3: Install system dependencies ──
echo "▸ [3/7] Installing system dependencies..."
$SSH "sudo apt-get update -qq && sudo apt-get install -y -qq \
python3 python3-venv python3-pip \
portaudio19-dev libatlas-base-dev alsa-utils \
network-manager curl"
# Install Node.js 22 if not present
$SSH "node --version 2>/dev/null | grep -q 'v22' || {
echo 'Installing Node.js 22...'
curl -fsSL https://deb.nodesource.com/setup_22.x | sudo bash -
sudo apt-get install -y -qq nodejs
}"
# ── Step 4: Create tipote directory structure ──
echo "▸ [4/7] Setting up /opt/tipote..."
$SSH "sudo mkdir -p /opt/tipote && sudo chown ${PI_USER}:${PI_USER} /opt/tipote"
# ── Step 5: Deploy files ──
echo "▸ [5/7] Uploading files..."
# Sync dist/ and package.json
rsync -az --delete \
"$ROBOT_CLIENT_DIR/dist/" \
"${PI_USER}@${PI_HOST}:/opt/tipote/dist/"
$SCP "$ROBOT_CLIENT_DIR/package.json" "${PI_USER}@${PI_HOST}:/opt/tipote/package.json"
# Install production deps on Pi
echo "▸ [5/7] Installing production dependencies on Pi..."
$SSH "cd /opt/tipote && npm install --omit=dev 2>/dev/null || npm install --production"
# ── Step 6: Setup Python venv + openwakeword ──
echo "▸ [6/7] Setting up Python venv..."
$SSH "test -d /opt/tipote/.venv || {
python3 -m venv /opt/tipote/.venv
/opt/tipote/.venv/bin/pip install --upgrade pip
/opt/tipote/.venv/bin/pip install openwakeword
}"
# ── Step 7: Install systemd + configs ──
echo "▸ [7/7] Installing systemd service..."
# .env (only if not exists — don't overwrite user config)
$SSH "test -f /opt/tipote/.env || cat > /opt/tipote/.env << 'ENVEOF'
ROBOT_MODE=physical
CLOUD_URL=https://api.tipote.dev
AUDIO_BACKEND=esp32
TRIGGER_MODE=wakeword
HARDWARE_SERIAL_ENABLED=true
HARDWARE_SERIAL_PORT=/dev/serial0
HARDWARE_SERIAL_BAUD_RATE=921600
WAKEWORD_PYTHON_PATH=/opt/tipote/.venv/bin/python3
NODE_ENV=production
LOG_LEVEL=info
ENVEOF
chmod 600 /opt/tipote/.env"
# systemd service
$SCP "$ROBOT_CLIENT_DIR/deploy/tipote.service" "${PI_USER}@${PI_HOST}:/tmp/tipote.service"
$SSH "sudo mv /tmp/tipote.service /etc/systemd/system/tipote.service && sudo systemctl daemon-reload && sudo systemctl enable tipote"
# journald config
$SCP "$ROBOT_CLIENT_DIR/deploy/journald-tipote.conf" "${PI_USER}@${PI_HOST}:/tmp/journald-tipote.conf"
$SSH "sudo mkdir -p /etc/systemd/journald.conf.d && sudo mv /tmp/journald-tipote.conf /etc/systemd/journald.conf.d/tipote.conf"
# CLI symlink
$SSH "sudo ln -sf /opt/tipote/dist/cli.js /usr/local/bin/tipote && sudo chmod +x /opt/tipote/dist/cli.js"
# Permissions
$SSH "mkdir -p /opt/tipote/.tipote && chmod 700 /opt/tipote/.tipote"
# Add user to dialout + audio groups (for serial + audio)
$SSH "sudo usermod -aG dialout,audio ${PI_USER} 2>/dev/null || true"
echo ""
echo "════════════════════════════════════════"
echo " Deployment complete!"
echo ""
echo " Start: ssh ${PI_USER}@${PI_HOST} 'sudo systemctl start tipote'"
echo " Status: ssh ${PI_USER}@${PI_HOST} 'sudo systemctl status tipote'"
echo " Logs: ssh ${PI_USER}@${PI_HOST} 'journalctl -u tipote -f'"
echo " CLI: ssh ${PI_USER}@${PI_HOST} 'tipote doctor'"
echo "════════════════════════════════════════"