diff --git a/.gitea/workflows/build-sd-image.yml b/.gitea/workflows/build-sd-image.yml new file mode 100644 index 0000000..dfbbb35 --- /dev/null +++ b/.gitea/workflows/build-sd-image.yml @@ -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" diff --git a/.gitignore b/.gitignore index 19b5807..e8ee1bb 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,9 @@ node_modules/ dist/ build/ .next/ +*.tsbuildinfo +vite.config.js +vite.config.d.ts # Environment .env @@ -47,4 +50,8 @@ apps/robot-client-pi/ pi-snapshot/ -.pio/ \ No newline at end of file +.pio/ + +# Pi-gen +tools/pi-gen-tipote/output/ +tools/pi-gen-tipote/pi-gen/ \ No newline at end of file diff --git a/apps/robot-client/package.json b/apps/robot-client/package.json index fb9c037..37ae60b 100644 --- a/apps/robot-client/package.json +++ b/apps/robot-client/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 20fa950..5bb65ee 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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 diff --git a/tools/pi-gen-tipote/Dockerfile b/tools/pi-gen-tipote/Dockerfile new file mode 100644 index 0000000..3100f04 --- /dev/null +++ b/tools/pi-gen-tipote/Dockerfile @@ -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/"] diff --git a/tools/pi-gen-tipote/build.sh b/tools/pi-gen-tipote/build.sh index 3993273..7b20de1 100755 --- a/tools/pi-gen-tipote/build.sh +++ b/tools/pi-gen-tipote/build.sh @@ -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 diff --git a/tools/pi-gen-tipote/config b/tools/pi-gen-tipote/config index 5318cdd..4c5f8ba 100644 --- a/tools/pi-gen-tipote/config +++ b/tools/pi-gen-tipote/config @@ -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" diff --git a/tools/pi-gen-tipote/deploy.sh b/tools/pi-gen-tipote/deploy.sh new file mode 100755 index 0000000..738f2f5 --- /dev/null +++ b/tools/pi-gen-tipote/deploy.sh @@ -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 +# ./deploy.sh 192.168.1.124 +# ./deploy.sh ti-pote.local +# ────────────────────────────────────────────────────── + +if [ $# -lt 1 ]; then + echo "Usage: $0 [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 "════════════════════════════════════════"