Better parsing for extras; Added deviceID resolution

This commit is contained in:
2026-03-18 17:01:46 +01:00
parent be8cd00c00
commit 17caa22331
8 changed files with 1517 additions and 240 deletions

View File

@@ -1,7 +1,7 @@
{
"name": "@hamradio/aprs",
"type": "module",
"version": "1.2.0",
"version": "1.2.1",
"description": "APRS (Automatic Packet Reporting System) protocol support for Typescript",
"keywords": [
"APRS",
@@ -17,7 +17,7 @@
"license": "MIT",
"author": "Wijnand Modderman-Lenstra",
"main": "dist/index.js",
"module": "dist/index.mjs",
"module": "dist/index.js",
"types": "dist/index.d.ts",
"files": [
"dist"
@@ -25,7 +25,7 @@
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.mjs",
"import": "./dist/index.js",
"require": "./dist/index.js"
}
},
@@ -39,20 +39,20 @@
"lint": "eslint .",
"prepare": "npm run build"
},
"dependencies": {
"@hamradio/packet": "^1.1.0",
"extended-nmea": "^2.1.3"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@trivago/prettier-plugin-sort-imports": "^6.0.2",
"@vitest/coverage-v8": "^4.0.18",
"@vitest/coverage-v8": "^4.1.0",
"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",
"vitest": "^4.0.18"
},
"dependencies": {
"@hamradio/packet": "^1.1.0",
"extended-nmea": "^2.1.3"
"typescript-eslint": "^8.57.1",
"vitest": "^4.1.0"
}
}

