286 lines
9.1 KiB
TypeScript
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);
|
|
};
|