23 Commits

Author SHA1 Message Date
e49333611f 1.4.0 2026-03-20 10:40:41 +01:00
0055938338 Added push scripts 2026-03-20 10:39:45 +01:00
75e31c2008 Cleaned up the frame.ts by splitting payload parsing to subpackages 2026-03-20 10:38:36 +01:00
1aa8eb363f More bugfixes in comment offsets 2026-03-18 19:23:27 +01:00
34240dfbd8 More bugfixes in comment offsets 2026-03-18 19:17:53 +01:00
46e7694ec6 Fixed bug in extras segment parsing 2026-03-18 19:07:17 +01:00
04166daeee Fixed bug in extras segment parsing 2026-03-18 19:01:16 +01:00
e9e329ccc1 Refactored extras field parsing 2026-03-18 18:22:53 +01:00
6adf1281ef Upgrade @hamradio/packet 2026-03-18 18:22:31 +01:00
5b836a4e0c Parse object as one segment 2026-03-18 17:09:49 +01:00
c28572e3b6 Version 1.3.0 2026-03-18 17:03:44 +01:00
17caa22331 Better parsing for extras; Added deviceID resolution 2026-03-18 17:01:46 +01:00
be8cd00c00 Export all interfaces 2026-03-18 13:12:38 +01:00
7dc15e360d Version 1.2.0 2026-03-18 13:11:40 +01:00
b1cd8449d9 Also parse the extras from the comment field 2026-03-18 13:11:16 +01:00
78dbd3b0ef Version 1.1.3 2026-03-18 10:07:06 +01:00
df266bab12 Correctly parse compressed position with no timestamp 2026-03-18 10:06:45 +01:00
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
33 changed files with 4512 additions and 2377 deletions

19
.prettierrc.ts Normal file
View File

@@ -0,0 +1,19 @@
import { type Config } from "prettier";
const config: Config = {
plugins: ["@trivago/prettier-plugin-sort-imports"],
trailingComma: "none",
printWidth: 120,
importOrder: [
"<BUILTIN_MODULES>",
"<THIRD_PARTY_MODULES>",
"(?:services|components|contexts|pages|libs|types)/(.*)$",
"^[./].*\\.(?:ts|tsx)$",
"\\.(?:scss|css)$",
"^[./]"
],
importOrderSeparation: true,
importOrderSortSpecifiers: true
};
export default config;

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

View File

@@ -1,6 +1,7 @@
{ {
"name": "@hamradio/aprs", "name": "@hamradio/aprs",
"version": "1.1.0", "type": "module",
"version": "1.4.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",
@@ -16,7 +17,7 @@
"license": "MIT", "license": "MIT",
"author": "Wijnand Modderman-Lenstra", "author": "Wijnand Modderman-Lenstra",
"main": "dist/index.js", "main": "dist/index.js",
"module": "dist/index.mjs", "module": "dist/index.js",
"types": "dist/index.d.ts", "types": "dist/index.d.ts",
"files": [ "files": [
"dist" "dist"
@@ -24,7 +25,7 @@
"exports": { "exports": {
".": { ".": {
"types": "./dist/index.d.ts", "types": "./dist/index.d.ts",
"import": "./dist/index.mjs", "import": "./dist/index.js",
"require": "./dist/index.js" "require": "./dist/index.js"
} }
}, },
@@ -36,20 +37,25 @@
"test:watch": "vitest --watch", "test:watch": "vitest --watch",
"test:ci": "vitest --run", "test:ci": "vitest --run",
"lint": "eslint .", "lint": "eslint .",
"prepare": "npm run build" "prepare": "npm run build",
"push": "npm version patch && git push",
"push-minor": "npm version minor && git push",
"push-major": "npm version major && git push"
},
"dependencies": {
"@hamradio/packet": "^1.1.1",
"extended-nmea": "^2.1.3"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^10.0.1", "@eslint/js": "^10.0.1",
"@vitest/coverage-v8": "^4.0.18", "@trivago/prettier-plugin-sort-imports": "^6.0.2",
"@vitest/coverage-v8": "^4.1.0",
"eslint": "^10.0.3", "eslint": "^10.0.3",
"globals": "^17.4.0", "globals": "^17.4.0",
"prettier": "^3.8.1",
"tsup": "^8.5.1", "tsup": "^8.5.1",
"typescript": "^5.9.3", "typescript": "^5.9.3",
"typescript-eslint": "^8.57.0", "typescript-eslint": "^8.57.1",
"vitest": "^4.0.18" "vitest": "^4.1.0"
},
"dependencies": {
"@hamradio/packet": "^1.1.0",
"extended-nmea": "^2.1.3"
} }
} }

