Cleaned up the frame.ts by splitting payload parsing to subpackages
This commit is contained in:
504
src/payload.extras.ts
Normal file
504
src/payload.extras.ts
Normal 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
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user