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