robot client qui fonctionne avec le hey jarvis et discussion ok avec un casque blutooth banger

This commit is contained in:
ordinarthur 2026-04-02 11:21:42 +02:00
parent 4ac7bd16d2
commit 98aa1439e3
30 changed files with 3028 additions and 0 deletions

View File

@ -0,0 +1,52 @@
# ──────────────────────────────────────────────
# Ti-Pote Robot Client — Dev/Test Configuration
# Pour tester sur Raspberry Pi 3B+ avec casque USB Logitech Pro X
# ──────────────────────────────────────────────
# Mode dev : test sur Pi avec USB audio
ROBOT_MODE=dev
# Trigger mode : 'wakeword' (OpenWakeWord) ou 'keyboard' (Entrée = wake word)
# Utilise 'wakeword' si tu as installé OpenWakeWord (pip install openwakeword pyaudio)
# Utilise 'keyboard' pour tester sans OpenWakeWord
TRIGGER_MODE=keyboard
# Device identification (à remplir après avoir enregistré le device sur le backend)
DEVICE_ID=
DEVICE_TOKEN=
# Cloud backend URL (le backend NestJS tourne sur ton Mac ou un serveur)
CLOUD_URL=ws://192.168.1.XXX:3000
# Robot name
ROBOT_NAME=Ti-Pote-Dev
# Log level debug pour le dev
LOG_LEVEL=debug
# ── Audio (ALSA — casque USB Logitech Pro X) ──
#
# Pour trouver le bon device, lance sur le Pi :
# arecord -l → liste les devices de capture (micro)
# aplay -l → liste les devices de playback (speaker/casque)
#
# Le casque USB apparaîtra comme un device "USB Audio" ou "Logitech".
# Note le numéro de carte (card X) et de device (device Y),
# puis utilise : plughw:X,Y
#
# Exemple si le casque est card 1, device 0 :
AUDIO_CAPTURE_DEVICE=plughw:1,0
AUDIO_PLAYBACK_DEVICE=plughw:1,0
# Sample rate 16kHz (doit matcher le backend)
AUDIO_SAMPLE_RATE=16000
# Chunks de 100ms
AUDIO_CHUNK_MS=100
# ── Wake Word ──
# Python du venv où openwakeword est installé
WAKEWORD_PYTHON_PATH=/home/ti-pote/.tipote-venv/bin/python3
WAKEWORD_SCRIPT_PATH=./scripts/wake_word.py
WAKEWORD_MODEL=hey_jarvis
WAKEWORD_THRESHOLD=0.5

View File

@ -0,0 +1,55 @@
# ──────────────────────────────────────────────
# Ti-Pote Robot Client — Configuration
# ──────────────────────────────────────────────
# Mode: 'physical' (Raspberry Pi prod) | 'dev' (Pi + USB audio test) | 'simulator' (PC dev)
ROBOT_MODE=physical
# Trigger mode: 'wakeword' (OpenWakeWord) | 'keyboard' (press Enter = wake word)
TRIGGER_MODE=wakeword
# Device identification (set during first setup or manually)
DEVICE_ID=
DEVICE_TOKEN=
# Cloud backend URL
CLOUD_URL=ws://localhost:3000
# Robot name
ROBOT_NAME=Ti-Pote
# Log level: fatal, error, warn, info, debug, trace
LOG_LEVEL=info
# ── Audio (ALSA) ──
# ALSA device for microphone capture
# Examples: 'default', 'plughw:1,0', 'hw:1,0'
# Use 'arecord -l' to list available devices
AUDIO_CAPTURE_DEVICE=default
# ALSA device for speaker playback
# Examples: 'default', 'plughw:0,0', 'hw:0,0'
# Use 'aplay -l' to list available devices
AUDIO_PLAYBACK_DEVICE=default
# Audio sample rate in Hz (must match backend: 16000)
AUDIO_SAMPLE_RATE=16000
# Audio chunk duration in ms (how often chunks are sent to cloud)
AUDIO_CHUNK_MS=100
# ── Wake Word ──
# Path to the Python binary (use venv python if openwakeword is in a virtualenv)
# Examples: 'python3', '/home/pi/.tipote-venv/bin/python3'
WAKEWORD_PYTHON_PATH=python3
# Path to the OpenWakeWord Python script
WAKEWORD_SCRIPT_PATH=./scripts/wake_word.py
# Wake word model name (use 'hey_jarvis' as placeholder until custom model is trained)
WAKEWORD_MODEL=hey_jarvis
# Wake word detection threshold (0.0 to 1.0, higher = less false positives)
WAKEWORD_THRESHOLD=0.5

View File

@ -0,0 +1,31 @@
{
"name": "@ti-pote/robot-client",
"version": "0.0.1",
"private": true,
"type": "module",
"description": "Ti-Pote Robot Client — Runs on Raspberry Pi Zero 2W",
"scripts": {
"dev": "tsx watch src/main.ts",
"build": "tsup src/main.ts --format esm --dts --clean",
"start": "node dist/main.js",
"lint": "eslint \"src/**/*.ts\" --fix",
"format": "prettier --write \"src/**/*.ts\"",
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"socket.io-client": "^4.8.3",
"dotenv": "^17.3.1",
"pino": "^9.6.0",
"pino-pretty": "^13.0.0"
},
"devDependencies": {
"typescript": "^5.8.3",
"tsx": "^4.21.0",
"tsup": "^8.5.0",
"@types/node": "^22.15.0",
"vitest": "^3.2.1",
"eslint": "^10.1.0",
"prettier": "^3.8.1"
}
}

View File

@ -0,0 +1,116 @@
#!/bin/bash
# ──────────────────────────────────────────────
# Ti-Pote Robot Client — Installation Script
# For Raspberry Pi Zero 2W running Raspberry Pi OS Lite (64-bit)
# ──────────────────────────────────────────────
set -euo pipefail
echo "╔══════════════════════════════════════╗"
echo "║ Ti-Pote Robot Client Installer ║"
echo "╚══════════════════════════════════════╝"
# ── System packages ──
echo ""
echo "→ Updating system packages..."
sudo apt update && sudo apt upgrade -y
echo "→ Installing system dependencies..."
sudo apt install -y \
git \
curl \
alsa-utils \
python3 \
python3-pip \
python3-venv \
portaudio19-dev \
libatlas-base-dev
# ── Node.js (LTS via nvm) ──
echo ""
echo "→ Installing Node.js LTS..."
if ! command -v node &> /dev/null; then
curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash -
sudo apt install -y nodejs
echo "Node.js $(node -v) installed"
else
echo "Node.js $(node -v) already installed"
fi
# ── pnpm ──
echo ""
echo "→ Installing pnpm..."
if ! command -v pnpm &> /dev/null; then
npm install -g pnpm
echo "pnpm installed"
else
echo "pnpm already installed"
fi
# ── Python dependencies (wake word) ──
echo ""
echo "→ Setting up Python virtual environment for wake word..."
VENV_DIR="$HOME/.tipote-venv"
if [ ! -d "$VENV_DIR" ]; then
python3 -m venv "$VENV_DIR"
fi
source "$VENV_DIR/bin/activate"
pip install --upgrade pip
pip install openwakeword pyaudio numpy
deactivate
echo "Python dependencies installed in $VENV_DIR"
# ── I2S Audio setup (for INMP441 + MAX98357) ──
echo ""
echo "→ Checking I2S audio configuration..."
CONFIG_FILE="/boot/firmware/config.txt"
if [ ! -f "$CONFIG_FILE" ]; then
CONFIG_FILE="/boot/config.txt"
fi
if ! grep -q "dtoverlay=googlevoicehat-soundcard" "$CONFIG_FILE" 2>/dev/null; then
echo ""
echo "⚠ I2S audio overlay not configured."
echo " To enable I2S audio (INMP441 mic + MAX98357 amp), add to $CONFIG_FILE:"
echo ""
echo " # I2S audio for Ti-Pote"
echo " dtparam=i2s=on"
echo " dtoverlay=googlevoicehat-soundcard"
echo ""
echo " Then reboot the Pi."
echo ""
echo " Alternatively, if using USB audio, no changes are needed."
else
echo "I2S audio overlay already configured"
fi
# ── Verify audio devices ──
echo ""
echo "→ Available audio capture devices:"
arecord -l 2>/dev/null || echo " (no capture devices found — connect a microphone)"
echo ""
echo "→ Available audio playback devices:"
aplay -l 2>/dev/null || echo " (no playback devices found — connect a speaker)"
# ── Summary ──
echo ""
echo "╔══════════════════════════════════════╗"
echo "║ Installation complete! ║"
echo "╚══════════════════════════════════════╝"
echo ""
echo "Next steps:"
echo " 1. Configure audio (see above)"
echo " 2. Copy .env.example to .env and fill in your device credentials"
echo " 3. Run: pnpm install"
echo " 4. Run: pnpm dev"
echo ""

View File

