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); }