8 Commits

Author SHA1 Message Date
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
12 changed files with 417 additions and 242 deletions

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,6 @@
{ {
"name": "@hamradio/aprs", "name": "@hamradio/aprs",
"version": "1.1.0", "version": "1.1.3",
"description": "APRS (Automatic Packet Reporting System) protocol support for Typescript", "description": "APRS (Automatic Packet Reporting System) protocol support for Typescript",
"keywords": [ "keywords": [
"APRS", "APRS",

View File

@@ -1,26 +1,28 @@
import type { Dissected, Segment, Field } from "@hamradio/packet"; import type { Dissected, Segment, Field } from "@hamradio/packet";
import { FieldType } from "@hamradio/packet"; import { FieldType } from "@hamradio/packet";
import type { import {
IAddress, type IAddress,
IFrame, type IFrame,
Payload, type Payload,
ITimestamp, type ITimestamp,
PositionPayload, type PositionPayload,
MessagePayload, type MessagePayload,
IPosition, type IPosition,
ObjectPayload, type ObjectPayload,
ItemPayload, type ItemPayload,
StatusPayload, type StatusPayload,
QueryPayload, type QueryPayload,
TelemetryDataPayload, type TelemetryDataPayload,
TelemetryBitSensePayload, type TelemetryBitSensePayload,
TelemetryCoefficientsPayload, type TelemetryCoefficientsPayload,
TelemetryParameterPayload, type TelemetryParameterPayload,
TelemetryUnitPayload, type TelemetryUnitPayload,
WeatherPayload, type WeatherPayload,
RawGPSPayload, type RawGPSPayload,
StationCapabilitiesPayload, type StationCapabilitiesPayload,
ThirdPartyPayload, type ThirdPartyPayload,
DataType,
MicEPayload,
} from "./frame.types"; } from "./frame.types";
import { Position } from "./position"; import { Position } from "./position";
import { base91ToNumber } from "./parser"; import { base91ToNumber } from "./parser";
@@ -376,6 +378,13 @@ export class Frame implements IFrame {
if (routingSection) { if (routingSection) {
structure.push(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) { if (payloadsegment) {
structure.push(...payloadsegment); structure.push(...payloadsegment);
} }
@@ -414,7 +423,9 @@ export class Frame implements IFrame {
offset += 7; offset += 7;
} }
if (this.payload.length < offset + 19) return { payload: null }; // Need at least enough characters for compressed position (13) or
// uncompressed (19). Allow parsing to continue if compressed-length is present.
if (this.payload.length < offset + 13) return { payload: null };
// Check if compressed format // Check if compressed format
const isCompressed = this.isCompressedPosition( const isCompressed = this.isCompressedPosition(
@@ -498,8 +509,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 = { const payload: PositionPayload = {
type: "position", type: payloadType,
timestamp, timestamp,
position, position,
messaging, messaging,
@@ -897,8 +930,20 @@ export class Frame implements IFrame {
} }
} }
const result: PositionPayload = { let payloadType: DataType.MicECurrent | DataType.MicEOld;
type: "position", 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: { position: {
latitude, latitude,
longitude, longitude,
@@ -907,11 +952,8 @@ export class Frame implements IFrame {
code: symbolCode, code: symbolCode,
}, },
}, },
messaging: true, // Mic-E is always messaging-capable
micE: {
messageType, messageType,
isStandard, isStandard,
},
}; };
if (speed > 0) { if (speed > 0) {
@@ -1133,6 +1175,13 @@ export class Frame implements IFrame {
text = this.payload.substring(textStart + 1); text = this.payload.substring(textStart + 1);
} }
const payload: MessagePayload = {
type: DataType.Message,
variant: "message",
addressee: recipient,
text,
};
if (withStructure) { if (withStructure) {
// Emit text section // Emit text section
segments.push({ segments.push({
@@ -1142,21 +1191,9 @@ export class Frame implements IFrame {
fields: [{ type: FieldType.STRING, name: "text", length: text.length }], fields: [{ type: FieldType.STRING, name: "text", length: text.length }],
}); });
const payload: MessagePayload = {
type: "message",
addressee: recipient,
text,
};
return { payload, segment: segments }; return { payload, segment: segments };
} }
const payload: MessagePayload = {
type: "message",
addressee: recipient,
text,
};
return { payload }; return { payload };
} }
@@ -1285,7 +1322,7 @@ export class Frame implements IFrame {
} }
const payload: ObjectPayload = { const payload: ObjectPayload = {
type: "object", type: DataType.Object,
name, name,
timestamp, timestamp,
alive, alive,
@@ -1420,7 +1457,7 @@ export class Frame implements IFrame {
} }
const payload: ItemPayload = { const payload: ItemPayload = {
type: "item", type: DataType.Item,
name, name,
alive, alive,
position, position,
@@ -1472,7 +1509,7 @@ export class Frame implements IFrame {
} }
const payload: StatusPayload = { const payload: StatusPayload = {
type: "status", type: DataType.Status,
timestamp: undefined, timestamp: undefined,
text: statusText, text: statusText,
}; };
@@ -1557,7 +1594,7 @@ export class Frame implements IFrame {
} }
const payload: QueryPayload = { const payload: QueryPayload = {
type: "query", type: DataType.Query,
queryType, queryType,
...(target ? { target } : {}), ...(target ? { target } : {}),
}; };
@@ -1639,7 +1676,8 @@ export class Frame implements IFrame {
} }
const payload: TelemetryDataPayload = { const payload: TelemetryDataPayload = {
type: "telemetry-data", type: DataType.TelemetryData,
variant: "data",
sequence: isNaN(seq) ? 0 : seq, sequence: isNaN(seq) ? 0 : seq,
analog, analog,
digital: isNaN(digital) ? 0 : digital, digital: isNaN(digital) ? 0 : digital,
@@ -1664,7 +1702,8 @@ export class Frame implements IFrame {
}); });
} }
const payload: TelemetryParameterPayload = { const payload: TelemetryParameterPayload = {
type: "telemetry-parameters", type: DataType.TelemetryData,
variant: "parameters",
names, names,
}; };
if (withStructure) return { payload, segment: segments }; if (withStructure) return { payload, segment: segments };
@@ -1686,7 +1725,8 @@ export class Frame implements IFrame {
}); });
} }
const payload: TelemetryUnitPayload = { const payload: TelemetryUnitPayload = {
type: "telemetry-units", type: DataType.TelemetryData,
variant: "unit",
units, units,
}; };
if (withStructure) return { payload, segment: segments }; if (withStructure) return { payload, segment: segments };
@@ -1717,7 +1757,8 @@ export class Frame implements IFrame {
}); });
} }
const payload: TelemetryCoefficientsPayload = { const payload: TelemetryCoefficientsPayload = {
type: "telemetry-coefficients", type: DataType.TelemetryData,
variant: "coefficients",
coefficients, coefficients,
}; };
if (withStructure) return { payload, segment: segments }; if (withStructure) return { payload, segment: segments };
@@ -1741,7 +1782,8 @@ export class Frame implements IFrame {
}); });
} }
const payload: TelemetryBitSensePayload = { const payload: TelemetryBitSensePayload = {
type: "telemetry-bitsense", type: DataType.TelemetryData,
variant: "bitsense",
sense: isNaN(sense) ? 0 : sense, sense: isNaN(sense) ? 0 : sense,
...(projectName ? { projectName } : {}), ...(projectName ? { projectName } : {}),
}; };
@@ -1818,7 +1860,9 @@ export class Frame implements IFrame {
const rest = this.payload.substring(offset).trim(); const rest = this.payload.substring(offset).trim();
const payload: WeatherPayload = { type: "weather" }; const payload: WeatherPayload = {
type: DataType.WeatherReportNoPosition,
};
if (timestamp) payload.timestamp = timestamp; if (timestamp) payload.timestamp = timestamp;
if (position) payload.position = position; if (position) payload.position = position;
@@ -1894,7 +1938,7 @@ export class Frame implements IFrame {
} }
const payload: RawGPSPayload = { const payload: RawGPSPayload = {
type: "raw-gps", type: DataType.RawGPS,
sentence, sentence,
}; };
@@ -2055,7 +2099,7 @@ export class Frame implements IFrame {
.filter(Boolean); .filter(Boolean);
const payload: StationCapabilitiesPayload = { const payload: StationCapabilitiesPayload = {
type: "capabilities", type: DataType.StationCapabilities,
capabilities: tokens, capabilities: tokens,
} as const; } as const;
@@ -2118,7 +2162,7 @@ export class Frame implements IFrame {
} }
const payloadObj = { const payloadObj = {
type: "user-defined", type: DataType.UserDefined,
userPacketType, userPacketType,
data, data,
} as const; } as const;
@@ -2185,7 +2229,7 @@ export class Frame implements IFrame {
} }
const payloadObj: ThirdPartyPayload = { const payloadObj: ThirdPartyPayload = {
type: "third-party", type: DataType.ThirdParty,
comment: rest, comment: rest,
...(nestedFrame ? { frame: nestedFrame } : {}), ...(nestedFrame ? { frame: nestedFrame } : {}),
} as const; } as const;
@@ -2271,34 +2315,34 @@ const parseFrame = (data: string): Frame => {
pathFields.push({ pathFields.push({
type: FieldType.CHAR, type: FieldType.CHAR,
name: `Path separator ${i}`, name: `path separator ${i}`,
length: 1, length: 1,
}); });
pathFields.push({ pathFields.push({
type: FieldType.STRING, type: FieldType.STRING,
name: `Repeater ${i}`, name: `repeater ${i}`,
length: pathStr.length, length: pathStr.length,
}); });
} }
const routingSection: Segment = { const routingSection: Segment = {
name: "Routing", name: "routing",
data: encoder.encode(data.slice(0, routeSepIndex)).buffer, data: encoder.encode(data.slice(0, routeSepIndex + 1)).buffer,
isString: true, isString: true,
fields: [ fields: [
{ {
type: FieldType.STRING, type: FieldType.STRING,
name: "Source address", name: "source address",
length: sourceStr.length, length: sourceStr.length,
}, },
{ type: FieldType.CHAR, name: "Route separator", length: 1 }, { type: FieldType.CHAR, name: "route separator", length: 1 },
{ {
type: FieldType.STRING, type: FieldType.STRING,
name: "Destination address", name: "destination address",
length: destinationStr.length, length: destinationStr.length,
}, },
...pathFields, ...pathFields,
{ type: FieldType.CHAR, name: "Payload separator", length: 1 }, { type: FieldType.CHAR, name: "payload separator", length: 1 },
], ],
}; };