@ -0,0 +1,247 @@
#!/usr/bin/env python3
"""
Ti-Pote Wake Word Detection Script.
Runs OpenWakeWord model continuously, listening on the specified ALSA device.
Prints "DETECTED" to stdout when the wake word is heard.
Supports PAUSE/RESUME commands on stdin to temporarily stop/start listening
without reloading the model. When paused, the audio stream is closed so other
processes (arecord) can use the device.
Usage:
python3 wake_word.py --model hey_jarvis --threshold 0.5 --device default --sample-rate 16000
Requirements:
pip install openwakeword pyaudio numpy
"""
import argparse
import sys
import os
import signal
import select
import threading
import numpy as np
def main():
parser = argparse.ArgumentParser(description='Ti-Pote Wake Word Detection')
parser.add_argument('--model', type=str, default='hey_jarvis',
help='Wake word model name (default: hey_jarvis as placeholder)')
parser.add_argument('--threshold', type=float, default=0.5,
help='Detection threshold (0.0-1.0)')
parser.add_argument('--device', type=str, default='default',
help='ALSA audio capture device')
parser.add_argument('--sample-rate', type=int, default=16000,
help='Audio sample rate in Hz')
args = parser.parse_args()
try:
from openwakeword.model import Model
except ImportError:
print("ERROR: openwakeword not installed. Run: pip install openwakeword", file=sys.stderr)
sys.exit(1)
try:
import pyaudio
except ImportError:
print("ERROR: pyaudio not installed. Run: pip install pyaudio", file=sys.stderr)
sys.exit(1)
# ── Load the wake word model (one time only) ──
print(f"Loading wake word model: {args.model}...", file=sys.stderr)
import openwakeword
pretrained_paths = openwakeword.get_pretrained_model_paths()
model_path = None
for p in pretrained_paths:
basename = os.path.basename(p)
if basename.startswith(args.model):
model_path = p
break
if model_path is None:
if os.path.isfile(args.model):
model_path = args.model
else:
print(f"ERROR: model '{args.model}' not found in pretrained models", file=sys.stderr)
print(f"Available models:", file=sys.stderr)
for p in pretrained_paths:
print(f" - {os.path.basename(p)}", file=sys.stderr)
sys.exit(1)
print(f"Resolved model path: {model_path}", file=sys.stderr)
try:
oww_model = Model(wakeword_model_paths=[model_path])
except Exception as e:
print(f"ERROR loading model '{args.model}': {e}", file=sys.stderr)
sys.exit(1)
print(f"Wake word model loaded: {args.model}", file=sys.stderr)
print(f"Threshold: {args.threshold}", file=sys.stderr)
print(f"Listening on device: {args.device}", file=sys.stderr)
# ── Initialize PyAudio ──
pa = pyaudio.PyAudio()
# Find the device index
import re
device_index = None
if args.device != 'default':
try:
idx = int(args.device)
info = pa.get_device_info_by_index(idx)
if info.get('maxInputChannels', 0) > 0:
device_index = idx
print(f"Using device by index: [{idx}] {info['name']}", file=sys.stderr)
except (ValueError, IOError):
pass
if device_index is None:
hw_match = re.search(r'(\d+),(\d+)', args.device)
hw_pattern = f"hw:{hw_match.group(1)},{hw_match.group(2)}" if hw_match else None
for i in range(pa.get_device_count()):
info = pa.get_device_info_by_index(i)
if info.get('maxInputChannels', 0) <= 0:
continue
name = str(info.get('name', ''))
if (hw_pattern and hw_pattern in name) or args.device in name:
device_index = i
print(f"Matched device: [{i}] {name}", file=sys.stderr)
break
if device_index is None:
print(f"WARNING: Device '{args.device}' not found, listing available inputs:", file=sys.stderr)
for i in range(pa.get_device_count()):
info = pa.get_device_info_by_index(i)
if info.get('maxInputChannels', 0) > 0:
print(f" [{i}] {info['name']}", file=sys.stderr)
print("Falling back to default device", file=sys.stderr)
# ── Audio stream helpers ──
chunk_size = 1280 # ~80ms at 16kHz (OpenWakeWord expects this)
stream = None
def open_stream():
nonlocal stream
stream = pa.open(
format=pyaudio.paInt16,
channels=1,
rate=args.sample_rate,
input=True,
frames_per_buffer=chunk_size,
input_device_index=device_index,
)
def close_stream():
nonlocal stream
if stream is not None:
try:
stream.stop_stream()
stream.close()
except Exception:
pass
stream = None
# ── Stdin command reader (PAUSE / RESUME) ──
paused = False
running = True
lock = threading.Lock()
def stdin_reader():
nonlocal paused, running
while running:
try:
line = sys.stdin.readline()
if not line: # EOF
running = False
break
cmd = line.strip().upper()
with lock:
if cmd == 'PAUSE':
if not paused:
paused = True
print("PAUSED", file=sys.stderr, flush=True)
elif cmd == 'RESUME':
if paused:
paused = False
print("RESUMED", file=sys.stderr, flush=True)
elif cmd == 'QUIT':
running = False
break
except Exception:
break
stdin_thread = threading.Thread(target=stdin_reader, daemon=True)
stdin_thread.start()
# ── Signal handling ──
def handle_signal(sig, frame):
nonlocal running
running = False
signal.signal(signal.SIGTERM, handle_signal)
signal.signal(signal.SIGINT, handle_signal)
# ── Main loop ──
open_stream()
print("READY", file=sys.stderr, flush=True)
try:
while running:
with lock:
is_paused = paused
if is_paused:
# Close the audio stream so arecord can use the device
if stream is not None:
close_stream()
print("STREAM_CLOSED", file=sys.stderr, flush=True)
# Wait a bit before checking again
import time
time.sleep(0.1)
continue
# Reopen stream if it was closed (after resume)
if stream is None:
open_stream()
oww_model.reset()
print("STREAM_REOPENED", file=sys.stderr, flush=True)
try:
audio_data = stream.read(chunk_size, exception_on_overflow=False)
except Exception as e:
print(f"Audio read error: {e}", file=sys.stderr)
close_stream()
import time
time.sleep(0.5)
continue
audio_array = np.frombuffer(audio_data, dtype=np.int16)
oww_model.predict(audio_array)
for model_name, score in oww_model.prediction_buffer.items():
if len(score) > 0 and score[-1] > args.threshold:
print("DETECTED", flush=True)
oww_model.reset()
break
except KeyboardInterrupt:
pass
finally:
close_stream()
pa.terminate()
print("Wake word detection stopped", file=sys.stderr)
if __name__ == '__main__':
main()

View File

@ -0,0 +1,57 @@
export interface AudioConfig {
/** ALSA device for capture (e.g., 'plughw:1,0' or 'default') */
captureDevice: string;
/** ALSA device for playback (e.g., 'plughw:0,0' or 'default') */
playbackDevice: string;
/** Sample rate in Hz */
sampleRate: number;
/** Bits per sample */
bitDepth: number;
/** Number of audio channels */
channels: number;
/** Audio chunk duration in ms (how often we send chunks to cloud) */
chunkDurationMs: number;
}
export interface WakeWordConfig {
/** Path to Python binary (use venv python if openwakeword is in a venv) */
pythonPath: string;
/** Path to the OpenWakeWord Python script */
scriptPath: string;
/** Wake word model name or path */
modelName: string;
/** Detection threshold (0.0 to 1.0) */
threshold: number;
}
export interface HardwareConfig {
audio: AudioConfig;
wakeWord: WakeWordConfig;
}
export function loadHardwareConfig(): HardwareConfig {
return {
audio: {
captureDevice: process.env.AUDIO_CAPTURE_DEVICE || 'default',
playbackDevice: process.env.AUDIO_PLAYBACK_DEVICE || 'default',
sampleRate: parseInt(process.env.AUDIO_SAMPLE_RATE || '16000', 10),
bitDepth: 16,
channels: 1,
chunkDurationMs: parseInt(process.env.AUDIO_CHUNK_MS || '100', 10),
},
wakeWord: {
pythonPath: process.env.WAKEWORD_PYTHON_PATH || 'python3',
scriptPath: process.env.WAKEWORD_SCRIPT_PATH || './scripts/wake_word.py',
modelName: process.env.WAKEWORD_MODEL || 'hey_ti_pote',
threshold: parseFloat(process.env.WAKEWORD_THRESHOLD || '0.5'),
},
};
}

View File

@ -0,0 +1,2 @@
export { loadRobotConfig, type RobotConfig, type RobotMode, type TriggerMode } from './robot.config.js';
export { loadHardwareConfig, type HardwareConfig, type AudioConfig, type WakeWordConfig } from './hardware.config.js';

View File

@ -0,0 +1,75 @@
import { resolve } from 'node:path';
import { config } from 'dotenv';
/**
* Robot operating modes:
* - 'physical': Production on Pi with I2S audio
* - 'dev': Test on Pi with USB audio (e.g., USB headset)
* - 'simulator': PC dev, no real audio (future: mock services)
*/
export type RobotMode = 'physical' | 'dev' | 'simulator';
/**
* How conversations are triggered:
* - 'wakeword': OpenWakeWord listens for "Hey Ti-Pote" (requires Python + openwakeword)
* - 'keyboard': Press Enter in the terminal to start a conversation
*/
export type TriggerMode = 'wakeword' | 'keyboard';
export interface RobotConfig {
/** Operating mode */
mode: RobotMode;
/** How conversations are triggered */
triggerMode: TriggerMode;
/** Unique device identifier (generated at first setup or from env) */
deviceId: string;
/** JWT device token for cloud authentication */
deviceToken: string;
/** Cloud backend WebSocket URL */
cloudUrl: string;
/** Robot display name */
robotName: string;
/** Log level */
logLevel: string;
}
export function loadRobotConfig(): RobotConfig {
// Load the right .env file based on ROBOT_MODE or --env flag
const envFile = process.env.ENV_FILE || (process.env.ROBOT_MODE === 'dev' ? '.env.dev' : '.env');
config({ path: resolve(process.cwd(), envFile) });
const mode = (process.env.ROBOT_MODE || 'dev') as RobotMode;
if (!['physical', 'dev', 'simulator'].includes(mode)) {
throw new Error(`Invalid ROBOT_MODE: ${mode}. Must be 'physical', 'dev', or 'simulator'.`);
}
const triggerMode = (process.env.TRIGGER_MODE || 'wakeword') as TriggerMode;
if (!['wakeword', 'keyboard'].includes(triggerMode)) {
throw new Error(`Invalid TRIGGER_MODE: ${triggerMode}. Must be 'wakeword' or 'keyboard'.`);
}
return {
mode,
triggerMode,
deviceId: requireEnv('DEVICE_ID'),
deviceToken: requireEnv('DEVICE_TOKEN'),
cloudUrl: process.env.CLOUD_URL || 'ws://localhost:3000',
robotName: process.env.ROBOT_NAME || 'Ti-Pote',
logLevel: process.env.LOG_LEVEL || 'info',
};
}
function requireEnv(key: string): string {
const value = process.env[key];
if (!value) {
throw new Error(`Missing required environment variable: ${key}`);
}
return value;
}

View File

