@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
|
@@ -0,0 +1,378 @@
|
|
|
1
|
+
// Position reports, objects, items, Mic-E reports and positionless weather reports.
|
|
2
|
+
|
|
3
|
+
import { findMicESuffix } from '../deviceid.js';
|
|
4
|
+
import type {
|
|
5
|
+
AprsTimestamp,
|
|
6
|
+
ItemReport,
|
|
7
|
+
MicEMessage,
|
|
8
|
+
MicEReport,
|
|
9
|
+
ObjectReport,
|
|
10
|
+
PositionReport,
|
|
11
|
+
WeatherReport,
|
|
12
|
+
} from '../types.js';
|
|
13
|
+
import type { DecodeContext } from './context.js';
|
|
14
|
+
import {
|
|
15
|
+
applyDao,
|
|
16
|
+
applyLiftedTo,
|
|
17
|
+
base91Value,
|
|
18
|
+
decodeAfterPosition,
|
|
19
|
+
isDigit,
|
|
20
|
+
isSymbolTable,
|
|
21
|
+
liftMicEComment,
|
|
22
|
+
METRES_PER_FOOT,
|
|
23
|
+
parsePosition,
|
|
24
|
+
parsePositionlessWeather,
|
|
25
|
+
positionDecodesAt,
|
|
26
|
+
timestampAt,
|
|
27
|
+
timestampValid,
|
|
28
|
+
type Fields,
|
|
29
|
+
type RawPosition,
|
|
30
|
+
} from './positioned.js';
|
|
31
|
+
|
|
32
|
+
function withTimestamp<T extends object>(fields: T, timestamp: AprsTimestamp | undefined): T {
|
|
33
|
+
return timestamp ? { ...fields, timestamp } : fields;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** `!`, `=`, `/`, `@`: a position report. */
|
|
37
|
+
export function decodePositionReport(ctx: DecodeContext, s: string): PositionReport {
|
|
38
|
+
const dti = s[0]!;
|
|
39
|
+
const messaging = dti === '=' || dti === '@';
|
|
40
|
+
let start = 1;
|
|
41
|
+
let timestamp: AprsTimestamp | undefined;
|
|
42
|
+
if (dti === '/' || dti === '@') {
|
|
43
|
+
timestamp = timestampAt(s, 1);
|
|
44
|
+
if (timestamp) {
|
|
45
|
+
if (!timestampValid(timestamp)) ctx.tolerate('invalid-timestamp');
|
|
46
|
+
start = 8;
|
|
47
|
+
} else if (positionDecodesAt(ctx, s, 1)) {
|
|
48
|
+
ctx.tolerate('malformed-timestamp');
|
|
49
|
+
start = 1;
|
|
50
|
+
} else if (positionDecodesAt(ctx, s, 8)) {
|
|
51
|
+
ctx.tolerate('malformed-timestamp');
|
|
52
|
+
start = 8;
|
|
53
|
+
} else {
|
|
54
|
+
ctx.fail('malformed-timestamp');
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
const pos = parsePosition(ctx, s, start);
|
|
58
|
+
const fields = decodeAfterPosition(ctx, s, start + pos.length, pos);
|
|
59
|
+
const head: { type: 'position'; timestamp?: AprsTimestamp; messaging?: boolean } = { type: 'position' };
|
|
60
|
+
if (timestamp) head.timestamp = timestamp;
|
|
61
|
+
if (messaging) head.messaging = true;
|
|
62
|
+
return { ...head, ...fields };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** `;`: an object report. */
|
|
66
|
+
export function decodeObject(ctx: DecodeContext, s: string): ObjectReport {
|
|
67
|
+
if (s.length < 11) ctx.fail('truncated');
|
|
68
|
+
let marker = 10;
|
|
69
|
+
if (s[10] !== '*' && s[10] !== '_') {
|
|
70
|
+
marker = -1;
|
|
71
|
+
for (let k = 1; k <= 9; k++) {
|
|
72
|
+
if (s[k] === '*' || s[k] === '_') {
|
|
73
|
+
marker = k;
|
|
74
|
+
break;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
if (marker < 0) ctx.fail('invalid-object-name');
|
|
78
|
+
ctx.tolerate('object-name-not-padded');
|
|
79
|
+
}
|
|
80
|
+
const name = s.slice(1, marker).replace(/ +$/, '');
|
|
81
|
+
if (name.length === 0 || !/^[\x20-\x7e]+$/.test(name)) ctx.fail('invalid-object-name');
|
|
82
|
+
const killed = s[marker] === '_';
|
|
83
|
+
let timestamp = timestampAt(s, marker + 1);
|
|
84
|
+
let start: number;
|
|
85
|
+
if (timestamp) {
|
|
86
|
+
if (!timestampValid(timestamp)) ctx.tolerate('invalid-timestamp');
|
|
87
|
+
start = marker + 8;
|
|
88
|
+
} else {
|
|
89
|
+
const seven = s.substr(marker + 1, 7);
|
|
90
|
+
const looksLike = seven.length === 7 && (/^[0-9]{6}/.test(seven) || 'z/h'.includes(seven[6]!));
|
|
91
|
+
if (looksLike && positionDecodesAt(ctx, s, marker + 8)) {
|
|
92
|
+
ctx.tolerate('malformed-timestamp');
|
|
93
|
+
start = marker + 8;
|
|
94
|
+
} else {
|
|
95
|
+
ctx.tolerate('object-without-timestamp');
|
|
96
|
+
start = marker + 1;
|
|
97
|
+
}
|
|
98
|
+
timestamp = undefined;
|
|
99
|
+
}
|
|
100
|
+
const pos = parsePosition(ctx, s, start);
|
|
101
|
+
const fields = decodeAfterPosition(ctx, s, start + pos.length, pos);
|
|
102
|
+
const head: { type: 'object'; name: string; killed?: boolean } = { type: 'object', name };
|
|
103
|
+
if (killed) head.killed = true;
|
|
104
|
+
return withTimestamp({ ...head, ...fields }, timestamp);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** `)`: an item report. */
|
|
108
|
+
export function decodeItem(ctx: DecodeContext, s: string): ItemReport {
|
|
109
|
+
let marker = -1;
|
|
110
|
+
for (let k = 4; k < s.length && k <= 10; k++) {
|
|
111
|
+
if (s[k] === '!' || s[k] === '_') {
|
|
112
|
+
marker = k;
|
|
113
|
+
break;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
if (marker < 0) ctx.fail('invalid-item-name');
|
|
117
|
+
const name = s.slice(1, marker);
|
|
118
|
+
if (!/^[\x20-\x7e]{3,9}$/.test(name)) ctx.fail('invalid-item-name');
|
|
119
|
+
const killed = s[marker] === '_';
|
|
120
|
+
const pos = parsePosition(ctx, s, marker + 1);
|
|
121
|
+
const fields = decodeAfterPosition(ctx, s, marker + 1 + pos.length, pos);
|
|
122
|
+
const head: { type: 'item'; name: string; killed?: boolean } = { type: 'item', name };
|
|
123
|
+
if (killed) head.killed = true;
|
|
124
|
+
return { ...head, ...fields };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** `_`: a positionless weather report. */
|
|
128
|
+
export function decodePositionlessWeather(ctx: DecodeContext, s: string): WeatherReport {
|
|
129
|
+
ctx.info('obsolete-format');
|
|
130
|
+
const ts = s.substr(1, 8);
|
|
131
|
+
if (!/^[0-9]{8}$/.test(ts)) ctx.fail('invalid-timestamp');
|
|
132
|
+
const timestamp: AprsTimestamp = {
|
|
133
|
+
kind: 'mdhm',
|
|
134
|
+
month: Number(ts.substr(0, 2)),
|
|
135
|
+
day: Number(ts.substr(2, 2)),
|
|
136
|
+
hour: Number(ts.substr(4, 2)),
|
|
137
|
+
minute: Number(ts.substr(6, 2)),
|
|
138
|
+
};
|
|
139
|
+
if (!timestampValid(timestamp)) ctx.tolerate('invalid-timestamp');
|
|
140
|
+
const parsed = parsePositionlessWeather(ctx, s, 9);
|
|
141
|
+
const out: { -readonly [K in keyof WeatherReport]: WeatherReport[K] } = {
|
|
142
|
+
type: 'weather',
|
|
143
|
+
timestamp,
|
|
144
|
+
weather: parsed.weather,
|
|
145
|
+
};
|
|
146
|
+
if (parsed.comment !== undefined && parsed.comment.length > 0) out.comment = parsed.comment;
|
|
147
|
+
return out;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// ---- Mic-E (APRS12c ch. 10)
|
|
151
|
+
|
|
152
|
+
const STANDARD_MESSAGES: readonly MicEMessage[] = [
|
|
153
|
+
'emergency',
|
|
154
|
+
'priority',
|
|
155
|
+
'special',
|
|
156
|
+
'committed',
|
|
157
|
+
'returning',
|
|
158
|
+
'in-service',
|
|
159
|
+
'en-route',
|
|
160
|
+
'off-duty',
|
|
161
|
+
];
|
|
162
|
+
const CUSTOM_MESSAGES: readonly MicEMessage[] = [
|
|
163
|
+
'emergency',
|
|
164
|
+
'custom6',
|
|
165
|
+
'custom5',
|
|
166
|
+
'custom4',
|
|
167
|
+
'custom3',
|
|
168
|
+
'custom2',
|
|
169
|
+
'custom1',
|
|
170
|
+
'custom0',
|
|
171
|
+
];
|
|
172
|
+
|
|
173
|
+
interface MicEDestination {
|
|
174
|
+
digits: string;
|
|
175
|
+
ambiguity: number;
|
|
176
|
+
message: MicEMessage;
|
|
177
|
+
north: boolean;
|
|
178
|
+
lonOffset: boolean;
|
|
179
|
+
west: boolean;
|
|
180
|
+
ssid: number;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function decodeMicEDestination(ctx: DecodeContext, destination: string): MicEDestination {
|
|
184
|
+
const dash = destination.indexOf('-');
|
|
185
|
+
const call = dash >= 0 ? destination.slice(0, dash) : destination;
|
|
186
|
+
const ssid = dash >= 0 ? Number(destination.slice(dash + 1)) : 0;
|
|
187
|
+
if (call.length !== 6) ctx.fail('invalid-mic-e-destination');
|
|
188
|
+
let digits = '';
|
|
189
|
+
const kinds: ('std' | 'custom' | 'zero')[] = [];
|
|
190
|
+
const flags: boolean[] = [];
|
|
191
|
+
for (let k = 0; k < 6; k++) {
|
|
192
|
+
const c = call[k]!;
|
|
193
|
+
if (c >= '0' && c <= '9') {
|
|
194
|
+
digits += c;
|
|
195
|
+
kinds.push('zero');
|
|
196
|
+
flags.push(false);
|
|
197
|
+
} else if (k < 3 && c >= 'A' && c <= 'J') {
|
|
198
|
+
digits += String.fromCharCode(c.charCodeAt(0) - 17);
|
|
199
|
+
kinds.push('custom');
|
|
200
|
+
flags.push(false);
|
|
201
|
+
} else if (k < 3 && c === 'K') {
|
|
202
|
+
digits += ' ';
|
|
203
|
+
kinds.push('custom');
|
|
204
|
+
flags.push(false);
|
|
205
|
+
} else if (c === 'L') {
|
|
206
|
+
digits += ' ';
|
|
207
|
+
kinds.push('zero');
|
|
208
|
+
flags.push(false);
|
|
209
|
+
} else if (c >= 'P' && c <= 'Y') {
|
|
210
|
+
digits += String.fromCharCode(c.charCodeAt(0) - 32);
|
|
211
|
+
kinds.push('std');
|
|
212
|
+
flags.push(true);
|
|
213
|
+
} else if (c === 'Z') {
|
|
214
|
+
digits += ' ';
|
|
215
|
+
kinds.push('std');
|
|
216
|
+
flags.push(true);
|
|
217
|
+
} else {
|
|
218
|
+
ctx.fail('invalid-mic-e-destination');
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
// Ambiguity: trailing blanks in the latitude digits (minutes and hundredths only).
|
|
222
|
+
let ambiguity = 0;
|
|
223
|
+
for (let k = 5; k >= 2 && digits[k] === ' '; k--) ambiguity++;
|
|
224
|
+
for (let k = 0; k < 6 - ambiguity; k++) if (!isDigit(digits[k])) ctx.fail('invalid-mic-e-destination');
|
|
225
|
+
const num = (c: string): number => (c === ' ' ? 0 : c.charCodeAt(0) - 48);
|
|
226
|
+
const degrees = num(digits[0]!) * 10 + num(digits[1]!);
|
|
227
|
+
const minutes = num(digits[2]!) * 10 + num(digits[3]!) + (num(digits[4]!) * 10 + num(digits[5]!)) / 100;
|
|
228
|
+
if (minutes >= 60 || degrees > 90 || (degrees === 90 && minutes > 0)) ctx.fail('invalid-mic-e-destination');
|
|
229
|
+
const bits = kinds.slice(0, 3);
|
|
230
|
+
const hasStd = bits.includes('std');
|
|
231
|
+
const hasCustom = bits.includes('custom');
|
|
232
|
+
const index = (bits[0] !== 'zero' ? 4 : 0) + (bits[1] !== 'zero' ? 2 : 0) + (bits[2] !== 'zero' ? 1 : 0);
|
|
233
|
+
const message: MicEMessage =
|
|
234
|
+
hasStd && hasCustom ? 'unknown' : hasCustom ? CUSTOM_MESSAGES[index]! : STANDARD_MESSAGES[index]!;
|
|
235
|
+
return { digits, ambiguity, message, north: flags[3]!, lonOffset: flags[4]!, west: flags[5]!, ssid };
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function micEMinutes(tens: number, units: number, hundredths: number, ambiguity: number): number {
|
|
239
|
+
switch (ambiguity) {
|
|
240
|
+
case 0:
|
|
241
|
+
return tens * 10 + units + hundredths / 100;
|
|
242
|
+
case 1:
|
|
243
|
+
return tens * 10 + units + Math.floor(hundredths / 10) / 10 + 0.05;
|
|
244
|
+
case 2:
|
|
245
|
+
return tens * 10 + units + 0.5;
|
|
246
|
+
case 3:
|
|
247
|
+
return tens * 10 + 5;
|
|
248
|
+
default:
|
|
249
|
+
return 30;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const TYPE_CODES = '`\'>] ';
|
|
254
|
+
|
|
255
|
+
/** `` ` ``, `'`, 0x1c, 0x1d: a Mic-E report. */
|
|
256
|
+
export function decodeMicE(ctx: DecodeContext, s: string): MicEReport {
|
|
257
|
+
const dest = decodeMicEDestination(ctx, ctx.destination);
|
|
258
|
+
if (s.length < 9) ctx.fail('invalid-mic-e-information');
|
|
259
|
+
const b = (k: number): number => s.charCodeAt(k);
|
|
260
|
+
if (b(1) < 38 || b(1) > 127 || b(2) < 38 || b(2) > 97 || b(3) < 28 || b(3) > 127) ctx.fail('invalid-mic-e-information');
|
|
261
|
+
if (b(4) < 28 || b(4) > 127 || b(5) < 28 || b(5) > 127 || b(6) < 28 || b(6) > 127) ctx.fail('invalid-mic-e-information');
|
|
262
|
+
// Latitude
|
|
263
|
+
const d = dest.digits;
|
|
264
|
+
const num = (c: string): number => (c === ' ' ? 0 : c.charCodeAt(0) - 48);
|
|
265
|
+
const latDegrees = num(d[0]!) * 10 + num(d[1]!);
|
|
266
|
+
const latMinutes = micEMinutes(num(d[2]!), num(d[3]!), num(d[4]!) * 10 + num(d[5]!), dest.ambiguity);
|
|
267
|
+
// Longitude
|
|
268
|
+
let lonDegrees = b(1) - 28;
|
|
269
|
+
if (dest.lonOffset) lonDegrees += 100;
|
|
270
|
+
if (lonDegrees >= 180 && lonDegrees <= 189) lonDegrees -= 80;
|
|
271
|
+
else if (lonDegrees >= 190 && lonDegrees <= 199) lonDegrees -= 190;
|
|
272
|
+
let lonMin = b(2) - 28;
|
|
273
|
+
if (lonMin >= 60) lonMin -= 60;
|
|
274
|
+
const lonHundredths = b(3) - 28;
|
|
275
|
+
if (lonDegrees > 179) ctx.fail('invalid-mic-e-information');
|
|
276
|
+
const lonMinutes = micEMinutes(Math.floor(lonMin / 10), lonMin % 10, lonHundredths, dest.ambiguity);
|
|
277
|
+
// Speed and course
|
|
278
|
+
const sp = b(4) - 28;
|
|
279
|
+
const dc = b(5) - 28;
|
|
280
|
+
const se = b(6) - 28;
|
|
281
|
+
let speed = sp * 10 + Math.floor(dc / 10);
|
|
282
|
+
if (speed >= 800) speed -= 800;
|
|
283
|
+
let course = (dc % 10) * 100 + se;
|
|
284
|
+
if (course >= 400) course -= 400;
|
|
285
|
+
let courseOk = true;
|
|
286
|
+
if (course > 360) {
|
|
287
|
+
ctx.tolerate('out-of-range-value');
|
|
288
|
+
courseOk = false;
|
|
289
|
+
}
|
|
290
|
+
const code = s[7]!;
|
|
291
|
+
const table = s[8]!;
|
|
292
|
+
if (!isSymbolTable(table)) ctx.fail('invalid-symbol-table');
|
|
293
|
+
const codeValue = code.charCodeAt(0);
|
|
294
|
+
if (codeValue < 0x21 || codeValue > 0x7e) ctx.fail('invalid-symbol-code');
|
|
295
|
+
const latitude = (dest.north ? 1 : -1) * (latDegrees + latMinutes / 60);
|
|
296
|
+
const longitude = (dest.west ? -1 : 1) * (lonDegrees + lonMinutes / 60);
|
|
297
|
+
const pos: RawPosition = {
|
|
298
|
+
latDegrees,
|
|
299
|
+
latMinutes,
|
|
300
|
+
latSouth: !dest.north,
|
|
301
|
+
lonDegrees,
|
|
302
|
+
lonMinutes,
|
|
303
|
+
lonWest: dest.west,
|
|
304
|
+
latitude,
|
|
305
|
+
longitude,
|
|
306
|
+
ambiguity: dest.ambiguity,
|
|
307
|
+
symbol: { table, code },
|
|
308
|
+
compressed: false,
|
|
309
|
+
csBlank: true,
|
|
310
|
+
length: 9,
|
|
311
|
+
};
|
|
312
|
+
const f: Fields = { latitude: pos.latitude, longitude: pos.longitude, symbol: pos.symbol };
|
|
313
|
+
if (pos.ambiguity > 0) f.ambiguity = pos.ambiguity;
|
|
314
|
+
if (courseOk && course !== 0) f.courseDegrees = course;
|
|
315
|
+
f.speedKnots = speed;
|
|
316
|
+
|
|
317
|
+
// Status text
|
|
318
|
+
let text = s.slice(9);
|
|
319
|
+
if (text.includes('\xff')) {
|
|
320
|
+
ctx.tolerate('kenwood-ff-padding');
|
|
321
|
+
text = text.replace(/\xff/g, '');
|
|
322
|
+
}
|
|
323
|
+
let typeCode: string | undefined;
|
|
324
|
+
if (text.length > 0 && TYPE_CODES.includes(text[0]!)) {
|
|
325
|
+
typeCode = text[0]!;
|
|
326
|
+
text = text.slice(1);
|
|
327
|
+
} else if (text.length > 0) {
|
|
328
|
+
ctx.info('mic-e-missing-device-type');
|
|
329
|
+
}
|
|
330
|
+
let deviceSuffix: string | undefined;
|
|
331
|
+
if (typeCode !== undefined) {
|
|
332
|
+
deviceSuffix = findMicESuffix(typeCode, text);
|
|
333
|
+
if (deviceSuffix !== undefined) text = text.slice(0, text.length - deviceSuffix.length);
|
|
334
|
+
}
|
|
335
|
+
let altitudeFeet: number | undefined;
|
|
336
|
+
const altFirst = /^([!-{]{3})\}/.exec(text);
|
|
337
|
+
if (altFirst) {
|
|
338
|
+
altitudeFeet = (base91Value(altFirst[1]!) - 10000) / METRES_PER_FOOT;
|
|
339
|
+
text = text.slice(4);
|
|
340
|
+
} else {
|
|
341
|
+
const later = /([!-{]{3})\}/.exec(text);
|
|
342
|
+
if (later && ctx.allows('mic-e-altitude-not-first')) {
|
|
343
|
+
altitudeFeet = (base91Value(later[1]!) - 10000) / METRES_PER_FOOT;
|
|
344
|
+
text = text.slice(0, later.index) + text.slice(later.index + 4);
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
if (altitudeFeet !== undefined) f.altitudeFeet = altitudeFeet;
|
|
348
|
+
let locator: string | undefined;
|
|
349
|
+
const loc = /^([A-Ra-r]{2}[0-9]{2}(?:[A-Xa-x]{2})?)\/G/.exec(text);
|
|
350
|
+
if (loc) {
|
|
351
|
+
locator = loc[1]!.toUpperCase();
|
|
352
|
+
text = text.slice(loc[0].length);
|
|
353
|
+
if (text.length > 0) {
|
|
354
|
+
if (text[0] === ' ') text = text.slice(1);
|
|
355
|
+
else ctx.tolerate('missing-space-after-locator');
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
const { lifted } = liftMicEComment(ctx, text, pos.symbol);
|
|
359
|
+
applyLiftedTo(ctx, f, lifted);
|
|
360
|
+
applyDao(ctx, f, pos, lifted.dao);
|
|
361
|
+
|
|
362
|
+
const dti = s[0]!;
|
|
363
|
+
const head: {
|
|
364
|
+
type: 'mic-e';
|
|
365
|
+
micEMessage: MicEMessage;
|
|
366
|
+
oldData?: boolean;
|
|
367
|
+
typeCode?: string;
|
|
368
|
+
deviceSuffix?: string;
|
|
369
|
+
locator?: string;
|
|
370
|
+
destinationSsid?: number;
|
|
371
|
+
} = { type: 'mic-e', micEMessage: dest.message };
|
|
372
|
+
if (dti === "'" || dti === '\x1d') head.oldData = true;
|
|
373
|
+
if (typeCode !== undefined) head.typeCode = typeCode;
|
|
374
|
+
if (deviceSuffix !== undefined) head.deviceSuffix = deviceSuffix;
|
|
375
|
+
if (locator !== undefined) head.locator = locator;
|
|
376
|
+
if (dest.ssid !== 0) head.destinationSsid = dest.ssid;
|
|
377
|
+
return { ...head, ...f };
|
|
378
|
+
}
|
package/src/decoder.ts
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// The public decoding functions.
|
|
2
|
+
|
|
3
|
+
import { encodeUtf8 } from './bytes.js';
|
|
4
|
+
import { decodeInformation } from './decode/index.js';
|
|
5
|
+
import { identifyMicE, identifyTocall } from './deviceid.js';
|
|
6
|
+
import { ParseOptions } from './diagnostics.js';
|
|
7
|
+
import { findQConstruct, parseAx25Frame, parseTnc2Header, type ParsedHeader } from './header.js';
|
|
8
|
+
import type { AprsPacket } from './types.js';
|
|
9
|
+
|
|
10
|
+
function assemble(header: ParsedHeader, options: ParseOptions): AprsPacket {
|
|
11
|
+
const decoded = decodeInformation(header.info, header.destination, options);
|
|
12
|
+
const packet: { -readonly [K in keyof AprsPacket]: AprsPacket[K] } = {
|
|
13
|
+
source: header.source,
|
|
14
|
+
destination: header.destination,
|
|
15
|
+
path: header.path,
|
|
16
|
+
information: header.info,
|
|
17
|
+
data: decoded.data,
|
|
18
|
+
diagnostics: [...header.diagnostics, ...decoded.diagnostics],
|
|
19
|
+
};
|
|
20
|
+
const q = findQConstruct(header.path);
|
|
21
|
+
if (q) packet.qConstruct = q;
|
|
22
|
+
const device =
|
|
23
|
+
decoded.data.type === 'mic-e'
|
|
24
|
+
? identifyMicE(decoded.data.typeCode, decoded.data.deviceSuffix)
|
|
25
|
+
: identifyTocall(header.destination);
|
|
26
|
+
if (device) packet.device = device;
|
|
27
|
+
return packet;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Decodes a TNC2 / APRS-IS line (`SOURCE>DEST,PATH:information`), as text or as the bytes
|
|
32
|
+
* received. A string is taken as UTF-8.
|
|
33
|
+
*
|
|
34
|
+
* Throws `AprsHeaderError` when the header is unusable; everything else is reported in the
|
|
35
|
+
* packet's diagnostics, and data that cannot be decoded is `{ type: 'unrecognized' }`.
|
|
36
|
+
*/
|
|
37
|
+
export function decodeTnc2(line: string | Uint8Array, options: ParseOptions = ParseOptions.lenient): AprsPacket {
|
|
38
|
+
const bytes = typeof line === 'string' ? encodeUtf8(line) : line;
|
|
39
|
+
return assemble(parseTnc2Header(bytes, options), options);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Decodes an AX.25 UI frame in KISS form: the address, control, PID and information fields,
|
|
44
|
+
* with no flags and no FCS. Throws `AprsHeaderError` when it is not an APRS frame.
|
|
45
|
+
*/
|
|
46
|
+
export function decodeAx25(frame: Uint8Array, options: ParseOptions = ParseOptions.lenient): AprsPacket {
|
|
47
|
+
return assemble(parseAx25Frame(frame, options), options);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Decodes a KISS frame as read from a TNC: strips any FEND delimiters, undoes FESC escaping and
|
|
52
|
+
* drops the port/command byte (which must be a data frame), then decodes the AX.25 frame.
|
|
53
|
+
*/
|
|
54
|
+
export function decodeKiss(frame: Uint8Array, options: ParseOptions = ParseOptions.lenient): AprsPacket {
|
|
55
|
+
return decodeAx25(unwrapKiss(frame), options);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Removes KISS framing: FEND delimiters, FESC escapes and the command byte. */
|
|
59
|
+
export function unwrapKiss(frame: Uint8Array): Uint8Array {
|
|
60
|
+
let start = 0;
|
|
61
|
+
let end = frame.length;
|
|
62
|
+
while (start < end && frame[start] === 0xc0) start++;
|
|
63
|
+
while (end > start && frame[end - 1] === 0xc0) end--;
|
|
64
|
+
const out: number[] = [];
|
|
65
|
+
for (let i = start; i < end; i++) {
|
|
66
|
+
const b = frame[i]!;
|
|
67
|
+
if (b === 0xdb && i + 1 < end) {
|
|
68
|
+
const n = frame[++i]!;
|
|
69
|
+
out.push(n === 0xdc ? 0xc0 : n === 0xdd ? 0xdb : n);
|
|
70
|
+
} else {
|
|
71
|
+
out.push(b);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
if (out.length === 0 || (out[0]! & 0x0f) !== 0) throw new RangeError('not a KISS data frame');
|
|
75
|
+
return Uint8Array.from(out.slice(1));
|
|
76
|
+
}
|