13 Commits
v1.1.0 ... main

Author SHA1 Message Date
c28572e3b6 Version 1.3.0 2026-03-18 17:03:44 +01:00
17caa22331 Better parsing for extras; Added deviceID resolution 2026-03-18 17:01:46 +01:00
be8cd00c00 Export all interfaces 2026-03-18 13:12:38 +01:00
7dc15e360d Version 1.2.0 2026-03-18 13:11:40 +01:00
b1cd8449d9 Also parse the extras from the comment field 2026-03-18 13:11:16 +01:00
78dbd3b0ef Version 1.1.3 2026-03-18 10:07:06 +01:00
df266bab12 Correctly parse compressed position with no timestamp 2026-03-18 10:06:45 +01:00
0ab62dab02 Version 1.1.2 2026-03-16 13:16:18 +01:00
38b617728c Bug fixes in structure parsing 2026-03-16 13:16:06 +01:00
16f638301b Version 1.1.1 2026-03-16 07:41:46 +01:00
d0a100359d Repair missing datatypes 2026-03-16 07:41:32 +01:00
c300aefc0b Major change: switched to DataType enum 2026-03-15 22:57:19 +01:00
074806528f Added README 2026-03-15 21:38:09 +01:00
19 changed files with 2445 additions and 730 deletions

19
.prettierrc.ts Normal file
View File

@@ -0,0 +1,19 @@
import { type Config } from "prettier";
const config: Config = {
plugins: ["@trivago/prettier-plugin-sort-imports"],
trailingComma: "none",
printWidth: 120,
importOrder: [
"<BUILTIN_MODULES>",
"<THIRD_PARTY_MODULES>",
"(?:services|components|contexts|pages|libs|types)/(.*)$",
"^[./].*\\.(?:ts|tsx)$",
"\\.(?:scss|css)$",
"^[./]"
],
importOrderSeparation: true,
importOrderSortSpecifiers: true
};
export default config;

118
README.md
View File

@@ -0,0 +1,118 @@
# @hamradio/aprs
APRS (Automatic Packet Reporting System) utilities and parsers for TypeScript/JavaScript.
> For AX.25 frame parsing, see [@hamradio/ax25](https://www.npmjs.com/package/@hamradio/ax25).
This package provides lightweight parsing and helpers for APRS frames (APRS-IS style payloads). It exposes a small API for parsing frames, decoding payloads, working with APRS timestamps and addresses, and a few utility conversions.
## Install
Using npm:
```bash
npm install @hamradio/aprs
```
Or with yarn:
```bash
yarn add @hamradio/aprs
```
## Quick examples
Examples below show ESM / TypeScript usage. For CommonJS require() the same symbols are available from the package entrypoint.
### Import
```ts
import {
Frame,
Address,
Timestamp,
base91ToNumber,
knotsToKmh,
} from '@hamradio/aprs';
```
### Parse a raw APRS frame and decode payload
```ts
const raw = 'NOCALL-1>APRS,WIDE1-1:@092345z/:*E";qZ=OMRC/A=088132Hello World!';
// Parse into a Frame instance
const frame = Frame.fromString(raw);
// Inspect routing and payload
console.log(frame.source.toString()); // e.g. NOCALL-1
console.log(frame.destination.toString()); // APRS
console.log(frame.path.map(p => p.toString()));
// Decode payload (returns a structured payload object or null)
const payload = frame.decode();
console.log(payload?.type); // e.g. 'position' | 'message' | 'status' | ...
// Or ask for sections (dissection) along with decoded payload
const res = frame.decode(true) as { payload: any | null; structure: any };
console.log(res.payload, res.structure);
```
### Message decoding
```ts
const msg = 'W1AW>APRS::KB1ABC-5 :Hello World';
const f = Frame.fromString(msg);
const decoded = f.decode();
if (decoded && decoded.type === 'message') {
console.log(decoded.addressee); // KB1ABC-5
console.log(decoded.text); // Hello World
}
```
### Work with addresses and timestamps
```ts
const a = Address.parse('WA1PLE-4*');
console.log(a.call, a.ssid, a.isRepeated);
const ts = new Timestamp(12, 45, 'HMS', { seconds: 30, zulu: true });
console.log(ts.toDate()); // JavaScript Date representing the timestamp
```
### Utility conversions
```ts
console.log(base91ToNumber('!!!!')); // decode base91 values used in some APRS payloads
console.log(knotsToKmh(10)); // convert speed
```
## API summary
- `Frame` — parse frames with `Frame.fromString()` / `Frame.parse()` and decode payloads with `frame.decode()`.
- `Address` — helpers to parse and format APRS addresses: `Address.parse()` / `Address.fromString()`.
- `Timestamp` — APRS timestamp wrapper with `toDate()` conversion.
- Utility functions: `base91ToNumber`, `knotsToKmh`, `kmhToKnots`, `feetToMeters`, `metersToFeet`, `celsiusToFahrenheit`, `fahrenheitToCelsius`.
## Development
Run tests with:
```bash
npm install
npm test
```
Build the distribution with:
```bash
npm run build
```
## Contributing
See the project repository for contribution guidelines and tests.
---
Project: @hamradio/aprs — APRS parsing utilities for TypeScript

View File

@@ -1,6 +1,7 @@
{
"name": "@hamradio/aprs",
"version": "1.1.0",
"type": "module",
"version": "1.3.0",
"description": "APRS (Automatic Packet Reporting System) protocol support for Typescript",
"keywords": [
"APRS",
@@ -16,7 +17,7 @@
"license": "MIT",
"author": "Wijnand Modderman-Lenstra",
"main": "dist/index.js",
"module": "dist/index.mjs",
"module": "dist/index.js",
"types": "dist/index.d.ts",
"files": [
"dist"
@@ -24,7 +25,7 @@
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.mjs",
"import": "./dist/index.js",
"require": "./dist/index.js"
}
},
@@ -38,18 +39,20 @@
"lint": "eslint .",
"prepare": "npm run build"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@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"
},
"dependencies": {
"@hamradio/packet": "^1.1.0",
"extended-nmea": "^2.1.3"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@trivago/prettier-plugin-sort-imports": "^6.0.2",
"@vitest/coverage-v8": "^4.1.0",
"eslint": "^10.0.3",
"globals": "^17.4.0",
"prettier": "^3.8.1",
"tsup": "^8.5.1",
"typescript": "^5.9.3",
"typescript-eslint": "^8.57.1",
"vitest": "^4.1.0"
}
}

