9 Commits

Author SHA1 Message Date
d62d7962fe Version 1.1.0 2026-03-15 21:32:25 +01:00
1f4108b888 Implemented remaining payload types 2026-03-15 21:32:01 +01:00
eca757b24f Implemented Query, Telemetry, Weather and RawGPS parsing 2026-03-15 21:13:12 +01:00
e0d4844c5b Stricter decoding 2026-03-15 20:21:26 +01:00
4669783b67 chore(release): v1.0.1 - bump from v1.0.0 2026-03-11 18:00:38 +01:00
94c96ebf15 Added release script 2026-03-11 18:00:10 +01:00
121aa9d1ad More unit tests 2026-03-11 17:59:02 +01:00
ebe4670c08 Added message, object and status decoding 2026-03-11 17:57:07 +01:00
08177f4e6f Use plain ASCII 2026-03-11 17:56:15 +01:00
19 changed files with 2663 additions and 4476 deletions

3
.gitignore vendored
View File

@@ -103,6 +103,9 @@ web_modules/
# Optional npm cache directory # Optional npm cache directory
.npm .npm
# Optional npm package-lock.json
package-lock.json
# Optional eslint cache # Optional eslint cache
.eslintcache .eslintcache

View File

@@ -11,16 +11,22 @@ repos:
hooks: hooks:
- id: shellcheck - id: shellcheck
- repo: https://github.com/pre-commit/mirrors-eslint - repo: local
rev: v10.0.3 hooks:
- id: prettier
name: prettier
entry: npx prettier --write
language: system
files: "\\.(js|jsx|ts|tsx)$"
- repo: local
hooks: hooks:
- id: eslint - id: eslint
name: eslint
entry: npx eslint --fix
language: system
files: "\\.(js|jsx|ts|tsx)$" files: "\\.(js|jsx|ts|tsx)$"
exclude: node_modules/
# Use stylelint (local) instead of the deprecated scss-lint Ruby gem which
# cannot parse modern Sass `@use` and module syntax. This invokes the
# project's installed `stylelint` via `npx` so the devDependency is used.
- repo: local - repo: local
hooks: hooks:
- id: stylelint - id: stylelint

3283
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{ {
"name": "@hamradio/aprs", "name": "@hamradio/aprs",
"version": "1.0.0", "version": "1.1.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",
@@ -11,7 +11,7 @@
], ],
"repository": { "repository": {
"type": "git", "type": "git",
"url": "https://git.maze.io/ham/aprs.js" "url": "https://git.maze.io/ham/aprs.ts"
}, },
"license": "MIT", "license": "MIT",
"author": "Wijnand Modderman-Lenstra", "author": "Wijnand Modderman-Lenstra",
@@ -38,7 +38,6 @@
"lint": "eslint .", "lint": "eslint .",
"prepare": "npm run build" "prepare": "npm run build"
}, },
"dependencies": {},
"devDependencies": { "devDependencies": {
"@eslint/js": "^10.0.1", "@eslint/js": "^10.0.1",
"@vitest/coverage-v8": "^4.0.18", "@vitest/coverage-v8": "^4.0.18",
@@ -48,5 +47,9 @@
"typescript": "^5.9.3", "typescript": "^5.9.3",
"typescript-eslint": "^8.57.0", "typescript-eslint": "^8.57.0",
"vitest": "^4.0.18" "vitest": "^4.0.18"
},
"dependencies": {
"@hamradio/packet": "^1.1.0",
"extended-nmea": "^2.1.3"
} }
} }

115
scripts/release.js Executable file
View File

