105 lines
4.0 KiB
TypeScript
105 lines
4.0 KiB
TypeScript
import { fromBinary, toBinary } from '@bufbuild/protobuf';
|
|
import * as P from '@meshtastic/protobufs';
|
|
import { describe, expect, it } from 'vitest';
|
|
import { buildTextToRadio, extractFrames, frame } from './codec.js';
|
|
|
|
const F = (payload: number[]) => [
|
|
0x94,
|
|
0xc3,
|
|
(payload.length >> 8) & 0xff,
|
|
payload.length & 0xff,
|
|
...payload,
|
|
];
|
|
|
|
describe('extractFrames', () => {
|
|
it('emits one complete frame and empties rest', () => {
|
|
const { packets, rest } = extractFrames(Uint8Array.from(F([1, 2, 3])));
|
|
expect(packets).toHaveLength(1);
|
|
expect(Array.from(packets[0])).toEqual([1, 2, 3]);
|
|
expect(rest.length).toBe(0);
|
|
});
|
|
|
|
it('emits two frames from one chunk', () => {
|
|
const buf = Uint8Array.from([...F([1]), ...F([2, 2])]);
|
|
const { packets, rest } = extractFrames(buf);
|
|
expect(packets.map((p) => Array.from(p))).toEqual([[1], [2, 2]]);
|
|
expect(rest.length).toBe(0);
|
|
});
|
|
|
|
it('reassembles a frame split across two feedings', () => {
|
|
const full = Uint8Array.from(F([9, 8, 7]));
|
|
const first = extractFrames(full.subarray(0, 5)); // header + 1 payload byte
|
|
expect(first.packets).toHaveLength(0);
|
|
expect(Array.from(first.rest)).toEqual(Array.from(full.subarray(0, 5)));
|
|
|
|
const second = extractFrames(
|
|
Uint8Array.from([...first.rest, ...full.subarray(5)]),
|
|
);
|
|
expect(second.packets.map((p) => Array.from(p))).toEqual([[9, 8, 7]]);
|
|
expect(second.rest.length).toBe(0);
|
|
});
|
|
|
|
it('discards garbage bytes before the header', () => {
|
|
const buf = Uint8Array.from([0xaa, 0xbb, ...F([5])]);
|
|
const { packets, rest } = extractFrames(buf);
|
|
expect(packets.map((p) => Array.from(p))).toEqual([[5]]);
|
|
expect(rest.length).toBe(0);
|
|
});
|
|
|
|
it('resyncs past a bogus len > 1024 and finds the real frame', () => {
|
|
const buf = Uint8Array.from([0x94, 0xc3, 0xff, 0xfe, ...F([7, 6])]);
|
|
const { packets, rest } = extractFrames(buf);
|
|
expect(packets.map((p) => Array.from(p))).toEqual([[7, 6]]);
|
|
expect(rest.length).toBe(0);
|
|
});
|
|
|
|
it('keeps a trailing lone 0x94 in rest', () => {
|
|
const { packets, rest } = extractFrames(Uint8Array.from([0xaa, 0x94]));
|
|
expect(packets).toHaveLength(0);
|
|
expect(Array.from(rest)).toEqual([0x94]);
|
|
});
|
|
});
|
|
|
|
describe('frame', () => {
|
|
it('prepends the 0x94 0xC3 big-endian length header', () => {
|
|
const out = frame(Uint8Array.from([1, 2, 3]));
|
|
expect(Array.from(out)).toEqual([0x94, 0xc3, 0x00, 0x03, 1, 2, 3]);
|
|
});
|
|
|
|
it('encodes lengths above 255 in two bytes', () => {
|
|
const payload = new Uint8Array(300).fill(7);
|
|
const out = frame(payload);
|
|
expect(Array.from(out.subarray(0, 4))).toEqual([0x94, 0xc3, 0x01, 0x2c]);
|
|
expect(out.length).toBe(304);
|
|
});
|
|
|
|
it('round-trips through extractFrames', () => {
|
|
const framed = frame(Uint8Array.from([4, 5]));
|
|
const { packets, rest } = extractFrames(framed);
|
|
expect(packets.map((p) => Array.from(p))).toEqual([[4, 5]]);
|
|
expect(rest.length).toBe(0);
|
|
});
|
|
});
|
|
|
|
// re-serialize just the nested Data message so its wire bytes are inspectable without the random packet id interfering
|
|
function dataBytes(framed: Uint8Array): Uint8Array {
|
|
const { packets } = extractFrames(framed); // buildTextToRadio returns framed bytes
|
|
expect(packets).toHaveLength(1);
|
|
const tr = fromBinary(P.Mesh.ToRadioSchema, packets[0]);
|
|
if (tr.payloadVariant?.case !== 'packet') throw new Error('not a packet');
|
|
const pv = tr.payloadVariant.value.payloadVariant;
|
|
if (pv?.case !== 'decoded') throw new Error('not decoded');
|
|
return toBinary(P.Mesh.DataSchema, pv.value);
|
|
}
|
|
|
|
describe('buildTextToRadio', () => {
|
|
it('writes wire tag 0x3d (Data.reply_id, field 7) iff replyId is given', async () => {
|
|
const withReply = await buildTextToRadio(42, 'hi', 3, 42);
|
|
expect(Array.from(dataBytes(withReply.bytes))).toContain(0x3d);
|
|
|
|
// omitting it must leave the field off the wire (proto3 default 0) — snake_case reply_id would be silently dropped here
|
|
const plain = await buildTextToRadio(42, 'hi', 3);
|
|
expect(Array.from(dataBytes(plain.bytes))).not.toContain(0x3d);
|
|
});
|
|
});
|