Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
c28572e3b6
|
|||
|
17caa22331
|
20
package.json
20
package.json
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@hamradio/aprs",
|
||||
"type": "module",
|
||||
"version": "1.2.0",
|
||||
"version": "1.3.0",
|
||||
"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
1088
src/deviceid.ts
Normal file
File diff suppressed because it is too large
Load Diff
446
src/frame.ts
446
src/frame.ts
@@ -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) {
|
||||
// Check for altitude in old format
|
||||
if (comment.length >= 4 && comment.charAt(3) === "}") {
|
||||
try {
|
||||
const altBase91 = remaining.substring(1, 4);
|
||||
const altFeet = base91ToNumber(altBase91) - 10000;
|
||||
altitude = altFeet * 0.3048; // feet to meters
|
||||
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
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
22
test/deviceid.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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).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).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();
|
||||
|
||||
Reference in New Issue
Block a user