View File

@@ -14,54 +14,51 @@ 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: "`", MicECurrent = "`",
MicEOld: "'", MicEOld = "'",
// Messages and Bulletins // Messages and Bulletins
Message: ":", Message = ":",
// Objects and Items // Objects and Items
Object: ";", Object = ";",
Item: ")", Item = ")",
// Status // Status
Status: ">", Status = ">",
// Query // Query
Query: "?", Query = "?",
// Telemetry // Telemetry
TelemetryData: "T", TelemetryData = "T",
// Weather // Weather
WeatherReportNoPosition: "_", WeatherReportNoPosition = "_",
// Raw GPS Data // Raw GPS Data
RawGPS: "$", RawGPS = "$",
// Station Capabilities // Station Capabilities
StationCapabilities: "<", StationCapabilities = "<",
// User-Defined // User-Defined
UserDefined: "{", UserDefined = "{",
// Third-Party Traffic // Third-Party Traffic
ThirdParty: "}", ThirdParty = "}",
// Invalid/Test Data // Invalid/Test Data
InvalidOrTest: ",", InvalidOrTest = ",",
} as const; }
export type DataTypeIdentifier =
(typeof DataTypeIdentifier)[keyof typeof DataTypeIdentifier];
export interface ISymbol { export interface ISymbol {
table: string; // Symbol table identifier table: string; // Symbol table identifier
@@ -99,7 +96,11 @@ export interface ITimestamp {
// Position Report Payload // Position Report Payload
export interface PositionPayload { export interface PositionPayload {
type: "position"; type:
| DataType.PositionNoTimestampNoMessaging
| DataType.PositionNoTimestampWithMessaging
| DataType.PositionWithTimestampNoMessaging
| DataType.PositionWithTimestampWithMessaging;
timestamp?: ITimestamp; timestamp?: ITimestamp;
position: IPosition; position: IPosition;
messaging: boolean; // Whether APRS messaging is enabled messaging: boolean; // Whether APRS messaging is enabled
@@ -128,19 +129,20 @@ 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.MicECurrent | DataType.MicEOld;
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";
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 +152,8 @@ 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";
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 +161,7 @@ export interface BulletinPayload {
// Object Payload // Object Payload
export interface ObjectPayload { export interface ObjectPayload {
type: "object"; type: DataType.Object;
name: string; // 9 character object name name: string; // 9 character object name
timestamp: ITimestamp; timestamp: ITimestamp;
alive: boolean; // True if object is active, false if killed alive: boolean; // True if object is active, false if killed
@@ -169,7 +172,7 @@ export interface ObjectPayload {
// Item Payload // Item Payload
export interface ItemPayload { export interface ItemPayload {
type: "item"; type: DataType.Item;
name: string; // 3-9 character item name name: string; // 3-9 character item name
alive: boolean; // True if item is active, false if killed alive: boolean; // True if item is active, false if killed
position: IPosition; position: IPosition;
@@ -177,7 +180,7 @@ export interface ItemPayload {
// Status Payload // Status Payload
export interface StatusPayload { export interface StatusPayload {
type: "status"; type: DataType.Status;
timestamp?: ITimestamp; timestamp?: ITimestamp;
text: string; text: string;
maidenhead?: string; // Optional Maidenhead grid locator maidenhead?: string; // Optional Maidenhead grid locator
@@ -189,14 +192,22 @@ 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 +215,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 +240,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 +270,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 +309,7 @@ export interface DFReportPayload {
} }
export interface BasePayload { export interface BasePayload {
type: string; type: DataType;
} }
// Union type for all decoded payload types // Union type for all decoded payload types
@@ -319,7 +333,6 @@ export type Payload = BasePayload &
| StationCapabilitiesPayload | StationCapabilitiesPayload
| UserDefinedPayload | UserDefinedPayload
| ThirdPartyPayload | ThirdPartyPayload
| DFReportPayload
); );
// Extended Frame with decoded payload // Extended Frame with decoded payload

View File

@@ -1,8 +1,13 @@
export { Frame, Address, Timestamp } from "./frame"; 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 { export {
DataType,
type ISymbol, type ISymbol,
type IPosition, type IPosition,
type ITimestamp, type ITimestamp,

View File

@@ -1,6 +1,10 @@
import { describe, it, expect } from "vitest"; import { describe, it, expect } from "vitest";
import { Frame } from "../src/frame"; 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"; import { Dissected } from "@hamradio/packet";
describe("Frame.decodeCapabilities", () => { describe("Frame.decodeCapabilities", () => {
@@ -9,7 +13,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 +27,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");

View File

@@ -2,14 +2,14 @@ import { expect } from "vitest";
import { describe, it } from "vitest"; import { describe, it } from "vitest";
import { Dissected } from "@hamradio/packet"; 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 +18,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 +30,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,6 +1,6 @@
import { describe, it, expect } from "vitest"; import { describe, it, expect } from "vitest";
import { Frame } from "../src/frame"; 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"; import { Dissected } from "@hamradio/packet";
describe("Raw GPS decoding", () => { describe("Raw GPS decoding", () => {
@@ -13,7 +13,7 @@ describe("Raw GPS decoding", () => {
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");
@@ -32,7 +32,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");

View File

@@ -5,6 +5,7 @@ import {
TelemetryUnitPayload, TelemetryUnitPayload,
TelemetryCoefficientsPayload, TelemetryCoefficientsPayload,
TelemetryBitSensePayload, TelemetryBitSensePayload,
DataType,
} from "../src/frame.types"; } from "../src/frame.types";
import { Frame } from "../src/frame"; import { Frame } from "../src/frame";
import { expect } from "vitest"; 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 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 +27,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 +37,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 +46,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 +57,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,12 +1,14 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { Address, Frame, Timestamp } from "../src/frame"; import { Address, Frame, Timestamp } from "../src/frame";
import type { import {
Payload, type Payload,
PositionPayload, type PositionPayload,
ObjectPayload, type ObjectPayload,
StatusPayload, type StatusPayload,
ITimestamp, type ITimestamp,
MessagePayload, type MessagePayload,
DataType,
MicEPayload,
} from "../src/frame.types"; } from "../src/frame.types";
import { Dissected, FieldType } from "@hamradio/packet"; import { Dissected, FieldType } from "@hamradio/packet";
@@ -79,7 +81,7 @@ describe("Frame.decode (basic)", () => {
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", () => {
@@ -217,17 +219,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.MicECurrent);
}); });
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 +239,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,7 +252,7 @@ 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();
@@ -266,10 +268,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);
} }
@@ -294,7 +296,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.PositionWithTimestampWithMessaging);
}); });
it("decodes uncompressed position without timestamp", () => { it("decodes uncompressed position without timestamp", () => {
@@ -302,7 +304,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", () => {
@@ -322,7 +324,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");
@@ -349,7 +351,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);
}); });
}); });
@@ -456,29 +458,27 @@ describe("Frame.decodeMicE", () => {
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.MicECurrent);
if (decoded && decoded.type === "position") { if (decoded && decoded.type === DataType.MicECurrent) {
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 +486,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.MicECurrent) {
expect(decoded.position.latitude).toBeCloseTo(12 + 34.56 / 60, 3); 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)", () => { 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.MicECurrent) {
expect(decoded.position.latitude).toBeCloseTo(1 + 20.45 / 60, 3); 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", () => { 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.MicECurrent) {
expect(decoded.position.latitude).toBeCloseTo(40 + 12.34 / 60, 3); expect(decoded.position.latitude).toBeCloseTo(40 + 12.34 / 60, 3);
} }
}); });
@@ -522,11 +522,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.MicECurrent) {
expect(decoded.position.latitude).toBeLessThan(0); expect(decoded.position.latitude).toBeLessThan(0);
} }
}); });
@@ -536,11 +536,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.MicECurrent) {
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 +550,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.MicECurrent) {
expect(typeof decoded.position.longitude).toBe("number"); expect(typeof decoded.position.longitude).toBe("number");
} }
}); });
@@ -562,11 +562,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.MicECurrent) {
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 +581,7 @@ describe("Frame.decodeMicE", () => {
expect(decoded).not.toBeNull(); expect(decoded).not.toBeNull();
if (decoded && decoded.type === "position") { if (decoded && decoded.type === DataType.MicECurrent) {
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 +596,7 @@ describe("Frame.decodeMicE", () => {
expect(decoded).not.toBeNull(); expect(decoded).not.toBeNull();
if (decoded && decoded.type === "position") { if (decoded && decoded.type === DataType.MicECurrent) {
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 +611,7 @@ describe("Frame.decodeMicE", () => {
expect(decoded).not.toBeNull(); expect(decoded).not.toBeNull();
if (decoded && decoded.type === "position") { if (decoded && decoded.type === DataType.MicECurrent) {
expect(decoded.position.speed).toBeUndefined(); expect(decoded.position.speed).toBeUndefined();
} }
}); });
@@ -619,11 +619,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.MicECurrent) {
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 +640,7 @@ describe("Frame.decodeMicE", () => {
expect(decoded).not.toBeNull(); expect(decoded).not.toBeNull();
if (decoded && decoded.type === "position") { if (decoded && decoded.type === DataType.MicECurrent) {
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();
@@ -658,7 +658,7 @@ describe("Frame.decodeMicE", () => {
expect(decoded).not.toBeNull(); expect(decoded).not.toBeNull();
if (decoded && decoded.type === "position") { if (decoded && decoded.type === DataType.MicECurrent) {
expect(decoded.position.altitude).toBeCloseTo(1234 * 0.3048, 1); expect(decoded.position.altitude).toBeCloseTo(1234 * 0.3048, 1);
} }
}); });
@@ -670,7 +670,7 @@ describe("Frame.decodeMicE", () => {
expect(decoded).not.toBeNull(); expect(decoded).not.toBeNull();
if (decoded && decoded.type === "position") { if (decoded && decoded.type === DataType.MicECurrent) {
if (decoded.position.comment?.startsWith("}")) { if (decoded.position.comment?.startsWith("}")) {
expect(decoded.position.altitude).toBeDefined(); expect(decoded.position.altitude).toBeDefined();
} }
@@ -680,11 +680,11 @@ describe("Frame.decodeMicE", () => {
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.MicECurrent) {
expect(decoded.position.altitude).toBeCloseTo(5000 * 0.3048, 1); expect(decoded.position.altitude).toBeCloseTo(5000 * 0.3048, 1);
} }
}); });
@@ -692,11 +692,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.MicECurrent) {
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 +707,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.MicECurrent) {
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.MicECurrent) {
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 +746,7 @@ describe("Frame.decodeMicE", () => {
expect(decoded).not.toBeNull(); 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"); expect(decoded.position.comment).toContain("This is a test comment");
} }
}); });
@@ -763,7 +758,7 @@ describe("Frame.decodeMicE", () => {
expect(decoded).not.toBeNull(); expect(decoded).not.toBeNull();
if (decoded && decoded.type === "position") { if (decoded && decoded.type === DataType.MicECurrent) {
expect(decoded.position.comment).toBeDefined(); expect(decoded.position.comment).toBeDefined();
} }
}); });
@@ -799,8 +794,10 @@ 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.MicECurrent).toBe(
true,
);
}); });
}); });
@@ -809,37 +806,21 @@ describe("Frame.decodeMicE", () => {
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.MicECurrent);
if (decoded && decoded.type === "position") { if (decoded && decoded.type === DataType.MicECurrent) {
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,19 +837,19 @@ 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,12 +859,12 @@ 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);
@@ -900,10 +881,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,11 +893,13 @@ 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",
@@ -935,11 +918,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",
@@ -956,11 +941,11 @@ 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);
@@ -975,7 +960,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,7 +974,7 @@ 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();
@@ -1013,7 +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 +1029,12 @@ 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,7 +1,7 @@
import { describe, it, expect } from "vitest"; import { describe, it, expect } from "vitest";
import { Dissected } from "@hamradio/packet"; import { Dissected } from "@hamradio/packet";
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 +9,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,7 +22,7 @@ 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");

View File

@@ -1,6 +1,6 @@
import { describe, it, expect } from "vitest"; import { describe, it, expect } from "vitest";
import { Frame } from "../src/frame"; import { Frame } from "../src/frame";
import { WeatherPayload } from "../src/frame.types"; import { DataType, WeatherPayload } from "../src/frame.types";
import { Dissected } from "@hamradio/packet"; import { Dissected } from "@hamradio/packet";
describe("Frame decode - Weather", () => { describe("Frame decode - Weather", () => {
@@ -9,7 +9,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);