@packet-net/pdn-aprs 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +13 -0
- package/LICENSE +661 -0
- package/README.md +192 -0
- package/THIRD-PARTY-NOTICES.md +5 -0
- package/dist/builder.d.ts +232 -0
- package/dist/builder.d.ts.map +1 -0
- package/dist/builder.js +508 -0
- package/dist/builder.js.map +1 -0
- package/dist/bytes.d.ts +19 -0
- package/dist/bytes.d.ts.map +1 -0
- package/dist/bytes.js +120 -0
- package/dist/bytes.js.map +1 -0
- package/dist/codes.d.ts +13 -0
- package/dist/codes.d.ts.map +1 -0
- package/dist/codes.js +112 -0
- package/dist/codes.js.map +1 -0
- package/dist/decode/context.d.ts +33 -0
- package/dist/decode/context.d.ts.map +1 -0
- package/dist/decode/context.js +65 -0
- package/dist/decode/context.js.map +1 -0
- package/dist/decode/index.d.ts +10 -0
- package/dist/decode/index.d.ts.map +1 -0
- package/dist/decode/index.js +121 -0
- package/dist/decode/index.js.map +1 -0
- package/dist/decode/message.d.ts +5 -0
- package/dist/decode/message.d.ts.map +1 -0
- package/dist/decode/message.js +190 -0
- package/dist/decode/message.js.map +1 -0
- package/dist/decode/other.d.ts +25 -0
- package/dist/decode/other.d.ts.map +1 -0
- package/dist/decode/other.js +326 -0
- package/dist/decode/other.js.map +1 -0
- package/dist/decode/positioned.d.ts +132 -0
- package/dist/decode/positioned.d.ts.map +1 -0
- package/dist/decode/positioned.js +1063 -0
- package/dist/decode/positioned.js.map +1 -0
- package/dist/decode/reports.d.ts +13 -0
- package/dist/decode/reports.d.ts.map +1 -0
- package/dist/decode/reports.js +375 -0
- package/dist/decode/reports.js.map +1 -0
- package/dist/decoder.d.ts +23 -0
- package/dist/decoder.d.ts.map +1 -0
- package/dist/decoder.js +75 -0
- package/dist/decoder.js.map +1 -0
- package/dist/deviceid-data.d.ts +33 -0
- package/dist/deviceid-data.d.ts.map +1 -0
- package/dist/deviceid-data.js +2941 -0
- package/dist/deviceid-data.js.map +1 -0
- package/dist/deviceid.d.ts +12 -0
- package/dist/deviceid.d.ts.map +1 -0
- package/dist/deviceid.js +86 -0
- package/dist/deviceid.js.map +1 -0
- package/dist/diagnostics.d.ts +48 -0
- package/dist/diagnostics.d.ts.map +1 -0
- package/dist/diagnostics.js +65 -0
- package/dist/diagnostics.js.map +1 -0
- package/dist/encode/common.d.ts +19 -0
- package/dist/encode/common.d.ts.map +1 -0
- package/dist/encode/common.js +84 -0
- package/dist/encode/common.js.map +1 -0
- package/dist/encode/index.d.ts +35 -0
- package/dist/encode/index.d.ts.map +1 -0
- package/dist/encode/index.js +248 -0
- package/dist/encode/index.js.map +1 -0
- package/dist/encode/other.d.ts +21 -0
- package/dist/encode/other.d.ts.map +1 -0
- package/dist/encode/other.js +253 -0
- package/dist/encode/other.js.map +1 -0
- package/dist/encode/position.d.ts +31 -0
- package/dist/encode/position.d.ts.map +1 -0
- package/dist/encode/position.js +729 -0
- package/dist/encode/position.js.map +1 -0
- package/dist/header.d.ts +42 -0
- package/dist/header.d.ts.map +1 -0
- package/dist/header.js +206 -0
- package/dist/header.js.map +1 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +14 -0
- package/dist/index.js.map +1 -0
- package/dist/neutral.d.ts +22 -0
- package/dist/neutral.d.ts.map +1 -0
- package/dist/neutral.js +593 -0
- package/dist/neutral.js.map +1 -0
- package/dist/symbols-data.d.ts +832 -0
- package/dist/symbols-data.d.ts.map +1 -0
- package/dist/symbols-data.js +173 -0
- package/dist/symbols-data.js.map +1 -0
- package/dist/symbols.d.ts +35 -0
- package/dist/symbols.d.ts.map +1 -0
- package/dist/symbols.js +71 -0
- package/dist/symbols.js.map +1 -0
- package/dist/types.d.ts +458 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +4 -0
- package/dist/types.js.map +1 -0
- package/package.json +68 -0
- package/src/builder.ts +617 -0
- package/src/bytes.ts +118 -0
- package/src/codes.ts +218 -0
- package/src/decode/context.ts +63 -0
- package/src/decode/index.ts +135 -0
- package/src/decode/message.ts +194 -0
- package/src/decode/other.ts +307 -0
- package/src/decode/positioned.ts +1161 -0
- package/src/decode/reports.ts +378 -0
- package/src/decoder.ts +76 -0
- package/src/deviceid-data.ts +2954 -0
- package/src/deviceid.ts +83 -0
- package/src/diagnostics.ts +86 -0
- package/src/encode/common.ts +87 -0
- package/src/encode/index.ts +280 -0
- package/src/encode/other.ts +247 -0
- package/src/encode/position.ts +656 -0
- package/src/header.ts +221 -0
- package/src/index.ts +57 -0
- package/src/neutral.ts +595 -0
- package/src/symbols-data.ts +181 -0
- package/src/symbols.ts +85 -0
- package/src/types.ts +571 -0
package/src/deviceid.ts
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// Device identification from the APRS device identification database
|
|
2
|
+
// (https://github.com/aprsorg/aprs-deviceid): by destination (tocall), or for a Mic-E report by
|
|
3
|
+
// its type code and suffix.
|
|
4
|
+
|
|
5
|
+
import { MICE, MICE_LEGACY, TOCALLS, type DeviceEntry } from './deviceid-data.js';
|
|
6
|
+
import type { DeviceInfo } from './types.js';
|
|
7
|
+
|
|
8
|
+
export { DEVICEID_SOURCE, DEVICE_CLASSES } from './deviceid-data.js';
|
|
9
|
+
|
|
10
|
+
function toInfo(entry: DeviceEntry): DeviceInfo {
|
|
11
|
+
const out: { -readonly [K in keyof DeviceInfo]: DeviceInfo[K] } = {};
|
|
12
|
+
if (entry.vendor !== undefined) out.vendor = entry.vendor;
|
|
13
|
+
if (entry.model !== undefined) out.model = entry.model;
|
|
14
|
+
if (entry.class !== undefined) out.class = entry.class;
|
|
15
|
+
if (entry.os !== undefined) out.os = entry.os;
|
|
16
|
+
if (entry.features !== undefined) out.features = entry.features;
|
|
17
|
+
return out;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
interface CompiledTocall {
|
|
21
|
+
regex: RegExp;
|
|
22
|
+
fixed: number;
|
|
23
|
+
length: number;
|
|
24
|
+
entry: DeviceEntry;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
let compiled: CompiledTocall[] | undefined;
|
|
28
|
+
|
|
29
|
+
function compile(): CompiledTocall[] {
|
|
30
|
+
if (compiled) return compiled;
|
|
31
|
+
compiled = TOCALLS.map((t) => {
|
|
32
|
+
let re = '';
|
|
33
|
+
let fixed = 0;
|
|
34
|
+
for (const c of t.tocall) {
|
|
35
|
+
if (c === '?') re += '.';
|
|
36
|
+
else if (c === '*') re += '.*';
|
|
37
|
+
else if (c === 'n') re += '[0-9]';
|
|
38
|
+
else {
|
|
39
|
+
re += c.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
40
|
+
fixed++;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return { regex: new RegExp(`^${re}$`), fixed, length: t.tocall.length, entry: t };
|
|
44
|
+
});
|
|
45
|
+
// Most specific first: more fixed characters, then longer patterns.
|
|
46
|
+
compiled.sort((a, b) => b.fixed - a.fixed || b.length - a.length);
|
|
47
|
+
return compiled;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** The device a destination address (tocall) identifies, e.g. `APDW18` is Dire Wolf. */
|
|
51
|
+
export function identifyTocall(destination: string): DeviceInfo | undefined {
|
|
52
|
+
const call = destination.split('-')[0]!.toUpperCase();
|
|
53
|
+
for (const t of compile()) if (t.regex.test(call)) return toInfo(t.entry);
|
|
54
|
+
return undefined;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The device suffix at the end of Mic-E status text, if the database knows one for this type
|
|
59
|
+
* code: a two-character suffix after `` ` `` or `'`, a one-character Kenwood suffix after `>` or `]`.
|
|
60
|
+
*/
|
|
61
|
+
export function findMicESuffix(typeCode: string, text: string): string | undefined {
|
|
62
|
+
if (typeCode === '`' || typeCode === "'") {
|
|
63
|
+
for (const m of MICE) if (text.endsWith(m.suffix)) return m.suffix;
|
|
64
|
+
} else if (typeCode === '>' || typeCode === ']') {
|
|
65
|
+
for (const m of MICE_LEGACY) if (m.prefix === typeCode && m.suffix !== undefined && text.endsWith(m.suffix)) return m.suffix;
|
|
66
|
+
}
|
|
67
|
+
return undefined;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** The device a Mic-E type code and suffix identify. */
|
|
71
|
+
export function identifyMicE(typeCode: string | undefined, suffix: string | undefined): DeviceInfo | undefined {
|
|
72
|
+
if (typeCode === undefined) return undefined;
|
|
73
|
+
if (typeCode === '`' || typeCode === "'") {
|
|
74
|
+
if (suffix === undefined) return undefined;
|
|
75
|
+
const m = MICE.find((e) => e.suffix === suffix);
|
|
76
|
+
return m ? toInfo(m) : undefined;
|
|
77
|
+
}
|
|
78
|
+
if (typeCode === '>' || typeCode === ']') {
|
|
79
|
+
const m = MICE_LEGACY.find((e) => e.prefix === typeCode && e.suffix === suffix);
|
|
80
|
+
return m ? toInfo(m) : undefined;
|
|
81
|
+
}
|
|
82
|
+
return undefined;
|
|
83
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { DIAGNOSTIC_MEANINGS, isTolerable, TOLERABLE_CODES, type DiagnosticCode, type TolerableCode } from './codes.js';
|
|
2
|
+
|
|
3
|
+
/** How serious a diagnostic is. */
|
|
4
|
+
export type Severity = 'info' | 'warning' | 'error';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Something the decoder noticed about a packet: a defect it tolerated (`warning`), one that
|
|
8
|
+
* stopped it (`error`), or a remark (`info`, e.g. an obsolete format).
|
|
9
|
+
*/
|
|
10
|
+
export interface Diagnostic {
|
|
11
|
+
readonly severity: Severity;
|
|
12
|
+
readonly code: DiagnosticCode;
|
|
13
|
+
/** A plain ASCII description. */
|
|
14
|
+
readonly message: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Makes a diagnostic with the standard description of its code. */
|
|
18
|
+
export function diagnostic(severity: Severity, code: DiagnosticCode, detail?: string): Diagnostic {
|
|
19
|
+
const meaning = DIAGNOSTIC_MEANINGS[code];
|
|
20
|
+
return { severity, code, message: detail ? `${meaning} ${detail}` : meaning };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Formats a diagnostic as `severity:code`, the form the conformance vectors use. */
|
|
24
|
+
export function formatDiagnostic(d: Diagnostic): string {
|
|
25
|
+
return `${d.severity}:${d.code}`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Which defects the decoder tolerates. A lenient decoder (the default) tolerates every tolerable
|
|
30
|
+
* code, with a warning; a strict one tolerates none, and rejects a packet at the first defect.
|
|
31
|
+
* Each code can be switched on or off by itself.
|
|
32
|
+
*
|
|
33
|
+
* ```ts
|
|
34
|
+
* ParseOptions.lenient; // the default
|
|
35
|
+
* ParseOptions.strict;
|
|
36
|
+
* ParseOptions.lenient.without('trailing-line-break'); // lenient, except for that one
|
|
37
|
+
* ParseOptions.strict.with('lowercase-hemisphere'); // strict, except for that one
|
|
38
|
+
* ```
|
|
39
|
+
*/
|
|
40
|
+
export class ParseOptions {
|
|
41
|
+
/** Tolerates every tolerable defect, with a warning. */
|
|
42
|
+
static readonly lenient: ParseOptions = new ParseOptions(new Set(TOLERABLE_CODES));
|
|
43
|
+
/** Tolerates nothing. */
|
|
44
|
+
static readonly strict: ParseOptions = new ParseOptions(new Set());
|
|
45
|
+
|
|
46
|
+
private readonly tolerated: ReadonlySet<TolerableCode>;
|
|
47
|
+
|
|
48
|
+
private constructor(tolerated: ReadonlySet<TolerableCode>) {
|
|
49
|
+
this.tolerated = tolerated;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Options that tolerate exactly these codes. */
|
|
53
|
+
static tolerating(codes: Iterable<TolerableCode>): ParseOptions {
|
|
54
|
+
const set = new Set<TolerableCode>();
|
|
55
|
+
for (const code of codes) {
|
|
56
|
+
if (!isTolerable(code)) throw new RangeError(`${String(code)} is not a tolerable code`);
|
|
57
|
+
set.add(code);
|
|
58
|
+
}
|
|
59
|
+
return new ParseOptions(set);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** These options, also tolerating `code`. */
|
|
63
|
+
with(code: TolerableCode): ParseOptions {
|
|
64
|
+
if (!isTolerable(code)) throw new RangeError(`${String(code)} is not a tolerable code`);
|
|
65
|
+
const set = new Set(this.tolerated);
|
|
66
|
+
set.add(code);
|
|
67
|
+
return new ParseOptions(set);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** These options, no longer tolerating `code`. */
|
|
71
|
+
without(code: TolerableCode): ParseOptions {
|
|
72
|
+
const set = new Set(this.tolerated);
|
|
73
|
+
set.delete(code);
|
|
74
|
+
return new ParseOptions(set);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Whether `code` is tolerated. */
|
|
78
|
+
tolerates(code: DiagnosticCode): boolean {
|
|
79
|
+
return this.tolerated.has(code as TolerableCode);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** The tolerated codes. */
|
|
83
|
+
get toleratedCodes(): readonly TolerableCode[] {
|
|
84
|
+
return [...this.tolerated];
|
|
85
|
+
}
|
|
86
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// Shared pieces of the encoder.
|
|
2
|
+
|
|
3
|
+
import { encodeUtf8Binary } from '../bytes.js';
|
|
4
|
+
import type { AprsSymbol, AprsTimestamp } from '../types.js';
|
|
5
|
+
|
|
6
|
+
/** Thrown when the encoder declines to write something the spec does not allow; says why. */
|
|
7
|
+
export class AprsEncodeError extends Error {
|
|
8
|
+
constructor(message: string) {
|
|
9
|
+
super(message);
|
|
10
|
+
this.name = 'AprsEncodeError';
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function refuse(reason: string): never {
|
|
15
|
+
throw new AprsEncodeError(reason);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function pad(n: number, width: number): string {
|
|
19
|
+
return String(n).padStart(width, '0');
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** A whole number within [min, max], else refused. */
|
|
23
|
+
export function whole(value: number, min: number, max: number, what: string): number {
|
|
24
|
+
if (!Number.isFinite(value)) refuse(`${what} is not a number`);
|
|
25
|
+
const n = Math.round(value);
|
|
26
|
+
if (n < min || n > max) refuse(`${what} ${value} is out of range (${min}-${max})`);
|
|
27
|
+
return n;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Free text as UTF-8 bytes (a binary string); refuses a line break, which receivers strip. */
|
|
31
|
+
export function freeText(text: string, what: string): string {
|
|
32
|
+
if (/[\r\n]/.test(text)) refuse(`${what} contains a line break`);
|
|
33
|
+
return encodeUtf8Binary(text);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function isValidTimestamp(t: AprsTimestamp): boolean {
|
|
37
|
+
const ok = (v: number, lo: number, hi: number): boolean => Number.isInteger(v) && v >= lo && v <= hi;
|
|
38
|
+
switch (t.kind) {
|
|
39
|
+
case 'dhm-zulu':
|
|
40
|
+
case 'dhm-local':
|
|
41
|
+
return ok(t.day, 1, 31) && ok(t.hour, 0, 23) && ok(t.minute, 0, 59);
|
|
42
|
+
case 'hms':
|
|
43
|
+
return ok(t.hour, 0, 23) && ok(t.minute, 0, 59) && ok(t.second, 0, 59);
|
|
44
|
+
case 'mdhm':
|
|
45
|
+
return ok(t.month, 1, 12) && ok(t.day, 1, 31) && ok(t.hour, 0, 23) && ok(t.minute, 0, 59);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** A 7-byte timestamp for a position or object report. */
|
|
50
|
+
export function timestamp7(t: AprsTimestamp): string {
|
|
51
|
+
if (!isValidTimestamp(t)) refuse('the timestamp is out of range');
|
|
52
|
+
switch (t.kind) {
|
|
53
|
+
case 'dhm-zulu':
|
|
54
|
+
return `${pad(t.day, 2)}${pad(t.hour, 2)}${pad(t.minute, 2)}z`;
|
|
55
|
+
case 'dhm-local':
|
|
56
|
+
return `${pad(t.day, 2)}${pad(t.hour, 2)}${pad(t.minute, 2)}/`;
|
|
57
|
+
case 'hms':
|
|
58
|
+
return `${pad(t.hour, 2)}${pad(t.minute, 2)}${pad(t.second, 2)}h`;
|
|
59
|
+
case 'mdhm':
|
|
60
|
+
return refuse('a month/day/hour/minute timestamp is only for positionless weather reports');
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function checkSymbol(symbol: AprsSymbol): void {
|
|
65
|
+
if (symbol.code.length !== 1 || symbol.table.length !== 1) refuse('a symbol is a table and a code, one character each');
|
|
66
|
+
const code = symbol.code.charCodeAt(0);
|
|
67
|
+
if (code < 0x21 || code > 0x7e) refuse('the symbol code is not printable ASCII');
|
|
68
|
+
const t = symbol.table;
|
|
69
|
+
const ok = t === '/' || t === '\\' || (t >= '0' && t <= '9') || (t >= 'A' && t <= 'Z');
|
|
70
|
+
if (!ok) refuse(`the symbol table ${JSON.stringify(t)} is not /, \\, 0-9 or A-Z`);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function base91(value: number, width: number): string {
|
|
74
|
+
let v = value;
|
|
75
|
+
let out = '';
|
|
76
|
+
for (let i = 0; i < width; i++) {
|
|
77
|
+
out = String.fromCharCode((v % 91) + 33) + out;
|
|
78
|
+
v = Math.floor(v / 91);
|
|
79
|
+
}
|
|
80
|
+
return out;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Formats a number as short text: `0.53`, `-32`, `1e-7`. */
|
|
84
|
+
export function numberText(v: number): string {
|
|
85
|
+
if (!Number.isFinite(v)) refuse('a value is not a number');
|
|
86
|
+
return String(v);
|
|
87
|
+
}
|
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
// The encoder: writes only what the spec allows and refuses anything else with a reason. Free
|
|
2
|
+
// text is checked by decoding what was written: if the result does not read back as the same
|
|
3
|
+
// data (a comment that would read as an altitude, say), a `/` delimiter is tried, and if that
|
|
4
|
+
// does not help either the encoder refuses.
|
|
5
|
+
|
|
6
|
+
import { binaryToBytes, bytesToBinary, decodeUtf8 } from '../bytes.js';
|
|
7
|
+
import { decodeInformation } from '../decode/index.js';
|
|
8
|
+
import { formatDiagnostic, ParseOptions } from '../diagnostics.js';
|
|
9
|
+
import { isAprsIsAddress } from '../header.js';
|
|
10
|
+
import { toNeutralData } from '../neutral.js';
|
|
11
|
+
import type { AprsData, CompressionType, PathEntry } from '../types.js';
|
|
12
|
+
import { AprsEncodeError, refuse } from './common.js';
|
|
13
|
+
import {
|
|
14
|
+
encodeAck,
|
|
15
|
+
encodeAgrelo,
|
|
16
|
+
encodeBulletin,
|
|
17
|
+
encodeCapabilities,
|
|
18
|
+
encodeDirectedQuery,
|
|
19
|
+
encodeMaidenhead,
|
|
20
|
+
encodeMessage,
|
|
21
|
+
encodeNmea,
|
|
22
|
+
encodeQuery,
|
|
23
|
+
encodeRawWeather,
|
|
24
|
+
encodeStatus,
|
|
25
|
+
encodeTelemetryBits,
|
|
26
|
+
encodeTelemetryCoefficients,
|
|
27
|
+
encodeTelemetryNames,
|
|
28
|
+
encodeTelemetryReport,
|
|
29
|
+
encodeTest,
|
|
30
|
+
encodeThirdParty,
|
|
31
|
+
encodeUserDefined,
|
|
32
|
+
} from './other.js';
|
|
33
|
+
import { encodeItem, encodeMicE, encodeObject, encodePositionReport, encodeWeatherReport, type CommentMode } from './position.js';
|
|
34
|
+
|
|
35
|
+
export { AprsEncodeError } from './common.js';
|
|
36
|
+
|
|
37
|
+
/** An encoded information field, and for Mic-E the destination address it needs. */
|
|
38
|
+
export interface EncodedInformation {
|
|
39
|
+
/** The information field bytes. */
|
|
40
|
+
info: Uint8Array;
|
|
41
|
+
/** Mic-E only: the destination address, which carries half the position. */
|
|
42
|
+
destination?: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
interface Attempt {
|
|
46
|
+
binary: string;
|
|
47
|
+
destination?: string;
|
|
48
|
+
compression?: CompressionType;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function withCompression(b: { text: string; compression?: CompressionType }): Attempt {
|
|
52
|
+
return b.compression ? { binary: b.text, compression: b.compression } : { binary: b.text };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function attempt(data: AprsData, mode: CommentMode): Attempt {
|
|
56
|
+
switch (data.type) {
|
|
57
|
+
case 'position':
|
|
58
|
+
return withCompression(encodePositionReport(data, mode));
|
|
59
|
+
case 'object':
|
|
60
|
+
return withCompression(encodeObject(data, mode));
|
|
61
|
+
case 'item':
|
|
62
|
+
return withCompression(encodeItem(data, mode));
|
|
63
|
+
case 'mic-e': {
|
|
64
|
+
const m = encodeMicE(data, mode);
|
|
65
|
+
return { binary: m.text, destination: m.destination };
|
|
66
|
+
}
|
|
67
|
+
case 'weather':
|
|
68
|
+
return { binary: encodeWeatherReport(data) };
|
|
69
|
+
case 'message':
|
|
70
|
+
return { binary: encodeMessage(data) };
|
|
71
|
+
case 'ack':
|
|
72
|
+
case 'reject':
|
|
73
|
+
return { binary: encodeAck(data) };
|
|
74
|
+
case 'bulletin':
|
|
75
|
+
case 'nws-bulletin':
|
|
76
|
+
return { binary: encodeBulletin(data) };
|
|
77
|
+
case 'telemetry-names':
|
|
78
|
+
case 'telemetry-units':
|
|
79
|
+
return { binary: encodeTelemetryNames(data) };
|
|
80
|
+
case 'telemetry-coefficients':
|
|
81
|
+
return { binary: encodeTelemetryCoefficients(data) };
|
|
82
|
+
case 'telemetry-bits':
|
|
83
|
+
return { binary: encodeTelemetryBits(data) };
|
|
84
|
+
case 'directed-query':
|
|
85
|
+
return { binary: encodeDirectedQuery(data) };
|
|
86
|
+
case 'status':
|
|
87
|
+
return { binary: encodeStatus(data) };
|
|
88
|
+
case 'telemetry':
|
|
89
|
+
return { binary: encodeTelemetryReport(data) };
|
|
90
|
+
case 'raw-weather':
|
|
91
|
+
return { binary: encodeRawWeather(data) };
|
|
92
|
+
case 'nmea':
|
|
93
|
+
return { binary: encodeNmea(data) };
|
|
94
|
+
case 'maidenhead-beacon':
|
|
95
|
+
return { binary: encodeMaidenhead(data) };
|
|
96
|
+
case 'query':
|
|
97
|
+
return { binary: encodeQuery(data) };
|
|
98
|
+
case 'capabilities':
|
|
99
|
+
return { binary: encodeCapabilities(data) };
|
|
100
|
+
case 'third-party': {
|
|
101
|
+
// The original information field must not be changed (APRS12c ch. 17).
|
|
102
|
+
const original = data.packet.information;
|
|
103
|
+
const inner = original.length > 0 ? original : encodeInformation(data.packet.data).info;
|
|
104
|
+
return { binary: encodeThirdParty(data, bytesToBinary(inner)) };
|
|
105
|
+
}
|
|
106
|
+
case 'user-defined':
|
|
107
|
+
return { binary: encodeUserDefined(data) };
|
|
108
|
+
case 'test':
|
|
109
|
+
return { binary: encodeTest(data) };
|
|
110
|
+
case 'agrelo-df':
|
|
111
|
+
return { binary: encodeAgrelo(data) };
|
|
112
|
+
case 'unrecognized':
|
|
113
|
+
return refuse('unrecognized data has nothing to encode');
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Where the written data would read back differently: strings, booleans and which fields are present. */
|
|
118
|
+
function shapeDifferences(a: unknown, b: unknown, path: string, out: string[]): void {
|
|
119
|
+
if (typeof a === 'number' && typeof b === 'number') return;
|
|
120
|
+
if (Array.isArray(a) && Array.isArray(b)) {
|
|
121
|
+
if (a.length !== b.length) out.push(`${path.replace(/\.$/, '')} would have ${a.length} items, not ${b.length}`);
|
|
122
|
+
else a.forEach((x, i) => shapeDifferences(x, b[i], `${path}${i}.`, out));
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
if (a !== null && b !== null && typeof a === 'object' && typeof b === 'object') {
|
|
126
|
+
const ao = a as Record<string, unknown>;
|
|
127
|
+
const bo = b as Record<string, unknown>;
|
|
128
|
+
for (const k of new Set([...Object.keys(ao), ...Object.keys(bo)])) {
|
|
129
|
+
if (!(k in ao)) out.push(`${path}${k} would be lost`);
|
|
130
|
+
else if (!(k in bo)) out.push(`${path}${k} would appear`);
|
|
131
|
+
else shapeDifferences(ao[k], bo[k], `${path}${k}.`, out);
|
|
132
|
+
}
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
if (a !== b) out.push(`${path.replace(/\.$/, '')} would read back as ${JSON.stringify(a)}`);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** The data as it should read back: what the encoder fills in that the input may leave out. */
|
|
139
|
+
function expected(data: AprsData, a: Attempt): AprsData {
|
|
140
|
+
let d = data;
|
|
141
|
+
if ((d.type === 'position' || d.type === 'object' || d.type === 'item') && d.compressed && a.compression) {
|
|
142
|
+
if (!d.compression) d = { ...d, compression: a.compression };
|
|
143
|
+
if (d.courseDegrees !== undefined && d.speedKnots === undefined) d = { ...d, speedKnots: 0 };
|
|
144
|
+
}
|
|
145
|
+
if (d.type === 'mic-e') {
|
|
146
|
+
if (d.speedKnots === undefined) d = { ...d, speedKnots: 0 };
|
|
147
|
+
if (d.courseDegrees === 0) {
|
|
148
|
+
const copy: Record<string, unknown> = { ...d };
|
|
149
|
+
delete copy.courseDegrees;
|
|
150
|
+
d = copy as unknown as AprsData;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
return d;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function check(data: AprsData, a: Attempt): string[] {
|
|
157
|
+
const decoded = decodeInformation(binaryToBytes(a.binary), a.destination ?? 'APZ001', ParseOptions.lenient);
|
|
158
|
+
const problems: string[] = [];
|
|
159
|
+
const bad = decoded.diagnostics.filter((d) => d.severity !== 'info');
|
|
160
|
+
if (bad.length > 0) problems.push(`it would read back with ${bad.map(formatDiagnostic).join(', ')}`);
|
|
161
|
+
if (decoded.data.type !== data.type) {
|
|
162
|
+
problems.push(`it would read back as ${decoded.data.type}`);
|
|
163
|
+
return problems;
|
|
164
|
+
}
|
|
165
|
+
shapeDifferences(toNeutralData(decoded.data), toNeutralData(expected(data, a)), '', problems);
|
|
166
|
+
return problems;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Encodes data as an information field. Mic-E also gives the destination address it computed.
|
|
171
|
+
* Throws `AprsEncodeError`, with the reason, for anything the spec does not allow.
|
|
172
|
+
*/
|
|
173
|
+
export function encodeInformation(data: AprsData): EncodedInformation {
|
|
174
|
+
const first = attempt(data, 'plain');
|
|
175
|
+
let problems = check(data, first);
|
|
176
|
+
let chosen = first;
|
|
177
|
+
if (problems.length > 0 && typeof (data as { comment?: unknown }).comment === 'string') {
|
|
178
|
+
// The comment would read as something else: try it straight after a frequency, then after a /.
|
|
179
|
+
for (const mode of ['joined', 'delimited'] as const) {
|
|
180
|
+
let next: Attempt | undefined;
|
|
181
|
+
try {
|
|
182
|
+
next = attempt(data, mode);
|
|
183
|
+
} catch (e) {
|
|
184
|
+
if (!(e instanceof AprsEncodeError)) throw e;
|
|
185
|
+
}
|
|
186
|
+
if (next && next.binary !== first.binary && check(data, next).length === 0) {
|
|
187
|
+
chosen = next;
|
|
188
|
+
problems = [];
|
|
189
|
+
break;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
if (problems.length > 0) refuse(`it would not read back the same: ${problems.join('; ')}`);
|
|
194
|
+
const out: EncodedInformation = { info: binaryToBytes(chosen.binary) };
|
|
195
|
+
if (chosen.destination !== undefined) out.destination = chosen.destination;
|
|
196
|
+
return out;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** A packet to encode. */
|
|
200
|
+
export interface PacketToEncode {
|
|
201
|
+
source: string;
|
|
202
|
+
/** The destination (tocall); ignored for Mic-E, which computes it. Defaults to `APZ001`. */
|
|
203
|
+
destination?: string;
|
|
204
|
+
/** Digipeater path, e.g. `['WIDE1-1', 'WIDE2-1']`; a trailing `*` marks an entry used. */
|
|
205
|
+
path?: readonly (string | PathEntry)[];
|
|
206
|
+
data: AprsData;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function pathText(entry: string | PathEntry): string {
|
|
210
|
+
return typeof entry === 'string' ? entry : entry.used ? `${entry.address}*` : entry.address;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function resolve(p: PacketToEncode): { destination: string; info: Uint8Array; path: string[] } {
|
|
214
|
+
const encoded = encodeInformation(p.data);
|
|
215
|
+
const destination = encoded.destination ?? p.destination ?? 'APZ001';
|
|
216
|
+
return { destination, info: encoded.info, path: (p.path ?? []).map(pathText) };
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** Encodes a packet as the bytes of a TNC2 / APRS-IS line (the information field as sent). */
|
|
220
|
+
export function encodeTnc2Bytes(p: PacketToEncode): Uint8Array {
|
|
221
|
+
const r = resolve(p);
|
|
222
|
+
if (!isAprsIsAddress(p.source)) refuse(`the source ${JSON.stringify(p.source)} is not a valid address`);
|
|
223
|
+
if (!isAprsIsAddress(r.destination)) refuse(`the destination ${JSON.stringify(r.destination)} is not a valid address`);
|
|
224
|
+
for (const e of r.path) if (!isAprsIsAddress(e.replace(/\*$/, ''))) refuse(`the path entry ${JSON.stringify(e)} is not a valid address`);
|
|
225
|
+
const header = [`${p.source}>${r.destination}`, ...r.path].join(',') + ':';
|
|
226
|
+
const out = new Uint8Array(header.length + r.info.length);
|
|
227
|
+
out.set(binaryToBytes(header), 0);
|
|
228
|
+
out.set(r.info, header.length);
|
|
229
|
+
return out;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Encodes a packet as a TNC2 / APRS-IS line of text. User-defined data that is not UTF-8 has no
|
|
234
|
+
* text form; use `encodeTnc2Bytes` for that.
|
|
235
|
+
*/
|
|
236
|
+
export function encodeTnc2(p: PacketToEncode): string {
|
|
237
|
+
const text = decodeUtf8(encodeTnc2Bytes(p));
|
|
238
|
+
if (text === undefined) refuse('the information field is not UTF-8 text; use encodeTnc2Bytes');
|
|
239
|
+
return text;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function ax25Address(text: string, last: boolean, flag: boolean): number[] {
|
|
243
|
+
const m = /^([A-Z0-9]{1,6})(?:-([0-9]{1,2}))?$/.exec(text);
|
|
244
|
+
if (!m) refuse(`${JSON.stringify(text)} is not an AX.25 address`);
|
|
245
|
+
const ssid = m[2] === undefined ? 0 : Number(m[2]);
|
|
246
|
+
if (ssid > 15) refuse(`${JSON.stringify(text)} has an SSID over 15`);
|
|
247
|
+
const call = m[1]!.padEnd(6, ' ');
|
|
248
|
+
const out = Array.from(call, (c) => c.charCodeAt(0) << 1);
|
|
249
|
+
out.push(0x60 | (ssid << 1) | (flag ? 0x80 : 0) | (last ? 1 : 0));
|
|
250
|
+
return out;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** Encodes a packet as an AX.25 UI frame in KISS form (no flags, no FCS). */
|
|
254
|
+
export function encodeAx25(p: PacketToEncode): Uint8Array {
|
|
255
|
+
const r = resolve(p);
|
|
256
|
+
const path = p.path ?? [];
|
|
257
|
+
if (path.length > 8) refuse('AX.25 allows at most 8 digipeaters');
|
|
258
|
+
const bytes: number[] = [];
|
|
259
|
+
bytes.push(...ax25Address(r.destination, false, true));
|
|
260
|
+
bytes.push(...ax25Address(p.source, path.length === 0, false));
|
|
261
|
+
path.forEach((e, i) => {
|
|
262
|
+
const address = typeof e === 'string' ? e.replace(/\*$/, '') : e.address;
|
|
263
|
+
const used = typeof e === 'string' ? e.endsWith('*') : e.used;
|
|
264
|
+
bytes.push(...ax25Address(address, i === path.length - 1, used));
|
|
265
|
+
});
|
|
266
|
+
bytes.push(0x03, 0xf0, ...r.info);
|
|
267
|
+
return Uint8Array.from(bytes);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** Wraps an AX.25 frame for a KISS TNC: FEND, a data command on `port`, escaping, FEND. */
|
|
271
|
+
export function wrapKiss(frame: Uint8Array, port = 0): Uint8Array {
|
|
272
|
+
const out: number[] = [0xc0, (port & 0x0f) << 4];
|
|
273
|
+
for (const b of frame) {
|
|
274
|
+
if (b === 0xc0) out.push(0xdb, 0xdc);
|
|
275
|
+
else if (b === 0xdb) out.push(0xdb, 0xdd);
|
|
276
|
+
else out.push(b);
|
|
277
|
+
}
|
|
278
|
+
out.push(0xc0);
|
|
279
|
+
return Uint8Array.from(out);
|
|
280
|
+
}
|