1110 lines
36 KiB
TypeScript
1110 lines
36 KiB
TypeScript
import type { IAddress, IFrame, Payload, ITimestamp, PositionPayload, IPosition } from "./frame.types"
|
|
import { type PacketStructure, type PacketSegment, type PacketField, FieldType } from "./parser.types"
|
|
import { Position } from "./position";
|
|
import { base91ToNumber } from "./parser"
|
|
|
|
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?: PacketSegment;
|
|
|
|
constructor(source: Address, destination: Address, path: Address[], payload: string, routingSection?: PacketSegment) {
|
|
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(): PacketSegment | 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: PacketStructure } {
|
|
if (!this.payload) {
|
|
if (withStructure) {
|
|
const structure: PacketStructure = [];
|
|
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)),
|
|
fields: [
|
|
{ type: FieldType.CHAR, name: 'Identifier', size: 1 },
|
|
],
|
|
});
|
|
}
|
|
return { payload: null, structure };
|
|
}
|
|
return null;
|
|
}
|
|
|
|
const dataType = this.getDataTypeIdentifier();
|
|
let decodedPayload: Payload | null = null;
|
|
let payloadsegment: PacketSegment[] | 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: PacketStructure = [];
|
|
const routingSection = this.getRoutingSection();
|
|
if (routingSection) {
|
|
structure.push(routingSection);
|
|
}
|
|
if (payloadsegment) {
|
|
structure.push(...payloadsegment);
|
|
}
|
|
return { payload: decodedPayload, structure };
|
|
}
|
|
|
|
return decodedPayload;
|
|
}
|
|
|
|
private decodePosition(dataType: string, withStructure: boolean = false): { payload: Payload | null; segment?: PacketSegment[] } {
|
|
try {
|
|
const hasTimestamp = dataType === '/' || dataType === '@';
|
|
const messaging = dataType === '=' || dataType === '@';
|
|
let offset = 1; // Skip data type identifier
|
|
|
|
// Build structure as we parse
|
|
const structure: PacketSegment[] = 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 timestampOffset = offset;
|
|
const timeStr = this.payload.substring(offset, offset + 7);
|
|
const { timestamp: parsedTimestamp, segment: timestampSegment } = this.parseTimestamp(timeStr, withStructure, timestampOffset);
|
|
timestamp = parsedTimestamp;
|
|
|
|
if (timestampSegment) {
|
|
structure.push(timestampSegment);
|
|
}
|
|
|
|
offset += 7;
|
|
}
|
|
|
|
if (this.payload.length < offset + 19) return { payload: null };
|
|
|
|
// Check if compressed format
|
|
const positionOffset = offset;
|
|
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, positionOffset);
|
|
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, positionOffset);
|
|
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
|
|
}
|
|
|
|
if (comment) {
|
|
position.comment = comment;
|
|
|
|
// Emit comment section as we parse
|
|
if (withStructure) {
|
|
structure.push({
|
|
name: 'comment',
|
|
data: new TextEncoder().encode(comment),
|
|
fields: [
|
|
{ type: FieldType.STRING, name: 'text', size: comment.length },
|
|
],
|
|
});
|
|
}
|
|
}
|
|
|
|
const payload: PositionPayload = {
|
|
type: 'position',
|
|
timestamp,
|
|
position,
|
|
messaging,
|
|
};
|
|
|
|
if (withStructure) {
|
|
return { payload, segment: structure };
|
|
}
|
|
|
|
return { payload };
|
|
} catch (e) {
|
|
return { payload: null };
|
|
}
|
|
}
|
|
|
|
private parseTimestamp(timeStr: string, withStructure: boolean = false, offset: number = 0): { timestamp: Timestamp | undefined; segment?: PacketSegment } {
|
|
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),
|
|
fields: [
|
|
{ type: FieldType.STRING, name: 'day (DD)', size: 2 },
|
|
{ type: FieldType.STRING, name: 'hour (HH)', size: 2 },
|
|
{ type: FieldType.STRING, name: 'minute (MM)', size: 2 },
|
|
{ type: FieldType.CHAR, name: 'timezone indicator', size: 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),
|
|
fields: [
|
|
{ type: FieldType.STRING, name: 'hour (HH)', size: 2 },
|
|
{ type: FieldType.STRING, name: 'minute (MM)', size: 2 },
|
|
{ type: FieldType.STRING, name: 'second (SS)', size: 2 },
|
|
{ type: FieldType.CHAR, name: 'timezone indicator', size: 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),
|
|
fields: [
|
|
{ type: FieldType.STRING, name: 'month (MM)', size: 2 },
|
|
{ type: FieldType.STRING, name: 'day (DD)', size: 2 },
|
|
{ type: FieldType.STRING, name: 'hour (HH)', size: 2 },
|
|
{ type: FieldType.STRING, name: 'minute (MM)', size: 2 },
|
|
{ type: FieldType.CHAR, name: 'timezone indicator', size: 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, 0);
|
|
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, offset: number = 0): { position: { latitude: number; longitude: number; symbol: any; altitude?: number } | null; segment?: PacketSegment } {
|
|
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: any = {
|
|
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: PacketSegment | undefined = withStructure ? {
|
|
name: 'position',
|
|
data: new TextEncoder().encode(data.substring(0, 13)),
|
|
fields: [
|
|
{ type: FieldType.CHAR, size: 1, name: 'symbol table' },
|
|
{ type: FieldType.STRING, size: 4, name: 'latitude' },
|
|
{ type: FieldType.STRING, size: 4, name: 'longitude' },
|
|
{ type: FieldType.CHAR, size: 1, name: 'symbol code' },
|
|
{ type: FieldType.CHAR, size: 1, name: 'course/speed type' },
|
|
{ type: FieldType.CHAR, size: 1, name: 'course/speed value' },
|
|
{ type: FieldType.CHAR, size: 1, name: 'altitude' },
|
|
],
|
|
} : undefined;
|
|
|
|
return { position: result, segment: section };
|
|
} catch (e) {
|
|
return { position: null };
|
|
}
|
|
}
|
|
|
|
private parseUncompressedPosition(data: string, withStructure: boolean = false, offset: number = 0): { position: { latitude: number; longitude: number; symbol: any; ambiguity?: number } | null; segment?: PacketSegment } {
|
|
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: any = {
|
|
latitude,
|
|
longitude,
|
|
symbol: {
|
|
table: symbolTable,
|
|
code: symbolCode,
|
|
},
|
|
};
|
|
|
|
if (ambiguity > 0) {
|
|
result.ambiguity = ambiguity;
|
|
}
|
|
|
|
const segment: PacketSegment | undefined = withStructure ? {
|
|
name: 'position',
|
|
data: new TextEncoder().encode(data.substring(0, 19)),
|
|
fields: [
|
|
{ type: FieldType.STRING, size: 8, name: 'latitude' },
|
|
{ type: FieldType.CHAR, size: 1, name: 'symbol table' },
|
|
{ type: FieldType.STRING, size: 9, name: 'longitude' },
|
|
{ type: FieldType.CHAR, size: 1, name: 'symbol code' },
|
|
],
|
|
} : undefined;
|
|
|
|
return { position: result, segment };
|
|
}
|
|
|
|
private decodeMicE(withStructure: boolean = false): { payload: Payload | null; segment?: PacketSegment[] } {
|
|
try {
|
|
// TODO: Add section emission support when withStructure is true
|
|
// For now, Mic-E returns payload without structure
|
|
|
|
// 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
|
|
|
|
// Decode latitude from destination address (6 characters)
|
|
const latResult = this.decodeMicELatitude(dest);
|
|
if (!latResult) return { payload: null };
|
|
|
|
const { latitude, messageType, longitudeOffset, isWest, isStandard } = latResult;
|
|
|
|
// 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
|
|
// Format 1: }xyz where xyz is altitude in base-91 (obsolete)
|
|
// Format 2: /A=NNNNNN where NNNNNN is altitude in feet
|
|
const altMatch = remaining.match(/\/A=(\d{6})/);
|
|
if (altMatch) {
|
|
altitude = parseInt(altMatch[1], 10) * 0.3048; // feet to meters
|
|
} else if (remaining.startsWith('}')) {
|
|
// Base-91 altitude (3 characters after })
|
|
if (remaining.length >= 4) {
|
|
try {
|
|
const altBase91 = remaining.substring(1, 4);
|
|
const altFeet = base91ToNumber(altBase91) - 10000;
|
|
altitude = altFeet * 0.3048; // feet to meters
|
|
} catch (e) {
|
|
// Ignore altitude parsing errors
|
|
}
|
|
}
|
|
}
|
|
|
|
const result: any = {
|
|
type: 'position',
|
|
position: {
|
|
latitude,
|
|
longitude,
|
|
symbol: {
|
|
table: symbolTable,
|
|
code: symbolCode,
|
|
},
|
|
},
|
|
messaging: true, // Mic-E is always messaging-capable
|
|
micE: {
|
|
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;
|
|
}
|
|
|
|
return { payload: result };
|
|
} catch (e) {
|
|
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?: PacketSegment[] } {
|
|
// TODO: Implement message decoding with section emission
|
|
// When implemented, build structure during parsing like decodePosition does
|
|
return { payload: null };
|
|
}
|
|
|
|
private decodeObject(withStructure: boolean = false): { payload: Payload | null; segment?: PacketSegment[] } {
|
|
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: PacketSegment[] = 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),
|
|
fields: [
|
|
{ type: FieldType.STRING, name: 'name', size: 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),
|
|
fields: [
|
|
{ type: FieldType.CHAR, name: 'State (* alive, _ killed)', size: 1 },
|
|
],
|
|
});
|
|
}
|
|
offset += 1;
|
|
|
|
const timeStr = this.payload.substring(offset, offset + 7);
|
|
const { timestamp, segment: timestampSection } = this.parseTimestamp(timeStr, withStructure, offset);
|
|
if (!timestamp) {
|
|
return { payload: null };
|
|
}
|
|
if (timestampSection) {
|
|
segment.push(timestampSection);
|
|
}
|
|
offset += 7;
|
|
|
|
const positionOffset = offset;
|
|
const isCompressed = this.isCompressedPosition(this.payload.substring(offset));
|
|
|
|
let position: { latitude: number; longitude: number; symbol: any; ambiguity?: number; altitude?: number; comment?: string } | null = null;
|
|
let consumed = 0;
|
|
|
|
if (isCompressed) {
|
|
const { position: compressed, segment: compressedSection } = this.parseCompressedPosition(this.payload.substring(offset), withStructure, positionOffset);
|
|
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, positionOffset);
|
|
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;
|
|
const comment = this.payload.substring(offset);
|
|
if (comment) {
|
|
position.comment = comment;
|
|
|
|
if (withStructure) {
|
|
segment.push({
|
|
name: 'Comment',
|
|
data: new TextEncoder().encode(comment),
|
|
fields: [
|
|
{ type: FieldType.STRING, name: 'text', size: comment.length },
|
|
],
|
|
});
|
|
}
|
|
}
|
|
|
|
const payload: any = {
|
|
type: 'object',
|
|
name,
|
|
timestamp,
|
|
alive,
|
|
position,
|
|
};
|
|
|
|
if (withStructure) {
|
|
return { payload, segment };
|
|
}
|
|
|
|
return { payload };
|
|
} catch (e) {
|
|
return { payload: null };
|
|
}
|
|
}
|
|
|
|
private decodeItem(_withStructure: boolean = false): { payload: Payload | null; segment?: PacketSegment[] } {
|
|
// TODO: Implement item decoding with section emission
|
|
return { payload: null };
|
|
}
|
|
|
|
private decodeStatus(_withStructure: boolean = false): { payload: Payload | null; segment?: PacketSegment[] } {
|
|
// TODO: Implement status decoding with section emission
|
|
return { payload: null };
|
|
}
|
|
|
|
private decodeQuery(_withStructure: boolean = false): { payload: Payload | null; segment?: PacketSegment[] } {
|
|
// TODO: Implement query decoding with section emission
|
|
return { payload: null };
|
|
}
|
|
|
|
private decodeTelemetry(_withStructure: boolean = false): { payload: Payload | null; segment?: PacketSegment[] } {
|
|
// TODO: Implement telemetry decoding with section emission
|
|
return { payload: null };
|
|
}
|
|
|
|
private decodeWeather(_withStructure: boolean = false): { payload: Payload | null; segment?: PacketSegment[] } {
|
|
// TODO: Implement weather decoding with section emission
|
|
return { payload: null };
|
|
}
|
|
|
|
private decodeRawGPS(_withStructure: boolean = false): { payload: Payload | null; segment?: PacketSegment[] } {
|
|
// TODO: Implement raw GPS decoding with section emission
|
|
return { payload: null };
|
|
}
|
|
|
|
private decodeCapabilities(_withStructure: boolean = false): { payload: Payload | null; segment?: PacketSegment[] } {
|
|
// TODO: Implement capabilities decoding with section emission
|
|
return { payload: null };
|
|
}
|
|
|
|
private decodeUserDefined(_withStructure: boolean = false): { payload: Payload | null; segment?: PacketSegment[] } {
|
|
// TODO: Implement user-defined decoding with section emission
|
|
return { payload: null };
|
|
}
|
|
|
|
private decodeThirdParty(_withStructure: boolean = false): { payload: Payload | null; segment?: PacketSegment[] } {
|
|
// TODO: Implement third-party decoding with section emission
|
|
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
|
|
let offset = 0;
|
|
const sourceStr = parts[0];
|
|
const source = Address.fromString(sourceStr);
|
|
|
|
offset += sourceStr.length + 1; // +1 for '>'
|
|
|
|
// Parse destination and path
|
|
const destinationAndPath = parts[1].split(',');
|
|
const destinationStr = destinationAndPath[0];
|
|
const destination = Address.fromString(destinationStr);
|
|
|
|
offset += destinationStr.length;
|
|
|
|
// Parse path
|
|
const path: Address[] = [];
|
|
const pathFields: PacketField[] = [];
|
|
for (let i = 1; i < destinationAndPath.length; i++) {
|
|
offset += 1; // +1 for ','
|
|
const pathStr = destinationAndPath[i];
|
|
path.push(Address.fromString(pathStr));
|
|
|
|
pathFields.push({
|
|
type: FieldType.CHAR,
|
|
name: `Path separator ${i}`,
|
|
size: 1
|
|
});
|
|
pathFields.push({
|
|
type: FieldType.STRING,
|
|
name: `Repeater ${i}`,
|
|
size: pathStr.length,
|
|
});
|
|
offset += pathStr.length;
|
|
}
|
|
|
|
const routingSection: PacketSegment = {
|
|
name: 'Routing',
|
|
data: encoder.encode(data.slice(0, routeSepIndex)),
|
|
fields: [
|
|
{ type: FieldType.STRING, name: 'Source address', size: sourceStr.length },
|
|
{ type: FieldType.CHAR, name: 'Route separator', size: 1 },
|
|
{ type: FieldType.STRING, name: 'Destination address', size: destinationStr.length },
|
|
...pathFields,
|
|
{ type: FieldType.CHAR, name: 'Payload separator', size: 1 },
|
|
],
|
|
};
|
|
|
|
return new Frame(source, destination, path, payload, routingSection);
|
|
}
|