robot client qui fonctionne avec le hey jarvis et discussion ok avec un casque blutooth banger
This commit is contained in:
parent
4ac7bd16d2
commit
98aa1439e3
0
_tmp_945_290d62cdc9142d0226f30816d854ae86
Normal file
0
_tmp_945_290d62cdc9142d0226f30816d854ae86
Normal file
0
_tmp_945_eb782ccbcda1bcca02bbc45f41d4c1b8
Normal file
0
_tmp_945_eb782ccbcda1bcca02bbc45f41d4c1b8
Normal file
52
apps/robot-client/.env.dev
Normal file
52
apps/robot-client/.env.dev
Normal 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
|
||||
55
apps/robot-client/.env.example
Normal file
55
apps/robot-client/.env.example
Normal 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
|
||||
31
apps/robot-client/package.json
Normal file
31
apps/robot-client/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
116
apps/robot-client/scripts/install.sh
Executable file
116
apps/robot-client/scripts/install.sh
Executable 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 ""
|
||||
247
apps/robot-client/scripts/wake_word.py
Executable file
247
apps/robot-client/scripts/wake_word.py
Executable 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()
|
||||
57
apps/robot-client/src/config/hardware.config.ts
Normal file
57
apps/robot-client/src/config/hardware.config.ts
Normal 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'),
|
||||
},
|
||||
};
|
||||
}
|
||||
2
apps/robot-client/src/config/index.ts
Normal file
2
apps/robot-client/src/config/index.ts
Normal 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';
|
||||
75
apps/robot-client/src/config/robot.config.ts
Normal file
75
apps/robot-client/src/config/robot.config.ts
Normal 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;
|
||||
}
|
||||
151
apps/robot-client/src/main.ts
Normal file
151
apps/robot-client/src/main.ts
Normal 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);
|
||||
});
|
||||
203
apps/robot-client/src/services/audio.service.ts
Normal file
203
apps/robot-client/src/services/audio.service.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
57
apps/robot-client/src/services/health.service.ts
Normal file
57
apps/robot-client/src/services/health.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
8
apps/robot-client/src/services/index.ts
Normal file
8
apps/robot-client/src/services/index.ts
Normal 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';
|
||||
91
apps/robot-client/src/services/keyboard-trigger.service.ts
Normal file
91
apps/robot-client/src/services/keyboard-trigger.service.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
169
apps/robot-client/src/services/local-store.service.ts
Normal file
169
apps/robot-client/src/services/local-store.service.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
336
apps/robot-client/src/services/orchestrator.service.ts
Normal file
336
apps/robot-client/src/services/orchestrator.service.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
42
apps/robot-client/src/services/trigger.interface.ts
Normal file
42
apps/robot-client/src/services/trigger.interface.ts
Normal 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
|
||||
);
|
||||
}
|
||||
200
apps/robot-client/src/services/wake-word.service.ts
Normal file
200
apps/robot-client/src/services/wake-word.service.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
227
apps/robot-client/src/services/wifi.service.ts
Normal file
227
apps/robot-client/src/services/wifi.service.ts
Normal 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');
|
||||
}
|
||||
}
|
||||
224
apps/robot-client/src/setup/captive-portal.ts
Normal file
224
apps/robot-client/src/setup/captive-portal.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
}
|
||||
2
apps/robot-client/src/setup/index.ts
Normal file
2
apps/robot-client/src/setup/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export { SetupFlow } from './setup-flow.js';
|
||||
export { CaptivePortal } from './captive-portal.js';
|
||||
346
apps/robot-client/src/setup/portal.html.ts
Normal file
346
apps/robot-client/src/setup/portal.html.ts
Normal 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>`;
|
||||
}
|
||||
106
apps/robot-client/src/setup/setup-flow.ts
Normal file
106
apps/robot-client/src/setup/setup-flow.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
188
apps/robot-client/src/transport/cloud-socket.ts
Normal file
188
apps/robot-client/src/transport/cloud-socket.ts
Normal 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');
|
||||
}
|
||||
}
|
||||
}
|
||||
1
apps/robot-client/src/transport/index.ts
Normal file
1
apps/robot-client/src/transport/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export { CloudSocket, type RobotState, type CloudSocketEvents } from './cloud-socket.js';
|
||||
1
apps/robot-client/src/utils/index.ts
Normal file
1
apps/robot-client/src/utils/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export { createLogger, type Logger } from './logger.js';
|
||||
14
apps/robot-client/src/utils/logger.ts
Normal file
14
apps/robot-client/src/utils/logger.ts
Normal 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;
|
||||
24
apps/robot-client/tsconfig.json
Normal file
24
apps/robot-client/tsconfig.json
Normal 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"]
|
||||
}
|
||||
@ -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"
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user