Drop FX.25 for now
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -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
|
||||
|
||||
71
README.md
Normal file
71
README.md
Normal file
@@ -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.
|
||||
BIN
docs/AX25_plus_FEC_equals_FX25.pdf
Normal file
BIN
docs/AX25_plus_FEC_equals_FX25.pdf
Normal file
Binary file not shown.
BIN
docs/FX-25_01_06.pdf
Normal file
BIN
docs/FX-25_01_06.pdf
Normal file
Binary file not shown.
124
src/frame.ts
124
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ export interface IAddress {
|
||||
export interface IFrame {
|
||||
destination: IAddress;
|
||||
source: IAddress;
|
||||
repeaters?: IAddress[];
|
||||
control: number;
|
||||
pid?: number;
|
||||
info: Uint8Array;
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user