300 lines
13 KiB
TypeScript
300 lines
13 KiB
TypeScript
import type { Dissected, Field, Segment } from "@hamradio/packet";
|
|
import { describe, expect, it } from "vitest";
|
|
|
|
import { Frame } from "../src/frame";
|
|
import { DataType, type ObjectPayload, type PositionPayload } from "../src/frame.types";
|
|
import { base91ToNumber, feetToMeters, knotsToKmh, milesToMeters } from "../src/parser";
|
|
import { decodeDAO, decodeTelemetry } from "../src/payload.extras";
|
|
|
|
describe("APRS extras test vectors", () => {
|
|
it("parses altitude token in the beginning of a comment and emits structure", () => {
|
|
const raw =
|
|
"DL3QP-R>APDG03,TCPIP*,qAC,T2ROMANIA:!5151.12ND00637.65E&/A=000000440 MMDVM Voice 439.40000MHz -7.6000MHz, DL3QP_Pi-Star";
|
|
const frame = Frame.fromString(raw);
|
|
const res = frame.decode(true) as { payload: PositionPayload | null; structure: Dissected };
|
|
const { payload, structure } = res;
|
|
|
|
expect(payload).not.toBeNull();
|
|
// Altitude 001234 ft -> meters
|
|
expect(payload!.position.altitude).toBe(0);
|
|
|
|
const commentSeg = structure.find((s) => /comment/i.test(String(s.name))) as Segment | undefined;
|
|
expect(commentSeg).toBeDefined();
|
|
const fieldsAlt = (commentSeg!.fields ?? []) as Field[];
|
|
const hasAlt = fieldsAlt.some((f) => f.name === "altitude");
|
|
expect(hasAlt).toBe(true);
|
|
|
|
expect(payload!.position.comment).toBe("440 MMDVM Voice 439.40000MHz -7.6000MHz, DL3QP_Pi-Star");
|
|
});
|
|
|
|
it("parses altitude token marker mid-comment and emits structure", () => {
|
|
const raw = "N0CALL>APRS,WIDE1-1:!4500.00N/07000.00W#RNG0001ALT/A=001234 Your Comment Here";
|
|
const frame = Frame.fromString(raw);
|
|
const res = frame.decode(true) as { payload: PositionPayload | null; structure: Dissected };
|
|
const { payload, structure } = res;
|
|
|
|
// console.log(structure[structure.length - 1]); // Log the last segment for debugging
|
|
|
|
expect(payload).not.toBeNull();
|
|
// 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 fieldsAlt = (commentSeg!.fields ?? []) as Field[];
|
|
const hasAlt = fieldsAlt.some((f) => f.name === "altitude");
|
|
expect(hasAlt).toBe(true);
|
|
|
|
const commentIndex = (commentSeg!.fields ?? []).findIndex((f) => f.name === "comment");
|
|
expect(commentIndex).toBe(2); // Range marker + range go before.
|
|
|
|
const altitudeIndex = (commentSeg!.fields ?? []).findIndex((f) => f.name === "altitude");
|
|
expect(altitudeIndex).toBeGreaterThan(0); // Altitude should come after comment in the structure
|
|
expect(altitudeIndex).toBeGreaterThan(commentIndex);
|
|
|
|
const secondCommentIndex = (commentSeg!.fields ?? []).findIndex((f, i) => f.name === "comment" && i > commentIndex);
|
|
expect(secondCommentIndex).toBeGreaterThan(altitudeIndex); // Any additional comment fields should come after altitude
|
|
});
|
|
|
|
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);
|
|
expect(payload!.position!.comment).toBe("73 de NOCALL");
|
|
});
|
|
|
|
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;
|
|
|
|
// console.log(structure[structure.length - 1]); // Log the last segment for debugging
|
|
|
|
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 { payload, structure } = frame.decode(true) as { payload: PositionPayload | null; structure: Dissected };
|
|
|
|
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 =
|
|
"N0CALL-S>APDG01,TCPIP*,qAC,N0CALL-GS:;N0CALL B *181721z5148.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 === "range marker");
|
|
expect(hasRNG).toBe(true);
|
|
|
|
const rangeIndex = (commentSeg!.fields ?? []).findIndex((f) => f.name === "range marker");
|
|
expect(rangeIndex).toBeGreaterThanOrEqual(0);
|
|
const altitudeIndex = (commentSeg!.fields ?? []).findIndex((f) => f.name === "altitude");
|
|
expect(altitudeIndex).toBeGreaterThanOrEqual(0);
|
|
expect(rangeIndex).toBeGreaterThanOrEqual(0);
|
|
expect(altitudeIndex).toBeGreaterThan(rangeIndex); // Altitude comes after range
|
|
const commentIndex = (commentSeg!.fields ?? []).findIndex((f) => f.name === "comment");
|
|
expect(commentIndex).toBeGreaterThan(altitudeIndex); // Comment comes after altitude
|
|
});
|
|
|
|
it("parses DAO token and emits structure", () => {
|
|
const raw = "N0CALL-7>APLT00,WIDE1-1,QB1N4,qAO,N0CALL-10:!5140.06N/00615.91E[360/028/A=000085 !wrt!";
|
|
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!.type).toBe(DataType.PositionNoTimestampNoMessaging);
|
|
expect(payload!.position.dao!).toBeDefined();
|
|
expect(payload!.position.dao!.datum_id).toBe("W");
|
|
});
|
|
});
|
|
|
|
describe("decodeDAO", () => {
|
|
it("decodes valid DAO token with WGS84 datum", () => {
|
|
const dao = decodeDAO("W84");
|
|
expect(dao).not.toBeNull();
|
|
expect(dao!.datum_id).toBe("W");
|
|
expect(dao!.resolution).toBe(knotsToKmh(1));
|
|
expect(dao!.latitude).toBeCloseTo((8 * 0.01) / 60, 6);
|
|
expect(dao!.longitude).toBeCloseTo((4 * 0.01) / 60, 6);
|
|
});
|
|
|
|
it("decodes valid DAO base91 token", () => {
|
|
const dao = decodeDAO("wrt");
|
|
expect(dao).not.toBeNull();
|
|
expect(dao!.datum_id).toBe("W");
|
|
expect(dao!.resolution).toBe(knotsToKmh(0.1));
|
|
expect(dao!.latitude).toBeCloseTo((base91ToNumber("r") * 0.01) / 60, 6);
|
|
expect(dao!.longitude).toBeCloseTo((base91ToNumber("t") * 0.01) / 60, 6);
|
|
});
|
|
|
|
it("decodes valid DAO only token", () => {
|
|
const dao = decodeDAO("! ");
|
|
expect(dao).not.toBeNull();
|
|
expect(dao!.datum_id).toBe("!");
|
|
});
|
|
|
|
it("returns undefined for invalid DAO token", () => {
|
|
expect(decodeDAO("invalid")).toBeUndefined();
|
|
expect(decodeDAO("")).toBeUndefined();
|
|
expect(decodeDAO("ab")).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe("decodeTelemetry", () => {
|
|
it("decodes minimal telemetry (|!!!!|)", () => {
|
|
const result = decodeTelemetry("!!!!");
|
|
expect(result.sequence).toBe(0);
|
|
expect(result.analog).toEqual([0]);
|
|
expect(result.digital).toBeUndefined();
|
|
});
|
|
|
|
it("decodes sequence and one channel", () => {
|
|
const result = decodeTelemetry("ss11");
|
|
expect(result.sequence).toBe(7544);
|
|
expect(result.analog).toEqual([1472]);
|
|
expect(result.digital).toBeUndefined();
|
|
});
|
|
|
|
it("decodes sequence and two channels", () => {
|
|
const result = decodeTelemetry("ss1122");
|
|
expect(result.sequence).toBe(7544);
|
|
expect(result.analog).toEqual([1472, 1564]);
|
|
expect(result.digital).toBeUndefined();
|
|
});
|
|
|
|
it("decodes sequence and five channels", () => {
|
|
const result = decodeTelemetry("ss1122334455");
|
|
expect(result.sequence).toBe(7544);
|
|
expect(result.analog).toEqual([1472, 1564, 1656, 1748, 1840]);
|
|
expect(result.digital).toBeUndefined();
|
|
});
|
|
|
|
it("decodes sequence, five channels, and digital", () => {
|
|
const result = decodeTelemetry('ss1122334455!"');
|
|
expect(result.sequence).toBe(7544);
|
|
expect(result.analog).toEqual([1472, 1564, 1656, 1748, 1840]);
|
|
expect(result.digital).toBe(1);
|
|
});
|
|
|
|
it("throws on too short input", () => {
|
|
expect(() => decodeTelemetry("!")).toThrow();
|
|
expect(() => decodeTelemetry("")).toThrow();
|
|
});
|
|
|
|
it("throws on invalid base91", () => {
|
|
expect(() => decodeTelemetry("ss11~~")).toThrow();
|
|
});
|
|
|
|
it("decodes telemetry test vector", () => {
|
|
const result = decodeTelemetry("$T%R#`");
|
|
expect(result.sequence).toBe(324);
|
|
expect(result.analog).toEqual([413, 245]);
|
|
expect(result.digital).toBeUndefined();
|
|
});
|
|
|
|
it("decodes test vector with embedded telemetry", () => {
|
|
const raw = "N0CALL-11>APLRFT,qAR,N0CALL-10:!\\45;<P(6y>HIGLoRa APRS Tracker|$T%R#`|";
|
|
const frame = Frame.fromString(raw);
|
|
const { payload } = frame.decode(true) as { payload: ObjectPayload | null; structure: Dissected };
|
|
|
|
expect(payload).not.toBeNull();
|
|
expect(payload!.type).toBe(DataType.PositionNoTimestampNoMessaging);
|
|
expect(payload!.position).toBeDefined();
|
|
expect(payload!.position.comment).toBe("LoRa APRS Tracker");
|
|
});
|
|
|
|
it("decodes composite test vector with altitude and telemetry", () => {
|
|
const raw = "N0CALL-11>APLRFT,qAR,N0CALL-10:!\\45;<P(6y>HIGLoRa APRS Tracker|$T%R#`| on air/A=000012!";
|
|
const frame = Frame.fromString(raw);
|
|
const { payload, structure } = frame.decode(true) as { payload: ObjectPayload | null; structure: Dissected };
|
|
|
|
// console.log(structure[structure.length - 1]); // Log the last segment for debugging
|
|
|
|
expect(payload).not.toBeNull();
|
|
expect(payload!.position).toBeDefined();
|
|
expect(payload!.position.altitude).toBeCloseTo(feetToMeters(12), 3);
|
|
//expect(payload!.position.comment).toBe("LoRa APRS Tracker on air");
|
|
expect(structure[structure.length - 1].fields.filter((s) => s.name === "comment").length).toBe(3);
|
|
});
|
|
});
|