@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.
Files changed (120) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/LICENSE +661 -0
  3. package/README.md +192 -0
  4. package/THIRD-PARTY-NOTICES.md +5 -0
  5. package/dist/builder.d.ts +232 -0
  6. package/dist/builder.d.ts.map +1 -0
  7. package/dist/builder.js +508 -0
  8. package/dist/builder.js.map +1 -0
  9. package/dist/bytes.d.ts +19 -0
  10. package/dist/bytes.d.ts.map +1 -0
  11. package/dist/bytes.js +120 -0
  12. package/dist/bytes.js.map +1 -0
  13. package/dist/codes.d.ts +13 -0
  14. package/dist/codes.d.ts.map +1 -0
  15. package/dist/codes.js +112 -0
  16. package/dist/codes.js.map +1 -0
  17. package/dist/decode/context.d.ts +33 -0
  18. package/dist/decode/context.d.ts.map +1 -0
  19. package/dist/decode/context.js +65 -0
  20. package/dist/decode/context.js.map +1 -0
  21. package/dist/decode/index.d.ts +10 -0
  22. package/dist/decode/index.d.ts.map +1 -0
  23. package/dist/decode/index.js +121 -0
  24. package/dist/decode/index.js.map +1 -0
  25. package/dist/decode/message.d.ts +5 -0
  26. package/dist/decode/message.d.ts.map +1 -0
  27. package/dist/decode/message.js +190 -0
  28. package/dist/decode/message.js.map +1 -0
  29. package/dist/decode/other.d.ts +25 -0
  30. package/dist/decode/other.d.ts.map +1 -0
  31. package/dist/decode/other.js +326 -0
  32. package/dist/decode/other.js.map +1 -0
  33. package/dist/decode/positioned.d.ts +132 -0
  34. package/dist/decode/positioned.d.ts.map +1 -0
  35. package/dist/decode/positioned.js +1063 -0
  36. package/dist/decode/positioned.js.map +1 -0
  37. package/dist/decode/reports.d.ts +13 -0
  38. package/dist/decode/reports.d.ts.map +1 -0
  39. package/dist/decode/reports.js +375 -0
  40. package/dist/decode/reports.js.map +1 -0
  41. package/dist/decoder.d.ts +23 -0
  42. package/dist/decoder.d.ts.map +1 -0
  43. package/dist/decoder.js +75 -0
  44. package/dist/decoder.js.map +1 -0
  45. package/dist/deviceid-data.d.ts +33 -0
  46. package/dist/deviceid-data.d.ts.map +1 -0
  47. package/dist/deviceid-data.js +2941 -0
  48. package/dist/deviceid-data.js.map +1 -0
  49. package/dist/deviceid.d.ts +12 -0
  50. package/dist/deviceid.d.ts.map +1 -0
  51. package/dist/deviceid.js +86 -0
  52. package/dist/deviceid.js.map +1 -0
  53. package/dist/diagnostics.d.ts +48 -0
  54. package/dist/diagnostics.d.ts.map +1 -0
  55. package/dist/diagnostics.js +65 -0
  56. package/dist/diagnostics.js.map +1 -0
  57. package/dist/encode/common.d.ts +19 -0
  58. package/dist/encode/common.d.ts.map +1 -0
  59. package/dist/encode/common.js +84 -0
  60. package/dist/encode/common.js.map +1 -0
  61. package/dist/encode/index.d.ts +35 -0
  62. package/dist/encode/index.d.ts.map +1 -0
  63. package/dist/encode/index.js +248 -0
  64. package/dist/encode/index.js.map +1 -0
  65. package/dist/encode/other.d.ts +21 -0
  66. package/dist/encode/other.d.ts.map +1 -0
  67. package/dist/encode/other.js +253 -0
  68. package/dist/encode/other.js.map +1 -0
  69. package/dist/encode/position.d.ts +31 -0
  70. package/dist/encode/position.d.ts.map +1 -0
  71. package/dist/encode/position.js +729 -0
  72. package/dist/encode/position.js.map +1 -0
  73. package/dist/header.d.ts +42 -0
  74. package/dist/header.d.ts.map +1 -0
  75. package/dist/header.js +206 -0
  76. package/dist/header.js.map +1 -0
  77. package/dist/index.d.ts +13 -0
  78. package/dist/index.d.ts.map +1 -0
  79. package/dist/index.js +14 -0
  80. package/dist/index.js.map +1 -0
  81. package/dist/neutral.d.ts +22 -0
  82. package/dist/neutral.d.ts.map +1 -0
  83. package/dist/neutral.js +593 -0
  84. package/dist/neutral.js.map +1 -0
  85. package/dist/symbols-data.d.ts +832 -0
  86. package/dist/symbols-data.d.ts.map +1 -0
  87. package/dist/symbols-data.js +173 -0
  88. package/dist/symbols-data.js.map +1 -0
  89. package/dist/symbols.d.ts +35 -0
  90. package/dist/symbols.d.ts.map +1 -0
  91. package/dist/symbols.js +71 -0
  92. package/dist/symbols.js.map +1 -0
  93. package/dist/types.d.ts +458 -0
  94. package/dist/types.d.ts.map +1 -0
  95. package/dist/types.js +4 -0
  96. package/dist/types.js.map +1 -0
  97. package/package.json +68 -0
  98. package/src/builder.ts +617 -0
  99. package/src/bytes.ts +118 -0
  100. package/src/codes.ts +218 -0
  101. package/src/decode/context.ts +63 -0
  102. package/src/decode/index.ts +135 -0
  103. package/src/decode/message.ts +194 -0
  104. package/src/decode/other.ts +307 -0
  105. package/src/decode/positioned.ts +1161 -0
  106. package/src/decode/reports.ts +378 -0
  107. package/src/decoder.ts +76 -0
  108. package/src/deviceid-data.ts +2954 -0
  109. package/src/deviceid.ts +83 -0
  110. package/src/diagnostics.ts +86 -0
  111. package/src/encode/common.ts +87 -0
  112. package/src/encode/index.ts +280 -0
  113. package/src/encode/other.ts +247 -0
  114. package/src/encode/position.ts +656 -0
  115. package/src/header.ts +221 -0
  116. package/src/index.ts +57 -0
  117. package/src/neutral.ts +595 -0
  118. package/src/symbols-data.ts +181 -0
  119. package/src/symbols.ts +85 -0
  120. package/src/types.ts +571 -0