@ -0,0 +1,151 @@
import { loadRobotConfig, loadHardwareConfig } from './config/index.js';
import { CloudSocket } from './transport/index.js';
import {
AudioService,
WakeWordService,
KeyboardTriggerService,
HealthService,
OrchestratorService,
LocalStore,
WifiService,
} from './services/index.js';
import { type ITriggerService } from './services/trigger.interface.js';
import { SetupFlow } from './setup/index.js';
import { createLogger } from './utils/index.js';
const logger = createLogger('main', 'info');
async function main(): Promise<void> {
logger.info('╔══════════════════════════════════════╗');
logger.info('║ Ti-Pote Robot Client v0.0.1 ║');
logger.info('╚══════════════════════════════════════╝');
// ── Load configuration ──
const robotConfig = loadRobotConfig();
const hardwareConfig = loadHardwareConfig();
const store = new LocalStore();
logger.info(
{ mode: robotConfig.mode, triggerMode: robotConfig.triggerMode },
'Configuration loaded',
);
// ── Step 1: Ensure WiFi connectivity ──
// Only run the setup flow (captive portal) in physical/production mode.
// In dev mode, the Pi is already on the network (configured manually).
// In simulator mode, the dev machine is already on the network.
if (robotConfig.mode === 'physical') {
const wifiService = new WifiService();
const setupFlow = new SetupFlow(wifiService, store, robotConfig.robotName);
const wifiReady = await setupFlow.run();
if (!wifiReady) {
logger.fatal('WiFi setup failed — cannot continue without network');
process.exit(1);
}
} else {
logger.info('Skipping WiFi setup (mode: %s)', robotConfig.mode);
}
// ── Step 2: Resolve device credentials ──
// Use stored device credentials if available, fall back to env vars.
const deviceId = store.device?.id || robotConfig.deviceId;
const deviceToken = store.device?.token || robotConfig.deviceToken;
if (!deviceId || !deviceToken) {
logger.fatal(
'No device credentials found. Register this device on the backend first, ' +
'then set DEVICE_ID and DEVICE_TOKEN in your .env file.',
);
process.exit(1);
}
// Override config with resolved credentials
const resolvedConfig = { ...robotConfig, deviceId, deviceToken };
logger.info({ deviceId }, 'Device credentials resolved');
// ── Step 3: Initialize services ──
const cloudSocket = new CloudSocket(resolvedConfig);
const audioService = new AudioService(hardwareConfig.audio);
const healthService = new HealthService(cloudSocket);
// Choose trigger based on TRIGGER_MODE:
// wakeword → OpenWakeWord subprocess (requires Python + openwakeword)
// keyboard → Press Enter in terminal to talk
let trigger: ITriggerService;
if (resolvedConfig.triggerMode === 'wakeword') {
logger.info('Trigger: wake word (OpenWakeWord)');
trigger = new WakeWordService(hardwareConfig.wakeWord, hardwareConfig.audio);
} else {
logger.info('Trigger: keyboard (press Enter to talk)');
trigger = new KeyboardTriggerService();
}
const orchestrator = new OrchestratorService(
cloudSocket,
audioService,
trigger,
hardwareConfig.audio,
);
// ── Step 4: Connect to cloud ──
logger.info({ url: resolvedConfig.cloudUrl }, 'Connecting to cloud backend...');
try {
await cloudSocket.connect();
logger.info('Cloud connection established');
} catch (err) {
logger.fatal({ err }, 'Failed to connect to cloud backend');
process.exit(1);
}
// ── Step 5: Start services ──
healthService.start();
orchestrator.start();
if (resolvedConfig.triggerMode === 'wakeword') {
logger.info('Ti-Pote is ready! Say "Hey Ti-Pote" to start a conversation.');
} else {
logger.info('Ti-Pote is ready! Press Enter to start a conversation.');
}
// ── Graceful shutdown ──
const shutdown = async (signal: string) => {
logger.info({ signal }, 'Shutdown signal received');
await orchestrator.stop();
healthService.stop();
await audioService.destroy();
await cloudSocket.disconnect();
logger.info('Goodbye!');
process.exit(0);
};
process.on('SIGINT', () => shutdown('SIGINT'));
process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('uncaughtException', (err) => {
logger.fatal({ err }, 'Uncaught exception');
shutdown('uncaughtException');
});
process.on('unhandledRejection', (reason) => {
logger.fatal({ reason }, 'Unhandled promise rejection');
shutdown('unhandledRejection');
});
}
main().catch((err) => {
logger.fatal({ err }, 'Fatal error during startup');
process.exit(1);
});

View File

@ -0,0 +1,203 @@
import { ChildProcess, spawn } from 'node:child_process';
import { EventEmitter } from 'node:events';
import { type AudioConfig } from '../config/index.js';
import { createLogger, type Logger } from '../utils/index.js';
export interface AudioServiceEvents {
/** Emitted when a raw PCM audio chunk is captured from the microphone */
audio_chunk: (chunk: Buffer) => void;
/** Emitted when playback of a response finishes */
playback_done: () => void;
/** Emitted on audio errors */
error: (error: Error) => void;
}
/**
* Audio service for Raspberry Pi.
*
* Uses ALSA tools (arecord/aplay) via child processes.
* Works with any ALSA-compatible audio device:
* - I2S (INMP441 mic, MAX98357 amp) connected directly to Pi GPIO
* - USB audio devices
* - Default system audio
*
* Audio format: PCM signed 16-bit little-endian, mono, 16kHz
*/
export class AudioService extends EventEmitter {
private captureProcess: ChildProcess | null = null;
private readonly logger: Logger;
private _isCapturing = false;
private _isPlaying = false;
private _stoppedManually = false;
constructor(private readonly config: AudioConfig) {
super();
this.logger = createLogger('audio', 'info');
}
get isCapturing(): boolean {
return this._isCapturing;
}
get isPlaying(): boolean {
return this._isPlaying;
}
/**
* Start capturing audio from the microphone.
* Emits 'audio_chunk' events with raw PCM buffers.
*/
startCapture(): void {
if (this._isCapturing) {
this.logger.warn('Already capturing audio');
return;
}
this.logger.info(
{ device: this.config.captureDevice, sampleRate: this.config.sampleRate },
'Starting audio capture',
);
// arecord outputs raw PCM to stdout
// -D: ALSA device
// -f: format (S16_LE = signed 16-bit little-endian)
// -r: sample rate
// -c: channels
// -t: type (raw = no header)
// --buffer-size: in frames, controls latency
const bufferFrames = Math.floor(this.config.sampleRate * (this.config.chunkDurationMs / 1000));
this.captureProcess = spawn('arecord', [
'-D', this.config.captureDevice,
'-f', 'S16_LE',
'-r', String(this.config.sampleRate),
'-c', String(this.config.channels),
'-t', 'raw',
'--buffer-size', String(bufferFrames * 4),
'--period-size', String(bufferFrames),
], {
stdio: ['ignore', 'pipe', 'pipe'],
});
this._isCapturing = true;
let chunkCount = 0;
this.captureProcess.stdout?.on('data', (chunk: Buffer) => {
chunkCount++;
if (chunkCount === 1) {
this.logger.info({ bytes: chunk.length }, 'First audio chunk received from arecord');
}
this.emit('audio_chunk', chunk);
});
this.captureProcess.stderr?.on('data', (data: Buffer) => {
const msg = data.toString().trim();
if (msg) {
this.logger.debug({ msg }, 'arecord stderr');
}
});
this.captureProcess.on('error', (err) => {
this.logger.error({ err }, 'arecord process error');
this._isCapturing = false;
this.emit('error', new Error(`Audio capture failed: ${err.message}`));
});
this.captureProcess.on('exit', (code) => {
this._isCapturing = false;
if (code !== null && code !== 0 && !this._stoppedManually) {
this.logger.warn({ code }, 'arecord exited with non-zero code');
}
this._stoppedManually = false;
});
}
/**
* Stop capturing audio from the microphone.
*/
stopCapture(): void {
if (!this.captureProcess) return;
this.logger.info('Stopping audio capture');
this._stoppedManually = true;
this.captureProcess.kill('SIGTERM');
this.captureProcess = null;
this._isCapturing = false;
}
/**
* Play audio through the speaker.
* Accepts either raw PCM or WAV (with RIFF header) data.
*
* @returns Promise that resolves when playback is complete
*/
async play(audioBuffer: Buffer): Promise<void> {
if (this._isPlaying) {
this.logger.warn('Already playing audio, queueing...');
}
this._isPlaying = true;
const isWav = audioBuffer.length > 4 && audioBuffer.toString('ascii', 0, 4) === 'RIFF';
return new Promise((resolve, reject) => {
const args = isWav
? ['-D', this.config.playbackDevice, '-t', 'wav', '-']
: [
'-D', this.config.playbackDevice,
'-f', 'S16_LE',
'-r', String(this.config.sampleRate),
'-c', String(this.config.channels),
'-t', 'raw',
'-',
];
const playProcess = spawn('aplay', args, {
stdio: ['pipe', 'ignore', 'pipe'],
});
playProcess.stderr?.on('data', (data: Buffer) => {
const msg = data.toString().trim();
if (msg && !msg.startsWith('Playing') && !msg.startsWith('Warning')) {
this.logger.error({ msg }, 'aplay stderr');
}
});
playProcess.on('error', (err) => {
this._isPlaying = false;
reject(new Error(`Audio playback failed: ${err.message}`));
});
playProcess.on('exit', (code) => {
this._isPlaying = false;
if (code === 0 || code === null) {
this.emit('playback_done');
resolve();
} else {
reject(new Error(`aplay exited with code ${code}`));
}
});
// Write audio data to aplay's stdin and close it
playProcess.stdin?.write(audioBuffer);
playProcess.stdin?.end();
});
}
/**
* Stop any currently playing audio.
*/
stopPlayback(): void {
// aplay is spawned per-play, so we can't easily stop it here
// For interrupt support, we'd track the play process
this._isPlaying = false;
}
/**
* Clean up resources.
*/
async destroy(): Promise<void> {
this.stopCapture();
this.removeAllListeners();
}
}

View File

@ -0,0 +1,57 @@
import { EventEmitter } from 'node:events';
import { createLogger, type Logger } from '../utils/index.js';
import { type CloudSocket } from '../transport/cloud-socket.js';
/**
* Health monitoring service.
* Tracks connectivity, system health, and provides diagnostics.
*/
export class HealthService extends EventEmitter {
private readonly logger: Logger;
private heartbeatInterval: ReturnType<typeof setInterval> | null = null;
private _isCloudConnected = false;
constructor(private readonly cloudSocket: CloudSocket) {
super();
this.logger = createLogger('health', 'info');
this.cloudSocket.on('connected', () => {
this._isCloudConnected = true;
this.logger.info('Cloud connection established');
});
this.cloudSocket.on('disconnected', () => {
this._isCloudConnected = false;
this.logger.warn('Cloud connection lost');
});
}
get isCloudConnected(): boolean {
return this._isCloudConnected;
}
/**
* Start periodic health checks.
*/
start(intervalMs = 30_000): void {
this.logger.info({ intervalMs }, 'Starting health monitoring');
this.heartbeatInterval = setInterval(() => {
this.logger.debug({
cloud: this._isCloudConnected,
uptime: process.uptime(),
memoryMB: Math.round(process.memoryUsage().heapUsed / 1024 / 1024),
}, 'Health check');
}, intervalMs);
}
/**
* Stop health monitoring.
*/
stop(): void {
if (this.heartbeatInterval) {
clearInterval(this.heartbeatInterval);
this.heartbeatInterval = null;
}
}
}

