Also parse the extras from the comment field

This commit is contained in:
2026-03-18 13:11:16 +01:00
parent 78dbd3b0ef
commit b1cd8449d9
16 changed files with 754 additions and 493 deletions

View File

@@ -1,16 +1,17 @@
import { Dissected, FieldType } from "@hamradio/packet";
import { describe, expect, it } from "vitest";
import { Address, Frame, Timestamp } from "../src/frame";
import {
type Payload,
type PositionPayload,
type ObjectPayload,
type StatusPayload,
DataType,
type ITimestamp,
type MessagePayload,
DataType,
MicEPayload,
type ObjectPayload,
type Payload,
type PositionPayload,
type StatusPayload
} from "../src/frame.types";
import { Dissected, FieldType } from "@hamradio/packet";
// Address parsing: split by method
describe("Address.parse", () => {
@@ -49,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("@");
});
@@ -76,8 +76,7 @@ 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();
@@ -99,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);
@@ -112,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);
@@ -174,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");
});
});
@@ -257,9 +269,7 @@ describe("Frame.decodeMessage", () => {
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");
});
});
@@ -291,8 +301,7 @@ 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();
@@ -324,8 +333,7 @@ describe("Frame.decodeStatus", () => {
structure: Dissected;
};
expect(res.payload).not.toBeNull();
if (res.payload?.type !== DataType.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");
@@ -423,7 +431,7 @@ describe("Timestamp.toDate", () => {
if (futureHours < 24) {
const ts = new Timestamp(futureHours, 0, "HMS", {
seconds: 0,
zulu: true,
zulu: true
});
const date = ts.toDate();
@@ -440,7 +448,7 @@ describe("Timestamp.toDate", () => {
const ts = new Timestamp(12, 0, "MDHM", {
month: futureMonth + 1,
day: 1,
zulu: false,
zulu: false
});
const date = ts.toDate();
@@ -455,8 +463,7 @@ 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 MicEPayload;
@@ -795,16 +802,13 @@ 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.MicECurrent).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 MicEPayload;
@@ -842,15 +846,11 @@ describe("Packet dissection with sections", () => {
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);
});
@@ -869,9 +869,7 @@ describe("Packet dissection with sections", () => {
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();
@@ -897,20 +895,16 @@ describe("Packet dissection with sections", () => {
structure: Dissected;
};
expect(result.payload?.type).toBe(
DataType.PositionWithTimestampWithMessaging,
);
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"
]);
});
@@ -922,13 +916,9 @@ describe("Packet dissection with sections", () => {
structure: Dissected;
};
expect(result.payload?.type).toBe(
DataType.PositionWithTimestampWithMessaging,
);
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);
@@ -979,9 +969,7 @@ describe("Frame.decodeMessage", () => {
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");
});
});
@@ -998,8 +986,7 @@ describe("Frame.decoding: object and status", () => {
expect(res).toHaveProperty("payload");
expect(res.payload).not.toBeNull();
if (res.payload?.type !== DataType.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 };
@@ -1029,8 +1016,7 @@ describe("Frame.decoding: object and status", () => {
expect(res).toHaveProperty("payload");
expect(res.payload).not.toBeNull();
if (res.payload?.type !== DataType.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 };