@packet-net/pdn-aprs 0.1.1 → 0.3.0
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 +44 -1
- package/README.md +9 -5
- package/dist/builder.d.ts +33 -8
- package/dist/builder.d.ts.map +1 -1
- package/dist/builder.js +64 -10
- package/dist/builder.js.map +1 -1
- package/dist/bytes.d.ts.map +1 -1
- package/dist/bytes.js +2 -1
- package/dist/bytes.js.map +1 -1
- package/dist/codes.js +8 -8
- package/dist/codes.js.map +1 -1
- package/dist/decode/index.d.ts.map +1 -1
- package/dist/decode/index.js +8 -5
- package/dist/decode/index.js.map +1 -1
- package/dist/decode/message.d.ts +2 -0
- package/dist/decode/message.d.ts.map +1 -1
- package/dist/decode/message.js +59 -33
- package/dist/decode/message.js.map +1 -1
- package/dist/decode/other.d.ts +20 -2
- package/dist/decode/other.d.ts.map +1 -1
- package/dist/decode/other.js +110 -48
- package/dist/decode/other.js.map +1 -1
- package/dist/decode/positioned.d.ts +26 -2
- package/dist/decode/positioned.d.ts.map +1 -1
- package/dist/decode/positioned.js +145 -79
- package/dist/decode/positioned.js.map +1 -1
- package/dist/decode/reports.d.ts.map +1 -1
- package/dist/decode/reports.js +47 -5
- package/dist/decode/reports.js.map +1 -1
- package/dist/decoder.d.ts.map +1 -1
- package/dist/decoder.js +2 -4
- package/dist/decoder.js.map +1 -1
- package/dist/deviceid.d.ts +8 -2
- package/dist/deviceid.d.ts.map +1 -1
- package/dist/deviceid.js +28 -6
- package/dist/deviceid.js.map +1 -1
- package/dist/encode/common.d.ts +13 -2
- package/dist/encode/common.d.ts.map +1 -1
- package/dist/encode/common.js +36 -4
- package/dist/encode/common.js.map +1 -1
- package/dist/encode/index.d.ts.map +1 -1
- package/dist/encode/index.js +59 -22
- package/dist/encode/index.js.map +1 -1
- package/dist/encode/other.d.ts.map +1 -1
- package/dist/encode/other.js +45 -17
- package/dist/encode/other.js.map +1 -1
- package/dist/encode/position.d.ts +9 -3
- package/dist/encode/position.d.ts.map +1 -1
- package/dist/encode/position.js +258 -124
- package/dist/encode/position.js.map +1 -1
- package/dist/header.d.ts +5 -2
- package/dist/header.d.ts.map +1 -1
- package/dist/header.js +8 -3
- package/dist/header.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/neutral.d.ts.map +1 -1
- package/dist/neutral.js +22 -3
- package/dist/neutral.js.map +1 -1
- package/dist/types.d.ts +19 -9
- package/dist/types.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/builder.ts +77 -14
- package/src/bytes.ts +2 -1
- package/src/codes.ts +8 -8
- package/src/decode/index.ts +7 -4
- package/src/decode/message.ts +54 -37
- package/src/decode/other.ts +116 -44
- package/src/decode/positioned.ts +159 -90
- package/src/decode/reports.ts +47 -4
- package/src/decoder.ts +2 -5
- package/src/deviceid.ts +29 -5
- package/src/encode/common.ts +34 -4
- package/src/encode/index.ts +57 -20
- package/src/encode/other.ts +39 -13
- package/src/encode/position.ts +261 -119
- package/src/header.ts +9 -2
- package/src/index.ts +1 -0
- package/src/neutral.ts +24 -3
- package/src/types.ts +20 -5
package/src/decode/positioned.ts
CHANGED
|
@@ -22,6 +22,7 @@ import type {
|
|
|
22
22
|
VoiceFrequency,
|
|
23
23
|
Weather,
|
|
24
24
|
} from '../types.js';
|
|
25
|
+
import type { DiagnosticCode } from '../codes.js';
|
|
25
26
|
import type { DecodeContext } from './context.js';
|
|
26
27
|
|
|
27
28
|
// ---- small helpers
|
|
@@ -57,7 +58,8 @@ function isCompressedTable(c: string | undefined): boolean {
|
|
|
57
58
|
return c === '/' || c === '\\' || (c !== undefined && ((c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'j')));
|
|
58
59
|
}
|
|
59
60
|
|
|
60
|
-
|
|
61
|
+
/** Knots to mph, exactly: a nautical mile is 1852 m and a statute mile 1609.344 m. */
|
|
62
|
+
export const KNOTS_TO_MPH = 1852 / 1609.344;
|
|
61
63
|
export const METRES_PER_FOOT = 0.3048;
|
|
62
64
|
|
|
63
65
|
// ---- timestamps
|
|
@@ -161,7 +163,9 @@ export function parseUncompressed(ctx: DecodeContext, s: string, i: number): Raw
|
|
|
161
163
|
const latDegrees = Number(lat.substr(0, 2));
|
|
162
164
|
const latMinutes = ambiguousMinutes(lat[2]!, lat[3]!, lat[5]!, lat[6]!, latAmbiguity);
|
|
163
165
|
const rawLatMinutes = ambiguousMinutes(lat[2]!, lat[3]!, lat[5]!, lat[6]!, 0);
|
|
164
|
-
|
|
166
|
+
// The latitude it reports is the centre of its ambiguity box, so 90 degrees with blanked
|
|
167
|
+
// minutes (centred on 90 degrees 30 minutes) is past the pole.
|
|
168
|
+
if (rawLatMinutes >= 60 || latDegrees > 90 || (latDegrees === 90 && (rawLatMinutes > 0 || latMinutes > 0))) ctx.fail('invalid-latitude');
|
|
165
169
|
let ns = lat[7]!;
|
|
166
170
|
if (ns === 'n' || ns === 's') {
|
|
167
171
|
ctx.tolerate('lowercase-hemisphere');
|
|
@@ -174,12 +178,19 @@ export function parseUncompressed(ctx: DecodeContext, s: string, i: number): Raw
|
|
|
174
178
|
// Longitude
|
|
175
179
|
const lon = s.substr(i + 9, 9);
|
|
176
180
|
if (!isDigit(lon[0]) || !isDigit(lon[1]) || !isDigit(lon[2]) || lon[5] !== '.') ctx.fail('invalid-longitude');
|
|
177
|
-
|
|
178
|
-
|
|
181
|
+
// The latitude alone sets the ambiguity: a longitude place it blanks may hold a digit or a
|
|
182
|
+
// space, and is ignored; every other place is a digit (vectors interpretations.md, "Longitude
|
|
183
|
+
// blanks follow the latitude").
|
|
184
|
+
const lonPlaces = [lon[3]!, lon[4]!, lon[6]!, lon[7]!];
|
|
185
|
+
for (let k = 0; k < 4; k++) {
|
|
186
|
+
const ignored = k >= 4 - latAmbiguity;
|
|
187
|
+
if (!isDigit(lonPlaces[k]) && !(ignored && lonPlaces[k] === ' ')) ctx.fail('invalid-longitude');
|
|
188
|
+
}
|
|
179
189
|
const lonDegrees = Number(lon.substr(0, 3));
|
|
180
190
|
const lonMinutes = ambiguousMinutes(lon[3]!, lon[4]!, lon[6]!, lon[7]!, latAmbiguity);
|
|
181
191
|
const rawLonMinutes = ambiguousMinutes(lon[3]!, lon[4]!, lon[6]!, lon[7]!, 0);
|
|
182
|
-
|
|
192
|
+
// As for the latitude, the centre of an ambiguous 180 degrees is past 180.
|
|
193
|
+
if (rawLonMinutes >= 60 || lonDegrees > 180 || (lonDegrees === 180 && (rawLonMinutes > 0 || lonMinutes > 0))) ctx.fail('invalid-longitude');
|
|
183
194
|
let ew = lon[8]!;
|
|
184
195
|
if (ew === 'e' || ew === 'w') {
|
|
185
196
|
ctx.tolerate('lowercase-hemisphere');
|
|
@@ -327,6 +338,12 @@ function daoAt(s: string, p: number): DaoFound | undefined {
|
|
|
327
338
|
};
|
|
328
339
|
return undefined;
|
|
329
340
|
}
|
|
341
|
+
if (isDigit(d)) {
|
|
342
|
+
// A local datum (0-9) has no case to say how to read A and O, so it only carries spaces
|
|
343
|
+
// (vectors interpretations.md, "!DAO! datum digits").
|
|
344
|
+
if (a === ' ' && o === ' ') return { start: p, dao: { datum: d, precision: 'none' }, latAdd: 0, lonAdd: 0 };
|
|
345
|
+
return undefined;
|
|
346
|
+
}
|
|
330
347
|
if (d >= 'a' && d <= 'z') {
|
|
331
348
|
const datum = d.toUpperCase();
|
|
332
349
|
if (a === ' ' && o === ' ') return { start: p, dao: { datum, precision: 'none' }, latAdd: 0, lonAdd: 0 };
|
|
@@ -362,8 +379,9 @@ function findCommentTelemetry(s: string): TelemetryFound | undefined {
|
|
|
362
379
|
for (let k = 0; k < body.length; k += 2) values.push(base91Value(body.substr(k, 2)));
|
|
363
380
|
const sequence = values[0]!;
|
|
364
381
|
const rest = values.slice(1);
|
|
382
|
+
// The binary value is eight channels; bits 9-13 are reserved and ignored (APRS12c ch. 13).
|
|
365
383
|
const telemetry: CommentTelemetry =
|
|
366
|
-
rest.length === 6 ? { sequence, analog: rest.slice(0, 5), digital: rest[5]! } : { sequence, analog: rest };
|
|
384
|
+
rest.length === 6 ? { sequence, analog: rest.slice(0, 5), digital: rest[5]! & 0xff } : { sequence, analog: rest };
|
|
367
385
|
return { start: prev, end: last + 1, telemetry };
|
|
368
386
|
}
|
|
369
387
|
|
|
@@ -411,14 +429,14 @@ function rateValue(c: string): number {
|
|
|
411
429
|
return isDigit(c) ? c.charCodeAt(0) - 48 : c.charCodeAt(0) - 55;
|
|
412
430
|
}
|
|
413
431
|
|
|
414
|
-
interface ExtensionFound {
|
|
432
|
+
export interface ExtensionFound {
|
|
415
433
|
length: number;
|
|
416
434
|
phg?: Phg;
|
|
417
435
|
rangeMiles?: number;
|
|
418
436
|
dfs?: Dfs;
|
|
419
437
|
}
|
|
420
438
|
|
|
421
|
-
function extensionAt(s: string, i: number): ExtensionFound | undefined {
|
|
439
|
+
export function extensionAt(s: string, i: number): ExtensionFound | undefined {
|
|
422
440
|
const t = s.slice(i, i + 9);
|
|
423
441
|
let m = PHG_RE.exec(t);
|
|
424
442
|
if (m) {
|
|
@@ -482,8 +500,9 @@ const MICROWAVE_BASE: Record<string, number> = {
|
|
|
482
500
|
|
|
483
501
|
export { MICROWAVE_BASE };
|
|
484
502
|
|
|
485
|
-
|
|
486
|
-
const
|
|
503
|
+
// The band letter is upper case (A96.000MHz is 1296 MHz); only MHz may be in any case.
|
|
504
|
+
const FREQ_KHZ_RE = /^([0-9]{3}|[A-O][0-9]{2})\.([0-9]{3})[Mm][Hh][Zz]/;
|
|
505
|
+
const FREQ_10KHZ_RE = /^([0-9]{3}|[A-O][0-9]{2})\.([0-9]{2}) [Mm][Hh][Zz]/;
|
|
487
506
|
|
|
488
507
|
function frequencyMhz(whole: string, fraction: string): number {
|
|
489
508
|
const first = whole[0]!;
|
|
@@ -592,9 +611,22 @@ interface LiftOptions {
|
|
|
592
611
|
extensionFound: boolean;
|
|
593
612
|
/** Look for a data extension at the very start of the text first (Mic-E status text). */
|
|
594
613
|
extensionAtStart?: boolean;
|
|
614
|
+
/** A data extension already read from the start of the text (Mic-E status text). */
|
|
615
|
+
extension?: ExtensionFound;
|
|
616
|
+
/** Places in the text where something was already taken out (Mic-E status text). */
|
|
617
|
+
joins?: readonly number[];
|
|
595
618
|
symbol: AprsSymbol;
|
|
596
619
|
/** An area object's line shape, whose corridor width may be in braces. */
|
|
597
620
|
areaLine?: boolean;
|
|
621
|
+
/** Telemetry and the `!DAO!`, already lifted out of the text (Mic-E status text). */
|
|
622
|
+
pre?: PreLifted;
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
/** Base-91 telemetry and a `!DAO!` lifted out of text, and the text left. */
|
|
626
|
+
export interface PreLifted {
|
|
627
|
+
telemetry?: CommentTelemetry;
|
|
628
|
+
dao?: DaoFound;
|
|
629
|
+
rest: string;
|
|
598
630
|
}
|
|
599
631
|
|
|
600
632
|
/** What the comment lifting found. */
|
|
@@ -623,12 +655,19 @@ function removeRanges(s: string, ranges: readonly [number, number][]): string {
|
|
|
623
655
|
return out + s.slice(from);
|
|
624
656
|
}
|
|
625
657
|
|
|
626
|
-
/**
|
|
627
|
-
|
|
658
|
+
/**
|
|
659
|
+
* Lifts base-91 telemetry and the last `!DAO!` outside it. A `!DAO!` is five bytes as sent, so one
|
|
660
|
+
* is never read across a `join`, a place in `s` where another element was already taken out.
|
|
661
|
+
*/
|
|
662
|
+
export function liftTelemetryAndDao(
|
|
663
|
+
s: string,
|
|
664
|
+
joins: readonly number[] = [],
|
|
665
|
+
): { telemetry?: CommentTelemetry; dao?: DaoFound; rest: string; cuts: number[] } {
|
|
628
666
|
const tel = findCommentTelemetry(s);
|
|
629
667
|
let dao: DaoFound | undefined;
|
|
630
668
|
for (let p = s.length - 5; p >= 0; p--) {
|
|
631
669
|
if (tel && p + 5 > tel.start && p < tel.end) continue;
|
|
670
|
+
if (joins.some((j) => j > p && j < p + 5)) continue;
|
|
632
671
|
const found = daoAt(s, p);
|
|
633
672
|
if (found) {
|
|
634
673
|
dao = found;
|
|
@@ -638,7 +677,14 @@ function liftTelemetryAndDao(s: string): { telemetry?: CommentTelemetry; dao?: D
|
|
|
638
677
|
const ranges: [number, number][] = [];
|
|
639
678
|
if (tel) ranges.push([tel.start, tel.end]);
|
|
640
679
|
if (dao) ranges.push([dao.start, dao.start + 5]);
|
|
641
|
-
|
|
680
|
+
// Where, in the text left, something was taken out.
|
|
681
|
+
let removed = 0;
|
|
682
|
+
const cuts: number[] = [];
|
|
683
|
+
for (const [a, b] of [...ranges].sort((x, y) => x[0] - y[0])) {
|
|
684
|
+
cuts.push(a - removed);
|
|
685
|
+
removed += b - a;
|
|
686
|
+
}
|
|
687
|
+
const out: { telemetry?: CommentTelemetry; dao?: DaoFound; rest: string; cuts: number[] } = { rest: removeRanges(s, ranges), cuts };
|
|
642
688
|
if (tel) out.telemetry = tel.telemetry;
|
|
643
689
|
if (dao) out.dao = dao;
|
|
644
690
|
return out;
|
|
@@ -648,13 +694,16 @@ const ALTITUDE_RE = /\/A=(-[0-9]{5}|[0-9]{6})/;
|
|
|
648
694
|
|
|
649
695
|
/** Lifts the structured elements out of a comment, in the order the vectors' README gives. */
|
|
650
696
|
function liftComment(ctx: DecodeContext, text: string, options: LiftOptions): Lifted {
|
|
651
|
-
const first = liftTelemetryAndDao(text);
|
|
697
|
+
const first = options.pre ? { ...options.pre, rest: text } : liftTelemetryAndDao(text, options.joins);
|
|
652
698
|
const lifted: Lifted = { rest: first.rest };
|
|
653
699
|
if (first.telemetry) lifted.telemetry = first.telemetry;
|
|
654
700
|
if (first.dao) lifted.dao = first.dao;
|
|
655
701
|
let s = first.rest;
|
|
656
702
|
let extensionFound = options.extensionFound;
|
|
657
|
-
if (options.
|
|
703
|
+
if (options.extension) {
|
|
704
|
+
applyExtension(lifted, options.extension);
|
|
705
|
+
extensionFound = true;
|
|
706
|
+
} else if (options.extensionAtStart) {
|
|
658
707
|
const ext = extensionAt(s, 0);
|
|
659
708
|
if (ext) {
|
|
660
709
|
applyExtension(lifted, ext);
|
|
@@ -670,15 +719,15 @@ function liftComment(ctx: DecodeContext, text: string, options: LiftOptions): Li
|
|
|
670
719
|
}
|
|
671
720
|
// Signpost or corridor braces.
|
|
672
721
|
if (isSignpost(options.symbol) || options.areaLine) {
|
|
673
|
-
|
|
722
|
+
// The first well-formed braces wherever they are: 1-3 printable ASCII characters other than
|
|
723
|
+
// a brace for a signpost, 1-3 digits for a corridor. Braces that do not qualify are comment
|
|
724
|
+
// text and do not stop the search (vectors interpretations.md).
|
|
725
|
+
const signpost = isSignpost(options.symbol);
|
|
726
|
+
const brace = (signpost ? /\{([\x20-\x7a\x7c\x7e]{1,3})\}/ : /\{([0-9]{1,3})\}/).exec(s);
|
|
674
727
|
if (brace) {
|
|
675
|
-
if (
|
|
676
|
-
|
|
677
|
-
|
|
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
|
-
}
|
|
728
|
+
if (signpost) lifted.signpost = brace[1]!;
|
|
729
|
+
else lifted.corridorWidthMiles = Number(brace[1]);
|
|
730
|
+
s = s.slice(0, brace.index) + s.slice(brace.index + brace[0].length);
|
|
682
731
|
}
|
|
683
732
|
}
|
|
684
733
|
// A data extension later in the text, only when none came straight after the symbol.
|
|
@@ -782,15 +831,20 @@ function isLetter(c: string | undefined): boolean {
|
|
|
782
831
|
return c !== undefined && ((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z'));
|
|
783
832
|
}
|
|
784
833
|
|
|
785
|
-
/**
|
|
834
|
+
/**
|
|
835
|
+
* Parses the run of weather fields from `j`, and reports what it finds in reading order: for a
|
|
836
|
+
* position, object or item report the wind first, where the `DDD/SSS` extension belongs (missing,
|
|
837
|
+
* incomplete, or sent as `c` and `s` fields), then each field's defects, then a missing gust or
|
|
838
|
+
* temperature. The fields are read the same whatever is tolerated, so they are read first and
|
|
839
|
+
* reported after.
|
|
840
|
+
*/
|
|
786
841
|
function parseWeatherFields(ctx: DecodeContext, s: string, j: number, options: WeatherFieldOptions): WeatherParse {
|
|
787
842
|
const w: MutableWeather = {};
|
|
788
843
|
if (options.wind.direction !== undefined) w.windDirectionDegrees = options.wind.direction;
|
|
789
844
|
if (options.wind.speedMph !== undefined) w.windSpeedMph = options.wind.speedMph;
|
|
790
845
|
const seen = new Set<string>();
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
let warnedWindFields = false;
|
|
846
|
+
const windKnown = options.wind.present;
|
|
847
|
+
const defects: DiagnosticCode[] = [];
|
|
794
848
|
const extra: ExtraWeatherField[] = [];
|
|
795
849
|
for (;;) {
|
|
796
850
|
const letter = s[j];
|
|
@@ -798,47 +852,54 @@ function parseWeatherFields(ctx: DecodeContext, s: string, j: number, options: W
|
|
|
798
852
|
let key = letter;
|
|
799
853
|
const width = WEATHER_WIDTH[letter];
|
|
800
854
|
if (letter === 's') {
|
|
801
|
-
// s is the wind speed after c
|
|
855
|
+
// s is the wind speed once the wind comes as fields (after c, or anywhere in a positionless
|
|
856
|
+
// report) until the speed is known, and snowfall after that.
|
|
802
857
|
const windSlot = !seen.has('s:wind') && (seen.has('c') || (options.positionless && !windKnown));
|
|
803
858
|
key = windSlot ? 's:wind' : 's:snow';
|
|
804
859
|
}
|
|
860
|
+
// L and l are one field, luminosity.
|
|
861
|
+
if (letter === 'l') key = 'L';
|
|
805
862
|
if (width !== undefined) {
|
|
806
863
|
if (seen.has(key)) break;
|
|
807
864
|
if (letter === 'c' && windKnown) break;
|
|
808
865
|
if (key === 's:snow') {
|
|
866
|
+
// Snowfall is three characters, which may include one decimal point ("A decimal point
|
|
867
|
+
// is allowed for non-integer values", APRS12c ch. 12), so s.50 is 0.5 inches. Otherwise
|
|
868
|
+
// a run of dots, read up to the field's width, is an unknown snowfall.
|
|
809
869
|
const v = s.slice(j + 1, j + 4);
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
870
|
+
if (/^[0-9.]{3}$/.test(v) && /[0-9]/.test(v) && !/\..*\./.test(v)) {
|
|
871
|
+
w.snow24hIn = Number(v);
|
|
872
|
+
seen.add(key);
|
|
873
|
+
j += 4;
|
|
874
|
+
continue;
|
|
875
|
+
}
|
|
876
|
+
// Exactly three spaces is a snowfall sent as unknown, as for any other field.
|
|
877
|
+
if (v === ' ') {
|
|
813
878
|
seen.add(key);
|
|
814
|
-
j +=
|
|
879
|
+
j += 4;
|
|
815
880
|
continue;
|
|
816
881
|
}
|
|
817
|
-
|
|
818
|
-
|
|
882
|
+
// Only a run of dots may be shorter than three, and only when no digit follows it: s..6 is
|
|
883
|
+
// not a field, so the fields end there.
|
|
884
|
+
const dots = /^\.{1,3}/.exec(v)?.[0];
|
|
885
|
+
if (dots === undefined || (dots.length < 3 && isDigit(v[dots.length]))) break;
|
|
886
|
+
if (dots.length !== 3) defects.push('non-standard-weather-field-width');
|
|
819
887
|
seen.add(key);
|
|
820
|
-
j +=
|
|
888
|
+
j += 1 + dots.length;
|
|
821
889
|
continue;
|
|
822
890
|
}
|
|
823
891
|
let run = weatherValueRun(s, j + 1, width, letter === 't');
|
|
824
892
|
if (run.length === 0) break;
|
|
825
893
|
if (run.length > width + 1) run = run.slice(0, width);
|
|
826
|
-
if (run.length !== width)
|
|
894
|
+
if (run.length !== width) defects.push('non-standard-weather-field-width');
|
|
827
895
|
seen.add(key);
|
|
828
896
|
j += 1 + run.length;
|
|
829
897
|
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
898
|
if (unknown) continue;
|
|
838
899
|
const v = Number(run);
|
|
839
|
-
switch (key) {
|
|
900
|
+
switch (letter === 's' ? key : letter) {
|
|
840
901
|
case 'c':
|
|
841
|
-
if (v > 360)
|
|
902
|
+
if (v > 360) defects.push('out-of-range-value');
|
|
842
903
|
else w.windDirectionDegrees = v;
|
|
843
904
|
break;
|
|
844
905
|
case 's:wind':
|
|
@@ -860,7 +921,7 @@ function parseWeatherFields(ctx: DecodeContext, s: string, j: number, options: W
|
|
|
860
921
|
w.rainMidnightIn = v / 100;
|
|
861
922
|
break;
|
|
862
923
|
case 'h':
|
|
863
|
-
if (v > 100)
|
|
924
|
+
if (v > 100) defects.push('out-of-range-value');
|
|
864
925
|
else w.humidityPercent = v === 0 ? 100 : v;
|
|
865
926
|
break;
|
|
866
927
|
case 'b':
|
|
@@ -888,14 +949,21 @@ function parseWeatherFields(ctx: DecodeContext, s: string, j: number, options: W
|
|
|
888
949
|
}
|
|
889
950
|
break;
|
|
890
951
|
}
|
|
891
|
-
if (seen.has('c') || seen.has('s:wind')) windKnown = true;
|
|
892
952
|
if (extra.length > 0) w.extra = extra;
|
|
893
|
-
|
|
953
|
+
const hasDirection = seen.has('c');
|
|
954
|
+
const hasSpeed = seen.has('s:wind');
|
|
955
|
+
const windFromFields = hasDirection || hasSpeed;
|
|
894
956
|
if (options.positionless) {
|
|
895
|
-
|
|
957
|
+
for (const code of defects) ctx.tolerate(code);
|
|
958
|
+
if (!hasDirection || !hasSpeed || !seen.has('g') || !seen.has('t')) ctx.tolerate('incomplete-weather');
|
|
896
959
|
} else {
|
|
897
|
-
//
|
|
898
|
-
|
|
960
|
+
// The wind is decided where the DDD/SSS extension belongs, before any field. After a
|
|
961
|
+
// compressed position whose cs bytes carry none, the wind is unknown, never missing.
|
|
962
|
+
if (!windKnown) {
|
|
963
|
+
if (windFromFields) ctx.tolerate('wind-fields-instead-of-extension');
|
|
964
|
+
if (!options.compressed && !(hasDirection && hasSpeed)) ctx.tolerate('incomplete-weather');
|
|
965
|
+
}
|
|
966
|
+
for (const code of defects) ctx.tolerate(code);
|
|
899
967
|
if (!seen.has('g') || !seen.has('t')) ctx.tolerate('incomplete-weather');
|
|
900
968
|
}
|
|
901
969
|
return { weather: w, end: j, windFromFields };
|
|
@@ -904,16 +972,18 @@ function parseWeatherFields(ctx: DecodeContext, s: string, j: number, options: W
|
|
|
904
972
|
const SOFTWARE_UNIT_RE = /^([A-Za-z])([A-Za-z0-9_-]{2,4})$/;
|
|
905
973
|
|
|
906
974
|
/**
|
|
907
|
-
* The software type and unit after the weather fields, or the weather comment.
|
|
908
|
-
*
|
|
975
|
+
* The software type and unit after the weather fields, or the weather comment. A position's
|
|
976
|
+
* base-91 telemetry and `!DAO!` are lifted out first, and one leading delimiter is dropped from its
|
|
977
|
+
* comment; a positionless report has no position for them to belong to, so it keeps them, and its
|
|
978
|
+
* delimiter, in its comment.
|
|
909
979
|
*/
|
|
910
980
|
function weatherTail(
|
|
911
981
|
ctx: DecodeContext,
|
|
912
982
|
w: MutableWeather,
|
|
913
983
|
rest: string,
|
|
914
|
-
|
|
984
|
+
positioned: boolean,
|
|
915
985
|
): { comment?: string; telemetry?: CommentTelemetry; dao?: DaoFound } {
|
|
916
|
-
const lifted = liftTelemetryAndDao(rest);
|
|
986
|
+
const lifted: PreLifted = positioned ? liftTelemetryAndDao(rest) : { rest };
|
|
917
987
|
const out: { comment?: string; telemetry?: CommentTelemetry; dao?: DaoFound } = {};
|
|
918
988
|
if (lifted.telemetry) out.telemetry = lifted.telemetry;
|
|
919
989
|
if (lifted.dao) out.dao = lifted.dao;
|
|
@@ -926,7 +996,7 @@ function weatherTail(
|
|
|
926
996
|
return out;
|
|
927
997
|
}
|
|
928
998
|
ctx.tolerate('weather-comment');
|
|
929
|
-
if (
|
|
999
|
+
if (positioned && (text[0] === ' ' || text[0] === '/')) text = text.slice(1);
|
|
930
1000
|
if (text.length > 0) out.comment = text;
|
|
931
1001
|
return out;
|
|
932
1002
|
}
|
|
@@ -954,13 +1024,8 @@ export function decodeAfterPosition(ctx: DecodeContext, s: string, i: number, po
|
|
|
954
1024
|
f.compressed = true;
|
|
955
1025
|
if (pos.compression) f.compression = pos.compression;
|
|
956
1026
|
}
|
|
957
|
-
|
|
958
|
-
|
|
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);
|
|
1027
|
+
if (pos.symbol.code === '_') decodeWeatherAfterPosition(ctx, s, i, pos, f);
|
|
1028
|
+
else decodeExtensionAndComment(ctx, s, i, pos, f);
|
|
964
1029
|
return f;
|
|
965
1030
|
}
|
|
966
1031
|
|
|
@@ -979,13 +1044,7 @@ export function applyDao(ctx: DecodeContext, f: Fields, pos: RawPosition, dao: D
|
|
|
979
1044
|
f.longitude = lon;
|
|
980
1045
|
}
|
|
981
1046
|
|
|
982
|
-
function decodeExtensionAndComment(
|
|
983
|
-
ctx: DecodeContext,
|
|
984
|
-
s: string,
|
|
985
|
-
i: number,
|
|
986
|
-
pos: RawPosition,
|
|
987
|
-
f: Fields,
|
|
988
|
-
): DaoFound | undefined {
|
|
1047
|
+
function decodeExtensionAndComment(ctx: DecodeContext, s: string, i: number, pos: RawPosition, f: Fields): void {
|
|
989
1048
|
let extensionFound = false;
|
|
990
1049
|
if (pos.compressed) {
|
|
991
1050
|
if (pos.csCourse !== undefined) {
|
|
@@ -1024,12 +1083,17 @@ function decodeExtensionAndComment(
|
|
|
1024
1083
|
if (pos.symbol.table === '/' && pos.symbol.code === '\\') {
|
|
1025
1084
|
const df = /^\/([0-9]{3})\/([0-9])([0-9])([0-9])/.exec(s.slice(i, i + 8));
|
|
1026
1085
|
if (df) {
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1086
|
+
// The bearing is degrees: one over 360 drops the whole /BRG/NRQ, as a course over 360 is dropped.
|
|
1087
|
+
if (Number(df[1]) > 360) {
|
|
1088
|
+
ctx.tolerate('out-of-range-value');
|
|
1089
|
+
} else {
|
|
1090
|
+
f.dfBearing = {
|
|
1091
|
+
bearingDegrees: Number(df[1]),
|
|
1092
|
+
number: Number(df[2]),
|
|
1093
|
+
range: Number(df[3]),
|
|
1094
|
+
quality: Number(df[4]),
|
|
1095
|
+
};
|
|
1096
|
+
}
|
|
1033
1097
|
i += 8;
|
|
1034
1098
|
}
|
|
1035
1099
|
}
|
|
@@ -1053,8 +1117,9 @@ function decodeExtensionAndComment(
|
|
|
1053
1117
|
}
|
|
1054
1118
|
const areaLine = f.area !== undefined && (f.area.shape === 'line-down-right' || f.area.shape === 'line-down-left');
|
|
1055
1119
|
const lifted = liftComment(ctx, s.slice(i), { extensionFound, symbol: pos.symbol, areaLine });
|
|
1120
|
+
// The !DAO! is structure, so it is judged before the text's encoding.
|
|
1121
|
+
applyDao(ctx, f, pos, lifted.dao);
|
|
1056
1122
|
applyLifted(ctx, f, lifted);
|
|
1057
|
-
return lifted.dao;
|
|
1058
1123
|
}
|
|
1059
1124
|
|
|
1060
1125
|
function applyLifted(ctx: DecodeContext, f: Fields, lifted: Lifted): void {
|
|
@@ -1075,7 +1140,8 @@ function applyLifted(ctx: DecodeContext, f: Fields, lifted: Lifted): void {
|
|
|
1075
1140
|
const STORM_TYPES: Record<string, StormType> = { TS: 'tropical-storm', HC: 'hurricane', TD: 'tropical-depression' };
|
|
1076
1141
|
|
|
1077
1142
|
function parseStorm(s: string): { storm: Storm; length: number } | undefined {
|
|
1078
|
-
|
|
1143
|
+
// Each field is digits (vectors object-item/storm-data-with-a-space-in-a-field-is-text).
|
|
1144
|
+
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
1145
|
if (!m) return undefined;
|
|
1080
1146
|
const num = (v: string | undefined): number | undefined => (v !== undefined && /^[0-9]+$/.test(v) ? Number(v) : undefined);
|
|
1081
1147
|
const storm: { -readonly [K in keyof Storm]: Storm[K] } = { type: STORM_TYPES[m[1]!]! };
|
|
@@ -1094,13 +1160,7 @@ function parseStorm(s: string): { storm: Storm; length: number } | undefined {
|
|
|
1094
1160
|
|
|
1095
1161
|
const DEFAULT_COMPRESSION: CompressionType = { fix: 'current', source: 'other', origin: 'software' };
|
|
1096
1162
|
|
|
1097
|
-
function decodeWeatherAfterPosition(
|
|
1098
|
-
ctx: DecodeContext,
|
|
1099
|
-
s: string,
|
|
1100
|
-
i: number,
|
|
1101
|
-
pos: RawPosition,
|
|
1102
|
-
f: Fields,
|
|
1103
|
-
): DaoFound | undefined {
|
|
1163
|
+
function decodeWeatherAfterPosition(ctx: DecodeContext, s: string, i: number, pos: RawPosition, f: Fields): void {
|
|
1104
1164
|
const wind: WindStart = { present: false };
|
|
1105
1165
|
let windFromCs = false;
|
|
1106
1166
|
if (pos.compressed && pos.csCourseRaw !== undefined) {
|
|
@@ -1131,26 +1191,35 @@ function decodeWeatherAfterPosition(
|
|
|
1131
1191
|
i += 7;
|
|
1132
1192
|
}
|
|
1133
1193
|
const parsed = parseWeatherFields(ctx, s, i, { wind, positionless: false, compressed: pos.compressed });
|
|
1134
|
-
const tail = weatherTail(ctx, parsed.weather, s.slice(parsed.end));
|
|
1194
|
+
const tail = weatherTail(ctx, parsed.weather, s.slice(parsed.end), true);
|
|
1135
1195
|
f.weather = parsed.weather;
|
|
1136
1196
|
if (pos.compressed && pos.csBlank && !windFromCs) {
|
|
1137
1197
|
const hasWind = parsed.weather.windDirectionDegrees !== undefined || parsed.weather.windSpeedMph !== undefined;
|
|
1138
1198
|
if (hasWind && (windFromExtension || parsed.windFromFields)) f.compression = DEFAULT_COMPRESSION;
|
|
1139
1199
|
}
|
|
1140
1200
|
if (tail.telemetry) f.telemetry = tail.telemetry;
|
|
1201
|
+
applyDao(ctx, f, pos, tail.dao);
|
|
1141
1202
|
if (tail.comment !== undefined) {
|
|
1142
1203
|
const text = ctx.text(tail.comment);
|
|
1143
1204
|
if (text.length > 0) f.comment = text;
|
|
1144
1205
|
}
|
|
1145
|
-
return tail.dao;
|
|
1146
1206
|
}
|
|
1147
1207
|
|
|
1148
1208
|
// ---- Mic-E status text
|
|
1149
1209
|
|
|
1150
1210
|
/** Lifts the elements out of Mic-E status text after its type code, altitude and locator. */
|
|
1151
|
-
export function liftMicEComment(
|
|
1152
|
-
|
|
1153
|
-
|
|
1211
|
+
export function liftMicEComment(
|
|
1212
|
+
ctx: DecodeContext,
|
|
1213
|
+
text: string,
|
|
1214
|
+
symbol: AprsSymbol,
|
|
1215
|
+
extension: ExtensionFound | undefined,
|
|
1216
|
+
pre: PreLifted,
|
|
1217
|
+
atStart = true,
|
|
1218
|
+
): { lifted: Lifted } {
|
|
1219
|
+
const options: LiftOptions = extension
|
|
1220
|
+
? { extensionFound: true, extension, symbol, pre }
|
|
1221
|
+
: { extensionFound: false, extensionAtStart: atStart, symbol, pre };
|
|
1222
|
+
return { lifted: liftComment(ctx, text, options) };
|
|
1154
1223
|
}
|
|
1155
1224
|
|
|
1156
1225
|
/** Applies lifted comment elements to fields (Mic-E). */
|
package/src/decode/reports.ts
CHANGED
|
@@ -16,15 +16,18 @@ import {
|
|
|
16
16
|
applyLiftedTo,
|
|
17
17
|
base91Value,
|
|
18
18
|
decodeAfterPosition,
|
|
19
|
+
extensionAt,
|
|
19
20
|
isDigit,
|
|
20
21
|
isSymbolTable,
|
|
21
22
|
liftMicEComment,
|
|
23
|
+
liftTelemetryAndDao,
|
|
22
24
|
METRES_PER_FOOT,
|
|
23
25
|
parsePosition,
|
|
24
26
|
parsePositionlessWeather,
|
|
25
27
|
positionDecodesAt,
|
|
26
28
|
timestampAt,
|
|
27
29
|
timestampValid,
|
|
30
|
+
type ExtensionFound,
|
|
28
31
|
type Fields,
|
|
29
32
|
type RawPosition,
|
|
30
33
|
} from './positioned.js';
|
|
@@ -225,7 +228,10 @@ function decodeMicEDestination(ctx: DecodeContext, destination: string): MicEDes
|
|
|
225
228
|
const num = (c: string): number => (c === ' ' ? 0 : c.charCodeAt(0) - 48);
|
|
226
229
|
const degrees = num(digits[0]!) * 10 + num(digits[1]!);
|
|
227
230
|
const minutes = num(digits[2]!) * 10 + num(digits[3]!) + (num(digits[4]!) * 10 + num(digits[5]!)) / 100;
|
|
228
|
-
|
|
231
|
+
// The latitude reported is the centre of the ambiguity box, so 90 degrees with blanked minutes
|
|
232
|
+
// (centred on 90 degrees 30 minutes, say) is past the pole.
|
|
233
|
+
const centre = micEMinutes(num(digits[2]!), num(digits[3]!), num(digits[4]!) * 10 + num(digits[5]!), ambiguity);
|
|
234
|
+
if (minutes >= 60 || degrees > 90 || (degrees === 90 && (minutes > 0 || centre > 0))) ctx.fail('invalid-mic-e-destination');
|
|
229
235
|
const bits = kinds.slice(0, 3);
|
|
230
236
|
const hasStd = bits.includes('std');
|
|
231
237
|
const hasCustom = bits.includes('custom');
|
|
@@ -252,8 +258,12 @@ function micEMinutes(tens: number, units: number, hundredths: number, ambiguity:
|
|
|
252
258
|
|
|
253
259
|
const TYPE_CODES = '`\'>] ';
|
|
254
260
|
|
|
261
|
+
const MIC_E_LOCATOR = /^([A-Ra-r]{2}[0-9]{2}(?:[A-Xa-x]{2})?)\/G/;
|
|
262
|
+
|
|
255
263
|
/** `` ` ``, `'`, 0x1c, 0x1d: a Mic-E report. */
|
|
256
264
|
export function decodeMicE(ctx: DecodeContext, s: string): MicEReport {
|
|
265
|
+
// The Rev 0 beta data type identifiers are obsolete: said at the DTI, before anything is checked.
|
|
266
|
+
if (s[0] === '\x1c' || s[0] === '\x1d') ctx.info('obsolete-format');
|
|
257
267
|
const dest = decodeMicEDestination(ctx, ctx.destination);
|
|
258
268
|
if (s.length < 9) ctx.fail('invalid-mic-e-information');
|
|
259
269
|
const b = (k: number): number => s.charCodeAt(k);
|
|
@@ -340,20 +350,49 @@ export function decodeMicE(ctx: DecodeContext, s: string): MicEReport {
|
|
|
340
350
|
if (deviceSuffix !== undefined) text = text.slice(0, text.length - deviceSuffix.length);
|
|
341
351
|
}
|
|
342
352
|
let altitudeFeet: number | undefined;
|
|
353
|
+
let extension: ExtensionFound | undefined;
|
|
354
|
+
let extensionAfterLocator = false;
|
|
355
|
+
// Where a later altitude was taken out of the text: a !DAO! is never read across it.
|
|
356
|
+
let join: number | undefined;
|
|
343
357
|
const altFirst = /^([!-{]{3})\}/.exec(text);
|
|
344
358
|
if (altFirst) {
|
|
345
359
|
altitudeFeet = (base91Value(altFirst[1]!) - 10000) / METRES_PER_FOOT;
|
|
346
360
|
text = text.slice(4);
|
|
347
361
|
} else {
|
|
362
|
+
// A data extension straight after the type code, or after a locator there and its space, is
|
|
363
|
+
// read first, so its bytes are never taken for an altitude later in the text (`0PH}` in
|
|
364
|
+
// `PHG3330PH}`).
|
|
365
|
+
const loc = MIC_E_LOCATOR.exec(text);
|
|
366
|
+
const at = loc && text[loc[0].length] === ' ' ? loc[0].length + 1 : 0;
|
|
367
|
+
extension = extensionAt(text, at);
|
|
368
|
+
if (extension) {
|
|
369
|
+
text = text.slice(0, at) + text.slice(at + extension.length);
|
|
370
|
+
extensionAfterLocator = at > 0;
|
|
371
|
+
}
|
|
348
372
|
const later = /([!-{]{3})\}/.exec(text);
|
|
349
373
|
if (later && ctx.allows('mic-e-altitude-not-first')) {
|
|
350
374
|
altitudeFeet = (base91Value(later[1]!) - 10000) / METRES_PER_FOOT;
|
|
351
375
|
text = text.slice(0, later.index) + text.slice(later.index + 4);
|
|
376
|
+
join = later.index;
|
|
377
|
+
} else if (!ctx.options.tolerates('mic-e-altitude-not-first')) {
|
|
378
|
+
// A later altitude declined is left as text; lifting telemetry or the !DAO! must then not
|
|
379
|
+
// join the bytes either side of its place into another one. With no later altitude, strict reads as lenient (vectors
|
|
380
|
+
// README, Mic-E).
|
|
381
|
+
const lifted = liftTelemetryAndDao(text);
|
|
382
|
+
const across = [...lifted.rest.matchAll(/(?=([!-{]{3})\})/g)].some((m) => lifted.cuts.some((c) => c > m.index && c < m.index + 4));
|
|
383
|
+
if (later && across) ctx.fail('mic-e-altitude-not-first');
|
|
352
384
|
}
|
|
353
385
|
}
|
|
354
386
|
if (altitudeFeet !== undefined) f.altitudeFeet = altitudeFeet;
|
|
387
|
+
// Base-91 telemetry and the !DAO! are lifted before the locator is read, so a locator followed
|
|
388
|
+
// straight by either has no missing space (vectors rulings, F16).
|
|
389
|
+
const joins = join !== undefined && join > 0 ? [join] : [];
|
|
390
|
+
const pre = liftTelemetryAndDao(text, joins);
|
|
391
|
+
text = pre.rest;
|
|
392
|
+
// A locator is read only at the start of the status text, after any type code or a Mic-E
|
|
393
|
+
// altitude there; after a PHG or RNG, say, it is text (vectors rulings, F17).
|
|
355
394
|
let locator: string | undefined;
|
|
356
|
-
const loc =
|
|
395
|
+
const loc = extension && !extensionAfterLocator ? null : MIC_E_LOCATOR.exec(text);
|
|
357
396
|
if (loc) {
|
|
358
397
|
locator = loc[1]!.toUpperCase();
|
|
359
398
|
text = text.slice(loc[0].length);
|
|
@@ -362,9 +401,13 @@ export function decodeMicE(ctx: DecodeContext, s: string): MicEReport {
|
|
|
362
401
|
else ctx.tolerate('missing-space-after-locator');
|
|
363
402
|
}
|
|
364
403
|
}
|
|
365
|
-
|
|
366
|
-
|
|
404
|
+
// A PHG or RNG is straight after the type code only where it is so as sent: one that reaches
|
|
405
|
+
// there once a later altitude is lifted is late (vectors mic-e/an-rng-reached-only-by-lifting-an-altitude-is-late).
|
|
406
|
+
const startJoined = !extension && !loc && join !== undefined && (extensionAt(text, 0)?.length ?? 0) > join;
|
|
407
|
+
const { lifted } = liftMicEComment(ctx, text, pos.symbol, extension, pre, !startJoined);
|
|
408
|
+
// The !DAO! is structure, so it is judged before the text's encoding.
|
|
367
409
|
applyDao(ctx, f, pos, lifted.dao);
|
|
410
|
+
applyLiftedTo(ctx, f, lifted);
|
|
368
411
|
|
|
369
412
|
const dti = s[0]!;
|
|
370
413
|
const head: {
|
package/src/decoder.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import { encodeUtf8 } from './bytes.js';
|
|
4
4
|
import { decodeInformation } from './decode/index.js';
|
|
5
|
-
import {
|
|
5
|
+
import { identifyDevice } from './deviceid.js';
|
|
6
6
|
import { ParseOptions } from './diagnostics.js';
|
|
7
7
|
import { findQConstruct, parseAx25Frame, parseTnc2Header, type ParsedHeader } from './header.js';
|
|
8
8
|
import type { AprsPacket } from './types.js';
|
|
@@ -19,10 +19,7 @@ function assemble(header: ParsedHeader, options: ParseOptions): AprsPacket {
|
|
|
19
19
|
};
|
|
20
20
|
const q = findQConstruct(header.path);
|
|
21
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);
|
|
22
|
+
const device = identifyDevice(decoded.data, header.destination);
|
|
26
23
|
if (device) packet.device = device;
|
|
27
24
|
return packet;
|
|
28
25
|
}
|