Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
be8cd00c00
|
|||
|
7dc15e360d
|
|||
|
b1cd8449d9
|
|||
|
78dbd3b0ef
|
|||
|
df266bab12
|
|||
|
0ab62dab02
|
|||
|
38b617728c
|
|||
|
16f638301b
|
|||
|
d0a100359d
|
|||
|
c300aefc0b
|
|||
|
074806528f
|
|||
|
d62d7962fe
|
|||
|
1f4108b888
|
|||
|
eca757b24f
|
|||
|
e0d4844c5b
|
|||
|
4669783b67
|
|||
|
94c96ebf15
|
|||
|
121aa9d1ad
|
|||
|
ebe4670c08
|
|||
|
08177f4e6f
|
3
.gitignore
vendored
3
.gitignore
vendored
@@ -103,6 +103,9 @@ web_modules/
|
|||||||
# Optional npm cache directory
|
# Optional npm cache directory
|
||||||
.npm
|
.npm
|
||||||
|
|
||||||
|
# Optional npm package-lock.json
|
||||||
|
package-lock.json
|
||||||
|
|
||||||
# Optional eslint cache
|
# Optional eslint cache
|
||||||
.eslintcache
|
.eslintcache
|
||||||
|
|
||||||
|
|||||||
@@ -11,16 +11,22 @@ repos:
|
|||||||
hooks:
|
hooks:
|
||||||
- id: shellcheck
|
- id: shellcheck
|
||||||
|
|
||||||
- repo: https://github.com/pre-commit/mirrors-eslint
|
- repo: local
|
||||||
rev: v10.0.3
|
hooks:
|
||||||
|
- id: prettier
|
||||||
|
name: prettier
|
||||||
|
entry: npx prettier --write
|
||||||
|
language: system
|
||||||
|
files: "\\.(js|jsx|ts|tsx)$"
|
||||||
|
|
||||||
|
- repo: local
|
||||||
hooks:
|
hooks:
|
||||||
- id: eslint
|
- id: eslint
|
||||||
|
name: eslint
|
||||||
|
entry: npx eslint --fix
|
||||||
|
language: system
|
||||||
files: "\\.(js|jsx|ts|tsx)$"
|
files: "\\.(js|jsx|ts|tsx)$"
|
||||||
exclude: node_modules/
|
|
||||||
|
|
||||||
# Use stylelint (local) instead of the deprecated scss-lint Ruby gem which
|
|
||||||
# cannot parse modern Sass `@use` and module syntax. This invokes the
|
|
||||||
# project's installed `stylelint` via `npx` so the devDependency is used.
|
|
||||||
- repo: local
|
- repo: local
|
||||||
hooks:
|
hooks:
|
||||||
- id: stylelint
|
- id: stylelint
|
||||||
|
|||||||
19
.prettierrc.ts
Normal file
19
.prettierrc.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { type Config } from "prettier";
|
||||||
|
|
||||||
|
const config: Config = {
|
||||||
|
plugins: ["@trivago/prettier-plugin-sort-imports"],
|
||||||
|
trailingComma: "none",
|
||||||
|
printWidth: 120,
|
||||||
|
importOrder: [
|
||||||
|
"<BUILTIN_MODULES>",
|
||||||
|
"<THIRD_PARTY_MODULES>",
|
||||||
|
"(?:services|components|contexts|pages|libs|types)/(.*)$",
|
||||||
|
"^[./].*\\.(?:ts|tsx)$",
|
||||||
|
"\\.(?:scss|css)$",
|
||||||
|
"^[./]"
|
||||||
|
],
|
||||||
|
importOrderSeparation: true,
|
||||||
|
importOrderSortSpecifiers: true
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
118
README.md
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
|
||||||
|
|||||||
3283
package-lock.json
generated
3283
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
12
package.json
12
package.json
@@ -1,6 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "@hamradio/aprs",
|
"name": "@hamradio/aprs",
|
||||||
"version": "1.0.0",
|
"type": "module",
|
||||||
|
"version": "1.2.0",
|
||||||
"description": "APRS (Automatic Packet Reporting System) protocol support for Typescript",
|
"description": "APRS (Automatic Packet Reporting System) protocol support for Typescript",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"APRS",
|
"APRS",
|
||||||
@@ -11,7 +12,7 @@
|
|||||||
],
|
],
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://git.maze.io/ham/aprs.js"
|
"url": "https://git.maze.io/ham/aprs.ts"
|
||||||
},
|
},
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"author": "Wijnand Modderman-Lenstra",
|
"author": "Wijnand Modderman-Lenstra",
|
||||||
@@ -38,15 +39,20 @@
|
|||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"prepare": "npm run build"
|
"prepare": "npm run build"
|
||||||
},
|
},
|
||||||
"dependencies": {},
|
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^10.0.1",
|
"@eslint/js": "^10.0.1",
|
||||||
|
"@trivago/prettier-plugin-sort-imports": "^6.0.2",
|
||||||
"@vitest/coverage-v8": "^4.0.18",
|
"@vitest/coverage-v8": "^4.0.18",
|
||||||
"eslint": "^10.0.3",
|
"eslint": "^10.0.3",
|
||||||
"globals": "^17.4.0",
|
"globals": "^17.4.0",
|
||||||
|
"prettier": "^3.8.1",
|
||||||
"tsup": "^8.5.1",
|
"tsup": "^8.5.1",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^5.9.3",
|
||||||
"typescript-eslint": "^8.57.0",
|
"typescript-eslint": "^8.57.0",
|
||||||
"vitest": "^4.0.18"
|
"vitest": "^4.0.18"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@hamradio/packet": "^1.1.0",
|
||||||
|
"extended-nmea": "^2.1.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
115
scripts/release.js
Executable file
115
scripts/release.js
Executable file
@@ -0,0 +1,115 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
// Minimal safe release script.
|
||||||
|
// Usage: node scripts/release.js [major|minor|patch|<version>]
|
||||||
|
const { execSync } = require("child_process");
|
||||||
|
const fs = require("fs");
|
||||||
|
const path = require("path");
|
||||||
|
|
||||||
|
const root = path.resolve(__dirname, "..");
|
||||||
|
const pkgPath = path.join(root, "package.json");
|
||||||
|
|
||||||
|
function run(cmd, opts = {}) {
|
||||||
|
return execSync(cmd, { stdio: "inherit", cwd: root, ...opts });
|
||||||
|
}
|
||||||
|
function runOutput(cmd) {
|
||||||
|
return execSync(cmd, { cwd: root }).toString().trim();
|
||||||
|
}
|
||||||
|
function bumpSemver(current, spec) {
|
||||||
|
if (["major","minor","patch"].includes(spec)) {
|
||||||
|
const [maj, min, patch] = current.split(".").map(n=>parseInt(n,10));
|
||||||
|
if (spec==="major") return `${maj+1}.0.0`;
|
||||||
|
if (spec==="minor") return `${maj}.${min+1}.0`;
|
||||||
|
return `${maj}.${min}.${patch+1}`;
|
||||||
|
}
|
||||||
|
if (!/^\d+\.\d+\.\d+$/.test(spec)) throw new Error("Invalid version spec");
|
||||||
|
return spec;
|
||||||
|
}
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
const arg = process.argv[2] || "patch";
|
||||||
|
const pkgRaw = fs.readFileSync(pkgPath, "utf8");
|
||||||
|
const pkg = JSON.parse(pkgRaw);
|
||||||
|
const oldVersion = pkg.version;
|
||||||
|
const newVersion = bumpSemver(oldVersion, arg);
|
||||||
|
let committed = false;
|
||||||
|
let tagged = false;
|
||||||
|
let pushedTags = false;
|
||||||
|
try {
|
||||||
|
// refuse to run if there are unstaged/uncommitted changes
|
||||||
|
const status = runOutput("git status --porcelain");
|
||||||
|
if (status) throw new Error("Repository has uncommitted changes; please commit or stash before releasing.");
|
||||||
|
|
||||||
|
console.log("Running tests...");
|
||||||
|
run("npm run test:ci");
|
||||||
|
|
||||||
|
console.log("Building...");
|
||||||
|
run("npm run build");
|
||||||
|
|
||||||
|
// write new version
|
||||||
|
pkg.version = newVersion;
|
||||||
|
fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + "\n", "utf8");
|
||||||
|
console.log(`Bumped version: ${oldVersion} -> ${newVersion}`);
|
||||||
|
|
||||||
|
// commit
|
||||||
|
run(`git add ${pkgPath}`);
|
||||||
|
run(`git commit -m "chore(release): v${newVersion} - bump from v${oldVersion}"`);
|
||||||
|
committed = true;
|
||||||
|
|
||||||
|
// ensure tag doesn't already exist locally
|
||||||
|
let localTagExists = false;
|
||||||
|
try {
|
||||||
|
runOutput(`git rev-parse --verify refs/tags/v${newVersion}`);
|
||||||
|
localTagExists = true;
|
||||||
|
} catch (_) {
|
||||||
|
localTagExists = false;
|
||||||
|
}
|
||||||
|
if (localTagExists) throw new Error(`Tag v${newVersion} already exists locally — aborting to avoid overwrite.`);
|
||||||
|
|
||||||
|
// ensure tag doesn't exist on remote
|
||||||
|
const remoteTagInfo = (() => {
|
||||||
|
try { return runOutput(`git ls-remote --tags origin v${newVersion}`); } catch (_) { return ""; }
|
||||||
|
})();
|
||||||
|
if (remoteTagInfo) throw new Error(`Tag v${newVersion} already exists on remote — aborting to avoid overwrite.`);
|
||||||
|
|
||||||
|
// tag
|
||||||
|
run(`git tag -a v${newVersion} -m "Release v${newVersion}"`);
|
||||||
|
tagged = true;
|
||||||
|
|
||||||
|
// push commit and tags
|
||||||
|
run("git push");
|
||||||
|
run("git push --tags");
|
||||||
|
pushedTags = true;
|
||||||
|
|
||||||
|
// publish
|
||||||
|
console.log("Publishing to npm...");
|
||||||
|
const publishCmd = pkg.name && pkg.name.startsWith("@") ? "npm publish --access public" : "npm publish";
|
||||||
|
run(publishCmd);
|
||||||
|
|
||||||
|
console.log(`Release v${newVersion} succeeded.`);
|
||||||
|
process.exit(0);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Release failed:", err.message || err);
|
||||||
|
try {
|
||||||
|
// delete local tag
|
||||||
|
if (tagged) {
|
||||||
|
try { run(`git tag -d v${newVersion}`); } catch {}
|
||||||
|
if (pushedTags) {
|
||||||
|
try { run(`git push origin :refs/tags/v${newVersion}`); } catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// undo commit if made
|
||||||
|
if (committed) {
|
||||||
|
try { run("git reset --hard HEAD~1"); } catch {
|
||||||
|
// fallback: restore package.json content
|
||||||
|
fs.writeFileSync(pkgPath, pkgRaw, "utf8");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// restore package.json
|
||||||
|
fs.writeFileSync(pkgPath, pkgRaw, "utf8");
|
||||||
|
}
|
||||||
|
} catch (rbErr) {
|
||||||
|
console.error("Rollback error:", rbErr.message || rbErr);
|
||||||
|
}
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
})();
|
||||||
1899
src/frame.ts
1899
src/frame.ts
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,4 @@
|
|||||||
import { PacketSegment, PacketStructure } from "./parser.types";
|
import { Dissected, Segment } from "@hamradio/packet";
|
||||||
|
|
||||||
export interface IAddress {
|
export interface IAddress {
|
||||||
call: string;
|
call: string;
|
||||||
@@ -14,53 +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
|
||||||
@@ -79,26 +77,57 @@ export interface IPosition {
|
|||||||
course?: number; // Course in degrees
|
course?: number; // Course in degrees
|
||||||
symbol?: ISymbol;
|
symbol?: ISymbol;
|
||||||
comment?: string;
|
comment?: string;
|
||||||
|
/**
|
||||||
|
* Optional reported radio range in miles (from RNG token in comment)
|
||||||
|
*/
|
||||||
|
range?: number;
|
||||||
|
/**
|
||||||
|
* Optional power/height/gain information from PHG token
|
||||||
|
* PHG format: PHGpphhgg (pp=power, hh=height, gg=gain) as numeric values
|
||||||
|
*/
|
||||||
|
phg?: IPowerHeightGain;
|
||||||
|
/** Direction-finding / DF information parsed from comment tokens */
|
||||||
|
dfs?: IDirectionFinding;
|
||||||
|
|
||||||
toString(): string; // Return combined position representation (e.g., "lat,lon,alt")
|
toString(): string; // Return combined position representation (e.g., "lat,lon,alt")
|
||||||
toCompressed?(): CompressedPosition; // Optional method to convert to compressed format
|
toCompressed?(): CompressedPosition; // Optional method to convert to compressed format
|
||||||
distanceTo?(other: IPosition): number; // Optional method to calculate distance to another position
|
distanceTo?(other: IPosition): number; // Optional method to calculate distance to another position
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface IPowerHeightGain {
|
||||||
|
power?: number; // Transmit power in watts
|
||||||
|
height?: number; // Antenna height in meters
|
||||||
|
gain?: number; // Antenna gain in dBi
|
||||||
|
directivity?: number | "omni" | "unknown"; // Optional directivity pattern (numeric code or "omni")
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IDirectionFinding {
|
||||||
|
bearing?: number; // Direction finding bearing in degrees
|
||||||
|
strength?: number; // Relative signal strength (0-9)
|
||||||
|
height?: number; // Antenna height in meters
|
||||||
|
gain?: number; // Antenna gain in dBi
|
||||||
|
quality?: number; // Signal quality or other metric (0-9)
|
||||||
|
directivity?: number | "omni" | "unknown"; // Optional directivity pattern (numeric code or "omni")
|
||||||
|
}
|
||||||
|
|
||||||
export interface ITimestamp {
|
export interface ITimestamp {
|
||||||
day?: number; // Day of month (DHM format)
|
day?: number; // Day of month (DHM format)
|
||||||
month?: number; // Month (MDHM format)
|
month?: number; // Month (MDHM format)
|
||||||
hours: number;
|
hours: number;
|
||||||
minutes: number;
|
minutes: number;
|
||||||
seconds?: number;
|
seconds?: number;
|
||||||
format: 'DHM' | 'HMS' | 'MDHM'; // Day-Hour-Minute, Hour-Minute-Second, Month-Day-Hour-Minute
|
format: "DHM" | "HMS" | "MDHM"; // Day-Hour-Minute, Hour-Minute-Second, Month-Day-Hour-Minute
|
||||||
zulu?: boolean; // Is UTC/Zulu time
|
zulu?: boolean; // Is UTC/Zulu time
|
||||||
toDate(): Date; // Convert to Date object respecting timezone
|
toDate(): Date; // Convert to Date object respecting timezone
|
||||||
}
|
}
|
||||||
|
|
||||||
// Position Report Payload
|
// Position Report Payload
|
||||||
export interface PositionPayload {
|
export interface PositionPayload {
|
||||||
type: 'position';
|
type:
|
||||||
|
| 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
|
||||||
@@ -106,7 +135,7 @@ export interface PositionPayload {
|
|||||||
messageType?: string;
|
messageType?: string;
|
||||||
isStandard?: boolean;
|
isStandard?: boolean;
|
||||||
};
|
};
|
||||||
sections?: PacketSegment[];
|
sections?: Segment[];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Compressed Position Format
|
// Compressed Position Format
|
||||||
@@ -122,24 +151,25 @@ export interface CompressedPosition {
|
|||||||
range?: number; // Miles
|
range?: number; // Miles
|
||||||
altitude?: number; // Feet
|
altitude?: number; // Feet
|
||||||
radioRange?: number; // Miles
|
radioRange?: number; // Miles
|
||||||
compression: 'old' | 'current';
|
compression: "old" | "current";
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mic-E Payload (compressed in destination address)
|
// Mic-E Payload (compressed in destination address)
|
||||||
export interface MicEPayload {
|
export interface MicEPayload {
|
||||||
type: 'mic-e';
|
type: 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
|
||||||
@@ -149,7 +179,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
|
||||||
@@ -157,7 +188,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
|
||||||
@@ -168,7 +199,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;
|
||||||
@@ -176,7 +207,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
|
||||||
@@ -188,14 +219,17 @@ export interface StatusPayload {
|
|||||||
|
|
||||||
// Query Payload
|
// Query Payload
|
||||||
export interface QueryPayload {
|
export interface QueryPayload {
|
||||||
type: 'query';
|
type: DataType.Query;
|
||||||
queryType: string; // e.g., 'APRSD', 'APRST', 'PING'
|
queryType: string; // e.g., 'APRSD', 'APRST', 'PING'
|
||||||
target?: string; // Target callsign or area
|
target?: string; // Target callsign or area
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type TelemetryVariant = "data" | "parameters" | "unit" | "coefficients" | "bitsense";
|
||||||
|
|
||||||
// Telemetry Data Payload
|
// Telemetry Data Payload
|
||||||
export interface TelemetryDataPayload {
|
export interface TelemetryDataPayload {
|
||||||
type: 'telemetry-data';
|
type: DataType.TelemetryData;
|
||||||
|
variant: "data";
|
||||||
sequence: number;
|
sequence: number;
|
||||||
analog: number[]; // Up to 5 analog channels
|
analog: number[]; // Up to 5 analog channels
|
||||||
digital: number; // 8-bit digital value
|
digital: number; // 8-bit digital value
|
||||||
@@ -203,19 +237,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
|
||||||
@@ -225,14 +262,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
|
||||||
@@ -249,37 +287,38 @@ export interface WeatherPayload {
|
|||||||
rawRain?: number; // Raw rain counter
|
rawRain?: number; // Raw rain counter
|
||||||
software?: string; // Weather software type
|
software?: string; // Weather software type
|
||||||
weatherUnit?: string; // Weather station type
|
weatherUnit?: string; // Weather station type
|
||||||
|
comment?: string; // Additional comment
|
||||||
}
|
}
|
||||||
|
|
||||||
// Raw GPS Payload (NMEA sentences)
|
// Raw GPS Payload (NMEA sentences)
|
||||||
export interface RawGPSPayload {
|
export interface RawGPSPayload {
|
||||||
type: 'raw-gps';
|
type: DataType.RawGPS;
|
||||||
sentence: string; // Raw NMEA sentence
|
sentence: string; // Raw NMEA sentence
|
||||||
|
position?: IPosition; // Optional parsed position if available
|
||||||
}
|
}
|
||||||
|
|
||||||
// Station Capabilities Payload
|
// Station Capabilities Payload
|
||||||
export interface StationCapabilitiesPayload {
|
export interface StationCapabilitiesPayload {
|
||||||
type: 'capabilities';
|
type: 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;
|
||||||
header: string; // Source path of third-party packet
|
frame?: IFrame; // Optional nested frame if payload contains another APRS frame
|
||||||
payload: string; // Nested APRS packet
|
comment?: string; // Optional comment
|
||||||
}
|
}
|
||||||
|
|
||||||
// DF Report Payload
|
// DF Report Payload
|
||||||
export interface DFReportPayload {
|
export interface DFReportPayload {
|
||||||
type: 'df-report';
|
|
||||||
timestamp?: ITimestamp;
|
timestamp?: ITimestamp;
|
||||||
position: IPosition;
|
position: IPosition;
|
||||||
course?: number;
|
course?: number;
|
||||||
@@ -292,11 +331,12 @@ 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
|
||||||
export type Payload = BasePayload & (
|
export type Payload = BasePayload &
|
||||||
|
(
|
||||||
| PositionPayload
|
| PositionPayload
|
||||||
| MicEPayload
|
| MicEPayload
|
||||||
| MessagePayload
|
| MessagePayload
|
||||||
@@ -315,11 +355,10 @@ export type Payload = BasePayload & (
|
|||||||
| StationCapabilitiesPayload
|
| StationCapabilitiesPayload
|
||||||
| UserDefinedPayload
|
| UserDefinedPayload
|
||||||
| ThirdPartyPayload
|
| ThirdPartyPayload
|
||||||
| DFReportPayload
|
);
|
||||||
);
|
|
||||||
|
|
||||||
// Extended Frame with decoded payload
|
// Extended Frame with decoded payload
|
||||||
export interface DecodedFrame extends IFrame {
|
export interface DecodedFrame extends IFrame {
|
||||||
decoded?: Payload;
|
decoded?: Payload;
|
||||||
structure?: PacketStructure; // Routing and other frame-level sections
|
structure?: Dissected; // Routing and other frame-level sections
|
||||||
}
|
}
|
||||||
|
|||||||
28
src/index.ts
28
src/index.ts
@@ -1,19 +1,14 @@
|
|||||||
export {
|
export { Frame, Address, Timestamp } from "./frame";
|
||||||
Frame,
|
|
||||||
Address,
|
export { type IAddress, type IFrame, DataType as DataTypeIdentifier } from "./frame.types";
|
||||||
Timestamp,
|
|
||||||
} from "./frame";
|
|
||||||
|
|
||||||
export {
|
|
||||||
type IAddress,
|
|
||||||
type IFrame,
|
|
||||||
DataTypeIdentifier,
|
|
||||||
} from "./frame.types";
|
|
||||||
|
|
||||||
export {
|
export {
|
||||||
|
DataType,
|
||||||
type ISymbol,
|
type ISymbol,
|
||||||
type IPosition,
|
type IPosition,
|
||||||
type ITimestamp,
|
type ITimestamp,
|
||||||
|
type IPowerHeightGain,
|
||||||
|
type IDirectionFinding,
|
||||||
type PositionPayload,
|
type PositionPayload,
|
||||||
type CompressedPosition,
|
type CompressedPosition,
|
||||||
type MicEPayload,
|
type MicEPayload,
|
||||||
@@ -36,7 +31,7 @@ export {
|
|||||||
type DFReportPayload,
|
type DFReportPayload,
|
||||||
type BasePayload,
|
type BasePayload,
|
||||||
type Payload,
|
type Payload,
|
||||||
type DecodedFrame,
|
type DecodedFrame
|
||||||
} from "./frame.types";
|
} from "./frame.types";
|
||||||
|
|
||||||
export {
|
export {
|
||||||
@@ -46,12 +41,5 @@ export {
|
|||||||
feetToMeters,
|
feetToMeters,
|
||||||
metersToFeet,
|
metersToFeet,
|
||||||
celsiusToFahrenheit,
|
celsiusToFahrenheit,
|
||||||
fahrenheitToCelsius,
|
fahrenheitToCelsius
|
||||||
} from "./parser";
|
} from "./parser";
|
||||||
export {
|
|
||||||
type PacketStructure,
|
|
||||||
type PacketSegment,
|
|
||||||
type PacketField,
|
|
||||||
type PacketFieldBit,
|
|
||||||
FieldType,
|
|
||||||
} from "./parser.types";
|
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ export const base91ToNumber = (str: string): number => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return value;
|
return value;
|
||||||
}
|
};
|
||||||
|
|
||||||
/* Conversions from Freedom Units to whatever the rest of the world uses and understands. */
|
/* Conversions from Freedom Units to whatever the rest of the world uses and understands. */
|
||||||
|
|
||||||
@@ -38,7 +38,7 @@ const FAHRENHEIT_TO_CELSIUS_OFFSET = 32;
|
|||||||
*/
|
*/
|
||||||
export const knotsToKmh = (knots: number): number => {
|
export const knotsToKmh = (knots: number): number => {
|
||||||
return knots * KNOTS_TO_KMH;
|
return knots * KNOTS_TO_KMH;
|
||||||
}
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Convert speed from kilometers per hour to knots.
|
* Convert speed from kilometers per hour to knots.
|
||||||
@@ -48,7 +48,7 @@ export const knotsToKmh = (knots: number): number => {
|
|||||||
*/
|
*/
|
||||||
export const kmhToKnots = (kmh: number): number => {
|
export const kmhToKnots = (kmh: number): number => {
|
||||||
return kmh / KNOTS_TO_KMH;
|
return kmh / KNOTS_TO_KMH;
|
||||||
}
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Convert altitude from feet to meters.
|
* Convert altitude from feet to meters.
|
||||||
@@ -58,7 +58,16 @@ export const kmhToKnots = (kmh: number): number => {
|
|||||||
*/
|
*/
|
||||||
export const feetToMeters = (feet: number): number => {
|
export const feetToMeters = (feet: number): number => {
|
||||||
return feet * FEET_TO_METERS;
|
return feet * FEET_TO_METERS;
|
||||||
}
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert miles to meters.
|
||||||
|
* @param miles number of miles
|
||||||
|
* @returns meters
|
||||||
|
*/
|
||||||
|
export const milesToMeters = (miles: number): number => {
|
||||||
|
return miles * 1609.344;
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Convert altitude from meters to feet.
|
* Convert altitude from meters to feet.
|
||||||
@@ -68,7 +77,7 @@ export const feetToMeters = (feet: number): number => {
|
|||||||
*/
|
*/
|
||||||
export const metersToFeet = (meters: number): number => {
|
export const metersToFeet = (meters: number): number => {
|
||||||
return meters / FEET_TO_METERS;
|
return meters / FEET_TO_METERS;
|
||||||
}
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Convert temperature from Celsius to Fahrenheit.
|
* Convert temperature from Celsius to Fahrenheit.
|
||||||
@@ -77,8 +86,8 @@ export const metersToFeet = (meters: number): number => {
|
|||||||
* @returns equivalent temperature in Fahrenheit
|
* @returns equivalent temperature in Fahrenheit
|
||||||
*/
|
*/
|
||||||
export const celsiusToFahrenheit = (celsius: number): number => {
|
export const celsiusToFahrenheit = (celsius: number): number => {
|
||||||
return (celsius * 9/5) + FAHRENHEIT_TO_CELSIUS_OFFSET;
|
return (celsius * 9) / 5 + FAHRENHEIT_TO_CELSIUS_OFFSET;
|
||||||
}
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Convert temperature from Fahrenheit to Celsius.
|
* Convert temperature from Fahrenheit to Celsius.
|
||||||
@@ -87,5 +96,5 @@ export const celsiusToFahrenheit = (celsius: number): number => {
|
|||||||
* @returns equivalent temperature in Celsius
|
* @returns equivalent temperature in Celsius
|
||||||
*/
|
*/
|
||||||
export const fahrenheitToCelsius = (fahrenheit: number): number => {
|
export const fahrenheitToCelsius = (fahrenheit: number): number => {
|
||||||
return (fahrenheit - FAHRENHEIT_TO_CELSIUS_OFFSET) * 5/9;
|
return ((fahrenheit - FAHRENHEIT_TO_CELSIUS_OFFSET) * 5) / 9;
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -1,37 +0,0 @@
|
|||||||
export enum FieldType {
|
|
||||||
BITS = 0,
|
|
||||||
UINT8 = 1,
|
|
||||||
UINT16_LE = 2,
|
|
||||||
UINT16_BE = 3,
|
|
||||||
UINT32_LE = 4,
|
|
||||||
UINT32_BE = 5,
|
|
||||||
BYTES = 6, // 8-bits per value
|
|
||||||
WORDS = 7, // 16-bits per value
|
|
||||||
DWORDS = 8, // 32-bits per value
|
|
||||||
QWORDS = 9, // 64-bits per value
|
|
||||||
STRING = 10,
|
|
||||||
C_STRING = 11, // Null-terminated string
|
|
||||||
CHAR = 12, // Single ASCII character
|
|
||||||
}
|
|
||||||
|
|
||||||
// Interface for the parsed packet segments, used for debugging and testing.
|
|
||||||
export type PacketStructure = PacketSegment[];
|
|
||||||
|
|
||||||
export interface PacketSegment {
|
|
||||||
name: string;
|
|
||||||
data: Uint8Array;
|
|
||||||
fields: PacketField[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PacketField {
|
|
||||||
type: FieldType;
|
|
||||||
size: number; // Size in bytes
|
|
||||||
name?: string;
|
|
||||||
bits?: PacketFieldBit[]; // Only for bit fields in FieldType.BITS
|
|
||||||
value?: any; // Optional decoded value
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PacketFieldBit {
|
|
||||||
name: string;
|
|
||||||
size: number; // Size in bits
|
|
||||||
}
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { IPosition, ISymbol } from "./frame.types";
|
import { IDirectionFinding, IPosition, IPowerHeightGain, ISymbol } from "./frame.types";
|
||||||
|
|
||||||
export class Symbol implements ISymbol {
|
export class Symbol implements ISymbol {
|
||||||
table: string; // Symbol table identifier
|
table: string; // Symbol table identifier
|
||||||
@@ -32,6 +32,9 @@ export class Position implements IPosition {
|
|||||||
course?: number; // Course in degrees
|
course?: number; // Course in degrees
|
||||||
symbol?: Symbol;
|
symbol?: Symbol;
|
||||||
comment?: string;
|
comment?: string;
|
||||||
|
range?: number;
|
||||||
|
phg?: IPowerHeightGain;
|
||||||
|
dfs?: IDirectionFinding;
|
||||||
|
|
||||||
constructor(data: Partial<IPosition>) {
|
constructor(data: Partial<IPosition>) {
|
||||||
this.latitude = data.latitude ?? 0;
|
this.latitude = data.latitude ?? 0;
|
||||||
@@ -40,32 +43,35 @@ export class Position implements IPosition {
|
|||||||
this.altitude = data.altitude;
|
this.altitude = data.altitude;
|
||||||
this.speed = data.speed;
|
this.speed = data.speed;
|
||||||
this.course = data.course;
|
this.course = data.course;
|
||||||
if (typeof data.symbol === 'string') {
|
if (typeof data.symbol === "string") {
|
||||||
this.symbol = new Symbol(data.symbol);
|
this.symbol = new Symbol(data.symbol);
|
||||||
} else if (data.symbol) {
|
} else if (data.symbol) {
|
||||||
this.symbol = new Symbol(data.symbol.table, data.symbol.code);
|
this.symbol = new Symbol(data.symbol.table, data.symbol.code);
|
||||||
}
|
}
|
||||||
this.comment = data.comment;
|
this.comment = data.comment;
|
||||||
|
this.range = data.range;
|
||||||
|
this.phg = data.phg;
|
||||||
|
this.dfs = data.dfs;
|
||||||
}
|
}
|
||||||
|
|
||||||
public toString(): string {
|
public toString(): string {
|
||||||
const latStr = this.latitude.toFixed(5);
|
const latStr = this.latitude.toFixed(5);
|
||||||
const lonStr = this.longitude.toFixed(5);
|
const lonStr = this.longitude.toFixed(5);
|
||||||
const altStr = this.altitude !== undefined ? `,${this.altitude}m` : '';
|
const altStr = this.altitude !== undefined ? `,${this.altitude}m` : "";
|
||||||
return `${latStr},${lonStr}${altStr}`;
|
return `${latStr},${lonStr}${altStr}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
public distanceTo(other: IPosition): number {
|
public distanceTo(other: IPosition): number {
|
||||||
const R = 6371e3; // Earth radius in meters
|
const R = 6371e3; // Earth radius in meters
|
||||||
const φ1 = this.latitude * Math.PI / 180;
|
const lat1 = (this.latitude * Math.PI) / 180;
|
||||||
const φ2 = other.latitude * Math.PI / 180;
|
const lat2 = (other.latitude * Math.PI) / 180;
|
||||||
const Δφ = (other.latitude - this.latitude) * Math.PI / 180;
|
const dLat = ((other.latitude - this.latitude) * Math.PI) / 180;
|
||||||
const Δλ = (other.longitude - this.longitude) * Math.PI / 180;
|
const dLon = ((other.longitude - this.longitude) * Math.PI) / 180;
|
||||||
|
|
||||||
const a = Math.sin(Δφ/2) * Math.sin(Δφ/2) +
|
const a =
|
||||||
Math.cos(φ1) * Math.cos(φ2) *
|
Math.sin(dLat / 2) * Math.sin(dLat / 2) +
|
||||||
Math.sin(Δλ/2) * Math.sin(Δλ/2);
|
Math.cos(lat1) * Math.cos(lat2) * Math.sin(dLon / 2) * Math.sin(dLon / 2);
|
||||||
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1-a));
|
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||||
|
|
||||||
return R * c; // Distance in meters
|
return R * c; // Distance in meters
|
||||||
}
|
}
|
||||||
|
|||||||
35
test/frame.capabilities.test.ts
Normal file
35
test/frame.capabilities.test.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import { Dissected } from "@hamradio/packet";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { Frame } from "../src/frame";
|
||||||
|
import { DataType, type Payload, type StationCapabilitiesPayload } from "../src/frame.types";
|
||||||
|
|
||||||
|
describe("Frame.decodeCapabilities", () => {
|
||||||
|
it("parses comma separated capabilities", () => {
|
||||||
|
const data = "CALL>APRS:<IGATE,MSG_CNT";
|
||||||
|
const frame = Frame.fromString(data);
|
||||||
|
const decoded = frame.decode() as StationCapabilitiesPayload;
|
||||||
|
expect(decoded).not.toBeNull();
|
||||||
|
expect(decoded.type).toBe(DataType.StationCapabilities);
|
||||||
|
expect(Array.isArray(decoded.capabilities)).toBeTruthy();
|
||||||
|
expect(decoded.capabilities).toContain("IGATE");
|
||||||
|
expect(decoded.capabilities).toContain("MSG_CNT");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("emits structure sections when requested", () => {
|
||||||
|
const data = "CALL>APRS:<IGATE MSG_CNT>";
|
||||||
|
const frame = Frame.fromString(data);
|
||||||
|
const res = frame.decode(true) as {
|
||||||
|
payload: Payload | null;
|
||||||
|
structure: Dissected;
|
||||||
|
};
|
||||||
|
expect(res.payload).not.toBeNull();
|
||||||
|
if (res.payload && res.payload.type !== DataType.StationCapabilities)
|
||||||
|
throw new Error("expected capabilities payload");
|
||||||
|
expect(res.structure).toBeDefined();
|
||||||
|
const caps = res.structure.find((s) => s.name === "capabilities");
|
||||||
|
expect(caps).toBeDefined();
|
||||||
|
const capEntry = res.structure.find((s) => s.name === "capability");
|
||||||
|
expect(capEntry).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
97
test/frame.extras.test.ts
Normal file
97
test/frame.extras.test.ts
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
import type { Dissected, Field, Segment } from "@hamradio/packet";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { Frame } from "../src/frame";
|
||||||
|
import type { PositionPayload } from "../src/frame.types";
|
||||||
|
|
||||||
|
describe("APRS extras test vectors (RNG / PHG / CSE/DDD / SPD / DFS)", () => {
|
||||||
|
it("parses PHG from position with messaging (spec vector 1)", () => {
|
||||||
|
const raw = "NOCALL>APZRAZ,qAS,PA2RDK-14:=5154.19N/00627.77E>PHG500073 de NOCALL";
|
||||||
|
const frame = Frame.fromString(raw);
|
||||||
|
const res = frame.decode(true) as { payload: PositionPayload | null; structure: Dissected };
|
||||||
|
const { payload } = res;
|
||||||
|
|
||||||
|
expect(payload).not.toBeNull();
|
||||||
|
expect(payload!.position.phg).toBeDefined();
|
||||||
|
// PHG500073 parsed per spec: p=5 -> 25 W, h='0' -> 10 ft, g='0' -> 0 dBi
|
||||||
|
expect(payload!.position.phg!.power).toBe(25);
|
||||||
|
expect(payload!.position.phg!.height).toBeCloseTo(3.048, 3);
|
||||||
|
expect(payload!.position.phg!.gain).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parses PHG token with hyphen separators (spec vector 2)", () => {
|
||||||
|
const raw = "NOCALL>APRS,TCPIP*,qAC,NINTH:;P-PA3RD *061000z5156.26NP00603.29E#PHG0210DAPNET";
|
||||||
|
const frame = Frame.fromString(raw);
|
||||||
|
const res = frame.decode(true) as { payload: PositionPayload | null; structure: Dissected };
|
||||||
|
const { payload, structure } = res;
|
||||||
|
|
||||||
|
expect(payload).not.toBeNull();
|
||||||
|
// Use a spec PHG example: PHG0210 -> p=0 -> power 0 W, h=2 -> 40 ft
|
||||||
|
expect(payload!.position.phg).toBeDefined();
|
||||||
|
expect(payload!.position.phg!.power).toBe(0);
|
||||||
|
expect(payload!.position.phg!.height).toBeCloseTo(12.192, 3);
|
||||||
|
|
||||||
|
const commentSeg = structure.find((s) => /comment/i.test(String(s.name))) as Segment | undefined;
|
||||||
|
expect(commentSeg).toBeDefined();
|
||||||
|
const fields = (commentSeg!.fields ?? []) as Field[];
|
||||||
|
const hasPHG = fields.some((f) => f.name === "PHG");
|
||||||
|
expect(hasPHG).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parses DFS token with long numeric strength", () => {
|
||||||
|
const raw = "N0CALL>APRS,WIDE1-1:!4500.00N/07000.00W#DFS2360/Your Comment";
|
||||||
|
const frame = Frame.fromString(raw);
|
||||||
|
const res = frame.decode(true) as { payload: PositionPayload | null; structure: Dissected };
|
||||||
|
const { payload, structure } = res;
|
||||||
|
|
||||||
|
expect(payload).not.toBeNull();
|
||||||
|
expect(payload!.position.dfs).toBeDefined();
|
||||||
|
// DFSshgd: strength is single-digit s value (here '2')
|
||||||
|
expect(payload!.position.dfs!.strength).toBe(2);
|
||||||
|
|
||||||
|
const commentSeg = structure.find((s) => /comment/i.test(String(s.name))) as Segment | undefined;
|
||||||
|
expect(commentSeg).toBeDefined();
|
||||||
|
const fieldsDFS = (commentSeg!.fields ?? []) as Field[];
|
||||||
|
const hasDFS = fieldsDFS.some((f) => f.name === "DFS");
|
||||||
|
expect(hasDFS).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parses course/speed in DDD/SSS form and altitude /A=", () => {
|
||||||
|
const raw = "N0CALL>APRS,WIDE1-1:!4500.00N/07000.00W>090/045/A=001234";
|
||||||
|
const frame = Frame.fromString(raw);
|
||||||
|
const res = frame.decode(true) as { payload: PositionPayload | null; structure: Dissected };
|
||||||
|
const { payload, structure } = res;
|
||||||
|
|
||||||
|
expect(payload).not.toBeNull();
|
||||||
|
expect(payload!.position.course).toBe(90);
|
||||||
|
// Speed is converted from knots to km/h
|
||||||
|
expect(payload!.position.speed).toBeCloseTo(45 * 1.852, 3);
|
||||||
|
// Altitude 001234 ft -> meters
|
||||||
|
expect(Math.round((payload!.position.altitude || 0) / 0.3048)).toBe(1234);
|
||||||
|
|
||||||
|
const commentSeg = structure.find((s) => /comment/i.test(String(s.name))) as Segment | undefined;
|
||||||
|
expect(commentSeg).toBeDefined();
|
||||||
|
const fieldsCSE = (commentSeg!.fields ?? []) as Field[];
|
||||||
|
const hasCSE = fieldsCSE.some((f) => f.name === "CSE/SPD");
|
||||||
|
expect(hasCSE).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parses combined tokens: DDD/SSS PHG and DFS", () => {
|
||||||
|
const raw = "N0CALL>APRS,WIDE1-1:!4500.00N/07000.00W>090/045PHG5132/DFS2132";
|
||||||
|
const frame = Frame.fromString(raw);
|
||||||
|
const res = frame.decode(true) as { payload: PositionPayload | null; structure: Dissected };
|
||||||
|
const { payload, structure } = res;
|
||||||
|
|
||||||
|
expect(payload).not.toBeNull();
|
||||||
|
expect(payload!.position.course).toBe(90);
|
||||||
|
expect(payload!.position.speed).toBeCloseTo(45 * 1.852, 3);
|
||||||
|
expect(payload!.position.phg).toBeDefined();
|
||||||
|
expect(payload!.position.dfs).toBeDefined();
|
||||||
|
expect(payload!.position.dfs!.strength).toBe(2);
|
||||||
|
|
||||||
|
const commentSeg = structure.find((s) => /comment/i.test(String(s.name))) as Segment | undefined;
|
||||||
|
expect(commentSeg).toBeDefined();
|
||||||
|
const fieldsCombined = (commentSeg!.fields ?? []) as Field[];
|
||||||
|
expect(fieldsCombined.some((f) => ["CSE/SPD", "PHG", "DFS"].includes(String(f.name)))).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
40
test/frame.query.test.ts
Normal file
40
test/frame.query.test.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import { Dissected } from "@hamradio/packet";
|
||||||
|
import { expect } from "vitest";
|
||||||
|
import { describe, it } from "vitest";
|
||||||
|
|
||||||
|
import { Frame } from "../src/frame";
|
||||||
|
import { DataType, QueryPayload } from "../src/frame.types";
|
||||||
|
|
||||||
|
describe("Frame decode - Query", () => {
|
||||||
|
it("decodes simple query without target", () => {
|
||||||
|
const frame = Frame.fromString("SRC>DEST:?APRS");
|
||||||
|
const payload = frame.decode() as QueryPayload;
|
||||||
|
expect(payload).not.toBeNull();
|
||||||
|
expect(payload.type).toBe(DataType.Query);
|
||||||
|
expect(payload.queryType).toBe("APRS");
|
||||||
|
expect(payload.target).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("decodes query with target", () => {
|
||||||
|
const frame = Frame.fromString("SRC>DEST:?PING N0CALL");
|
||||||
|
const payload = frame.decode() as QueryPayload;
|
||||||
|
expect(payload).not.toBeNull();
|
||||||
|
expect(payload.type).toBe(DataType.Query);
|
||||||
|
expect(payload.queryType).toBe("PING");
|
||||||
|
expect(payload.target).toBe("N0CALL");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns structure sections when requested", () => {
|
||||||
|
const frame = Frame.fromString("SRC>DEST:?PING N0CALL");
|
||||||
|
const result = frame.decode(true) as {
|
||||||
|
payload: QueryPayload;
|
||||||
|
structure: Dissected;
|
||||||
|
};
|
||||||
|
expect(result).toHaveProperty("payload");
|
||||||
|
expect(result.payload.type).toBe(DataType.Query);
|
||||||
|
expect(Array.isArray(result.structure)).toBe(true);
|
||||||
|
const names = result.structure.map((s) => s.name);
|
||||||
|
expect(names).toContain("query type");
|
||||||
|
expect(names).toContain("query target");
|
||||||
|
});
|
||||||
|
});
|
||||||
45
test/frame.rawgps.test.ts
Normal file
45
test/frame.rawgps.test.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import { Dissected } from "@hamradio/packet";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { Frame } from "../src/frame";
|
||||||
|
import { DataType, type RawGPSPayload } from "../src/frame.types";
|
||||||
|
|
||||||
|
describe("Raw GPS decoding", () => {
|
||||||
|
it("decodes simple NMEA sentence as raw-gps payload", () => {
|
||||||
|
const sentence = "GPRMC,123519,A,4807.038,N,01131.000,E,022.4,084.4,230394,003.1,W*6A";
|
||||||
|
const frameStr = `SRC>DEST:$${sentence}`;
|
||||||
|
|
||||||
|
const f = Frame.parse(frameStr);
|
||||||
|
const payload = f.decode(false) as RawGPSPayload | null;
|
||||||
|
|
||||||
|
expect(payload).not.toBeNull();
|
||||||
|
expect(payload?.type).toBe(DataType.RawGPS);
|
||||||
|
expect(payload?.sentence).toBe(sentence);
|
||||||
|
expect(payload?.position).toBeDefined();
|
||||||
|
expect(typeof payload?.position?.latitude).toBe("number");
|
||||||
|
expect(typeof payload?.position?.longitude).toBe("number");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns structure when requested", () => {
|
||||||
|
const sentence = "GPGGA,092750.000,5321.6802,N,00630.3372,W,1,08,1.0,73.0,M,0.0,M,,*6A";
|
||||||
|
const frameStr = `SRC>DEST:$${sentence}`;
|
||||||
|
|
||||||
|
const f = Frame.parse(frameStr);
|
||||||
|
const result = f.decode(true) as {
|
||||||
|
payload: RawGPSPayload | null;
|
||||||
|
structure: Dissected;
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(result.payload).not.toBeNull();
|
||||||
|
expect(result.payload?.type).toBe(DataType.RawGPS);
|
||||||
|
expect(result.payload?.sentence).toBe(sentence);
|
||||||
|
expect(result.payload?.position).toBeDefined();
|
||||||
|
expect(typeof result.payload?.position?.latitude).toBe("number");
|
||||||
|
expect(typeof result.payload?.position?.longitude).toBe("number");
|
||||||
|
expect(result.structure).toBeDefined();
|
||||||
|
const rawSection = result.structure.find((s) => s.name === "raw-gps");
|
||||||
|
expect(rawSection).toBeDefined();
|
||||||
|
const posSection = result.structure.find((s) => s.name === "raw-gps-position");
|
||||||
|
expect(posSection).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
66
test/frame.telemetry.test.ts
Normal file
66
test/frame.telemetry.test.ts
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import { describe, it } from "vitest";
|
||||||
|
import { expect } from "vitest";
|
||||||
|
|
||||||
|
import { Frame } from "../src/frame";
|
||||||
|
import {
|
||||||
|
DataType,
|
||||||
|
TelemetryBitSensePayload,
|
||||||
|
TelemetryCoefficientsPayload,
|
||||||
|
TelemetryDataPayload,
|
||||||
|
TelemetryParameterPayload,
|
||||||
|
TelemetryUnitPayload
|
||||||
|
} from "../src/frame.types";
|
||||||
|
|
||||||
|
describe("Frame decode - Telemetry", () => {
|
||||||
|
it("decodes telemetry data payload", () => {
|
||||||
|
const frame = Frame.fromString("SRC>DEST:T#1 10,20,30,40,50 7");
|
||||||
|
const payload = frame.decode() as TelemetryDataPayload;
|
||||||
|
expect(payload).not.toBeNull();
|
||||||
|
expect(payload.type).toBe(DataType.TelemetryData);
|
||||||
|
expect(payload.variant).toBe("data");
|
||||||
|
expect(payload.sequence).toBe(1);
|
||||||
|
expect(Array.isArray(payload.analog)).toBe(true);
|
||||||
|
expect(payload.analog.length).toBe(5);
|
||||||
|
expect(payload.digital).toBe(7);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("decodes telemetry parameters list", () => {
|
||||||
|
const frame = Frame.fromString("SRC>DEST:TPARAM Temp,Hum,Wind");
|
||||||
|
const payload = frame.decode() as TelemetryParameterPayload;
|
||||||
|
expect(payload).not.toBeNull();
|
||||||
|
expect(payload.type).toBe(DataType.TelemetryData);
|
||||||
|
expect(payload.variant).toBe("parameters");
|
||||||
|
expect(Array.isArray(payload.names)).toBe(true);
|
||||||
|
expect(payload.names).toEqual(["Temp", "Hum", "Wind"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("decodes telemetry units list", () => {
|
||||||
|
const frame = Frame.fromString("SRC>DEST:TUNIT C,% ,mph");
|
||||||
|
const payload = frame.decode() as TelemetryUnitPayload;
|
||||||
|
expect(payload).not.toBeNull();
|
||||||
|
expect(payload.type).toBe(DataType.TelemetryData);
|
||||||
|
expect(payload.variant).toBe("unit");
|
||||||
|
expect(payload.units).toEqual(["C", "%", "mph"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("decodes telemetry coefficients", () => {
|
||||||
|
const frame = Frame.fromString("SRC>DEST:TCOEFF A:1,2 B:3,4 C:5,6");
|
||||||
|
const payload = frame.decode() as TelemetryCoefficientsPayload;
|
||||||
|
expect(payload).not.toBeNull();
|
||||||
|
expect(payload.type).toBe(DataType.TelemetryData);
|
||||||
|
expect(payload.variant).toBe("coefficients");
|
||||||
|
expect(payload.coefficients.a).toEqual([1, 2]);
|
||||||
|
expect(payload.coefficients.b).toEqual([3, 4]);
|
||||||
|
expect(payload.coefficients.c).toEqual([5, 6]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("decodes telemetry bitsense with project", () => {
|
||||||
|
const frame = Frame.fromString("SRC>DEST:TBITS 255 ProjectX");
|
||||||
|
const payload = frame.decode() as TelemetryBitSensePayload;
|
||||||
|
expect(payload).not.toBeNull();
|
||||||
|
expect(payload.type).toBe(DataType.TelemetryData);
|
||||||
|
expect(payload.variant).toBe("bitsense");
|
||||||
|
expect(payload.sense).toBe(255);
|
||||||
|
expect(payload.projectName).toBe("ProjectX");
|
||||||
|
});
|
||||||
|
});
|
||||||
1334
test/frame.test.ts
1334
test/frame.test.ts
File diff suppressed because it is too large
Load Diff
37
test/frame.userdefined.test.ts
Normal file
37
test/frame.userdefined.test.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { Dissected } from "@hamradio/packet";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { Frame } from "../src/frame";
|
||||||
|
import { DataType, type UserDefinedPayload } from "../src/frame.types";
|
||||||
|
|
||||||
|
describe("Frame.decodeUserDefined", () => {
|
||||||
|
it("parses packet type only", () => {
|
||||||
|
const data = "CALL>APRS:{01";
|
||||||
|
const frame = Frame.fromString(data);
|
||||||
|
const decoded = frame.decode() as UserDefinedPayload;
|
||||||
|
expect(decoded).not.toBeNull();
|
||||||
|
expect(decoded.type).toBe(DataType.UserDefined);
|
||||||
|
expect(decoded.userPacketType).toBe("01");
|
||||||
|
expect(decoded.data).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parses packet type and data and emits sections", () => {
|
||||||
|
const data = "CALL>APRS:{TEX Hello world";
|
||||||
|
const frame = Frame.fromString(data);
|
||||||
|
const res = frame.decode(true) as {
|
||||||
|
payload: UserDefinedPayload;
|
||||||
|
structure: Dissected;
|
||||||
|
};
|
||||||
|
expect(res.payload).not.toBeNull();
|
||||||
|
expect(res.payload.type).toBe(DataType.UserDefined);
|
||||||
|
expect(res.payload.userPacketType).toBe("TEX");
|
||||||
|
expect(res.payload.data).toBe("Hello world");
|
||||||
|
|
||||||
|
const raw = res.structure.find((s) => s.name === "user-defined");
|
||||||
|
const typeSection = res.structure.find((s) => s.name === "user-packet-type");
|
||||||
|
const dataSection = res.structure.find((s) => s.name === "user-data");
|
||||||
|
expect(raw).toBeDefined();
|
||||||
|
expect(typeSection).toBeDefined();
|
||||||
|
expect(dataSection).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
38
test/frame.weather.test.ts
Normal file
38
test/frame.weather.test.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import { Dissected } from "@hamradio/packet";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { Frame } from "../src/frame";
|
||||||
|
import { DataType, WeatherPayload } from "../src/frame.types";
|
||||||
|
|
||||||
|
describe("Frame decode - Weather", () => {
|
||||||
|
it("parses weather with timestamp, wind, temp, rain, humidity and pressure", () => {
|
||||||
|
const data = "SRC>DEST:_120345z180/10g15t072r000p025P050h50b10132";
|
||||||
|
const frame = Frame.fromString(data);
|
||||||
|
const payload = frame.decode() as WeatherPayload;
|
||||||
|
expect(payload).not.toBeNull();
|
||||||
|
expect(payload.type).toBe(DataType.WeatherReportNoPosition);
|
||||||
|
expect(payload.timestamp).toBeDefined();
|
||||||
|
expect(payload.windDirection).toBe(180);
|
||||||
|
expect(payload.windSpeed).toBe(10);
|
||||||
|
expect(payload.windGust).toBe(15);
|
||||||
|
expect(payload.temperature).toBe(72);
|
||||||
|
expect(payload.rainLast24Hours).toBe(25);
|
||||||
|
expect(payload.rainSinceMidnight).toBe(50);
|
||||||
|
expect(payload.humidity).toBe(50);
|
||||||
|
expect(payload.pressure).toBe(10132);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("emits structure when requested", () => {
|
||||||
|
const data = "SRC>DEST:_120345z180/10g15t072r000p025P050h50b10132";
|
||||||
|
const frame = Frame.fromString(data);
|
||||||
|
const res = frame.decode(true) as {
|
||||||
|
payload: WeatherPayload;
|
||||||
|
structure: Dissected;
|
||||||
|
};
|
||||||
|
expect(res.payload).not.toBeNull();
|
||||||
|
expect(Array.isArray(res.structure)).toBe(true);
|
||||||
|
const names = res.structure.map((s) => s.name);
|
||||||
|
expect(names).toContain("timestamp");
|
||||||
|
expect(names).toContain("weather");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,85 +1,86 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
base91ToNumber,
|
base91ToNumber,
|
||||||
knotsToKmh,
|
|
||||||
kmhToKnots,
|
|
||||||
feetToMeters,
|
|
||||||
metersToFeet,
|
|
||||||
celsiusToFahrenheit,
|
celsiusToFahrenheit,
|
||||||
fahrenheitToCelsius,
|
fahrenheitToCelsius,
|
||||||
} from '../src/parser';
|
feetToMeters,
|
||||||
|
kmhToKnots,
|
||||||
|
knotsToKmh,
|
||||||
|
metersToFeet
|
||||||
|
} from "../src/parser";
|
||||||
|
|
||||||
describe('parser utilities', () => {
|
describe("parser utilities", () => {
|
||||||
describe('base91ToNumber', () => {
|
describe("base91ToNumber", () => {
|
||||||
it('decodes all-! to 0', () => {
|
it("decodes all-! to 0", () => {
|
||||||
expect(base91ToNumber('!!!!')).toBe(0);
|
expect(base91ToNumber("!!!!")).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('decodes single character correctly', () => {
|
it("decodes single character correctly", () => {
|
||||||
// 'A' === 65, digit = 65 - 33 = 32
|
// 'A' === 65, digit = 65 - 33 = 32
|
||||||
expect(base91ToNumber('A')).toBe(32);
|
expect(base91ToNumber("A")).toBe(32);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should decode multiple Base91 characters', () => {
|
it("should decode multiple Base91 characters", () => {
|
||||||
// "!!" = 0 * 91 + 0 = 0
|
// "!!" = 0 * 91 + 0 = 0
|
||||||
expect(base91ToNumber('!!')).toBe(0);
|
expect(base91ToNumber("!!")).toBe(0);
|
||||||
|
|
||||||
// "!#" = 0 * 91 + 2 = 2
|
// "!#" = 0 * 91 + 2 = 2
|
||||||
expect(base91ToNumber('!#')).toBe(2);
|
expect(base91ToNumber("!#")).toBe(2);
|
||||||
|
|
||||||
// "#!" = 2 * 91 + 0 = 182
|
// "#!" = 2 * 91 + 0 = 182
|
||||||
expect(base91ToNumber('#!')).toBe(182);
|
expect(base91ToNumber("#!")).toBe(182);
|
||||||
|
|
||||||
// "##" = 2 * 91 + 2 = 184
|
// "##" = 2 * 91 + 2 = 184
|
||||||
expect(base91ToNumber('##')).toBe(184);
|
expect(base91ToNumber("##")).toBe(184);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should decode 4-character Base91 strings (used in APRS)', () => {
|
it("should decode 4-character Base91 strings (used in APRS)", () => {
|
||||||
// Test with printable ASCII Base91 characters (33-123)
|
// Test with printable ASCII Base91 characters (33-123)
|
||||||
const testValue = base91ToNumber('!#%\'');
|
const testValue = base91ToNumber("!#%'");
|
||||||
expect(testValue).toBeGreaterThan(0);
|
expect(testValue).toBeGreaterThan(0);
|
||||||
expect(testValue).toBeLessThan(91 * 91 * 91 * 91);
|
expect(testValue).toBeLessThan(91 * 91 * 91 * 91);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should decode maximum valid Base91 value', () => {
|
it("should decode maximum valid Base91 value", () => {
|
||||||
// Maximum is '{' (ASCII 123, digit 90) repeated
|
// Maximum is '{' (ASCII 123, digit 90) repeated
|
||||||
const maxValue = base91ToNumber('{{{{');
|
const maxValue = base91ToNumber("{{{{");
|
||||||
const expected = 90 * 91 * 91 * 91 + 90 * 91 * 91 + 90 * 91 + 90;
|
const expected = 90 * 91 * 91 * 91 + 90 * 91 * 91 + 90 * 91 + 90;
|
||||||
expect(maxValue).toBe(expected);
|
expect(maxValue).toBe(expected);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle APRS compressed position example', () => {
|
it("should handle APRS compressed position example", () => {
|
||||||
// Using actual characters from APRS test vector
|
// Using actual characters from APRS test vector
|
||||||
const latStr = '/:*E';
|
const latStr = "/:*E";
|
||||||
const lonStr = 'qZ=O';
|
const lonStr = "qZ=O";
|
||||||
|
|
||||||
const latValue = base91ToNumber(latStr);
|
const latValue = base91ToNumber(latStr);
|
||||||
const lonValue = base91ToNumber(lonStr);
|
const lonValue = base91ToNumber(lonStr);
|
||||||
|
|
||||||
// Just verify they decode without error and produce valid numbers
|
// Just verify they decode without error and produce valid numbers
|
||||||
expect(typeof latValue).toBe('number');
|
expect(typeof latValue).toBe("number");
|
||||||
expect(typeof lonValue).toBe('number');
|
expect(typeof lonValue).toBe("number");
|
||||||
expect(latValue).toBeGreaterThanOrEqual(0);
|
expect(latValue).toBeGreaterThanOrEqual(0);
|
||||||
expect(lonValue).toBeGreaterThanOrEqual(0);
|
expect(lonValue).toBeGreaterThanOrEqual(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('throws on invalid character', () => {
|
it("throws on invalid character", () => {
|
||||||
expect(() => base91ToNumber(' ')).toThrow(); // space (code 32) is invalid
|
expect(() => base91ToNumber(" ")).toThrow(); // space (code 32) is invalid
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('unit conversions', () => {
|
describe("unit conversions", () => {
|
||||||
it('converts knots <-> km/h', () => {
|
it("converts knots <-> km/h", () => {
|
||||||
expect(knotsToKmh(10)).toBeCloseTo(18.52, 5);
|
expect(knotsToKmh(10)).toBeCloseTo(18.52, 5);
|
||||||
expect(kmhToKnots(18.52)).toBeCloseTo(10, 3);
|
expect(kmhToKnots(18.52)).toBeCloseTo(10, 3);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('converts feet <-> meters', () => {
|
it("converts feet <-> meters", () => {
|
||||||
expect(feetToMeters(10)).toBeCloseTo(3.048, 6);
|
expect(feetToMeters(10)).toBeCloseTo(3.048, 6);
|
||||||
expect(metersToFeet(3.048)).toBeCloseTo(10, 6);
|
expect(metersToFeet(3.048)).toBeCloseTo(10, 6);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('converts celsius <-> fahrenheit', () => {
|
it("converts celsius <-> fahrenheit", () => {
|
||||||
expect(celsiusToFahrenheit(0)).toBeCloseTo(32, 6);
|
expect(celsiusToFahrenheit(0)).toBeCloseTo(32, 6);
|
||||||
expect(fahrenheitToCelsius(32)).toBeCloseTo(0, 6);
|
expect(fahrenheitToCelsius(32)).toBeCloseTo(0, 6);
|
||||||
expect(celsiusToFahrenheit(100)).toBeCloseTo(212, 6);
|
expect(celsiusToFahrenheit(100)).toBeCloseTo(212, 6);
|
||||||
|
|||||||
Reference in New Issue
Block a user