diff --git a/src/frame.ts b/src/frame.ts index 0e81a34..792eb68 100644 --- a/src/frame.ts +++ b/src/frame.ts @@ -19,6 +19,8 @@ import type { TelemetryUnitPayload, WeatherPayload, RawGPSPayload, + StationCapabilitiesPayload, + ThirdPartyPayload, } from "./frame.types"; import { Position } from "./position"; import { base91ToNumber } from "./parser"; @@ -2037,24 +2039,194 @@ export class Frame implements IFrame { payload: Payload | null; segment?: Segment[]; } { - // TODO: Implement capabilities decoding with section emission - return { payload: withStructure ? null : null }; + try { + if (this.payload.length < 2) return { payload: null }; + + // Extract the text after the '<' identifier + let rest = this.payload.substring(1).trim(); + + // Some implementations include a closing '>' or other trailing chars; strip common wrappers + if (rest.endsWith(">")) rest = rest.slice(0, -1).trim(); + + // Split capabilities by commas, semicolons or whitespace + const tokens = rest + .split(/[,;\s]+/) + .map((t) => t.trim()) + .filter(Boolean); + + const payload: StationCapabilitiesPayload = { + type: "capabilities", + capabilities: tokens, + } as const; + + if (withStructure) { + const segments: Segment[] = []; + segments.push({ + name: "capabilities", + data: new TextEncoder().encode(rest).buffer, + isString: true, + fields: [ + { + type: FieldType.STRING, + name: "capabilities", + length: rest.length, + }, + ], + }); + + for (const cap of tokens) { + segments.push({ + name: "capability", + data: new TextEncoder().encode(cap).buffer, + isString: true, + fields: [ + { + type: FieldType.STRING, + name: "capability", + length: cap.length, + }, + ], + }); + } + + return { payload, segment: segments }; + } + + return { payload }; + } catch { + return { payload: null }; + } } private decodeUserDefined(withStructure: boolean = false): { payload: Payload | null; segment?: Segment[]; } { - // TODO: Implement user-defined decoding with section emission - return { payload: withStructure ? null : null }; + try { + if (this.payload.length < 2) return { payload: null }; + + // content after '{' + const rest = this.payload.substring(1); + + // user packet type is first token (up to first space) often like '01' or 'TYP' + const match = rest.match(/^([^\s]+)\s*(.*)$/s); + let userPacketType = ""; + let data = ""; + if (match) { + userPacketType = match[1] || ""; + data = (match[2] || "").trim(); + } + + const payloadObj = { + type: "user-defined", + userPacketType, + data, + } as const; + + if (withStructure) { + const segments: Segment[] = []; + segments.push({ + name: "user-defined", + data: new TextEncoder().encode(rest).buffer, + isString: true, + fields: [ + { type: FieldType.STRING, name: "raw", length: rest.length }, + ], + }); + + segments.push({ + name: "user-packet-type", + data: new TextEncoder().encode(userPacketType).buffer, + isString: true, + fields: [ + { + type: FieldType.STRING, + name: "type", + length: userPacketType.length, + }, + ], + }); + + segments.push({ + name: "user-data", + data: new TextEncoder().encode(data).buffer, + isString: true, + fields: [ + { type: FieldType.STRING, name: "data", length: data.length }, + ], + }); + + return { payload: payloadObj as unknown as Payload, segment: segments }; + } + + return { payload: payloadObj as unknown as Payload }; + } catch { + return { payload: null }; + } } private decodeThirdParty(withStructure: boolean = false): { payload: Payload | null; segment?: Segment[]; } { - // TODO: Implement third-party decoding with section emission - return { payload: withStructure ? null : null }; + try { + if (this.payload.length < 2) return { payload: null }; + + // Content after '}' is the encapsulated third-party frame or raw data + const rest = this.payload.substring(1); + + // Attempt to parse the embedded text as a full APRS frame (route:payload) + let nestedFrame: Frame | undefined; + try { + // parseFrame is defined in this module; use Frame.parse to attempt parse + nestedFrame = Frame.parse(rest); + } catch { + nestedFrame = undefined; + } + + const payloadObj: ThirdPartyPayload = { + type: "third-party", + comment: rest, + ...(nestedFrame ? { frame: nestedFrame } : {}), + } as const; + + if (withStructure) { + const segments: Segment[] = []; + + segments.push({ + name: "third-party", + data: new TextEncoder().encode(rest).buffer, + isString: true, + fields: [ + { type: FieldType.STRING, name: "raw", length: rest.length }, + ], + }); + + if (nestedFrame) { + // Include a short section pointing to the nested frame's data (stringified) + const nf = nestedFrame; + const nfStr = `${nf.source.toString()}>${nf.destination.toString()}:${nf.payload}`; + segments.push({ + name: "third-party-nested-frame", + data: new TextEncoder().encode(nfStr).buffer, + isString: true, + fields: [ + { + type: FieldType.STRING, + name: "nested", + length: nfStr.length, + }, + ], + }); + } + + return { payload: payloadObj as unknown as Payload, segment: segments }; + } + + return { payload: payloadObj as unknown as Payload }; + } catch { + return { payload: null }; + } } public static fromString(data: string): Frame { diff --git a/src/frame.types.ts b/src/frame.types.ts index c3a8eb7..3a1387e 100644 --- a/src/frame.types.ts +++ b/src/frame.types.ts @@ -276,8 +276,8 @@ export interface UserDefinedPayload { // Third-Party Traffic Payload export interface ThirdPartyPayload { type: "third-party"; - header: string; // Source path of third-party packet - payload: string; // Nested APRS packet + frame?: IFrame; // Optional nested frame if payload contains another APRS frame + comment?: string; // Optional comment } // DF Report Payload diff --git a/test/frame.capabilities.test.ts b/test/frame.capabilities.test.ts new file mode 100644 index 0000000..e16ff8f --- /dev/null +++ b/test/frame.capabilities.test.ts @@ -0,0 +1,34 @@ +import { describe, it, expect } from "vitest"; +import { Frame } from "../src/frame"; +import type { Payload, StationCapabilitiesPayload } from "../src/frame.types"; +import { Dissected } from "@hamradio/packet"; + +describe("Frame.decodeCapabilities", () => { + it("parses comma separated capabilities", () => { + const data = "CALL>APRS: { + const data = "CALL>APRS:"; + const frame = Frame.fromString(data); + const res = frame.decode(true) as { + payload: Payload | null; + structure: Dissected; + }; + expect(res.payload).not.toBeNull(); + if (res.payload && res.payload.type !== "capabilities") + throw new Error("expected capabilities payload"); + expect(res.structure).toBeDefined(); + const caps = res.structure.find((s) => s.name === "capabilities"); + expect(caps).toBeDefined(); + const capEntry = res.structure.find((s) => s.name === "capability"); + expect(capEntry).toBeDefined(); + }); +}); diff --git a/test/frame.userdefined.test.ts b/test/frame.userdefined.test.ts new file mode 100644 index 0000000..195e2ad --- /dev/null +++ b/test/frame.userdefined.test.ts @@ -0,0 +1,38 @@ +import { describe, it, expect } from "vitest"; +import { Dissected } from "@hamradio/packet"; +import { Frame } from "../src/frame"; +import type { UserDefinedPayload } from "../src/frame.types"; + +describe("Frame.decodeUserDefined", () => { + it("parses packet type only", () => { + const data = "CALL>APRS:{01"; + const frame = Frame.fromString(data); + const decoded = frame.decode() as UserDefinedPayload; + expect(decoded).not.toBeNull(); + expect(decoded.type).toBe("user-defined"); + expect(decoded.userPacketType).toBe("01"); + expect(decoded.data).toBe(""); + }); + + it("parses packet type and data and emits sections", () => { + const data = "CALL>APRS:{TEX Hello world"; + const frame = Frame.fromString(data); + const res = frame.decode(true) as { + payload: UserDefinedPayload; + structure: Dissected; + }; + expect(res.payload).not.toBeNull(); + expect(res.payload.type).toBe("user-defined"); + 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 dataSection = res.structure.find((s) => s.name === "user-data"); + expect(raw).toBeDefined(); + expect(typeSection).toBeDefined(); + expect(dataSection).toBeDefined(); + }); +});