View File

@ -0,0 +1,8 @@
export { AudioService } from './audio.service.js';
export { WakeWordService } from './wake-word.service.js';
export { KeyboardTriggerService } from './keyboard-trigger.service.js';
export { HealthService } from './health.service.js';
export { OrchestratorService } from './orchestrator.service.js';
export { LocalStore } from './local-store.service.js';
export { WifiService } from './wifi.service.js';
export { type ITriggerService } from './trigger.interface.js';

View File

@ -0,0 +1,91 @@
import { EventEmitter } from 'node:events';
import * as readline from 'node:readline';
import { type ITriggerService } from './trigger.interface.js';
import { createLogger, type Logger } from '../utils/index.js';
/**
* Keyboard trigger for dev/test mode.
*
* Replaces the wake word: press Enter in the terminal to start a conversation.
* Perfect for testing on a Pi over SSH without needing OpenWakeWord installed.
*/
export class KeyboardTriggerService extends EventEmitter implements ITriggerService {
private rl: readline.Interface | null = null;
private readonly logger: Logger;
private _isListening = false;
private _isPaused = false;
constructor() {
super();
this.logger = createLogger('keyboard-trigger', 'info');
}
get isListening(): boolean {
return this._isListening;
}
/**
* Start listening for Enter key presses.
*/
start(): void {
if (this._isListening) return;
this.rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
this._isListening = true;
this._isPaused = false;
this.prompt();
this.rl.on('line', () => {
if (this._isPaused) {
this.logger.debug('Key press ignored — currently paused');
return;
}
this.logger.info('⏎ Enter pressed — triggering conversation');
this.emit('detected');
});
this.rl.on('close', () => {
this._isListening = false;
});
}
private prompt(): void {
if (this._isPaused || !this._isListening) return;
this.logger.info('──────────────────────────────────────');
this.logger.info('Appuie sur Entrée pour parler à Ti-Pote...');
}
/**
* Pause: ignore key presses (e.g., while the robot is speaking).
*/
pause(): void {
this._isPaused = true;
}
/**
* Resume after pause.
*/
resume(): void {
this._isPaused = false;
this.prompt();
}
/**
* Stop and clean up.
*/
stop(): void {
if (this.rl) {
this.rl.close();
this.rl = null;
}
this._isListening = false;
this._isPaused = false;
this.removeAllListeners();
}
}

View File

@ -0,0 +1,169 @@
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
import { dirname } from 'node:path';
import { createLogger, type Logger } from '../utils/index.js';
/**
* What we persist on the SD card.
*/
export interface LocalStoreData {
/** WiFi credentials */
wifi?: {
ssid: string;
password: string;
};
/** Device registration (received from backend on first setup) */
device?: {
id: string;
token: string;
homeId?: string;
};
/** Robot preferences */
preferences?: {
robotName?: string;
volume?: number;
wakeWordModel?: string;
language?: string;
};
/** Tracks first setup state */
setupComplete?: boolean;
}
const DEFAULT_STORE_PATH = '/home/pi/.tipote/config.json';
/**
* Simple JSON file store for persisting robot config on the SD card.
*
* Stores WiFi credentials, device token, and preferences.
* Survives reboots and code redeployments unlike .env files,
* this is the robot's own "memory" of who it is and where it connects.
*/
export class LocalStore {
private data: LocalStoreData;
private readonly filePath: string;
private readonly logger: Logger;
constructor(filePath?: string) {
this.logger = createLogger('local-store', 'info');
this.filePath = filePath || process.env.STORE_PATH || DEFAULT_STORE_PATH;
this.data = this.load();
}
/**
* Load stored data from disk. Returns empty object if file doesn't exist.
*/
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');
return parsed;
}
} catch (err) {
this.logger.error({ err }, 'Failed to load config file, starting fresh');
}
return {};
}
/**
* Save current data to disk.
*/
private save(): void {
try {
const dir = dirname(this.filePath);
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true });
}
writeFileSync(this.filePath, JSON.stringify(this.data, null, 2), 'utf-8');
this.logger.debug({ path: this.filePath }, 'Config saved to disk');
} catch (err) {
this.logger.error({ err }, 'Failed to save config file');
}
}
// ── WiFi ──
get wifi(): LocalStoreData['wifi'] {
return this.data.wifi;
}
get hasWifi(): boolean {
return !!this.data.wifi?.ssid;
}
setWifi(ssid: string, password: string): void {
this.data.wifi = { ssid, password };
this.save();
this.logger.info({ ssid }, 'WiFi credentials saved');
}
clearWifi(): void {
delete this.data.wifi;
this.save();
this.logger.info('WiFi credentials cleared');
}
// ── Device ──
get device(): LocalStoreData['device'] {
return this.data.device;
}
get hasDevice(): boolean {
return !!this.data.device?.id && !!this.data.device?.token;
}
setDevice(id: string, token: string, homeId?: string): void {
this.data.device = { id, token, homeId };
this.save();
this.logger.info({ deviceId: id }, 'Device credentials saved');
}
// ── Preferences ──
get preferences(): LocalStoreData['preferences'] {
return this.data.preferences ?? {};
}
setPreference<K extends keyof NonNullable<LocalStoreData['preferences']>>(
key: K,
value: NonNullable<LocalStoreData['preferences']>[K],
): void {
if (!this.data.preferences) this.data.preferences = {};
this.data.preferences[key] = value;
this.save();
}
// ── Setup state ──
get isSetupComplete(): boolean {
return this.data.setupComplete === true;
}
markSetupComplete(): void {
this.data.setupComplete = true;
this.save();
this.logger.info('Setup marked as complete');
}
// ── Reset ──
/**
* Factory reset: clear all stored data.
*/
factoryReset(): void {
this.data = {};
this.save();
this.logger.info('Factory reset — all config cleared');
}
/**
* Get all stored data (for debug).
*/
getAll(): Readonly<LocalStoreData> {
return { ...this.data };
}
}

View File

