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
|
# TypeORM
|
||||||
*.sqlite
|
*.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 { JwtStrategy } from './adapters/inbound/rest/auth/strategies/jwt.strategy';
|
||||||
import { AuthController } from './adapters/inbound/rest/auth/auth.controller';
|
import { AuthController } from './adapters/inbound/rest/auth/auth.controller';
|
||||||
import { DeviceController } from './adapters/inbound/rest/device/device.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 { RobotGateway } from './adapters/inbound/websocket/robot.gateway';
|
||||||
import { DeepgramAdapter } from './adapters/outbound/stt/deepgram.adapter';
|
import { DeepgramAdapter } from './adapters/outbound/stt/deepgram.adapter';
|
||||||
import { AnthropicAdapter } from './adapters/outbound/llm/anthropic.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: [
|
providers: [
|
||||||
AuthService,
|
AuthService,
|
||||||
UserService,
|
UserService,
|
||||||
HomeService,
|
HomeService,
|
||||||
DeviceService,
|
DeviceService,
|
||||||
|
PairingService,
|
||||||
JwtStrategy,
|
JwtStrategy,
|
||||||
RobotGateway,
|
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)
|
# Wake word detection threshold (0.0 to 1.0, higher = less false positives)
|
||||||
WAKEWORD_THRESHOLD=0.5
|
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",
|
"lint": "eslint \"src/**/*.ts\" --fix",
|
||||||
"format": "prettier --write \"src/**/*.ts\"",
|
"format": "prettier --write \"src/**/*.ts\"",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"test:watch": "vitest"
|
"test:watch": "vitest",
|
||||||
|
"hw:demo": "tsx scripts/hardware-demo.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"socket.io-client": "^4.8.3",
|
"socket.io-client": "^4.8.3",
|
||||||
"dotenv": "^17.3.1",
|
"dotenv": "^17.3.1",
|
||||||
"pino": "^9.6.0",
|
"pino": "^9.6.0",
|
||||||
"pino-pretty": "^13.0.0"
|
"pino-pretty": "^13.0.0",
|
||||||
|
"serialport": "^12.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"typescript": "^5.8.3",
|
"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;
|
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 {
|
export interface HardwareConfig {
|
||||||
audio: AudioConfig;
|
audio: AudioConfig;
|
||||||
wakeWord: WakeWordConfig;
|
wakeWord: WakeWordConfig;
|
||||||
|
serial: SerialHardwareConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function loadHardwareConfig(): HardwareConfig {
|
export function loadHardwareConfig(): HardwareConfig {
|
||||||
@ -53,5 +68,11 @@ export function loadHardwareConfig(): HardwareConfig {
|
|||||||
modelName: process.env.WAKEWORD_MODEL || 'hey_ti_pote',
|
modelName: process.env.WAKEWORD_MODEL || 'hey_ti_pote',
|
||||||
threshold: parseFloat(process.env.WAKEWORD_THRESHOLD || '0.5'),
|
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 */
|
/** How conversations are triggered */
|
||||||
triggerMode: TriggerMode;
|
triggerMode: TriggerMode;
|
||||||
|
|
||||||
/** Unique device identifier (generated at first setup or from env) */
|
/** Unique device identifier (from env, store, or pairing) */
|
||||||
deviceId: string;
|
deviceId?: string;
|
||||||
|
|
||||||
/** JWT device token for cloud authentication */
|
/** JWT device token for cloud authentication (from env, store, or pairing) */
|
||||||
deviceToken: string;
|
deviceToken?: string;
|
||||||
|
|
||||||
/** Cloud backend WebSocket URL */
|
/** Cloud backend WebSocket URL */
|
||||||
cloudUrl: string;
|
cloudUrl: string;
|
||||||
@ -58,18 +58,11 @@ export function loadRobotConfig(): RobotConfig {
|
|||||||
return {
|
return {
|
||||||
mode,
|
mode,
|
||||||
triggerMode,
|
triggerMode,
|
||||||
deviceId: requireEnv('DEVICE_ID'),
|
deviceId: process.env.DEVICE_ID || undefined,
|
||||||
deviceToken: requireEnv('DEVICE_TOKEN'),
|
deviceToken: process.env.DEVICE_TOKEN || undefined,
|
||||||
cloudUrl: process.env.CLOUD_URL || 'ws://localhost:3000',
|
cloudUrl: process.env.CLOUD_URL || 'ws://localhost:3000',
|
||||||
robotName: process.env.ROBOT_NAME || 'Ti-Pote',
|
robotName: process.env.ROBOT_NAME || 'Ti-Pote',
|
||||||
logLevel: process.env.LOG_LEVEL || 'info',
|
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,
|
OrchestratorService,
|
||||||
LocalStore,
|
LocalStore,
|
||||||
WifiService,
|
WifiService,
|
||||||
|
PairingService,
|
||||||
} from './services/index.js';
|
} from './services/index.js';
|
||||||
import { type ITriggerService } from './services/trigger.interface.js';
|
import { type ITriggerService } from './services/trigger.interface.js';
|
||||||
import { SetupFlow } from './setup/index.js';
|
import { SetupFlow } from './setup/index.js';
|
||||||
|
import { HardwareService, Emotion } from './hardware/index.js';
|
||||||
import { createLogger } from './utils/index.js';
|
import { createLogger } from './utils/index.js';
|
||||||
|
|
||||||
const logger = createLogger('main', 'info');
|
const logger = createLogger('main', 'info');
|
||||||
@ -33,8 +35,6 @@ async function main(): Promise<void> {
|
|||||||
|
|
||||||
// ── Step 1: Ensure WiFi connectivity ──
|
// ── Step 1: Ensure WiFi connectivity ──
|
||||||
// Only run the setup flow (captive portal) in physical/production mode.
|
// 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') {
|
if (robotConfig.mode === 'physical') {
|
||||||
const wifiService = new WifiService();
|
const wifiService = new WifiService();
|
||||||
@ -50,33 +50,57 @@ async function main(): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Step 2: Resolve device credentials ──
|
// ── 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;
|
let deviceId = store.device?.id || robotConfig.deviceId;
|
||||||
const deviceToken = store.device?.token || robotConfig.deviceToken;
|
let deviceToken = store.device?.token || robotConfig.deviceToken;
|
||||||
|
|
||||||
if (!deviceId || !deviceToken) {
|
if (!deviceId || !deviceToken) {
|
||||||
logger.fatal(
|
logger.info('🔗 No device credentials found — starting pairing flow...');
|
||||||
'No device credentials found. Register this device on the backend first, ' +
|
|
||||||
'then set DEVICE_ID and DEVICE_TOKEN in your .env file.',
|
const pairingService = new PairingService(robotConfig.cloudUrl, store);
|
||||||
);
|
const credentials = await pairingService.pair(robotConfig.robotName);
|
||||||
process.exit(1);
|
|
||||||
|
deviceId = credentials.deviceId;
|
||||||
|
deviceToken = credentials.deviceToken;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Override config with resolved credentials
|
logger.info({ deviceId }, '✅ Device credentials resolved');
|
||||||
const resolvedConfig = { ...robotConfig, deviceId, deviceToken };
|
|
||||||
|
|
||||||
logger.info({ deviceId }, 'Device credentials resolved');
|
|
||||||
|
|
||||||
// ── Step 3: Initialize services ──
|
// ── 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 audioService = new AudioService(hardwareConfig.audio);
|
||||||
const healthService = new HealthService(cloudSocket);
|
const healthService = new HealthService(cloudSocket);
|
||||||
|
|
||||||
// Choose trigger based on TRIGGER_MODE:
|
// ── Optional: hardware bridge (ESP32 firmware) ──
|
||||||
// wakeword → OpenWakeWord subprocess (requires Python + openwakeword)
|
// The serial link is opt-in via HARDWARE_SERIAL_ENABLED=true. We
|
||||||
// keyboard → Press Enter in terminal to talk
|
// 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;
|
let trigger: ITriggerService;
|
||||||
|
|
||||||
if (resolvedConfig.triggerMode === 'wakeword') {
|
if (resolvedConfig.triggerMode === 'wakeword') {
|
||||||
@ -125,6 +149,10 @@ async function main(): Promise<void> {
|
|||||||
await orchestrator.stop();
|
await orchestrator.stop();
|
||||||
healthService.stop();
|
healthService.stop();
|
||||||
await audioService.destroy();
|
await audioService.destroy();
|
||||||
|
if (hardwareService) {
|
||||||
|
hardwareService.sendEmotion(Emotion.SLEEPY);
|
||||||
|
await hardwareService.disconnect();
|
||||||
|
}
|
||||||
await cloudSocket.disconnect();
|
await cloudSocket.disconnect();
|
||||||
|
|
||||||
logger.info('Goodbye!');
|
logger.info('Goodbye!');
|
||||||
|
|||||||
@ -5,4 +5,5 @@ export { HealthService } from './health.service.js';
|
|||||||
export { OrchestratorService } from './orchestrator.service.js';
|
export { OrchestratorService } from './orchestrator.service.js';
|
||||||
export { LocalStore } from './local-store.service.js';
|
export { LocalStore } from './local-store.service.js';
|
||||||
export { WifiService } from './wifi.service.js';
|
export { WifiService } from './wifi.service.js';
|
||||||
|
export { PairingService } from './pairing.service.js';
|
||||||
export { type ITriggerService } from './trigger.interface.js';
|
export { type ITriggerService } from './trigger.interface.js';
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
|
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';
|
import { createLogger, type Logger } from '../utils/index.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -31,7 +32,8 @@ export interface LocalStoreData {
|
|||||||
setupComplete?: boolean;
|
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.
|
* 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')) {
|
} else if (msg.startsWith('Matched device') || msg.startsWith('Using device')) {
|
||||||
this.logger.info(`🔊 ${msg}`);
|
this.logger.info(`🔊 ${msg}`);
|
||||||
} else {
|
} 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 */ });
|
await execAsync('nmcli connection delete Hotspot').catch(() => { /* ignore */ });
|
||||||
|
|
||||||
// Create and start an open hotspot (no password — easier for setup)
|
// Create and start an open hotspot (no password — easier for setup)
|
||||||
|
// Run commands sequentially with delays — chaining with && causes activation failures
|
||||||
await execAsync(
|
await execAsync(
|
||||||
`nmcli connection add type wifi ifname wlan0 con-name Hotspot autoconnect no ssid "${this.escapeShell(name)}" && ` +
|
`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`,
|
|
||||||
);
|
);
|
||||||
|
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');
|
this.logger.info({ apName: name }, 'Access Point started');
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
@ -82,6 +82,17 @@ export class CaptivePortal {
|
|||||||
this.logger.debug({ method, url }, 'Request');
|
this.logger.debug({ method, url }, 'Request');
|
||||||
|
|
||||||
try {
|
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 ──
|
// ── Captive portal detection URLs ──
|
||||||
// iOS, Android, Windows, etc. check these URLs to detect captive portals.
|
// iOS, Android, Windows, etc. check these URLs to detect captive portals.
|
||||||
// Redirecting them triggers the captive portal popup on the user's device.
|
// Redirecting them triggers the captive portal popup on the user's device.
|
||||||
@ -93,6 +104,20 @@ export class CaptivePortal {
|
|||||||
|
|
||||||
// ── API routes ──
|
// ── 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') {
|
if (url === '/api/wifi/scan' && method === 'GET') {
|
||||||
await this.handleScan(res);
|
await this.handleScan(res);
|
||||||
return;
|
return;
|
||||||
@ -141,7 +166,7 @@ export class CaptivePortal {
|
|||||||
private async handleScan(res: ServerResponse): Promise<void> {
|
private async handleScan(res: ServerResponse): Promise<void> {
|
||||||
const networks = await this.wifiService.scan();
|
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));
|
res.end(JSON.stringify(networks));
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -159,13 +184,13 @@ export class CaptivePortal {
|
|||||||
ssid = parsed.ssid;
|
ssid = parsed.ssid;
|
||||||
password = parsed.password;
|
password = parsed.password;
|
||||||
} catch {
|
} 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' }));
|
res.end(JSON.stringify({ success: false, error: 'Invalid request body' }));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!ssid) {
|
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' }));
|
res.end(JSON.stringify({ success: false, error: 'SSID is required' }));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -186,7 +211,7 @@ export class CaptivePortal {
|
|||||||
|
|
||||||
// Respond with success (we may lose the connection since AP is down,
|
// Respond with success (we may lose the connection since AP is down,
|
||||||
// but the page JavaScript handles this gracefully)
|
// 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 }));
|
res.end(JSON.stringify({ success: true }));
|
||||||
|
|
||||||
// Notify the setup flow that WiFi is configured
|
// Notify the setup flow that WiFi is configured
|
||||||
@ -198,7 +223,7 @@ export class CaptivePortal {
|
|||||||
// Connection failed — restart AP so user can retry
|
// Connection failed — restart AP so user can retry
|
||||||
await this.wifiService.startAP(`Ti-Pote`);
|
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(
|
res.end(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
success: false,
|
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