first version of the tipote software
This commit is contained in:
parent
02705ea8b5
commit
36f38d78db
4
.gitignore
vendored
4
.gitignore
vendored
@ -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/
|
||||
6
apps/robot-client/deploy/journald-tipote.conf
Normal file
6
apps/robot-client/deploy/journald-tipote.conf
Normal 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
|
||||
37
apps/robot-client/deploy/tipote.service
Normal file
37
apps/robot-client/deploy/tipote.service
Normal 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
|
||||
@ -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",
|
||||
|
||||
329
apps/robot-client/src/cli.ts
Normal file
329
apps/robot-client/src/cli.ts
Normal 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);
|
||||
}
|
||||
@ -154,6 +154,7 @@ async function main(): Promise<void> {
|
||||
|
||||
// ── Step 5: Start services ──
|
||||
|
||||
healthService.notifyReady();
|
||||
healthService.start();
|
||||
orchestrator.start();
|
||||
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -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
11
tools/pi-gen-tipote/.gitignore
vendored
Normal 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
91
tools/pi-gen-tipote/build.sh
Executable 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
|
||||
15
tools/pi-gen-tipote/config
Normal file
15
tools/pi-gen-tipote/config
Normal 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
|
||||
@ -0,0 +1,9 @@
|
||||
python3
|
||||
python3-venv
|
||||
python3-pip
|
||||
portaudio19-dev
|
||||
libatlas-base-dev
|
||||
alsa-utils
|
||||
network-manager
|
||||
git
|
||||
curl
|
||||
15
tools/pi-gen-tipote/stage-tipote/00-install-deps/00-run.sh
Executable file
15
tools/pi-gen-tipote/stage-tipote/00-install-deps/00-run.sh
Executable 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
|
||||
102
tools/pi-gen-tipote/stage-tipote/01-install-tipote/00-run.sh
Executable file
102
tools/pi-gen-tipote/stage-tipote/01-install-tipote/00-run.sh
Executable 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
|
||||
3
tools/pi-gen-tipote/stage-tipote/prerun.sh
Executable file
3
tools/pi-gen-tipote/stage-tipote/prerun.sh
Executable 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"
|
||||
Loading…
x
Reference in New Issue
Block a user