Files
MeshBot/src/mesh/codec.spec.ts
T
2026-09-25 13:53:47 +02:00

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);
});
});