2481 lines
79 KiB
TypeScript
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);
|
|
};
|