@ -0,0 +1,336 @@
import { EventEmitter } from 'node:events';
import { type AudioConfig } from '../config/index.js';
import { type CloudSocket, type RobotState, type ResponseTextEvent } from '../transport/cloud-socket.js';
import { type AudioService } from './audio.service.js';
import { type ITriggerService } from './trigger.interface.js';
import { createLogger, type Logger } from '../utils/index.js';
/**
* Conversation orchestrator the main brain of the robot client.
*
* Coordinates the full conversation flow:
* 1. Trigger detected (wake word or keyboard) notify cloud, start audio capture
* 2. Audio chunks forward to cloud for STT
* 3. User stops speaking notify cloud
* 4. Cloud sends response (text + audio) play audio through speaker
* 5. Playback done resume trigger listening
*
* State machine:
* IDLE (trigger) LISTENING (speech end) THINKING (response) SPEAKING IDLE
*/
export class OrchestratorService extends EventEmitter {
private readonly logger: Logger;
private state: RobotState = 'idle';
/** Timer for Voice Activity Detection (silence timeout) */
private silenceTimer: ReturnType<typeof setTimeout> | null = null;
private readonly silenceTimeoutMs = 2000; // 2s of silence = speech end
private readonly initialGracePeriodMs = 3000; // 3s grace period before silence detection kicks in
/** Track when the last audio chunk was received */
private lastAudioChunkTime = 0;
private conversationStartTime = 0;
private hasDetectedSpeech = false;
/** Buffer audio chunks until the cloud confirms it's ready (STT stream open) */
private audioBuffer: Buffer[] = [];
private cloudReady = false;
constructor(
private readonly cloudSocket: CloudSocket,
private readonly audioService: AudioService,
private readonly trigger: ITriggerService,
private readonly audioConfig: AudioConfig,
) {
super();
this.logger = createLogger('orchestrator', 'info');
}
/**
* Initialize the orchestrator: wire up all event handlers.
*/
start(): void {
this.logger.info('Starting conversation orchestrator');
// ── Trigger (wake word or keyboard) → start conversation ──
this.trigger.on('detected', () => {
this.handleTriggerDetected();
});
// ── Audio chunks from microphone ──
this.audioService.on('audio_chunk', (chunk: Buffer) => {
this.handleAudioChunk(chunk);
});
// ── Cloud responses ──
this.cloudSocket.on('status', (event) => {
this.handleStatusChange(event.state);
});
this.cloudSocket.on('response_text', (event) => {
this.handleResponse(event);
});
// ── Start trigger ──
this.trigger.start();
this.state = 'idle';
this.logger.info('Orchestrator ready — waiting for trigger');
}
/**
* Handle trigger detection (wake word or Enter key).
*/
private async handleTriggerDetected(): Promise<void> {
if (this.state !== 'idle') {
this.logger.debug({ state: this.state }, 'Trigger detected but not idle, ignoring');
return;
}
this.logger.info('👂 Trigger detected — listening...');
this.state = 'listening';
// Pause trigger and wait for it to fully release the audio device
await this.trigger.pause();
// Reset cloud readiness and audio buffer
this.cloudReady = false;
this.audioBuffer = [];
// Notify cloud
this.cloudSocket.sendWakeWordDetected();
// Start capturing audio from the microphone
this.audioService.startCapture();
// Start silence detection
this.lastAudioChunkTime = Date.now();
this.conversationStartTime = Date.now();
this.hasDetectedSpeech = false;
this.startSilenceDetection();
}
/**
* Handle incoming audio chunks from the microphone.
*/
private handleAudioChunk(chunk: Buffer): void {
if (this.state !== 'listening') return;
if (!this.cloudReady) {
// Cloud STT stream not ready yet — buffer chunks so we don't lose the first words
this.audioBuffer.push(chunk);
} else {
// Forward raw PCM to cloud for STT
this.cloudSocket.sendAudioChunk(chunk, this.audioConfig.sampleRate);
}
// Update silence tracking
// Simple VAD: if we're receiving non-silent audio, reset the silence timer
if (this.isAudioSignificant(chunk)) {
if (!this.hasDetectedSpeech) {
this.logger.info('🗣️ Speech detected — listening...');
this.hasDetectedSpeech = true;
}
this.lastAudioChunkTime = Date.now();
}
}
/**
* Basic Voice Activity Detection: check if audio chunk contains significant signal.
* Uses RMS (root mean square) amplitude threshold.
*/
private isAudioSignificant(chunk: Buffer, threshold = 200): boolean {
let sumSquares = 0;
const samples = chunk.length / 2; // 16-bit = 2 bytes per sample
for (let i = 0; i < chunk.length - 1; i += 2) {
const sample = chunk.readInt16LE(i);
sumSquares += sample * sample;
}
const rms = Math.sqrt(sumSquares / samples);
return rms > threshold;
}
/**
* Start a timer that triggers speech_end after silence.
*/
private startSilenceDetection(): void {
this.clearSilenceTimer();
this.silenceTimer = setInterval(() => {
if (this.state !== 'listening') {
this.clearSilenceTimer();
return;
}
const timeSinceStart = Date.now() - this.conversationStartTime;
const silenceDuration = Date.now() - this.lastAudioChunkTime;
// Don't end speech during the initial grace period (give user time to start talking)
if (!this.hasDetectedSpeech && timeSinceStart < this.initialGracePeriodMs) {
return;
}
// If user never spoke and grace period is over, return to idle (conversation over)
if (!this.hasDetectedSpeech && timeSinceStart >= this.initialGracePeriodMs) {
this.logger.info('💤 No speech detected — back to sleep');
this.clearSilenceTimer();
this.audioService.stopCapture();
this.returnToIdle();
return;
}
// Normal silence detection after speech was detected
if (silenceDuration >= this.silenceTimeoutMs) {
this.logger.info({ silenceDuration }, 'Silence detected — ending speech');
this.handleSpeechEnd();
}
}, 200); // Check every 200ms
}
/**
* Handle end of user speech.
*/
private handleSpeechEnd(): void {
if (this.state !== 'listening') return;
this.state = 'thinking';
this.clearSilenceTimer();
// Stop audio capture
this.audioService.stopCapture();
// Notify cloud that speech has ended
this.cloudSocket.sendSpeechEnd();
this.logger.info('🤔 Thinking...');
}
/**
* Handle status changes from the cloud.
*/
private handleStatusChange(newState: RobotState): void {
this.logger.debug({ from: this.state, to: newState }, 'State change from cloud');
// Cloud confirmed STT stream is ready — flush buffered audio
if (newState === 'listening' && this.state === 'listening' && !this.cloudReady) {
this.cloudReady = true;
if (this.audioBuffer.length > 0) {
this.logger.info(
{ bufferedChunks: this.audioBuffer.length },
'Cloud ready — flushing buffered audio',
);
for (const chunk of this.audioBuffer) {
this.cloudSocket.sendAudioChunk(chunk, this.audioConfig.sampleRate);
}
this.audioBuffer = [];
}
}
// The cloud drives the state machine for thinking → speaking → idle
// We only override our local state if it makes sense
if (newState === 'idle' && this.state !== 'idle') {
this.returnToIdle();
}
}
/**
* Handle a response from the cloud (text + audio).
*/
private async handleResponse(event: ResponseTextEvent): Promise<void> {
this.logger.info('🔊 Speaking: %s', event.text);
this.state = 'speaking';
if (event.audio) {
try {
// Decode base64 WAV audio and play it
const audioBuffer = Buffer.from(event.audio, 'base64');
await this.audioService.play(audioBuffer);
} catch (err) {
this.logger.error({ err }, 'Failed to play audio response');
}
}
// After playback, continue listening for more speech (continuous conversation)
this.continueListening();
}
/**
* Continue the conversation go back to listening immediately after playback.
* No need to re-trigger (wake word or Enter), just start capturing audio again.
*/
private continueListening(): void {
this.logger.info('👂 Listening...');
this.state = 'listening';
// Reset cloud readiness and audio buffer
this.cloudReady = false;
this.audioBuffer = [];
// Notify cloud we're listening again (so it opens a new STT stream)
this.cloudSocket.sendWakeWordDetected();
// Start capturing audio again
this.audioService.startCapture();
// Reset silence detection
this.lastAudioChunkTime = Date.now();
this.conversationStartTime = Date.now();
this.hasDetectedSpeech = false;
this.startSilenceDetection();
}
/**
* Return to idle state: resume trigger listening.
*/
private returnToIdle(): void {
this.state = 'idle';
this.clearSilenceTimer();
// Resume trigger
if (!this.trigger.isListening) {
this.trigger.start();
} else {
this.trigger.resume();
}
this.logger.info('💤 Idle — waiting for wake word...');
}
/**
* Handle user interrupt (e.g., button press, or second wake word).
*/
interrupt(): void {
this.logger.info('User interrupt');
this.audioService.stopCapture();
this.audioService.stopPlayback();
this.clearSilenceTimer();
this.cloudSocket.sendUserInterrupt();
this.returnToIdle();
}
private clearSilenceTimer(): void {
if (this.silenceTimer) {
clearInterval(this.silenceTimer);
this.silenceTimer = null;
}
}
/**
* Stop the orchestrator and clean up resources.
*/
async stop(): Promise<void> {
this.logger.info('Stopping orchestrator');
this.clearSilenceTimer();
this.audioService.stopCapture();
this.trigger.stop();
this.removeAllListeners();
}
}

View File

@ -0,0 +1,42 @@
/**
* Common interface for conversation trigger services.
*
* Both WakeWordService and KeyboardTriggerService implement this interface,
* allowing the orchestrator to work with any trigger mechanism.
*/
export interface ITriggerService {
/** Whether the trigger is currently active and listening */
readonly isListening: boolean;
/** Start listening for the trigger */
start(): void;
/** Temporarily pause (e.g., while robot is speaking) */
pause(): void | Promise<void>;
/** Resume after pause */
resume(): void;
/** Stop permanently and clean up resources */
stop(): void;
/** EventEmitter methods */
on(event: 'detected', listener: () => void): this;
on(event: 'error', listener: (error: Error) => void): this;
emit(event: 'detected'): boolean;
emit(event: 'error', error: Error): boolean;
}
/**
* Type guard to ensure a service implements ITriggerService.
*/
export function isTriggerService(obj: unknown): obj is ITriggerService {
return (
obj !== null &&
typeof obj === 'object' &&
'isListening' in obj &&
'start' in obj &&
'pause' in obj &&
'stop' in obj
);
}

View File

@ -0,0 +1,200 @@
import { ChildProcess, spawn } from 'node:child_process';
import { EventEmitter } from 'node:events';
import { type WakeWordConfig, type AudioConfig } from '../config/index.js';
import { createLogger, type Logger } from '../utils/index.js';
export interface WakeWordServiceEvents {
/** Emitted when the wake word is detected */
detected: () => void;
/** Emitted when the engine is ready */
ready: () => void;
/** Emitted on errors */
error: (error: Error) => void;
}
/**
* Wake word detection service.
*
* Runs OpenWakeWord as a **long-lived** Python subprocess.
* The model is loaded once at startup; pause/resume is handled via
* PAUSE/RESUME commands on stdin, so the audio device is released
* while arecord is capturing, then reclaimed when listening resumes.
*/
export class WakeWordService extends EventEmitter {
private process: ChildProcess | null = null;
private readonly logger: Logger;
private _isListening = false;
private _isPaused = false;
private _streamClosed = false;
constructor(
private readonly wakeWordConfig: WakeWordConfig,
private readonly audioConfig: AudioConfig,
) {
super();
this.logger = createLogger('wake-word', 'info');
}
get isListening(): boolean {
return this._isListening && !this._isPaused;
}
/**
* Start the wake word Python subprocess.
* The model is loaded once; subsequent pause/resume cycles are fast.
*/
start(): void {
if (this.process) {
// Process already running — just resume if paused
if (this._isPaused) {
this.resume();
}
return;
}
this.logger.info(
{ model: this.wakeWordConfig.modelName, threshold: this.wakeWordConfig.threshold },
'Starting wake word detection',
);
this.process = spawn(this.wakeWordConfig.pythonPath, [
this.wakeWordConfig.scriptPath,
'--model', this.wakeWordConfig.modelName,
'--threshold', String(this.wakeWordConfig.threshold),
'--device', this.audioConfig.captureDevice,
'--sample-rate', String(this.audioConfig.sampleRate),
], {
stdio: ['pipe', 'pipe', 'pipe'],
});
this._isListening = true;
this._isPaused = false;
// ── stdout: DETECTED events ──
this.process.stdout?.on('data', (data: Buffer) => {
const lines = data.toString().trim().split('\n');
for (const line of lines) {
if (line.trim() === 'DETECTED') {
this.logger.info('🎙️ Wake word detected!');
this.emit('detected');
} else {
this.logger.debug({ line }, 'Wake word stdout');
}
}
});
// ── stderr: status messages ──
this.process.stderr?.on('data', (data: Buffer) => {
const lines = data.toString().trim().split('\n');
for (const line of lines) {
const msg = line.trim();
if (!msg) continue;
if (msg === 'READY') {
this.logger.info('🎤 Wake word engine ready — listening...');
this.emit('ready');
} else if (msg === 'PAUSED') {
this._streamClosed = false;
this.logger.debug('Wake word paused');
} else if (msg === 'STREAM_CLOSED') {
this._streamClosed = true;
this.logger.debug('Wake word audio stream closed');
} else if (msg === 'RESUMED') {
this.logger.debug('Wake word resumed');
} else if (msg === 'STREAM_REOPENED') {
this.logger.debug('Wake word audio stream reopened');
} else if (msg.startsWith('Loading wake word model')) {
this.logger.info('⏳ Loading wake word model...');
} else if (msg.startsWith('Wake word model loaded')) {
this.logger.info('✅ Wake word model loaded');
} else if (msg.startsWith('Matched device') || msg.startsWith('Using device')) {
this.logger.info(`🔊 ${msg}`);
} else {
this.logger.debug({ msg }, 'Wake word stderr');
}
}
});
this.process.on('error', (err) => {
this._isListening = false;
this.logger.error({ err }, 'Wake word process error');
this.emit('error', new Error(`Wake word process failed: ${err.message}`));
});
this.process.on('exit', (code) => {
this._isListening = false;
this._isPaused = false;
this.process = null;
if (code !== 0 && code !== null) {
this.logger.warn({ code }, 'Wake word process exited unexpectedly');
// Auto-restart after a short delay
setTimeout(() => {
this.logger.info('Restarting wake word detection...');
this.start();
}, 2000);
}
});
}
/**
* Pause wake word detection.
* Sends PAUSE command to Python subprocess which closes the audio stream,
* freeing the device for arecord. Returns a promise that resolves when
* the audio stream is confirmed closed.
*/
pause(): Promise<void> {
if (!this.process || this._isPaused) return Promise.resolve();
this._isPaused = true;
this._streamClosed = false;
this.process.stdin?.write('PAUSE\n');
// Wait for the stream to be closed (so arecord can use the device)
return new Promise((resolve) => {
const checkInterval = setInterval(() => {
if (this._streamClosed || !this.process) {
clearInterval(checkInterval);
resolve();
}
}, 50);
// Safety timeout
setTimeout(() => {
clearInterval(checkInterval);
resolve();
}, 2000);
});
}
/**
* Resume wake word detection after pause.
* The Python subprocess reopens the audio stream (fast, no model reload).
*/
resume(): void {
if (!this.process || !this._isPaused) return;
this._isPaused = false;
this.process.stdin?.write('RESUME\n');
this.logger.info('🎤 Resuming wake word listening...');
}
/**
* Stop wake word detection permanently.
*/
stop(): void {
if (this.process) {
this.process.stdin?.write('QUIT\n');
// Give it a moment to exit cleanly, then force kill
setTimeout(() => {
if (this.process) {
this.process.kill('SIGTERM');
this.process = null;
}
}, 500);
}
this._isListening = false;
this._isPaused = false;
this.removeAllListeners();
}
}

