add code
This commit is contained in:
parent
98aa1439e3
commit
a98397a241
10
.gitignore
vendored
10
.gitignore
vendored
@ -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/
|
||||
@ -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;
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
{
|
||||
|
||||
143
apps/backend/src/core/services/pairing.service.ts
Normal file
143
apps/backend/src/core/services/pairing.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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",
|
||||
|
||||
69
apps/robot-client/scripts/hardware-demo.ts
Normal file
69
apps/robot-client/scripts/hardware-demo.ts
Normal 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);
|
||||
});
|
||||
64
apps/robot-client/scripts/verify-protocol.ts
Normal file
64
apps/robot-client/scripts/verify-protocol.ts
Normal 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');
|
||||
@ -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),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
211
apps/robot-client/src/hardware/hardware.service.ts
Normal file
211
apps/robot-client/src/hardware/hardware.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
13
apps/robot-client/src/hardware/index.ts
Normal file
13
apps/robot-client/src/hardware/index.ts
Normal 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';
|
||||
212
apps/robot-client/src/hardware/protocol.ts
Normal file
212
apps/robot-client/src/hardware/protocol.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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!');
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -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.
|
||||
|
||||
177
apps/robot-client/src/services/pairing.service.ts
Normal file
177
apps/robot-client/src/services/pairing.service.ts
Normal 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));
|
||||
}
|
||||
}
|
||||
@ -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');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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,
|
||||
|
||||
333
apps/robot-client/src/setup/pairing-app.html.ts
Normal file
333
apps/robot-client/src/setup/pairing-app.html.ts
Normal 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>`;
|
||||
}
|
||||
124
apps/robot-client/tests/hardware/protocol.test.ts
Normal file
124
apps/robot-client/tests/hardware/protocol.test.ts
Normal 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
8
apps/robot-hardware/.gitignore
vendored
Normal file
@ -0,0 +1,8 @@
|
||||
.pio/
|
||||
.vscode/
|
||||
.cache/
|
||||
*.bin
|
||||
*.elf
|
||||
*.map
|
||||
*.hex
|
||||
*.log
|
||||
115
apps/robot-hardware/README.md
Normal file
115
apps/robot-hardware/README.md
Normal 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
|
||||
91
apps/robot-hardware/include/protocol_types.h
Normal file
91
apps/robot-hardware/include/protocol_types.h
Normal 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
|
||||
14
apps/robot-hardware/legacy/README.md
Normal file
14
apps/robot-hardware/legacy/README.md
Normal 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`.
|
||||
253
apps/robot-hardware/legacy/robot-emotion/robot-emotion.ino
Normal file
253
apps/robot-hardware/legacy/robot-emotion/robot-emotion.ino
Normal 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();
|
||||
}
|
||||
}
|
||||
81
apps/robot-hardware/legacy/robot-hardware.ino
Normal file
81
apps/robot-hardware/legacy/robot-hardware.ino
Normal 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
|
||||
}
|
||||
10
apps/robot-hardware/lib/Eyes/library.json
Normal file
10
apps/robot-hardware/lib/Eyes/library.json
Normal 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"
|
||||
}
|
||||
}
|
||||
203
apps/robot-hardware/lib/Eyes/src/Eyes.cpp
Normal file
203
apps/robot-hardware/lib/Eyes/src/Eyes.cpp
Normal 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
|
||||
60
apps/robot-hardware/lib/Eyes/src/Eyes.h
Normal file
60
apps/robot-hardware/lib/Eyes/src/Eyes.h
Normal 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
|
||||
7
apps/robot-hardware/lib/Protocol/library.json
Normal file
7
apps/robot-hardware/lib/Protocol/library.json
Normal 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"
|
||||
}
|
||||
122
apps/robot-hardware/lib/Protocol/src/Protocol.cpp
Normal file
122
apps/robot-hardware/lib/Protocol/src/Protocol.cpp
Normal 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
|
||||
98
apps/robot-hardware/lib/Protocol/src/Protocol.h
Normal file
98
apps/robot-hardware/lib/Protocol/src/Protocol.h
Normal 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
|
||||
48
apps/robot-hardware/platformio.ini
Normal file
48
apps/robot-hardware/platformio.ini
Normal 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
|
||||
147
apps/robot-hardware/src/main.cpp
Normal file
147
apps/robot-hardware/src/main.cpp
Normal 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
104
docs/STATUS.md
Normal 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/
|
||||
```
|
||||
146
hardware/fusion-libraries/INMP441_Breakout.lbr
Normal file
146
hardware/fusion-libraries/INMP441_Breakout.lbr
Normal 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">>NAME</text>
|
||||
<text x="-7.5" y="-6.6" size="0.8" layer="27">>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">>NAME</text>
|
||||
<text x="-7.62" y="-12.7" size="1.778" layer="96">>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
1609
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Loading…
x
Reference in New Issue
Block a user