This commit is contained in:
ordinarthur 2026-04-08 18:37:08 +02:00
parent 98aa1439e3
commit a98397a241
42 changed files with 4691 additions and 45 deletions

10
.gitignore vendored
View File

@ -34,3 +34,13 @@ docker-compose.override.yml
# TypeORM
*.sqlite
#desktop
apps/robot-desktop/
apps/robot-desktop/src-tauri/target
apps/robot-desktop/src-tauri/target/
apps/robot-desktop/dist
apps/robot-client/node_modules
.pio/

View File

@ -0,0 +1,16 @@
import { IsString, IsNotEmpty, Length, Matches } from 'class-validator';
export class RequestPairingDto {
@IsString()
@IsNotEmpty()
@Length(1, 100)
deviceName!: string;
}
export class ConfirmPairingDto {
@IsString()
@IsNotEmpty()
@Length(6, 6)
@Matches(/^\d{6}$/, { message: 'Pairing code must be 6 digits' })
code!: string;
}

View File

@ -0,0 +1,57 @@
import { Controller, Post, Get, Body, Param, UseGuards, Request } from '@nestjs/common';
import { PairingService } from '../../../../core/services/pairing.service';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { RequestPairingDto, ConfirmPairingDto } from './dto/pairing.dto';
@Controller('pairing')
export class PairingController {
constructor(
private readonly pairingService: PairingService,
) {}
/**
* POST /pairing/request
* Called by the robot on first boot. No auth required.
* Returns a requestId + 6-digit code for the robot to display.
*/
@Post('request')
async requestPairing(@Body() dto: RequestPairingDto) {
return this.pairingService.requestPairing(dto.deviceName);
}
/**
* GET /pairing/status/:requestId
* Called by the robot to poll for confirmation. No auth required.
* Returns { status: 'pending' } or { status: 'confirmed', deviceId, deviceToken }.
*/
@Get('status/:requestId')
async getPairingStatus(@Param('requestId') requestId: string) {
const request = await this.pairingService.getPairingStatus(requestId);
if (request.status === 'pending') {
return { status: 'pending', code: request.code };
}
// Confirmed — return credentials to the robot
return {
status: 'confirmed',
deviceId: request.deviceId,
deviceToken: request.deviceToken,
homeId: request.homeId,
};
}
/**
* POST /pairing/confirm
* Called by the user's app with their JWT + the 6-digit code.
* Associates the robot to the user's home.
*/
@Post('confirm')
@UseGuards(JwtAuthGuard)
async confirmPairing(
@Body() dto: ConfirmPairingDto,
@Request() req: { user: { homeId: string } },
) {
return this.pairingService.confirmPairing(dto.code, req.user.homeId);
}
}

View File

