diff --git a/.gitignore b/.gitignore index a61cd08..b68996b 100644 --- a/.gitignore +++ b/.gitignore @@ -273,4 +273,7 @@ $RECYCLE.BIN/ # Windows shortcuts *.lnk +# Packet samples +*.sample + # End of https://www.toptal.com/developers/gitignore/api/node,react,sass,macos,linux,windows,visualstudiocode,vim diff --git a/README.md b/README.md new file mode 100644 index 0000000..abea3b4 --- /dev/null +++ b/README.md @@ -0,0 +1,71 @@ +# ax25.js — AX.25 + HDLC utilities (TypeScript) + +This repository provides low-level utilities for AX.25 frame construction/parsing and HDLC framing used in amateur radio applications. It's intentionally small and focused on framing, FCS and control-field helpers. + +## Highlights + +- `Address` – create and parse callsigns, produce 7-byte AX.25 address fields. +- `Frame` – `InformationFrame`, `SupervisoryFrame`, `UnnumberedFrame` and `Frame.fromBytes()` parsing. +- Control builders: `buildIControl`, `buildSControl`, `buildUControl` (and extended 2‑byte builders). +- Encoders: `encodeFrame`, convenience `encodeAx25` / alias `encodeAX25`. +- HDLC: `encodeHDLC`, `escapeBuffer`, `unescapeBuffer`, `computeFcs` / `crc16Ccitt`, `verifyAndStripFcs`. +- Convenience stream helpers: `toHDLC`, `toWire`, `toWireStream`. +- `FX25` class: detection heuristics; full FEC decoding (Reed–Solomon) is a TODO. + +## Install & tests + +```bash +npm install +npm test +``` + +## Examples + +Note: during development you can import directly from `src/*`. When published, import from the package entrypoint. + +### Address + +```ts +import { Address } from './src/address'; + +const a = Address.fromString('N0CALL-0'); +console.log(a.toString()); // N0CALL-0 +``` + +### Build an AX.25 UI payload and HDLC-wrap it + +```ts +import { Address } from './src/address'; +import { UnnumberedFrame, encodeFrame, encodeAX25, toHDLC } from './src/frame'; + +const src = Address.fromString('SRC-1'); +const dst = Address.fromString('DST-0'); +const info = new TextEncoder().encode('payload'); + +// quick builder +const ax = encodeAX25(dst, src, info, { ui: true }); + +// or construct Frame and HDLC-wrap +const f = new UnnumberedFrame(src, dst, 0x03, 'UI', 0, info); +const hdlc = toHDLC(f); // includes FCS, escapes and flags +``` + +### Parsing + +```ts +import { Frame } from './src/frame'; + +const frame = Frame.fromBytes(ax); +console.log(frame.toString()); +``` + +## HDLC & FCS + +- `computeFcs(payload)` — CRC-16-CCITT with AX.25/X.25 inversion semantics. +- `encodeHDLC(payload, { includeFcs: true })` — wraps payload with 0x7E flags, appends FCS and escapes special bytes. +- `verifyAndStripFcs(buf)` — validates and strips a little-endian 2-byte FCS from a buffer. + +## Testing and style + +- Tests live in `test/` and are run with Vitest. They follow `describe('Class.method', ...)` naming. +- Keep tests targeted and add coverage for new behavior. diff --git a/docs/AX25_plus_FEC_equals_FX25.pdf b/docs/AX25_plus_FEC_equals_FX25.pdf new file mode 100644 index 0000000..3113a1b Binary files /dev/null and b/docs/AX25_plus_FEC_equals_FX25.pdf differ diff --git a/docs/FX-25_01_06.pdf b/docs/FX-25_01_06.pdf new file mode 100644 index 0000000..9aa41a0 Binary files /dev/null and b/docs/FX-25_01_06.pdf differ diff --git a/src/frame.ts b/src/frame.ts index e737dc9..e8ac157 100644 --- a/src/frame.ts +++ b/src/frame.ts @@ -1,14 +1,20 @@ +import { Readable } from 'stream'; import { IFrame, IAddress } from './frame.types'; import { Address } from './address'; -import { encodeHDLC, crc16Ccitt, verifyAndStripFcs } from './hdlc'; -import { Readable } from 'stream'; - -const trimRight: (str: string) => string = (str) => str.replace(/\s+$/g, ''); +import { encodeHDLC } from './hdlc'; +/** + * Represents an AX.25 frame with source/destination addresses, control field, and information field. + * + * This class serves as a base for specific frame types (InformationFrame, SupervisoryFrame, UnnumberedFrame) + * and provides common functionality such as parsing from raw bytes and encoding to AX.25 format. + */ export class Frame implements IFrame { destination: IAddress; source: IAddress; + repeaters: IAddress[]; + repeatedIndex: number | null; control: number; pid?: number; info: Uint8Array; @@ -17,6 +23,8 @@ export class Frame implements IFrame { constructor(src: Address | string = '', dst: Address | string = '', control = 0, info?: Uint8Array) { this.source = typeof src === 'string' ? Address.fromString(src) : src; this.destination = typeof dst === 'string' ? Address.fromString(dst) : dst; + this.repeaters = []; + this.repeatedIndex = null; this.control = control; this.info = info ?? new Uint8Array(0); } @@ -36,6 +44,17 @@ export class Frame implements IFrame { ctl = `CTL${this.control.toString(16)}`; } + // Repeater path formatting + let viaStr = ''; + if (this.repeaters && this.repeaters.length > 0) { + const path = this.repeaters.map((repeater, i) => { + let s = repeater.toString(); + if (this.repeatedIndex === i) s += '*'; + return s; + }).join(','); + viaStr = ` via ${path}`; + } + const pidStr = typeof this.pid === 'number' ? ` pid ${this.pid.toString(16).toUpperCase().padStart(2, '0')}` : ''; const lenStr = ` len=${this.info?.length ?? 0}`; @@ -52,7 +71,7 @@ export class Frame implements IFrame { } } - return `fm ${src} to ${dst} ctl ${ctl}${pidStr}${lenStr}${infoText}`; + return `fm ${src} to ${dst}${viaStr} ctl ${ctl}${pidStr}${lenStr}${infoText}`; } /** @@ -68,20 +87,27 @@ export class Frame implements IFrame { * @throws {Error} If the payload is malformed, missing required fields, or does not conform to AX.25. */ static fromBytes(payload: Uint8Array): Frame { - // Parse AX.25 addresses (each 7 bytes) until last address (bit 0 of SSID byte === 1) + // Skip leading null bytes (common in some KISS/TNC output) let offset = 0; - const addrs: IAddress[] = []; + while (offset < payload.length && payload[offset] === 0x00) { + offset++; + } + + // Parse AX.25 addresses (each 7 bytes) until last address (bit 0 of SSID byte === 1) + const addrs: Address[] = []; + const repeaterFlags: boolean[] = []; while (offset + 7 <= payload.length) { - const field = payload.slice(offset, offset + 7); - const ssidByte = field[6]; - const isLast = (ssidByte & 0x01) === 1; - // construct Address instance from the 7-byte AX.25 address field - const addr = Address.fromBytes(field); - addrs.push(addr); + const field = payload.slice(offset, offset + 7); + const addr = Address.fromBytes(field); + const isLast = addr.getExtension(); + const hasRepeated = addr.getCH(); + addrs.push(addr); + repeaterFlags.push(hasRepeated); offset += 7; if (isLast) break; } + // Need at least destination and source addresses if (addrs.length < 2) throw new Error('AX.25: not enough address fields'); // next byte: control @@ -90,8 +116,10 @@ export class Frame implements IFrame { offset += 1; // Addresses as Address objects - const dst = Address.fromBytes(addrsRaw(payload, 0)); - const src = Address.fromBytes(addrsRaw(payload, 7)); + const dst: Address = addrs[0]; + const src: Address = addrs[1]; + const repeaters: Address[] = addrs.length > 2 ? addrs.slice(2) : []; + const repeatedIndex = repeaterFlags.length > 2 ? repeaterFlags.slice(2).lastIndexOf(true) : -1; // Decode control field (only 1-byte control supported here) // I-frame: bit0 == 0 @@ -101,6 +129,8 @@ export class Frame implements IFrame { const nr = (control >> 5) & 0x07; const info = payload.slice(offset); const f = new InformationFrame(src, dst, control, info, ns, nr, pf); + f.repeaters = repeaters; + f.repeatedIndex = repeatedIndex >= 0 ? repeatedIndex : null; f.raw = payload; return f; } @@ -126,6 +156,8 @@ export class Frame implements IFrame { break; } const f = new SupervisoryFrame(src, dst, control, sType, nr, pf); + f.repeaters = repeaters; + f.repeatedIndex = repeatedIndex >= 0 ? repeatedIndex : null; f.raw = payload; return f; } @@ -162,6 +194,8 @@ export class Frame implements IFrame { } } const f = new UnnumberedFrame(src, dst, control, uType, pf, info); + f.repeaters = repeaters; + f.repeatedIndex = repeatedIndex >= 0 ? repeatedIndex : null; f.pid = uPid; f.raw = payload; return f; @@ -174,7 +208,7 @@ export class InformationFrame extends Frame { nr: number; pf: number; - constructor(src: IAddress, dst: IAddress, control: number, info: Uint8Array, ns: number, nr: number, pf: number) { + constructor(src: Address, dst: Address, control: number, info: Uint8Array, ns: number, nr: number, pf: number) { super(src, dst, control, info); this.ns = ns; this.nr = nr; @@ -187,7 +221,7 @@ export class SupervisoryFrame extends Frame { nr: number; pf: number; - constructor(src: IAddress, dst: IAddress, control: number, sType: 'RR' | 'RNR' | 'REJ' | 'SREJ', nr: number, pf: number) { + constructor(src: Address, dst: Address, control: number, sType: 'RR' | 'RNR' | 'REJ' | 'SREJ', nr: number, pf: number) { super(src, dst, control, new Uint8Array(0)); this.sType = sType; this.nr = nr; @@ -199,7 +233,7 @@ export class UnnumberedFrame extends Frame { uType: string; pf: number; - constructor(src: IAddress, dst: IAddress, control: number, uType: string, pf: number, info?: Uint8Array) { + constructor(src: Address, dst: Address, control: number, uType: string, pf: number, info?: Uint8Array) { super(src, dst, control, info); this.uType = uType; this.pf = pf; @@ -306,57 +340,3 @@ export const encodeAX25: (dst: Address | string, src: Address | string, info: Ui parts.push(...info); return Uint8Array.from(parts); } - -export class FX25 { - /** - * Heuristic detection: if raw AX.25 parsing and simple FCS checks fail, - * this may be an FX.25 frame (FEC encoded). This is a lightweight - * starter; proper FX.25 requires Reed-Solomon decoding which is - * not implemented here. - */ - static isLikelyFx25(raw: Uint8Array): boolean { - try { - Frame.fromBytes(raw); - return false; - } catch (_) {} - const f = verifyAndStripFcs(raw); - if (f.ok) { - try { - Frame.fromBytes(f.payload!); - return false; - } catch (_) {} - } - return raw.length > 0; // conservative: could be FX.25 - } - - /** - * Attempt to decode FX.25 to AX.25 frames. Currently tries plain AX.25 - * and FCS-stripped AX.25. A full FX.25 (FEC) implementation would go - * where the TODO is. - */ - static decode(raw: Uint8Array): Frame[] { - // try raw - try { - return [Frame.fromBytes(raw)]; - } catch (_) { - // continue - } - - // try strip FCS - const res = verifyAndStripFcs(raw); - if (res.ok && res.payload) { - try { - return [Frame.fromBytes(res.payload)]; - } catch (_) { - // continue - } - } - - // TODO: implement FX.25 FEC decode (Reed-Solomon) here - throw new Error('FX25.decode: FEC decoding not implemented'); - } - - static computeCheck(raw: Uint8Array): number { - return crc16Ccitt(raw); - } -} diff --git a/src/frame.types.ts b/src/frame.types.ts index c6fee23..2cc520c 100644 --- a/src/frame.types.ts +++ b/src/frame.types.ts @@ -10,6 +10,7 @@ export interface IAddress { export interface IFrame { destination: IAddress; source: IAddress; + repeaters?: IAddress[]; control: number; pid?: number; info: Uint8Array; diff --git a/src/hdlc.ts b/src/hdlc.ts index 4ac8dba..a9a272e 100644 --- a/src/hdlc.ts +++ b/src/hdlc.ts @@ -49,7 +49,7 @@ export const encodeHDLC: (payload: Uint8Array, options?: { includeFcs?: boolean }; // CRC / FCS helpers (CRC-16-CCITT) -export const crc16Ccitt: (buf: Uint8Array, initial?: number) => number = (buf, initial = 0xffff) => { +export const crc16CCITT: (buf: Uint8Array, initial?: number) => number = (buf, initial = 0xffff) => { let crc = initial & 0xffff; for (let i = 0; i < buf.length; i++) { crc ^= (buf[i] << 8) & 0xffff; @@ -65,14 +65,14 @@ export const verifyAndStripFcs: (buf: Uint8Array) => { ok: boolean; payload?: Ui if (buf.length < 2) return { ok: false }; const fcs = buf[buf.length - 2] | (buf[buf.length - 1] << 8); const payload = buf.slice(0, buf.length - 2); - const crc = crc16Ccitt(payload); + const crc = crc16CCITT(payload); // AX.25 transmits inverted CRC (FCS = CRC ^ 0xFFFF) if ((crc ^ 0xffff) === fcs) return { ok: true, payload, fcs }; return { ok: false }; }; export const computeFcs: (buf: Uint8Array) => number = (buf) => { - const crc = crc16Ccitt(buf); + const crc = crc16CCITT(buf); return crc ^ 0xffff; }; diff --git a/src/index.ts b/src/index.ts index e69de29..87f9810 100644 --- a/src/index.ts +++ b/src/index.ts @@ -0,0 +1,6 @@ +export * from './frame'; +export * from './frame.types'; +export type * from './frame.types'; +export * from './address'; +export * from './hdlc'; +export * from './kiss'; diff --git a/test/frame.test.ts b/test/frame.test.ts index fd6dbed..e89f643 100644 --- a/test/frame.test.ts +++ b/test/frame.test.ts @@ -96,4 +96,148 @@ describe('Frame.toHDLC', () => { 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); + } + }); + }); + + }); }); diff --git a/test/hdlc.test.ts b/test/hdlc.test.ts index 26ead85..f8f53d5 100644 --- a/test/hdlc.test.ts +++ b/test/hdlc.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { escapeBuffer, unescapeBuffer, crc16Ccitt, verifyAndStripFcs, computeFcs, encodeHDLC, appendFcsLE, bitStuffBits } from '../src/hdlc'; +import { escapeBuffer, unescapeBuffer, crc16CCITT, verifyAndStripFcs, computeFcs, encodeHDLC, appendFcsLE, bitStuffBits } from '../src/hdlc'; describe('HDLC.escapeBuffer', () => { it('escapes and unescapes special bytes', () => { @@ -22,7 +22,7 @@ describe('HDLC.computeFcs', () => { it('CRC/X-25 standard test string', () => { const payload = new TextEncoder().encode('123456789'); - const pre = crc16Ccitt(payload); + const pre = crc16CCITT(payload); expect(pre).toBe(0x29b1); const fcs = computeFcs(payload); expect(fcs).toBe(0xd64e);