Better parsing for extras; Added deviceID resolution

This commit is contained in:
2026-03-18 17:01:46 +01:00
parent be8cd00c00
commit 17caa22331
8 changed files with 1517 additions and 240 deletions

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

@@ -3,8 +3,9 @@ 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 (RNG / PHG / CSE/DDD / SPD / DFS)", () => {
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);
@@ -34,7 +35,7 @@ describe("APRS extras test vectors (RNG / PHG / CSE/DDD / SPD / DFS)", () => {
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");
const hasPHG = fields.some((f) => f.name === "PHG marker");
expect(hasPHG).toBe(true);
});
@@ -52,7 +53,7 @@ describe("APRS extras test vectors (RNG / PHG / CSE/DDD / SPD / DFS)", () => {
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");
const hasDFS = fieldsDFS.some((f) => f.name === "DFS marker");
expect(hasDFS).toBe(true);
});
@@ -72,12 +73,12 @@ describe("APRS extras test vectors (RNG / PHG / CSE/DDD / SPD / DFS)", () => {
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");
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/045PHG5132/DFS2132";
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;
@@ -92,6 +93,24 @@ describe("APRS extras test vectors (RNG / PHG / CSE/DDD / SPD / DFS)", () => {
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);
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

@@ -233,7 +233,7 @@ describe("Frame.decodeMicE", () => {
const frame = Frame.fromString(data);
const decoded = frame.decode() as MicEPayload;
expect(decoded).not.toBeNull();
expect(decoded?.type).toBe(DataType.MicECurrent);
expect(decoded?.type).toBe(DataType.MicE);
});
it("decodes a Mic-E packet with old format (single quote)", () => {
@@ -322,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", () => {
@@ -468,9 +478,9 @@ describe("Frame.decodeMicE", () => {
const decoded = frame.decode() as MicEPayload;
expect(decoded).not.toBeNull();
expect(decoded?.type).toBe(DataType.MicECurrent);
expect(decoded?.type).toBe(DataType.MicE);
if (decoded && decoded.type === DataType.MicECurrent) {
if (decoded && decoded.type === DataType.MicE) {
expect(decoded.position).toBeDefined();
expect(typeof decoded.position.latitude).toBe("number");
expect(typeof decoded.position.longitude).toBe("number");
@@ -497,7 +507,7 @@ describe("Frame.decodeMicE", () => {
expect(decoded).not.toBeNull();
if (decoded && decoded.type === DataType.MicECurrent) {
if (decoded && decoded.type === DataType.MicE) {
expect(decoded.position.latitude).toBeCloseTo(12 + 34.56 / 60, 3);
}
});
@@ -509,7 +519,7 @@ describe("Frame.decodeMicE", () => {
expect(decoded).not.toBeNull();
if (decoded && decoded.type === DataType.MicECurrent) {
if (decoded && decoded.type === DataType.MicE) {
expect(decoded.position.latitude).toBeCloseTo(1 + 20.45 / 60, 3);
}
});
@@ -521,7 +531,7 @@ describe("Frame.decodeMicE", () => {
expect(decoded).not.toBeNull();
if (decoded && decoded.type === DataType.MicECurrent) {
if (decoded && decoded.type === DataType.MicE) {
expect(decoded.position.latitude).toBeCloseTo(40 + 12.34 / 60, 3);
}
});
@@ -533,7 +543,7 @@ describe("Frame.decodeMicE", () => {
expect(decoded).not.toBeNull();
if (decoded && decoded.type === DataType.MicECurrent) {
if (decoded && decoded.type === DataType.MicE) {
expect(decoded.position.latitude).toBeLessThan(0);
}
});
@@ -547,7 +557,7 @@ describe("Frame.decodeMicE", () => {
expect(decoded).not.toBeNull();
if (decoded && decoded.type === DataType.MicECurrent) {
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);
@@ -561,7 +571,7 @@ describe("Frame.decodeMicE", () => {
expect(decoded).not.toBeNull();
if (decoded && decoded.type === DataType.MicECurrent) {
if (decoded && decoded.type === DataType.MicE) {
expect(typeof decoded.position.longitude).toBe("number");
}
});
@@ -573,7 +583,7 @@ describe("Frame.decodeMicE", () => {
expect(decoded).not.toBeNull();
if (decoded && decoded.type === DataType.MicECurrent) {
if (decoded && decoded.type === DataType.MicE) {
expect(typeof decoded.position.longitude).toBe("number");
expect(Math.abs(decoded.position.longitude)).toBeGreaterThan(90);
}
@@ -588,7 +598,7 @@ describe("Frame.decodeMicE", () => {
expect(decoded).not.toBeNull();
if (decoded && decoded.type === DataType.MicECurrent) {
if (decoded && decoded.type === DataType.MicE) {
if (decoded.position.speed !== undefined) {
expect(decoded.position.speed).toBeGreaterThanOrEqual(0);
expect(typeof decoded.position.speed).toBe("number");
@@ -603,7 +613,7 @@ describe("Frame.decodeMicE", () => {
expect(decoded).not.toBeNull();
if (decoded && decoded.type === DataType.MicECurrent) {
if (decoded && decoded.type === DataType.MicE) {
if (decoded.position.course !== undefined) {
expect(decoded.position.course).toBeGreaterThanOrEqual(0);
expect(decoded.position.course).toBeLessThan(360);
@@ -618,7 +628,7 @@ describe("Frame.decodeMicE", () => {
expect(decoded).not.toBeNull();
if (decoded && decoded.type === DataType.MicECurrent) {
if (decoded && decoded.type === DataType.MicE) {
expect(decoded.position.speed).toBeUndefined();
}
});
@@ -630,7 +640,7 @@ describe("Frame.decodeMicE", () => {
expect(decoded).not.toBeNull();
if (decoded && decoded.type === DataType.MicECurrent) {
if (decoded && decoded.type === DataType.MicE) {
if (decoded.position.course !== undefined) {
expect(decoded.position.course).toBeGreaterThan(0);
expect(decoded.position.course).toBeLessThan(360);
@@ -647,7 +657,7 @@ describe("Frame.decodeMicE", () => {
expect(decoded).not.toBeNull();
if (decoded && decoded.type === DataType.MicECurrent) {
if (decoded && decoded.type === DataType.MicE) {
expect(decoded.position.symbol).toBeDefined();
expect(decoded.position.symbol?.table).toBeDefined();
expect(decoded.position.symbol?.code).toBeDefined();
@@ -659,29 +669,30 @@ 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 === DataType.MicECurrent) {
expect(decoded.position.altitude).toBeCloseTo(1234 * 0.3048, 1);
}
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 === DataType.MicECurrent) {
if (decoded.position.comment?.startsWith("}")) {
expect(decoded.position.altitude).toBeDefined();
}
}
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", () => {
@@ -691,7 +702,7 @@ describe("Frame.decodeMicE", () => {
expect(decoded).not.toBeNull();
if (decoded && decoded.type === DataType.MicECurrent) {
if (decoded && decoded.type === DataType.MicE) {
expect(decoded.position.altitude).toBeCloseTo(5000 * 0.3048, 1);
}
});
@@ -703,7 +714,7 @@ describe("Frame.decodeMicE", () => {
expect(decoded).not.toBeNull();
if (decoded && decoded.type === DataType.MicECurrent) {
if (decoded && decoded.type === DataType.MicE) {
expect(decoded.position.altitude).toBeUndefined();
expect(decoded.position.comment).toContain("Just a comment");
}
@@ -718,7 +729,7 @@ describe("Frame.decodeMicE", () => {
expect(decoded).not.toBeNull();
if (decoded && decoded.type === DataType.MicECurrent) {
if (decoded && decoded.type === DataType.MicE) {
expect(decoded.messageType).toBe("M0: Off Duty");
}
});
@@ -730,7 +741,7 @@ describe("Frame.decodeMicE", () => {
expect(decoded).not.toBeNull();
if (decoded && decoded.type === DataType.MicECurrent) {
if (decoded && decoded.type === DataType.MicE) {
expect(decoded.messageType).toBeDefined();
expect(typeof decoded.messageType).toBe("string");
}
@@ -753,7 +764,7 @@ describe("Frame.decodeMicE", () => {
expect(decoded).not.toBeNull();
if (decoded && decoded.type === DataType.MicECurrent) {
if (decoded && decoded.type === DataType.MicE) {
expect(decoded.position.comment).toContain("This is a test comment");
}
});
@@ -765,7 +776,7 @@ describe("Frame.decodeMicE", () => {
expect(decoded).not.toBeNull();
if (decoded && decoded.type === DataType.MicECurrent) {
if (decoded && decoded.type === DataType.MicE) {
expect(decoded.position.comment).toBeDefined();
}
});
@@ -802,7 +813,7 @@ describe("Frame.decodeMicE", () => {
expect(() => frame.decode()).not.toThrow();
const decoded = frame.decode() as MicEPayload;
expect(decoded === null || decoded?.type === DataType.MicECurrent).toBe(true);
expect(decoded === null || decoded?.type === DataType.MicE).toBe(true);
});
});
@@ -813,9 +824,9 @@ describe("Frame.decodeMicE", () => {
const decoded = frame.decode() as MicEPayload;
expect(decoded).not.toBeNull();
expect(decoded?.type).toBe(DataType.MicECurrent);
expect(decoded?.type).toBe(DataType.MicE);
if (decoded && decoded.type === DataType.MicECurrent) {
if (decoded && decoded.type === DataType.MicE) {
expect(decoded.position.latitude).toBeDefined();
expect(decoded.position.longitude).toBeDefined();
expect(decoded.position.symbol).toBeDefined();