@ -19,6 +19,8 @@ import { ConversationService } from './core/services/conversation.service';
import { JwtStrategy } from './adapters/inbound/rest/auth/strategies/jwt.strategy';
import { AuthController } from './adapters/inbound/rest/auth/auth.controller';
import { DeviceController } from './adapters/inbound/rest/device/device.controller';
import { PairingController } from './adapters/inbound/rest/pairing/pairing.controller';
import { PairingService } from './core/services/pairing.service';
import { RobotGateway } from './adapters/inbound/websocket/robot.gateway';
import { DeepgramAdapter } from './adapters/outbound/stt/deepgram.adapter';
import { AnthropicAdapter } from './adapters/outbound/llm/anthropic.adapter';
@ -54,12 +56,13 @@ import { CACHE_PORT } from './core/ports/outbound/cache.port';
}),
}),
],
controllers: [AuthController, DeviceController],
controllers: [AuthController, DeviceController, PairingController],
providers: [
AuthService,
UserService,
HomeService,
DeviceService,
PairingService,
JwtStrategy,
RobotGateway,
{

View File

@ -0,0 +1,143 @@
import { Injectable, Inject, BadRequestException, NotFoundException } from '@nestjs/common';
import { ICachePort, CACHE_PORT } from '../ports/outbound/cache.port';
import { AuthService } from './auth.service';
import * as crypto from 'crypto';
export interface PairingRequest {
requestId: string;
code: string;
deviceName: string;
status: 'pending' | 'confirmed';
deviceId?: string;
deviceToken?: string;
homeId?: string;
createdAt: number;
}
const PAIRING_TTL = 600; // 10 minutes
const PAIRING_CODE_PREFIX = 'pairing:code:';
const PAIRING_REQUEST_PREFIX = 'pairing:request:';
@Injectable()
export class PairingService {
constructor(
@Inject(CACHE_PORT) private readonly cache: ICachePort,
private readonly authService: AuthService,
) {}
/**
* Step 1: Robot requests pairing.
* Generates a 6-digit code and stores the request in Redis.
* Returns the requestId + code for the robot to display.
*/
async requestPairing(deviceName: string): Promise<{ requestId: string; code: string }> {
const requestId = crypto.randomUUID();
const code = this.generateCode();
const pairingRequest: PairingRequest = {
requestId,
code,
deviceName,
status: 'pending',
createdAt: Date.now(),
};
// Store by requestId (for robot polling)
await this.cache.set<PairingRequest>(
`${PAIRING_REQUEST_PREFIX}${requestId}`,
pairingRequest,
PAIRING_TTL,
);
// Store code → requestId mapping (for user confirmation lookup)
await this.cache.set<string>(
`${PAIRING_CODE_PREFIX}${code}`,
requestId,
PAIRING_TTL,
);
return { requestId, code };
}
/**
* Step 2: Robot polls for pairing status.
* Returns current status; once confirmed, includes deviceId + token.
*/
async getPairingStatus(requestId: string): Promise<PairingRequest> {
const request = await this.cache.get<PairingRequest>(
`${PAIRING_REQUEST_PREFIX}${requestId}`,
);
if (!request) {
throw new NotFoundException('Pairing request not found or expired');
}
return request;
}
/**
* Step 3: User confirms pairing with the 6-digit code.
* Associates the device to the user's home, generates credentials.
*/
async confirmPairing(code: string, homeId: string): Promise<{ deviceId: string; deviceName: string }> {
// Look up requestId from code
const requestId = await this.cache.get<string>(
`${PAIRING_CODE_PREFIX}${code}`,
);
if (!requestId) {
throw new BadRequestException('Invalid or expired pairing code');
}
// Get the pairing request
const request = await this.cache.get<PairingRequest>(
`${PAIRING_REQUEST_PREFIX}${requestId}`,
);
if (!request) {
throw new BadRequestException('Pairing request expired');
}
if (request.status === 'confirmed') {
throw new BadRequestException('Pairing code already used');
}
// Register the device on the user's home
const { deviceId, token } = await this.authService.registerDevice(
homeId,
request.deviceName,
);
// Update the pairing request with credentials
const confirmedRequest: PairingRequest = {
...request,
status: 'confirmed',
deviceId,
deviceToken: token,
homeId,
};
await this.cache.set<PairingRequest>(
`${PAIRING_REQUEST_PREFIX}${requestId}`,
confirmedRequest,
PAIRING_TTL,
);
// Clean up the code mapping
await this.cache.del(`${PAIRING_CODE_PREFIX}${code}`);
return { deviceId, deviceName: request.deviceName };
}
/**
* Generate a random 6-digit numeric code.
* Avoids ambiguous patterns (000000, 111111, etc.)
*/
private generateCode(): string {
let code: string;
do {
code = String(crypto.randomInt(0, 1000000)).padStart(6, '0');
} while (/^(\d)\1{5}$/.test(code)); // Avoid 000000, 111111, etc.
return code;
}
}

View File

@ -53,3 +53,23 @@ WAKEWORD_MODEL=hey_jarvis
# Wake word detection threshold (0.0 to 1.0, higher = less false positives)
WAKEWORD_THRESHOLD=0.5
# ── Hardware bridge (ESP32 firmware over UART) ──
# Enable the serial link to the ESP32 running apps/robot-hardware.
# When false, the robot-client runs headless (no eyes / no face).
HARDWARE_SERIAL_ENABLED=false
# Serial device path.
# Dev (ESP32 plugged into laptop): /dev/ttyUSB0 or /dev/ttyACM0 (Linux),
# /dev/tty.usbserial-XXXX (macOS),
# COM3 (Windows).
# Prod (Pi → ESP32 UART): /dev/serial0 or /dev/ttyAMA0
HARDWARE_SERIAL_PORT=/dev/ttyUSB0
# Must match HW_SERIAL_BAUD in apps/robot-hardware/platformio.ini
HARDWARE_SERIAL_BAUD=921600
# Heartbeat interval (ms). The firmware falls back to a SLEEPY
# animation if it stops hearing from us for ~5s (HW_HEARTBEAT_TIMEOUT_MS).
HARDWARE_HEARTBEAT_MS=1000

View File

@ -11,13 +11,15 @@
"lint": "eslint \"src/**/*.ts\" --fix",
"format": "prettier --write \"src/**/*.ts\"",
"test": "vitest run",
"test:watch": "vitest"
"test:watch": "vitest",
"hw:demo": "tsx scripts/hardware-demo.ts"
},
"dependencies": {
"socket.io-client": "^4.8.3",
"dotenv": "^17.3.1",
"pino": "^9.6.0",
"pino-pretty": "^13.0.0"
"pino-pretty": "^13.0.0",
"serialport": "^12.0.0"
},
"devDependencies": {
"typescript": "^5.8.3",

View File

@ -0,0 +1,69 @@
/**
* Ti-Pote Hardware bring-up demo.
*
* Run with:
* HARDWARE_SERIAL_PORT=/dev/ttyUSB0 pnpm --filter @ti-pote/robot-client hw:demo
*
* What it does:
* 1. Opens the serial link to the ESP32 firmware.
* 2. Pings it and prints the round-trip time.
* 3. Cycles through every emotion with a 1.2 s pause so you can
* watch the OLED eyes react.
* 4. Finishes on NEUTRAL and disconnects cleanly.
*
* Use this as a smoke test after flashing new firmware, before
* wiring the full robot-client/cloud pipeline.
*/
import { HardwareService, Emotion } from '../src/hardware/index.js';
const path = process.env.HARDWARE_SERIAL_PORT ?? '/dev/ttyUSB0';
const baudRate = parseInt(process.env.HARDWARE_SERIAL_BAUD ?? '921600', 10);
const EMOTION_SEQUENCE: Emotion[] = [
Emotion.NEUTRAL,
Emotion.HAPPY,
Emotion.SAD,
Emotion.ANGRY,
Emotion.SURPRISED,
Emotion.SLEEPY,
Emotion.WINK,
Emotion.LOVE,
Emotion.DIZZY,
Emotion.DEAD,
Emotion.NEUTRAL,
];
async function sleep(ms: number): Promise<void> {
return new Promise((r) => setTimeout(r, ms));
}
async function main(): Promise<void> {
const hw = new HardwareService({ path, baudRate, heartbeatIntervalMs: 1000 });
hw.on('log', (line) => console.log(`[firmware] ${line}`));
hw.on('error', (err) => console.error(`[firmware error] ${err.message}`));
console.log(`→ opening ${path} @ ${baudRate} baud`);
await hw.connect();
try {
const rtt = await hw.ping(Buffer.from('hello'));
console.log(`← pong (rtt ${rtt.toFixed(1)} ms)`);
} catch (err) {
console.warn(`ping failed: ${(err as Error).message}`);
}
for (const emotion of EMOTION_SEQUENCE) {
console.log(`→ emotion ${Emotion[emotion]}`);
hw.sendEmotion(emotion);
await sleep(1200);
}
await hw.disconnect();
console.log('done.');
}
main().catch((err) => {
console.error(err);
process.exit(1);
});

View File

@ -0,0 +1,64 @@
// One-off verification script used during development to ensure the
// TS protocol codec round-trips correctly and matches the C++ side.
// Not intended to ship — kept because vitest couldn't run in the
// sandbox where this was bootstrapped, and a plain tsx script lets
// us re-run it quickly on any machine.
import {
crc8,
encodeFrame,
FrameDecoder,
MsgType,
Emotion,
type DecodedFrame,
} from '../src/hardware/protocol.js';
function assert(cond: unknown, msg: string): void {
if (!cond) {
console.error(`FAIL: ${msg}`);
process.exitCode = 1;
} else {
console.log(`ok: ${msg}`);
}
}
// CRC reference values (also validated against Python implementation).
assert(crc8(Buffer.from('123456789', 'ascii')) === 0xf4, "crc8('123456789') === 0xF4");
assert(crc8(Buffer.from([0x00])) === 0x00, 'crc8([0x00]) === 0x00');
assert(crc8(Buffer.from([0x01])) === 0x07, 'crc8([0x01]) === 0x07');
assert(crc8(Buffer.from([0xaa])) === 0x5f, 'crc8([0xAA]) === 0x5F');
// DISPLAY_EMOTION HAPPY must produce a 6-byte frame with CRC 0xDC
// (matches Python reference computed in verify script).
const happy = encodeFrame(MsgType.DISPLAY_EMOTION, Buffer.from([Emotion.HAPPY]));
assert(happy.length === 6, 'DISPLAY_EMOTION frame length === 6');
assert(happy[0] === 0xaa, 'start byte 0xAA');
assert(happy[1] === 0x20, 'type 0x20');
assert(happy[2] === 0x00 && happy[3] === 0x01, 'length = 1');
assert(happy[4] === 0x01, 'payload = HAPPY (1)');
assert(happy[5] === 0xdc, 'CRC === 0xDC');
// Round-trip through the decoder.
let received: DecodedFrame | null = null;
const decoder = new FrameDecoder((f) => {
received = f;
});
decoder.feed(happy);
assert(received !== null, 'frame emitted');
assert(received!.type === MsgType.DISPLAY_EMOTION, 'type preserved');
assert(received!.payload[0] === Emotion.HAPPY, 'payload preserved');
// Resync after corrupted CRC.
received = null;
const bad = Buffer.from(happy);
bad[bad.length - 1] = (bad[bad.length - 1]! ^ 0xff) & 0xff;
decoder.feed(bad);
assert(received === null, 'bad CRC not emitted');
assert(decoder.framesDropped === 1, 'drop counter === 1');
const again = encodeFrame(MsgType.DISPLAY_EMOTION, Buffer.from([Emotion.WINK]));
decoder.feed(again);
assert(received !== null, 'decoder resynced');
assert((received as unknown as DecodedFrame).payload[0] === Emotion.WINK, 'resync payload ok');
console.log('\nall protocol checks passed');

View File

@ -32,9 +32,24 @@ export interface WakeWordConfig {
threshold: number;
}
export interface SerialHardwareConfig {
/** Enable the serial link to the ESP32 firmware. */
enabled: boolean;
/** Serial device path. Linux/macOS: /dev/ttyUSB0, /dev/ttyACM0. Windows: COM3. */
path: string;
/** Baud rate. Must match HW_SERIAL_BAUD in apps/robot-hardware/platformio.ini. */
baudRate: number;
/** Interval between STATUS heartbeats to the firmware (ms). 0 disables. */
heartbeatIntervalMs: number;
}
export interface HardwareConfig {
audio: AudioConfig;
wakeWord: WakeWordConfig;
serial: SerialHardwareConfig;
}
export function loadHardwareConfig(): HardwareConfig {
@ -53,5 +68,11 @@ export function loadHardwareConfig(): HardwareConfig {
modelName: process.env.WAKEWORD_MODEL || 'hey_ti_pote',
threshold: parseFloat(process.env.WAKEWORD_THRESHOLD || '0.5'),
},
serial: {
enabled: (process.env.HARDWARE_SERIAL_ENABLED || 'false').toLowerCase() === 'true',
path: process.env.HARDWARE_SERIAL_PORT || '/dev/ttyUSB0',
baudRate: parseInt(process.env.HARDWARE_SERIAL_BAUD || '921600', 10),
heartbeatIntervalMs: parseInt(process.env.HARDWARE_HEARTBEAT_MS || '1000', 10),
},
};
}

View File

@ -23,11 +23,11 @@ export interface RobotConfig {
/** How conversations are triggered */
triggerMode: TriggerMode;
/** Unique device identifier (generated at first setup or from env) */
deviceId: string;
/** Unique device identifier (from env, store, or pairing) */
deviceId?: string;
/** JWT device token for cloud authentication */
deviceToken: string;
/** JWT device token for cloud authentication (from env, store, or pairing) */
deviceToken?: string;
/** Cloud backend WebSocket URL */
cloudUrl: string;
@ -58,18 +58,11 @@ export function loadRobotConfig(): RobotConfig {
return {
mode,
triggerMode,
deviceId: requireEnv('DEVICE_ID'),
deviceToken: requireEnv('DEVICE_TOKEN'),
deviceId: process.env.DEVICE_ID || undefined,
deviceToken: process.env.DEVICE_TOKEN || undefined,
cloudUrl: process.env.CLOUD_URL || 'ws://localhost:3000',
robotName: process.env.ROBOT_NAME || 'Ti-Pote',
logLevel: process.env.LOG_LEVEL || 'info',
};
}
function requireEnv(key: string): string {
const value = process.env[key];
if (!value) {
throw new Error(`Missing required environment variable: ${key}`);
}
return value;
}

View File

@ -0,0 +1,211 @@
import { EventEmitter } from 'node:events';
import { SerialPort } from 'serialport';
import { createLogger } from '../utils/index.js';
import {
encodeFrame,
FrameDecoder,
MsgType,
Emotion,
type DecodedFrame,
} from './protocol.js';
export interface HardwareServiceOptions {
/** Serial device path. Examples: '/dev/ttyUSB0', '/dev/ttyACM0', 'COM3'. */
path: string;
/** Baud rate. Must match HW_SERIAL_BAUD in apps/robot-hardware/platformio.ini. */
baudRate: number;
/** Interval between STATUS heartbeats sent to the firmware, in ms. 0 disables. */
heartbeatIntervalMs?: number;
/** PING round-trip timeout when calling `ping()`, in ms. */
pingTimeoutMs?: number;
}
export interface HardwareServiceEvents {
open: () => void;
close: () => void;
error: (err: Error) => void;
log: (message: string) => void;
frame: (frame: DecodedFrame) => void;
ack: (payload: Buffer) => void;
}
/**
* HardwareService the robot-client's only direct link to the ESP32.
*
* Responsibilities:
* - Own the `SerialPort` instance.
* - Reassemble binary frames via `FrameDecoder`.
* - Expose a small typed command surface (`sendEmotion`, `clearDisplay`,
* `ping`, ) that the rest of the client can use without caring
* about the wire format.
* - Send periodic STATUS heartbeats so the firmware does not fall
* back to its sleepy idle animation.
*
* The service is resilient to the ESP32 not being present: if the
* port cannot be opened, `connect()` throws and the caller decides
* whether that is fatal. For the MVP we treat it as non-fatal
* the robot can still talk to the cloud even without a face.
*/
export class HardwareService extends EventEmitter {
private readonly log = createLogger('hardware', 'info');
private readonly decoder: FrameDecoder;
private port: SerialPort | null = null;
private heartbeatTimer: NodeJS.Timeout | null = null;
constructor(private readonly options: HardwareServiceOptions) {
super();
this.decoder = new FrameDecoder((frame) => this.onDecodedFrame(frame));
}
/**
* Open the serial port and wire up event handlers.
* Rejects if the port cannot be opened within ~2 s.
*/
connect(): Promise<void> {
return new Promise((resolve, reject) => {
if (this.port?.isOpen) {
resolve();
return;
}
this.log.info(
{ path: this.options.path, baudRate: this.options.baudRate },
'Opening hardware serial port',
);
this.port = new SerialPort(
{
path: this.options.path,
baudRate: this.options.baudRate,
autoOpen: false,
},
(err: Error | null) => {
// constructor callback is only called with autoOpen: true,
// so this is a no-op in practice.
if (err) reject(err);
},
);
this.port.on('data', (chunk: Buffer) => this.decoder.feed(chunk));
this.port.on('error', (err: Error) => {
this.log.error({ err }, 'Hardware serial error');
this.emit('error', err);
});
this.port.on('close', () => {
this.log.warn('Hardware serial port closed');
this.stopHeartbeat();
this.emit('close');
});
this.port.open((err: Error | null) => {
if (err) {
this.log.error({ err, path: this.options.path }, 'Failed to open serial port');
reject(err);
return;
}
this.log.info('Hardware serial port open');
this.startHeartbeat();
this.emit('open');
resolve();
});
});
}
async disconnect(): Promise<void> {
this.stopHeartbeat();
if (!this.port?.isOpen) return;
await new Promise<void>((resolve) => this.port!.close(() => resolve()));
this.port = null;
}
isConnected(): boolean {
return this.port?.isOpen === true;
}
// ──────────────────────────────────────────────────────────
// Typed command API
// ──────────────────────────────────────────────────────────
/** Display a named emotion on the OLED eyes. */
sendEmotion(emotion: Emotion): void {
this.writeFrame(MsgType.DISPLAY_EMOTION, Buffer.from([emotion]));
}
/** Blank the OLED. */
clearDisplay(): void {
this.writeFrame(MsgType.DISPLAY_CLEAR);
}
/**
* Round-trip PING PONG used for bring-up and latency checks.
* Resolves with the measured RTT in ms.
*/
ping(payload: Buffer = Buffer.alloc(0)): Promise<number> {
const timeout = this.options.pingTimeoutMs ?? 1000;
return new Promise((resolve, reject) => {
const started = performance.now();
const timer = setTimeout(() => {
this.off('frame', handler);
reject(new Error(`ping timed out after ${timeout}ms`));
}, timeout);
const handler = (frame: DecodedFrame) => {
if (frame.type !== MsgType.PONG) return;
clearTimeout(timer);
this.off('frame', handler);
resolve(performance.now() - started);
};
this.on('frame', handler);
this.writeFrame(MsgType.PING, payload);
});
}
// ──────────────────────────────────────────────────────────
// Internals
// ──────────────────────────────────────────────────────────
private writeFrame(type: MsgType, payload: Buffer = Buffer.alloc(0)): void {
if (!this.port?.isOpen) {
this.log.warn({ type }, 'Dropping frame — serial port not open');
return;
}
this.port.write(encodeFrame(type, payload));
}
private onDecodedFrame(frame: DecodedFrame): void {
this.emit('frame', frame);
switch (frame.type) {
case MsgType.LOG:
this.emit('log', frame.payload.toString('utf8'));
this.log.debug({ line: frame.payload.toString('utf8') }, 'firmware log');
return;
case MsgType.ACK:
this.emit('ack', frame.payload);
return;
case MsgType.ERROR:
this.log.error({ payload: frame.payload.toString('utf8') }, 'firmware error');
return;
default:
return;
}
}
private startHeartbeat(): void {
const interval = this.options.heartbeatIntervalMs ?? 1000;
if (interval <= 0) return;
this.heartbeatTimer = setInterval(() => {
// Empty STATUS payload — the firmware only cares that *something*
// arrived recently.
this.writeFrame(MsgType.STATUS);
}, interval);
}
private stopHeartbeat(): void {
if (this.heartbeatTimer) {
clearInterval(this.heartbeatTimer);
this.heartbeatTimer = null;
}
}
}

View File

@ -0,0 +1,13 @@
export { HardwareService } from './hardware.service.js';
export type { HardwareServiceOptions } from './hardware.service.js';
export {
Emotion,
MsgType,
FrameDecoder,
encodeFrame,
crc8,
FRAME_START,
FRAME_OVERHEAD,
MAX_PAYLOAD_SIZE,
type DecodedFrame,
} from './protocol.js';

View File

@ -0,0 +1,212 @@
/**
* Ti-Pote Binary UART protocol, TypeScript side.
*
* This file MUST stay byte-for-byte compatible with the C++ reference
* at `apps/robot-hardware/include/protocol_types.h` and the decoder
* implementation at `apps/robot-hardware/lib/Protocol/`.
*
* Frame layout:
*
*
* START TYPE LENGTH_H LENGTH_L PAYLOAD CRC8
* 0xAA 1B 1B 1B 0..65535 B 1B
*
*
* CRC8: poly=0x07, init=0x00, no reflection, no final XOR.
* Computed over TYPE + LENGTH_H + LENGTH_L + PAYLOAD.
*/
export const FRAME_START = 0xaa;
export const FRAME_HEADER_SIZE = 4; // START + TYPE + LEN_H + LEN_L
export const FRAME_OVERHEAD = FRAME_HEADER_SIZE + 1; // + CRC
export const MAX_PAYLOAD_SIZE = 1024;
/**
* Message type codes keep in sync with `MsgType` in protocol_types.h.
*/
export enum MsgType {
// Reserved for Phase 2 (not yet implemented)
AUDIO_UP = 0x01,
AUDIO_DOWN = 0x02,
SERVO_CMD = 0x03,
LED_CMD = 0x04,
STATUS = 0x05,
SENSOR_DATA = 0x06,
CONFIG = 0x07,
ACK = 0x08,
IDLE_MODE = 0x09,
// v0 — display / eyes
DISPLAY_EMOTION = 0x20,
DISPLAY_CLEAR = 0x21,
// v0 — bring-up / diagnostics
PING = 0xf0,
PONG = 0xf1,
LOG = 0xfd,
ERROR = 0xfe,
}
/**
* Emotion catalogue keep in sync with `Emotion` in protocol_types.h
* and the switch in `lib/Eyes/src/Eyes.cpp`.
*/
export enum Emotion {
NEUTRAL = 0,
HAPPY = 1,
SAD = 2,
ANGRY = 3,
SURPRISED = 4,
SLEEPY = 5,
WINK = 6,
LOVE = 7,
DIZZY = 8,
DEAD = 9,
}
export interface DecodedFrame {
type: MsgType;
payload: Buffer;
}
/**
* CRC-8 with polynomial 0x07, init 0x00, no reflection, no final XOR.
*/
export function crc8(data: Uint8Array | Buffer, start = 0, end = data.length): number {
let crc = 0x00;
for (let i = start; i < end; i++) {
crc ^= data[i]!;
for (let b = 0; b < 8; b++) {
crc = (crc & 0x80) !== 0 ? ((crc << 1) ^ 0x07) & 0xff : (crc << 1) & 0xff;
}
}
return crc;
}
/**
* Build a complete framed message ready to write to the serial port.
*/
export function encodeFrame(type: MsgType, payload: Buffer = Buffer.alloc(0)): Buffer {
if (payload.length > MAX_PAYLOAD_SIZE) {
throw new Error(
`Payload too large: ${payload.length} > MAX_PAYLOAD_SIZE (${MAX_PAYLOAD_SIZE})`,
);
}
const total = FRAME_OVERHEAD + payload.length;
const out = Buffer.alloc(total);
out[0] = FRAME_START;
out[1] = type;
out[2] = (payload.length >> 8) & 0xff;
out[3] = payload.length & 0xff;
payload.copy(out, FRAME_HEADER_SIZE);
// CRC over TYPE + LEN + PAYLOAD (skip the START byte).
out[FRAME_HEADER_SIZE + payload.length] = crc8(out, 1, FRAME_HEADER_SIZE + payload.length);
return out;
}
/**
* Streaming decoder: feed bytes as they come off the wire and the
* decoder will emit complete, CRC-validated frames via the handler.
*
* Matches the state machine in `apps/robot-hardware/lib/Protocol/src/Protocol.cpp`.
*/
export class FrameDecoder {
private state:
| 'WAIT_START'
| 'READ_TYPE'
| 'READ_LEN_H'
| 'READ_LEN_L'
| 'READ_PAYLOAD'
| 'READ_CRC' = 'WAIT_START';
private type = 0;
private length = 0;
private payload: Buffer = Buffer.alloc(0);
private payloadIdx = 0;
private _ok = 0;
private _dropped = 0;
constructor(private readonly onFrame: (frame: DecodedFrame) => void) {}
get framesOk(): number {
return this._ok;
}
get framesDropped(): number {
return this._dropped;
}
feed(chunk: Buffer): void {
for (const byte of chunk) {
this.feedByte(byte);
}
}
private reset(): void {
this.state = 'WAIT_START';
this.type = 0;
this.length = 0;
this.payloadIdx = 0;
}
private feedByte(byte: number): void {
switch (this.state) {
case 'WAIT_START':
if (byte === FRAME_START) this.state = 'READ_TYPE';
return;
case 'READ_TYPE':
this.type = byte;
this.state = 'READ_LEN_H';
return;
case 'READ_LEN_H':
this.length = (byte & 0xff) << 8;
this.state = 'READ_LEN_L';
return;
case 'READ_LEN_L':
this.length |= byte & 0xff;
if (this.length > MAX_PAYLOAD_SIZE) {
this._dropped++;
this.reset();
return;
}
this.payload = Buffer.alloc(this.length);
this.payloadIdx = 0;
this.state = this.length === 0 ? 'READ_CRC' : 'READ_PAYLOAD';
return;
case 'READ_PAYLOAD':
this.payload[this.payloadIdx++] = byte;
if (this.payloadIdx === this.length) this.state = 'READ_CRC';
return;
case 'READ_CRC': {
// Re-materialise the header to CRC over it.
const header = Buffer.from([
this.type,
(this.length >> 8) & 0xff,
this.length & 0xff,
]);
let crc = crc8(header);
// Fold the payload in (matches the C++ impl exactly).
for (let i = 0; i < this.length; i++) {
crc ^= this.payload[i]!;
for (let b = 0; b < 8; b++) {
crc = (crc & 0x80) !== 0 ? ((crc << 1) ^ 0x07) & 0xff : (crc << 1) & 0xff;
}
}
if (crc === byte) {
this._ok++;
this.onFrame({ type: this.type as MsgType, payload: this.payload });
} else {
this._dropped++;
}
this.reset();
return;
}
}
}
}

View File

@ -8,9 +8,11 @@ import {
OrchestratorService,
LocalStore,
WifiService,
PairingService,
} from './services/index.js';
import { type ITriggerService } from './services/trigger.interface.js';
import { SetupFlow } from './setup/index.js';
import { HardwareService, Emotion } from './hardware/index.js';
import { createLogger } from './utils/index.js';
const logger = createLogger('main', 'info');
@ -33,8 +35,6 @@ async function main(): Promise<void> {
// ── 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();
@ -50,33 +50,57 @@ async function main(): Promise<void> {
}
// ── Step 2: Resolve device credentials ──
// Use stored device credentials if available, fall back to env vars.
// Priority: LocalStore → env vars → auto-pairing
const deviceId = store.device?.id || robotConfig.deviceId;
const deviceToken = store.device?.token || robotConfig.deviceToken;
let deviceId = store.device?.id || robotConfig.deviceId;
let 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);
logger.info('🔗 No device credentials found — starting pairing flow...');
const pairingService = new PairingService(robotConfig.cloudUrl, store);
const credentials = await pairingService.pair(robotConfig.robotName);
deviceId = credentials.deviceId;
deviceToken = credentials.deviceToken;
}
// Override config with resolved credentials
const resolvedConfig = { ...robotConfig, deviceId, deviceToken };
logger.info({ deviceId }, 'Device credentials resolved');
logger.info({ deviceId }, '✅ Device credentials resolved');
// ── Step 3: Initialize services ──
const cloudSocket = new CloudSocket(resolvedConfig);
const resolvedConfig = { ...robotConfig, deviceId, deviceToken };
const cloudSocket = new CloudSocket(resolvedConfig as Required<typeof 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
// ── Optional: hardware bridge (ESP32 firmware) ──
// The serial link is opt-in via HARDWARE_SERIAL_ENABLED=true. We
// treat failures here as non-fatal: even without a face, the
// robot can still converse with the cloud.
let hardwareService: HardwareService | null = null;
if (hardwareConfig.serial.enabled) {
hardwareService = new HardwareService({
path: hardwareConfig.serial.path,
baudRate: hardwareConfig.serial.baudRate,
heartbeatIntervalMs: hardwareConfig.serial.heartbeatIntervalMs,
});
hardwareService.on('log', (line) => logger.debug({ line }, 'firmware log'));
try {
await hardwareService.connect();
hardwareService.sendEmotion(Emotion.HAPPY);
logger.info('Hardware bridge connected');
} catch (err) {
logger.warn({ err }, 'Hardware bridge unavailable — continuing without face');
hardwareService = null;
}
} else {
logger.info('Hardware bridge disabled (set HARDWARE_SERIAL_ENABLED=true to enable)');
}
// Choose trigger based on TRIGGER_MODE
let trigger: ITriggerService;
if (resolvedConfig.triggerMode === 'wakeword') {
@ -125,6 +149,10 @@ async function main(): Promise<void> {
await orchestrator.stop();
healthService.stop();
await audioService.destroy();
if (hardwareService) {
hardwareService.sendEmotion(Emotion.SLEEPY);
await hardwareService.disconnect();
}
await cloudSocket.disconnect();
logger.info('Goodbye!');

View File

@ -5,4 +5,5 @@ 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 { PairingService } from './pairing.service.js';
export { type ITriggerService } from './trigger.interface.js';

View File

@ -1,5 +1,6 @@
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
import { dirname } from 'node:path';
import { dirname, join } from 'node:path';
import { homedir } from 'node:os';
import { createLogger, type Logger } from '../utils/index.js';
/**
@ -31,7 +32,8 @@ export interface LocalStoreData {
setupComplete?: boolean;
}
const DEFAULT_STORE_PATH = '/home/pi/.tipote/config.json';
const DEFAULT_STORE_PATH = process.env.TIPOTE_STORE_PATH
|| join(homedir(), '.tipote', 'config.json');
/**
* Simple JSON file store for persisting robot config on the SD card.

View File

@ -0,0 +1,177 @@
import { createLogger, type Logger } from '../utils/index.js';
import { type LocalStore } from './local-store.service.js';
interface PairingRequestResponse {
requestId: string;
code: string;
}
interface PairingStatusResponse {
status: 'pending' | 'confirmed';
code?: string;
deviceId?: string;
deviceToken?: string;
homeId?: string;
}
/**
* Pairing service handles automatic device registration.
*
* Flow:
* 1. Call backend POST /pairing/request get a 6-digit code
* 2. Display the code on screen (HDMI / captive portal)
* 3. Poll GET /pairing/status/:requestId until confirmed
* 4. Store credentials in LocalStore
*/
export class PairingService {
private readonly logger: Logger;
constructor(
private readonly cloudUrl: string,
private readonly store: LocalStore,
) {
this.logger = createLogger('pairing', 'info');
}
/**
* Run the full pairing flow.
* Returns device credentials once the user confirms on the app.
*/
async pair(deviceName: string): Promise<{ deviceId: string; deviceToken: string }> {
// Step 1: Request a pairing code from the backend
const backendUrl = this.cloudUrl.replace(/^ws/, 'http');
this.logger.info({ backendUrl }, 'Requesting pairing code from backend...');
const { requestId, code } = await this.requestPairing(backendUrl, deviceName);
// Step 2: Display the code
this.displayCode(code);
// Step 3: Poll for confirmation
this.logger.info('Waiting for user to confirm pairing on the app...');
const credentials = await this.pollForConfirmation(backendUrl, requestId);
// Step 4: Store credentials
this.store.setDevice(credentials.deviceId, credentials.deviceToken, credentials.homeId);
this.store.markSetupComplete();
this.logger.info({ deviceId: credentials.deviceId }, '✅ Device paired successfully!');
return {
deviceId: credentials.deviceId,
deviceToken: credentials.deviceToken,
};
}
private async requestPairing(backendUrl: string, deviceName: string): Promise<PairingRequestResponse> {
const res = await fetch(`${backendUrl}/api/pairing/request`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ deviceName }),
});
if (!res.ok) {
const body = await res.text();
throw new Error(`Pairing request failed (${res.status}): ${body}`);
}
return res.json() as Promise<PairingRequestResponse>;
}
private async pollForConfirmation(
backendUrl: string,
requestId: string,
): Promise<{ deviceId: string; deviceToken: string; homeId: string }> {
const pollIntervalMs = 3000;
const maxAttempts = 200; // 10 minutes at 3s intervals
for (let attempt = 0; attempt < maxAttempts; attempt++) {
await this.sleep(pollIntervalMs);
try {
const res = await fetch(`${backendUrl}/api/pairing/status/${requestId}`);
if (!res.ok) {
if (res.status === 404) {
throw new Error('Pairing request expired. Please restart the robot to try again.');
}
continue;
}
const data = (await res.json()) as PairingStatusResponse;
if (data.status === 'confirmed' && data.deviceId && data.deviceToken && data.homeId) {
return {
deviceId: data.deviceId,
deviceToken: data.deviceToken,
homeId: data.homeId,
};
}
} catch (err) {
if (err instanceof Error && err.message.includes('expired')) {
throw err;
}
this.logger.debug({ err }, 'Poll error (will retry)');
}
}
throw new Error('Pairing timed out. Please restart the robot to try again.');
}
/**
* Display the pairing code prominently.
* For now: big ASCII art in the console (visible on HDMI).
* Future: display on an OLED/LCD screen.
*/
private displayCode(code: string): void {
const digits = code.split('');
const big = digits.map((d) => this.bigDigit(d));
// Build 5 lines of ASCII art
const lines: string[] = [];
for (let row = 0; row < 5; row++) {
lines.push(big.map((d) => d[row]).join(' '));
}
console.log('');
console.log(`${'═'.repeat(58)}`);
console.log(`${'PAIRING CODE'.padStart(35).padEnd(58)}`);
console.log(`${'═'.repeat(58)}`);
console.log(`${' '.repeat(58)}`);
for (const line of lines) {
console.log(`${line.padEnd(56)}`);
}
console.log(`${' '.repeat(58)}`);
console.log(`${'Enter this code in the Ti-Pote app'.padStart(46).padEnd(58)}`);
console.log(`${'to pair this robot to your account'.padStart(46).padEnd(58)}`);
console.log(`${' '.repeat(58)}`);
console.log(`${'Code expires in 10 minutes'.padStart(42).padEnd(58)}`);
console.log(`${'═'.repeat(58)}`);
console.log('');
this.logger.info({ code }, '📱 Pairing code displayed — enter it in the Ti-Pote app');
}
/**
* 5-line ASCII art for a single digit (3 chars wide).
*/
private bigDigit(d: string): string[] {
const digits: Record<string, string[]> = {
'0': [' ██ ', '█ █', '█ █', '█ █', ' ██ '],
'1': [' █ ', ' ██ ', ' █ ', ' █ ', ' ███'],
'2': [' ██ ', '█ █', ' █ ', ' █ ', '████'],
'3': ['████', ' █', ' ██ ', ' █', '████'],
'4': ['█ █', '█ █', '████', ' █', ' █'],
'5': ['████', '█ ', '███ ', ' █', '███ '],
'6': [' ██ ', '█ ', '███ ', '█ █', ' ██ '],
'7': ['████', ' █', ' █ ', ' █ ', ' █ '],
'8': [' ██ ', '█ █', ' ██ ', '█ █', ' ██ '],
'9': [' ██ ', '█ █', ' ███', ' █', ' ██ '],
};
return digits[d] || [' ', ' ', ' ', ' ', ' '];
}
private sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
}

View File

@ -110,7 +110,8 @@ export class WakeWordService extends EventEmitter {
} else if (msg.startsWith('Matched device') || msg.startsWith('Using device')) {
this.logger.info(`🔊 ${msg}`);
} else {
this.logger.debug({ msg }, 'Wake word stderr');
// Log unknown stderr messages at warn level to catch errors
this.logger.warn({ msg }, 'Wake word stderr');
}
}
});