1088
src/deviceid.ts Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -3,7 +3,9 @@ import { FieldType } from "@hamradio/packet";
import { DTM, GGA, INmeaSentence, Decoder as NmeaDecoder, RMC } from "extended-nmea";
import {
DO_NOT_ARCHIVE_MARKER,
DataType,
DataTypeNames,
type IAddress,
IDirectionFinding,
type IFrame,
@@ -171,6 +173,16 @@ export class Address implements IAddress {
}
}
interface Extras {
comment: string;
altitude?: number;
range?: number;
phg?: IPowerHeightGain;
dfs?: IDirectionFinding;
cse?: number;
spd?: number;
fields?: Field[];
}
export class Frame implements IFrame {
source: Address;
destination: Address;
@@ -213,11 +225,12 @@ export class Frame implements IFrame {
structure.push(routingSection);
// Add data type identifier section
const fieldName: string = DataTypeNames[this.getDataTypeIdentifier() as DataType] || "unknown";
structure.push({
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, value: fieldName }]
});
}
return { payload: null, structure };
@@ -299,11 +312,12 @@ export class Frame implements IFrame {
structure.push(routingSection);
}
// Add data type identifier section
const fieldName: string = DataTypeNames[dataType as DataType] || "unknown";
structure.push({
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, value: fieldName }]
});
if (payloadsegment) {
structure.push(...payloadsegment);
@@ -402,21 +416,10 @@ export class Frame implements IFrame {
comment = this.payload.substring(offset);
}
// Parse altitude from comment if present (format: /A=NNNNNN)
const altMatch = comment.match(/\/A=(\d{6})/);
if (altMatch) {
position.altitude = parseInt(altMatch[1], 10) * 0.3048; // feet to meters
// 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;
// Extract Altitude, CSE/SPD, RNG and PHG tokens and optionally emit sections
const remainder = comment; // Use the remaining comment text for parsing extras
const doNotArchive = remainder.includes(DO_NOT_ARCHIVE_MARKER);
const extras = this.parseCommentExtras(remainder, withStructure);
comment = extras.comment;
if (comment) {
@@ -425,12 +428,11 @@ export class Frame implements IFrame {
// 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,
data: new TextEncoder().encode(remainder).buffer,
isString: true,
fields: commentFields
fields: [...(extras.fields || []), ...commentFields]
});
}
} else if (withStructure && extras.fields) {
@@ -439,7 +441,7 @@ export class Frame implements IFrame {
name: "comment",
data: new TextEncoder().encode("").buffer,
isString: true,
fields: extras.fields
fields: [...(extras.fields || [])]
});
}
@@ -467,10 +469,12 @@ export class Frame implements IFrame {
const payload: PositionPayload = {
type: payloadType,
doNotArchive,
timestamp,
position,
messaging
};
this.attachExtras(payload, extras);
if (withStructure) {
return { payload, segment: structure };
@@ -645,7 +649,7 @@ export class Frame implements IFrame {
if (cs === " " && t >= 33 && t <= 124) {
// Compressed altitude: altitude = 1.002^(t-33) feet
const altFeet = Math.pow(1.002, t - 33);
result.altitude = altFeet * 0.3048; // Convert to meters
result.altitude = feetToMeters(altFeet); // Convert to meters
}
const section: Segment | undefined = withStructure
@@ -755,52 +759,67 @@ export class Frame implements IFrame {
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;
} {
private parseCommentExtras(comment: string, withStructure: boolean = false): Extras {
if (!comment || comment.length === 0) return { comment };
let cleaned = comment;
let range: number | undefined;
let phg: IPowerHeightGain | undefined;
let dfs: IDirectionFinding | undefined;
const extras: Partial<Extras> = {};
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;
let ext = comment.trimStart();
while (ext.length >= 7) {
// /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 (altMatch) {
const altitude = feetToMeters(parseInt(altMatch[1], 10)); // feet to meters
if (isNaN(altitude)) {
break; // Invalid altitude format, stop parsing extras
}
extras.altitude = altitude;
// 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);
if (withStructure) {
fields.push({ type: FieldType.STRING, name: "altitude marker", length: 2 });
fields.push({
type: FieldType.STRING,
name: "altitude value",
length: altMatch[1].length,
value: altitude.toFixed(3) + "m"
});
}
// remove altitude token from comment and advance ext for further parsing
comment = comment.replace(altMatch[0], "").trimStart();
ext = ext.replace(altMatch[0], "").trimStart();
continue;
}
// 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 });
extras.range = milesToMeters(parseInt(r, 10)) / 1000.0; // Convert to kilometers
if (withStructure) {
fields.push({ type: FieldType.STRING, name: "RNG marker", length: 3, value: "RNG" });
fields.push({
type: FieldType.STRING,
name: "range (rrrr)",
length: 4,
value: extras.range.toString() + "km"
});
}
// remove range token from comment and advance ext for further parsing
comment = comment.substring(7).trimStart();
ext = ext.substring(7).trimStart();
continue;
}
}
// PHGphgd
if (!phg && ext.startsWith("PHG")) {
if (!extras.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);
@@ -825,14 +844,50 @@ export class Frame implements IFrame {
directivity = "unknown";
}
phg = {
extras.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 });
if (withStructure) {
fields.push({ type: FieldType.STRING, name: "PHG marker", length: 3, value: "PHG" });
fields.push({
type: FieldType.STRING,
name: "power (p)",
length: 1,
value: powerWatts !== undefined ? powerWatts.toString() + "W" : undefined
});
fields.push({
type: FieldType.STRING,
name: "height (h)",
length: 1,
value: heightMeters !== undefined ? heightMeters.toString() + "m" : undefined
});
fields.push({
type: FieldType.STRING,
name: "gain (g)",
length: 1,
value: gainDbi !== undefined ? gainDbi.toString() + "dBi" : undefined
});
fields.push({
type: FieldType.STRING,
name: "directivity (d)",
length: 1,
value:
directivity !== undefined
? typeof directivity === "number"
? directivity.toString() + "°"
: directivity
: undefined
});
}
// remove PHG token from comment and advance ext for further parsing
comment = comment.substring(7).trimStart();
ext = ext.substring(7).trimStart();
continue;
}
@@ -871,68 +926,149 @@ export class Frame implements IFrame {
directivity = "unknown";
}
dfs = {
extras.dfs = {
strength,
height: heightMeters,
gain: gainDbi,
directivity
};
cleaned = trimmed.substring(prefixLen + 7).trim();
if (withStructure) fields.push({ type: FieldType.STRING, name: "DFS", length: 7 });
if (withStructure) {
fields.push({ type: FieldType.STRING, name: "DFS marker", length: 3, value: "DFS" });
fields.push({
type: FieldType.STRING,
name: "strength (s)",
length: 1,
value: strength !== undefined ? strength.toString() : undefined
});
fields.push({
type: FieldType.STRING,
name: "height (h)",
length: 1,
value: heightMeters !== undefined ? heightMeters.toString() + "m" : undefined
});
fields.push({
type: FieldType.STRING,
name: "gain (g)",
length: 1,
value: gainDbi !== undefined ? gainDbi.toString() + "dBi" : undefined
});
fields.push({
type: FieldType.STRING,
name: "directivity (d)",
length: 1,
value:
directivity !== undefined
? typeof directivity === "number"
? directivity.toString() + "°"
: directivity
: undefined
});
}
// remove DFS token from comment and advance ext for further parsing
comment = comment.substring(7).trimStart();
ext = ext.substring(7).trimStart();
continue;
}
// Course/Speed DDD/SSS (7 bytes: 3 digits / 3 digits)
if (!cse && /^\d{3}\/\d{3}$/.test(ext)) {
if (extras.cse === undefined && /^\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 });
extras.cse = parseInt(courseStr, 10);
extras.spd = knotsToKmh(parseInt(speedStr, 10));
if (withStructure) {
fields.push({ type: FieldType.STRING, name: "course", length: 3, value: extras.cse.toString() + "°" });
fields.push({ type: FieldType.CHAR, name: "marker", length: 1, value: "/" });
fields.push({ 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
comment = comment.substring(7).trimStart();
ext = ext.substring(7).trimStart();
// 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
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 (dfs === undefined) {
dfs = {};
if (extras.dfs === undefined) {
extras.dfs = {};
}
dfs.bearing = dfBearing;
dfs.strength = dfStrength;
if (withStructure) fields.push({ type: FieldType.STRING, name: "DF/NRQ", length: 8 });
cleaned = cleaned.substring(8).trim();
extras.dfs.bearing = dfBearing;
extras.dfs.strength = dfStrength;
if (withStructure) {
fields.push({ type: FieldType.STRING, name: "DF marker", length: 1, value: "/" });
fields.push({ type: FieldType.STRING, name: "bearing", length: 3, value: dfBearing.toString() + "°" });
fields.push({ type: FieldType.CHAR, name: "separator", length: 1, value: "/" });
fields.push({ type: FieldType.STRING, name: "strength", length: 3, value: dfStrength.toString() });
}
// remove DF token from comment and advance ext for further parsing
comment = comment.substring(8).trimStart();
ext = ext.substring(8).trimStart();
continue;
}
}
continue;
}
// No recognized 7-byte extension at start
// 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;
extras.comment = comment;
extras.fields = fields.length > 0 ? fields : undefined;
return extras as Extras;
}
private attachExtras(payload: Payload, extras: Extras) {
if ("position" in payload && payload.position) {
if (extras.altitude !== undefined) {
payload.position.altitude = extras.altitude;
}
if (extras.range !== undefined) {
payload.position.range = extras.range;
}
if (extras.phg !== undefined) {
payload.position.phg = extras.phg;
}
if (extras.dfs !== undefined) {
payload.position.dfs = extras.dfs;
}
if (extras.cse !== undefined && payload.position.course === undefined) {
payload.position.course = extras.cse;
}
if (extras.spd !== undefined && payload.position.speed === undefined) {
payload.position.speed = extras.spd;
}
}
if ("altitude" in payload && payload.altitude === undefined && extras.altitude !== undefined) {
payload.altitude = extras.altitude;
}
if ("range" in payload && payload.range === undefined && extras.range !== undefined) {
payload.range = extras.range;
}
if ("phg" in payload && payload.phg === undefined && extras.phg !== undefined) {
payload.phg = extras.phg;
}
if ("dfs" in payload && payload.dfs === undefined && extras.dfs !== undefined) {
payload.dfs = extras.dfs;
}
if ("course" in payload && payload.course === undefined && extras.cse !== undefined) {
payload.course = extras.cse;
}
if ("speed" in payload && payload.speed === undefined && extras.spd !== undefined) {
payload.speed = extras.spd;
}
}
private decodeMicE(withStructure: boolean = false): {
@@ -1007,7 +1143,7 @@ export class Frame implements IFrame {
if (speed >= 800) speed -= 800;
// Convert speed from knots to km/h
const speedKmh = speed * 1.852;
const speedKmh = knotsToKmh(speed);
// Symbol code and table
if (this.payload.length < offset + 2) return { payload: null };
@@ -1017,39 +1153,30 @@ export class Frame implements IFrame {
// Parse remaining data (altitude, comment, telemetry)
const remaining = this.payload.substring(offset);
const doNotArchive = remaining.includes(DO_NOT_ARCHIVE_MARKER);
let altitude: number | undefined = undefined;
let comment = remaining;
// Check for altitude in 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 {
const altBase91 = remaining.substring(1, 4);
const altFeet = base91ToNumber(altBase91) - 10000;
altitude = altFeet * 0.3048; // feet to meters
} catch {
// Ignore altitude parsing errors
}
// Check for altitude in old format
if (comment.length >= 4 && comment.charAt(3) === "}") {
try {
const altBase91 = comment.substring(0, 3);
altitude = base91ToNumber(altBase91) - 10000; // Relative to 10km below mean sea level
comment = comment.substring(4); // Remove altitude token from comment
} catch {
// Ignore altitude parsing errors
}
}
// Parse RNG/PHG tokens from comment (defer attaching to result until created)
const 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;
const remainder = comment; // Use the remaining comment text for parsing extras
const extras = this.parseCommentExtras(remainder, withStructure);
comment = extras.comment;
let payloadType: DataType.MicECurrent | DataType.MicEOld;
let payloadType: DataType.MicE | DataType.MicEOld;
switch (this.payload.charAt(0)) {
case "`":
payloadType = DataType.MicECurrent;
payloadType = DataType.MicE;
break;
case "'":
payloadType = DataType.MicEOld;
@@ -1060,6 +1187,7 @@ export class Frame implements IFrame {
const result: MicEPayload = {
type: payloadType,
doNotArchive,
position: {
latitude,
longitude,
@@ -1088,22 +1216,14 @@ 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
}
// Attach parsed extras if present
this.attachExtras(result, extras);
if (withStructure) {
// Information field section (bytes after data type up to comment)
const infoData = this.payload.substring(1, offset);
segments.push({
name: "mic-e info",
name: "mic-E info",
data: new TextEncoder().encode(infoData).buffer,
isString: true,
fields: [
@@ -1119,18 +1239,19 @@ export class Frame implements IFrame {
});
if (comment && comment.length > 0) {
const commentFields: Field[] = [{ type: FieldType.STRING, name: "text", length: comment.length }];
if (extras.fields) commentFields.push(...extras.fields);
const commentFields: Field[] = [
{ type: FieldType.STRING, name: "comment", length: comment.length, value: comment }
];
segments.push({
name: "comment",
data: new TextEncoder().encode(comment).buffer,
data: new TextEncoder().encode(remainder).buffer,
isString: true,
fields: commentFields
fields: [...(extras.fields || []), ...commentFields]
});
} else if (extras.fields) {
segments.push({
name: "comment",
data: new TextEncoder().encode("").buffer,
data: new TextEncoder().encode(remainder).buffer,
isString: true,
fields: extras.fields
});
@@ -1301,10 +1422,12 @@ export class Frame implements IFrame {
if (textStart >= 0 && textStart + 1 <= this.payload.length) {
text = this.payload.substring(textStart + 1);
}
const doNotArchive = text.includes(DO_NOT_ARCHIVE_MARKER);
const payload: MessagePayload = {
type: DataType.Message,
variant: "message",
doNotArchive,
addressee: recipient,
text
};
@@ -1425,53 +1548,44 @@ export class Frame implements IFrame {
}
offset += consumed;
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();
}
const remainder = this.payload.substring(offset);
const doNotArchive = remainder.includes(DO_NOT_ARCHIVE_MARKER);
let comment = remainder;
// 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;
const extras = this.parseCommentExtras(comment, withStructure);
comment = extras.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);
const commentFields: Field[] = [{ type: FieldType.STRING, name: "comment", length: comment.length }];
segment.push({
name: "Comment",
data: new TextEncoder().encode(comment).buffer,
data: new TextEncoder().encode(remainder).buffer,
isString: true,
fields: commentFields
fields: [...(extras.fields || []), ...commentFields]
});
}
} else if (withStructure && extrasObj.fields) {
} else if (withStructure && extras.fields) {
segment.push({
name: "Comment",
data: new TextEncoder().encode("").buffer,
data: new TextEncoder().encode(remainder).buffer,
isString: true,
fields: extrasObj.fields
fields: [...(extras.fields || [])]
});
}
const payload: ObjectPayload = {
type: DataType.Object,
doNotArchive,
name,
timestamp,
alive,
position
};
this.attachExtras(payload, extras);
if (withStructure) {
return { payload, segment };
@@ -1578,54 +1692,44 @@ export class Frame implements IFrame {
}
offset += consumed;
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 remainder = this.payload.substring(offset);
const doNotArchive = remainder.includes(DO_NOT_ARCHIVE_MARKER);
let comment = remainder;
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) {
const commentFields: Field[] = [
{ type: FieldType.STRING, name: "comment", length: comment.length, value: comment }
];
segment.push({
name: "Comment",
data: new TextEncoder().encode(comment).buffer,
data: new TextEncoder().encode(remainder).buffer,
isString: true,
fields: [{ type: FieldType.STRING, name: "text", length: comment.length }]
fields: [...(extrasItem.fields || []), ...commentFields]
});
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,
data: new TextEncoder().encode(remainder).buffer,
isString: true,
fields: extrasItem.fields
fields: [...(extrasItem.fields || [])]
});
}
const payload: ItemPayload = {
type: DataType.Item,
doNotArchive,
name,
alive,
position
};
this.attachExtras(payload, extrasItem);
if (withStructure) {
return { payload, segment };
@@ -1659,6 +1763,7 @@ export class Frame implements IFrame {
// Remaining text is status text
const text = this.payload.substring(offset);
if (!text) return { payload: null };
const doNotArchive = text.includes(DO_NOT_ARCHIVE_MARKER);
// Detect trailing Maidenhead locator (4 or 6 chars) at end of text separated by space
let maidenhead: string | undefined;
@@ -1671,6 +1776,7 @@ export class Frame implements IFrame {
const payload: StatusPayload = {
type: DataType.Status,
doNotArchive,
timestamp: undefined,
text: statusText
};

View File

@@ -1,5 +1,12 @@
import { Dissected, Segment } from "@hamradio/packet";
// Any comment that contains this marker will set the doNotArchive flag on the
// decoded payload, which can be used by applications to skip archiving or
// logging frames that are meant to be transient or test data. This allows users
// to include the marker in their APRS comments when they want to indicate that
// a particular frame should not be stored long-term.
export const DO_NOT_ARCHIVE_MARKER = "!x!";
export interface IAddress {
call: string;
ssid: string;
@@ -22,7 +29,7 @@ export enum DataType {
PositionWithTimestampWithMessaging = "@",
// Mic-E
MicECurrent = "`",
MicE = "`",
MicEOld = "'",
// Messages and Bulletins
@@ -60,6 +67,27 @@ export enum DataType {
InvalidOrTest = ","
}
export const DataTypeNames: { [key in DataType]: string } = {
[DataType.PositionNoTimestampNoMessaging]: "position",
[DataType.PositionNoTimestampWithMessaging]: "position with messaging",
[DataType.PositionWithTimestampNoMessaging]: "position with timestamp",
[DataType.PositionWithTimestampWithMessaging]: "position with timestamp and messaging",
[DataType.MicE]: "Mic-E",
[DataType.MicEOld]: "Mic-E (old)",
[DataType.Message]: "message/bulletin",
[DataType.Object]: "object",
[DataType.Item]: "item",
[DataType.Status]: "status",
[DataType.Query]: "query",
[DataType.TelemetryData]: "telemetry data",
[DataType.WeatherReportNoPosition]: "weather report",
[DataType.RawGPS]: "raw GPS data",
[DataType.StationCapabilities]: "station capabilities",
[DataType.UserDefined]: "user defined",
[DataType.ThirdParty]: "third-party traffic",
[DataType.InvalidOrTest]: "invalid/test"
};
export interface ISymbol {
table: string; // Symbol table identifier
code: string; // Symbol code
@@ -73,21 +101,13 @@ export interface IPosition {
longitude: number; // Decimal degrees
ambiguity?: number; // Position ambiguity (0-4)
altitude?: number; // Meters
speed?: number; // Speed in knots/kmh depending on source
speed?: number; // Speed in km/h
course?: number; // Course in degrees
range?: number; // Kilometers
phg?: IPowerHeightGain;
dfs?: IDirectionFinding;
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
@@ -128,6 +148,7 @@ export interface PositionPayload {
| DataType.PositionNoTimestampWithMessaging
| DataType.PositionWithTimestampNoMessaging
| DataType.PositionWithTimestampWithMessaging;
doNotArchive?: boolean; // Optional flag to indicate frame should not be archived
timestamp?: ITimestamp;
position: IPosition;
messaging: boolean; // Whether APRS messaging is enabled
@@ -156,7 +177,8 @@ export interface CompressedPosition {
// Mic-E Payload (compressed in destination address)
export interface MicEPayload {
type: DataType.MicECurrent | DataType.MicEOld;
type: DataType.MicE | DataType.MicEOld;
doNotArchive?: boolean; // Optional flag to indicate frame should not be archived
position: IPosition;
messageType?: string; // Standard Mic-E message
isStandard?: boolean; // Whether messageType is a standard Mic-E message
@@ -170,6 +192,7 @@ export type MessageVariant = "message" | "bulletin";
export interface MessagePayload {
type: DataType.Message;
variant: "message";
doNotArchive?: boolean; // Optional flag to indicate frame should not be archived
addressee: string; // 9 character padded callsign
text: string; // Message text
messageNumber?: string; // Message ID for acknowledgment
@@ -181,6 +204,7 @@ export interface MessagePayload {
export interface BulletinPayload {
type: DataType.Message;
variant: "bulletin";
doNotArchive?: boolean; // Optional flag to indicate frame should not be archived
bulletinId: string; // Bulletin identifier (BLN#)
text: string;
group?: string; // Optional group bulletin
@@ -189,6 +213,7 @@ export interface BulletinPayload {
// Object Payload
export interface ObjectPayload {
type: DataType.Object;
doNotArchive?: boolean; // Optional flag to indicate frame should not be archived
name: string; // 9 character object name
timestamp: ITimestamp;
alive: boolean; // True if object is active, false if killed
@@ -200,6 +225,7 @@ export interface ObjectPayload {
// Item Payload
export interface ItemPayload {
type: DataType.Item;
doNotArchive?: boolean; // Optional flag to indicate frame should not be archived
name: string; // 3-9 character item name
alive: boolean; // True if item is active, false if killed
position: IPosition;
@@ -208,6 +234,7 @@ export interface ItemPayload {
// Status Payload
export interface StatusPayload {
type: DataType.Status;
doNotArchive?: boolean; // Optional flag to indicate frame should not be archived
timestamp?: ITimestamp;
text: string;
maidenhead?: string; // Optional Maidenhead grid locator
@@ -332,6 +359,7 @@ export interface DFReportPayload {
export interface BasePayload {
type: DataType;
doNotArchive?: boolean; // Optional flag to indicate frame should not be archived
}
// Union type for all decoded payload types

View File

@@ -43,3 +43,6 @@ export {
celsiusToFahrenheit,
fahrenheitToCelsius
} from "./parser";
export { getDeviceID } from "./deviceid";
export type { DeviceID } from "./deviceid";

22
test/deviceid.test.ts Normal file
View File

@@ -0,0 +1,22 @@
import { describe, expect, it } from "vitest";
import { getDeviceID } from "../src/deviceid";
import { Frame } from "../src/frame";
describe("DeviceID parsing", () => {
it("parses known device ID from tocall", () => {
const data = "WB2OSZ-5>APDW17:!4237.14NS07120.83W#PHG7140";
const frame = Frame.fromString(data);
const deviceID = getDeviceID(frame.destination);
expect(deviceID).not.toBeNull();
expect(deviceID?.tocall).toBe("APDW??");
expect(deviceID?.vendor).toBe("WB2OSZ");
});
it("returns null for unknown device ID", () => {
const data = "CALL>WORLD:!4237.14NS07120.83W#PHG7140";
const frame = Frame.fromString(data);
const deviceID = getDeviceID(frame.destination);
expect(deviceID).toBeNull();
});
});

View File

@@ -3,8 +3,9 @@ import { describe, expect, it } from "vitest";
import { Frame } from "../src/frame";
import type { PositionPayload } from "../src/frame.types";
import { feetToMeters, milesToMeters } from "../src/parser";
describe("APRS extras test vectors (RNG / PHG / CSE/DDD / SPD / DFS)", () => {
describe("APRS extras test vectors", () => {
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);
@@ -34,7 +35,7 @@ describe("APRS extras test vectors (RNG / PHG / CSE/DDD / SPD / DFS)", () => {
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");
const hasPHG = fields.some((f) => f.name === "PHG marker");
expect(hasPHG).toBe(true);
});
@@ -52,7 +53,7 @@ describe("APRS extras test vectors (RNG / PHG / CSE/DDD / SPD / DFS)", () => {
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");
const hasDFS = fieldsDFS.some((f) => f.name === "DFS marker");
expect(hasDFS).toBe(true);
});
@@ -72,12 +73,12 @@ describe("APRS extras test vectors (RNG / PHG / CSE/DDD / SPD / DFS)", () => {
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");
const hasCSE = fieldsCSE.some((f) => f.name === "course");
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 raw = "N0CALL>APRS,WIDE1-1:!4500.00N/07000.00W>090/045PHG5132DFS2132";
const frame = Frame.fromString(raw);
const res = frame.decode(true) as { payload: PositionPayload | null; structure: Dissected };
const { payload, structure } = res;
@@ -92,6 +93,24 @@ describe("APRS extras test vectors (RNG / PHG / CSE/DDD / SPD / DFS)", () => {
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);
expect(fieldsCombined.some((f) => ["course", "PHG marker", "DFS marker"].includes(String(f.name)))).toBe(true);
});
it("parses RNG token and emits structure", () => {
const raw =
"NOCALL-S>APDG01,TCPIP*,qAC,NOCALL-GS:;DN9PJF B *181227z5148.38ND00634.32EaRNG0001/A=000010 70cm Voice (D-Star) 439.50000MHz -7.6000MHz";
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.altitude).toBeCloseTo(feetToMeters(10), 3);
expect(payload!.position.range).toBe(milesToMeters(1) / 1000);
const commentSeg = structure.find((s) => /comment/i.test(String(s.name))) as Segment | undefined;
expect(commentSeg).toBeDefined();
const fieldsRNG = (commentSeg!.fields ?? []) as Field[];
const hasRNG = fieldsRNG.some((f) => f.name === "RNG marker");
expect(hasRNG).toBe(true);
});
});

View File

@@ -233,7 +233,7 @@ describe("Frame.decodeMicE", () => {
const frame = Frame.fromString(data);
const decoded = frame.decode() as MicEPayload;
expect(decoded).not.toBeNull();
expect(decoded?.type).toBe(DataType.MicECurrent);
expect(decoded?.type).toBe(DataType.MicE);
});
it("decodes a Mic-E packet with old format (single quote)", () => {
@@ -322,6 +322,16 @@ describe("Frame.decodePosition", () => {
const decoded = frame.decode() as PositionPayload;
expect(decoded).not.toBeNull();
});
it("should handle UTF-8 characters", () => {
const data =
"WB2OSZ-5>APDW17:!4237.14NS07120.83W#PHG7140 Did you know that APRS comments and messages can contain UTF-8 characters? アマチュア無線";
const frame = Frame.fromString(data);
const decoded = frame.decode() as PositionPayload;
expect(decoded).not.toBeNull();
expect(decoded?.type).toBe(DataType.PositionNoTimestampNoMessaging);
expect(decoded?.position.comment).toContain("UTF-8 characters? アマチュア無線");
});
});
describe("Frame.decodeStatus", () => {
@@ -468,9 +478,9 @@ describe("Frame.decodeMicE", () => {
const decoded = frame.decode() as MicEPayload;
expect(decoded).not.toBeNull();
expect(decoded?.type).toBe(DataType.MicECurrent);
expect(decoded?.type).toBe(DataType.MicE);
if (decoded && decoded.type === DataType.MicECurrent) {
if (decoded && decoded.type === DataType.MicE) {
expect(decoded.position).toBeDefined();
expect(typeof decoded.position.latitude).toBe("number");
expect(typeof decoded.position.longitude).toBe("number");
@@ -497,7 +507,7 @@ describe("Frame.decodeMicE", () => {
expect(decoded).not.toBeNull();
if (decoded && decoded.type === DataType.MicECurrent) {
if (decoded && decoded.type === DataType.MicE) {
expect(decoded.position.latitude).toBeCloseTo(12 + 34.56 / 60, 3);
}
});
@@ -509,7 +519,7 @@ describe("Frame.decodeMicE", () => {
expect(decoded).not.toBeNull();
if (decoded && decoded.type === DataType.MicECurrent) {
if (decoded && decoded.type === DataType.MicE) {
expect(decoded.position.latitude).toBeCloseTo(1 + 20.45 / 60, 3);
}
});
@@ -521,7 +531,7 @@ describe("Frame.decodeMicE", () => {
expect(decoded).not.toBeNull();
if (decoded && decoded.type === DataType.MicECurrent) {
if (decoded && decoded.type === DataType.MicE) {
expect(decoded.position.latitude).toBeCloseTo(40 + 12.34 / 60, 3);
}
});
@@ -533,7 +543,7 @@ describe("Frame.decodeMicE", () => {
expect(decoded).not.toBeNull();
if (decoded && decoded.type === DataType.MicECurrent) {
if (decoded && decoded.type === DataType.MicE) {
expect(decoded.position.latitude).toBeLessThan(0);
}
});
@@ -547,7 +557,7 @@ describe("Frame.decodeMicE", () => {
expect(decoded).not.toBeNull();
if (decoded && decoded.type === DataType.MicECurrent) {
if (decoded && decoded.type === DataType.MicE) {
expect(typeof decoded.position.longitude).toBe("number");
expect(decoded.position.longitude).toBeGreaterThanOrEqual(-180);
expect(decoded.position.longitude).toBeLessThanOrEqual(180);
@@ -561,7 +571,7 @@ describe("Frame.decodeMicE", () => {
expect(decoded).not.toBeNull();
if (decoded && decoded.type === DataType.MicECurrent) {
if (decoded && decoded.type === DataType.MicE) {
expect(typeof decoded.position.longitude).toBe("number");
}
});
@@ -573,7 +583,7 @@ describe("Frame.decodeMicE", () => {
expect(decoded).not.toBeNull();
if (decoded && decoded.type === DataType.MicECurrent) {
if (decoded && decoded.type === DataType.MicE) {
expect(typeof decoded.position.longitude).toBe("number");
expect(Math.abs(decoded.position.longitude)).toBeGreaterThan(90);
}
@@ -588,7 +598,7 @@ describe("Frame.decodeMicE", () => {
expect(decoded).not.toBeNull();
if (decoded && decoded.type === DataType.MicECurrent) {
if (decoded && decoded.type === DataType.MicE) {
if (decoded.position.speed !== undefined) {
expect(decoded.position.speed).toBeGreaterThanOrEqual(0);
expect(typeof decoded.position.speed).toBe("number");
@@ -603,7 +613,7 @@ describe("Frame.decodeMicE", () => {
expect(decoded).not.toBeNull();
if (decoded && decoded.type === DataType.MicECurrent) {
if (decoded && decoded.type === DataType.MicE) {
if (decoded.position.course !== undefined) {
expect(decoded.position.course).toBeGreaterThanOrEqual(0);
expect(decoded.position.course).toBeLessThan(360);
@@ -618,7 +628,7 @@ describe("Frame.decodeMicE", () => {
expect(decoded).not.toBeNull();
if (decoded && decoded.type === DataType.MicECurrent) {
if (decoded && decoded.type === DataType.MicE) {
expect(decoded.position.speed).toBeUndefined();
}
});
@@ -630,7 +640,7 @@ describe("Frame.decodeMicE", () => {
expect(decoded).not.toBeNull();
if (decoded && decoded.type === DataType.MicECurrent) {
if (decoded && decoded.type === DataType.MicE) {
if (decoded.position.course !== undefined) {
expect(decoded.position.course).toBeGreaterThan(0);
expect(decoded.position.course).toBeLessThan(360);
@@ -647,7 +657,7 @@ describe("Frame.decodeMicE", () => {
expect(decoded).not.toBeNull();
if (decoded && decoded.type === DataType.MicECurrent) {
if (decoded && decoded.type === DataType.MicE) {
expect(decoded.position.symbol).toBeDefined();
expect(decoded.position.symbol?.table).toBeDefined();
expect(decoded.position.symbol?.code).toBeDefined();
@@ -659,29 +669,30 @@ describe("Frame.decodeMicE", () => {
describe("Altitude decoding", () => {
it("should decode altitude from /A=NNNNNN format", () => {
const data = "CALL>4ABCDE:`c.l+@&'/'\"G:}/A=001234";
const data = "CALL>4ABCDE:`c.l+@&'//A=001234";
const frame = Frame.fromString(data);
const decoded = frame.decode() as Payload;
const decoded = frame.decode() as MicEPayload;
expect(decoded).not.toBeNull();
expect(decoded.type).toBe(DataType.MicE);
if (decoded && decoded.type === DataType.MicECurrent) {
expect(decoded.position.altitude).toBeCloseTo(1234 * 0.3048, 1);
}
expect(decoded.position).toBeDefined();
expect(decoded.position.altitude).toBeDefined();
expect(decoded.position.altitude).toBeCloseTo(1234 * 0.3048, 1);
});
it("should decode altitude from base-91 format }abc", () => {
const data = "CALL>4AB2DE:`c.l+@&'/'\"G:}}S^X";
it("should decode altitude from base-91 format abc}", () => {
const data = "N83MZ>T2TQ5U,WA1PLE-4*:`c.l+@&'/\"4T}KJ6TMS";
const frame = Frame.fromString(data);
const decoded = frame.decode() as Payload;
const decoded = frame.decode() as MicEPayload;
expect(decoded).not.toBeNull();
expect(decoded.type).toBe(DataType.MicE);
if (decoded && decoded.type === DataType.MicECurrent) {
if (decoded.position.comment?.startsWith("}")) {
expect(decoded.position.altitude).toBeDefined();
}
}
expect(decoded.position).toBeDefined();
expect(decoded.position.altitude).toBeDefined();
expect(decoded.position.comment).toBe("KJ6TMS");
expect(decoded.position.altitude).toBeCloseTo(61, 1);
});
it("should prefer /A= format over base-91 when both present", () => {
@@ -691,7 +702,7 @@ describe("Frame.decodeMicE", () => {
expect(decoded).not.toBeNull();
if (decoded && decoded.type === DataType.MicECurrent) {
if (decoded && decoded.type === DataType.MicE) {
expect(decoded.position.altitude).toBeCloseTo(5000 * 0.3048, 1);
}
});
@@ -703,7 +714,7 @@ describe("Frame.decodeMicE", () => {
expect(decoded).not.toBeNull();
if (decoded && decoded.type === DataType.MicECurrent) {
if (decoded && decoded.type === DataType.MicE) {
expect(decoded.position.altitude).toBeUndefined();
expect(decoded.position.comment).toContain("Just a comment");
}
@@ -718,7 +729,7 @@ describe("Frame.decodeMicE", () => {
expect(decoded).not.toBeNull();
if (decoded && decoded.type === DataType.MicECurrent) {
if (decoded && decoded.type === DataType.MicE) {
expect(decoded.messageType).toBe("M0: Off Duty");
}
});
@@ -730,7 +741,7 @@ describe("Frame.decodeMicE", () => {
expect(decoded).not.toBeNull();
if (decoded && decoded.type === DataType.MicECurrent) {
if (decoded && decoded.type === DataType.MicE) {
expect(decoded.messageType).toBeDefined();
expect(typeof decoded.messageType).toBe("string");
}
@@ -753,7 +764,7 @@ describe("Frame.decodeMicE", () => {
expect(decoded).not.toBeNull();
if (decoded && decoded.type === DataType.MicECurrent) {
if (decoded && decoded.type === DataType.MicE) {
expect(decoded.position.comment).toContain("This is a test comment");
}
});
@@ -765,7 +776,7 @@ describe("Frame.decodeMicE", () => {
expect(decoded).not.toBeNull();
if (decoded && decoded.type === DataType.MicECurrent) {
if (decoded && decoded.type === DataType.MicE) {
expect(decoded.position.comment).toBeDefined();
}
});
@@ -802,7 +813,7 @@ 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.MicE).toBe(true);
});
});
@@ -813,9 +824,9 @@ describe("Frame.decodeMicE", () => {
const decoded = frame.decode() as MicEPayload;
expect(decoded).not.toBeNull();
expect(decoded?.type).toBe(DataType.MicECurrent);
expect(decoded?.type).toBe(DataType.MicE);
if (decoded && decoded.type === DataType.MicECurrent) {
if (decoded && decoded.type === DataType.MicE) {
expect(decoded.position.latitude).toBeDefined();
expect(decoded.position.longitude).toBeDefined();
expect(decoded.position.symbol).toBeDefined();