Files
aprs.ts/src/frame.ts

286 lines
9.1 KiB
TypeScript

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);
};