Loading files
This file is pinned to the selected release. Download the original to open it in your design tool.
710fbeaaeThis file is pinned to the selected release. Download the original to open it in your design tool.
// Framing for the USB link to the dial — turns a byte stream into messages, and messages back into bytes.
//
// The mirror image of devices/harness-device/firmware/main/cable_frame.c. Both halves are checked against ONE shared
// vector file (vectors/cable_frame.txt here, generated by devices/harness-device/firmware/scripts/gen_cable_vectors.py from
// the format description rather than from either implementation), so a shared misreading shows up as a
// disagreement instead of being blessed by both. The two halves are written in two languages and share no
// code; the vectors are the only thing they share.
//
// Wire format, little-endian throughout:
//
// A5 5A | ver:u8 | type:u8 | len:u16 | payload[len] | crc16:u16
//
// The magic is what the reader scans for after losing its place; the CRC is what separates a real frame
// from two bytes of noise that happened to look like the magic. That matters more here than on most
// links, and for a reason specific to this board: it has exactly ONE USB port, so the console and this
// protocol share a wire. The ROM, the second-stage bootloader and the panic handler all write plain text
// to it, and this reader walks into the middle of a stream at every boot.
//
// That is also why `Log` is a frame type: an ESP_LOG line arrives framed and can be filed, and only what
// the firmware cannot route — ROM chatter, a panic dump — comes as bare text, which the decoder steps
// over a byte at a time and counts.
// ⚠️ THE SECOND BYTE NAMES THE PRODUCT. Must match the firmware's cable_frame.h exactly, and the long
// comment there is the one to read: a sibling product ships the SAME board with the same USB identity and
// a protocol descended from the same ancestor, and its updater flashes any panel whose version differs
// from its bundled one — in either direction. Different magic is what makes that impossible without
// needing the other product to agree to anything: it can never read a `hello` from this dial, so it never
// learns a version, so it never offers.
//
// 0x48 is 'H'. The sibling takes 'G'. Changing one of these two without the other is a link that opens
// and then delivers nothing at all.
export const CABLE_MAGIC_0 = 0xa5
export const CABLE_MAGIC_1 = 0x48
/** Bumped only when the ENVELOPE changes. The vocabulary carries its own version in `hello`. */
export const CABLE_FRAME_VERSION = 1
export const CABLE_HEADER_BYTES = 6 // magic(2) + ver(1) + type(1) + len(2)
export const CABLE_CRC_BYTES = 2
/**
* A bound on damage, not a capacity target. A corrupt length field can claim up to 65535 bytes, and with
* no ceiling the reader would sit waiting for a frame that is never coming while real frames pile up
* behind it — a hang, not a dropped message.
*/
export const CABLE_MAX_PAYLOAD = 8192
export const CABLE_MAX_FRAME = CABLE_HEADER_BYTES + CABLE_MAX_PAYLOAD + CABLE_CRC_BYTES
/** Payload kinds. An unknown code is handed up with its raw byte, never dropped. */
export const CableType = {
Json: 0x01, // UTF-8 JSON object with a "t" discriminator — the vocabulary
Pcm: 0x02, // one chunk of 16-bit mono PCM, dial → daemon, during a voice turn
Fw: 0x03, // one slice of a firmware image, daemon → dial only
Log: 0x04, // one ESP_LOG line, dial → daemon, raw bytes (no JSON escaping)
} as const
/**
* CRC-16/CCITT-FALSE — poly 0x1021, init 0xFFFF, no reflection, no final xor.
*
* Named exactly because several 16-bit CRCs share the 0x1021 polynomial and differ only in init or
* reflection; the published check value for "123456789" is 0x29B1, which both test suites assert.
*/
export function cableCrc16(data: Uint8Array, from = 0, to = data.length): number {
let crc = 0xffff
for (let i = from; i < to; i++) {
crc ^= data[i] << 8
for (let bit = 0; bit < 8; bit++) {
crc = crc & 0x8000 ? ((crc << 1) ^ 0x1021) & 0xffff : (crc << 1) & 0xffff
}
}
return crc
}
/**
* Frame `payload`. Throws when it is over the cap rather than truncating: an oversized message is a bug
* on this side, and the far end could only ever report it as noise.
*/
export function encodeCableFrame(type: number, payload: Uint8Array = new Uint8Array(0)): Uint8Array {
if (payload.length > CABLE_MAX_PAYLOAD) {
throw new Error(`cable frame payload ${payload.length} over the ${CABLE_MAX_PAYLOAD} cap`)
}
const out = new Uint8Array(CABLE_HEADER_BYTES + payload.length + CABLE_CRC_BYTES)
out[0] = CABLE_MAGIC_0
out[1] = CABLE_MAGIC_1
out[2] = CABLE_FRAME_VERSION
out[3] = type
out[4] = payload.length & 0xff
out[5] = (payload.length >> 8) & 0xff
out.set(payload, CABLE_HEADER_BYTES)
// Covers ver..payload. The magic is a marker, not data — including a constant would add nothing to
// detect.
const crc = cableCrc16(out, 2, CABLE_HEADER_BYTES + payload.length)
out[out.length - 2] = crc & 0xff
out[out.length - 1] = (crc >> 8) & 0xff
return out
}
export interface CableFrame {
version: number
type: number
payload: Uint8Array
}
/**
* Reassembles frames from a byte stream that may start anywhere and contain anything.
*
* Never throws: everything arriving on this port is untrusted, and the only useful response to a byte
* that makes no sense is to step over it.
*/
export class CableDecoder {
private buf = new Uint8Array(CABLE_MAX_FRAME)
private len = 0
/**
* The RATE is what matters, which is why these are counters and not booleans: a handful of discarded
* bytes at every boot is the bootloader's parting words and is expected, while a steady trickle during
* a session means the two sides disagree about the format or the cable is bad. Those are very
* different problems and look identical without a count.
*/
discardedBytes = 0
corruptFrames = 0
/**
* Forget any partial frame. Call when the port is reopened: leftover bytes belong to a session that has
* ended, and carrying them across would put a stale half-frame in front of the next real one.
*
* The counters deliberately survive — they exist to show a trend across the whole run.
*/
reset(): void {
this.len = 0
}
/** Feed bytes and emit every frame they complete, in order. */
feed(data: Uint8Array, onFrame: (frame: CableFrame) => void): void {
for (let i = 0; i < data.length; i++) {
// Full and still no frame means the head was never a real one. Making room by dropping the oldest
// byte keeps the link alive; refusing the new byte instead would wedge it permanently.
if (this.len === this.buf.length) this.discard(1)
this.buf[this.len++] = data[i]
// Only worth attempting once a header could be complete. This also keeps the magic scan amortised:
// it runs on resync, not per byte.
if (this.len >= CABLE_HEADER_BYTES) {
while (this.takeFront(onFrame)) {
// keep going: one read can complete several frames
}
}
}
}
/** Take `count` bytes off the front, keeping the rest. Says nothing about why. */
private consume(count: number): void {
if (count <= 0) return
if (count >= this.len) {
this.len = 0
return
}
this.buf.copyWithin(0, count, this.len)
this.len -= count
}
/**
* Consume bytes that turned out not to be a frame, and say so.
*
* Split from consume() rather than counting inside it: the bytes of a frame that decoded fine are also
* consumed, and counting those as discarded would make the health number read as "this link is full of
* noise" on a link that is working perfectly.
*/
private discard(count: number): void {
this.discardedBytes += Math.min(count, this.len)
this.consume(count)
}
/**
* Offset of the next complete magic, or -1.
*
* Stops one short of the end on purpose: a final lone byte cannot be judged yet, and the caller keeps
* it in case the next read completes the pair.
*/
private indexOfMagic(): number {
for (let i = 0; i + 1 < this.len; i++) {
if (this.buf[i] === CABLE_MAGIC_0 && this.buf[i + 1] === CABLE_MAGIC_1) return i
}
return -1
}
/**
* Try to take one frame off the front. True when one was emitted (so try again), false when more bytes
* are needed. Noise is consumed internally rather than reported, so the caller never has to tell "wait"
* from "that was rubbish".
*/
private takeFront(onFrame: (frame: CableFrame) => void): boolean {
for (;;) {
const headIsMagic =
this.len >= 2 && this.buf[0] === CABLE_MAGIC_0 && this.buf[1] === CABLE_MAGIC_1
if (!headIsMagic) {
const at = this.indexOfMagic()
if (at < 0) {
// Keep a trailing A5: the 5A may simply not have arrived yet.
const keep = this.len > 0 && this.buf[this.len - 1] === CABLE_MAGIC_0 ? 1 : 0
this.discard(this.len - keep)
return false
}
this.discard(at)
continue
}
if (this.len < CABLE_HEADER_BYTES) return false
const payloadLen = this.buf[4] | (this.buf[5] << 8)
// A length this large cannot be real, so those two bytes were noise rather than a header. Step over
// one byte and keep hunting; waiting for 64 KB that is never coming would stall the link.
if (payloadLen > CABLE_MAX_PAYLOAD) {
this.discard(1)
continue
}
const total = CABLE_HEADER_BYTES + payloadLen + CABLE_CRC_BYTES
if (this.len < total) return false
const expected = this.buf[total - 2] | (this.buf[total - 1] << 8)
const actual = cableCrc16(this.buf, 2, CABLE_HEADER_BYTES + payloadLen)
if (actual !== expected) {
// Drop ONE byte, not the whole frame: the magic may have been a coincidence inside noise, and a
// genuine frame can begin one byte further in. Dropping the lot would swallow it.
this.corruptFrames++
this.discard(1)
continue
}
onFrame({
version: this.buf[2],
type: this.buf[3],
// Copied, not a view: the caller keeps PCM and firmware payloads across ticks, and the buffer
// underneath is about to be shifted by the next frame.
payload: this.buf.slice(CABLE_HEADER_BYTES, CABLE_HEADER_BYTES + payloadLen),
})
this.consume(total)
return true
}
}
}