first version of the tipote software

This commit is contained in:
ordinarthur 2026-04-13 20:38:07 +02:00
parent 02705ea8b5
commit 36f38d78db
15 changed files with 725 additions and 12 deletions

4
.gitignore vendored
View File

@ -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/

View File

@ -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

View File

@ -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

View File

@ -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",

View File

@ -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 <command>
${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);
}

View File

@ -154,6 +154,7 @@ async function main(): Promise<void> {
// ── Step 5: Start services ──
healthService.notifyReady();
healthService.start();
orchestrator.start();

View File

@ -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(),

View File

@ -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');
}

11
tools/pi-gen-tipote/.gitignore vendored Normal file
View File

@ -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/

91
tools/pi-gen-tipote/build.sh Executable file
View File

@ -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

View File

@ -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

View File

@ -0,0 +1,9 @@
python3
python3-venv
python3-pip
portaudio19-dev
libatlas-base-dev
alsa-utils
network-manager
git
curl

View File

@ -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

View File

@ -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

View File

@ -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"