1088
src/deviceid.ts Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,12 @@
import { Dissected, Segment } from "@hamradio/packet";
// Any comment that contains this marker will set the doNotArchive flag on the
// decoded payload, which can be used by applications to skip archiving or
// logging frames that are meant to be transient or test data. This allows users
// to include the marker in their APRS comments when they want to indicate that
// a particular frame should not be stored long-term.
export const DO_NOT_ARCHIVE_MARKER = "!x!";
export interface IAddress {
call: string;
ssid: string;
@@ -14,54 +21,72 @@ export interface IFrame {
}
// APRS Data Type Identifiers (first character of payload)
export const DataTypeIdentifier = {
export enum DataType {
// Position Reports
PositionNoTimestampNoMessaging: "!",
PositionNoTimestampWithMessaging: "=",
PositionWithTimestampNoMessaging: "/",
PositionWithTimestampWithMessaging: "@",
PositionNoTimestampNoMessaging = "!",
PositionNoTimestampWithMessaging = "=",
PositionWithTimestampNoMessaging = "/",
PositionWithTimestampWithMessaging = "@",
// Mic-E
MicECurrent: "`",
MicEOld: "'",
MicE = "`",
MicEOld = "'",
// Messages and Bulletins
Message: ":",
Message = ":",
// Objects and Items
Object: ";",
Item: ")",
Object = ";",
Item = ")",
// Status
Status: ">",
Status = ">",
// Query
Query: "?",
Query = "?",
// Telemetry
TelemetryData: "T",
TelemetryData = "T",
// Weather
WeatherReportNoPosition: "_",
WeatherReportNoPosition = "_",
// Raw GPS Data
RawGPS: "$",
RawGPS = "$",
// Station Capabilities
StationCapabilities: "<",
StationCapabilities = "<",
// User-Defined
UserDefined: "{",
UserDefined = "{",
// Third-Party Traffic
ThirdParty: "}",
ThirdParty = "}",
// Invalid/Test Data
InvalidOrTest: ",",
} as const;
InvalidOrTest = ","
}
export type DataTypeIdentifier =
(typeof DataTypeIdentifier)[keyof typeof DataTypeIdentifier];
export const DataTypeNames: { [key in DataType]: string } = {
[DataType.PositionNoTimestampNoMessaging]: "position",
[DataType.PositionNoTimestampWithMessaging]: "position with messaging",
[DataType.PositionWithTimestampNoMessaging]: "position with timestamp",
[DataType.PositionWithTimestampWithMessaging]: "position with timestamp and messaging",
[DataType.MicE]: "Mic-E",
[DataType.MicEOld]: "Mic-E (old)",
[DataType.Message]: "message/bulletin",
[DataType.Object]: "object",
[DataType.Item]: "item",
[DataType.Status]: "status",
[DataType.Query]: "query",
[DataType.TelemetryData]: "telemetry data",
[DataType.WeatherReportNoPosition]: "weather report",
[DataType.RawGPS]: "raw GPS data",
[DataType.StationCapabilities]: "station capabilities",
[DataType.UserDefined]: "user defined",
[DataType.ThirdParty]: "third-party traffic",
[DataType.InvalidOrTest]: "invalid/test"
};
export interface ISymbol {
table: string; // Symbol table identifier
@@ -76,8 +101,11 @@ export interface IPosition {
longitude: number; // Decimal degrees
ambiguity?: number; // Position ambiguity (0-4)
altitude?: number; // Meters
speed?: number; // Speed in knots/kmh depending on source
speed?: number; // Speed in km/h
course?: number; // Course in degrees
range?: number; // Kilometers
phg?: IPowerHeightGain;
dfs?: IDirectionFinding;
symbol?: ISymbol;
comment?: string;
@@ -86,6 +114,22 @@ export interface IPosition {
distanceTo?(other: IPosition): number; // Optional method to calculate distance to another position
}
export interface IPowerHeightGain {
power?: number; // Transmit power in watts
height?: number; // Antenna height in meters
gain?: number; // Antenna gain in dBi
directivity?: number | "omni" | "unknown"; // Optional directivity pattern (numeric code or "omni")
}
export interface IDirectionFinding {
bearing?: number; // Direction finding bearing in degrees
strength?: number; // Relative signal strength (0-9)
height?: number; // Antenna height in meters
gain?: number; // Antenna gain in dBi
quality?: number; // Signal quality or other metric (0-9)
directivity?: number | "omni" | "unknown"; // Optional directivity pattern (numeric code or "omni")
}
export interface ITimestamp {
day?: number; // Day of month (DHM format)
month?: number; // Month (MDHM format)
@@ -99,7 +143,12 @@ export interface ITimestamp {
// Position Report Payload
export interface PositionPayload {
type: "position";
type:
| DataType.PositionNoTimestampNoMessaging
| DataType.PositionNoTimestampWithMessaging
| DataType.PositionWithTimestampNoMessaging
| DataType.PositionWithTimestampWithMessaging;
doNotArchive?: boolean; // Optional flag to indicate frame should not be archived
timestamp?: ITimestamp;
position: IPosition;
messaging: boolean; // Whether APRS messaging is enabled
@@ -128,19 +177,22 @@ export interface CompressedPosition {
// Mic-E Payload (compressed in destination address)
export interface MicEPayload {
type: "mic-e";
type: DataType.MicE | DataType.MicEOld;
doNotArchive?: boolean; // Optional flag to indicate frame should not be archived
position: IPosition;
course?: number;
speed?: number;
altitude?: number;
messageType?: string; // Standard Mic-E message
isStandard?: boolean; // Whether messageType is a standard Mic-E message
telemetry?: number[]; // Optional telemetry channels
status?: string;
}
export type MessageVariant = "message" | "bulletin";
// Message Payload
export interface MessagePayload {
type: "message";
type: DataType.Message;
variant: "message";
doNotArchive?: boolean; // Optional flag to indicate frame should not be archived
addressee: string; // 9 character padded callsign
text: string; // Message text
messageNumber?: string; // Message ID for acknowledgment
@@ -150,7 +202,9 @@ export interface MessagePayload {
// Bulletin/Announcement (variant of message)
export interface BulletinPayload {
type: "bulletin";
type: DataType.Message;
variant: "bulletin";
doNotArchive?: boolean; // Optional flag to indicate frame should not be archived
bulletinId: string; // Bulletin identifier (BLN#)
text: string;
group?: string; // Optional group bulletin
@@ -158,7 +212,8 @@ export interface BulletinPayload {
// Object Payload
export interface ObjectPayload {
type: "object";
type: DataType.Object;
doNotArchive?: boolean; // Optional flag to indicate frame should not be archived
name: string; // 9 character object name
timestamp: ITimestamp;
alive: boolean; // True if object is active, false if killed
@@ -169,7 +224,8 @@ export interface ObjectPayload {
// Item Payload
export interface ItemPayload {
type: "item";
type: DataType.Item;
doNotArchive?: boolean; // Optional flag to indicate frame should not be archived
name: string; // 3-9 character item name
alive: boolean; // True if item is active, false if killed
position: IPosition;
@@ -177,7 +233,8 @@ export interface ItemPayload {
// Status Payload
export interface StatusPayload {
type: "status";
type: DataType.Status;
doNotArchive?: boolean; // Optional flag to indicate frame should not be archived
timestamp?: ITimestamp;
text: string;
maidenhead?: string; // Optional Maidenhead grid locator
@@ -189,14 +246,17 @@ export interface StatusPayload {
// Query Payload
export interface QueryPayload {
type: "query";
type: DataType.Query;
queryType: string; // e.g., 'APRSD', 'APRST', 'PING'
target?: string; // Target callsign or area
}
export type TelemetryVariant = "data" | "parameters" | "unit" | "coefficients" | "bitsense";
// Telemetry Data Payload
export interface TelemetryDataPayload {
type: "telemetry-data";
type: DataType.TelemetryData;
variant: "data";
sequence: number;
analog: number[]; // Up to 5 analog channels
digital: number; // 8-bit digital value
@@ -204,19 +264,22 @@ export interface TelemetryDataPayload {
// Telemetry Parameter Names
export interface TelemetryParameterPayload {
type: "telemetry-parameters";
type: DataType.TelemetryData;
variant: "parameters";
names: string[]; // Parameter names
}
// Telemetry Unit/Label
export interface TelemetryUnitPayload {
type: "telemetry-units";
type: DataType.TelemetryData;
variant: "unit";
units: string[]; // Units for each parameter
}
// Telemetry Coefficients
export interface TelemetryCoefficientsPayload {
type: "telemetry-coefficients";
type: DataType.TelemetryData;
variant: "coefficients";
coefficients: {
a: number[]; // a coefficients
b: number[]; // b coefficients
@@ -226,14 +289,15 @@ export interface TelemetryCoefficientsPayload {
// Telemetry Bit Sense/Project Name
export interface TelemetryBitSensePayload {
type: "telemetry-bitsense";
type: DataType.TelemetryData;
variant: "bitsense";
sense: number; // 8-bit sense value
projectName?: string;
}
// Weather Report Payload
export interface WeatherPayload {
type: "weather";
type: DataType.WeatherReportNoPosition;
timestamp?: ITimestamp;
position?: IPosition;
windDirection?: number; // Degrees
@@ -255,34 +319,33 @@ export interface WeatherPayload {
// Raw GPS Payload (NMEA sentences)
export interface RawGPSPayload {
type: "raw-gps";
type: DataType.RawGPS;
sentence: string; // Raw NMEA sentence
position?: IPosition; // Optional parsed position if available
}
// Station Capabilities Payload
export interface StationCapabilitiesPayload {
type: "capabilities";
type: DataType.StationCapabilities;
capabilities: string[];
}
// User-Defined Payload
export interface UserDefinedPayload {
type: "user-defined";
type: DataType.UserDefined;
userPacketType: string;
data: string;
}
// Third-Party Traffic Payload
export interface ThirdPartyPayload {
type: "third-party";
type: DataType.ThirdParty;
frame?: IFrame; // Optional nested frame if payload contains another APRS frame
comment?: string; // Optional comment
}
// DF Report Payload
export interface DFReportPayload {
type: "df-report";
timestamp?: ITimestamp;
position: IPosition;
course?: number;
@@ -295,7 +358,8 @@ export interface DFReportPayload {
}
export interface BasePayload {
type: string;
type: DataType;
doNotArchive?: boolean; // Optional flag to indicate frame should not be archived
}
// Union type for all decoded payload types
@@ -319,7 +383,6 @@ export type Payload = BasePayload &
| StationCapabilitiesPayload
| UserDefinedPayload
| ThirdPartyPayload
| DFReportPayload
);
// Extended Frame with decoded payload

View File

@@ -1,11 +1,14 @@
export { Frame, Address, Timestamp } from "./frame";
export { type IAddress, type IFrame, DataTypeIdentifier } from "./frame.types";
export { type IAddress, type IFrame, DataType as DataTypeIdentifier } from "./frame.types";
export {
DataType,
type ISymbol,
type IPosition,
type ITimestamp,
type IPowerHeightGain,
type IDirectionFinding,
type PositionPayload,
type CompressedPosition,
type MicEPayload,
@@ -28,7 +31,7 @@ export {
type DFReportPayload,
type BasePayload,
type Payload,
type DecodedFrame,
type DecodedFrame
} from "./frame.types";
export {
@@ -38,5 +41,8 @@ export {
feetToMeters,
metersToFeet,
celsiusToFahrenheit,
fahrenheitToCelsius,
fahrenheitToCelsius
} from "./parser";
export { getDeviceID } from "./deviceid";
export type { DeviceID } from "./deviceid";

View File

@@ -15,9 +15,7 @@ export const base91ToNumber = (str: string): number => {
const digit = charCode - 33; // Base91 uses chars 33-123 (! to {)
if (digit < 0 || digit >= base) {
throw new Error(
`Invalid Base91 character: '${str[i]}' (code ${charCode})`,
);
throw new Error(`Invalid Base91 character: '${str[i]}' (code ${charCode})`);
}
value = value * base + digit;
@@ -62,6 +60,15 @@ export const feetToMeters = (feet: number): number => {
return feet * FEET_TO_METERS;
};
/**
* Convert miles to meters.
* @param miles number of miles
* @returns meters
*/
export const milesToMeters = (miles: number): number => {
return miles * 1609.344;
};
/**
* Convert altitude from meters to feet.
*

View File

@@ -1,4 +1,4 @@
import { IPosition, ISymbol } from "./frame.types";
import { IDirectionFinding, IPosition, IPowerHeightGain, ISymbol } from "./frame.types";
export class Symbol implements ISymbol {
table: string; // Symbol table identifier
@@ -10,9 +10,7 @@ export class Symbol implements ISymbol {
this.code = table[1];
this.table = table[0];
} else {
throw new Error(
`Invalid symbol format: '${table}' (expected 2 characters if code is not provided)`,
);
throw new Error(`Invalid symbol format: '${table}' (expected 2 characters if code is not provided)`);
}
} else {
this.table = table;
@@ -34,6 +32,9 @@ export class Position implements IPosition {
course?: number; // Course in degrees
symbol?: Symbol;
comment?: string;
range?: number;
phg?: IPowerHeightGain;
dfs?: IDirectionFinding;
constructor(data: Partial<IPosition>) {
this.latitude = data.latitude ?? 0;
@@ -48,6 +49,9 @@ export class Position implements IPosition {
this.symbol = new Symbol(data.symbol.table, data.symbol.code);
}
this.comment = data.comment;
this.range = data.range;
this.phg = data.phg;
this.dfs = data.dfs;
}
public toString(): string {

22
test/deviceid.test.ts Normal file
View File

@@ -0,0 +1,22 @@
import { describe, expect, it } from "vitest";
import { getDeviceID } from "../src/deviceid";
import { Frame } from "../src/frame";
describe("DeviceID parsing", () => {
it("parses known device ID from tocall", () => {
const data = "WB2OSZ-5>APDW17:!4237.14NS07120.83W#PHG7140";
const frame = Frame.fromString(data);
const deviceID = getDeviceID(frame.destination);
expect(deviceID).not.toBeNull();
expect(deviceID?.tocall).toBe("APDW??");
expect(deviceID?.vendor).toBe("WB2OSZ");
});
it("returns null for unknown device ID", () => {
const data = "CALL>WORLD:!4237.14NS07120.83W#PHG7140";
const frame = Frame.fromString(data);
const deviceID = getDeviceID(frame.destination);
expect(deviceID).toBeNull();
});
});

View File

@@ -1,7 +1,8 @@
import { describe, it, expect } from "vitest";
import { Frame } from "../src/frame";
import type { Payload, StationCapabilitiesPayload } from "../src/frame.types";
import { Dissected } from "@hamradio/packet";
import { describe, expect, it } from "vitest";
import { Frame } from "../src/frame";
import { DataType, type Payload, type StationCapabilitiesPayload } from "../src/frame.types";
describe("Frame.decodeCapabilities", () => {
it("parses comma separated capabilities", () => {
@@ -9,7 +10,7 @@ describe("Frame.decodeCapabilities", () => {
const frame = Frame.fromString(data);
const decoded = frame.decode() as StationCapabilitiesPayload;
expect(decoded).not.toBeNull();
expect(decoded.type).toBe("capabilities");
expect(decoded.type).toBe(DataType.StationCapabilities);
expect(Array.isArray(decoded.capabilities)).toBeTruthy();
expect(decoded.capabilities).toContain("IGATE");
expect(decoded.capabilities).toContain("MSG_CNT");
@@ -23,7 +24,7 @@ describe("Frame.decodeCapabilities", () => {
structure: Dissected;
};
expect(res.payload).not.toBeNull();
if (res.payload && res.payload.type !== "capabilities")
if (res.payload && res.payload.type !== DataType.StationCapabilities)
throw new Error("expected capabilities payload");
expect(res.structure).toBeDefined();
const caps = res.structure.find((s) => s.name === "capabilities");

116
test/frame.extras.test.ts Normal file
View File

@@ -0,0 +1,116 @@
import type { Dissected, Field, Segment } from "@hamradio/packet";
import { describe, expect, it } from "vitest";
import { Frame } from "../src/frame";
import type { PositionPayload } from "../src/frame.types";
import { feetToMeters, milesToMeters } from "../src/parser";
describe("APRS extras test vectors", () => {
it("parses PHG from position with messaging (spec vector 1)", () => {
const raw = "NOCALL>APZRAZ,qAS,PA2RDK-14:=5154.19N/00627.77E>PHG500073 de NOCALL";
const frame = Frame.fromString(raw);
const res = frame.decode(true) as { payload: PositionPayload | null; structure: Dissected };
const { payload } = res;
expect(payload).not.toBeNull();
expect(payload!.position.phg).toBeDefined();
// PHG500073 parsed per spec: p=5 -> 25 W, h='0' -> 10 ft, g='0' -> 0 dBi
expect(payload!.position.phg!.power).toBe(25);
expect(payload!.position.phg!.height).toBeCloseTo(3.048, 3);
expect(payload!.position.phg!.gain).toBe(0);
});
it("parses PHG token with hyphen separators (spec vector 2)", () => {
const raw = "NOCALL>APRS,TCPIP*,qAC,NINTH:;P-PA3RD *061000z5156.26NP00603.29E#PHG0210DAPNET";
const frame = Frame.fromString(raw);
const res = frame.decode(true) as { payload: PositionPayload | null; structure: Dissected };
const { payload, structure } = res;
expect(payload).not.toBeNull();
// Use a spec PHG example: PHG0210 -> p=0 -> power 0 W, h=2 -> 40 ft
expect(payload!.position.phg).toBeDefined();
expect(payload!.position.phg!.power).toBe(0);
expect(payload!.position.phg!.height).toBeCloseTo(12.192, 3);
const commentSeg = structure.find((s) => /comment/i.test(String(s.name))) as Segment | undefined;
expect(commentSeg).toBeDefined();
const fields = (commentSeg!.fields ?? []) as Field[];
const hasPHG = fields.some((f) => f.name === "PHG marker");
expect(hasPHG).toBe(true);
});
it("parses DFS token with long numeric strength", () => {
const raw = "N0CALL>APRS,WIDE1-1:!4500.00N/07000.00W#DFS2360/Your Comment";
const frame = Frame.fromString(raw);
const res = frame.decode(true) as { payload: PositionPayload | null; structure: Dissected };
const { payload, structure } = res;
expect(payload).not.toBeNull();
expect(payload!.position.dfs).toBeDefined();
// DFSshgd: strength is single-digit s value (here '2')
expect(payload!.position.dfs!.strength).toBe(2);
const commentSeg = structure.find((s) => /comment/i.test(String(s.name))) as Segment | undefined;
expect(commentSeg).toBeDefined();
const fieldsDFS = (commentSeg!.fields ?? []) as Field[];
const hasDFS = fieldsDFS.some((f) => f.name === "DFS marker");
expect(hasDFS).toBe(true);
});
it("parses course/speed in DDD/SSS form and altitude /A=", () => {
const raw = "N0CALL>APRS,WIDE1-1:!4500.00N/07000.00W>090/045/A=001234";
const frame = Frame.fromString(raw);
const res = frame.decode(true) as { payload: PositionPayload | null; structure: Dissected };
const { payload, structure } = res;
expect(payload).not.toBeNull();
expect(payload!.position.course).toBe(90);
// Speed is converted from knots to km/h
expect(payload!.position.speed).toBeCloseTo(45 * 1.852, 3);
// Altitude 001234 ft -> meters
expect(Math.round((payload!.position.altitude || 0) / 0.3048)).toBe(1234);
const commentSeg = structure.find((s) => /comment/i.test(String(s.name))) as Segment | undefined;
expect(commentSeg).toBeDefined();
const fieldsCSE = (commentSeg!.fields ?? []) as Field[];
const hasCSE = fieldsCSE.some((f) => f.name === "course");
expect(hasCSE).toBe(true);
});
it("parses combined tokens: DDD/SSS PHG and DFS", () => {
const raw = "N0CALL>APRS,WIDE1-1:!4500.00N/07000.00W>090/045PHG5132DFS2132";
const frame = Frame.fromString(raw);
const res = frame.decode(true) as { payload: PositionPayload | null; structure: Dissected };
const { payload, structure } = res;
expect(payload).not.toBeNull();
expect(payload!.position.course).toBe(90);
expect(payload!.position.speed).toBeCloseTo(45 * 1.852, 3);
expect(payload!.position.phg).toBeDefined();
expect(payload!.position.dfs).toBeDefined();
expect(payload!.position.dfs!.strength).toBe(2);
const commentSeg = structure.find((s) => /comment/i.test(String(s.name))) as Segment | undefined;
expect(commentSeg).toBeDefined();
const fieldsCombined = (commentSeg!.fields ?? []) as Field[];
expect(fieldsCombined.some((f) => ["course", "PHG marker", "DFS marker"].includes(String(f.name)))).toBe(true);
});
it("parses RNG token and emits structure", () => {
const raw =
"NOCALL-S>APDG01,TCPIP*,qAC,NOCALL-GS:;DN9PJF B *181227z5148.38ND00634.32EaRNG0001/A=000010 70cm Voice (D-Star) 439.50000MHz -7.6000MHz";
const frame = Frame.fromString(raw);
const res = frame.decode(true) as { payload: PositionPayload | null; structure: Dissected };
const { payload, structure } = res;
expect(payload).not.toBeNull();
expect(payload!.position.altitude).toBeCloseTo(feetToMeters(10), 3);
expect(payload!.position.range).toBe(milesToMeters(1) / 1000);
const commentSeg = structure.find((s) => /comment/i.test(String(s.name))) as Segment | undefined;
expect(commentSeg).toBeDefined();
const fieldsRNG = (commentSeg!.fields ?? []) as Field[];
const hasRNG = fieldsRNG.some((f) => f.name === "RNG marker");
expect(hasRNG).toBe(true);
});
});

View File

@@ -1,15 +1,16 @@
import { Dissected } from "@hamradio/packet";
import { expect } from "vitest";
import { describe, it } from "vitest";
import { Dissected } from "@hamradio/packet";
import { Frame } from "../src/frame";
import { QueryPayload } from "../src/frame.types";
import { DataType, QueryPayload } from "../src/frame.types";
describe("Frame decode - Query", () => {
it("decodes simple query without target", () => {
const frame = Frame.fromString("SRC>DEST:?APRS");
const payload = frame.decode() as QueryPayload;
expect(payload).not.toBeNull();
expect(payload.type).toBe("query");
expect(payload.type).toBe(DataType.Query);
expect(payload.queryType).toBe("APRS");
expect(payload.target).toBeUndefined();
});
@@ -18,7 +19,7 @@ describe("Frame decode - Query", () => {
const frame = Frame.fromString("SRC>DEST:?PING N0CALL");
const payload = frame.decode() as QueryPayload;
expect(payload).not.toBeNull();
expect(payload.type).toBe("query");
expect(payload.type).toBe(DataType.Query);
expect(payload.queryType).toBe("PING");
expect(payload.target).toBe("N0CALL");
});
@@ -30,7 +31,7 @@ describe("Frame decode - Query", () => {
structure: Dissected;
};
expect(result).toHaveProperty("payload");
expect(result.payload.type).toBe("query");
expect(result.payload.type).toBe(DataType.Query);
expect(Array.isArray(result.structure)).toBe(true);
const names = result.structure.map((s) => s.name);
expect(names).toContain("query type");

View File

@@ -1,19 +1,19 @@
import { describe, it, expect } from "vitest";
import { Frame } from "../src/frame";
import type { RawGPSPayload } from "../src/frame.types";
import { Dissected } from "@hamradio/packet";
import { describe, expect, it } from "vitest";
import { Frame } from "../src/frame";
import { DataType, type RawGPSPayload } from "../src/frame.types";
describe("Raw GPS decoding", () => {
it("decodes simple NMEA sentence as raw-gps payload", () => {
const sentence =
"GPRMC,123519,A,4807.038,N,01131.000,E,022.4,084.4,230394,003.1,W*6A";
const sentence = "GPRMC,123519,A,4807.038,N,01131.000,E,022.4,084.4,230394,003.1,W*6A";
const frameStr = `SRC>DEST:$${sentence}`;
const f = Frame.parse(frameStr);
const payload = f.decode(false) as RawGPSPayload | null;
expect(payload).not.toBeNull();
expect(payload?.type).toBe("raw-gps");
expect(payload?.type).toBe(DataType.RawGPS);
expect(payload?.sentence).toBe(sentence);
expect(payload?.position).toBeDefined();
expect(typeof payload?.position?.latitude).toBe("number");
@@ -21,8 +21,7 @@ describe("Raw GPS decoding", () => {
});
it("returns structure when requested", () => {
const sentence =
"GPGGA,092750.000,5321.6802,N,00630.3372,W,1,08,1.0,73.0,M,0.0,M,,*6A";
const sentence = "GPGGA,092750.000,5321.6802,N,00630.3372,W,1,08,1.0,73.0,M,0.0,M,,*6A";
const frameStr = `SRC>DEST:$${sentence}`;
const f = Frame.parse(frameStr);
@@ -32,7 +31,7 @@ describe("Raw GPS decoding", () => {
};
expect(result.payload).not.toBeNull();
expect(result.payload?.type).toBe("raw-gps");
expect(result.payload?.type).toBe(DataType.RawGPS);
expect(result.payload?.sentence).toBe(sentence);
expect(result.payload?.position).toBeDefined();
expect(typeof result.payload?.position?.latitude).toBe("number");
@@ -40,9 +39,7 @@ describe("Raw GPS decoding", () => {
expect(result.structure).toBeDefined();
const rawSection = result.structure.find((s) => s.name === "raw-gps");
expect(rawSection).toBeDefined();
const posSection = result.structure.find(
(s) => s.name === "raw-gps-position",
);
const posSection = result.structure.find((s) => s.name === "raw-gps-position");
expect(posSection).toBeDefined();
});
});

View File

@@ -1,20 +1,23 @@
import { describe, it } from "vitest";
import { expect } from "vitest";
import { Frame } from "../src/frame";
import {
DataType,
TelemetryBitSensePayload,
TelemetryCoefficientsPayload,
TelemetryDataPayload,
TelemetryParameterPayload,
TelemetryUnitPayload,
TelemetryCoefficientsPayload,
TelemetryBitSensePayload,
TelemetryUnitPayload
} from "../src/frame.types";
import { Frame } from "../src/frame";
import { expect } from "vitest";
describe("Frame decode - Telemetry", () => {
it("decodes telemetry data payload", () => {
const frame = Frame.fromString("SRC>DEST:T#1 10,20,30,40,50 7");
const payload = frame.decode() as TelemetryDataPayload;
expect(payload).not.toBeNull();
expect(payload.type).toBe("telemetry-data");
expect(payload.type).toBe(DataType.TelemetryData);
expect(payload.variant).toBe("data");
expect(payload.sequence).toBe(1);
expect(Array.isArray(payload.analog)).toBe(true);
expect(payload.analog.length).toBe(5);
@@ -25,7 +28,8 @@ describe("Frame decode - Telemetry", () => {
const frame = Frame.fromString("SRC>DEST:TPARAM Temp,Hum,Wind");
const payload = frame.decode() as TelemetryParameterPayload;
expect(payload).not.toBeNull();
expect(payload.type).toBe("telemetry-parameters");
expect(payload.type).toBe(DataType.TelemetryData);
expect(payload.variant).toBe("parameters");
expect(Array.isArray(payload.names)).toBe(true);
expect(payload.names).toEqual(["Temp", "Hum", "Wind"]);
});
@@ -34,7 +38,8 @@ describe("Frame decode - Telemetry", () => {
const frame = Frame.fromString("SRC>DEST:TUNIT C,% ,mph");
const payload = frame.decode() as TelemetryUnitPayload;
expect(payload).not.toBeNull();
expect(payload.type).toBe("telemetry-units");
expect(payload.type).toBe(DataType.TelemetryData);
expect(payload.variant).toBe("unit");
expect(payload.units).toEqual(["C", "%", "mph"]);
});
@@ -42,7 +47,8 @@ describe("Frame decode - Telemetry", () => {
const frame = Frame.fromString("SRC>DEST:TCOEFF A:1,2 B:3,4 C:5,6");
const payload = frame.decode() as TelemetryCoefficientsPayload;
expect(payload).not.toBeNull();
expect(payload.type).toBe("telemetry-coefficients");
expect(payload.type).toBe(DataType.TelemetryData);
expect(payload.variant).toBe("coefficients");
expect(payload.coefficients.a).toEqual([1, 2]);
expect(payload.coefficients.b).toEqual([3, 4]);
expect(payload.coefficients.c).toEqual([5, 6]);
@@ -52,7 +58,8 @@ describe("Frame decode - Telemetry", () => {
const frame = Frame.fromString("SRC>DEST:TBITS 255 ProjectX");
const payload = frame.decode() as TelemetryBitSensePayload;
expect(payload).not.toBeNull();
expect(payload.type).toBe("telemetry-bitsense");
expect(payload.type).toBe(DataType.TelemetryData);
expect(payload.variant).toBe("bitsense");
expect(payload.sense).toBe(255);
expect(payload.projectName).toBe("ProjectX");
});

View File

@@ -1,14 +1,17 @@
import { describe, expect, it } from "vitest";
import { Address, Frame, Timestamp } from "../src/frame";
import type {
Payload,
PositionPayload,
ObjectPayload,
StatusPayload,
ITimestamp,
MessagePayload,
} from "../src/frame.types";
import { Dissected, FieldType } from "@hamradio/packet";
import { describe, expect, it } from "vitest";
import { Address, Frame, Timestamp } from "../src/frame";
import {
DataType,
type ITimestamp,
type MessagePayload,
MicEPayload,
type ObjectPayload,
type Payload,
type PositionPayload,
type StatusPayload
} from "../src/frame.types";
// Address parsing: split by method
describe("Address.parse", () => {
@@ -47,8 +50,7 @@ describe("Frame.constructor", () => {
// Frame properties / instance methods
describe("Frame.getDataTypeIdentifier", () => {
it("returns @ for position identifier", () => {
const data =
'NOCALL-1>APRS,WIDE1-1:@092345z/:*E";qZ=OMRC/A=088132Hello World!';
const data = 'NOCALL-1>APRS,WIDE1-1:@092345z/:*E";qZ=OMRC/A=088132Hello World!';
const frame = Frame.fromString(data);
expect(frame.getDataTypeIdentifier()).toBe("@");
});
@@ -74,12 +76,11 @@ describe("Frame.getDataTypeIdentifier", () => {
describe("Frame.decode (basic)", () => {
it("should call decode and return position payload", () => {
const data =
'NOCALL-1>APRS,WIDE1-1:@092345z/:*E";qZ=OMRC/A=088132Hello World!';
const data = 'NOCALL-1>APRS,WIDE1-1:@092345z/:*E";qZ=OMRC/A=088132Hello World!';
const frame = Frame.fromString(data);
const decoded = frame.decode() as PositionPayload;
expect(decoded).not.toBeNull();
expect(decoded?.type).toBe("position");
expect(decoded?.type).toBe(DataType.PositionWithTimestampWithMessaging);
});
it("should handle various data type identifiers without throwing", () => {
@@ -97,7 +98,7 @@ describe("Frame.decode (basic)", () => {
{ data: "CALL>APRS:$GPRMC,...", type: "$" },
{ data: "CALL>APRS:<IGATE,MSG_CNT", type: "<" },
{ data: "CALL>APRS:{01", type: "{" },
{ data: "CALL>APRS:}W1AW>APRS:test", type: "}" },
{ data: "CALL>APRS:}W1AW>APRS:test", type: "}" }
];
for (const testCase of testCases) {
const frame = Frame.fromString(testCase.data);
@@ -110,53 +111,70 @@ describe("Frame.decode (basic)", () => {
// Static functions
describe("Frame.fromString", () => {
it("parses APRS position frame (test vector 1)", () => {
const data =
'NOCALL-1>APRS,WIDE1-1:@092345z/:*E";qZ=OMRC/A=088132Hello World!';
const data = 'NOCALL-1>APRS,WIDE1-1:@092345z/:*E";qZ=OMRC/A=088132Hello World!';
const result = Frame.fromString(data);
expect(result.source).toEqual({
call: "NOCALL",
ssid: "1",
isRepeated: false,
isRepeated: false
});
expect(result.destination).toEqual({
call: "APRS",
ssid: "",
isRepeated: false,
isRepeated: false
});
expect(result.path).toHaveLength(1);
expect(result.path[0]).toEqual({
call: "WIDE1",
ssid: "1",
isRepeated: false,
isRepeated: false
});
expect(result.payload).toBe('@092345z/:*E";qZ=OMRC/A=088132Hello World!');
});
it("parses APRS position frame without messaging (test vector 3)", () => {
const data = "N0CALL-7>APLRT1,qAO,DG2EAZ-10:!/4CkRP&-V>76Q";
const result = Frame.fromString(data);
expect(result.source).toEqual({
call: "N0CALL",
ssid: "7",
isRepeated: false
});
expect(result.destination).toEqual({
call: "APLRT1",
ssid: "",
isRepeated: false
});
expect(result.path).toHaveLength(2);
const payload = result.decode(false);
expect(payload).not.toBeNull();
});
it("parses APRS Mic-E frame with repeated digipeater (test vector 2)", () => {
const data = "N83MZ>T2TQ5U,WA1PLE-4*:`c.l+@&'/'\"G:} KJ6TMS|!:&0'p|!w#f!|3";
const result = Frame.fromString(data);
expect(result.source).toEqual({
call: "N83MZ",
ssid: "",
isRepeated: false,
isRepeated: false
});
expect(result.destination).toEqual({
call: "T2TQ5U",
ssid: "",
isRepeated: false,
isRepeated: false
});
expect(result.path).toHaveLength(1);
expect(result.path[0]).toEqual({
call: "WA1PLE",
ssid: "4",
isRepeated: true,
isRepeated: true
});
expect(result.payload).toBe("`c.l+@&'/'\"G:} KJ6TMS|!:&0'p|!w#f!|3");
});
it("parses frame with multiple path elements", () => {
const data =
"KB1ABC-5>APRS,WIDE1-1,WIDE2-2*,IGATE:!4903.50N/07201.75W-Test";
const data = "KB1ABC-5>APRS,WIDE1-1,WIDE2-2*,IGATE:!4903.50N/07201.75W-Test";
const result = Frame.fromString(data);
expect(result.source.call).toBe("KB1ABC");
expect(result.path).toHaveLength(3);
@@ -172,16 +190,12 @@ describe("Frame.fromString", () => {
it("throws for frame without route separator", () => {
const data = "NOCALL-1>APRS";
expect(() => Frame.fromString(data)).toThrow(
"APRS: invalid frame, no route separator found",
);
expect(() => Frame.fromString(data)).toThrow("APRS: invalid frame, no route separator found");
});
it("throws for frame with invalid addresses", () => {
const data = "NOCALL:payload";
expect(() => Frame.fromString(data)).toThrow(
"APRS: invalid addresses in route",
);
expect(() => Frame.fromString(data)).toThrow("APRS: invalid addresses in route");
});
});
@@ -217,17 +231,17 @@ describe("Frame.decodeMicE", () => {
it("decodes a basic Mic-E packet (current format)", () => {
const data = "N83MZ>T2TQ5U,WA1PLE-4*:`c.l+@&'/'\"G:} KJ6TMS|!:&0'p|!w#f!|3";
const frame = Frame.fromString(data);
const decoded = frame.decode() as Payload;
const decoded = frame.decode() as MicEPayload;
expect(decoded).not.toBeNull();
expect(decoded?.type).toBe("position");
expect(decoded?.type).toBe(DataType.MicE);
});
it("decodes a Mic-E packet with old format (single quote)", () => {
const data = "CALL>T2TQ5U:'c.l+@&'/'\"G:}";
const frame = Frame.fromString(data);
const decoded = frame.decode() as Payload;
const decoded = frame.decode() as MicEPayload;
expect(decoded).not.toBeNull();
expect(decoded?.type).toBe("position");
expect(decoded?.type).toBe(DataType.MicEOld);
});
});
@@ -237,7 +251,7 @@ describe("Frame.decodeMessage", () => {
const frame = Frame.fromString(raw);
const decoded = frame.decode() as MessagePayload;
expect(decoded).not.toBeNull();
expect(decoded?.type).toBe("message");
expect(decoded?.type).toBe(DataType.Message);
expect(decoded?.addressee).toBe("KB1ABC-5");
expect(decoded?.text).toBe("Hello World");
});
@@ -250,14 +264,12 @@ describe("Frame.decodeMessage", () => {
structure: Dissected;
};
expect(res.payload).not.toBeNull();
expect(res.payload?.type).toBe("message");
expect(res.payload?.type).toBe(DataType.Message);
const recipientSection = res.structure.find((s) => s.name === "recipient");
const textSection = res.structure.find((s) => s.name === "text");
expect(recipientSection).toBeDefined();
expect(textSection).toBeDefined();
expect(new TextDecoder().decode(recipientSection!.data).trim()).toBe(
"KB1ABC",
);
expect(new TextDecoder().decode(recipientSection!.data).trim()).toBe("KB1ABC");
expect(new TextDecoder().decode(textSection!.data)).toBe("Test via spec");
});
});
@@ -266,10 +278,10 @@ describe("Frame.decodeObject", () => {
it("decodes object payload with uncompressed position", () => {
const data = "CALL>APRS:;OBJECT *092345z4903.50N/07201.75W>Test object";
const frame = Frame.fromString(data);
const decoded = frame.decode() as Payload;
const decoded = frame.decode() as ObjectPayload;
expect(decoded).not.toBeNull();
expect(decoded?.type).toBe("object");
if (decoded && decoded.type === "object") {
expect(decoded?.type).toBe(DataType.Object);
if (decoded && decoded.type === DataType.Object) {
expect(decoded.name).toBe("OBJECT");
expect(decoded.alive).toBe(true);
}
@@ -289,12 +301,11 @@ describe("Frame.decodeObject", () => {
describe("Frame.decodePosition", () => {
it("decodes position with timestamp and compressed format", () => {
const data =
'NOCALL-1>APRS,WIDE1-1:@092345z/:*E";qZ=OMRC/A=088132Hello World!';
const data = 'NOCALL-1>APRS,WIDE1-1:@092345z/:*E";qZ=OMRC/A=088132Hello World!';
const frame = Frame.fromString(data);
const decoded = frame.decode() as PositionPayload;
expect(decoded).not.toBeNull();
expect(decoded?.type).toBe("position");
expect(decoded?.type).toBe(DataType.PositionWithTimestampWithMessaging);
});
it("decodes uncompressed position without timestamp", () => {
@@ -302,7 +313,7 @@ describe("Frame.decodePosition", () => {
const frame = Frame.fromString(data);
const decoded = frame.decode() as PositionPayload;
expect(decoded).not.toBeNull();
expect(decoded?.type).toBe("position");
expect(decoded?.type).toBe(DataType.PositionNoTimestampNoMessaging);
});
it("handles ambiguity masking in position", () => {
@@ -311,6 +322,16 @@ describe("Frame.decodePosition", () => {
const decoded = frame.decode() as PositionPayload;
expect(decoded).not.toBeNull();
});
it("should handle UTF-8 characters", () => {
const data =
"WB2OSZ-5>APDW17:!4237.14NS07120.83W#PHG7140 Did you know that APRS comments and messages can contain UTF-8 characters? アマチュア無線";
const frame = Frame.fromString(data);
const decoded = frame.decode() as PositionPayload;
expect(decoded).not.toBeNull();
expect(decoded?.type).toBe(DataType.PositionNoTimestampNoMessaging);
expect(decoded?.position.comment).toContain("UTF-8 characters? アマチュア無線");
});
});
describe("Frame.decodeStatus", () => {
@@ -322,8 +343,7 @@ describe("Frame.decodeStatus", () => {
structure: Dissected;
};
expect(res.payload).not.toBeNull();
if (res.payload?.type !== "status")
throw new Error("expected status payload");
if (res.payload?.type !== DataType.Status) throw new Error("expected status payload");
const payload = res.payload as StatusPayload & { timestamp?: ITimestamp };
expect(payload.text).toBe("Testing status");
expect(payload.maidenhead).toBe("FN20");
@@ -349,7 +369,7 @@ describe("Frame.decode (sections)", () => {
payload: PositionPayload | null;
structure: Dissected;
};
expect(result.payload?.type).toBe("position");
expect(result.payload?.type).toBe(DataType.PositionNoTimestampNoMessaging);
});
});
@@ -421,7 +441,7 @@ describe("Timestamp.toDate", () => {
if (futureHours < 24) {
const ts = new Timestamp(futureHours, 0, "HMS", {
seconds: 0,
zulu: true,
zulu: true
});
const date = ts.toDate();
@@ -438,7 +458,7 @@ describe("Timestamp.toDate", () => {
const ts = new Timestamp(12, 0, "MDHM", {
month: futureMonth + 1,
day: 1,
zulu: false,
zulu: false
});
const date = ts.toDate();
@@ -453,32 +473,29 @@ describe("Timestamp.toDate", () => {
describe("Frame.decodeMicE", () => {
describe("Basic Mic-E frames", () => {
it("should decode a basic Mic-E packet (current format)", () => {
const data =
"N83MZ>T2TQ5U,WA1PLE-4*:`c.l+@&'/'\"G:} KJ6TMS|!:&0'p|!w#f!|3";
const data = "N83MZ>T2TQ5U,WA1PLE-4*:`c.l+@&'/'\"G:} KJ6TMS|!:&0'p|!w#f!|3";
const frame = Frame.fromString(data);
const decoded = frame.decode() as Payload;
const decoded = frame.decode() as MicEPayload;
expect(decoded).not.toBeNull();
expect(decoded?.type).toBe("position");
expect(decoded?.type).toBe(DataType.MicE);
if (decoded && decoded.type === "position") {
expect(decoded.messaging).toBe(true);
if (decoded && decoded.type === DataType.MicE) {
expect(decoded.position).toBeDefined();
expect(typeof decoded.position.latitude).toBe("number");
expect(typeof decoded.position.longitude).toBe("number");
expect(decoded.position.symbol).toBeDefined();
expect(decoded.micE).toBeDefined();
expect(decoded.micE?.messageType).toBeDefined();
expect(decoded.messageType).toBeDefined();
}
});
it("should decode a Mic-E packet with old format (single quote)", () => {
const data = "CALL>T2TQ5U:'c.l+@&'/'\"G:}";
const frame = Frame.fromString(data);
const decoded = frame.decode() as Payload;
const decoded = frame.decode() as MicEPayload;
expect(decoded).not.toBeNull();
expect(decoded?.type).toBe("position");
expect(decoded?.type).toBe(DataType.MicEOld);
});
});
@@ -486,11 +503,11 @@ describe("Frame.decodeMicE", () => {
it("should decode latitude from numeric digits (0-9)", () => {
const data = "CALL>123456:`c.l+@&'/'\"G:}";
const frame = Frame.fromString(data);
const decoded = frame.decode() as Payload;
const decoded = frame.decode() as MicEPayload;
expect(decoded).not.toBeNull();
if (decoded && decoded.type === "position") {
if (decoded && decoded.type === DataType.MicE) {
expect(decoded.position.latitude).toBeCloseTo(12 + 34.56 / 60, 3);
}
});
@@ -498,11 +515,11 @@ describe("Frame.decodeMicE", () => {
it("should decode latitude from letter digits (A-J)", () => {
const data = "CALL>ABC0EF:`c.l+@&'/'\"G:}";
const frame = Frame.fromString(data);
const decoded = frame.decode() as Payload;
const decoded = frame.decode() as MicEPayload;
expect(decoded).not.toBeNull();
if (decoded && decoded.type === "position") {
if (decoded && decoded.type === DataType.MicE) {
expect(decoded.position.latitude).toBeCloseTo(1 + 20.45 / 60, 3);
}
});
@@ -510,11 +527,11 @@ describe("Frame.decodeMicE", () => {
it("should decode latitude with mixed digits and letters", () => {
const data = "CALL>4AB2DE:`c.l+@&'/'\"G:}";
const frame = Frame.fromString(data);
const decoded = frame.decode() as Payload;
const decoded = frame.decode() as MicEPayload;
expect(decoded).not.toBeNull();
if (decoded && decoded.type === "position") {
if (decoded && decoded.type === DataType.MicE) {
expect(decoded.position.latitude).toBeCloseTo(40 + 12.34 / 60, 3);
}
});
@@ -522,11 +539,11 @@ describe("Frame.decodeMicE", () => {
it("should decode latitude for southern hemisphere", () => {
const data = "CALL>4A0P0U:`c.l+@&'/'\"G:}";
const frame = Frame.fromString(data);
const decoded = frame.decode() as Payload;
const decoded = frame.decode() as MicEPayload;
expect(decoded).not.toBeNull();
if (decoded && decoded.type === "position") {
if (decoded && decoded.type === DataType.MicE) {
expect(decoded.position.latitude).toBeLessThan(0);
}
});
@@ -536,11 +553,11 @@ describe("Frame.decodeMicE", () => {
it("should decode longitude from information field", () => {
const data = "CALL>4ABCDE:`c.l+@&'/'\"G:}";
const frame = Frame.fromString(data);
const decoded = frame.decode() as Payload;
const decoded = frame.decode() as MicEPayload;
expect(decoded).not.toBeNull();
if (decoded && decoded.type === "position") {
if (decoded && decoded.type === DataType.MicE) {
expect(typeof decoded.position.longitude).toBe("number");
expect(decoded.position.longitude).toBeGreaterThanOrEqual(-180);
expect(decoded.position.longitude).toBeLessThanOrEqual(180);
@@ -550,11 +567,11 @@ describe("Frame.decodeMicE", () => {
it("should handle eastern hemisphere longitude", () => {
const data = "CALL>4ABPDE:`c.l+@&'/'\"G:}";
const frame = Frame.fromString(data);
const decoded = frame.decode() as Payload;
const decoded = frame.decode() as MicEPayload;
expect(decoded).not.toBeNull();
if (decoded && decoded.type === "position") {
if (decoded && decoded.type === DataType.MicE) {
expect(typeof decoded.position.longitude).toBe("number");
}
});
@@ -562,11 +579,11 @@ describe("Frame.decodeMicE", () => {
it("should handle longitude offset +100", () => {
const data = "CALL>4ABCDP:`c.l+@&'/'\"G:}";
const frame = Frame.fromString(data);
const decoded = frame.decode() as Payload;
const decoded = frame.decode() as MicEPayload;
expect(decoded).not.toBeNull();
if (decoded && decoded.type === "position") {
if (decoded && decoded.type === DataType.MicE) {
expect(typeof decoded.position.longitude).toBe("number");
expect(Math.abs(decoded.position.longitude)).toBeGreaterThan(90);
}
@@ -581,7 +598,7 @@ describe("Frame.decodeMicE", () => {
expect(decoded).not.toBeNull();
if (decoded && decoded.type === "position") {
if (decoded && decoded.type === DataType.MicE) {
if (decoded.position.speed !== undefined) {
expect(decoded.position.speed).toBeGreaterThanOrEqual(0);
expect(typeof decoded.position.speed).toBe("number");
@@ -596,7 +613,7 @@ describe("Frame.decodeMicE", () => {
expect(decoded).not.toBeNull();
if (decoded && decoded.type === "position") {
if (decoded && decoded.type === DataType.MicE) {
if (decoded.position.course !== undefined) {
expect(decoded.position.course).toBeGreaterThanOrEqual(0);
expect(decoded.position.course).toBeLessThan(360);
@@ -611,7 +628,7 @@ describe("Frame.decodeMicE", () => {
expect(decoded).not.toBeNull();
if (decoded && decoded.type === "position") {
if (decoded && decoded.type === DataType.MicE) {
expect(decoded.position.speed).toBeUndefined();
}
});
@@ -619,11 +636,11 @@ describe("Frame.decodeMicE", () => {
it("should not include zero or 360+ course in result", () => {
const data = "CALL>4ABCDE:`c.l\x1c\x1c\x1c/>}";
const frame = Frame.fromString(data);
const decoded = frame.decode() as Payload;
const decoded = frame.decode() as MicEPayload;
expect(decoded).not.toBeNull();
if (decoded && decoded.type === "position") {
if (decoded && decoded.type === DataType.MicE) {
if (decoded.position.course !== undefined) {
expect(decoded.position.course).toBeGreaterThan(0);
expect(decoded.position.course).toBeLessThan(360);
@@ -640,7 +657,7 @@ describe("Frame.decodeMicE", () => {
expect(decoded).not.toBeNull();
if (decoded && decoded.type === "position") {
if (decoded && decoded.type === DataType.MicE) {
expect(decoded.position.symbol).toBeDefined();
expect(decoded.position.symbol?.table).toBeDefined();
expect(decoded.position.symbol?.code).toBeDefined();
@@ -652,39 +669,40 @@ describe("Frame.decodeMicE", () => {
describe("Altitude decoding", () => {
it("should decode altitude from /A=NNNNNN format", () => {
const data = "CALL>4ABCDE:`c.l+@&'/'\"G:}/A=001234";
const data = "CALL>4ABCDE:`c.l+@&'//A=001234";
const frame = Frame.fromString(data);
const decoded = frame.decode() as Payload;
const decoded = frame.decode() as MicEPayload;
expect(decoded).not.toBeNull();
expect(decoded.type).toBe(DataType.MicE);
if (decoded && decoded.type === "position") {
expect(decoded.position).toBeDefined();
expect(decoded.position.altitude).toBeDefined();
expect(decoded.position.altitude).toBeCloseTo(1234 * 0.3048, 1);
}
});
it("should decode altitude from base-91 format }abc", () => {
const data = "CALL>4AB2DE:`c.l+@&'/'\"G:}}S^X";
it("should decode altitude from base-91 format abc}", () => {
const data = "N83MZ>T2TQ5U,WA1PLE-4*:`c.l+@&'/\"4T}KJ6TMS";
const frame = Frame.fromString(data);
const decoded = frame.decode() as Payload;
const decoded = frame.decode() as MicEPayload;
expect(decoded).not.toBeNull();
expect(decoded.type).toBe(DataType.MicE);
if (decoded && decoded.type === "position") {
if (decoded.position.comment?.startsWith("}")) {
expect(decoded.position).toBeDefined();
expect(decoded.position.altitude).toBeDefined();
}
}
expect(decoded.position.comment).toBe("KJ6TMS");
expect(decoded.position.altitude).toBeCloseTo(61, 1);
});
it("should prefer /A= format over base-91 when both present", () => {
const data = "CALL>4ABCDE:`c.l+@&'/'\"G:}}!!!/A=005000";
const frame = Frame.fromString(data);
const decoded = frame.decode() as Payload;
const decoded = frame.decode() as MicEPayload;
expect(decoded).not.toBeNull();
if (decoded && decoded.type === "position") {
if (decoded && decoded.type === DataType.MicE) {
expect(decoded.position.altitude).toBeCloseTo(5000 * 0.3048, 1);
}
});
@@ -692,11 +710,11 @@ describe("Frame.decodeMicE", () => {
it("should handle comment without altitude", () => {
const data = "CALL>4ABCDE:`c.l+@&'/'\"G:}Just a comment";
const frame = Frame.fromString(data);
const decoded = frame.decode() as Payload;
const decoded = frame.decode() as MicEPayload;
expect(decoded).not.toBeNull();
if (decoded && decoded.type === "position") {
if (decoded && decoded.type === DataType.MicE) {
expect(decoded.position.altitude).toBeUndefined();
expect(decoded.position.comment).toContain("Just a comment");
}
@@ -707,39 +725,34 @@ describe("Frame.decodeMicE", () => {
it("should decode message type M0 (Off Duty)", () => {
const data = "CALL>012345:`c.l+@&'/'\"G:}";
const frame = Frame.fromString(data);
const decoded = frame.decode() as Payload;
const decoded = frame.decode() as MicEPayload;
expect(decoded).not.toBeNull();
if (decoded && decoded.type === "position") {
expect(decoded.micE?.messageType).toBe("M0: Off Duty");
if (decoded && decoded.type === DataType.MicE) {
expect(decoded.messageType).toBe("M0: Off Duty");
}
});
it("should decode message type M7 (Emergency)", () => {
const data = "CALL>ABCDEF:`c.l+@&'/'\"G:}";
const frame = Frame.fromString(data);
const decoded = frame.decode() as Payload;
const decoded = frame.decode() as MicEPayload;
expect(decoded).not.toBeNull();
if (decoded && decoded.type === "position") {
expect(decoded.micE?.messageType).toBeDefined();
expect(typeof decoded.micE?.messageType).toBe("string");
if (decoded && decoded.type === DataType.MicE) {
expect(decoded.messageType).toBeDefined();
expect(typeof decoded.messageType).toBe("string");
}
});
it("should decode standard vs custom message indicator", () => {
const data = "CALL>ABCDEF:`c.l+@&'/'\"G:}";
const frame = Frame.fromString(data);
const decoded = frame.decode() as Payload;
const decoded = frame.decode() as MicEPayload;
expect(decoded).not.toBeNull();
if (decoded && decoded.type === "position") {
expect(decoded.micE?.isStandard).toBeDefined();
expect(typeof decoded.micE?.isStandard).toBe("boolean");
}
});
});
@@ -751,7 +764,7 @@ describe("Frame.decodeMicE", () => {
expect(decoded).not.toBeNull();
if (decoded && decoded.type === "position") {
if (decoded && decoded.type === DataType.MicE) {
expect(decoded.position.comment).toContain("This is a test comment");
}
});
@@ -763,7 +776,7 @@ describe("Frame.decodeMicE", () => {
expect(decoded).not.toBeNull();
if (decoded && decoded.type === "position") {
if (decoded && decoded.type === DataType.MicE) {
expect(decoded.position.comment).toBeDefined();
}
});
@@ -799,47 +812,30 @@ describe("Frame.decodeMicE", () => {
const frame = Frame.fromString(data);
expect(() => frame.decode()).not.toThrow();
const decoded = frame.decode() as Payload;
expect(decoded === null || decoded?.type === "position").toBe(true);
const decoded = frame.decode() as MicEPayload;
expect(decoded === null || decoded?.type === DataType.MicE).toBe(true);
});
});
describe("Real-world test vectors", () => {
it("should decode real Mic-E packet from test vector 2", () => {
const data =
"N83MZ>T2TQ5U,WA1PLE-4*:`c.l+@&'/'\"G:} KJ6TMS|!:&0'p|!w#f!|3";
const data = "N83MZ>T2TQ5U,WA1PLE-4*:`c.l+@&'/'\"G:} KJ6TMS|!:&0'p|!w#f!|3";
const frame = Frame.fromString(data);
const decoded = frame.decode() as Payload;
const decoded = frame.decode() as MicEPayload;
expect(decoded).not.toBeNull();
expect(decoded?.type).toBe("position");
expect(decoded?.type).toBe(DataType.MicE);
if (decoded && decoded.type === "position") {
expect(decoded.messaging).toBe(true);
if (decoded && decoded.type === DataType.MicE) {
expect(decoded.position.latitude).toBeDefined();
expect(decoded.position.longitude).toBeDefined();
expect(decoded.position.symbol).toBeDefined();
expect(decoded.micE).toBeDefined();
expect(Math.abs(decoded.position.latitude)).toBeLessThanOrEqual(90);
expect(Math.abs(decoded.position.longitude)).toBeLessThanOrEqual(180);
}
});
});
describe("Messaging capability", () => {
it("should always set messaging to true for Mic-E", () => {
const data = "CALL>4ABCDE:`c.l+@&'/'\"G:}";
const frame = Frame.fromString(data);
const decoded = frame.decode() as Payload;
expect(decoded).not.toBeNull();
if (decoded && decoded.type === "position") {
expect(decoded.messaging).toBe(true);
}
});
});
});
describe("Packet dissection with sections", () => {
@@ -856,20 +852,16 @@ describe("Packet dissection with sections", () => {
expect(result.structure).toBeDefined();
expect(result.structure.length).toBeGreaterThan(0);
const routingSection = result.structure.find((s) => s.name === "Routing");
const routingSection = result.structure.find((s) => s.name === "routing");
expect(routingSection).toBeDefined();
expect(routingSection?.fields).toBeDefined();
expect(routingSection?.fields?.length).toBeGreaterThan(0);
const sourceField = routingSection?.fields?.find(
(a) => a.name === "Source address",
);
const sourceField = routingSection?.fields?.find((a) => a.name === "source address");
expect(sourceField).toBeDefined();
expect(sourceField?.length).toBeGreaterThan(0);
const destField = routingSection?.fields?.find(
(a) => a.name === "Destination address",
);
const destField = routingSection?.fields?.find((a) => a.name === "destination address");
expect(destField).toBeDefined();
expect(destField?.length).toBeGreaterThan(0);
});
@@ -878,19 +870,17 @@ describe("Packet dissection with sections", () => {
const data = "CALL>APRS:!4903.50N/07201.75W-Test message";
const frame = Frame.fromString(data);
const result = frame.decode(true) as {
payload: Payload;
payload: PositionPayload;
structure: Dissected;
};
expect(result.payload).not.toBeNull();
expect(result.payload?.type).toBe("position");
expect(result.payload?.type).toBe(DataType.PositionNoTimestampNoMessaging);
expect(result.structure).toBeDefined();
expect(result.structure?.length).toBeGreaterThan(0);
const positionSection = result.structure?.find(
(s) => s.name === "position",
);
const positionSection = result.structure?.find((s) => s.name === "position");
expect(positionSection).toBeDefined();
expect(positionSection?.data?.byteLength).toBe(19);
expect(positionSection?.fields).toBeDefined();
@@ -900,10 +890,10 @@ describe("Packet dissection with sections", () => {
it("should not emit sections when emitSections is false or omitted", () => {
const data = "CALL>APRS:!4903.50N/07201.75W-Test";
const frame = Frame.fromString(data);
const result = frame.decode() as Payload;
const result = frame.decode() as PositionPayload;
expect(result).not.toBeNull();
expect(result?.type).toBe("position");
expect(result?.type).toBe(DataType.PositionNoTimestampNoMessaging);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
expect((result as any).sections).toBeUndefined();
});
@@ -912,22 +902,20 @@ describe("Packet dissection with sections", () => {
const data = "CALL>APRS:@092345z4903.50N/07201.75W>";
const frame = Frame.fromString(data);
const result = frame.decode(true) as {
payload: Payload;
payload: PositionPayload;
structure: Dissected;
};
expect(result.payload?.type).toBe("position");
expect(result.payload?.type).toBe(DataType.PositionWithTimestampWithMessaging);
const timestampSection = result.structure?.find(
(s) => s.name === "timestamp",
);
const timestampSection = result.structure?.find((s) => s.name === "timestamp");
expect(timestampSection).toBeDefined();
expect(timestampSection?.data?.byteLength).toBe(7);
expect(timestampSection?.fields?.map((a) => a.name)).toEqual([
"day (DD)",
"hour (HH)",
"minute (MM)",
"timezone indicator",
"timezone indicator"
]);
});
@@ -935,15 +923,13 @@ describe("Packet dissection with sections", () => {
const data = 'NOCALL-1>APRS:@092345z/:*E";qZ=OMRC/A=088132Hello World!';
const frame = Frame.fromString(data);
const result = frame.decode(true) as {
payload: Payload;
payload: PositionPayload;
structure: Dissected;
};
expect(result.payload?.type).toBe("position");
expect(result.payload?.type).toBe(DataType.PositionWithTimestampWithMessaging);
const positionSection = result.structure?.find(
(s) => s.name === "position",
);
const positionSection = result.structure?.find((s) => s.name === "position");
expect(positionSection).toBeDefined();
expect(positionSection?.data?.byteLength).toBe(13);
@@ -956,11 +942,11 @@ describe("Packet dissection with sections", () => {
const data = "CALL>APRS:!4903.50N/07201.75W-Test message";
const frame = Frame.fromString(data);
const result = frame.decode(true) as {
payload: Payload;
payload: PositionPayload;
structure: Dissected;
};
expect(result.payload?.type).toBe("position");
expect(result.payload?.type).toBe(DataType.PositionNoTimestampNoMessaging);
const commentSection = result.structure?.find((s) => s.name === "comment");
expect(commentSection).toBeDefined();
expect(commentSection?.data?.byteLength).toBe("Test message".length);
@@ -975,7 +961,7 @@ describe("Frame.decodeMessage", () => {
const decoded = frame.decode() as MessagePayload;
expect(decoded).not.toBeNull();
expect(decoded?.type).toBe("message");
expect(decoded?.type).toBe(DataType.Message);
expect(decoded?.addressee).toBe("KB1ABC-5");
expect(decoded?.text).toBe("Hello World");
});
@@ -989,14 +975,12 @@ describe("Frame.decodeMessage", () => {
};
expect(res.payload).not.toBeNull();
expect(res.payload.type).toBe("message");
expect(res.payload.type).toBe(DataType.Message);
const recipientSection = res.structure.find((s) => s.name === "recipient");
const textSection = res.structure.find((s) => s.name === "text");
expect(recipientSection).toBeDefined();
expect(textSection).toBeDefined();
expect(new TextDecoder().decode(recipientSection!.data).trim()).toBe(
"KB1ABC",
);
expect(new TextDecoder().decode(recipientSection!.data).trim()).toBe("KB1ABC");
expect(new TextDecoder().decode(textSection!.data)).toBe("Test via spec");
});
});
@@ -1013,8 +997,7 @@ describe("Frame.decoding: object and status", () => {
expect(res).toHaveProperty("payload");
expect(res.payload).not.toBeNull();
if (res.payload?.type !== "object")
throw new Error("expected object payload");
if (res.payload?.type !== DataType.Object) throw new Error("expected object payload");
const payload = res.payload as ObjectPayload & { timestamp?: ITimestamp };
@@ -1044,12 +1027,11 @@ describe("Frame.decoding: object and status", () => {
expect(res).toHaveProperty("payload");
expect(res.payload).not.toBeNull();
if (res.payload?.type !== "status")
throw new Error("expected status payload");
if (res.payload?.type !== DataType.Status) throw new Error("expected status payload");
const payload = res.payload as StatusPayload & { timestamp?: ITimestamp };
expect(payload.type).toBe("status");
expect(payload.type).toBe(DataType.Status);
expect(payload.timestamp).toBeDefined();
expect(payload.timestamp?.day).toBe(12);

View File

@@ -1,7 +1,8 @@
import { describe, it, expect } from "vitest";
import { Dissected } from "@hamradio/packet";
import { describe, expect, it } from "vitest";
import { Frame } from "../src/frame";
import type { UserDefinedPayload } from "../src/frame.types";
import { DataType, type UserDefinedPayload } from "../src/frame.types";
describe("Frame.decodeUserDefined", () => {
it("parses packet type only", () => {
@@ -9,7 +10,7 @@ describe("Frame.decodeUserDefined", () => {
const frame = Frame.fromString(data);
const decoded = frame.decode() as UserDefinedPayload;
expect(decoded).not.toBeNull();
expect(decoded.type).toBe("user-defined");
expect(decoded.type).toBe(DataType.UserDefined);
expect(decoded.userPacketType).toBe("01");
expect(decoded.data).toBe("");
});
@@ -22,14 +23,12 @@ describe("Frame.decodeUserDefined", () => {
structure: Dissected;
};
expect(res.payload).not.toBeNull();
expect(res.payload.type).toBe("user-defined");
expect(res.payload.type).toBe(DataType.UserDefined);
expect(res.payload.userPacketType).toBe("TEX");
expect(res.payload.data).toBe("Hello world");
const raw = res.structure.find((s) => s.name === "user-defined");
const typeSection = res.structure.find(
(s) => s.name === "user-packet-type",
);
const typeSection = res.structure.find((s) => s.name === "user-packet-type");
const dataSection = res.structure.find((s) => s.name === "user-data");
expect(raw).toBeDefined();
expect(typeSection).toBeDefined();

View File

@@ -1,7 +1,8 @@
import { describe, it, expect } from "vitest";
import { Frame } from "../src/frame";
import { WeatherPayload } from "../src/frame.types";
import { Dissected } from "@hamradio/packet";
import { describe, expect, it } from "vitest";
import { Frame } from "../src/frame";
import { DataType, WeatherPayload } from "../src/frame.types";
describe("Frame decode - Weather", () => {
it("parses weather with timestamp, wind, temp, rain, humidity and pressure", () => {
@@ -9,7 +10,7 @@ describe("Frame decode - Weather", () => {
const frame = Frame.fromString(data);
const payload = frame.decode() as WeatherPayload;
expect(payload).not.toBeNull();
expect(payload.type).toBe("weather");
expect(payload.type).toBe(DataType.WeatherReportNoPosition);
expect(payload.timestamp).toBeDefined();
expect(payload.windDirection).toBe(180);
expect(payload.windSpeed).toBe(10);

View File

@@ -1,12 +1,13 @@
import { describe, it, expect } from "vitest";
import { describe, expect, it } from "vitest";
import {
base91ToNumber,
knotsToKmh,
kmhToKnots,
feetToMeters,
metersToFeet,
celsiusToFahrenheit,
fahrenheitToCelsius,
feetToMeters,
kmhToKnots,
knotsToKmh,
metersToFeet
} from "../src/parser";
describe("parser utilities", () => {