@@ -0,0 +1,1161 @@
1
+ // Positions, and everything that can follow one: data extensions, weather data, and the
2
+ // structured elements lifted out of a comment (APRS12c ch. 6-9, 12, 13, 18).
3
+
4
+ import type {
5
+ AprsSymbol,
6
+ AprsTimestamp,
7
+ AreaColor,
8
+ AreaObject,
9
+ AreaShape,
10
+ CommentTelemetry,
11
+ CompressionOrigin,
12
+ CompressionSource,
13
+ CompressionType,
14
+ Dao,
15
+ DfBearing,
16
+ Dfs,
17
+ ExtraWeatherField,
18
+ Phg,
19
+ Storm,
20
+ StormType,
21
+ ToneType,
22
+ VoiceFrequency,
23
+ Weather,
24
+ } from '../types.js';
25
+ import type { DecodeContext } from './context.js';
26
+
27
+ // ---- small helpers
28
+
29
+ export function isDigit(c: string | undefined): boolean {
30
+ return c !== undefined && c >= '0' && c <= '9';
31
+ }
32
+
33
+ export function isBase91(c: string | undefined): boolean {
34
+ if (c === undefined || c.length === 0) return false;
35
+ const code = c.charCodeAt(0);
36
+ return code >= 33 && code <= 123;
37
+ }
38
+
39
+ export function base91Value(s: string): number {
40
+ let v = 0;
41
+ for (let i = 0; i < s.length; i++) v = v * 91 + (s.charCodeAt(i) - 33);
42
+ return v;
43
+ }
44
+
45
+ function isPrintableSymbolCode(c: string | undefined): boolean {
46
+ if (c === undefined) return false;
47
+ const code = c.charCodeAt(0);
48
+ return code >= 0x21 && code <= 0x7e;
49
+ }
50
+
51
+ /** Whether `c` can be an uncompressed symbol table identifier: `/`, `\`, `0`-`9`, `A`-`Z`. */
52
+ export function isSymbolTable(c: string | undefined): boolean {
53
+ return c === '/' || c === '\\' || isDigit(c) || (c !== undefined && c >= 'A' && c <= 'Z');
54
+ }
55
+
56
+ function isCompressedTable(c: string | undefined): boolean {
57
+ return c === '/' || c === '\\' || (c !== undefined && ((c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'j')));
58
+ }
59
+
60
+ export const KNOTS_TO_MPH = 1.150779448;
61
+ export const METRES_PER_FOOT = 0.3048;
62
+
63
+ // ---- timestamps
64
+
65
+ /** A 7-byte timestamp shaped `ddhhmmz`, `ddhhmm/` or `hhmmssh` at `i`, without checking ranges. */
66
+ export function timestampAt(s: string, i: number): AprsTimestamp | undefined {
67
+ if (s.length < i + 7) return undefined;
68
+ for (let k = 0; k < 6; k++) if (!isDigit(s[i + k])) return undefined;
69
+ const a = Number(s.substr(i, 2));
70
+ const b = Number(s.substr(i + 2, 2));
71
+ const c = Number(s.substr(i + 4, 2));
72
+ switch (s[i + 6]) {
73
+ case 'z':
74
+ return { kind: 'dhm-zulu', day: a, hour: b, minute: c };
75
+ case '/':
76
+ return { kind: 'dhm-local', day: a, hour: b, minute: c };
77
+ case 'h':
78
+ return { kind: 'hms', hour: a, minute: b, second: c };
79
+ default:
80
+ return undefined;
81
+ }
82
+ }
83
+
84
+ /** Whether a timestamp's fields are in range. */
85
+ export function timestampValid(t: AprsTimestamp): boolean {
86
+ switch (t.kind) {
87
+ case 'dhm-zulu':
88
+ case 'dhm-local':
89
+ return t.day >= 1 && t.day <= 31 && t.hour <= 23 && t.minute <= 59;
90
+ case 'hms':
91
+ return t.hour <= 23 && t.minute <= 59 && t.second <= 59;
92
+ case 'mdhm':
93
+ return t.month >= 1 && t.month <= 12 && t.day >= 1 && t.day <= 31 && t.hour <= 23 && t.minute <= 59;
94
+ }
95
+ }
96
+
97
+ // ---- positions
98
+
99
+ /** A decoded position, before any `!DAO!` is applied. */
100
+ export interface RawPosition {
101
+ latDegrees: number;
102
+ latMinutes: number;
103
+ latSouth: boolean;
104
+ lonDegrees: number;
105
+ lonMinutes: number;
106
+ lonWest: boolean;
107
+ /** For a compressed position, the decoded degrees. */
108
+ latitude: number;
109
+ longitude: number;
110
+ ambiguity: number;
111
+ symbol: AprsSymbol;
112
+ compressed: boolean;
113
+ /** Compressed: the cs bytes were blank (a space). */
114
+ csBlank: boolean;
115
+ compression?: CompressionType;
116
+ /** Compressed cs: course/speed. */
117
+ csCourse?: number;
118
+ csCourseRaw?: number;
119
+ csSpeedKnots?: number;
120
+ csRangeMiles?: number;
121
+ csAltitudeFeet?: number;
122
+ /** Bytes consumed. */
123
+ length: number;
124
+ }
125
+
126
+ function ambiguityDigits(chars: string[]): number | undefined {
127
+ // chars are the four blankable digits, most significant first; blanks must trail.
128
+ let blanks = 0;
129
+ for (let k = chars.length - 1; k >= 0; k--) {
130
+ if (chars[k] === ' ') blanks++;
131
+ else break;
132
+ }
133
+ for (let k = 0; k < chars.length - blanks; k++) if (!isDigit(chars[k])) return undefined;
134
+ return blanks;
135
+ }
136
+
137
+ function ambiguousMinutes(tens: string, units: string, tenths: string, hundredths: string, ambiguity: number): number {
138
+ const d = (c: string): number => (isDigit(c) ? c.charCodeAt(0) - 48 : 0);
139
+ switch (ambiguity) {
140
+ case 0:
141
+ return d(tens) * 10 + d(units) + d(tenths) / 10 + d(hundredths) / 100;
142
+ case 1:
143
+ return d(tens) * 10 + d(units) + d(tenths) / 10 + 0.05;
144
+ case 2:
145
+ return d(tens) * 10 + d(units) + 0.5;
146
+ case 3:
147
+ return d(tens) * 10 + 5;
148
+ default:
149
+ return 30;
150
+ }
151
+ }
152
+
153
+ /** Parses an uncompressed position (`ddmm.hhN/dddmm.hhW$`) at `i`; 19 bytes. */
154
+ export function parseUncompressed(ctx: DecodeContext, s: string, i: number): RawPosition {
155
+ if (s.length < i + 19) ctx.fail('truncated');
156
+ // Latitude
157
+ const lat = s.substr(i, 8);
158
+ if (!isDigit(lat[0]) || !isDigit(lat[1]) || lat[4] !== '.') ctx.fail('invalid-latitude');
159
+ const latAmbiguity = ambiguityDigits([lat[2]!, lat[3]!, lat[5]!, lat[6]!]);
160
+ if (latAmbiguity === undefined) ctx.fail('invalid-latitude');
161
+ const latDegrees = Number(lat.substr(0, 2));
162
+ const latMinutes = ambiguousMinutes(lat[2]!, lat[3]!, lat[5]!, lat[6]!, latAmbiguity);
163
+ const rawLatMinutes = ambiguousMinutes(lat[2]!, lat[3]!, lat[5]!, lat[6]!, 0);
164
+ if (rawLatMinutes >= 60 || latDegrees > 90 || (latDegrees === 90 && rawLatMinutes > 0)) ctx.fail('invalid-latitude');
165
+ let ns = lat[7]!;
166
+ if (ns === 'n' || ns === 's') {
167
+ ctx.tolerate('lowercase-hemisphere');
168
+ ns = ns.toUpperCase();
169
+ }
170
+ if (ns !== 'N' && ns !== 'S') ctx.fail('invalid-latitude');
171
+ // Symbol table
172
+ const table = s[i + 8]!;
173
+ if (!isSymbolTable(table)) ctx.fail('invalid-symbol-table');
174
+ // Longitude
175
+ const lon = s.substr(i + 9, 9);
176
+ if (!isDigit(lon[0]) || !isDigit(lon[1]) || !isDigit(lon[2]) || lon[5] !== '.') ctx.fail('invalid-longitude');
177
+ const lonBlanks = ambiguityDigits([lon[3]!, lon[4]!, lon[6]!, lon[7]!]);
178
+ if (lonBlanks === undefined || lonBlanks > latAmbiguity) ctx.fail('invalid-longitude');
179
+ const lonDegrees = Number(lon.substr(0, 3));
180
+ const lonMinutes = ambiguousMinutes(lon[3]!, lon[4]!, lon[6]!, lon[7]!, latAmbiguity);
181
+ const rawLonMinutes = ambiguousMinutes(lon[3]!, lon[4]!, lon[6]!, lon[7]!, 0);
182
+ if (rawLonMinutes >= 60 || lonDegrees > 180 || (lonDegrees === 180 && rawLonMinutes > 0)) ctx.fail('invalid-longitude');
183
+ let ew = lon[8]!;
184
+ if (ew === 'e' || ew === 'w') {
185
+ ctx.tolerate('lowercase-hemisphere');
186
+ ew = ew.toUpperCase();
187
+ }
188
+ if (ew !== 'E' && ew !== 'W') ctx.fail('invalid-longitude');
189
+ const code = s[i + 18]!;
190
+ if (!isPrintableSymbolCode(code)) ctx.fail('invalid-symbol-code');
191
+ const latitude = (ns === 'S' ? -1 : 1) * (latDegrees + latMinutes / 60);
192
+ const longitude = (ew === 'W' ? -1 : 1) * (lonDegrees + lonMinutes / 60);
193
+ return {
194
+ latDegrees,
195
+ latMinutes,
196
+ latSouth: ns === 'S',
197
+ lonDegrees,
198
+ lonMinutes,
199
+ lonWest: ew === 'W',
200
+ // A zero coordinate keeps its hemisphere as the sign of zero, so it is written back the same.
201
+ latitude,
202
+ longitude,
203
+ ambiguity: latAmbiguity,
204
+ symbol: { table, code },
205
+ compressed: false,
206
+ csBlank: true,
207
+ length: 19,
208
+ };
209
+ }
210
+
211
+ const COMPRESSION_SOURCES: readonly CompressionSource[] = ['other', 'gll', 'gga', 'rmc'];
212
+ const COMPRESSION_ORIGINS: readonly CompressionOrigin[] = [
213
+ 'compressed',
214
+ 'tnc-beacon-text',
215
+ 'software',
216
+ 'reserved3',
217
+ 'kpc3',
218
+ 'pico',
219
+ 'other-tracker',
220
+ 'digipeater-conversion',
221
+ ];
222
+
223
+ /** Decodes a compression type byte's value (0-63). */
224
+ export function compressionTypeOf(t: number): CompressionType {
225
+ return {
226
+ fix: (t & 0x20) !== 0 ? 'current' : 'old',
227
+ source: COMPRESSION_SOURCES[(t >> 3) & 3]!,
228
+ origin: COMPRESSION_ORIGINS[t & 7]!,
229
+ };
230
+ }
231
+
232
+ /** Parses a compressed position (`/YYYYXXXX$csT`) at `i`; 13 bytes. */
233
+ export function parseCompressed(ctx: DecodeContext, s: string, i: number): RawPosition {
234
+ if (s.length < i + 13) ctx.fail('truncated');
235
+ let table = s[i]!;
236
+ if (table >= 'a' && table <= 'j') table = String.fromCharCode(table.charCodeAt(0) - 49); // a-j -> 0-9
237
+ for (let k = 1; k <= 8; k++) if (!isBase91(s[i + k])) ctx.fail('invalid-compressed-position');
238
+ const latitude = 90 - base91Value(s.substr(i + 1, 4)) / 380926;
239
+ const longitude = -180 + base91Value(s.substr(i + 5, 4)) / 190463;
240
+ if (latitude < -90 || latitude > 90 || longitude < -180 || longitude > 180) ctx.fail('invalid-compressed-position');
241
+ const code = s[i + 9]!;
242
+ if (!isPrintableSymbolCode(code)) ctx.fail('invalid-symbol-code');
243
+ const c = s[i + 10]!;
244
+ const sByte = s[i + 11]!;
245
+ const tByte = s[i + 12]!;
246
+ const pos: RawPosition = {
247
+ latDegrees: 0,
248
+ latMinutes: 0,
249
+ latSouth: false,
250
+ lonDegrees: 0,
251
+ lonMinutes: 0,
252
+ lonWest: false,
253
+ latitude,
254
+ longitude,
255
+ ambiguity: 0,
256
+ symbol: { table, code },
257
+ compressed: true,
258
+ csBlank: c === ' ',
259
+ length: 13,
260
+ };
261
+ if (c === ' ') return pos;
262
+ if (!isBase91(c) || !isBase91(sByte) || !isBase91(tByte)) ctx.fail('invalid-compressed-position');
263
+ let t = tByte.charCodeAt(0) - 33;
264
+ if ((t & 0xc0) !== 0) {
265
+ ctx.tolerate('compression-type-reserved-bits');
266
+ t &= 0x3f;
267
+ }
268
+ pos.compression = compressionTypeOf(t);
269
+ const cv = c.charCodeAt(0) - 33;
270
+ const sv = sByte.charCodeAt(0) - 33;
271
+ if (pos.compression.source === 'gga') {
272
+ pos.csAltitudeFeet = Math.pow(1.002, cv * 91 + sv);
273
+ } else if (cv <= 89) {
274
+ pos.csCourseRaw = cv * 4;
275
+ pos.csCourse = cv === 0 ? 360 : cv * 4;
276
+ pos.csSpeedKnots = Math.pow(1.08, sv) - 1;
277
+ } else {
278
+ pos.csRangeMiles = 2 * Math.pow(1.08, sv);
279
+ }
280
+ return pos;
281
+ }
282
+
283
+ /** Parses a position at `i`, uncompressed or compressed by its first byte. */
284
+ export function parsePosition(ctx: DecodeContext, s: string, i: number): RawPosition {
285
+ const c = s[i];
286
+ if (c === undefined) ctx.fail('truncated');
287
+ if (isDigit(c)) return parseUncompressed(ctx, s, i);
288
+ if (isCompressedTable(c)) return parseCompressed(ctx, s, i);
289
+ return ctx.fail('invalid-position');
290
+ }
291
+
292
+ /** Whether a position decodes at `i`, without reporting anything. */
293
+ export function positionDecodesAt(ctx: DecodeContext, s: string, i: number): boolean {
294
+ const probe = Object.create(ctx) as DecodeContext;
295
+ // A throwaway context: diagnostics go nowhere and every defect is tolerated silently.
296
+ Object.defineProperty(probe, 'diagnostics', { value: [] });
297
+ try {
298
+ parsePosition(probe, s, i);
299
+ return true;
300
+ } catch {
301
+ return false;
302
+ }
303
+ }
304
+
305
+ // ---- !DAO!
306
+
307
+ interface DaoFound {
308
+ start: number;
309
+ dao: Dao;
310
+ latAdd: number;
311
+ lonAdd: number;
312
+ }
313
+
314
+ function daoAt(s: string, p: number): DaoFound | undefined {
315
+ if (s[p] !== '!' || s[p + 4] !== '!') return undefined;
316
+ const d = s[p + 1]!;
317
+ const a = s[p + 2]!;
318
+ const o = s[p + 3]!;
319
+ if (d >= 'A' && d <= 'Z') {
320
+ if (a === ' ' && o === ' ') return { start: p, dao: { datum: d, precision: 'none' }, latAdd: 0, lonAdd: 0 };
321
+ if (isDigit(a) && isDigit(o))
322
+ return {
323
+ start: p,
324
+ dao: { datum: d, precision: 'thousandths' },
325
+ latAdd: (a.charCodeAt(0) - 48) / 1000,
326
+ lonAdd: (o.charCodeAt(0) - 48) / 1000,
327
+ };
328
+ return undefined;
329
+ }
330
+ if (d >= 'a' && d <= 'z') {
331
+ const datum = d.toUpperCase();
332
+ if (a === ' ' && o === ' ') return { start: p, dao: { datum, precision: 'none' }, latAdd: 0, lonAdd: 0 };
333
+ if (isBase91(a) && isBase91(o))
334
+ return {
335
+ start: p,
336
+ dao: { datum, precision: 'base91' },
337
+ latAdd: (a.charCodeAt(0) - 33) / 91 / 100,
338
+ lonAdd: (o.charCodeAt(0) - 33) / 91 / 100,
339
+ };
340
+ return undefined;
341
+ }
342
+ return undefined;
343
+ }
344
+
345
+ // ---- base-91 comment telemetry
346
+
347
+ interface TelemetryFound {
348
+ start: number;
349
+ end: number; // exclusive
350
+ telemetry: CommentTelemetry;
351
+ }
352
+
353
+ function findCommentTelemetry(s: string): TelemetryFound | undefined {
354
+ const last = s.lastIndexOf('|');
355
+ if (last <= 0) return undefined;
356
+ const prev = s.lastIndexOf('|', last - 1);
357
+ if (prev < 0) return undefined;
358
+ const body = s.slice(prev + 1, last);
359
+ if (body.length < 4 || body.length > 14 || body.length % 2 !== 0) return undefined;
360
+ for (let k = 0; k < body.length; k++) if (!isBase91(body[k])) return undefined;
361
+ const values: number[] = [];
362
+ for (let k = 0; k < body.length; k += 2) values.push(base91Value(body.substr(k, 2)));
363
+ const sequence = values[0]!;
364
+ const rest = values.slice(1);
365
+ const telemetry: CommentTelemetry =
366
+ rest.length === 6 ? { sequence, analog: rest.slice(0, 5), digital: rest[5]! } : { sequence, analog: rest };
367
+ return { start: prev, end: last + 1, telemetry };
368
+ }
369
+
370
+ // ---- data extensions
371
+
372
+ const AREA_SHAPES: readonly AreaShape[] = [
373
+ 'open-circle',
374
+ 'line-down-right',
375
+ 'open-ellipse',
376
+ 'open-triangle',
377
+ 'open-box',
378
+ 'filled-circle',
379
+ 'line-down-left',
380
+ 'filled-ellipse',
381
+ 'filled-triangle',
382
+ 'filled-box',
383
+ ];
384
+ const AREA_COLORS: readonly AreaColor[] = [
385
+ 'black',
386
+ 'blue',
387
+ 'green',
388
+ 'cyan',
389
+ 'red',
390
+ 'violet',
391
+ 'yellow',
392
+ 'gray',
393
+ 'black-low',
394
+ 'blue-low',
395
+ 'green-low',
396
+ 'cyan-low',
397
+ 'red-low',
398
+ 'violet-low',
399
+ 'yellow-low',
400
+ 'gray-low',
401
+ ];
402
+
403
+ export { AREA_SHAPES, AREA_COLORS };
404
+
405
+ const PHG_RE = /^PHG([0-9])([0-~])([0-9])([0-9])(?:([1-9A-Z])\/)?/;
406
+ const RNG_RE = /^RNG([0-9]{4})/;
407
+ const DFS_RE = /^DFS([0-9])([0-~])([0-9])([0-9])/;
408
+ const CSE_SPD_RE = /^([0-9]{3}|\.{3}| {3})\/([0-9]{3}|\.{3}| {3})/;
409
+
410
+ function rateValue(c: string): number {
411
+ return isDigit(c) ? c.charCodeAt(0) - 48 : c.charCodeAt(0) - 55;
412
+ }
413
+
414
+ interface ExtensionFound {
415
+ length: number;
416
+ phg?: Phg;
417
+ rangeMiles?: number;
418
+ dfs?: Dfs;
419
+ }
420
+
421
+ function extensionAt(s: string, i: number): ExtensionFound | undefined {
422
+ const t = s.slice(i, i + 9);
423
+ let m = PHG_RE.exec(t);
424
+ if (m) {
425
+ const phg: Phg = {
426
+ power: Number(m[1]),
427
+ height: m[2]!.charCodeAt(0) - 48,
428
+ gain: Number(m[3]),
429
+ directivity: Number(m[4]),
430
+ ...(m[5] !== undefined ? { beaconsPerHour: rateValue(m[5]) } : {}),
431
+ };
432
+ return { length: m[0].length, phg };
433
+ }
434
+ m = RNG_RE.exec(t);
435
+ if (m) return { length: 7, rangeMiles: Number(m[1]) };
436
+ m = DFS_RE.exec(t);
437
+ if (m)
438
+ return {
439
+ length: 7,
440
+ dfs: { strength: Number(m[1]), height: m[2]!.charCodeAt(0) - 48, gain: Number(m[3]), directivity: Number(m[4]) },
441
+ };
442
+ return undefined;
443
+ }
444
+
445
+ function findLaterExtension(s: string): { start: number; ext: ExtensionFound } | undefined {
446
+ for (const key of ['PHG', 'RNG', 'DFS']) {
447
+ let from = 0;
448
+ for (;;) {
449
+ const p = s.indexOf(key, from);
450
+ if (p < 0) break;
451
+ const ext = extensionAt(s, p);
452
+ if (ext) return { start: p, ext };
453
+ from = p + 1;
454
+ }
455
+ }
456
+ return undefined;
457
+ }
458
+
459
+ function threeDigits(v: string): number | undefined {
460
+ return /^[0-9]{3}$/.test(v) ? Number(v) : undefined;
461
+ }
462
+
463
+ // ---- voice frequency (APRS12c ch. 18)
464
+
465
+ const MICROWAVE_BASE: Record<string, number> = {
466
+ A: 1200,
467
+ B: 2300,
468
+ C: 2400,
469
+ D: 3400,
470
+ E: 5600,
471
+ F: 5700,
472
+ G: 5800,
473
+ H: 10100,
474
+ I: 10200,
475
+ J: 10300,
476
+ K: 10400,
477
+ L: 10500,
478
+ M: 24000,
479
+ N: 24100,
480
+ O: 24200,
481
+ };
482
+
483
+ export { MICROWAVE_BASE };
484
+
485
+ const FREQ_KHZ_RE = /^([0-9]{3}|[A-O][0-9]{2})\.([0-9]{3})MHz/i;
486
+ const FREQ_10KHZ_RE = /^([0-9]{3}|[A-O][0-9]{2})\.([0-9]{2}) MHz/i;
487
+
488
+ function frequencyMhz(whole: string, fraction: string): number {
489
+ const first = whole[0]!;
490
+ const base = isDigit(first) ? Number(whole) : MICROWAVE_BASE[first]! + Number(whole.slice(1));
491
+ return base + Number(fraction) / Math.pow(10, fraction.length);
492
+ }
493
+
494
+ interface FrequencyFound {
495
+ frequency: VoiceFrequency;
496
+ length: number;
497
+ }
498
+
499
+ /** Parses a voice frequency and its tone, offset and range fields at the start of `s`. */
500
+ function frequencyAt(s: string): FrequencyFound | undefined {
501
+ let m = FREQ_KHZ_RE.exec(s);
502
+ let tenKhz = false;
503
+ if (!m) {
504
+ m = FREQ_10KHZ_RE.exec(s);
505
+ tenKhz = true;
506
+ }
507
+ if (!m) return undefined;
508
+ let freq: { -readonly [K in keyof VoiceFrequency]: VoiceFrequency[K] } = { mhz: frequencyMhz(m[1]!, m[2]!) };
509
+ if (tenKhz) freq.tenKhzResolution = true;
510
+ let p = m[0].length;
511
+ const fieldAt = (re: RegExp): RegExpExecArray | undefined => {
512
+ if (s[p] !== ' ') return undefined;
513
+ const r = re.exec(s.slice(p + 1));
514
+ if (!r) return undefined;
515
+ const after = s[p + 1 + r[0].length];
516
+ if (after !== undefined && after !== ' ') return undefined;
517
+ return r;
518
+ };
519
+ let r = fieldAt(/^(?:([TtCcDd])([0-9]{3})|([Tt])off|(1|l)750)/);
520
+ if (r) {
521
+ if (r[1] !== undefined) {
522
+ const kind: ToneType = r[1].toLowerCase() === 't' ? 'tone' : r[1].toLowerCase() === 'c' ? 'ctcss' : 'dcs';
523
+ freq = { ...freq, tone: kind, toneValue: Number(r[2]) };
524
+ if (r[1] >= 'a') freq.narrow = true;
525
+ } else if (r[3] !== undefined) {
526
+ freq = { ...freq, tone: 'off' };
527
+ if (r[3] === 't') freq.narrow = true;
528
+ } else {
529
+ freq = { ...freq, tone: 'tone-burst' };
530
+ if (r[4] === 'l') freq.narrow = true;
531
+ }
532
+ p += 1 + r[0].length;
533
+ }
534
+ r = fieldAt(/^([+-])([0-9]{3})/);
535
+ if (r) {
536
+ freq.offsetKhz = (r[1] === '-' ? -1 : 1) * Number(r[2]) * 10;
537
+ p += 1 + r[0].length;
538
+ }
539
+ r = fieldAt(/^R([0-9]{2})([mk])/);
540
+ if (r) {
541
+ freq.range = Number(r[1]);
542
+ if (r[2] === 'k') freq.rangeKm = true;
543
+ p += 1 + r[0].length;
544
+ }
545
+ // One space after the frequency and its fields is a separator.
546
+ if (s[p] === ' ') p++;
547
+ return { frequency: sortFrequency(freq), length: p };
548
+ }
549
+
550
+ function sortFrequency(f: VoiceFrequency): VoiceFrequency {
551
+ const out: { -readonly [K in keyof VoiceFrequency]: VoiceFrequency[K] } = { mhz: f.mhz };
552
+ if (f.tone !== undefined) out.tone = f.tone;
553
+ if (f.toneValue !== undefined) out.toneValue = f.toneValue;
554
+ if (f.offsetKhz !== undefined) out.offsetKhz = f.offsetKhz;
555
+ if (f.range !== undefined) out.range = f.range;
556
+ if (f.rangeKm) out.rangeKm = true;
557
+ if (f.narrow) out.narrow = true;
558
+ if (f.tenKhzResolution) out.tenKhzResolution = true;
559
+ return out;
560
+ }
561
+
562
+ // ---- the positioned fields shared by positions, objects, items and Mic-E
563
+
564
+ /** Mutable positioned fields, as they are built up. */
565
+ export interface Fields {
566
+ latitude: number;
567
+ longitude: number;
568
+ ambiguity?: number;
569
+ symbol: AprsSymbol;
570
+ compressed?: boolean;
571
+ compression?: CompressionType;
572
+ courseDegrees?: number;
573
+ speedKnots?: number;
574
+ altitudeFeet?: number;
575
+ phg?: Phg;
576
+ rangeMiles?: number;
577
+ dfs?: Dfs;
578
+ area?: AreaObject;
579
+ dfBearing?: DfBearing;
580
+ storm?: Storm;
581
+ dao?: Dao;
582
+ telemetry?: CommentTelemetry;
583
+ frequency?: VoiceFrequency;
584
+ weather?: Weather;
585
+ signpost?: string;
586
+ comment?: string;
587
+ }
588
+
589
+ /** Options for lifting elements out of a comment. */
590
+ interface LiftOptions {
591
+ /** A data extension came straight after the symbol, so none is looked for later. */
592
+ extensionFound: boolean;
593
+ /** Look for a data extension at the very start of the text first (Mic-E status text). */
594
+ extensionAtStart?: boolean;
595
+ symbol: AprsSymbol;
596
+ /** An area object's line shape, whose corridor width may be in braces. */
597
+ areaLine?: boolean;
598
+ }
599
+
600
+ /** What the comment lifting found. */
601
+ interface Lifted {
602
+ telemetry?: CommentTelemetry;
603
+ dao?: DaoFound;
604
+ altitudeFeet?: number;
605
+ signpost?: string;
606
+ corridorWidthMiles?: number;
607
+ phg?: Phg;
608
+ rangeMiles?: number;
609
+ dfs?: Dfs;
610
+ frequency?: VoiceFrequency;
611
+ /** The comment text left (binary). */
612
+ rest: string;
613
+ }
614
+
615
+ function removeRanges(s: string, ranges: readonly [number, number][]): string {
616
+ const sorted = [...ranges].sort((a, b) => a[0] - b[0]);
617
+ let out = '';
618
+ let from = 0;
619
+ for (const [a, b] of sorted) {
620
+ out += s.slice(from, a);
621
+ from = b;
622
+ }
623
+ return out + s.slice(from);
624
+ }
625
+
626
+ /** Lifts base-91 telemetry and the last `!DAO!` outside it. */
627
+ function liftTelemetryAndDao(s: string): { telemetry?: CommentTelemetry; dao?: DaoFound; rest: string } {
628
+ const tel = findCommentTelemetry(s);
629
+ let dao: DaoFound | undefined;
630
+ for (let p = s.length - 5; p >= 0; p--) {
631
+ if (tel && p + 5 > tel.start && p < tel.end) continue;
632
+ const found = daoAt(s, p);
633
+ if (found) {
634
+ dao = found;
635
+ break;
636
+ }
637
+ }
638
+ const ranges: [number, number][] = [];
639
+ if (tel) ranges.push([tel.start, tel.end]);
640
+ if (dao) ranges.push([dao.start, dao.start + 5]);
641
+ const out: { telemetry?: CommentTelemetry; dao?: DaoFound; rest: string } = { rest: removeRanges(s, ranges) };
642
+ if (tel) out.telemetry = tel.telemetry;
643
+ if (dao) out.dao = dao;
644
+ return out;
645
+ }
646
+
647
+ const ALTITUDE_RE = /\/A=(-[0-9]{5}|[0-9]{6})/;
648
+
649
+ /** Lifts the structured elements out of a comment, in the order the vectors' README gives. */
650
+ function liftComment(ctx: DecodeContext, text: string, options: LiftOptions): Lifted {
651
+ const first = liftTelemetryAndDao(text);
652
+ const lifted: Lifted = { rest: first.rest };
653
+ if (first.telemetry) lifted.telemetry = first.telemetry;
654
+ if (first.dao) lifted.dao = first.dao;
655
+ let s = first.rest;
656
+ let extensionFound = options.extensionFound;
657
+ if (options.extensionAtStart) {
658
+ const ext = extensionAt(s, 0);
659
+ if (ext) {
660
+ applyExtension(lifted, ext);
661
+ s = s.slice(ext.length);
662
+ extensionFound = true;
663
+ }
664
+ }
665
+ // Altitude anywhere.
666
+ const alt = ALTITUDE_RE.exec(s);
667
+ if (alt) {
668
+ lifted.altitudeFeet = Number(alt[1]);
669
+ s = s.slice(0, alt.index) + s.slice(alt.index + alt[0].length);
670
+ }
671
+ // Signpost or corridor braces.
672
+ if (isSignpost(options.symbol) || options.areaLine) {
673
+ const brace = /\{([^{}]{1,3})\}/.exec(s);
674
+ if (brace) {
675
+ if (isSignpost(options.symbol)) {
676
+ lifted.signpost = brace[1]!;
677
+ s = s.slice(0, brace.index) + s.slice(brace.index + brace[0].length);
678
+ } else if (/^[0-9]{1,3}$/.test(brace[1]!)) {
679
+ lifted.corridorWidthMiles = Number(brace[1]);
680
+ s = s.slice(0, brace.index) + s.slice(brace.index + brace[0].length);
681
+ }
682
+ }
683
+ }
684
+ // A data extension later in the text, only when none came straight after the symbol.
685
+ if (!extensionFound) {
686
+ const later = findLaterExtension(s);
687
+ if (later && ctx.allows('data-extension-in-comment')) {
688
+ applyExtension(lifted, later.ext);
689
+ s = s.slice(0, later.start) + s.slice(later.start + later.ext.length);
690
+ }
691
+ }
692
+ // A voice frequency at the start, after at most one space or /.
693
+ let freq = frequencyAt(s);
694
+ let skip = 0;
695
+ if (!freq && (s[0] === ' ' || s[0] === '/')) {
696
+ freq = frequencyAt(s.slice(1));
697
+ skip = 1;
698
+ }
699
+ if (freq) {
700
+ lifted.frequency = freq.frequency;
701
+ s = s.slice(skip + freq.length);
702
+ }
703
+ // One leading delimiter.
704
+ if (s[0] === ' ' || s[0] === '/') s = s.slice(1);
705
+ lifted.rest = s;
706
+ return lifted;
707
+ }
708
+
709
+ function applyExtension(lifted: Lifted, ext: ExtensionFound): void {
710
+ if (ext.phg) lifted.phg = ext.phg;
711
+ if (ext.rangeMiles !== undefined) lifted.rangeMiles = ext.rangeMiles;
712
+ if (ext.dfs) lifted.dfs = ext.dfs;
713
+ }
714
+
715
+ function isSignpost(symbol: AprsSymbol): boolean {
716
+ return symbol.table === '\\' && symbol.code === 'm';
717
+ }
718
+
719
+ // ---- weather data (APRS12c ch. 12)
720
+
721
+ const WEATHER_WIDTH: Record<string, number> = {
722
+ c: 3,
723
+ s: 3,
724
+ g: 3,
725
+ t: 3,
726
+ r: 3,
727
+ p: 3,
728
+ P: 3,
729
+ h: 2,
730
+ b: 5,
731
+ L: 3,
732
+ l: 3,
733
+ '#': 3,
734
+ };
735
+
736
+ type MutableWeather = { -readonly [K in keyof Weather]: Weather[K] };
737
+
738
+ /** The wind a weather report starts with, from an extension or the compressed cs bytes. */
739
+ export interface WindStart {
740
+ /** Some wind source was present (even if unknown). */
741
+ present: boolean;
742
+ direction?: number;
743
+ speedMph?: number;
744
+ }
745
+
746
+ export interface WeatherParse {
747
+ weather: MutableWeather;
748
+ /** The index after the fields. */
749
+ end: number;
750
+ /** Wind came from c/s fields. */
751
+ windFromFields: boolean;
752
+ }
753
+
754
+ interface WeatherFieldOptions {
755
+ wind: WindStart;
756
+ /** Positionless (`_`) report: c and s are its wind fields. */
757
+ positionless: boolean;
758
+ /** After a compressed position, whose cs bytes are where the wind goes. */
759
+ compressed?: boolean;
760
+ }
761
+
762
+ /**
763
+ * A weather field's value: a run of dots (unknown) at most `width` long, exactly `width` spaces
764
+ * (unknown), or digits.
765
+ */
766
+ function weatherValueRun(s: string, j: number, width: number, signed: boolean): string {
767
+ if (s[j] === ' ') return s.slice(j, j + width) === ' '.repeat(width) ? s.slice(j, j + width) : '';
768
+ if (s[j] === '.') {
769
+ let k = j;
770
+ while (s[k] === '.' && k - j < width) k++;
771
+ return s.slice(j, k);
772
+ }
773
+ let k = j;
774
+ if (signed && s[k] === '-') k++;
775
+ const digitsFrom = k;
776
+ while (isDigit(s[k])) k++;
777
+ if (k === digitsFrom) return '';
778
+ return s.slice(j, k);
779
+ }
780
+
781
+ function isLetter(c: string | undefined): boolean {
782
+ return c !== undefined && ((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z'));
783
+ }
784
+
785
+ /** Parses the run of weather fields from `j`. */
786
+ function parseWeatherFields(ctx: DecodeContext, s: string, j: number, options: WeatherFieldOptions): WeatherParse {
787
+ const w: MutableWeather = {};
788
+ if (options.wind.direction !== undefined) w.windDirectionDegrees = options.wind.direction;
789
+ if (options.wind.speedMph !== undefined) w.windSpeedMph = options.wind.speedMph;
790
+ const seen = new Set<string>();
791
+ let windKnown = options.wind.present;
792
+ let windFromFields = false;
793
+ let warnedWindFields = false;
794
+ const extra: ExtraWeatherField[] = [];
795
+ for (;;) {
796
+ const letter = s[j];
797
+ if (letter === undefined) break;
798
+ let key = letter;
799
+ const width = WEATHER_WIDTH[letter];
800
+ if (letter === 's') {
801
+ // s is the wind speed after c (or first thing in a positionless report), else snowfall.
802
+ const windSlot = !seen.has('s:wind') && (seen.has('c') || (options.positionless && !windKnown));
803
+ key = windSlot ? 's:wind' : 's:snow';
804
+ }
805
+ if (width !== undefined) {
806
+ if (seen.has(key)) break;
807
+ if (letter === 'c' && windKnown) break;
808
+ if (key === 's:snow') {
809
+ const v = s.slice(j + 1, j + 4);
810
+ const dots = /^\.+/.exec(s.slice(j + 1))?.[0];
811
+ if (dots !== undefined) {
812
+ if (dots.length !== 3) ctx.tolerate('non-standard-weather-field-width');
813
+ seen.add(key);
814
+ j += 1 + dots.length;
815
+ continue;
816
+ }
817
+ if (!/^[0-9.]{3}$/.test(v) || !/[0-9]/.test(v)) break;
818
+ w.snow24hIn = Number(v);
819
+ seen.add(key);
820
+ j += 4;
821
+ continue;
822
+ }
823
+ let run = weatherValueRun(s, j + 1, width, letter === 't');
824
+ if (run.length === 0) break;
825
+ if (run.length > width + 1) run = run.slice(0, width);
826
+ if (run.length !== width) ctx.tolerate('non-standard-weather-field-width');
827
+ seen.add(key);
828
+ j += 1 + run.length;
829
+ const unknown = /^(?:\.+| +)$/.test(run);
830
+ if (letter === 'c' || key === 's:wind') {
831
+ windFromFields = true;
832
+ if (!warnedWindFields && !options.positionless) {
833
+ warnedWindFields = true;
834
+ ctx.tolerate('wind-fields-instead-of-extension');
835
+ }
836
+ }
837
+ if (unknown) continue;
838
+ const v = Number(run);
839
+ switch (key) {
840
+ case 'c':
841
+ if (v > 360) ctx.tolerate('out-of-range-value');
842
+ else w.windDirectionDegrees = v;
843
+ break;
844
+ case 's:wind':
845
+ w.windSpeedMph = v;
846
+ break;
847
+ case 'g':
848
+ w.windGustMph = v;
849
+ break;
850
+ case 't':
851
+ w.temperatureF = v;
852
+ break;
853
+ case 'r':
854
+ w.rain1hIn = v / 100;
855
+ break;
856
+ case 'p':
857
+ w.rain24hIn = v / 100;
858
+ break;
859
+ case 'P':
860
+ w.rainMidnightIn = v / 100;
861
+ break;
862
+ case 'h':
863
+ if (v > 100) ctx.tolerate('out-of-range-value');
864
+ else w.humidityPercent = v === 0 ? 100 : v;
865
+ break;
866
+ case 'b':
867
+ w.pressureMbar = v / 10;
868
+ break;
869
+ case 'L':
870
+ w.luminosityWM2 = v;
871
+ break;
872
+ case 'l':
873
+ w.luminosityWM2 = v + 1000;
874
+ break;
875
+ case '#':
876
+ w.rainRaw = v;
877
+ break;
878
+ }
879
+ continue;
880
+ }
881
+ if (isLetter(letter)) {
882
+ const m = /^[0-9.-]{2,}/.exec(s.slice(j + 1));
883
+ if (m && isDigit(m[0][m[0].length - 1])) {
884
+ extra.push({ letter, value: m[0] });
885
+ j += 1 + m[0].length;
886
+ continue;
887
+ }
888
+ }
889
+ break;
890
+ }
891
+ if (seen.has('c') || seen.has('s:wind')) windKnown = true;
892
+ if (extra.length > 0) w.extra = extra;
893
+ // Completeness: wind, and gust and temperature.
894
+ if (options.positionless) {
895
+ if (!seen.has('c') || !seen.has('s:wind') || !seen.has('g') || !seen.has('t')) ctx.tolerate('incomplete-weather');
896
+ } else {
897
+ // An uncompressed report's wind (the DDD/SSS extension, or c/s fields), then gust and temperature.
898
+ if (!windKnown && !options.compressed) ctx.tolerate('incomplete-weather');
899
+ if (!seen.has('g') || !seen.has('t')) ctx.tolerate('incomplete-weather');
900
+ }
901
+ return { weather: w, end: j, windFromFields };
902
+ }
903
+
904
+ const SOFTWARE_UNIT_RE = /^([A-Za-z])([A-Za-z0-9_-]{2,4})$/;
905
+
906
+ /**
907
+ * The software type and unit after the weather fields, or the weather comment. One leading
908
+ * delimiter is dropped from a position's comment, not from a positionless report's.
909
+ */
910
+ function weatherTail(
911
+ ctx: DecodeContext,
912
+ w: MutableWeather,
913
+ rest: string,
914
+ dropDelimiter = true,
915
+ ): { comment?: string; telemetry?: CommentTelemetry; dao?: DaoFound } {
916
+ const lifted = liftTelemetryAndDao(rest);
917
+ const out: { comment?: string; telemetry?: CommentTelemetry; dao?: DaoFound } = {};
918
+ if (lifted.telemetry) out.telemetry = lifted.telemetry;
919
+ if (lifted.dao) out.dao = lifted.dao;
920
+ let text = lifted.rest;
921
+ if (text.length === 0) return out;
922
+ const m = SOFTWARE_UNIT_RE.exec(text);
923
+ if (m && !/^[0-9]+$/.test(m[2]!)) {
924
+ w.software = m[1]!;
925
+ w.unit = m[2]!;
926
+ return out;
927
+ }
928
+ ctx.tolerate('weather-comment');
929
+ if (dropDelimiter && (text[0] === ' ' || text[0] === '/')) text = text.slice(1);
930
+ if (text.length > 0) out.comment = text;
931
+ return out;
932
+ }
933
+
934
+ /** Parses a positionless weather report's fields and tail from `j`. */
935
+ export function parsePositionlessWeather(
936
+ ctx: DecodeContext,
937
+ s: string,
938
+ j: number,
939
+ ): { weather: Weather; comment?: string } {
940
+ const parsed = parseWeatherFields(ctx, s, j, { wind: { present: false }, positionless: true });
941
+ const tail = weatherTail(ctx, parsed.weather, s.slice(parsed.end), false);
942
+ const out: { weather: Weather; comment?: string } = { weather: parsed.weather };
943
+ if (tail.comment !== undefined) out.comment = ctx.text(tail.comment);
944
+ return out;
945
+ }
946
+
947
+ // ---- after the position: extension, weather or comment
948
+
949
+ /** What follows a position, decoded into the fields. */
950
+ export function decodeAfterPosition(ctx: DecodeContext, s: string, i: number, pos: RawPosition): Fields {
951
+ const f: Fields = { latitude: pos.latitude, longitude: pos.longitude, symbol: pos.symbol };
952
+ if (pos.ambiguity > 0) f.ambiguity = pos.ambiguity;
953
+ if (pos.compressed) {
954
+ f.compressed = true;
955
+ if (pos.compression) f.compression = pos.compression;
956
+ }
957
+ let dao: DaoFound | undefined;
958
+ if (pos.symbol.code === '_') {
959
+ dao = decodeWeatherAfterPosition(ctx, s, i, pos, f);
960
+ } else {
961
+ dao = decodeExtensionAndComment(ctx, s, i, pos, f);
962
+ }
963
+ applyDao(ctx, f, pos, dao);
964
+ return f;
965
+ }
966
+
967
+ /** Applies a `!DAO!` to the fields. */
968
+ export function applyDao(ctx: DecodeContext, f: Fields, pos: RawPosition, dao: DaoFound | undefined): void {
969
+ if (!dao) return;
970
+ f.dao = dao.dao;
971
+ if (pos.compressed) return;
972
+ if (pos.ambiguity > 0) {
973
+ ctx.tolerate('dao-with-ambiguity');
974
+ return;
975
+ }
976
+ const lat = (pos.latSouth ? -1 : 1) * (pos.latDegrees + (pos.latMinutes + dao.latAdd) / 60);
977
+ const lon = (pos.lonWest ? -1 : 1) * (pos.lonDegrees + (pos.lonMinutes + dao.lonAdd) / 60);
978
+ f.latitude = lat;
979
+ f.longitude = lon;
980
+ }
981
+
982
+ function decodeExtensionAndComment(
983
+ ctx: DecodeContext,
984
+ s: string,
985
+ i: number,
986
+ pos: RawPosition,
987
+ f: Fields,
988
+ ): DaoFound | undefined {
989
+ let extensionFound = false;
990
+ if (pos.compressed) {
991
+ if (pos.csCourse !== undefined) {
992
+ f.courseDegrees = pos.csCourse;
993
+ f.speedKnots = pos.csSpeedKnots!;
994
+ } else if (pos.csRangeMiles !== undefined) {
995
+ f.rangeMiles = pos.csRangeMiles;
996
+ extensionFound = true;
997
+ } else if (pos.csAltitudeFeet !== undefined) {
998
+ f.altitudeFeet = pos.csAltitudeFeet;
999
+ }
1000
+ } else {
1001
+ const isArea = pos.symbol.table === '\\' && pos.symbol.code === 'l';
1002
+ const t = s.slice(i, i + 7);
1003
+ const cs = CSE_SPD_RE.exec(t);
1004
+ let area: RegExpExecArray | null = null;
1005
+ if (isArea) area = /^([0-9])([0-9]{2})(\/[0-9]|1[0-5])([0-9]{2})/.exec(t);
1006
+ if (area) {
1007
+ const colorCode = area[3]!.startsWith('/') ? Number(area[3]![1]) : Number(area[3]);
1008
+ f.area = {
1009
+ shape: AREA_SHAPES[Number(area[1])]!,
1010
+ color: AREA_COLORS[colorCode]!,
1011
+ latOffset: Number(area[2]),
1012
+ lonOffset: Number(area[4]),
1013
+ };
1014
+ i += 7;
1015
+ } else if (cs) {
1016
+ const course = threeDigits(cs[1]!);
1017
+ const speed = threeDigits(cs[2]!);
1018
+ if (course !== undefined) {
1019
+ if (course > 360) ctx.tolerate('out-of-range-value');
1020
+ else f.courseDegrees = course;
1021
+ }
1022
+ if (speed !== undefined) f.speedKnots = speed;
1023
+ i += 7;
1024
+ if (pos.symbol.table === '/' && pos.symbol.code === '\\') {
1025
+ const df = /^\/([0-9]{3})\/([0-9])([0-9])([0-9])/.exec(s.slice(i, i + 8));
1026
+ if (df) {
1027
+ f.dfBearing = {
1028
+ bearingDegrees: Number(df[1]),
1029
+ number: Number(df[2]),
1030
+ range: Number(df[3]),
1031
+ quality: Number(df[4]),
1032
+ };
1033
+ i += 8;
1034
+ }
1035
+ }
1036
+ if (pos.symbol.code === '@' && (pos.symbol.table === '/' || pos.symbol.table === '\\')) {
1037
+ const storm = parseStorm(s.slice(i));
1038
+ if (storm) {
1039
+ f.storm = storm.storm;
1040
+ i += storm.length;
1041
+ }
1042
+ }
1043
+ } else {
1044
+ const ext = extensionAt(s, i);
1045
+ if (ext) {
1046
+ if (ext.phg) f.phg = ext.phg;
1047
+ if (ext.rangeMiles !== undefined) f.rangeMiles = ext.rangeMiles;
1048
+ if (ext.dfs) f.dfs = ext.dfs;
1049
+ i += ext.length;
1050
+ extensionFound = true;
1051
+ }
1052
+ }
1053
+ }
1054
+ const areaLine = f.area !== undefined && (f.area.shape === 'line-down-right' || f.area.shape === 'line-down-left');
1055
+ const lifted = liftComment(ctx, s.slice(i), { extensionFound, symbol: pos.symbol, areaLine });
1056
+ applyLifted(ctx, f, lifted);
1057
+ return lifted.dao;
1058
+ }
1059
+
1060
+ function applyLifted(ctx: DecodeContext, f: Fields, lifted: Lifted): void {
1061
+ if (lifted.altitudeFeet !== undefined) f.altitudeFeet = lifted.altitudeFeet;
1062
+ if (lifted.phg) f.phg = lifted.phg;
1063
+ if (lifted.rangeMiles !== undefined) f.rangeMiles = lifted.rangeMiles;
1064
+ if (lifted.dfs) f.dfs = lifted.dfs;
1065
+ if (lifted.corridorWidthMiles !== undefined && f.area) f.area = { ...f.area, corridorWidthMiles: lifted.corridorWidthMiles };
1066
+ if (lifted.telemetry) f.telemetry = lifted.telemetry;
1067
+ if (lifted.frequency) f.frequency = lifted.frequency;
1068
+ if (lifted.signpost !== undefined) f.signpost = ctx.text(lifted.signpost);
1069
+ if (lifted.rest.length > 0) {
1070
+ const text = ctx.text(lifted.rest);
1071
+ if (text.length > 0) f.comment = text;
1072
+ }
1073
+ }
1074
+
1075
+ const STORM_TYPES: Record<string, StormType> = { TS: 'tropical-storm', HC: 'hurricane', TD: 'tropical-depression' };
1076
+
1077
+ function parseStorm(s: string): { storm: Storm; length: number } | undefined {
1078
+ const m = /^\/(TS|HC|TD)\/([0-9 .]{3})\^([0-9 .]{3})\/([0-9 .]{4})>([0-9 .]{3})&([0-9 .]{3})(?:%([0-9 .]{3}))?/.exec(s);
1079
+ if (!m) return undefined;
1080
+ const num = (v: string | undefined): number | undefined => (v !== undefined && /^[0-9]+$/.test(v) ? Number(v) : undefined);
1081
+ const storm: { -readonly [K in keyof Storm]: Storm[K] } = { type: STORM_TYPES[m[1]!]! };
1082
+ const set = (key: 'sustainedWindKnots' | 'gustKnots' | 'centralPressureMbar' | 'hurricaneRadiusNm' | 'tropicalStormRadiusNm' | 'wholeGaleRadiusNm', v: string | undefined): void => {
1083
+ const n = num(v);
1084
+ if (n !== undefined) storm[key] = n;
1085
+ };
1086
+ set('sustainedWindKnots', m[2]);
1087
+ set('gustKnots', m[3]);
1088
+ set('centralPressureMbar', m[4]);
1089
+ set('hurricaneRadiusNm', m[5]);
1090
+ set('tropicalStormRadiusNm', m[6]);
1091
+ set('wholeGaleRadiusNm', m[7]);
1092
+ return { storm, length: m[0].length };
1093
+ }
1094
+
1095
+ const DEFAULT_COMPRESSION: CompressionType = { fix: 'current', source: 'other', origin: 'software' };
1096
+
1097
+ function decodeWeatherAfterPosition(
1098
+ ctx: DecodeContext,
1099
+ s: string,
1100
+ i: number,
1101
+ pos: RawPosition,
1102
+ f: Fields,
1103
+ ): DaoFound | undefined {
1104
+ const wind: WindStart = { present: false };
1105
+ let windFromCs = false;
1106
+ if (pos.compressed && pos.csCourseRaw !== undefined) {
1107
+ wind.present = true;
1108
+ windFromCs = true;
1109
+ wind.direction = pos.csCourseRaw;
1110
+ wind.speedMph = pos.csSpeedKnots! * KNOTS_TO_MPH;
1111
+ } else if (pos.compressed && pos.csAltitudeFeet !== undefined) {
1112
+ f.altitudeFeet = pos.csAltitudeFeet;
1113
+ } else if (pos.compressed && pos.csRangeMiles !== undefined) {
1114
+ f.rangeMiles = pos.csRangeMiles;
1115
+ }
1116
+ const ext = CSE_SPD_RE.exec(s.slice(i, i + 7));
1117
+ let windFromExtension = false;
1118
+ if (ext) {
1119
+ if (pos.compressed) ctx.tolerate('wind-extension-after-compressed');
1120
+ wind.present = true;
1121
+ windFromExtension = true;
1122
+ delete wind.direction;
1123
+ delete wind.speedMph;
1124
+ const dir = threeDigits(ext[1]!);
1125
+ const spd = threeDigits(ext[2]!);
1126
+ if (dir !== undefined) {
1127
+ if (dir > 360) ctx.tolerate('out-of-range-value');
1128
+ else wind.direction = dir;
1129
+ }
1130
+ if (spd !== undefined) wind.speedMph = spd;
1131
+ i += 7;
1132
+ }
1133
+ const parsed = parseWeatherFields(ctx, s, i, { wind, positionless: false, compressed: pos.compressed });
1134
+ const tail = weatherTail(ctx, parsed.weather, s.slice(parsed.end));
1135
+ f.weather = parsed.weather;
1136
+ if (pos.compressed && pos.csBlank && !windFromCs) {
1137
+ const hasWind = parsed.weather.windDirectionDegrees !== undefined || parsed.weather.windSpeedMph !== undefined;
1138
+ if (hasWind && (windFromExtension || parsed.windFromFields)) f.compression = DEFAULT_COMPRESSION;
1139
+ }
1140
+ if (tail.telemetry) f.telemetry = tail.telemetry;
1141
+ if (tail.comment !== undefined) {
1142
+ const text = ctx.text(tail.comment);
1143
+ if (text.length > 0) f.comment = text;
1144
+ }
1145
+ return tail.dao;
1146
+ }
1147
+
1148
+ // ---- Mic-E status text
1149
+
1150
+ /** Lifts the elements out of Mic-E status text after its type code, altitude and locator. */
1151
+ export function liftMicEComment(ctx: DecodeContext, text: string, symbol: AprsSymbol): { lifted: Lifted } {
1152
+ const lifted = liftComment(ctx, text, { extensionFound: false, extensionAtStart: true, symbol });
1153
+ return { lifted };
1154
+ }
1155
+
1156
+ /** Applies lifted comment elements to fields (Mic-E). */
1157
+ export function applyLiftedTo(ctx: DecodeContext, f: Fields, lifted: Lifted): void {
1158
+ applyLifted(ctx, f, lifted);
1159
+ }
1160
+
1161
+ export type { DaoFound, Lifted };