10 Commits

Author SHA1 Message Date
0ab62dab02 Version 1.1.2 2026-03-16 13:16:18 +01:00
38b617728c Bug fixes in structure parsing 2026-03-16 13:16:06 +01:00
16f638301b Version 1.1.1 2026-03-16 07:41:46 +01:00
d0a100359d Repair missing datatypes 2026-03-16 07:41:32 +01:00
c300aefc0b Major change: switched to DataType enum 2026-03-15 22:57:19 +01:00
074806528f Added README 2026-03-15 21:38:09 +01:00
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
19 changed files with 2424 additions and 4259 deletions

3
.gitignore vendored
View File

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

View File

@@ -11,16 +11,22 @@ repos:
hooks:
- id: shellcheck
- repo: https://github.com/pre-commit/mirrors-eslint
rev: v10.0.3
- repo: local
hooks:
- id: prettier
name: prettier
entry: npx prettier --write
language: system
files: "\\.(js|jsx|ts|tsx)$"
- repo: local
hooks:
- id: eslint
name: eslint
entry: npx eslint --fix
language: system
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
hooks:
- id: stylelint

118
README.md
View File

@@ -0,0 +1,118 @@
# @hamradio/aprs
APRS (Automatic Packet Reporting System) utilities and parsers for TypeScript/JavaScript.
> For AX.25 frame parsing, see [@hamradio/ax25](https://www.npmjs.com/package/@hamradio/ax25).
This package provides lightweight parsing and helpers for APRS frames (APRS-IS style payloads). It exposes a small API for parsing frames, decoding payloads, working with APRS timestamps and addresses, and a few utility conversions.
## Install
Using npm:
```bash
npm install @hamradio/aprs
```
Or with yarn:
```bash
yarn add @hamradio/aprs
```
## Quick examples
Examples below show ESM / TypeScript usage. For CommonJS require() the same symbols are available from the package entrypoint.
### Import
```ts
import {
Frame,
Address,
Timestamp,
base91ToNumber,
knotsToKmh,
} from '@hamradio/aprs';
```
### Parse a raw APRS frame and decode payload
```ts
const raw = 'NOCALL-1>APRS,WIDE1-1:@092345z/:*E";qZ=OMRC/A=088132Hello World!';
// Parse into a Frame instance
const frame = Frame.fromString(raw);
// Inspect routing and payload
console.log(frame.source.toString()); // e.g. NOCALL-1
console.log(frame.destination.toString()); // APRS
console.log(frame.path.map(p => p.toString()));
// Decode payload (returns a structured payload object or null)
const payload = frame.decode();
console.log(payload?.type); // e.g. 'position' | 'message' | 'status' | ...
// Or ask for sections (dissection) along with decoded payload
const res = frame.decode(true) as { payload: any | null; structure: any };
console.log(res.payload, res.structure);
```
### Message decoding
```ts
const msg = 'W1AW>APRS::KB1ABC-5 :Hello World';
const f = Frame.fromString(msg);
const decoded = f.decode();
if (decoded && decoded.type === 'message') {
console.log(decoded.addressee); // KB1ABC-5
console.log(decoded.text); // Hello World
}
```
### Work with addresses and timestamps
```ts
const a = Address.parse('WA1PLE-4*');
console.log(a.call, a.ssid, a.isRepeated);
const ts = new Timestamp(12, 45, 'HMS', { seconds: 30, zulu: true });
console.log(ts.toDate()); // JavaScript Date representing the timestamp
```
### Utility conversions
```ts
console.log(base91ToNumber('!!!!')); // decode base91 values used in some APRS payloads
console.log(knotsToKmh(10)); // convert speed
```
## API summary
- `Frame` — parse frames with `Frame.fromString()` / `Frame.parse()` and decode payloads with `frame.decode()`.
- `Address` — helpers to parse and format APRS addresses: `Address.parse()` / `Address.fromString()`.
- `Timestamp` — APRS timestamp wrapper with `toDate()` conversion.
- Utility functions: `base91ToNumber`, `knotsToKmh`, `kmhToKnots`, `feetToMeters`, `metersToFeet`, `celsiusToFahrenheit`, `fahrenheitToCelsius`.
## Development
Run tests with:
```bash
npm install
npm test
```
Build the distribution with:
```bash
npm run build
```
## Contributing
See the project repository for contribution guidelines and tests.
---
Project: @hamradio/aprs — APRS parsing utilities for TypeScript

3283
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "@hamradio/aprs",
"version": "1.0.1",
"version": "1.1.2",
"description": "APRS (Automatic Packet Reporting System) protocol support for Typescript",
"keywords": [
"APRS",
@@ -11,7 +11,7 @@
],
"repository": {
"type": "git",
"url": "https://git.maze.io/ham/aprs.js"
"url": "https://git.maze.io/ham/aprs.ts"
},
"license": "MIT",
"author": "Wijnand Modderman-Lenstra",
@@ -38,7 +38,6 @@
"lint": "eslint .",
"prepare": "npm run build"
},
"dependencies": {},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@vitest/coverage-v8": "^4.0.18",
@@ -48,5 +47,9 @@
"typescript": "^5.9.3",
"typescript-eslint": "^8.57.0",
"vitest": "^4.0.18"
},
"dependencies": {
"@hamradio/packet": "^1.1.0",
"extended-nmea": "^2.1.3"
}
}

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 {
call: string;
@@ -14,53 +14,51 @@ export interface IFrame {
}
// APRS Data Type Identifiers (first character of payload)
export const DataTypeIdentifier = {
export enum DataType {
// Position Reports
PositionNoTimestampNoMessaging: '!',
PositionNoTimestampWithMessaging: '=',
PositionWithTimestampNoMessaging: '/',
PositionWithTimestampWithMessaging: '@',
PositionNoTimestampNoMessaging = "!",
PositionNoTimestampWithMessaging = "=",
PositionWithTimestampNoMessaging = "/",
PositionWithTimestampWithMessaging = "@",
// Mic-E
MicECurrent: '`',
MicEOld: "'",
MicECurrent = "`",
MicEOld = "'",
// Messages and Bulletins
Message: ':',
Message = ":",
// Objects and Items
Object: ';',
Item: ')',
Object = ";",
Item = ")",
// Status
Status: '>',
Status = ">",
// Query
Query: '?',
Query = "?",
// Telemetry
TelemetryData: 'T',
TelemetryData = "T",
// Weather
WeatherReportNoPosition: '_',
WeatherReportNoPosition = "_",
// Raw GPS Data
RawGPS: '$',
RawGPS = "$",
// Station Capabilities
StationCapabilities: '<',
StationCapabilities = "<",
// User-Defined
UserDefined: '{',
UserDefined = "{",
// Third-Party Traffic
ThirdParty: '}',
ThirdParty = "}",
// Invalid/Test Data
InvalidOrTest: ',',
} as const;
export type DataTypeIdentifier = typeof DataTypeIdentifier[keyof typeof DataTypeIdentifier];
InvalidOrTest = ",",
}
export interface ISymbol {
table: string; // Symbol table identifier
@@ -91,14 +89,18 @@ export interface ITimestamp {
hours: number;
minutes: 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
toDate(): Date; // Convert to Date object respecting timezone
}
// Position Report Payload
export interface PositionPayload {
type: 'position';
type:
| DataType.PositionNoTimestampNoMessaging
| DataType.PositionNoTimestampWithMessaging
| DataType.PositionWithTimestampNoMessaging
| DataType.PositionWithTimestampWithMessaging;
timestamp?: ITimestamp;
position: IPosition;
messaging: boolean; // Whether APRS messaging is enabled
@@ -106,7 +108,7 @@ export interface PositionPayload {
messageType?: string;
isStandard?: boolean;
};
sections?: PacketSegment[];
sections?: Segment[];
}
// Compressed Position Format
@@ -122,24 +124,25 @@ export interface CompressedPosition {
range?: number; // Miles
altitude?: number; // Feet
radioRange?: number; // Miles
compression: 'old' | 'current';
compression: "old" | "current";
}
// Mic-E Payload (compressed in destination address)
export interface MicEPayload {
type: 'mic-e';
type: DataType.MicECurrent | DataType.MicEOld;
position: IPosition;
course?: number;
speed?: number;
altitude?: number;
messageType?: string; // Standard Mic-E message
isStandard?: boolean; // Whether messageType is a standard Mic-E message
telemetry?: number[]; // Optional telemetry channels
status?: string;
}
export type MessageVariant = "message" | "bulletin";
// Message Payload
export interface MessagePayload {
type: 'message';
type: DataType.Message;
variant: "message";
addressee: string; // 9 character padded callsign
text: string; // Message text
messageNumber?: string; // Message ID for acknowledgment
@@ -149,7 +152,8 @@ export interface MessagePayload {
// Bulletin/Announcement (variant of message)
export interface BulletinPayload {
type: 'bulletin';
type: DataType.Message;
variant: "bulletin";
bulletinId: string; // Bulletin identifier (BLN#)
text: string;
group?: string; // Optional group bulletin
@@ -157,7 +161,7 @@ export interface BulletinPayload {
// Object Payload
export interface ObjectPayload {
type: 'object';
type: DataType.Object;
name: string; // 9 character object name
timestamp: ITimestamp;
alive: boolean; // True if object is active, false if killed
@@ -168,7 +172,7 @@ export interface ObjectPayload {
// Item Payload
export interface ItemPayload {
type: 'item';
type: DataType.Item;
name: string; // 3-9 character item name
alive: boolean; // True if item is active, false if killed
position: IPosition;
@@ -176,7 +180,7 @@ export interface ItemPayload {
// Status Payload
export interface StatusPayload {
type: 'status';
type: DataType.Status;
timestamp?: ITimestamp;
text: string;
maidenhead?: string; // Optional Maidenhead grid locator
@@ -188,14 +192,22 @@ export interface StatusPayload {
// Query Payload
export interface QueryPayload {
type: 'query';
type: DataType.Query;
queryType: string; // e.g., 'APRSD', 'APRST', 'PING'
target?: string; // Target callsign or area
}
export type TelemetryVariant =
| "data"
| "parameters"
| "unit"
| "coefficients"
| "bitsense";
// Telemetry Data Payload
export interface TelemetryDataPayload {
type: 'telemetry-data';
type: DataType.TelemetryData;
variant: "data";
sequence: number;
analog: number[]; // Up to 5 analog channels
digital: number; // 8-bit digital value
@@ -203,19 +215,22 @@ export interface TelemetryDataPayload {
// Telemetry Parameter Names
export interface TelemetryParameterPayload {
type: 'telemetry-parameters';
type: DataType.TelemetryData;
variant: "parameters";
names: string[]; // Parameter names
}
// Telemetry Unit/Label
export interface TelemetryUnitPayload {
type: 'telemetry-units';
type: DataType.TelemetryData;
variant: "unit";
units: string[]; // Units for each parameter
}
// Telemetry Coefficients
export interface TelemetryCoefficientsPayload {
type: 'telemetry-coefficients';
type: DataType.TelemetryData;
variant: "coefficients";
coefficients: {
a: number[]; // a coefficients
b: number[]; // b coefficients
@@ -225,14 +240,15 @@ export interface TelemetryCoefficientsPayload {
// Telemetry Bit Sense/Project Name
export interface TelemetryBitSensePayload {
type: 'telemetry-bitsense';
type: DataType.TelemetryData;
variant: "bitsense";
sense: number; // 8-bit sense value
projectName?: string;
}
// Weather Report Payload
export interface WeatherPayload {
type: 'weather';
type: DataType.WeatherReportNoPosition;
timestamp?: ITimestamp;
position?: IPosition;
windDirection?: number; // Degrees
@@ -249,37 +265,38 @@ export interface WeatherPayload {
rawRain?: number; // Raw rain counter
software?: string; // Weather software type
weatherUnit?: string; // Weather station type
comment?: string; // Additional comment
}
// Raw GPS Payload (NMEA sentences)
export interface RawGPSPayload {
type: 'raw-gps';
type: DataType.RawGPS;
sentence: string; // Raw NMEA sentence
position?: IPosition; // Optional parsed position if available
}
// Station Capabilities Payload
export interface StationCapabilitiesPayload {
type: 'capabilities';
type: DataType.StationCapabilities;
capabilities: string[];
}
// User-Defined Payload
export interface UserDefinedPayload {
type: 'user-defined';
type: DataType.UserDefined;
userPacketType: string;
data: string;
}
// Third-Party Traffic Payload
export interface ThirdPartyPayload {
type: 'third-party';
header: string; // Source path of third-party packet
payload: string; // Nested APRS packet
type: DataType.ThirdParty;
frame?: IFrame; // Optional nested frame if payload contains another APRS frame
comment?: string; // Optional comment
}
// DF Report Payload
export interface DFReportPayload {
type: 'df-report';
timestamp?: ITimestamp;
position: IPosition;
course?: number;
@@ -292,11 +309,12 @@ export interface DFReportPayload {
}
export interface BasePayload {
type: string;
type: DataType;
}
// Union type for all decoded payload types
export type Payload = BasePayload & (
export type Payload = BasePayload &
(
| PositionPayload
| MicEPayload
| MessagePayload
@@ -315,11 +333,10 @@ export type Payload = BasePayload & (
| StationCapabilitiesPayload
| UserDefinedPayload
| ThirdPartyPayload
| DFReportPayload
);
);
// Extended Frame with decoded payload
export interface DecodedFrame extends IFrame {
decoded?: Payload;
structure?: PacketStructure; // Routing and other frame-level sections
structure?: Dissected; // Routing and other frame-level sections
}

View File

@@ -1,16 +1,13 @@
export {
Frame,
Address,
Timestamp,
} from "./frame";
export { Frame, Address, Timestamp } from "./frame";
export {
type IAddress,
type IFrame,
DataTypeIdentifier,
DataType as DataTypeIdentifier,
} from "./frame.types";
export {
DataType,
type ISymbol,
type IPosition,
type ITimestamp,
@@ -48,10 +45,3 @@ export {
celsiusToFahrenheit,
fahrenheitToCelsius,
} 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 {)
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;
}
return value;
}
};
/* 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 => {
return knots * KNOTS_TO_KMH;
}
};
/**
* Convert speed from kilometers per hour to knots.
@@ -48,7 +50,7 @@ export const knotsToKmh = (knots: number): number => {
*/
export const kmhToKnots = (kmh: number): number => {
return kmh / KNOTS_TO_KMH;
}
};
/**
* Convert altitude from feet to meters.
@@ -58,7 +60,7 @@ export const kmhToKnots = (kmh: number): number => {
*/
export const feetToMeters = (feet: number): number => {
return feet * FEET_TO_METERS;
}
};
/**
* Convert altitude from meters to feet.
@@ -68,7 +70,7 @@ export const feetToMeters = (feet: number): number => {
*/
export const metersToFeet = (meters: number): number => {
return meters / FEET_TO_METERS;
}
};
/**
* Convert temperature from Celsius to Fahrenheit.
@@ -77,8 +79,8 @@ export const metersToFeet = (meters: number): number => {
* @returns equivalent temperature in Fahrenheit
*/
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.
@@ -87,5 +89,5 @@ export const celsiusToFahrenheit = (celsius: number): number => {
* @returns equivalent temperature in Celsius
*/
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.table = table[0];
} 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 {
this.table = table;
@@ -40,7 +42,7 @@ export class Position implements IPosition {
this.altitude = data.altitude;
this.speed = data.speed;
this.course = data.course;
if (typeof data.symbol === 'string') {
if (typeof data.symbol === "string") {
this.symbol = new Symbol(data.symbol);
} else if (data.symbol) {
this.symbol = new Symbol(data.symbol.table, data.symbol.code);
@@ -51,21 +53,21 @@ export class Position implements IPosition {
public toString(): string {
const latStr = this.latitude.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}`;
}
public distanceTo(other: IPosition): number {
const R = 6371e3; // Earth radius in meters
const lat1 = this.latitude * Math.PI / 180;
const lat2 = other.latitude * Math.PI / 180;
const dLat = (other.latitude - this.latitude) * Math.PI / 180;
const dLon = (other.longitude - this.longitude) * Math.PI / 180;
const lat1 = (this.latitude * Math.PI) / 180;
const lat2 = (other.latitude * Math.PI) / 180;
const dLat = ((other.latitude - this.latitude) * Math.PI) / 180;
const dLon = ((other.longitude - this.longitude) * Math.PI) / 180;
const a = Math.sin(dLat/2) * Math.sin(dLat/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 a =
Math.sin(dLat / 2) * Math.sin(dLat / 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));
return R * c; // Distance in meters
}

View File

@@ -0,0 +1,38 @@
import { describe, it, expect } from "vitest";
import { Frame } from "../src/frame";
import {
DataType,
type Payload,
type 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(DataType.StationCapabilities);
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 !== DataType.StationCapabilities)
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 { DataType, 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(DataType.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(DataType.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(DataType.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 { DataType, 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(DataType.RawGPS);
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(DataType.RawGPS);
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,65 @@
import { describe, it } from "vitest";
import {
TelemetryDataPayload,
TelemetryParameterPayload,
TelemetryUnitPayload,
TelemetryCoefficientsPayload,
TelemetryBitSensePayload,
DataType,
} 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(DataType.TelemetryData);
expect(payload.variant).toBe("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(DataType.TelemetryData);
expect(payload.variant).toBe("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(DataType.TelemetryData);
expect(payload.variant).toBe("unit");
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(DataType.TelemetryData);
expect(payload.variant).toBe("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(DataType.TelemetryData);
expect(payload.variant).toBe("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 { DataType, 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(DataType.UserDefined);
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(DataType.UserDefined);
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 { DataType, 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(DataType.WeatherReportNoPosition);
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 {
base91ToNumber,
knotsToKmh,
@@ -7,79 +7,79 @@ import {
metersToFeet,
celsiusToFahrenheit,
fahrenheitToCelsius,
} from '../src/parser';
} from "../src/parser";
describe('parser utilities', () => {
describe('base91ToNumber', () => {
it('decodes all-! to 0', () => {
expect(base91ToNumber('!!!!')).toBe(0);
describe("parser utilities", () => {
describe("base91ToNumber", () => {
it("decodes all-! to 0", () => {
expect(base91ToNumber("!!!!")).toBe(0);
});
it('decodes single character correctly', () => {
it("decodes single character correctly", () => {
// '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
expect(base91ToNumber('!!')).toBe(0);
expect(base91ToNumber("!!")).toBe(0);
// "!#" = 0 * 91 + 2 = 2
expect(base91ToNumber('!#')).toBe(2);
expect(base91ToNumber("!#")).toBe(2);
// "#!" = 2 * 91 + 0 = 182
expect(base91ToNumber('#!')).toBe(182);
expect(base91ToNumber("#!")).toBe(182);
// "##" = 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)
const testValue = base91ToNumber('!#%\'');
const testValue = base91ToNumber("!#%'");
expect(testValue).toBeGreaterThan(0);
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
const maxValue = base91ToNumber('{{{{');
const maxValue = base91ToNumber("{{{{");
const expected = 90 * 91 * 91 * 91 + 90 * 91 * 91 + 90 * 91 + 90;
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
const latStr = '/:*E';
const lonStr = 'qZ=O';
const latStr = "/:*E";
const lonStr = "qZ=O";
const latValue = base91ToNumber(latStr);
const lonValue = base91ToNumber(lonStr);
// Just verify they decode without error and produce valid numbers
expect(typeof latValue).toBe('number');
expect(typeof lonValue).toBe('number');
expect(typeof latValue).toBe("number");
expect(typeof lonValue).toBe("number");
expect(latValue).toBeGreaterThanOrEqual(0);
expect(lonValue).toBeGreaterThanOrEqual(0);
});
it('throws on invalid character', () => {
expect(() => base91ToNumber(' ')).toThrow(); // space (code 32) is invalid
it("throws on invalid character", () => {
expect(() => base91ToNumber(" ")).toThrow(); // space (code 32) is invalid
});
});
describe('unit conversions', () => {
it('converts knots <-> km/h', () => {
describe("unit conversions", () => {
it("converts knots <-> km/h", () => {
expect(knotsToKmh(10)).toBeCloseTo(18.52, 5);
expect(kmhToKnots(18.52)).toBeCloseTo(10, 3);
});
it('converts feet <-> meters', () => {
it("converts feet <-> meters", () => {
expect(feetToMeters(10)).toBeCloseTo(3.048, 6);
expect(metersToFeet(3.048)).toBeCloseTo(10, 6);
});
it('converts celsius <-> fahrenheit', () => {
it("converts celsius <-> fahrenheit", () => {
expect(celsiusToFahrenheit(0)).toBeCloseTo(32, 6);
expect(fahrenheitToCelsius(32)).toBeCloseTo(0, 6);
expect(celsiusToFahrenheit(100)).toBeCloseTo(212, 6);