2 Commits

Author SHA1 Message Date
c28572e3b6 Version 1.3.0 2026-03-18 17:03:44 +01:00
17caa22331 Better parsing for extras; Added deviceID resolution 2026-03-18 17:01:46 +01:00
8 changed files with 1517 additions and 240 deletions

View File

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

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

View File

@@ -1,5 +1,12 @@
import { Dissected, Segment } from "@hamradio/packet"; 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 { export interface IAddress {
call: string; call: string;
ssid: string; ssid: string;
@@ -22,7 +29,7 @@ export enum DataType {
PositionWithTimestampWithMessaging = "@", PositionWithTimestampWithMessaging = "@",
// Mic-E // Mic-E
MicECurrent = "`", MicE = "`",
MicEOld = "'", MicEOld = "'",
// Messages and Bulletins // Messages and Bulletins
@@ -60,6 +67,27 @@ export enum DataType {
InvalidOrTest = "," 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 { export interface ISymbol {
table: string; // Symbol table identifier table: string; // Symbol table identifier
code: string; // Symbol code code: string; // Symbol code
@@ -73,21 +101,13 @@ export interface IPosition {
longitude: number; // Decimal degrees longitude: number; // Decimal degrees
ambiguity?: number; // Position ambiguity (0-4) ambiguity?: number; // Position ambiguity (0-4)
altitude?: number; // Meters altitude?: number; // Meters
speed?: number; // Speed in knots/kmh depending on source speed?: number; // Speed in km/h
course?: number; // Course in degrees course?: number; // Course in degrees
range?: number; // Kilometers
phg?: IPowerHeightGain;
dfs?: IDirectionFinding;
symbol?: ISymbol; symbol?: ISymbol;
comment?: string; 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") toString(): string; // Return combined position representation (e.g., "lat,lon,alt")
toCompressed?(): CompressedPosition; // Optional method to convert to compressed format toCompressed?(): CompressedPosition; // Optional method to convert to compressed format
@@ -128,6 +148,7 @@ export interface PositionPayload {
| DataType.PositionNoTimestampWithMessaging | DataType.PositionNoTimestampWithMessaging
| DataType.PositionWithTimestampNoMessaging | DataType.PositionWithTimestampNoMessaging
| DataType.PositionWithTimestampWithMessaging; | DataType.PositionWithTimestampWithMessaging;
doNotArchive?: boolean; // Optional flag to indicate frame should not be archived
timestamp?: ITimestamp; timestamp?: ITimestamp;
position: IPosition; position: IPosition;
messaging: boolean; // Whether APRS messaging is enabled messaging: boolean; // Whether APRS messaging is enabled
@@ -156,7 +177,8 @@ export interface CompressedPosition {
// Mic-E Payload (compressed in destination address) // Mic-E Payload (compressed in destination address)
export interface MicEPayload { 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; position: IPosition;
messageType?: string; // Standard Mic-E message messageType?: string; // Standard Mic-E message
isStandard?: boolean; // Whether messageType is a 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 { export interface MessagePayload {
type: DataType.Message; type: DataType.Message;
variant: "message"; variant: "message";
doNotArchive?: boolean; // Optional flag to indicate frame should not be archived
addressee: string; // 9 character padded callsign addressee: string; // 9 character padded callsign
text: string; // Message text text: string; // Message text
messageNumber?: string; // Message ID for acknowledgment messageNumber?: string; // Message ID for acknowledgment
@@ -181,6 +204,7 @@ export interface MessagePayload {
export interface BulletinPayload { export interface BulletinPayload {
type: DataType.Message; type: DataType.Message;
variant: "bulletin"; variant: "bulletin";
doNotArchive?: boolean; // Optional flag to indicate frame should not be archived
bulletinId: string; // Bulletin identifier (BLN#) bulletinId: string; // Bulletin identifier (BLN#)
text: string; text: string;
group?: string; // Optional group bulletin group?: string; // Optional group bulletin
@@ -189,6 +213,7 @@ export interface BulletinPayload {
// Object Payload // Object Payload
export interface ObjectPayload { export interface ObjectPayload {
type: DataType.Object; type: DataType.Object;
doNotArchive?: boolean; // Optional flag to indicate frame should not be archived
name: string; // 9 character object name name: string; // 9 character object name
timestamp: ITimestamp; timestamp: ITimestamp;
alive: boolean; // True if object is active, false if killed alive: boolean; // True if object is active, false if killed
@@ -200,6 +225,7 @@ export interface ObjectPayload {
// Item Payload // Item Payload
export interface ItemPayload { export interface ItemPayload {
type: DataType.Item; type: DataType.Item;
doNotArchive?: boolean; // Optional flag to indicate frame should not be archived
name: string; // 3-9 character item name name: string; // 3-9 character item name
alive: boolean; // True if item is active, false if killed alive: boolean; // True if item is active, false if killed
position: IPosition; position: IPosition;
@@ -208,6 +234,7 @@ export interface ItemPayload {
// Status Payload // Status Payload
export interface StatusPayload { export interface StatusPayload {
type: DataType.Status; type: DataType.Status;
doNotArchive?: boolean; // Optional flag to indicate frame should not be archived
timestamp?: ITimestamp; timestamp?: ITimestamp;
text: string; text: string;
maidenhead?: string; // Optional Maidenhead grid locator maidenhead?: string; // Optional Maidenhead grid locator
@@ -332,6 +359,7 @@ export interface DFReportPayload {
export interface BasePayload { export interface BasePayload {
type: DataType; type: DataType;
doNotArchive?: boolean; // Optional flag to indicate frame should not be archived
} }
// Union type for all decoded payload types // Union type for all decoded payload types

View File

@@ -43,3 +43,6 @@ export {
celsiusToFahrenheit, celsiusToFahrenheit,
fahrenheitToCelsius fahrenheitToCelsius
} from "./parser"; } 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 { Frame } from "../src/frame";
import type { PositionPayload } from "../src/frame.types"; 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)", () => { 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 raw = "NOCALL>APZRAZ,qAS,PA2RDK-14:=5154.19N/00627.77E>PHG500073 de NOCALL";
const frame = Frame.fromString(raw); 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; const commentSeg = structure.find((s) => /comment/i.test(String(s.name))) as Segment | undefined;
expect(commentSeg).toBeDefined(); expect(commentSeg).toBeDefined();
const fields = (commentSeg!.fields ?? []) as Field[]; 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); 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; const commentSeg = structure.find((s) => /comment/i.test(String(s.name))) as Segment | undefined;
expect(commentSeg).toBeDefined(); expect(commentSeg).toBeDefined();
const fieldsDFS = (commentSeg!.fields ?? []) as Field[]; 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); 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; const commentSeg = structure.find((s) => /comment/i.test(String(s.name))) as Segment | undefined;
expect(commentSeg).toBeDefined(); expect(commentSeg).toBeDefined();
const fieldsCSE = (commentSeg!.fields ?? []) as Field[]; 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); expect(hasCSE).toBe(true);
}); });
it("parses combined tokens: DDD/SSS PHG and DFS", () => { 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 frame = Frame.fromString(raw);
const res = frame.decode(true) as { payload: PositionPayload | null; structure: Dissected }; const res = frame.decode(true) as { payload: PositionPayload | null; structure: Dissected };
const { payload, structure } = res; 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; const commentSeg = structure.find((s) => /comment/i.test(String(s.name))) as Segment | undefined;
expect(commentSeg).toBeDefined(); expect(commentSeg).toBeDefined();
const fieldsCombined = (commentSeg!.fields ?? []) as Field[]; 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 frame = Frame.fromString(data);
const decoded = frame.decode() as MicEPayload; const decoded = frame.decode() as MicEPayload;
expect(decoded).not.toBeNull(); 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)", () => { it("decodes a Mic-E packet with old format (single quote)", () => {
@@ -322,6 +322,16 @@ describe("Frame.decodePosition", () => {
const decoded = frame.decode() as PositionPayload; const decoded = frame.decode() as PositionPayload;
expect(decoded).not.toBeNull(); 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", () => { describe("Frame.decodeStatus", () => {
@@ -468,9 +478,9 @@ describe("Frame.decodeMicE", () => {
const decoded = frame.decode() as MicEPayload; const decoded = frame.decode() as MicEPayload;
expect(decoded).not.toBeNull(); 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(decoded.position).toBeDefined();
expect(typeof decoded.position.latitude).toBe("number"); expect(typeof decoded.position.latitude).toBe("number");
expect(typeof decoded.position.longitude).toBe("number"); expect(typeof decoded.position.longitude).toBe("number");
@@ -497,7 +507,7 @@ describe("Frame.decodeMicE", () => {
expect(decoded).not.toBeNull(); 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); expect(decoded.position.latitude).toBeCloseTo(12 + 34.56 / 60, 3);
} }
}); });
@@ -509,7 +519,7 @@ describe("Frame.decodeMicE", () => {
expect(decoded).not.toBeNull(); 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); expect(decoded.position.latitude).toBeCloseTo(1 + 20.45 / 60, 3);
} }
}); });
@@ -521,7 +531,7 @@ describe("Frame.decodeMicE", () => {
expect(decoded).not.toBeNull(); 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); expect(decoded.position.latitude).toBeCloseTo(40 + 12.34 / 60, 3);
} }
}); });
@@ -533,7 +543,7 @@ describe("Frame.decodeMicE", () => {
expect(decoded).not.toBeNull(); expect(decoded).not.toBeNull();
if (decoded && decoded.type === DataType.MicECurrent) { if (decoded && decoded.type === DataType.MicE) {
expect(decoded.position.latitude).toBeLessThan(0); expect(decoded.position.latitude).toBeLessThan(0);
} }
}); });
@@ -547,7 +557,7 @@ describe("Frame.decodeMicE", () => {
expect(decoded).not.toBeNull(); expect(decoded).not.toBeNull();
if (decoded && decoded.type === DataType.MicECurrent) { if (decoded && decoded.type === DataType.MicE) {
expect(typeof decoded.position.longitude).toBe("number"); expect(typeof decoded.position.longitude).toBe("number");
expect(decoded.position.longitude).toBeGreaterThanOrEqual(-180); expect(decoded.position.longitude).toBeGreaterThanOrEqual(-180);
expect(decoded.position.longitude).toBeLessThanOrEqual(180); expect(decoded.position.longitude).toBeLessThanOrEqual(180);
@@ -561,7 +571,7 @@ describe("Frame.decodeMicE", () => {
expect(decoded).not.toBeNull(); expect(decoded).not.toBeNull();
if (decoded && decoded.type === DataType.MicECurrent) { if (decoded && decoded.type === DataType.MicE) {
expect(typeof decoded.position.longitude).toBe("number"); expect(typeof decoded.position.longitude).toBe("number");
} }
}); });
@@ -573,7 +583,7 @@ describe("Frame.decodeMicE", () => {
expect(decoded).not.toBeNull(); expect(decoded).not.toBeNull();
if (decoded && decoded.type === DataType.MicECurrent) { if (decoded && decoded.type === DataType.MicE) {
expect(typeof decoded.position.longitude).toBe("number"); expect(typeof decoded.position.longitude).toBe("number");
expect(Math.abs(decoded.position.longitude)).toBeGreaterThan(90); expect(Math.abs(decoded.position.longitude)).toBeGreaterThan(90);
} }
@@ -588,7 +598,7 @@ describe("Frame.decodeMicE", () => {
expect(decoded).not.toBeNull(); expect(decoded).not.toBeNull();
if (decoded && decoded.type === DataType.MicECurrent) { if (decoded && decoded.type === DataType.MicE) {
if (decoded.position.speed !== undefined) { if (decoded.position.speed !== undefined) {
expect(decoded.position.speed).toBeGreaterThanOrEqual(0); expect(decoded.position.speed).toBeGreaterThanOrEqual(0);
expect(typeof decoded.position.speed).toBe("number"); expect(typeof decoded.position.speed).toBe("number");
@@ -603,7 +613,7 @@ describe("Frame.decodeMicE", () => {
expect(decoded).not.toBeNull(); expect(decoded).not.toBeNull();
if (decoded && decoded.type === DataType.MicECurrent) { if (decoded && decoded.type === DataType.MicE) {
if (decoded.position.course !== undefined) { if (decoded.position.course !== undefined) {
expect(decoded.position.course).toBeGreaterThanOrEqual(0); expect(decoded.position.course).toBeGreaterThanOrEqual(0);
expect(decoded.position.course).toBeLessThan(360); expect(decoded.position.course).toBeLessThan(360);
@@ -618,7 +628,7 @@ describe("Frame.decodeMicE", () => {
expect(decoded).not.toBeNull(); expect(decoded).not.toBeNull();
if (decoded && decoded.type === DataType.MicECurrent) { if (decoded && decoded.type === DataType.MicE) {
expect(decoded.position.speed).toBeUndefined(); expect(decoded.position.speed).toBeUndefined();
} }
}); });
@@ -630,7 +640,7 @@ describe("Frame.decodeMicE", () => {
expect(decoded).not.toBeNull(); expect(decoded).not.toBeNull();
if (decoded && decoded.type === DataType.MicECurrent) { if (decoded && decoded.type === DataType.MicE) {
if (decoded.position.course !== undefined) { if (decoded.position.course !== undefined) {
expect(decoded.position.course).toBeGreaterThan(0); expect(decoded.position.course).toBeGreaterThan(0);
expect(decoded.position.course).toBeLessThan(360); expect(decoded.position.course).toBeLessThan(360);
@@ -647,7 +657,7 @@ describe("Frame.decodeMicE", () => {
expect(decoded).not.toBeNull(); 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).toBeDefined();
expect(decoded.position.symbol?.table).toBeDefined(); expect(decoded.position.symbol?.table).toBeDefined();
expect(decoded.position.symbol?.code).toBeDefined(); expect(decoded.position.symbol?.code).toBeDefined();
@@ -659,29 +669,30 @@ describe("Frame.decodeMicE", () => {
describe("Altitude decoding", () => { describe("Altitude decoding", () => {
it("should decode altitude from /A=NNNNNN format", () => { 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 frame = Frame.fromString(data);
const decoded = frame.decode() as Payload; const decoded = frame.decode() as MicEPayload;
expect(decoded).not.toBeNull(); expect(decoded).not.toBeNull();
expect(decoded.type).toBe(DataType.MicE);
if (decoded && decoded.type === DataType.MicECurrent) { expect(decoded.position).toBeDefined();
expect(decoded.position.altitude).toBeCloseTo(1234 * 0.3048, 1); expect(decoded.position.altitude).toBeDefined();
} expect(decoded.position.altitude).toBeCloseTo(1234 * 0.3048, 1);
}); });
it("should decode altitude from base-91 format }abc", () => { it("should decode altitude from base-91 format abc}", () => {
const data = "CALL>4AB2DE:`c.l+@&'/'\"G:}}S^X"; const data = "N83MZ>T2TQ5U,WA1PLE-4*:`c.l+@&'/\"4T}KJ6TMS";
const frame = Frame.fromString(data); const frame = Frame.fromString(data);
const decoded = frame.decode() as Payload; const decoded = frame.decode() as MicEPayload;
expect(decoded).not.toBeNull(); expect(decoded).not.toBeNull();
expect(decoded.type).toBe(DataType.MicE);
if (decoded && decoded.type === DataType.MicECurrent) { expect(decoded.position).toBeDefined();
if (decoded.position.comment?.startsWith("}")) { expect(decoded.position.altitude).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", () => { it("should prefer /A= format over base-91 when both present", () => {
@@ -691,7 +702,7 @@ describe("Frame.decodeMicE", () => {
expect(decoded).not.toBeNull(); 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); expect(decoded.position.altitude).toBeCloseTo(5000 * 0.3048, 1);
} }
}); });
@@ -703,7 +714,7 @@ describe("Frame.decodeMicE", () => {
expect(decoded).not.toBeNull(); expect(decoded).not.toBeNull();
if (decoded && decoded.type === DataType.MicECurrent) { if (decoded && decoded.type === DataType.MicE) {
expect(decoded.position.altitude).toBeUndefined(); expect(decoded.position.altitude).toBeUndefined();
expect(decoded.position.comment).toContain("Just a comment"); expect(decoded.position.comment).toContain("Just a comment");
} }
@@ -718,7 +729,7 @@ describe("Frame.decodeMicE", () => {
expect(decoded).not.toBeNull(); expect(decoded).not.toBeNull();
if (decoded && decoded.type === DataType.MicECurrent) { if (decoded && decoded.type === DataType.MicE) {
expect(decoded.messageType).toBe("M0: Off Duty"); expect(decoded.messageType).toBe("M0: Off Duty");
} }
}); });
@@ -730,7 +741,7 @@ describe("Frame.decodeMicE", () => {
expect(decoded).not.toBeNull(); expect(decoded).not.toBeNull();
if (decoded && decoded.type === DataType.MicECurrent) { if (decoded && decoded.type === DataType.MicE) {
expect(decoded.messageType).toBeDefined(); expect(decoded.messageType).toBeDefined();
expect(typeof decoded.messageType).toBe("string"); expect(typeof decoded.messageType).toBe("string");
} }
@@ -753,7 +764,7 @@ describe("Frame.decodeMicE", () => {
expect(decoded).not.toBeNull(); 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"); expect(decoded.position.comment).toContain("This is a test comment");
} }
}); });
@@ -765,7 +776,7 @@ describe("Frame.decodeMicE", () => {
expect(decoded).not.toBeNull(); expect(decoded).not.toBeNull();
if (decoded && decoded.type === DataType.MicECurrent) { if (decoded && decoded.type === DataType.MicE) {
expect(decoded.position.comment).toBeDefined(); expect(decoded.position.comment).toBeDefined();
} }
}); });
@@ -802,7 +813,7 @@ describe("Frame.decodeMicE", () => {
expect(() => frame.decode()).not.toThrow(); expect(() => frame.decode()).not.toThrow();
const decoded = frame.decode() as MicEPayload; 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; const decoded = frame.decode() as MicEPayload;
expect(decoded).not.toBeNull(); 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.latitude).toBeDefined();
expect(decoded.position.longitude).toBeDefined(); expect(decoded.position.longitude).toBeDefined();
expect(decoded.position.symbol).toBeDefined(); expect(decoded.position.symbol).toBeDefined();