View File

@ -0,0 +1,227 @@
import { exec } from 'node:child_process';
import { promisify } from 'node:util';
import { createLogger, type Logger } from '../utils/index.js';
const execAsync = promisify(exec);
export interface WifiNetwork {
ssid: string;
signal: number; // dBm
security: string; // e.g., 'WPA2', 'WPA3', 'Open'
frequency: number; // MHz
}
/**
* WiFi management service for Raspberry Pi.
*
* Uses `nmcli` (NetworkManager) which is available on Raspberry Pi OS.
* Handles scanning, connecting, AP mode, and connectivity checking.
*/
export class WifiService {
private readonly logger: Logger;
constructor() {
this.logger = createLogger('wifi', 'info');
}
/**
* Check if the Pi is currently connected to a WiFi network.
*/
async isConnected(): Promise<boolean> {
try {
const { stdout } = await execAsync('nmcli -t -f TYPE,STATE device | grep wifi');
return stdout.includes('connected') && !stdout.includes('disconnected');
} catch {
return false;
}
}
/**
* Get the current WiFi SSID, if connected.
*/
async currentSSID(): Promise<string | null> {
try {
const { stdout } = await execAsync('iwgetid -r');
const ssid = stdout.trim();
return ssid || null;
} catch {
return null;
}
}
/**
* Check internet connectivity by pinging a reliable host.
*/
async hasInternet(): Promise<boolean> {
try {
await execAsync('ping -c 1 -W 3 8.8.8.8');
return true;
} catch {
return false;
}
}
/**
* Scan available WiFi networks.
* Returns a list sorted by signal strength (strongest first).
*/
async scan(): Promise<WifiNetwork[]> {
try {
// Force a fresh scan
await execAsync('nmcli device wifi rescan').catch(() => { /* ignore */ });
// Wait a moment for scan results
await new Promise((resolve) => setTimeout(resolve, 2000));
const { stdout } = await execAsync(
'nmcli -t -f SSID,SIGNAL,SECURITY,FREQ device wifi list',
);
const networks: WifiNetwork[] = [];
const seen = new Set<string>();
for (const line of stdout.trim().split('\n')) {
if (!line) continue;
const parts = line.split(':');
if (parts.length < 4) continue;
const ssid = parts[0];
if (!ssid || seen.has(ssid)) continue;
seen.add(ssid);
networks.push({
ssid,
signal: parseInt(parts[1], 10),
security: parts[2] || 'Open',
frequency: parseInt(parts[3], 10),
});
}
// Sort by signal strength (higher = better)
networks.sort((a, b) => b.signal - a.signal);
this.logger.info({ count: networks.length }, 'WiFi scan complete');
return networks;
} catch (err) {
this.logger.error({ err }, 'WiFi scan failed');
return [];
}
}
/**
* Connect to a WiFi network.
*
* @returns true if connection succeeded
*/
async connect(ssid: string, password: string): Promise<boolean> {
this.logger.info({ ssid }, 'Connecting to WiFi network...');
try {
// First, try to connect (this creates a connection profile automatically)
await execAsync(
`nmcli device wifi connect "${this.escapeShell(ssid)}" password "${this.escapeShell(password)}"`,
);
// Verify connection
const connected = await this.isConnected();
if (connected) {
this.logger.info({ ssid }, 'WiFi connected successfully');
return true;
}
this.logger.warn({ ssid }, 'WiFi connection attempt returned but not connected');
return false;
} catch (err) {
this.logger.error({ err, ssid }, 'WiFi connection failed');
return false;
}
}
/**
* Disconnect from current WiFi network.
*/
async disconnect(): Promise<void> {
try {
await execAsync('nmcli device disconnect wlan0');
this.logger.info('WiFi disconnected');
} catch (err) {
this.logger.error({ err }, 'WiFi disconnect failed');
}
}
/**
* Start Access Point mode for the captive portal.
* Creates a hotspot named "Ti-Pote-XXXX".
*
* @param apName - Name of the hotspot (default: Ti-Pote-XXXX based on hostname)
*/
async startAP(apName?: string): Promise<boolean> {
const name = apName || `Ti-Pote-${await this.getShortId()}`;
this.logger.info({ apName: name }, 'Starting WiFi Access Point...');
try {
// Stop any existing hotspot
await execAsync('nmcli connection down Hotspot').catch(() => { /* ignore */ });
await execAsync('nmcli connection delete Hotspot').catch(() => { /* ignore */ });
// Create and start an open hotspot (no password — easier for setup)
await execAsync(
`nmcli connection add type wifi ifname wlan0 con-name Hotspot autoconnect no ssid "${this.escapeShell(name)}" && ` +
`nmcli connection modify Hotspot 802-11-wireless.mode ap ipv4.method shared ipv4.addresses 192.168.4.1/24 && ` +
`nmcli connection up Hotspot`,
);
this.logger.info({ apName: name }, 'Access Point started');
return true;
} catch (err) {
this.logger.error({ err }, 'Failed to start Access Point');
return false;
}
}
/**
* Stop Access Point mode.
*/
async stopAP(): Promise<void> {
try {
await execAsync('nmcli connection down Hotspot');
await execAsync('nmcli connection delete Hotspot').catch(() => { /* ignore */ });
this.logger.info('Access Point stopped');
} catch (err) {
this.logger.error({ err }, 'Failed to stop Access Point');
}
}
/**
* Get the Pi's IP address on the current network.
*/
async getIPAddress(): Promise<string | null> {
try {
const { stdout } = await execAsync(
"hostname -I | awk '{print $1}'",
);
return stdout.trim() || null;
} catch {
return null;
}
}
/**
* Generate a short unique ID from the Pi's hostname or MAC address.
*/
private async getShortId(): Promise<string> {
try {
const { stdout } = await execAsync("cat /sys/class/net/wlan0/address | tr -d ':'");
return stdout.trim().slice(-4).toUpperCase();
} catch {
return Math.random().toString(36).substring(2, 6).toUpperCase();
}
}
/**
* Escape a string for safe use in shell commands.
*/
private escapeShell(str: string): string {
return str.replace(/(['"\\$`!])/g, '\\$1');
}
}

View File

@ -0,0 +1,224 @@
import { createServer, type IncomingMessage, type ServerResponse, type Server } from 'node:http';
import { type WifiService } from '../services/wifi.service.js';
import { type LocalStore } from '../services/local-store.service.js';
import { createLogger, type Logger } from '../utils/index.js';
import { portalHTML } from './portal.html.js';
const PORTAL_PORT = 80; // Standard HTTP port — captive portals need this
/**
* Captive Portal server.
*
* When the robot starts in AP mode, this HTTP server serves:
* - GET / the setup HTML page
* - GET /api/wifi/scan JSON list of available WiFi networks
* - POST /api/wifi/connect attempt to connect to a WiFi network
*
* Also handles captive portal detection URLs from iOS/Android/Windows
* by redirecting them to the portal page.
*/
export class CaptivePortal {
private server: Server | null = null;
private readonly logger: Logger;
private resolveSetup: ((value: boolean) => void) | null = null;
constructor(
private readonly wifiService: WifiService,
private readonly store: LocalStore,
private readonly robotName: string,
) {
this.logger = createLogger('captive-portal', 'info');
}
/**
* Start the captive portal server.
* Returns a promise that resolves when WiFi is successfully configured.
*/
async start(): Promise<boolean> {
return new Promise((resolve) => {
this.resolveSetup = resolve;
this.server = createServer((req, res) => {
this.handleRequest(req, res);
});
this.server.listen(PORTAL_PORT, '0.0.0.0', () => {
this.logger.info({ port: PORTAL_PORT }, 'Captive portal running');
});
this.server.on('error', (err: NodeJS.ErrnoException) => {
if (err.code === 'EACCES') {
this.logger.error(
'Port 80 requires root. Run with sudo or use: sudo setcap cap_net_bind_service=+ep $(which node)',
);
} else {
this.logger.error({ err }, 'Captive portal server error');
}
resolve(false);
});
});
}
/**
* Stop the captive portal server.
*/
async stop(): Promise<void> {
return new Promise((resolve) => {
if (this.server) {
this.server.close(() => {
this.logger.info('Captive portal stopped');
resolve();
});
} else {
resolve();
}
});
}
private async handleRequest(req: IncomingMessage, res: ServerResponse): Promise<void> {
const url = req.url || '/';
const method = req.method || 'GET';
this.logger.debug({ method, url }, 'Request');
try {
// ── Captive portal detection URLs ──
// iOS, Android, Windows, etc. check these URLs to detect captive portals.
// Redirecting them triggers the captive portal popup on the user's device.
if (this.isCaptivePortalCheck(url)) {
res.writeHead(302, { Location: 'http://192.168.4.1/' });
res.end();
return;
}
// ── API routes ──
if (url === '/api/wifi/scan' && method === 'GET') {
await this.handleScan(res);
return;
}
if (url === '/api/wifi/connect' && method === 'POST') {
await this.handleConnect(req, res);
return;
}
// ── Portal HTML page ──
res.writeHead(200, {
'Content-Type': 'text/html; charset=utf-8',
'Cache-Control': 'no-cache',
});
res.end(portalHTML(this.robotName));
} catch (err) {
this.logger.error({ err, url }, 'Request handler error');
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Internal server error' }));
}
}
/**
* Check if the URL is a captive portal detection request.
*/
private isCaptivePortalCheck(url: string): boolean {
const captivePortalPaths = [
'/generate_204', // Android
'/gen_204', // Android
'/hotspot-detect.html', // iOS / macOS
'/library/test/success.html', // iOS
'/ncsi.txt', // Windows
'/connecttest.txt', // Windows
'/redirect', // Windows
'/canonical.html', // Firefox
'/success.txt', // Firefox
];
return captivePortalPaths.some((path) => url.startsWith(path));
}
/**
* GET /api/wifi/scan return available WiFi networks as JSON.
*/
private async handleScan(res: ServerResponse): Promise<void> {
const networks = await this.wifiService.scan();
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(networks));
}
/**
* POST /api/wifi/connect attempt to connect to a WiFi network.
*/
private async handleConnect(req: IncomingMessage, res: ServerResponse): Promise<void> {
const body = await this.readBody(req);
let ssid: string;
let password: string;
try {
const parsed = JSON.parse(body);
ssid = parsed.ssid;
password = parsed.password;
} catch {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ success: false, error: 'Invalid request body' }));
return;
}
if (!ssid) {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ success: false, error: 'SSID is required' }));
return;
}
this.logger.info({ ssid }, 'WiFi connection attempt from portal');
// Stop AP mode before attempting to connect
await this.wifiService.stopAP();
// Small delay to let AP go down cleanly
await new Promise((r) => setTimeout(r, 2000));
const success = await this.wifiService.connect(ssid, password);
if (success) {
// Save credentials to local store
this.store.setWifi(ssid, password);
// Respond with success (we may lose the connection since AP is down,
// but the page JavaScript handles this gracefully)
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ success: true }));
// Notify the setup flow that WiFi is configured
if (this.resolveSetup) {
this.resolveSetup(true);
this.resolveSetup = null;
}
} else {
// Connection failed — restart AP so user can retry
await this.wifiService.startAP(`Ti-Pote`);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(
JSON.stringify({
success: false,
error: 'Connexion échouée. Vérifiez le mot de passe et réessayez.',
}),
);
}
}
/**
* Read the full request body as a string.
*/
private readBody(req: IncomingMessage): Promise<string> {
return new Promise((resolve, reject) => {
let data = '';
req.on('data', (chunk: Buffer) => {
data += chunk.toString();
});
req.on('end', () => resolve(data));
req.on('error', reject);
});
}
}