1088
src/deviceid.ts Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,11 @@
import { Dissected, Segment } from "@hamradio/packet"; import { Dissected, Field, Segment } from "@hamradio/packet";
// Any comment that contains this marker will set the doNotArchive flag on the
// decoded payload, which can be used by applications to skip archiving or
// logging frames that are meant to be transient or test data. This allows users
// to include the marker in their APRS comments when they want to indicate that
// a particular frame should not be stored long-term.
export const DO_NOT_ARCHIVE_MARKER = "!x!";
export interface IAddress { export interface IAddress {
call: string; call: string;
@@ -14,54 +21,72 @@ export interface IFrame {
} }
// APRS Data Type Identifiers (first character of payload) // APRS Data Type Identifiers (first character of payload)
export const DataTypeIdentifier = { export enum DataType {
// Position Reports // Position Reports
PositionNoTimestampNoMessaging: "!", PositionNoTimestampNoMessaging = "!",
PositionNoTimestampWithMessaging: "=", PositionNoTimestampWithMessaging = "=",
PositionWithTimestampNoMessaging: "/", PositionWithTimestampNoMessaging = "/",
PositionWithTimestampWithMessaging: "@", PositionWithTimestampWithMessaging = "@",
// Mic-E // Mic-E
MicECurrent: "`", MicE = "`",
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; }
export type DataTypeIdentifier = export const DataTypeNames: { [key in DataType]: string } = {
(typeof DataTypeIdentifier)[keyof typeof DataTypeIdentifier]; [DataType.PositionNoTimestampNoMessaging]: "position",
[DataType.PositionNoTimestampWithMessaging]: "position with messaging",
[DataType.PositionWithTimestampNoMessaging]: "position with timestamp",
[DataType.PositionWithTimestampWithMessaging]: "position with timestamp and messaging",
[DataType.MicE]: "Mic-E",
[DataType.MicEOld]: "Mic-E (old)",
[DataType.Message]: "message/bulletin",
[DataType.Object]: "object",
[DataType.Item]: "item",
[DataType.Status]: "status",
[DataType.Query]: "query",
[DataType.TelemetryData]: "telemetry data",
[DataType.WeatherReportNoPosition]: "weather report",
[DataType.RawGPS]: "raw GPS data",
[DataType.StationCapabilities]: "station capabilities",
[DataType.UserDefined]: "user defined",
[DataType.ThirdParty]: "third-party traffic",
[DataType.InvalidOrTest]: "invalid/test"
};
export interface ISymbol { export interface ISymbol {
table: string; // Symbol table identifier table: string; // Symbol table identifier
@@ -76,8 +101,11 @@ export interface IPosition {
longitude: number; // Decimal degrees longitude: number; // Decimal degrees
ambiguity?: number; // Position ambiguity (0-4) ambiguity?: number; // Position ambiguity (0-4)
altitude?: number; // Meters altitude?: number; // Meters
speed?: number; // Speed in knots/kmh depending on source speed?: number; // Speed in km/h
course?: number; // Course in degrees course?: number; // Course in degrees
range?: number; // Kilometers
phg?: IPowerHeightGain;
dfs?: IDirectionFinding;
symbol?: ISymbol; symbol?: ISymbol;
comment?: string; comment?: string;
@@ -86,6 +114,22 @@ export interface IPosition {
distanceTo?(other: IPosition): number; // Optional method to calculate distance to another position distanceTo?(other: IPosition): number; // Optional method to calculate distance to another position
} }
export interface IPowerHeightGain {
power?: number; // Transmit power in watts
height?: number; // Antenna height in meters
gain?: number; // Antenna gain in dBi
directivity?: number | "omni" | "unknown"; // Optional directivity pattern (numeric code or "omni")
}
export interface IDirectionFinding {
bearing?: number; // Direction finding bearing in degrees
strength?: number; // Relative signal strength (0-9)
height?: number; // Antenna height in meters
gain?: number; // Antenna gain in dBi
quality?: number; // Signal quality or other metric (0-9)
directivity?: number | "omni" | "unknown"; // Optional directivity pattern (numeric code or "omni")
}
export interface ITimestamp { export interface ITimestamp {
day?: number; // Day of month (DHM format) day?: number; // Day of month (DHM format)
month?: number; // Month (MDHM format) month?: number; // Month (MDHM format)
@@ -97,9 +141,20 @@ export interface ITimestamp {
toDate(): Date; // Convert to Date object respecting timezone toDate(): Date; // Convert to Date object respecting timezone
} }
export interface ITelemetry {
sequence: number;
analog: number[];
digital?: number;
}
// Position Report Payload // Position Report Payload
export interface PositionPayload { export interface PositionPayload {
type: "position"; type:
| DataType.PositionNoTimestampNoMessaging
| DataType.PositionNoTimestampWithMessaging
| DataType.PositionWithTimestampNoMessaging
| DataType.PositionWithTimestampWithMessaging;
doNotArchive?: boolean; // Optional flag to indicate frame should not be archived
timestamp?: ITimestamp; timestamp?: ITimestamp;
position: IPosition; position: IPosition;
messaging: boolean; // Whether APRS messaging is enabled messaging: boolean; // Whether APRS messaging is enabled
@@ -128,19 +183,22 @@ export interface CompressedPosition {
// Mic-E Payload (compressed in destination address) // Mic-E Payload (compressed in destination address)
export interface MicEPayload { export interface MicEPayload {
type: "mic-e"; type: DataType.MicE | DataType.MicEOld;
doNotArchive?: boolean; // Optional flag to indicate frame should not be archived
position: IPosition; position: IPosition;
course?: number;
speed?: number;
altitude?: number;
messageType?: string; // Standard Mic-E message messageType?: string; // Standard Mic-E message
isStandard?: boolean; // Whether messageType is a standard Mic-E message
telemetry?: number[]; // Optional telemetry channels telemetry?: number[]; // Optional telemetry channels
status?: string; status?: string;
} }
export type MessageVariant = "message" | "bulletin";
// Message Payload // Message Payload
export interface MessagePayload { export interface MessagePayload {
type: "message"; type: DataType.Message;
variant: "message";
doNotArchive?: boolean; // Optional flag to indicate frame should not be archived
addressee: string; // 9 character padded callsign addressee: string; // 9 character padded callsign
text: string; // Message text text: string; // Message text
messageNumber?: string; // Message ID for acknowledgment messageNumber?: string; // Message ID for acknowledgment
@@ -150,7 +208,9 @@ export interface MessagePayload {
// Bulletin/Announcement (variant of message) // Bulletin/Announcement (variant of message)
export interface BulletinPayload { export interface BulletinPayload {
type: "bulletin"; type: DataType.Message;
variant: "bulletin";
doNotArchive?: boolean; // Optional flag to indicate frame should not be archived
bulletinId: string; // Bulletin identifier (BLN#) bulletinId: string; // Bulletin identifier (BLN#)
text: string; text: string;
group?: string; // Optional group bulletin group?: string; // Optional group bulletin
@@ -158,7 +218,8 @@ export interface BulletinPayload {
// Object Payload // Object Payload
export interface ObjectPayload { export interface ObjectPayload {
type: "object"; type: DataType.Object;
doNotArchive?: boolean; // Optional flag to indicate frame should not be archived
name: string; // 9 character object name name: string; // 9 character object name
timestamp: ITimestamp; timestamp: ITimestamp;
alive: boolean; // True if object is active, false if killed alive: boolean; // True if object is active, false if killed
@@ -169,7 +230,8 @@ export interface ObjectPayload {
// Item Payload // Item Payload
export interface ItemPayload { export interface ItemPayload {
type: "item"; type: DataType.Item;
doNotArchive?: boolean; // Optional flag to indicate frame should not be archived
name: string; // 3-9 character item name name: string; // 3-9 character item name
alive: boolean; // True if item is active, false if killed alive: boolean; // True if item is active, false if killed
position: IPosition; position: IPosition;
@@ -177,7 +239,8 @@ export interface ItemPayload {
// Status Payload // Status Payload
export interface StatusPayload { export interface StatusPayload {
type: "status"; type: DataType.Status;
doNotArchive?: boolean; // Optional flag to indicate frame should not be archived
timestamp?: ITimestamp; timestamp?: ITimestamp;
text: string; text: string;
maidenhead?: string; // Optional Maidenhead grid locator maidenhead?: string; // Optional Maidenhead grid locator
@@ -189,14 +252,17 @@ export interface StatusPayload {
// Query Payload // Query Payload
export interface QueryPayload { export interface QueryPayload {
type: "query"; type: DataType.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
} }
export type TelemetryVariant = "data" | "parameters" | "unit" | "coefficients" | "bitsense";
// Telemetry Data Payload // Telemetry Data Payload
export interface TelemetryDataPayload { export interface TelemetryDataPayload {
type: "telemetry-data"; type: DataType.TelemetryData;
variant: "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
@@ -204,19 +270,22 @@ export interface TelemetryDataPayload {
// Telemetry Parameter Names // Telemetry Parameter Names
export interface TelemetryParameterPayload { export interface TelemetryParameterPayload {
type: "telemetry-parameters"; type: DataType.TelemetryData;
variant: "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: DataType.TelemetryData;
variant: "unit";
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: DataType.TelemetryData;
variant: "coefficients";
coefficients: { coefficients: {
a: number[]; // a coefficients a: number[]; // a coefficients
b: number[]; // b coefficients b: number[]; // b coefficients
@@ -226,14 +295,15 @@ export interface TelemetryCoefficientsPayload {
// Telemetry Bit Sense/Project Name // Telemetry Bit Sense/Project Name
export interface TelemetryBitSensePayload { export interface TelemetryBitSensePayload {
type: "telemetry-bitsense"; type: DataType.TelemetryData;
variant: "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: DataType.WeatherReportNoPosition;
timestamp?: ITimestamp; timestamp?: ITimestamp;
position?: IPosition; position?: IPosition;
windDirection?: number; // Degrees windDirection?: number; // Degrees
@@ -255,34 +325,33 @@ export interface WeatherPayload {
// Raw GPS Payload (NMEA sentences) // Raw GPS Payload (NMEA sentences)
export interface RawGPSPayload { export interface RawGPSPayload {
type: "raw-gps"; type: DataType.RawGPS;
sentence: string; // Raw NMEA sentence sentence: string; // Raw NMEA sentence
position?: IPosition; // Optional parsed position if available position?: IPosition; // Optional parsed position if available
} }
// Station Capabilities Payload // Station Capabilities Payload
export interface StationCapabilitiesPayload { export interface StationCapabilitiesPayload {
type: "capabilities"; type: DataType.StationCapabilities;
capabilities: string[]; capabilities: string[];
} }
// User-Defined Payload // User-Defined Payload
export interface UserDefinedPayload { export interface UserDefinedPayload {
type: "user-defined"; type: DataType.UserDefined;
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: DataType.ThirdParty;
frame?: IFrame; // Optional nested frame if payload contains another APRS frame frame?: IFrame; // Optional nested frame if payload contains another APRS frame
comment?: string; // Optional comment comment?: string; // Optional comment
} }
// DF Report Payload // DF Report Payload
export interface DFReportPayload { export interface DFReportPayload {
type: "df-report";
timestamp?: ITimestamp; timestamp?: ITimestamp;
position: IPosition; position: IPosition;
course?: number; course?: number;
@@ -295,7 +364,8 @@ export interface DFReportPayload {
} }
export interface BasePayload { export interface BasePayload {
type: string; type: DataType;
doNotArchive?: boolean; // Optional flag to indicate frame should not be archived
} }
// Union type for all decoded payload types // Union type for all decoded payload types
@@ -319,7 +389,6 @@ export type Payload = BasePayload &
| StationCapabilitiesPayload | StationCapabilitiesPayload
| UserDefinedPayload | UserDefinedPayload
| ThirdPartyPayload | ThirdPartyPayload
| DFReportPayload
); );
// Extended Frame with decoded payload // Extended Frame with decoded payload
@@ -327,3 +396,19 @@ export interface DecodedFrame extends IFrame {
decoded?: Payload; decoded?: Payload;
structure?: Dissected; // Routing and other frame-level sections structure?: Dissected; // Routing and other frame-level sections
} }
// Extras is an internal helper type used during decoding to accumulate additional
// information that may not fit directly into the standard payload structure,
// such as comments, calculated fields, or other metadata that can be useful for
// applications consuming the decoded frames.
export interface Extras {
comment: string;
altitude?: number;
range?: number;
phg?: IPowerHeightGain;
dfs?: IDirectionFinding;
cse?: number;
spd?: number;
fields?: Field[];
telemetry?: ITelemetry;
}

View File

@@ -1,11 +1,14 @@
export { Frame, Address, Timestamp } from "./frame"; export { Frame, Address } from "./frame";
export { type IAddress, type IFrame, DataTypeIdentifier } from "./frame.types"; export { type IAddress, type IFrame, DataType as DataTypeIdentifier } from "./frame.types";
export { export {
DataType,
type ISymbol, type ISymbol,
type IPosition, type IPosition,
type ITimestamp, type ITimestamp,
type IPowerHeightGain,
type IDirectionFinding,
type PositionPayload, type PositionPayload,
type CompressedPosition, type CompressedPosition,
type MicEPayload, type MicEPayload,
@@ -28,9 +31,12 @@ export {
type DFReportPayload, type DFReportPayload,
type BasePayload, type BasePayload,
type Payload, type Payload,
type DecodedFrame, type DecodedFrame
} from "./frame.types"; } from "./frame.types";
export { Position } from "./position";
export { Timestamp } from "./timestamp";
export { export {
base91ToNumber, base91ToNumber,
knotsToKmh, knotsToKmh,
@@ -38,5 +44,8 @@ export {
feetToMeters, feetToMeters,
metersToFeet, metersToFeet,
celsiusToFahrenheit, celsiusToFahrenheit,
fahrenheitToCelsius, fahrenheitToCelsius
} from "./parser"; } from "./parser";
export { getDeviceID } from "./deviceid";
export type { DeviceID } from "./deviceid";

View File

@@ -15,9 +15,7 @@ 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( throw new Error(`Invalid Base91 character: '${str[i]}' (code ${charCode})`);
`Invalid Base91 character: '${str[i]}' (code ${charCode})`,
);
} }
value = value * base + digit; value = value * base + digit;
@@ -62,6 +60,15 @@ export const feetToMeters = (feet: number): number => {
return feet * FEET_TO_METERS; return feet * FEET_TO_METERS;
}; };
/**
* Convert miles to meters.
* @param miles number of miles
* @returns meters
*/
export const milesToMeters = (miles: number): number => {
return miles * 1609.344;
};
/** /**
* Convert altitude from meters to feet. * Convert altitude from meters to feet.
* *

View File

@@ -0,0 +1,71 @@
import { FieldType, type Segment } from "@hamradio/packet";
import { DataType, type Payload, type StationCapabilitiesPayload } from "./frame.types";
export const decodeCapabilitiesPayload = (
raw: string,
withStructure: boolean = false
): {
payload: Payload | null;
segment?: Segment[];
} => {
try {
if (raw.length < 2) return { payload: null };
// Extract the text after the '<' identifier
let rest = raw.substring(1).trim();
// Some implementations include a closing '>' or other trailing chars; strip common wrappers
if (rest.endsWith(">")) rest = rest.slice(0, -1).trim();
// Split capabilities by commas, semicolons or whitespace
const tokens = rest
.split(/[,;\s]+/)
.map((t) => t.trim())
.filter(Boolean);
const payload: StationCapabilitiesPayload = {
type: DataType.StationCapabilities,
capabilities: tokens
} as const;
if (withStructure) {
const segments: Segment[] = [];
segments.push({
name: "capabilities",
data: new TextEncoder().encode(rest).buffer,
isString: true,
fields: [
{
type: FieldType.STRING,
name: "capabilities",
length: rest.length
}
]
});
for (const cap of tokens) {
segments.push({
name: "capability",
data: new TextEncoder().encode(cap).buffer,
isString: true,
fields: [
{
type: FieldType.STRING,
name: "capability",
length: cap.length
}
]
});
}
return { payload, segment: segments };
}
return { payload };
} catch {
return { payload: null };
}
};
export default decodeCapabilitiesPayload;

504
src/payload.extras.ts Normal file
View File

@@ -0,0 +1,504 @@
import { type Field, FieldType } from "@hamradio/packet";
import type { Extras, ITelemetry, Payload } from "./frame.types";
import { base91ToNumber, feetToMeters, knotsToKmh, milesToMeters } from "./parser";
/**
* Decodes structured extras from an APRS comment string, extracting known tokens
* for altitude, range, PHG, DFS, course/speed, and embedded telemetry, and
* returns an object with the extracted values and a cleaned comment string with
* the tokens removed.
*
* If withStructure is true, also returns an array of fields representing the
* structure of the extras for use in structured packet parsing.
*
* @param comment The APRS comment string to decode.
* @param withStructure Whether to include structured fields in the result.
* @returns An object containing the decoded extras and the cleaned comment string.
*/
export const decodeCommentExtras = (comment: string, withStructure: boolean = false): Extras => {
if (!comment || comment.length === 0) return { comment };
const extras: Partial<Extras> = {};
const fields: Field[] = [];
const beforeFields: Field[] = [];
let altitudeOffset: number | undefined = undefined;
let altitudeFields: Field[] = [];
let commentOffset: number = 0;
let commentBefore: string | undefined = undefined;
// eslint-disable-next-line no-useless-assignment
let match: RegExpMatchArray | null = null;
// Process successive 7-byte data extensions at the start of the comment.
comment = comment.trimStart();
let ext = comment;
while (ext.length >= 7) {
// We first process the altitude marker, because it may appear anywhere
// in the comment and we want to extract it and its value before
// processing other tokens that may be present.
//
// /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 (altitudeOffset === undefined && altMatch) {
const altitude = feetToMeters(parseInt(altMatch[1], 10)); // feet to meters
if (isNaN(altitude)) {
break; // Invalid altitude format, stop parsing extras
}
extras.altitude = altitude;
// Keep track of where the altitude token appears in the comment for structure purposes.
altitudeOffset = comment.indexOf(altMatch[0]);
if (withStructure) {
altitudeFields = [
{
type: FieldType.STRING,
name: "altitude marker",
data: new TextEncoder().encode("/A=").buffer,
value: "/A=",
length: 3
},
{
type: FieldType.STRING,
name: "altitude",
data: new TextEncoder().encode(altMatch[1]).buffer,
value: altitude.toFixed(1) + "m",
length: 6
}
];
}
if (altitudeOffset > 0) {
// Reset the comment with the altitude marker removed.
commentBefore = comment.substring(0, altitudeOffset);
comment = comment.substring(altitudeOffset + altMatch[0].length);
ext = commentBefore; // Continue processing extensions in the part of the comment before the altitude marker
commentOffset = 0; // Reset
continue;
}
// remove altitude token from ext and advance ext for further parsing
commentOffset += altMatch[0].length;
ext = ext.replace(altMatch[0], "").trimStart();
continue;
}
// RNGrrrr -> pre-calculated range in miles (4 digits)
if ((match = ext.match(/^RNG(\d{4})/))) {
const r = match[1];
extras.range = milesToMeters(parseInt(r, 10)) / 1000.0; // Convert to kilometers
if (withStructure) {
(altitudeOffset !== undefined && commentOffset < altitudeOffset ? beforeFields : fields).push(
{
type: FieldType.STRING,
name: "range marker",
value: "RNG",
length: 3
},
{
type: FieldType.STRING,
name: "range (rrrr)",
length: 4,
value: extras.range.toFixed(1) + "km"
}
);
}
// remove range token from ext and advance ext for further parsing
if (commentBefore !== undefined && commentBefore.length > 0) {
commentBefore = commentBefore.substring(7);
ext = commentBefore;
} else {
commentOffset += 7;
ext = ext.substring(7);
}
continue;
}
// PHGphgd
//if (!extras.phg && ext.startsWith("PHG")) {
if (!extras.phg && (match = ext.match(/^PHG([0-9 ])([0-9 ])([0-9 ])([0-9 ])/))) {
// 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 = match[1];
const h = match[2];
const g = match[3];
const d = match[4];
const pNum = parseInt(p, 10);
const powerWatts = Number.isNaN(pNum) ? undefined : pNum * pNum;
const hIndex = h.charCodeAt(0) - 48;
const heightFeet = 10 * Math.pow(2, hIndex);
const heightMeters = feetToMeters(heightFeet);
const gNum = parseInt(g, 10);
const gainDbi = Number.isNaN(gNum) ? undefined : gNum;
const dNum = parseInt(d, 10);
let directivity: number | "omni" | "unknown" | undefined;
if (Number.isNaN(dNum)) {
directivity = undefined;
} else if (dNum === 0) {
directivity = "omni";
} else if (dNum >= 1 && dNum <= 8) {
directivity = dNum * 45;
} else if (dNum === 9) {
directivity = "unknown";
}
extras.phg = {
power: powerWatts,
height: heightMeters,
gain: gainDbi,
directivity
};
if (withStructure) {
(altitudeOffset !== undefined && commentOffset < altitudeOffset ? beforeFields : fields).push(
{ type: FieldType.STRING, name: "PHG marker", length: 3, value: "PHG" },
{
type: FieldType.STRING,
name: "power (p)",
length: 1,
value: powerWatts !== undefined ? powerWatts.toString() + "W" : undefined
},
{
type: FieldType.STRING,
name: "height (h)",
length: 1,
value: heightMeters !== undefined ? heightMeters.toString() + "m" : undefined
},
{
type: FieldType.STRING,
name: "gain (g)",
length: 1,
value: gainDbi !== undefined ? gainDbi.toString() + "dBi" : undefined
},
{
type: FieldType.STRING,
name: "directivity (d)",
length: 1,
value:
directivity !== undefined
? typeof directivity === "number"
? directivity.toString() + "°"
: directivity
: undefined
}
);
}
// remove PHG token from ext and advance ext for further parsing
if (commentBefore !== undefined && commentBefore.length > 0) {
commentBefore = commentBefore.substring(7);
} else {
commentOffset += 7;
}
ext = ext.substring(7).trimStart();
continue;
}
// DFSshgd
if (ext.startsWith("DFS")) {
// DFSshgd: s = strength (0-9), h = height (0-9), g = gain (0-9), d = directivity (0-9)
const s = ext.charAt(3);
const h = ext.charAt(4);
const g = ext.charAt(5);
const d = ext.charAt(6);
const sNum = parseInt(s, 10);
const hNum = parseInt(h, 10);
const gNum = parseInt(g, 10);
const dNum = parseInt(d, 10);
// Strength: s = 0-9, direct value
const strength = Number.isNaN(sNum) ? undefined : sNum;
// Height: h = 0-9, height = 10 * 2^h feet (spec: h is exponent)
const heightFeet = Number.isNaN(hNum) ? undefined : 10 * Math.pow(2, hNum);
const heightMeters = heightFeet !== undefined ? feetToMeters(heightFeet) : undefined;
// Gain: g = 0-9, gain in dB
const gainDbi = Number.isNaN(gNum) ? undefined : gNum;
// Directivity: d = 0-9, 0 = omni, 1-8 = d*45°, 9 = unknown
let directivity: number | "omni" | "unknown" | undefined;
if (Number.isNaN(dNum)) {
directivity = undefined;
} else if (dNum === 0) {
directivity = "omni";
} else if (dNum >= 1 && dNum <= 8) {
directivity = dNum * 45;
} else if (dNum === 9) {
directivity = "unknown";
}
extras.dfs = {
strength,
height: heightMeters,
gain: gainDbi,
directivity
};
if (withStructure) {
(altitudeOffset !== undefined && commentOffset < altitudeOffset ? beforeFields : fields).push(
{ type: FieldType.STRING, name: "DFS marker", length: 3, value: "DFS" },
{
type: FieldType.STRING,
name: "strength (s)",
length: 1,
value: strength !== undefined ? strength.toString() : undefined
},
{
type: FieldType.STRING,
name: "height (h)",
length: 1,
value: heightMeters !== undefined ? heightMeters.toString() + "m" : undefined
},
{
type: FieldType.STRING,
name: "gain (g)",
length: 1,
value: gainDbi !== undefined ? gainDbi.toString() + "dBi" : undefined
},
{
type: FieldType.STRING,
name: "directivity (d)",
length: 1,
value:
directivity !== undefined
? typeof directivity === "number"
? directivity.toString() + "°"
: directivity
: undefined
}
);
}
// remove DFS token from ext and advance ext for further parsing
if (commentBefore !== undefined && commentBefore.length > 0) {
commentBefore = commentBefore.substring(7);
} else {
commentOffset += 7;
}
ext = ext.substring(7).trimStart();
continue;
}
// Course/Speed DDD/SSS (7 bytes: 3 digits / 3 digits)
if (extras.cse === undefined && /^\d{3}\/\d{3}/.test(ext)) {
const courseStr = ext.substring(0, 3);
const speedStr = ext.substring(4, 7);
extras.cse = parseInt(courseStr, 10);
extras.spd = knotsToKmh(parseInt(speedStr, 10));
if (withStructure) {
(altitudeOffset !== undefined && commentOffset < altitudeOffset ? beforeFields : fields).push(
{ type: FieldType.STRING, name: "course", length: 3, value: extras.cse.toString() + "°" },
{ type: FieldType.CHAR, name: "marker", length: 1, value: "/" },
{ 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
ext = ext.substring(7).trimStart();
// If there is an 8-byte DF/NRQ following (leading '/'), parse that too
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 (extras.dfs === undefined) {
extras.dfs = {};
}
extras.dfs.bearing = dfBearing;
extras.dfs.strength = dfStrength;
if (withStructure) {
(altitudeOffset !== undefined && commentOffset < altitudeOffset ? beforeFields : fields).push(
{ type: FieldType.STRING, name: "DF marker", length: 1, value: "/" },
{ type: FieldType.STRING, name: "bearing", length: 3, value: dfBearing.toString() + "°" },
{ type: FieldType.CHAR, name: "separator", length: 1, value: "/" },
{ type: FieldType.STRING, name: "strength", length: 3, value: dfStrength.toString() }
);
}
// remove DF token from ext and advance ext for further parsing
if (commentBefore !== undefined && commentBefore.length > 0) {
commentBefore = commentBefore.substring(8);
} else {
commentOffset += 8;
}
ext = ext.substring(8).trimStart();
continue;
}
}
continue;
}
// No recognized 7+-byte extension at start
break;
}
// Parse embedded telemetry in comment. Look for |ss11|, |ss1122|, |ss112233|, |ss1122334455|, or |ss1122334455!"| patterns (where ss is sequence and each pair of digits is an analog channel in base91, and optional last pair is digital channel in base91).
if ((match = comment.match(/\|([a-z0-9]{4,14})\|/i))) {
try {
const telemetry = decodeTelemetry(match[1]);
extras.telemetry = telemetry;
if (withStructure) {
fields.push(
{
type: FieldType.CHAR,
name: "telemetry start",
length: 1,
value: "|"
},
{
type: FieldType.STRING,
name: "sequence",
length: 2,
value: telemetry.sequence.toString()
},
...telemetry.analog.map((a, i) => ({
type: FieldType.STRING,
name: `analog${i + 1}`,
length: 2,
value: a.toString()
})),
...(telemetry.digital !== undefined
? [
{
type: FieldType.STRING,
name: "digital",
length: 2,
value: telemetry.digital.toString()
}
]
: []),
{
type: FieldType.CHAR,
name: "telemetry end",
length: 1,
value: "|"
}
);
}
} catch {
// Invalid telemetry format, ignore
}
}
// Export comment with extras fields removed, if any were parsed.
if (commentOffset > 0 && commentBefore !== undefined && commentBefore.length > 0) {
extras.comment = commentBefore.substring(commentOffset) + comment;
} else if (commentBefore !== undefined && commentBefore.length > 0) {
extras.comment = commentBefore + comment;
} else {
extras.comment = comment.substring(commentOffset);
}
if (withStructure) {
const commentBeforeFields: Field[] = commentBefore
? [
{
type: FieldType.STRING,
name: "comment",
length: commentBefore.length
}
]
: [];
const commentFields: Field[] = comment
? [
{
type: FieldType.STRING,
name: "comment",
length: comment.length
}
]
: [];
// Insert the altitude fields at the correct position in the comment section based on where the altitude token was located in the original comment. If there was no altitude token, put all fields at the start of the comment section.
extras.fields = [...beforeFields, ...commentBeforeFields, ...altitudeFields, ...fields, ...commentFields];
}
return extras as Extras;
};
export const attachExtras = (payload: Payload, extras: Extras): void => {
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;
}
};
/**
* Decodes a Base91 Telemetry extension string (delimited by '|') into its components.
*
* @param ext The string between the '|' delimiters (e.g. 'ss11', 'ss112233', 'ss1122334455!"')
* @returns An object with sequence, analog (array), and optional digital (number)
*/
export const decodeTelemetry = (ext: string): ITelemetry => {
if (!ext || ext.length < 4) throw new Error("Telemetry extension too short");
// Must be even length, at least 4 (2 for seq, 2 for ch1)
if (ext.length % 2 !== 0) throw new Error("Telemetry extension must have even length");
// Sequence counter is always first 2 chars
const sequence = base91ToNumber(ext.slice(0, 2));
const analog: number[] = [];
let i = 2;
// If there are more than 12 chars, last pair is digital
let digital: number | undefined = undefined;
const analogPairs = Math.min(Math.floor((ext.length - 2) / 2), 5);
for (let j = 0; j < analogPairs; j++, i += 2) {
analog.push(base91ToNumber(ext.slice(i, i + 2)));
}
// If there are 2 chars left after 5 analogs, it's digital
if (ext.length === 14) {
digital = base91ToNumber(ext.slice(12, 14));
}
return {
sequence,
analog,
digital
};
};

149
src/payload.item.ts Normal file
View File

@@ -0,0 +1,149 @@
import { FieldType, type Segment } from "@hamradio/packet";
import { DO_NOT_ARCHIVE_MARKER, DataType, type IPosition, type ItemPayload, type Payload } from "./frame.types";
import { attachExtras, decodeCommentExtras } from "./payload.extras";
import { isCompressedPosition, parseCompressedPosition, parseUncompressedPosition } from "./payload.position";
import Timestamp from "./timestamp";
export const decodeItemPayload = (
raw: string,
withStructure: boolean = false
): {
payload: Payload | null;
segment?: Segment[];
} => {
// Item format is similar to Object but name may be 3-9 chars (stored in a 9-char field)
// Example: )NNN... where ) is data type, next 9 chars are name, then state char, then timestamp, then position
if (raw.length < 12) return { payload: null }; // minimal: 1 + 3 + 1 + 7
let offset = 1; // skip data type identifier ')'
const segment: Segment[] = withStructure ? [] : [];
// Read 9-char name field (pad/truncate as present)
const rawName = raw.substring(offset, offset + 9);
const name = rawName.trimEnd();
if (withStructure) {
segment.push({
name: "item name",
data: new TextEncoder().encode(rawName).buffer,
isString: true,
fields: [{ type: FieldType.STRING, name: "name", length: 9 }]
});
}
offset += 9;
// State character: '*' = alive, '_' = killed
const stateChar = raw.charAt(offset);
if (stateChar !== "*" && stateChar !== "_") {
return { payload: null };
}
const alive = stateChar === "*";
if (withStructure) {
segment.push({
name: "item state",
data: new TextEncoder().encode(stateChar).buffer,
isString: true,
fields: [
{
type: FieldType.CHAR,
name: "State (* alive, _ killed)",
length: 1
}
]
});
}
offset += 1;
// Timestamp (7 chars)
const timeStr = raw.substring(offset, offset + 7);
const { timestamp, segment: timestampSection } = Timestamp.fromString(timeStr.substring(offset), withStructure);
if (!timestamp) return { payload: null };
if (timestampSection) segment.push(timestampSection);
offset += 7;
const isCompressed = isCompressedPosition(raw.substring(offset));
// eslint-disable-next-line no-useless-assignment
let position: IPosition | null = null;
// eslint-disable-next-line no-useless-assignment
let consumed = 0;
if (isCompressed) {
const { position: compressed, segment: compressedSection } = parseCompressedPosition(
raw.substring(offset),
withStructure
);
if (!compressed) return { payload: null };
position = {
latitude: compressed.latitude,
longitude: compressed.longitude,
symbol: compressed.symbol,
altitude: compressed.altitude
};
consumed = 13;
if (compressedSection) segment.push(compressedSection);
} else {
const { position: uncompressed, segment: uncompressedSection } = parseUncompressedPosition(
raw.substring(offset),
withStructure
);
if (!uncompressed) return { payload: null };
position = {
latitude: uncompressed.latitude,
longitude: uncompressed.longitude,
symbol: uncompressed.symbol,
ambiguity: uncompressed.ambiguity
};
consumed = 19;
if (uncompressedSection) segment.push(uncompressedSection);
}
offset += consumed;
const remainder = raw.substring(offset);
const doNotArchive = remainder.includes(DO_NOT_ARCHIVE_MARKER);
let comment = remainder;
const extras = decodeCommentExtras(comment, withStructure);
comment = extras.comment;
if (comment) {
position.comment = comment;
if (withStructure) {
segment.push({
name: "comment",
data: new TextEncoder().encode(remainder).buffer,
isString: true,
fields: extras.fields || []
});
}
} else if (withStructure && extras.fields) {
// No free-text comment, but extras fields exist: emit comment-only segment
segment.push({
name: "comment",
data: new TextEncoder().encode(remainder).buffer,
isString: true,
fields: extras.fields || []
});
}
const payload: ItemPayload = {
type: DataType.Item,
doNotArchive,
name,
alive,
position
};
attachExtras(payload, extras);
if (withStructure) {
return { payload, segment };
}
return { payload };
};
export default decodeItemPayload;

94
src/payload.message.ts Normal file
View File

@@ -0,0 +1,94 @@
import { FieldType, type Segment } from "@hamradio/packet";
import { DO_NOT_ARCHIVE_MARKER, DataType, type MessagePayload, type Payload } from "./frame.types";
export const decodeMessagePayload = (
rawPayload: string,
withStructure: boolean = false
): {
payload: Payload | null;
segment?: Segment[];
} => {
// Message format: :AAAAAAAAA[ ]:message text
// where AAAAAAAAA is a 9-character recipient field (padded with spaces)
if (rawPayload.length < 2) return { payload: null };
let offset = 1; // skip ':' data type
const segments: Segment[] = withStructure ? [] : [];
// Attempt to read a 9-char recipient field if present
let recipient = "";
if (rawPayload.length >= offset + 1) {
// Try to read up to 9 chars for recipient, but stop early if a ':' separator appears
const look = rawPayload.substring(offset, Math.min(offset + 9, rawPayload.length));
const sepIdx = look.indexOf(":");
let raw = look;
if (sepIdx !== -1) {
raw = look.substring(0, sepIdx);
} else if (look.length < 9 && rawPayload.length >= offset + 9) {
// pad to full 9 chars if possible
raw = rawPayload.substring(offset, offset + 9);
} else if (look.length === 9) {
raw = look;
}
recipient = raw.trimEnd();
if (withStructure) {
segments.push({
name: "recipient",
data: new TextEncoder().encode(raw).buffer,
isString: true,
fields: [{ type: FieldType.STRING, name: "to", length: 9 }]
});
}
// Advance offset past the raw we consumed
offset += raw.length;
// If there was a ':' immediately after the consumed raw, skip it as separator
if (rawPayload.charAt(offset) === ":") {
offset += 1;
} else if (sepIdx !== -1) {
// Shouldn't normally happen, but ensure we advance past separator
offset += 1;
}
}
// After recipient there is typically a space and a colon separator before the text
// Find the first ':' after the recipient (it separates the address field from the text)
let textStart = rawPayload.indexOf(":", offset);
if (textStart === -1) {
// No explicit separator; skip any spaces and take remainder as text
while (rawPayload.charAt(offset) === " " && offset < rawPayload.length) offset += 1;
textStart = offset - 1;
}
let text = "";
if (textStart >= 0 && textStart + 1 <= rawPayload.length) {
text = rawPayload.substring(textStart + 1);
}
const doNotArchive = text.includes(DO_NOT_ARCHIVE_MARKER);
const payload: MessagePayload = {
type: DataType.Message,
variant: "message",
doNotArchive,
addressee: recipient,
text
};
if (withStructure) {
// Emit text section
segments.push({
name: "text",
data: new TextEncoder().encode(text).buffer,
isString: true,
fields: [{ type: FieldType.STRING, name: "text", length: text.length }]
});
return { payload, segment: segments };
}
return { payload };
};
export default decodeMessagePayload;

300
src/payload.mice.ts Normal file
View File

@@ -0,0 +1,300 @@
import { FieldType, type Segment } from "@hamradio/packet";
import { base91ToNumber, knotsToKmh } from ".";
import { DO_NOT_ARCHIVE_MARKER, DataType, type IAddress, MicEPayload, type Payload } from "./frame.types";
import { attachExtras, decodeCommentExtras } from "./payload.extras";
export const decodeMicEPayload = (
destination: IAddress,
raw: string,
withStructure: boolean = false
): {
payload: Payload | null;
segment?: Segment[];
} => {
try {
// Mic-E encodes position in both destination address and information field
const dest = destination.call;
if (dest.length < 6) return { payload: null };
if (raw.length < 9) return { payload: null }; // Need at least data type + 8 bytes
const segments: Segment[] = withStructure ? [] : [];
// Decode latitude from destination address (6 characters)
const latResult = decodeMicELatitude(dest);
if (!latResult) return { payload: null };
const { latitude, messageType, longitudeOffset, isWest, isStandard } = latResult;
if (withStructure) {
segments.push({
name: "mic-E destination",
data: new TextEncoder().encode(dest).buffer,
isString: true,
fields: [
{
type: FieldType.STRING,
name: "destination",
length: dest.length
}
]
});
}
// Parse information field (skip data type identifier at position 0)
let offset = 1;
// Longitude: 3 bytes (degrees, minutes, hundredths)
const lonDegRaw = raw.charCodeAt(offset) - 28;
const lonMinRaw = raw.charCodeAt(offset + 1) - 28;
const lonHunRaw = raw.charCodeAt(offset + 2) - 28;
offset += 3;
// Apply longitude offset and hemisphere
let lonDeg = lonDegRaw;
if (longitudeOffset) {
lonDeg += 100;
}
if (lonDeg >= 180 && lonDeg <= 189) {
lonDeg -= 80;
} else if (lonDeg >= 190 && lonDeg <= 199) {
lonDeg -= 190;
}
let longitude = lonDeg + lonMinRaw / 60.0 + lonHunRaw / 6000.0;
if (isWest) {
longitude = -longitude;
}
// Speed and course: 3 bytes
const sp = raw.charCodeAt(offset) - 28;
const dc = raw.charCodeAt(offset + 1) - 28;
const se = raw.charCodeAt(offset + 2) - 28;
offset += 3;
let speed = sp * 10 + Math.floor(dc / 10); // Speed in knots
let course = (dc % 10) * 100 + se; // Course in degrees
if (course >= 400) course -= 400;
if (speed >= 800) speed -= 800;
// Convert speed from knots to km/h
const speedKmh = knotsToKmh(speed);
// Symbol code and table
if (raw.length < offset + 2) return { payload: null };
const symbolCode = raw.charAt(offset);
const symbolTable = raw.charAt(offset + 1);
offset += 2;
// Parse remaining data (altitude, comment, telemetry)
const remaining = raw.substring(offset);
const doNotArchive = remaining.includes(DO_NOT_ARCHIVE_MARKER);
let altitude: number | undefined = undefined;
let comment = remaining;
// Check for altitude in old format
if (comment.length >= 4 && comment.charAt(3) === "}") {
try {
const altBase91 = comment.substring(0, 3);
altitude = base91ToNumber(altBase91) - 10000; // Relative to 10km below mean sea level
comment = comment.substring(4); // Remove altitude token from comment
} catch {
// Ignore altitude parsing errors
}
}
// Parse RNG/PHG tokens from comment (defer attaching to result until created)
const remainder = comment; // Use the remaining comment text for parsing extras
const extras = decodeCommentExtras(remainder, withStructure);
comment = extras.comment;
let payloadType: DataType.MicE | DataType.MicEOld;
switch (raw.charAt(0)) {
case "`":
payloadType = DataType.MicE;
break;
case "'":
payloadType = DataType.MicEOld;
break;
default:
return { payload: null };
}
const result: MicEPayload = {
type: payloadType,
doNotArchive,
position: {
latitude,
longitude,
symbol: {
table: symbolTable,
code: symbolCode
}
},
messageType,
isStandard
};
if (speed > 0) {
result.position.speed = speedKmh;
}
if (course > 0 && course < 360) {
result.position.course = course;
}
if (altitude !== undefined) {
result.position.altitude = altitude;
}
if (comment) {
result.position.comment = comment;
}
// Attach parsed extras if present
attachExtras(result, extras);
if (withStructure) {
// Information field section (bytes after data type up to comment)
const infoData = raw.substring(1, offset);
segments.push({
name: "mic-E info",
data: new TextEncoder().encode(infoData).buffer,
isString: true,
fields: [
{ type: FieldType.CHAR, name: "longitude deg", length: 1 },
{ type: FieldType.CHAR, name: "longitude min", length: 1 },
{ type: FieldType.CHAR, name: "longitude hundredths", length: 1 },
{ type: FieldType.CHAR, name: "speed byte", length: 1 },
{ type: FieldType.CHAR, name: "course byte 1", length: 1 },
{ type: FieldType.CHAR, name: "course byte 2", length: 1 },
{ type: FieldType.CHAR, name: "symbol code", length: 1 },
{ type: FieldType.CHAR, name: "symbol table", length: 1 }
]
});
if (comment && comment.length > 0) {
segments.push({
name: "comment",
data: new TextEncoder().encode(remainder).buffer,
isString: true,
fields: extras.fields || []
});
} else if (extras.fields) {
segments.push({
name: "comment",
data: new TextEncoder().encode(remainder).buffer,
isString: true,
fields: extras.fields
});
}
return { payload: result, segment: segments };
}
return { payload: result };
} catch {
return { payload: null };
}
};
const decodeMicELatitude = (
dest: string
): {
latitude: number;
messageType: string;
longitudeOffset: boolean;
isWest: boolean;
isStandard: boolean;
} | null => {
if (dest.length < 6) return null;
// Each destination character encodes a latitude digit and message bits
const digits: number[] = [];
const messageBits: number[] = [];
for (let i = 0; i < 6; i++) {
const code = dest.charCodeAt(i);
let digit: number;
let msgBit: number;
if (code >= 48 && code <= 57) {
// '0'-'9'
digit = code - 48;
msgBit = 0;
} else if (code >= 65 && code <= 74) {
// 'A'-'J' (A=0, B=1, ... J=9)
digit = code - 65;
msgBit = 1;
} else if (code === 75) {
// 'K' means space (used for ambiguity)
digit = 0;
msgBit = 1;
} else if (code === 76) {
// 'L' means space
digit = 0;
msgBit = 0;
} else if (code >= 80 && code <= 89) {
// 'P'-'Y' custom message types (P=0, Q=1, R=2, ... Y=9)
digit = code - 80;
msgBit = 1;
} else if (code === 90) {
// 'Z' means space
digit = 0;
msgBit = 1;
} else {
return null; // Invalid character
}
digits.push(digit);
messageBits.push(msgBit);
}
// Decode latitude: format is DDMM.HH (degrees, minutes, hundredths)
const latDeg = digits[0] * 10 + digits[1];
const latMin = digits[2] * 10 + digits[3];
const latHun = digits[4] * 10 + digits[5];
let latitude = latDeg + latMin / 60.0 + latHun / 6000.0;
// Message bits determine hemisphere and other flags
// Bit 3 (messageBits[3]): 0 = North, 1 = South
// Bit 4 (messageBits[4]): 0 = West, 1 = East
// Bit 5 (messageBits[5]): 0 = longitude offset +0, 1 = longitude offset +100
const isNorth = messageBits[3] === 0;
const isWest = messageBits[4] === 0;
const longitudeOffset = messageBits[5] === 1;
if (!isNorth) {
latitude = -latitude;
}
// Decode message type from bits 0, 1, 2
const msgValue = messageBits[0] * 4 + messageBits[1] * 2 + messageBits[2];
const messageTypes = [
"M0: Off Duty",
"M1: En Route",
"M2: In Service",
"M3: Returning",
"M4: Committed",
"M5: Special",
"M6: Priority",
"M7: Emergency"
];
const messageType = messageTypes[msgValue] || "Unknown";
// Standard vs custom message indicator
const isStandard = messageBits[0] === 1;
return {
latitude,
messageType,
longitudeOffset,
isWest,
isStandard
};
};
export default decodeMicEPayload;

161
src/payload.object.ts Normal file
View File

@@ -0,0 +1,161 @@
import { FieldType, Segment } from "@hamradio/packet";
import { DO_NOT_ARCHIVE_MARKER, DataType, type IPosition, ObjectPayload, type Payload } from "./frame.types";
import { attachExtras, decodeCommentExtras } from "./payload.extras";
import { isCompressedPosition, parseCompressedPosition, parseUncompressedPosition } from "./payload.position";
import Timestamp from "./timestamp";
export const decodeObjectPayload = (
raw: string,
withStructure: boolean = false
): {
payload: Payload | null;
segment?: Segment[];
} => {
try {
// Object format: ;AAAAAAAAAcDDHHMMzDDMM.hhN/DDDMM.hhW$comment
// ^ data type
// 9-char name
// alive (*) / killed (_)
if (raw.length < 18) return { payload: null }; // 1 + 9 + 1 + 7 minimum
let offset = 1; // Skip data type identifier ';'
const segment: Segment[] = withStructure ? [] : [];
const rawName = raw.substring(offset, offset + 9);
const name = rawName.trimEnd();
if (withStructure) {
segment.push({
name: "object",
data: new TextEncoder().encode(rawName).buffer,
isString: true,
fields: [{ type: FieldType.STRING, name: "name", length: 9 }]
});
}
offset += 9;
const stateChar = raw.charAt(offset);
if (stateChar !== "*" && stateChar !== "_") {
return { payload: null };
}
const alive = stateChar === "*";
if (withStructure) {
let state: string = "invalid";
if (stateChar === "*") {
state = "alive";
} else if (stateChar === "_") {
state = "killed";
}
segment[segment.length - 1].data = new TextEncoder().encode(raw.substring(offset - 9, offset + 1)).buffer;
segment[segment.length - 1].fields.push({
type: FieldType.CHAR,
name: "state",
length: 1,
value: state
});
}
offset += 1;
const timeStr = raw.substring(offset, offset + 7);
const { timestamp, segment: timestampSection } = Timestamp.fromString(timeStr, withStructure);
if (!timestamp) {
return { payload: null };
}
if (timestampSection) {
segment.push(timestampSection);
}
offset += 7;
const isCompressed = isCompressedPosition(raw.substring(offset));
let position: IPosition | null = null;
let consumed = 0;
if (isCompressed) {
const { position: compressed, segment: compressedSection } = parseCompressedPosition(
raw.substring(offset),
withStructure
);
if (!compressed) return { payload: null };
position = {
latitude: compressed.latitude,
longitude: compressed.longitude,
symbol: compressed.symbol,
altitude: compressed.altitude
};
consumed = 13;
if (compressedSection) {
segment.push(compressedSection);
}
} else {
const { position: uncompressed, segment: uncompressedSection } = parseUncompressedPosition(
raw.substring(offset),
withStructure
);
if (!uncompressed) return { payload: null };
position = {
latitude: uncompressed.latitude,
longitude: uncompressed.longitude,
symbol: uncompressed.symbol,
ambiguity: uncompressed.ambiguity
};
consumed = 19;
if (uncompressedSection) {
segment.push(uncompressedSection);
}
}
offset += consumed;
const remainder = raw.substring(offset);
const doNotArchive = remainder.includes(DO_NOT_ARCHIVE_MARKER);
let comment = remainder;
// Parse RNG/PHG tokens
const extras = decodeCommentExtras(comment, withStructure);
comment = extras.comment;
if (comment) {
position.comment = comment;
if (withStructure) {
segment.push({
name: "comment",
data: new TextEncoder().encode(remainder).buffer,
isString: true,
fields: extras.fields || []
});
}
} else if (withStructure && extras.fields) {
segment.push({
name: "comment",
data: new TextEncoder().encode(remainder).buffer,
isString: true,
fields: extras.fields || []
});
}
const payload: ObjectPayload = {
type: DataType.Object,
doNotArchive,
name,
timestamp,
alive,
position
};
attachExtras(payload, extras);
if (withStructure) {
return { payload, segment };
}
return { payload };
} catch {
return { payload: null };
}
};
export default decodeObjectPayload;

344
src/payload.position.ts Normal file
View File

@@ -0,0 +1,344 @@
import { FieldType, type Segment } from "@hamradio/packet";
import { DO_NOT_ARCHIVE_MARKER, DataType, type IPosition, type Payload, type PositionPayload } from "./frame.types";
import { base91ToNumber, feetToMeters } from "./parser";
import { attachExtras, decodeCommentExtras } from "./payload.extras";
import Position from "./position";
import Timestamp from "./timestamp";
export const decodePositionPayload = (
dataType: string,
raw: string,
withStructure: boolean = false
): { payload: Payload | null; segment?: Segment[] } => {
try {
const hasTimestamp = dataType === "/" || dataType === "@";
const messaging = dataType === "=" || dataType === "@";
let offset = 1; // Skip data type identifier
// Build structure as we parse
const structure: Segment[] = withStructure ? [] : [];
let timestamp: Timestamp | undefined = undefined;
// Parse timestamp if present (7 characters: DDHHMMz or HHMMSSh or MMDDHMMM)
if (hasTimestamp) {
if (raw.length < 8) return { payload: null };
const timeStr = raw.substring(offset, offset + 7);
const { timestamp: parsedTimestamp, segment: timestampSegment } = Timestamp.fromString(timeStr, withStructure);
timestamp = parsedTimestamp;
if (timestampSegment) {
structure.push(timestampSegment);
}
offset += 7;
}
// Need at least enough characters for compressed position (13) or
// uncompressed (19). Allow parsing to continue if compressed-length is present.
if (raw.length < offset + 13) return { payload: null };
// Check if compressed format
const isCompressed = isCompressedPosition(raw.substring(offset));
let position: Position;
let comment = "";
if (isCompressed) {
// Compressed format: /YYYYXXXX$csT
const { position: compressed, segment: compressedSegment } = parseCompressedPosition(
raw.substring(offset),
withStructure
);
if (!compressed) return { payload: null };
position = new Position({
latitude: compressed.latitude,
longitude: compressed.longitude,
symbol: compressed.symbol
});
if (compressed.altitude !== undefined) {
position.altitude = compressed.altitude;
}
if (compressedSegment) {
structure.push(compressedSegment);
}
offset += 13; // Compressed position is 13 chars
comment = raw.substring(offset);
} else {
// Uncompressed format: DDMMmmH/DDDMMmmH$
const { position: uncompressed, segment: uncompressedSegment } = parseUncompressedPosition(
raw.substring(offset),
withStructure
);
if (!uncompressed) return { payload: null };
position = new Position({
latitude: uncompressed.latitude,
longitude: uncompressed.longitude,
symbol: uncompressed.symbol
});
if (uncompressed.ambiguity !== undefined) {
position.ambiguity = uncompressed.ambiguity;
}
if (uncompressedSegment) {
structure.push(uncompressedSegment);
}
offset += 19; // Uncompressed position is 19 chars
comment = raw.substring(offset);
}
// 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 = decodeCommentExtras(remainder, withStructure);
comment = extras.comment;
if (comment) {
position.comment = comment;
// Emit comment section as we parse
if (withStructure) {
structure.push({
name: "comment",
data: new TextEncoder().encode(remainder).buffer,
isString: true,
fields: extras.fields || []
});
}
} else if (withStructure && extras.fields) {
// No free-text comment, but extras were present: emit a comment section containing only fields
structure.push({
name: "comment",
data: new TextEncoder().encode("").buffer,
isString: true,
fields: extras.fields || []
});
}
let payloadType:
| DataType.PositionNoTimestampNoMessaging
| DataType.PositionNoTimestampWithMessaging
| DataType.PositionWithTimestampNoMessaging
| DataType.PositionWithTimestampWithMessaging;
switch (dataType) {
case "!":
payloadType = DataType.PositionNoTimestampNoMessaging;
break;
case "=":
payloadType = DataType.PositionNoTimestampWithMessaging;
break;
case "/":
payloadType = DataType.PositionWithTimestampNoMessaging;
break;
case "@":
payloadType = DataType.PositionWithTimestampWithMessaging;
break;
default:
return { payload: null };
}
const payload: PositionPayload = {
type: payloadType,
doNotArchive,
timestamp,
position,
messaging
};
attachExtras(payload, extras);
if (withStructure) {
return { payload, segment: structure };
}
return { payload };
} catch {
return { payload: null };
}
};
export const isCompressedPosition = (data: string): boolean => {
if (data.length < 13) return false;
// First prefer uncompressed detection by attempting an uncompressed parse.
// Uncompressed APRS positions do not have a fixed symbol table separator;
// position 8 is a symbol table identifier and may vary.
if (data.length >= 19) {
const uncompressed = parseUncompressedPosition(data, false);
if (uncompressed.position) {
return false;
}
}
// For compressed format, check if the position part looks like base-91 encoded data
// Compressed format: STYYYYXXXXcsT where ST is symbol table/code
// Base-91 chars are in range 33-124 (! to |)
const lat1 = data.charCodeAt(1);
const lat2 = data.charCodeAt(2);
const lon1 = data.charCodeAt(5);
const lon2 = data.charCodeAt(6);
return (
lat1 >= 33 && lat1 <= 124 && lat2 >= 33 && lat2 <= 124 && lon1 >= 33 && lon1 <= 124 && lon2 >= 33 && lon2 <= 124
);
};
export const parseCompressedPosition = (
data: string,
withStructure: boolean = false
): {
position: IPosition | null;
segment?: Segment;
} => {
if (data.length < 13) return { position: null };
const symbolTable = data.charAt(0);
const symbolCode = data.charAt(9);
// Extract base-91 encoded position (4 characters each)
const latStr = data.substring(1, 5);
const lonStr = data.substring(5, 9);
try {
// Decode base-91 encoded latitude and longitude
const latBase91 = base91ToNumber(latStr);
const lonBase91 = base91ToNumber(lonStr);
// Convert to degrees
const latitude = 90 - latBase91 / 380926;
const longitude = -180 + lonBase91 / 190463;
const result: IPosition = {
latitude,
longitude,
symbol: {
table: symbolTable,
code: symbolCode
}
};
// Check for compressed altitude (csT format)
const cs = data.charAt(10);
const t = data.charCodeAt(11);
if (cs === " " && t >= 33 && t <= 124) {
// Compressed altitude: altitude = 1.002^(t-33) feet
const altFeet = Math.pow(1.002, t - 33);
result.altitude = feetToMeters(altFeet); // Convert to meters
}
const section: Segment | undefined = withStructure
? {
name: "position",
data: new TextEncoder().encode(data.substring(0, 13)).buffer,
isString: true,
fields: [
{ type: FieldType.CHAR, length: 1, name: "symbol table" },
{ type: FieldType.STRING, length: 4, name: "latitude" },
{ type: FieldType.STRING, length: 4, name: "longitude" },
{ type: FieldType.CHAR, length: 1, name: "symbol code" },
{ type: FieldType.CHAR, length: 1, name: "course/speed type" },
{ type: FieldType.CHAR, length: 1, name: "course/speed value" },
{ type: FieldType.CHAR, length: 1, name: "altitude" }
]
}
: undefined;
return { position: result, segment: section };
} catch {
return { position: null };
}
};
export const parseUncompressedPosition = (
data: string,
withStructure: boolean = false
): {
position: IPosition | null;
segment?: Segment;
} => {
if (data.length < 19) return { position: null };
// Format: DDMMmmH/DDDMMmmH$ where H is hemisphere, $ is symbol code
// Positions: 0-7 (latitude), 8 (symbol table), 9-17 (longitude), 18 (symbol code)
// Spaces may replace rightmost digits for ambiguity/privacy
const latStr = data.substring(0, 8); // DDMMmmH (8 chars: 49 03.50 N)
const symbolTable = data.charAt(8);
const lonStr = data.substring(9, 18); // DDDMMmmH (9 chars: 072 01.75 W)
const symbolCode = data.charAt(18);
// Count and handle ambiguity (spaces in minutes part replace rightmost digits)
let ambiguity = 0;
const latSpaceCount = (latStr.match(/ /g) || []).length;
const lonSpaceCount = (lonStr.match(/ /g) || []).length;
if (latSpaceCount > 0 || lonSpaceCount > 0) {
// Use the maximum space count (they should be the same, but be defensive)
ambiguity = Math.max(latSpaceCount, lonSpaceCount);
}
// Replace spaces with zeros for parsing
const latStrNormalized = latStr.replace(/ /g, "0");
const lonStrNormalized = lonStr.replace(/ /g, "0");
// Parse latitude
const latDeg = parseInt(latStrNormalized.substring(0, 2), 10);
const latMin = parseFloat(latStrNormalized.substring(2, 7));
const latHem = latStrNormalized.charAt(7);
if (isNaN(latDeg) || isNaN(latMin)) return { position: null };
if (latHem !== "N" && latHem !== "S") return { position: null };
let latitude = latDeg + latMin / 60;
if (latHem === "S") latitude = -latitude;
// Parse longitude
const lonDeg = parseInt(lonStrNormalized.substring(0, 3), 10);
const lonMin = parseFloat(lonStrNormalized.substring(3, 8));
const lonHem = lonStrNormalized.charAt(8);
if (isNaN(lonDeg) || isNaN(lonMin)) return { position: null };
if (lonHem !== "E" && lonHem !== "W") return { position: null };
let longitude = lonDeg + lonMin / 60;
if (lonHem === "W") longitude = -longitude;
const result: IPosition = {
latitude,
longitude,
symbol: {
table: symbolTable,
code: symbolCode
}
};
if (ambiguity > 0) {
result.ambiguity = ambiguity;
}
const segment: Segment | undefined = withStructure
? {
name: "position",
data: new TextEncoder().encode(data.substring(0, 19)).buffer,
isString: true,
fields: [
{ type: FieldType.STRING, length: 8, name: "latitude" },
{ type: FieldType.CHAR, length: 1, name: "symbol table" },
{ type: FieldType.STRING, length: 9, name: "longitude" },
{ type: FieldType.CHAR, length: 1, name: "symbol code" }
]
}
: undefined;
return { position: result, segment };
};
export default decodePositionPayload;

69
src/payload.query.ts Normal file
View File

@@ -0,0 +1,69 @@
import { FieldType, type Segment } from "@hamradio/packet";
import { DataType, type Payload, type QueryPayload } from "./frame.types";
export const decodeQueryPayload = (
raw: string,
withStructure: boolean = false
): {
payload: Payload | null;
segment?: Segment[];
} => {
try {
if (raw.length < 2) return { payload: null };
// Skip data type identifier '?'
const segments: Segment[] = withStructure ? [] : [];
// Remaining payload
const rest = raw.substring(1).trim();
if (!rest) return { payload: null };
// Query type is the first token (up to first space)
const firstSpace = rest.indexOf(" ");
let queryType = "";
let target: string | undefined = undefined;
if (firstSpace === -1) {
queryType = rest;
} else {
queryType = rest.substring(0, firstSpace);
target = rest.substring(firstSpace + 1).trim();
if (target === "") target = undefined;
}
if (!queryType) return { payload: null };
if (withStructure) {
// Emit query type section
segments.push({
name: "query type",
data: new TextEncoder().encode(queryType).buffer,
isString: true,
fields: [{ type: FieldType.STRING, name: "type", length: queryType.length }]
});
if (target) {
segments.push({
name: "query target",
data: new TextEncoder().encode(target).buffer,
isString: true,
fields: [{ type: FieldType.STRING, name: "target", length: target.length }]
});
}
}
const payload: QueryPayload = {
type: DataType.Query,
queryType,
...(target ? { target } : {})
};
if (withStructure) return { payload, segment: segments };
return { payload };
} catch {
return { payload: null };
}
};
export default decodeQueryPayload;

161
src/payload.rawgps.ts Normal file
View File

@@ -0,0 +1,161 @@
import { FieldType, type Segment } from "@hamradio/packet";
import { DTM, GGA, INmeaSentence, Decoder as NmeaDecoder, RMC } from "extended-nmea";
import { DataType, type IPosition, type Payload, type RawGPSPayload } from "./frame.types";
export const decodeRawGPSPayload = (
raw: string,
withStructure: boolean = false
): {
payload: Payload | null;
segment?: Segment[];
} => {
try {
if (raw.length < 2) return { payload: null };
// Raw GPS payloads start with '$' followed by an NMEA sentence
const sentence = raw.substring(1).trim();
// Attempt to parse with extended-nmea Decoder to extract position (best-effort)
let parsed: INmeaSentence | null = null;
try {
const full = sentence.startsWith("$") ? sentence : `$${sentence}`;
parsed = NmeaDecoder.decode(full);
} catch {
// ignore parse errors - accept any sentence as raw-gps per APRS
}
const payload: RawGPSPayload = {
type: DataType.RawGPS,
sentence
};
// If parse produced latitude/longitude, attach structured position.
// Otherwise fallback to a minimal NMEA parser for common sentences (RMC, GGA).
if (
parsed &&
(parsed instanceof RMC || parsed instanceof GGA || parsed instanceof DTM) &&
parsed.latitude &&
parsed.longitude
) {
// extended-nmea latitude/longitude are GeoCoordinate objects with
// fields { degrees, decimal, quadrant }
const latObj = parsed.latitude;
const lonObj = parsed.longitude;
const lat = latObj.degrees + (Number(latObj.decimal) || 0) / 60.0;
const lon = lonObj.degrees + (Number(lonObj.decimal) || 0) / 60.0;
const latitude = latObj.quadrant === "S" ? -lat : lat;
const longitude = lonObj.quadrant === "W" ? -lon : lon;
const pos: IPosition = {
latitude,
longitude
};
// altitude
if ("altMean" in parsed && parsed.altMean !== undefined) {
pos.altitude = Number(parsed.altMean);
}
if ("altitude" in parsed && parsed.altitude !== undefined) {
pos.altitude = Number(parsed.altitude);
}
// speed/course (RMC fields)
if ("speedOverGround" in parsed && parsed.speedOverGround !== undefined) {
pos.speed = Number(parsed.speedOverGround);
}
if ("courseOverGround" in parsed && parsed.courseOverGround !== undefined) {
pos.course = Number(parsed.courseOverGround);
}
payload.position = pos;
} else {
try {
const full = sentence.startsWith("$") ? sentence : `$${sentence}`;
const withoutChecksum = full.split("*")[0];
const parts = withoutChecksum.split(",");
const header = parts[0].slice(1).toUpperCase();
const parseCoord = (coord: string, hemi: string) => {
if (!coord || coord === "") return undefined;
const degDigits = hemi === "N" || hemi === "S" ? 2 : 3;
if (coord.length <= degDigits) return undefined;
const degPart = coord.slice(0, degDigits);
const minPart = coord.slice(degDigits);
const degrees = parseFloat(degPart);
const mins = parseFloat(minPart);
if (Number.isNaN(degrees) || Number.isNaN(mins)) return undefined;
let dec = degrees + mins / 60.0;
if (hemi === "S" || hemi === "W") dec = -dec;
return dec;
};
if (header.endsWith("RMC")) {
const lat = parseCoord(parts[3], parts[4]);
const lon = parseCoord(parts[5], parts[6]);
if (lat !== undefined && lon !== undefined) {
const pos: IPosition = { latitude: lat, longitude: lon };
if (parts[7]) pos.speed = Number(parts[7]);
if (parts[8]) pos.course = Number(parts[8]);
payload.position = pos;
}
} else if (header.endsWith("GGA")) {
const lat = parseCoord(parts[2], parts[3]);
const lon = parseCoord(parts[4], parts[5]);
if (lat !== undefined && lon !== undefined) {
const pos: IPosition = { latitude: lat, longitude: lon };
if (parts[9]) pos.altitude = Number(parts[9]);
payload.position = pos;
}
}
} catch {
// ignore fallback parse errors
}
}
if (withStructure) {
const segments: Segment[] = [
{
name: "raw-gps",
data: new TextEncoder().encode(sentence).buffer,
isString: true,
fields: [
{
type: FieldType.STRING,
name: "sentence",
length: sentence.length
}
]
}
];
if (payload.position) {
segments.push({
name: "raw-gps-position",
data: new TextEncoder().encode(JSON.stringify(payload.position)).buffer,
isString: true,
fields: [
{
type: FieldType.STRING,
name: "latitude",
length: String(payload.position.latitude).length
},
{
type: FieldType.STRING,
name: "longitude",
length: String(payload.position.longitude).length
}
]
});
}
return { payload, segment: segments };
}
return { payload };
} catch {
return { payload: null };
}
};
export default decodeRawGPSPayload;

79
src/payload.status.ts Normal file
View File

@@ -0,0 +1,79 @@
import { FieldType, type Segment } from "@hamradio/packet";
import { DO_NOT_ARCHIVE_MARKER, DataType, type Payload, type StatusPayload } from "./frame.types";
import Timestamp from "./timestamp";
export const decodeStatusPayload = (
raw: string,
withStructure: boolean = false
): {
payload: Payload | null;
segment?: Segment[];
} => {
// Status payload: optional 7-char timestamp followed by free text.
// We'll also detect a trailing Maidenhead locator (4 or 6 chars) and expose it.
const offsetBase = 1; // skip data type identifier '>'
if (raw.length <= offsetBase) return { payload: null };
let offset = offsetBase;
const segments: Segment[] = withStructure ? [] : [];
// Try parse optional timestamp (7 chars)
if (raw.length >= offset + 7) {
const timeStr = raw.substring(offset, offset + 7);
const { timestamp, segment: tsSegment } = Timestamp.fromString(timeStr, withStructure);
if (timestamp) {
offset += 7;
if (tsSegment) segments.push(tsSegment);
}
}
// Remaining text is status text
const text = raw.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;
const mhMatch = text.match(/\s([A-Ra-r]{2}\d{2}(?:[A-Ra-r]{2})?)$/);
let statusText = text;
if (mhMatch) {
maidenhead = mhMatch[1].toUpperCase();
statusText = text.slice(0, mhMatch.index).trimEnd();
}
const payload: StatusPayload = {
type: DataType.Status,
doNotArchive,
timestamp: undefined,
text: statusText
};
// If timestamp was parsed, attach it
if (segments.length > 0) {
// The first segment may be timestamp; parseTimestamp returns the Timestamp object
// Re-parse to obtain timestamp object (cheap) - alternate would be to capture earlier
const timeSegment = segments.find((s) => s.name === "timestamp");
if (timeSegment) {
const tsStr = new TextDecoder().decode(timeSegment.data);
const { timestamp } = Timestamp.fromString(tsStr, false);
if (timestamp) payload.timestamp = timestamp;
}
}
if (maidenhead) payload.maidenhead = maidenhead;
if (withStructure) {
segments.push({
name: "status",
data: new TextEncoder().encode(text).buffer,
isString: true,
fields: [{ type: FieldType.STRING, name: "text", length: text.length }]
});
return { payload, segment: segments };
}
return { payload };
};
export default decodeStatusPayload;

197
src/payload.telemetry.ts Normal file
View File

@@ -0,0 +1,197 @@
import { FieldType, type Segment } from "@hamradio/packet";
import {
DataType,
type Payload,
type TelemetryBitSensePayload,
type TelemetryCoefficientsPayload,
type TelemetryDataPayload,
type TelemetryParameterPayload,
type TelemetryUnitPayload
} from "./frame.types";
export const decodeTelemetryPayload = (
raw: string,
withStructure: boolean = false
): {
payload: Payload | null;
segment?: Segment[];
} => {
try {
if (raw.length < 2) return { payload: null };
const rest = raw.substring(1).trim();
if (!rest) return { payload: null };
const segments: Segment[] = withStructure ? [] : [];
// Telemetry data: convention used here: starts with '#' then sequence then analogs and digital
if (rest.startsWith("#")) {
const parts = rest.substring(1).trim().split(/\s+/);
const seq = parseInt(parts[0], 10);
let analog: number[] = [];
let digital = 0;
if (parts.length >= 2) {
// analogs as comma separated
analog = parts[1].split(",").map((v) => parseFloat(v));
}
if (parts.length >= 3) {
digital = parseInt(parts[2], 10);
}
if (withStructure) {
segments.push({
name: "telemetry sequence",
data: new TextEncoder().encode(String(seq)).buffer,
isString: true,
fields: [
{
type: FieldType.STRING,
name: "sequence",
length: String(seq).length
}
]
});
segments.push({
name: "telemetry analog",
data: new TextEncoder().encode(parts[1] || "").buffer,
isString: true,
fields: [
{
type: FieldType.STRING,
name: "analogs",
length: (parts[1] || "").length
}
]
});
segments.push({
name: "telemetry digital",
data: new TextEncoder().encode(String(digital)).buffer,
isString: true,
fields: [
{
type: FieldType.STRING,
name: "digital",
length: String(digital).length
}
]
});
}
const payload: TelemetryDataPayload = {
type: DataType.TelemetryData,
variant: "data",
sequence: isNaN(seq) ? 0 : seq,
analog,
digital: isNaN(digital) ? 0 : digital
};
if (withStructure) return { payload, segment: segments };
return { payload };
}
// Telemetry parameters: 'PARAM' keyword
if (/^PARAM/i.test(rest)) {
const after = rest.replace(/^PARAM\s*/i, "");
const names = after.split(/[,\s]+/).filter(Boolean);
if (withStructure) {
segments.push({
name: "telemetry parameters",
data: new TextEncoder().encode(after).buffer,
isString: true,
fields: [{ type: FieldType.STRING, name: "names", length: after.length }]
});
}
const payload: TelemetryParameterPayload = {
type: DataType.TelemetryData,
variant: "parameters",
names
};
if (withStructure) return { payload, segment: segments };
return { payload };
}
// Telemetry units: 'UNIT'
if (/^UNIT/i.test(rest)) {
const after = rest.replace(/^UNIT\s*/i, "");
const units = after.split(/[,\s]+/).filter(Boolean);
if (withStructure) {
segments.push({
name: "telemetry units",
data: new TextEncoder().encode(after).buffer,
isString: true,
fields: [{ type: FieldType.STRING, name: "units", length: after.length }]
});
}
const payload: TelemetryUnitPayload = {
type: DataType.TelemetryData,
variant: "unit",
units
};
if (withStructure) return { payload, segment: segments };
return { payload };
}
// Telemetry coefficients: 'COEFF' a:,b:,c:
if (/^COEFF/i.test(rest)) {
const after = rest.replace(/^COEFF\s*/i, "");
const aMatch = after.match(/A:([^\s;]+)/i);
const bMatch = after.match(/B:([^\s;]+)/i);
const cMatch = after.match(/C:([^\s;]+)/i);
const parseList = (s?: string) => (s ? s.split(",").map((v) => parseFloat(v)) : []);
const coefficients = {
a: parseList(aMatch?.[1]),
b: parseList(bMatch?.[1]),
c: parseList(cMatch?.[1])
};
if (withStructure) {
segments.push({
name: "telemetry coefficients",
data: new TextEncoder().encode(after).buffer,
isString: true,
fields: [{ type: FieldType.STRING, name: "coeffs", length: after.length }]
});
}
const payload: TelemetryCoefficientsPayload = {
type: DataType.TelemetryData,
variant: "coefficients",
coefficients
};
if (withStructure) return { payload, segment: segments };
return { payload };
}
// Telemetry bitsense/project: 'BITS' <number> [project]
if (/^BITS?/i.test(rest)) {
const parts = rest.split(/\s+/).slice(1);
const sense = parts.length > 0 ? parseInt(parts[0], 10) : 0;
const projectName = parts.length > 1 ? parts.slice(1).join(" ") : undefined;
if (withStructure) {
segments.push({
name: "telemetry bitsense",
data: new TextEncoder().encode(rest).buffer,
isString: true,
fields: [{ type: FieldType.STRING, name: "bitsense", length: rest.length }]
});
}
const payload: TelemetryBitSensePayload = {
type: DataType.TelemetryData,
variant: "bitsense",
sense: isNaN(sense) ? 0 : sense,
...(projectName ? { projectName } : {})
};
if (withStructure) return { payload, segment: segments };
return { payload };
}
return { payload: null };
} catch {
return { payload: null };
}
};
export default decodeTelemetryPayload;

135
src/payload.thirdparty.ts Normal file
View File

@@ -0,0 +1,135 @@
import { FieldType, type Segment } from "@hamradio/packet";
import { Frame } from "./frame";
import { DataType, type Payload, type ThirdPartyPayload, UserDefinedPayload } from "./frame.types";
export const decodeUserDefinedPayload = (
raw: string,
withStructure: boolean = false
): {
payload: Payload | null;
segment?: Segment[];
} => {
try {
if (raw.length < 2) return { payload: null };
// content after '{'
const rest = raw.substring(1);
// user packet type is first token (up to first space) often like '01' or 'TYP'
const match = rest.match(/^([^\s]+)\s*(.*)$/s);
let userPacketType = "";
let data = "";
if (match) {
userPacketType = match[1] || "";
data = (match[2] || "").trim();
}
const payload: UserDefinedPayload = {
type: DataType.UserDefined,
userPacketType,
data
} as const;
if (withStructure) {
const segments: Segment[] = [];
segments.push({
name: "user-defined",
data: new TextEncoder().encode(rest).buffer,
isString: true,
fields: [{ type: FieldType.STRING, name: "raw", length: rest.length }]
});
segments.push({
name: "user-packet-type",
data: new TextEncoder().encode(userPacketType).buffer,
isString: true,
fields: [
{
type: FieldType.STRING,
name: "type",
length: userPacketType.length
}
]
});
segments.push({
name: "user-data",
data: new TextEncoder().encode(data).buffer,
isString: true,
fields: [{ type: FieldType.STRING, name: "data", length: data.length }]
});
return { payload, segment: segments };
}
return { payload };
} catch {
return { payload: null };
}
};
export const decodeThirdPartyPayload = (
raw: string,
withStructure: boolean = false
): {
payload: Payload | null;
segment?: Segment[];
} => {
try {
if (raw.length < 2) return { payload: null };
// Content after '}' is the encapsulated third-party frame or raw data
const rest = raw.substring(1);
// Attempt to parse the embedded text as a full APRS frame (route:payload)
let nestedFrame: Frame | undefined;
try {
// parseFrame is defined in this module; use Frame.parse to attempt parse
nestedFrame = Frame.parse(rest);
} catch {
nestedFrame = undefined;
}
const payload: ThirdPartyPayload = {
type: DataType.ThirdParty,
comment: rest,
...(nestedFrame ? { frame: nestedFrame } : {})
} as const;
if (withStructure) {
const segments: Segment[] = [];
segments.push({
name: "third-party",
data: new TextEncoder().encode(rest).buffer,
isString: true,
fields: [{ type: FieldType.STRING, name: "raw", length: rest.length }]
});
if (nestedFrame) {
// Include a short section pointing to the nested frame's data (stringified)
const nf = nestedFrame;
const nfStr = `${nf.source.toString()}>${nf.destination.toString()}:${nf.payload}`;
segments.push({
name: "third-party-nested-frame",
data: new TextEncoder().encode(nfStr).buffer,
isString: true,
fields: [
{
type: FieldType.STRING,
name: "nested",
length: nfStr.length
}
]
});
}
return { payload, segment: segments };
}
return { payload };
} catch {
return { payload: null };
}
};

129
src/payload.weather.ts Normal file
View File

@@ -0,0 +1,129 @@
import { FieldType, type Segment } from "@hamradio/packet";
import { DataType, type IPosition, type Payload, type WeatherPayload } from "./frame.types";
import { isCompressedPosition, parseCompressedPosition, parseUncompressedPosition } from "./payload.position";
import Timestamp from "./timestamp";
export const decodeWeatherPayload = (
raw: string,
withStructure: boolean = false
): {
payload: Payload | null;
segment?: Segment[];
} => {
try {
if (raw.length < 2) return { payload: null };
let offset = 1; // skip '_' data type
const segments: Segment[] = withStructure ? [] : [];
// Try optional timestamp (7 chars)
let timestamp;
if (raw.length >= offset + 7) {
const timeStr = raw.substring(offset, offset + 7);
const parsed = Timestamp.fromString(timeStr, withStructure);
timestamp = parsed.timestamp;
if (parsed.segment) {
segments.push(parsed.segment);
}
if (timestamp) offset += 7;
}
// Try optional position following timestamp
let position: IPosition | undefined;
let consumed = 0;
const tail = raw.substring(offset);
if (tail.length > 0) {
// If the tail starts with a wind token like DDD/SSS, treat it as weather data
// and do not attempt to parse it as a position (avoids mis-detecting wind
// values as compressed position fields).
if (/^\s*\d{3}\/\d{1,3}/.test(tail)) {
// no position present; leave consumed = 0
} else if (isCompressedPosition(tail)) {
const parsed = parseCompressedPosition(tail, withStructure);
if (parsed.position) {
position = {
latitude: parsed.position.latitude,
longitude: parsed.position.longitude,
symbol: parsed.position.symbol,
altitude: parsed.position.altitude
};
if (parsed.segment) segments.push(parsed.segment);
consumed = 13;
}
} else {
const parsed = parseUncompressedPosition(tail, withStructure);
if (parsed.position) {
position = {
latitude: parsed.position.latitude,
longitude: parsed.position.longitude,
symbol: parsed.position.symbol,
ambiguity: parsed.position.ambiguity
};
if (parsed.segment) segments.push(parsed.segment);
consumed = 19;
}
}
}
offset += consumed;
const rest = raw.substring(offset).trim();
const payload: WeatherPayload = {
type: DataType.WeatherReportNoPosition
};
if (timestamp) payload.timestamp = timestamp;
if (position) payload.position = position;
if (rest && rest.length > 0) {
// Parse common tokens
// Wind: DDD/SSS [gGGG]
const windMatch = rest.match(/(\d{3})\/(\d{1,3})(?:g(\d{1,3}))?/);
if (windMatch) {
payload.windDirection = parseInt(windMatch[1], 10);
payload.windSpeed = parseInt(windMatch[2], 10);
if (windMatch[3]) payload.windGust = parseInt(windMatch[3], 10);
}
// Temperature: tNNN (F)
const tempMatch = rest.match(/t(-?\d{1,3})/i);
if (tempMatch) payload.temperature = parseInt(tempMatch[1], 10);
// Rain: rNNN (last hour), pNNN (24h), PNNN (since midnight) - values are hundredths of inch
const rMatch = rest.match(/r(\d{3})/);
if (rMatch) payload.rainLastHour = parseInt(rMatch[1], 10);
const pMatch = rest.match(/p(\d{3})/);
if (pMatch) payload.rainLast24Hours = parseInt(pMatch[1], 10);
const PMatch = rest.match(/P(\d{3})/);
if (PMatch) payload.rainSinceMidnight = parseInt(PMatch[1], 10);
// Humidity: hNN
const hMatch = rest.match(/h(\d{1,3})/);
if (hMatch) payload.humidity = parseInt(hMatch[1], 10);
// Pressure: bXXXX or bXXXXX (tenths of millibar)
const bMatch = rest.match(/b(\d{4,5})/);
if (bMatch) payload.pressure = parseInt(bMatch[1], 10);
// Add raw comment
payload.comment = rest;
if (withStructure) {
segments.push({
name: "weather",
data: new TextEncoder().encode(rest).buffer,
isString: true,
fields: [{ type: FieldType.STRING, name: "text", length: rest.length }]
});
}
}
if (withStructure) return { payload, segment: segments };
return { payload };
} catch {
return { payload: null };
}
};
export default decodeWeatherPayload;

View File

@@ -1,4 +1,4 @@
import { IPosition, ISymbol } from "./frame.types"; import { IDirectionFinding, IPosition, IPowerHeightGain, ISymbol } from "./frame.types";
export class Symbol implements ISymbol { export class Symbol implements ISymbol {
table: string; // Symbol table identifier table: string; // Symbol table identifier
@@ -10,9 +10,7 @@ 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( throw new Error(`Invalid symbol format: '${table}' (expected 2 characters if code is not provided)`);
`Invalid symbol format: '${table}' (expected 2 characters if code is not provided)`,
);
} }
} else { } else {
this.table = table; this.table = table;
@@ -34,6 +32,9 @@ export class Position implements IPosition {
course?: number; // Course in degrees course?: number; // Course in degrees
symbol?: Symbol; symbol?: Symbol;
comment?: string; comment?: string;
range?: number;
phg?: IPowerHeightGain;
dfs?: IDirectionFinding;
constructor(data: Partial<IPosition>) { constructor(data: Partial<IPosition>) {
this.latitude = data.latitude ?? 0; this.latitude = data.latitude ?? 0;
@@ -48,6 +49,9 @@ export class Position implements IPosition {
this.symbol = new Symbol(data.symbol.table, data.symbol.code); this.symbol = new Symbol(data.symbol.table, data.symbol.code);
} }
this.comment = data.comment; this.comment = data.comment;
this.range = data.range;
this.phg = data.phg;
this.dfs = data.dfs;
} }
public toString(): string { public toString(): string {
@@ -72,3 +76,5 @@ export class Position implements IPosition {
return R * c; // Distance in meters return R * c; // Distance in meters
} }
} }
export default Position;

189
src/timestamp.ts Normal file
View File

@@ -0,0 +1,189 @@
import { FieldType, Segment } from "@hamradio/packet";
import { ITimestamp } from "./frame.types";
export class Timestamp implements ITimestamp {
day?: number;
month?: number;
hours: number;
minutes: number;
seconds?: number;
format: "DHM" | "HMS" | "MDHM";
zulu?: boolean;
constructor(
hours: number,
minutes: number,
format: "DHM" | "HMS" | "MDHM",
options: {
day?: number;
month?: number;
seconds?: number;
zulu?: boolean;
} = {}
) {
this.hours = hours;
this.minutes = minutes;
this.format = format;
this.day = options.day;
this.month = options.month;
this.seconds = options.seconds;
this.zulu = options.zulu;
}
/**
* Convert APRS timestamp to JavaScript Date object
* Note: APRS timestamps don't include year, so we use current year
* For DHM format, we find the most recent occurrence of that day
* For HMS format, we use current date
* For MDHM format, we use the specified month/day in current year
*/
toDate(): Date {
const now = new Date();
if (this.format === "DHM") {
// Day-Hour-Minute format (UTC)
// Find the most recent occurrence of this day
const currentYear = this.zulu ? now.getUTCFullYear() : now.getFullYear();
const currentMonth = this.zulu ? now.getUTCMonth() : now.getMonth();
let date: Date;
if (this.zulu) {
date = new Date(Date.UTC(currentYear, currentMonth, this.day!, this.hours, this.minutes, 0, 0));
} else {
date = new Date(currentYear, currentMonth, this.day!, this.hours, this.minutes, 0, 0);
}
// If the date is in the future, it's from last month
if (date > now) {
if (this.zulu) {
date = new Date(Date.UTC(currentYear, currentMonth - 1, this.day!, this.hours, this.minutes, 0, 0));
} else {
date = new Date(currentYear, currentMonth - 1, this.day!, this.hours, this.minutes, 0, 0);
}
}
return date;
} else if (this.format === "HMS") {
// Hour-Minute-Second format (UTC)
// Use current date
if (this.zulu) {
const date = new Date();
date.setUTCHours(this.hours, this.minutes, this.seconds || 0, 0);
// If time is in the future, it's from yesterday
if (date > now) {
date.setUTCDate(date.getUTCDate() - 1);
}
return date;
} else {
const date = new Date();
date.setHours(this.hours, this.minutes, this.seconds || 0, 0);
if (date > now) {
date.setDate(date.getDate() - 1);
}
return date;
}
} else {
// MDHM format: Month-Day-Hour-Minute (local time)
const currentYear = now.getFullYear();
let date = new Date(currentYear, (this.month || 1) - 1, this.day!, this.hours, this.minutes, 0, 0);
// If date is in the future, it's from last year
if (date > now) {
date = new Date(currentYear - 1, (this.month || 1) - 1, this.day!, this.hours, this.minutes, 0, 0);
}
return date;
}
}
static fromString(
str: string,
withStructure: boolean = false
): {
timestamp: Timestamp | undefined;
segment?: Segment;
} {
if (str.length !== 7) return { timestamp: undefined };
const timeType = str.charAt(6);
if (timeType === "z") {
// DHM format: Day-Hour-Minute (UTC)
const timestamp = new Timestamp(parseInt(str.substring(2, 4), 10), parseInt(str.substring(4, 6), 10), "DHM", {
day: parseInt(str.substring(0, 2), 10),
zulu: true
});
const segment = withStructure
? {
name: "timestamp",
data: new TextEncoder().encode(str).buffer,
isString: true,
fields: [
{ type: FieldType.STRING, name: "day (DD)", length: 2 },
{ type: FieldType.STRING, name: "hour (HH)", length: 2 },
{ type: FieldType.STRING, name: "minute (MM)", length: 2 },
{ type: FieldType.CHAR, name: "timezone indicator", length: 1 }
]
}
: undefined;
return { timestamp, segment };
} else if (timeType === "h") {
// HMS format: Hour-Minute-Second (UTC)
const timestamp = new Timestamp(parseInt(str.substring(0, 2), 10), parseInt(str.substring(2, 4), 10), "HMS", {
seconds: parseInt(str.substring(4, 6), 10),
zulu: true
});
const segment = withStructure
? {
name: "timestamp",
data: new TextEncoder().encode(str).buffer,
isString: true,
fields: [
{ type: FieldType.STRING, name: "hour (HH)", length: 2 },
{ type: FieldType.STRING, name: "minute (MM)", length: 2 },
{ type: FieldType.STRING, name: "second (SS)", length: 2 },
{ type: FieldType.CHAR, name: "timezone indicator", length: 1 }
]
}
: undefined;
return { timestamp, segment };
} else if (timeType === "/") {
// MDHM format: Month-Day-Hour-Minute (local)
const timestamp = new Timestamp(parseInt(str.substring(4, 6), 10), parseInt(str.substring(6, 8), 10), "MDHM", {
month: parseInt(str.substring(0, 2), 10),
day: parseInt(str.substring(2, 4), 10),
zulu: false
});
const segment = withStructure
? {
name: "timestamp",
data: new TextEncoder().encode(str).buffer,
isString: true,
fields: [
{ type: FieldType.STRING, name: "month (MM)", length: 2 },
{ type: FieldType.STRING, name: "day (DD)", length: 2 },
{ type: FieldType.STRING, name: "hour (HH)", length: 2 },
{ type: FieldType.STRING, name: "minute (MM)", length: 2 },
{ type: FieldType.CHAR, name: "timezone indicator", length: 1 }
]
}
: undefined;
return { timestamp, segment };
}
return { timestamp: undefined };
}
}
export default Timestamp;

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

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

View File

@@ -1,14 +1,18 @@
import { describe, expect, it } from "vitest";
import { Address, Frame, Timestamp } from "../src/frame";
import type {
Payload,
PositionPayload,
ObjectPayload,
StatusPayload,
ITimestamp,
MessagePayload,
} from "../src/frame.types";
import { Dissected, FieldType } from "@hamradio/packet"; import { Dissected, FieldType } from "@hamradio/packet";
import { describe, expect, it } from "vitest";
import { Address, Frame } from "../src/frame";
import {
DataType,
type ITimestamp,
type MessagePayload,
MicEPayload,
type ObjectPayload,
type Payload,
type PositionPayload,
type StatusPayload
} from "../src/frame.types";
import Timestamp from "../src/timestamp";
// Address parsing: split by method // Address parsing: split by method
describe("Address.parse", () => { describe("Address.parse", () => {
@@ -47,8 +51,7 @@ describe("Frame.constructor", () => {
// Frame properties / instance methods // Frame properties / instance methods
describe("Frame.getDataTypeIdentifier", () => { describe("Frame.getDataTypeIdentifier", () => {
it("returns @ for position identifier", () => { it("returns @ for position identifier", () => {
const data = const data = 'NOCALL-1>APRS,WIDE1-1:@092345z/:*E";qZ=OMRC/A=088132Hello World!';
'NOCALL-1>APRS,WIDE1-1:@092345z/:*E";qZ=OMRC/A=088132Hello World!';
const frame = Frame.fromString(data); const frame = Frame.fromString(data);
expect(frame.getDataTypeIdentifier()).toBe("@"); expect(frame.getDataTypeIdentifier()).toBe("@");
}); });
@@ -74,12 +77,11 @@ describe("Frame.getDataTypeIdentifier", () => {
describe("Frame.decode (basic)", () => { describe("Frame.decode (basic)", () => {
it("should call decode and return position payload", () => { it("should call decode and return position payload", () => {
const data = const data = 'NOCALL-1>APRS,WIDE1-1:@092345z/:*E";qZ=OMRC/A=088132Hello World!';
'NOCALL-1>APRS,WIDE1-1:@092345z/:*E";qZ=OMRC/A=088132Hello World!';
const frame = Frame.fromString(data); const frame = Frame.fromString(data);
const decoded = frame.decode() as PositionPayload; const decoded = frame.decode() as PositionPayload;
expect(decoded).not.toBeNull(); expect(decoded).not.toBeNull();
expect(decoded?.type).toBe("position"); expect(decoded?.type).toBe(DataType.PositionWithTimestampWithMessaging);
}); });
it("should handle various data type identifiers without throwing", () => { it("should handle various data type identifiers without throwing", () => {
@@ -97,7 +99,7 @@ describe("Frame.decode (basic)", () => {
{ data: "CALL>APRS:$GPRMC,...", type: "$" }, { data: "CALL>APRS:$GPRMC,...", type: "$" },
{ data: "CALL>APRS:<IGATE,MSG_CNT", type: "<" }, { data: "CALL>APRS:<IGATE,MSG_CNT", type: "<" },
{ data: "CALL>APRS:{01", type: "{" }, { data: "CALL>APRS:{01", type: "{" },
{ data: "CALL>APRS:}W1AW>APRS:test", type: "}" }, { data: "CALL>APRS:}W1AW>APRS:test", type: "}" }
]; ];
for (const testCase of testCases) { for (const testCase of testCases) {
const frame = Frame.fromString(testCase.data); const frame = Frame.fromString(testCase.data);
@@ -110,53 +112,70 @@ describe("Frame.decode (basic)", () => {
// Static functions // Static functions
describe("Frame.fromString", () => { describe("Frame.fromString", () => {
it("parses APRS position frame (test vector 1)", () => { it("parses APRS position frame (test vector 1)", () => {
const data = const data = 'NOCALL-1>APRS,WIDE1-1:@092345z/:*E";qZ=OMRC/A=088132Hello World!';
'NOCALL-1>APRS,WIDE1-1:@092345z/:*E";qZ=OMRC/A=088132Hello World!';
const result = Frame.fromString(data); const result = Frame.fromString(data);
expect(result.source).toEqual({ expect(result.source).toEqual({
call: "NOCALL", call: "NOCALL",
ssid: "1", ssid: "1",
isRepeated: false, isRepeated: false
}); });
expect(result.destination).toEqual({ expect(result.destination).toEqual({
call: "APRS", call: "APRS",
ssid: "", ssid: "",
isRepeated: false, isRepeated: false
}); });
expect(result.path).toHaveLength(1); expect(result.path).toHaveLength(1);
expect(result.path[0]).toEqual({ expect(result.path[0]).toEqual({
call: "WIDE1", call: "WIDE1",
ssid: "1", ssid: "1",
isRepeated: false, isRepeated: false
}); });
expect(result.payload).toBe('@092345z/:*E";qZ=OMRC/A=088132Hello World!'); expect(result.payload).toBe('@092345z/:*E";qZ=OMRC/A=088132Hello World!');
}); });
it("parses APRS position frame without messaging (test vector 3)", () => {
const data = "N0CALL-7>APLRT1,qAO,DG2EAZ-10:!/4CkRP&-V>76Q";
const result = Frame.fromString(data);
expect(result.source).toEqual({
call: "N0CALL",
ssid: "7",
isRepeated: false
});
expect(result.destination).toEqual({
call: "APLRT1",
ssid: "",
isRepeated: false
});
expect(result.path).toHaveLength(2);
const payload = result.decode(false);
expect(payload).not.toBeNull();
});
it("parses APRS Mic-E frame with repeated digipeater (test vector 2)", () => { it("parses APRS Mic-E frame with repeated digipeater (test vector 2)", () => {
const data = "N83MZ>T2TQ5U,WA1PLE-4*:`c.l+@&'/'\"G:} KJ6TMS|!:&0'p|!w#f!|3"; const data = "N83MZ>T2TQ5U,WA1PLE-4*:`c.l+@&'/'\"G:} KJ6TMS|!:&0'p|!w#f!|3";
const result = Frame.fromString(data); const result = Frame.fromString(data);
expect(result.source).toEqual({ expect(result.source).toEqual({
call: "N83MZ", call: "N83MZ",
ssid: "", ssid: "",
isRepeated: false, isRepeated: false
}); });
expect(result.destination).toEqual({ expect(result.destination).toEqual({
call: "T2TQ5U", call: "T2TQ5U",
ssid: "", ssid: "",
isRepeated: false, isRepeated: false
}); });
expect(result.path).toHaveLength(1); expect(result.path).toHaveLength(1);
expect(result.path[0]).toEqual({ expect(result.path[0]).toEqual({
call: "WA1PLE", call: "WA1PLE",
ssid: "4", ssid: "4",
isRepeated: true, isRepeated: true
}); });
expect(result.payload).toBe("`c.l+@&'/'\"G:} KJ6TMS|!:&0'p|!w#f!|3"); expect(result.payload).toBe("`c.l+@&'/'\"G:} KJ6TMS|!:&0'p|!w#f!|3");
}); });
it("parses frame with multiple path elements", () => { it("parses frame with multiple path elements", () => {
const data = const data = "KB1ABC-5>APRS,WIDE1-1,WIDE2-2*,IGATE:!4903.50N/07201.75W-Test";
"KB1ABC-5>APRS,WIDE1-1,WIDE2-2*,IGATE:!4903.50N/07201.75W-Test";
const result = Frame.fromString(data); const result = Frame.fromString(data);
expect(result.source.call).toBe("KB1ABC"); expect(result.source.call).toBe("KB1ABC");
expect(result.path).toHaveLength(3); expect(result.path).toHaveLength(3);
@@ -172,16 +191,12 @@ describe("Frame.fromString", () => {
it("throws for frame without route separator", () => { it("throws for frame without route separator", () => {
const data = "NOCALL-1>APRS"; const data = "NOCALL-1>APRS";
expect(() => Frame.fromString(data)).toThrow( expect(() => Frame.fromString(data)).toThrow("APRS: invalid frame, no route separator found");
"APRS: invalid frame, no route separator found",
);
}); });
it("throws for frame with invalid addresses", () => { it("throws for frame with invalid addresses", () => {
const data = "NOCALL:payload"; const data = "NOCALL:payload";
expect(() => Frame.fromString(data)).toThrow( expect(() => Frame.fromString(data)).toThrow("APRS: invalid addresses in route");
"APRS: invalid addresses in route",
);
}); });
}); });
@@ -217,17 +232,17 @@ describe("Frame.decodeMicE", () => {
it("decodes a basic Mic-E packet (current format)", () => { it("decodes a basic Mic-E packet (current format)", () => {
const data = "N83MZ>T2TQ5U,WA1PLE-4*:`c.l+@&'/'\"G:} KJ6TMS|!:&0'p|!w#f!|3"; const data = "N83MZ>T2TQ5U,WA1PLE-4*:`c.l+@&'/'\"G:} KJ6TMS|!:&0'p|!w#f!|3";
const frame = Frame.fromString(data); const frame = Frame.fromString(data);
const decoded = frame.decode() as Payload; const decoded = frame.decode() as MicEPayload;
expect(decoded).not.toBeNull(); expect(decoded).not.toBeNull();
expect(decoded?.type).toBe("position"); expect(decoded?.type).toBe(DataType.MicE);
}); });
it("decodes a Mic-E packet with old format (single quote)", () => { it("decodes a Mic-E packet with old format (single quote)", () => {
const data = "CALL>T2TQ5U:'c.l+@&'/'\"G:}"; const data = "CALL>T2TQ5U:'c.l+@&'/'\"G:}";
const frame = Frame.fromString(data); const frame = Frame.fromString(data);
const decoded = frame.decode() as Payload; const decoded = frame.decode() as MicEPayload;
expect(decoded).not.toBeNull(); expect(decoded).not.toBeNull();
expect(decoded?.type).toBe("position"); expect(decoded?.type).toBe(DataType.MicEOld);
}); });
}); });
@@ -237,7 +252,7 @@ describe("Frame.decodeMessage", () => {
const frame = Frame.fromString(raw); const frame = Frame.fromString(raw);
const decoded = frame.decode() as MessagePayload; const decoded = frame.decode() as MessagePayload;
expect(decoded).not.toBeNull(); expect(decoded).not.toBeNull();
expect(decoded?.type).toBe("message"); expect(decoded?.type).toBe(DataType.Message);
expect(decoded?.addressee).toBe("KB1ABC-5"); expect(decoded?.addressee).toBe("KB1ABC-5");
expect(decoded?.text).toBe("Hello World"); expect(decoded?.text).toBe("Hello World");
}); });
@@ -250,14 +265,12 @@ describe("Frame.decodeMessage", () => {
structure: Dissected; structure: Dissected;
}; };
expect(res.payload).not.toBeNull(); expect(res.payload).not.toBeNull();
expect(res.payload?.type).toBe("message"); expect(res.payload?.type).toBe(DataType.Message);
const recipientSection = res.structure.find((s) => s.name === "recipient"); const recipientSection = res.structure.find((s) => s.name === "recipient");
const textSection = res.structure.find((s) => s.name === "text"); const textSection = res.structure.find((s) => s.name === "text");
expect(recipientSection).toBeDefined(); expect(recipientSection).toBeDefined();
expect(textSection).toBeDefined(); expect(textSection).toBeDefined();
expect(new TextDecoder().decode(recipientSection!.data).trim()).toBe( expect(new TextDecoder().decode(recipientSection!.data).trim()).toBe("KB1ABC");
"KB1ABC",
);
expect(new TextDecoder().decode(textSection!.data)).toBe("Test via spec"); expect(new TextDecoder().decode(textSection!.data)).toBe("Test via spec");
}); });
}); });
@@ -266,10 +279,10 @@ describe("Frame.decodeObject", () => {
it("decodes object payload with uncompressed position", () => { it("decodes object payload with uncompressed position", () => {
const data = "CALL>APRS:;OBJECT *092345z4903.50N/07201.75W>Test object"; const data = "CALL>APRS:;OBJECT *092345z4903.50N/07201.75W>Test object";
const frame = Frame.fromString(data); const frame = Frame.fromString(data);
const decoded = frame.decode() as Payload; const decoded = frame.decode() as ObjectPayload;
expect(decoded).not.toBeNull(); expect(decoded).not.toBeNull();
expect(decoded?.type).toBe("object"); expect(decoded?.type).toBe(DataType.Object);
if (decoded && decoded.type === "object") { if (decoded && decoded.type === DataType.Object) {
expect(decoded.name).toBe("OBJECT"); expect(decoded.name).toBe("OBJECT");
expect(decoded.alive).toBe(true); expect(decoded.alive).toBe(true);
} }
@@ -289,12 +302,11 @@ describe("Frame.decodeObject", () => {
describe("Frame.decodePosition", () => { describe("Frame.decodePosition", () => {
it("decodes position with timestamp and compressed format", () => { it("decodes position with timestamp and compressed format", () => {
const data = const data = 'NOCALL-1>APRS,WIDE1-1:@092345z/:*E";qZ=OMRC/A=088132Hello World!';
'NOCALL-1>APRS,WIDE1-1:@092345z/:*E";qZ=OMRC/A=088132Hello World!';
const frame = Frame.fromString(data); const frame = Frame.fromString(data);
const decoded = frame.decode() as PositionPayload; const decoded = frame.decode() as PositionPayload;
expect(decoded).not.toBeNull(); expect(decoded).not.toBeNull();
expect(decoded?.type).toBe("position"); expect(decoded?.type).toBe(DataType.PositionWithTimestampWithMessaging);
}); });
it("decodes uncompressed position without timestamp", () => { it("decodes uncompressed position without timestamp", () => {
@@ -302,7 +314,7 @@ describe("Frame.decodePosition", () => {
const frame = Frame.fromString(data); const frame = Frame.fromString(data);
const decoded = frame.decode() as PositionPayload; const decoded = frame.decode() as PositionPayload;
expect(decoded).not.toBeNull(); expect(decoded).not.toBeNull();
expect(decoded?.type).toBe("position"); expect(decoded?.type).toBe(DataType.PositionNoTimestampNoMessaging);
}); });
it("handles ambiguity masking in position", () => { it("handles ambiguity masking in position", () => {
@@ -311,6 +323,16 @@ describe("Frame.decodePosition", () => {
const decoded = frame.decode() as PositionPayload; const decoded = frame.decode() as PositionPayload;
expect(decoded).not.toBeNull(); expect(decoded).not.toBeNull();
}); });
it("should handle UTF-8 characters", () => {
const data =
"WB2OSZ-5>APDW17:!4237.14NS07120.83W#PHG7140 Did you know that APRS comments and messages can contain UTF-8 characters? アマチュア無線";
const frame = Frame.fromString(data);
const decoded = frame.decode() as PositionPayload;
expect(decoded).not.toBeNull();
expect(decoded?.type).toBe(DataType.PositionNoTimestampNoMessaging);
expect(decoded?.position.comment).toContain("UTF-8 characters? アマチュア無線");
});
}); });
describe("Frame.decodeStatus", () => { describe("Frame.decodeStatus", () => {
@@ -322,8 +344,7 @@ describe("Frame.decodeStatus", () => {
structure: Dissected; structure: Dissected;
}; };
expect(res.payload).not.toBeNull(); expect(res.payload).not.toBeNull();
if (res.payload?.type !== "status") if (res.payload?.type !== DataType.Status) throw new Error("expected status payload");
throw new Error("expected status payload");
const payload = res.payload as StatusPayload & { timestamp?: ITimestamp }; const payload = res.payload as StatusPayload & { timestamp?: ITimestamp };
expect(payload.text).toBe("Testing status"); expect(payload.text).toBe("Testing status");
expect(payload.maidenhead).toBe("FN20"); expect(payload.maidenhead).toBe("FN20");
@@ -349,7 +370,7 @@ describe("Frame.decode (sections)", () => {
payload: PositionPayload | null; payload: PositionPayload | null;
structure: Dissected; structure: Dissected;
}; };
expect(result.payload?.type).toBe("position"); expect(result.payload?.type).toBe(DataType.PositionNoTimestampNoMessaging);
}); });
}); });
@@ -421,7 +442,7 @@ describe("Timestamp.toDate", () => {
if (futureHours < 24) { if (futureHours < 24) {
const ts = new Timestamp(futureHours, 0, "HMS", { const ts = new Timestamp(futureHours, 0, "HMS", {
seconds: 0, seconds: 0,
zulu: true, zulu: true
}); });
const date = ts.toDate(); const date = ts.toDate();
@@ -438,7 +459,7 @@ describe("Timestamp.toDate", () => {
const ts = new Timestamp(12, 0, "MDHM", { const ts = new Timestamp(12, 0, "MDHM", {
month: futureMonth + 1, month: futureMonth + 1,
day: 1, day: 1,
zulu: false, zulu: false
}); });
const date = ts.toDate(); const date = ts.toDate();
@@ -453,32 +474,29 @@ describe("Timestamp.toDate", () => {
describe("Frame.decodeMicE", () => { describe("Frame.decodeMicE", () => {
describe("Basic Mic-E frames", () => { describe("Basic Mic-E frames", () => {
it("should decode a basic Mic-E packet (current format)", () => { it("should decode a basic Mic-E packet (current format)", () => {
const data = const data = "N83MZ>T2TQ5U,WA1PLE-4*:`c.l+@&'/'\"G:} KJ6TMS|!:&0'p|!w#f!|3";
"N83MZ>T2TQ5U,WA1PLE-4*:`c.l+@&'/'\"G:} KJ6TMS|!:&0'p|!w#f!|3";
const frame = Frame.fromString(data); const frame = Frame.fromString(data);
const decoded = frame.decode() as Payload; const decoded = frame.decode() as MicEPayload;
expect(decoded).not.toBeNull(); expect(decoded).not.toBeNull();
expect(decoded?.type).toBe("position"); expect(decoded?.type).toBe(DataType.MicE);
if (decoded && decoded.type === "position") { if (decoded && decoded.type === DataType.MicE) {
expect(decoded.messaging).toBe(true);
expect(decoded.position).toBeDefined(); expect(decoded.position).toBeDefined();
expect(typeof decoded.position.latitude).toBe("number"); expect(typeof decoded.position.latitude).toBe("number");
expect(typeof decoded.position.longitude).toBe("number"); expect(typeof decoded.position.longitude).toBe("number");
expect(decoded.position.symbol).toBeDefined(); expect(decoded.position.symbol).toBeDefined();
expect(decoded.micE).toBeDefined(); expect(decoded.messageType).toBeDefined();
expect(decoded.micE?.messageType).toBeDefined();
} }
}); });
it("should decode a Mic-E packet with old format (single quote)", () => { it("should decode a Mic-E packet with old format (single quote)", () => {
const data = "CALL>T2TQ5U:'c.l+@&'/'\"G:}"; const data = "CALL>T2TQ5U:'c.l+@&'/'\"G:}";
const frame = Frame.fromString(data); const frame = Frame.fromString(data);
const decoded = frame.decode() as Payload; const decoded = frame.decode() as MicEPayload;
expect(decoded).not.toBeNull(); expect(decoded).not.toBeNull();
expect(decoded?.type).toBe("position"); expect(decoded?.type).toBe(DataType.MicEOld);
}); });
}); });
@@ -486,11 +504,11 @@ describe("Frame.decodeMicE", () => {
it("should decode latitude from numeric digits (0-9)", () => { it("should decode latitude from numeric digits (0-9)", () => {
const data = "CALL>123456:`c.l+@&'/'\"G:}"; const data = "CALL>123456:`c.l+@&'/'\"G:}";
const frame = Frame.fromString(data); const frame = Frame.fromString(data);
const decoded = frame.decode() as Payload; const decoded = frame.decode() as MicEPayload;
expect(decoded).not.toBeNull(); expect(decoded).not.toBeNull();
if (decoded && decoded.type === "position") { if (decoded && decoded.type === DataType.MicE) {
expect(decoded.position.latitude).toBeCloseTo(12 + 34.56 / 60, 3); expect(decoded.position.latitude).toBeCloseTo(12 + 34.56 / 60, 3);
} }
}); });
@@ -498,11 +516,11 @@ describe("Frame.decodeMicE", () => {
it("should decode latitude from letter digits (A-J)", () => { it("should decode latitude from letter digits (A-J)", () => {
const data = "CALL>ABC0EF:`c.l+@&'/'\"G:}"; const data = "CALL>ABC0EF:`c.l+@&'/'\"G:}";
const frame = Frame.fromString(data); const frame = Frame.fromString(data);
const decoded = frame.decode() as Payload; const decoded = frame.decode() as MicEPayload;
expect(decoded).not.toBeNull(); expect(decoded).not.toBeNull();
if (decoded && decoded.type === "position") { if (decoded && decoded.type === DataType.MicE) {
expect(decoded.position.latitude).toBeCloseTo(1 + 20.45 / 60, 3); expect(decoded.position.latitude).toBeCloseTo(1 + 20.45 / 60, 3);
} }
}); });
@@ -510,11 +528,11 @@ describe("Frame.decodeMicE", () => {
it("should decode latitude with mixed digits and letters", () => { it("should decode latitude with mixed digits and letters", () => {
const data = "CALL>4AB2DE:`c.l+@&'/'\"G:}"; const data = "CALL>4AB2DE:`c.l+@&'/'\"G:}";
const frame = Frame.fromString(data); const frame = Frame.fromString(data);
const decoded = frame.decode() as Payload; const decoded = frame.decode() as MicEPayload;
expect(decoded).not.toBeNull(); expect(decoded).not.toBeNull();
if (decoded && decoded.type === "position") { if (decoded && decoded.type === DataType.MicE) {
expect(decoded.position.latitude).toBeCloseTo(40 + 12.34 / 60, 3); expect(decoded.position.latitude).toBeCloseTo(40 + 12.34 / 60, 3);
} }
}); });
@@ -522,11 +540,11 @@ describe("Frame.decodeMicE", () => {
it("should decode latitude for southern hemisphere", () => { it("should decode latitude for southern hemisphere", () => {
const data = "CALL>4A0P0U:`c.l+@&'/'\"G:}"; const data = "CALL>4A0P0U:`c.l+@&'/'\"G:}";
const frame = Frame.fromString(data); const frame = Frame.fromString(data);
const decoded = frame.decode() as Payload; const decoded = frame.decode() as MicEPayload;
expect(decoded).not.toBeNull(); expect(decoded).not.toBeNull();
if (decoded && decoded.type === "position") { if (decoded && decoded.type === DataType.MicE) {
expect(decoded.position.latitude).toBeLessThan(0); expect(decoded.position.latitude).toBeLessThan(0);
} }
}); });
@@ -536,11 +554,11 @@ describe("Frame.decodeMicE", () => {
it("should decode longitude from information field", () => { it("should decode longitude from information field", () => {
const data = "CALL>4ABCDE:`c.l+@&'/'\"G:}"; const data = "CALL>4ABCDE:`c.l+@&'/'\"G:}";
const frame = Frame.fromString(data); const frame = Frame.fromString(data);
const decoded = frame.decode() as Payload; const decoded = frame.decode() as MicEPayload;
expect(decoded).not.toBeNull(); expect(decoded).not.toBeNull();
if (decoded && decoded.type === "position") { if (decoded && decoded.type === DataType.MicE) {
expect(typeof decoded.position.longitude).toBe("number"); expect(typeof decoded.position.longitude).toBe("number");
expect(decoded.position.longitude).toBeGreaterThanOrEqual(-180); expect(decoded.position.longitude).toBeGreaterThanOrEqual(-180);
expect(decoded.position.longitude).toBeLessThanOrEqual(180); expect(decoded.position.longitude).toBeLessThanOrEqual(180);
@@ -550,11 +568,11 @@ describe("Frame.decodeMicE", () => {
it("should handle eastern hemisphere longitude", () => { it("should handle eastern hemisphere longitude", () => {
const data = "CALL>4ABPDE:`c.l+@&'/'\"G:}"; const data = "CALL>4ABPDE:`c.l+@&'/'\"G:}";
const frame = Frame.fromString(data); const frame = Frame.fromString(data);
const decoded = frame.decode() as Payload; const decoded = frame.decode() as MicEPayload;
expect(decoded).not.toBeNull(); expect(decoded).not.toBeNull();
if (decoded && decoded.type === "position") { if (decoded && decoded.type === DataType.MicE) {
expect(typeof decoded.position.longitude).toBe("number"); expect(typeof decoded.position.longitude).toBe("number");
} }
}); });
@@ -562,11 +580,11 @@ describe("Frame.decodeMicE", () => {
it("should handle longitude offset +100", () => { it("should handle longitude offset +100", () => {
const data = "CALL>4ABCDP:`c.l+@&'/'\"G:}"; const data = "CALL>4ABCDP:`c.l+@&'/'\"G:}";
const frame = Frame.fromString(data); const frame = Frame.fromString(data);
const decoded = frame.decode() as Payload; const decoded = frame.decode() as MicEPayload;
expect(decoded).not.toBeNull(); expect(decoded).not.toBeNull();
if (decoded && decoded.type === "position") { if (decoded && decoded.type === DataType.MicE) {
expect(typeof decoded.position.longitude).toBe("number"); expect(typeof decoded.position.longitude).toBe("number");
expect(Math.abs(decoded.position.longitude)).toBeGreaterThan(90); expect(Math.abs(decoded.position.longitude)).toBeGreaterThan(90);
} }
@@ -581,7 +599,7 @@ describe("Frame.decodeMicE", () => {
expect(decoded).not.toBeNull(); expect(decoded).not.toBeNull();
if (decoded && decoded.type === "position") { if (decoded && decoded.type === DataType.MicE) {
if (decoded.position.speed !== undefined) { if (decoded.position.speed !== undefined) {
expect(decoded.position.speed).toBeGreaterThanOrEqual(0); expect(decoded.position.speed).toBeGreaterThanOrEqual(0);
expect(typeof decoded.position.speed).toBe("number"); expect(typeof decoded.position.speed).toBe("number");
@@ -596,7 +614,7 @@ describe("Frame.decodeMicE", () => {
expect(decoded).not.toBeNull(); expect(decoded).not.toBeNull();
if (decoded && decoded.type === "position") { if (decoded && decoded.type === DataType.MicE) {
if (decoded.position.course !== undefined) { if (decoded.position.course !== undefined) {
expect(decoded.position.course).toBeGreaterThanOrEqual(0); expect(decoded.position.course).toBeGreaterThanOrEqual(0);
expect(decoded.position.course).toBeLessThan(360); expect(decoded.position.course).toBeLessThan(360);
@@ -611,7 +629,7 @@ describe("Frame.decodeMicE", () => {
expect(decoded).not.toBeNull(); expect(decoded).not.toBeNull();
if (decoded && decoded.type === "position") { if (decoded && decoded.type === DataType.MicE) {
expect(decoded.position.speed).toBeUndefined(); expect(decoded.position.speed).toBeUndefined();
} }
}); });
@@ -619,11 +637,11 @@ describe("Frame.decodeMicE", () => {
it("should not include zero or 360+ course in result", () => { it("should not include zero or 360+ course in result", () => {
const data = "CALL>4ABCDE:`c.l\x1c\x1c\x1c/>}"; const data = "CALL>4ABCDE:`c.l\x1c\x1c\x1c/>}";
const frame = Frame.fromString(data); const frame = Frame.fromString(data);
const decoded = frame.decode() as Payload; const decoded = frame.decode() as MicEPayload;
expect(decoded).not.toBeNull(); expect(decoded).not.toBeNull();
if (decoded && decoded.type === "position") { if (decoded && decoded.type === DataType.MicE) {
if (decoded.position.course !== undefined) { if (decoded.position.course !== undefined) {
expect(decoded.position.course).toBeGreaterThan(0); expect(decoded.position.course).toBeGreaterThan(0);
expect(decoded.position.course).toBeLessThan(360); expect(decoded.position.course).toBeLessThan(360);
@@ -640,7 +658,7 @@ describe("Frame.decodeMicE", () => {
expect(decoded).not.toBeNull(); expect(decoded).not.toBeNull();
if (decoded && decoded.type === "position") { if (decoded && decoded.type === DataType.MicE) {
expect(decoded.position.symbol).toBeDefined(); expect(decoded.position.symbol).toBeDefined();
expect(decoded.position.symbol?.table).toBeDefined(); expect(decoded.position.symbol?.table).toBeDefined();
expect(decoded.position.symbol?.code).toBeDefined(); expect(decoded.position.symbol?.code).toBeDefined();
@@ -652,39 +670,40 @@ describe("Frame.decodeMicE", () => {
describe("Altitude decoding", () => { describe("Altitude decoding", () => {
it("should decode altitude from /A=NNNNNN format", () => { it("should decode altitude from /A=NNNNNN format", () => {
const data = "CALL>4ABCDE:`c.l+@&'/'\"G:}/A=001234"; const data = "CALL>4ABCDE:`c.l+@&'//A=001234";
const frame = Frame.fromString(data); const frame = Frame.fromString(data);
const decoded = frame.decode() as Payload; const decoded = frame.decode() as MicEPayload;
expect(decoded).not.toBeNull(); expect(decoded).not.toBeNull();
expect(decoded.type).toBe(DataType.MicE);
if (decoded && decoded.type === "position") { expect(decoded.position).toBeDefined();
expect(decoded.position.altitude).toBeDefined();
expect(decoded.position.altitude).toBeCloseTo(1234 * 0.3048, 1); expect(decoded.position.altitude).toBeCloseTo(1234 * 0.3048, 1);
}
}); });
it("should decode altitude from base-91 format }abc", () => { it("should decode altitude from base-91 format abc}", () => {
const data = "CALL>4AB2DE:`c.l+@&'/'\"G:}}S^X"; const data = "N83MZ>T2TQ5U,WA1PLE-4*:`c.l+@&'/\"4T}KJ6TMS";
const frame = Frame.fromString(data); const frame = Frame.fromString(data);
const decoded = frame.decode() as Payload; const decoded = frame.decode() as MicEPayload;
expect(decoded).not.toBeNull(); expect(decoded).not.toBeNull();
expect(decoded.type).toBe(DataType.MicE);
if (decoded && decoded.type === "position") { expect(decoded.position).toBeDefined();
if (decoded.position.comment?.startsWith("}")) {
expect(decoded.position.altitude).toBeDefined(); expect(decoded.position.altitude).toBeDefined();
} expect(decoded.position.comment).toBe("KJ6TMS");
} expect(decoded.position.altitude).toBeCloseTo(61, 1);
}); });
it("should prefer /A= format over base-91 when both present", () => { it("should prefer /A= format over base-91 when both present", () => {
const data = "CALL>4ABCDE:`c.l+@&'/'\"G:}}!!!/A=005000"; const data = "CALL>4ABCDE:`c.l+@&'/'\"G:}}!!!/A=005000";
const frame = Frame.fromString(data); const frame = Frame.fromString(data);
const decoded = frame.decode() as Payload; const decoded = frame.decode() as MicEPayload;
expect(decoded).not.toBeNull(); expect(decoded).not.toBeNull();
if (decoded && decoded.type === "position") { if (decoded && decoded.type === DataType.MicE) {
expect(decoded.position.altitude).toBeCloseTo(5000 * 0.3048, 1); expect(decoded.position.altitude).toBeCloseTo(5000 * 0.3048, 1);
} }
}); });
@@ -692,11 +711,11 @@ describe("Frame.decodeMicE", () => {
it("should handle comment without altitude", () => { it("should handle comment without altitude", () => {
const data = "CALL>4ABCDE:`c.l+@&'/'\"G:}Just a comment"; const data = "CALL>4ABCDE:`c.l+@&'/'\"G:}Just a comment";
const frame = Frame.fromString(data); const frame = Frame.fromString(data);
const decoded = frame.decode() as Payload; const decoded = frame.decode() as MicEPayload;
expect(decoded).not.toBeNull(); expect(decoded).not.toBeNull();
if (decoded && decoded.type === "position") { if (decoded && decoded.type === DataType.MicE) {
expect(decoded.position.altitude).toBeUndefined(); expect(decoded.position.altitude).toBeUndefined();
expect(decoded.position.comment).toContain("Just a comment"); expect(decoded.position.comment).toContain("Just a comment");
} }
@@ -707,39 +726,34 @@ describe("Frame.decodeMicE", () => {
it("should decode message type M0 (Off Duty)", () => { it("should decode message type M0 (Off Duty)", () => {
const data = "CALL>012345:`c.l+@&'/'\"G:}"; const data = "CALL>012345:`c.l+@&'/'\"G:}";
const frame = Frame.fromString(data); const frame = Frame.fromString(data);
const decoded = frame.decode() as Payload; const decoded = frame.decode() as MicEPayload;
expect(decoded).not.toBeNull(); expect(decoded).not.toBeNull();
if (decoded && decoded.type === "position") { if (decoded && decoded.type === DataType.MicE) {
expect(decoded.micE?.messageType).toBe("M0: Off Duty"); expect(decoded.messageType).toBe("M0: Off Duty");
} }
}); });
it("should decode message type M7 (Emergency)", () => { it("should decode message type M7 (Emergency)", () => {
const data = "CALL>ABCDEF:`c.l+@&'/'\"G:}"; const data = "CALL>ABCDEF:`c.l+@&'/'\"G:}";
const frame = Frame.fromString(data); const frame = Frame.fromString(data);
const decoded = frame.decode() as Payload; const decoded = frame.decode() as MicEPayload;
expect(decoded).not.toBeNull(); expect(decoded).not.toBeNull();
if (decoded && decoded.type === "position") { if (decoded && decoded.type === DataType.MicE) {
expect(decoded.micE?.messageType).toBeDefined(); expect(decoded.messageType).toBeDefined();
expect(typeof decoded.micE?.messageType).toBe("string"); expect(typeof decoded.messageType).toBe("string");
} }
}); });
it("should decode standard vs custom message indicator", () => { it("should decode standard vs custom message indicator", () => {
const data = "CALL>ABCDEF:`c.l+@&'/'\"G:}"; const data = "CALL>ABCDEF:`c.l+@&'/'\"G:}";
const frame = Frame.fromString(data); const frame = Frame.fromString(data);
const decoded = frame.decode() as Payload; const decoded = frame.decode() as MicEPayload;
expect(decoded).not.toBeNull(); expect(decoded).not.toBeNull();
if (decoded && decoded.type === "position") {
expect(decoded.micE?.isStandard).toBeDefined();
expect(typeof decoded.micE?.isStandard).toBe("boolean");
}
}); });
}); });
@@ -751,7 +765,7 @@ describe("Frame.decodeMicE", () => {
expect(decoded).not.toBeNull(); expect(decoded).not.toBeNull();
if (decoded && decoded.type === "position") { if (decoded && decoded.type === DataType.MicE) {
expect(decoded.position.comment).toContain("This is a test comment"); expect(decoded.position.comment).toContain("This is a test comment");
} }
}); });
@@ -763,7 +777,7 @@ describe("Frame.decodeMicE", () => {
expect(decoded).not.toBeNull(); expect(decoded).not.toBeNull();
if (decoded && decoded.type === "position") { if (decoded && decoded.type === DataType.MicE) {
expect(decoded.position.comment).toBeDefined(); expect(decoded.position.comment).toBeDefined();
} }
}); });
@@ -799,47 +813,30 @@ describe("Frame.decodeMicE", () => {
const frame = Frame.fromString(data); const frame = Frame.fromString(data);
expect(() => frame.decode()).not.toThrow(); expect(() => frame.decode()).not.toThrow();
const decoded = frame.decode() as Payload; const decoded = frame.decode() as MicEPayload;
expect(decoded === null || decoded?.type === "position").toBe(true); expect(decoded === null || decoded?.type === DataType.MicE).toBe(true);
}); });
}); });
describe("Real-world test vectors", () => { describe("Real-world test vectors", () => {
it("should decode real Mic-E packet from test vector 2", () => { it("should decode real Mic-E packet from test vector 2", () => {
const data = const data = "N83MZ>T2TQ5U,WA1PLE-4*:`c.l+@&'/'\"G:} KJ6TMS|!:&0'p|!w#f!|3";
"N83MZ>T2TQ5U,WA1PLE-4*:`c.l+@&'/'\"G:} KJ6TMS|!:&0'p|!w#f!|3";
const frame = Frame.fromString(data); const frame = Frame.fromString(data);
const decoded = frame.decode() as Payload; const decoded = frame.decode() as MicEPayload;
expect(decoded).not.toBeNull(); expect(decoded).not.toBeNull();
expect(decoded?.type).toBe("position"); expect(decoded?.type).toBe(DataType.MicE);
if (decoded && decoded.type === "position") { if (decoded && decoded.type === DataType.MicE) {
expect(decoded.messaging).toBe(true);
expect(decoded.position.latitude).toBeDefined(); expect(decoded.position.latitude).toBeDefined();
expect(decoded.position.longitude).toBeDefined(); expect(decoded.position.longitude).toBeDefined();
expect(decoded.position.symbol).toBeDefined(); expect(decoded.position.symbol).toBeDefined();
expect(decoded.micE).toBeDefined();
expect(Math.abs(decoded.position.latitude)).toBeLessThanOrEqual(90); expect(Math.abs(decoded.position.latitude)).toBeLessThanOrEqual(90);
expect(Math.abs(decoded.position.longitude)).toBeLessThanOrEqual(180); expect(Math.abs(decoded.position.longitude)).toBeLessThanOrEqual(180);
} }
}); });
}); });
describe("Messaging capability", () => {
it("should always set messaging to true for Mic-E", () => {
const data = "CALL>4ABCDE:`c.l+@&'/'\"G:}";
const frame = Frame.fromString(data);
const decoded = frame.decode() as Payload;
expect(decoded).not.toBeNull();
if (decoded && decoded.type === "position") {
expect(decoded.messaging).toBe(true);
}
});
});
}); });
describe("Packet dissection with sections", () => { describe("Packet dissection with sections", () => {
@@ -856,20 +853,16 @@ describe("Packet dissection with sections", () => {
expect(result.structure).toBeDefined(); expect(result.structure).toBeDefined();
expect(result.structure.length).toBeGreaterThan(0); expect(result.structure.length).toBeGreaterThan(0);
const routingSection = result.structure.find((s) => s.name === "Routing"); const routingSection = result.structure.find((s) => s.name === "routing");
expect(routingSection).toBeDefined(); expect(routingSection).toBeDefined();
expect(routingSection?.fields).toBeDefined(); expect(routingSection?.fields).toBeDefined();
expect(routingSection?.fields?.length).toBeGreaterThan(0); expect(routingSection?.fields?.length).toBeGreaterThan(0);
const sourceField = routingSection?.fields?.find( const sourceField = routingSection?.fields?.find((a) => a.name === "source address");
(a) => a.name === "Source address",
);
expect(sourceField).toBeDefined(); expect(sourceField).toBeDefined();
expect(sourceField?.length).toBeGreaterThan(0); expect(sourceField?.length).toBeGreaterThan(0);
const destField = routingSection?.fields?.find( const destField = routingSection?.fields?.find((a) => a.name === "destination address");
(a) => a.name === "Destination address",
);
expect(destField).toBeDefined(); expect(destField).toBeDefined();
expect(destField?.length).toBeGreaterThan(0); expect(destField?.length).toBeGreaterThan(0);
}); });
@@ -878,19 +871,17 @@ describe("Packet dissection with sections", () => {
const data = "CALL>APRS:!4903.50N/07201.75W-Test message"; const data = "CALL>APRS:!4903.50N/07201.75W-Test message";
const frame = Frame.fromString(data); const frame = Frame.fromString(data);
const result = frame.decode(true) as { const result = frame.decode(true) as {
payload: Payload; payload: PositionPayload;
structure: Dissected; structure: Dissected;
}; };
expect(result.payload).not.toBeNull(); expect(result.payload).not.toBeNull();
expect(result.payload?.type).toBe("position"); expect(result.payload?.type).toBe(DataType.PositionNoTimestampNoMessaging);
expect(result.structure).toBeDefined(); expect(result.structure).toBeDefined();
expect(result.structure?.length).toBeGreaterThan(0); expect(result.structure?.length).toBeGreaterThan(0);
const positionSection = result.structure?.find( const positionSection = result.structure?.find((s) => s.name === "position");
(s) => s.name === "position",
);
expect(positionSection).toBeDefined(); expect(positionSection).toBeDefined();
expect(positionSection?.data?.byteLength).toBe(19); expect(positionSection?.data?.byteLength).toBe(19);
expect(positionSection?.fields).toBeDefined(); expect(positionSection?.fields).toBeDefined();
@@ -900,10 +891,10 @@ describe("Packet dissection with sections", () => {
it("should not emit sections when emitSections is false or omitted", () => { it("should not emit sections when emitSections is false or omitted", () => {
const data = "CALL>APRS:!4903.50N/07201.75W-Test"; const data = "CALL>APRS:!4903.50N/07201.75W-Test";
const frame = Frame.fromString(data); const frame = Frame.fromString(data);
const result = frame.decode() as Payload; const result = frame.decode() as PositionPayload;
expect(result).not.toBeNull(); expect(result).not.toBeNull();
expect(result?.type).toBe("position"); expect(result?.type).toBe(DataType.PositionNoTimestampNoMessaging);
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
expect((result as any).sections).toBeUndefined(); expect((result as any).sections).toBeUndefined();
}); });
@@ -912,22 +903,20 @@ describe("Packet dissection with sections", () => {
const data = "CALL>APRS:@092345z4903.50N/07201.75W>"; const data = "CALL>APRS:@092345z4903.50N/07201.75W>";
const frame = Frame.fromString(data); const frame = Frame.fromString(data);
const result = frame.decode(true) as { const result = frame.decode(true) as {
payload: Payload; payload: PositionPayload;
structure: Dissected; structure: Dissected;
}; };
expect(result.payload?.type).toBe("position"); expect(result.payload?.type).toBe(DataType.PositionWithTimestampWithMessaging);
const timestampSection = result.structure?.find( const timestampSection = result.structure?.find((s) => s.name === "timestamp");
(s) => s.name === "timestamp",
);
expect(timestampSection).toBeDefined(); expect(timestampSection).toBeDefined();
expect(timestampSection?.data?.byteLength).toBe(7); expect(timestampSection?.data?.byteLength).toBe(7);
expect(timestampSection?.fields?.map((a) => a.name)).toEqual([ expect(timestampSection?.fields?.map((a) => a.name)).toEqual([
"day (DD)", "day (DD)",
"hour (HH)", "hour (HH)",
"minute (MM)", "minute (MM)",
"timezone indicator", "timezone indicator"
]); ]);
}); });
@@ -935,15 +924,13 @@ describe("Packet dissection with sections", () => {
const data = 'NOCALL-1>APRS:@092345z/:*E";qZ=OMRC/A=088132Hello World!'; const data = 'NOCALL-1>APRS:@092345z/:*E";qZ=OMRC/A=088132Hello World!';
const frame = Frame.fromString(data); const frame = Frame.fromString(data);
const result = frame.decode(true) as { const result = frame.decode(true) as {
payload: Payload; payload: PositionPayload;
structure: Dissected; structure: Dissected;
}; };
expect(result.payload?.type).toBe("position"); expect(result.payload?.type).toBe(DataType.PositionWithTimestampWithMessaging);
const positionSection = result.structure?.find( const positionSection = result.structure?.find((s) => s.name === "position");
(s) => s.name === "position",
);
expect(positionSection).toBeDefined(); expect(positionSection).toBeDefined();
expect(positionSection?.data?.byteLength).toBe(13); expect(positionSection?.data?.byteLength).toBe(13);
@@ -956,15 +943,15 @@ describe("Packet dissection with sections", () => {
const data = "CALL>APRS:!4903.50N/07201.75W-Test message"; const data = "CALL>APRS:!4903.50N/07201.75W-Test message";
const frame = Frame.fromString(data); const frame = Frame.fromString(data);
const result = frame.decode(true) as { const result = frame.decode(true) as {
payload: Payload; payload: PositionPayload;
structure: Dissected; structure: Dissected;
}; };
expect(result.payload?.type).toBe("position"); expect(result.payload?.type).toBe(DataType.PositionNoTimestampNoMessaging);
const commentSection = result.structure?.find((s) => s.name === "comment"); const commentSection = result.structure?.find((s) => s.name === "comment");
expect(commentSection).toBeDefined(); expect(commentSection).toBeDefined();
expect(commentSection?.data?.byteLength).toBe("Test message".length); expect(commentSection?.data?.byteLength).toBe("Test message".length);
expect(commentSection?.fields?.[0]?.name).toBe("text"); expect(commentSection?.fields?.[0]?.name).toBe("comment");
}); });
}); });
@@ -975,7 +962,7 @@ describe("Frame.decodeMessage", () => {
const decoded = frame.decode() as MessagePayload; const decoded = frame.decode() as MessagePayload;
expect(decoded).not.toBeNull(); expect(decoded).not.toBeNull();
expect(decoded?.type).toBe("message"); expect(decoded?.type).toBe(DataType.Message);
expect(decoded?.addressee).toBe("KB1ABC-5"); expect(decoded?.addressee).toBe("KB1ABC-5");
expect(decoded?.text).toBe("Hello World"); expect(decoded?.text).toBe("Hello World");
}); });
@@ -989,14 +976,12 @@ describe("Frame.decodeMessage", () => {
}; };
expect(res.payload).not.toBeNull(); expect(res.payload).not.toBeNull();
expect(res.payload.type).toBe("message"); expect(res.payload.type).toBe(DataType.Message);
const recipientSection = res.structure.find((s) => s.name === "recipient"); const recipientSection = res.structure.find((s) => s.name === "recipient");
const textSection = res.structure.find((s) => s.name === "text"); const textSection = res.structure.find((s) => s.name === "text");
expect(recipientSection).toBeDefined(); expect(recipientSection).toBeDefined();
expect(textSection).toBeDefined(); expect(textSection).toBeDefined();
expect(new TextDecoder().decode(recipientSection!.data).trim()).toBe( expect(new TextDecoder().decode(recipientSection!.data).trim()).toBe("KB1ABC");
"KB1ABC",
);
expect(new TextDecoder().decode(textSection!.data)).toBe("Test via spec"); expect(new TextDecoder().decode(textSection!.data)).toBe("Test via spec");
}); });
}); });
@@ -1013,8 +998,7 @@ describe("Frame.decoding: object and status", () => {
expect(res).toHaveProperty("payload"); expect(res).toHaveProperty("payload");
expect(res.payload).not.toBeNull(); expect(res.payload).not.toBeNull();
if (res.payload?.type !== "object") if (res.payload?.type !== DataType.Object) throw new Error("expected object payload");
throw new Error("expected object payload");
const payload = res.payload as ObjectPayload & { timestamp?: ITimestamp }; const payload = res.payload as ObjectPayload & { timestamp?: ITimestamp };
@@ -1044,12 +1028,11 @@ describe("Frame.decoding: object and status", () => {
expect(res).toHaveProperty("payload"); expect(res).toHaveProperty("payload");
expect(res.payload).not.toBeNull(); expect(res.payload).not.toBeNull();
if (res.payload?.type !== "status") if (res.payload?.type !== DataType.Status) throw new Error("expected status payload");
throw new Error("expected status payload");
const payload = res.payload as StatusPayload & { timestamp?: ITimestamp }; const payload = res.payload as StatusPayload & { timestamp?: ITimestamp };
expect(payload.type).toBe("status"); expect(payload.type).toBe(DataType.Status);
expect(payload.timestamp).toBeDefined(); expect(payload.timestamp).toBeDefined();
expect(payload.timestamp?.day).toBe(12); expect(payload.timestamp?.day).toBe(12);

View File

@@ -1,12 +1,13 @@
import { describe, it, expect } from "vitest"; import { describe, expect, it } from "vitest";
import { import {
base91ToNumber, base91ToNumber,
knotsToKmh,
kmhToKnots,
feetToMeters,
metersToFeet,
celsiusToFahrenheit, celsiusToFahrenheit,
fahrenheitToCelsius, fahrenheitToCelsius,
feetToMeters,
kmhToKnots,
knotsToKmh,
metersToFeet
} from "../src/parser"; } from "../src/parser";
describe("parser utilities", () => { describe("parser utilities", () => {

View File

@@ -1,7 +1,8 @@
import { describe, it, expect } from "vitest";
import { Frame } from "../src/frame";
import type { Payload, StationCapabilitiesPayload } from "../src/frame.types";
import { Dissected } from "@hamradio/packet"; import { Dissected } from "@hamradio/packet";
import { describe, expect, it } from "vitest";
import { Frame } from "../src/frame";
import { DataType, type Payload, type StationCapabilitiesPayload } from "../src/frame.types";
describe("Frame.decodeCapabilities", () => { describe("Frame.decodeCapabilities", () => {
it("parses comma separated capabilities", () => { it("parses comma separated capabilities", () => {
@@ -9,7 +10,7 @@ describe("Frame.decodeCapabilities", () => {
const frame = Frame.fromString(data); const frame = Frame.fromString(data);
const decoded = frame.decode() as StationCapabilitiesPayload; const decoded = frame.decode() as StationCapabilitiesPayload;
expect(decoded).not.toBeNull(); expect(decoded).not.toBeNull();
expect(decoded.type).toBe("capabilities"); expect(decoded.type).toBe(DataType.StationCapabilities);
expect(Array.isArray(decoded.capabilities)).toBeTruthy(); expect(Array.isArray(decoded.capabilities)).toBeTruthy();
expect(decoded.capabilities).toContain("IGATE"); expect(decoded.capabilities).toContain("IGATE");
expect(decoded.capabilities).toContain("MSG_CNT"); expect(decoded.capabilities).toContain("MSG_CNT");
@@ -23,7 +24,7 @@ describe("Frame.decodeCapabilities", () => {
structure: Dissected; structure: Dissected;
}; };
expect(res.payload).not.toBeNull(); expect(res.payload).not.toBeNull();
if (res.payload && res.payload.type !== "capabilities") if (res.payload && res.payload.type !== DataType.StationCapabilities)
throw new Error("expected capabilities payload"); throw new Error("expected capabilities payload");
expect(res.structure).toBeDefined(); expect(res.structure).toBeDefined();
const caps = res.structure.find((s) => s.name === "capabilities"); const caps = res.structure.find((s) => s.name === "capabilities");

224
test/payload.extras.test.ts Normal file
View File

@@ -0,0 +1,224 @@
import type { Dissected, Field, Segment } from "@hamradio/packet";
import { describe, expect, it } from "vitest";
import { Frame } from "../src/frame";
import type { PositionPayload } from "../src/frame.types";
import { feetToMeters, milesToMeters } from "../src/parser";
import { decodeTelemetry } from "../src/payload.extras";
describe("APRS extras test vectors", () => {
it("parses altitude token in the beginning of a comment and emits structure", () => {
const raw =
"DL3QP-R>APDG03,TCPIP*,qAC,T2ROMANIA:!5151.12ND00637.65E&/A=000000440 MMDVM Voice 439.40000MHz -7.6000MHz, DL3QP_Pi-Star";
const frame = Frame.fromString(raw);
const res = frame.decode(true) as { payload: PositionPayload | null; structure: Dissected };
const { payload, structure } = res;
expect(payload).not.toBeNull();
// Altitude 001234 ft -> meters
expect(payload!.position.altitude).toBe(0);
const commentSeg = structure.find((s) => /comment/i.test(String(s.name))) as Segment | undefined;
expect(commentSeg).toBeDefined();
const fieldsAlt = (commentSeg!.fields ?? []) as Field[];
const hasAlt = fieldsAlt.some((f) => f.name === "altitude");
expect(hasAlt).toBe(true);
expect(payload!.position.comment).toBe("440 MMDVM Voice 439.40000MHz -7.6000MHz, DL3QP_Pi-Star");
});
it("parses altitude token marker mid-comment and emits structure", () => {
const raw = "N0CALL>APRS,WIDE1-1:!4500.00N/07000.00W#RNG0001ALT/A=001234 Your Comment Here";
const frame = Frame.fromString(raw);
const res = frame.decode(true) as { payload: PositionPayload | null; structure: Dissected };
const { payload, structure } = res;
// console.log(structure[structure.length - 1]); // Log the last segment for debugging
expect(payload).not.toBeNull();
// Altitude 001234 ft -> meters
expect(Math.round((payload!.position.altitude || 0) / 0.3048)).toBe(1234);
const commentSeg = structure.find((s) => /comment/i.test(String(s.name))) as Segment | undefined;
expect(commentSeg).toBeDefined();
const fieldsAlt = (commentSeg!.fields ?? []) as Field[];
const hasAlt = fieldsAlt.some((f) => f.name === "altitude");
expect(hasAlt).toBe(true);
const commentIndex = (commentSeg!.fields ?? []).findIndex((f) => f.name === "comment");
expect(commentIndex).toBe(2); // Range marker + range go before.
const altitudeIndex = (commentSeg!.fields ?? []).findIndex((f) => f.name === "altitude");
expect(altitudeIndex).toBeGreaterThan(0); // Altitude should come after comment in the structure
expect(altitudeIndex).toBeGreaterThan(commentIndex);
const secondCommentIndex = (commentSeg!.fields ?? []).findIndex((f, i) => f.name === "comment" && i > commentIndex);
expect(secondCommentIndex).toBeGreaterThan(altitudeIndex); // Any additional comment fields should come after altitude
});
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);
const res = frame.decode(true) as { payload: PositionPayload | null; structure: Dissected };
const { payload } = res;
expect(payload).not.toBeNull();
expect(payload!.position.phg).toBeDefined();
// PHG500073 parsed per spec: p=5 -> 25 W, h='0' -> 10 ft, g='0' -> 0 dBi
expect(payload!.position.phg!.power).toBe(25);
expect(payload!.position.phg!.height).toBeCloseTo(3.048, 3);
expect(payload!.position.phg!.gain).toBe(0);
expect(payload!.position!.comment).toBe("73 de NOCALL");
});
it("parses PHG token with hyphen separators (spec vector 2)", () => {
const raw = "NOCALL>APRS,TCPIP*,qAC,NINTH:;P-PA3RD *061000z5156.26NP00603.29E#PHG0210DAPNET";
const frame = Frame.fromString(raw);
const res = frame.decode(true) as { payload: PositionPayload | null; structure: Dissected };
const { payload, structure } = res;
// console.log(structure[structure.length - 1]); // Log the last segment for debugging
expect(payload).not.toBeNull();
// Use a spec PHG example: PHG0210 -> p=0 -> power 0 W, h=2 -> 40 ft
expect(payload!.position.phg).toBeDefined();
expect(payload!.position.phg!.power).toBe(0);
expect(payload!.position.phg!.height).toBeCloseTo(12.192, 3);
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 marker");
expect(hasPHG).toBe(true);
});
it("parses DFS token with long numeric strength", () => {
const raw = "N0CALL>APRS,WIDE1-1:!4500.00N/07000.00W#DFS2360/Your Comment";
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.dfs).toBeDefined();
// DFSshgd: strength is single-digit s value (here '2')
expect(payload!.position.dfs!.strength).toBe(2);
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 marker");
expect(hasDFS).toBe(true);
});
it("parses course/speed in DDD/SSS form and altitude /A=", () => {
const raw = "N0CALL>APRS,WIDE1-1:!4500.00N/07000.00W>090/045/A=001234";
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.course).toBe(90);
// Speed is converted from knots to km/h
expect(payload!.position.speed).toBeCloseTo(45 * 1.852, 3);
// Altitude 001234 ft -> meters
expect(Math.round((payload!.position.altitude || 0) / 0.3048)).toBe(1234);
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 === "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/045PHG5132DFS2132";
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.course).toBe(90);
expect(payload!.position.speed).toBeCloseTo(45 * 1.852, 3);
expect(payload!.position.phg).toBeDefined();
expect(payload!.position.dfs).toBeDefined();
expect(payload!.position.dfs!.strength).toBe(2);
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) => ["course", "PHG marker", "DFS marker"].includes(String(f.name)))).toBe(true);
});
it("parses RNG token and emits structure", () => {
const raw =
"N0CALL-S>APDG01,TCPIP*,qAC,N0CALL-GS:;N0CALL B *181721z5148.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 === "range marker");
expect(hasRNG).toBe(true);
const rangeIndex = (commentSeg!.fields ?? []).findIndex((f) => f.name === "range marker");
expect(rangeIndex).toBeGreaterThanOrEqual(0);
const altitudeIndex = (commentSeg!.fields ?? []).findIndex((f) => f.name === "altitude");
expect(altitudeIndex).toBeGreaterThanOrEqual(0);
expect(rangeIndex).toBeGreaterThanOrEqual(0);
expect(altitudeIndex).toBeGreaterThan(rangeIndex); // Altitude comes after range
const commentIndex = (commentSeg!.fields ?? []).findIndex((f) => f.name === "comment");
expect(commentIndex).toBeGreaterThan(altitudeIndex); // Comment comes after altitude
});
});
describe("decodeTelemetry", () => {
it("decodes minimal telemetry (|!!!!|)", () => {
const result = decodeTelemetry("!!!!");
expect(result.sequence).toBe(0);
expect(result.analog).toEqual([0]);
expect(result.digital).toBeUndefined();
});
it("decodes sequence and one channel", () => {
const result = decodeTelemetry("ss11");
expect(result.sequence).toBe(7544);
expect(result.analog).toEqual([1472]);
expect(result.digital).toBeUndefined();
});
it("decodes sequence and two channels", () => {
const result = decodeTelemetry("ss1122");
expect(result.sequence).toBe(7544);
expect(result.analog).toEqual([1472, 1564]);
expect(result.digital).toBeUndefined();
});
it("decodes sequence and five channels", () => {
const result = decodeTelemetry("ss1122334455");
expect(result.sequence).toBe(7544);
expect(result.analog).toEqual([1472, 1564, 1656, 1748, 1840]);
expect(result.digital).toBeUndefined();
});
it("decodes sequence, five channels, and digital", () => {
const result = decodeTelemetry('ss1122334455!"');
expect(result.sequence).toBe(7544);
expect(result.analog).toEqual([1472, 1564, 1656, 1748, 1840]);
expect(result.digital).toBe(1);
});
it("throws on too short input", () => {
expect(() => decodeTelemetry("!")).toThrow();
expect(() => decodeTelemetry("")).toThrow();
});
it("throws on invalid base91", () => {
expect(() => decodeTelemetry("ss11~~")).toThrow();
});
});

View File

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

View File

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

View File

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

View File

@@ -1,7 +1,8 @@
import { describe, it, expect } from "vitest";
import { Dissected } from "@hamradio/packet"; import { Dissected } from "@hamradio/packet";
import { describe, expect, it } from "vitest";
import { Frame } from "../src/frame"; import { Frame } from "../src/frame";
import type { UserDefinedPayload } from "../src/frame.types"; import { DataType, type UserDefinedPayload } from "../src/frame.types";
describe("Frame.decodeUserDefined", () => { describe("Frame.decodeUserDefined", () => {
it("parses packet type only", () => { it("parses packet type only", () => {
@@ -9,7 +10,7 @@ describe("Frame.decodeUserDefined", () => {
const frame = Frame.fromString(data); const frame = Frame.fromString(data);
const decoded = frame.decode() as UserDefinedPayload; const decoded = frame.decode() as UserDefinedPayload;
expect(decoded).not.toBeNull(); expect(decoded).not.toBeNull();
expect(decoded.type).toBe("user-defined"); expect(decoded.type).toBe(DataType.UserDefined);
expect(decoded.userPacketType).toBe("01"); expect(decoded.userPacketType).toBe("01");
expect(decoded.data).toBe(""); expect(decoded.data).toBe("");
}); });
@@ -22,14 +23,12 @@ describe("Frame.decodeUserDefined", () => {
structure: Dissected; structure: Dissected;
}; };
expect(res.payload).not.toBeNull(); expect(res.payload).not.toBeNull();
expect(res.payload.type).toBe("user-defined"); expect(res.payload.type).toBe(DataType.UserDefined);
expect(res.payload.userPacketType).toBe("TEX"); expect(res.payload.userPacketType).toBe("TEX");
expect(res.payload.data).toBe("Hello world"); expect(res.payload.data).toBe("Hello world");
const raw = res.structure.find((s) => s.name === "user-defined"); const raw = res.structure.find((s) => s.name === "user-defined");
const typeSection = res.structure.find( const typeSection = res.structure.find((s) => s.name === "user-packet-type");
(s) => s.name === "user-packet-type",
);
const dataSection = res.structure.find((s) => s.name === "user-data"); const dataSection = res.structure.find((s) => s.name === "user-data");
expect(raw).toBeDefined(); expect(raw).toBeDefined();
expect(typeSection).toBeDefined(); expect(typeSection).toBeDefined();

View File

@@ -1,7 +1,8 @@
import { describe, it, expect } from "vitest";
import { Frame } from "../src/frame";
import { WeatherPayload } from "../src/frame.types";
import { Dissected } from "@hamradio/packet"; import { Dissected } from "@hamradio/packet";
import { describe, expect, it } from "vitest";
import { Frame } from "../src/frame";
import { DataType, WeatherPayload } from "../src/frame.types";
describe("Frame decode - Weather", () => { describe("Frame decode - Weather", () => {
it("parses weather with timestamp, wind, temp, rain, humidity and pressure", () => { it("parses weather with timestamp, wind, temp, rain, humidity and pressure", () => {
@@ -9,7 +10,7 @@ describe("Frame decode - Weather", () => {
const frame = Frame.fromString(data); const frame = Frame.fromString(data);
const payload = frame.decode() as WeatherPayload; const payload = frame.decode() as WeatherPayload;
expect(payload).not.toBeNull(); expect(payload).not.toBeNull();
expect(payload.type).toBe("weather"); expect(payload.type).toBe(DataType.WeatherReportNoPosition);
expect(payload.timestamp).toBeDefined(); expect(payload.timestamp).toBeDefined();
expect(payload.windDirection).toBe(180); expect(payload.windDirection).toBe(180);
expect(payload.windSpeed).toBe(10); expect(payload.windSpeed).toBe(10);