Initial import

This commit is contained in:
2026-03-11 19:08:39 +01:00
commit 5bf12c326a
14 changed files with 4601 additions and 0 deletions

84
test/address.test.ts Normal file
View File

@@ -0,0 +1,84 @@
import { describe, it, expect } from 'vitest';
import { Address } from '../src/address';
describe('Address.constructor', () => {
it('truncates callsign to 6 chars and uppercases; accepts valid SSID', () => {
const a = new Address('abcdefg', 12);
expect(a.callsign).toBe('ABCDEF');
expect(a.ssid).toBe(12);
});
describe('input validation', () => {
it('rejects non-string callsign', () => {
expect(() => new Address(123 as any, 0)).toThrow(TypeError);
});
it('rejects empty callsign', () => {
expect(() => new Address(' ', 0)).toThrow();
});
it('rejects non-integer or out-of-range SSID', () => {
// non-integer
expect(() => new Address('ABC', 1.5 as any)).toThrow(TypeError);
// out of range
expect(() => new Address('ABC', -1)).toThrow(RangeError);
expect(() => new Address('ABC', 16)).toThrow(RangeError);
});
});
});
describe('Address.fromBytes', () => {
it('roundtrips to/from bytes and string', () => {
const a = new Address('NOCALL', 3);
const b = Address.fromBytes(a.toBytes(true));
expect(b.callsign).toBe('NOCALL');
expect(b.ssid).toBe(3);
expect(a.toString()).toBe('NOCALL-3');
});
it('throws on short buffer', () => {
expect(() => Address.fromBytes(new Uint8Array([1, 2, 3]))).toThrow();
});
});
describe('Address.fromString', () => {
it('parses string addresses', () => {
const a = Address.fromString('FOO-1');
expect(a.callsign).toBe('FOO');
expect(a.ssid).toBe(1);
});
describe('input validation', () => {
it('rejects invalid fromString inputs', () => {
// non-string
expect(() => Address.fromString(123 as any)).toThrow(TypeError);
// empty
expect(() => Address.fromString('-1')).toThrow();
// non-numeric SSID
expect(() => Address.fromString('FOO-A')).toThrow();
});
});
});
describe('Address.toBytes', () => {
it('encodes callsign bytes and SSID correctly with and without last bit', () => {
const a = new Address('AB', 5);
const b = a.toBytes(false);
expect(b[0]).toBe('A'.charCodeAt(0) << 1);
expect(b[1]).toBe('B'.charCodeAt(0) << 1);
// padding spaces are 0x20 << 1 == 0x40
expect(b[2]).toBe(0x40);
expect(b[6]).toBe(0x60 | ((5 << 1) & 0xfe));
const last = a.toBytes(true);
expect((last[6] & 0x01)).toBe(1);
});
});
describe('Address.toString', () => {
it('omits SSID when showSsid is false and supports custom separator', () => {
const a = new Address('call', 2);
expect(a.toString({ showSsid: false })).toBe('CALL');
expect(a.toString({ sep: ':' })).toBe('CALL:2');
});
});

99
test/frame.test.ts Normal file
View File

@@ -0,0 +1,99 @@
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);
});
});

93
test/hdlc.test.ts Normal file
View File

@@ -0,0 +1,93 @@
import { describe, it, expect } from 'vitest';
import { escapeBuffer, unescapeBuffer, crc16Ccitt, verifyAndStripFcs, computeFcs, encodeHDLC, appendFcsLE, bitStuffBits } from '../src/hdlc';
describe('HDLC.escapeBuffer', () => {
it('escapes and unescapes special bytes', () => {
const raw = new Uint8Array([0x01, 0x7e, 0x7d, 0x02]);
const e = escapeBuffer(raw);
const u = unescapeBuffer(e);
expect(Array.from(u)).toEqual(Array.from(raw));
});
});
describe('HDLC.computeFcs', () => {
it('crc16 and verify works', () => {
const payload = new Uint8Array([1, 2, 3, 4, 5]);
const fcs = computeFcs(payload);
const buf = new Uint8Array([...payload, fcs & 0xff, (fcs >> 8) & 0xff]);
const res = verifyAndStripFcs(buf);
expect(res.ok).toBe(true);
expect(Array.from(res.payload || [])).toEqual(Array.from(payload));
});
it('CRC/X-25 standard test string', () => {
const payload = new TextEncoder().encode('123456789');
const pre = crc16Ccitt(payload);
expect(pre).toBe(0x29b1);
const fcs = computeFcs(payload);
expect(fcs).toBe(0xd64e);
const withFcs = appendFcsLE(payload);
// transmitted as little-endian bytes: low then high
expect(withFcs[withFcs.length - 2]).toBe(0x4e);
expect(withFcs[withFcs.length - 1]).toBe(0xd6);
});
});
describe('HDLC.bitStuffBits', () => {
it('bit stuffing inserts a zero after five 1s', () => {
const rawBits = '01111111';
const stuffed = bitStuffBits(rawBits);
expect(stuffed).toBe('011111011');
});
});
describe('HDLC.encodeHDLC', () => {
it('HDLC framing wraps with FLAG and escapes bytes', () => {
const raw = new Uint8Array([0x7e, 0x7d, 0x11]);
const framed = encodeHDLC(raw);
expect(framed[0]).toBe(0x7e);
expect(framed[framed.length - 1]).toBe(0x7e);
// internal must not contain raw FLAG (0x7e); ESC (0x7d) will appear as escape
const inner = framed.slice(1, framed.length - 1);
for (const b of inner) expect(b !== 0x7e).toBe(true);
});
});
describe('HDLC.verifyAndStripFcs', () => {
it('CRC appends low byte then high byte and verify strips it', () => {
const payload = new Uint8Array([0x10, 0x20, 0x30]);
const fcs = computeFcs(payload);
const buf = new Uint8Array([...payload, fcs & 0xff, (fcs >> 8) & 0xff]);
const res = verifyAndStripFcs(buf);
expect(res.ok).toBe(true);
expect(res.fcs).toBe(fcs);
expect(Array.from(res.payload || [])).toEqual(Array.from(payload));
});
});
describe('HDLC.APRS', () => {
it('APRS UI-frame example and HDLC encoding', () => {
const destHex = [0x82, 0xa0, 0xa4, 0xa6, 0x40, 0x40, 0x60];
const srcHex = [0x9c, 0x6c, 0xa0, 0x8e, 0x40, 0x40, 0x61];
const infoStr = '!4540.00N/12300.00W-';
const info = new TextEncoder().encode(infoStr);
const parts: number[] = [];
parts.push(...destHex, ...srcHex);
parts.push(0x03, 0xf0);
parts.push(...info);
const ax25 = Uint8Array.from(parts);
// compute HDLC frame with FCS
const hdlc = encodeHDLC(ax25, { includeFcs: true });
// sanity checks: starts/ends with flag and contains escaped data
expect(hdlc[0]).toBe(0x7e);
expect(hdlc[hdlc.length - 1]).toBe(0x7e);
// parse the AX.25 portion back from the unescaped frame
const inner = hdlc.slice(1, hdlc.length - 1);
// unescapeBuffer is internal; use HDLCDeframer to extract - simpler: verify FCS appended
const withFcs = appendFcsLE(ax25);
// last two bytes of withFcs are little-endian FCS
expect(withFcs[withFcs.length - 2]).toBe((computeFcs(ax25) & 0xff));
});
});