Files
aprs.ts/src/frame.ts

2481 lines
79 KiB
TypeScript

import type { Dissected, Field, Segment } from "@hamradio/packet";
import { FieldType } from "@hamradio/packet";
import { DTM, GGA, INmeaSentence, Decoder as NmeaDecoder, RMC } from "extended-nmea";
import {
DataType,
type IAddress,
IDirectionFinding,
type IFrame,
type IPosition,
IPowerHeightGain,
type ITimestamp,
type ItemPayload,
type MessagePayload,
MicEPayload,
type ObjectPayload,
type Payload,
type PositionPayload,
type QueryPayload,
type RawGPSPayload,
type StationCapabilitiesPayload,
type StatusPayload,
type TelemetryBitSensePayload,
type TelemetryCoefficientsPayload,
type TelemetryDataPayload,
type TelemetryParameterPayload,
type TelemetryUnitPayload,
type ThirdPartyPayload,
type WeatherPayload
} from "./frame.types";
import { base91ToNumber, feetToMeters, knotsToKmh, milesToMeters } from "./parser";
import { Position } from "./position";
export class Timestamp implements ITimestamp {
day?: number;
month?: number;
hours: number;
minutes: number;
seconds?: number;
format: "DHM" | "HMS" | "MDHM";
zulu?: boolean;
constructor(
hours: number,
minutes: number,
format: "DHM" | "HMS" | "MDHM",
options: {
day?: number;
month?: number;
seconds?: number;
zulu?: boolean;
} = {}
) {
this.hours = hours;
this.minutes = minutes;
this.format = format;
this.day = options.day;
this.month = options.month;
this.seconds = options.seconds;
this.zulu = options.zulu;
}
/**
* Convert APRS timestamp to JavaScript Date object
* Note: APRS timestamps don't include year, so we use current year
* For DHM format, we find the most recent occurrence of that day
* For HMS format, we use current date
* For MDHM format, we use the specified month/day in current year
*/
toDate(): Date {
const now = new Date();
if (this.format === "DHM") {
// Day-Hour-Minute format (UTC)
// Find the most recent occurrence of this day
const currentYear = this.zulu ? now.getUTCFullYear() : now.getFullYear();
const currentMonth = this.zulu ? now.getUTCMonth() : now.getMonth();
let date: Date;
if (this.zulu) {
date = new Date(Date.UTC(currentYear, currentMonth, this.day!, this.hours, this.minutes, 0, 0));
} else {
date = new Date(currentYear, currentMonth, this.day!, this.hours, this.minutes, 0, 0);
}
// If the date is in the future, it's from last month
if (date > now) {
if (this.zulu) {
date = new Date(Date.UTC(currentYear, currentMonth - 1, this.day!, this.hours, this.minutes, 0, 0));
} else {
date = new Date(currentYear, currentMonth - 1, this.day!, this.hours, this.minutes, 0, 0);
}
}
return date;
} else if (this.format === "HMS") {
// Hour-Minute-Second format (UTC)
// Use current date
if (this.zulu) {
const date = new Date();
date.setUTCHours(this.hours, this.minutes, this.seconds || 0, 0);
// If time is in the future, it's from yesterday
if (date > now) {
date.setUTCDate(date.getUTCDate() - 1);
}
return date;
} else {
const date = new Date();
date.setHours(this.hours, this.minutes, this.seconds || 0, 0);
if (date > now) {
date.setDate(date.getDate() - 1);
}
return date;
}
} else {
// MDHM format: Month-Day-Hour-Minute (local time)
const currentYear = now.getFullYear();
let date = new Date(currentYear, (this.month || 1) - 1, this.day!, this.hours, this.minutes, 0, 0);
// If date is in the future, it's from last year
if (date > now) {
date = new Date(currentYear - 1, (this.month || 1) - 1, this.day!, this.hours, this.minutes, 0, 0);
}
return date;
}
}
}
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
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 }]
});
}
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 } = this.decodePosition(dataType, withStructure));
break;
case "`": // Mic-E current
case "'": // Mic-E old
({ payload: decodedPayload, segment: payloadsegment } = this.decodeMicE(withStructure));
break;
case ":": // Message
({ payload: decodedPayload, segment: payloadsegment } = this.decodeMessage(withStructure));
break;
case ";": // Object
({ payload: decodedPayload, segment: payloadsegment } = this.decodeObject(withStructure));
break;
case ")": // Item
({ payload: decodedPayload, segment: payloadsegment } = this.decodeItem(withStructure));
break;
case ">": // Status
({ payload: decodedPayload, segment: payloadsegment } = this.decodeStatus(withStructure));
break;
case "?": // Query
({ payload: decodedPayload, segment: payloadsegment } = this.decodeQuery(withStructure));
break;
case "T": // Telemetry
({ payload: decodedPayload, segment: payloadsegment } = this.decodeTelemetry(withStructure));
break;
case "_": // Weather without position
({ payload: decodedPayload, segment: payloadsegment } = this.decodeWeather(withStructure));
break;
case "$": // Raw GPS
({ payload: decodedPayload, segment: payloadsegment } = this.decodeRawGPS(withStructure));
break;
case "<": // Station capabilities
({ payload: decodedPayload, segment: payloadsegment } = this.decodeCapabilities(withStructure));
break;
case "{": // User-defined
({ payload: decodedPayload, segment: payloadsegment } = this.decodeUserDefined(withStructure));
break;
case "}": // Third-party
({ payload: decodedPayload, segment: payloadsegment } = this.decodeThirdParty(withStructure));
break;
default:
decodedPayload = null;
}
if (withStructure) {
const structure: Dissected = [];
const routingSection = this.getRoutingSection();
if (routingSection) {
structure.push(routingSection);
}
// Add data type identifier section
structure.push({
name: "data type",
data: new TextEncoder().encode(this.payload.charAt(0)).buffer,
isString: true,
fields: [{ type: FieldType.CHAR, name: "identifier", length: 1 }]
});
if (payloadsegment) {
structure.push(...payloadsegment);
}
return { payload: decodedPayload, structure };
}
return decodedPayload;
}
private decodePosition(
dataType: string,
withStructure: boolean = false
): { payload: Payload | null; segment?: Segment[] } {
try {
const hasTimestamp = dataType === "/" || dataType === "@";
const messaging = dataType === "=" || dataType === "@";
let offset = 1; // Skip data type identifier
// Build structure as we parse
const structure: Segment[] = withStructure ? [] : [];
let timestamp: Timestamp | undefined = undefined;
// Parse timestamp if present (7 characters: DDHHMMz or HHMMSSh or MMDDHMMM)
if (hasTimestamp) {
if (this.payload.length < 8) return { payload: null };
const timeStr = this.payload.substring(offset, offset + 7);
const { timestamp: parsedTimestamp, segment: timestampSegment } = this.parseTimestamp(timeStr, withStructure);
timestamp = parsedTimestamp;
if (timestampSegment) {
structure.push(timestampSegment);
}
offset += 7;
}
// Need at least enough characters for compressed position (13) or
// uncompressed (19). Allow parsing to continue if compressed-length is present.
if (this.payload.length < offset + 13) return { payload: null };
// Check if compressed format
const isCompressed = this.isCompressedPosition(this.payload.substring(offset));
let position: Position;
let comment = "";
if (isCompressed) {
// Compressed format: /YYYYXXXX$csT
const { position: compressed, segment: compressedSegment } = this.parseCompressedPosition(
this.payload.substring(offset),
withStructure
);
if (!compressed) return { payload: null };
position = new Position({
latitude: compressed.latitude,
longitude: compressed.longitude,
symbol: compressed.symbol
});
if (compressed.altitude !== undefined) {
position.altitude = compressed.altitude;
}
if (compressedSegment) {
structure.push(compressedSegment);
}
offset += 13; // Compressed position is 13 chars
comment = this.payload.substring(offset);
} else {
// Uncompressed format: DDMMmmH/DDDMMmmH$
const { position: uncompressed, segment: uncompressedSegment } = this.parseUncompressedPosition(
this.payload.substring(offset),
withStructure
);
if (!uncompressed) return { payload: null };
position = new Position({
latitude: uncompressed.latitude,
longitude: uncompressed.longitude,
symbol: uncompressed.symbol
});
if (uncompressed.ambiguity !== undefined) {
position.ambiguity = uncompressed.ambiguity;
}
if (uncompressedSegment) {
structure.push(uncompressedSegment);
}
offset += 19; // Uncompressed position is 19 chars
comment = this.payload.substring(offset);
}
// Parse altitude from comment if present (format: /A=NNNNNN)
const altMatch = comment.match(/\/A=(\d{6})/);
if (altMatch) {
position.altitude = parseInt(altMatch[1], 10) * 0.3048; // feet to meters
// remove altitude token from comment
comment = comment.replace(altMatch[0], "").trim();
}
// Extract RNG and PHG tokens and optionally emit sections
const extras = this.parseCommentExtras(comment, withStructure);
if (extras.range !== undefined) position.range = extras.range;
if (extras.phg !== undefined) position.phg = extras.phg;
if (extras.dfs !== undefined) position.dfs = extras.dfs;
if (extras.cse !== undefined && position.course === undefined) position.course = extras.cse;
if (extras.spd !== undefined && position.speed === undefined) position.speed = extras.spd;
comment = extras.comment;
if (comment) {
position.comment = comment;
// Emit comment section as we parse
if (withStructure) {
const commentFields: Field[] = [{ type: FieldType.STRING, name: "text", length: comment.length }];
if (extras.fields) commentFields.push(...extras.fields);
structure.push({
name: "comment",
data: new TextEncoder().encode(comment).buffer,
isString: true,
fields: commentFields
});
}
} else if (withStructure && extras.fields) {
// No free-text comment, but extras were present: emit a comment section containing only fields
structure.push({
name: "comment",
data: new TextEncoder().encode("").buffer,
isString: true,
fields: extras.fields
});
}
let payloadType:
| DataType.PositionNoTimestampNoMessaging
| DataType.PositionNoTimestampWithMessaging
| DataType.PositionWithTimestampNoMessaging
| DataType.PositionWithTimestampWithMessaging;
switch (dataType) {
case "!":
payloadType = DataType.PositionNoTimestampNoMessaging;
break;
case "=":
payloadType = DataType.PositionNoTimestampWithMessaging;
break;
case "/":
payloadType = DataType.PositionWithTimestampNoMessaging;
break;
case "@":
payloadType = DataType.PositionWithTimestampWithMessaging;
break;
default:
return { payload: null };
}
const payload: PositionPayload = {
type: payloadType,
timestamp,
position,
messaging
};
if (withStructure) {
return { payload, segment: structure };
}
return { payload };
} catch {
return { payload: null };
}
}
private parseTimestamp(
timeStr: string,
withStructure: boolean = false
): { timestamp: Timestamp | undefined; segment?: Segment } {
if (timeStr.length !== 7) return { timestamp: undefined };
const timeType = timeStr.charAt(6);
if (timeType === "z") {
// DHM format: Day-Hour-Minute (UTC)
const timestamp = new Timestamp(
parseInt(timeStr.substring(2, 4), 10),
parseInt(timeStr.substring(4, 6), 10),
"DHM",
{
day: parseInt(timeStr.substring(0, 2), 10),
zulu: true
}
);
const segment = withStructure
? {
name: "timestamp",
data: new TextEncoder().encode(timeStr).buffer,
isString: true,
fields: [
{ type: FieldType.STRING, name: "day (DD)", length: 2 },
{ type: FieldType.STRING, name: "hour (HH)", length: 2 },
{ type: FieldType.STRING, name: "minute (MM)", length: 2 },
{ type: FieldType.CHAR, name: "timezone indicator", length: 1 }
]
}
: undefined;
return { timestamp, segment };
} else if (timeType === "h") {
// HMS format: Hour-Minute-Second (UTC)
const timestamp = new Timestamp(
parseInt(timeStr.substring(0, 2), 10),
parseInt(timeStr.substring(2, 4), 10),
"HMS",
{
seconds: parseInt(timeStr.substring(4, 6), 10),
zulu: true
}
);
const segment = withStructure
? {
name: "timestamp",
data: new TextEncoder().encode(timeStr).buffer,
isString: true,
fields: [
{ type: FieldType.STRING, name: "hour (HH)", length: 2 },
{ type: FieldType.STRING, name: "minute (MM)", length: 2 },
{ type: FieldType.STRING, name: "second (SS)", length: 2 },
{ type: FieldType.CHAR, name: "timezone indicator", length: 1 }
]
}
: undefined;
return { timestamp, segment };
} else if (timeType === "/") {
// MDHM format: Month-Day-Hour-Minute (local)
const timestamp = new Timestamp(
parseInt(timeStr.substring(4, 6), 10),
parseInt(timeStr.substring(6, 8), 10),
"MDHM",
{
month: parseInt(timeStr.substring(0, 2), 10),
day: parseInt(timeStr.substring(2, 4), 10),
zulu: false
}
);
const segment = withStructure
? {
name: "timestamp",
data: new TextEncoder().encode(timeStr).buffer,
isString: true,
fields: [
{ type: FieldType.STRING, name: "month (MM)", length: 2 },
{ type: FieldType.STRING, name: "day (DD)", length: 2 },
{ type: FieldType.STRING, name: "hour (HH)", length: 2 },
{ type: FieldType.STRING, name: "minute (MM)", length: 2 },
{ type: FieldType.CHAR, name: "timezone indicator", length: 1 }
]
}
: undefined;
return { timestamp, segment };
}
return { timestamp: undefined };
}
private isCompressedPosition(data: string): boolean {
if (data.length < 13) return false;
// First prefer uncompressed detection by attempting an uncompressed parse.
// Uncompressed APRS positions do not have a fixed symbol table separator;
// position 8 is a symbol table identifier and may vary.
if (data.length >= 19) {
const uncompressed = this.parseUncompressedPosition(data, false);
if (uncompressed.position) {
return false;
}
}
// For compressed format, check if the position part looks like base-91 encoded data
// Compressed format: STYYYYXXXXcsT where ST is symbol table/code
// Base-91 chars are in range 33-124 (! to |)
const lat1 = data.charCodeAt(1);
const lat2 = data.charCodeAt(2);
const lon1 = data.charCodeAt(5);
const lon2 = data.charCodeAt(6);
return (
lat1 >= 33 && lat1 <= 124 && lat2 >= 33 && lat2 <= 124 && lon1 >= 33 && lon1 <= 124 && lon2 >= 33 && lon2 <= 124
);
}
private parseCompressedPosition(
data: string,
withStructure: boolean = false
): {
position: IPosition | null;
segment?: Segment;
} {
if (data.length < 13) return { position: null };
const symbolTable = data.charAt(0);
const symbolCode = data.charAt(9);
// Extract base-91 encoded position (4 characters each)
const latStr = data.substring(1, 5);
const lonStr = data.substring(5, 9);
try {
// Decode base-91 encoded latitude and longitude
const latBase91 = base91ToNumber(latStr);
const lonBase91 = base91ToNumber(lonStr);
// Convert to degrees
const latitude = 90 - latBase91 / 380926;
const longitude = -180 + lonBase91 / 190463;
const result: IPosition = {
latitude,
longitude,
symbol: {
table: symbolTable,
code: symbolCode
}
};
// Check for compressed altitude (csT format)
const cs = data.charAt(10);
const t = data.charCodeAt(11);
if (cs === " " && t >= 33 && t <= 124) {
// Compressed altitude: altitude = 1.002^(t-33) feet
const altFeet = Math.pow(1.002, t - 33);
result.altitude = altFeet * 0.3048; // Convert to meters
}
const section: Segment | undefined = withStructure
? {
name: "position",
data: new TextEncoder().encode(data.substring(0, 13)).buffer,
isString: true,
fields: [
{ type: FieldType.CHAR, length: 1, name: "symbol table" },
{ type: FieldType.STRING, length: 4, name: "latitude" },
{ type: FieldType.STRING, length: 4, name: "longitude" },
{ type: FieldType.CHAR, length: 1, name: "symbol code" },
{ type: FieldType.CHAR, length: 1, name: "course/speed type" },
{ type: FieldType.CHAR, length: 1, name: "course/speed value" },
{ type: FieldType.CHAR, length: 1, name: "altitude" }
]
}
: undefined;
return { position: result, segment: section };
} catch {
return { position: null };
}
}
private parseUncompressedPosition(
data: string,
withStructure: boolean = false
): {
position: IPosition | null;
segment?: Segment;
} {
if (data.length < 19) return { position: null };
// Format: DDMMmmH/DDDMMmmH$ where H is hemisphere, $ is symbol code
// Positions: 0-7 (latitude), 8 (symbol table), 9-17 (longitude), 18 (symbol code)
// Spaces may replace rightmost digits for ambiguity/privacy
const latStr = data.substring(0, 8); // DDMMmmH (8 chars: 49 03.50 N)
const symbolTable = data.charAt(8);
const lonStr = data.substring(9, 18); // DDDMMmmH (9 chars: 072 01.75 W)
const symbolCode = data.charAt(18);
// Count and handle ambiguity (spaces in minutes part replace rightmost digits)
let ambiguity = 0;
const latSpaceCount = (latStr.match(/ /g) || []).length;
const lonSpaceCount = (lonStr.match(/ /g) || []).length;
if (latSpaceCount > 0 || lonSpaceCount > 0) {
// Use the maximum space count (they should be the same, but be defensive)
ambiguity = Math.max(latSpaceCount, lonSpaceCount);
}
// Replace spaces with zeros for parsing
const latStrNormalized = latStr.replace(/ /g, "0");
const lonStrNormalized = lonStr.replace(/ /g, "0");
// Parse latitude
const latDeg = parseInt(latStrNormalized.substring(0, 2), 10);
const latMin = parseFloat(latStrNormalized.substring(2, 7));
const latHem = latStrNormalized.charAt(7);
if (isNaN(latDeg) || isNaN(latMin)) return { position: null };
if (latHem !== "N" && latHem !== "S") return { position: null };
let latitude = latDeg + latMin / 60;
if (latHem === "S") latitude = -latitude;
// Parse longitude
const lonDeg = parseInt(lonStrNormalized.substring(0, 3), 10);
const lonMin = parseFloat(lonStrNormalized.substring(3, 8));
const lonHem = lonStrNormalized.charAt(8);
if (isNaN(lonDeg) || isNaN(lonMin)) return { position: null };
if (lonHem !== "E" && lonHem !== "W") return { position: null };
let longitude = lonDeg + lonMin / 60;
if (lonHem === "W") longitude = -longitude;
const result: IPosition = {
latitude,
longitude,
symbol: {
table: symbolTable,
code: symbolCode
}
};
if (ambiguity > 0) {
result.ambiguity = ambiguity;
}
const segment: Segment | undefined = withStructure
? {
name: "position",
data: new TextEncoder().encode(data.substring(0, 19)).buffer,
isString: true,
fields: [
{ type: FieldType.STRING, length: 8, name: "latitude" },
{ type: FieldType.CHAR, length: 1, name: "symbol table" },
{ type: FieldType.STRING, length: 9, name: "longitude" },
{ type: FieldType.CHAR, length: 1, name: "symbol code" }
]
}
: undefined;
return { position: result, segment };
}
private parseCommentExtras(
comment: string,
withStructure: boolean = false
): {
comment: string;
range?: number;
phg?: IPowerHeightGain;
dfs?: IDirectionFinding;
fields?: Field[];
cse?: number;
spd?: number;
} {
if (!comment || comment.length === 0) return { comment };
let cleaned = comment;
let range: number | undefined;
let phg: IPowerHeightGain | undefined;
let dfs: IDirectionFinding | undefined;
const fields: Field[] = [];
let cse: number | undefined;
let spd: number | undefined;
// Process successive 7-byte data extensions at the start of the comment.
while (true) {
const trimmed = cleaned.trimStart();
if (trimmed.length < 7) break;
// Allow a single non-alphanumeric prefix (e.g. '>' or '#') before extension
const prefixLen = /^[^A-Za-z0-9]/.test(trimmed.charAt(0)) ? 1 : 0;
if (trimmed.length < prefixLen + 7) break;
const ext = trimmed.substring(prefixLen, prefixLen + 7);
// RNGrrrr -> pre-calculated range in miles (4 digits)
if (ext.startsWith("RNG")) {
const r = ext.substring(3, 7);
if (/^\d{4}$/.test(r)) {
range = milesToMeters(parseInt(r, 10));
cleaned = trimmed.substring(prefixLen + 7).trim();
if (withStructure) fields.push({ type: FieldType.STRING, name: "RNG", length: 7 });
continue;
}
}
// PHGphgd
if (!phg && ext.startsWith("PHG")) {
// PHGphgd: p = power (0-9 or space), h = height (0-9 or space), g = gain (0-9 or space), d = directivity (0-9 or space)
const p = ext.charAt(3);
const h = ext.charAt(4);
const g = ext.charAt(5);
const d = ext.charAt(6);
const pNum = parseInt(p, 10);
const powerWatts = Number.isNaN(pNum) ? undefined : pNum * pNum;
const hIndex = h.charCodeAt(0) - 48;
const heightFeet = 10 * Math.pow(2, hIndex);
const heightMeters = feetToMeters(heightFeet);
const gNum = parseInt(g, 10);
const gainDbi = Number.isNaN(gNum) ? undefined : gNum;
const dNum = parseInt(d, 10);
let directivity: number | "omni" | "unknown" | undefined;
if (Number.isNaN(dNum)) {
directivity = undefined;
} else if (dNum === 0) {
directivity = "omni";
} else if (dNum >= 1 && dNum <= 8) {
directivity = dNum * 45;
} else if (dNum === 9) {
directivity = "unknown";
}
phg = {
power: powerWatts,
height: heightMeters,
gain: gainDbi,
directivity
};
cleaned = trimmed.substring(prefixLen + 7).trim();
if (withStructure) fields.push({ type: FieldType.STRING, name: "PHG", length: 7 });
continue;
}
// DFSshgd
if (ext.startsWith("DFS")) {
// DFSshgd: s = strength (0-9), h = height (0-9), g = gain (0-9), d = directivity (0-9)
const s = ext.charAt(3);
const h = ext.charAt(4);
const g = ext.charAt(5);
const d = ext.charAt(6);
const sNum = parseInt(s, 10);
const hNum = parseInt(h, 10);
const gNum = parseInt(g, 10);
const dNum = parseInt(d, 10);
// Strength: s = 0-9, direct value
const strength = Number.isNaN(sNum) ? undefined : sNum;
// Height: h = 0-9, height = 10 * 2^h feet (spec: h is exponent)
const heightFeet = Number.isNaN(hNum) ? undefined : 10 * Math.pow(2, hNum);
const heightMeters = heightFeet !== undefined ? feetToMeters(heightFeet) : undefined;
// Gain: g = 0-9, gain in dB
const gainDbi = Number.isNaN(gNum) ? undefined : gNum;
// Directivity: d = 0-9, 0 = omni, 1-8 = d*45°, 9 = unknown
let directivity: number | "omni" | "unknown" | undefined;
if (Number.isNaN(dNum)) {
directivity = undefined;
} else if (dNum === 0) {
directivity = "omni";
} else if (dNum >= 1 && dNum <= 8) {
directivity = dNum * 45;
} else if (dNum === 9) {
directivity = "unknown";
}
dfs = {
strength,
height: heightMeters,
gain: gainDbi,
directivity
};
cleaned = trimmed.substring(prefixLen + 7).trim();
if (withStructure) fields.push({ type: FieldType.STRING, name: "DFS", length: 7 });
continue;
}
// Course/Speed DDD/SSS (7 bytes: 3 digits / 3 digits)
if (!cse && /^\d{3}\/\d{3}$/.test(ext)) {
const courseStr = ext.substring(0, 3);
const speedStr = ext.substring(4, 7);
cse = parseInt(courseStr, 10);
spd = knotsToKmh(parseInt(speedStr, 10));
cleaned = trimmed.substring(prefixLen + 7).trim();
if (withStructure) fields.push({ type: FieldType.STRING, name: "CSE/SPD", length: 7 });
// If there is an 8-byte DF/NRQ following (leading '/'), parse that too
if (cleaned.length >= 8 && cleaned.charAt(0) === "/") {
const dfExt = cleaned.substring(0, 8); // e.g. /270/729
const m = dfExt.match(/\/(\d{3})\/(\d{3})/);
if (m) {
const dfBearing = parseInt(m[1], 10);
const dfStrength = parseInt(m[2], 10);
if (dfs === undefined) {
dfs = {};
}
dfs.bearing = dfBearing;
dfs.strength = dfStrength;
if (withStructure) fields.push({ type: FieldType.STRING, name: "DF/NRQ", length: 8 });
cleaned = cleaned.substring(8).trim();
}
}
continue;
}
// No recognized 7-byte extension at start
break;
}
const extrasObj: {
comment: string;
range?: number;
phg?: IPowerHeightGain;
dfs?: IDirectionFinding;
cse?: number;
spd?: number;
dfBearing?: number;
dfStrength?: number;
fields?: Field[];
} = { comment: cleaned };
if (range !== undefined) extrasObj.range = range;
if (phg !== undefined) extrasObj.phg = phg;
if (dfs !== undefined) extrasObj.dfs = dfs;
if (cse !== undefined) extrasObj.cse = cse;
if (spd !== undefined) extrasObj.spd = spd;
if (fields.length) extrasObj.fields = fields;
return extrasObj;
}
private decodeMicE(withStructure: boolean = false): {
payload: Payload | null;
segment?: Segment[];
} {
try {
// Mic-E encodes position in both destination address and information field
const dest = this.destination.call;
if (dest.length < 6) return { payload: null };
if (this.payload.length < 9) return { payload: null }; // Need at least data type + 8 bytes
const segments: Segment[] = withStructure ? [] : [];
// Decode latitude from destination address (6 characters)
const latResult = this.decodeMicELatitude(dest);
if (!latResult) return { payload: null };
const { latitude, messageType, longitudeOffset, isWest, isStandard } = latResult;
if (withStructure) {
segments.push({
name: "mic-E destination",
data: new TextEncoder().encode(dest).buffer,
isString: true,
fields: [
{
type: FieldType.STRING,
name: "destination",
length: dest.length
}
]
});
}
// Parse information field (skip data type identifier at position 0)
let offset = 1;
// Longitude: 3 bytes (degrees, minutes, hundredths)
const lonDegRaw = this.payload.charCodeAt(offset) - 28;
const lonMinRaw = this.payload.charCodeAt(offset + 1) - 28;
const lonHunRaw = this.payload.charCodeAt(offset + 2) - 28;
offset += 3;
// Apply longitude offset and hemisphere
let lonDeg = lonDegRaw;
if (longitudeOffset) {
lonDeg += 100;
}
if (lonDeg >= 180 && lonDeg <= 189) {
lonDeg -= 80;
} else if (lonDeg >= 190 && lonDeg <= 199) {
lonDeg -= 190;
}
let longitude = lonDeg + lonMinRaw / 60.0 + lonHunRaw / 6000.0;
if (isWest) {
longitude = -longitude;
}
// Speed and course: 3 bytes
const sp = this.payload.charCodeAt(offset) - 28;
const dc = this.payload.charCodeAt(offset + 1) - 28;
const se = this.payload.charCodeAt(offset + 2) - 28;
offset += 3;
let speed = sp * 10 + Math.floor(dc / 10); // Speed in knots
let course = (dc % 10) * 100 + se; // Course in degrees
if (course >= 400) course -= 400;
if (speed >= 800) speed -= 800;
// Convert speed from knots to km/h
const speedKmh = speed * 1.852;
// Symbol code and table
if (this.payload.length < offset + 2) return { payload: null };
const symbolCode = this.payload.charAt(offset);
const symbolTable = this.payload.charAt(offset + 1);
offset += 2;
// Parse remaining data (altitude, comment, telemetry)
const remaining = this.payload.substring(offset);
let altitude: number | undefined = undefined;
let comment = remaining;
// Check for altitude in various formats
const altMatch = remaining.match(/\/A=(\d{6})/);
if (altMatch) {
altitude = parseInt(altMatch[1], 10) * 0.3048; // feet to meters
comment = comment.replace(altMatch[0], "").trim();
} else if (remaining.startsWith("}")) {
if (remaining.length >= 4) {
try {
const altBase91 = remaining.substring(1, 4);
const altFeet = base91ToNumber(altBase91) - 10000;
altitude = altFeet * 0.3048; // feet to meters
} catch {
// Ignore altitude parsing errors
}
}
}
// Parse RNG/PHG tokens from comment (defer attaching to result until created)
const extras = this.parseCommentExtras(comment, withStructure);
const extrasRange = extras.range;
const extrasPhg = extras.phg;
const extrasDfs = extras.dfs;
const extrasCse = extras.cse;
const extrasSpd = extras.spd;
comment = extras.comment;
let payloadType: DataType.MicECurrent | DataType.MicEOld;
switch (this.payload.charAt(0)) {
case "`":
payloadType = DataType.MicECurrent;
break;
case "'":
payloadType = DataType.MicEOld;
break;
default:
return { payload: null };
}
const result: MicEPayload = {
type: payloadType,
position: {
latitude,
longitude,
symbol: {
table: symbolTable,
code: symbolCode
}
},
messageType,
isStandard
};
if (speed > 0) {
result.position.speed = speedKmh;
}
if (course > 0 && course < 360) {
result.position.course = course;
}
if (altitude !== undefined) {
result.position.altitude = altitude;
}
if (comment) {
result.position.comment = comment;
}
// Attach parsed extras (RNG / PHG / CSE / SPD / DF) if present
if (extrasRange !== undefined) result.position.range = extrasRange;
if (extrasPhg !== undefined) result.position.phg = extrasPhg;
if (extrasDfs !== undefined) result.position.dfs = extrasDfs;
if (extrasCse !== undefined && result.position.course === undefined) result.position.course = extrasCse;
if (extrasSpd !== undefined && result.position.speed === undefined) result.position.speed = extrasSpd;
if (withStructure && extras.fields) {
// merge extras fields into comment field(s)
// if there is an existing comment segment later, we'll include fields there; otherwise add a comment-only segment
}
if (withStructure) {
// Information field section (bytes after data type up to comment)
const infoData = this.payload.substring(1, offset);
segments.push({
name: "mic-e info",
data: new TextEncoder().encode(infoData).buffer,
isString: true,
fields: [
{ type: FieldType.CHAR, name: "longitude deg", length: 1 },
{ type: FieldType.CHAR, name: "longitude min", length: 1 },
{ type: FieldType.CHAR, name: "longitude hundredths", length: 1 },
{ type: FieldType.CHAR, name: "speed byte", length: 1 },
{ type: FieldType.CHAR, name: "course byte 1", length: 1 },
{ type: FieldType.CHAR, name: "course byte 2", length: 1 },
{ type: FieldType.CHAR, name: "symbol code", length: 1 },
{ type: FieldType.CHAR, name: "symbol table", length: 1 }
]
});
if (comment && comment.length > 0) {
const commentFields: Field[] = [{ type: FieldType.STRING, name: "text", length: comment.length }];
if (extras.fields) commentFields.push(...extras.fields);
segments.push({
name: "comment",
data: new TextEncoder().encode(comment).buffer,
isString: true,
fields: commentFields
});
} else if (extras.fields) {
segments.push({
name: "comment",
data: new TextEncoder().encode("").buffer,
isString: true,
fields: extras.fields
});
}
return { payload: result, segment: segments };
}
return { payload: result };
} catch {
return { payload: null };
}
}
private decodeMicELatitude(dest: string): {
latitude: number;
messageType: string;
longitudeOffset: boolean;
isWest: boolean;
isStandard: boolean;
} | null {
if (dest.length < 6) return null;
// Each destination character encodes a latitude digit and message bits
const digits: number[] = [];
const messageBits: number[] = [];
for (let i = 0; i < 6; i++) {
const code = dest.charCodeAt(i);
let digit: number;
let msgBit: number;
if (code >= 48 && code <= 57) {
// '0'-'9'
digit = code - 48;
msgBit = 0;
} else if (code >= 65 && code <= 74) {
// 'A'-'J' (A=0, B=1, ... J=9)
digit = code - 65;
msgBit = 1;
} else if (code === 75) {
// 'K' means space (used for ambiguity)
digit = 0;
msgBit = 1;
} else if (code === 76) {
// 'L' means space
digit = 0;
msgBit = 0;
} else if (code >= 80 && code <= 89) {
// 'P'-'Y' custom message types (P=0, Q=1, R=2, ... Y=9)
digit = code - 80;
msgBit = 1;
} else if (code === 90) {
// 'Z' means space
digit = 0;
msgBit = 1;
} else {
return null; // Invalid character
}
digits.push(digit);
messageBits.push(msgBit);
}
// Decode latitude: format is DDMM.HH (degrees, minutes, hundredths)
const latDeg = digits[0] * 10 + digits[1];
const latMin = digits[2] * 10 + digits[3];
const latHun = digits[4] * 10 + digits[5];
let latitude = latDeg + latMin / 60.0 + latHun / 6000.0;
// Message bits determine hemisphere and other flags
// Bit 3 (messageBits[3]): 0 = North, 1 = South
// Bit 4 (messageBits[4]): 0 = West, 1 = East
// Bit 5 (messageBits[5]): 0 = longitude offset +0, 1 = longitude offset +100
const isNorth = messageBits[3] === 0;
const isWest = messageBits[4] === 0;
const longitudeOffset = messageBits[5] === 1;
if (!isNorth) {
latitude = -latitude;
}
// Decode message type from bits 0, 1, 2
const msgValue = messageBits[0] * 4 + messageBits[1] * 2 + messageBits[2];
const messageTypes = [
"M0: Off Duty",
"M1: En Route",
"M2: In Service",
"M3: Returning",
"M4: Committed",
"M5: Special",
"M6: Priority",
"M7: Emergency"
];
const messageType = messageTypes[msgValue] || "Unknown";
// Standard vs custom message indicator
const isStandard = messageBits[0] === 1;
return {
latitude,
messageType,
longitudeOffset,
isWest,
isStandard
};
}
private decodeMessage(withStructure: boolean = false): {
payload: Payload | null;
segment?: Segment[];
} {
// Message format: :AAAAAAAAA[ ]:message text
// where AAAAAAAAA is a 9-character recipient field (padded with spaces)
if (this.payload.length < 2) return { payload: null };
let offset = 1; // skip ':' data type
const segments: Segment[] = withStructure ? [] : [];
// Attempt to read a 9-char recipient field if present
let recipient = "";
if (this.payload.length >= offset + 1) {
// Try to read up to 9 chars for recipient, but stop early if a ':' separator appears
const look = this.payload.substring(offset, Math.min(offset + 9, this.payload.length));
const sepIdx = look.indexOf(":");
let raw = look;
if (sepIdx !== -1) {
raw = look.substring(0, sepIdx);
} else if (look.length < 9 && this.payload.length >= offset + 9) {
// pad to full 9 chars if possible
raw = this.payload.substring(offset, offset + 9);
} else if (look.length === 9) {
raw = look;
}
recipient = raw.trimEnd();
if (withStructure) {
segments.push({
name: "recipient",
data: new TextEncoder().encode(raw).buffer,
isString: true,
fields: [{ type: FieldType.STRING, name: "to", length: 9 }]
});
}
// Advance offset past the raw we consumed
offset += raw.length;
// If there was a ':' immediately after the consumed raw, skip it as separator
if (this.payload.charAt(offset) === ":") {
offset += 1;
} else if (sepIdx !== -1) {
// Shouldn't normally happen, but ensure we advance past separator
offset += 1;
}
}
// After recipient there is typically a space and a colon separator before the text
// Find the first ':' after the recipient (it separates the address field from the text)
let textStart = this.payload.indexOf(":", offset);
if (textStart === -1) {
// No explicit separator; skip any spaces and take remainder as text
while (this.payload.charAt(offset) === " " && offset < this.payload.length) offset += 1;
textStart = offset - 1;
}
let text = "";
if (textStart >= 0 && textStart + 1 <= this.payload.length) {
text = this.payload.substring(textStart + 1);
}
const payload: MessagePayload = {
type: DataType.Message,
variant: "message",
addressee: recipient,
text
};
if (withStructure) {
// Emit text section
segments.push({
name: "text",
data: new TextEncoder().encode(text).buffer,
isString: true,
fields: [{ type: FieldType.STRING, name: "text", length: text.length }]
});
return { payload, segment: segments };
}
return { payload };
}
private decodeObject(withStructure: boolean = false): {
payload: Payload | null;
segment?: Segment[];
} {
try {
// Object format: ;AAAAAAAAAcDDHHMMzDDMM.hhN/DDDMM.hhW$comment
// ^ data type
// 9-char name
// alive (*) / killed (_)
if (this.payload.length < 18) return { payload: null }; // 1 + 9 + 1 + 7 minimum
let offset = 1; // Skip data type identifier ';'
const segment: Segment[] = withStructure ? [] : [];
const rawName = this.payload.substring(offset, offset + 9);
const name = rawName.trimEnd();
if (withStructure) {
segment.push({
name: "object name",
data: new TextEncoder().encode(rawName).buffer,
isString: true,
fields: [{ type: FieldType.STRING, name: "name", length: 9 }]
});
}
offset += 9;
const stateChar = this.payload.charAt(offset);
if (stateChar !== "*" && stateChar !== "_") {
return { payload: null };
}
const alive = stateChar === "*";
if (withStructure) {
segment.push({
name: "object state",
data: new TextEncoder().encode(stateChar).buffer,
isString: true,
fields: [
{
type: FieldType.CHAR,
name: "State (* alive, _ killed)",
length: 1
}
]
});
}
offset += 1;
const timeStr = this.payload.substring(offset, offset + 7);
const { timestamp, segment: timestampSection } = this.parseTimestamp(timeStr, withStructure);
if (!timestamp) {
return { payload: null };
}
if (timestampSection) {
segment.push(timestampSection);
}
offset += 7;
const isCompressed = this.isCompressedPosition(this.payload.substring(offset));
let position: IPosition | null = null;
let consumed = 0;
if (isCompressed) {
const { position: compressed, segment: compressedSection } = this.parseCompressedPosition(
this.payload.substring(offset),
withStructure
);
if (!compressed) return { payload: null };
position = {
latitude: compressed.latitude,
longitude: compressed.longitude,
symbol: compressed.symbol,
altitude: compressed.altitude
};
consumed = 13;
if (compressedSection) {
segment.push(compressedSection);
}
} else {
const { position: uncompressed, segment: uncompressedSection } = this.parseUncompressedPosition(
this.payload.substring(offset),
withStructure
);
if (!uncompressed) return { payload: null };
position = {
latitude: uncompressed.latitude,
longitude: uncompressed.longitude,
symbol: uncompressed.symbol,
ambiguity: uncompressed.ambiguity
};
consumed = 19;
if (uncompressedSection) {
segment.push(uncompressedSection);
}
}
offset += consumed;
let comment = this.payload.substring(offset);
// Parse altitude token in comment (/A=NNNNNN)
const altMatchObj = comment.match(/\/A=(\d{6})/);
if (altMatchObj) {
position.altitude = parseInt(altMatchObj[1], 10) * 0.3048;
comment = comment.replace(altMatchObj[0], "").trim();
}
// Parse RNG/PHG tokens
const extrasObj = this.parseCommentExtras(comment, withStructure);
if (extrasObj.range !== undefined) position.range = extrasObj.range;
if (extrasObj.phg !== undefined) position.phg = extrasObj.phg;
if (extrasObj.dfs !== undefined) position.dfs = extrasObj.dfs;
if (extrasObj.cse !== undefined && position.course === undefined) position.course = extrasObj.cse;
if (extrasObj.spd !== undefined && position.speed === undefined) position.speed = extrasObj.spd;
comment = extrasObj.comment;
if (comment) {
position.comment = comment;
if (withStructure) {
const commentFields: Field[] = [{ type: FieldType.STRING, name: "text", length: comment.length }];
if (extrasObj.fields) commentFields.push(...extrasObj.fields);
segment.push({
name: "Comment",
data: new TextEncoder().encode(comment).buffer,
isString: true,
fields: commentFields
});
}
} else if (withStructure && extrasObj.fields) {
segment.push({
name: "Comment",
data: new TextEncoder().encode("").buffer,
isString: true,
fields: extrasObj.fields
});
}
const payload: ObjectPayload = {
type: DataType.Object,
name,
timestamp,
alive,
position
};
if (withStructure) {
return { payload, segment };
}
return { payload };
} catch {
return { payload: null };
}
}
private decodeItem(withStructure: boolean = false): {
payload: Payload | null;
segment?: Segment[];
} {
// Item format is similar to Object but name may be 3-9 chars (stored in a 9-char field)
// Example: )NNN... where ) is data type, next 9 chars are name, then state char, then timestamp, then position
if (this.payload.length < 12) return { payload: null }; // minimal: 1 + 3 + 1 + 7
let offset = 1; // skip data type identifier ')'
const segment: Segment[] = withStructure ? [] : [];
// Read 9-char name field (pad/truncate as present)
const rawName = this.payload.substring(offset, offset + 9);
const name = rawName.trimEnd();
if (withStructure) {
segment.push({
name: "item name",
data: new TextEncoder().encode(rawName).buffer,
isString: true,
fields: [{ type: FieldType.STRING, name: "name", length: 9 }]
});
}
offset += 9;
// State character: '*' = alive, '_' = killed
const stateChar = this.payload.charAt(offset);
if (stateChar !== "*" && stateChar !== "_") {
return { payload: null };
}
const alive = stateChar === "*";
if (withStructure) {
segment.push({
name: "item state",
data: new TextEncoder().encode(stateChar).buffer,
isString: true,
fields: [
{
type: FieldType.CHAR,
name: "State (* alive, _ killed)",
length: 1
}
]
});
}
offset += 1;
// Timestamp (7 chars)
const timeStr = this.payload.substring(offset, offset + 7);
const { timestamp, segment: timestampSection } = this.parseTimestamp(timeStr.substring(offset), withStructure);
if (!timestamp) return { payload: null };
if (timestampSection) segment.push(timestampSection);
offset += 7;
const isCompressed = this.isCompressedPosition(this.payload.substring(offset));
// eslint-disable-next-line no-useless-assignment
let position: IPosition | null = null;
// eslint-disable-next-line no-useless-assignment
let consumed = 0;
if (isCompressed) {
const { position: compressed, segment: compressedSection } = this.parseCompressedPosition(
this.payload.substring(offset),
withStructure
);
if (!compressed) return { payload: null };
position = {
latitude: compressed.latitude,
longitude: compressed.longitude,
symbol: compressed.symbol,
altitude: compressed.altitude
};
consumed = 13;
if (compressedSection) segment.push(compressedSection);
} else {
const { position: uncompressed, segment: uncompressedSection } = this.parseUncompressedPosition(
this.payload.substring(offset),
withStructure
);
if (!uncompressed) return { payload: null };
position = {
latitude: uncompressed.latitude,
longitude: uncompressed.longitude,
symbol: uncompressed.symbol,
ambiguity: uncompressed.ambiguity
};
consumed = 19;
if (uncompressedSection) segment.push(uncompressedSection);
}
offset += consumed;
let comment = this.payload.substring(offset);
// Parse altitude token in comment (/A=NNNNNN)
const altMatchItem = comment.match(/\/A=(\d{6})/);
if (altMatchItem) {
position.altitude = parseInt(altMatchItem[1], 10) * 0.3048;
comment = comment.replace(altMatchItem[0], "").trim();
}
const extrasItem = this.parseCommentExtras(comment, withStructure);
if (extrasItem.range !== undefined) position.range = extrasItem.range;
if (extrasItem.phg !== undefined) position.phg = extrasItem.phg;
if (extrasItem.dfs !== undefined) position.dfs = extrasItem.dfs;
if (extrasItem.cse !== undefined && position.course === undefined) position.course = extrasItem.cse;
if (extrasItem.spd !== undefined && position.speed === undefined) position.speed = extrasItem.spd;
comment = extrasItem.comment;
if (comment) {
position.comment = comment;
if (withStructure) {
segment.push({
name: "Comment",
data: new TextEncoder().encode(comment).buffer,
isString: true,
fields: [{ type: FieldType.STRING, name: "text", length: comment.length }]
});
if (extrasItem.fields) {
// merge extras fields into the last comment segment
const last = segment[segment.length - 1];
if (last && last.fields) last.fields.push(...extrasItem.fields);
}
}
} else if (withStructure && extrasItem.fields) {
// No free-text comment, but extras fields exist: emit comment-only segment
segment.push({
name: "Comment",
data: new TextEncoder().encode("").buffer,
isString: true,
fields: extrasItem.fields
});
}
const payload: ItemPayload = {
type: DataType.Item,
name,
alive,
position
};
if (withStructure) {
return { payload, segment };
}
return { payload };
}
private decodeStatus(withStructure: boolean = false): {
payload: Payload | null;
segment?: Segment[];
} {
// Status payload: optional 7-char timestamp followed by free text.
// We'll also detect a trailing Maidenhead locator (4 or 6 chars) and expose it.
const offsetBase = 1; // skip data type identifier '>'
if (this.payload.length <= offsetBase) return { payload: null };
let offset = offsetBase;
const segments: Segment[] = withStructure ? [] : [];
// Try parse optional timestamp (7 chars)
if (this.payload.length >= offset + 7) {
const timeStr = this.payload.substring(offset, offset + 7);
const { timestamp, segment: tsSegment } = this.parseTimestamp(timeStr, withStructure);
if (timestamp) {
offset += 7;
if (tsSegment) segments.push(tsSegment);
}
}
// Remaining text is status text
const text = this.payload.substring(offset);
if (!text) return { payload: null };
// Detect trailing Maidenhead locator (4 or 6 chars) at end of text separated by space
let maidenhead: string | undefined;
const mhMatch = text.match(/\s([A-Ra-r]{2}\d{2}(?:[A-Ra-r]{2})?)$/);
let statusText = text;
if (mhMatch) {
maidenhead = mhMatch[1].toUpperCase();
statusText = text.slice(0, mhMatch.index).trimEnd();
}
const payload: StatusPayload = {
type: DataType.Status,
timestamp: undefined,
text: statusText
};
// If timestamp was parsed, attach it
if (segments.length > 0) {
// The first segment may be timestamp; parseTimestamp returns the Timestamp object
// Re-parse to obtain timestamp object (cheap) - alternate would be to capture earlier
const timeSegment = segments.find((s) => s.name === "timestamp");
if (timeSegment) {
const tsStr = new TextDecoder().decode(timeSegment.data);
const { timestamp } = this.parseTimestamp(tsStr, false);
if (timestamp) payload.timestamp = timestamp;
}
}
if (maidenhead) payload.maidenhead = maidenhead;
if (withStructure) {
segments.push({
name: "status",
data: new TextEncoder().encode(text).buffer,
isString: true,
fields: [{ type: FieldType.STRING, name: "text", length: text.length }]
});
return { payload, segment: segments };
}
return { payload };
}
private decodeQuery(withStructure: boolean = false): {
payload: Payload | null;
segment?: Segment[];
} {
try {
if (this.payload.length < 2) return { payload: null };
// Skip data type identifier '?'
const segments: Segment[] = withStructure ? [] : [];
// Remaining payload
const rest = this.payload.substring(1).trim();
if (!rest) return { payload: null };
// Query type is the first token (up to first space)
const firstSpace = rest.indexOf(" ");
let queryType = "";
let target: string | undefined = undefined;
if (firstSpace === -1) {
queryType = rest;
} else {
queryType = rest.substring(0, firstSpace);
target = rest.substring(firstSpace + 1).trim();
if (target === "") target = undefined;
}
if (!queryType) return { payload: null };
if (withStructure) {
// Emit query type section
segments.push({
name: "query type",
data: new TextEncoder().encode(queryType).buffer,
isString: true,
fields: [{ type: FieldType.STRING, name: "type", length: queryType.length }]
});
if (target) {
segments.push({
name: "query target",
data: new TextEncoder().encode(target).buffer,
isString: true,
fields: [{ type: FieldType.STRING, name: "target", length: target.length }]
});
}
}
const payload: QueryPayload = {
type: DataType.Query,
queryType,
...(target ? { target } : {})
};
if (withStructure) return { payload, segment: segments };
return { payload };
} catch {
return { payload: null };
}
}
private decodeTelemetry(withStructure: boolean = false): {
payload: Payload | null;
segment?: Segment[];
} {
try {
if (this.payload.length < 2) return { payload: null };
const rest = this.payload.substring(1).trim();
if (!rest) return { payload: null };
const segments: Segment[] = withStructure ? [] : [];
// Telemetry data: convention used here: starts with '#' then sequence then analogs and digital
if (rest.startsWith("#")) {
const parts = rest.substring(1).trim().split(/\s+/);
const seq = parseInt(parts[0], 10);
let analog: number[] = [];
let digital = 0;
if (parts.length >= 2) {
// analogs as comma separated
analog = parts[1].split(",").map((v) => parseFloat(v));
}
if (parts.length >= 3) {
digital = parseInt(parts[2], 10);
}
if (withStructure) {
segments.push({
name: "telemetry sequence",
data: new TextEncoder().encode(String(seq)).buffer,
isString: true,
fields: [
{
type: FieldType.STRING,
name: "sequence",
length: String(seq).length
}
]
});
segments.push({
name: "telemetry analog",
data: new TextEncoder().encode(parts[1] || "").buffer,
isString: true,
fields: [
{
type: FieldType.STRING,
name: "analogs",
length: (parts[1] || "").length
}
]
});
segments.push({
name: "telemetry digital",
data: new TextEncoder().encode(String(digital)).buffer,
isString: true,
fields: [
{
type: FieldType.STRING,
name: "digital",
length: String(digital).length
}
]
});
}
const payload: TelemetryDataPayload = {
type: DataType.TelemetryData,
variant: "data",
sequence: isNaN(seq) ? 0 : seq,
analog,
digital: isNaN(digital) ? 0 : digital
};
if (withStructure) return { payload, segment: segments };
return { payload };
}
// Telemetry parameters: 'PARAM' keyword
if (/^PARAM/i.test(rest)) {
const after = rest.replace(/^PARAM\s*/i, "");
const names = after.split(/[,\s]+/).filter(Boolean);
if (withStructure) {
segments.push({
name: "telemetry parameters",
data: new TextEncoder().encode(after).buffer,
isString: true,
fields: [{ type: FieldType.STRING, name: "names", length: after.length }]
});
}
const payload: TelemetryParameterPayload = {
type: DataType.TelemetryData,
variant: "parameters",
names
};
if (withStructure) return { payload, segment: segments };
return { payload };
}
// Telemetry units: 'UNIT'
if (/^UNIT/i.test(rest)) {
const after = rest.replace(/^UNIT\s*/i, "");
const units = after.split(/[,\s]+/).filter(Boolean);
if (withStructure) {
segments.push({
name: "telemetry units",
data: new TextEncoder().encode(after).buffer,
isString: true,
fields: [{ type: FieldType.STRING, name: "units", length: after.length }]
});
}
const payload: TelemetryUnitPayload = {
type: DataType.TelemetryData,
variant: "unit",
units
};
if (withStructure) return { payload, segment: segments };
return { payload };
}
// Telemetry coefficients: 'COEFF' a:,b:,c:
if (/^COEFF/i.test(rest)) {
const after = rest.replace(/^COEFF\s*/i, "");
const aMatch = after.match(/A:([^\s;]+)/i);
const bMatch = after.match(/B:([^\s;]+)/i);
const cMatch = after.match(/C:([^\s;]+)/i);
const parseList = (s?: string) => (s ? s.split(",").map((v) => parseFloat(v)) : []);
const coefficients = {
a: parseList(aMatch?.[1]),
b: parseList(bMatch?.[1]),
c: parseList(cMatch?.[1])
};
if (withStructure) {
segments.push({
name: "telemetry coefficients",
data: new TextEncoder().encode(after).buffer,
isString: true,
fields: [{ type: FieldType.STRING, name: "coeffs", length: after.length }]
});
}
const payload: TelemetryCoefficientsPayload = {
type: DataType.TelemetryData,
variant: "coefficients",
coefficients
};
if (withStructure) return { payload, segment: segments };
return { payload };
}
// Telemetry bitsense/project: 'BITS' <number> [project]
if (/^BITS?/i.test(rest)) {
const parts = rest.split(/\s+/).slice(1);
const sense = parts.length > 0 ? parseInt(parts[0], 10) : 0;
const projectName = parts.length > 1 ? parts.slice(1).join(" ") : undefined;
if (withStructure) {
segments.push({
name: "telemetry bitsense",
data: new TextEncoder().encode(rest).buffer,
isString: true,
fields: [{ type: FieldType.STRING, name: "bitsense", length: rest.length }]
});
}
const payload: TelemetryBitSensePayload = {
type: DataType.TelemetryData,
variant: "bitsense",
sense: isNaN(sense) ? 0 : sense,
...(projectName ? { projectName } : {})
};
if (withStructure) return { payload, segment: segments };
return { payload };
}
return { payload: null };
} catch {
return { payload: null };
}
}
private decodeWeather(withStructure: boolean = false): {
payload: Payload | null;
segment?: Segment[];
} {
try {
if (this.payload.length < 2) return { payload: null };
let offset = 1; // skip '_' data type
const segments: Segment[] = withStructure ? [] : [];
// Try optional timestamp (7 chars)
let timestamp;
if (this.payload.length >= offset + 7) {
const timeStr = this.payload.substring(offset, offset + 7);
const parsed = this.parseTimestamp(timeStr, withStructure);
timestamp = parsed.timestamp;
if (parsed.segment) {
segments.push(parsed.segment);
}
if (timestamp) offset += 7;
}
// Try optional position following timestamp
let position: IPosition | undefined;
let consumed = 0;
const tail = this.payload.substring(offset);
if (tail.length > 0) {
// If the tail starts with a wind token like DDD/SSS, treat it as weather data
// and do not attempt to parse it as a position (avoids mis-detecting wind
// values as compressed position fields).
if (/^\s*\d{3}\/\d{1,3}/.test(tail)) {
// no position present; leave consumed = 0
} else if (this.isCompressedPosition(tail)) {
const parsed = this.parseCompressedPosition(tail, withStructure);
if (parsed.position) {
position = {
latitude: parsed.position.latitude,
longitude: parsed.position.longitude,
symbol: parsed.position.symbol,
altitude: parsed.position.altitude
};
if (parsed.segment) segments.push(parsed.segment);
consumed = 13;
}
} else {
const parsed = this.parseUncompressedPosition(tail, withStructure);
if (parsed.position) {
position = {
latitude: parsed.position.latitude,
longitude: parsed.position.longitude,
symbol: parsed.position.symbol,
ambiguity: parsed.position.ambiguity
};
if (parsed.segment) segments.push(parsed.segment);
consumed = 19;
}
}
}
offset += consumed;
const rest = this.payload.substring(offset).trim();
const payload: WeatherPayload = {
type: DataType.WeatherReportNoPosition
};
if (timestamp) payload.timestamp = timestamp;
if (position) payload.position = position;
if (rest && rest.length > 0) {
// Parse common tokens
// Wind: DDD/SSS [gGGG]
const windMatch = rest.match(/(\d{3})\/(\d{1,3})(?:g(\d{1,3}))?/);
if (windMatch) {
payload.windDirection = parseInt(windMatch[1], 10);
payload.windSpeed = parseInt(windMatch[2], 10);
if (windMatch[3]) payload.windGust = parseInt(windMatch[3], 10);
}
// Temperature: tNNN (F)
const tempMatch = rest.match(/t(-?\d{1,3})/i);
if (tempMatch) payload.temperature = parseInt(tempMatch[1], 10);
// Rain: rNNN (last hour), pNNN (24h), PNNN (since midnight) - values are hundredths of inch
const rMatch = rest.match(/r(\d{3})/);
if (rMatch) payload.rainLastHour = parseInt(rMatch[1], 10);
const pMatch = rest.match(/p(\d{3})/);
if (pMatch) payload.rainLast24Hours = parseInt(pMatch[1], 10);
const PMatch = rest.match(/P(\d{3})/);
if (PMatch) payload.rainSinceMidnight = parseInt(PMatch[1], 10);
// Humidity: hNN
const hMatch = rest.match(/h(\d{1,3})/);
if (hMatch) payload.humidity = parseInt(hMatch[1], 10);
// Pressure: bXXXX or bXXXXX (tenths of millibar)
const bMatch = rest.match(/b(\d{4,5})/);
if (bMatch) payload.pressure = parseInt(bMatch[1], 10);
// Add raw comment
payload.comment = rest;
if (withStructure) {
segments.push({
name: "weather",
data: new TextEncoder().encode(rest).buffer,
isString: true,
fields: [{ type: FieldType.STRING, name: "text", length: rest.length }]
});
}
}
if (withStructure) return { payload, segment: segments };
return { payload };
} catch {
return { payload: null };
}
}
private decodeRawGPS(withStructure: boolean = false): {
payload: Payload | null;
segment?: Segment[];
} {
try {
if (this.payload.length < 2) return { payload: null };
// Raw GPS payloads start with '$' followed by an NMEA sentence
const sentence = this.payload.substring(1).trim();
// Attempt to parse with extended-nmea Decoder to extract position (best-effort)
let parsed: INmeaSentence | null = null;
try {
const full = sentence.startsWith("$") ? sentence : `$${sentence}`;
parsed = NmeaDecoder.decode(full);
} catch {
// ignore parse errors - accept any sentence as raw-gps per APRS
}
const payload: RawGPSPayload = {
type: DataType.RawGPS,
sentence
};
// If parse produced latitude/longitude, attach structured position.
// Otherwise fallback to a minimal NMEA parser for common sentences (RMC, GGA).
if (
parsed &&
(parsed instanceof RMC || parsed instanceof GGA || parsed instanceof DTM) &&
parsed.latitude &&
parsed.longitude
) {
// extended-nmea latitude/longitude are GeoCoordinate objects with
// fields { degrees, decimal, quadrant }
const latObj = parsed.latitude;
const lonObj = parsed.longitude;
const lat = latObj.degrees + (Number(latObj.decimal) || 0) / 60.0;
const lon = lonObj.degrees + (Number(lonObj.decimal) || 0) / 60.0;
const latitude = latObj.quadrant === "S" ? -lat : lat;
const longitude = lonObj.quadrant === "W" ? -lon : lon;
const pos: IPosition = {
latitude,
longitude
};
// altitude
if ("altMean" in parsed && parsed.altMean !== undefined) {
pos.altitude = Number(parsed.altMean);
}
if ("altitude" in parsed && parsed.altitude !== undefined) {
pos.altitude = Number(parsed.altitude);
}
// speed/course (RMC fields)
if ("speedOverGround" in parsed && parsed.speedOverGround !== undefined) {
pos.speed = Number(parsed.speedOverGround);
}
if ("courseOverGround" in parsed && parsed.courseOverGround !== undefined) {
pos.course = Number(parsed.courseOverGround);
}
payload.position = pos;
} else {
try {
const full = sentence.startsWith("$") ? sentence : `$${sentence}`;
const withoutChecksum = full.split("*")[0];
const parts = withoutChecksum.split(",");
const header = parts[0].slice(1).toUpperCase();
const parseCoord = (coord: string, hemi: string) => {
if (!coord || coord === "") return undefined;
const degDigits = hemi === "N" || hemi === "S" ? 2 : 3;
if (coord.length <= degDigits) return undefined;
const degPart = coord.slice(0, degDigits);
const minPart = coord.slice(degDigits);
const degrees = parseFloat(degPart);
const mins = parseFloat(minPart);
if (Number.isNaN(degrees) || Number.isNaN(mins)) return undefined;
let dec = degrees + mins / 60.0;
if (hemi === "S" || hemi === "W") dec = -dec;
return dec;
};
if (header.endsWith("RMC")) {
const lat = parseCoord(parts[3], parts[4]);
const lon = parseCoord(parts[5], parts[6]);
if (lat !== undefined && lon !== undefined) {
const pos: IPosition = { latitude: lat, longitude: lon };
if (parts[7]) pos.speed = Number(parts[7]);
if (parts[8]) pos.course = Number(parts[8]);
payload.position = pos;
}
} else if (header.endsWith("GGA")) {
const lat = parseCoord(parts[2], parts[3]);
const lon = parseCoord(parts[4], parts[5]);
if (lat !== undefined && lon !== undefined) {
const pos: IPosition = { latitude: lat, longitude: lon };
if (parts[9]) pos.altitude = Number(parts[9]);
payload.position = pos;
}
}
} catch {
// ignore fallback parse errors
}
}
if (withStructure) {
const segments: Segment[] = [
{
name: "raw-gps",
data: new TextEncoder().encode(sentence).buffer,
isString: true,
fields: [
{
type: FieldType.STRING,
name: "sentence",
length: sentence.length
}
]
}
];
if (payload.position) {
segments.push({
name: "raw-gps-position",
data: new TextEncoder().encode(JSON.stringify(payload.position)).buffer,
isString: true,
fields: [
{
type: FieldType.STRING,
name: "latitude",
length: String(payload.position.latitude).length
},
{
type: FieldType.STRING,
name: "longitude",
length: String(payload.position.longitude).length
}
]
});
}
return { payload, segment: segments };
}
return { payload };
} catch {
return { payload: null };
}
}
private decodeCapabilities(withStructure: boolean = false): {
payload: Payload | null;
segment?: Segment[];
} {
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: DataType.StationCapabilities,
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[];
} {
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: DataType.UserDefined,
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[];
} {
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: DataType.ThirdParty,
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 {
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);
};