import type { Dissected, Field, Segment } from "@hamradio/packet"; import { FieldType } from "@hamradio/packet"; import { DataType, DataTypeNames, type IAddress, type IFrame, type Payload } from "./frame.types"; import decodeCapabilitiesPayload from "./payload.capabilities"; import decodeItemPayload from "./payload.item"; import decodeMessagePayload from "./payload.message"; import decodeMicEPayload from "./payload.mice"; import decodeObjectPayload from "./payload.object"; import decodePositionPayload from "./payload.position"; import decodeQueryPayload from "./payload.query"; import decodeRawGPSPayload from "./payload.rawgps"; import decodeStatusPayload from "./payload.status"; import decodeTelemetryPayload from "./payload.telemetry"; import { decodeThirdPartyPayload, decodeUserDefinedPayload } from "./payload.thirdparty"; import decodeWeatherPayload from "./payload.weather"; export class Address implements IAddress { call: string; ssid: string = ""; isRepeated: boolean = false; constructor(call: string, ssid: string | number = "", isRepeated: boolean = false) { this.call = call; if (typeof ssid === "number") { this.ssid = ssid.toString(); } else if (typeof ssid === "string") { this.ssid = ssid; } else { throw new Error("SSID must be a string or number"); } if (typeof isRepeated !== "boolean") { throw new Error("isRepeated must be a boolean"); } this.isRepeated = isRepeated || false; } public toString(): string { return `${this.call}${this.ssid ? "-" + this.ssid : ""}${this.isRepeated ? "*" : ""}`; } public static fromString(addr: string): Address { const isRepeated = addr.endsWith("*"); const baseAddr = isRepeated ? addr.slice(0, -1) : addr; const parts = baseAddr.split("-"); const call = parts[0]; const ssid = parts.length > 1 ? parts[1] : ""; return new Address(call, ssid, isRepeated); } public static parse(addr: string): Address { return Address.fromString(addr); } } export class Frame implements IFrame { source: Address; destination: Address; path: Address[]; payload: string; private _routingSection?: Segment; constructor(source: Address, destination: Address, path: Address[], payload: string, routingSection?: Segment) { this.source = source; this.destination = destination; this.path = path; this.payload = payload; this._routingSection = routingSection; } /** * Get the data type identifier (first character of payload) */ getDataTypeIdentifier(): string { return this.payload.charAt(0); } /** * Get or build routing section from cached data */ private getRoutingSection(): Segment | undefined { return this._routingSection; } /** * Decode the APRS payload based on its data type identifier * Returns the decoded payload with optional structure for packet dissection */ decode(withStructure?: boolean): Payload | null | { payload: Payload | null; structure: Dissected } { if (!this.payload) { if (withStructure) { const structure: Dissected = []; const routingSection = this.getRoutingSection(); if (routingSection) { structure.push(routingSection); // Add data type identifier section const fieldName: string = DataTypeNames[this.getDataTypeIdentifier() as DataType] || "unknown"; structure.push({ name: "Data Type Identifier", data: new TextEncoder().encode(this.payload.charAt(0)).buffer, isString: true, fields: [{ type: FieldType.CHAR, name: "identifier", length: 1, value: fieldName }] }); } return { payload: null, structure }; } return null; } const dataType = this.getDataTypeIdentifier(); // eslint-disable-next-line no-useless-assignment let decodedPayload: Payload | null = null; let payloadsegment: Segment[] | undefined = undefined; // TODO: Implement full decoding logic for each payload type switch (dataType) { case "!": // Position without timestamp, no messaging case "=": // Position without timestamp, with messaging case "/": // Position with timestamp, no messaging case "@": // Position with timestamp, with messaging ({ payload: decodedPayload, segment: payloadsegment } = decodePositionPayload( dataType, this.payload, withStructure )); break; case "`": // Mic-E case "'": // Mic-E old ({ payload: decodedPayload, segment: payloadsegment } = decodeMicEPayload( this.destination, this.payload, withStructure )); break; case ":": // Message ({ payload: decodedPayload, segment: payloadsegment } = decodeMessagePayload(this.payload, withStructure)); break; case ";": // Object ({ payload: decodedPayload, segment: payloadsegment } = decodeObjectPayload(this.payload, withStructure)); break; case ")": // Item ({ payload: decodedPayload, segment: payloadsegment } = decodeItemPayload(this.payload, withStructure)); break; case ">": // Status ({ payload: decodedPayload, segment: payloadsegment } = decodeStatusPayload(this.payload, withStructure)); break; case "?": // Query ({ payload: decodedPayload, segment: payloadsegment } = decodeQueryPayload(this.payload, withStructure)); break; case "T": // Telemetry ({ payload: decodedPayload, segment: payloadsegment } = decodeTelemetryPayload(this.payload, withStructure)); break; case "_": // Weather without position ({ payload: decodedPayload, segment: payloadsegment } = decodeWeatherPayload(this.payload, withStructure)); break; case "$": // Raw GPS ({ payload: decodedPayload, segment: payloadsegment } = decodeRawGPSPayload(this.payload, withStructure)); break; case "<": // Station capabilities ({ payload: decodedPayload, segment: payloadsegment } = decodeCapabilitiesPayload(this.payload, withStructure)); break; case "{": // User-defined ({ payload: decodedPayload, segment: payloadsegment } = decodeUserDefinedPayload(this.payload, withStructure)); break; case "}": // Third-party ({ payload: decodedPayload, segment: payloadsegment } = decodeThirdPartyPayload(this.payload, withStructure)); break; default: decodedPayload = null; } if (withStructure) { const structure: Dissected = []; const routingSection = this.getRoutingSection(); if (routingSection) { structure.push(routingSection); } // Add data type identifier section const fieldName: string = DataTypeNames[dataType as DataType] || "unknown"; structure.push({ name: "data type", data: new TextEncoder().encode(this.payload.charAt(0)).buffer, isString: true, fields: [{ type: FieldType.CHAR, name: "identifier", length: 1, value: fieldName }] }); if (payloadsegment) { structure.push(...payloadsegment); } return { payload: decodedPayload, structure }; } return decodedPayload; } public static fromString(data: string): Frame { return parseFrame(data); } public static parse(data: string): Frame { return parseFrame(data); } } const parseFrame = (data: string): Frame => { const encoder = new TextEncoder(); const routeSepIndex = data.indexOf(":"); if (routeSepIndex === -1) { throw new Error("APRS: invalid frame, no route separator found"); } const route = data.slice(0, routeSepIndex); const payload = data.slice(routeSepIndex + 1); const parts = route.split(">"); if (parts.length < 2) { throw new Error("APRS: invalid addresses in route"); } // Parse source - track byte offset as we parse const sourceStr = parts[0]; const source = Address.fromString(sourceStr); // Parse destination and path const destinationAndPath = parts[1].split(","); const destinationStr = destinationAndPath[0]; const destination = Address.fromString(destinationStr); // Parse path const path: Address[] = []; const pathFields: Field[] = []; for (let i = 1; i < destinationAndPath.length; i++) { const pathStr = destinationAndPath[i]; path.push(Address.fromString(pathStr)); pathFields.push({ type: FieldType.CHAR, name: `path separator ${i}`, length: 1 }); pathFields.push({ type: FieldType.STRING, name: `repeater ${i}`, length: pathStr.length }); } const routingSection: Segment = { name: "routing", data: encoder.encode(data.slice(0, routeSepIndex + 1)).buffer, isString: true, fields: [ { type: FieldType.STRING, name: "source address", length: sourceStr.length }, { type: FieldType.CHAR, name: "route separator", length: 1 }, { type: FieldType.STRING, name: "destination address", length: destinationStr.length }, ...pathFields, { type: FieldType.CHAR, name: "payload separator", length: 1 } ] }; return new Frame(source, destination, path, payload, routingSection); };