Cleaned up the frame.ts by splitting payload parsing to subpackages

This commit is contained in:
2026-03-20 10:38:36 +01:00
parent 1aa8eb363f
commit 75e31c2008
26 changed files with 2695 additions and 2429 deletions

504
src/payload.extras.ts Normal file
View File

@@ -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<Extras> = {};
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
};
};