Initial import
This commit is contained in:
362
src/frame.ts
Normal file
362
src/frame.ts
Normal file
@@ -0,0 +1,362 @@
|
||||
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, '');
|
||||
|
||||
|
||||
export class Frame implements IFrame {
|
||||
destination: IAddress;
|
||||
source: IAddress;
|
||||
control: number;
|
||||
pid?: number;
|
||||
info: Uint8Array;
|
||||
raw?: Uint8Array;
|
||||
|
||||
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.control = control;
|
||||
this.info = info ?? new Uint8Array(0);
|
||||
}
|
||||
|
||||
toString() {
|
||||
// Format similar to common packet-radio programs
|
||||
const src = this.source.toString();
|
||||
const dst = this.destination.toString();
|
||||
let ctl = '';
|
||||
if (this instanceof UnnumberedFrame) {
|
||||
ctl = `${this.uType}${this.pf ? '^' : '-'}`;
|
||||
} else if (this instanceof SupervisoryFrame) {
|
||||
ctl = `${this.sType}${this.nr}${this.pf ? 'v' : '-'}`;
|
||||
} else if (this instanceof InformationFrame) {
|
||||
ctl = `${this.ns}${this.nr}${this.pf ? '^' : '-'}`;
|
||||
} else {
|
||||
ctl = `CTL${this.control.toString(16)}`;
|
||||
}
|
||||
|
||||
const pidStr = typeof this.pid === 'number' ? ` pid ${this.pid.toString(16).toUpperCase().padStart(2, '0')}` : '';
|
||||
const lenStr = ` len=${this.info?.length ?? 0}`;
|
||||
|
||||
// If info looks like printable ASCII, append it after a colon
|
||||
let infoText = '';
|
||||
if (this.info && this.info.length > 0) {
|
||||
const printable = Array.from(this.info).every(b => (b >= 0x20 && b <= 0x7e) || b === 0x09 || b === 0x0a || b === 0x0d);
|
||||
if (printable) {
|
||||
try {
|
||||
infoText = `: ${new TextDecoder().decode(this.info)}`;
|
||||
} catch {
|
||||
infoText = '';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return `fm ${src} to ${dst} ctl ${ctl}${pidStr}${lenStr}${infoText}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a given AX.25 frame payload and returns the corresponding Frame instance.
|
||||
*
|
||||
* This function processes the AX.25 protocol frame structure, extracting address fields,
|
||||
* control field, and information field as appropriate. It supports parsing of I-frames,
|
||||
* S-frames, and U-frames, returning the correct subclass of `Frame` for each.
|
||||
*
|
||||
* @param payload - The raw AX.25 frame as a Uint8Array.
|
||||
* @returns {Frame} An instance of `InformationFrame`, `SupervisoryFrame`, or `UnnumberedFrame`
|
||||
* depending on the type of AX.25 frame parsed.
|
||||
* @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)
|
||||
let offset = 0;
|
||||
const addrs: IAddress[] = [];
|
||||
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);
|
||||
offset += 7;
|
||||
if (isLast) break;
|
||||
}
|
||||
|
||||
if (addrs.length < 2) throw new Error('AX.25: not enough address fields');
|
||||
|
||||
// next byte: control
|
||||
if (offset >= payload.length) throw new Error('AX.25: missing control byte');
|
||||
const control = payload[offset];
|
||||
offset += 1;
|
||||
|
||||
// Addresses as Address objects
|
||||
const dst = Address.fromBytes(addrsRaw(payload, 0));
|
||||
const src = Address.fromBytes(addrsRaw(payload, 7));
|
||||
|
||||
// Decode control field (only 1-byte control supported here)
|
||||
// I-frame: bit0 == 0
|
||||
if ((control & 0x01) === 0) {
|
||||
const ns = (control >> 1) & 0x07;
|
||||
const pf = (control >> 4) & 0x01;
|
||||
const nr = (control >> 5) & 0x07;
|
||||
const info = payload.slice(offset);
|
||||
const f = new InformationFrame(src, dst, control, info, ns, nr, pf);
|
||||
f.raw = payload;
|
||||
return f;
|
||||
}
|
||||
|
||||
// S-frame: bits 0-1 == 01
|
||||
if ((control & 0x03) === 0x01) {
|
||||
const sCode = (control >> 2) & 0x03;
|
||||
const pf = (control >> 4) & 0x01;
|
||||
const nr = (control >> 5) & 0x07;
|
||||
let sType: 'RR' | 'RNR' | 'REJ' | 'SREJ' = 'RR';
|
||||
switch (sCode) {
|
||||
case 0:
|
||||
sType = 'RR';
|
||||
break;
|
||||
case 1:
|
||||
sType = 'RNR';
|
||||
break;
|
||||
case 2:
|
||||
sType = 'REJ';
|
||||
break;
|
||||
case 3:
|
||||
sType = 'SREJ';
|
||||
break;
|
||||
}
|
||||
const f = new SupervisoryFrame(src, dst, control, sType, nr, pf);
|
||||
f.raw = payload;
|
||||
return f;
|
||||
}
|
||||
|
||||
// U-frame: bits 0-1 == 11
|
||||
{
|
||||
const pf = (control >> 4) & 0x01;
|
||||
// common U-frame types (not exhaustive)
|
||||
let uType = `U(${control.toString(16)})`;
|
||||
switch (control) {
|
||||
case 0x03:
|
||||
uType = 'UI';
|
||||
break;
|
||||
case 0x2f:
|
||||
uType = 'SABM';
|
||||
break;
|
||||
case 0x63:
|
||||
uType = 'UA';
|
||||
break;
|
||||
case 0x43:
|
||||
uType = 'DISC';
|
||||
break;
|
||||
case 0x0f:
|
||||
uType = 'SNRM';
|
||||
break;
|
||||
}
|
||||
let uPid: number | undefined;
|
||||
let info: Uint8Array = new Uint8Array(0);
|
||||
if (uType === 'UI') {
|
||||
if (offset < payload.length) {
|
||||
uPid = payload[offset];
|
||||
offset += 1;
|
||||
info = payload.slice(offset);
|
||||
}
|
||||
}
|
||||
const f = new UnnumberedFrame(src, dst, control, uType, pf, info);
|
||||
f.pid = uPid;
|
||||
f.raw = payload;
|
||||
return f;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class InformationFrame extends Frame {
|
||||
ns: number;
|
||||
nr: number;
|
||||
pf: number;
|
||||
|
||||
constructor(src: IAddress, dst: IAddress, control: number, info: Uint8Array, ns: number, nr: number, pf: number) {
|
||||
super(src, dst, control, info);
|
||||
this.ns = ns;
|
||||
this.nr = nr;
|
||||
this.pf = pf;
|
||||
}
|
||||
}
|
||||
|
||||
export class SupervisoryFrame extends Frame {
|
||||
sType: 'RR' | 'RNR' | 'REJ' | 'SREJ';
|
||||
nr: number;
|
||||
pf: number;
|
||||
|
||||
constructor(src: IAddress, dst: IAddress, control: number, sType: 'RR' | 'RNR' | 'REJ' | 'SREJ', nr: number, pf: number) {
|
||||
super(src, dst, control, new Uint8Array(0));
|
||||
this.sType = sType;
|
||||
this.nr = nr;
|
||||
this.pf = pf;
|
||||
}
|
||||
}
|
||||
|
||||
export class UnnumberedFrame extends Frame {
|
||||
uType: string;
|
||||
pf: number;
|
||||
|
||||
constructor(src: IAddress, dst: IAddress, control: number, uType: string, pf: number, info?: Uint8Array) {
|
||||
super(src, dst, control, info);
|
||||
this.uType = uType;
|
||||
this.pf = pf;
|
||||
}
|
||||
}
|
||||
|
||||
export const buildIControl: (ns: number, nr: number, pf?: number) => number = (ns, nr, pf = 0) => {
|
||||
// bit0 = 0 for I-frames; ns in bits 1-3, pf in bit4, nr in bits 5-7
|
||||
return ((ns & 0x07) << 1) | ((pf & 0x01) << 4) | ((nr & 0x07) << 5);
|
||||
};
|
||||
|
||||
|
||||
export const buildSControl: (sType: 'RR' | 'RNR' | 'REJ' | 'SREJ', nr: number, pf?: number) => number = (sType, nr, pf = 0) => {
|
||||
const sCode = sType === 'RR' ? 0 : sType === 'RNR' ? 1 : sType === 'REJ' ? 2 : 3;
|
||||
// bits 0-1 = 01
|
||||
return 0x01 | (sCode << 2) | ((pf & 0x01) << 4) | ((nr & 0x07) << 5);
|
||||
};
|
||||
|
||||
export const buildUControl: (uType: string, pf?: number) => number = (uType, pf = 0) => {
|
||||
// common mappings
|
||||
switch (uType) {
|
||||
case 'UI':
|
||||
return 0x03 | ((pf & 0x01) << 4);
|
||||
case 'SABM':
|
||||
return 0x2f | ((pf & 0x01) << 4);
|
||||
case 'UA':
|
||||
return 0x63 | ((pf & 0x01) << 4);
|
||||
case 'DISC':
|
||||
return 0x43 | ((pf & 0x01) << 4);
|
||||
default:
|
||||
return 0x03 | ((pf & 0x01) << 4);
|
||||
}
|
||||
};
|
||||
|
||||
// Extended control builders (2-byte) - minimal support for encoding larger NS/NR
|
||||
export const buildIControlExtended: (ns: number, nr: number, pf?: number) => Uint8Array = (ns, nr, pf = 0) => {
|
||||
const b1 = ((ns & 0x7f) << 1) | ((pf & 0x01) << 4);
|
||||
const b2 = ((nr & 0x7f) << 1);
|
||||
return Uint8Array.from([b1 & 0xff, b2 & 0xff]);
|
||||
};
|
||||
|
||||
export const buildSControlExtended: (sType: 'RR' | 'RNR' | 'REJ' | 'SREJ', nr: number, pf?: number) => Uint8Array = (sType, nr, pf = 0) => {
|
||||
const sCode = sType === 'RR' ? 0 : sType === 'RNR' ? 1 : sType === 'REJ' ? 2 : 3;
|
||||
const b1 = 0x01 | (sCode << 2) | ((pf & 0x01) << 4);
|
||||
const b2 = ((nr & 0x7f) << 1);
|
||||
return Uint8Array.from([b1 & 0xff, b2 & 0xff]);
|
||||
};
|
||||
|
||||
export const encodeFrame: (frame: Frame) => Uint8Array = (frame) => {
|
||||
const parts: number[] = [];
|
||||
// addresses: destination then source; mark source as last
|
||||
parts.push(...(frame.destination as IAddress).toBytes(false));
|
||||
parts.push(...(frame.source as IAddress).toBytes(true));
|
||||
// control
|
||||
parts.push(frame.control & 0xff);
|
||||
|
||||
if (frame instanceof UnnumberedFrame) {
|
||||
if (frame.uType === 'UI') {
|
||||
parts.push(frame.pid ?? 0xf0);
|
||||
parts.push(...(frame.info ?? new Uint8Array(0)));
|
||||
}
|
||||
// other U-frames have no pid/info in AX.25 common usage
|
||||
} else if (frame instanceof InformationFrame) {
|
||||
parts.push(...(frame.info ?? new Uint8Array(0)));
|
||||
} else if (frame instanceof SupervisoryFrame) {
|
||||
// S-frames typically carry no PID/info
|
||||
}
|
||||
|
||||
return Uint8Array.from(parts);
|
||||
};
|
||||
|
||||
export const toHDLC: (frame: Frame) => Uint8Array = (frame) => {
|
||||
const ax25 = encodeFrame(frame);
|
||||
return encodeHDLC(ax25, { includeFcs: true });
|
||||
};
|
||||
|
||||
export const toWire: (frame: Frame) => Buffer = (frame) => {
|
||||
const h = toHDLC(frame);
|
||||
return Buffer.from(h.buffer, h.byteOffset, h.byteLength);
|
||||
};
|
||||
|
||||
export const toWireStream: (frame: Frame) => Readable = (frame) => {
|
||||
const buf = toWire(frame);
|
||||
const r = new Readable();
|
||||
r.push(buf);
|
||||
r.push(null);
|
||||
return r;
|
||||
};
|
||||
|
||||
const addrsRaw: (payload: Uint8Array, offset: number) => Uint8Array = (payload, offset) => payload.slice(offset, offset + 7);
|
||||
|
||||
export const encodeAX25: (dst: Address | string, src: Address | string, info: Uint8Array, options?: { ui?: boolean }) => Uint8Array = (dst, src, info, options) => {
|
||||
// dst and src should be like 'CALL-SSID' or 'CALL'
|
||||
const dstAddr = typeof dst === 'string' ? Address.fromString(dst) : dst;
|
||||
const srcAddr = typeof src === 'string' ? Address.fromString(src) : src;
|
||||
const parts: number[] = [];
|
||||
const dfield = dstAddr.toBytes(false);
|
||||
const sfield = srcAddr.toBytes(true); // mark source as last address
|
||||
parts.push(...dfield, ...sfield);
|
||||
// control
|
||||
parts.push(options?.ui ? 0x03 : 0x03);
|
||||
// PID - use 0xf0 (no layer 3) as default for UI-frames
|
||||
parts.push(0xf0);
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user