7 Commits

Author SHA1 Message Date
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
16 changed files with 782 additions and 506 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;

View File

@@ -1,6 +1,7 @@
{ {
"name": "@hamradio/aprs", "name": "@hamradio/aprs",
"version": "1.1.1", "type": "module",
"version": "1.2.0",
"description": "APRS (Automatic Packet Reporting System) protocol support for Typescript", "description": "APRS (Automatic Packet Reporting System) protocol support for Typescript",
"keywords": [ "keywords": [
"APRS", "APRS",
@@ -40,9 +41,11 @@
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^10.0.1", "@eslint/js": "^10.0.1",
"@trivago/prettier-plugin-sort-imports": "^6.0.2",
"@vitest/coverage-v8": "^4.0.18", "@vitest/coverage-v8": "^4.0.18",
"eslint": "^10.0.3", "eslint": "^10.0.3",
"globals": "^17.4.0", "globals": "^17.4.0",
"prettier": "^3.8.1",
"tsup": "^8.5.1", "tsup": "^8.5.1",
"typescript": "^5.9.3", "typescript": "^5.9.3",
"typescript-eslint": "^8.57.0", "typescript-eslint": "^8.57.0",

File diff suppressed because it is too large Load Diff

View File

@@ -57,7 +57,7 @@ export enum DataType {
ThirdParty = "}", ThirdParty = "}",
// Invalid/Test Data // Invalid/Test Data
InvalidOrTest = ",", InvalidOrTest = ","
} }
export interface ISymbol { export interface ISymbol {
@@ -77,12 +77,39 @@ export interface IPosition {
course?: number; // Course in degrees course?: number; // Course in degrees
symbol?: ISymbol; symbol?: ISymbol;
comment?: string; comment?: string;
/**
* Optional reported radio range in miles (from RNG token in comment)
*/
range?: number;
/**
* Optional power/height/gain information from PHG token
* PHG format: PHGpphhgg (pp=power, hh=height, gg=gain) as numeric values
*/
phg?: IPowerHeightGain;
/** Direction-finding / DF information parsed from comment tokens */
dfs?: IDirectionFinding;
toString(): string; // Return combined position representation (e.g., "lat,lon,alt") toString(): string; // Return combined position representation (e.g., "lat,lon,alt")
toCompressed?(): CompressedPosition; // Optional method to convert to compressed format toCompressed?(): CompressedPosition; // Optional method to convert to compressed format
distanceTo?(other: IPosition): number; // Optional method to calculate distance to another position 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 { export interface ITimestamp {
day?: number; // Day of month (DHM format) day?: number; // Day of month (DHM format)
month?: number; // Month (MDHM format) month?: number; // Month (MDHM format)
@@ -197,12 +224,7 @@ export interface QueryPayload {
target?: string; // Target callsign or area target?: string; // Target callsign or area
} }
export type TelemetryVariant = export type TelemetryVariant = "data" | "parameters" | "unit" | "coefficients" | "bitsense";
| "data"
| "parameters"
| "unit"
| "coefficients"
| "bitsense";
// Telemetry Data Payload // Telemetry Data Payload
export interface TelemetryDataPayload { export interface TelemetryDataPayload {

View File

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

View File

@@ -15,9 +15,7 @@ export const base91ToNumber = (str: string): number => {
const digit = charCode - 33; // Base91 uses chars 33-123 (! to {) const digit = charCode - 33; // Base91 uses chars 33-123 (! to {)
if (digit < 0 || digit >= base) { if (digit < 0 || digit >= base) {
throw new Error( throw new Error(`Invalid Base91 character: '${str[i]}' (code ${charCode})`);
`Invalid Base91 character: '${str[i]}' (code ${charCode})`,
);
} }
value = value * base + digit; value = value * base + digit;
@@ -62,6 +60,15 @@ export const feetToMeters = (feet: number): number => {
return feet * FEET_TO_METERS; 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. * 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 { export class Symbol implements ISymbol {
table: string; // Symbol table identifier table: string; // Symbol table identifier
@@ -10,9 +10,7 @@ export class Symbol implements ISymbol {
this.code = table[1]; this.code = table[1];
this.table = table[0]; this.table = table[0];
} else { } else {
throw new Error( throw new Error(`Invalid symbol format: '${table}' (expected 2 characters if code is not provided)`);
`Invalid symbol format: '${table}' (expected 2 characters if code is not provided)`,
);
} }
} else { } else {
this.table = table; this.table = table;
@@ -34,6 +32,9 @@ export class Position implements IPosition {
course?: number; // Course in degrees course?: number; // Course in degrees
symbol?: Symbol; symbol?: Symbol;
comment?: string; comment?: string;
range?: number;
phg?: IPowerHeightGain;
dfs?: IDirectionFinding;
constructor(data: Partial<IPosition>) { constructor(data: Partial<IPosition>) {
this.latitude = data.latitude ?? 0; 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.symbol = new Symbol(data.symbol.table, data.symbol.code);
} }
this.comment = data.comment; this.comment = data.comment;
this.range = data.range;
this.phg = data.phg;
this.dfs = data.dfs;
} }
public toString(): string { public toString(): string {

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 { 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", () => { describe("Frame.decodeCapabilities", () => {
it("parses comma separated capabilities", () => { it("parses comma separated capabilities", () => {
@@ -9,7 +10,7 @@ describe("Frame.decodeCapabilities", () => {
const frame = Frame.fromString(data); const frame = Frame.fromString(data);
const decoded = frame.decode() as StationCapabilitiesPayload; const decoded = frame.decode() as StationCapabilitiesPayload;
expect(decoded).not.toBeNull(); expect(decoded).not.toBeNull();
expect(decoded.type).toBe("capabilities"); expect(decoded.type).toBe(DataType.StationCapabilities);
expect(Array.isArray(decoded.capabilities)).toBeTruthy(); expect(Array.isArray(decoded.capabilities)).toBeTruthy();
expect(decoded.capabilities).toContain("IGATE"); expect(decoded.capabilities).toContain("IGATE");
expect(decoded.capabilities).toContain("MSG_CNT"); expect(decoded.capabilities).toContain("MSG_CNT");
@@ -23,7 +24,7 @@ describe("Frame.decodeCapabilities", () => {
structure: Dissected; structure: Dissected;
}; };
expect(res.payload).not.toBeNull(); 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"); throw new Error("expected capabilities payload");
expect(res.structure).toBeDefined(); expect(res.structure).toBeDefined();
const caps = res.structure.find((s) => s.name === "capabilities"); const caps = res.structure.find((s) => s.name === "capabilities");

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

@@ -0,0 +1,97 @@
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";
describe("APRS extras test vectors (RNG / PHG / CSE/DDD / SPD / DFS)", () => {
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");
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");
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 === "CSE/SPD");
expect(hasCSE).toBe(true);
});
it("parses combined tokens: DDD/SSS PHG and DFS", () => {
const raw = "N0CALL>APRS,WIDE1-1:!4500.00N/07000.00W>090/045PHG5132/DFS2132";
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) => ["CSE/SPD", "PHG", "DFS"].includes(String(f.name)))).toBe(true);
});
});

View File

@@ -1,6 +1,7 @@
import { Dissected } from "@hamradio/packet";
import { expect } from "vitest"; import { expect } from "vitest";
import { describe, it } from "vitest"; import { describe, it } from "vitest";
import { Dissected } from "@hamradio/packet";
import { Frame } from "../src/frame"; import { Frame } from "../src/frame";
import { DataType, QueryPayload } from "../src/frame.types"; import { DataType, QueryPayload } from "../src/frame.types";

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

View File

@@ -1,14 +1,15 @@
import { describe, it } from "vitest"; import { describe, it } from "vitest";
import { expect } from "vitest";
import { Frame } from "../src/frame";
import { import {
DataType,
TelemetryBitSensePayload,
TelemetryCoefficientsPayload,
TelemetryDataPayload, TelemetryDataPayload,
TelemetryParameterPayload, TelemetryParameterPayload,
TelemetryUnitPayload, TelemetryUnitPayload
TelemetryCoefficientsPayload,
TelemetryBitSensePayload,
DataType,
} from "../src/frame.types"; } from "../src/frame.types";
import { Frame } from "../src/frame";
import { expect } from "vitest";
describe("Frame decode - Telemetry", () => { describe("Frame decode - Telemetry", () => {
it("decodes telemetry data payload", () => { it("decodes telemetry data payload", () => {

View File

@@ -1,16 +1,17 @@
import { Dissected, FieldType } from "@hamradio/packet";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { Address, Frame, Timestamp } from "../src/frame"; import { Address, Frame, Timestamp } from "../src/frame";
import { import {
type Payload, DataType,
type PositionPayload,
type ObjectPayload,
type StatusPayload,
type ITimestamp, type ITimestamp,
type MessagePayload, type MessagePayload,
DataType,
MicEPayload, MicEPayload,
type ObjectPayload,
type Payload,
type PositionPayload,
type StatusPayload
} from "../src/frame.types"; } from "../src/frame.types";
import { Dissected, FieldType } from "@hamradio/packet";
// Address parsing: split by method // Address parsing: split by method
describe("Address.parse", () => { describe("Address.parse", () => {
@@ -49,8 +50,7 @@ describe("Frame.constructor", () => {
// Frame properties / instance methods // Frame properties / instance methods
describe("Frame.getDataTypeIdentifier", () => { describe("Frame.getDataTypeIdentifier", () => {
it("returns @ for position identifier", () => { it("returns @ for position identifier", () => {
const data = const data = 'NOCALL-1>APRS,WIDE1-1:@092345z/:*E";qZ=OMRC/A=088132Hello World!';
'NOCALL-1>APRS,WIDE1-1:@092345z/:*E";qZ=OMRC/A=088132Hello World!';
const frame = Frame.fromString(data); const frame = Frame.fromString(data);
expect(frame.getDataTypeIdentifier()).toBe("@"); expect(frame.getDataTypeIdentifier()).toBe("@");
}); });
@@ -76,8 +76,7 @@ describe("Frame.getDataTypeIdentifier", () => {
describe("Frame.decode (basic)", () => { describe("Frame.decode (basic)", () => {
it("should call decode and return position payload", () => { it("should call decode and return position payload", () => {
const data = const data = 'NOCALL-1>APRS,WIDE1-1:@092345z/:*E";qZ=OMRC/A=088132Hello World!';
'NOCALL-1>APRS,WIDE1-1:@092345z/:*E";qZ=OMRC/A=088132Hello World!';
const frame = Frame.fromString(data); const frame = Frame.fromString(data);
const decoded = frame.decode() as PositionPayload; const decoded = frame.decode() as PositionPayload;
expect(decoded).not.toBeNull(); expect(decoded).not.toBeNull();
@@ -99,7 +98,7 @@ describe("Frame.decode (basic)", () => {
{ data: "CALL>APRS:$GPRMC,...", type: "$" }, { data: "CALL>APRS:$GPRMC,...", type: "$" },
{ data: "CALL>APRS:<IGATE,MSG_CNT", type: "<" }, { data: "CALL>APRS:<IGATE,MSG_CNT", type: "<" },
{ data: "CALL>APRS:{01", 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) { for (const testCase of testCases) {
const frame = Frame.fromString(testCase.data); const frame = Frame.fromString(testCase.data);
@@ -112,53 +111,70 @@ describe("Frame.decode (basic)", () => {
// Static functions // Static functions
describe("Frame.fromString", () => { describe("Frame.fromString", () => {
it("parses APRS position frame (test vector 1)", () => { it("parses APRS position frame (test vector 1)", () => {
const data = const data = 'NOCALL-1>APRS,WIDE1-1:@092345z/:*E";qZ=OMRC/A=088132Hello World!';
'NOCALL-1>APRS,WIDE1-1:@092345z/:*E";qZ=OMRC/A=088132Hello World!';
const result = Frame.fromString(data); const result = Frame.fromString(data);
expect(result.source).toEqual({ expect(result.source).toEqual({
call: "NOCALL", call: "NOCALL",
ssid: "1", ssid: "1",
isRepeated: false, isRepeated: false
}); });
expect(result.destination).toEqual({ expect(result.destination).toEqual({
call: "APRS", call: "APRS",
ssid: "", ssid: "",
isRepeated: false, isRepeated: false
}); });
expect(result.path).toHaveLength(1); expect(result.path).toHaveLength(1);
expect(result.path[0]).toEqual({ expect(result.path[0]).toEqual({
call: "WIDE1", call: "WIDE1",
ssid: "1", ssid: "1",
isRepeated: false, isRepeated: false
}); });
expect(result.payload).toBe('@092345z/:*E";qZ=OMRC/A=088132Hello World!'); 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)", () => { 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 data = "N83MZ>T2TQ5U,WA1PLE-4*:`c.l+@&'/'\"G:} KJ6TMS|!:&0'p|!w#f!|3";
const result = Frame.fromString(data); const result = Frame.fromString(data);
expect(result.source).toEqual({ expect(result.source).toEqual({
call: "N83MZ", call: "N83MZ",
ssid: "", ssid: "",
isRepeated: false, isRepeated: false
}); });
expect(result.destination).toEqual({ expect(result.destination).toEqual({
call: "T2TQ5U", call: "T2TQ5U",
ssid: "", ssid: "",
isRepeated: false, isRepeated: false
}); });
expect(result.path).toHaveLength(1); expect(result.path).toHaveLength(1);
expect(result.path[0]).toEqual({ expect(result.path[0]).toEqual({
call: "WA1PLE", call: "WA1PLE",
ssid: "4", ssid: "4",
isRepeated: true, isRepeated: true
}); });
expect(result.payload).toBe("`c.l+@&'/'\"G:} KJ6TMS|!:&0'p|!w#f!|3"); expect(result.payload).toBe("`c.l+@&'/'\"G:} KJ6TMS|!:&0'p|!w#f!|3");
}); });
it("parses frame with multiple path elements", () => { it("parses frame with multiple path elements", () => {
const data = const data = "KB1ABC-5>APRS,WIDE1-1,WIDE2-2*,IGATE:!4903.50N/07201.75W-Test";
"KB1ABC-5>APRS,WIDE1-1,WIDE2-2*,IGATE:!4903.50N/07201.75W-Test";
const result = Frame.fromString(data); const result = Frame.fromString(data);
expect(result.source.call).toBe("KB1ABC"); expect(result.source.call).toBe("KB1ABC");
expect(result.path).toHaveLength(3); expect(result.path).toHaveLength(3);
@@ -174,16 +190,12 @@ describe("Frame.fromString", () => {
it("throws for frame without route separator", () => { it("throws for frame without route separator", () => {
const data = "NOCALL-1>APRS"; const data = "NOCALL-1>APRS";
expect(() => Frame.fromString(data)).toThrow( expect(() => Frame.fromString(data)).toThrow("APRS: invalid frame, no route separator found");
"APRS: invalid frame, no route separator found",
);
}); });
it("throws for frame with invalid addresses", () => { it("throws for frame with invalid addresses", () => {
const data = "NOCALL:payload"; const data = "NOCALL:payload";
expect(() => Frame.fromString(data)).toThrow( expect(() => Frame.fromString(data)).toThrow("APRS: invalid addresses in route");
"APRS: invalid addresses in route",
);
}); });
}); });
@@ -257,9 +269,7 @@ describe("Frame.decodeMessage", () => {
const textSection = res.structure.find((s) => s.name === "text"); const textSection = res.structure.find((s) => s.name === "text");
expect(recipientSection).toBeDefined(); expect(recipientSection).toBeDefined();
expect(textSection).toBeDefined(); expect(textSection).toBeDefined();
expect(new TextDecoder().decode(recipientSection!.data).trim()).toBe( expect(new TextDecoder().decode(recipientSection!.data).trim()).toBe("KB1ABC");
"KB1ABC",
);
expect(new TextDecoder().decode(textSection!.data)).toBe("Test via spec"); expect(new TextDecoder().decode(textSection!.data)).toBe("Test via spec");
}); });
}); });
@@ -291,8 +301,7 @@ describe("Frame.decodeObject", () => {
describe("Frame.decodePosition", () => { describe("Frame.decodePosition", () => {
it("decodes position with timestamp and compressed format", () => { it("decodes position with timestamp and compressed format", () => {
const data = const data = 'NOCALL-1>APRS,WIDE1-1:@092345z/:*E";qZ=OMRC/A=088132Hello World!';
'NOCALL-1>APRS,WIDE1-1:@092345z/:*E";qZ=OMRC/A=088132Hello World!';
const frame = Frame.fromString(data); const frame = Frame.fromString(data);
const decoded = frame.decode() as PositionPayload; const decoded = frame.decode() as PositionPayload;
expect(decoded).not.toBeNull(); expect(decoded).not.toBeNull();
@@ -324,8 +333,7 @@ describe("Frame.decodeStatus", () => {
structure: Dissected; structure: Dissected;
}; };
expect(res.payload).not.toBeNull(); expect(res.payload).not.toBeNull();
if (res.payload?.type !== DataType.Status) if (res.payload?.type !== DataType.Status) throw new Error("expected status payload");
throw new Error("expected status payload");
const payload = res.payload as StatusPayload & { timestamp?: ITimestamp }; const payload = res.payload as StatusPayload & { timestamp?: ITimestamp };
expect(payload.text).toBe("Testing status"); expect(payload.text).toBe("Testing status");
expect(payload.maidenhead).toBe("FN20"); expect(payload.maidenhead).toBe("FN20");
@@ -423,7 +431,7 @@ describe("Timestamp.toDate", () => {
if (futureHours < 24) { if (futureHours < 24) {
const ts = new Timestamp(futureHours, 0, "HMS", { const ts = new Timestamp(futureHours, 0, "HMS", {
seconds: 0, seconds: 0,
zulu: true, zulu: true
}); });
const date = ts.toDate(); const date = ts.toDate();
@@ -440,7 +448,7 @@ describe("Timestamp.toDate", () => {
const ts = new Timestamp(12, 0, "MDHM", { const ts = new Timestamp(12, 0, "MDHM", {
month: futureMonth + 1, month: futureMonth + 1,
day: 1, day: 1,
zulu: false, zulu: false
}); });
const date = ts.toDate(); const date = ts.toDate();
@@ -455,8 +463,7 @@ describe("Timestamp.toDate", () => {
describe("Frame.decodeMicE", () => { describe("Frame.decodeMicE", () => {
describe("Basic Mic-E frames", () => { describe("Basic Mic-E frames", () => {
it("should decode a basic Mic-E packet (current format)", () => { it("should decode a basic Mic-E packet (current format)", () => {
const data = const data = "N83MZ>T2TQ5U,WA1PLE-4*:`c.l+@&'/'\"G:} KJ6TMS|!:&0'p|!w#f!|3";
"N83MZ>T2TQ5U,WA1PLE-4*:`c.l+@&'/'\"G:} KJ6TMS|!:&0'p|!w#f!|3";
const frame = Frame.fromString(data); const frame = Frame.fromString(data);
const decoded = frame.decode() as MicEPayload; const decoded = frame.decode() as MicEPayload;
@@ -795,16 +802,13 @@ describe("Frame.decodeMicE", () => {
expect(() => frame.decode()).not.toThrow(); expect(() => frame.decode()).not.toThrow();
const decoded = frame.decode() as MicEPayload; const decoded = frame.decode() as MicEPayload;
expect(decoded === null || decoded?.type === DataType.MicECurrent).toBe( expect(decoded === null || decoded?.type === DataType.MicECurrent).toBe(true);
true,
);
}); });
}); });
describe("Real-world test vectors", () => { describe("Real-world test vectors", () => {
it("should decode real Mic-E packet from test vector 2", () => { it("should decode real Mic-E packet from test vector 2", () => {
const data = const data = "N83MZ>T2TQ5U,WA1PLE-4*:`c.l+@&'/'\"G:} KJ6TMS|!:&0'p|!w#f!|3";
"N83MZ>T2TQ5U,WA1PLE-4*:`c.l+@&'/'\"G:} KJ6TMS|!:&0'p|!w#f!|3";
const frame = Frame.fromString(data); const frame = Frame.fromString(data);
const decoded = frame.decode() as MicEPayload; const decoded = frame.decode() as MicEPayload;
@@ -837,20 +841,16 @@ describe("Packet dissection with sections", () => {
expect(result.structure).toBeDefined(); expect(result.structure).toBeDefined();
expect(result.structure.length).toBeGreaterThan(0); 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).toBeDefined();
expect(routingSection?.fields).toBeDefined(); expect(routingSection?.fields).toBeDefined();
expect(routingSection?.fields?.length).toBeGreaterThan(0); expect(routingSection?.fields?.length).toBeGreaterThan(0);
const sourceField = routingSection?.fields?.find( const sourceField = routingSection?.fields?.find((a) => a.name === "source address");
(a) => a.name === "Source address",
);
expect(sourceField).toBeDefined(); expect(sourceField).toBeDefined();
expect(sourceField?.length).toBeGreaterThan(0); expect(sourceField?.length).toBeGreaterThan(0);
const destField = routingSection?.fields?.find( const destField = routingSection?.fields?.find((a) => a.name === "destination address");
(a) => a.name === "Destination address",
);
expect(destField).toBeDefined(); expect(destField).toBeDefined();
expect(destField?.length).toBeGreaterThan(0); expect(destField?.length).toBeGreaterThan(0);
}); });
@@ -869,9 +869,7 @@ describe("Packet dissection with sections", () => {
expect(result.structure).toBeDefined(); expect(result.structure).toBeDefined();
expect(result.structure?.length).toBeGreaterThan(0); expect(result.structure?.length).toBeGreaterThan(0);
const positionSection = result.structure?.find( const positionSection = result.structure?.find((s) => s.name === "position");
(s) => s.name === "position",
);
expect(positionSection).toBeDefined(); expect(positionSection).toBeDefined();
expect(positionSection?.data?.byteLength).toBe(19); expect(positionSection?.data?.byteLength).toBe(19);
expect(positionSection?.fields).toBeDefined(); expect(positionSection?.fields).toBeDefined();
@@ -897,20 +895,16 @@ describe("Packet dissection with sections", () => {
structure: Dissected; structure: Dissected;
}; };
expect(result.payload?.type).toBe( expect(result.payload?.type).toBe(DataType.PositionWithTimestampWithMessaging);
DataType.PositionWithTimestampWithMessaging,
);
const timestampSection = result.structure?.find( const timestampSection = result.structure?.find((s) => s.name === "timestamp");
(s) => s.name === "timestamp",
);
expect(timestampSection).toBeDefined(); expect(timestampSection).toBeDefined();
expect(timestampSection?.data?.byteLength).toBe(7); expect(timestampSection?.data?.byteLength).toBe(7);
expect(timestampSection?.fields?.map((a) => a.name)).toEqual([ expect(timestampSection?.fields?.map((a) => a.name)).toEqual([
"day (DD)", "day (DD)",
"hour (HH)", "hour (HH)",
"minute (MM)", "minute (MM)",
"timezone indicator", "timezone indicator"
]); ]);
}); });
@@ -922,13 +916,9 @@ describe("Packet dissection with sections", () => {
structure: Dissected; structure: Dissected;
}; };
expect(result.payload?.type).toBe( expect(result.payload?.type).toBe(DataType.PositionWithTimestampWithMessaging);
DataType.PositionWithTimestampWithMessaging,
);
const positionSection = result.structure?.find( const positionSection = result.structure?.find((s) => s.name === "position");
(s) => s.name === "position",
);
expect(positionSection).toBeDefined(); expect(positionSection).toBeDefined();
expect(positionSection?.data?.byteLength).toBe(13); expect(positionSection?.data?.byteLength).toBe(13);
@@ -979,9 +969,7 @@ describe("Frame.decodeMessage", () => {
const textSection = res.structure.find((s) => s.name === "text"); const textSection = res.structure.find((s) => s.name === "text");
expect(recipientSection).toBeDefined(); expect(recipientSection).toBeDefined();
expect(textSection).toBeDefined(); expect(textSection).toBeDefined();
expect(new TextDecoder().decode(recipientSection!.data).trim()).toBe( expect(new TextDecoder().decode(recipientSection!.data).trim()).toBe("KB1ABC");
"KB1ABC",
);
expect(new TextDecoder().decode(textSection!.data)).toBe("Test via spec"); expect(new TextDecoder().decode(textSection!.data)).toBe("Test via spec");
}); });
}); });
@@ -998,8 +986,7 @@ describe("Frame.decoding: object and status", () => {
expect(res).toHaveProperty("payload"); expect(res).toHaveProperty("payload");
expect(res.payload).not.toBeNull(); expect(res.payload).not.toBeNull();
if (res.payload?.type !== DataType.Object) if (res.payload?.type !== DataType.Object) throw new Error("expected object payload");
throw new Error("expected object payload");
const payload = res.payload as ObjectPayload & { timestamp?: ITimestamp }; const payload = res.payload as ObjectPayload & { timestamp?: ITimestamp };
@@ -1029,8 +1016,7 @@ describe("Frame.decoding: object and status", () => {
expect(res).toHaveProperty("payload"); expect(res).toHaveProperty("payload");
expect(res.payload).not.toBeNull(); expect(res.payload).not.toBeNull();
if (res.payload?.type !== DataType.Status) if (res.payload?.type !== DataType.Status) throw new Error("expected status payload");
throw new Error("expected status payload");
const payload = res.payload as StatusPayload & { timestamp?: ITimestamp }; const payload = res.payload as StatusPayload & { timestamp?: ITimestamp };

View File

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

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 { Frame } from "../src/frame";
import { DataType, WeatherPayload } from "../src/frame.types"; import { DataType, WeatherPayload } from "../src/frame.types";
import { Dissected } from "@hamradio/packet";
describe("Frame decode - Weather", () => { describe("Frame decode - Weather", () => {
it("parses weather with timestamp, wind, temp, rain, humidity and pressure", () => { it("parses weather with timestamp, wind, temp, rain, humidity and pressure", () => {

View File

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