Initial import
This commit is contained in:
276
.gitignore
vendored
Normal file
276
.gitignore
vendored
Normal file
@@ -0,0 +1,276 @@
|
||||
# Created by https://www.toptal.com/developers/gitignore/api/node,react,sass,macos,linux,windows,visualstudiocode,vim
|
||||
# Edit at https://www.toptal.com/developers/gitignore?templates=node,react,sass,macos,linux,windows,visualstudiocode,vim
|
||||
|
||||
### Linux ###
|
||||
*~
|
||||
|
||||
# temporary files which can be created if a process still has a handle open of a deleted file
|
||||
.fuse_hidden*
|
||||
|
||||
# KDE directory preferences
|
||||
.directory
|
||||
|
||||
# Linux trash folder which might appear on any partition or disk
|
||||
.Trash-*
|
||||
|
||||
# .nfs files are created when an open file is removed but is still being accessed
|
||||
.nfs*
|
||||
|
||||
### macOS ###
|
||||
# General
|
||||
.DS_Store
|
||||
.AppleDouble
|
||||
.LSOverride
|
||||
|
||||
# Icon must end with two \r
|
||||
Icon
|
||||
|
||||
|
||||
# Thumbnails
|
||||
._*
|
||||
|
||||
# Files that might appear in the root of a volume
|
||||
.DocumentRevisions-V100
|
||||
.fseventsd
|
||||
.Spotlight-V100
|
||||
.TemporaryItems
|
||||
.Trashes
|
||||
.VolumeIcon.icns
|
||||
.com.apple.timemachine.donotpresent
|
||||
|
||||
# Directories potentially created on remote AFP share
|
||||
.AppleDB
|
||||
.AppleDesktop
|
||||
Network Trash Folder
|
||||
Temporary Items
|
||||
.apdisk
|
||||
|
||||
### macOS Patch ###
|
||||
# iCloud generated files
|
||||
*.icloud
|
||||
|
||||
### Node ###
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
lerna-debug.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# Diagnostic reports (https://nodejs.org/api/report.html)
|
||||
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
|
||||
|
||||
# Runtime data
|
||||
pids
|
||||
*.pid
|
||||
*.seed
|
||||
*.pid.lock
|
||||
|
||||
# Directory for instrumented libs generated by jscoverage/JSCover
|
||||
lib-cov
|
||||
|
||||
# Coverage directory used by tools like istanbul
|
||||
coverage
|
||||
*.lcov
|
||||
|
||||
# nyc test coverage
|
||||
.nyc_output
|
||||
|
||||
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
|
||||
.grunt
|
||||
|
||||
# Bower dependency directory (https://bower.io/)
|
||||
bower_components
|
||||
|
||||
# node-waf configuration
|
||||
.lock-wscript
|
||||
|
||||
# Compiled binary addons (https://nodejs.org/api/addons.html)
|
||||
build/Release
|
||||
|
||||
# Dependency directories
|
||||
node_modules/
|
||||
jspm_packages/
|
||||
|
||||
# Snowpack dependency directory (https://snowpack.dev/)
|
||||
web_modules/
|
||||
|
||||
# TypeScript cache
|
||||
*.tsbuildinfo
|
||||
|
||||
# Optional npm cache directory
|
||||
.npm
|
||||
|
||||
# Optional eslint cache
|
||||
.eslintcache
|
||||
|
||||
# Optional stylelint cache
|
||||
.stylelintcache
|
||||
|
||||
# Microbundle cache
|
||||
.rpt2_cache/
|
||||
.rts2_cache_cjs/
|
||||
.rts2_cache_es/
|
||||
.rts2_cache_umd/
|
||||
|
||||
# Optional REPL history
|
||||
.node_repl_history
|
||||
|
||||
# Output of 'npm pack'
|
||||
*.tgz
|
||||
|
||||
# Yarn Integrity file
|
||||
.yarn-integrity
|
||||
|
||||
# dotenv environment variable files
|
||||
.env
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
.env.local
|
||||
|
||||
# parcel-bundler cache (https://parceljs.org/)
|
||||
.cache
|
||||
.parcel-cache
|
||||
|
||||
# Next.js build output
|
||||
.next
|
||||
out
|
||||
|
||||
# Nuxt.js build / generate output
|
||||
.nuxt
|
||||
dist
|
||||
|
||||
# Gatsby files
|
||||
.cache/
|
||||
# Comment in the public line in if your project uses Gatsby and not Next.js
|
||||
# https://nextjs.org/blog/next-9-1#public-directory-support
|
||||
# public
|
||||
|
||||
# vuepress build output
|
||||
.vuepress/dist
|
||||
|
||||
# vuepress v2.x temp and cache directory
|
||||
.temp
|
||||
|
||||
# Docusaurus cache and generated files
|
||||
.docusaurus
|
||||
|
||||
# Serverless directories
|
||||
.serverless/
|
||||
|
||||
# FuseBox cache
|
||||
.fusebox/
|
||||
|
||||
# DynamoDB Local files
|
||||
.dynamodb/
|
||||
|
||||
# TernJS port file
|
||||
.tern-port
|
||||
|
||||
# Stores VSCode versions used for testing VSCode extensions
|
||||
.vscode-test
|
||||
|
||||
# yarn v2
|
||||
.yarn/cache
|
||||
.yarn/unplugged
|
||||
.yarn/build-state.yml
|
||||
.yarn/install-state.gz
|
||||
.pnp.*
|
||||
|
||||
### Node Patch ###
|
||||
# Serverless Webpack directories
|
||||
.webpack/
|
||||
|
||||
# Optional stylelint cache
|
||||
|
||||
# SvelteKit build / generate output
|
||||
.svelte-kit
|
||||
|
||||
### react ###
|
||||
.DS_*
|
||||
**/*.backup.*
|
||||
**/*.back.*
|
||||
|
||||
node_modules
|
||||
|
||||
*.sublime*
|
||||
|
||||
psd
|
||||
thumb
|
||||
sketch
|
||||
|
||||
### Sass ###
|
||||
.sass-cache/
|
||||
*.css.map
|
||||
*.sass.map
|
||||
*.scss.map
|
||||
|
||||
### Vim ###
|
||||
# Swap
|
||||
[._]*.s[a-v][a-z]
|
||||
!*.svg # comment out if you don't need vector files
|
||||
[._]*.sw[a-p]
|
||||
[._]s[a-rt-v][a-z]
|
||||
[._]ss[a-gi-z]
|
||||
[._]sw[a-p]
|
||||
|
||||
# Session
|
||||
Session.vim
|
||||
Sessionx.vim
|
||||
|
||||
# Temporary
|
||||
.netrwhist
|
||||
# Auto-generated tag files
|
||||
tags
|
||||
# Persistent undo
|
||||
[._]*.un~
|
||||
|
||||
### VisualStudioCode ###
|
||||
.vscode/*
|
||||
!.vscode/settings.json
|
||||
!.vscode/tasks.json
|
||||
!.vscode/launch.json
|
||||
!.vscode/extensions.json
|
||||
!.vscode/*.code-snippets
|
||||
|
||||
# Local History for Visual Studio Code
|
||||
.history/
|
||||
|
||||
# Built Visual Studio Code Extensions
|
||||
*.vsix
|
||||
|
||||
### VisualStudioCode Patch ###
|
||||
# Ignore all local history of files
|
||||
.history
|
||||
.ionide
|
||||
|
||||
### Windows ###
|
||||
# Windows thumbnail cache files
|
||||
Thumbs.db
|
||||
Thumbs.db:encryptable
|
||||
ehthumbs.db
|
||||
ehthumbs_vista.db
|
||||
|
||||
# Dump file
|
||||
*.stackdump
|
||||
|
||||
# Folder config file
|
||||
[Dd]esktop.ini
|
||||
|
||||
# Recycle Bin used on file shares
|
||||
$RECYCLE.BIN/
|
||||
|
||||
# Windows Installer files
|
||||
*.cab
|
||||
*.msi
|
||||
*.msix
|
||||
*.msm
|
||||
*.msp
|
||||
|
||||
# Windows shortcuts
|
||||
*.lnk
|
||||
|
||||
# End of https://www.toptal.com/developers/gitignore/api/node,react,sass,macos,linux,windows,visualstudiocode,vim
|
||||
30
.pre-commit-config.yaml
Normal file
30
.pre-commit-config.yaml
Normal file
@@ -0,0 +1,30 @@
|
||||
repos:
|
||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||
rev: v6.0.0
|
||||
hooks:
|
||||
- id: check-yaml
|
||||
- id: end-of-file-fixer
|
||||
- id: trailing-whitespace
|
||||
|
||||
- repo: https://github.com/shellcheck-py/shellcheck-py
|
||||
rev: v0.11.0.1
|
||||
hooks:
|
||||
- id: shellcheck
|
||||
|
||||
- repo: https://github.com/pre-commit/mirrors-eslint
|
||||
rev: v10.0.3
|
||||
hooks:
|
||||
- id: eslint
|
||||
files: "\\.(js|jsx|ts|tsx)$"
|
||||
exclude: node_modules/
|
||||
|
||||
# Use stylelint (local) instead of the deprecated scss-lint Ruby gem which
|
||||
# cannot parse modern Sass `@use` and module syntax. This invokes the
|
||||
# project's installed `stylelint` via `npx` so the devDependency is used.
|
||||
- repo: local
|
||||
hooks:
|
||||
- id: stylelint
|
||||
name: stylelint
|
||||
entry: npx stylelint --fix
|
||||
language: system
|
||||
files: "\\.(scss|sass|css)$"
|
||||
51
.vscode/settings.json
vendored
Normal file
51
.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,51 @@
|
||||
{
|
||||
"gopls": {
|
||||
"formatting.local": "git.maze.io",
|
||||
"ui.semanticTokens": true
|
||||
},
|
||||
|
||||
// Global defaults for all other languages (4 spaces)
|
||||
"editor.tabSize": 4,
|
||||
"editor.insertSpaces": true,
|
||||
"editor.detectIndentation": false,
|
||||
|
||||
// Go: Use tabs, with a tab size of 4
|
||||
"[go]": {
|
||||
"editor.insertSpaces": false,
|
||||
"editor.tabSize": 4,
|
||||
"editor.detectIndentation": false
|
||||
},
|
||||
|
||||
// CSS, JavaScript, TypeScript, JSON: Use 2 spaces
|
||||
"[css]": {
|
||||
"editor.tabSize": 2,
|
||||
},
|
||||
"[sass]": {
|
||||
"editor.tabSize": 2,
|
||||
},
|
||||
"[scss]": {
|
||||
"editor.tabSize": 2,
|
||||
},
|
||||
"[javascript]": {
|
||||
"editor.tabSize": 2,
|
||||
},
|
||||
"[typescript]": {
|
||||
"editor.tabSize": 2,
|
||||
},
|
||||
"[typescriptreact]": {
|
||||
"editor.tabSize": 2,
|
||||
},
|
||||
"[json]": {
|
||||
"editor.tabSize": 2,
|
||||
},
|
||||
"[yaml]": {
|
||||
"editor.tabSize": 2,
|
||||
},
|
||||
|
||||
// For JSON with comments, often used in VSCode config files
|
||||
"[jsonc]": {
|
||||
"editor.insertSpaces": true,
|
||||
"editor.tabSize": 2,
|
||||
"editor.detectIndentation": false
|
||||
}
|
||||
}
|
||||
BIN
docs/AX25.2.2-Jul 98-2.pdf
Normal file
BIN
docs/AX25.2.2-Jul 98-2.pdf
Normal file
Binary file not shown.
3302
package-lock.json
generated
Normal file
3302
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
52
package.json
Normal file
52
package.json
Normal file
@@ -0,0 +1,52 @@
|
||||
{
|
||||
"name": "@hamradio/ax25",
|
||||
"version": "1.0.1",
|
||||
"description": "AX.25 protocol support for Typescript",
|
||||
"keywords": [
|
||||
"AX.25",
|
||||
"radio",
|
||||
"amateur radio",
|
||||
"HAM radio",
|
||||
"rf"
|
||||
],
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://git.maze.io/ham/ax25.js"
|
||||
},
|
||||
"license": "MIT",
|
||||
"author": "Wijnand Modderman-Lenstra",
|
||||
"main": "dist/index.js",
|
||||
"module": "dist/index.mjs",
|
||||
"types": "dist/index.d.ts",
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.mjs",
|
||||
"require": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsup src/index.ts --format cjs,esm --dts --out-dir dist",
|
||||
"dev": "tsup src/index.ts --format cjs,esm --dts --watch --out-dir dist",
|
||||
"clean": "rm -rf dist",
|
||||
"test": "vitest",
|
||||
"test:watch": "vitest --watch",
|
||||
"test:ci": "vitest --run",
|
||||
"lint": "eslint .",
|
||||
"prepare": "npm run build"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@types/node": "^25.4.0",
|
||||
"@vitest/coverage-v8": "^4.0.18",
|
||||
"eslint": "^10.0.3",
|
||||
"globals": "^17.4.0",
|
||||
"tsup": "^8.5.1",
|
||||
"typescript": "^5.9.3",
|
||||
"typescript-eslint": "^8.57.0",
|
||||
"vitest": "^4.0.18"
|
||||
}
|
||||
}
|
||||
57
src/address.ts
Normal file
57
src/address.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
export class Address {
|
||||
callsign: string;
|
||||
ssid: number;
|
||||
|
||||
constructor(callsign: string, ssid = 0) {
|
||||
if (typeof callsign !== 'string') throw new TypeError('callsign must be a string');
|
||||
const norm = callsign.toUpperCase().trim();
|
||||
if (norm.length === 0) throw new Error('callsign must not be empty');
|
||||
// callsign limited to 6 characters per AX.25 address field
|
||||
this.callsign = norm.slice(0, 6);
|
||||
|
||||
if (typeof ssid !== 'number' || !Number.isFinite(ssid) || !Number.isInteger(ssid)) {
|
||||
throw new TypeError('ssid must be an integer');
|
||||
}
|
||||
if (ssid < 0 || ssid > 15) throw new RangeError('ssid must be between 0 and 15');
|
||||
this.ssid = ssid & 0x0f;
|
||||
}
|
||||
|
||||
toString(opts?: { sep?: string; showSsid?: boolean }) {
|
||||
const sep = opts?.sep ?? '-';
|
||||
const show = opts?.showSsid ?? true;
|
||||
return show ? `${this.callsign}${sep}${this.ssid}` : this.callsign;
|
||||
}
|
||||
|
||||
static fromString(s: string): Address {
|
||||
if (typeof s !== 'string') throw new TypeError('argument must be a string');
|
||||
const parts = s.split('-');
|
||||
const call = (parts[0] || '').toUpperCase().trim();
|
||||
if (call.length === 0) throw new Error('callsign must not be empty');
|
||||
let ssid = 0;
|
||||
if (parts[1] !== undefined && parts[1] !== '') {
|
||||
// parse integer, reject non-integer
|
||||
if (!/^-?\d+$/.test(parts[1])) throw new Error('invalid SSID');
|
||||
ssid = Number(parts[1]);
|
||||
}
|
||||
return new Address(call, ssid);
|
||||
}
|
||||
|
||||
toBytes(last = false): Uint8Array {
|
||||
const buf = new Uint8Array(7);
|
||||
const cs = this.callsign.padEnd(6, ' ').slice(0, 6);
|
||||
for (let i = 0; i < 6; i++) buf[i] = cs.charCodeAt(i) << 1;
|
||||
// Per AX.25 spec the SSID byte contains reserved bits; commonly 0x60
|
||||
buf[6] = 0x60 | ((this.ssid & 0x0f) << 1);
|
||||
if (last) buf[6] |= 0x01; // mark as last address (HDLC extension bit)
|
||||
return buf;
|
||||
}
|
||||
|
||||
static fromBytes(buf: Uint8Array): Address {
|
||||
if (buf.length < 7) throw new Error('Address.fromBytes: buffer too short');
|
||||
const chars: number[] = [];
|
||||
for (let i = 0; i < 6; i++) chars.push(buf[i] >> 1);
|
||||
const callsign = String.fromCharCode(...chars).trim();
|
||||
const ssid = (buf[6] >> 1) & 0x0f;
|
||||
return new Address(callsign, ssid);
|
||||
}
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
19
src/frame.types.ts
Normal file
19
src/frame.types.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
export type Byte = number;
|
||||
|
||||
export interface IAddress {
|
||||
callsign: string;
|
||||
ssid: number;
|
||||
toString(opts?: { sep?: string; showSsid?: boolean }): string;
|
||||
toBytes(last?: boolean): Uint8Array;
|
||||
}
|
||||
|
||||
export interface IFrame {
|
||||
destination: IAddress;
|
||||
source: IAddress;
|
||||
control: number;
|
||||
pid?: number;
|
||||
info: Uint8Array;
|
||||
/** raw AX.25 payload (after HDLC unframing) */
|
||||
raw?: Uint8Array;
|
||||
toString(): string;
|
||||
}
|
||||
176
src/hdlc.ts
Normal file
176
src/hdlc.ts
Normal file
@@ -0,0 +1,176 @@
|
||||
/* HDLC framing helpers for AX.25 starter
|
||||
* - encodeHDLC(payload, { includeFcs }) -> Uint8Array
|
||||
* - HDLCDeframer: incremental push-based deframer producing unescaped frames
|
||||
* - framesFromAsyncIterable: async generator over framed chunks
|
||||
*/
|
||||
|
||||
import { Byte } from './frame.types';
|
||||
|
||||
const FLAG = 0x7e;
|
||||
const ESC = 0x7d;
|
||||
const ESC_XOR = 0x20;
|
||||
|
||||
export const escapeBuffer: (buf: Uint8Array) => Uint8Array = (buf) => {
|
||||
const out: number[] = [];
|
||||
for (const b of buf) {
|
||||
if (b === FLAG || b === ESC) {
|
||||
out.push(ESC, b ^ ESC_XOR);
|
||||
} else {
|
||||
out.push(b);
|
||||
}
|
||||
}
|
||||
return Uint8Array.from(out);
|
||||
};
|
||||
|
||||
export const unescapeBuffer: (buf: Uint8Array) => Uint8Array = (buf) => {
|
||||
const out: number[] = [];
|
||||
for (let i = 0; i < buf.length; i++) {
|
||||
const b = buf[i];
|
||||
if (b === ESC && i + 1 < buf.length) {
|
||||
out.push(buf[i + 1] ^ ESC_XOR);
|
||||
i++;
|
||||
} else {
|
||||
out.push(b);
|
||||
}
|
||||
}
|
||||
return Uint8Array.from(out);
|
||||
};
|
||||
|
||||
export const encodeHDLC: (payload: Uint8Array, options?: { includeFcs?: boolean }) => Uint8Array = (payload, options) => {
|
||||
// simple framing: FLAG (0x7E) + escaped payload (+ FCS if requested) + FLAG
|
||||
const includeFcs = options?.includeFcs ?? false;
|
||||
const bodySource = includeFcs ? appendFcsLE(payload) : payload;
|
||||
const body = escapeBuffer(bodySource);
|
||||
const out = new Uint8Array(1 + body.length + 1);
|
||||
out[0] = FLAG;
|
||||
out.set(body, 1);
|
||||
out[out.length - 1] = FLAG;
|
||||
return out;
|
||||
};
|
||||
|
||||
// CRC / FCS helpers (CRC-16-CCITT)
|
||||
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;
|
||||
for (let j = 0; j < 8; j++) {
|
||||
if ((crc & 0x8000) !== 0) crc = ((crc << 1) ^ 0x1021) & 0xffff;
|
||||
else crc = (crc << 1) & 0xffff;
|
||||
}
|
||||
}
|
||||
return crc & 0xffff;
|
||||
};
|
||||
|
||||
export const verifyAndStripFcs: (buf: Uint8Array) => { ok: boolean; payload?: Uint8Array; fcs?: number } = (buf) => {
|
||||
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);
|
||||
// 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);
|
||||
return crc ^ 0xffff;
|
||||
};
|
||||
|
||||
export const appendFcsLE: (buf: Uint8Array) => Uint8Array = (buf) => {
|
||||
const fcs = computeFcs(buf);
|
||||
const out = new Uint8Array(buf.length + 2);
|
||||
out.set(buf, 0);
|
||||
out[buf.length] = fcs & 0xff; // low byte first
|
||||
out[buf.length + 1] = (fcs >> 8) & 0xff;
|
||||
return out;
|
||||
};
|
||||
|
||||
export const bitStuffBits: (bits: string) => string = (bits) => {
|
||||
let out = '';
|
||||
let ones = 0;
|
||||
for (const b of bits) {
|
||||
out += b;
|
||||
if (b === '1') {
|
||||
ones++;
|
||||
if (ones === 5) {
|
||||
out += '0';
|
||||
ones = 0;
|
||||
}
|
||||
} else {
|
||||
ones = 0;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
export const bitUnstuffBits: (bits: string) => string = (bits) => {
|
||||
let out = '';
|
||||
let ones = 0;
|
||||
for (let i = 0; i < bits.length; i++) {
|
||||
const b = bits[i];
|
||||
out += b;
|
||||
if (b === '1') {
|
||||
ones++;
|
||||
if (ones === 5) {
|
||||
// next bit should be stuffed '0' - skip it
|
||||
i++;
|
||||
ones = 0;
|
||||
}
|
||||
} else {
|
||||
ones = 0;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
export class HDLCDeframer {
|
||||
private buf: number[] = [];
|
||||
private verifyFcs: boolean;
|
||||
|
||||
constructor(options?: { verifyFcs?: boolean }) {
|
||||
this.verifyFcs = options?.verifyFcs ?? true;
|
||||
}
|
||||
|
||||
push(chunk: Uint8Array): Uint8Array[] {
|
||||
// returns zero or more complete raw frame payloads (unescaped, FCS stripped if verifyFcs)
|
||||
const frames: Uint8Array[] = [];
|
||||
for (const b of chunk) this.buf.push(b);
|
||||
|
||||
let start = -1;
|
||||
for (let i = 0; i < this.buf.length; i++) {
|
||||
if (this.buf[i] === FLAG) {
|
||||
if (start === -1) {
|
||||
start = i;
|
||||
} else {
|
||||
// frame between start and i (exclusive)
|
||||
const body = this.buf.slice(start + 1, i);
|
||||
if (body.length > 0) {
|
||||
const unescaped = unescapeBuffer(Uint8Array.from(body));
|
||||
if (this.verifyFcs) {
|
||||
const res = verifyAndStripFcs(unescaped);
|
||||
if (res.ok && res.payload) frames.push(res.payload);
|
||||
} else {
|
||||
frames.push(unescaped);
|
||||
}
|
||||
}
|
||||
start = i; // new potential start
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// drop consumed prefix up to last start (keep trailing partial)
|
||||
if (start > 0) {
|
||||
this.buf = this.buf.slice(start);
|
||||
}
|
||||
|
||||
return frames;
|
||||
}
|
||||
}
|
||||
|
||||
export const framesFromAsyncIterable = async function* (source: AsyncIterable<Uint8Array>): AsyncGenerator<Uint8Array> {
|
||||
const def = new HDLCDeframer();
|
||||
for await (const chunk of source) {
|
||||
const frames = def.push(chunk);
|
||||
for (const f of frames) yield f;
|
||||
}
|
||||
};
|
||||
0
src/index.ts
Normal file
0
src/index.ts
Normal file
84
test/address.test.ts
Normal file
84
test/address.test.ts
Normal 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
99
test/frame.test.ts
Normal 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
93
test/hdlc.test.ts
Normal 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));
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user