244 lines
11 KiB
TypeScript
244 lines
11 KiB
TypeScript
import { describe, it, expect } from 'vitest';
|
|
import { encodeAX25, Frame, UnnumberedFrame, InformationFrame, SupervisoryFrame, buildIControl, buildSControl, buildUControl, encodeFrame, toHDLC } from '../src/frame';
|
|
import { Address } from '../src/address';
|
|
import { unescapeBuffer, computeFcs } from '../src/hdlc';
|
|
|
|
describe('Frame.buildSControl', () => {
|
|
it('builds S control byte', () => {
|
|
const c = buildSControl('RNR', 4, 0);
|
|
// bits 0-1 == 01 and s-code 1 in bits 2-3
|
|
expect((c & 0x03)).toBe(0x01);
|
|
});
|
|
});
|
|
|
|
describe('Frame.encodeFrame', () => {
|
|
it('builds I control byte and roundtrips encode/parse', () => {
|
|
const dst = new Address('DST', 0);
|
|
const src = new Address('SRC', 1);
|
|
const ns = 3;
|
|
const nr = 5;
|
|
const pf = 1;
|
|
const control = buildIControl(ns, nr, pf);
|
|
const info = new TextEncoder().encode('payload');
|
|
const f = new InformationFrame(src, dst, control, info, ns, nr, pf);
|
|
const buf = encodeFrame(f);
|
|
const parsed = Frame.fromBytes(buf);
|
|
expect(parsed instanceof InformationFrame).toBe(true);
|
|
const p = parsed as InformationFrame;
|
|
expect(p.ns).toBe(ns);
|
|
expect(p.nr).toBe(nr);
|
|
expect(p.pf).toBe(pf);
|
|
expect(new TextDecoder().decode(p.info)).toBe('payload');
|
|
});
|
|
|
|
it('encodes UI unnumbered frames including PID and info', () => {
|
|
const dst = new Address('APRS', 0);
|
|
const src = new Address('N0CALL', 0);
|
|
const control = buildUControl('UI', 0);
|
|
const info = new TextEncoder().encode('!Hello');
|
|
const f = new UnnumberedFrame(src, dst, control, 'UI', 0, info);
|
|
f.pid = 0xf0;
|
|
const buf = encodeFrame(f);
|
|
const parsed = Frame.fromBytes(buf);
|
|
expect(parsed instanceof UnnumberedFrame).toBe(true);
|
|
const u = parsed as UnnumberedFrame;
|
|
expect(u.pid).toBe(0xf0);
|
|
expect(new TextDecoder().decode(u.info)).toBe('!Hello');
|
|
});
|
|
});
|
|
|
|
describe('Frame.fromBytes', () => {
|
|
it('parses a simple UI frame', () => {
|
|
const info = new TextEncoder().encode('hello');
|
|
const payload = encodeAX25('DEST-0', 'SRC-1', info, { ui: true });
|
|
const f = Frame.fromBytes(payload);
|
|
expect(f.source).toBeInstanceOf(Address);
|
|
expect(f.destination).toBeInstanceOf(Address);
|
|
expect(f.source.toString()).toBe('SRC-1');
|
|
expect(f.destination.toString()).toBe('DEST-0');
|
|
expect(new TextDecoder().decode(f.info)).toBe('hello');
|
|
});
|
|
it('parses frames with multiple address fields', () => {
|
|
const dest = new Address('DST', 0);
|
|
const src = new Address('SRC', 1);
|
|
const digi = new Address('DIGI', 2);
|
|
const parts: number[] = [];
|
|
parts.push(...dest.toBytes(false));
|
|
parts.push(...src.toBytes(false));
|
|
parts.push(...digi.toBytes(true));
|
|
parts.push(0x03); // control
|
|
parts.push(0xf0); // pid
|
|
parts.push(...new TextEncoder().encode('spec'));
|
|
const payload = Uint8Array.from(parts);
|
|
const f = Frame.fromBytes(payload);
|
|
expect(f.source.toString()).toBe('SRC-1');
|
|
expect(f.destination.toString()).toBe('DST-0');
|
|
expect(new TextDecoder().decode(f.info)).toBe('spec');
|
|
});
|
|
});
|
|
|
|
describe('Frame.toHDLC', () => {
|
|
it('produces HDLC frame with FCS via toHDLC', () => {
|
|
const dst = new Address('APRS', 0);
|
|
const src = new Address('N0CALL', 0);
|
|
const control = buildUControl('UI', 0);
|
|
const info = new TextEncoder().encode('!Hello');
|
|
const f = new UnnumberedFrame(src, dst, control, 'UI', 0, info);
|
|
f.pid = 0xf0;
|
|
const h = toHDLC(f);
|
|
expect(h[0]).toBe(0x7e);
|
|
expect(h[h.length - 1]).toBe(0x7e);
|
|
const inner = h.slice(1, h.length - 1);
|
|
const unescaped = unescapeBuffer(inner);
|
|
// last two bytes of unescaped payload must equal computed FCS (LE)
|
|
const ax = encodeFrame(f);
|
|
const fcs = computeFcs(ax);
|
|
expect(unescaped[unescaped.length - 2]).toBe(fcs & 0xff);
|
|
expect(unescaped[unescaped.length - 1]).toBe((fcs >> 8) & 0xff);
|
|
});
|
|
|
|
describe('Frame.fromBytes (embedded hex test vectors)', () => {
|
|
// Helper to convert hex string to Uint8Array
|
|
const hexToBytes = (hex: string): Uint8Array => {
|
|
const clean = hex.replace(/[^a-fA-F0-9]/g, '');
|
|
const arr = [];
|
|
for (let i = 0; i < clean.length; i += 2) {
|
|
arr.push(parseInt(clean.slice(i, i + 2), 16));
|
|
}
|
|
return new Uint8Array(arr);
|
|
};
|
|
|
|
// Common property keys for all vectors
|
|
const commonKeys = [
|
|
'hex', 'length', 'type', 'source', 'destination', 'repeaters', 'control', 'pid', 'infoHex', 'toString',
|
|
];
|
|
|
|
// Test vectors generated from radio.sample.log
|
|
const vectors = [
|
|
{
|
|
hex: '82a0aa646a9ce09c6cae96b44066aea46c828488e103f03d333734362e34324e3131323232362e30305723207b55495633324e7d0d',
|
|
length: 53,
|
|
type: 'UnnumberedFrame',
|
|
source: 'N6WKZ-3',
|
|
destination: 'APU25N-0',
|
|
repeaters: ["WR6ABD-0"],
|
|
control: 3,
|
|
pid: 240,
|
|
infoHex: '3d333734362e34324e3131323232362e30305723207b55495633324e7d0d',
|
|
infoStr: '=3746.42N112226.00W# {UIV32N}\n',
|
|
toString: 'fm N6WKZ-3 to APU25N-0 via WR6ABD-0* ctl UI- pid F0 len=30: =3746.42N112226.00W# {UIV32N}\n',
|
|
},
|
|
{
|
|
hex: '82a0a866626260ae628a94404074ae846ca89aa6ea9c6cb4b04040e6ae92888a6440e103f02f3231303732357a333831342e32394e2f31323233362e3933573e3237352f3030302f413d3030303031332f4544204a20534147',
|
|
length: 89,
|
|
type: 'UnnumberedFrame',
|
|
source: 'W1EJ-10',
|
|
destination: 'APT311-0',
|
|
repeaters: ["WB6TMS-5","N6ZX-3","WIDE2-0"],
|
|
control: 3,
|
|
pid: 240,
|
|
infoHex: '2f3231303732357a333831342e32394e2f31323233362e3933573e3237352f3030302f413d3030303031332f4544204a20534147',
|
|
infoStr: '/210725z3814.29N/12236.93W>275/000/A=000013/ED J SAG',
|
|
toString: 'fm W1EJ-10 to APT311-0 via WB6TMS-5,N6ZX-3,WIDE2-0* ctl UI- pid F0 len=52: /210725z3814.29N/12236.93W>275/000/A=000013/ED J SAG',
|
|
},
|
|
{
|
|
hex: '82a09c667262608682a4a69e9c608a86909e4040e09c6cb4b04040e6ae92888a6440e103f021333834312e36384e3131313935392e33365723504847373633362f4e43416e2c54454d506e2f574736442f436172736f6e20506173732c2043412f413d3030383537330d',
|
|
length: 106,
|
|
type: 'UnnumberedFrame',
|
|
source: 'CARSON-0',
|
|
destination: 'APN391-0',
|
|
repeaters: ["ECHO-0","N6ZX-3","WIDE2-0"],
|
|
control: 3,
|
|
pid: 240,
|
|
infoHex: '21333834312e36384e3131313935392e33365723504847373633362f4e43416e2c54454d506e2f574736442f436172736f6e20506173732c2043412f413d3030383537330d',
|
|
infoStr: '!3841.68N111959.36W#PHG7636/NCAn,TEMPn/WG6D/Carson Pass, CA/A=008573\n',
|
|
toString: 'fm CARSON-0 to APN391-0 via ECHO-0,N6ZX-3,WIDE2-0* ctl UI- pid F0 len=69: !3841.68N111959.36W#PHG7636/NCAn,TEMPn/WG6D/Carson Pass, CA/A=008573\n',
|
|
},
|
|
{
|
|
hex: '82a0aa646a9ce0968a6c96b29260966ca8aa9e40e69c6cb4b04040e6ae92888a6440e103f0403231303732367a333735312e35334e2f31323031322e3833575f3231332f3030306730303074303633723030307030303050303030683435623130303936415052532f43574f5020576561746865720d',
|
|
length: 118,
|
|
type: 'UnnumberedFrame',
|
|
source: 'KE6KYI-0',
|
|
destination: 'APU25N-0',
|
|
repeaters: ["K6TUO-3","N6ZX-3","WIDE2-0"],
|
|
control: 3,
|
|
pid: 240,
|
|
infoHex: '403231303732367a333735312e35334e2f31323031322e3833575f3231332f3030306730303074303633723030307030303050303030683435623130303936415052532f43574f5020576561746865720d',
|
|
infoStr: '@210726z3751.53N/12012.83W_213/000g000t063r000p000P000h45b10096APRS/CWOP Weather\n',
|
|
toString: 'fm KE6KYI-0 to APU25N-0 via K6TUO-3,N6ZX-3,WIDE2-0* ctl UI- pid F0 len=81: @210726z3751.53N/12012.83W_213/000g000t063r000p000P000h45b10096APRS/CWOP Weather\n',
|
|
},
|
|
{
|
|
hex: '66b06aa6a4a460968e6cb498a2788a86909e4040e0ae92888a6240e09c6cb4b04040e6ae92888a6440e103f060305a296c227b6a2f22494e7d',
|
|
length: 57,
|
|
type: 'UnnumberedFrame',
|
|
source: 'KG6ZLQ-12',
|
|
destination: '3X5SRR-0',
|
|
repeaters: ["ECHO-0","WIDE1-0","N6ZX-3","WIDE2-0"],
|
|
control: 3,
|
|
pid: 240,
|
|
infoHex: '60305a296c227b6a2f22494e7d',
|
|
infoStr: '`0Z)l"{j/"IN}',
|
|
toString: 'fm KG6ZLQ-12 to 3X5SRR-0 via ECHO-0,WIDE1-0,N6ZX-3,WIDE2-0* ctl UI- pid F0 len=13: `0Z)l"{j/"IN}',
|
|
},
|
|
{
|
|
hex: '82a0a664647060ae6ca6928e4060ae6c86b04040e703f03d333833342e32324e2f31323131382e3336576f504847333344302043616c454d412d4d61746865720d',
|
|
length: 65,
|
|
type: 'UnnumberedFrame',
|
|
source: 'W6SIG-0',
|
|
destination: 'APS228-0',
|
|
repeaters: ["W6CX-3"],
|
|
control: 3,
|
|
pid: 240,
|
|
infoStr: '=3834.22N/12118.36WoPHG33D0 CalEMA-Mather',
|
|
toString: 'fm W6SIG-0 to APS228-0 via W6CX-3* ctl UI- pid F0 len=42: =3834.22N/12118.36WoPHG33D0 CalEMA-Mather',
|
|
},
|
|
{
|
|
hex: 'a66ea6b0aeac6096926c82a69060ae826ca89eaee4ae6c86b04040e6ae92888a64406103f0603234676c201c3e2f272233757d4d542d5254477c25562560276e7c21777755217c33',
|
|
length: 72,
|
|
type: 'UnnumberedFrame',
|
|
source: 'KI6ASH-0',
|
|
destination: 'S7SXWV-0',
|
|
repeaters: ["WA6TOW-2","W6CX-3","WIDE2-0"],
|
|
control: 3,
|
|
pid: 240,
|
|
infoHex: '603234676c201c3e2f272233757d4d542d5254477c25562560276e7c21777755217c33',
|
|
toString: 'fm KI6ASH-0 to S7SXWV-0 via WA6TOW-2,W6CX-3*,WIDE2-0 ctl UI- pid F0 len=35',
|
|
},
|
|
];
|
|
|
|
// Tests for each vector: assert properties for parsed frames
|
|
vectors.forEach((vector) => {
|
|
it(`parses ${vector.toString} (all properties)`, () => {
|
|
const buf = hexToBytes(vector.hex);
|
|
const frame = Frame.fromBytes(buf);
|
|
expect(frame).toBeDefined();
|
|
expect(buf.length).toBe(vector.length);
|
|
expect(frame.constructor.name).toBe(vector.type);
|
|
expect(frame.source.toString()).toBe(vector.source);
|
|
expect(frame.destination.toString()).toBe(vector.destination);
|
|
if (vector.repeaters && frame.repeaters) {
|
|
const printable = vector.repeaters.every(r => /^[\x20-\x7E,]*$/.test(r));
|
|
if (printable) {
|
|
expect(frame.repeaters.map(d => d.toString())).toEqual(vector.repeaters);
|
|
}
|
|
}
|
|
expect(frame.control).toBe(vector.control);
|
|
expect(frame.pid).toBe(vector.pid);
|
|
if (vector.infoStr !== undefined) {
|
|
const got = new TextDecoder().decode(frame.info).replace(/\r\n?/g, "\n").replace(/\n+$/g, "");
|
|
const expected = vector.infoStr.replace(/\r\n?/g, "\n").replace(/\n+$/g, "");
|
|
expect(got).toBe(expected);
|
|
} else {
|
|
expect(Buffer.from(frame.info).toString('hex')).toBe(vector.infoHex);
|
|
}
|
|
if (vector.toString !== undefined) {
|
|
const got = frame.toString().replace(/\r\n?/g, "\n").replace(/\n+$/g, "");
|
|
const expected = vector.toString.replace(/\r\n?/g, "\n").replace(/\n+$/g, "");
|
|
expect(got).toBe(expected);
|
|
}
|
|
});
|
|
});
|
|
|
|
});
|
|
});
|