/** * 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; } } } }