diff --git a/.prettierrc.ts b/.prettierrc.ts new file mode 100644 index 0000000..e805ae9 --- /dev/null +++ b/.prettierrc.ts @@ -0,0 +1,19 @@ +import { type Config } from "prettier"; + +const config: Config = { + plugins: ["@trivago/prettier-plugin-sort-imports"], + trailingComma: "none", + printWidth: 120, + importOrder: [ + "", + "", + "(?:services|components|contexts|pages|libs|types)/(.*)$", + "^[./].*\\.(?:ts|tsx)$", + "\\.(?:scss|css)$", + "^[./]" + ], + importOrderSeparation: true, + importOrderSortSpecifiers: true +}; + +export default config; diff --git a/package.json b/package.json index ede8646..86017fa 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,6 @@ { "name": "@hamradio/aprs", + "type": "module", "version": "1.1.3", "description": "APRS (Automatic Packet Reporting System) protocol support for Typescript", "keywords": [ @@ -40,9 +41,11 @@ }, "devDependencies": { "@eslint/js": "^10.0.1", + "@trivago/prettier-plugin-sort-imports": "^6.0.2", "@vitest/coverage-v8": "^4.0.18", "eslint": "^10.0.3", "globals": "^17.4.0", + "prettier": "^3.8.1", "tsup": "^8.5.1", "typescript": "^5.9.3", "typescript-eslint": "^8.57.0", diff --git a/src/frame.ts b/src/frame.ts index 7443866..f604c8f 100644 --- a/src/frame.ts +++ b/src/frame.ts @@ -1,38 +1,35 @@ -import type { Dissected, Segment, Field } from "@hamradio/packet"; +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 { + DataType, type IAddress, + IDirectionFinding, type IFrame, - type Payload, - type ITimestamp, - type PositionPayload, - type MessagePayload, type IPosition, - type ObjectPayload, + IPowerHeightGain, + type ITimestamp, type ItemPayload, - type StatusPayload, + type MessagePayload, + MicEPayload, + type ObjectPayload, + type Payload, + type PositionPayload, type QueryPayload, - type TelemetryDataPayload, - type TelemetryBitSensePayload, - type TelemetryCoefficientsPayload, - type TelemetryParameterPayload, - type TelemetryUnitPayload, - type WeatherPayload, type RawGPSPayload, type StationCapabilitiesPayload, + type StatusPayload, + type TelemetryBitSensePayload, + type TelemetryCoefficientsPayload, + type TelemetryDataPayload, + type TelemetryParameterPayload, + type TelemetryUnitPayload, type ThirdPartyPayload, - DataType, - MicEPayload, + type WeatherPayload } from "./frame.types"; +import { base91ToNumber, feetToMeters, knotsToKmh, milesToMeters } from "./parser"; import { Position } from "./position"; -import { base91ToNumber } from "./parser"; -import { - DTM, - GGA, - INmeaSentence, - Decoder as NmeaDecoder, - RMC, -} from "extended-nmea"; export class Timestamp implements ITimestamp { day?: number; @@ -52,7 +49,7 @@ export class Timestamp implements ITimestamp { month?: number; seconds?: number; zulu?: boolean; - } = {}, + } = {} ) { this.hours = hours; this.minutes = minutes; @@ -81,53 +78,17 @@ export class Timestamp implements ITimestamp { let date: Date; if (this.zulu) { - date = new Date( - Date.UTC( - currentYear, - currentMonth, - this.day!, - this.hours, - this.minutes, - 0, - 0, - ), - ); + 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, - ); + 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, - ), - ); + 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, - ); + date = new Date(currentYear, currentMonth - 1, this.day!, this.hours, this.minutes, 0, 0); } } @@ -158,27 +119,11 @@ export class Timestamp implements ITimestamp { } 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, - ); + 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, - ); + date = new Date(currentYear - 1, (this.month || 1) - 1, this.day!, this.hours, this.minutes, 0, 0); } return date; @@ -191,11 +136,7 @@ export class Address implements IAddress { ssid: string = ""; isRepeated: boolean = false; - constructor( - call: string, - ssid: string | number = "", - isRepeated: boolean = false, - ) { + constructor(call: string, ssid: string | number = "", isRepeated: boolean = false) { this.call = call; if (typeof ssid === "number") { this.ssid = ssid.toString(); @@ -237,13 +178,7 @@ export class Frame implements IFrame { payload: string; private _routingSection?: Segment; - constructor( - source: Address, - destination: Address, - path: Address[], - payload: string, - routingSection?: Segment, - ) { + constructor(source: Address, destination: Address, path: Address[], payload: string, routingSection?: Segment) { this.source = source; this.destination = destination; this.path = path; @@ -269,9 +204,7 @@ export class Frame implements IFrame { * Decode the APRS payload based on its data type identifier * Returns the decoded payload with optional structure for packet dissection */ - decode( - withStructure?: boolean, - ): Payload | null | { payload: Payload | null; structure: Dissected } { + decode(withStructure?: boolean): Payload | null | { payload: Payload | null; structure: Dissected } { if (!this.payload) { if (withStructure) { const structure: Dissected = []; @@ -284,7 +217,7 @@ export class Frame implements IFrame { name: "Data Type Identifier", data: new TextEncoder().encode(this.payload.charAt(0)).buffer, isString: true, - fields: [{ type: FieldType.CHAR, name: "Identifier", length: 1 }], + fields: [{ type: FieldType.CHAR, name: "Identifier", length: 1 }] }); } return { payload: null, structure }; @@ -303,69 +236,56 @@ 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 } = this.decodePosition(dataType, withStructure)); break; case "`": // Mic-E current case "'": // Mic-E old - ({ payload: decodedPayload, segment: payloadsegment } = - this.decodeMicE(withStructure)); + ({ payload: decodedPayload, segment: payloadsegment } = this.decodeMicE(withStructure)); break; case ":": // Message - ({ payload: decodedPayload, segment: payloadsegment } = - this.decodeMessage(withStructure)); + ({ payload: decodedPayload, segment: payloadsegment } = this.decodeMessage(withStructure)); break; case ";": // Object - ({ payload: decodedPayload, segment: payloadsegment } = - this.decodeObject(withStructure)); + ({ payload: decodedPayload, segment: payloadsegment } = this.decodeObject(withStructure)); break; case ")": // Item - ({ payload: decodedPayload, segment: payloadsegment } = - this.decodeItem(withStructure)); + ({ payload: decodedPayload, segment: payloadsegment } = this.decodeItem(withStructure)); break; case ">": // Status - ({ payload: decodedPayload, segment: payloadsegment } = - this.decodeStatus(withStructure)); + ({ payload: decodedPayload, segment: payloadsegment } = this.decodeStatus(withStructure)); break; case "?": // Query - ({ payload: decodedPayload, segment: payloadsegment } = - this.decodeQuery(withStructure)); + ({ payload: decodedPayload, segment: payloadsegment } = this.decodeQuery(withStructure)); break; case "T": // Telemetry - ({ payload: decodedPayload, segment: payloadsegment } = - this.decodeTelemetry(withStructure)); + ({ payload: decodedPayload, segment: payloadsegment } = this.decodeTelemetry(withStructure)); break; case "_": // Weather without position - ({ payload: decodedPayload, segment: payloadsegment } = - this.decodeWeather(withStructure)); + ({ payload: decodedPayload, segment: payloadsegment } = this.decodeWeather(withStructure)); break; case "$": // Raw GPS - ({ payload: decodedPayload, segment: payloadsegment } = - this.decodeRawGPS(withStructure)); + ({ payload: decodedPayload, segment: payloadsegment } = this.decodeRawGPS(withStructure)); break; case "<": // Station capabilities - ({ payload: decodedPayload, segment: payloadsegment } = - this.decodeCapabilities(withStructure)); + ({ payload: decodedPayload, segment: payloadsegment } = this.decodeCapabilities(withStructure)); break; case "{": // User-defined - ({ payload: decodedPayload, segment: payloadsegment } = - this.decodeUserDefined(withStructure)); + ({ payload: decodedPayload, segment: payloadsegment } = this.decodeUserDefined(withStructure)); break; case "}": // Third-party - ({ payload: decodedPayload, segment: payloadsegment } = - this.decodeThirdParty(withStructure)); + ({ payload: decodedPayload, segment: payloadsegment } = this.decodeThirdParty(withStructure)); break; default: @@ -383,7 +303,7 @@ export class Frame implements IFrame { name: "data type", data: new TextEncoder().encode(this.payload.charAt(0)).buffer, isString: true, - fields: [{ type: FieldType.CHAR, name: "identifier", length: 1 }], + fields: [{ type: FieldType.CHAR, name: "identifier", length: 1 }] }); if (payloadsegment) { structure.push(...payloadsegment); @@ -396,7 +316,7 @@ export class Frame implements IFrame { private decodePosition( dataType: string, - withStructure: boolean = false, + withStructure: boolean = false ): { payload: Payload | null; segment?: Segment[] } { try { const hasTimestamp = dataType === "/" || dataType === "@"; @@ -412,8 +332,7 @@ export class Frame implements IFrame { 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); + const { timestamp: parsedTimestamp, segment: timestampSegment } = this.parseTimestamp(timeStr, withStructure); timestamp = parsedTimestamp; if (timestampSegment) { @@ -428,26 +347,23 @@ export class Frame implements IFrame { if (this.payload.length < offset + 13) return { payload: null }; // Check if compressed format - const isCompressed = this.isCompressedPosition( - this.payload.substring(offset), - ); + const isCompressed = this.isCompressedPosition(this.payload.substring(offset)); let position: Position; let comment = ""; if (isCompressed) { // Compressed format: /YYYYXXXX$csT - const { position: compressed, segment: compressedSegment } = - this.parseCompressedPosition( - this.payload.substring(offset), - withStructure, - ); + 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, + symbol: compressed.symbol }); if (compressed.altitude !== undefined) { @@ -462,17 +378,16 @@ export class Frame implements IFrame { comment = this.payload.substring(offset); } else { // Uncompressed format: DDMMmmH/DDDMMmmH$ - const { position: uncompressed, segment: uncompressedSegment } = - this.parseUncompressedPosition( - this.payload.substring(offset), - withStructure, - ); + 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, + symbol: uncompressed.symbol }); if (uncompressed.ambiguity !== undefined) { @@ -491,22 +406,41 @@ export class Frame implements IFrame { const altMatch = comment.match(/\/A=(\d{6})/); if (altMatch) { position.altitude = parseInt(altMatch[1], 10) * 0.3048; // feet to meters + // remove altitude token from comment + comment = comment.replace(altMatch[0], "").trim(); } + // Extract RNG and PHG tokens and optionally emit sections + const extras = this.parseCommentExtras(comment, withStructure); + if (extras.range !== undefined) position.range = extras.range; + if (extras.phg !== undefined) position.phg = extras.phg; + if (extras.dfs !== undefined) position.dfs = extras.dfs; + if (extras.cse !== undefined && position.course === undefined) position.course = extras.cse; + if (extras.spd !== undefined && position.speed === undefined) position.speed = extras.spd; + comment = extras.comment; + if (comment) { position.comment = comment; // Emit comment section as we parse if (withStructure) { + const commentFields: Field[] = [{ type: FieldType.STRING, name: "text", length: comment.length }]; + if (extras.fields) commentFields.push(...extras.fields); structure.push({ name: "comment", data: new TextEncoder().encode(comment).buffer, isString: true, - fields: [ - { type: FieldType.STRING, name: "text", length: comment.length }, - ], + fields: commentFields }); } + } 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: @@ -535,7 +469,7 @@ export class Frame implements IFrame { type: payloadType, timestamp, position, - messaging, + messaging }; if (withStructure) { @@ -550,7 +484,7 @@ export class Frame implements IFrame { private parseTimestamp( timeStr: string, - withStructure: boolean = false, + withStructure: boolean = false ): { timestamp: Timestamp | undefined; segment?: Segment } { if (timeStr.length !== 7) return { timestamp: undefined }; @@ -564,8 +498,8 @@ export class Frame implements IFrame { "DHM", { day: parseInt(timeStr.substring(0, 2), 10), - zulu: true, - }, + zulu: true + } ); const segment = withStructure @@ -577,8 +511,8 @@ export class Frame implements IFrame { { 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 }, - ], + { type: FieldType.CHAR, name: "timezone indicator", length: 1 } + ] } : undefined; @@ -591,8 +525,8 @@ export class Frame implements IFrame { "HMS", { seconds: parseInt(timeStr.substring(4, 6), 10), - zulu: true, - }, + zulu: true + } ); const segment = withStructure @@ -604,8 +538,8 @@ export class Frame implements IFrame { { 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 }, - ], + { type: FieldType.CHAR, name: "timezone indicator", length: 1 } + ] } : undefined; @@ -619,8 +553,8 @@ export class Frame implements IFrame { { month: parseInt(timeStr.substring(0, 2), 10), day: parseInt(timeStr.substring(2, 4), 10), - zulu: false, - }, + zulu: false + } ); const segment = withStructure @@ -633,8 +567,8 @@ export class Frame implements IFrame { { 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 }, - ], + { type: FieldType.CHAR, name: "timezone indicator", length: 1 } + ] } : undefined; @@ -666,20 +600,13 @@ export class Frame implements IFrame { const lon2 = data.charCodeAt(6); return ( - lat1 >= 33 && - lat1 <= 124 && - lat2 >= 33 && - lat2 <= 124 && - lon1 >= 33 && - lon1 <= 124 && - lon2 >= 33 && - lon2 <= 124 + lat1 >= 33 && lat1 <= 124 && lat2 >= 33 && lat2 <= 124 && lon1 >= 33 && lon1 <= 124 && lon2 >= 33 && lon2 <= 124 ); } private parseCompressedPosition( data: string, - withStructure: boolean = false, + withStructure: boolean = false ): { position: IPosition | null; segment?: Segment; @@ -707,8 +634,8 @@ export class Frame implements IFrame { longitude, symbol: { table: symbolTable, - code: symbolCode, - }, + code: symbolCode + } }; // Check for compressed altitude (csT format) @@ -733,8 +660,8 @@ export class Frame implements IFrame { { 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" }, - ], + { type: FieldType.CHAR, length: 1, name: "altitude" } + ] } : undefined; @@ -746,7 +673,7 @@ export class Frame implements IFrame { private parseUncompressedPosition( data: string, - withStructure: boolean = false, + withStructure: boolean = false ): { position: IPosition | null; segment?: Segment; @@ -803,8 +730,8 @@ export class Frame implements IFrame { longitude, symbol: { table: symbolTable, - code: symbolCode, - }, + code: symbolCode + } }; if (ambiguity > 0) { @@ -820,14 +747,194 @@ export class Frame implements IFrame { { 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" }, - ], + { type: FieldType.CHAR, length: 1, name: "symbol code" } + ] } : undefined; return { position: result, segment }; } + private parseCommentExtras( + comment: string, + withStructure: boolean = false + ): { + comment: string; + range?: number; + phg?: IPowerHeightGain; + dfs?: IDirectionFinding; + fields?: Field[]; + cse?: number; + spd?: number; + } { + if (!comment || comment.length === 0) return { comment }; + + let cleaned = comment; + let range: number | undefined; + let phg: IPowerHeightGain | undefined; + let dfs: IDirectionFinding | undefined; + const fields: Field[] = []; + + let cse: number | undefined; + let spd: number | undefined; + + // Process successive 7-byte data extensions at the start of the comment. + while (true) { + const trimmed = cleaned.trimStart(); + if (trimmed.length < 7) break; + + // Allow a single non-alphanumeric prefix (e.g. '>' or '#') before extension + const prefixLen = /^[^A-Za-z0-9]/.test(trimmed.charAt(0)) ? 1 : 0; + if (trimmed.length < prefixLen + 7) break; + const ext = trimmed.substring(prefixLen, prefixLen + 7); + + // RNGrrrr -> pre-calculated range in miles (4 digits) + if (ext.startsWith("RNG")) { + const r = ext.substring(3, 7); + if (/^\d{4}$/.test(r)) { + range = milesToMeters(parseInt(r, 10)); + cleaned = trimmed.substring(prefixLen + 7).trim(); + if (withStructure) fields.push({ type: FieldType.STRING, name: "RNG", length: 7 }); + continue; + } + } + + // PHGphgd + if (!phg && ext.startsWith("PHG")) { + // 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 = ext.charAt(3); + const h = ext.charAt(4); + const g = ext.charAt(5); + const d = ext.charAt(6); + 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"; + } + + phg = { + power: powerWatts, + height: heightMeters, + gain: gainDbi, + directivity + }; + cleaned = trimmed.substring(prefixLen + 7).trim(); + if (withStructure) fields.push({ type: FieldType.STRING, name: "PHG", length: 7 }); + 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"; + } + + dfs = { + strength, + height: heightMeters, + gain: gainDbi, + directivity + }; + + cleaned = trimmed.substring(prefixLen + 7).trim(); + if (withStructure) fields.push({ type: FieldType.STRING, name: "DFS", length: 7 }); + continue; + } + + // Course/Speed DDD/SSS (7 bytes: 3 digits / 3 digits) + if (!cse && /^\d{3}\/\d{3}$/.test(ext)) { + const courseStr = ext.substring(0, 3); + const speedStr = ext.substring(4, 7); + cse = parseInt(courseStr, 10); + spd = knotsToKmh(parseInt(speedStr, 10)); + cleaned = trimmed.substring(prefixLen + 7).trim(); + if (withStructure) fields.push({ type: FieldType.STRING, name: "CSE/SPD", length: 7 }); + + // If there is an 8-byte DF/NRQ following (leading '/'), parse that too + if (cleaned.length >= 8 && cleaned.charAt(0) === "/") { + const dfExt = cleaned.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 (dfs === undefined) { + dfs = {}; + } + dfs.bearing = dfBearing; + dfs.strength = dfStrength; + if (withStructure) fields.push({ type: FieldType.STRING, name: "DF/NRQ", length: 8 }); + cleaned = cleaned.substring(8).trim(); + } + } + continue; + } + + // No recognized 7-byte extension at start + break; + } + + const extrasObj: { + comment: string; + range?: number; + phg?: IPowerHeightGain; + dfs?: IDirectionFinding; + cse?: number; + spd?: number; + dfBearing?: number; + dfStrength?: number; + fields?: Field[]; + } = { comment: cleaned }; + if (range !== undefined) extrasObj.range = range; + if (phg !== undefined) extrasObj.phg = phg; + if (dfs !== undefined) extrasObj.dfs = dfs; + if (cse !== undefined) extrasObj.cse = cse; + if (spd !== undefined) extrasObj.spd = spd; + if (fields.length) extrasObj.fields = fields; + return extrasObj; + } + private decodeMicE(withStructure: boolean = false): { payload: Payload | null; segment?: Segment[]; @@ -845,21 +952,20 @@ export class Frame implements IFrame { const latResult = this.decodeMicELatitude(dest); if (!latResult) return { payload: null }; - const { latitude, messageType, longitudeOffset, isWest, isStandard } = - latResult; + const { latitude, messageType, longitudeOffset, isWest, isStandard } = latResult; if (withStructure) { segments.push({ - name: "mic-e destination", + name: "mic-E destination", data: new TextEncoder().encode(dest).buffer, isString: true, fields: [ { type: FieldType.STRING, name: "destination", - length: dest.length, - }, - ], + length: dest.length + } + ] }); } @@ -912,12 +1018,13 @@ export class Frame implements IFrame { // Parse remaining data (altitude, comment, telemetry) const remaining = this.payload.substring(offset); let altitude: number | undefined = undefined; - const comment = remaining; + let comment = remaining; // Check for altitude in various formats const altMatch = remaining.match(/\/A=(\d{6})/); if (altMatch) { altitude = parseInt(altMatch[1], 10) * 0.3048; // feet to meters + comment = comment.replace(altMatch[0], "").trim(); } else if (remaining.startsWith("}")) { if (remaining.length >= 4) { try { @@ -930,6 +1037,15 @@ export class Frame implements IFrame { } } + // Parse RNG/PHG tokens from comment (defer attaching to result until created) + const extras = this.parseCommentExtras(comment, withStructure); + const extrasRange = extras.range; + const extrasPhg = extras.phg; + const extrasDfs = extras.dfs; + const extrasCse = extras.cse; + const extrasSpd = extras.spd; + comment = extras.comment; + let payloadType: DataType.MicECurrent | DataType.MicEOld; switch (this.payload.charAt(0)) { case "`": @@ -949,11 +1065,11 @@ export class Frame implements IFrame { longitude, symbol: { table: symbolTable, - code: symbolCode, - }, + code: symbolCode + } }, messageType, - isStandard, + isStandard }; if (speed > 0) { @@ -972,6 +1088,17 @@ export class Frame implements IFrame { result.position.comment = comment; } + // Attach parsed extras (RNG / PHG / CSE / SPD / DF) if present + if (extrasRange !== undefined) result.position.range = extrasRange; + if (extrasPhg !== undefined) result.position.phg = extrasPhg; + if (extrasDfs !== undefined) result.position.dfs = extrasDfs; + if (extrasCse !== undefined && result.position.course === undefined) result.position.course = extrasCse; + if (extrasSpd !== undefined && result.position.speed === undefined) result.position.speed = extrasSpd; + if (withStructure && extras.fields) { + // merge extras fields into comment field(s) + // if there is an existing comment segment later, we'll include fields there; otherwise add a comment-only segment + } + if (withStructure) { // Information field section (bytes after data type up to comment) const infoData = this.payload.substring(1, offset); @@ -987,18 +1114,25 @@ export class Frame implements IFrame { { 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 }, - ], + { type: FieldType.CHAR, name: "symbol table", length: 1 } + ] }); if (comment && comment.length > 0) { + const commentFields: Field[] = [{ type: FieldType.STRING, name: "text", length: comment.length }]; + if (extras.fields) commentFields.push(...extras.fields); segments.push({ name: "comment", data: new TextEncoder().encode(comment).buffer, isString: true, - fields: [ - { type: FieldType.STRING, name: "text", length: comment.length }, - ], + fields: commentFields + }); + } else if (extras.fields) { + segments.push({ + name: "comment", + data: new TextEncoder().encode("").buffer, + isString: true, + fields: extras.fields }); } @@ -1090,7 +1224,7 @@ export class Frame implements IFrame { "M4: Committed", "M5: Special", "M6: Priority", - "M7: Emergency", + "M7: Emergency" ]; const messageType = messageTypes[msgValue] || "Unknown"; @@ -1102,7 +1236,7 @@ export class Frame implements IFrame { messageType, longitudeOffset, isWest, - isStandard, + isStandard }; } @@ -1121,10 +1255,7 @@ export class Frame implements IFrame { 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 look = this.payload.substring(offset, Math.min(offset + 9, this.payload.length)); const sepIdx = look.indexOf(":"); let raw = look; if (sepIdx !== -1) { @@ -1142,7 +1273,7 @@ export class Frame implements IFrame { name: "recipient", data: new TextEncoder().encode(raw).buffer, isString: true, - fields: [{ type: FieldType.STRING, name: "to", length: 9 }], + fields: [{ type: FieldType.STRING, name: "to", length: 9 }] }); } @@ -1162,11 +1293,7 @@ export class Frame implements IFrame { 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; + while (this.payload.charAt(offset) === " " && offset < this.payload.length) offset += 1; textStart = offset - 1; } @@ -1179,7 +1306,7 @@ export class Frame implements IFrame { type: DataType.Message, variant: "message", addressee: recipient, - text, + text }; if (withStructure) { @@ -1188,7 +1315,7 @@ export class Frame implements IFrame { name: "text", data: new TextEncoder().encode(text).buffer, isString: true, - fields: [{ type: FieldType.STRING, name: "text", length: text.length }], + fields: [{ type: FieldType.STRING, name: "text", length: text.length }] }); return { payload, segment: segments }; @@ -1218,7 +1345,7 @@ export class Frame implements IFrame { name: "object name", data: new TextEncoder().encode(rawName).buffer, isString: true, - fields: [{ type: FieldType.STRING, name: "name", length: 9 }], + fields: [{ type: FieldType.STRING, name: "name", length: 9 }] }); } offset += 9; @@ -1237,18 +1364,15 @@ export class Frame implements IFrame { { type: FieldType.CHAR, name: "State (* alive, _ killed)", - length: 1, - }, - ], + length: 1 + } + ] }); } offset += 1; const timeStr = this.payload.substring(offset, offset + 7); - const { timestamp, segment: timestampSection } = this.parseTimestamp( - timeStr, - withStructure, - ); + const { timestamp, segment: timestampSection } = this.parseTimestamp(timeStr, withStructure); if (!timestamp) { return { payload: null }; } @@ -1257,26 +1381,23 @@ export class Frame implements IFrame { } offset += 7; - const isCompressed = this.isCompressedPosition( - this.payload.substring(offset), - ); + 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, - ); + 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, + altitude: compressed.altitude }; consumed = 13; @@ -1284,18 +1405,17 @@ export class Frame implements IFrame { segment.push(compressedSection); } } else { - const { position: uncompressed, segment: uncompressedSection } = - this.parseUncompressedPosition( - this.payload.substring(offset), - withStructure, - ); + 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, + ambiguity: uncompressed.ambiguity }; consumed = 19; @@ -1305,20 +1425,44 @@ export class Frame implements IFrame { } offset += consumed; - const comment = this.payload.substring(offset); + let comment = this.payload.substring(offset); + + // Parse altitude token in comment (/A=NNNNNN) + const altMatchObj = comment.match(/\/A=(\d{6})/); + if (altMatchObj) { + position.altitude = parseInt(altMatchObj[1], 10) * 0.3048; + comment = comment.replace(altMatchObj[0], "").trim(); + } + + // Parse RNG/PHG tokens + const extrasObj = this.parseCommentExtras(comment, withStructure); + if (extrasObj.range !== undefined) position.range = extrasObj.range; + if (extrasObj.phg !== undefined) position.phg = extrasObj.phg; + if (extrasObj.dfs !== undefined) position.dfs = extrasObj.dfs; + if (extrasObj.cse !== undefined && position.course === undefined) position.course = extrasObj.cse; + if (extrasObj.spd !== undefined && position.speed === undefined) position.speed = extrasObj.spd; + comment = extrasObj.comment; + if (comment) { position.comment = comment; if (withStructure) { + const commentFields: Field[] = [{ type: FieldType.STRING, name: "text", length: comment.length }]; + if (extrasObj.fields) commentFields.push(...extrasObj.fields); segment.push({ name: "Comment", data: new TextEncoder().encode(comment).buffer, isString: true, - fields: [ - { type: FieldType.STRING, name: "text", length: comment.length }, - ], + fields: commentFields }); } + } else if (withStructure && extrasObj.fields) { + segment.push({ + name: "Comment", + data: new TextEncoder().encode("").buffer, + isString: true, + fields: extrasObj.fields + }); } const payload: ObjectPayload = { @@ -1326,7 +1470,7 @@ export class Frame implements IFrame { name, timestamp, alive, - position, + position }; if (withStructure) { @@ -1358,7 +1502,7 @@ export class Frame implements IFrame { name: "item name", data: new TextEncoder().encode(rawName).buffer, isString: true, - fields: [{ type: FieldType.STRING, name: "name", length: 9 }], + fields: [{ type: FieldType.STRING, name: "name", length: 9 }] }); } offset += 9; @@ -1378,26 +1522,21 @@ export class Frame implements IFrame { { type: FieldType.CHAR, name: "State (* alive, _ killed)", - length: 1, - }, - ], + 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, - ); + 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), - ); + const isCompressed = this.isCompressedPosition(this.payload.substring(offset)); // eslint-disable-next-line no-useless-assignment let position: IPosition | null = null; @@ -1405,35 +1544,33 @@ export class Frame implements IFrame { let consumed = 0; if (isCompressed) { - const { position: compressed, segment: compressedSection } = - this.parseCompressedPosition( - this.payload.substring(offset), - withStructure, - ); + 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, + altitude: compressed.altitude }; consumed = 13; if (compressedSection) segment.push(compressedSection); } else { - const { position: uncompressed, segment: uncompressedSection } = - this.parseUncompressedPosition( - this.payload.substring(offset), - withStructure, - ); + 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, + ambiguity: uncompressed.ambiguity }; consumed = 19; @@ -1441,7 +1578,23 @@ export class Frame implements IFrame { } offset += consumed; - const comment = this.payload.substring(offset); + let comment = this.payload.substring(offset); + + // Parse altitude token in comment (/A=NNNNNN) + const altMatchItem = comment.match(/\/A=(\d{6})/); + if (altMatchItem) { + position.altitude = parseInt(altMatchItem[1], 10) * 0.3048; + comment = comment.replace(altMatchItem[0], "").trim(); + } + + const extrasItem = this.parseCommentExtras(comment, withStructure); + if (extrasItem.range !== undefined) position.range = extrasItem.range; + if (extrasItem.phg !== undefined) position.phg = extrasItem.phg; + if (extrasItem.dfs !== undefined) position.dfs = extrasItem.dfs; + if (extrasItem.cse !== undefined && position.course === undefined) position.course = extrasItem.cse; + if (extrasItem.spd !== undefined && position.speed === undefined) position.speed = extrasItem.spd; + comment = extrasItem.comment; + if (comment) { position.comment = comment; if (withStructure) { @@ -1449,18 +1602,29 @@ export class Frame implements IFrame { name: "Comment", data: new TextEncoder().encode(comment).buffer, isString: true, - fields: [ - { type: FieldType.STRING, name: "text", length: comment.length }, - ], + fields: [{ type: FieldType.STRING, name: "text", length: comment.length }] }); + if (extrasItem.fields) { + // merge extras fields into the last comment segment + const last = segment[segment.length - 1]; + if (last && last.fields) last.fields.push(...extrasItem.fields); + } } + } else if (withStructure && extrasItem.fields) { + // No free-text comment, but extras fields exist: emit comment-only segment + segment.push({ + name: "Comment", + data: new TextEncoder().encode("").buffer, + isString: true, + fields: extrasItem.fields + }); } const payload: ItemPayload = { type: DataType.Item, name, alive, - position, + position }; if (withStructure) { @@ -1485,10 +1649,7 @@ export class Frame implements IFrame { // 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, - ); + const { timestamp, segment: tsSegment } = this.parseTimestamp(timeStr, withStructure); if (timestamp) { offset += 7; if (tsSegment) segments.push(tsSegment); @@ -1511,7 +1672,7 @@ export class Frame implements IFrame { const payload: StatusPayload = { type: DataType.Status, timestamp: undefined, - text: statusText, + text: statusText }; // If timestamp was parsed, attach it @@ -1533,7 +1694,7 @@ export class Frame implements IFrame { name: "status", data: new TextEncoder().encode(text).buffer, isString: true, - fields: [{ type: FieldType.STRING, name: "text", length: text.length }], + fields: [{ type: FieldType.STRING, name: "text", length: text.length }] }); return { payload, segment: segments }; } @@ -1576,9 +1737,7 @@ export class Frame implements IFrame { name: "query type", data: new TextEncoder().encode(queryType).buffer, isString: true, - fields: [ - { type: FieldType.STRING, name: "type", length: queryType.length }, - ], + fields: [{ type: FieldType.STRING, name: "type", length: queryType.length }] }); if (target) { @@ -1586,9 +1745,7 @@ export class Frame implements IFrame { name: "query target", data: new TextEncoder().encode(target).buffer, isString: true, - fields: [ - { type: FieldType.STRING, name: "target", length: target.length }, - ], + fields: [{ type: FieldType.STRING, name: "target", length: target.length }] }); } } @@ -1596,7 +1753,7 @@ export class Frame implements IFrame { const payload: QueryPayload = { type: DataType.Query, queryType, - ...(target ? { target } : {}), + ...(target ? { target } : {}) }; if (withStructure) return { payload, segment: segments }; @@ -1643,9 +1800,9 @@ export class Frame implements IFrame { { type: FieldType.STRING, name: "sequence", - length: String(seq).length, - }, - ], + length: String(seq).length + } + ] }); segments.push({ @@ -1656,9 +1813,9 @@ export class Frame implements IFrame { { type: FieldType.STRING, name: "analogs", - length: (parts[1] || "").length, - }, - ], + length: (parts[1] || "").length + } + ] }); segments.push({ @@ -1669,9 +1826,9 @@ export class Frame implements IFrame { { type: FieldType.STRING, name: "digital", - length: String(digital).length, - }, - ], + length: String(digital).length + } + ] }); } @@ -1680,7 +1837,7 @@ export class Frame implements IFrame { variant: "data", sequence: isNaN(seq) ? 0 : seq, analog, - digital: isNaN(digital) ? 0 : digital, + digital: isNaN(digital) ? 0 : digital }; if (withStructure) return { payload, segment: segments }; @@ -1696,15 +1853,13 @@ export class Frame implements IFrame { name: "telemetry parameters", data: new TextEncoder().encode(after).buffer, isString: true, - fields: [ - { type: FieldType.STRING, name: "names", length: after.length }, - ], + fields: [{ type: FieldType.STRING, name: "names", length: after.length }] }); } const payload: TelemetryParameterPayload = { type: DataType.TelemetryData, variant: "parameters", - names, + names }; if (withStructure) return { payload, segment: segments }; return { payload }; @@ -1719,15 +1874,13 @@ export class Frame implements IFrame { name: "telemetry units", data: new TextEncoder().encode(after).buffer, isString: true, - fields: [ - { type: FieldType.STRING, name: "units", length: after.length }, - ], + fields: [{ type: FieldType.STRING, name: "units", length: after.length }] }); } const payload: TelemetryUnitPayload = { type: DataType.TelemetryData, variant: "unit", - units, + units }; if (withStructure) return { payload, segment: segments }; return { payload }; @@ -1739,27 +1892,24 @@ export class Frame implements IFrame { 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 parseList = (s?: string) => (s ? s.split(",").map((v) => parseFloat(v)) : []); const coefficients = { a: parseList(aMatch?.[1]), b: parseList(bMatch?.[1]), - c: parseList(cMatch?.[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 }, - ], + fields: [{ type: FieldType.STRING, name: "coeffs", length: after.length }] }); } const payload: TelemetryCoefficientsPayload = { type: DataType.TelemetryData, variant: "coefficients", - coefficients, + coefficients }; if (withStructure) return { payload, segment: segments }; return { payload }; @@ -1769,23 +1919,20 @@ export class Frame implements IFrame { 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; + 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 }, - ], + fields: [{ type: FieldType.STRING, name: "bitsense", length: rest.length }] }); } const payload: TelemetryBitSensePayload = { type: DataType.TelemetryData, variant: "bitsense", sense: isNaN(sense) ? 0 : sense, - ...(projectName ? { projectName } : {}), + ...(projectName ? { projectName } : {}) }; if (withStructure) return { payload, segment: segments }; return { payload }; @@ -1836,7 +1983,7 @@ export class Frame implements IFrame { latitude: parsed.position.latitude, longitude: parsed.position.longitude, symbol: parsed.position.symbol, - altitude: parsed.position.altitude, + altitude: parsed.position.altitude }; if (parsed.segment) segments.push(parsed.segment); consumed = 13; @@ -1848,7 +1995,7 @@ export class Frame implements IFrame { latitude: parsed.position.latitude, longitude: parsed.position.longitude, symbol: parsed.position.symbol, - ambiguity: parsed.position.ambiguity, + ambiguity: parsed.position.ambiguity }; if (parsed.segment) segments.push(parsed.segment); consumed = 19; @@ -1861,7 +2008,7 @@ export class Frame implements IFrame { const rest = this.payload.substring(offset).trim(); const payload: WeatherPayload = { - type: DataType.WeatherReportNoPosition, + type: DataType.WeatherReportNoPosition }; if (timestamp) payload.timestamp = timestamp; if (position) payload.position = position; @@ -1904,9 +2051,7 @@ export class Frame implements IFrame { name: "weather", data: new TextEncoder().encode(rest).buffer, isString: true, - fields: [ - { type: FieldType.STRING, name: "text", length: rest.length }, - ], + fields: [{ type: FieldType.STRING, name: "text", length: rest.length }] }); } } @@ -1939,16 +2084,14 @@ export class Frame implements IFrame { const payload: RawGPSPayload = { type: DataType.RawGPS, - sentence, + 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 instanceof RMC || parsed instanceof GGA || parsed instanceof DTM) && parsed.latitude && parsed.longitude ) { @@ -1963,7 +2106,7 @@ export class Frame implements IFrame { const pos: IPosition = { latitude, - longitude, + longitude }; // altitude @@ -1975,16 +2118,10 @@ export class Frame implements IFrame { } // speed/course (RMC fields) - if ( - "speedOverGround" in parsed && - parsed.speedOverGround !== undefined - ) { + if ("speedOverGround" in parsed && parsed.speedOverGround !== undefined) { pos.speed = Number(parsed.speedOverGround); } - if ( - "courseOverGround" in parsed && - parsed.courseOverGround !== undefined - ) { + if ("courseOverGround" in parsed && parsed.courseOverGround !== undefined) { pos.course = Number(parsed.courseOverGround); } @@ -2043,30 +2180,29 @@ export class Frame implements IFrame { { type: FieldType.STRING, name: "sentence", - length: sentence.length, - }, - ], - }, + length: sentence.length + } + ] + } ]; if (payload.position) { segments.push({ name: "raw-gps-position", - data: new TextEncoder().encode(JSON.stringify(payload.position)) - .buffer, + data: new TextEncoder().encode(JSON.stringify(payload.position)).buffer, isString: true, fields: [ { type: FieldType.STRING, name: "latitude", - length: String(payload.position.latitude).length, + length: String(payload.position.latitude).length }, { type: FieldType.STRING, name: "longitude", - length: String(payload.position.longitude).length, - }, - ], + length: String(payload.position.longitude).length + } + ] }); } @@ -2100,7 +2236,7 @@ export class Frame implements IFrame { const payload: StationCapabilitiesPayload = { type: DataType.StationCapabilities, - capabilities: tokens, + capabilities: tokens } as const; if (withStructure) { @@ -2113,9 +2249,9 @@ export class Frame implements IFrame { { type: FieldType.STRING, name: "capabilities", - length: rest.length, - }, - ], + length: rest.length + } + ] }); for (const cap of tokens) { @@ -2127,9 +2263,9 @@ export class Frame implements IFrame { { type: FieldType.STRING, name: "capability", - length: cap.length, - }, - ], + length: cap.length + } + ] }); } @@ -2164,7 +2300,7 @@ export class Frame implements IFrame { const payloadObj = { type: DataType.UserDefined, userPacketType, - data, + data } as const; if (withStructure) { @@ -2173,9 +2309,7 @@ export class Frame implements IFrame { name: "user-defined", data: new TextEncoder().encode(rest).buffer, isString: true, - fields: [ - { type: FieldType.STRING, name: "raw", length: rest.length }, - ], + fields: [{ type: FieldType.STRING, name: "raw", length: rest.length }] }); segments.push({ @@ -2186,18 +2320,16 @@ export class Frame implements IFrame { { type: FieldType.STRING, name: "type", - length: userPacketType.length, - }, - ], + 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 }, - ], + fields: [{ type: FieldType.STRING, name: "data", length: data.length }] }); return { payload: payloadObj as unknown as Payload, segment: segments }; @@ -2231,7 +2363,7 @@ export class Frame implements IFrame { const payloadObj: ThirdPartyPayload = { type: DataType.ThirdParty, comment: rest, - ...(nestedFrame ? { frame: nestedFrame } : {}), + ...(nestedFrame ? { frame: nestedFrame } : {}) } as const; if (withStructure) { @@ -2241,9 +2373,7 @@ export class Frame implements IFrame { name: "third-party", data: new TextEncoder().encode(rest).buffer, isString: true, - fields: [ - { type: FieldType.STRING, name: "raw", length: rest.length }, - ], + fields: [{ type: FieldType.STRING, name: "raw", length: rest.length }] }); if (nestedFrame) { @@ -2258,9 +2388,9 @@ export class Frame implements IFrame { { type: FieldType.STRING, name: "nested", - length: nfStr.length, - }, - ], + length: nfStr.length + } + ] }); } @@ -2316,12 +2446,12 @@ const parseFrame = (data: string): Frame => { pathFields.push({ type: FieldType.CHAR, name: `path separator ${i}`, - length: 1, + length: 1 }); pathFields.push({ type: FieldType.STRING, name: `repeater ${i}`, - length: pathStr.length, + length: pathStr.length }); } @@ -2333,17 +2463,17 @@ const parseFrame = (data: string): Frame => { { type: FieldType.STRING, name: "source address", - length: sourceStr.length, + length: sourceStr.length }, { type: FieldType.CHAR, name: "route separator", length: 1 }, { type: FieldType.STRING, name: "destination address", - length: destinationStr.length, + length: destinationStr.length }, ...pathFields, - { type: FieldType.CHAR, name: "payload separator", length: 1 }, - ], + { type: FieldType.CHAR, name: "payload separator", length: 1 } + ] }; return new Frame(source, destination, path, payload, routingSection); diff --git a/src/frame.types.ts b/src/frame.types.ts index f90b8d1..fac9b71 100644 --- a/src/frame.types.ts +++ b/src/frame.types.ts @@ -57,7 +57,7 @@ export enum DataType { ThirdParty = "}", // Invalid/Test Data - InvalidOrTest = ",", + InvalidOrTest = "," } export interface ISymbol { @@ -77,12 +77,39 @@ export interface IPosition { course?: number; // Course in degrees symbol?: ISymbol; comment?: string; + /** + * Optional reported radio range in miles (from RNG token in comment) + */ + range?: number; + /** + * Optional power/height/gain information from PHG token + * PHG format: PHGpphhgg (pp=power, hh=height, gg=gain) as numeric values + */ + phg?: IPowerHeightGain; + /** Direction-finding / DF information parsed from comment tokens */ + dfs?: IDirectionFinding; toString(): string; // Return combined position representation (e.g., "lat,lon,alt") toCompressed?(): CompressedPosition; // Optional method to convert to compressed format distanceTo?(other: IPosition): number; // Optional method to calculate distance to another position } +export interface IPowerHeightGain { + power?: number; // Transmit power in watts + height?: number; // Antenna height in meters + gain?: number; // Antenna gain in dBi + directivity?: number | "omni" | "unknown"; // Optional directivity pattern (numeric code or "omni") +} + +export interface IDirectionFinding { + bearing?: number; // Direction finding bearing in degrees + strength?: number; // Relative signal strength (0-9) + height?: number; // Antenna height in meters + gain?: number; // Antenna gain in dBi + quality?: number; // Signal quality or other metric (0-9) + directivity?: number | "omni" | "unknown"; // Optional directivity pattern (numeric code or "omni") +} + export interface ITimestamp { day?: number; // Day of month (DHM format) month?: number; // Month (MDHM format) @@ -197,12 +224,7 @@ export interface QueryPayload { target?: string; // Target callsign or area } -export type TelemetryVariant = - | "data" - | "parameters" - | "unit" - | "coefficients" - | "bitsense"; +export type TelemetryVariant = "data" | "parameters" | "unit" | "coefficients" | "bitsense"; // Telemetry Data Payload export interface TelemetryDataPayload { diff --git a/src/index.ts b/src/index.ts index 4de4029..0d7e92a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,10 +1,6 @@ export { Frame, Address, Timestamp } from "./frame"; -export { - type IAddress, - type IFrame, - DataType as DataTypeIdentifier, -} from "./frame.types"; +export { type IAddress, type IFrame, DataType as DataTypeIdentifier } from "./frame.types"; export { DataType, @@ -33,7 +29,7 @@ export { type DFReportPayload, type BasePayload, type Payload, - type DecodedFrame, + type DecodedFrame } from "./frame.types"; export { @@ -43,5 +39,5 @@ export { feetToMeters, metersToFeet, celsiusToFahrenheit, - fahrenheitToCelsius, + fahrenheitToCelsius } from "./parser"; diff --git a/src/parser.ts b/src/parser.ts index 4cfa52c..9bbe8dc 100644 --- a/src/parser.ts +++ b/src/parser.ts @@ -15,9 +15,7 @@ export const base91ToNumber = (str: string): number => { const digit = charCode - 33; // Base91 uses chars 33-123 (! to {) if (digit < 0 || digit >= base) { - throw new Error( - `Invalid Base91 character: '${str[i]}' (code ${charCode})`, - ); + throw new Error(`Invalid Base91 character: '${str[i]}' (code ${charCode})`); } value = value * base + digit; @@ -62,6 +60,15 @@ export const feetToMeters = (feet: number): number => { return feet * FEET_TO_METERS; }; +/** + * Convert miles to meters. + * @param miles number of miles + * @returns meters + */ +export const milesToMeters = (miles: number): number => { + return miles * 1609.344; +}; + /** * Convert altitude from meters to feet. * diff --git a/src/position.ts b/src/position.ts index 069832a..4992102 100644 --- a/src/position.ts +++ b/src/position.ts @@ -1,4 +1,4 @@ -import { IPosition, ISymbol } from "./frame.types"; +import { IDirectionFinding, IPosition, IPowerHeightGain, ISymbol } from "./frame.types"; export class Symbol implements ISymbol { table: string; // Symbol table identifier @@ -10,9 +10,7 @@ export class Symbol implements ISymbol { this.code = table[1]; this.table = table[0]; } else { - throw new Error( - `Invalid symbol format: '${table}' (expected 2 characters if code is not provided)`, - ); + throw new Error(`Invalid symbol format: '${table}' (expected 2 characters if code is not provided)`); } } else { this.table = table; @@ -34,6 +32,9 @@ export class Position implements IPosition { course?: number; // Course in degrees symbol?: Symbol; comment?: string; + range?: number; + phg?: IPowerHeightGain; + dfs?: IDirectionFinding; constructor(data: Partial) { this.latitude = data.latitude ?? 0; @@ -48,6 +49,9 @@ export class Position implements IPosition { this.symbol = new Symbol(data.symbol.table, data.symbol.code); } this.comment = data.comment; + this.range = data.range; + this.phg = data.phg; + this.dfs = data.dfs; } public toString(): string { diff --git a/test/frame.capabilities.test.ts b/test/frame.capabilities.test.ts index a82d3fa..373e881 100644 --- a/test/frame.capabilities.test.ts +++ b/test/frame.capabilities.test.ts @@ -1,11 +1,8 @@ -import { describe, it, expect } from "vitest"; -import { Frame } from "../src/frame"; -import { - DataType, - type Payload, - type StationCapabilitiesPayload, -} from "../src/frame.types"; import { Dissected } from "@hamradio/packet"; +import { describe, expect, it } from "vitest"; + +import { Frame } from "../src/frame"; +import { DataType, type Payload, type StationCapabilitiesPayload } from "../src/frame.types"; describe("Frame.decodeCapabilities", () => { it("parses comma separated capabilities", () => { diff --git a/test/frame.extras.test.ts b/test/frame.extras.test.ts new file mode 100644 index 0000000..b813942 --- /dev/null +++ b/test/frame.extras.test.ts @@ -0,0 +1,97 @@ +import type { Dissected, Field, Segment } from "@hamradio/packet"; +import { describe, expect, it } from "vitest"; + +import { Frame } from "../src/frame"; +import type { PositionPayload } from "../src/frame.types"; + +describe("APRS extras test vectors (RNG / PHG / CSE/DDD / SPD / DFS)", () => { + it("parses PHG from position with messaging (spec vector 1)", () => { + const raw = "NOCALL>APZRAZ,qAS,PA2RDK-14:=5154.19N/00627.77E>PHG500073 de NOCALL"; + const frame = Frame.fromString(raw); + const res = frame.decode(true) as { payload: PositionPayload | null; structure: Dissected }; + const { payload } = res; + + expect(payload).not.toBeNull(); + expect(payload!.position.phg).toBeDefined(); + // PHG500073 parsed per spec: p=5 -> 25 W, h='0' -> 10 ft, g='0' -> 0 dBi + expect(payload!.position.phg!.power).toBe(25); + expect(payload!.position.phg!.height).toBeCloseTo(3.048, 3); + expect(payload!.position.phg!.gain).toBe(0); + }); + + it("parses PHG token with hyphen separators (spec vector 2)", () => { + const raw = "NOCALL>APRS,TCPIP*,qAC,NINTH:;P-PA3RD *061000z5156.26NP00603.29E#PHG0210DAPNET"; + const frame = Frame.fromString(raw); + const res = frame.decode(true) as { payload: PositionPayload | null; structure: Dissected }; + const { payload, structure } = res; + + expect(payload).not.toBeNull(); + // Use a spec PHG example: PHG0210 -> p=0 -> power 0 W, h=2 -> 40 ft + expect(payload!.position.phg).toBeDefined(); + expect(payload!.position.phg!.power).toBe(0); + expect(payload!.position.phg!.height).toBeCloseTo(12.192, 3); + + const commentSeg = structure.find((s) => /comment/i.test(String(s.name))) as Segment | undefined; + expect(commentSeg).toBeDefined(); + const fields = (commentSeg!.fields ?? []) as Field[]; + const hasPHG = fields.some((f) => f.name === "PHG"); + expect(hasPHG).toBe(true); + }); + + it("parses DFS token with long numeric strength", () => { + const raw = "N0CALL>APRS,WIDE1-1:!4500.00N/07000.00W#DFS2360/Your Comment"; + const frame = Frame.fromString(raw); + const res = frame.decode(true) as { payload: PositionPayload | null; structure: Dissected }; + const { payload, structure } = res; + + expect(payload).not.toBeNull(); + expect(payload!.position.dfs).toBeDefined(); + // DFSshgd: strength is single-digit s value (here '2') + expect(payload!.position.dfs!.strength).toBe(2); + + const commentSeg = structure.find((s) => /comment/i.test(String(s.name))) as Segment | undefined; + expect(commentSeg).toBeDefined(); + const fieldsDFS = (commentSeg!.fields ?? []) as Field[]; + const hasDFS = fieldsDFS.some((f) => f.name === "DFS"); + expect(hasDFS).toBe(true); + }); + + it("parses course/speed in DDD/SSS form and altitude /A=", () => { + const raw = "N0CALL>APRS,WIDE1-1:!4500.00N/07000.00W>090/045/A=001234"; + const frame = Frame.fromString(raw); + const res = frame.decode(true) as { payload: PositionPayload | null; structure: Dissected }; + const { payload, structure } = res; + + expect(payload).not.toBeNull(); + expect(payload!.position.course).toBe(90); + // Speed is converted from knots to km/h + expect(payload!.position.speed).toBeCloseTo(45 * 1.852, 3); + // Altitude 001234 ft -> meters + expect(Math.round((payload!.position.altitude || 0) / 0.3048)).toBe(1234); + + const commentSeg = structure.find((s) => /comment/i.test(String(s.name))) as Segment | undefined; + expect(commentSeg).toBeDefined(); + const fieldsCSE = (commentSeg!.fields ?? []) as Field[]; + const hasCSE = fieldsCSE.some((f) => f.name === "CSE/SPD"); + expect(hasCSE).toBe(true); + }); + + it("parses combined tokens: DDD/SSS PHG and DFS", () => { + const raw = "N0CALL>APRS,WIDE1-1:!4500.00N/07000.00W>090/045PHG5132/DFS2132"; + const frame = Frame.fromString(raw); + const res = frame.decode(true) as { payload: PositionPayload | null; structure: Dissected }; + const { payload, structure } = res; + + expect(payload).not.toBeNull(); + expect(payload!.position.course).toBe(90); + expect(payload!.position.speed).toBeCloseTo(45 * 1.852, 3); + expect(payload!.position.phg).toBeDefined(); + expect(payload!.position.dfs).toBeDefined(); + expect(payload!.position.dfs!.strength).toBe(2); + + const commentSeg = structure.find((s) => /comment/i.test(String(s.name))) as Segment | undefined; + expect(commentSeg).toBeDefined(); + const fieldsCombined = (commentSeg!.fields ?? []) as Field[]; + expect(fieldsCombined.some((f) => ["CSE/SPD", "PHG", "DFS"].includes(String(f.name)))).toBe(true); + }); +}); diff --git a/test/frame.query.test.ts b/test/frame.query.test.ts index 9a79682..2ae413c 100644 --- a/test/frame.query.test.ts +++ b/test/frame.query.test.ts @@ -1,6 +1,7 @@ +import { Dissected } from "@hamradio/packet"; import { expect } from "vitest"; import { describe, it } from "vitest"; -import { Dissected } from "@hamradio/packet"; + import { Frame } from "../src/frame"; import { DataType, QueryPayload } from "../src/frame.types"; diff --git a/test/frame.rawgps.test.ts b/test/frame.rawgps.test.ts index b42dbd2..c1d4282 100644 --- a/test/frame.rawgps.test.ts +++ b/test/frame.rawgps.test.ts @@ -1,12 +1,12 @@ -import { describe, it, expect } from "vitest"; +import { Dissected } from "@hamradio/packet"; +import { describe, expect, it } from "vitest"; + import { Frame } from "../src/frame"; import { DataType, type RawGPSPayload } from "../src/frame.types"; -import { Dissected } from "@hamradio/packet"; describe("Raw GPS decoding", () => { it("decodes simple NMEA sentence as raw-gps payload", () => { - const sentence = - "GPRMC,123519,A,4807.038,N,01131.000,E,022.4,084.4,230394,003.1,W*6A"; + const sentence = "GPRMC,123519,A,4807.038,N,01131.000,E,022.4,084.4,230394,003.1,W*6A"; const frameStr = `SRC>DEST:$${sentence}`; const f = Frame.parse(frameStr); @@ -21,8 +21,7 @@ describe("Raw GPS decoding", () => { }); it("returns structure when requested", () => { - const sentence = - "GPGGA,092750.000,5321.6802,N,00630.3372,W,1,08,1.0,73.0,M,0.0,M,,*6A"; + const sentence = "GPGGA,092750.000,5321.6802,N,00630.3372,W,1,08,1.0,73.0,M,0.0,M,,*6A"; const frameStr = `SRC>DEST:$${sentence}`; const f = Frame.parse(frameStr); @@ -40,9 +39,7 @@ describe("Raw GPS decoding", () => { expect(result.structure).toBeDefined(); const rawSection = result.structure.find((s) => s.name === "raw-gps"); expect(rawSection).toBeDefined(); - const posSection = result.structure.find( - (s) => s.name === "raw-gps-position", - ); + const posSection = result.structure.find((s) => s.name === "raw-gps-position"); expect(posSection).toBeDefined(); }); }); diff --git a/test/frame.telemetry.test.ts b/test/frame.telemetry.test.ts index ed8643d..783616f 100644 --- a/test/frame.telemetry.test.ts +++ b/test/frame.telemetry.test.ts @@ -1,14 +1,15 @@ import { describe, it } from "vitest"; +import { expect } from "vitest"; + +import { Frame } from "../src/frame"; import { + DataType, + TelemetryBitSensePayload, + TelemetryCoefficientsPayload, TelemetryDataPayload, TelemetryParameterPayload, - TelemetryUnitPayload, - TelemetryCoefficientsPayload, - TelemetryBitSensePayload, - DataType, + TelemetryUnitPayload } from "../src/frame.types"; -import { Frame } from "../src/frame"; -import { expect } from "vitest"; describe("Frame decode - Telemetry", () => { it("decodes telemetry data payload", () => { diff --git a/test/frame.test.ts b/test/frame.test.ts index 16f4703..9ff696d 100644 --- a/test/frame.test.ts +++ b/test/frame.test.ts @@ -1,16 +1,17 @@ +import { Dissected, FieldType } from "@hamradio/packet"; import { describe, expect, it } from "vitest"; + import { Address, Frame, Timestamp } from "../src/frame"; import { - type Payload, - type PositionPayload, - type ObjectPayload, - type StatusPayload, + DataType, type ITimestamp, type MessagePayload, - DataType, MicEPayload, + type ObjectPayload, + type Payload, + type PositionPayload, + type StatusPayload } from "../src/frame.types"; -import { Dissected, FieldType } from "@hamradio/packet"; // Address parsing: split by method describe("Address.parse", () => { @@ -49,8 +50,7 @@ describe("Frame.constructor", () => { // Frame properties / instance methods describe("Frame.getDataTypeIdentifier", () => { it("returns @ for position identifier", () => { - const data = - 'NOCALL-1>APRS,WIDE1-1:@092345z/:*E";qZ=OMRC/A=088132Hello World!'; + const data = 'NOCALL-1>APRS,WIDE1-1:@092345z/:*E";qZ=OMRC/A=088132Hello World!'; const frame = Frame.fromString(data); expect(frame.getDataTypeIdentifier()).toBe("@"); }); @@ -76,8 +76,7 @@ describe("Frame.getDataTypeIdentifier", () => { describe("Frame.decode (basic)", () => { it("should call decode and return position payload", () => { - const data = - 'NOCALL-1>APRS,WIDE1-1:@092345z/:*E";qZ=OMRC/A=088132Hello World!'; + const data = 'NOCALL-1>APRS,WIDE1-1:@092345z/:*E";qZ=OMRC/A=088132Hello World!'; const frame = Frame.fromString(data); const decoded = frame.decode() as PositionPayload; expect(decoded).not.toBeNull(); @@ -99,7 +98,7 @@ describe("Frame.decode (basic)", () => { { data: "CALL>APRS:$GPRMC,...", type: "$" }, { data: "CALL>APRS:APRS:{01", type: "{" }, - { data: "CALL>APRS:}W1AW>APRS:test", type: "}" }, + { data: "CALL>APRS:}W1AW>APRS:test", type: "}" } ]; for (const testCase of testCases) { const frame = Frame.fromString(testCase.data); @@ -112,53 +111,70 @@ describe("Frame.decode (basic)", () => { // Static functions describe("Frame.fromString", () => { it("parses APRS position frame (test vector 1)", () => { - const data = - 'NOCALL-1>APRS,WIDE1-1:@092345z/:*E";qZ=OMRC/A=088132Hello World!'; + const data = 'NOCALL-1>APRS,WIDE1-1:@092345z/:*E";qZ=OMRC/A=088132Hello World!'; const result = Frame.fromString(data); expect(result.source).toEqual({ call: "NOCALL", ssid: "1", - isRepeated: false, + isRepeated: false }); expect(result.destination).toEqual({ call: "APRS", ssid: "", - isRepeated: false, + isRepeated: false }); expect(result.path).toHaveLength(1); expect(result.path[0]).toEqual({ call: "WIDE1", ssid: "1", - isRepeated: false, + isRepeated: false }); expect(result.payload).toBe('@092345z/:*E";qZ=OMRC/A=088132Hello World!'); }); + it("parses APRS position frame without messaging (test vector 3)", () => { + const data = "N0CALL-7>APLRT1,qAO,DG2EAZ-10:!/4CkRP&-V>76Q"; + const result = Frame.fromString(data); + expect(result.source).toEqual({ + call: "N0CALL", + ssid: "7", + isRepeated: false + }); + expect(result.destination).toEqual({ + call: "APLRT1", + ssid: "", + isRepeated: false + }); + expect(result.path).toHaveLength(2); + + const payload = result.decode(false); + expect(payload).not.toBeNull(); + }); + it("parses APRS Mic-E frame with repeated digipeater (test vector 2)", () => { const data = "N83MZ>T2TQ5U,WA1PLE-4*:`c.l+@&'/'\"G:} KJ6TMS|!:&0'p|!w#f!|3"; const result = Frame.fromString(data); expect(result.source).toEqual({ call: "N83MZ", ssid: "", - isRepeated: false, + isRepeated: false }); expect(result.destination).toEqual({ call: "T2TQ5U", ssid: "", - isRepeated: false, + isRepeated: false }); expect(result.path).toHaveLength(1); expect(result.path[0]).toEqual({ call: "WA1PLE", ssid: "4", - isRepeated: true, + isRepeated: true }); expect(result.payload).toBe("`c.l+@&'/'\"G:} KJ6TMS|!:&0'p|!w#f!|3"); }); it("parses frame with multiple path elements", () => { - const data = - "KB1ABC-5>APRS,WIDE1-1,WIDE2-2*,IGATE:!4903.50N/07201.75W-Test"; + const data = "KB1ABC-5>APRS,WIDE1-1,WIDE2-2*,IGATE:!4903.50N/07201.75W-Test"; const result = Frame.fromString(data); expect(result.source.call).toBe("KB1ABC"); expect(result.path).toHaveLength(3); @@ -174,16 +190,12 @@ describe("Frame.fromString", () => { it("throws for frame without route separator", () => { const data = "NOCALL-1>APRS"; - expect(() => Frame.fromString(data)).toThrow( - "APRS: invalid frame, no route separator found", - ); + expect(() => Frame.fromString(data)).toThrow("APRS: invalid frame, no route separator found"); }); it("throws for frame with invalid addresses", () => { const data = "NOCALL:payload"; - expect(() => Frame.fromString(data)).toThrow( - "APRS: invalid addresses in route", - ); + expect(() => Frame.fromString(data)).toThrow("APRS: invalid addresses in route"); }); }); @@ -257,9 +269,7 @@ describe("Frame.decodeMessage", () => { const textSection = res.structure.find((s) => s.name === "text"); expect(recipientSection).toBeDefined(); expect(textSection).toBeDefined(); - expect(new TextDecoder().decode(recipientSection!.data).trim()).toBe( - "KB1ABC", - ); + expect(new TextDecoder().decode(recipientSection!.data).trim()).toBe("KB1ABC"); expect(new TextDecoder().decode(textSection!.data)).toBe("Test via spec"); }); }); @@ -291,8 +301,7 @@ describe("Frame.decodeObject", () => { describe("Frame.decodePosition", () => { it("decodes position with timestamp and compressed format", () => { - const data = - 'NOCALL-1>APRS,WIDE1-1:@092345z/:*E";qZ=OMRC/A=088132Hello World!'; + const data = 'NOCALL-1>APRS,WIDE1-1:@092345z/:*E";qZ=OMRC/A=088132Hello World!'; const frame = Frame.fromString(data); const decoded = frame.decode() as PositionPayload; expect(decoded).not.toBeNull(); @@ -324,8 +333,7 @@ describe("Frame.decodeStatus", () => { structure: Dissected; }; expect(res.payload).not.toBeNull(); - if (res.payload?.type !== DataType.Status) - throw new Error("expected status payload"); + if (res.payload?.type !== DataType.Status) throw new Error("expected status payload"); const payload = res.payload as StatusPayload & { timestamp?: ITimestamp }; expect(payload.text).toBe("Testing status"); expect(payload.maidenhead).toBe("FN20"); @@ -423,7 +431,7 @@ describe("Timestamp.toDate", () => { if (futureHours < 24) { const ts = new Timestamp(futureHours, 0, "HMS", { seconds: 0, - zulu: true, + zulu: true }); const date = ts.toDate(); @@ -440,7 +448,7 @@ describe("Timestamp.toDate", () => { const ts = new Timestamp(12, 0, "MDHM", { month: futureMonth + 1, day: 1, - zulu: false, + zulu: false }); const date = ts.toDate(); @@ -455,8 +463,7 @@ describe("Timestamp.toDate", () => { describe("Frame.decodeMicE", () => { describe("Basic Mic-E frames", () => { it("should decode a basic Mic-E packet (current format)", () => { - const data = - "N83MZ>T2TQ5U,WA1PLE-4*:`c.l+@&'/'\"G:} KJ6TMS|!:&0'p|!w#f!|3"; + const data = "N83MZ>T2TQ5U,WA1PLE-4*:`c.l+@&'/'\"G:} KJ6TMS|!:&0'p|!w#f!|3"; const frame = Frame.fromString(data); const decoded = frame.decode() as MicEPayload; @@ -795,16 +802,13 @@ describe("Frame.decodeMicE", () => { expect(() => frame.decode()).not.toThrow(); const decoded = frame.decode() as MicEPayload; - expect(decoded === null || decoded?.type === DataType.MicECurrent).toBe( - true, - ); + expect(decoded === null || decoded?.type === DataType.MicECurrent).toBe(true); }); }); describe("Real-world test vectors", () => { it("should decode real Mic-E packet from test vector 2", () => { - const data = - "N83MZ>T2TQ5U,WA1PLE-4*:`c.l+@&'/'\"G:} KJ6TMS|!:&0'p|!w#f!|3"; + const data = "N83MZ>T2TQ5U,WA1PLE-4*:`c.l+@&'/'\"G:} KJ6TMS|!:&0'p|!w#f!|3"; const frame = Frame.fromString(data); const decoded = frame.decode() as MicEPayload; @@ -842,15 +846,11 @@ describe("Packet dissection with sections", () => { expect(routingSection?.fields).toBeDefined(); expect(routingSection?.fields?.length).toBeGreaterThan(0); - const sourceField = routingSection?.fields?.find( - (a) => a.name === "source address", - ); + const sourceField = routingSection?.fields?.find((a) => a.name === "source address"); expect(sourceField).toBeDefined(); expect(sourceField?.length).toBeGreaterThan(0); - const destField = routingSection?.fields?.find( - (a) => a.name === "destination address", - ); + const destField = routingSection?.fields?.find((a) => a.name === "destination address"); expect(destField).toBeDefined(); expect(destField?.length).toBeGreaterThan(0); }); @@ -869,9 +869,7 @@ describe("Packet dissection with sections", () => { expect(result.structure).toBeDefined(); expect(result.structure?.length).toBeGreaterThan(0); - const positionSection = result.structure?.find( - (s) => s.name === "position", - ); + const positionSection = result.structure?.find((s) => s.name === "position"); expect(positionSection).toBeDefined(); expect(positionSection?.data?.byteLength).toBe(19); expect(positionSection?.fields).toBeDefined(); @@ -897,20 +895,16 @@ describe("Packet dissection with sections", () => { structure: Dissected; }; - expect(result.payload?.type).toBe( - DataType.PositionWithTimestampWithMessaging, - ); + expect(result.payload?.type).toBe(DataType.PositionWithTimestampWithMessaging); - const timestampSection = result.structure?.find( - (s) => s.name === "timestamp", - ); + const timestampSection = result.structure?.find((s) => s.name === "timestamp"); expect(timestampSection).toBeDefined(); expect(timestampSection?.data?.byteLength).toBe(7); expect(timestampSection?.fields?.map((a) => a.name)).toEqual([ "day (DD)", "hour (HH)", "minute (MM)", - "timezone indicator", + "timezone indicator" ]); }); @@ -922,13 +916,9 @@ describe("Packet dissection with sections", () => { structure: Dissected; }; - expect(result.payload?.type).toBe( - DataType.PositionWithTimestampWithMessaging, - ); + expect(result.payload?.type).toBe(DataType.PositionWithTimestampWithMessaging); - const positionSection = result.structure?.find( - (s) => s.name === "position", - ); + const positionSection = result.structure?.find((s) => s.name === "position"); expect(positionSection).toBeDefined(); expect(positionSection?.data?.byteLength).toBe(13); @@ -979,9 +969,7 @@ describe("Frame.decodeMessage", () => { const textSection = res.structure.find((s) => s.name === "text"); expect(recipientSection).toBeDefined(); expect(textSection).toBeDefined(); - expect(new TextDecoder().decode(recipientSection!.data).trim()).toBe( - "KB1ABC", - ); + expect(new TextDecoder().decode(recipientSection!.data).trim()).toBe("KB1ABC"); expect(new TextDecoder().decode(textSection!.data)).toBe("Test via spec"); }); }); @@ -998,8 +986,7 @@ describe("Frame.decoding: object and status", () => { expect(res).toHaveProperty("payload"); expect(res.payload).not.toBeNull(); - if (res.payload?.type !== DataType.Object) - throw new Error("expected object payload"); + if (res.payload?.type !== DataType.Object) throw new Error("expected object payload"); const payload = res.payload as ObjectPayload & { timestamp?: ITimestamp }; @@ -1029,8 +1016,7 @@ describe("Frame.decoding: object and status", () => { expect(res).toHaveProperty("payload"); expect(res.payload).not.toBeNull(); - if (res.payload?.type !== DataType.Status) - throw new Error("expected status payload"); + if (res.payload?.type !== DataType.Status) throw new Error("expected status payload"); const payload = res.payload as StatusPayload & { timestamp?: ITimestamp }; diff --git a/test/frame.userdefined.test.ts b/test/frame.userdefined.test.ts index 4ca498f..dda63e9 100644 --- a/test/frame.userdefined.test.ts +++ b/test/frame.userdefined.test.ts @@ -1,5 +1,6 @@ -import { describe, it, expect } from "vitest"; import { Dissected } from "@hamradio/packet"; +import { describe, expect, it } from "vitest"; + import { Frame } from "../src/frame"; import { DataType, type UserDefinedPayload } from "../src/frame.types"; @@ -27,9 +28,7 @@ describe("Frame.decodeUserDefined", () => { expect(res.payload.data).toBe("Hello world"); const raw = res.structure.find((s) => s.name === "user-defined"); - const typeSection = res.structure.find( - (s) => s.name === "user-packet-type", - ); + const typeSection = res.structure.find((s) => s.name === "user-packet-type"); const dataSection = res.structure.find((s) => s.name === "user-data"); expect(raw).toBeDefined(); expect(typeSection).toBeDefined(); diff --git a/test/frame.weather.test.ts b/test/frame.weather.test.ts index a7b5da6..fec2d40 100644 --- a/test/frame.weather.test.ts +++ b/test/frame.weather.test.ts @@ -1,7 +1,8 @@ -import { describe, it, expect } from "vitest"; +import { Dissected } from "@hamradio/packet"; +import { describe, expect, it } from "vitest"; + import { Frame } from "../src/frame"; import { DataType, WeatherPayload } from "../src/frame.types"; -import { Dissected } from "@hamradio/packet"; describe("Frame decode - Weather", () => { it("parses weather with timestamp, wind, temp, rain, humidity and pressure", () => { diff --git a/test/parser.test.ts b/test/parser.test.ts index 7fa82c1..553505d 100644 --- a/test/parser.test.ts +++ b/test/parser.test.ts @@ -1,12 +1,13 @@ -import { describe, it, expect } from "vitest"; +import { describe, expect, it } from "vitest"; + import { base91ToNumber, - knotsToKmh, - kmhToKnots, - feetToMeters, - metersToFeet, celsiusToFahrenheit, fahrenheitToCelsius, + feetToMeters, + kmhToKnots, + knotsToKmh, + metersToFeet } from "../src/parser"; describe("parser utilities", () => {