From 36f38d78dbec8480ce630eb3d89216b051523659 Mon Sep 17 00:00:00 2001 From: ordinarthur <@arthurbarre.js@gmail.com> Date: Mon, 13 Apr 2026 20:38:07 +0200 Subject: [PATCH] first version of the tipote software --- .gitignore | 4 + apps/robot-client/deploy/journald-tipote.conf | 6 + apps/robot-client/deploy/tipote.service | 37 ++ apps/robot-client/package.json | 8 +- apps/robot-client/src/cli.ts | 329 ++++++++++++++++++ apps/robot-client/src/main.ts | 1 + .../src/services/health.service.ts | 27 +- .../src/services/local-store.service.ts | 79 ++++- tools/pi-gen-tipote/.gitignore | 11 + tools/pi-gen-tipote/build.sh | 91 +++++ tools/pi-gen-tipote/config | 15 + .../stage-tipote/00-install-deps/00-packages | 9 + .../stage-tipote/00-install-deps/00-run.sh | 15 + .../stage-tipote/01-install-tipote/00-run.sh | 102 ++++++ tools/pi-gen-tipote/stage-tipote/prerun.sh | 3 + 15 files changed, 725 insertions(+), 12 deletions(-) create mode 100644 apps/robot-client/deploy/journald-tipote.conf create mode 100644 apps/robot-client/deploy/tipote.service create mode 100644 apps/robot-client/src/cli.ts create mode 100644 tools/pi-gen-tipote/.gitignore create mode 100755 tools/pi-gen-tipote/build.sh create mode 100644 tools/pi-gen-tipote/config create mode 100644 tools/pi-gen-tipote/stage-tipote/00-install-deps/00-packages create mode 100755 tools/pi-gen-tipote/stage-tipote/00-install-deps/00-run.sh create mode 100755 tools/pi-gen-tipote/stage-tipote/01-install-tipote/00-run.sh create mode 100755 tools/pi-gen-tipote/stage-tipote/prerun.sh diff --git a/.gitignore b/.gitignore index 8c93aa0..19b5807 100644 --- a/.gitignore +++ b/.gitignore @@ -42,5 +42,9 @@ apps/robot-desktop/src-tauri/target/ apps/robot-desktop/dist apps/robot-client/node_modules +apps/robot-client-pi/ + +pi-snapshot/ + .pio/ \ No newline at end of file diff --git a/apps/robot-client/deploy/journald-tipote.conf b/apps/robot-client/deploy/journald-tipote.conf new file mode 100644 index 0000000..dca620a --- /dev/null +++ b/apps/robot-client/deploy/journald-tipote.conf @@ -0,0 +1,6 @@ +# /etc/systemd/journald.conf.d/tipote.conf +# Limit journal size on Pi Zero 2W (32GB SD card) +[Journal] +SystemMaxUse=50M +SystemMaxFileSize=10M +MaxRetentionSec=7day diff --git a/apps/robot-client/deploy/tipote.service b/apps/robot-client/deploy/tipote.service new file mode 100644 index 0000000..e120fd9 --- /dev/null +++ b/apps/robot-client/deploy/tipote.service @@ -0,0 +1,37 @@ +[Unit] +Description=Ti-Pote Robot Client +After=network.target NetworkManager.service +Wants=NetworkManager.service + +[Service] +Type=notify +ExecStart=/usr/bin/node /opt/tipote/dist/main.js +WorkingDirectory=/opt/tipote +EnvironmentFile=/opt/tipote/.env +User=tipote +Group=tipote + +# Restart policy: max 5 restarts per 5 minutes +Restart=on-failure +RestartSec=5 +StartLimitIntervalSec=300 +StartLimitBurst=5 + +# Watchdog: process must ping every 60s or gets killed +WatchdogSec=60 + +# Logging via journald +StandardOutput=journal +StandardError=journal +SyslogIdentifier=tipote + +# Security: bind port 80 (captive portal) without root +AmbientCapabilities=CAP_NET_BIND_SERVICE +NoNewPrivileges=true + +# Resource limits (Pi Zero 2W has 416MB RAM) +MemoryMax=200M +MemoryHigh=150M + +[Install] +WantedBy=multi-user.target diff --git a/apps/robot-client/package.json b/apps/robot-client/package.json index 2f064c1..fb9c037 100644 --- a/apps/robot-client/package.json +++ b/apps/robot-client/package.json @@ -4,9 +4,12 @@ "private": true, "type": "module", "description": "Ti-Pote Robot Client — Runs on Raspberry Pi Zero 2W", + "bin": { + "tipote": "./dist/cli.js" + }, "scripts": { "dev": "tsx watch src/main.ts", - "build": "tsup src/main.ts --format esm --dts --clean", + "build": "tsup src/main.ts src/cli.ts --format esm --dts --clean", "start": "node dist/main.js", "lint": "eslint \"src/**/*.ts\" --fix", "format": "prettier --write \"src/**/*.ts\"", @@ -23,7 +26,8 @@ "dotenv": "^17.3.1", "pino": "^9.6.0", "pino-pretty": "^13.0.0", - "serialport": "^12.0.0" + "serialport": "^12.0.0", + "sd-notify": "^3.0.2" }, "devDependencies": { "typescript": "^5.8.3", diff --git a/apps/robot-client/src/cli.ts b/apps/robot-client/src/cli.ts new file mode 100644 index 0000000..395263d --- /dev/null +++ b/apps/robot-client/src/cli.ts @@ -0,0 +1,329 @@ +#!/usr/bin/env node + +import { execSync } from 'node:child_process'; +import { readFileSync, existsSync } from 'node:fs'; +import { join } from 'node:path'; +import { freemem, totalmem } from 'node:os'; + +const TIPOTE_DIR = process.env.TIPOTE_DIR || '/opt/tipote'; +const ENV_FILE = join(TIPOTE_DIR, '.env'); +const STORE_FILE = process.env.TIPOTE_STORE_PATH || join(TIPOTE_DIR, '.tipote', 'config.json'); +const SERVICE_NAME = 'tipote'; + +// ── Colors ── + +const green = (s: string) => `\x1b[32m${s}\x1b[0m`; +const red = (s: string) => `\x1b[31m${s}\x1b[0m`; +const yellow = (s: string) => `\x1b[33m${s}\x1b[0m`; +const bold = (s: string) => `\x1b[1m${s}\x1b[0m`; +const dim = (s: string) => `\x1b[2m${s}\x1b[0m`; + +// ── Helpers ── + +function exec(cmd: string): string { + try { + return execSync(cmd, { encoding: 'utf-8', timeout: 5000 }).trim(); + } catch { + return ''; + } +} + +function getPackageVersion(): string { + try { + const pkg = JSON.parse(readFileSync(join(TIPOTE_DIR, 'package.json'), 'utf-8')); + return pkg.version || 'unknown'; + } catch { + return 'unknown'; + } +} + +function getEnvValue(key: string): string | undefined { + try { + const env = readFileSync(ENV_FILE, 'utf-8'); + const match = env.match(new RegExp(`^${key}=(.*)$`, 'm')); + return match?.[1]?.trim(); + } catch { + return undefined; + } +} + +// ── Commands ── + +function doctor(): void { + console.log(bold('\n Ti-Pote Doctor\n')); + + const checks: Array<{ name: string; ok: boolean; detail: string; fix?: string }> = []; + + // Node.js version + const nodeVersion = process.version; + const nodeMajor = parseInt(nodeVersion.slice(1), 10); + checks.push({ + name: 'Node.js >= 22', + ok: nodeMajor >= 22, + detail: nodeVersion, + fix: 'Install Node.js 22 LTS: curl -fsSL https://deb.nodesource.com/setup_22.x | sudo bash -', + }); + + // Python venv + openwakeword + const pythonOk = exec('python3 -c "import openwakeword; print(\'ok\')"') === 'ok'; + const venvPath = getEnvValue('WAKEWORD_PYTHON_PATH') || 'python3'; + checks.push({ + name: 'Python + openwakeword', + ok: pythonOk, + detail: pythonOk ? venvPath : 'import failed', + fix: `Create venv: python3 -m venv ${TIPOTE_DIR}/.venv && ${TIPOTE_DIR}/.venv/bin/pip install openwakeword`, + }); + + // .env file + const envExists = existsSync(ENV_FILE); + const requiredKeys = ['CLOUD_URL', 'AUDIO_BACKEND', 'ROBOT_MODE']; + const missingKeys = envExists + ? requiredKeys.filter((k) => !getEnvValue(k)) + : requiredKeys; + checks.push({ + name: '.env configuration', + ok: envExists && missingKeys.length === 0, + detail: envExists ? (missingKeys.length ? `missing: ${missingKeys.join(', ')}` : 'OK') : 'file not found', + fix: `Copy template: cp ${TIPOTE_DIR}/.env.example ${ENV_FILE}`, + }); + + // Serial port + const serialEnabled = getEnvValue('HARDWARE_SERIAL_ENABLED') === 'true'; + const serialPath = getEnvValue('HARDWARE_SERIAL_PORT') || '/dev/serial0'; + if (serialEnabled) { + const serialExists = existsSync(serialPath); + checks.push({ + name: `Serial port (${serialPath})`, + ok: serialExists, + detail: serialExists ? 'accessible' : 'not found', + fix: 'Check UART wiring. Ensure user is in dialout group: sudo usermod -aG dialout tipote', + }); + } + + // systemd service + const serviceActive = exec(`systemctl is-active ${SERVICE_NAME}`) === 'active'; + checks.push({ + name: 'systemd service', + ok: serviceActive, + detail: serviceActive ? 'active' : exec(`systemctl is-active ${SERVICE_NAME}`) || 'not found', + fix: `sudo systemctl enable --now ${SERVICE_NAME}`, + }); + + // Disk space + const dfOutput = exec('df -BM / | tail -1'); + const dfMatch = dfOutput.match(/(\d+)M\s+(\d+)M\s+(\d+)M/); + const diskFreeMB = dfMatch ? parseInt(dfMatch[3], 10) : 0; + checks.push({ + name: 'Disk space > 100MB', + ok: diskFreeMB > 100, + detail: `${diskFreeMB}MB free`, + fix: 'Free up space: sudo apt autoremove && sudo journalctl --vacuum-size=20M', + }); + + // RAM + const freeMemMB = Math.round(freemem() / 1024 / 1024); + const totalMemMB = Math.round(totalmem() / 1024 / 1024); + checks.push({ + name: 'RAM available > 50MB', + ok: freeMemMB > 50, + detail: `${freeMemMB}MB free / ${totalMemMB}MB total`, + fix: 'Stop unused services or increase swap', + }); + + // CPU temperature + let cpuTemp = 0; + try { + const raw = readFileSync('/sys/class/thermal/thermal_zone0/temp', 'utf-8').trim(); + cpuTemp = parseInt(raw, 10) / 1000; + } catch { /* not available */ } + if (cpuTemp > 0) { + checks.push({ + name: 'CPU temperature < 80C', + ok: cpuTemp < 80, + detail: `${cpuTemp.toFixed(1)}C`, + fix: 'Improve ventilation or reduce workload', + }); + } + + // WiFi + const wifiSsid = exec("iwconfig wlan0 2>/dev/null | grep ESSID | sed 's/.*ESSID:\"\\(.*\\)\"/\\1/'"); + const isAP = exec('nmcli -t -f TYPE,STATE con show --active | grep wifi').includes('wifi'); + checks.push({ + name: 'WiFi connected', + ok: !!wifiSsid && wifiSsid !== 'off/any', + detail: wifiSsid ? `SSID: ${wifiSsid}` : (isAP ? 'AP mode' : 'not connected'), + }); + + // Print results + let allOk = true; + for (const check of checks) { + const icon = check.ok ? green('\u2713') : red('\u2717'); + console.log(` ${icon} ${check.name} ${dim(`(${check.detail})`)}`); + if (!check.ok) { + allOk = false; + if (check.fix) { + console.log(` ${yellow('\u2192')} ${dim(check.fix)}`); + } + } + } + + console.log(); + if (allOk) { + console.log(green(' All checks passed!\n')); + } else { + console.log(yellow(' Some checks failed. See suggestions above.\n')); + } + + process.exit(allOk ? 0 : 1); +} + +function status(): void { + console.log(bold('\n Ti-Pote Status\n')); + + const version = getPackageVersion(); + console.log(` Version: ${version}`); + console.log(` Node: ${process.version}`); + + const serviceState = exec(`systemctl is-active ${SERVICE_NAME}`) || 'unknown'; + const stateColor = serviceState === 'active' ? green : red; + console.log(` Service: ${stateColor(serviceState)}`); + + const wifiSsid = exec("iwconfig wlan0 2>/dev/null | grep ESSID | sed 's/.*ESSID:\"\\(.*\\)\"/\\1/'"); + console.log(` WiFi: ${wifiSsid || dim('not connected')}`); + + const ip = exec("hostname -I | awk '{print $1}'"); + console.log(` IP: ${ip || dim('none')}`); + + const cloudUrl = getEnvValue('CLOUD_URL'); + console.log(` Cloud URL: ${cloudUrl || dim('not configured')}`); + + const storeExists = existsSync(STORE_FILE); + console.log(` Store: ${storeExists ? green('encrypted') : yellow('not found')}`); + + let cpuTemp = ''; + try { + const raw = readFileSync('/sys/class/thermal/thermal_zone0/temp', 'utf-8').trim(); + cpuTemp = `${(parseInt(raw, 10) / 1000).toFixed(1)}C`; + } catch { /* not available */ } + if (cpuTemp) { + console.log(` CPU temp: ${cpuTemp}`); + } + + const freeMemMB = Math.round(freemem() / 1024 / 1024); + const totalMemMB = Math.round(totalmem() / 1024 / 1024); + console.log(` RAM: ${freeMemMB}MB free / ${totalMemMB}MB total`); + + console.log(); +} + +function logs(): void { + try { + execSync(`journalctl -u ${SERVICE_NAME} -f --no-pager -n 50`, { + stdio: 'inherit', + }); + } catch { + // User pressed Ctrl+C + } +} + +function restart(): void { + console.log('Restarting Ti-Pote service...'); + try { + execSync(`sudo systemctl restart ${SERVICE_NAME}`, { stdio: 'inherit' }); + console.log(green('Service restarted.')); + } catch { + console.error(red('Failed to restart. Are you running as root or with sudo?')); + process.exit(1); + } +} + +function reset(): void { + console.log(yellow('Factory reset: this will erase all stored credentials and WiFi config.')); + console.log('Press Ctrl+C within 3 seconds to cancel...\n'); + + // Give user time to cancel + execSync('sleep 3'); + + // Remove encrypted store + if (existsSync(STORE_FILE)) { + execSync(`rm -f "${STORE_FILE}"`); + console.log(' Removed stored credentials'); + } + + // Remove all NetworkManager WiFi connections + const connections = exec('nmcli -t -f NAME,TYPE con show | grep wifi') + .split('\n') + .filter(Boolean) + .map((line) => line.split(':')[0]); + + for (const conn of connections) { + exec(`sudo nmcli con delete "${conn}"`); + console.log(` Removed WiFi connection: ${conn}`); + } + + console.log('\nRestarting service...'); + try { + execSync(`sudo systemctl restart ${SERVICE_NAME}`, { stdio: 'inherit' }); + console.log(green('\nFactory reset complete. Robot will start in AP mode.\n')); + } catch { + console.log(yellow('\nCredentials cleared. Restart the service manually: sudo systemctl restart tipote\n')); + } +} + +function version(): void { + console.log(`ti-pote ${getPackageVersion()}`); +} + +function help(): void { + console.log(` +${bold('Ti-Pote CLI')} — Robot management tool + +${bold('Usage:')} tipote + +${bold('Commands:')} + doctor Run diagnostic checks on the robot + status Show current robot status + logs Stream service logs (journalctl) + restart Restart the Ti-Pote service + reset Factory reset (erase credentials + WiFi) + version Show version + help Show this help message +`); +} + +// ── Main ── + +const command = process.argv[2]; + +switch (command) { + case 'doctor': + doctor(); + break; + case 'status': + status(); + break; + case 'logs': + logs(); + break; + case 'restart': + restart(); + break; + case 'reset': + reset(); + break; + case 'version': + case '--version': + case '-v': + version(); + break; + case 'help': + case '--help': + case '-h': + case undefined: + help(); + break; + default: + console.error(red(`Unknown command: ${command}`)); + help(); + process.exit(1); +} diff --git a/apps/robot-client/src/main.ts b/apps/robot-client/src/main.ts index 034cb9a..b7b2784 100644 --- a/apps/robot-client/src/main.ts +++ b/apps/robot-client/src/main.ts @@ -154,6 +154,7 @@ async function main(): Promise { // ── Step 5: Start services ── + healthService.notifyReady(); healthService.start(); orchestrator.start(); diff --git a/apps/robot-client/src/services/health.service.ts b/apps/robot-client/src/services/health.service.ts index 1203f12..7ff2199 100644 --- a/apps/robot-client/src/services/health.service.ts +++ b/apps/robot-client/src/services/health.service.ts @@ -2,9 +2,18 @@ import { EventEmitter } from 'node:events'; import { createLogger, type Logger } from '../utils/index.js'; import { type CloudSocket } from '../transport/cloud-socket.js'; +let sdNotify: { ready: () => void; watchdog: () => void } | null = null; +try { + const mod = await import('sd-notify'); + sdNotify = mod.default ?? mod; +} catch { + // sd-notify not available (dev env, non-systemd) — silently skip +} + /** * Health monitoring service. * Tracks connectivity, system health, and provides diagnostics. + * Integrates with systemd watchdog when running as a service. */ export class HealthService extends EventEmitter { private readonly logger: Logger; @@ -31,12 +40,28 @@ export class HealthService extends EventEmitter { } /** - * Start periodic health checks. + * Notify systemd that the service is ready. + * Call this once after all services are initialized. + */ + notifyReady(): void { + if (sdNotify) { + sdNotify.ready(); + this.logger.info('Notified systemd: READY'); + } + } + + /** + * Start periodic health checks + systemd watchdog pings. */ start(intervalMs = 30_000): void { this.logger.info({ intervalMs }, 'Starting health monitoring'); this.heartbeatInterval = setInterval(() => { + // Ping systemd watchdog (must be called within WatchdogSec interval) + if (sdNotify) { + sdNotify.watchdog(); + } + this.logger.debug({ cloud: this._isCloudConnected, uptime: process.uptime(), diff --git a/apps/robot-client/src/services/local-store.service.ts b/apps/robot-client/src/services/local-store.service.ts index de17a5b..221aaa1 100644 --- a/apps/robot-client/src/services/local-store.service.ts +++ b/apps/robot-client/src/services/local-store.service.ts @@ -1,6 +1,12 @@ import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { homedir } from 'node:os'; +import { + createCipheriv, + createDecipheriv, + createHash, + randomBytes, +} from 'node:crypto'; import { createLogger, type Logger } from '../utils/index.js'; /** @@ -35,10 +41,45 @@ export interface LocalStoreData { const DEFAULT_STORE_PATH = process.env.TIPOTE_STORE_PATH || join(homedir(), '.tipote', 'config.json'); +const ALGORITHM = 'aes-256-gcm'; +const IV_LENGTH = 12; +const AUTH_TAG_LENGTH = 16; + +function deriveKey(): Buffer { + try { + const machineId = readFileSync('/etc/machine-id', 'utf-8').trim(); + return createHash('sha256').update(machineId).digest(); + } catch { + // Fallback for dev machines (macOS, etc.) — use hostname + return createHash('sha256').update(homedir()).digest(); + } +} + +function encrypt(plaintext: string, key: Buffer): Buffer { + const iv = randomBytes(IV_LENGTH); + const cipher = createCipheriv(ALGORITHM, key, iv); + const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]); + const authTag = cipher.getAuthTag(); + // Layout: [IV 12B] [AuthTag 16B] [Ciphertext] + return Buffer.concat([iv, authTag, encrypted]); +} + +function decrypt(data: Buffer, key: Buffer): string { + const iv = data.subarray(0, IV_LENGTH); + const authTag = data.subarray(IV_LENGTH, IV_LENGTH + AUTH_TAG_LENGTH); + const ciphertext = data.subarray(IV_LENGTH + AUTH_TAG_LENGTH); + const decipher = createDecipheriv(ALGORITHM, key, iv); + decipher.setAuthTag(authTag); + return decipher.update(ciphertext) + decipher.final('utf8'); +} + /** - * Simple JSON file store for persisting robot config on the SD card. + * Encrypted JSON file store for persisting robot config on the SD card. * * Stores WiFi credentials, device token, and preferences. + * Data is encrypted with AES-256-GCM using a key derived from /etc/machine-id, + * tying the data to the specific Pi hardware. + * * Survives reboots and code redeployments — unlike .env files, * this is the robot's own "memory" of who it is and where it connects. */ @@ -46,22 +87,40 @@ export class LocalStore { private data: LocalStoreData; private readonly filePath: string; private readonly logger: Logger; + private readonly encryptionKey: Buffer; constructor(filePath?: string) { this.logger = createLogger('local-store', 'info'); this.filePath = filePath || process.env.STORE_PATH || DEFAULT_STORE_PATH; + this.encryptionKey = deriveKey(); this.data = this.load(); } /** - * Load stored data from disk. Returns empty object if file doesn't exist. + * Load stored data from disk. + * Handles both encrypted (binary) and legacy plaintext (JSON) formats. */ private load(): LocalStoreData { try { if (existsSync(this.filePath)) { - const raw = readFileSync(this.filePath, 'utf-8'); - const parsed = JSON.parse(raw) as LocalStoreData; - this.logger.info({ path: this.filePath }, 'Config loaded from disk'); + const raw = readFileSync(this.filePath); + + // Try parsing as plaintext JSON first (backward compatibility) + try { + const text = raw.toString('utf-8'); + const parsed = JSON.parse(text) as LocalStoreData; + this.logger.info({ path: this.filePath }, 'Legacy plaintext config loaded — will re-encrypt on next save'); + // Re-save encrypted to migrate + this.data = parsed; + this.save(); + return parsed; + } catch { + // Not valid JSON — try decrypting + } + + const json = decrypt(raw, this.encryptionKey); + const parsed = JSON.parse(json) as LocalStoreData; + this.logger.info({ path: this.filePath }, 'Encrypted config loaded from disk'); return parsed; } } catch (err) { @@ -71,16 +130,18 @@ export class LocalStore { } /** - * Save current data to disk. + * Save current data to disk (encrypted). */ private save(): void { try { const dir = dirname(this.filePath); if (!existsSync(dir)) { - mkdirSync(dir, { recursive: true }); + mkdirSync(dir, { recursive: true, mode: 0o700 }); } - writeFileSync(this.filePath, JSON.stringify(this.data, null, 2), 'utf-8'); - this.logger.debug({ path: this.filePath }, 'Config saved to disk'); + const json = JSON.stringify(this.data, null, 2); + const encrypted = encrypt(json, this.encryptionKey); + writeFileSync(this.filePath, encrypted, { mode: 0o600 }); + this.logger.debug({ path: this.filePath }, 'Encrypted config saved to disk'); } catch (err) { this.logger.error({ err }, 'Failed to save config file'); } diff --git a/tools/pi-gen-tipote/.gitignore b/tools/pi-gen-tipote/.gitignore new file mode 100644 index 0000000..0f3cab0 --- /dev/null +++ b/tools/pi-gen-tipote/.gitignore @@ -0,0 +1,11 @@ +# pi-gen clone (downloaded at build time) +pi-gen/ + +# Built stage files (copied at build time) +stage-tipote/01-install-tipote/files/dist/ +stage-tipote/01-install-tipote/files/package.json +stage-tipote/01-install-tipote/files/tipote.service +stage-tipote/01-install-tipote/files/journald-tipote.conf + +# Build output +output/ diff --git a/tools/pi-gen-tipote/build.sh b/tools/pi-gen-tipote/build.sh new file mode 100755 index 0000000..3993273 --- /dev/null +++ b/tools/pi-gen-tipote/build.sh @@ -0,0 +1,91 @@ +#!/bin/bash +set -euo pipefail + +# ────────────────────────────────────────────────────── +# Ti-Pote SD Image Builder +# Builds a flashable .img using pi-gen (Docker mode) +# ────────────────────────────────────────────────────── + +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" + +echo "╔══════════════════════════════════════╗" +echo "║ Ti-Pote SD Image Builder ║" +echo "╚══════════════════════════════════════╝" + +# ── Step 1: Build robot-client ── + +echo "" +echo "▸ Building robot-client..." +cd "$REPO_ROOT" +pnpm --filter @ti-pote/robot-client build + +# ── Step 2: Prepare files for pi-gen stage ── + +echo "▸ Preparing stage files..." +rm -rf "$STAGE_FILES" +mkdir -p "$STAGE_FILES" + +# Copy built dist +cp -r "$ROBOT_CLIENT_DIR/dist" "$STAGE_FILES/dist" + +# Copy package.json (for npm install --omit=dev in chroot) +cp "$ROBOT_CLIENT_DIR/package.json" "$STAGE_FILES/package.json" + +# 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" + +# ── Step 3: Clone or update pi-gen ── + +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 + +# ── 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" +mkdir -p "$OUTPUT_DIR" + +IMG_FILE=$(find "$PI_GEN_DIR/deploy" -name "*.img.xz" -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" + echo "════════════════════════════════════════" +else + echo "⚠ No image found in pi-gen/deploy/ — check build logs" + exit 1 +fi diff --git a/tools/pi-gen-tipote/config b/tools/pi-gen-tipote/config new file mode 100644 index 0000000..5318cdd --- /dev/null +++ b/tools/pi-gen-tipote/config @@ -0,0 +1,15 @@ +IMG_NAME=tipote +RELEASE=trixie +TARGET_HOSTNAME=tipote +FIRST_USER_NAME=tipote +FIRST_USER_PASS=tipote +LOCALE_DEFAULT=fr_FR.UTF-8 +KEYBOARD_KEYMAP=fr +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 diff --git a/tools/pi-gen-tipote/stage-tipote/00-install-deps/00-packages b/tools/pi-gen-tipote/stage-tipote/00-install-deps/00-packages new file mode 100644 index 0000000..fc34ad3 --- /dev/null +++ b/tools/pi-gen-tipote/stage-tipote/00-install-deps/00-packages @@ -0,0 +1,9 @@ +python3 +python3-venv +python3-pip +portaudio19-dev +libatlas-base-dev +alsa-utils +network-manager +git +curl diff --git a/tools/pi-gen-tipote/stage-tipote/00-install-deps/00-run.sh b/tools/pi-gen-tipote/stage-tipote/00-install-deps/00-run.sh new file mode 100755 index 0000000..70d38a7 --- /dev/null +++ b/tools/pi-gen-tipote/stage-tipote/00-install-deps/00-run.sh @@ -0,0 +1,15 @@ +#!/bin/bash -e +# Install Node.js 22 LTS (not in Debian repos yet) + +on_chroot << 'CHEOF' +# Node.js 22 LTS via NodeSource +curl -fsSL https://deb.nodesource.com/setup_22.x | bash - +apt-get install -y nodejs + +# Verify +node --version +npm --version + +# Install pnpm globally (used to build, not at runtime) +npm install -g pnpm@latest +CHEOF diff --git a/tools/pi-gen-tipote/stage-tipote/01-install-tipote/00-run.sh b/tools/pi-gen-tipote/stage-tipote/01-install-tipote/00-run.sh new file mode 100755 index 0000000..f3a7ea1 --- /dev/null +++ b/tools/pi-gen-tipote/stage-tipote/01-install-tipote/00-run.sh @@ -0,0 +1,102 @@ +#!/bin/bash -e +# Install Ti-Pote robot client into /opt/tipote/ + +TIPOTE_DIR="/opt/tipote" + +# ── Create tipote system user ── + +on_chroot << 'CHEOF' +if ! id tipote &>/dev/null; then + useradd --system --create-home --home-dir /opt/tipote \ + --shell /usr/sbin/nologin \ + --groups dialout,audio \ + tipote +fi +CHEOF + +# ── Copy pre-built robot-client ── + +install -d "${ROOTFS_DIR}${TIPOTE_DIR}" + +# Copy the built dist/ and package.json (prepared by build.sh) +cp -r "${STAGE_DIR}/files/dist" "${ROOTFS_DIR}${TIPOTE_DIR}/dist" +cp "${STAGE_DIR}/files/package.json" "${ROOTFS_DIR}${TIPOTE_DIR}/package.json" + +# ── Install production deps ── + +on_chroot << CHEOF +cd ${TIPOTE_DIR} +npm install --omit=dev --ignore-scripts 2>/dev/null || npm install --production +CHEOF + +# ── Python venv + openwakeword ── + +on_chroot << CHEOF +python3 -m venv ${TIPOTE_DIR}/.venv +${TIPOTE_DIR}/.venv/bin/pip install --upgrade pip +${TIPOTE_DIR}/.venv/bin/pip install openwakeword +CHEOF + +# ── .env with defaults ── + +cat > "${ROOTFS_DIR}${TIPOTE_DIR}/.env" << 'ENVEOF' +# Ti-Pote Robot Configuration +# Pre-configured for production use on Raspberry Pi + +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 + +# ── Permissions ── + +on_chroot << CHEOF +chown -R tipote:tipote ${TIPOTE_DIR} +chmod 0755 ${TIPOTE_DIR} +chmod 0600 ${TIPOTE_DIR}/.env +mkdir -p ${TIPOTE_DIR}/.tipote +chmod 0700 ${TIPOTE_DIR}/.tipote +CHEOF + +# ── CLI symlink ── + +on_chroot << CHEOF +ln -sf ${TIPOTE_DIR}/dist/cli.js /usr/local/bin/tipote +chmod +x ${TIPOTE_DIR}/dist/cli.js +CHEOF + +# ── systemd service ── + +install -m 644 "${STAGE_DIR}/files/tipote.service" \ + "${ROOTFS_DIR}/etc/systemd/system/tipote.service" + +on_chroot << 'CHEOF' +systemctl enable tipote.service +CHEOF + +# ── journald config ── + +install -d "${ROOTFS_DIR}/etc/systemd/journald.conf.d" +install -m 644 "${STAGE_DIR}/files/journald-tipote.conf" \ + "${ROOTFS_DIR}/etc/systemd/journald.conf.d/tipote.conf" + +# ── NetworkManager: don't auto-connect to WiFi ── +# The robot must go through the setup flow (AP mode) first. + +install -d "${ROOTFS_DIR}/etc/NetworkManager/conf.d" +cat > "${ROOTFS_DIR}/etc/NetworkManager/conf.d/tipote-no-autoconnect.conf" << 'NMEOF' +[main] +# Don't create automatic WiFi connections on first boot +autoconnect-retries-default=0 + +[connection-wifi-no-auto] +match-device=type:wifi +connection.autoconnect=false +NMEOF diff --git a/tools/pi-gen-tipote/stage-tipote/prerun.sh b/tools/pi-gen-tipote/stage-tipote/prerun.sh new file mode 100755 index 0000000..cda5103 --- /dev/null +++ b/tools/pi-gen-tipote/stage-tipote/prerun.sh @@ -0,0 +1,3 @@ +#!/bin/bash -e +# Mark this as an export stage so pi-gen produces an image after it +echo "img" > "${STAGE_DIR}/EXPORT_IMAGE"