Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
0ab62dab02
|
|||
|
38b617728c
|
|||
|
16f638301b
|
|||
|
d0a100359d
|
|||
|
c300aefc0b
|
|||
|
074806528f
|
118
README.md
118
README.md
@@ -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
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@hamradio/aprs",
|
||||
"version": "1.1.0",
|
||||
"version": "1.1.2",
|
||||
"description": "APRS (Automatic Packet Reporting System) protocol support for Typescript",
|
||||
"keywords": [
|
||||
"APRS",
|
||||
|
||||
168
src/frame.ts
168
src/frame.ts
@@ -1,26 +1,28 @@
|
||||
import type { Dissected, Segment, Field } from "@hamradio/packet";
|
||||
import { FieldType } from "@hamradio/packet";
|
||||
import type {
|
||||
IAddress,
|
||||
IFrame,
|
||||
Payload,
|
||||
ITimestamp,
|
||||
PositionPayload,
|
||||
MessagePayload,
|
||||
IPosition,
|
||||
ObjectPayload,
|
||||
ItemPayload,
|
||||
StatusPayload,
|
||||
QueryPayload,
|
||||
TelemetryDataPayload,
|
||||
TelemetryBitSensePayload,
|
||||
TelemetryCoefficientsPayload,
|
||||
TelemetryParameterPayload,
|
||||
TelemetryUnitPayload,
|
||||
WeatherPayload,
|
||||
RawGPSPayload,
|
||||
StationCapabilitiesPayload,
|
||||
ThirdPartyPayload,
|
||||
import {
|
||||
type IAddress,
|
||||
type IFrame,
|
||||
type Payload,
|
||||
type ITimestamp,
|
||||
type PositionPayload,
|
||||
type MessagePayload,
|
||||
type IPosition,
|
||||
type ObjectPayload,
|
||||
type ItemPayload,
|
||||
type StatusPayload,
|
||||
type QueryPayload,
|
||||
type TelemetryDataPayload,
|
||||
type TelemetryBitSensePayload,
|
||||
type TelemetryCoefficientsPayload,
|
||||
type TelemetryParameterPayload,
|
||||
type TelemetryUnitPayload,
|
||||
type WeatherPayload,
|
||||
type RawGPSPayload,
|
||||
type StationCapabilitiesPayload,
|
||||
type ThirdPartyPayload,
|
||||
DataType,
|
||||
MicEPayload,
|
||||
} from "./frame.types";
|
||||
import { Position } from "./position";
|
||||
import { base91ToNumber } from "./parser";
|
||||
@@ -376,6 +378,13 @@ export class Frame implements IFrame {
|
||||
if (routingSection) {
|
||||
structure.push(routingSection);
|
||||
}
|
||||
// Add data type identifier section
|
||||
structure.push({
|
||||
name: "data type",
|
||||
data: new TextEncoder().encode(this.payload.charAt(0)).buffer,
|
||||
isString: true,
|
||||
fields: [{ type: FieldType.CHAR, name: "identifier", length: 1 }],
|
||||
});
|
||||
if (payloadsegment) {
|
||||
structure.push(...payloadsegment);
|
||||
}
|
||||
@@ -498,8 +507,30 @@ export class Frame implements IFrame {
|
||||
}
|
||||
}
|
||||
|
||||
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: "position",
|
||||
type: payloadType,
|
||||
timestamp,
|
||||
position,
|
||||
messaging,
|
||||
@@ -897,8 +928,20 @@ export class Frame implements IFrame {
|
||||
}
|
||||
}
|
||||
|
||||
const result: PositionPayload = {
|
||||
type: "position",
|
||||
let payloadType: DataType.MicECurrent | DataType.MicEOld;
|
||||
switch (this.payload.charAt(0)) {
|
||||
case "`":
|
||||
payloadType = DataType.MicECurrent;
|
||||
break;
|
||||
case "'":
|
||||
payloadType = DataType.MicEOld;
|
||||
break;
|
||||
default:
|
||||
return { payload: null };
|
||||
}
|
||||
|
||||
const result: MicEPayload = {
|
||||
type: payloadType,
|
||||
position: {
|
||||
latitude,
|
||||
longitude,
|
||||
@@ -907,11 +950,8 @@ export class Frame implements IFrame {
|
||||
code: symbolCode,
|
||||
},
|
||||
},
|
||||
messaging: true, // Mic-E is always messaging-capable
|
||||
micE: {
|
||||
messageType,
|
||||
isStandard,
|
||||
},
|
||||
messageType,
|
||||
isStandard,
|
||||
};
|
||||
|
||||
if (speed > 0) {
|
||||
@@ -1133,6 +1173,13 @@ export class Frame implements IFrame {
|
||||
text = this.payload.substring(textStart + 1);
|
||||
}
|
||||
|
||||
const payload: MessagePayload = {
|
||||
type: DataType.Message,
|
||||
variant: "message",
|
||||
addressee: recipient,
|
||||
text,
|
||||
};
|
||||
|
||||
if (withStructure) {
|
||||
// Emit text section
|
||||
segments.push({
|
||||
@@ -1142,21 +1189,9 @@ export class Frame implements IFrame {
|
||||
fields: [{ type: FieldType.STRING, name: "text", length: text.length }],
|
||||
});
|
||||
|
||||
const payload: MessagePayload = {
|
||||
type: "message",
|
||||
addressee: recipient,
|
||||
text,
|
||||
};
|
||||
|
||||
return { payload, segment: segments };
|
||||
}
|
||||
|
||||
const payload: MessagePayload = {
|
||||
type: "message",
|
||||
addressee: recipient,
|
||||
text,
|
||||
};
|
||||
|
||||
return { payload };
|
||||
}
|
||||
|
||||
@@ -1285,7 +1320,7 @@ export class Frame implements IFrame {
|
||||
}
|
||||
|
||||
const payload: ObjectPayload = {
|
||||
type: "object",
|
||||
type: DataType.Object,
|
||||
name,
|
||||
timestamp,
|
||||
alive,
|
||||
@@ -1420,7 +1455,7 @@ export class Frame implements IFrame {
|
||||
}
|
||||
|
||||
const payload: ItemPayload = {
|
||||
type: "item",
|
||||
type: DataType.Item,
|
||||
name,
|
||||
alive,
|
||||
position,
|
||||
@@ -1472,7 +1507,7 @@ export class Frame implements IFrame {
|
||||
}
|
||||
|
||||
const payload: StatusPayload = {
|
||||
type: "status",
|
||||
type: DataType.Status,
|
||||
timestamp: undefined,
|
||||
text: statusText,
|
||||
};
|
||||
@@ -1557,7 +1592,7 @@ export class Frame implements IFrame {
|
||||
}
|
||||
|
||||
const payload: QueryPayload = {
|
||||
type: "query",
|
||||
type: DataType.Query,
|
||||
queryType,
|
||||
...(target ? { target } : {}),
|
||||
};
|
||||
@@ -1639,7 +1674,8 @@ export class Frame implements IFrame {
|
||||
}
|
||||
|
||||
const payload: TelemetryDataPayload = {
|
||||
type: "telemetry-data",
|
||||
type: DataType.TelemetryData,
|
||||
variant: "data",
|
||||
sequence: isNaN(seq) ? 0 : seq,
|
||||
analog,
|
||||
digital: isNaN(digital) ? 0 : digital,
|
||||
@@ -1664,7 +1700,8 @@ export class Frame implements IFrame {
|
||||
});
|
||||
}
|
||||
const payload: TelemetryParameterPayload = {
|
||||
type: "telemetry-parameters",
|
||||
type: DataType.TelemetryData,
|
||||
variant: "parameters",
|
||||
names,
|
||||
};
|
||||
if (withStructure) return { payload, segment: segments };
|
||||
@@ -1686,7 +1723,8 @@ export class Frame implements IFrame {
|
||||
});
|
||||
}
|
||||
const payload: TelemetryUnitPayload = {
|
||||
type: "telemetry-units",
|
||||
type: DataType.TelemetryData,
|
||||
variant: "unit",
|
||||
units,
|
||||
};
|
||||
if (withStructure) return { payload, segment: segments };
|
||||
@@ -1717,7 +1755,8 @@ export class Frame implements IFrame {
|
||||
});
|
||||
}
|
||||
const payload: TelemetryCoefficientsPayload = {
|
||||
type: "telemetry-coefficients",
|
||||
type: DataType.TelemetryData,
|
||||
variant: "coefficients",
|
||||
coefficients,
|
||||
};
|
||||
if (withStructure) return { payload, segment: segments };
|
||||
@@ -1741,7 +1780,8 @@ export class Frame implements IFrame {
|
||||
});
|
||||
}
|
||||
const payload: TelemetryBitSensePayload = {
|
||||
type: "telemetry-bitsense",
|
||||
type: DataType.TelemetryData,
|
||||
variant: "bitsense",
|
||||
sense: isNaN(sense) ? 0 : sense,
|
||||
...(projectName ? { projectName } : {}),
|
||||
};
|
||||
@@ -1818,7 +1858,9 @@ export class Frame implements IFrame {
|
||||
|
||||
const rest = this.payload.substring(offset).trim();
|
||||
|
||||
const payload: WeatherPayload = { type: "weather" };
|
||||
const payload: WeatherPayload = {
|
||||
type: DataType.WeatherReportNoPosition,
|
||||
};
|
||||
if (timestamp) payload.timestamp = timestamp;
|
||||
if (position) payload.position = position;
|
||||
|
||||
@@ -1894,7 +1936,7 @@ export class Frame implements IFrame {
|
||||
}
|
||||
|
||||
const payload: RawGPSPayload = {
|
||||
type: "raw-gps",
|
||||
type: DataType.RawGPS,
|
||||
sentence,
|
||||
};
|
||||
|
||||
@@ -2055,7 +2097,7 @@ export class Frame implements IFrame {
|
||||
.filter(Boolean);
|
||||
|
||||
const payload: StationCapabilitiesPayload = {
|
||||
type: "capabilities",
|
||||
type: DataType.StationCapabilities,
|
||||
capabilities: tokens,
|
||||
} as const;
|
||||
|
||||
@@ -2118,7 +2160,7 @@ export class Frame implements IFrame {
|
||||
}
|
||||
|
||||
const payloadObj = {
|
||||
type: "user-defined",
|
||||
type: DataType.UserDefined,
|
||||
userPacketType,
|
||||
data,
|
||||
} as const;
|
||||
@@ -2185,7 +2227,7 @@ export class Frame implements IFrame {
|
||||
}
|
||||
|
||||
const payloadObj: ThirdPartyPayload = {
|
||||
type: "third-party",
|
||||
type: DataType.ThirdParty,
|
||||
comment: rest,
|
||||
...(nestedFrame ? { frame: nestedFrame } : {}),
|
||||
} as const;
|
||||
@@ -2271,34 +2313,34 @@ const parseFrame = (data: string): Frame => {
|
||||
|
||||
pathFields.push({
|
||||
type: FieldType.CHAR,
|
||||
name: `Path separator ${i}`,
|
||||
name: `path separator ${i}`,
|
||||
length: 1,
|
||||
});
|
||||
pathFields.push({
|
||||
type: FieldType.STRING,
|
||||
name: `Repeater ${i}`,
|
||||
name: `repeater ${i}`,
|
||||
length: pathStr.length,
|
||||
});
|
||||
}
|
||||
|
||||
const routingSection: Segment = {
|
||||
name: "Routing",
|
||||
data: encoder.encode(data.slice(0, routeSepIndex)).buffer,
|
||||
name: "routing",
|
||||
data: encoder.encode(data.slice(0, routeSepIndex + 1)).buffer,
|
||||
isString: true,
|
||||
fields: [
|
||||
{
|
||||
type: FieldType.STRING,
|
||||
name: "Source address",
|
||||
name: "source address",
|
||||
length: sourceStr.length,
|
||||
},
|
||||
{ type: FieldType.CHAR, name: "Route separator", length: 1 },
|
||||
{ type: FieldType.CHAR, name: "route separator", length: 1 },
|
||||
{
|
||||
type: FieldType.STRING,
|
||||
name: "Destination address",
|
||||
name: "destination address",
|
||||
length: destinationStr.length,
|
||||
},
|
||||
...pathFields,
|
||||
{ type: FieldType.CHAR, name: "Payload separator", length: 1 },
|
||||
{ type: FieldType.CHAR, name: "payload separator", length: 1 },
|
||||
],
|
||||
};
|
||||
|
||||
|
||||
@@ -14,54 +14,51 @@ export interface IFrame {
|
||||
}
|
||||
|
||||
// APRS Data Type Identifiers (first character of payload)
|
||||
export const DataTypeIdentifier = {
|
||||
export enum DataType {
|
||||
// Position Reports
|
||||
PositionNoTimestampNoMessaging: "!",
|
||||
PositionNoTimestampWithMessaging: "=",
|
||||
PositionWithTimestampNoMessaging: "/",
|
||||
PositionWithTimestampWithMessaging: "@",
|
||||
PositionNoTimestampNoMessaging = "!",
|
||||
PositionNoTimestampWithMessaging = "=",
|
||||
PositionWithTimestampNoMessaging = "/",
|
||||
PositionWithTimestampWithMessaging = "@",
|
||||
|
||||
// Mic-E
|
||||
MicECurrent: "`",
|
||||
MicEOld: "'",
|
||||
MicECurrent = "`",
|
||||
MicEOld = "'",
|
||||
|
||||
// Messages and Bulletins
|
||||
Message: ":",
|
||||
Message = ":",
|
||||
|
||||
// Objects and Items
|
||||
Object: ";",
|
||||
Item: ")",
|
||||
Object = ";",
|
||||
Item = ")",
|
||||
|
||||
// Status
|
||||
Status: ">",
|
||||
Status = ">",
|
||||
|
||||
// Query
|
||||
Query: "?",
|
||||
Query = "?",
|
||||
|
||||
// Telemetry
|
||||
TelemetryData: "T",
|
||||
TelemetryData = "T",
|
||||
|
||||
// Weather
|
||||
WeatherReportNoPosition: "_",
|
||||
WeatherReportNoPosition = "_",
|
||||
|
||||
// Raw GPS Data
|
||||
RawGPS: "$",
|
||||
RawGPS = "$",
|
||||
|
||||
// Station Capabilities
|
||||
StationCapabilities: "<",
|
||||
StationCapabilities = "<",
|
||||
|
||||
// User-Defined
|
||||
UserDefined: "{",
|
||||
UserDefined = "{",
|
||||
|
||||
// Third-Party Traffic
|
||||
ThirdParty: "}",
|
||||
ThirdParty = "}",
|
||||
|
||||
// Invalid/Test Data
|
||||
InvalidOrTest: ",",
|
||||
} as const;
|
||||
|
||||
export type DataTypeIdentifier =
|
||||
(typeof DataTypeIdentifier)[keyof typeof DataTypeIdentifier];
|
||||
InvalidOrTest = ",",
|
||||
}
|
||||
|
||||
export interface ISymbol {
|
||||
table: string; // Symbol table identifier
|
||||
@@ -99,7 +96,11 @@ export interface ITimestamp {
|
||||
|
||||
// Position Report Payload
|
||||
export interface PositionPayload {
|
||||
type: "position";
|
||||
type:
|
||||
| DataType.PositionNoTimestampNoMessaging
|
||||
| DataType.PositionNoTimestampWithMessaging
|
||||
| DataType.PositionWithTimestampNoMessaging
|
||||
| DataType.PositionWithTimestampWithMessaging;
|
||||
timestamp?: ITimestamp;
|
||||
position: IPosition;
|
||||
messaging: boolean; // Whether APRS messaging is enabled
|
||||
@@ -128,19 +129,20 @@ export interface CompressedPosition {
|
||||
|
||||
// Mic-E Payload (compressed in destination address)
|
||||
export interface MicEPayload {
|
||||
type: "mic-e";
|
||||
type: DataType.MicECurrent | DataType.MicEOld;
|
||||
position: IPosition;
|
||||
course?: number;
|
||||
speed?: number;
|
||||
altitude?: number;
|
||||
messageType?: string; // Standard Mic-E message
|
||||
isStandard?: boolean; // Whether messageType is a standard Mic-E message
|
||||
telemetry?: number[]; // Optional telemetry channels
|
||||
status?: string;
|
||||
}
|
||||
|
||||
export type MessageVariant = "message" | "bulletin";
|
||||
|
||||
// Message Payload
|
||||
export interface MessagePayload {
|
||||
type: "message";
|
||||
type: DataType.Message;
|
||||
variant: "message";
|
||||
addressee: string; // 9 character padded callsign
|
||||
text: string; // Message text
|
||||
messageNumber?: string; // Message ID for acknowledgment
|
||||
@@ -150,7 +152,8 @@ export interface MessagePayload {
|
||||
|
||||
// Bulletin/Announcement (variant of message)
|
||||
export interface BulletinPayload {
|
||||
type: "bulletin";
|
||||
type: DataType.Message;
|
||||
variant: "bulletin";
|
||||
bulletinId: string; // Bulletin identifier (BLN#)
|
||||
text: string;
|
||||
group?: string; // Optional group bulletin
|
||||
@@ -158,7 +161,7 @@ export interface BulletinPayload {
|
||||
|
||||
// Object Payload
|
||||
export interface ObjectPayload {
|
||||
type: "object";
|
||||
type: DataType.Object;
|
||||
name: string; // 9 character object name
|
||||
timestamp: ITimestamp;
|
||||
alive: boolean; // True if object is active, false if killed
|
||||
@@ -169,7 +172,7 @@ export interface ObjectPayload {
|
||||
|
||||
// Item Payload
|
||||
export interface ItemPayload {
|
||||
type: "item";
|
||||
type: DataType.Item;
|
||||
name: string; // 3-9 character item name
|
||||
alive: boolean; // True if item is active, false if killed
|
||||
position: IPosition;
|
||||
@@ -177,7 +180,7 @@ export interface ItemPayload {
|
||||
|
||||
// Status Payload
|
||||
export interface StatusPayload {
|
||||
type: "status";
|
||||
type: DataType.Status;
|
||||
timestamp?: ITimestamp;
|
||||
text: string;
|
||||
maidenhead?: string; // Optional Maidenhead grid locator
|
||||
@@ -189,14 +192,22 @@ export interface StatusPayload {
|
||||
|
||||
// Query Payload
|
||||
export interface QueryPayload {
|
||||
type: "query";
|
||||
type: DataType.Query;
|
||||
queryType: string; // e.g., 'APRSD', 'APRST', 'PING'
|
||||
target?: string; // Target callsign or area
|
||||
}
|
||||
|
||||
export type TelemetryVariant =
|
||||
| "data"
|
||||
| "parameters"
|
||||
| "unit"
|
||||
| "coefficients"
|
||||
| "bitsense";
|
||||
|
||||
// Telemetry Data Payload
|
||||
export interface TelemetryDataPayload {
|
||||
type: "telemetry-data";
|
||||
type: DataType.TelemetryData;
|
||||
variant: "data";
|
||||
sequence: number;
|
||||
analog: number[]; // Up to 5 analog channels
|
||||
digital: number; // 8-bit digital value
|
||||
@@ -204,19 +215,22 @@ export interface TelemetryDataPayload {
|
||||
|
||||
// Telemetry Parameter Names
|
||||
export interface TelemetryParameterPayload {
|
||||
type: "telemetry-parameters";
|
||||
type: DataType.TelemetryData;
|
||||
variant: "parameters";
|
||||
names: string[]; // Parameter names
|
||||
}
|
||||
|
||||
// Telemetry Unit/Label
|
||||
export interface TelemetryUnitPayload {
|
||||
type: "telemetry-units";
|
||||
type: DataType.TelemetryData;
|
||||
variant: "unit";
|
||||
units: string[]; // Units for each parameter
|
||||
}
|
||||
|
||||
// Telemetry Coefficients
|
||||
export interface TelemetryCoefficientsPayload {
|
||||
type: "telemetry-coefficients";
|
||||
type: DataType.TelemetryData;
|
||||
variant: "coefficients";
|
||||
coefficients: {
|
||||
a: number[]; // a coefficients
|
||||
b: number[]; // b coefficients
|
||||
@@ -226,14 +240,15 @@ export interface TelemetryCoefficientsPayload {
|
||||
|
||||
// Telemetry Bit Sense/Project Name
|
||||
export interface TelemetryBitSensePayload {
|
||||
type: "telemetry-bitsense";
|
||||
type: DataType.TelemetryData;
|
||||
variant: "bitsense";
|
||||
sense: number; // 8-bit sense value
|
||||
projectName?: string;
|
||||
}
|
||||
|
||||
// Weather Report Payload
|
||||
export interface WeatherPayload {
|
||||
type: "weather";
|
||||
type: DataType.WeatherReportNoPosition;
|
||||
timestamp?: ITimestamp;
|
||||
position?: IPosition;
|
||||
windDirection?: number; // Degrees
|
||||
@@ -255,34 +270,33 @@ export interface WeatherPayload {
|
||||
|
||||
// Raw GPS Payload (NMEA sentences)
|
||||
export interface RawGPSPayload {
|
||||
type: "raw-gps";
|
||||
type: DataType.RawGPS;
|
||||
sentence: string; // Raw NMEA sentence
|
||||
position?: IPosition; // Optional parsed position if available
|
||||
}
|
||||
|
||||
// Station Capabilities Payload
|
||||
export interface StationCapabilitiesPayload {
|
||||
type: "capabilities";
|
||||
type: DataType.StationCapabilities;
|
||||
capabilities: string[];
|
||||
}
|
||||
|
||||
// User-Defined Payload
|
||||
export interface UserDefinedPayload {
|
||||
type: "user-defined";
|
||||
type: DataType.UserDefined;
|
||||
userPacketType: string;
|
||||
data: string;
|
||||
}
|
||||
|
||||
// Third-Party Traffic Payload
|
||||
export interface ThirdPartyPayload {
|
||||
type: "third-party";
|
||||
type: DataType.ThirdParty;
|
||||
frame?: IFrame; // Optional nested frame if payload contains another APRS frame
|
||||
comment?: string; // Optional comment
|
||||
}
|
||||
|
||||
// DF Report Payload
|
||||
export interface DFReportPayload {
|
||||
type: "df-report";
|
||||
timestamp?: ITimestamp;
|
||||
position: IPosition;
|
||||
course?: number;
|
||||
@@ -295,7 +309,7 @@ export interface DFReportPayload {
|
||||
}
|
||||
|
||||
export interface BasePayload {
|
||||
type: string;
|
||||
type: DataType;
|
||||
}
|
||||
|
||||
// Union type for all decoded payload types
|
||||
@@ -319,7 +333,6 @@ export type Payload = BasePayload &
|
||||
| StationCapabilitiesPayload
|
||||
| UserDefinedPayload
|
||||
| ThirdPartyPayload
|
||||
| DFReportPayload
|
||||
);
|
||||
|
||||
// Extended Frame with decoded payload
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
export { Frame, Address, Timestamp } from "./frame";
|
||||
|
||||
export { type IAddress, type IFrame, DataTypeIdentifier } from "./frame.types";
|
||||
export {
|
||||
type IAddress,
|
||||
type IFrame,
|
||||
DataType as DataTypeIdentifier,
|
||||
} from "./frame.types";
|
||||
|
||||
export {
|
||||
DataType,
|
||||
type ISymbol,
|
||||
type IPosition,
|
||||
type ITimestamp,
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { Frame } from "../src/frame";
|
||||
import type { Payload, StationCapabilitiesPayload } from "../src/frame.types";
|
||||
import {
|
||||
DataType,
|
||||
type Payload,
|
||||
type StationCapabilitiesPayload,
|
||||
} from "../src/frame.types";
|
||||
import { Dissected } from "@hamradio/packet";
|
||||
|
||||
describe("Frame.decodeCapabilities", () => {
|
||||
@@ -9,7 +13,7 @@ describe("Frame.decodeCapabilities", () => {
|
||||
const frame = Frame.fromString(data);
|
||||
const decoded = frame.decode() as StationCapabilitiesPayload;
|
||||
expect(decoded).not.toBeNull();
|
||||
expect(decoded.type).toBe("capabilities");
|
||||
expect(decoded.type).toBe(DataType.StationCapabilities);
|
||||
expect(Array.isArray(decoded.capabilities)).toBeTruthy();
|
||||
expect(decoded.capabilities).toContain("IGATE");
|
||||
expect(decoded.capabilities).toContain("MSG_CNT");
|
||||
@@ -23,7 +27,7 @@ describe("Frame.decodeCapabilities", () => {
|
||||
structure: Dissected;
|
||||
};
|
||||
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");
|
||||
expect(res.structure).toBeDefined();
|
||||
const caps = res.structure.find((s) => s.name === "capabilities");
|
||||
|
||||
@@ -2,14 +2,14 @@ import { expect } from "vitest";
|
||||
import { describe, it } from "vitest";
|
||||
import { Dissected } from "@hamradio/packet";
|
||||
import { Frame } from "../src/frame";
|
||||
import { QueryPayload } from "../src/frame.types";
|
||||
import { DataType, QueryPayload } from "../src/frame.types";
|
||||
|
||||
describe("Frame decode - Query", () => {
|
||||
it("decodes simple query without target", () => {
|
||||
const frame = Frame.fromString("SRC>DEST:?APRS");
|
||||
const payload = frame.decode() as QueryPayload;
|
||||
expect(payload).not.toBeNull();
|
||||
expect(payload.type).toBe("query");
|
||||
expect(payload.type).toBe(DataType.Query);
|
||||
expect(payload.queryType).toBe("APRS");
|
||||
expect(payload.target).toBeUndefined();
|
||||
});
|
||||
@@ -18,7 +18,7 @@ describe("Frame decode - Query", () => {
|
||||
const frame = Frame.fromString("SRC>DEST:?PING N0CALL");
|
||||
const payload = frame.decode() as QueryPayload;
|
||||
expect(payload).not.toBeNull();
|
||||
expect(payload.type).toBe("query");
|
||||
expect(payload.type).toBe(DataType.Query);
|
||||
expect(payload.queryType).toBe("PING");
|
||||
expect(payload.target).toBe("N0CALL");
|
||||
});
|
||||
@@ -30,7 +30,7 @@ describe("Frame decode - Query", () => {
|
||||
structure: Dissected;
|
||||
};
|
||||
expect(result).toHaveProperty("payload");
|
||||
expect(result.payload.type).toBe("query");
|
||||
expect(result.payload.type).toBe(DataType.Query);
|
||||
expect(Array.isArray(result.structure)).toBe(true);
|
||||
const names = result.structure.map((s) => s.name);
|
||||
expect(names).toContain("query type");
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { Frame } from "../src/frame";
|
||||
import type { RawGPSPayload } from "../src/frame.types";
|
||||
import { DataType, type RawGPSPayload } from "../src/frame.types";
|
||||
import { Dissected } from "@hamradio/packet";
|
||||
|
||||
describe("Raw GPS decoding", () => {
|
||||
@@ -13,7 +13,7 @@ describe("Raw GPS decoding", () => {
|
||||
const payload = f.decode(false) as RawGPSPayload | null;
|
||||
|
||||
expect(payload).not.toBeNull();
|
||||
expect(payload?.type).toBe("raw-gps");
|
||||
expect(payload?.type).toBe(DataType.RawGPS);
|
||||
expect(payload?.sentence).toBe(sentence);
|
||||
expect(payload?.position).toBeDefined();
|
||||
expect(typeof payload?.position?.latitude).toBe("number");
|
||||
@@ -32,7 +32,7 @@ describe("Raw GPS decoding", () => {
|
||||
};
|
||||
|
||||
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?.position).toBeDefined();
|
||||
expect(typeof result.payload?.position?.latitude).toBe("number");
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
TelemetryUnitPayload,
|
||||
TelemetryCoefficientsPayload,
|
||||
TelemetryBitSensePayload,
|
||||
DataType,
|
||||
} from "../src/frame.types";
|
||||
import { Frame } from "../src/frame";
|
||||
import { expect } from "vitest";
|
||||
@@ -14,7 +15,8 @@ describe("Frame decode - Telemetry", () => {
|
||||
const frame = Frame.fromString("SRC>DEST:T#1 10,20,30,40,50 7");
|
||||
const payload = frame.decode() as TelemetryDataPayload;
|
||||
expect(payload).not.toBeNull();
|
||||
expect(payload.type).toBe("telemetry-data");
|
||||
expect(payload.type).toBe(DataType.TelemetryData);
|
||||
expect(payload.variant).toBe("data");
|
||||
expect(payload.sequence).toBe(1);
|
||||
expect(Array.isArray(payload.analog)).toBe(true);
|
||||
expect(payload.analog.length).toBe(5);
|
||||
@@ -25,7 +27,8 @@ describe("Frame decode - Telemetry", () => {
|
||||
const frame = Frame.fromString("SRC>DEST:TPARAM Temp,Hum,Wind");
|
||||
const payload = frame.decode() as TelemetryParameterPayload;
|
||||
expect(payload).not.toBeNull();
|
||||
expect(payload.type).toBe("telemetry-parameters");
|
||||
expect(payload.type).toBe(DataType.TelemetryData);
|
||||
expect(payload.variant).toBe("parameters");
|
||||
expect(Array.isArray(payload.names)).toBe(true);
|
||||
expect(payload.names).toEqual(["Temp", "Hum", "Wind"]);
|
||||
});
|
||||
@@ -34,7 +37,8 @@ describe("Frame decode - Telemetry", () => {
|
||||
const frame = Frame.fromString("SRC>DEST:TUNIT C,% ,mph");
|
||||
const payload = frame.decode() as TelemetryUnitPayload;
|
||||
expect(payload).not.toBeNull();
|
||||
expect(payload.type).toBe("telemetry-units");
|
||||
expect(payload.type).toBe(DataType.TelemetryData);
|
||||
expect(payload.variant).toBe("unit");
|
||||
expect(payload.units).toEqual(["C", "%", "mph"]);
|
||||
});
|
||||
|
||||
@@ -42,7 +46,8 @@ describe("Frame decode - Telemetry", () => {
|
||||
const frame = Frame.fromString("SRC>DEST:TCOEFF A:1,2 B:3,4 C:5,6");
|
||||
const payload = frame.decode() as TelemetryCoefficientsPayload;
|
||||
expect(payload).not.toBeNull();
|
||||
expect(payload.type).toBe("telemetry-coefficients");
|
||||
expect(payload.type).toBe(DataType.TelemetryData);
|
||||
expect(payload.variant).toBe("coefficients");
|
||||
expect(payload.coefficients.a).toEqual([1, 2]);
|
||||
expect(payload.coefficients.b).toEqual([3, 4]);
|
||||
expect(payload.coefficients.c).toEqual([5, 6]);
|
||||
@@ -52,7 +57,8 @@ describe("Frame decode - Telemetry", () => {
|
||||
const frame = Frame.fromString("SRC>DEST:TBITS 255 ProjectX");
|
||||
const payload = frame.decode() as TelemetryBitSensePayload;
|
||||
expect(payload).not.toBeNull();
|
||||
expect(payload.type).toBe("telemetry-bitsense");
|
||||
expect(payload.type).toBe(DataType.TelemetryData);
|
||||
expect(payload.variant).toBe("bitsense");
|
||||
expect(payload.sense).toBe(255);
|
||||
expect(payload.projectName).toBe("ProjectX");
|
||||
});
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { Address, Frame, Timestamp } from "../src/frame";
|
||||
import type {
|
||||
Payload,
|
||||
PositionPayload,
|
||||
ObjectPayload,
|
||||
StatusPayload,
|
||||
ITimestamp,
|
||||
MessagePayload,
|
||||
import {
|
||||
type Payload,
|
||||
type PositionPayload,
|
||||
type ObjectPayload,
|
||||
type StatusPayload,
|
||||
type ITimestamp,
|
||||
type MessagePayload,
|
||||
DataType,
|
||||
MicEPayload,
|
||||
} from "../src/frame.types";
|
||||
import { Dissected, FieldType } from "@hamradio/packet";
|
||||
|
||||
@@ -79,7 +81,7 @@ describe("Frame.decode (basic)", () => {
|
||||
const frame = Frame.fromString(data);
|
||||
const decoded = frame.decode() as PositionPayload;
|
||||
expect(decoded).not.toBeNull();
|
||||
expect(decoded?.type).toBe("position");
|
||||
expect(decoded?.type).toBe(DataType.PositionWithTimestampWithMessaging);
|
||||
});
|
||||
|
||||
it("should handle various data type identifiers without throwing", () => {
|
||||
@@ -217,17 +219,17 @@ describe("Frame.decodeMicE", () => {
|
||||
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 frame = Frame.fromString(data);
|
||||
const decoded = frame.decode() as Payload;
|
||||
const decoded = frame.decode() as MicEPayload;
|
||||
expect(decoded).not.toBeNull();
|
||||
expect(decoded?.type).toBe("position");
|
||||
expect(decoded?.type).toBe(DataType.MicECurrent);
|
||||
});
|
||||
|
||||
it("decodes a Mic-E packet with old format (single quote)", () => {
|
||||
const data = "CALL>T2TQ5U:'c.l+@&'/'\"G:}";
|
||||
const frame = Frame.fromString(data);
|
||||
const decoded = frame.decode() as Payload;
|
||||
const decoded = frame.decode() as MicEPayload;
|
||||
expect(decoded).not.toBeNull();
|
||||
expect(decoded?.type).toBe("position");
|
||||
expect(decoded?.type).toBe(DataType.MicEOld);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -237,7 +239,7 @@ describe("Frame.decodeMessage", () => {
|
||||
const frame = Frame.fromString(raw);
|
||||
const decoded = frame.decode() as MessagePayload;
|
||||
expect(decoded).not.toBeNull();
|
||||
expect(decoded?.type).toBe("message");
|
||||
expect(decoded?.type).toBe(DataType.Message);
|
||||
expect(decoded?.addressee).toBe("KB1ABC-5");
|
||||
expect(decoded?.text).toBe("Hello World");
|
||||
});
|
||||
@@ -250,7 +252,7 @@ describe("Frame.decodeMessage", () => {
|
||||
structure: Dissected;
|
||||
};
|
||||
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 textSection = res.structure.find((s) => s.name === "text");
|
||||
expect(recipientSection).toBeDefined();
|
||||
@@ -266,10 +268,10 @@ describe("Frame.decodeObject", () => {
|
||||
it("decodes object payload with uncompressed position", () => {
|
||||
const data = "CALL>APRS:;OBJECT *092345z4903.50N/07201.75W>Test object";
|
||||
const frame = Frame.fromString(data);
|
||||
const decoded = frame.decode() as Payload;
|
||||
const decoded = frame.decode() as ObjectPayload;
|
||||
expect(decoded).not.toBeNull();
|
||||
expect(decoded?.type).toBe("object");
|
||||
if (decoded && decoded.type === "object") {
|
||||
expect(decoded?.type).toBe(DataType.Object);
|
||||
if (decoded && decoded.type === DataType.Object) {
|
||||
expect(decoded.name).toBe("OBJECT");
|
||||
expect(decoded.alive).toBe(true);
|
||||
}
|
||||
@@ -294,7 +296,7 @@ describe("Frame.decodePosition", () => {
|
||||
const frame = Frame.fromString(data);
|
||||
const decoded = frame.decode() as PositionPayload;
|
||||
expect(decoded).not.toBeNull();
|
||||
expect(decoded?.type).toBe("position");
|
||||
expect(decoded?.type).toBe(DataType.PositionWithTimestampWithMessaging);
|
||||
});
|
||||
|
||||
it("decodes uncompressed position without timestamp", () => {
|
||||
@@ -302,7 +304,7 @@ describe("Frame.decodePosition", () => {
|
||||
const frame = Frame.fromString(data);
|
||||
const decoded = frame.decode() as PositionPayload;
|
||||
expect(decoded).not.toBeNull();
|
||||
expect(decoded?.type).toBe("position");
|
||||
expect(decoded?.type).toBe(DataType.PositionNoTimestampNoMessaging);
|
||||
});
|
||||
|
||||
it("handles ambiguity masking in position", () => {
|
||||
@@ -322,7 +324,7 @@ describe("Frame.decodeStatus", () => {
|
||||
structure: Dissected;
|
||||
};
|
||||
expect(res.payload).not.toBeNull();
|
||||
if (res.payload?.type !== "status")
|
||||
if (res.payload?.type !== DataType.Status)
|
||||
throw new Error("expected status payload");
|
||||
const payload = res.payload as StatusPayload & { timestamp?: ITimestamp };
|
||||
expect(payload.text).toBe("Testing status");
|
||||
@@ -349,7 +351,7 @@ describe("Frame.decode (sections)", () => {
|
||||
payload: PositionPayload | null;
|
||||
structure: Dissected;
|
||||
};
|
||||
expect(result.payload?.type).toBe("position");
|
||||
expect(result.payload?.type).toBe(DataType.PositionNoTimestampNoMessaging);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -456,29 +458,27 @@ describe("Frame.decodeMicE", () => {
|
||||
const data =
|
||||
"N83MZ>T2TQ5U,WA1PLE-4*:`c.l+@&'/'\"G:} KJ6TMS|!:&0'p|!w#f!|3";
|
||||
const frame = Frame.fromString(data);
|
||||
const decoded = frame.decode() as Payload;
|
||||
const decoded = frame.decode() as MicEPayload;
|
||||
|
||||
expect(decoded).not.toBeNull();
|
||||
expect(decoded?.type).toBe("position");
|
||||
expect(decoded?.type).toBe(DataType.MicECurrent);
|
||||
|
||||
if (decoded && decoded.type === "position") {
|
||||
expect(decoded.messaging).toBe(true);
|
||||
if (decoded && decoded.type === DataType.MicECurrent) {
|
||||
expect(decoded.position).toBeDefined();
|
||||
expect(typeof decoded.position.latitude).toBe("number");
|
||||
expect(typeof decoded.position.longitude).toBe("number");
|
||||
expect(decoded.position.symbol).toBeDefined();
|
||||
expect(decoded.micE).toBeDefined();
|
||||
expect(decoded.micE?.messageType).toBeDefined();
|
||||
expect(decoded.messageType).toBeDefined();
|
||||
}
|
||||
});
|
||||
|
||||
it("should decode a Mic-E packet with old format (single quote)", () => {
|
||||
const data = "CALL>T2TQ5U:'c.l+@&'/'\"G:}";
|
||||
const frame = Frame.fromString(data);
|
||||
const decoded = frame.decode() as Payload;
|
||||
const decoded = frame.decode() as MicEPayload;
|
||||
|
||||
expect(decoded).not.toBeNull();
|
||||
expect(decoded?.type).toBe("position");
|
||||
expect(decoded?.type).toBe(DataType.MicEOld);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -486,11 +486,11 @@ describe("Frame.decodeMicE", () => {
|
||||
it("should decode latitude from numeric digits (0-9)", () => {
|
||||
const data = "CALL>123456:`c.l+@&'/'\"G:}";
|
||||
const frame = Frame.fromString(data);
|
||||
const decoded = frame.decode() as Payload;
|
||||
const decoded = frame.decode() as MicEPayload;
|
||||
|
||||
expect(decoded).not.toBeNull();
|
||||
|
||||
if (decoded && decoded.type === "position") {
|
||||
if (decoded && decoded.type === DataType.MicECurrent) {
|
||||
expect(decoded.position.latitude).toBeCloseTo(12 + 34.56 / 60, 3);
|
||||
}
|
||||
});
|
||||
@@ -498,11 +498,11 @@ describe("Frame.decodeMicE", () => {
|
||||
it("should decode latitude from letter digits (A-J)", () => {
|
||||
const data = "CALL>ABC0EF:`c.l+@&'/'\"G:}";
|
||||
const frame = Frame.fromString(data);
|
||||
const decoded = frame.decode() as Payload;
|
||||
const decoded = frame.decode() as MicEPayload;
|
||||
|
||||
expect(decoded).not.toBeNull();
|
||||
|
||||
if (decoded && decoded.type === "position") {
|
||||
if (decoded && decoded.type === DataType.MicECurrent) {
|
||||
expect(decoded.position.latitude).toBeCloseTo(1 + 20.45 / 60, 3);
|
||||
}
|
||||
});
|
||||
@@ -510,11 +510,11 @@ describe("Frame.decodeMicE", () => {
|
||||
it("should decode latitude with mixed digits and letters", () => {
|
||||
const data = "CALL>4AB2DE:`c.l+@&'/'\"G:}";
|
||||
const frame = Frame.fromString(data);
|
||||
const decoded = frame.decode() as Payload;
|
||||
const decoded = frame.decode() as MicEPayload;
|
||||
|
||||
expect(decoded).not.toBeNull();
|
||||
|
||||
if (decoded && decoded.type === "position") {
|
||||
if (decoded && decoded.type === DataType.MicECurrent) {
|
||||
expect(decoded.position.latitude).toBeCloseTo(40 + 12.34 / 60, 3);
|
||||
}
|
||||
});
|
||||
@@ -522,11 +522,11 @@ describe("Frame.decodeMicE", () => {
|
||||
it("should decode latitude for southern hemisphere", () => {
|
||||
const data = "CALL>4A0P0U:`c.l+@&'/'\"G:}";
|
||||
const frame = Frame.fromString(data);
|
||||
const decoded = frame.decode() as Payload;
|
||||
const decoded = frame.decode() as MicEPayload;
|
||||
|
||||
expect(decoded).not.toBeNull();
|
||||
|
||||
if (decoded && decoded.type === "position") {
|
||||
if (decoded && decoded.type === DataType.MicECurrent) {
|
||||
expect(decoded.position.latitude).toBeLessThan(0);
|
||||
}
|
||||
});
|
||||
@@ -536,11 +536,11 @@ describe("Frame.decodeMicE", () => {
|
||||
it("should decode longitude from information field", () => {
|
||||
const data = "CALL>4ABCDE:`c.l+@&'/'\"G:}";
|
||||
const frame = Frame.fromString(data);
|
||||
const decoded = frame.decode() as Payload;
|
||||
const decoded = frame.decode() as MicEPayload;
|
||||
|
||||
expect(decoded).not.toBeNull();
|
||||
|
||||
if (decoded && decoded.type === "position") {
|
||||
if (decoded && decoded.type === DataType.MicECurrent) {
|
||||
expect(typeof decoded.position.longitude).toBe("number");
|
||||
expect(decoded.position.longitude).toBeGreaterThanOrEqual(-180);
|
||||
expect(decoded.position.longitude).toBeLessThanOrEqual(180);
|
||||
@@ -550,11 +550,11 @@ describe("Frame.decodeMicE", () => {
|
||||
it("should handle eastern hemisphere longitude", () => {
|
||||
const data = "CALL>4ABPDE:`c.l+@&'/'\"G:}";
|
||||
const frame = Frame.fromString(data);
|
||||
const decoded = frame.decode() as Payload;
|
||||
const decoded = frame.decode() as MicEPayload;
|
||||
|
||||
expect(decoded).not.toBeNull();
|
||||
|
||||
if (decoded && decoded.type === "position") {
|
||||
if (decoded && decoded.type === DataType.MicECurrent) {
|
||||
expect(typeof decoded.position.longitude).toBe("number");
|
||||
}
|
||||
});
|
||||
@@ -562,11 +562,11 @@ describe("Frame.decodeMicE", () => {
|
||||
it("should handle longitude offset +100", () => {
|
||||
const data = "CALL>4ABCDP:`c.l+@&'/'\"G:}";
|
||||
const frame = Frame.fromString(data);
|
||||
const decoded = frame.decode() as Payload;
|
||||
const decoded = frame.decode() as MicEPayload;
|
||||
|
||||
expect(decoded).not.toBeNull();
|
||||
|
||||
if (decoded && decoded.type === "position") {
|
||||
if (decoded && decoded.type === DataType.MicECurrent) {
|
||||
expect(typeof decoded.position.longitude).toBe("number");
|
||||
expect(Math.abs(decoded.position.longitude)).toBeGreaterThan(90);
|
||||
}
|
||||
@@ -581,7 +581,7 @@ describe("Frame.decodeMicE", () => {
|
||||
|
||||
expect(decoded).not.toBeNull();
|
||||
|
||||
if (decoded && decoded.type === "position") {
|
||||
if (decoded && decoded.type === DataType.MicECurrent) {
|
||||
if (decoded.position.speed !== undefined) {
|
||||
expect(decoded.position.speed).toBeGreaterThanOrEqual(0);
|
||||
expect(typeof decoded.position.speed).toBe("number");
|
||||
@@ -596,7 +596,7 @@ describe("Frame.decodeMicE", () => {
|
||||
|
||||
expect(decoded).not.toBeNull();
|
||||
|
||||
if (decoded && decoded.type === "position") {
|
||||
if (decoded && decoded.type === DataType.MicECurrent) {
|
||||
if (decoded.position.course !== undefined) {
|
||||
expect(decoded.position.course).toBeGreaterThanOrEqual(0);
|
||||
expect(decoded.position.course).toBeLessThan(360);
|
||||
@@ -611,7 +611,7 @@ describe("Frame.decodeMicE", () => {
|
||||
|
||||
expect(decoded).not.toBeNull();
|
||||
|
||||
if (decoded && decoded.type === "position") {
|
||||
if (decoded && decoded.type === DataType.MicECurrent) {
|
||||
expect(decoded.position.speed).toBeUndefined();
|
||||
}
|
||||
});
|
||||
@@ -619,11 +619,11 @@ describe("Frame.decodeMicE", () => {
|
||||
it("should not include zero or 360+ course in result", () => {
|
||||
const data = "CALL>4ABCDE:`c.l\x1c\x1c\x1c/>}";
|
||||
const frame = Frame.fromString(data);
|
||||
const decoded = frame.decode() as Payload;
|
||||
const decoded = frame.decode() as MicEPayload;
|
||||
|
||||
expect(decoded).not.toBeNull();
|
||||
|
||||
if (decoded && decoded.type === "position") {
|
||||
if (decoded && decoded.type === DataType.MicECurrent) {
|
||||
if (decoded.position.course !== undefined) {
|
||||
expect(decoded.position.course).toBeGreaterThan(0);
|
||||
expect(decoded.position.course).toBeLessThan(360);
|
||||
@@ -640,7 +640,7 @@ describe("Frame.decodeMicE", () => {
|
||||
|
||||
expect(decoded).not.toBeNull();
|
||||
|
||||
if (decoded && decoded.type === "position") {
|
||||
if (decoded && decoded.type === DataType.MicECurrent) {
|
||||
expect(decoded.position.symbol).toBeDefined();
|
||||
expect(decoded.position.symbol?.table).toBeDefined();
|
||||
expect(decoded.position.symbol?.code).toBeDefined();
|
||||
@@ -658,7 +658,7 @@ describe("Frame.decodeMicE", () => {
|
||||
|
||||
expect(decoded).not.toBeNull();
|
||||
|
||||
if (decoded && decoded.type === "position") {
|
||||
if (decoded && decoded.type === DataType.MicECurrent) {
|
||||
expect(decoded.position.altitude).toBeCloseTo(1234 * 0.3048, 1);
|
||||
}
|
||||
});
|
||||
@@ -670,7 +670,7 @@ describe("Frame.decodeMicE", () => {
|
||||
|
||||
expect(decoded).not.toBeNull();
|
||||
|
||||
if (decoded && decoded.type === "position") {
|
||||
if (decoded && decoded.type === DataType.MicECurrent) {
|
||||
if (decoded.position.comment?.startsWith("}")) {
|
||||
expect(decoded.position.altitude).toBeDefined();
|
||||
}
|
||||
@@ -680,11 +680,11 @@ describe("Frame.decodeMicE", () => {
|
||||
it("should prefer /A= format over base-91 when both present", () => {
|
||||
const data = "CALL>4ABCDE:`c.l+@&'/'\"G:}}!!!/A=005000";
|
||||
const frame = Frame.fromString(data);
|
||||
const decoded = frame.decode() as Payload;
|
||||
const decoded = frame.decode() as MicEPayload;
|
||||
|
||||
expect(decoded).not.toBeNull();
|
||||
|
||||
if (decoded && decoded.type === "position") {
|
||||
if (decoded && decoded.type === DataType.MicECurrent) {
|
||||
expect(decoded.position.altitude).toBeCloseTo(5000 * 0.3048, 1);
|
||||
}
|
||||
});
|
||||
@@ -692,11 +692,11 @@ describe("Frame.decodeMicE", () => {
|
||||
it("should handle comment without altitude", () => {
|
||||
const data = "CALL>4ABCDE:`c.l+@&'/'\"G:}Just a comment";
|
||||
const frame = Frame.fromString(data);
|
||||
const decoded = frame.decode() as Payload;
|
||||
const decoded = frame.decode() as MicEPayload;
|
||||
|
||||
expect(decoded).not.toBeNull();
|
||||
|
||||
if (decoded && decoded.type === "position") {
|
||||
if (decoded && decoded.type === DataType.MicECurrent) {
|
||||
expect(decoded.position.altitude).toBeUndefined();
|
||||
expect(decoded.position.comment).toContain("Just a comment");
|
||||
}
|
||||
@@ -707,39 +707,34 @@ describe("Frame.decodeMicE", () => {
|
||||
it("should decode message type M0 (Off Duty)", () => {
|
||||
const data = "CALL>012345:`c.l+@&'/'\"G:}";
|
||||
const frame = Frame.fromString(data);
|
||||
const decoded = frame.decode() as Payload;
|
||||
const decoded = frame.decode() as MicEPayload;
|
||||
|
||||
expect(decoded).not.toBeNull();
|
||||
|
||||
if (decoded && decoded.type === "position") {
|
||||
expect(decoded.micE?.messageType).toBe("M0: Off Duty");
|
||||
if (decoded && decoded.type === DataType.MicECurrent) {
|
||||
expect(decoded.messageType).toBe("M0: Off Duty");
|
||||
}
|
||||
});
|
||||
|
||||
it("should decode message type M7 (Emergency)", () => {
|
||||
const data = "CALL>ABCDEF:`c.l+@&'/'\"G:}";
|
||||
const frame = Frame.fromString(data);
|
||||
const decoded = frame.decode() as Payload;
|
||||
const decoded = frame.decode() as MicEPayload;
|
||||
|
||||
expect(decoded).not.toBeNull();
|
||||
|
||||
if (decoded && decoded.type === "position") {
|
||||
expect(decoded.micE?.messageType).toBeDefined();
|
||||
expect(typeof decoded.micE?.messageType).toBe("string");
|
||||
if (decoded && decoded.type === DataType.MicECurrent) {
|
||||
expect(decoded.messageType).toBeDefined();
|
||||
expect(typeof decoded.messageType).toBe("string");
|
||||
}
|
||||
});
|
||||
|
||||
it("should decode standard vs custom message indicator", () => {
|
||||
const data = "CALL>ABCDEF:`c.l+@&'/'\"G:}";
|
||||
const frame = Frame.fromString(data);
|
||||
const decoded = frame.decode() as Payload;
|
||||
const decoded = frame.decode() as MicEPayload;
|
||||
|
||||
expect(decoded).not.toBeNull();
|
||||
|
||||
if (decoded && decoded.type === "position") {
|
||||
expect(decoded.micE?.isStandard).toBeDefined();
|
||||
expect(typeof decoded.micE?.isStandard).toBe("boolean");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -751,7 +746,7 @@ describe("Frame.decodeMicE", () => {
|
||||
|
||||
expect(decoded).not.toBeNull();
|
||||
|
||||
if (decoded && decoded.type === "position") {
|
||||
if (decoded && decoded.type === DataType.MicECurrent) {
|
||||
expect(decoded.position.comment).toContain("This is a test comment");
|
||||
}
|
||||
});
|
||||
@@ -763,7 +758,7 @@ describe("Frame.decodeMicE", () => {
|
||||
|
||||
expect(decoded).not.toBeNull();
|
||||
|
||||
if (decoded && decoded.type === "position") {
|
||||
if (decoded && decoded.type === DataType.MicECurrent) {
|
||||
expect(decoded.position.comment).toBeDefined();
|
||||
}
|
||||
});
|
||||
@@ -799,8 +794,10 @@ describe("Frame.decodeMicE", () => {
|
||||
const frame = Frame.fromString(data);
|
||||
|
||||
expect(() => frame.decode()).not.toThrow();
|
||||
const decoded = frame.decode() as Payload;
|
||||
expect(decoded === null || decoded?.type === "position").toBe(true);
|
||||
const decoded = frame.decode() as MicEPayload;
|
||||
expect(decoded === null || decoded?.type === DataType.MicECurrent).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -809,37 +806,21 @@ describe("Frame.decodeMicE", () => {
|
||||
const data =
|
||||
"N83MZ>T2TQ5U,WA1PLE-4*:`c.l+@&'/'\"G:} KJ6TMS|!:&0'p|!w#f!|3";
|
||||
const frame = Frame.fromString(data);
|
||||
const decoded = frame.decode() as Payload;
|
||||
const decoded = frame.decode() as MicEPayload;
|
||||
|
||||
expect(decoded).not.toBeNull();
|
||||
expect(decoded?.type).toBe("position");
|
||||
expect(decoded?.type).toBe(DataType.MicECurrent);
|
||||
|
||||
if (decoded && decoded.type === "position") {
|
||||
expect(decoded.messaging).toBe(true);
|
||||
if (decoded && decoded.type === DataType.MicECurrent) {
|
||||
expect(decoded.position.latitude).toBeDefined();
|
||||
expect(decoded.position.longitude).toBeDefined();
|
||||
expect(decoded.position.symbol).toBeDefined();
|
||||
expect(decoded.micE).toBeDefined();
|
||||
|
||||
expect(Math.abs(decoded.position.latitude)).toBeLessThanOrEqual(90);
|
||||
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", () => {
|
||||
@@ -856,19 +837,19 @@ describe("Packet dissection with sections", () => {
|
||||
expect(result.structure).toBeDefined();
|
||||
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?.fields).toBeDefined();
|
||||
expect(routingSection?.fields?.length).toBeGreaterThan(0);
|
||||
|
||||
const sourceField = routingSection?.fields?.find(
|
||||
(a) => a.name === "Source address",
|
||||
(a) => a.name === "source address",
|
||||
);
|
||||
expect(sourceField).toBeDefined();
|
||||
expect(sourceField?.length).toBeGreaterThan(0);
|
||||
|
||||
const destField = routingSection?.fields?.find(
|
||||
(a) => a.name === "Destination address",
|
||||
(a) => a.name === "destination address",
|
||||
);
|
||||
expect(destField).toBeDefined();
|
||||
expect(destField?.length).toBeGreaterThan(0);
|
||||
@@ -878,12 +859,12 @@ describe("Packet dissection with sections", () => {
|
||||
const data = "CALL>APRS:!4903.50N/07201.75W-Test message";
|
||||
const frame = Frame.fromString(data);
|
||||
const result = frame.decode(true) as {
|
||||
payload: Payload;
|
||||
payload: PositionPayload;
|
||||
structure: Dissected;
|
||||
};
|
||||
|
||||
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?.length).toBeGreaterThan(0);
|
||||
@@ -900,10 +881,10 @@ describe("Packet dissection with sections", () => {
|
||||
it("should not emit sections when emitSections is false or omitted", () => {
|
||||
const data = "CALL>APRS:!4903.50N/07201.75W-Test";
|
||||
const frame = Frame.fromString(data);
|
||||
const result = frame.decode() as Payload;
|
||||
const result = frame.decode() as PositionPayload;
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.type).toBe("position");
|
||||
expect(result?.type).toBe(DataType.PositionNoTimestampNoMessaging);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
expect((result as any).sections).toBeUndefined();
|
||||
});
|
||||
@@ -912,11 +893,13 @@ describe("Packet dissection with sections", () => {
|
||||
const data = "CALL>APRS:@092345z4903.50N/07201.75W>";
|
||||
const frame = Frame.fromString(data);
|
||||
const result = frame.decode(true) as {
|
||||
payload: Payload;
|
||||
payload: PositionPayload;
|
||||
structure: Dissected;
|
||||
};
|
||||
|
||||
expect(result.payload?.type).toBe("position");
|
||||
expect(result.payload?.type).toBe(
|
||||
DataType.PositionWithTimestampWithMessaging,
|
||||
);
|
||||
|
||||
const timestampSection = result.structure?.find(
|
||||
(s) => s.name === "timestamp",
|
||||
@@ -935,11 +918,13 @@ describe("Packet dissection with sections", () => {
|
||||
const data = 'NOCALL-1>APRS:@092345z/:*E";qZ=OMRC/A=088132Hello World!';
|
||||
const frame = Frame.fromString(data);
|
||||
const result = frame.decode(true) as {
|
||||
payload: Payload;
|
||||
payload: PositionPayload;
|
||||
structure: Dissected;
|
||||
};
|
||||
|
||||
expect(result.payload?.type).toBe("position");
|
||||
expect(result.payload?.type).toBe(
|
||||
DataType.PositionWithTimestampWithMessaging,
|
||||
);
|
||||
|
||||
const positionSection = result.structure?.find(
|
||||
(s) => s.name === "position",
|
||||
@@ -956,11 +941,11 @@ describe("Packet dissection with sections", () => {
|
||||
const data = "CALL>APRS:!4903.50N/07201.75W-Test message";
|
||||
const frame = Frame.fromString(data);
|
||||
const result = frame.decode(true) as {
|
||||
payload: Payload;
|
||||
payload: PositionPayload;
|
||||
structure: Dissected;
|
||||
};
|
||||
|
||||
expect(result.payload?.type).toBe("position");
|
||||
expect(result.payload?.type).toBe(DataType.PositionNoTimestampNoMessaging);
|
||||
const commentSection = result.structure?.find((s) => s.name === "comment");
|
||||
expect(commentSection).toBeDefined();
|
||||
expect(commentSection?.data?.byteLength).toBe("Test message".length);
|
||||
@@ -975,7 +960,7 @@ describe("Frame.decodeMessage", () => {
|
||||
const decoded = frame.decode() as MessagePayload;
|
||||
|
||||
expect(decoded).not.toBeNull();
|
||||
expect(decoded?.type).toBe("message");
|
||||
expect(decoded?.type).toBe(DataType.Message);
|
||||
expect(decoded?.addressee).toBe("KB1ABC-5");
|
||||
expect(decoded?.text).toBe("Hello World");
|
||||
});
|
||||
@@ -989,7 +974,7 @@ describe("Frame.decodeMessage", () => {
|
||||
};
|
||||
|
||||
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 textSection = res.structure.find((s) => s.name === "text");
|
||||
expect(recipientSection).toBeDefined();
|
||||
@@ -1013,7 +998,7 @@ describe("Frame.decoding: object and status", () => {
|
||||
expect(res).toHaveProperty("payload");
|
||||
expect(res.payload).not.toBeNull();
|
||||
|
||||
if (res.payload?.type !== "object")
|
||||
if (res.payload?.type !== DataType.Object)
|
||||
throw new Error("expected object payload");
|
||||
|
||||
const payload = res.payload as ObjectPayload & { timestamp?: ITimestamp };
|
||||
@@ -1044,12 +1029,12 @@ describe("Frame.decoding: object and status", () => {
|
||||
expect(res).toHaveProperty("payload");
|
||||
expect(res.payload).not.toBeNull();
|
||||
|
||||
if (res.payload?.type !== "status")
|
||||
if (res.payload?.type !== DataType.Status)
|
||||
throw new Error("expected status payload");
|
||||
|
||||
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?.day).toBe(12);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { Dissected } from "@hamradio/packet";
|
||||
import { Frame } from "../src/frame";
|
||||
import type { UserDefinedPayload } from "../src/frame.types";
|
||||
import { DataType, type UserDefinedPayload } from "../src/frame.types";
|
||||
|
||||
describe("Frame.decodeUserDefined", () => {
|
||||
it("parses packet type only", () => {
|
||||
@@ -9,7 +9,7 @@ describe("Frame.decodeUserDefined", () => {
|
||||
const frame = Frame.fromString(data);
|
||||
const decoded = frame.decode() as UserDefinedPayload;
|
||||
expect(decoded).not.toBeNull();
|
||||
expect(decoded.type).toBe("user-defined");
|
||||
expect(decoded.type).toBe(DataType.UserDefined);
|
||||
expect(decoded.userPacketType).toBe("01");
|
||||
expect(decoded.data).toBe("");
|
||||
});
|
||||
@@ -22,7 +22,7 @@ describe("Frame.decodeUserDefined", () => {
|
||||
structure: Dissected;
|
||||
};
|
||||
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.data).toBe("Hello world");
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { Frame } from "../src/frame";
|
||||
import { WeatherPayload } from "../src/frame.types";
|
||||
import { DataType, WeatherPayload } from "../src/frame.types";
|
||||
import { Dissected } from "@hamradio/packet";
|
||||
|
||||
describe("Frame decode - Weather", () => {
|
||||
@@ -9,7 +9,7 @@ describe("Frame decode - Weather", () => {
|
||||
const frame = Frame.fromString(data);
|
||||
const payload = frame.decode() as WeatherPayload;
|
||||
expect(payload).not.toBeNull();
|
||||
expect(payload.type).toBe("weather");
|
||||
expect(payload.type).toBe(DataType.WeatherReportNoPosition);
|
||||
expect(payload.timestamp).toBeDefined();
|
||||
expect(payload.windDirection).toBe(180);
|
||||
expect(payload.windSpeed).toBe(10);
|
||||
|
||||
Reference in New Issue
Block a user