@@ -0,0 +1,115 @@
#!/usr/bin/env node
// Minimal safe release script.
// Usage: node scripts/release.js [major|minor|patch|<version>]
const { execSync } = require("child_process");
const fs = require("fs");
const path = require("path");
const root = path.resolve(__dirname, "..");
const pkgPath = path.join(root, "package.json");
function run(cmd, opts = {}) {
return execSync(cmd, { stdio: "inherit", cwd: root, ...opts });
}
function runOutput(cmd) {
return execSync(cmd, { cwd: root }).toString().trim();
}
function bumpSemver(current, spec) {
if (["major","minor","patch"].includes(spec)) {
const [maj, min, patch] = current.split(".").map(n=>parseInt(n,10));
if (spec==="major") return `${maj+1}.0.0`;
if (spec==="minor") return `${maj}.${min+1}.0`;
return `${maj}.${min}.${patch+1}`;
}
if (!/^\d+\.\d+\.\d+$/.test(spec)) throw new Error("Invalid version spec");
return spec;
}
(async () => {
const arg = process.argv[2] || "patch";
const pkgRaw = fs.readFileSync(pkgPath, "utf8");
const pkg = JSON.parse(pkgRaw);
const oldVersion = pkg.version;
const newVersion = bumpSemver(oldVersion, arg);
let committed = false;
let tagged = false;
let pushedTags = false;
try {
// refuse to run if there are unstaged/uncommitted changes
const status = runOutput("git status --porcelain");
if (status) throw new Error("Repository has uncommitted changes; please commit or stash before releasing.");
console.log("Running tests...");
run("npm run test:ci");
console.log("Building...");
run("npm run build");
// write new version
pkg.version = newVersion;
fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + "\n", "utf8");
console.log(`Bumped version: ${oldVersion} -> ${newVersion}`);
// commit
run(`git add ${pkgPath}`);
run(`git commit -m "chore(release): v${newVersion} - bump from v${oldVersion}"`);
committed = true;
// ensure tag doesn't already exist locally
let localTagExists = false;
try {
runOutput(`git rev-parse --verify refs/tags/v${newVersion}`);
localTagExists = true;
} catch (_) {
localTagExists = false;
}
if (localTagExists) throw new Error(`Tag v${newVersion} already exists locally — aborting to avoid overwrite.`);
// ensure tag doesn't exist on remote
const remoteTagInfo = (() => {
try { return runOutput(`git ls-remote --tags origin v${newVersion}`); } catch (_) { return ""; }
})();
if (remoteTagInfo) throw new Error(`Tag v${newVersion} already exists on remote — aborting to avoid overwrite.`);
// tag
run(`git tag -a v${newVersion} -m "Release v${newVersion}"`);
tagged = true;
// push commit and tags
run("git push");
run("git push --tags");
pushedTags = true;
// publish
console.log("Publishing to npm...");
const publishCmd = pkg.name && pkg.name.startsWith("@") ? "npm publish --access public" : "npm publish";
run(publishCmd);
console.log(`Release v${newVersion} succeeded.`);
process.exit(0);
} catch (err) {
console.error("Release failed:", err.message || err);
try {
// delete local tag
if (tagged) {
try { run(`git tag -d v${newVersion}`); } catch {}
if (pushedTags) {
try { run(`git push origin :refs/tags/v${newVersion}`); } catch {}
}
}
// undo commit if made
if (committed) {
try { run("git reset --hard HEAD~1"); } catch {
// fallback: restore package.json content
fs.writeFileSync(pkgPath, pkgRaw, "utf8");
}
} else {
// restore package.json
fs.writeFileSync(pkgPath, pkgRaw, "utf8");
}
} catch (rbErr) {
console.error("Rollback error:", rbErr.message || rbErr);
}
process.exit(1);
}
})();

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,4 @@
import { PacketSegment, PacketStructure } from "./parser.types"; import { Dissected, Segment } from "@hamradio/packet";
export interface IAddress { export interface IAddress {
call: string; call: string;
@@ -16,51 +16,52 @@ export interface IFrame {
// APRS Data Type Identifiers (first character of payload) // APRS Data Type Identifiers (first character of payload)
export const DataTypeIdentifier = { export const DataTypeIdentifier = {
// Position Reports // Position Reports
PositionNoTimestampNoMessaging: '!', PositionNoTimestampNoMessaging: "!",
PositionNoTimestampWithMessaging: '=', PositionNoTimestampWithMessaging: "=",
PositionWithTimestampNoMessaging: '/', PositionWithTimestampNoMessaging: "/",
PositionWithTimestampWithMessaging: '@', PositionWithTimestampWithMessaging: "@",
// Mic-E // Mic-E
MicECurrent: '`', MicECurrent: "`",
MicEOld: "'", MicEOld: "'",
// Messages and Bulletins // Messages and Bulletins
Message: ':', Message: ":",
// Objects and Items // Objects and Items
Object: ';', Object: ";",
Item: ')', Item: ")",
// Status // Status
Status: '>', Status: ">",
// Query // Query
Query: '?', Query: "?",
// Telemetry // Telemetry
TelemetryData: 'T', TelemetryData: "T",
// Weather // Weather
WeatherReportNoPosition: '_', WeatherReportNoPosition: "_",
// Raw GPS Data // Raw GPS Data
RawGPS: '$', RawGPS: "$",
// Station Capabilities // Station Capabilities
StationCapabilities: '<', StationCapabilities: "<",
// User-Defined // User-Defined
UserDefined: '{', UserDefined: "{",
// Third-Party Traffic // Third-Party Traffic
ThirdParty: '}', ThirdParty: "}",
// Invalid/Test Data // Invalid/Test Data
InvalidOrTest: ',', InvalidOrTest: ",",
} as const; } as const;
export type DataTypeIdentifier = typeof DataTypeIdentifier[keyof typeof DataTypeIdentifier]; export type DataTypeIdentifier =
(typeof DataTypeIdentifier)[keyof typeof DataTypeIdentifier];
export interface ISymbol { export interface ISymbol {
table: string; // Symbol table identifier table: string; // Symbol table identifier
@@ -91,14 +92,14 @@ export interface ITimestamp {
hours: number; hours: number;
minutes: number; minutes: number;
seconds?: number; seconds?: number;
format: 'DHM' | 'HMS' | 'MDHM'; // Day-Hour-Minute, Hour-Minute-Second, Month-Day-Hour-Minute format: "DHM" | "HMS" | "MDHM"; // Day-Hour-Minute, Hour-Minute-Second, Month-Day-Hour-Minute
zulu?: boolean; // Is UTC/Zulu time zulu?: boolean; // Is UTC/Zulu time
toDate(): Date; // Convert to Date object respecting timezone toDate(): Date; // Convert to Date object respecting timezone
} }
// Position Report Payload // Position Report Payload
export interface PositionPayload { export interface PositionPayload {
type: 'position'; type: "position";
timestamp?: ITimestamp; timestamp?: ITimestamp;
position: IPosition; position: IPosition;
messaging: boolean; // Whether APRS messaging is enabled messaging: boolean; // Whether APRS messaging is enabled
@@ -106,7 +107,7 @@ export interface PositionPayload {
messageType?: string; messageType?: string;
isStandard?: boolean; isStandard?: boolean;
}; };
sections?: PacketSegment[]; sections?: Segment[];
} }
// Compressed Position Format // Compressed Position Format
@@ -122,12 +123,12 @@ export interface CompressedPosition {
range?: number; // Miles range?: number; // Miles
altitude?: number; // Feet altitude?: number; // Feet
radioRange?: number; // Miles radioRange?: number; // Miles
compression: 'old' | 'current'; compression: "old" | "current";
} }
// Mic-E Payload (compressed in destination address) // Mic-E Payload (compressed in destination address)
export interface MicEPayload { export interface MicEPayload {
type: 'mic-e'; type: "mic-e";
position: IPosition; position: IPosition;
course?: number; course?: number;
speed?: number; speed?: number;
@@ -139,7 +140,7 @@ export interface MicEPayload {
// Message Payload // Message Payload
export interface MessagePayload { export interface MessagePayload {
type: 'message'; type: "message";
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
@@ -149,7 +150,7 @@ export interface MessagePayload {
// Bulletin/Announcement (variant of message) // Bulletin/Announcement (variant of message)
export interface BulletinPayload { export interface BulletinPayload {
type: 'bulletin'; type: "bulletin";
bulletinId: string; // Bulletin identifier (BLN#) bulletinId: string; // Bulletin identifier (BLN#)
text: string; text: string;
group?: string; // Optional group bulletin group?: string; // Optional group bulletin
@@ -157,7 +158,7 @@ export interface BulletinPayload {
// Object Payload // Object Payload
export interface ObjectPayload { export interface ObjectPayload {
type: 'object'; type: "object";
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
@@ -168,7 +169,7 @@ export interface ObjectPayload {
// Item Payload // Item Payload
export interface ItemPayload { export interface ItemPayload {
type: 'item'; type: "item";
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;
@@ -176,7 +177,7 @@ export interface ItemPayload {
// Status Payload // Status Payload
export interface StatusPayload { export interface StatusPayload {
type: 'status'; type: "status";
timestamp?: ITimestamp; timestamp?: ITimestamp;
text: string; text: string;
maidenhead?: string; // Optional Maidenhead grid locator maidenhead?: string; // Optional Maidenhead grid locator
@@ -188,14 +189,14 @@ export interface StatusPayload {
// Query Payload // Query Payload
export interface QueryPayload { export interface QueryPayload {
type: 'query'; type: "query";
queryType: string; // e.g., 'APRSD', 'APRST', 'PING' queryType: string; // e.g., 'APRSD', 'APRST', 'PING'
target?: string; // Target callsign or area target?: string; // Target callsign or area
} }
// Telemetry Data Payload // Telemetry Data Payload
export interface TelemetryDataPayload { export interface TelemetryDataPayload {
type: 'telemetry-data'; type: "telemetry-data";
sequence: number; sequence: number;
analog: number[]; // Up to 5 analog channels analog: number[]; // Up to 5 analog channels
digital: number; // 8-bit digital value digital: number; // 8-bit digital value
@@ -203,19 +204,19 @@ export interface TelemetryDataPayload {
// Telemetry Parameter Names // Telemetry Parameter Names
export interface TelemetryParameterPayload { export interface TelemetryParameterPayload {
type: 'telemetry-parameters'; type: "telemetry-parameters";
names: string[]; // Parameter names names: string[]; // Parameter names
} }
// Telemetry Unit/Label // Telemetry Unit/Label
export interface TelemetryUnitPayload { export interface TelemetryUnitPayload {
type: 'telemetry-units'; type: "telemetry-units";
units: string[]; // Units for each parameter units: string[]; // Units for each parameter
} }
// Telemetry Coefficients // Telemetry Coefficients
export interface TelemetryCoefficientsPayload { export interface TelemetryCoefficientsPayload {
type: 'telemetry-coefficients'; type: "telemetry-coefficients";
coefficients: { coefficients: {
a: number[]; // a coefficients a: number[]; // a coefficients
b: number[]; // b coefficients b: number[]; // b coefficients
@@ -225,14 +226,14 @@ export interface TelemetryCoefficientsPayload {
// Telemetry Bit Sense/Project Name // Telemetry Bit Sense/Project Name
export interface TelemetryBitSensePayload { export interface TelemetryBitSensePayload {
type: 'telemetry-bitsense'; type: "telemetry-bitsense";
sense: number; // 8-bit sense value sense: number; // 8-bit sense value
projectName?: string; projectName?: string;
} }
// Weather Report Payload // Weather Report Payload
export interface WeatherPayload { export interface WeatherPayload {
type: 'weather'; type: "weather";
timestamp?: ITimestamp; timestamp?: ITimestamp;
position?: IPosition; position?: IPosition;
windDirection?: number; // Degrees windDirection?: number; // Degrees
@@ -249,37 +250,39 @@ export interface WeatherPayload {
rawRain?: number; // Raw rain counter rawRain?: number; // Raw rain counter
software?: string; // Weather software type software?: string; // Weather software type
weatherUnit?: string; // Weather station type weatherUnit?: string; // Weather station type
comment?: string; // Additional comment
} }
// Raw GPS Payload (NMEA sentences) // Raw GPS Payload (NMEA sentences)
export interface RawGPSPayload { export interface RawGPSPayload {
type: 'raw-gps'; type: "raw-gps";
sentence: string; // Raw NMEA sentence sentence: string; // Raw NMEA sentence
position?: IPosition; // Optional parsed position if available
} }
// Station Capabilities Payload // Station Capabilities Payload
export interface StationCapabilitiesPayload { export interface StationCapabilitiesPayload {
type: 'capabilities'; type: "capabilities";
capabilities: string[]; capabilities: string[];
} }
// User-Defined Payload // User-Defined Payload
export interface UserDefinedPayload { export interface UserDefinedPayload {
type: 'user-defined'; type: "user-defined";
userPacketType: string; userPacketType: string;
data: string; data: string;
} }
// Third-Party Traffic Payload // Third-Party Traffic Payload
export interface ThirdPartyPayload { export interface ThirdPartyPayload {
type: 'third-party'; type: "third-party";
header: string; // Source path of third-party packet frame?: IFrame; // Optional nested frame if payload contains another APRS frame
payload: string; // Nested APRS packet comment?: string; // Optional comment
} }
// DF Report Payload // DF Report Payload
export interface DFReportPayload { export interface DFReportPayload {
type: 'df-report'; type: "df-report";
timestamp?: ITimestamp; timestamp?: ITimestamp;
position: IPosition; position: IPosition;
course?: number; course?: number;
@@ -296,7 +299,8 @@ export interface BasePayload {
} }
// Union type for all decoded payload types // Union type for all decoded payload types
export type Payload = BasePayload & ( export type Payload = BasePayload &
(
| PositionPayload | PositionPayload
| MicEPayload | MicEPayload
| MessagePayload | MessagePayload
@@ -316,10 +320,10 @@ export type Payload = BasePayload & (
| UserDefinedPayload | UserDefinedPayload
| ThirdPartyPayload | ThirdPartyPayload
| DFReportPayload | DFReportPayload
); );
// Extended Frame with decoded payload // Extended Frame with decoded payload
export interface DecodedFrame extends IFrame { export interface DecodedFrame extends IFrame {
decoded?: Payload; decoded?: Payload;
structure?: PacketStructure; // Routing and other frame-level sections structure?: Dissected; // Routing and other frame-level sections
} }

View File

@@ -1,14 +1,6 @@
export { export { Frame, Address, Timestamp } from "./frame";
Frame,
Address,
Timestamp,
} from "./frame";
export { export { type IAddress, type IFrame, DataTypeIdentifier } from "./frame.types";
type IAddress,
type IFrame,
DataTypeIdentifier,
} from "./frame.types";
export { export {
type ISymbol, type ISymbol,
@@ -48,10 +40,3 @@ export {
celsiusToFahrenheit, celsiusToFahrenheit,
fahrenheitToCelsius, fahrenheitToCelsius,
} from "./parser"; } from "./parser";
export {
type PacketStructure,
type PacketSegment,
type PacketField,
type PacketFieldBit,
FieldType,
} from "./parser.types";

View File

@@ -15,14 +15,16 @@ export const base91ToNumber = (str: string): number => {
const digit = charCode - 33; // Base91 uses chars 33-123 (! to {) const digit = charCode - 33; // Base91 uses chars 33-123 (! to {)
if (digit < 0 || digit >= base) { if (digit < 0 || digit >= base) {
throw new Error(`Invalid Base91 character: '${str[i]}' (code ${charCode})`); throw new Error(
`Invalid Base91 character: '${str[i]}' (code ${charCode})`,
);
} }
value = value * base + digit; value = value * base + digit;
} }
return value; return value;
} };
/* Conversions from Freedom Units to whatever the rest of the world uses and understands. */ /* Conversions from Freedom Units to whatever the rest of the world uses and understands. */
@@ -38,7 +40,7 @@ const FAHRENHEIT_TO_CELSIUS_OFFSET = 32;
*/ */
export const knotsToKmh = (knots: number): number => { export const knotsToKmh = (knots: number): number => {
return knots * KNOTS_TO_KMH; return knots * KNOTS_TO_KMH;
} };
/** /**
* Convert speed from kilometers per hour to knots. * Convert speed from kilometers per hour to knots.
@@ -48,7 +50,7 @@ export const knotsToKmh = (knots: number): number => {
*/ */
export const kmhToKnots = (kmh: number): number => { export const kmhToKnots = (kmh: number): number => {
return kmh / KNOTS_TO_KMH; return kmh / KNOTS_TO_KMH;
} };
/** /**
* Convert altitude from feet to meters. * Convert altitude from feet to meters.
@@ -58,7 +60,7 @@ export const kmhToKnots = (kmh: number): number => {
*/ */
export const feetToMeters = (feet: number): number => { export const feetToMeters = (feet: number): number => {
return feet * FEET_TO_METERS; return feet * FEET_TO_METERS;
} };
/** /**
* Convert altitude from meters to feet. * Convert altitude from meters to feet.
@@ -68,7 +70,7 @@ export const feetToMeters = (feet: number): number => {
*/ */
export const metersToFeet = (meters: number): number => { export const metersToFeet = (meters: number): number => {
return meters / FEET_TO_METERS; return meters / FEET_TO_METERS;
} };
/** /**
* Convert temperature from Celsius to Fahrenheit. * Convert temperature from Celsius to Fahrenheit.
@@ -77,8 +79,8 @@ export const metersToFeet = (meters: number): number => {
* @returns equivalent temperature in Fahrenheit * @returns equivalent temperature in Fahrenheit
*/ */
export const celsiusToFahrenheit = (celsius: number): number => { export const celsiusToFahrenheit = (celsius: number): number => {
return (celsius * 9/5) + FAHRENHEIT_TO_CELSIUS_OFFSET; return (celsius * 9) / 5 + FAHRENHEIT_TO_CELSIUS_OFFSET;
} };
/** /**
* Convert temperature from Fahrenheit to Celsius. * Convert temperature from Fahrenheit to Celsius.
@@ -87,5 +89,5 @@ export const celsiusToFahrenheit = (celsius: number): number => {
* @returns equivalent temperature in Celsius * @returns equivalent temperature in Celsius
*/ */
export const fahrenheitToCelsius = (fahrenheit: number): number => { export const fahrenheitToCelsius = (fahrenheit: number): number => {
return (fahrenheit - FAHRENHEIT_TO_CELSIUS_OFFSET) * 5/9; return ((fahrenheit - FAHRENHEIT_TO_CELSIUS_OFFSET) * 5) / 9;
} };

View File

@@ -1,37 +0,0 @@
export enum FieldType {
BITS = 0,
UINT8 = 1,
UINT16_LE = 2,
UINT16_BE = 3,
UINT32_LE = 4,
UINT32_BE = 5,
BYTES = 6, // 8-bits per value
WORDS = 7, // 16-bits per value
DWORDS = 8, // 32-bits per value
QWORDS = 9, // 64-bits per value
STRING = 10,
C_STRING = 11, // Null-terminated string
CHAR = 12, // Single ASCII character
}
// Interface for the parsed packet segments, used for debugging and testing.
export type PacketStructure = PacketSegment[];
export interface PacketSegment {
name: string;
data: Uint8Array;
fields: PacketField[];
}
export interface PacketField {
type: FieldType;
size: number; // Size in bytes
name?: string;
bits?: PacketFieldBit[]; // Only for bit fields in FieldType.BITS
value?: any; // Optional decoded value
}
export interface PacketFieldBit {
name: string;
size: number; // Size in bits
}

View File

@@ -10,7 +10,9 @@ export class Symbol implements ISymbol {
this.code = table[1]; this.code = table[1];
this.table = table[0]; this.table = table[0];
} else { } else {
throw new Error(`Invalid symbol format: '${table}' (expected 2 characters if code is not provided)`); throw new Error(
`Invalid symbol format: '${table}' (expected 2 characters if code is not provided)`,
);
} }
} else { } else {
this.table = table; this.table = table;
@@ -40,7 +42,7 @@ export class Position implements IPosition {
this.altitude = data.altitude; this.altitude = data.altitude;
this.speed = data.speed; this.speed = data.speed;
this.course = data.course; this.course = data.course;
if (typeof data.symbol === 'string') { if (typeof data.symbol === "string") {
this.symbol = new Symbol(data.symbol); this.symbol = new Symbol(data.symbol);
} else if (data.symbol) { } else if (data.symbol) {
this.symbol = new Symbol(data.symbol.table, data.symbol.code); this.symbol = new Symbol(data.symbol.table, data.symbol.code);
@@ -51,21 +53,21 @@ export class Position implements IPosition {
public toString(): string { public toString(): string {
const latStr = this.latitude.toFixed(5); const latStr = this.latitude.toFixed(5);
const lonStr = this.longitude.toFixed(5); const lonStr = this.longitude.toFixed(5);
const altStr = this.altitude !== undefined ? `,${this.altitude}m` : ''; const altStr = this.altitude !== undefined ? `,${this.altitude}m` : "";
return `${latStr},${lonStr}${altStr}`; return `${latStr},${lonStr}${altStr}`;
} }
public distanceTo(other: IPosition): number { public distanceTo(other: IPosition): number {
const R = 6371e3; // Earth radius in meters const R = 6371e3; // Earth radius in meters
const φ1 = this.latitude * Math.PI / 180; const lat1 = (this.latitude * Math.PI) / 180;
const φ2 = other.latitude * Math.PI / 180; const lat2 = (other.latitude * Math.PI) / 180;
const Δφ = (other.latitude - this.latitude) * Math.PI / 180; const dLat = ((other.latitude - this.latitude) * Math.PI) / 180;
const Δλ = (other.longitude - this.longitude) * Math.PI / 180; const dLon = ((other.longitude - this.longitude) * Math.PI) / 180;
const a = Math.sin(Δφ/2) * Math.sin(Δφ/2) + const a =
Math.cos(φ1) * Math.cos(φ2) * Math.sin(dLat / 2) * Math.sin(dLat / 2) +
Math.sin(Δλ/2) * Math.sin(Δλ/2); Math.cos(lat1) * Math.cos(lat2) * Math.sin(dLon / 2) * Math.sin(dLon / 2);
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1-a)); const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
return R * c; // Distance in meters return R * c; // Distance in meters
} }

View File

@@ -0,0 +1,34 @@
import { describe, it, expect } from "vitest";
import { Frame } from "../src/frame";
import type { Payload, StationCapabilitiesPayload } from "../src/frame.types";
import { Dissected } from "@hamradio/packet";
describe("Frame.decodeCapabilities", () => {
it("parses comma separated capabilities", () => {
const data = "CALL>APRS:<IGATE,MSG_CNT";
const frame = Frame.fromString(data);
const decoded = frame.decode() as StationCapabilitiesPayload;
expect(decoded).not.toBeNull();
expect(decoded.type).toBe("capabilities");
expect(Array.isArray(decoded.capabilities)).toBeTruthy();
expect(decoded.capabilities).toContain("IGATE");
expect(decoded.capabilities).toContain("MSG_CNT");
});
it("emits structure sections when requested", () => {
const data = "CALL>APRS:<IGATE MSG_CNT>";
const frame = Frame.fromString(data);
const res = frame.decode(true) as {
payload: Payload | null;
structure: Dissected;
};
expect(res.payload).not.toBeNull();
if (res.payload && res.payload.type !== "capabilities")
throw new Error("expected capabilities payload");
expect(res.structure).toBeDefined();
const caps = res.structure.find((s) => s.name === "capabilities");
expect(caps).toBeDefined();
const capEntry = res.structure.find((s) => s.name === "capability");
expect(capEntry).toBeDefined();
});
});

39
test/frame.query.test.ts Normal file
View File

@@ -0,0 +1,39 @@
import { expect } from "vitest";
import { describe, it } from "vitest";
import { Dissected } from "@hamradio/packet";
import { Frame } from "../src/frame";
import { QueryPayload } from "../src/frame.types";
describe("Frame decode - Query", () => {
it("decodes simple query without target", () => {
const frame = Frame.fromString("SRC>DEST:?APRS");
const payload = frame.decode() as QueryPayload;
expect(payload).not.toBeNull();
expect(payload.type).toBe("query");
expect(payload.queryType).toBe("APRS");
expect(payload.target).toBeUndefined();
});
it("decodes query with target", () => {
const frame = Frame.fromString("SRC>DEST:?PING N0CALL");
const payload = frame.decode() as QueryPayload;
expect(payload).not.toBeNull();
expect(payload.type).toBe("query");
expect(payload.queryType).toBe("PING");
expect(payload.target).toBe("N0CALL");
});
it("returns structure sections when requested", () => {
const frame = Frame.fromString("SRC>DEST:?PING N0CALL");
const result = frame.decode(true) as {
payload: QueryPayload;
structure: Dissected;
};
expect(result).toHaveProperty("payload");
expect(result.payload.type).toBe("query");
expect(Array.isArray(result.structure)).toBe(true);
const names = result.structure.map((s) => s.name);
expect(names).toContain("query type");
expect(names).toContain("query target");
});
});

48
test/frame.rawgps.test.ts Normal file
View File

@@ -0,0 +1,48 @@
import { describe, it, expect } from "vitest";
import { Frame } from "../src/frame";
import type { RawGPSPayload } from "../src/frame.types";
import { Dissected } from "@hamradio/packet";
describe("Raw GPS decoding", () => {
it("decodes simple NMEA sentence as raw-gps payload", () => {
const sentence =
"GPRMC,123519,A,4807.038,N,01131.000,E,022.4,084.4,230394,003.1,W*6A";
const frameStr = `SRC>DEST:$${sentence}`;
const f = Frame.parse(frameStr);
const payload = f.decode(false) as RawGPSPayload | null;
expect(payload).not.toBeNull();
expect(payload?.type).toBe("raw-gps");
expect(payload?.sentence).toBe(sentence);
expect(payload?.position).toBeDefined();
expect(typeof payload?.position?.latitude).toBe("number");
expect(typeof payload?.position?.longitude).toBe("number");
});
it("returns structure when requested", () => {
const sentence =
"GPGGA,092750.000,5321.6802,N,00630.3372,W,1,08,1.0,73.0,M,0.0,M,,*6A";
const frameStr = `SRC>DEST:$${sentence}`;
const f = Frame.parse(frameStr);
const result = f.decode(true) as {
payload: RawGPSPayload | null;
structure: Dissected;
};
expect(result.payload).not.toBeNull();
expect(result.payload?.type).toBe("raw-gps");
expect(result.payload?.sentence).toBe(sentence);
expect(result.payload?.position).toBeDefined();
expect(typeof result.payload?.position?.latitude).toBe("number");
expect(typeof result.payload?.position?.longitude).toBe("number");
expect(result.structure).toBeDefined();
const rawSection = result.structure.find((s) => s.name === "raw-gps");
expect(rawSection).toBeDefined();
const posSection = result.structure.find(
(s) => s.name === "raw-gps-position",
);
expect(posSection).toBeDefined();
});
});

View File

@@ -0,0 +1,59 @@
import { describe, it } from "vitest";
import {
TelemetryDataPayload,
TelemetryParameterPayload,
TelemetryUnitPayload,
TelemetryCoefficientsPayload,
TelemetryBitSensePayload,
} from "../src/frame.types";
import { Frame } from "../src/frame";
import { expect } from "vitest";
describe("Frame decode - Telemetry", () => {
it("decodes telemetry data payload", () => {
const frame = Frame.fromString("SRC>DEST:T#1 10,20,30,40,50 7");
const payload = frame.decode() as TelemetryDataPayload;
expect(payload).not.toBeNull();
expect(payload.type).toBe("telemetry-data");
expect(payload.sequence).toBe(1);
expect(Array.isArray(payload.analog)).toBe(true);
expect(payload.analog.length).toBe(5);
expect(payload.digital).toBe(7);
});
it("decodes telemetry parameters list", () => {
const frame = Frame.fromString("SRC>DEST:TPARAM Temp,Hum,Wind");
const payload = frame.decode() as TelemetryParameterPayload;
expect(payload).not.toBeNull();
expect(payload.type).toBe("telemetry-parameters");
expect(Array.isArray(payload.names)).toBe(true);
expect(payload.names).toEqual(["Temp", "Hum", "Wind"]);
});
it("decodes telemetry units list", () => {
const frame = Frame.fromString("SRC>DEST:TUNIT C,% ,mph");
const payload = frame.decode() as TelemetryUnitPayload;
expect(payload).not.toBeNull();
expect(payload.type).toBe("telemetry-units");
expect(payload.units).toEqual(["C", "%", "mph"]);
});
it("decodes telemetry coefficients", () => {
const frame = Frame.fromString("SRC>DEST:TCOEFF A:1,2 B:3,4 C:5,6");
const payload = frame.decode() as TelemetryCoefficientsPayload;
expect(payload).not.toBeNull();
expect(payload.type).toBe("telemetry-coefficients");
expect(payload.coefficients.a).toEqual([1, 2]);
expect(payload.coefficients.b).toEqual([3, 4]);
expect(payload.coefficients.c).toEqual([5, 6]);
});
it("decodes telemetry bitsense with project", () => {
const frame = Frame.fromString("SRC>DEST:TBITS 255 ProjectX");
const payload = frame.decode() as TelemetryBitSensePayload;
expect(payload).not.toBeNull();
expect(payload.type).toBe("telemetry-bitsense");
expect(payload.sense).toBe(255);
expect(payload.projectName).toBe("ProjectX");
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,38 @@
import { describe, it, expect } from "vitest";
import { Dissected } from "@hamradio/packet";
import { Frame } from "../src/frame";
import type { UserDefinedPayload } from "../src/frame.types";
describe("Frame.decodeUserDefined", () => {
it("parses packet type only", () => {
const data = "CALL>APRS:{01";
const frame = Frame.fromString(data);
const decoded = frame.decode() as UserDefinedPayload;
expect(decoded).not.toBeNull();
expect(decoded.type).toBe("user-defined");
expect(decoded.userPacketType).toBe("01");
expect(decoded.data).toBe("");
});
it("parses packet type and data and emits sections", () => {
const data = "CALL>APRS:{TEX Hello world";
const frame = Frame.fromString(data);
const res = frame.decode(true) as {
payload: UserDefinedPayload;
structure: Dissected;
};
expect(res.payload).not.toBeNull();
expect(res.payload.type).toBe("user-defined");
expect(res.payload.userPacketType).toBe("TEX");
expect(res.payload.data).toBe("Hello world");
const raw = res.structure.find((s) => s.name === "user-defined");
const typeSection = res.structure.find(
(s) => s.name === "user-packet-type",
);
const dataSection = res.structure.find((s) => s.name === "user-data");
expect(raw).toBeDefined();
expect(typeSection).toBeDefined();
expect(dataSection).toBeDefined();
});
});

View File

@@ -0,0 +1,37 @@
import { describe, it, expect } from "vitest";
import { Frame } from "../src/frame";
import { WeatherPayload } from "../src/frame.types";
import { Dissected } from "@hamradio/packet";
describe("Frame decode - Weather", () => {
it("parses weather with timestamp, wind, temp, rain, humidity and pressure", () => {
const data = "SRC>DEST:_120345z180/10g15t072r000p025P050h50b10132";
const frame = Frame.fromString(data);
const payload = frame.decode() as WeatherPayload;
expect(payload).not.toBeNull();
expect(payload.type).toBe("weather");
expect(payload.timestamp).toBeDefined();
expect(payload.windDirection).toBe(180);
expect(payload.windSpeed).toBe(10);
expect(payload.windGust).toBe(15);
expect(payload.temperature).toBe(72);
expect(payload.rainLast24Hours).toBe(25);
expect(payload.rainSinceMidnight).toBe(50);
expect(payload.humidity).toBe(50);
expect(payload.pressure).toBe(10132);
});
it("emits structure when requested", () => {
const data = "SRC>DEST:_120345z180/10g15t072r000p025P050h50b10132";
const frame = Frame.fromString(data);
const res = frame.decode(true) as {
payload: WeatherPayload;
structure: Dissected;
};
expect(res.payload).not.toBeNull();
expect(Array.isArray(res.structure)).toBe(true);
const names = res.structure.map((s) => s.name);
expect(names).toContain("timestamp");
expect(names).toContain("weather");
});
});

View File

@@ -1,4 +1,4 @@
import { describe, it, expect } from 'vitest'; import { describe, it, expect } from "vitest";
import { import {
base91ToNumber, base91ToNumber,
knotsToKmh, knotsToKmh,
@@ -7,79 +7,79 @@ import {
metersToFeet, metersToFeet,
celsiusToFahrenheit, celsiusToFahrenheit,
fahrenheitToCelsius, fahrenheitToCelsius,
} from '../src/parser'; } from "../src/parser";
describe('parser utilities', () => { describe("parser utilities", () => {
describe('base91ToNumber', () => { describe("base91ToNumber", () => {
it('decodes all-! to 0', () => { it("decodes all-! to 0", () => {
expect(base91ToNumber('!!!!')).toBe(0); expect(base91ToNumber("!!!!")).toBe(0);
}); });
it('decodes single character correctly', () => { it("decodes single character correctly", () => {
// 'A' === 65, digit = 65 - 33 = 32 // 'A' === 65, digit = 65 - 33 = 32
expect(base91ToNumber('A')).toBe(32); expect(base91ToNumber("A")).toBe(32);
}); });
it('should decode multiple Base91 characters', () => { it("should decode multiple Base91 characters", () => {
// "!!" = 0 * 91 + 0 = 0 // "!!" = 0 * 91 + 0 = 0
expect(base91ToNumber('!!')).toBe(0); expect(base91ToNumber("!!")).toBe(0);
// "!#" = 0 * 91 + 2 = 2 // "!#" = 0 * 91 + 2 = 2
expect(base91ToNumber('!#')).toBe(2); expect(base91ToNumber("!#")).toBe(2);
// "#!" = 2 * 91 + 0 = 182 // "#!" = 2 * 91 + 0 = 182
expect(base91ToNumber('#!')).toBe(182); expect(base91ToNumber("#!")).toBe(182);
// "##" = 2 * 91 + 2 = 184 // "##" = 2 * 91 + 2 = 184
expect(base91ToNumber('##')).toBe(184); expect(base91ToNumber("##")).toBe(184);
}); });
it('should decode 4-character Base91 strings (used in APRS)', () => { it("should decode 4-character Base91 strings (used in APRS)", () => {
// Test with printable ASCII Base91 characters (33-123) // Test with printable ASCII Base91 characters (33-123)
const testValue = base91ToNumber('!#%\''); const testValue = base91ToNumber("!#%'");
expect(testValue).toBeGreaterThan(0); expect(testValue).toBeGreaterThan(0);
expect(testValue).toBeLessThan(91 * 91 * 91 * 91); expect(testValue).toBeLessThan(91 * 91 * 91 * 91);
}); });
it('should decode maximum valid Base91 value', () => { it("should decode maximum valid Base91 value", () => {
// Maximum is '{' (ASCII 123, digit 90) repeated // Maximum is '{' (ASCII 123, digit 90) repeated
const maxValue = base91ToNumber('{{{{'); const maxValue = base91ToNumber("{{{{");
const expected = 90 * 91 * 91 * 91 + 90 * 91 * 91 + 90 * 91 + 90; const expected = 90 * 91 * 91 * 91 + 90 * 91 * 91 + 90 * 91 + 90;
expect(maxValue).toBe(expected); expect(maxValue).toBe(expected);
}); });
it('should handle APRS compressed position example', () => { it("should handle APRS compressed position example", () => {
// Using actual characters from APRS test vector // Using actual characters from APRS test vector
const latStr = '/:*E'; const latStr = "/:*E";
const lonStr = 'qZ=O'; const lonStr = "qZ=O";
const latValue = base91ToNumber(latStr); const latValue = base91ToNumber(latStr);
const lonValue = base91ToNumber(lonStr); const lonValue = base91ToNumber(lonStr);
// Just verify they decode without error and produce valid numbers // Just verify they decode without error and produce valid numbers
expect(typeof latValue).toBe('number'); expect(typeof latValue).toBe("number");
expect(typeof lonValue).toBe('number'); expect(typeof lonValue).toBe("number");
expect(latValue).toBeGreaterThanOrEqual(0); expect(latValue).toBeGreaterThanOrEqual(0);
expect(lonValue).toBeGreaterThanOrEqual(0); expect(lonValue).toBeGreaterThanOrEqual(0);
}); });
it('throws on invalid character', () => { it("throws on invalid character", () => {
expect(() => base91ToNumber(' ')).toThrow(); // space (code 32) is invalid expect(() => base91ToNumber(" ")).toThrow(); // space (code 32) is invalid
}); });
}); });
describe('unit conversions', () => { describe("unit conversions", () => {
it('converts knots <-> km/h', () => { it("converts knots <-> km/h", () => {
expect(knotsToKmh(10)).toBeCloseTo(18.52, 5); expect(knotsToKmh(10)).toBeCloseTo(18.52, 5);
expect(kmhToKnots(18.52)).toBeCloseTo(10, 3); expect(kmhToKnots(18.52)).toBeCloseTo(10, 3);
}); });
it('converts feet <-> meters', () => { it("converts feet <-> meters", () => {
expect(feetToMeters(10)).toBeCloseTo(3.048, 6); expect(feetToMeters(10)).toBeCloseTo(3.048, 6);
expect(metersToFeet(3.048)).toBeCloseTo(10, 6); expect(metersToFeet(3.048)).toBeCloseTo(10, 6);
}); });
it('converts celsius <-> fahrenheit', () => { it("converts celsius <-> fahrenheit", () => {
expect(celsiusToFahrenheit(0)).toBeCloseTo(32, 6); expect(celsiusToFahrenheit(0)).toBeCloseTo(32, 6);
expect(fahrenheitToCelsius(32)).toBeCloseTo(0, 6); expect(fahrenheitToCelsius(32)).toBeCloseTo(0, 6);
expect(celsiusToFahrenheit(100)).toBeCloseTo(212, 6); expect(celsiusToFahrenheit(100)).toBeCloseTo(212, 6);