View File

@ -0,0 +1,2 @@
export { SetupFlow } from './setup-flow.js';
export { CaptivePortal } from './captive-portal.js';

View File

@ -0,0 +1,346 @@
/**
* Captive portal HTML page served locally when the robot is in setup mode.
*
* Single-page app that:
* 1. Fetches available WiFi networks from the Pi's local API
* 2. Lets the user select their network and enter the password
* 3. Shows connection progress and result
*
* No external dependencies must work offline (the user is on the robot's hotspot).
*/
export function portalHTML(robotName: string): string {
return /* html */ `<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<title>${robotName} Configuration</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #0f0f1a;
color: #e0e0e0;
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
padding: 24px 16px;
}
.logo {
font-size: 48px;
margin-bottom: 8px;
}
h1 {
font-size: 22px;
font-weight: 600;
margin-bottom: 4px;
color: #fff;
}
.subtitle {
font-size: 14px;
color: #888;
margin-bottom: 32px;
}
.card {
background: #1a1a2e;
border-radius: 16px;
padding: 24px;
width: 100%;
max-width: 400px;
margin-bottom: 16px;
}
.card h2 {
font-size: 16px;
font-weight: 600;
margin-bottom: 16px;
color: #fff;
}
.network-list {
list-style: none;
max-height: 240px;
overflow-y: auto;
}
.network-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px;
border-radius: 10px;
cursor: pointer;
transition: background 0.15s;
}
.network-item:hover, .network-item.selected {
background: #2a2a4a;
}
.network-item.selected {
border: 1px solid #6c63ff;
}
.network-name {
font-size: 15px;
font-weight: 500;
}
.network-signal {
font-size: 12px;
color: #888;
}
.signal-bars {
display: flex;
gap: 2px;
align-items: flex-end;
}
.signal-bar {
width: 4px;
background: #333;
border-radius: 1px;
}
.signal-bar.active { background: #6c63ff; }
input[type="password"], input[type="text"] {
width: 100%;
padding: 14px 16px;
background: #0f0f1a;
border: 1px solid #333;
border-radius: 10px;
color: #fff;
font-size: 15px;
outline: none;
margin-bottom: 12px;
transition: border-color 0.15s;
}
input:focus { border-color: #6c63ff; }
.btn {
width: 100%;
padding: 14px;
border: none;
border-radius: 10px;
font-size: 16px;
font-weight: 600;
cursor: pointer;
transition: opacity 0.15s;
}
.btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.btn-primary {
background: #6c63ff;
color: #fff;
}
.btn-primary:hover:not(:disabled) { opacity: 0.9; }
.btn-scan {
background: transparent;
color: #6c63ff;
border: 1px solid #333;
margin-bottom: 12px;
}
.status {
text-align: center;
padding: 16px;
}
.status .icon { font-size: 48px; margin-bottom: 12px; }
.status .msg {
font-size: 15px;
line-height: 1.6;
}
.spinner {
width: 40px;
height: 40px;
border: 3px solid #333;
border-top-color: #6c63ff;
border-radius: 50%;
animation: spin 0.8s linear infinite;
margin: 0 auto 16px;
}
@keyframes spin { to { transform: rotate(360deg); } }
.hidden { display: none !important; }
.error { color: #ff6b6b; font-size: 14px; margin-bottom: 12px; }
</style>
</head>
<body>
<div class="logo">🤖</div>
<h1>${robotName}</h1>
<p class="subtitle">Configuration WiFi</p>
<!-- Step 1: Select WiFi -->
<div id="step-scan" class="card">
<h2>Réseaux disponibles</h2>
<button class="btn btn-scan" onclick="scanNetworks()">Actualiser</button>
<ul id="network-list" class="network-list">
<li class="status"><div class="spinner"></div><p class="msg">Recherche des réseaux...</p></li>
</ul>
</div>
<!-- Step 2: Enter password -->
<div id="step-password" class="card hidden">
<h2>Connexion à <span id="selected-ssid"></span></h2>
<div id="password-error" class="error hidden"></div>
<input type="password" id="wifi-password" placeholder="Mot de passe WiFi" autocomplete="off">
<button class="btn btn-primary" id="btn-connect" onclick="connectWifi()">Connecter</button>
<button class="btn btn-scan" onclick="backToScan()" style="margin-top:8px;margin-bottom:0">Retour</button>
</div>
<!-- Step 3: Connecting -->
<div id="step-connecting" class="card hidden">
<div class="status">
<div class="spinner"></div>
<p class="msg">Connexion en cours...</p>
</div>
</div>
<!-- Step 4: Success -->
<div id="step-success" class="card hidden">
<div class="status">
<div class="icon"></div>
<p class="msg">
Connecté au WiFi !<br>
<strong>${robotName}</strong> est prêt.<br>
<small style="color:#888">Vous pouvez fermer cette page.</small>
</p>
</div>
</div>
<!-- Step 4 alt: Error -->
<div id="step-error" class="card hidden">
<div class="status">
<div class="icon"></div>
<p class="msg" id="error-msg">Connexion échouée.</p>
</div>
<button class="btn btn-primary" onclick="backToScan()" style="margin-top:16px">Réessayer</button>
</div>
<script>
let selectedSSID = '';
function show(id) {
document.querySelectorAll('.card').forEach(c => c.classList.add('hidden'));
document.getElementById(id).classList.remove('hidden');
}
function signalBars(strength) {
// strength is 0-100 from nmcli
const bars = [10, 30, 55, 80];
return '<div class="signal-bars">' +
bars.map((threshold, i) => {
const height = 6 + i * 4;
const active = strength >= threshold ? 'active' : '';
return '<div class="signal-bar ' + active + '" style="height:' + height + 'px"></div>';
}).join('') +
'</div>';
}
async function scanNetworks() {
const list = document.getElementById('network-list');
list.innerHTML = '<li class="status"><div class="spinner"></div><p class="msg">Recherche des réseaux...</p></li>';
show('step-scan');
try {
const res = await fetch('/api/wifi/scan');
const networks = await res.json();
if (networks.length === 0) {
list.innerHTML = '<li class="status"><p class="msg">Aucun réseau trouvé</p></li>';
return;
}
list.innerHTML = networks.map(n =>
'<li class="network-item" onclick="selectNetwork(\\'' + n.ssid.replace(/'/g, "\\\\'") + '\\')">' +
'<div><div class="network-name">' + n.ssid + '</div>' +
'<div class="network-signal">' + n.security + '</div></div>' +
signalBars(n.signal) +
'</li>'
).join('');
} catch (err) {
list.innerHTML = '<li class="status"><p class="msg error">Erreur lors du scan</p></li>';
}
}
function selectNetwork(ssid) {
selectedSSID = ssid;
document.getElementById('selected-ssid').textContent = ssid;
document.getElementById('wifi-password').value = '';
document.getElementById('password-error').classList.add('hidden');
show('step-password');
document.getElementById('wifi-password').focus();
}
async function connectWifi() {
const password = document.getElementById('wifi-password').value;
if (!password) {
document.getElementById('password-error').textContent = 'Entrez le mot de passe';
document.getElementById('password-error').classList.remove('hidden');
return;
}
show('step-connecting');
try {
const res = await fetch('/api/wifi/connect', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ssid: selectedSSID, password }),
});
const result = await res.json();
if (result.success) {
show('step-success');
} else {
document.getElementById('error-msg').textContent =
result.error || 'Connexion échouée. Vérifiez le mot de passe.';
show('step-error');
}
} catch (err) {
document.getElementById('error-msg').textContent =
'Impossible de contacter le robot. Vérifiez que vous êtes connecté au hotspot.';
show('step-error');
}
}
function backToScan() {
show('step-scan');
scanNetworks();
}
// Handle Enter key in password field
document.addEventListener('DOMContentLoaded', () => {
document.getElementById('wifi-password').addEventListener('keydown', (e) => {
if (e.key === 'Enter') connectWifi();
});
});
// Initial scan on load
scanNetworks();
</script>
</body>
</html>`;
}

