2026-04-08 18:37:08 +02:00

213 lines
5.6 KiB
TypeScript

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