Files
hamview/ui/src/protocols/aprs.ts
maze e83df1c143
Some checks failed
Test and build / Test and lint (push) Failing after 35s
Test and build / Build collector (push) Failing after 36s
Test and build / Build receiver (push) Failing after 36s
More APRS enhancements
2026-03-05 22:24:09 +01:00

745 lines
21 KiB
TypeScript

import type { Address, Frame as IFrame, DecodedPayload, Timestamp as ITimestamp } from "./aprs.types"
import { base91ToNumber } from "../libs/base91"
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 const parseAddress = (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 {call, ssid, isRepeated};
}
export class Frame implements IFrame {
source: Address;
destination: Address;
path: Address[];
payload: string;
constructor(source: Address, destination: Address, path: Address[], payload: string) {
this.source = source;
this.destination = destination;
this.path = path;
this.payload = payload;
}
/**
* Get the data type identifier (first character of payload)
*/
getDataTypeIdentifier(): string {
return this.payload.charAt(0);
}
/**
* Decode the APRS payload based on its data type identifier
*/
decode(): DecodedPayload | null {
if (!this.payload) {
return null;
}
const dataType = this.getDataTypeIdentifier();
// 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
return this.decodePosition(dataType);
case '`': // Mic-E current
case "'": // Mic-E old
return this.decodeMicE();
case ':': // Message
return this.decodeMessage();
case ';': // Object
return this.decodeObject();
case ')': // Item
return this.decodeItem();
case '>': // Status
return this.decodeStatus();
case '?': // Query
return this.decodeQuery();
case 'T': // Telemetry
return this.decodeTelemetry();
case '_': // Weather without position
return this.decodeWeather();
case '$': // Raw GPS
return this.decodeRawGPS();
case '<': // Station capabilities
return this.decodeCapabilities();
case '{': // User-defined
return this.decodeUserDefined();
case '}': // Third-party
return this.decodeThirdParty();
default:
return null;
}
}
private decodePosition(dataType: string): DecodedPayload | null {
try {
const hasTimestamp = dataType === '/' || dataType === '@';
const messaging = dataType === '=' || dataType === '@';
let offset = 1; // Skip data type identifier
let timestamp: Timestamp | undefined = undefined;
// Parse timestamp if present (7 characters: DDHHMMz or HHMMSSh or MMDDHHMM)
if (hasTimestamp) {
if (this.payload.length < 8) return null;
const timeStr = this.payload.substring(offset, offset + 7);
timestamp = this.parseTimestamp(timeStr);
offset += 7;
}
if (this.payload.length < offset + 19) return null;
// Check if compressed format
const isCompressed = this.isCompressedPosition(this.payload.substring(offset));
let position: any;
let comment = '';
if (isCompressed) {
// Compressed format: /YYYYXXXX$csT
const compressed = this.parseCompressedPosition(this.payload.substring(offset));
if (!compressed) return null;
position = {
latitude: compressed.latitude,
longitude: compressed.longitude,
symbol: compressed.symbol,
};
if (compressed.altitude !== undefined) {
position.altitude = compressed.altitude;
}
offset += 13; // Compressed position is 13 chars
comment = this.payload.substring(offset);
} else {
// Uncompressed format: DDMMmmH/DDDMMmmH$
const uncompressed = this.parseUncompressedPosition(this.payload.substring(offset));
if (!uncompressed) return null;
position = {
latitude: uncompressed.latitude,
longitude: uncompressed.longitude,
symbol: uncompressed.symbol,
};
if (uncompressed.ambiguity !== undefined) {
position.ambiguity = uncompressed.ambiguity;
}
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;
}
return {
type: 'position',
timestamp,
position,
messaging,
};
} catch (e) {
return null;
}
}
private parseTimestamp(timeStr: string): Timestamp | undefined {
if (timeStr.length !== 7) return undefined;
const timeType = timeStr.charAt(6);
if (timeType === 'z') {
// DHM format: Day-Hour-Minute (UTC)
return new Timestamp(
parseInt(timeStr.substring(2, 4), 10),
parseInt(timeStr.substring(4, 6), 10),
'DHM',
{
day: parseInt(timeStr.substring(0, 2), 10),
zulu: true,
}
);
} else if (timeType === 'h') {
// HMS format: Hour-Minute-Second (UTC)
return new Timestamp(
parseInt(timeStr.substring(0, 2), 10),
parseInt(timeStr.substring(2, 4), 10),
'HMS',
{
seconds: parseInt(timeStr.substring(4, 6), 10),
zulu: true,
}
);
} else if (timeType === '/') {
// MDHM format: Month-Day-Hour-Minute (local)
return 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,
}
);
}
return undefined;
}
private isCompressedPosition(data: string): boolean {
if (data.length < 13) return false;
// Uncompressed format has / at position 8 (symbol table separator)
// Format: DDMMmmH/DDDMMmmH$ where / is at position 8
if (data.length >= 19 && data.charAt(8) === '/') {
return false; // It's uncompressed
}
// 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): { latitude: number; longitude: number; symbol: any; altitude?: number } | null {
if (data.length < 13) return 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
}
return result;
} catch (e) {
return null;
}
}
private parseUncompressedPosition(data: string): { latitude: number; longitude: number; symbol: any; ambiguity?: number } | null {
if (data.length < 19) return 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 null;
if (latHem !== 'N' && latHem !== 'S') return 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 null;
if (lonHem !== 'E' && lonHem !== 'W') return 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;
}
return result;
}
private decodeMicE(): DecodedPayload | null {
try {
// Mic-E encodes position in both destination address and information field
const dest = this.destination.call;
if (dest.length < 6) return null;
if (this.payload.length < 9) return null; // Need at least data type + 8 bytes
// Decode latitude from destination address (6 characters)
const latResult = this.decodeMicELatitude(dest);
if (!latResult) return 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 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 result;
} catch (e) {
return 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(): DecodedPayload | null {
// TODO: Implement message decoding
return null;
}
private decodeObject(): DecodedPayload | null {
// TODO: Implement object decoding
return null;
}
private decodeItem(): DecodedPayload | null {
// TODO: Implement item decoding
return null;
}
private decodeStatus(): DecodedPayload | null {
// TODO: Implement status decoding
return null;
}
private decodeQuery(): DecodedPayload | null {
// TODO: Implement query decoding
return null;
}
private decodeTelemetry(): DecodedPayload | null {
// TODO: Implement telemetry decoding
return null;
}
private decodeWeather(): DecodedPayload | null {
// TODO: Implement weather decoding
return null;
}
private decodeRawGPS(): DecodedPayload | null {
// TODO: Implement raw GPS decoding
return null;
}
private decodeCapabilities(): DecodedPayload | null {
// TODO: Implement capabilities decoding
return null;
}
private decodeUserDefined(): DecodedPayload | null {
// TODO: Implement user-defined decoding
return null;
}
private decodeThirdParty(): DecodedPayload | null {
// TODO: Implement third-party decoding
return null;
}
}
export const parseFrame = (data: string): Frame => {
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');
}
const source = parseAddress(parts[0]);
const destinationAndPath = parts[1].split(',');
const destination = parseAddress(destinationAndPath[0]);
const path = destinationAndPath.slice(1).map(addr => parseAddress(addr));
return new Frame(source, destination, path, payload);
}