213 lines
5.6 KiB
TypeScript
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;
|
|
}
|
|
}
|
|
}
|
|
}
|