View File

@ -0,0 +1,106 @@
import { type WifiService } from '../services/wifi.service.js';
import { type LocalStore } from '../services/local-store.service.js';
import { CaptivePortal } from './captive-portal.js';
import { createLogger, type Logger } from '../utils/index.js';
/**
* First-boot setup flow.
*
* Orchestrates the entire onboarding experience:
*
* Power on
*
*
* WiFi configured in store?
* YES Try to connect with stored credentials
* Connected Done
* Failed Clear stored creds, fall through to AP mode
*
* NO Start AP mode + Captive Portal
* User configures WiFi via portal
* Connected Save credentials Done
* Failed Retry via portal
*/
export class SetupFlow {
private readonly logger: Logger;
constructor(
private readonly wifiService: WifiService,
private readonly store: LocalStore,
private readonly robotName: string,
) {
this.logger = createLogger('setup', 'info');
}
/**
* Run the setup flow. Returns true when WiFi is connected and ready.
*/
async run(): Promise<boolean> {
this.logger.info('Starting setup flow...');
// ── Step 1: Check if WiFi is already connected ──
const alreadyConnected = await this.wifiService.isConnected();
if (alreadyConnected) {
const ssid = await this.wifiService.currentSSID();
this.logger.info({ ssid }, 'Already connected to WiFi — skipping setup');
return true;
}
// ── Step 2: Try stored credentials ──
if (this.store.hasWifi) {
const { ssid, password } = this.store.wifi!;
this.logger.info({ ssid }, 'Trying stored WiFi credentials...');
const connected = await this.wifiService.connect(ssid, password);
if (connected) {
this.logger.info({ ssid }, 'Connected with stored credentials');
return true;
}
// Stored credentials failed — clear them and go to portal
this.logger.warn({ ssid }, 'Stored credentials failed — starting captive portal');
this.store.clearWifi();
}
// ── Step 3: Start AP mode + Captive Portal ──
this.logger.info('No WiFi configured — starting Access Point for setup');
const apStarted = await this.wifiService.startAP(`Ti-Pote`);
if (!apStarted) {
this.logger.error('Failed to start Access Point');
return false;
}
this.logger.info('╔══════════════════════════════════════════════╗');
this.logger.info('║ Connectez-vous au WiFi "Ti-Pote" puis ║');
this.logger.info('║ ouvrez http://192.168.4.1 dans votre ║');
this.logger.info('║ navigateur pour configurer le réseau. ║');
this.logger.info('╚══════════════════════════════════════════════╝');
const portal = new CaptivePortal(this.wifiService, this.store, this.robotName);
const wifiConfigured = await portal.start();
// Portal resolved — WiFi should be connected
await portal.stop();
if (wifiConfigured) {
this.logger.info('WiFi configured via captive portal');
// Verify internet connectivity
const hasInternet = await this.wifiService.hasInternet();
if (hasInternet) {
this.logger.info('Internet connectivity verified');
} else {
this.logger.warn('WiFi connected but no internet — cloud features may not work');
}
return true;
}
this.logger.error('Setup flow ended without WiFi configuration');
return false;
}
}

View File

@ -0,0 +1,188 @@
import { io, Socket } from 'socket.io-client';
import { EventEmitter } from 'node:events';
import { type RobotConfig } from '../config/index.js';
import { createLogger, type Logger } from '../utils/index.js';
// ── Types matching backend protocol ──
export type RobotState = 'idle' | 'listening' | 'thinking' | 'speaking';
export interface StatusEvent {
state: RobotState;
}
export interface ResponseTextEvent {
text: string;
audio?: string; // base64-encoded WAV
}
export interface AudioChunkEvent {
data: string; // base64-encoded PCM
}
// ── Events emitted by CloudSocket ──
export interface CloudSocketEvents {
connected: () => void;
disconnected: (reason: string) => void;
status: (event: StatusEvent) => void;
response_text: (event: ResponseTextEvent) => void;
audio_chunk: (event: AudioChunkEvent) => void;
notification: (payload: Record<string, unknown>) => void;
error: (error: Error) => void;
}
/**
* WebSocket client for communication with the Ti-Pote backend cloud.
*
* Matches the protocol expected by RobotGateway:
* - Outbound: wake_word_detected, audio_chunk, speech_end, user_interrupt
* - Inbound: status, response_text, audio_chunk, notification
*/
export class CloudSocket extends EventEmitter {
private socket: Socket | null = null;
private readonly logger: Logger;
private reconnectAttempts = 0;
private readonly maxReconnectAttempts = 50;
private _currentState: RobotState = 'idle';
constructor(private readonly config: RobotConfig) {
super();
this.logger = createLogger('cloud-socket', config.logLevel);
}
get currentState(): RobotState {
return this._currentState;
}
get isConnected(): boolean {
return this.socket?.connected ?? false;
}
/**
* Connect to the backend cloud WebSocket.
*/
async connect(): Promise<void> {
return new Promise((resolve, reject) => {
const url = `${this.config.cloudUrl}/ws/robot`;
this.logger.info({ url }, 'Connecting to cloud backend...');
this.socket = io(url, {
auth: { token: this.config.deviceToken },
transports: ['websocket'],
reconnection: true,
reconnectionDelay: 1000,
reconnectionDelayMax: 30000,
reconnectionAttempts: this.maxReconnectAttempts,
});
this.socket.on('connect', () => {
this.reconnectAttempts = 0;
this.logger.info('Connected to cloud backend');
this.emit('connected');
resolve();
});
this.socket.on('connect_error', (err) => {
this.reconnectAttempts++;
this.logger.error({ err: err.message, attempt: this.reconnectAttempts }, 'Connection error');
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
const error = new Error(`Failed to connect after ${this.maxReconnectAttempts} attempts`);
this.emit('error', error);
reject(error);
}
});
this.socket.on('disconnect', (reason) => {
this.logger.warn({ reason }, 'Disconnected from cloud backend');
this._currentState = 'idle';
this.emit('disconnected', reason);
});
// ── Inbound events from backend ──
this.socket.on('status', (event: StatusEvent) => {
this._currentState = event.state;
this.logger.debug({ state: event.state }, 'Status update');
this.emit('status', event);
});
this.socket.on('response_text', (event: ResponseTextEvent) => {
this.logger.info({ text: event.text, hasAudio: !!event.audio }, 'Response received');
this.emit('response_text', event);
});
this.socket.on('audio_chunk', (event: AudioChunkEvent) => {
this.logger.debug('Audio chunk received from cloud');
this.emit('audio_chunk', event);
});
this.socket.on('notification', (payload: Record<string, unknown>) => {
this.logger.info({ payload }, 'Notification received');
this.emit('notification', payload);
});
});
}
/**
* Notify the backend that the wake word was detected.
*/
sendWakeWordDetected(): void {
if (!this.socket?.connected) {
this.logger.warn('Cannot send wake_word_detected: not connected');
return;
}
this.logger.info('Sending wake_word_detected');
this.socket.emit('wake_word_detected');
}
/**
* Send an audio chunk (raw PCM) to the backend for STT processing.
*/
sendAudioChunk(pcmBuffer: Buffer, sampleRate: number): void {
if (!this.socket?.connected) return;
this.socket.emit('audio_chunk', {
data: pcmBuffer,
sampleRate,
});
}
/**
* Notify the backend that the user has finished speaking.
*/
sendSpeechEnd(): void {
if (!this.socket?.connected) {
this.logger.warn('Cannot send speech_end: not connected');
return;
}
this.logger.info('Sending speech_end');
this.socket.emit('speech_end');
}
/**
* Notify the backend that the user wants to interrupt the current response.
*/
sendUserInterrupt(): void {
if (!this.socket?.connected) {
this.logger.warn('Cannot send user_interrupt: not connected');
return;
}
this.logger.info('Sending user_interrupt');
this.socket.emit('user_interrupt');
}
/**
* Disconnect from the cloud backend.
*/
async disconnect(): Promise<void> {
if (this.socket) {
this.socket.removeAllListeners();
this.socket.disconnect();
this.socket = null;
this.logger.info('Disconnected from cloud backend');
}
}
}

View File

@ -0,0 +1 @@
export { CloudSocket, type RobotState, type CloudSocketEvents } from './cloud-socket.js';

View File

@ -0,0 +1 @@
export { createLogger, type Logger } from './logger.js';

View File

@ -0,0 +1,14 @@
import pino from 'pino';
export function createLogger(name: string, level = 'info') {
return pino({
name,
level,
transport:
process.env.NODE_ENV !== 'production'
? { target: 'pino-pretty', options: { colorize: true } }
: undefined,
});
}
export type Logger = pino.Logger;

View File

@ -0,0 +1,24 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ES2022"],
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "tests"]
}

View File

@ -6,8 +6,11 @@
"scripts": {
"dev": "pnpm --filter @ti-pote/backend dev",
"dev:sim": "pnpm --filter simulator dev",
"dev:robot": "pnpm --filter @ti-pote/robot-client dev",
"build": "pnpm --filter @ti-pote/backend build",
"build:robot": "pnpm --filter @ti-pote/robot-client build",
"start": "pnpm --filter @ti-pote/backend start:prod",
"start:robot": "pnpm --filter @ti-pote/robot-client start",
"lint": "pnpm -r lint",
"format": "pnpm -r format"
}