View File

@ -165,11 +165,16 @@ export class WifiService {
await execAsync('nmcli connection delete Hotspot').catch(() => { /* ignore */ });
// Create and start an open hotspot (no password — easier for setup)
// Run commands sequentially with delays — chaining with && causes activation failures
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`,
`nmcli connection add type wifi ifname wlan0 con-name Hotspot autoconnect no ssid "${this.escapeShell(name)}"`,
);
await new Promise((r) => setTimeout(r, 1000));
await execAsync(
`nmcli connection modify Hotspot 802-11-wireless.mode ap ipv4.method shared ipv4.addresses 192.168.4.1/24`,
);
await new Promise((r) => setTimeout(r, 1000));
await execAsync('nmcli connection up Hotspot');
this.logger.info({ apName: name }, 'Access Point started');
return true;

View File

@ -82,6 +82,17 @@ export class CaptivePortal {
this.logger.debug({ method, url }, 'Request');
try {
// ── CORS preflight for Tauri app ──
if (method === 'OPTIONS') {
res.writeHead(204, {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type',
});
res.end();
return;
}
// ── 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.
@ -93,6 +104,20 @@ export class CaptivePortal {
// ── API routes ──
if (url === '/api/status' && method === 'GET') {
res.writeHead(200, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' });
res.end(JSON.stringify({ ready: true, robotName: this.robotName }));
return;
}
if (url === '/api/wifi/status' && method === 'GET') {
const connected = await this.wifiService.isConnected();
const ssid = connected ? await this.wifiService.currentSSID() : null;
res.writeHead(200, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' });
res.end(JSON.stringify({ connected, ssid }));
return;
}
if (url === '/api/wifi/scan' && method === 'GET') {
await this.handleScan(res);
return;
@ -141,7 +166,7 @@ export class CaptivePortal {
private async handleScan(res: ServerResponse): Promise<void> {
const networks = await this.wifiService.scan();
res.writeHead(200, { 'Content-Type': 'application/json' });
res.writeHead(200, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' });
res.end(JSON.stringify(networks));
}
@ -159,13 +184,13 @@ export class CaptivePortal {
ssid = parsed.ssid;
password = parsed.password;
} catch {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.writeHead(400, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' });
res.end(JSON.stringify({ success: false, error: 'Invalid request body' }));
return;
}
if (!ssid) {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.writeHead(400, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' });
res.end(JSON.stringify({ success: false, error: 'SSID is required' }));
return;
}
@ -186,7 +211,7 @@ export class CaptivePortal {
// 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.writeHead(200, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' });
res.end(JSON.stringify({ success: true }));
// Notify the setup flow that WiFi is configured
@ -198,7 +223,7 @@ export class CaptivePortal {
// Connection failed — restart AP so user can retry
await this.wifiService.startAP(`Ti-Pote`);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.writeHead(200, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' });
res.end(
JSON.stringify({
success: false,

View File

@ -0,0 +1,333 @@
/**
* Generates the HTML page for the desktop pairing app.
* This is served by the backend or opened locally.
* Allows the user to log in and enter the 6-digit pairing code.
*/
export function pairingAppHTML(backendUrl: string): string {
return `<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Ti-Pote Appairer mon robot</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;
align-items: center;
justify-content: center;
}
.container {
width: 100%;
max-width: 420px;
padding: 2rem;
}
.logo {
text-align: center;
margin-bottom: 2rem;
}
.logo h1 {
font-size: 2rem;
color: #6c5ce7;
margin-bottom: 0.25rem;
}
.logo p {
color: #888;
font-size: 0.9rem;
}
.card {
background: #1a1a2e;
border-radius: 16px;
padding: 2rem;
border: 1px solid #2a2a4a;
}
.step-indicator {
display: flex;
justify-content: center;
gap: 0.5rem;
margin-bottom: 1.5rem;
}
.step-dot {
width: 10px;
height: 10px;
border-radius: 50%;
background: #2a2a4a;
transition: background 0.3s;
}
.step-dot.active { background: #6c5ce7; }
.step-dot.done { background: #00b894; }
h2 {
font-size: 1.2rem;
margin-bottom: 1rem;
text-align: center;
}
.form-group {
margin-bottom: 1rem;
}
label {
display: block;
font-size: 0.85rem;
color: #888;
margin-bottom: 0.4rem;
}
input {
width: 100%;
padding: 0.75rem 1rem;
border-radius: 10px;
border: 1px solid #2a2a4a;
background: #16213e;
color: #e0e0e0;
font-size: 1rem;
outline: none;
transition: border-color 0.2s;
}
input:focus { border-color: #6c5ce7; }
input.code-input {
text-align: center;
font-size: 2rem;
letter-spacing: 0.5rem;
font-family: 'SF Mono', 'Fira Code', monospace;
font-weight: bold;
}
button {
width: 100%;
padding: 0.85rem;
border: none;
border-radius: 10px;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
margin-top: 0.5rem;
}
.btn-primary {
background: #6c5ce7;
color: white;
}
.btn-primary:hover { background: #5a4bd1; }
.btn-primary:disabled {
background: #3a3a5a;
cursor: not-allowed;
}
.error {
background: #e74c3c22;
border: 1px solid #e74c3c44;
color: #e74c3c;
padding: 0.75rem;
border-radius: 8px;
font-size: 0.85rem;
margin-bottom: 1rem;
text-align: center;
}
.success {
text-align: center;
padding: 2rem 0;
}
.success .icon {
font-size: 3rem;
margin-bottom: 1rem;
}
.success h2 {
color: #00b894;
margin-bottom: 0.5rem;
}
.success p {
color: #888;
font-size: 0.9rem;
}
.hidden { display: none; }
.spinner {
display: inline-block;
width: 20px;
height: 20px;
border: 2px solid #ffffff44;
border-top-color: white;
border-radius: 50%;
animation: spin 0.8s linear infinite;
vertical-align: middle;
margin-right: 0.5rem;
}
@keyframes spin { to { transform: rotate(360deg); } }
</style>
</head>
<body>
<div class="container">
<div class="logo">
<h1>Ti-Pote</h1>
<p>Appairer mon robot</p>
</div>
<div class="card">
<div class="step-indicator">
<div class="step-dot active" id="dot1"></div>
<div class="step-dot" id="dot2"></div>
<div class="step-dot" id="dot3"></div>
</div>
<!-- Step 1: Login -->
<div id="step-login">
<h2>Connexion</h2>
<div id="login-error" class="error hidden"></div>
<div class="form-group">
<label>Email</label>
<input type="email" id="email" placeholder="you@example.com" autocomplete="email" />
</div>
<div class="form-group">
<label>Mot de passe</label>
<input type="password" id="password" placeholder="••••••••" autocomplete="current-password" />
</div>
<button class="btn-primary" id="btn-login" onclick="doLogin()">
Se connecter
</button>
</div>
<!-- Step 2: Enter code -->
<div id="step-code" class="hidden">
<h2>Code d'appareillage</h2>
<p style="text-align:center;color:#888;font-size:0.85rem;margin-bottom:1rem;">
Entre le code affiché sur l'écran de ton robot
</p>
<div id="code-error" class="error hidden"></div>
<div class="form-group">
<input type="text" id="pairing-code" class="code-input"
placeholder="000000" maxlength="6" pattern="[0-9]*"
inputmode="numeric" autocomplete="off" />
</div>
<button class="btn-primary" id="btn-pair" onclick="doPair()" disabled>
Appairer
</button>
</div>
<!-- Step 3: Success -->
<div id="step-success" class="hidden">
<div class="success">
<div class="icon">🤖</div>
<h2>Robot appairé !</h2>
<p id="success-name"></p>
<p style="margin-top:1rem;color:#666;">
Ton robot est prêt à discuter.<br/>Tu peux fermer cette page.
</p>
</div>
</div>
</div>
</div>
<script>
const API = '${backendUrl}';
let accessToken = null;
// Auto-focus email on load
document.getElementById('email').focus();
// Handle Enter key in login form
document.getElementById('password').addEventListener('keydown', (e) => {
if (e.key === 'Enter') doLogin();
});
// Code input: auto-enable button when 6 digits
const codeInput = document.getElementById('pairing-code');
codeInput.addEventListener('input', () => {
codeInput.value = codeInput.value.replace(/\\D/g, '').slice(0, 6);
document.getElementById('btn-pair').disabled = codeInput.value.length !== 6;
});
codeInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && codeInput.value.length === 6) doPair();
});
function showStep(step) {
document.getElementById('step-login').classList.toggle('hidden', step !== 1);
document.getElementById('step-code').classList.toggle('hidden', step !== 2);
document.getElementById('step-success').classList.toggle('hidden', step !== 3);
document.getElementById('dot1').className = 'step-dot ' + (step >= 1 ? (step > 1 ? 'done' : 'active') : '');
document.getElementById('dot2').className = 'step-dot ' + (step >= 2 ? (step > 2 ? 'done' : 'active') : '');
document.getElementById('dot3').className = 'step-dot ' + (step >= 3 ? 'done' : '');
}
async function doLogin() {
const email = document.getElementById('email').value.trim();
const password = document.getElementById('password').value;
const btn = document.getElementById('btn-login');
const errorEl = document.getElementById('login-error');
errorEl.classList.add('hidden');
if (!email || !password) {
errorEl.textContent = 'Email et mot de passe requis';
errorEl.classList.remove('hidden');
return;
}
btn.disabled = true;
btn.innerHTML = '<span class="spinner"></span>Connexion...';
try {
const res = await fetch(API + '/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.message || 'Identifiants incorrects');
}
const data = await res.json();
accessToken = data.accessToken;
showStep(2);
document.getElementById('pairing-code').focus();
} catch (err) {
errorEl.textContent = err.message;
errorEl.classList.remove('hidden');
} finally {
btn.disabled = false;
btn.innerHTML = 'Se connecter';
}
}
async function doPair() {
const code = document.getElementById('pairing-code').value.trim();
const btn = document.getElementById('btn-pair');
const errorEl = document.getElementById('code-error');
errorEl.classList.add('hidden');
btn.disabled = true;
btn.innerHTML = '<span class="spinner"></span>Appareillage...';
try {
const res = await fetch(API + '/pairing/confirm', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer ' + accessToken,
},
body: JSON.stringify({ code }),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.message || 'Code invalide ou expiré');
}
const data = await res.json();
document.getElementById('success-name').textContent =
data.deviceName + ' a été ajouté à votre maison.';
showStep(3);
} catch (err) {
errorEl.textContent = err.message;
errorEl.classList.remove('hidden');
btn.disabled = false;
btn.innerHTML = 'Appairer';
document.getElementById('pairing-code').value = '';
document.getElementById('pairing-code').focus();
}
}
</script>
</body>
</html>`;
}

View File

@ -0,0 +1,124 @@
import { describe, it, expect } from 'vitest';
import {
crc8,
encodeFrame,
FrameDecoder,
FRAME_START,
MsgType,
Emotion,
type DecodedFrame,
} from '../../src/hardware/protocol.js';
describe('hardware/protocol', () => {
describe('crc8', () => {
it('returns 0 for an empty buffer', () => {
expect(crc8(Buffer.alloc(0))).toBe(0);
});
it('matches the known-good value for a single byte', () => {
// CRC-8/SMBus (poly 0x07, init 0x00) of [0x00] is 0x00.
expect(crc8(Buffer.from([0x00]))).toBe(0x00);
// CRC-8 of [0x01] = 0x07.
expect(crc8(Buffer.from([0x01]))).toBe(0x07);
// CRC-8 of [0xAA] = 0x5F.
expect(crc8(Buffer.from([0xaa]))).toBe(0x5f);
});
it('is stable across reference inputs', () => {
// The string "123456789" is the canonical CRC test vector —
// CRC-8 (poly 0x07, init 0x00) = 0xF4.
expect(crc8(Buffer.from('123456789', 'ascii'))).toBe(0xf4);
});
});
describe('encodeFrame', () => {
it('builds a well-formed DISPLAY_EMOTION frame', () => {
const frame = encodeFrame(MsgType.DISPLAY_EMOTION, Buffer.from([Emotion.HAPPY]));
expect(frame[0]).toBe(FRAME_START);
expect(frame[1]).toBe(MsgType.DISPLAY_EMOTION);
expect(frame[2]).toBe(0x00); // length high byte
expect(frame[3]).toBe(0x01); // length low byte
expect(frame[4]).toBe(Emotion.HAPPY);
// The last byte is CRC8 over bytes [1..5).
expect(frame[5]).toBe(crc8(frame, 1, 5));
expect(frame.length).toBe(6);
});
it('handles zero-length payloads (DISPLAY_CLEAR, STATUS)', () => {
const frame = encodeFrame(MsgType.DISPLAY_CLEAR);
expect(frame.length).toBe(5);
expect(frame[0]).toBe(FRAME_START);
expect(frame[1]).toBe(MsgType.DISPLAY_CLEAR);
expect(frame[2]).toBe(0x00);
expect(frame[3]).toBe(0x00);
// CRC over just [type, 0, 0].
expect(frame[4]).toBe(crc8(Buffer.from([MsgType.DISPLAY_CLEAR, 0, 0])));
});
it('rejects oversized payloads', () => {
expect(() => encodeFrame(MsgType.DISPLAY_EMOTION, Buffer.alloc(2048))).toThrow();
});
});
describe('FrameDecoder', () => {
it('decodes a frame fed in one chunk', () => {
const received: DecodedFrame[] = [];
const decoder = new FrameDecoder((f) => received.push(f));
decoder.feed(encodeFrame(MsgType.DISPLAY_EMOTION, Buffer.from([Emotion.LOVE])));
expect(received).toHaveLength(1);
expect(received[0]!.type).toBe(MsgType.DISPLAY_EMOTION);
expect(received[0]!.payload[0]).toBe(Emotion.LOVE);
expect(decoder.framesOk).toBe(1);
expect(decoder.framesDropped).toBe(0);
});
it('decodes a frame fed byte-by-byte', () => {
const received: DecodedFrame[] = [];
const decoder = new FrameDecoder((f) => received.push(f));
const frame = encodeFrame(MsgType.PONG, Buffer.from('hello'));
for (const b of frame) decoder.feed(Buffer.from([b]));
expect(received).toHaveLength(1);
expect(received[0]!.type).toBe(MsgType.PONG);
expect(received[0]!.payload.toString('utf8')).toBe('hello');
});
it('resyncs after a corrupted CRC', () => {
const received: DecodedFrame[] = [];
const decoder = new FrameDecoder((f) => received.push(f));
const bad = encodeFrame(MsgType.DISPLAY_EMOTION, Buffer.from([Emotion.SAD]));
bad[bad.length - 1] = (bad[bad.length - 1]! ^ 0xff) & 0xff; // flip CRC
decoder.feed(bad);
const good = encodeFrame(MsgType.DISPLAY_EMOTION, Buffer.from([Emotion.HAPPY]));
decoder.feed(good);
expect(received).toHaveLength(1);
expect(received[0]!.payload[0]).toBe(Emotion.HAPPY);
expect(decoder.framesDropped).toBe(1);
expect(decoder.framesOk).toBe(1);
});
it('ignores noise before the start byte', () => {
const received: DecodedFrame[] = [];
const decoder = new FrameDecoder((f) => received.push(f));
decoder.feed(Buffer.from([0x00, 0x12, 0x34, 0xff])); // garbage
decoder.feed(encodeFrame(MsgType.PING));
expect(received).toHaveLength(1);
expect(received[0]!.type).toBe(MsgType.PING);
});
it('handles two back-to-back frames in one chunk', () => {
const received: DecodedFrame[] = [];
const decoder = new FrameDecoder((f) => received.push(f));
const buf = Buffer.concat([
encodeFrame(MsgType.DISPLAY_EMOTION, Buffer.from([Emotion.HAPPY])),
encodeFrame(MsgType.DISPLAY_EMOTION, Buffer.from([Emotion.WINK])),
]);
decoder.feed(buf);
expect(received).toHaveLength(2);
expect(received[0]!.payload[0]).toBe(Emotion.HAPPY);
expect(received[1]!.payload[0]).toBe(Emotion.WINK);
});
});
});

8
apps/robot-hardware/.gitignore vendored Normal file
View File

@ -0,0 +1,8 @@
.pio/
.vscode/
.cache/
*.bin
*.elf
*.map
*.hex
*.log

View File

@ -0,0 +1,115 @@
# robot-hardware
Firmware ESP32 de Ti-Pote. Gère l'OLED (yeux animatroniques), et
à terme l'audio I2S, les servos et les LEDs. Communique avec le
`robot-client` (Pi Zero / laptop en dev) via un protocole binaire
sur UART.
## Stack
- **Framework** : Arduino via [PlatformIO](https://platformio.org/)
- **Cibles** : `esp32dev` (prototype actuel) et `esp32-s3` (cible finale, cf. `docs/hardware.md`)
- **Libs** : `U8g2` pour l'OLED SSD1309
## Layout
```
apps/robot-hardware/
├── platformio.ini # envs esp32dev + esp32-s3
├── include/
│ └── protocol_types.h # Référence du protocole (C++) — mirroir TS côté robot-client
├── src/
│ └── main.cpp # setup/loop : RX frames → dispatch → Eyes
├── lib/
│ ├── Protocol/ # Encoder/decoder binaire + CRC8
│ └── Eyes/ # Rendu des 10 émotions sur SSD1309
└── legacy/ # Prototypes .ino originaux (référence)
```
## Protocole UART (v0)
```
┌────────┬──────┬──────────┬──────────┬─────────────┬──────┐
│ START │ TYPE │ LENGTH_H │ LENGTH_L │ PAYLOAD │ CRC8 │
│ 0xAA │ 1B │ 1B │ 1B │ 0..65535 B │ 1B │
└────────┴──────┴──────────┴──────────┴─────────────┴──────┘
```
CRC8 : polynôme `0x07`, init `0x00`, calculé sur
`TYPE + LENGTH + PAYLOAD`.
Types de messages implémentés dans cette itération :
| Code | Nom | Direction | Payload |
|---------|--------------------|-------------|----------------------------|
| `0x05` | `STATUS` | host → fw | vide (heartbeat) |
| `0x08` | `ACK` | fw → host | opaque (contexte) |
| `0x20` | `DISPLAY_EMOTION` | host → fw | 1 byte (code émotion 0..9) |
| `0x21` | `DISPLAY_CLEAR` | host → fw | vide |
| `0xF0` | `PING` | host → fw | opaque |
| `0xF1` | `PONG` | fw → host | echo du PING |
| `0xFD` | `LOG` | fw → host | texte UTF-8 |
| `0xFE` | `ERROR` | fw → host | texte UTF-8 |
Les codes `0x01..0x09` sont réservés pour la Phase 2 (audio, servos,
LEDs) — voir `docs/hardware.md`.
## Build & flash
Installe [PlatformIO Core](https://docs.platformio.org/en/latest/core/installation/index.html)
(extension VSCode ou `pipx install platformio`), puis :
```bash
cd apps/robot-hardware
# Compile
pio run
# Flash (auto-détection du port)
pio run -t upload
# Terminal série — tu verras les LOG frames en binaire + les
# bytes bruts. Pour un monitor plus lisible pendant le bring-up,
# préfère la demo TypeScript (voir ci-dessous).
pio device monitor
```
Pour cibler l'ESP32-S3 :
```bash
pio run -e esp32-s3 -t upload
```
## Câblage OLED (SSD1309, 4-wire HW SPI)
| OLED | ESP32 (VSPI) |
|------|--------------|
| VCC | 3.3V |
| GND | GND |
| SCK | GPIO18 |
| SDA | GPIO23 |
| CS | GPIO5 |
| DC | GPIO16 |
| RES | GPIO17 |
Si tu changes ces pins, modifie `EyesPins` dans `src/main.cpp` (ou
passe des pins custom à `Eyes{EyesPins{...}}`).
## Tester depuis le robot-client
Avec l'ESP32 branché en USB sur ton laptop :
```bash
export HARDWARE_SERIAL_PORT=/dev/ttyUSB0 # ou /dev/ttyACM0, /dev/tty.usbserial-XXXX…
pnpm --filter @ti-pote/robot-client hw:demo
```
La demo fait un `ping` puis cycle sur les 10 émotions. Tu dois
voir les yeux changer toutes les 1.2 s sur l'OLED.
## Phase 2 (à venir)
- `AUDIO_UP` / `AUDIO_DOWN` : streaming I2S 16 kHz via buffer DMA
- `SERVO_CMD` : contrôle PWM des servos de tête
- `LED_CMD` : animations NeoPixel
- Bascule de `Serial` vers `Serial2` pour la liaison Pi ↔ ESP32

View File

@ -0,0 +1,91 @@
// Ti-Pote — Shared protocol definitions (firmware side)
//
// This header is the C++ reference for the binary UART protocol between
// the robot-client (Pi Zero 2W, Node.js) and the robot-hardware (ESP32).
//
// The TypeScript mirror lives at:
// apps/robot-client/src/hardware/protocol.ts
//
// Any change here MUST be mirrored there — the byte layout and CRC must
// match exactly or frames will be rejected.
//
// Frame layout (see docs/hardware.md):
//
// ┌────────┬──────┬──────────┬──────────┬─────────────┬──────┐
// │ START │ TYPE │ LENGTH_H │ LENGTH_L │ PAYLOAD │ CRC8 │
// │ 0xAA │ 1B │ 1B │ 1B │ 0..65535 B │ 1B │
// └────────┴──────┴──────────┴──────────┴─────────────┴──────┘
//
// CRC8 is computed over TYPE + LENGTH_H + LENGTH_L + PAYLOAD,
// polynomial 0x07, init 0x00, no reflection, no final XOR.
#pragma once
#include <stdint.h>
namespace tipote {
static constexpr uint8_t FRAME_START = 0xAA;
static constexpr uint16_t MAX_PAYLOAD_SIZE = 1024; // plenty for v0; audio will bump this later
static constexpr size_t FRAME_HEADER_SIZE = 4; // START + TYPE + LEN_H + LEN_L
static constexpr size_t FRAME_OVERHEAD = FRAME_HEADER_SIZE + 1; // + CRC
// Message type codes.
//
// The 0x01..0x09 range is reserved for the full protocol defined in
// docs/hardware.md (AUDIO_UP, AUDIO_DOWN, SERVO_CMD, LED_CMD, STATUS,
// SENSOR_DATA, CONFIG, ACK, IDLE_MODE). We implement a subset for v0
// (just what's needed to drive the OLED eyes) and start the display
// specific codes at 0x20 to leave room for the rest.
enum class MsgType : uint8_t {
// Phase 1 reserved (not yet implemented)
AUDIO_UP = 0x01,
AUDIO_DOWN = 0x02,
SERVO_CMD = 0x03,
LED_CMD = 0x04,
STATUS = 0x05,
SENSOR_DATA = 0x06,
CONFIG = 0x07,
ACK = 0x08,
IDLE_MODE = 0x09,
// v0 — display / eyes
DISPLAY_EMOTION = 0x20, // payload: 1 byte (Emotion code)
DISPLAY_CLEAR = 0x21, // payload: 0 bytes
// v0 — bring-up / diagnostics
PING = 0xF0, // payload: optional opaque bytes, echoed back
PONG = 0xF1, // reply to PING
LOG = 0xFD, // firmware → host human-readable log line
ERROR = 0xFE, // firmware → host error code + message
};
// Emotion catalogue — must match the TS side AND the Eyes library.
enum class Emotion : uint8_t {
NEUTRAL = 0,
HAPPY = 1,
SAD = 2,
ANGRY = 3,
SURPRISED = 4,
SLEEPY = 5,
WINK = 6,
LOVE = 7,
DIZZY = 8,
DEAD = 9,
COUNT
};
// CRC-8, poly=0x07, init=0x00. Small and matches what the TS side computes.
inline uint8_t crc8(const uint8_t* data, size_t len) {
uint8_t crc = 0x00;
for (size_t i = 0; i < len; ++i) {
crc ^= data[i];
for (uint8_t b = 0; b < 8; ++b) {
crc = (crc & 0x80) ? static_cast<uint8_t>((crc << 1) ^ 0x07)
: static_cast<uint8_t>(crc << 1);
}
}
return crc;
}
} // namespace tipote

View File

@ -0,0 +1,14 @@
# Legacy Arduino sketches
Les fichiers de ce dossier sont les **prototypes originaux** en `.ino`
d'avril 2026, avant la restructuration du projet en PlatformIO.
Ils sont conservés comme **référence visuelle** : la géométrie de
`robot-emotion.ino` a été portée 1:1 dans `lib/Eyes/src/Eyes.cpp`,
donc à pixel près le rendu du firmware PlatformIO doit être identique.
- `robot-hardware.ino` — premier test d'affichage (neutre statique).
- `robot-emotion/robot-emotion.ino` — cycle des 10 émotions.
Ne pas les utiliser pour de nouveaux développements : le vrai point
d'entrée est désormais `src/main.cpp`.

View File

@ -0,0 +1,253 @@
#include <U8g2lib.h>
#include <SPI.h>
#include <math.h>
#define CS 5
#define DC 16
#define RESET 17
U8G2_SSD1309_128X64_NONAME0_F_4W_HW_SPI u8g2(U8G2_R2, CS, DC, RESET);
// ---- Géométrie commune ----
const int EYE_R = 29;
const int BAR_THICK = 5;
const int BAR_PITCH = 6;
const int BAR_ROUND = 2;
const int N_BARS = 10;
const int EYE_L_CX = 32;
const int EYE_R_CX = 96;
const int EYE_CY = 32;
const int PUPIL_R = 11;
enum Emotion {
NEUTRAL = 0, HAPPY, SAD, ANGRY, SURPRISED,
SLEEPY, WINK, LOVE, DIZZY, DEAD, N_EMOTIONS
};
// ---- Utilitaire : trace une barre horizontale arrondie dont la largeur
// suit le profil d'un cercle de rayon eyeR, à l'offset vertical dy ----
void drawBar(int cx, int cy, int dy, int eyeR) {
int dy2 = dy * dy;
int r2 = eyeR * eyeR;
if (dy2 >= r2) return;
int halfW = (int)sqrtf((float)(r2 - dy2));
int topY = cy + dy - BAR_THICK / 2;
int w = 2 * halfW + 1;
if (w > 2 * BAR_ROUND && BAR_THICK > 2 * BAR_ROUND) {
u8g2.drawRBox(cx - halfW, topY, w, BAR_THICK, BAR_ROUND);
} else {
u8g2.drawBox(cx - halfW, topY, w, BAR_THICK);
}
}
// =========================================================================
// 1) NEUTRAL : état "par défaut", 10 barres, 4e supprimée, pupille haute,
// 3e barre redessinée par-dessus la pupille.
// =========================================================================
void drawNeutral(int cx, int cy) {
const int SKIP_BAR = 3;
const int OVER_BAR = 2;
const int PUPIL_DY = -3;
for (int i = 0; i < N_BARS; i++) {
if (i == SKIP_BAR) continue;
float off = (i - (N_BARS - 1) / 2.0f) * BAR_PITCH;
drawBar(cx, cy, (int)roundf(off), EYE_R);
}
u8g2.setDrawColor(0);
u8g2.drawDisc(cx, cy + PUPIL_DY, PUPIL_R);
u8g2.setDrawColor(1);
float off = (OVER_BAR - (N_BARS - 1) / 2.0f) * BAR_PITCH;
drawBar(cx, cy, (int)roundf(off), EYE_R);
}
// =========================================================================
// 2) HAPPY : arc courbé vers le haut (^_^) — croissant fait avec 2 discs
// =========================================================================
void drawHappy(int cx, int cy) {
u8g2.drawDisc(cx, cy + 8, EYE_R - 2);
u8g2.setDrawColor(0);
u8g2.drawDisc(cx, cy + 16, EYE_R - 2);
u8g2.setDrawColor(1);
}
// =========================================================================
// 3) SAD : arc inversé (⌣) + petite larme sur l'œil gauche
// =========================================================================
void drawSad(int cx, int cy) {
u8g2.drawDisc(cx, cy - 8, EYE_R - 2);
u8g2.setDrawColor(0);
u8g2.drawDisc(cx, cy - 16, EYE_R - 2);
u8g2.setDrawColor(1);
// larme sous l'œil (côté extérieur)
bool isLeft = (cx < 64);
int tx = isLeft ? (cx - 14) : (cx + 14);
u8g2.drawDisc(tx, cy + 20, 3);
u8g2.drawTriangle(tx - 3, cy + 20, tx + 3, cy + 20, tx, cy + 14);
}
// =========================================================================
// 4) ANGRY : œil écrasé + sourcil diagonal froncé
// =========================================================================
void drawAngry(int cx, int cy) {
int cyShift = cy + 6;
int smallR = EYE_R - 6;
// 4 barres centrales
for (int i = 3; i <= 6; i++) {
float off = (i - (N_BARS - 1) / 2.0f) * BAR_PITCH;
drawBar(cx, cyShift, (int)roundf(off), smallR);
}
// petite pupille
u8g2.setDrawColor(0);
u8g2.drawDisc(cx, cyShift, 5);
u8g2.setDrawColor(1);
// sourcil diagonal épais
bool isLeft = (cx < 64);
int x1, y1, x2, y2;
if (isLeft) {
x1 = cx - smallR - 2; y1 = cy - smallR - 4;
x2 = cx + smallR + 2; y2 = cy - smallR + 6;
} else {
x1 = cx - smallR - 2; y1 = cy - smallR + 6;
x2 = cx + smallR + 2; y2 = cy - smallR - 4;
}
for (int k = -2; k <= 2; k++) {
u8g2.drawLine(x1, y1 + k, x2, y2 + k);
}
}
// =========================================================================
// 5) SURPRISED : œil plus grand, toutes les barres présentes, petite pupille
// =========================================================================
void drawSurprised(int cx, int cy) {
for (int i = 0; i < N_BARS; i++) {
float off = (i - (N_BARS - 1) / 2.0f) * BAR_PITCH;
drawBar(cx, cy, (int)roundf(off), EYE_R);
}
u8g2.setDrawColor(0);
u8g2.drawDisc(cx, cy, 5);
u8g2.setDrawColor(1);
}
// =========================================================================
// 6) SLEEPY : seulement les barres du bas + paupière épaisse en haut
// =========================================================================
void drawSleepy(int cx, int cy) {
for (int i = 5; i < N_BARS; i++) {
float off = (i - (N_BARS - 1) / 2.0f) * BAR_PITCH;
drawBar(cx, cy, (int)roundf(off), EYE_R);
}
u8g2.drawRBox(cx - EYE_R + 2, cy - 2, 2 * EYE_R - 3, 4, 2);
}
// =========================================================================
// 7) WINK : œil gauche normal, œil droit fermé (barre horizontale)
// =========================================================================
void drawWinkClosed(int cx, int cy) {
u8g2.drawRBox(cx - EYE_R + 4, cy - 3, 2 * EYE_R - 7, 6, 3);
}
// =========================================================================
// 8) LOVE : cœur plein (2 discs + triangle)
// =========================================================================
void drawHeart(int cx, int cy) {
const int r = 11;
int topY = cy - 6;
u8g2.drawDisc(cx - 7, topY, r);
u8g2.drawDisc(cx + 7, topY, r);
u8g2.drawTriangle(cx - 17, topY + 2, cx + 17, topY + 2, cx, cy + 20);
}
// =========================================================================
// 9) DIZZY : cercles concentriques + petite pupille décalée
// =========================================================================
void drawDizzy(int cx, int cy) {
u8g2.drawCircle(cx, cy, EYE_R - 2);
u8g2.drawCircle(cx, cy, EYE_R - 8);
u8g2.drawCircle(cx, cy, EYE_R - 14);
u8g2.drawCircle(cx, cy, EYE_R - 20);
u8g2.drawDisc(cx + 4, cy - 2, 3);
}
// =========================================================================
// 10) DEAD : deux lignes épaisses en X
// =========================================================================
void drawDead(int cx, int cy) {
int a = EYE_R - 4;
for (int k = -2; k <= 2; k++) {
u8g2.drawLine(cx - a + k, cy - a, cx + a + k, cy + a);
u8g2.drawLine(cx - a + k, cy + a, cx + a + k, cy - a);
}
}
// =========================================================================
// Cycle des émotions
// =========================================================================
void drawEmotion(Emotion e) {
u8g2.clearBuffer();
switch (e) {
case NEUTRAL:
drawNeutral(EYE_L_CX, EYE_CY);
drawNeutral(EYE_R_CX, EYE_CY);
break;
case HAPPY:
drawHappy(EYE_L_CX, EYE_CY);
drawHappy(EYE_R_CX, EYE_CY);
break;
case SAD:
drawSad(EYE_L_CX, EYE_CY);
drawSad(EYE_R_CX, EYE_CY);
break;
case ANGRY:
drawAngry(EYE_L_CX, EYE_CY);
drawAngry(EYE_R_CX, EYE_CY);
break;
case SURPRISED:
drawSurprised(EYE_L_CX, EYE_CY);
drawSurprised(EYE_R_CX, EYE_CY);
break;
case SLEEPY:
drawSleepy(EYE_L_CX, EYE_CY);
drawSleepy(EYE_R_CX, EYE_CY);
break;
case WINK:
drawNeutral(EYE_L_CX, EYE_CY);
drawWinkClosed(EYE_R_CX, EYE_CY);
break;
case LOVE:
drawHeart(EYE_L_CX, EYE_CY);
drawHeart(EYE_R_CX, EYE_CY);
break;
case DIZZY:
drawDizzy(EYE_L_CX, EYE_CY);
drawDizzy(EYE_R_CX, EYE_CY);
break;
case DEAD:
drawDead(EYE_L_CX, EYE_CY);
drawDead(EYE_R_CX, EYE_CY);
break;
default: break;
}
u8g2.sendBuffer();
}
const unsigned long EMOTION_INTERVAL = 3000;
unsigned long lastSwitch = 0;
int current = 0;
void setup() {
u8g2.begin();
drawEmotion((Emotion)current);
lastSwitch = millis();
}
void loop() {
if (millis() - lastSwitch >= EMOTION_INTERVAL) {
current = (current + 1) % N_EMOTIONS;
drawEmotion((Emotion)current);
lastSwitch = millis();
}
}

View File

@ -0,0 +1,81 @@
#include <U8g2lib.h>
#include <SPI.h>
#include <math.h>
#define CS 5
#define DC 16
#define RESET 17
U8G2_SSD1309_128X64_NONAME0_F_4W_HW_SPI u8g2(U8G2_R2, CS, DC, RESET);
// Yeux ronds formés de 10 barres horizontales, la 4e depuis le haut supprimée
const int EYE_R = 29; // rayon du cercle
const int BAR_THICK = 5; // épaisseur
const int BAR_PITCH = 6; // espacement centre-à-centre
const int BAR_ROUND = 2; // rayon coin arrondi des barres
const int N_BARS = 10;
const int SKIP_BAR = 3; // 4e en partant du haut (index 0 = top)
const int OVER_BAR = 2; // 3e en partant du haut, redessinée par-dessus la pupille
const int EYE_L_CX = 32;
const int EYE_R_CX = 96;
const int EYE_CY = 32;
const int PUPIL_R = 11;
const int PUPIL_DY = -3;
void drawEye(int cx, int cy) {
for (int i = 0; i < N_BARS; i++) {
if (i == SKIP_BAR) continue;
// offset vertical centré autour de cy (10 barres symétriques)
float off = (i - (N_BARS - 1) / 2.0f) * BAR_PITCH;
int dy = (int)roundf(off);
int dy2 = dy * dy;
int r2 = EYE_R * EYE_R;
if (dy2 >= r2) continue;
int halfW = (int)sqrtf((float)(r2 - dy2));
int topY = cy + dy - BAR_THICK / 2;
int w = 2 * halfW + 1;
// garde-fou pour drawRBox (w et h doivent être > 2*r)
if (w > 2 * BAR_ROUND && BAR_THICK > 2 * BAR_ROUND) {
u8g2.drawRBox(cx - halfW, topY, w, BAR_THICK, BAR_ROUND);
} else {
u8g2.drawBox(cx - halfW, topY, w, BAR_THICK);
}
}
// pupille : disque sombre creusé
u8g2.setDrawColor(0);
u8g2.drawDisc(cx, cy + PUPIL_DY, PUPIL_R);
u8g2.setDrawColor(1);
// redessine le 3e trait par-dessus la pupille
{
float off = (OVER_BAR - (N_BARS - 1) / 2.0f) * BAR_PITCH;
int dy = (int)roundf(off);
int dy2 = dy * dy;
int r2 = EYE_R * EYE_R;
if (dy2 < r2) {
int halfW = (int)sqrtf((float)(r2 - dy2));
int topY = cy + dy - BAR_THICK / 2;
int w = 2 * halfW + 1;
if (w > 2 * BAR_ROUND && BAR_THICK > 2 * BAR_ROUND) {
u8g2.drawRBox(cx - halfW, topY, w, BAR_THICK, BAR_ROUND);
} else {
u8g2.drawBox(cx - halfW, topY, w, BAR_THICK);
}
}
}
}
void setup() {
u8g2.begin();
u8g2.clearBuffer();
drawEye(EYE_L_CX, EYE_CY);
drawEye(EYE_R_CX, EYE_CY);
u8g2.sendBuffer();
}
void loop() {
// image statique
}

View File

@ -0,0 +1,10 @@
{
"name": "Eyes",
"version": "0.1.0",
"description": "Ti-Pote animatronic eyes renderer on SSD1309 (U8g2).",
"frameworks": "arduino",
"platforms": "espressif32",
"dependencies": {
"olikraus/U8g2": "^2.35.30"
}
}

View File

@ -0,0 +1,203 @@
#include "Eyes.h"
#include <math.h>
namespace tipote {
// Geometry constants — ported from legacy/robot-emotion/robot-emotion.ino
static constexpr int EYE_R = 29;
static constexpr int BAR_THICK = 5;
static constexpr int BAR_PITCH = 6;
static constexpr int BAR_ROUND = 2;
static constexpr int N_BARS = 10;
static constexpr int EYE_L_CX = 32;
static constexpr int EYE_R_CX = 96;
static constexpr int EYE_CY = 32;
static constexpr int PUPIL_R = 11;
Eyes::Eyes(const EyesPins& pins)
: display_(U8G2_R2, pins.cs, pins.dc, pins.reset) {}
void Eyes::begin() {
display_.begin();
show(Emotion::NEUTRAL);
}
void Eyes::clear() {
display_.clearBuffer();
display_.sendBuffer();
}
void Eyes::drawBar_(int cx, int cy, int dy, int eyeR) {
int dy2 = dy * dy;
int r2 = eyeR * eyeR;
if (dy2 >= r2) return;
int halfW = (int)sqrtf((float)(r2 - dy2));
int topY = cy + dy - BAR_THICK / 2;
int w = 2 * halfW + 1;
if (w > 2 * BAR_ROUND && BAR_THICK > 2 * BAR_ROUND) {
display_.drawRBox(cx - halfW, topY, w, BAR_THICK, BAR_ROUND);
} else {
display_.drawBox(cx - halfW, topY, w, BAR_THICK);
}
}
void Eyes::drawNeutral_(int cx, int cy) {
const int SKIP_BAR = 3;
const int OVER_BAR = 2;
const int PUPIL_DY = -3;
for (int i = 0; i < N_BARS; i++) {
if (i == SKIP_BAR) continue;
float off = (i - (N_BARS - 1) / 2.0f) * BAR_PITCH;
drawBar_(cx, cy, (int)roundf(off), EYE_R);
}
display_.setDrawColor(0);
display_.drawDisc(cx, cy + PUPIL_DY, PUPIL_R);
display_.setDrawColor(1);
float off = (OVER_BAR - (N_BARS - 1) / 2.0f) * BAR_PITCH;
drawBar_(cx, cy, (int)roundf(off), EYE_R);
}
void Eyes::drawHappy_(int cx, int cy) {
display_.drawDisc(cx, cy + 8, EYE_R - 2);
display_.setDrawColor(0);
display_.drawDisc(cx, cy + 16, EYE_R - 2);
display_.setDrawColor(1);
}
void Eyes::drawSad_(int cx, int cy) {
display_.drawDisc(cx, cy - 8, EYE_R - 2);
display_.setDrawColor(0);
display_.drawDisc(cx, cy - 16, EYE_R - 2);
display_.setDrawColor(1);
bool isLeft = (cx < 64);
int tx = isLeft ? (cx - 14) : (cx + 14);
display_.drawDisc(tx, cy + 20, 3);
display_.drawTriangle(tx - 3, cy + 20, tx + 3, cy + 20, tx, cy + 14);
}
void Eyes::drawAngry_(int cx, int cy) {
int cyShift = cy + 6;
int smallR = EYE_R - 6;
for (int i = 3; i <= 6; i++) {
float off = (i - (N_BARS - 1) / 2.0f) * BAR_PITCH;
drawBar_(cx, cyShift, (int)roundf(off), smallR);
}
display_.setDrawColor(0);
display_.drawDisc(cx, cyShift, 5);
display_.setDrawColor(1);
bool isLeft = (cx < 64);
int x1, y1, x2, y2;
if (isLeft) {
x1 = cx - smallR - 2; y1 = cy - smallR - 4;
x2 = cx + smallR + 2; y2 = cy - smallR + 6;
} else {
x1 = cx - smallR - 2; y1 = cy - smallR + 6;
x2 = cx + smallR + 2; y2 = cy - smallR - 4;
}
for (int k = -2; k <= 2; k++) {
display_.drawLine(x1, y1 + k, x2, y2 + k);
}
}
void Eyes::drawSurprised_(int cx, int cy) {
for (int i = 0; i < N_BARS; i++) {
float off = (i - (N_BARS - 1) / 2.0f) * BAR_PITCH;
drawBar_(cx, cy, (int)roundf(off), EYE_R);
}
display_.setDrawColor(0);
display_.drawDisc(cx, cy, 5);
display_.setDrawColor(1);
}
void Eyes::drawSleepy_(int cx, int cy) {
for (int i = 5; i < N_BARS; i++) {
float off = (i - (N_BARS - 1) / 2.0f) * BAR_PITCH;
drawBar_(cx, cy, (int)roundf(off), EYE_R);
}
display_.drawRBox(cx - EYE_R + 2, cy - 2, 2 * EYE_R - 3, 4, 2);
}
void Eyes::drawWinkClosed_(int cx, int cy) {
display_.drawRBox(cx - EYE_R + 4, cy - 3, 2 * EYE_R - 7, 6, 3);
}
void Eyes::drawHeart_(int cx, int cy) {
const int r = 11;
int topY = cy - 6;
display_.drawDisc(cx - 7, topY, r);
display_.drawDisc(cx + 7, topY, r);
display_.drawTriangle(cx - 17, topY + 2, cx + 17, topY + 2, cx, cy + 20);
}
void Eyes::drawDizzy_(int cx, int cy) {
display_.drawCircle(cx, cy, EYE_R - 2);
display_.drawCircle(cx, cy, EYE_R - 8);
display_.drawCircle(cx, cy, EYE_R - 14);
display_.drawCircle(cx, cy, EYE_R - 20);
display_.drawDisc(cx + 4, cy - 2, 3);
}
void Eyes::drawDead_(int cx, int cy) {
int a = EYE_R - 4;
for (int k = -2; k <= 2; k++) {
display_.drawLine(cx - a + k, cy - a, cx + a + k, cy + a);
display_.drawLine(cx - a + k, cy + a, cx + a + k, cy - a);
}
}
void Eyes::show(Emotion emotion) {
current_ = emotion;
display_.clearBuffer();
switch (emotion) {
case Emotion::NEUTRAL:
drawNeutral_(EYE_L_CX, EYE_CY);
drawNeutral_(EYE_R_CX, EYE_CY);
break;
case Emotion::HAPPY:
drawHappy_(EYE_L_CX, EYE_CY);
drawHappy_(EYE_R_CX, EYE_CY);
break;
case Emotion::SAD:
drawSad_(EYE_L_CX, EYE_CY);
drawSad_(EYE_R_CX, EYE_CY);
break;
case Emotion::ANGRY:
drawAngry_(EYE_L_CX, EYE_CY);
drawAngry_(EYE_R_CX, EYE_CY);
break;
case Emotion::SURPRISED:
drawSurprised_(EYE_L_CX, EYE_CY);
drawSurprised_(EYE_R_CX, EYE_CY);
break;
case Emotion::SLEEPY:
drawSleepy_(EYE_L_CX, EYE_CY);
drawSleepy_(EYE_R_CX, EYE_CY);
break;
case Emotion::WINK:
drawNeutral_(EYE_L_CX, EYE_CY);
drawWinkClosed_(EYE_R_CX, EYE_CY);
break;
case Emotion::LOVE:
drawHeart_(EYE_L_CX, EYE_CY);
drawHeart_(EYE_R_CX, EYE_CY);
break;
case Emotion::DIZZY:
drawDizzy_(EYE_L_CX, EYE_CY);
drawDizzy_(EYE_R_CX, EYE_CY);
break;
case Emotion::DEAD:
drawDead_(EYE_L_CX, EYE_CY);
drawDead_(EYE_R_CX, EYE_CY);
break;
default:
// Unknown emotion: render NEUTRAL as a safe fallback.
drawNeutral_(EYE_L_CX, EYE_CY);
drawNeutral_(EYE_R_CX, EYE_CY);
break;
}
display_.sendBuffer();
}
} // namespace tipote

View File

@ -0,0 +1,60 @@
// Ti-Pote — Animatronic eyes on an SSD1309 128x64 OLED.
//
// This is a refactor of the original Arduino sketches that used to
// live at apps/robot-hardware/robot-emotion/robot-emotion.ino. The
// drawing primitives are unchanged — only the organisation is new,
// so emotions can be driven from the main loop in response to
// incoming UART commands instead of cycling on a timer.
#pragma once
#include <U8g2lib.h>
#include "../../../include/protocol_types.h"
namespace tipote {
// Default SPI wiring matches the working prototype from the legacy
// sketches. Change these at construction time if the PCB uses
// different pins.
struct EyesPins {
uint8_t cs = 5;
uint8_t dc = 16;
uint8_t reset = 17;
};
class Eyes {
public:
explicit Eyes(const EyesPins& pins = EyesPins{});
// Must be called from setup() once.
void begin();
// Render a full-screen emotion. Safe to call from any context.
void show(Emotion emotion);
// Clear the screen (dark). Useful for "sleeping" or before
// switching to a custom animation.
void clear();
Emotion current() const { return current_; }
private:
U8G2_SSD1309_128X64_NONAME0_F_4W_HW_SPI display_;
Emotion current_ = Emotion::NEUTRAL;
// Primitives ported 1:1 from robot-emotion.ino so the visual
// output is byte-identical to the prototype.
void drawBar_(int cx, int cy, int dy, int eyeR);
void drawNeutral_(int cx, int cy);
void drawHappy_(int cx, int cy);
void drawSad_(int cx, int cy);
void drawAngry_(int cx, int cy);
void drawSurprised_(int cx, int cy);
void drawSleepy_(int cx, int cy);
void drawWinkClosed_(int cx, int cy);
void drawHeart_(int cx, int cy);
void drawDizzy_(int cx, int cy);
void drawDead_(int cx, int cy);
};
} // namespace tipote

View File

@ -0,0 +1,7 @@
{
"name": "Protocol",
"version": "0.1.0",
"description": "Ti-Pote binary UART protocol: framing, CRC, stream decoder.",
"frameworks": "arduino",
"platforms": "espressif32"
}

View File

@ -0,0 +1,122 @@
#include "Protocol.h"
namespace tipote {
bool FrameDecoder::feed(uint8_t byte) {
switch (state_) {
case State::WAIT_START:
if (byte == FRAME_START) {
state_ = State::READ_TYPE;
}
return false;
case State::READ_TYPE:
type_ = byte;
state_ = State::READ_LEN_H;
return false;
case State::READ_LEN_H:
length_ = static_cast<uint16_t>(byte) << 8;
state_ = State::READ_LEN_L;
return false;
case State::READ_LEN_L:
length_ |= byte;
if (length_ > MAX_PAYLOAD_SIZE) {
// Oversized frame: drop and re-sync.
framesDropped_++;
reset_();
return false;
}
payloadIdx_ = 0;
state_ = (length_ == 0) ? State::READ_CRC : State::READ_PAYLOAD;
return false;
case State::READ_PAYLOAD:
payload_[payloadIdx_++] = byte;
if (payloadIdx_ == length_) {
state_ = State::READ_CRC;
}
return false;
case State::READ_CRC: {
// Compute expected CRC over TYPE + LEN_H + LEN_L + PAYLOAD.
uint8_t header[3] = {
type_,
static_cast<uint8_t>((length_ >> 8) & 0xFF),
static_cast<uint8_t>(length_ & 0xFF),
};
uint8_t crc = crc8(header, 3);
// Extend over payload. We can't easily continue crc8 with a
// different pointer in one call, so fold payload in manually.
for (uint16_t i = 0; i < length_; ++i) {
crc ^= payload_[i];
for (uint8_t b = 0; b < 8; ++b) {
crc = (crc & 0x80) ? static_cast<uint8_t>((crc << 1) ^ 0x07)
: static_cast<uint8_t>(crc << 1);
}
}
bool emitted = false;
if (crc == byte) {
framesOk_++;
if (handler_) {
Frame frame{
static_cast<MsgType>(type_),
length_,
payload_,
};
handler_(frame, userData_);
}
emitted = true;
} else {
framesDropped_++;
}
reset_();
return emitted;
}
}
return false;
}
size_t FrameDecoder::feed(const uint8_t* data, size_t len) {
size_t emitted = 0;
for (size_t i = 0; i < len; ++i) {
if (feed(data[i])) emitted++;
}
return emitted;
}
size_t FrameEncoder::encode(MsgType type,
const uint8_t* payload,
uint16_t length,
uint8_t* out,
size_t outCapacity) {
if (length > MAX_PAYLOAD_SIZE) return 0;
const size_t total = FRAME_OVERHEAD + length;
if (outCapacity < total) return 0;
out[0] = FRAME_START;
out[1] = static_cast<uint8_t>(type);
out[2] = static_cast<uint8_t>((length >> 8) & 0xFF);
out[3] = static_cast<uint8_t>(length & 0xFF);
for (uint16_t i = 0; i < length; ++i) {
out[FRAME_HEADER_SIZE + i] = payload[i];
}
// CRC over TYPE + LEN + PAYLOAD (skip START).
out[FRAME_HEADER_SIZE + length] = crc8(out + 1, 3 + length);
return total;
}
bool FrameEncoder::writeTo(Stream& stream,
MsgType type,
const uint8_t* payload,
uint16_t length) {
uint8_t buffer[FRAME_OVERHEAD + MAX_PAYLOAD_SIZE];
size_t written = encode(type, payload, length, buffer, sizeof(buffer));
if (written == 0) return false;
return stream.write(buffer, written) == written;
}
} // namespace tipote

View File

@ -0,0 +1,98 @@
// Ti-Pote — Streaming frame decoder & encoder.
//
// FrameDecoder implements a small state machine that consumes bytes
// coming from a UART one at a time and emits complete frames once
// the CRC matches. Invalid frames are dropped silently (the decoder
// re-syncs on the next 0xAA start byte), and optional counters are
// exposed for diagnostics.
//
// FrameEncoder is a tiny helper that writes a fully framed message
// into a caller-provided buffer.
#pragma once
#include <Arduino.h>
#include <stdint.h>
#include "../../../include/protocol_types.h"
namespace tipote {
struct Frame {
MsgType type;
uint16_t length;
const uint8_t* payload; // points into the decoder's internal buffer — copy if you need to keep it
};
class FrameDecoder {
public:
// Called when a full, CRC-valid frame has been decoded.
using FrameHandler = void (*)(const Frame& frame, void* userData);
FrameDecoder() = default;
void onFrame(FrameHandler handler, void* userData = nullptr) {
handler_ = handler;
userData_ = userData;
}
// Feed one byte from the serial stream. Returns true if a full
// frame was emitted as a result of this byte.
bool feed(uint8_t byte);
// Feed a batch. Returns the number of frames emitted.
size_t feed(const uint8_t* data, size_t len);
uint32_t framesOk() const { return framesOk_; }
uint32_t framesDropped() const { return framesDropped_; }
private:
enum class State : uint8_t {
WAIT_START,
READ_TYPE,
READ_LEN_H,
READ_LEN_L,
READ_PAYLOAD,
READ_CRC,
};
State state_ = State::WAIT_START;
uint8_t type_ = 0;
uint16_t length_ = 0;
uint16_t payloadIdx_ = 0;
uint8_t payload_[MAX_PAYLOAD_SIZE] = {0};
FrameHandler handler_ = nullptr;
void* userData_ = nullptr;
uint32_t framesOk_ = 0;
uint32_t framesDropped_ = 0;
void reset_() {
state_ = State::WAIT_START;
type_ = 0;
length_ = 0;
payloadIdx_ = 0;
}
};
class FrameEncoder {
public:
// Encode a frame into `out`. Returns the total number of bytes
// written, or 0 if `out` is too small.
//
// Required capacity = FRAME_OVERHEAD + length.
static size_t encode(MsgType type,
const uint8_t* payload,
uint16_t length,
uint8_t* out,
size_t outCapacity);
// Convenience: encode + write to an Arduino Stream in one shot.
// Returns true on success.
static bool writeTo(Stream& stream,
MsgType type,
const uint8_t* payload = nullptr,
uint16_t length = 0);
};
} // namespace tipote

View File

@ -0,0 +1,48 @@
; Ti-Pote — Robot Hardware (ESP32 firmware)
; PlatformIO configuration
;
; Two envs are provided:
; - esp32dev : classic ESP32 (what the current OLED prototype uses)
; - esp32-s3 : target board per docs/hardware.md (Phase 2)
;
; Default is esp32dev because the current prototype wiring (VSPI pins
; CS=5, DC=16, RESET=17) targets the classic ESP32 module that Arthur
; currently has plugged into his laptop.
[platformio]
default_envs = esp32dev
src_dir = src
include_dir = include
lib_dir = lib
test_dir = test
[env]
framework = arduino
monitor_speed = 921600
upload_speed = 921600
lib_deps =
olikraus/U8g2@^2.35.30
build_flags =
-std=gnu++17
-Wall
-Wextra
; Protocol baud rate (matches docs/hardware.md)
-DHW_SERIAL_BAUD=921600
; Idle timeout before the eyes fall back to the default animation (ms)
-DHW_HEARTBEAT_TIMEOUT_MS=5000
build_unflags =
-std=gnu++11
[env:esp32dev]
platform = espressif32
board = esp32dev
board_build.f_cpu = 240000000L
[env:esp32-s3]
platform = espressif32
board = esp32-s3-devkitc-1
board_build.f_cpu = 240000000L
build_flags =
${env.build_flags}
-DARDUINO_USB_MODE=1
-DARDUINO_USB_CDC_ON_BOOT=1

View File

@ -0,0 +1,147 @@
// Ti-Pote — Robot Hardware firmware (ESP32)
//
// Responsibilities for v0:
// - Listen on UART0 (the USB-connected serial port while the ESP32
// is plugged into Arthur's laptop; on the real robot this will
// eventually be Serial2 wired to the Raspberry Pi).
// - Decode incoming binary frames (see include/protocol_types.h).
// - Dispatch commands to the Eyes renderer.
// - Reply to PING with PONG.
// - Fall back to a sleepy animation if no heartbeat is received
// for HW_HEARTBEAT_TIMEOUT_MS (set in platformio.ini).
//
// Intentionally NOT yet implemented (Phase 2):
// - I2S audio up/down streaming
// - Servo / LED commands
//
// The hook points for those are marked with TODO(phase2).
#include <Arduino.h>
#include "Protocol.h"
#include "Eyes.h"
#ifndef HW_SERIAL_BAUD
#define HW_SERIAL_BAUD 921600
#endif
#ifndef HW_HEARTBEAT_TIMEOUT_MS
#define HW_HEARTBEAT_TIMEOUT_MS 5000
#endif
// The communication stream. When the ESP32 is plugged into a
// computer, UART0 (Serial) is the USB-CDC port, which is exactly
// what the robot-client will talk to during development. Later,
// for the Pi wiring, change this to Serial2 and call
// `Serial2.begin(HW_SERIAL_BAUD, SERIAL_8N1, RX_PIN, TX_PIN)`.
#define HW_COMM Serial
using namespace tipote;
static Eyes eyes;
static FrameDecoder decoder;
static uint32_t lastHeartbeatMs = 0;
static bool idleMode = false;
// Forward decl
static void handleFrame(const Frame& frame, void* userData);
static void logLine(const char* line);
void setup() {
HW_COMM.begin(HW_SERIAL_BAUD);
// Give the host a beat to open the port after auto-reset.
delay(50);
eyes.begin();
decoder.onFrame(handleFrame);
lastHeartbeatMs = millis();
logLine("robot-hardware ready");
}
void loop() {
// Drain whatever the host has sent since the last tick.
while (HW_COMM.available() > 0) {
int b = HW_COMM.read();
if (b < 0) break;
decoder.feed(static_cast<uint8_t>(b));
}
// Heartbeat watchdog: if we haven't heard from the host in a
// while, slip into a sleepy animation so the robot doesn't
// look frozen. Any incoming frame resets this.
const uint32_t now = millis();
if (!idleMode && (now - lastHeartbeatMs) > HW_HEARTBEAT_TIMEOUT_MS) {
idleMode = true;
eyes.show(Emotion::SLEEPY);
}
}
// ---------------------------------------------------------------
// Frame dispatcher
// ---------------------------------------------------------------
static void handleFrame(const Frame& frame, void* /*userData*/) {
lastHeartbeatMs = millis();
if (idleMode) {
idleMode = false;
}
switch (frame.type) {
case MsgType::DISPLAY_EMOTION: {
if (frame.length < 1) {
logLine("DISPLAY_EMOTION: empty payload");
return;
}
const uint8_t code = frame.payload[0];
if (code >= static_cast<uint8_t>(Emotion::COUNT)) {
logLine("DISPLAY_EMOTION: out-of-range code");
return;
}
eyes.show(static_cast<Emotion>(code));
// ACK back so the host knows it was applied.
uint8_t ackPayload[1] = {code};
FrameEncoder::writeTo(HW_COMM, MsgType::ACK, ackPayload, 1);
return;
}
case MsgType::DISPLAY_CLEAR: {
eyes.clear();
FrameEncoder::writeTo(HW_COMM, MsgType::ACK);
return;
}
case MsgType::PING: {
// Echo the payload back as PONG. Useful for latency
// measurements and proving the link is symmetric.
FrameEncoder::writeTo(HW_COMM, MsgType::PONG,
frame.payload, frame.length);
return;
}
case MsgType::STATUS: {
// Heartbeat from host — lastHeartbeatMs was already
// bumped above. Nothing else to do for v0.
return;
}
// TODO(phase2): AUDIO_UP / AUDIO_DOWN / SERVO_CMD / LED_CMD
default:
logLine("unknown frame type");
return;
}
}
// ---------------------------------------------------------------
// Diagnostic logging — wraps text in a LOG frame so the host
// can parse it without getting confused by free text on the wire.
// ---------------------------------------------------------------
static void logLine(const char* line) {
const size_t len = strnlen(line, MAX_PAYLOAD_SIZE);
FrameEncoder::writeTo(HW_COMM, MsgType::LOG,
reinterpret_cast<const uint8_t*>(line),
static_cast<uint16_t>(len));
}

104
docs/STATUS.md Normal file
View File

@ -0,0 +1,104 @@
# Ti-Pote — État du projet & prochaines missions
> Dernière mise à jour : 2 avril 2026
---
## Ce qui fonctionne
### Voice conversation (end-to-end)
- Wake word ("Hey Jarvis") → capture audio (arecord) → STT Deepgram → LLM → TTS ElevenLabs → playback aplay
- Conversation continue : après chaque réponse, le robot réécoute automatiquement sans re-trigger
- Grace period 3s + silence detection (RMS 200, timeout 2s) pour savoir quand l'utilisateur a fini de parler
- Audio buffering : les chunks sont bufferisés côté robot-client jusqu'à ce que le backend confirme que le stream STT est prêt (évite de couper le premier mot)
### Wake word (OpenWakeWord)
- Script Python long-lived avec PAUSE/RESUME/QUIT via stdin
- Modèle chargé une seule fois au démarrage (pas de reload 8s à chaque cycle)
- PAUSE ferme le stream PyAudio (libère le device pour arecord), RESUME le rouvre
- stdin lu via `sys.stdin.readline()` (pas `for line in sys.stdin` qui bufferise)
### Auto-pairing
- Robot-client : si pas de credentials → appelle `POST /api/pairing/request` → affiche code 6 chiffres en ASCII art sur HDMI → poll `GET /api/pairing/status/:requestId` toutes les 3s
- Backend : Redis TTL 10min, `POST /api/pairing/confirm` (JWT required) associe le robot au home de l'utilisateur
- Credentials persistées dans `~/.tipote/config.json` via LocalStore (path dynamique via `os.homedir()`)
- App desktop Tauri v2 créée dans `apps/desktop/` (login → code 6 chiffres → succès)
### Infrastructure
- Backend NestJS avec archi hexagonale (ports & adapters)
- WebSocket socket.io entre robot-client et backend
- Docker Compose (PostgreSQL + Redis) en dev
- Robot-client TypeScript sur Raspberry Pi
---
## Bugs connus à fixer
### 1. Wake word crash en boucle (PRIORITÉ HAUTE)
**Symptôme** : Le modèle charge OK, puis le process Python exit avec code 1 ~2s après. Boucle de restart infinie.
**Cause probable** : Erreur à l'ouverture du stream audio PyAudio (device busy, mauvais index, ou erreur ALSA). Les messages d'erreur stderr étaient loggés en `debug` donc invisibles.
**Fix appliqué** : Les messages stderr inconnus sont maintenant loggés en `warn`. Relancer et lire les logs pour voir l'erreur exacte.
**Fichiers** :
- `apps/robot-client/scripts/wake_word.py`
- `apps/robot-client/src/services/wake-word.service.ts` (ligne ~113)
### 2. .env vs .env.dev loading
**Symptôme** : `ROBOT_MODE` n'est pas set avant que dotenv charge, donc `.env` est toujours chargé au lieu de `.env.dev`.
**Workaround** : Préfixer la commande avec `ROBOT_MODE=dev` ou éditer `.env` directement sur le Pi.
---
## Prochaines missions
### Court terme (sprint en cours)
1. **Fixer le crash wake word** — Lire les logs stderr (maintenant en `warn`), identifier l'erreur PyAudio, corriger
2. **Tester le pairing end-to-end avec l'app Tauri** — L'app est créée mais pas encore testée avec un vrai pairing flow
3. **Tester la persistence des credentials** — Vérifier qu'après reboot du Pi, le robot-client retrouve ses credentials dans `~/.tipote/config.json` et skip le pairing
### Moyen terme
4. **ESP32 + wake word embarqué** — Déléguer l'audio à l'ESP32 (I2S mic + speaker). Le wake word pourrait tourner sur l'ESP32 via un modèle TFLite léger ou rester sur le Pi
5. **Frontend Next.js** (`apps/frontend`) — Dashboard web pour gérer ses robots, voir les conversations, configurer les préférences
6. **Multi-robot / Multi-home** — Le backend supporte déjà le concept de `home`, mais le flow complet n'est pas testé
7. **OTA updates** — Mécanisme de mise à jour du robot-client sur le Pi (rsync pour le dev, mais il faudra un vrai système pour la prod)
### Long terme
8. **Personnalité configurable** — Chaque robot peut avoir un system prompt custom via l'app/dashboard
9. **Mémoire contextuelle** — Le robot se souvient des conversations passées (pgvector déjà dans le stack)
10. **Animations servo** — Synchroniser les mouvements du robot avec le TTS (lipsync, expressions)
11. **Mode offline** — Wake word + réponses basiques sans connexion cloud
---
## Structure du repo
```
apps/
├── backend/ # NestJS — API REST + WebSocket + LLM/STT/TTS orchestration
├── robot-client/ # TypeScript — tourne sur le Raspberry Pi
├── desktop/ # Tauri v2 — app native de pairing (NEW)
├── frontend/ # Next.js — dashboard web (à venir)
└── simulator/ # Simulateur (existant)
docs/ # Architecture, features, data model, roadmap
```
## Commandes utiles
```bash
# Backend (sur le Mac)
cd apps/backend && pnpm dev
docker compose up -d # PostgreSQL + Redis
# Robot-client (sur le Pi)
cd ~/robot-client && npx tsx src/main.ts
# ou avec mode dev explicite :
ROBOT_MODE=dev npx tsx src/main.ts
# Desktop app (sur le Mac)
cd apps/desktop && pnpm install && pnpm dev
# Rsync vers le Pi
rsync -avz --exclude node_modules --exclude .git apps/robot-client/ pi@192.168.1.XX:~/robot-client/
```

View File

@ -0,0 +1,146 @@
<?xml version="1.0" encoding="utf-8"?>
<!DOCTYPE eagle SYSTEM "eagle.dtd">
<!--
INMP441 Breakout Module - Fusion 360 Electronics / Eagle library
Target: common purple/green INMP441 I2S MEMS microphone breakout
(the one sold on AliExpress/Amazon with a 1x6 2.54 mm pin header).
Nominal module size: 15.0 x 10.5 mm, pin header on one long edge.
Pin order (from the silkscreen of the most common variant):
1 = SCK (I2S bit clock)
2 = SD (I2S data out)
3 = WS (I2S word select / LRCLK)
4 = L/R (channel select: GND = left, VDD = right)
5 = GND
6 = VDD (1.8 - 3.3 V)
Footprint: 1x6 through-hole header, 2.54 mm pitch, drill 1.0 mm,
pad 1.8 mm. Module outline drawn on tPlace (layer 21) and tDocu
(layer 51), with a keepout around the MEMS port hole.
Author: generated for Ti-pote project, 2026-04-08.
Verify against your actual module before ordering PCBs.
-->
<eagle version="9.6.2">
<drawing>
<settings>
<setting alwaysvectorfont="no"/>
<setting verticaltext="up"/>
</settings>
<grid distance="0.1" unitdist="inch" unit="inch" style="lines" multiple="1" display="no" altdistance="0.01" altunitdist="inch" altunit="inch"/>
<layers>
<layer number="1" name="Top" color="4" fill="1" visible="yes" active="yes"/>
<layer number="16" name="Bottom" color="1" fill="1" visible="yes" active="yes"/>
<layer number="17" name="Pads" color="2" fill="1" visible="yes" active="yes"/>
<layer number="18" name="Vias" color="2" fill="1" visible="yes" active="yes"/>
<layer number="19" name="Unrouted" color="6" fill="1" visible="yes" active="yes"/>
<layer number="20" name="Dimension" color="15" fill="1" visible="yes" active="yes"/>
<layer number="21" name="tPlace" color="7" fill="1" visible="yes" active="yes"/>
<layer number="22" name="bPlace" color="7" fill="1" visible="yes" active="yes"/>
<layer number="25" name="tNames" color="7" fill="1" visible="yes" active="yes"/>
<layer number="26" name="bNames" color="7" fill="1" visible="yes" active="yes"/>
<layer number="27" name="tValues" color="7" fill="1" visible="yes" active="yes"/>
<layer number="28" name="bValues" color="7" fill="1" visible="yes" active="yes"/>
<layer number="39" name="tKeepout" color="4" fill="11" visible="yes" active="yes"/>
<layer number="40" name="bKeepout" color="1" fill="11" visible="yes" active="yes"/>
<layer number="51" name="tDocu" color="7" fill="1" visible="yes" active="yes"/>
<layer number="52" name="bDocu" color="7" fill="1" visible="yes" active="yes"/>
<layer number="94" name="Symbols" color="4" fill="1" visible="yes" active="yes"/>
<layer number="95" name="Names" color="7" fill="1" visible="yes" active="yes"/>
<layer number="96" name="Values" color="7" fill="1" visible="yes" active="yes"/>
<layer number="97" name="Info" color="7" fill="1" visible="yes" active="yes"/>
<layer number="98" name="Guide" color="6" fill="1" visible="yes" active="yes"/>
</layers>
<library>
<description>INMP441 I2S MEMS microphone breakout module (1x6 2.54 mm header). Generated for Ti-pote project.</description>
<packages>
<package name="INMP441_BREAKOUT_1X6">
<description>INMP441 breakout module, 15.0 x 10.5 mm, 1x6 pin header, 2.54 mm pitch.
Pins numbered 1..6 = SCK, SD, WS, L/R, GND, VDD.</description>
<!-- Module outline on silkscreen (tPlace) -->
<wire x1="-7.5" y1="-5.25" x2="7.5" y2="-5.25" width="0.127" layer="21"/>
<wire x1="7.5" y1="-5.25" x2="7.5" y2="5.25" width="0.127" layer="21"/>
<wire x1="7.5" y1="5.25" x2="-7.5" y2="5.25" width="0.127" layer="21"/>
<wire x1="-7.5" y1="5.25" x2="-7.5" y2="-5.25" width="0.127" layer="21"/>
<!-- Same outline on tDocu -->
<wire x1="-7.5" y1="-5.25" x2="7.5" y2="-5.25" width="0.05" layer="51"/>
<wire x1="7.5" y1="-5.25" x2="7.5" y2="5.25" width="0.05" layer="51"/>
<wire x1="7.5" y1="5.25" x2="-7.5" y2="5.25" width="0.05" layer="51"/>
<wire x1="-7.5" y1="5.25" x2="-7.5" y2="-5.25" width="0.05" layer="51"/>
<!-- Pin 1 marker (small triangle near SCK) -->
<wire x1="-6.9" y1="-3.1" x2="-6.1" y2="-3.1" width="0.2" layer="21"/>
<wire x1="-6.5" y1="-3.1" x2="-6.5" y2="-3.7" width="0.2" layer="21"/>
<!-- MEMS acoustic port keepout (top-port mic, approx 1 mm dia) -->
<circle x="3.5" y="1.5" radius="0.9" width="0.1" layer="39"/>
<circle x="3.5" y="1.5" radius="0.9" width="0.05" layer="51"/>
<!-- Through-hole pads, 2.54 mm pitch, centered, along bottom edge -->
<pad name="1" x="-6.35" y="-2.54" drill="1.0" diameter="1.8" shape="square"/>
<pad name="2" x="-3.81" y="-2.54" drill="1.0" diameter="1.8"/>
<pad name="3" x="-1.27" y="-2.54" drill="1.0" diameter="1.8"/>
<pad name="4" x="1.27" y="-2.54" drill="1.0" diameter="1.8"/>
<pad name="5" x="3.81" y="-2.54" drill="1.0" diameter="1.8"/>
<pad name="6" x="6.35" y="-2.54" drill="1.0" diameter="1.8"/>
<!-- Silkscreen pin labels -->
<text x="-6.35" y="-4.7" size="0.8" layer="21" align="center">SCK</text>
<text x="-3.81" y="-4.7" size="0.8" layer="21" align="center">SD</text>
<text x="-1.27" y="-4.7" size="0.8" layer="21" align="center">WS</text>
<text x="1.27" y="-4.7" size="0.8" layer="21" align="center">L/R</text>
<text x="3.81" y="-4.7" size="0.8" layer="21" align="center">GND</text>
<text x="6.35" y="-4.7" size="0.8" layer="21" align="center">VDD</text>
<!-- Part designator and value -->
<text x="-7.5" y="5.6" size="1.0" layer="25">&gt;NAME</text>
<text x="-7.5" y="-6.6" size="0.8" layer="27">&gt;VALUE</text>
</package>
</packages>
<symbols>
<symbol name="INMP441">
<description>INMP441 I2S MEMS microphone (breakout module, 1 gate).</description>
<!-- Symbol body -->
<wire x1="-7.62" y1="10.16" x2="7.62" y2="10.16" width="0.254" layer="94"/>
<wire x1="7.62" y1="10.16" x2="7.62" y2="-10.16" width="0.254" layer="94"/>
<wire x1="7.62" y1="-10.16" x2="-7.62" y2="-10.16" width="0.254" layer="94"/>
<wire x1="-7.62" y1="-10.16" x2="-7.62" y2="10.16" width="0.254" layer="94"/>
<!-- Pins -->
<pin name="SCK" x="-12.7" y="7.62" length="middle" direction="in"/>
<pin name="SD" x="-12.7" y="2.54" length="middle" direction="out"/>
<pin name="WS" x="-12.7" y="-2.54" length="middle" direction="in"/>
<pin name="L/R" x="-12.7" y="-7.62" length="middle" direction="in"/>
<pin name="GND" x="12.7" y="-7.62" length="middle" direction="pwr" rot="R180"/>
<pin name="VDD" x="12.7" y="7.62" length="middle" direction="pwr" rot="R180"/>
<!-- Label and value placeholders -->
<text x="-7.62" y="10.8" size="1.778" layer="95">&gt;NAME</text>
<text x="-7.62" y="-12.7" size="1.778" layer="96">&gt;VALUE</text>
<!-- Part name inside the body -->
<text x="0" y="0" size="1.4" layer="94" align="center">INMP441</text>
<text x="0" y="-3" size="0.9" layer="94" align="center">I2S MEMS Mic</text>
</symbol>
</symbols>
<devicesets>
<deviceset name="INMP441_BREAKOUT" prefix="MIC">
<description>INMP441 I2S MEMS microphone breakout module.
Omnidirectional, 24-bit I2S output, 1.8-3.3 V supply.
Footprint: 1x6 through-hole header, 2.54 mm pitch.</description>
<gates>
<gate name="G$1" symbol="INMP441" x="0" y="0"/>
</gates>
<devices>
<device name="" package="INMP441_BREAKOUT_1X6">
<connects>
<connect gate="G$1" pin="SCK" pad="1"/>
<connect gate="G$1" pin="SD" pad="2"/>
<connect gate="G$1" pin="WS" pad="3"/>
<connect gate="G$1" pin="L/R" pad="4"/>
<connect gate="G$1" pin="GND" pad="5"/>
<connect gate="G$1" pin="VDD" pad="6"/>
</connects>
<technologies>
<technology name=""/>
</technologies>
</device>
</devices>
</deviceset>
</devicesets>
</library>
</drawing>
</eagle>

1609
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff