From 75e31c2008231301b9c127200abd0b39204ad5c3 Mon Sep 17 00:00:00 2001 From: maze Date: Fri, 20 Mar 2026 10:38:36 +0100 Subject: [PATCH] Cleaned up the frame.ts by splitting payload parsing to subpackages --- src/frame.ts | 2461 +---------------- src/frame.types.ts | 24 +- src/index.ts | 5 +- src/payload.capabilities.ts | 71 + src/payload.extras.ts | 504 ++++ src/payload.item.ts | 149 + src/payload.message.ts | 94 + src/payload.mice.ts | 300 ++ src/payload.object.ts | 161 ++ src/payload.position.ts | 344 +++ src/payload.query.ts | 69 + src/payload.rawgps.ts | 161 ++ src/payload.status.ts | 79 + src/payload.telemetry.ts | 197 ++ src/payload.thirdparty.ts | 135 + src/payload.weather.ts | 129 + src/position.ts | 2 + src/timestamp.ts | 189 ++ test/frame.test.ts | 3 +- ...s.test.ts => payload.capabilities.test.ts} | 0 ....extras.test.ts => payload.extras.test.ts} | 47 + ...me.query.test.ts => payload.query.test.ts} | 0 ....rawgps.test.ts => payload.rawgps.test.ts} | 0 ...etry.test.ts => payload.telemetry.test.ts} | 0 ...ned.test.ts => payload.thirdparty.test.ts} | 0 ...eather.test.ts => payload.weather.test.ts} | 0 26 files changed, 2695 insertions(+), 2429 deletions(-) create mode 100644 src/payload.capabilities.ts create mode 100644 src/payload.extras.ts create mode 100644 src/payload.item.ts create mode 100644 src/payload.message.ts create mode 100644 src/payload.mice.ts create mode 100644 src/payload.object.ts create mode 100644 src/payload.position.ts create mode 100644 src/payload.query.ts create mode 100644 src/payload.rawgps.ts create mode 100644 src/payload.status.ts create mode 100644 src/payload.telemetry.ts create mode 100644 src/payload.thirdparty.ts create mode 100644 src/payload.weather.ts create mode 100644 src/timestamp.ts rename test/{frame.capabilities.test.ts => payload.capabilities.test.ts} (100%) rename test/{frame.extras.test.ts => payload.extras.test.ts} (84%) rename test/{frame.query.test.ts => payload.query.test.ts} (100%) rename test/{frame.rawgps.test.ts => payload.rawgps.test.ts} (100%) rename test/{frame.telemetry.test.ts => payload.telemetry.test.ts} (100%) rename test/{frame.userdefined.test.ts => payload.thirdparty.test.ts} (100%) rename test/{frame.weather.test.ts => payload.weather.test.ts} (100%) diff --git a/src/frame.ts b/src/frame.ts index afbd717..75c8092 100644 --- a/src/frame.ts +++ b/src/frame.ts @@ -1,137 +1,19 @@ import type { Dissected, Field, Segment } from "@hamradio/packet"; import { FieldType } from "@hamradio/packet"; -import { DTM, GGA, INmeaSentence, Decoder as NmeaDecoder, RMC } from "extended-nmea"; -import { - DO_NOT_ARCHIVE_MARKER, - DataType, - DataTypeNames, - type IAddress, - IDirectionFinding, - type IFrame, - type IPosition, - IPowerHeightGain, - type ITimestamp, - type ItemPayload, - type MessagePayload, - MicEPayload, - type ObjectPayload, - type Payload, - type PositionPayload, - type QueryPayload, - type RawGPSPayload, - type StationCapabilitiesPayload, - type StatusPayload, - type TelemetryBitSensePayload, - type TelemetryCoefficientsPayload, - type TelemetryDataPayload, - type TelemetryParameterPayload, - type TelemetryUnitPayload, - type ThirdPartyPayload, - type WeatherPayload -} from "./frame.types"; -import { base91ToNumber, feetToMeters, knotsToKmh, milesToMeters } from "./parser"; -import { Position } from "./position"; - -export class Timestamp implements ITimestamp { - day?: number; - month?: number; - hours: number; - minutes: number; - seconds?: number; - format: "DHM" | "HMS" | "MDHM"; - zulu?: boolean; - - constructor( - hours: number, - minutes: number, - format: "DHM" | "HMS" | "MDHM", - options: { - day?: number; - month?: number; - seconds?: number; - zulu?: boolean; - } = {} - ) { - this.hours = hours; - this.minutes = minutes; - this.format = format; - this.day = options.day; - this.month = options.month; - this.seconds = options.seconds; - this.zulu = options.zulu; - } - - /** - * Convert APRS timestamp to JavaScript Date object - * Note: APRS timestamps don't include year, so we use current year - * For DHM format, we find the most recent occurrence of that day - * For HMS format, we use current date - * For MDHM format, we use the specified month/day in current year - */ - toDate(): Date { - const now = new Date(); - - if (this.format === "DHM") { - // Day-Hour-Minute format (UTC) - // Find the most recent occurrence of this day - const currentYear = this.zulu ? now.getUTCFullYear() : now.getFullYear(); - const currentMonth = this.zulu ? now.getUTCMonth() : now.getMonth(); - - let date: Date; - if (this.zulu) { - date = new Date(Date.UTC(currentYear, currentMonth, this.day!, this.hours, this.minutes, 0, 0)); - } else { - date = new Date(currentYear, currentMonth, this.day!, this.hours, this.minutes, 0, 0); - } - - // If the date is in the future, it's from last month - if (date > now) { - if (this.zulu) { - date = new Date(Date.UTC(currentYear, currentMonth - 1, this.day!, this.hours, this.minutes, 0, 0)); - } else { - date = new Date(currentYear, currentMonth - 1, this.day!, this.hours, this.minutes, 0, 0); - } - } - - return date; - } else if (this.format === "HMS") { - // Hour-Minute-Second format (UTC) - // Use current date - if (this.zulu) { - const date = new Date(); - date.setUTCHours(this.hours, this.minutes, this.seconds || 0, 0); - - // If time is in the future, it's from yesterday - if (date > now) { - date.setUTCDate(date.getUTCDate() - 1); - } - - return date; - } else { - const date = new Date(); - date.setHours(this.hours, this.minutes, this.seconds || 0, 0); - - if (date > now) { - date.setDate(date.getDate() - 1); - } - - return date; - } - } else { - // MDHM format: Month-Day-Hour-Minute (local time) - const currentYear = now.getFullYear(); - let date = new Date(currentYear, (this.month || 1) - 1, this.day!, this.hours, this.minutes, 0, 0); - - // If date is in the future, it's from last year - if (date > now) { - date = new Date(currentYear - 1, (this.month || 1) - 1, this.day!, this.hours, this.minutes, 0, 0); - } - - return date; - } - } -} +import { DataType, DataTypeNames, type IAddress, type IFrame, type Payload } from "./frame.types"; +import decodeCapabilitiesPayload from "./payload.capabilities"; +import decodeItemPayload from "./payload.item"; +import decodeMessagePayload from "./payload.message"; +import decodeMicEPayload from "./payload.mice"; +import decodeObjectPayload from "./payload.object"; +import decodePositionPayload from "./payload.position"; +import decodeQueryPayload from "./payload.query"; +import decodeRawGPSPayload from "./payload.rawgps"; +import decodeStatusPayload from "./payload.status"; +import decodeTelemetryPayload from "./payload.telemetry"; +import { decodeThirdPartyPayload, decodeUserDefinedPayload } from "./payload.thirdparty"; +import decodeWeatherPayload from "./payload.weather"; export class Address implements IAddress { call: string; @@ -173,16 +55,6 @@ export class Address implements IAddress { } } -interface Extras { - comment: string; - altitude?: number; - range?: number; - phg?: IPowerHeightGain; - dfs?: IDirectionFinding; - cse?: number; - spd?: number; - fields?: Field[]; -} export class Frame implements IFrame { source: Address; destination: Address; @@ -249,56 +121,64 @@ export class Frame implements IFrame { 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)); + ({ payload: decodedPayload, segment: payloadsegment } = decodePositionPayload( + dataType, + this.payload, + withStructure + )); break; - case "`": // Mic-E current + case "`": // Mic-E case "'": // Mic-E old - ({ payload: decodedPayload, segment: payloadsegment } = this.decodeMicE(withStructure)); + ({ payload: decodedPayload, segment: payloadsegment } = decodeMicEPayload( + this.destination, + this.payload, + withStructure + )); break; case ":": // Message - ({ payload: decodedPayload, segment: payloadsegment } = this.decodeMessage(withStructure)); + ({ payload: decodedPayload, segment: payloadsegment } = decodeMessagePayload(this.payload, withStructure)); break; case ";": // Object - ({ payload: decodedPayload, segment: payloadsegment } = this.decodeObject(withStructure)); + ({ payload: decodedPayload, segment: payloadsegment } = decodeObjectPayload(this.payload, withStructure)); break; case ")": // Item - ({ payload: decodedPayload, segment: payloadsegment } = this.decodeItem(withStructure)); + ({ payload: decodedPayload, segment: payloadsegment } = decodeItemPayload(this.payload, withStructure)); break; case ">": // Status - ({ payload: decodedPayload, segment: payloadsegment } = this.decodeStatus(withStructure)); + ({ payload: decodedPayload, segment: payloadsegment } = decodeStatusPayload(this.payload, withStructure)); break; case "?": // Query - ({ payload: decodedPayload, segment: payloadsegment } = this.decodeQuery(withStructure)); + ({ payload: decodedPayload, segment: payloadsegment } = decodeQueryPayload(this.payload, withStructure)); break; case "T": // Telemetry - ({ payload: decodedPayload, segment: payloadsegment } = this.decodeTelemetry(withStructure)); + ({ payload: decodedPayload, segment: payloadsegment } = decodeTelemetryPayload(this.payload, withStructure)); break; case "_": // Weather without position - ({ payload: decodedPayload, segment: payloadsegment } = this.decodeWeather(withStructure)); + ({ payload: decodedPayload, segment: payloadsegment } = decodeWeatherPayload(this.payload, withStructure)); break; case "$": // Raw GPS - ({ payload: decodedPayload, segment: payloadsegment } = this.decodeRawGPS(withStructure)); + ({ payload: decodedPayload, segment: payloadsegment } = decodeRawGPSPayload(this.payload, withStructure)); break; case "<": // Station capabilities - ({ payload: decodedPayload, segment: payloadsegment } = this.decodeCapabilities(withStructure)); + ({ payload: decodedPayload, segment: payloadsegment } = decodeCapabilitiesPayload(this.payload, withStructure)); break; case "{": // User-defined - ({ payload: decodedPayload, segment: payloadsegment } = this.decodeUserDefined(withStructure)); + ({ payload: decodedPayload, segment: payloadsegment } = decodeUserDefinedPayload(this.payload, withStructure)); break; case "}": // Third-party - ({ payload: decodedPayload, segment: payloadsegment } = this.decodeThirdParty(withStructure)); + ({ payload: decodedPayload, segment: payloadsegment } = decodeThirdPartyPayload(this.payload, withStructure)); break; default: @@ -328,2277 +208,6 @@ export class Frame implements IFrame { return decodedPayload; } - private decodePosition( - dataType: string, - withStructure: boolean = false - ): { payload: Payload | null; segment?: Segment[] } { - try { - const hasTimestamp = dataType === "/" || dataType === "@"; - const messaging = dataType === "=" || dataType === "@"; - let offset = 1; // Skip data type identifier - - // Build structure as we parse - const structure: Segment[] = withStructure ? [] : []; - - let timestamp: Timestamp | undefined = undefined; - - // Parse timestamp if present (7 characters: DDHHMMz or HHMMSSh or MMDDHMMM) - if (hasTimestamp) { - if (this.payload.length < 8) return { payload: null }; - const timeStr = this.payload.substring(offset, offset + 7); - const { timestamp: parsedTimestamp, segment: timestampSegment } = this.parseTimestamp(timeStr, withStructure); - timestamp = parsedTimestamp; - - if (timestampSegment) { - structure.push(timestampSegment); - } - - offset += 7; - } - - // Need at least enough characters for compressed position (13) or - // uncompressed (19). Allow parsing to continue if compressed-length is present. - if (this.payload.length < offset + 13) return { payload: null }; - - // Check if compressed format - const isCompressed = this.isCompressedPosition(this.payload.substring(offset)); - - let position: Position; - let comment = ""; - - if (isCompressed) { - // Compressed format: /YYYYXXXX$csT - const { position: compressed, segment: compressedSegment } = this.parseCompressedPosition( - this.payload.substring(offset), - withStructure - ); - if (!compressed) return { payload: null }; - - position = new Position({ - latitude: compressed.latitude, - longitude: compressed.longitude, - symbol: compressed.symbol - }); - - if (compressed.altitude !== undefined) { - position.altitude = compressed.altitude; - } - - if (compressedSegment) { - structure.push(compressedSegment); - } - - offset += 13; // Compressed position is 13 chars - comment = this.payload.substring(offset); - } else { - // Uncompressed format: DDMMmmH/DDDMMmmH$ - const { position: uncompressed, segment: uncompressedSegment } = this.parseUncompressedPosition( - this.payload.substring(offset), - withStructure - ); - if (!uncompressed) return { payload: null }; - - position = new Position({ - latitude: uncompressed.latitude, - longitude: uncompressed.longitude, - symbol: uncompressed.symbol - }); - - if (uncompressed.ambiguity !== undefined) { - position.ambiguity = uncompressed.ambiguity; - } - - if (uncompressedSegment) { - structure.push(uncompressedSegment); - } - - offset += 19; // Uncompressed position is 19 chars - comment = this.payload.substring(offset); - } - - // Extract Altitude, CSE/SPD, RNG and PHG tokens and optionally emit sections - const remainder = comment; // Use the remaining comment text for parsing extras - const doNotArchive = remainder.includes(DO_NOT_ARCHIVE_MARKER); - const extras = this.parseCommentExtras(remainder, withStructure); - comment = extras.comment; - - if (comment) { - position.comment = comment; - - // Emit comment section as we parse - if (withStructure) { - structure.push({ - name: "comment", - data: new TextEncoder().encode(remainder).buffer, - isString: true, - fields: extras.fields || [] - }); - } - } else if (withStructure && extras.fields) { - // No free-text comment, but extras were present: emit a comment section containing only fields - structure.push({ - name: "comment", - data: new TextEncoder().encode("").buffer, - isString: true, - fields: extras.fields || [] - }); - } - - let payloadType: - | DataType.PositionNoTimestampNoMessaging - | DataType.PositionNoTimestampWithMessaging - | DataType.PositionWithTimestampNoMessaging - | DataType.PositionWithTimestampWithMessaging; - switch (dataType) { - case "!": - payloadType = DataType.PositionNoTimestampNoMessaging; - break; - case "=": - payloadType = DataType.PositionNoTimestampWithMessaging; - break; - case "/": - payloadType = DataType.PositionWithTimestampNoMessaging; - break; - case "@": - payloadType = DataType.PositionWithTimestampWithMessaging; - break; - default: - return { payload: null }; - } - - const payload: PositionPayload = { - type: payloadType, - doNotArchive, - timestamp, - position, - messaging - }; - this.attachExtras(payload, extras); - - if (withStructure) { - return { payload, segment: structure }; - } - - return { payload }; - } catch { - return { payload: null }; - } - } - - private parseTimestamp( - timeStr: string, - withStructure: boolean = false - ): { timestamp: Timestamp | undefined; segment?: Segment } { - if (timeStr.length !== 7) return { timestamp: undefined }; - - const timeType = timeStr.charAt(6); - - if (timeType === "z") { - // DHM format: Day-Hour-Minute (UTC) - const timestamp = new Timestamp( - parseInt(timeStr.substring(2, 4), 10), - parseInt(timeStr.substring(4, 6), 10), - "DHM", - { - day: parseInt(timeStr.substring(0, 2), 10), - zulu: true - } - ); - - const segment = withStructure - ? { - name: "timestamp", - data: new TextEncoder().encode(timeStr).buffer, - isString: true, - fields: [ - { type: FieldType.STRING, name: "day (DD)", length: 2 }, - { type: FieldType.STRING, name: "hour (HH)", length: 2 }, - { type: FieldType.STRING, name: "minute (MM)", length: 2 }, - { type: FieldType.CHAR, name: "timezone indicator", length: 1 } - ] - } - : undefined; - - return { timestamp, segment }; - } else if (timeType === "h") { - // HMS format: Hour-Minute-Second (UTC) - const timestamp = new Timestamp( - parseInt(timeStr.substring(0, 2), 10), - parseInt(timeStr.substring(2, 4), 10), - "HMS", - { - seconds: parseInt(timeStr.substring(4, 6), 10), - zulu: true - } - ); - - const segment = withStructure - ? { - name: "timestamp", - data: new TextEncoder().encode(timeStr).buffer, - isString: true, - fields: [ - { type: FieldType.STRING, name: "hour (HH)", length: 2 }, - { type: FieldType.STRING, name: "minute (MM)", length: 2 }, - { type: FieldType.STRING, name: "second (SS)", length: 2 }, - { type: FieldType.CHAR, name: "timezone indicator", length: 1 } - ] - } - : undefined; - - return { timestamp, segment }; - } else if (timeType === "/") { - // MDHM format: Month-Day-Hour-Minute (local) - const timestamp = new Timestamp( - parseInt(timeStr.substring(4, 6), 10), - parseInt(timeStr.substring(6, 8), 10), - "MDHM", - { - month: parseInt(timeStr.substring(0, 2), 10), - day: parseInt(timeStr.substring(2, 4), 10), - zulu: false - } - ); - - const segment = withStructure - ? { - name: "timestamp", - data: new TextEncoder().encode(timeStr).buffer, - isString: true, - fields: [ - { type: FieldType.STRING, name: "month (MM)", length: 2 }, - { type: FieldType.STRING, name: "day (DD)", length: 2 }, - { type: FieldType.STRING, name: "hour (HH)", length: 2 }, - { type: FieldType.STRING, name: "minute (MM)", length: 2 }, - { type: FieldType.CHAR, name: "timezone indicator", length: 1 } - ] - } - : undefined; - - return { timestamp, segment }; - } - - return { timestamp: undefined }; - } - - private isCompressedPosition(data: string): boolean { - if (data.length < 13) return false; - - // First prefer uncompressed detection by attempting an uncompressed parse. - // Uncompressed APRS positions do not have a fixed symbol table separator; - // position 8 is a symbol table identifier and may vary. - if (data.length >= 19) { - const uncompressed = this.parseUncompressedPosition(data, false); - if (uncompressed.position) { - return false; - } - } - - // For compressed format, check if the position part looks like base-91 encoded data - // Compressed format: STYYYYXXXXcsT where ST is symbol table/code - // Base-91 chars are in range 33-124 (! to |) - const lat1 = data.charCodeAt(1); - const lat2 = data.charCodeAt(2); - const lon1 = data.charCodeAt(5); - const lon2 = data.charCodeAt(6); - - return ( - lat1 >= 33 && lat1 <= 124 && lat2 >= 33 && lat2 <= 124 && lon1 >= 33 && lon1 <= 124 && lon2 >= 33 && lon2 <= 124 - ); - } - - private parseCompressedPosition( - data: string, - withStructure: boolean = false - ): { - position: IPosition | null; - segment?: Segment; - } { - if (data.length < 13) return { position: null }; - - const symbolTable = data.charAt(0); - const symbolCode = data.charAt(9); - - // Extract base-91 encoded position (4 characters each) - const latStr = data.substring(1, 5); - const lonStr = data.substring(5, 9); - - try { - // Decode base-91 encoded latitude and longitude - const latBase91 = base91ToNumber(latStr); - const lonBase91 = base91ToNumber(lonStr); - - // Convert to degrees - const latitude = 90 - latBase91 / 380926; - const longitude = -180 + lonBase91 / 190463; - - const result: IPosition = { - latitude, - longitude, - symbol: { - table: symbolTable, - code: symbolCode - } - }; - - // Check for compressed altitude (csT format) - const cs = data.charAt(10); - const t = data.charCodeAt(11); - - if (cs === " " && t >= 33 && t <= 124) { - // Compressed altitude: altitude = 1.002^(t-33) feet - const altFeet = Math.pow(1.002, t - 33); - result.altitude = feetToMeters(altFeet); // Convert to meters - } - - const section: Segment | undefined = withStructure - ? { - name: "position", - data: new TextEncoder().encode(data.substring(0, 13)).buffer, - isString: true, - fields: [ - { type: FieldType.CHAR, length: 1, name: "symbol table" }, - { type: FieldType.STRING, length: 4, name: "latitude" }, - { type: FieldType.STRING, length: 4, name: "longitude" }, - { type: FieldType.CHAR, length: 1, name: "symbol code" }, - { type: FieldType.CHAR, length: 1, name: "course/speed type" }, - { type: FieldType.CHAR, length: 1, name: "course/speed value" }, - { type: FieldType.CHAR, length: 1, name: "altitude" } - ] - } - : undefined; - - return { position: result, segment: section }; - } catch { - return { position: null }; - } - } - - private parseUncompressedPosition( - data: string, - withStructure: boolean = false - ): { - position: IPosition | null; - segment?: Segment; - } { - if (data.length < 19) return { position: null }; - - // Format: DDMMmmH/DDDMMmmH$ where H is hemisphere, $ is symbol code - // Positions: 0-7 (latitude), 8 (symbol table), 9-17 (longitude), 18 (symbol code) - // Spaces may replace rightmost digits for ambiguity/privacy - - const latStr = data.substring(0, 8); // DDMMmmH (8 chars: 49 03.50 N) - const symbolTable = data.charAt(8); - const lonStr = data.substring(9, 18); // DDDMMmmH (9 chars: 072 01.75 W) - const symbolCode = data.charAt(18); - - // Count and handle ambiguity (spaces in minutes part replace rightmost digits) - let ambiguity = 0; - const latSpaceCount = (latStr.match(/ /g) || []).length; - const lonSpaceCount = (lonStr.match(/ /g) || []).length; - - if (latSpaceCount > 0 || lonSpaceCount > 0) { - // Use the maximum space count (they should be the same, but be defensive) - ambiguity = Math.max(latSpaceCount, lonSpaceCount); - } - - // Replace spaces with zeros for parsing - const latStrNormalized = latStr.replace(/ /g, "0"); - const lonStrNormalized = lonStr.replace(/ /g, "0"); - - // Parse latitude - const latDeg = parseInt(latStrNormalized.substring(0, 2), 10); - const latMin = parseFloat(latStrNormalized.substring(2, 7)); - const latHem = latStrNormalized.charAt(7); - - if (isNaN(latDeg) || isNaN(latMin)) return { position: null }; - if (latHem !== "N" && latHem !== "S") return { position: null }; - - let latitude = latDeg + latMin / 60; - if (latHem === "S") latitude = -latitude; - - // Parse longitude - const lonDeg = parseInt(lonStrNormalized.substring(0, 3), 10); - const lonMin = parseFloat(lonStrNormalized.substring(3, 8)); - const lonHem = lonStrNormalized.charAt(8); - - if (isNaN(lonDeg) || isNaN(lonMin)) return { position: null }; - if (lonHem !== "E" && lonHem !== "W") return { position: null }; - - let longitude = lonDeg + lonMin / 60; - if (lonHem === "W") longitude = -longitude; - - const result: IPosition = { - latitude, - longitude, - symbol: { - table: symbolTable, - code: symbolCode - } - }; - - if (ambiguity > 0) { - result.ambiguity = ambiguity; - } - - const segment: Segment | undefined = withStructure - ? { - name: "position", - data: new TextEncoder().encode(data.substring(0, 19)).buffer, - isString: true, - fields: [ - { type: FieldType.STRING, length: 8, name: "latitude" }, - { type: FieldType.CHAR, length: 1, name: "symbol table" }, - { type: FieldType.STRING, length: 9, name: "longitude" }, - { type: FieldType.CHAR, length: 1, name: "symbol code" } - ] - } - : undefined; - - return { position: result, segment }; - } - - private parseCommentExtras(comment: string, withStructure: boolean = false): Extras { - if (!comment || comment.length === 0) return { comment }; - - const extras: Partial = {}; - const fields: Field[] = []; - const beforeFields: Field[] = []; - let altitudeOffset: number | undefined = undefined; - let altitudeFields: Field[] = []; - let commentOffset: number = 0; - let commentBefore: string | undefined = undefined; - - // eslint-disable-next-line no-useless-assignment - let match: RegExpMatchArray | null = null; - - // Process successive 7-byte data extensions at the start of the comment. - comment = comment.trimStart(); - let ext = comment; - while (ext.length >= 7) { - // We first process the altitude marker, because it may appear anywhere - // in the comment and we want to extract it and its value before - // processing other tokens that may be present. - // - // /A=NNNNNN -> altitude in feet (6 digits) - // /A=-NNNNN -> altitude in feet with leading minus for negative altitudes (5 digits) - const altMatch = ext.match(/\/A=(-\d{5}|\d{6})/); - if (altitudeOffset === undefined && altMatch) { - const altitude = feetToMeters(parseInt(altMatch[1], 10)); // feet to meters - if (isNaN(altitude)) { - break; // Invalid altitude format, stop parsing extras - } - extras.altitude = altitude; - - // Keep track of where the altitude token appears in the comment for structure purposes. - altitudeOffset = comment.indexOf(altMatch[0]); - - if (withStructure) { - altitudeFields = [ - { - type: FieldType.STRING, - name: "altitude marker", - data: new TextEncoder().encode("/A=").buffer, - value: "/A=", - length: 3 - }, - { - type: FieldType.STRING, - name: "altitude", - data: new TextEncoder().encode(altMatch[1]).buffer, - value: altitude.toFixed(1) + "m", - length: 6 - } - ]; - } - - if (altitudeOffset > 0) { - // Reset the comment with the altitude marker removed. - commentBefore = comment.substring(0, altitudeOffset); - comment = comment.substring(altitudeOffset + altMatch[0].length); - ext = commentBefore; // Continue processing extensions in the part of the comment before the altitude marker - commentOffset = 0; // Reset - continue; - } - - // remove altitude token from ext and advance ext for further parsing - commentOffset += altMatch[0].length; - ext = ext.replace(altMatch[0], "").trimStart(); - - continue; - } - - // RNGrrrr -> pre-calculated range in miles (4 digits) - if ((match = ext.match(/^RNG(\d{4})/))) { - const r = match[1]; - extras.range = milesToMeters(parseInt(r, 10)) / 1000.0; // Convert to kilometers - if (withStructure) { - (altitudeOffset !== undefined && commentOffset < altitudeOffset ? beforeFields : fields).push( - { - type: FieldType.STRING, - name: "range marker", - value: "RNG", - length: 3 - }, - { - type: FieldType.STRING, - name: "range (rrrr)", - length: 4, - value: extras.range.toFixed(1) + "km" - } - ); - } - - // remove range token from ext and advance ext for further parsing - if (commentBefore !== undefined && commentBefore.length > 0) { - commentBefore = commentBefore.substring(7); - ext = commentBefore; - } else { - commentOffset += 7; - ext = ext.substring(7); - } - - continue; - } - - // PHGphgd - //if (!extras.phg && ext.startsWith("PHG")) { - if (!extras.phg && (match = ext.match(/^PHG([0-9 ])([0-9 ])([0-9 ])([0-9 ])/))) { - // PHGphgd: p = power (0-9 or space), h = height (0-9 or space), g = gain (0-9 or space), d = directivity (0-9 or space) - const p = match[1]; - const h = match[2]; - const g = match[3]; - const d = match[4]; - const pNum = parseInt(p, 10); - const powerWatts = Number.isNaN(pNum) ? undefined : pNum * pNum; - const hIndex = h.charCodeAt(0) - 48; - const heightFeet = 10 * Math.pow(2, hIndex); - const heightMeters = feetToMeters(heightFeet); - const gNum = parseInt(g, 10); - const gainDbi = Number.isNaN(gNum) ? undefined : gNum; - const dNum = parseInt(d, 10); - let directivity: number | "omni" | "unknown" | undefined; - if (Number.isNaN(dNum)) { - directivity = undefined; - } else if (dNum === 0) { - directivity = "omni"; - } else if (dNum >= 1 && dNum <= 8) { - directivity = dNum * 45; - } else if (dNum === 9) { - directivity = "unknown"; - } - - extras.phg = { - power: powerWatts, - height: heightMeters, - gain: gainDbi, - directivity - }; - - if (withStructure) { - (altitudeOffset !== undefined && commentOffset < altitudeOffset ? beforeFields : fields).push( - { type: FieldType.STRING, name: "PHG marker", length: 3, value: "PHG" }, - { - type: FieldType.STRING, - name: "power (p)", - length: 1, - value: powerWatts !== undefined ? powerWatts.toString() + "W" : undefined - }, - { - type: FieldType.STRING, - name: "height (h)", - length: 1, - value: heightMeters !== undefined ? heightMeters.toString() + "m" : undefined - }, - { - type: FieldType.STRING, - name: "gain (g)", - length: 1, - value: gainDbi !== undefined ? gainDbi.toString() + "dBi" : undefined - }, - { - type: FieldType.STRING, - name: "directivity (d)", - length: 1, - value: - directivity !== undefined - ? typeof directivity === "number" - ? directivity.toString() + "°" - : directivity - : undefined - } - ); - } - - // remove PHG token from ext and advance ext for further parsing - if (commentBefore !== undefined && commentBefore.length > 0) { - commentBefore = commentBefore.substring(7); - } else { - commentOffset += 7; - } - ext = ext.substring(7).trimStart(); - - continue; - } - - // DFSshgd - if (ext.startsWith("DFS")) { - // DFSshgd: s = strength (0-9), h = height (0-9), g = gain (0-9), d = directivity (0-9) - const s = ext.charAt(3); - const h = ext.charAt(4); - const g = ext.charAt(5); - const d = ext.charAt(6); - - const sNum = parseInt(s, 10); - const hNum = parseInt(h, 10); - const gNum = parseInt(g, 10); - const dNum = parseInt(d, 10); - - // Strength: s = 0-9, direct value - const strength = Number.isNaN(sNum) ? undefined : sNum; - - // Height: h = 0-9, height = 10 * 2^h feet (spec: h is exponent) - const heightFeet = Number.isNaN(hNum) ? undefined : 10 * Math.pow(2, hNum); - const heightMeters = heightFeet !== undefined ? feetToMeters(heightFeet) : undefined; - - // Gain: g = 0-9, gain in dB - const gainDbi = Number.isNaN(gNum) ? undefined : gNum; - - // Directivity: d = 0-9, 0 = omni, 1-8 = d*45°, 9 = unknown - let directivity: number | "omni" | "unknown" | undefined; - if (Number.isNaN(dNum)) { - directivity = undefined; - } else if (dNum === 0) { - directivity = "omni"; - } else if (dNum >= 1 && dNum <= 8) { - directivity = dNum * 45; - } else if (dNum === 9) { - directivity = "unknown"; - } - - extras.dfs = { - strength, - height: heightMeters, - gain: gainDbi, - directivity - }; - - if (withStructure) { - (altitudeOffset !== undefined && commentOffset < altitudeOffset ? beforeFields : fields).push( - { type: FieldType.STRING, name: "DFS marker", length: 3, value: "DFS" }, - { - type: FieldType.STRING, - name: "strength (s)", - length: 1, - value: strength !== undefined ? strength.toString() : undefined - }, - { - type: FieldType.STRING, - name: "height (h)", - length: 1, - value: heightMeters !== undefined ? heightMeters.toString() + "m" : undefined - }, - { - type: FieldType.STRING, - name: "gain (g)", - length: 1, - value: gainDbi !== undefined ? gainDbi.toString() + "dBi" : undefined - }, - { - type: FieldType.STRING, - name: "directivity (d)", - length: 1, - value: - directivity !== undefined - ? typeof directivity === "number" - ? directivity.toString() + "°" - : directivity - : undefined - } - ); - } - - // remove DFS token from ext and advance ext for further parsing - if (commentBefore !== undefined && commentBefore.length > 0) { - commentBefore = commentBefore.substring(7); - } else { - commentOffset += 7; - } - ext = ext.substring(7).trimStart(); - - continue; - } - - // Course/Speed DDD/SSS (7 bytes: 3 digits / 3 digits) - if (extras.cse === undefined && /^\d{3}\/\d{3}/.test(ext)) { - const courseStr = ext.substring(0, 3); - const speedStr = ext.substring(4, 7); - extras.cse = parseInt(courseStr, 10); - extras.spd = knotsToKmh(parseInt(speedStr, 10)); - - if (withStructure) { - (altitudeOffset !== undefined && commentOffset < altitudeOffset ? beforeFields : fields).push( - { type: FieldType.STRING, name: "course", length: 3, value: extras.cse.toString() + "°" }, - { type: FieldType.CHAR, name: "marker", length: 1, value: "/" }, - { type: FieldType.STRING, name: "speed", length: 3, value: extras.spd.toString() + " km/h" } - ); - } - - // remove course/speed token from comment and advance ext for further parsing - ext = ext.substring(7).trimStart(); - - // If there is an 8-byte DF/NRQ following (leading '/'), parse that too - if (ext.length >= 8 && ext.charAt(0) === "/") { - const dfExt = ext.substring(0, 8); // e.g. /270/729 - const m = dfExt.match(/\/(\d{3})\/(\d{3})/); - if (m) { - const dfBearing = parseInt(m[1], 10); - const dfStrength = parseInt(m[2], 10); - if (extras.dfs === undefined) { - extras.dfs = {}; - } - extras.dfs.bearing = dfBearing; - extras.dfs.strength = dfStrength; - - if (withStructure) { - (altitudeOffset !== undefined && commentOffset < altitudeOffset ? beforeFields : fields).push( - { type: FieldType.STRING, name: "DF marker", length: 1, value: "/" }, - { type: FieldType.STRING, name: "bearing", length: 3, value: dfBearing.toString() + "°" }, - { type: FieldType.CHAR, name: "separator", length: 1, value: "/" }, - { type: FieldType.STRING, name: "strength", length: 3, value: dfStrength.toString() } - ); - } - - // remove DF token from ext and advance ext for further parsing - if (commentBefore !== undefined && commentBefore.length > 0) { - commentBefore = commentBefore.substring(8); - } else { - commentOffset += 8; - } - ext = ext.substring(8).trimStart(); - - continue; - } - } - continue; - } - - // No recognized 7+-byte extension at start - break; - } - - // Export comment with extras fields removed, if any were parsed. - if (commentOffset > 0 && commentBefore !== undefined && commentBefore.length > 0) { - extras.comment = commentBefore.substring(commentOffset) + comment; - } else if (commentBefore !== undefined && commentBefore.length > 0) { - extras.comment = commentBefore + comment; - } else { - extras.comment = comment.substring(commentOffset); - } - - if (withStructure) { - const commentBeforeFields: Field[] = commentBefore - ? [ - { - type: FieldType.STRING, - name: "comment", - length: commentBefore.length - } - ] - : []; - - const commentFields: Field[] = comment - ? [ - { - type: FieldType.STRING, - name: "comment", - length: comment.length - } - ] - : []; - - // Insert the altitude fields at the correct position in the comment section based on where the altitude token was located in the original comment. If there was no altitude token, put all fields at the start of the comment section. - extras.fields = [...beforeFields, ...commentBeforeFields, ...altitudeFields, ...fields, ...commentFields]; - } - - return extras as Extras; - } - - private attachExtras(payload: Payload, extras: Extras) { - if ("position" in payload && payload.position) { - if (extras.altitude !== undefined) { - payload.position.altitude = extras.altitude; - } - if (extras.range !== undefined) { - payload.position.range = extras.range; - } - if (extras.phg !== undefined) { - payload.position.phg = extras.phg; - } - if (extras.dfs !== undefined) { - payload.position.dfs = extras.dfs; - } - if (extras.cse !== undefined && payload.position.course === undefined) { - payload.position.course = extras.cse; - } - if (extras.spd !== undefined && payload.position.speed === undefined) { - payload.position.speed = extras.spd; - } - } - if ("altitude" in payload && payload.altitude === undefined && extras.altitude !== undefined) { - payload.altitude = extras.altitude; - } - if ("range" in payload && payload.range === undefined && extras.range !== undefined) { - payload.range = extras.range; - } - if ("phg" in payload && payload.phg === undefined && extras.phg !== undefined) { - payload.phg = extras.phg; - } - if ("dfs" in payload && payload.dfs === undefined && extras.dfs !== undefined) { - payload.dfs = extras.dfs; - } - if ("course" in payload && payload.course === undefined && extras.cse !== undefined) { - payload.course = extras.cse; - } - if ("speed" in payload && payload.speed === undefined && extras.spd !== undefined) { - payload.speed = extras.spd; - } - } - - private decodeMicE(withStructure: boolean = false): { - payload: Payload | null; - segment?: Segment[]; - } { - try { - // Mic-E encodes position in both destination address and information field - const dest = this.destination.call; - - if (dest.length < 6) return { payload: null }; - if (this.payload.length < 9) return { payload: null }; // Need at least data type + 8 bytes - - const segments: Segment[] = withStructure ? [] : []; - - // Decode latitude from destination address (6 characters) - const latResult = this.decodeMicELatitude(dest); - if (!latResult) return { payload: null }; - - const { latitude, messageType, longitudeOffset, isWest, isStandard } = latResult; - - if (withStructure) { - segments.push({ - name: "mic-E destination", - data: new TextEncoder().encode(dest).buffer, - isString: true, - fields: [ - { - type: FieldType.STRING, - name: "destination", - length: dest.length - } - ] - }); - } - - // Parse information field (skip data type identifier at position 0) - let offset = 1; - - // Longitude: 3 bytes (degrees, minutes, hundredths) - const lonDegRaw = this.payload.charCodeAt(offset) - 28; - const lonMinRaw = this.payload.charCodeAt(offset + 1) - 28; - const lonHunRaw = this.payload.charCodeAt(offset + 2) - 28; - offset += 3; - - // Apply longitude offset and hemisphere - let lonDeg = lonDegRaw; - if (longitudeOffset) { - lonDeg += 100; - } - if (lonDeg >= 180 && lonDeg <= 189) { - lonDeg -= 80; - } else if (lonDeg >= 190 && lonDeg <= 199) { - lonDeg -= 190; - } - - let longitude = lonDeg + lonMinRaw / 60.0 + lonHunRaw / 6000.0; - if (isWest) { - longitude = -longitude; - } - - // Speed and course: 3 bytes - const sp = this.payload.charCodeAt(offset) - 28; - const dc = this.payload.charCodeAt(offset + 1) - 28; - const se = this.payload.charCodeAt(offset + 2) - 28; - offset += 3; - - let speed = sp * 10 + Math.floor(dc / 10); // Speed in knots - let course = (dc % 10) * 100 + se; // Course in degrees - - if (course >= 400) course -= 400; - if (speed >= 800) speed -= 800; - - // Convert speed from knots to km/h - const speedKmh = knotsToKmh(speed); - - // 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); - const doNotArchive = remaining.includes(DO_NOT_ARCHIVE_MARKER); - let altitude: number | undefined = undefined; - let comment = remaining; - - // Check for altitude in old format - if (comment.length >= 4 && comment.charAt(3) === "}") { - try { - const altBase91 = comment.substring(0, 3); - altitude = base91ToNumber(altBase91) - 10000; // Relative to 10km below mean sea level - comment = comment.substring(4); // Remove altitude token from comment - } catch { - // Ignore altitude parsing errors - } - } - - // Parse RNG/PHG tokens from comment (defer attaching to result until created) - const remainder = comment; // Use the remaining comment text for parsing extras - const extras = this.parseCommentExtras(remainder, withStructure); - comment = extras.comment; - - let payloadType: DataType.MicE | DataType.MicEOld; - switch (this.payload.charAt(0)) { - case "`": - payloadType = DataType.MicE; - break; - case "'": - payloadType = DataType.MicEOld; - break; - default: - return { payload: null }; - } - - const result: MicEPayload = { - type: payloadType, - doNotArchive, - position: { - latitude, - longitude, - symbol: { - table: symbolTable, - code: symbolCode - } - }, - messageType, - isStandard - }; - - if (speed > 0) { - result.position.speed = speedKmh; - } - - if (course > 0 && course < 360) { - result.position.course = course; - } - - if (altitude !== undefined) { - result.position.altitude = altitude; - } - - if (comment) { - result.position.comment = comment; - } - - // Attach parsed extras if present - this.attachExtras(result, extras); - - if (withStructure) { - // Information field section (bytes after data type up to comment) - const infoData = this.payload.substring(1, offset); - segments.push({ - name: "mic-E info", - data: new TextEncoder().encode(infoData).buffer, - isString: true, - fields: [ - { type: FieldType.CHAR, name: "longitude deg", length: 1 }, - { type: FieldType.CHAR, name: "longitude min", length: 1 }, - { type: FieldType.CHAR, name: "longitude hundredths", length: 1 }, - { type: FieldType.CHAR, name: "speed byte", length: 1 }, - { type: FieldType.CHAR, name: "course byte 1", length: 1 }, - { type: FieldType.CHAR, name: "course byte 2", length: 1 }, - { type: FieldType.CHAR, name: "symbol code", length: 1 }, - { type: FieldType.CHAR, name: "symbol table", length: 1 } - ] - }); - - if (comment && comment.length > 0) { - segments.push({ - name: "comment", - data: new TextEncoder().encode(remainder).buffer, - isString: true, - fields: extras.fields || [] - }); - } else if (extras.fields) { - segments.push({ - name: "comment", - data: new TextEncoder().encode(remainder).buffer, - isString: true, - fields: extras.fields - }); - } - - return { payload: result, segment: segments }; - } - - return { payload: result }; - } catch { - return { payload: null }; - } - } - - private decodeMicELatitude(dest: string): { - latitude: number; - messageType: string; - longitudeOffset: boolean; - isWest: boolean; - isStandard: boolean; - } | null { - if (dest.length < 6) return null; - - // Each destination character encodes a latitude digit and message bits - const digits: number[] = []; - const messageBits: number[] = []; - - for (let i = 0; i < 6; i++) { - const code = dest.charCodeAt(i); - let digit: number; - let msgBit: number; - - if (code >= 48 && code <= 57) { - // '0'-'9' - digit = code - 48; - msgBit = 0; - } else if (code >= 65 && code <= 74) { - // 'A'-'J' (A=0, B=1, ... J=9) - digit = code - 65; - msgBit = 1; - } else if (code === 75) { - // 'K' means space (used for ambiguity) - digit = 0; - msgBit = 1; - } else if (code === 76) { - // 'L' means space - digit = 0; - msgBit = 0; - } else if (code >= 80 && code <= 89) { - // 'P'-'Y' custom message types (P=0, Q=1, R=2, ... Y=9) - digit = code - 80; - msgBit = 1; - } else if (code === 90) { - // 'Z' means space - digit = 0; - msgBit = 1; - } else { - return null; // Invalid character - } - - digits.push(digit); - messageBits.push(msgBit); - } - - // Decode latitude: format is DDMM.HH (degrees, minutes, hundredths) - const latDeg = digits[0] * 10 + digits[1]; - const latMin = digits[2] * 10 + digits[3]; - const latHun = digits[4] * 10 + digits[5]; - - let latitude = latDeg + latMin / 60.0 + latHun / 6000.0; - - // Message bits determine hemisphere and other flags - // Bit 3 (messageBits[3]): 0 = North, 1 = South - // Bit 4 (messageBits[4]): 0 = West, 1 = East - // Bit 5 (messageBits[5]): 0 = longitude offset +0, 1 = longitude offset +100 - const isNorth = messageBits[3] === 0; - const isWest = messageBits[4] === 0; - const longitudeOffset = messageBits[5] === 1; - - if (!isNorth) { - latitude = -latitude; - } - - // Decode message type from bits 0, 1, 2 - const msgValue = messageBits[0] * 4 + messageBits[1] * 2 + messageBits[2]; - const messageTypes = [ - "M0: Off Duty", - "M1: En Route", - "M2: In Service", - "M3: Returning", - "M4: Committed", - "M5: Special", - "M6: Priority", - "M7: Emergency" - ]; - const messageType = messageTypes[msgValue] || "Unknown"; - - // Standard vs custom message indicator - const isStandard = messageBits[0] === 1; - - return { - latitude, - messageType, - longitudeOffset, - isWest, - isStandard - }; - } - - private decodeMessage(withStructure: boolean = false): { - payload: Payload | null; - segment?: Segment[]; - } { - // Message format: :AAAAAAAAA[ ]:message text - // where AAAAAAAAA is a 9-character recipient field (padded with spaces) - if (this.payload.length < 2) return { payload: null }; - - let offset = 1; // skip ':' data type - const segments: Segment[] = withStructure ? [] : []; - - // Attempt to read a 9-char recipient field if present - let recipient = ""; - if (this.payload.length >= offset + 1) { - // Try to read up to 9 chars for recipient, but stop early if a ':' separator appears - const look = this.payload.substring(offset, Math.min(offset + 9, this.payload.length)); - const sepIdx = look.indexOf(":"); - let raw = look; - if (sepIdx !== -1) { - raw = look.substring(0, sepIdx); - } else if (look.length < 9 && this.payload.length >= offset + 9) { - // pad to full 9 chars if possible - raw = this.payload.substring(offset, offset + 9); - } else if (look.length === 9) { - raw = look; - } - - recipient = raw.trimEnd(); - if (withStructure) { - segments.push({ - name: "recipient", - data: new TextEncoder().encode(raw).buffer, - isString: true, - fields: [{ type: FieldType.STRING, name: "to", length: 9 }] - }); - } - - // Advance offset past the raw we consumed - offset += raw.length; - // If there was a ':' immediately after the consumed raw, skip it as separator - if (this.payload.charAt(offset) === ":") { - offset += 1; - } else if (sepIdx !== -1) { - // Shouldn't normally happen, but ensure we advance past separator - offset += 1; - } - } - - // After recipient there is typically a space and a colon separator before the text - // Find the first ':' after the recipient (it separates the address field from the text) - let textStart = this.payload.indexOf(":", offset); - if (textStart === -1) { - // No explicit separator; skip any spaces and take remainder as text - while (this.payload.charAt(offset) === " " && offset < this.payload.length) offset += 1; - textStart = offset - 1; - } - - let text = ""; - if (textStart >= 0 && textStart + 1 <= this.payload.length) { - text = this.payload.substring(textStart + 1); - } - const doNotArchive = text.includes(DO_NOT_ARCHIVE_MARKER); - - const payload: MessagePayload = { - type: DataType.Message, - variant: "message", - doNotArchive, - addressee: recipient, - text - }; - - if (withStructure) { - // Emit text section - segments.push({ - name: "text", - data: new TextEncoder().encode(text).buffer, - isString: true, - fields: [{ type: FieldType.STRING, name: "text", length: text.length }] - }); - - return { payload, segment: segments }; - } - - return { payload }; - } - - private decodeObject(withStructure: boolean = false): { - payload: Payload | null; - segment?: Segment[]; - } { - try { - // Object format: ;AAAAAAAAAcDDHHMMzDDMM.hhN/DDDMM.hhW$comment - // ^ data type - // 9-char name - // alive (*) / killed (_) - if (this.payload.length < 18) return { payload: null }; // 1 + 9 + 1 + 7 minimum - - let offset = 1; // Skip data type identifier ';' - const segment: Segment[] = withStructure ? [] : []; - - const rawName = this.payload.substring(offset, offset + 9); - const name = rawName.trimEnd(); - if (withStructure) { - segment.push({ - name: "object", - data: new TextEncoder().encode(rawName).buffer, - isString: true, - fields: [{ type: FieldType.STRING, name: "name", length: 9 }] - }); - } - offset += 9; - - const stateChar = this.payload.charAt(offset); - if (stateChar !== "*" && stateChar !== "_") { - return { payload: null }; - } - const alive = stateChar === "*"; - if (withStructure) { - let state: string = "invalid"; - if (stateChar === "*") { - state = "alive"; - } else if (stateChar === "_") { - state = "killed"; - } - segment[segment.length - 1].data = new TextEncoder().encode( - this.payload.substring(offset - 9, offset + 1) - ).buffer; - segment[segment.length - 1].fields.push({ - type: FieldType.CHAR, - name: "state", - length: 1, - value: state - }); - } - offset += 1; - - const timeStr = this.payload.substring(offset, offset + 7); - const { timestamp, segment: timestampSection } = this.parseTimestamp(timeStr, withStructure); - if (!timestamp) { - return { payload: null }; - } - if (timestampSection) { - segment.push(timestampSection); - } - offset += 7; - - const isCompressed = this.isCompressedPosition(this.payload.substring(offset)); - - let position: IPosition | null = null; - let consumed = 0; - - if (isCompressed) { - const { position: compressed, segment: compressedSection } = this.parseCompressedPosition( - this.payload.substring(offset), - withStructure - ); - if (!compressed) return { payload: null }; - - position = { - latitude: compressed.latitude, - longitude: compressed.longitude, - symbol: compressed.symbol, - altitude: compressed.altitude - }; - consumed = 13; - - if (compressedSection) { - segment.push(compressedSection); - } - } else { - const { position: uncompressed, segment: uncompressedSection } = this.parseUncompressedPosition( - this.payload.substring(offset), - withStructure - ); - if (!uncompressed) return { payload: null }; - - position = { - latitude: uncompressed.latitude, - longitude: uncompressed.longitude, - symbol: uncompressed.symbol, - ambiguity: uncompressed.ambiguity - }; - consumed = 19; - - if (uncompressedSection) { - segment.push(uncompressedSection); - } - } - - offset += consumed; - const remainder = this.payload.substring(offset); - const doNotArchive = remainder.includes(DO_NOT_ARCHIVE_MARKER); - let comment = remainder; - - // Parse RNG/PHG tokens - const extras = this.parseCommentExtras(comment, withStructure); - comment = extras.comment; - - if (comment) { - position.comment = comment; - - if (withStructure) { - segment.push({ - name: "comment", - data: new TextEncoder().encode(remainder).buffer, - isString: true, - fields: extras.fields || [] - }); - } - } else if (withStructure && extras.fields) { - segment.push({ - name: "comment", - data: new TextEncoder().encode(remainder).buffer, - isString: true, - fields: extras.fields || [] - }); - } - - const payload: ObjectPayload = { - type: DataType.Object, - doNotArchive, - name, - timestamp, - alive, - position - }; - this.attachExtras(payload, extras); - - if (withStructure) { - return { payload, segment }; - } - - return { payload }; - } catch { - return { payload: null }; - } - } - - private decodeItem(withStructure: boolean = false): { - payload: Payload | null; - segment?: Segment[]; - } { - // Item format is similar to Object but name may be 3-9 chars (stored in a 9-char field) - // Example: )NNN... where ) is data type, next 9 chars are name, then state char, then timestamp, then position - if (this.payload.length < 12) return { payload: null }; // minimal: 1 + 3 + 1 + 7 - - let offset = 1; // skip data type identifier ')' - const segment: Segment[] = withStructure ? [] : []; - - // Read 9-char name field (pad/truncate as present) - const rawName = this.payload.substring(offset, offset + 9); - const name = rawName.trimEnd(); - if (withStructure) { - segment.push({ - name: "item name", - data: new TextEncoder().encode(rawName).buffer, - isString: true, - fields: [{ type: FieldType.STRING, name: "name", length: 9 }] - }); - } - offset += 9; - - // State character: '*' = alive, '_' = killed - const stateChar = this.payload.charAt(offset); - if (stateChar !== "*" && stateChar !== "_") { - return { payload: null }; - } - const alive = stateChar === "*"; - if (withStructure) { - segment.push({ - name: "item state", - data: new TextEncoder().encode(stateChar).buffer, - isString: true, - fields: [ - { - type: FieldType.CHAR, - name: "State (* alive, _ killed)", - length: 1 - } - ] - }); - } - offset += 1; - - // Timestamp (7 chars) - const timeStr = this.payload.substring(offset, offset + 7); - const { timestamp, segment: timestampSection } = this.parseTimestamp(timeStr.substring(offset), withStructure); - if (!timestamp) return { payload: null }; - if (timestampSection) segment.push(timestampSection); - offset += 7; - - const isCompressed = this.isCompressedPosition(this.payload.substring(offset)); - - // eslint-disable-next-line no-useless-assignment - let position: IPosition | null = null; - // eslint-disable-next-line no-useless-assignment - let consumed = 0; - - if (isCompressed) { - const { position: compressed, segment: compressedSection } = this.parseCompressedPosition( - this.payload.substring(offset), - withStructure - ); - if (!compressed) return { payload: null }; - - position = { - latitude: compressed.latitude, - longitude: compressed.longitude, - symbol: compressed.symbol, - altitude: compressed.altitude - }; - consumed = 13; - - if (compressedSection) segment.push(compressedSection); - } else { - const { position: uncompressed, segment: uncompressedSection } = this.parseUncompressedPosition( - this.payload.substring(offset), - withStructure - ); - if (!uncompressed) return { payload: null }; - - position = { - latitude: uncompressed.latitude, - longitude: uncompressed.longitude, - symbol: uncompressed.symbol, - ambiguity: uncompressed.ambiguity - }; - consumed = 19; - - if (uncompressedSection) segment.push(uncompressedSection); - } - - offset += consumed; - const remainder = this.payload.substring(offset); - const doNotArchive = remainder.includes(DO_NOT_ARCHIVE_MARKER); - let comment = remainder; - - const extras = this.parseCommentExtras(comment, withStructure); - comment = extras.comment; - - if (comment) { - position.comment = comment; - if (withStructure) { - segment.push({ - name: "comment", - data: new TextEncoder().encode(remainder).buffer, - isString: true, - fields: extras.fields || [] - }); - } - } else if (withStructure && extras.fields) { - // No free-text comment, but extras fields exist: emit comment-only segment - segment.push({ - name: "comment", - data: new TextEncoder().encode(remainder).buffer, - isString: true, - fields: extras.fields || [] - }); - } - - const payload: ItemPayload = { - type: DataType.Item, - doNotArchive, - name, - alive, - position - }; - this.attachExtras(payload, extras); - - if (withStructure) { - return { payload, segment }; - } - - return { payload }; - } - - private decodeStatus(withStructure: boolean = false): { - payload: Payload | null; - segment?: Segment[]; - } { - // Status payload: optional 7-char timestamp followed by free text. - // We'll also detect a trailing Maidenhead locator (4 or 6 chars) and expose it. - const offsetBase = 1; // skip data type identifier '>' - if (this.payload.length <= offsetBase) return { payload: null }; - - let offset = offsetBase; - const segments: Segment[] = withStructure ? [] : []; - - // Try parse optional timestamp (7 chars) - if (this.payload.length >= offset + 7) { - const timeStr = this.payload.substring(offset, offset + 7); - const { timestamp, segment: tsSegment } = this.parseTimestamp(timeStr, withStructure); - if (timestamp) { - offset += 7; - if (tsSegment) segments.push(tsSegment); - } - } - - // Remaining text is status text - const text = this.payload.substring(offset); - if (!text) return { payload: null }; - const doNotArchive = text.includes(DO_NOT_ARCHIVE_MARKER); - - // Detect trailing Maidenhead locator (4 or 6 chars) at end of text separated by space - let maidenhead: string | undefined; - const mhMatch = text.match(/\s([A-Ra-r]{2}\d{2}(?:[A-Ra-r]{2})?)$/); - let statusText = text; - if (mhMatch) { - maidenhead = mhMatch[1].toUpperCase(); - statusText = text.slice(0, mhMatch.index).trimEnd(); - } - - const payload: StatusPayload = { - type: DataType.Status, - doNotArchive, - timestamp: undefined, - text: statusText - }; - - // If timestamp was parsed, attach it - if (segments.length > 0) { - // The first segment may be timestamp; parseTimestamp returns the Timestamp object - // Re-parse to obtain timestamp object (cheap) - alternate would be to capture earlier - const timeSegment = segments.find((s) => s.name === "timestamp"); - if (timeSegment) { - const tsStr = new TextDecoder().decode(timeSegment.data); - const { timestamp } = this.parseTimestamp(tsStr, false); - if (timestamp) payload.timestamp = timestamp; - } - } - - if (maidenhead) payload.maidenhead = maidenhead; - - if (withStructure) { - segments.push({ - name: "status", - data: new TextEncoder().encode(text).buffer, - isString: true, - fields: [{ type: FieldType.STRING, name: "text", length: text.length }] - }); - return { payload, segment: segments }; - } - - return { payload }; - } - - private decodeQuery(withStructure: boolean = false): { - payload: Payload | null; - segment?: Segment[]; - } { - try { - if (this.payload.length < 2) return { payload: null }; - - // Skip data type identifier '?' - const segments: Segment[] = withStructure ? [] : []; - - // Remaining payload - const rest = this.payload.substring(1).trim(); - if (!rest) return { payload: null }; - - // Query type is the first token (up to first space) - const firstSpace = rest.indexOf(" "); - let queryType = ""; - let target: string | undefined = undefined; - - if (firstSpace === -1) { - queryType = rest; - } else { - queryType = rest.substring(0, firstSpace); - target = rest.substring(firstSpace + 1).trim(); - if (target === "") target = undefined; - } - - if (!queryType) return { payload: null }; - - if (withStructure) { - // Emit query type section - segments.push({ - name: "query type", - data: new TextEncoder().encode(queryType).buffer, - isString: true, - fields: [{ type: FieldType.STRING, name: "type", length: queryType.length }] - }); - - if (target) { - segments.push({ - name: "query target", - data: new TextEncoder().encode(target).buffer, - isString: true, - fields: [{ type: FieldType.STRING, name: "target", length: target.length }] - }); - } - } - - const payload: QueryPayload = { - type: DataType.Query, - queryType, - ...(target ? { target } : {}) - }; - - if (withStructure) return { payload, segment: segments }; - return { payload }; - } catch { - return { payload: null }; - } - } - - private decodeTelemetry(withStructure: boolean = false): { - payload: Payload | null; - segment?: Segment[]; - } { - try { - if (this.payload.length < 2) return { payload: null }; - - const rest = this.payload.substring(1).trim(); - if (!rest) return { payload: null }; - - const segments: Segment[] = withStructure ? [] : []; - - // Telemetry data: convention used here: starts with '#' then sequence then analogs and digital - if (rest.startsWith("#")) { - const parts = rest.substring(1).trim().split(/\s+/); - const seq = parseInt(parts[0], 10); - let analog: number[] = []; - let digital = 0; - - if (parts.length >= 2) { - // analogs as comma separated - analog = parts[1].split(",").map((v) => parseFloat(v)); - } - - if (parts.length >= 3) { - digital = parseInt(parts[2], 10); - } - - if (withStructure) { - segments.push({ - name: "telemetry sequence", - data: new TextEncoder().encode(String(seq)).buffer, - isString: true, - fields: [ - { - type: FieldType.STRING, - name: "sequence", - length: String(seq).length - } - ] - }); - - segments.push({ - name: "telemetry analog", - data: new TextEncoder().encode(parts[1] || "").buffer, - isString: true, - fields: [ - { - type: FieldType.STRING, - name: "analogs", - length: (parts[1] || "").length - } - ] - }); - - segments.push({ - name: "telemetry digital", - data: new TextEncoder().encode(String(digital)).buffer, - isString: true, - fields: [ - { - type: FieldType.STRING, - name: "digital", - length: String(digital).length - } - ] - }); - } - - const payload: TelemetryDataPayload = { - type: DataType.TelemetryData, - variant: "data", - sequence: isNaN(seq) ? 0 : seq, - analog, - digital: isNaN(digital) ? 0 : digital - }; - - if (withStructure) return { payload, segment: segments }; - return { payload }; - } - - // Telemetry parameters: 'PARAM' keyword - if (/^PARAM/i.test(rest)) { - const after = rest.replace(/^PARAM\s*/i, ""); - const names = after.split(/[,\s]+/).filter(Boolean); - if (withStructure) { - segments.push({ - name: "telemetry parameters", - data: new TextEncoder().encode(after).buffer, - isString: true, - fields: [{ type: FieldType.STRING, name: "names", length: after.length }] - }); - } - const payload: TelemetryParameterPayload = { - type: DataType.TelemetryData, - variant: "parameters", - names - }; - if (withStructure) return { payload, segment: segments }; - return { payload }; - } - - // Telemetry units: 'UNIT' - if (/^UNIT/i.test(rest)) { - const after = rest.replace(/^UNIT\s*/i, ""); - const units = after.split(/[,\s]+/).filter(Boolean); - if (withStructure) { - segments.push({ - name: "telemetry units", - data: new TextEncoder().encode(after).buffer, - isString: true, - fields: [{ type: FieldType.STRING, name: "units", length: after.length }] - }); - } - const payload: TelemetryUnitPayload = { - type: DataType.TelemetryData, - variant: "unit", - units - }; - if (withStructure) return { payload, segment: segments }; - return { payload }; - } - - // Telemetry coefficients: 'COEFF' a:,b:,c: - if (/^COEFF/i.test(rest)) { - const after = rest.replace(/^COEFF\s*/i, ""); - const aMatch = after.match(/A:([^\s;]+)/i); - const bMatch = after.match(/B:([^\s;]+)/i); - const cMatch = after.match(/C:([^\s;]+)/i); - const parseList = (s?: string) => (s ? s.split(",").map((v) => parseFloat(v)) : []); - const coefficients = { - a: parseList(aMatch?.[1]), - b: parseList(bMatch?.[1]), - c: parseList(cMatch?.[1]) - }; - if (withStructure) { - segments.push({ - name: "telemetry coefficients", - data: new TextEncoder().encode(after).buffer, - isString: true, - fields: [{ type: FieldType.STRING, name: "coeffs", length: after.length }] - }); - } - const payload: TelemetryCoefficientsPayload = { - type: DataType.TelemetryData, - variant: "coefficients", - coefficients - }; - if (withStructure) return { payload, segment: segments }; - return { payload }; - } - - // Telemetry bitsense/project: 'BITS' [project] - if (/^BITS?/i.test(rest)) { - const parts = rest.split(/\s+/).slice(1); - const sense = parts.length > 0 ? parseInt(parts[0], 10) : 0; - const projectName = parts.length > 1 ? parts.slice(1).join(" ") : undefined; - if (withStructure) { - segments.push({ - name: "telemetry bitsense", - data: new TextEncoder().encode(rest).buffer, - isString: true, - fields: [{ type: FieldType.STRING, name: "bitsense", length: rest.length }] - }); - } - const payload: TelemetryBitSensePayload = { - type: DataType.TelemetryData, - variant: "bitsense", - sense: isNaN(sense) ? 0 : sense, - ...(projectName ? { projectName } : {}) - }; - if (withStructure) return { payload, segment: segments }; - return { payload }; - } - - return { payload: null }; - } catch { - return { payload: null }; - } - } - - private decodeWeather(withStructure: boolean = false): { - payload: Payload | null; - segment?: Segment[]; - } { - try { - if (this.payload.length < 2) return { payload: null }; - - let offset = 1; // skip '_' data type - const segments: Segment[] = withStructure ? [] : []; - - // Try optional timestamp (7 chars) - let timestamp; - if (this.payload.length >= offset + 7) { - const timeStr = this.payload.substring(offset, offset + 7); - const parsed = this.parseTimestamp(timeStr, withStructure); - timestamp = parsed.timestamp; - if (parsed.segment) { - segments.push(parsed.segment); - } - if (timestamp) offset += 7; - } - - // Try optional position following timestamp - let position: IPosition | undefined; - let consumed = 0; - const tail = this.payload.substring(offset); - if (tail.length > 0) { - // If the tail starts with a wind token like DDD/SSS, treat it as weather data - // and do not attempt to parse it as a position (avoids mis-detecting wind - // values as compressed position fields). - if (/^\s*\d{3}\/\d{1,3}/.test(tail)) { - // no position present; leave consumed = 0 - } else if (this.isCompressedPosition(tail)) { - const parsed = this.parseCompressedPosition(tail, withStructure); - if (parsed.position) { - position = { - latitude: parsed.position.latitude, - longitude: parsed.position.longitude, - symbol: parsed.position.symbol, - altitude: parsed.position.altitude - }; - if (parsed.segment) segments.push(parsed.segment); - consumed = 13; - } - } else { - const parsed = this.parseUncompressedPosition(tail, withStructure); - if (parsed.position) { - position = { - latitude: parsed.position.latitude, - longitude: parsed.position.longitude, - symbol: parsed.position.symbol, - ambiguity: parsed.position.ambiguity - }; - if (parsed.segment) segments.push(parsed.segment); - consumed = 19; - } - } - } - - offset += consumed; - - const rest = this.payload.substring(offset).trim(); - - const payload: WeatherPayload = { - type: DataType.WeatherReportNoPosition - }; - if (timestamp) payload.timestamp = timestamp; - if (position) payload.position = position; - - if (rest && rest.length > 0) { - // Parse common tokens - // Wind: DDD/SSS [gGGG] - const windMatch = rest.match(/(\d{3})\/(\d{1,3})(?:g(\d{1,3}))?/); - if (windMatch) { - payload.windDirection = parseInt(windMatch[1], 10); - payload.windSpeed = parseInt(windMatch[2], 10); - if (windMatch[3]) payload.windGust = parseInt(windMatch[3], 10); - } - - // Temperature: tNNN (F) - const tempMatch = rest.match(/t(-?\d{1,3})/i); - if (tempMatch) payload.temperature = parseInt(tempMatch[1], 10); - - // Rain: rNNN (last hour), pNNN (24h), PNNN (since midnight) - values are hundredths of inch - const rMatch = rest.match(/r(\d{3})/); - if (rMatch) payload.rainLastHour = parseInt(rMatch[1], 10); - const pMatch = rest.match(/p(\d{3})/); - if (pMatch) payload.rainLast24Hours = parseInt(pMatch[1], 10); - const PMatch = rest.match(/P(\d{3})/); - if (PMatch) payload.rainSinceMidnight = parseInt(PMatch[1], 10); - - // Humidity: hNN - const hMatch = rest.match(/h(\d{1,3})/); - if (hMatch) payload.humidity = parseInt(hMatch[1], 10); - - // Pressure: bXXXX or bXXXXX (tenths of millibar) - const bMatch = rest.match(/b(\d{4,5})/); - if (bMatch) payload.pressure = parseInt(bMatch[1], 10); - - // Add raw comment - payload.comment = rest; - - if (withStructure) { - segments.push({ - name: "weather", - data: new TextEncoder().encode(rest).buffer, - isString: true, - fields: [{ type: FieldType.STRING, name: "text", length: rest.length }] - }); - } - } - - if (withStructure) return { payload, segment: segments }; - return { payload }; - } catch { - return { payload: null }; - } - } - - private decodeRawGPS(withStructure: boolean = false): { - payload: Payload | null; - segment?: Segment[]; - } { - try { - if (this.payload.length < 2) return { payload: null }; - - // Raw GPS payloads start with '$' followed by an NMEA sentence - const sentence = this.payload.substring(1).trim(); - - // Attempt to parse with extended-nmea Decoder to extract position (best-effort) - let parsed: INmeaSentence | null = null; - try { - const full = sentence.startsWith("$") ? sentence : `$${sentence}`; - parsed = NmeaDecoder.decode(full); - } catch { - // ignore parse errors - accept any sentence as raw-gps per APRS - } - - const payload: RawGPSPayload = { - type: DataType.RawGPS, - sentence - }; - - // If parse produced latitude/longitude, attach structured position. - // Otherwise fallback to a minimal NMEA parser for common sentences (RMC, GGA). - if ( - parsed && - (parsed instanceof RMC || parsed instanceof GGA || parsed instanceof DTM) && - parsed.latitude && - parsed.longitude - ) { - // extended-nmea latitude/longitude are GeoCoordinate objects with - // fields { degrees, decimal, quadrant } - const latObj = parsed.latitude; - const lonObj = parsed.longitude; - const lat = latObj.degrees + (Number(latObj.decimal) || 0) / 60.0; - const lon = lonObj.degrees + (Number(lonObj.decimal) || 0) / 60.0; - const latitude = latObj.quadrant === "S" ? -lat : lat; - const longitude = lonObj.quadrant === "W" ? -lon : lon; - - const pos: IPosition = { - latitude, - longitude - }; - - // altitude - if ("altMean" in parsed && parsed.altMean !== undefined) { - pos.altitude = Number(parsed.altMean); - } - if ("altitude" in parsed && parsed.altitude !== undefined) { - pos.altitude = Number(parsed.altitude); - } - - // speed/course (RMC fields) - if ("speedOverGround" in parsed && parsed.speedOverGround !== undefined) { - pos.speed = Number(parsed.speedOverGround); - } - if ("courseOverGround" in parsed && parsed.courseOverGround !== undefined) { - pos.course = Number(parsed.courseOverGround); - } - - payload.position = pos; - } else { - try { - const full = sentence.startsWith("$") ? sentence : `$${sentence}`; - const withoutChecksum = full.split("*")[0]; - const parts = withoutChecksum.split(","); - const header = parts[0].slice(1).toUpperCase(); - - const parseCoord = (coord: string, hemi: string) => { - if (!coord || coord === "") return undefined; - const degDigits = hemi === "N" || hemi === "S" ? 2 : 3; - if (coord.length <= degDigits) return undefined; - const degPart = coord.slice(0, degDigits); - const minPart = coord.slice(degDigits); - const degrees = parseFloat(degPart); - const mins = parseFloat(minPart); - if (Number.isNaN(degrees) || Number.isNaN(mins)) return undefined; - let dec = degrees + mins / 60.0; - if (hemi === "S" || hemi === "W") dec = -dec; - return dec; - }; - - if (header.endsWith("RMC")) { - const lat = parseCoord(parts[3], parts[4]); - const lon = parseCoord(parts[5], parts[6]); - if (lat !== undefined && lon !== undefined) { - const pos: IPosition = { latitude: lat, longitude: lon }; - if (parts[7]) pos.speed = Number(parts[7]); - if (parts[8]) pos.course = Number(parts[8]); - payload.position = pos; - } - } else if (header.endsWith("GGA")) { - const lat = parseCoord(parts[2], parts[3]); - const lon = parseCoord(parts[4], parts[5]); - if (lat !== undefined && lon !== undefined) { - const pos: IPosition = { latitude: lat, longitude: lon }; - if (parts[9]) pos.altitude = Number(parts[9]); - payload.position = pos; - } - } - } catch { - // ignore fallback parse errors - } - } - - if (withStructure) { - const segments: Segment[] = [ - { - name: "raw-gps", - data: new TextEncoder().encode(sentence).buffer, - isString: true, - fields: [ - { - type: FieldType.STRING, - name: "sentence", - length: sentence.length - } - ] - } - ]; - - if (payload.position) { - segments.push({ - name: "raw-gps-position", - data: new TextEncoder().encode(JSON.stringify(payload.position)).buffer, - isString: true, - fields: [ - { - type: FieldType.STRING, - name: "latitude", - length: String(payload.position.latitude).length - }, - { - type: FieldType.STRING, - name: "longitude", - length: String(payload.position.longitude).length - } - ] - }); - } - - return { payload, segment: segments }; - } - - return { payload }; - } catch { - return { payload: null }; - } - } - - private decodeCapabilities(withStructure: boolean = false): { - payload: Payload | null; - segment?: Segment[]; - } { - try { - if (this.payload.length < 2) return { payload: null }; - - // Extract the text after the '<' identifier - let rest = this.payload.substring(1).trim(); - - // Some implementations include a closing '>' or other trailing chars; strip common wrappers - if (rest.endsWith(">")) rest = rest.slice(0, -1).trim(); - - // Split capabilities by commas, semicolons or whitespace - const tokens = rest - .split(/[,;\s]+/) - .map((t) => t.trim()) - .filter(Boolean); - - const payload: StationCapabilitiesPayload = { - type: DataType.StationCapabilities, - capabilities: tokens - } as const; - - if (withStructure) { - const segments: Segment[] = []; - segments.push({ - name: "capabilities", - data: new TextEncoder().encode(rest).buffer, - isString: true, - fields: [ - { - type: FieldType.STRING, - name: "capabilities", - length: rest.length - } - ] - }); - - for (const cap of tokens) { - segments.push({ - name: "capability", - data: new TextEncoder().encode(cap).buffer, - isString: true, - fields: [ - { - type: FieldType.STRING, - name: "capability", - length: cap.length - } - ] - }); - } - - return { payload, segment: segments }; - } - - return { payload }; - } catch { - return { payload: null }; - } - } - - private decodeUserDefined(withStructure: boolean = false): { - payload: Payload | null; - segment?: Segment[]; - } { - try { - if (this.payload.length < 2) return { payload: null }; - - // content after '{' - const rest = this.payload.substring(1); - - // user packet type is first token (up to first space) often like '01' or 'TYP' - const match = rest.match(/^([^\s]+)\s*(.*)$/s); - let userPacketType = ""; - let data = ""; - if (match) { - userPacketType = match[1] || ""; - data = (match[2] || "").trim(); - } - - const payloadObj = { - type: DataType.UserDefined, - userPacketType, - data - } as const; - - if (withStructure) { - const segments: Segment[] = []; - segments.push({ - name: "user-defined", - data: new TextEncoder().encode(rest).buffer, - isString: true, - fields: [{ type: FieldType.STRING, name: "raw", length: rest.length }] - }); - - segments.push({ - name: "user-packet-type", - data: new TextEncoder().encode(userPacketType).buffer, - isString: true, - fields: [ - { - type: FieldType.STRING, - name: "type", - length: userPacketType.length - } - ] - }); - - segments.push({ - name: "user-data", - data: new TextEncoder().encode(data).buffer, - isString: true, - fields: [{ type: FieldType.STRING, name: "data", length: data.length }] - }); - - return { payload: payloadObj as unknown as Payload, segment: segments }; - } - - return { payload: payloadObj as unknown as Payload }; - } catch { - return { payload: null }; - } - } - - private decodeThirdParty(withStructure: boolean = false): { - payload: Payload | null; - segment?: Segment[]; - } { - try { - if (this.payload.length < 2) return { payload: null }; - - // Content after '}' is the encapsulated third-party frame or raw data - const rest = this.payload.substring(1); - - // Attempt to parse the embedded text as a full APRS frame (route:payload) - let nestedFrame: Frame | undefined; - try { - // parseFrame is defined in this module; use Frame.parse to attempt parse - nestedFrame = Frame.parse(rest); - } catch { - nestedFrame = undefined; - } - - const payloadObj: ThirdPartyPayload = { - type: DataType.ThirdParty, - comment: rest, - ...(nestedFrame ? { frame: nestedFrame } : {}) - } as const; - - if (withStructure) { - const segments: Segment[] = []; - - segments.push({ - name: "third-party", - data: new TextEncoder().encode(rest).buffer, - isString: true, - fields: [{ type: FieldType.STRING, name: "raw", length: rest.length }] - }); - - if (nestedFrame) { - // Include a short section pointing to the nested frame's data (stringified) - const nf = nestedFrame; - const nfStr = `${nf.source.toString()}>${nf.destination.toString()}:${nf.payload}`; - segments.push({ - name: "third-party-nested-frame", - data: new TextEncoder().encode(nfStr).buffer, - isString: true, - fields: [ - { - type: FieldType.STRING, - name: "nested", - length: nfStr.length - } - ] - }); - } - - return { payload: payloadObj as unknown as Payload, segment: segments }; - } - - return { payload: payloadObj as unknown as Payload }; - } catch { - return { payload: null }; - } - } - public static fromString(data: string): Frame { return parseFrame(data); } diff --git a/src/frame.types.ts b/src/frame.types.ts index 1657c49..4380b10 100644 --- a/src/frame.types.ts +++ b/src/frame.types.ts @@ -1,4 +1,4 @@ -import { Dissected, Segment } from "@hamradio/packet"; +import { Dissected, Field, Segment } from "@hamradio/packet"; // Any comment that contains this marker will set the doNotArchive flag on the // decoded payload, which can be used by applications to skip archiving or @@ -141,6 +141,12 @@ export interface ITimestamp { toDate(): Date; // Convert to Date object respecting timezone } +export interface ITelemetry { + sequence: number; + analog: number[]; + digital?: number; +} + // Position Report Payload export interface PositionPayload { type: @@ -390,3 +396,19 @@ export interface DecodedFrame extends IFrame { decoded?: Payload; structure?: Dissected; // Routing and other frame-level sections } + +// Extras is an internal helper type used during decoding to accumulate additional +// information that may not fit directly into the standard payload structure, +// such as comments, calculated fields, or other metadata that can be useful for +// applications consuming the decoded frames. +export interface Extras { + comment: string; + altitude?: number; + range?: number; + phg?: IPowerHeightGain; + dfs?: IDirectionFinding; + cse?: number; + spd?: number; + fields?: Field[]; + telemetry?: ITelemetry; +} diff --git a/src/index.ts b/src/index.ts index f7cd601..4ecfbc7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,4 @@ -export { Frame, Address, Timestamp } from "./frame"; +export { Frame, Address } from "./frame"; export { type IAddress, type IFrame, DataType as DataTypeIdentifier } from "./frame.types"; @@ -34,6 +34,9 @@ export { type DecodedFrame } from "./frame.types"; +export { Position } from "./position"; +export { Timestamp } from "./timestamp"; + export { base91ToNumber, knotsToKmh, diff --git a/src/payload.capabilities.ts b/src/payload.capabilities.ts new file mode 100644 index 0000000..b80d176 --- /dev/null +++ b/src/payload.capabilities.ts @@ -0,0 +1,71 @@ +import { FieldType, type Segment } from "@hamradio/packet"; + +import { DataType, type Payload, type StationCapabilitiesPayload } from "./frame.types"; + +export const decodeCapabilitiesPayload = ( + raw: string, + withStructure: boolean = false +): { + payload: Payload | null; + segment?: Segment[]; +} => { + try { + if (raw.length < 2) return { payload: null }; + + // Extract the text after the '<' identifier + let rest = raw.substring(1).trim(); + + // Some implementations include a closing '>' or other trailing chars; strip common wrappers + if (rest.endsWith(">")) rest = rest.slice(0, -1).trim(); + + // Split capabilities by commas, semicolons or whitespace + const tokens = rest + .split(/[,;\s]+/) + .map((t) => t.trim()) + .filter(Boolean); + + const payload: StationCapabilitiesPayload = { + type: DataType.StationCapabilities, + capabilities: tokens + } as const; + + if (withStructure) { + const segments: Segment[] = []; + segments.push({ + name: "capabilities", + data: new TextEncoder().encode(rest).buffer, + isString: true, + fields: [ + { + type: FieldType.STRING, + name: "capabilities", + length: rest.length + } + ] + }); + + for (const cap of tokens) { + segments.push({ + name: "capability", + data: new TextEncoder().encode(cap).buffer, + isString: true, + fields: [ + { + type: FieldType.STRING, + name: "capability", + length: cap.length + } + ] + }); + } + + return { payload, segment: segments }; + } + + return { payload }; + } catch { + return { payload: null }; + } +}; + +export default decodeCapabilitiesPayload; diff --git a/src/payload.extras.ts b/src/payload.extras.ts new file mode 100644 index 0000000..8402052 --- /dev/null +++ b/src/payload.extras.ts @@ -0,0 +1,504 @@ +import { type Field, FieldType } from "@hamradio/packet"; + +import type { Extras, ITelemetry, Payload } from "./frame.types"; +import { base91ToNumber, feetToMeters, knotsToKmh, milesToMeters } from "./parser"; + +/** + * Decodes structured extras from an APRS comment string, extracting known tokens + * for altitude, range, PHG, DFS, course/speed, and embedded telemetry, and + * returns an object with the extracted values and a cleaned comment string with + * the tokens removed. + * + * If withStructure is true, also returns an array of fields representing the + * structure of the extras for use in structured packet parsing. + * + * @param comment The APRS comment string to decode. + * @param withStructure Whether to include structured fields in the result. + * @returns An object containing the decoded extras and the cleaned comment string. + */ +export const decodeCommentExtras = (comment: string, withStructure: boolean = false): Extras => { + if (!comment || comment.length === 0) return { comment }; + + const extras: Partial = {}; + const fields: Field[] = []; + const beforeFields: Field[] = []; + let altitudeOffset: number | undefined = undefined; + let altitudeFields: Field[] = []; + let commentOffset: number = 0; + let commentBefore: string | undefined = undefined; + + // eslint-disable-next-line no-useless-assignment + let match: RegExpMatchArray | null = null; + + // Process successive 7-byte data extensions at the start of the comment. + comment = comment.trimStart(); + let ext = comment; + while (ext.length >= 7) { + // We first process the altitude marker, because it may appear anywhere + // in the comment and we want to extract it and its value before + // processing other tokens that may be present. + // + // /A=NNNNNN -> altitude in feet (6 digits) + // /A=-NNNNN -> altitude in feet with leading minus for negative altitudes (5 digits) + const altMatch = ext.match(/\/A=(-\d{5}|\d{6})/); + if (altitudeOffset === undefined && altMatch) { + const altitude = feetToMeters(parseInt(altMatch[1], 10)); // feet to meters + if (isNaN(altitude)) { + break; // Invalid altitude format, stop parsing extras + } + extras.altitude = altitude; + + // Keep track of where the altitude token appears in the comment for structure purposes. + altitudeOffset = comment.indexOf(altMatch[0]); + + if (withStructure) { + altitudeFields = [ + { + type: FieldType.STRING, + name: "altitude marker", + data: new TextEncoder().encode("/A=").buffer, + value: "/A=", + length: 3 + }, + { + type: FieldType.STRING, + name: "altitude", + data: new TextEncoder().encode(altMatch[1]).buffer, + value: altitude.toFixed(1) + "m", + length: 6 + } + ]; + } + + if (altitudeOffset > 0) { + // Reset the comment with the altitude marker removed. + commentBefore = comment.substring(0, altitudeOffset); + comment = comment.substring(altitudeOffset + altMatch[0].length); + ext = commentBefore; // Continue processing extensions in the part of the comment before the altitude marker + commentOffset = 0; // Reset + continue; + } + + // remove altitude token from ext and advance ext for further parsing + commentOffset += altMatch[0].length; + ext = ext.replace(altMatch[0], "").trimStart(); + + continue; + } + + // RNGrrrr -> pre-calculated range in miles (4 digits) + if ((match = ext.match(/^RNG(\d{4})/))) { + const r = match[1]; + extras.range = milesToMeters(parseInt(r, 10)) / 1000.0; // Convert to kilometers + if (withStructure) { + (altitudeOffset !== undefined && commentOffset < altitudeOffset ? beforeFields : fields).push( + { + type: FieldType.STRING, + name: "range marker", + value: "RNG", + length: 3 + }, + { + type: FieldType.STRING, + name: "range (rrrr)", + length: 4, + value: extras.range.toFixed(1) + "km" + } + ); + } + + // remove range token from ext and advance ext for further parsing + if (commentBefore !== undefined && commentBefore.length > 0) { + commentBefore = commentBefore.substring(7); + ext = commentBefore; + } else { + commentOffset += 7; + ext = ext.substring(7); + } + + continue; + } + + // PHGphgd + //if (!extras.phg && ext.startsWith("PHG")) { + if (!extras.phg && (match = ext.match(/^PHG([0-9 ])([0-9 ])([0-9 ])([0-9 ])/))) { + // PHGphgd: p = power (0-9 or space), h = height (0-9 or space), g = gain (0-9 or space), d = directivity (0-9 or space) + const p = match[1]; + const h = match[2]; + const g = match[3]; + const d = match[4]; + const pNum = parseInt(p, 10); + const powerWatts = Number.isNaN(pNum) ? undefined : pNum * pNum; + const hIndex = h.charCodeAt(0) - 48; + const heightFeet = 10 * Math.pow(2, hIndex); + const heightMeters = feetToMeters(heightFeet); + const gNum = parseInt(g, 10); + const gainDbi = Number.isNaN(gNum) ? undefined : gNum; + const dNum = parseInt(d, 10); + let directivity: number | "omni" | "unknown" | undefined; + if (Number.isNaN(dNum)) { + directivity = undefined; + } else if (dNum === 0) { + directivity = "omni"; + } else if (dNum >= 1 && dNum <= 8) { + directivity = dNum * 45; + } else if (dNum === 9) { + directivity = "unknown"; + } + + extras.phg = { + power: powerWatts, + height: heightMeters, + gain: gainDbi, + directivity + }; + + if (withStructure) { + (altitudeOffset !== undefined && commentOffset < altitudeOffset ? beforeFields : fields).push( + { type: FieldType.STRING, name: "PHG marker", length: 3, value: "PHG" }, + { + type: FieldType.STRING, + name: "power (p)", + length: 1, + value: powerWatts !== undefined ? powerWatts.toString() + "W" : undefined + }, + { + type: FieldType.STRING, + name: "height (h)", + length: 1, + value: heightMeters !== undefined ? heightMeters.toString() + "m" : undefined + }, + { + type: FieldType.STRING, + name: "gain (g)", + length: 1, + value: gainDbi !== undefined ? gainDbi.toString() + "dBi" : undefined + }, + { + type: FieldType.STRING, + name: "directivity (d)", + length: 1, + value: + directivity !== undefined + ? typeof directivity === "number" + ? directivity.toString() + "°" + : directivity + : undefined + } + ); + } + + // remove PHG token from ext and advance ext for further parsing + if (commentBefore !== undefined && commentBefore.length > 0) { + commentBefore = commentBefore.substring(7); + } else { + commentOffset += 7; + } + ext = ext.substring(7).trimStart(); + + continue; + } + + // DFSshgd + if (ext.startsWith("DFS")) { + // DFSshgd: s = strength (0-9), h = height (0-9), g = gain (0-9), d = directivity (0-9) + const s = ext.charAt(3); + const h = ext.charAt(4); + const g = ext.charAt(5); + const d = ext.charAt(6); + + const sNum = parseInt(s, 10); + const hNum = parseInt(h, 10); + const gNum = parseInt(g, 10); + const dNum = parseInt(d, 10); + + // Strength: s = 0-9, direct value + const strength = Number.isNaN(sNum) ? undefined : sNum; + + // Height: h = 0-9, height = 10 * 2^h feet (spec: h is exponent) + const heightFeet = Number.isNaN(hNum) ? undefined : 10 * Math.pow(2, hNum); + const heightMeters = heightFeet !== undefined ? feetToMeters(heightFeet) : undefined; + + // Gain: g = 0-9, gain in dB + const gainDbi = Number.isNaN(gNum) ? undefined : gNum; + + // Directivity: d = 0-9, 0 = omni, 1-8 = d*45°, 9 = unknown + let directivity: number | "omni" | "unknown" | undefined; + if (Number.isNaN(dNum)) { + directivity = undefined; + } else if (dNum === 0) { + directivity = "omni"; + } else if (dNum >= 1 && dNum <= 8) { + directivity = dNum * 45; + } else if (dNum === 9) { + directivity = "unknown"; + } + + extras.dfs = { + strength, + height: heightMeters, + gain: gainDbi, + directivity + }; + + if (withStructure) { + (altitudeOffset !== undefined && commentOffset < altitudeOffset ? beforeFields : fields).push( + { type: FieldType.STRING, name: "DFS marker", length: 3, value: "DFS" }, + { + type: FieldType.STRING, + name: "strength (s)", + length: 1, + value: strength !== undefined ? strength.toString() : undefined + }, + { + type: FieldType.STRING, + name: "height (h)", + length: 1, + value: heightMeters !== undefined ? heightMeters.toString() + "m" : undefined + }, + { + type: FieldType.STRING, + name: "gain (g)", + length: 1, + value: gainDbi !== undefined ? gainDbi.toString() + "dBi" : undefined + }, + { + type: FieldType.STRING, + name: "directivity (d)", + length: 1, + value: + directivity !== undefined + ? typeof directivity === "number" + ? directivity.toString() + "°" + : directivity + : undefined + } + ); + } + + // remove DFS token from ext and advance ext for further parsing + if (commentBefore !== undefined && commentBefore.length > 0) { + commentBefore = commentBefore.substring(7); + } else { + commentOffset += 7; + } + ext = ext.substring(7).trimStart(); + + continue; + } + + // Course/Speed DDD/SSS (7 bytes: 3 digits / 3 digits) + if (extras.cse === undefined && /^\d{3}\/\d{3}/.test(ext)) { + const courseStr = ext.substring(0, 3); + const speedStr = ext.substring(4, 7); + extras.cse = parseInt(courseStr, 10); + extras.spd = knotsToKmh(parseInt(speedStr, 10)); + + if (withStructure) { + (altitudeOffset !== undefined && commentOffset < altitudeOffset ? beforeFields : fields).push( + { type: FieldType.STRING, name: "course", length: 3, value: extras.cse.toString() + "°" }, + { type: FieldType.CHAR, name: "marker", length: 1, value: "/" }, + { type: FieldType.STRING, name: "speed", length: 3, value: extras.spd.toString() + " km/h" } + ); + } + + // remove course/speed token from comment and advance ext for further parsing + ext = ext.substring(7).trimStart(); + + // If there is an 8-byte DF/NRQ following (leading '/'), parse that too + if (ext.length >= 8 && ext.charAt(0) === "/") { + const dfExt = ext.substring(0, 8); // e.g. /270/729 + const m = dfExt.match(/\/(\d{3})\/(\d{3})/); + if (m) { + const dfBearing = parseInt(m[1], 10); + const dfStrength = parseInt(m[2], 10); + if (extras.dfs === undefined) { + extras.dfs = {}; + } + extras.dfs.bearing = dfBearing; + extras.dfs.strength = dfStrength; + + if (withStructure) { + (altitudeOffset !== undefined && commentOffset < altitudeOffset ? beforeFields : fields).push( + { type: FieldType.STRING, name: "DF marker", length: 1, value: "/" }, + { type: FieldType.STRING, name: "bearing", length: 3, value: dfBearing.toString() + "°" }, + { type: FieldType.CHAR, name: "separator", length: 1, value: "/" }, + { type: FieldType.STRING, name: "strength", length: 3, value: dfStrength.toString() } + ); + } + + // remove DF token from ext and advance ext for further parsing + if (commentBefore !== undefined && commentBefore.length > 0) { + commentBefore = commentBefore.substring(8); + } else { + commentOffset += 8; + } + ext = ext.substring(8).trimStart(); + + continue; + } + } + continue; + } + + // No recognized 7+-byte extension at start + break; + } + + // Parse embedded telemetry in comment. Look for |ss11|, |ss1122|, |ss112233|, |ss1122334455|, or |ss1122334455!"| patterns (where ss is sequence and each pair of digits is an analog channel in base91, and optional last pair is digital channel in base91). + if ((match = comment.match(/\|([a-z0-9]{4,14})\|/i))) { + try { + const telemetry = decodeTelemetry(match[1]); + extras.telemetry = telemetry; + if (withStructure) { + fields.push( + { + type: FieldType.CHAR, + name: "telemetry start", + length: 1, + value: "|" + }, + { + type: FieldType.STRING, + name: "sequence", + length: 2, + value: telemetry.sequence.toString() + }, + ...telemetry.analog.map((a, i) => ({ + type: FieldType.STRING, + name: `analog${i + 1}`, + length: 2, + value: a.toString() + })), + ...(telemetry.digital !== undefined + ? [ + { + type: FieldType.STRING, + name: "digital", + length: 2, + value: telemetry.digital.toString() + } + ] + : []), + { + type: FieldType.CHAR, + name: "telemetry end", + length: 1, + value: "|" + } + ); + } + } catch { + // Invalid telemetry format, ignore + } + } + + // Export comment with extras fields removed, if any were parsed. + if (commentOffset > 0 && commentBefore !== undefined && commentBefore.length > 0) { + extras.comment = commentBefore.substring(commentOffset) + comment; + } else if (commentBefore !== undefined && commentBefore.length > 0) { + extras.comment = commentBefore + comment; + } else { + extras.comment = comment.substring(commentOffset); + } + + if (withStructure) { + const commentBeforeFields: Field[] = commentBefore + ? [ + { + type: FieldType.STRING, + name: "comment", + length: commentBefore.length + } + ] + : []; + + const commentFields: Field[] = comment + ? [ + { + type: FieldType.STRING, + name: "comment", + length: comment.length + } + ] + : []; + + // Insert the altitude fields at the correct position in the comment section based on where the altitude token was located in the original comment. If there was no altitude token, put all fields at the start of the comment section. + extras.fields = [...beforeFields, ...commentBeforeFields, ...altitudeFields, ...fields, ...commentFields]; + } + + return extras as Extras; +}; + +export const attachExtras = (payload: Payload, extras: Extras): void => { + if ("position" in payload && payload.position) { + if (extras.altitude !== undefined) { + payload.position.altitude = extras.altitude; + } + if (extras.range !== undefined) { + payload.position.range = extras.range; + } + if (extras.phg !== undefined) { + payload.position.phg = extras.phg; + } + if (extras.dfs !== undefined) { + payload.position.dfs = extras.dfs; + } + if (extras.cse !== undefined && payload.position.course === undefined) { + payload.position.course = extras.cse; + } + if (extras.spd !== undefined && payload.position.speed === undefined) { + payload.position.speed = extras.spd; + } + } + if ("altitude" in payload && payload.altitude === undefined && extras.altitude !== undefined) { + payload.altitude = extras.altitude; + } + if ("range" in payload && payload.range === undefined && extras.range !== undefined) { + payload.range = extras.range; + } + if ("phg" in payload && payload.phg === undefined && extras.phg !== undefined) { + payload.phg = extras.phg; + } + if ("dfs" in payload && payload.dfs === undefined && extras.dfs !== undefined) { + payload.dfs = extras.dfs; + } + if ("course" in payload && payload.course === undefined && extras.cse !== undefined) { + payload.course = extras.cse; + } + if ("speed" in payload && payload.speed === undefined && extras.spd !== undefined) { + payload.speed = extras.spd; + } +}; + +/** + * Decodes a Base91 Telemetry extension string (delimited by '|') into its components. + * + * @param ext The string between the '|' delimiters (e.g. 'ss11', 'ss112233', 'ss1122334455!"') + * @returns An object with sequence, analog (array), and optional digital (number) + */ +export const decodeTelemetry = (ext: string): ITelemetry => { + if (!ext || ext.length < 4) throw new Error("Telemetry extension too short"); + // Must be even length, at least 4 (2 for seq, 2 for ch1) + if (ext.length % 2 !== 0) throw new Error("Telemetry extension must have even length"); + + // Sequence counter is always first 2 chars + const sequence = base91ToNumber(ext.slice(0, 2)); + const analog: number[] = []; + let i = 2; + // If there are more than 12 chars, last pair is digital + let digital: number | undefined = undefined; + const analogPairs = Math.min(Math.floor((ext.length - 2) / 2), 5); + for (let j = 0; j < analogPairs; j++, i += 2) { + analog.push(base91ToNumber(ext.slice(i, i + 2))); + } + // If there are 2 chars left after 5 analogs, it's digital + if (ext.length === 14) { + digital = base91ToNumber(ext.slice(12, 14)); + } + return { + sequence, + analog, + digital + }; +}; diff --git a/src/payload.item.ts b/src/payload.item.ts new file mode 100644 index 0000000..1d39ffb --- /dev/null +++ b/src/payload.item.ts @@ -0,0 +1,149 @@ +import { FieldType, type Segment } from "@hamradio/packet"; + +import { DO_NOT_ARCHIVE_MARKER, DataType, type IPosition, type ItemPayload, type Payload } from "./frame.types"; +import { attachExtras, decodeCommentExtras } from "./payload.extras"; +import { isCompressedPosition, parseCompressedPosition, parseUncompressedPosition } from "./payload.position"; +import Timestamp from "./timestamp"; + +export const decodeItemPayload = ( + raw: string, + withStructure: boolean = false +): { + payload: Payload | null; + segment?: Segment[]; +} => { + // Item format is similar to Object but name may be 3-9 chars (stored in a 9-char field) + // Example: )NNN... where ) is data type, next 9 chars are name, then state char, then timestamp, then position + if (raw.length < 12) return { payload: null }; // minimal: 1 + 3 + 1 + 7 + + let offset = 1; // skip data type identifier ')' + const segment: Segment[] = withStructure ? [] : []; + + // Read 9-char name field (pad/truncate as present) + const rawName = raw.substring(offset, offset + 9); + const name = rawName.trimEnd(); + if (withStructure) { + segment.push({ + name: "item name", + data: new TextEncoder().encode(rawName).buffer, + isString: true, + fields: [{ type: FieldType.STRING, name: "name", length: 9 }] + }); + } + offset += 9; + + // State character: '*' = alive, '_' = killed + const stateChar = raw.charAt(offset); + if (stateChar !== "*" && stateChar !== "_") { + return { payload: null }; + } + const alive = stateChar === "*"; + if (withStructure) { + segment.push({ + name: "item state", + data: new TextEncoder().encode(stateChar).buffer, + isString: true, + fields: [ + { + type: FieldType.CHAR, + name: "State (* alive, _ killed)", + length: 1 + } + ] + }); + } + offset += 1; + + // Timestamp (7 chars) + const timeStr = raw.substring(offset, offset + 7); + const { timestamp, segment: timestampSection } = Timestamp.fromString(timeStr.substring(offset), withStructure); + if (!timestamp) return { payload: null }; + if (timestampSection) segment.push(timestampSection); + offset += 7; + + const isCompressed = isCompressedPosition(raw.substring(offset)); + + // eslint-disable-next-line no-useless-assignment + let position: IPosition | null = null; + // eslint-disable-next-line no-useless-assignment + let consumed = 0; + + if (isCompressed) { + const { position: compressed, segment: compressedSection } = parseCompressedPosition( + raw.substring(offset), + withStructure + ); + if (!compressed) return { payload: null }; + + position = { + latitude: compressed.latitude, + longitude: compressed.longitude, + symbol: compressed.symbol, + altitude: compressed.altitude + }; + consumed = 13; + + if (compressedSection) segment.push(compressedSection); + } else { + const { position: uncompressed, segment: uncompressedSection } = parseUncompressedPosition( + raw.substring(offset), + withStructure + ); + if (!uncompressed) return { payload: null }; + + position = { + latitude: uncompressed.latitude, + longitude: uncompressed.longitude, + symbol: uncompressed.symbol, + ambiguity: uncompressed.ambiguity + }; + consumed = 19; + + if (uncompressedSection) segment.push(uncompressedSection); + } + + offset += consumed; + const remainder = raw.substring(offset); + const doNotArchive = remainder.includes(DO_NOT_ARCHIVE_MARKER); + let comment = remainder; + + const extras = decodeCommentExtras(comment, withStructure); + comment = extras.comment; + + if (comment) { + position.comment = comment; + if (withStructure) { + segment.push({ + name: "comment", + data: new TextEncoder().encode(remainder).buffer, + isString: true, + fields: extras.fields || [] + }); + } + } else if (withStructure && extras.fields) { + // No free-text comment, but extras fields exist: emit comment-only segment + segment.push({ + name: "comment", + data: new TextEncoder().encode(remainder).buffer, + isString: true, + fields: extras.fields || [] + }); + } + + const payload: ItemPayload = { + type: DataType.Item, + doNotArchive, + name, + alive, + position + }; + attachExtras(payload, extras); + + if (withStructure) { + return { payload, segment }; + } + + return { payload }; +}; + +export default decodeItemPayload; diff --git a/src/payload.message.ts b/src/payload.message.ts new file mode 100644 index 0000000..180b6bf --- /dev/null +++ b/src/payload.message.ts @@ -0,0 +1,94 @@ +import { FieldType, type Segment } from "@hamradio/packet"; + +import { DO_NOT_ARCHIVE_MARKER, DataType, type MessagePayload, type Payload } from "./frame.types"; + +export const decodeMessagePayload = ( + rawPayload: string, + withStructure: boolean = false +): { + payload: Payload | null; + segment?: Segment[]; +} => { + // Message format: :AAAAAAAAA[ ]:message text + // where AAAAAAAAA is a 9-character recipient field (padded with spaces) + if (rawPayload.length < 2) return { payload: null }; + + let offset = 1; // skip ':' data type + const segments: Segment[] = withStructure ? [] : []; + + // Attempt to read a 9-char recipient field if present + let recipient = ""; + if (rawPayload.length >= offset + 1) { + // Try to read up to 9 chars for recipient, but stop early if a ':' separator appears + const look = rawPayload.substring(offset, Math.min(offset + 9, rawPayload.length)); + const sepIdx = look.indexOf(":"); + let raw = look; + if (sepIdx !== -1) { + raw = look.substring(0, sepIdx); + } else if (look.length < 9 && rawPayload.length >= offset + 9) { + // pad to full 9 chars if possible + raw = rawPayload.substring(offset, offset + 9); + } else if (look.length === 9) { + raw = look; + } + + recipient = raw.trimEnd(); + if (withStructure) { + segments.push({ + name: "recipient", + data: new TextEncoder().encode(raw).buffer, + isString: true, + fields: [{ type: FieldType.STRING, name: "to", length: 9 }] + }); + } + + // Advance offset past the raw we consumed + offset += raw.length; + // If there was a ':' immediately after the consumed raw, skip it as separator + if (rawPayload.charAt(offset) === ":") { + offset += 1; + } else if (sepIdx !== -1) { + // Shouldn't normally happen, but ensure we advance past separator + offset += 1; + } + } + + // After recipient there is typically a space and a colon separator before the text + // Find the first ':' after the recipient (it separates the address field from the text) + let textStart = rawPayload.indexOf(":", offset); + if (textStart === -1) { + // No explicit separator; skip any spaces and take remainder as text + while (rawPayload.charAt(offset) === " " && offset < rawPayload.length) offset += 1; + textStart = offset - 1; + } + + let text = ""; + if (textStart >= 0 && textStart + 1 <= rawPayload.length) { + text = rawPayload.substring(textStart + 1); + } + const doNotArchive = text.includes(DO_NOT_ARCHIVE_MARKER); + + const payload: MessagePayload = { + type: DataType.Message, + variant: "message", + doNotArchive, + addressee: recipient, + text + }; + + if (withStructure) { + // Emit text section + segments.push({ + name: "text", + data: new TextEncoder().encode(text).buffer, + isString: true, + fields: [{ type: FieldType.STRING, name: "text", length: text.length }] + }); + + return { payload, segment: segments }; + } + + return { payload }; +}; + +export default decodeMessagePayload; diff --git a/src/payload.mice.ts b/src/payload.mice.ts new file mode 100644 index 0000000..f3247be --- /dev/null +++ b/src/payload.mice.ts @@ -0,0 +1,300 @@ +import { FieldType, type Segment } from "@hamradio/packet"; + +import { base91ToNumber, knotsToKmh } from "."; +import { DO_NOT_ARCHIVE_MARKER, DataType, type IAddress, MicEPayload, type Payload } from "./frame.types"; +import { attachExtras, decodeCommentExtras } from "./payload.extras"; + +export const decodeMicEPayload = ( + destination: IAddress, + raw: string, + withStructure: boolean = false +): { + payload: Payload | null; + segment?: Segment[]; +} => { + try { + // Mic-E encodes position in both destination address and information field + const dest = destination.call; + + if (dest.length < 6) return { payload: null }; + if (raw.length < 9) return { payload: null }; // Need at least data type + 8 bytes + + const segments: Segment[] = withStructure ? [] : []; + + // Decode latitude from destination address (6 characters) + const latResult = decodeMicELatitude(dest); + if (!latResult) return { payload: null }; + + const { latitude, messageType, longitudeOffset, isWest, isStandard } = latResult; + + if (withStructure) { + segments.push({ + name: "mic-E destination", + data: new TextEncoder().encode(dest).buffer, + isString: true, + fields: [ + { + type: FieldType.STRING, + name: "destination", + length: dest.length + } + ] + }); + } + + // Parse information field (skip data type identifier at position 0) + let offset = 1; + + // Longitude: 3 bytes (degrees, minutes, hundredths) + const lonDegRaw = raw.charCodeAt(offset) - 28; + const lonMinRaw = raw.charCodeAt(offset + 1) - 28; + const lonHunRaw = raw.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 = raw.charCodeAt(offset) - 28; + const dc = raw.charCodeAt(offset + 1) - 28; + const se = raw.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 = knotsToKmh(speed); + + // Symbol code and table + if (raw.length < offset + 2) return { payload: null }; + const symbolCode = raw.charAt(offset); + const symbolTable = raw.charAt(offset + 1); + offset += 2; + + // Parse remaining data (altitude, comment, telemetry) + const remaining = raw.substring(offset); + const doNotArchive = remaining.includes(DO_NOT_ARCHIVE_MARKER); + let altitude: number | undefined = undefined; + let comment = remaining; + + // Check for altitude in old format + if (comment.length >= 4 && comment.charAt(3) === "}") { + try { + const altBase91 = comment.substring(0, 3); + altitude = base91ToNumber(altBase91) - 10000; // Relative to 10km below mean sea level + comment = comment.substring(4); // Remove altitude token from comment + } catch { + // Ignore altitude parsing errors + } + } + + // Parse RNG/PHG tokens from comment (defer attaching to result until created) + const remainder = comment; // Use the remaining comment text for parsing extras + const extras = decodeCommentExtras(remainder, withStructure); + comment = extras.comment; + + let payloadType: DataType.MicE | DataType.MicEOld; + switch (raw.charAt(0)) { + case "`": + payloadType = DataType.MicE; + break; + case "'": + payloadType = DataType.MicEOld; + break; + default: + return { payload: null }; + } + + const result: MicEPayload = { + type: payloadType, + doNotArchive, + position: { + latitude, + longitude, + symbol: { + table: symbolTable, + code: symbolCode + } + }, + messageType, + isStandard + }; + + if (speed > 0) { + result.position.speed = speedKmh; + } + + if (course > 0 && course < 360) { + result.position.course = course; + } + + if (altitude !== undefined) { + result.position.altitude = altitude; + } + + if (comment) { + result.position.comment = comment; + } + + // Attach parsed extras if present + attachExtras(result, extras); + + if (withStructure) { + // Information field section (bytes after data type up to comment) + const infoData = raw.substring(1, offset); + segments.push({ + name: "mic-E info", + data: new TextEncoder().encode(infoData).buffer, + isString: true, + fields: [ + { type: FieldType.CHAR, name: "longitude deg", length: 1 }, + { type: FieldType.CHAR, name: "longitude min", length: 1 }, + { type: FieldType.CHAR, name: "longitude hundredths", length: 1 }, + { type: FieldType.CHAR, name: "speed byte", length: 1 }, + { type: FieldType.CHAR, name: "course byte 1", length: 1 }, + { type: FieldType.CHAR, name: "course byte 2", length: 1 }, + { type: FieldType.CHAR, name: "symbol code", length: 1 }, + { type: FieldType.CHAR, name: "symbol table", length: 1 } + ] + }); + + if (comment && comment.length > 0) { + segments.push({ + name: "comment", + data: new TextEncoder().encode(remainder).buffer, + isString: true, + fields: extras.fields || [] + }); + } else if (extras.fields) { + segments.push({ + name: "comment", + data: new TextEncoder().encode(remainder).buffer, + isString: true, + fields: extras.fields + }); + } + + return { payload: result, segment: segments }; + } + + return { payload: result }; + } catch { + return { payload: null }; + } +}; + +const 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 + }; +}; + +export default decodeMicEPayload; diff --git a/src/payload.object.ts b/src/payload.object.ts new file mode 100644 index 0000000..ba4c2d2 --- /dev/null +++ b/src/payload.object.ts @@ -0,0 +1,161 @@ +import { FieldType, Segment } from "@hamradio/packet"; + +import { DO_NOT_ARCHIVE_MARKER, DataType, type IPosition, ObjectPayload, type Payload } from "./frame.types"; +import { attachExtras, decodeCommentExtras } from "./payload.extras"; +import { isCompressedPosition, parseCompressedPosition, parseUncompressedPosition } from "./payload.position"; +import Timestamp from "./timestamp"; + +export const decodeObjectPayload = ( + raw: string, + withStructure: boolean = false +): { + payload: Payload | null; + segment?: Segment[]; +} => { + try { + // Object format: ;AAAAAAAAAcDDHHMMzDDMM.hhN/DDDMM.hhW$comment + // ^ data type + // 9-char name + // alive (*) / killed (_) + if (raw.length < 18) return { payload: null }; // 1 + 9 + 1 + 7 minimum + + let offset = 1; // Skip data type identifier ';' + const segment: Segment[] = withStructure ? [] : []; + + const rawName = raw.substring(offset, offset + 9); + const name = rawName.trimEnd(); + if (withStructure) { + segment.push({ + name: "object", + data: new TextEncoder().encode(rawName).buffer, + isString: true, + fields: [{ type: FieldType.STRING, name: "name", length: 9 }] + }); + } + offset += 9; + + const stateChar = raw.charAt(offset); + if (stateChar !== "*" && stateChar !== "_") { + return { payload: null }; + } + const alive = stateChar === "*"; + if (withStructure) { + let state: string = "invalid"; + if (stateChar === "*") { + state = "alive"; + } else if (stateChar === "_") { + state = "killed"; + } + segment[segment.length - 1].data = new TextEncoder().encode(raw.substring(offset - 9, offset + 1)).buffer; + segment[segment.length - 1].fields.push({ + type: FieldType.CHAR, + name: "state", + length: 1, + value: state + }); + } + offset += 1; + + const timeStr = raw.substring(offset, offset + 7); + const { timestamp, segment: timestampSection } = Timestamp.fromString(timeStr, withStructure); + if (!timestamp) { + return { payload: null }; + } + if (timestampSection) { + segment.push(timestampSection); + } + offset += 7; + + const isCompressed = isCompressedPosition(raw.substring(offset)); + + let position: IPosition | null = null; + let consumed = 0; + + if (isCompressed) { + const { position: compressed, segment: compressedSection } = parseCompressedPosition( + raw.substring(offset), + withStructure + ); + if (!compressed) return { payload: null }; + + position = { + latitude: compressed.latitude, + longitude: compressed.longitude, + symbol: compressed.symbol, + altitude: compressed.altitude + }; + consumed = 13; + + if (compressedSection) { + segment.push(compressedSection); + } + } else { + const { position: uncompressed, segment: uncompressedSection } = parseUncompressedPosition( + raw.substring(offset), + withStructure + ); + if (!uncompressed) return { payload: null }; + + position = { + latitude: uncompressed.latitude, + longitude: uncompressed.longitude, + symbol: uncompressed.symbol, + ambiguity: uncompressed.ambiguity + }; + consumed = 19; + + if (uncompressedSection) { + segment.push(uncompressedSection); + } + } + + offset += consumed; + const remainder = raw.substring(offset); + const doNotArchive = remainder.includes(DO_NOT_ARCHIVE_MARKER); + let comment = remainder; + + // Parse RNG/PHG tokens + const extras = decodeCommentExtras(comment, withStructure); + comment = extras.comment; + + if (comment) { + position.comment = comment; + + if (withStructure) { + segment.push({ + name: "comment", + data: new TextEncoder().encode(remainder).buffer, + isString: true, + fields: extras.fields || [] + }); + } + } else if (withStructure && extras.fields) { + segment.push({ + name: "comment", + data: new TextEncoder().encode(remainder).buffer, + isString: true, + fields: extras.fields || [] + }); + } + + const payload: ObjectPayload = { + type: DataType.Object, + doNotArchive, + name, + timestamp, + alive, + position + }; + attachExtras(payload, extras); + + if (withStructure) { + return { payload, segment }; + } + + return { payload }; + } catch { + return { payload: null }; + } +}; + +export default decodeObjectPayload; diff --git a/src/payload.position.ts b/src/payload.position.ts new file mode 100644 index 0000000..3bb77ab --- /dev/null +++ b/src/payload.position.ts @@ -0,0 +1,344 @@ +import { FieldType, type Segment } from "@hamradio/packet"; + +import { DO_NOT_ARCHIVE_MARKER, DataType, type IPosition, type Payload, type PositionPayload } from "./frame.types"; +import { base91ToNumber, feetToMeters } from "./parser"; +import { attachExtras, decodeCommentExtras } from "./payload.extras"; +import Position from "./position"; +import Timestamp from "./timestamp"; + +export const decodePositionPayload = ( + dataType: string, + raw: string, + withStructure: boolean = false +): { payload: Payload | null; segment?: Segment[] } => { + try { + const hasTimestamp = dataType === "/" || dataType === "@"; + const messaging = dataType === "=" || dataType === "@"; + let offset = 1; // Skip data type identifier + + // Build structure as we parse + const structure: Segment[] = withStructure ? [] : []; + + let timestamp: Timestamp | undefined = undefined; + + // Parse timestamp if present (7 characters: DDHHMMz or HHMMSSh or MMDDHMMM) + if (hasTimestamp) { + if (raw.length < 8) return { payload: null }; + const timeStr = raw.substring(offset, offset + 7); + const { timestamp: parsedTimestamp, segment: timestampSegment } = Timestamp.fromString(timeStr, withStructure); + timestamp = parsedTimestamp; + + if (timestampSegment) { + structure.push(timestampSegment); + } + + offset += 7; + } + + // Need at least enough characters for compressed position (13) or + // uncompressed (19). Allow parsing to continue if compressed-length is present. + if (raw.length < offset + 13) return { payload: null }; + + // Check if compressed format + const isCompressed = isCompressedPosition(raw.substring(offset)); + + let position: Position; + let comment = ""; + + if (isCompressed) { + // Compressed format: /YYYYXXXX$csT + const { position: compressed, segment: compressedSegment } = parseCompressedPosition( + raw.substring(offset), + withStructure + ); + if (!compressed) return { payload: null }; + + position = new Position({ + latitude: compressed.latitude, + longitude: compressed.longitude, + symbol: compressed.symbol + }); + + if (compressed.altitude !== undefined) { + position.altitude = compressed.altitude; + } + + if (compressedSegment) { + structure.push(compressedSegment); + } + + offset += 13; // Compressed position is 13 chars + comment = raw.substring(offset); + } else { + // Uncompressed format: DDMMmmH/DDDMMmmH$ + const { position: uncompressed, segment: uncompressedSegment } = parseUncompressedPosition( + raw.substring(offset), + withStructure + ); + if (!uncompressed) return { payload: null }; + + position = new Position({ + latitude: uncompressed.latitude, + longitude: uncompressed.longitude, + symbol: uncompressed.symbol + }); + + if (uncompressed.ambiguity !== undefined) { + position.ambiguity = uncompressed.ambiguity; + } + + if (uncompressedSegment) { + structure.push(uncompressedSegment); + } + + offset += 19; // Uncompressed position is 19 chars + comment = raw.substring(offset); + } + + // Extract Altitude, CSE/SPD, RNG and PHG tokens and optionally emit sections + const remainder = comment; // Use the remaining comment text for parsing extras + const doNotArchive = remainder.includes(DO_NOT_ARCHIVE_MARKER); + const extras = decodeCommentExtras(remainder, withStructure); + comment = extras.comment; + + if (comment) { + position.comment = comment; + + // Emit comment section as we parse + if (withStructure) { + structure.push({ + name: "comment", + data: new TextEncoder().encode(remainder).buffer, + isString: true, + fields: extras.fields || [] + }); + } + } else if (withStructure && extras.fields) { + // No free-text comment, but extras were present: emit a comment section containing only fields + structure.push({ + name: "comment", + data: new TextEncoder().encode("").buffer, + isString: true, + fields: extras.fields || [] + }); + } + + let payloadType: + | DataType.PositionNoTimestampNoMessaging + | DataType.PositionNoTimestampWithMessaging + | DataType.PositionWithTimestampNoMessaging + | DataType.PositionWithTimestampWithMessaging; + switch (dataType) { + case "!": + payloadType = DataType.PositionNoTimestampNoMessaging; + break; + case "=": + payloadType = DataType.PositionNoTimestampWithMessaging; + break; + case "/": + payloadType = DataType.PositionWithTimestampNoMessaging; + break; + case "@": + payloadType = DataType.PositionWithTimestampWithMessaging; + break; + default: + return { payload: null }; + } + + const payload: PositionPayload = { + type: payloadType, + doNotArchive, + timestamp, + position, + messaging + }; + attachExtras(payload, extras); + + if (withStructure) { + return { payload, segment: structure }; + } + + return { payload }; + } catch { + return { payload: null }; + } +}; + +export const 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 = parseUncompressedPosition(data, false); + if (uncompressed.position) { + return false; + } + } + + // For compressed format, check if the position part looks like base-91 encoded data + // Compressed format: STYYYYXXXXcsT where ST is symbol table/code + // Base-91 chars are in range 33-124 (! to |) + const lat1 = data.charCodeAt(1); + const lat2 = data.charCodeAt(2); + const lon1 = data.charCodeAt(5); + const lon2 = data.charCodeAt(6); + + return ( + lat1 >= 33 && lat1 <= 124 && lat2 >= 33 && lat2 <= 124 && lon1 >= 33 && lon1 <= 124 && lon2 >= 33 && lon2 <= 124 + ); +}; + +export const parseCompressedPosition = ( + data: string, + withStructure: boolean = false +): { + position: IPosition | null; + segment?: Segment; +} => { + if (data.length < 13) return { position: null }; + + const symbolTable = data.charAt(0); + const symbolCode = data.charAt(9); + + // Extract base-91 encoded position (4 characters each) + const latStr = data.substring(1, 5); + const lonStr = data.substring(5, 9); + + try { + // Decode base-91 encoded latitude and longitude + const latBase91 = base91ToNumber(latStr); + const lonBase91 = base91ToNumber(lonStr); + + // Convert to degrees + const latitude = 90 - latBase91 / 380926; + const longitude = -180 + lonBase91 / 190463; + + const result: IPosition = { + latitude, + longitude, + symbol: { + table: symbolTable, + code: symbolCode + } + }; + + // Check for compressed altitude (csT format) + const cs = data.charAt(10); + const t = data.charCodeAt(11); + + if (cs === " " && t >= 33 && t <= 124) { + // Compressed altitude: altitude = 1.002^(t-33) feet + const altFeet = Math.pow(1.002, t - 33); + result.altitude = feetToMeters(altFeet); // Convert to meters + } + + const section: Segment | undefined = withStructure + ? { + name: "position", + data: new TextEncoder().encode(data.substring(0, 13)).buffer, + isString: true, + fields: [ + { type: FieldType.CHAR, length: 1, name: "symbol table" }, + { type: FieldType.STRING, length: 4, name: "latitude" }, + { type: FieldType.STRING, length: 4, name: "longitude" }, + { type: FieldType.CHAR, length: 1, name: "symbol code" }, + { type: FieldType.CHAR, length: 1, name: "course/speed type" }, + { type: FieldType.CHAR, length: 1, name: "course/speed value" }, + { type: FieldType.CHAR, length: 1, name: "altitude" } + ] + } + : undefined; + + return { position: result, segment: section }; + } catch { + return { position: null }; + } +}; + +export const parseUncompressedPosition = ( + data: string, + withStructure: boolean = false +): { + position: IPosition | null; + segment?: Segment; +} => { + if (data.length < 19) return { position: null }; + + // Format: DDMMmmH/DDDMMmmH$ where H is hemisphere, $ is symbol code + // Positions: 0-7 (latitude), 8 (symbol table), 9-17 (longitude), 18 (symbol code) + // Spaces may replace rightmost digits for ambiguity/privacy + + const latStr = data.substring(0, 8); // DDMMmmH (8 chars: 49 03.50 N) + const symbolTable = data.charAt(8); + const lonStr = data.substring(9, 18); // DDDMMmmH (9 chars: 072 01.75 W) + const symbolCode = data.charAt(18); + + // Count and handle ambiguity (spaces in minutes part replace rightmost digits) + let ambiguity = 0; + const latSpaceCount = (latStr.match(/ /g) || []).length; + const lonSpaceCount = (lonStr.match(/ /g) || []).length; + + if (latSpaceCount > 0 || lonSpaceCount > 0) { + // Use the maximum space count (they should be the same, but be defensive) + ambiguity = Math.max(latSpaceCount, lonSpaceCount); + } + + // Replace spaces with zeros for parsing + const latStrNormalized = latStr.replace(/ /g, "0"); + const lonStrNormalized = lonStr.replace(/ /g, "0"); + + // Parse latitude + const latDeg = parseInt(latStrNormalized.substring(0, 2), 10); + const latMin = parseFloat(latStrNormalized.substring(2, 7)); + const latHem = latStrNormalized.charAt(7); + + if (isNaN(latDeg) || isNaN(latMin)) return { position: null }; + if (latHem !== "N" && latHem !== "S") return { position: null }; + + let latitude = latDeg + latMin / 60; + if (latHem === "S") latitude = -latitude; + + // Parse longitude + const lonDeg = parseInt(lonStrNormalized.substring(0, 3), 10); + const lonMin = parseFloat(lonStrNormalized.substring(3, 8)); + const lonHem = lonStrNormalized.charAt(8); + + if (isNaN(lonDeg) || isNaN(lonMin)) return { position: null }; + if (lonHem !== "E" && lonHem !== "W") return { position: null }; + + let longitude = lonDeg + lonMin / 60; + if (lonHem === "W") longitude = -longitude; + + const result: IPosition = { + latitude, + longitude, + symbol: { + table: symbolTable, + code: symbolCode + } + }; + + if (ambiguity > 0) { + result.ambiguity = ambiguity; + } + + const segment: Segment | undefined = withStructure + ? { + name: "position", + data: new TextEncoder().encode(data.substring(0, 19)).buffer, + isString: true, + fields: [ + { type: FieldType.STRING, length: 8, name: "latitude" }, + { type: FieldType.CHAR, length: 1, name: "symbol table" }, + { type: FieldType.STRING, length: 9, name: "longitude" }, + { type: FieldType.CHAR, length: 1, name: "symbol code" } + ] + } + : undefined; + + return { position: result, segment }; +}; + +export default decodePositionPayload; diff --git a/src/payload.query.ts b/src/payload.query.ts new file mode 100644 index 0000000..278c445 --- /dev/null +++ b/src/payload.query.ts @@ -0,0 +1,69 @@ +import { FieldType, type Segment } from "@hamradio/packet"; + +import { DataType, type Payload, type QueryPayload } from "./frame.types"; + +export const decodeQueryPayload = ( + raw: string, + withStructure: boolean = false +): { + payload: Payload | null; + segment?: Segment[]; +} => { + try { + if (raw.length < 2) return { payload: null }; + + // Skip data type identifier '?' + const segments: Segment[] = withStructure ? [] : []; + + // Remaining payload + const rest = raw.substring(1).trim(); + if (!rest) return { payload: null }; + + // Query type is the first token (up to first space) + const firstSpace = rest.indexOf(" "); + let queryType = ""; + let target: string | undefined = undefined; + + if (firstSpace === -1) { + queryType = rest; + } else { + queryType = rest.substring(0, firstSpace); + target = rest.substring(firstSpace + 1).trim(); + if (target === "") target = undefined; + } + + if (!queryType) return { payload: null }; + + if (withStructure) { + // Emit query type section + segments.push({ + name: "query type", + data: new TextEncoder().encode(queryType).buffer, + isString: true, + fields: [{ type: FieldType.STRING, name: "type", length: queryType.length }] + }); + + if (target) { + segments.push({ + name: "query target", + data: new TextEncoder().encode(target).buffer, + isString: true, + fields: [{ type: FieldType.STRING, name: "target", length: target.length }] + }); + } + } + + const payload: QueryPayload = { + type: DataType.Query, + queryType, + ...(target ? { target } : {}) + }; + + if (withStructure) return { payload, segment: segments }; + return { payload }; + } catch { + return { payload: null }; + } +}; + +export default decodeQueryPayload; diff --git a/src/payload.rawgps.ts b/src/payload.rawgps.ts new file mode 100644 index 0000000..371a8d2 --- /dev/null +++ b/src/payload.rawgps.ts @@ -0,0 +1,161 @@ +import { FieldType, type Segment } from "@hamradio/packet"; +import { DTM, GGA, INmeaSentence, Decoder as NmeaDecoder, RMC } from "extended-nmea"; + +import { DataType, type IPosition, type Payload, type RawGPSPayload } from "./frame.types"; + +export const decodeRawGPSPayload = ( + raw: string, + withStructure: boolean = false +): { + payload: Payload | null; + segment?: Segment[]; +} => { + try { + if (raw.length < 2) return { payload: null }; + + // Raw GPS payloads start with '$' followed by an NMEA sentence + const sentence = raw.substring(1).trim(); + + // Attempt to parse with extended-nmea Decoder to extract position (best-effort) + let parsed: INmeaSentence | null = null; + try { + const full = sentence.startsWith("$") ? sentence : `$${sentence}`; + parsed = NmeaDecoder.decode(full); + } catch { + // ignore parse errors - accept any sentence as raw-gps per APRS + } + + const payload: RawGPSPayload = { + type: DataType.RawGPS, + sentence + }; + + // If parse produced latitude/longitude, attach structured position. + // Otherwise fallback to a minimal NMEA parser for common sentences (RMC, GGA). + if ( + parsed && + (parsed instanceof RMC || parsed instanceof GGA || parsed instanceof DTM) && + parsed.latitude && + parsed.longitude + ) { + // extended-nmea latitude/longitude are GeoCoordinate objects with + // fields { degrees, decimal, quadrant } + const latObj = parsed.latitude; + const lonObj = parsed.longitude; + const lat = latObj.degrees + (Number(latObj.decimal) || 0) / 60.0; + const lon = lonObj.degrees + (Number(lonObj.decimal) || 0) / 60.0; + const latitude = latObj.quadrant === "S" ? -lat : lat; + const longitude = lonObj.quadrant === "W" ? -lon : lon; + + const pos: IPosition = { + latitude, + longitude + }; + + // altitude + if ("altMean" in parsed && parsed.altMean !== undefined) { + pos.altitude = Number(parsed.altMean); + } + if ("altitude" in parsed && parsed.altitude !== undefined) { + pos.altitude = Number(parsed.altitude); + } + + // speed/course (RMC fields) + if ("speedOverGround" in parsed && parsed.speedOverGround !== undefined) { + pos.speed = Number(parsed.speedOverGround); + } + if ("courseOverGround" in parsed && parsed.courseOverGround !== undefined) { + pos.course = Number(parsed.courseOverGround); + } + + payload.position = pos; + } else { + try { + const full = sentence.startsWith("$") ? sentence : `$${sentence}`; + const withoutChecksum = full.split("*")[0]; + const parts = withoutChecksum.split(","); + const header = parts[0].slice(1).toUpperCase(); + + const parseCoord = (coord: string, hemi: string) => { + if (!coord || coord === "") return undefined; + const degDigits = hemi === "N" || hemi === "S" ? 2 : 3; + if (coord.length <= degDigits) return undefined; + const degPart = coord.slice(0, degDigits); + const minPart = coord.slice(degDigits); + const degrees = parseFloat(degPart); + const mins = parseFloat(minPart); + if (Number.isNaN(degrees) || Number.isNaN(mins)) return undefined; + let dec = degrees + mins / 60.0; + if (hemi === "S" || hemi === "W") dec = -dec; + return dec; + }; + + if (header.endsWith("RMC")) { + const lat = parseCoord(parts[3], parts[4]); + const lon = parseCoord(parts[5], parts[6]); + if (lat !== undefined && lon !== undefined) { + const pos: IPosition = { latitude: lat, longitude: lon }; + if (parts[7]) pos.speed = Number(parts[7]); + if (parts[8]) pos.course = Number(parts[8]); + payload.position = pos; + } + } else if (header.endsWith("GGA")) { + const lat = parseCoord(parts[2], parts[3]); + const lon = parseCoord(parts[4], parts[5]); + if (lat !== undefined && lon !== undefined) { + const pos: IPosition = { latitude: lat, longitude: lon }; + if (parts[9]) pos.altitude = Number(parts[9]); + payload.position = pos; + } + } + } catch { + // ignore fallback parse errors + } + } + + if (withStructure) { + const segments: Segment[] = [ + { + name: "raw-gps", + data: new TextEncoder().encode(sentence).buffer, + isString: true, + fields: [ + { + type: FieldType.STRING, + name: "sentence", + length: sentence.length + } + ] + } + ]; + + if (payload.position) { + segments.push({ + name: "raw-gps-position", + data: new TextEncoder().encode(JSON.stringify(payload.position)).buffer, + isString: true, + fields: [ + { + type: FieldType.STRING, + name: "latitude", + length: String(payload.position.latitude).length + }, + { + type: FieldType.STRING, + name: "longitude", + length: String(payload.position.longitude).length + } + ] + }); + } + + return { payload, segment: segments }; + } + + return { payload }; + } catch { + return { payload: null }; + } +}; + +export default decodeRawGPSPayload; diff --git a/src/payload.status.ts b/src/payload.status.ts new file mode 100644 index 0000000..3b7a5cb --- /dev/null +++ b/src/payload.status.ts @@ -0,0 +1,79 @@ +import { FieldType, type Segment } from "@hamradio/packet"; + +import { DO_NOT_ARCHIVE_MARKER, DataType, type Payload, type StatusPayload } from "./frame.types"; +import Timestamp from "./timestamp"; + +export const decodeStatusPayload = ( + raw: string, + withStructure: boolean = false +): { + payload: Payload | null; + segment?: Segment[]; +} => { + // Status payload: optional 7-char timestamp followed by free text. + // We'll also detect a trailing Maidenhead locator (4 or 6 chars) and expose it. + const offsetBase = 1; // skip data type identifier '>' + if (raw.length <= offsetBase) return { payload: null }; + + let offset = offsetBase; + const segments: Segment[] = withStructure ? [] : []; + + // Try parse optional timestamp (7 chars) + if (raw.length >= offset + 7) { + const timeStr = raw.substring(offset, offset + 7); + const { timestamp, segment: tsSegment } = Timestamp.fromString(timeStr, withStructure); + if (timestamp) { + offset += 7; + if (tsSegment) segments.push(tsSegment); + } + } + + // Remaining text is status text + const text = raw.substring(offset); + if (!text) return { payload: null }; + const doNotArchive = text.includes(DO_NOT_ARCHIVE_MARKER); + + // Detect trailing Maidenhead locator (4 or 6 chars) at end of text separated by space + let maidenhead: string | undefined; + const mhMatch = text.match(/\s([A-Ra-r]{2}\d{2}(?:[A-Ra-r]{2})?)$/); + let statusText = text; + if (mhMatch) { + maidenhead = mhMatch[1].toUpperCase(); + statusText = text.slice(0, mhMatch.index).trimEnd(); + } + + const payload: StatusPayload = { + type: DataType.Status, + doNotArchive, + timestamp: undefined, + text: statusText + }; + + // If timestamp was parsed, attach it + if (segments.length > 0) { + // The first segment may be timestamp; parseTimestamp returns the Timestamp object + // Re-parse to obtain timestamp object (cheap) - alternate would be to capture earlier + const timeSegment = segments.find((s) => s.name === "timestamp"); + if (timeSegment) { + const tsStr = new TextDecoder().decode(timeSegment.data); + const { timestamp } = Timestamp.fromString(tsStr, false); + if (timestamp) payload.timestamp = timestamp; + } + } + + if (maidenhead) payload.maidenhead = maidenhead; + + if (withStructure) { + segments.push({ + name: "status", + data: new TextEncoder().encode(text).buffer, + isString: true, + fields: [{ type: FieldType.STRING, name: "text", length: text.length }] + }); + return { payload, segment: segments }; + } + + return { payload }; +}; + +export default decodeStatusPayload; diff --git a/src/payload.telemetry.ts b/src/payload.telemetry.ts new file mode 100644 index 0000000..2b7abea --- /dev/null +++ b/src/payload.telemetry.ts @@ -0,0 +1,197 @@ +import { FieldType, type Segment } from "@hamradio/packet"; + +import { + DataType, + type Payload, + type TelemetryBitSensePayload, + type TelemetryCoefficientsPayload, + type TelemetryDataPayload, + type TelemetryParameterPayload, + type TelemetryUnitPayload +} from "./frame.types"; + +export const decodeTelemetryPayload = ( + raw: string, + withStructure: boolean = false +): { + payload: Payload | null; + segment?: Segment[]; +} => { + try { + if (raw.length < 2) return { payload: null }; + + const rest = raw.substring(1).trim(); + if (!rest) return { payload: null }; + + const segments: Segment[] = withStructure ? [] : []; + + // Telemetry data: convention used here: starts with '#' then sequence then analogs and digital + if (rest.startsWith("#")) { + const parts = rest.substring(1).trim().split(/\s+/); + const seq = parseInt(parts[0], 10); + let analog: number[] = []; + let digital = 0; + + if (parts.length >= 2) { + // analogs as comma separated + analog = parts[1].split(",").map((v) => parseFloat(v)); + } + + if (parts.length >= 3) { + digital = parseInt(parts[2], 10); + } + + if (withStructure) { + segments.push({ + name: "telemetry sequence", + data: new TextEncoder().encode(String(seq)).buffer, + isString: true, + fields: [ + { + type: FieldType.STRING, + name: "sequence", + length: String(seq).length + } + ] + }); + + segments.push({ + name: "telemetry analog", + data: new TextEncoder().encode(parts[1] || "").buffer, + isString: true, + fields: [ + { + type: FieldType.STRING, + name: "analogs", + length: (parts[1] || "").length + } + ] + }); + + segments.push({ + name: "telemetry digital", + data: new TextEncoder().encode(String(digital)).buffer, + isString: true, + fields: [ + { + type: FieldType.STRING, + name: "digital", + length: String(digital).length + } + ] + }); + } + + const payload: TelemetryDataPayload = { + type: DataType.TelemetryData, + variant: "data", + sequence: isNaN(seq) ? 0 : seq, + analog, + digital: isNaN(digital) ? 0 : digital + }; + + if (withStructure) return { payload, segment: segments }; + return { payload }; + } + + // Telemetry parameters: 'PARAM' keyword + if (/^PARAM/i.test(rest)) { + const after = rest.replace(/^PARAM\s*/i, ""); + const names = after.split(/[,\s]+/).filter(Boolean); + if (withStructure) { + segments.push({ + name: "telemetry parameters", + data: new TextEncoder().encode(after).buffer, + isString: true, + fields: [{ type: FieldType.STRING, name: "names", length: after.length }] + }); + } + const payload: TelemetryParameterPayload = { + type: DataType.TelemetryData, + variant: "parameters", + names + }; + if (withStructure) return { payload, segment: segments }; + return { payload }; + } + + // Telemetry units: 'UNIT' + if (/^UNIT/i.test(rest)) { + const after = rest.replace(/^UNIT\s*/i, ""); + const units = after.split(/[,\s]+/).filter(Boolean); + if (withStructure) { + segments.push({ + name: "telemetry units", + data: new TextEncoder().encode(after).buffer, + isString: true, + fields: [{ type: FieldType.STRING, name: "units", length: after.length }] + }); + } + const payload: TelemetryUnitPayload = { + type: DataType.TelemetryData, + variant: "unit", + units + }; + if (withStructure) return { payload, segment: segments }; + return { payload }; + } + + // Telemetry coefficients: 'COEFF' a:,b:,c: + if (/^COEFF/i.test(rest)) { + const after = rest.replace(/^COEFF\s*/i, ""); + const aMatch = after.match(/A:([^\s;]+)/i); + const bMatch = after.match(/B:([^\s;]+)/i); + const cMatch = after.match(/C:([^\s;]+)/i); + const parseList = (s?: string) => (s ? s.split(",").map((v) => parseFloat(v)) : []); + const coefficients = { + a: parseList(aMatch?.[1]), + b: parseList(bMatch?.[1]), + c: parseList(cMatch?.[1]) + }; + if (withStructure) { + segments.push({ + name: "telemetry coefficients", + data: new TextEncoder().encode(after).buffer, + isString: true, + fields: [{ type: FieldType.STRING, name: "coeffs", length: after.length }] + }); + } + const payload: TelemetryCoefficientsPayload = { + type: DataType.TelemetryData, + variant: "coefficients", + coefficients + }; + if (withStructure) return { payload, segment: segments }; + return { payload }; + } + + // Telemetry bitsense/project: 'BITS' [project] + if (/^BITS?/i.test(rest)) { + const parts = rest.split(/\s+/).slice(1); + const sense = parts.length > 0 ? parseInt(parts[0], 10) : 0; + const projectName = parts.length > 1 ? parts.slice(1).join(" ") : undefined; + if (withStructure) { + segments.push({ + name: "telemetry bitsense", + data: new TextEncoder().encode(rest).buffer, + isString: true, + fields: [{ type: FieldType.STRING, name: "bitsense", length: rest.length }] + }); + } + const payload: TelemetryBitSensePayload = { + type: DataType.TelemetryData, + variant: "bitsense", + sense: isNaN(sense) ? 0 : sense, + ...(projectName ? { projectName } : {}) + }; + if (withStructure) return { payload, segment: segments }; + return { payload }; + } + + return { payload: null }; + } catch { + return { payload: null }; + } +}; + +export default decodeTelemetryPayload; diff --git a/src/payload.thirdparty.ts b/src/payload.thirdparty.ts new file mode 100644 index 0000000..f377e87 --- /dev/null +++ b/src/payload.thirdparty.ts @@ -0,0 +1,135 @@ +import { FieldType, type Segment } from "@hamradio/packet"; + +import { Frame } from "./frame"; +import { DataType, type Payload, type ThirdPartyPayload, UserDefinedPayload } from "./frame.types"; + +export const decodeUserDefinedPayload = ( + raw: string, + withStructure: boolean = false +): { + payload: Payload | null; + segment?: Segment[]; +} => { + try { + if (raw.length < 2) return { payload: null }; + + // content after '{' + const rest = raw.substring(1); + + // user packet type is first token (up to first space) often like '01' or 'TYP' + const match = rest.match(/^([^\s]+)\s*(.*)$/s); + let userPacketType = ""; + let data = ""; + if (match) { + userPacketType = match[1] || ""; + data = (match[2] || "").trim(); + } + + const payload: UserDefinedPayload = { + type: DataType.UserDefined, + userPacketType, + data + } as const; + + if (withStructure) { + const segments: Segment[] = []; + segments.push({ + name: "user-defined", + data: new TextEncoder().encode(rest).buffer, + isString: true, + fields: [{ type: FieldType.STRING, name: "raw", length: rest.length }] + }); + + segments.push({ + name: "user-packet-type", + data: new TextEncoder().encode(userPacketType).buffer, + isString: true, + fields: [ + { + type: FieldType.STRING, + name: "type", + length: userPacketType.length + } + ] + }); + + segments.push({ + name: "user-data", + data: new TextEncoder().encode(data).buffer, + isString: true, + fields: [{ type: FieldType.STRING, name: "data", length: data.length }] + }); + + return { payload, segment: segments }; + } + + return { payload }; + } catch { + return { payload: null }; + } +}; + +export const decodeThirdPartyPayload = ( + raw: string, + withStructure: boolean = false +): { + payload: Payload | null; + segment?: Segment[]; +} => { + try { + if (raw.length < 2) return { payload: null }; + + // Content after '}' is the encapsulated third-party frame or raw data + const rest = raw.substring(1); + + // Attempt to parse the embedded text as a full APRS frame (route:payload) + let nestedFrame: Frame | undefined; + try { + // parseFrame is defined in this module; use Frame.parse to attempt parse + nestedFrame = Frame.parse(rest); + } catch { + nestedFrame = undefined; + } + + const payload: ThirdPartyPayload = { + type: DataType.ThirdParty, + comment: rest, + ...(nestedFrame ? { frame: nestedFrame } : {}) + } as const; + + if (withStructure) { + const segments: Segment[] = []; + + segments.push({ + name: "third-party", + data: new TextEncoder().encode(rest).buffer, + isString: true, + fields: [{ type: FieldType.STRING, name: "raw", length: rest.length }] + }); + + if (nestedFrame) { + // Include a short section pointing to the nested frame's data (stringified) + const nf = nestedFrame; + const nfStr = `${nf.source.toString()}>${nf.destination.toString()}:${nf.payload}`; + segments.push({ + name: "third-party-nested-frame", + data: new TextEncoder().encode(nfStr).buffer, + isString: true, + fields: [ + { + type: FieldType.STRING, + name: "nested", + length: nfStr.length + } + ] + }); + } + + return { payload, segment: segments }; + } + + return { payload }; + } catch { + return { payload: null }; + } +}; diff --git a/src/payload.weather.ts b/src/payload.weather.ts new file mode 100644 index 0000000..1dd1c0f --- /dev/null +++ b/src/payload.weather.ts @@ -0,0 +1,129 @@ +import { FieldType, type Segment } from "@hamradio/packet"; + +import { DataType, type IPosition, type Payload, type WeatherPayload } from "./frame.types"; +import { isCompressedPosition, parseCompressedPosition, parseUncompressedPosition } from "./payload.position"; +import Timestamp from "./timestamp"; + +export const decodeWeatherPayload = ( + raw: string, + withStructure: boolean = false +): { + payload: Payload | null; + segment?: Segment[]; +} => { + try { + if (raw.length < 2) return { payload: null }; + + let offset = 1; // skip '_' data type + const segments: Segment[] = withStructure ? [] : []; + + // Try optional timestamp (7 chars) + let timestamp; + if (raw.length >= offset + 7) { + const timeStr = raw.substring(offset, offset + 7); + const parsed = Timestamp.fromString(timeStr, withStructure); + timestamp = parsed.timestamp; + if (parsed.segment) { + segments.push(parsed.segment); + } + if (timestamp) offset += 7; + } + + // Try optional position following timestamp + let position: IPosition | undefined; + let consumed = 0; + const tail = raw.substring(offset); + if (tail.length > 0) { + // If the tail starts with a wind token like DDD/SSS, treat it as weather data + // and do not attempt to parse it as a position (avoids mis-detecting wind + // values as compressed position fields). + if (/^\s*\d{3}\/\d{1,3}/.test(tail)) { + // no position present; leave consumed = 0 + } else if (isCompressedPosition(tail)) { + const parsed = parseCompressedPosition(tail, withStructure); + if (parsed.position) { + position = { + latitude: parsed.position.latitude, + longitude: parsed.position.longitude, + symbol: parsed.position.symbol, + altitude: parsed.position.altitude + }; + if (parsed.segment) segments.push(parsed.segment); + consumed = 13; + } + } else { + const parsed = parseUncompressedPosition(tail, withStructure); + if (parsed.position) { + position = { + latitude: parsed.position.latitude, + longitude: parsed.position.longitude, + symbol: parsed.position.symbol, + ambiguity: parsed.position.ambiguity + }; + if (parsed.segment) segments.push(parsed.segment); + consumed = 19; + } + } + } + + offset += consumed; + + const rest = raw.substring(offset).trim(); + + const payload: WeatherPayload = { + type: DataType.WeatherReportNoPosition + }; + if (timestamp) payload.timestamp = timestamp; + if (position) payload.position = position; + + if (rest && rest.length > 0) { + // Parse common tokens + // Wind: DDD/SSS [gGGG] + const windMatch = rest.match(/(\d{3})\/(\d{1,3})(?:g(\d{1,3}))?/); + if (windMatch) { + payload.windDirection = parseInt(windMatch[1], 10); + payload.windSpeed = parseInt(windMatch[2], 10); + if (windMatch[3]) payload.windGust = parseInt(windMatch[3], 10); + } + + // Temperature: tNNN (F) + const tempMatch = rest.match(/t(-?\d{1,3})/i); + if (tempMatch) payload.temperature = parseInt(tempMatch[1], 10); + + // Rain: rNNN (last hour), pNNN (24h), PNNN (since midnight) - values are hundredths of inch + const rMatch = rest.match(/r(\d{3})/); + if (rMatch) payload.rainLastHour = parseInt(rMatch[1], 10); + const pMatch = rest.match(/p(\d{3})/); + if (pMatch) payload.rainLast24Hours = parseInt(pMatch[1], 10); + const PMatch = rest.match(/P(\d{3})/); + if (PMatch) payload.rainSinceMidnight = parseInt(PMatch[1], 10); + + // Humidity: hNN + const hMatch = rest.match(/h(\d{1,3})/); + if (hMatch) payload.humidity = parseInt(hMatch[1], 10); + + // Pressure: bXXXX or bXXXXX (tenths of millibar) + const bMatch = rest.match(/b(\d{4,5})/); + if (bMatch) payload.pressure = parseInt(bMatch[1], 10); + + // Add raw comment + payload.comment = rest; + + if (withStructure) { + segments.push({ + name: "weather", + data: new TextEncoder().encode(rest).buffer, + isString: true, + fields: [{ type: FieldType.STRING, name: "text", length: rest.length }] + }); + } + } + + if (withStructure) return { payload, segment: segments }; + return { payload }; + } catch { + return { payload: null }; + } +}; + +export default decodeWeatherPayload; diff --git a/src/position.ts b/src/position.ts index 4992102..6229d1d 100644 --- a/src/position.ts +++ b/src/position.ts @@ -76,3 +76,5 @@ export class Position implements IPosition { return R * c; // Distance in meters } } + +export default Position; diff --git a/src/timestamp.ts b/src/timestamp.ts new file mode 100644 index 0000000..370d4b4 --- /dev/null +++ b/src/timestamp.ts @@ -0,0 +1,189 @@ +import { FieldType, Segment } from "@hamradio/packet"; + +import { ITimestamp } from "./frame.types"; + +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; + } + } + + static fromString( + str: string, + withStructure: boolean = false + ): { + timestamp: Timestamp | undefined; + segment?: Segment; + } { + if (str.length !== 7) return { timestamp: undefined }; + + const timeType = str.charAt(6); + + if (timeType === "z") { + // DHM format: Day-Hour-Minute (UTC) + const timestamp = new Timestamp(parseInt(str.substring(2, 4), 10), parseInt(str.substring(4, 6), 10), "DHM", { + day: parseInt(str.substring(0, 2), 10), + zulu: true + }); + + const segment = withStructure + ? { + name: "timestamp", + data: new TextEncoder().encode(str).buffer, + isString: true, + fields: [ + { type: FieldType.STRING, name: "day (DD)", length: 2 }, + { type: FieldType.STRING, name: "hour (HH)", length: 2 }, + { type: FieldType.STRING, name: "minute (MM)", length: 2 }, + { type: FieldType.CHAR, name: "timezone indicator", length: 1 } + ] + } + : undefined; + + return { timestamp, segment }; + } else if (timeType === "h") { + // HMS format: Hour-Minute-Second (UTC) + const timestamp = new Timestamp(parseInt(str.substring(0, 2), 10), parseInt(str.substring(2, 4), 10), "HMS", { + seconds: parseInt(str.substring(4, 6), 10), + zulu: true + }); + + const segment = withStructure + ? { + name: "timestamp", + data: new TextEncoder().encode(str).buffer, + isString: true, + fields: [ + { type: FieldType.STRING, name: "hour (HH)", length: 2 }, + { type: FieldType.STRING, name: "minute (MM)", length: 2 }, + { type: FieldType.STRING, name: "second (SS)", length: 2 }, + { type: FieldType.CHAR, name: "timezone indicator", length: 1 } + ] + } + : undefined; + + return { timestamp, segment }; + } else if (timeType === "/") { + // MDHM format: Month-Day-Hour-Minute (local) + const timestamp = new Timestamp(parseInt(str.substring(4, 6), 10), parseInt(str.substring(6, 8), 10), "MDHM", { + month: parseInt(str.substring(0, 2), 10), + day: parseInt(str.substring(2, 4), 10), + zulu: false + }); + + const segment = withStructure + ? { + name: "timestamp", + data: new TextEncoder().encode(str).buffer, + isString: true, + fields: [ + { type: FieldType.STRING, name: "month (MM)", length: 2 }, + { type: FieldType.STRING, name: "day (DD)", length: 2 }, + { type: FieldType.STRING, name: "hour (HH)", length: 2 }, + { type: FieldType.STRING, name: "minute (MM)", length: 2 }, + { type: FieldType.CHAR, name: "timezone indicator", length: 1 } + ] + } + : undefined; + + return { timestamp, segment }; + } + + return { timestamp: undefined }; + } +} + +export default Timestamp; diff --git a/test/frame.test.ts b/test/frame.test.ts index 493e37b..d879f49 100644 --- a/test/frame.test.ts +++ b/test/frame.test.ts @@ -1,7 +1,7 @@ import { Dissected, FieldType } from "@hamradio/packet"; import { describe, expect, it } from "vitest"; -import { Address, Frame, Timestamp } from "../src/frame"; +import { Address, Frame } from "../src/frame"; import { DataType, type ITimestamp, @@ -12,6 +12,7 @@ import { type PositionPayload, type StatusPayload } from "../src/frame.types"; +import Timestamp from "../src/timestamp"; // Address parsing: split by method describe("Address.parse", () => { diff --git a/test/frame.capabilities.test.ts b/test/payload.capabilities.test.ts similarity index 100% rename from test/frame.capabilities.test.ts rename to test/payload.capabilities.test.ts diff --git a/test/frame.extras.test.ts b/test/payload.extras.test.ts similarity index 84% rename from test/frame.extras.test.ts rename to test/payload.extras.test.ts index 4885480..37db809 100644 --- a/test/frame.extras.test.ts +++ b/test/payload.extras.test.ts @@ -4,6 +4,7 @@ import { describe, expect, it } from "vitest"; import { Frame } from "../src/frame"; import type { PositionPayload } from "../src/frame.types"; import { feetToMeters, milesToMeters } from "../src/parser"; +import { decodeTelemetry } from "../src/payload.extras"; describe("APRS extras test vectors", () => { it("parses altitude token in the beginning of a comment and emits structure", () => { @@ -175,3 +176,49 @@ describe("APRS extras test vectors", () => { expect(commentIndex).toBeGreaterThan(altitudeIndex); // Comment comes after altitude }); }); + +describe("decodeTelemetry", () => { + it("decodes minimal telemetry (|!!!!|)", () => { + const result = decodeTelemetry("!!!!"); + expect(result.sequence).toBe(0); + expect(result.analog).toEqual([0]); + expect(result.digital).toBeUndefined(); + }); + + it("decodes sequence and one channel", () => { + const result = decodeTelemetry("ss11"); + expect(result.sequence).toBe(7544); + expect(result.analog).toEqual([1472]); + expect(result.digital).toBeUndefined(); + }); + + it("decodes sequence and two channels", () => { + const result = decodeTelemetry("ss1122"); + expect(result.sequence).toBe(7544); + expect(result.analog).toEqual([1472, 1564]); + expect(result.digital).toBeUndefined(); + }); + + it("decodes sequence and five channels", () => { + const result = decodeTelemetry("ss1122334455"); + expect(result.sequence).toBe(7544); + expect(result.analog).toEqual([1472, 1564, 1656, 1748, 1840]); + expect(result.digital).toBeUndefined(); + }); + + it("decodes sequence, five channels, and digital", () => { + const result = decodeTelemetry('ss1122334455!"'); + expect(result.sequence).toBe(7544); + expect(result.analog).toEqual([1472, 1564, 1656, 1748, 1840]); + expect(result.digital).toBe(1); + }); + + it("throws on too short input", () => { + expect(() => decodeTelemetry("!")).toThrow(); + expect(() => decodeTelemetry("")).toThrow(); + }); + + it("throws on invalid base91", () => { + expect(() => decodeTelemetry("ss11~~")).toThrow(); + }); +}); diff --git a/test/frame.query.test.ts b/test/payload.query.test.ts similarity index 100% rename from test/frame.query.test.ts rename to test/payload.query.test.ts diff --git a/test/frame.rawgps.test.ts b/test/payload.rawgps.test.ts similarity index 100% rename from test/frame.rawgps.test.ts rename to test/payload.rawgps.test.ts diff --git a/test/frame.telemetry.test.ts b/test/payload.telemetry.test.ts similarity index 100% rename from test/frame.telemetry.test.ts rename to test/payload.telemetry.test.ts diff --git a/test/frame.userdefined.test.ts b/test/payload.thirdparty.test.ts similarity index 100% rename from test/frame.userdefined.test.ts rename to test/payload.thirdparty.test.ts diff --git a/test/frame.weather.test.ts b/test/payload.weather.test.ts similarity index 100% rename from test/frame.weather.test.ts rename to test/payload.weather.test.ts