@packet-net/pdn-aprs 0.1.1 → 0.2.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 +15 -0
- package/README.md +2 -2
- 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.js +3 -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 +10 -2
- package/dist/decode/positioned.d.ts.map +1 -1
- package/dist/decode/positioned.js +129 -77
- package/dist/decode/positioned.js.map +1 -1
- package/dist/decode/reports.d.ts.map +1 -1
- package/dist/decode/reports.js +24 -4
- package/dist/decode/reports.js.map +1 -1
- package/dist/deviceid.d.ts.map +1 -1
- package/dist/deviceid.js +18 -4
- package/dist/deviceid.js.map +1 -1
- package/dist/encode/index.d.ts.map +1 -1
- package/dist/encode/index.js +29 -12
- package/dist/encode/index.js.map +1 -1
- package/dist/encode/other.d.ts.map +1 -1
- package/dist/encode/other.js +35 -15
- package/dist/encode/other.js.map +1 -1
- package/dist/encode/position.d.ts +4 -2
- package/dist/encode/position.d.ts.map +1 -1
- package/dist/encode/position.js +58 -22
- 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/neutral.d.ts.map +1 -1
- package/dist/neutral.js +2 -0
- package/dist/neutral.js.map +1 -1
- package/dist/types.d.ts +6 -4
- package/dist/types.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/bytes.ts +2 -1
- package/src/codes.ts +8 -8
- package/src/decode/index.ts +3 -4
- package/src/decode/message.ts +54 -37
- package/src/decode/other.ts +116 -44
- package/src/decode/positioned.ts +133 -88
- package/src/decode/reports.ts +22 -3
- package/src/deviceid.ts +17 -2
- package/src/encode/index.ts +30 -12
- package/src/encode/other.ts +27 -11
- package/src/encode/position.ts +54 -20
- package/src/header.ts +9 -2
- package/src/neutral.ts +2 -0
- package/src/types.ts +6 -4
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,6 +611,10 @@ 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;
|
|
@@ -623,12 +646,19 @@ function removeRanges(s: string, ranges: readonly [number, number][]): string {
|
|
|
623
646
|
return out + s.slice(from);
|
|
624
647
|
}
|
|
625
648
|
|
|
626
|
-
/**
|
|
627
|
-
|
|
649
|
+
/**
|
|
650
|
+
* Lifts base-91 telemetry and the last `!DAO!` outside it. A `!DAO!` is five bytes as sent, so one
|
|
651
|
+
* is never read across a `join`, a place in `s` where another element was already taken out.
|
|
652
|
+
*/
|
|
653
|
+
function liftTelemetryAndDao(
|
|
654
|
+
s: string,
|
|
655
|
+
joins: readonly number[] = [],
|
|
656
|
+
): { telemetry?: CommentTelemetry; dao?: DaoFound; rest: string } {
|
|
628
657
|
const tel = findCommentTelemetry(s);
|
|
629
658
|
let dao: DaoFound | undefined;
|
|
630
659
|
for (let p = s.length - 5; p >= 0; p--) {
|
|
631
660
|
if (tel && p + 5 > tel.start && p < tel.end) continue;
|
|
661
|
+
if (joins.some((j) => j > p && j < p + 5)) continue;
|
|
632
662
|
const found = daoAt(s, p);
|
|
633
663
|
if (found) {
|
|
634
664
|
dao = found;
|
|
@@ -648,13 +678,16 @@ const ALTITUDE_RE = /\/A=(-[0-9]{5}|[0-9]{6})/;
|
|
|
648
678
|
|
|
649
679
|
/** Lifts the structured elements out of a comment, in the order the vectors' README gives. */
|
|
650
680
|
function liftComment(ctx: DecodeContext, text: string, options: LiftOptions): Lifted {
|
|
651
|
-
const first = liftTelemetryAndDao(text);
|
|
681
|
+
const first = liftTelemetryAndDao(text, options.joins);
|
|
652
682
|
const lifted: Lifted = { rest: first.rest };
|
|
653
683
|
if (first.telemetry) lifted.telemetry = first.telemetry;
|
|
654
684
|
if (first.dao) lifted.dao = first.dao;
|
|
655
685
|
let s = first.rest;
|
|
656
686
|
let extensionFound = options.extensionFound;
|
|
657
|
-
if (options.
|
|
687
|
+
if (options.extension) {
|
|
688
|
+
applyExtension(lifted, options.extension);
|
|
689
|
+
extensionFound = true;
|
|
690
|
+
} else if (options.extensionAtStart) {
|
|
658
691
|
const ext = extensionAt(s, 0);
|
|
659
692
|
if (ext) {
|
|
660
693
|
applyExtension(lifted, ext);
|
|
@@ -670,15 +703,15 @@ function liftComment(ctx: DecodeContext, text: string, options: LiftOptions): Li
|
|
|
670
703
|
}
|
|
671
704
|
// Signpost or corridor braces.
|
|
672
705
|
if (isSignpost(options.symbol) || options.areaLine) {
|
|
673
|
-
|
|
706
|
+
// The first well-formed braces wherever they are: 1-3 printable ASCII characters other than
|
|
707
|
+
// a brace for a signpost, 1-3 digits for a corridor. Braces that do not qualify are comment
|
|
708
|
+
// text and do not stop the search (vectors interpretations.md).
|
|
709
|
+
const signpost = isSignpost(options.symbol);
|
|
710
|
+
const brace = (signpost ? /\{([\x20-\x7a\x7c\x7e]{1,3})\}/ : /\{([0-9]{1,3})\}/).exec(s);
|
|
674
711
|
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
|
-
}
|
|
712
|
+
if (signpost) lifted.signpost = brace[1]!;
|
|
713
|
+
else lifted.corridorWidthMiles = Number(brace[1]);
|
|
714
|
+
s = s.slice(0, brace.index) + s.slice(brace.index + brace[0].length);
|
|
682
715
|
}
|
|
683
716
|
}
|
|
684
717
|
// A data extension later in the text, only when none came straight after the symbol.
|
|
@@ -782,15 +815,20 @@ function isLetter(c: string | undefined): boolean {
|
|
|
782
815
|
return c !== undefined && ((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z'));
|
|
783
816
|
}
|
|
784
817
|
|
|
785
|
-
/**
|
|
818
|
+
/**
|
|
819
|
+
* Parses the run of weather fields from `j`, and reports what it finds in reading order: for a
|
|
820
|
+
* position, object or item report the wind first, where the `DDD/SSS` extension belongs (missing,
|
|
821
|
+
* incomplete, or sent as `c` and `s` fields), then each field's defects, then a missing gust or
|
|
822
|
+
* temperature. The fields are read the same whatever is tolerated, so they are read first and
|
|
823
|
+
* reported after.
|
|
824
|
+
*/
|
|
786
825
|
function parseWeatherFields(ctx: DecodeContext, s: string, j: number, options: WeatherFieldOptions): WeatherParse {
|
|
787
826
|
const w: MutableWeather = {};
|
|
788
827
|
if (options.wind.direction !== undefined) w.windDirectionDegrees = options.wind.direction;
|
|
789
828
|
if (options.wind.speedMph !== undefined) w.windSpeedMph = options.wind.speedMph;
|
|
790
829
|
const seen = new Set<string>();
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
let warnedWindFields = false;
|
|
830
|
+
const windKnown = options.wind.present;
|
|
831
|
+
const defects: DiagnosticCode[] = [];
|
|
794
832
|
const extra: ExtraWeatherField[] = [];
|
|
795
833
|
for (;;) {
|
|
796
834
|
const letter = s[j];
|
|
@@ -798,47 +836,48 @@ function parseWeatherFields(ctx: DecodeContext, s: string, j: number, options: W
|
|
|
798
836
|
let key = letter;
|
|
799
837
|
const width = WEATHER_WIDTH[letter];
|
|
800
838
|
if (letter === 's') {
|
|
801
|
-
// s is the wind speed after c
|
|
839
|
+
// s is the wind speed once the wind comes as fields (after c, or anywhere in a positionless
|
|
840
|
+
// report) until the speed is known, and snowfall after that.
|
|
802
841
|
const windSlot = !seen.has('s:wind') && (seen.has('c') || (options.positionless && !windKnown));
|
|
803
842
|
key = windSlot ? 's:wind' : 's:snow';
|
|
804
843
|
}
|
|
844
|
+
// L and l are one field, luminosity.
|
|
845
|
+
if (letter === 'l') key = 'L';
|
|
805
846
|
if (width !== undefined) {
|
|
806
847
|
if (seen.has(key)) break;
|
|
807
848
|
if (letter === 'c' && windKnown) break;
|
|
808
849
|
if (key === 's:snow') {
|
|
850
|
+
// Snowfall is three characters, which may include one decimal point ("A decimal point
|
|
851
|
+
// is allowed for non-integer values", APRS12c ch. 12), so s.50 is 0.5 inches. Otherwise
|
|
852
|
+
// a run of dots, read up to the field's width, is an unknown snowfall.
|
|
809
853
|
const v = s.slice(j + 1, j + 4);
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
if (dots.length !== 3) ctx.tolerate('non-standard-weather-field-width');
|
|
854
|
+
if (/^[0-9.]{3}$/.test(v) && /[0-9]/.test(v) && !/\..*\./.test(v)) {
|
|
855
|
+
w.snow24hIn = Number(v);
|
|
813
856
|
seen.add(key);
|
|
814
|
-
j +=
|
|
857
|
+
j += 4;
|
|
815
858
|
continue;
|
|
816
859
|
}
|
|
817
|
-
|
|
818
|
-
|
|
860
|
+
// Only a run of dots may be shorter than three, and only when no digit follows it: s..6 is
|
|
861
|
+
// not a field, so the fields end there.
|
|
862
|
+
const dots = /^\.{1,3}/.exec(v)?.[0];
|
|
863
|
+
if (dots === undefined || (dots.length < 3 && isDigit(v[dots.length]))) break;
|
|
864
|
+
if (dots.length !== 3) defects.push('non-standard-weather-field-width');
|
|
819
865
|
seen.add(key);
|
|
820
|
-
j +=
|
|
866
|
+
j += 1 + dots.length;
|
|
821
867
|
continue;
|
|
822
868
|
}
|
|
823
869
|
let run = weatherValueRun(s, j + 1, width, letter === 't');
|
|
824
870
|
if (run.length === 0) break;
|
|
825
871
|
if (run.length > width + 1) run = run.slice(0, width);
|
|
826
|
-
if (run.length !== width)
|
|
872
|
+
if (run.length !== width) defects.push('non-standard-weather-field-width');
|
|
827
873
|
seen.add(key);
|
|
828
874
|
j += 1 + run.length;
|
|
829
875
|
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
876
|
if (unknown) continue;
|
|
838
877
|
const v = Number(run);
|
|
839
|
-
switch (key) {
|
|
878
|
+
switch (letter === 's' ? key : letter) {
|
|
840
879
|
case 'c':
|
|
841
|
-
if (v > 360)
|
|
880
|
+
if (v > 360) defects.push('out-of-range-value');
|
|
842
881
|
else w.windDirectionDegrees = v;
|
|
843
882
|
break;
|
|
844
883
|
case 's:wind':
|
|
@@ -860,7 +899,7 @@ function parseWeatherFields(ctx: DecodeContext, s: string, j: number, options: W
|
|
|
860
899
|
w.rainMidnightIn = v / 100;
|
|
861
900
|
break;
|
|
862
901
|
case 'h':
|
|
863
|
-
if (v > 100)
|
|
902
|
+
if (v > 100) defects.push('out-of-range-value');
|
|
864
903
|
else w.humidityPercent = v === 0 ? 100 : v;
|
|
865
904
|
break;
|
|
866
905
|
case 'b':
|
|
@@ -888,14 +927,21 @@ function parseWeatherFields(ctx: DecodeContext, s: string, j: number, options: W
|
|
|
888
927
|
}
|
|
889
928
|
break;
|
|
890
929
|
}
|
|
891
|
-
if (seen.has('c') || seen.has('s:wind')) windKnown = true;
|
|
892
930
|
if (extra.length > 0) w.extra = extra;
|
|
893
|
-
|
|
931
|
+
const hasDirection = seen.has('c');
|
|
932
|
+
const hasSpeed = seen.has('s:wind');
|
|
933
|
+
const windFromFields = hasDirection || hasSpeed;
|
|
894
934
|
if (options.positionless) {
|
|
895
|
-
|
|
935
|
+
for (const code of defects) ctx.tolerate(code);
|
|
936
|
+
if (!hasDirection || !hasSpeed || !seen.has('g') || !seen.has('t')) ctx.tolerate('incomplete-weather');
|
|
896
937
|
} else {
|
|
897
|
-
//
|
|
898
|
-
|
|
938
|
+
// The wind is decided where the DDD/SSS extension belongs, before any field. After a
|
|
939
|
+
// compressed position whose cs bytes carry none, the wind is unknown, never missing.
|
|
940
|
+
if (!windKnown) {
|
|
941
|
+
if (windFromFields) ctx.tolerate('wind-fields-instead-of-extension');
|
|
942
|
+
if (!options.compressed && !(hasDirection && hasSpeed)) ctx.tolerate('incomplete-weather');
|
|
943
|
+
}
|
|
944
|
+
for (const code of defects) ctx.tolerate(code);
|
|
899
945
|
if (!seen.has('g') || !seen.has('t')) ctx.tolerate('incomplete-weather');
|
|
900
946
|
}
|
|
901
947
|
return { weather: w, end: j, windFromFields };
|
|
@@ -904,16 +950,18 @@ function parseWeatherFields(ctx: DecodeContext, s: string, j: number, options: W
|
|
|
904
950
|
const SOFTWARE_UNIT_RE = /^([A-Za-z])([A-Za-z0-9_-]{2,4})$/;
|
|
905
951
|
|
|
906
952
|
/**
|
|
907
|
-
* The software type and unit after the weather fields, or the weather comment.
|
|
908
|
-
*
|
|
953
|
+
* The software type and unit after the weather fields, or the weather comment. A position's
|
|
954
|
+
* base-91 telemetry and `!DAO!` are lifted out first, and one leading delimiter is dropped from its
|
|
955
|
+
* comment; a positionless report has no position for them to belong to, so it keeps them, and its
|
|
956
|
+
* delimiter, in its comment.
|
|
909
957
|
*/
|
|
910
958
|
function weatherTail(
|
|
911
959
|
ctx: DecodeContext,
|
|
912
960
|
w: MutableWeather,
|
|
913
961
|
rest: string,
|
|
914
|
-
|
|
962
|
+
positioned: boolean,
|
|
915
963
|
): { comment?: string; telemetry?: CommentTelemetry; dao?: DaoFound } {
|
|
916
|
-
const lifted = liftTelemetryAndDao(rest);
|
|
964
|
+
const lifted = positioned ? liftTelemetryAndDao(rest) : { rest };
|
|
917
965
|
const out: { comment?: string; telemetry?: CommentTelemetry; dao?: DaoFound } = {};
|
|
918
966
|
if (lifted.telemetry) out.telemetry = lifted.telemetry;
|
|
919
967
|
if (lifted.dao) out.dao = lifted.dao;
|
|
@@ -926,7 +974,7 @@ function weatherTail(
|
|
|
926
974
|
return out;
|
|
927
975
|
}
|
|
928
976
|
ctx.tolerate('weather-comment');
|
|
929
|
-
if (
|
|
977
|
+
if (positioned && (text[0] === ' ' || text[0] === '/')) text = text.slice(1);
|
|
930
978
|
if (text.length > 0) out.comment = text;
|
|
931
979
|
return out;
|
|
932
980
|
}
|
|
@@ -954,13 +1002,8 @@ export function decodeAfterPosition(ctx: DecodeContext, s: string, i: number, po
|
|
|
954
1002
|
f.compressed = true;
|
|
955
1003
|
if (pos.compression) f.compression = pos.compression;
|
|
956
1004
|
}
|
|
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);
|
|
1005
|
+
if (pos.symbol.code === '_') decodeWeatherAfterPosition(ctx, s, i, pos, f);
|
|
1006
|
+
else decodeExtensionAndComment(ctx, s, i, pos, f);
|
|
964
1007
|
return f;
|
|
965
1008
|
}
|
|
966
1009
|
|
|
@@ -979,13 +1022,7 @@ export function applyDao(ctx: DecodeContext, f: Fields, pos: RawPosition, dao: D
|
|
|
979
1022
|
f.longitude = lon;
|
|
980
1023
|
}
|
|
981
1024
|
|
|
982
|
-
function decodeExtensionAndComment(
|
|
983
|
-
ctx: DecodeContext,
|
|
984
|
-
s: string,
|
|
985
|
-
i: number,
|
|
986
|
-
pos: RawPosition,
|
|
987
|
-
f: Fields,
|
|
988
|
-
): DaoFound | undefined {
|
|
1025
|
+
function decodeExtensionAndComment(ctx: DecodeContext, s: string, i: number, pos: RawPosition, f: Fields): void {
|
|
989
1026
|
let extensionFound = false;
|
|
990
1027
|
if (pos.compressed) {
|
|
991
1028
|
if (pos.csCourse !== undefined) {
|
|
@@ -1024,12 +1061,17 @@ function decodeExtensionAndComment(
|
|
|
1024
1061
|
if (pos.symbol.table === '/' && pos.symbol.code === '\\') {
|
|
1025
1062
|
const df = /^\/([0-9]{3})\/([0-9])([0-9])([0-9])/.exec(s.slice(i, i + 8));
|
|
1026
1063
|
if (df) {
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1064
|
+
// The bearing is degrees: one over 360 drops the whole /BRG/NRQ, as a course over 360 is dropped.
|
|
1065
|
+
if (Number(df[1]) > 360) {
|
|
1066
|
+
ctx.tolerate('out-of-range-value');
|
|
1067
|
+
} else {
|
|
1068
|
+
f.dfBearing = {
|
|
1069
|
+
bearingDegrees: Number(df[1]),
|
|
1070
|
+
number: Number(df[2]),
|
|
1071
|
+
range: Number(df[3]),
|
|
1072
|
+
quality: Number(df[4]),
|
|
1073
|
+
};
|
|
1074
|
+
}
|
|
1033
1075
|
i += 8;
|
|
1034
1076
|
}
|
|
1035
1077
|
}
|
|
@@ -1053,8 +1095,9 @@ function decodeExtensionAndComment(
|
|
|
1053
1095
|
}
|
|
1054
1096
|
const areaLine = f.area !== undefined && (f.area.shape === 'line-down-right' || f.area.shape === 'line-down-left');
|
|
1055
1097
|
const lifted = liftComment(ctx, s.slice(i), { extensionFound, symbol: pos.symbol, areaLine });
|
|
1098
|
+
// The !DAO! is structure, so it is judged before the text's encoding.
|
|
1099
|
+
applyDao(ctx, f, pos, lifted.dao);
|
|
1056
1100
|
applyLifted(ctx, f, lifted);
|
|
1057
|
-
return lifted.dao;
|
|
1058
1101
|
}
|
|
1059
1102
|
|
|
1060
1103
|
function applyLifted(ctx: DecodeContext, f: Fields, lifted: Lifted): void {
|
|
@@ -1094,13 +1137,7 @@ function parseStorm(s: string): { storm: Storm; length: number } | undefined {
|
|
|
1094
1137
|
|
|
1095
1138
|
const DEFAULT_COMPRESSION: CompressionType = { fix: 'current', source: 'other', origin: 'software' };
|
|
1096
1139
|
|
|
1097
|
-
function decodeWeatherAfterPosition(
|
|
1098
|
-
ctx: DecodeContext,
|
|
1099
|
-
s: string,
|
|
1100
|
-
i: number,
|
|
1101
|
-
pos: RawPosition,
|
|
1102
|
-
f: Fields,
|
|
1103
|
-
): DaoFound | undefined {
|
|
1140
|
+
function decodeWeatherAfterPosition(ctx: DecodeContext, s: string, i: number, pos: RawPosition, f: Fields): void {
|
|
1104
1141
|
const wind: WindStart = { present: false };
|
|
1105
1142
|
let windFromCs = false;
|
|
1106
1143
|
if (pos.compressed && pos.csCourseRaw !== undefined) {
|
|
@@ -1131,26 +1168,34 @@ function decodeWeatherAfterPosition(
|
|
|
1131
1168
|
i += 7;
|
|
1132
1169
|
}
|
|
1133
1170
|
const parsed = parseWeatherFields(ctx, s, i, { wind, positionless: false, compressed: pos.compressed });
|
|
1134
|
-
const tail = weatherTail(ctx, parsed.weather, s.slice(parsed.end));
|
|
1171
|
+
const tail = weatherTail(ctx, parsed.weather, s.slice(parsed.end), true);
|
|
1135
1172
|
f.weather = parsed.weather;
|
|
1136
1173
|
if (pos.compressed && pos.csBlank && !windFromCs) {
|
|
1137
1174
|
const hasWind = parsed.weather.windDirectionDegrees !== undefined || parsed.weather.windSpeedMph !== undefined;
|
|
1138
1175
|
if (hasWind && (windFromExtension || parsed.windFromFields)) f.compression = DEFAULT_COMPRESSION;
|
|
1139
1176
|
}
|
|
1140
1177
|
if (tail.telemetry) f.telemetry = tail.telemetry;
|
|
1178
|
+
applyDao(ctx, f, pos, tail.dao);
|
|
1141
1179
|
if (tail.comment !== undefined) {
|
|
1142
1180
|
const text = ctx.text(tail.comment);
|
|
1143
1181
|
if (text.length > 0) f.comment = text;
|
|
1144
1182
|
}
|
|
1145
|
-
return tail.dao;
|
|
1146
1183
|
}
|
|
1147
1184
|
|
|
1148
1185
|
// ---- Mic-E status text
|
|
1149
1186
|
|
|
1150
1187
|
/** Lifts the elements out of Mic-E status text after its type code, altitude and locator. */
|
|
1151
|
-
export function liftMicEComment(
|
|
1152
|
-
|
|
1153
|
-
|
|
1188
|
+
export function liftMicEComment(
|
|
1189
|
+
ctx: DecodeContext,
|
|
1190
|
+
text: string,
|
|
1191
|
+
symbol: AprsSymbol,
|
|
1192
|
+
extension: ExtensionFound | undefined,
|
|
1193
|
+
joins: readonly number[],
|
|
1194
|
+
): { lifted: Lifted } {
|
|
1195
|
+
const options: LiftOptions = extension
|
|
1196
|
+
? { extensionFound: true, extension, symbol, joins }
|
|
1197
|
+
: { extensionFound: false, extensionAtStart: true, symbol, joins };
|
|
1198
|
+
return { lifted: liftComment(ctx, text, options) };
|
|
1154
1199
|
}
|
|
1155
1200
|
|
|
1156
1201
|
/** Applies lifted comment elements to fields (Mic-E). */
|
package/src/decode/reports.ts
CHANGED
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
applyLiftedTo,
|
|
17
17
|
base91Value,
|
|
18
18
|
decodeAfterPosition,
|
|
19
|
+
extensionAt,
|
|
19
20
|
isDigit,
|
|
20
21
|
isSymbolTable,
|
|
21
22
|
liftMicEComment,
|
|
@@ -25,6 +26,7 @@ import {
|
|
|
25
26
|
positionDecodesAt,
|
|
26
27
|
timestampAt,
|
|
27
28
|
timestampValid,
|
|
29
|
+
type ExtensionFound,
|
|
28
30
|
type Fields,
|
|
29
31
|
type RawPosition,
|
|
30
32
|
} from './positioned.js';
|
|
@@ -225,7 +227,10 @@ function decodeMicEDestination(ctx: DecodeContext, destination: string): MicEDes
|
|
|
225
227
|
const num = (c: string): number => (c === ' ' ? 0 : c.charCodeAt(0) - 48);
|
|
226
228
|
const degrees = num(digits[0]!) * 10 + num(digits[1]!);
|
|
227
229
|
const minutes = num(digits[2]!) * 10 + num(digits[3]!) + (num(digits[4]!) * 10 + num(digits[5]!)) / 100;
|
|
228
|
-
|
|
230
|
+
// The latitude reported is the centre of the ambiguity box, so 90 degrees with blanked minutes
|
|
231
|
+
// (centred on 90 degrees 30 minutes, say) is past the pole.
|
|
232
|
+
const centre = micEMinutes(num(digits[2]!), num(digits[3]!), num(digits[4]!) * 10 + num(digits[5]!), ambiguity);
|
|
233
|
+
if (minutes >= 60 || degrees > 90 || (degrees === 90 && (minutes > 0 || centre > 0))) ctx.fail('invalid-mic-e-destination');
|
|
229
234
|
const bits = kinds.slice(0, 3);
|
|
230
235
|
const hasStd = bits.includes('std');
|
|
231
236
|
const hasCustom = bits.includes('custom');
|
|
@@ -254,6 +259,8 @@ const TYPE_CODES = '`\'>] ';
|
|
|
254
259
|
|
|
255
260
|
/** `` ` ``, `'`, 0x1c, 0x1d: a Mic-E report. */
|
|
256
261
|
export function decodeMicE(ctx: DecodeContext, s: string): MicEReport {
|
|
262
|
+
// The Rev 0 beta data type identifiers are obsolete: said at the DTI, before anything is checked.
|
|
263
|
+
if (s[0] === '\x1c' || s[0] === '\x1d') ctx.info('obsolete-format');
|
|
257
264
|
const dest = decodeMicEDestination(ctx, ctx.destination);
|
|
258
265
|
if (s.length < 9) ctx.fail('invalid-mic-e-information');
|
|
259
266
|
const b = (k: number): number => s.charCodeAt(k);
|
|
@@ -340,15 +347,23 @@ export function decodeMicE(ctx: DecodeContext, s: string): MicEReport {
|
|
|
340
347
|
if (deviceSuffix !== undefined) text = text.slice(0, text.length - deviceSuffix.length);
|
|
341
348
|
}
|
|
342
349
|
let altitudeFeet: number | undefined;
|
|
350
|
+
let extension: ExtensionFound | undefined;
|
|
351
|
+
// Where a later altitude was taken out of the text: a !DAO! is never read across it.
|
|
352
|
+
let join: number | undefined;
|
|
343
353
|
const altFirst = /^([!-{]{3})\}/.exec(text);
|
|
344
354
|
if (altFirst) {
|
|
345
355
|
altitudeFeet = (base91Value(altFirst[1]!) - 10000) / METRES_PER_FOOT;
|
|
346
356
|
text = text.slice(4);
|
|
347
357
|
} else {
|
|
358
|
+
// A data extension straight after the type code is read first, so its bytes are never taken
|
|
359
|
+
// for an altitude later in the text (`0PH}` in `PHG3330PH}`).
|
|
360
|
+
extension = extensionAt(text, 0);
|
|
361
|
+
if (extension) text = text.slice(extension.length);
|
|
348
362
|
const later = /([!-{]{3})\}/.exec(text);
|
|
349
363
|
if (later && ctx.allows('mic-e-altitude-not-first')) {
|
|
350
364
|
altitudeFeet = (base91Value(later[1]!) - 10000) / METRES_PER_FOOT;
|
|
351
365
|
text = text.slice(0, later.index) + text.slice(later.index + 4);
|
|
366
|
+
join = later.index;
|
|
352
367
|
}
|
|
353
368
|
}
|
|
354
369
|
if (altitudeFeet !== undefined) f.altitudeFeet = altitudeFeet;
|
|
@@ -356,15 +371,19 @@ export function decodeMicE(ctx: DecodeContext, s: string): MicEReport {
|
|
|
356
371
|
const loc = /^([A-Ra-r]{2}[0-9]{2}(?:[A-Xa-x]{2})?)\/G/.exec(text);
|
|
357
372
|
if (loc) {
|
|
358
373
|
locator = loc[1]!.toUpperCase();
|
|
374
|
+
const before = text.length;
|
|
359
375
|
text = text.slice(loc[0].length);
|
|
360
376
|
if (text.length > 0) {
|
|
361
377
|
if (text[0] === ' ') text = text.slice(1);
|
|
362
378
|
else ctx.tolerate('missing-space-after-locator');
|
|
363
379
|
}
|
|
380
|
+
if (join !== undefined) join -= before - text.length;
|
|
364
381
|
}
|
|
365
|
-
const
|
|
366
|
-
|
|
382
|
+
const joins = join !== undefined && join > 0 ? [join] : [];
|
|
383
|
+
const { lifted } = liftMicEComment(ctx, text, pos.symbol, extension, joins);
|
|
384
|
+
// The !DAO! is structure, so it is judged before the text's encoding.
|
|
367
385
|
applyDao(ctx, f, pos, lifted.dao);
|
|
386
|
+
applyLiftedTo(ctx, f, lifted);
|
|
368
387
|
|
|
369
388
|
const dti = s[0]!;
|
|
370
389
|
const head: {
|
package/src/deviceid.ts
CHANGED
|
@@ -47,11 +47,26 @@ function compile(): CompiledTocall[] {
|
|
|
47
47
|
return compiled;
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
+
// Destinations repeat constantly (a handful of tocalls cover most traffic), and matching one means
|
|
51
|
+
// trying up to every pattern in the database, so answers are remembered. The cache is bounded so a
|
|
52
|
+
// stream of junk destinations cannot grow it without limit.
|
|
53
|
+
const tocallCache = new Map<string, DeviceInfo | undefined>();
|
|
54
|
+
const TOCALL_CACHE_LIMIT = 4096;
|
|
55
|
+
|
|
50
56
|
/** The device a destination address (tocall) identifies, e.g. `APDW18` is Dire Wolf. */
|
|
51
57
|
export function identifyTocall(destination: string): DeviceInfo | undefined {
|
|
52
58
|
const call = destination.split('-')[0]!.toUpperCase();
|
|
53
|
-
|
|
54
|
-
|
|
59
|
+
if (tocallCache.has(call)) return tocallCache.get(call);
|
|
60
|
+
let found: DeviceInfo | undefined;
|
|
61
|
+
for (const t of compile()) {
|
|
62
|
+
if (t.regex.test(call)) {
|
|
63
|
+
found = Object.freeze(toInfo(t.entry));
|
|
64
|
+
break;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
if (tocallCache.size >= TOCALL_CACHE_LIMIT) tocallCache.clear();
|
|
68
|
+
tocallCache.set(call, found);
|
|
69
|
+
return found;
|
|
55
70
|
}
|
|
56
71
|
|
|
57
72
|
/**
|
package/src/encode/index.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// The encoder: writes only what the spec allows and refuses anything else with a reason. Free
|
|
2
2
|
// text is checked by decoding what was written: if the result does not read back as the same
|
|
3
|
-
// data (a comment that would read as an altitude, say), a `/` delimiter is tried,
|
|
4
|
-
//
|
|
3
|
+
// data (a comment that would read as an altitude, say), a `/` delimiter is tried, then the
|
|
4
|
+
// signpost or corridor braces before the comment, and if none of that helps the encoder refuses.
|
|
5
5
|
|
|
6
6
|
import { binaryToBytes, bytesToBinary, decodeUtf8 } from '../bytes.js';
|
|
7
7
|
import { decodeInformation } from '../decode/index.js';
|
|
@@ -99,9 +99,12 @@ function attempt(data: AprsData, mode: CommentMode): Attempt {
|
|
|
99
99
|
return { binary: encodeCapabilities(data) };
|
|
100
100
|
case 'third-party': {
|
|
101
101
|
// The original information field must not be changed (APRS12c ch. 17).
|
|
102
|
+
// An inner packet built by hand, with no information field, is encoded from its data.
|
|
102
103
|
const original = data.packet.information;
|
|
103
|
-
const inner =
|
|
104
|
-
|
|
104
|
+
const inner = data.packet.data;
|
|
105
|
+
const asReceived = original.length > 0 || (inner.type === 'unrecognized' && inner.reason === 'empty');
|
|
106
|
+
const info = asReceived ? original : encodeInformation(inner).info;
|
|
107
|
+
return { binary: encodeThirdParty(data, bytesToBinary(info)) };
|
|
105
108
|
}
|
|
106
109
|
case 'user-defined':
|
|
107
110
|
return { binary: encodeUserDefined(data) };
|
|
@@ -114,12 +117,25 @@ function attempt(data: AprsData, mode: CommentMode): Attempt {
|
|
|
114
117
|
}
|
|
115
118
|
}
|
|
116
119
|
|
|
117
|
-
/**
|
|
118
|
-
function
|
|
119
|
-
|
|
120
|
+
/** Numbers that agree within 1e-9, relative to the larger (absolute below 1). */
|
|
121
|
+
function sameNumber(a: number, b: number): boolean {
|
|
122
|
+
const scale = Math.max(Math.abs(a), Math.abs(b));
|
|
123
|
+
return Math.abs(a - b) <= 1e-9 * (scale >= 1 ? scale : 1);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Where the written data would read back differently: strings, booleans and which fields are
|
|
128
|
+
* present, and with `numbers` the numbers too. Numbers are left out otherwise, because a value
|
|
129
|
+
* given with more precision than the format carries is written to the format's precision.
|
|
130
|
+
*/
|
|
131
|
+
function shapeDifferences(a: unknown, b: unknown, path: string, out: string[], numbers: boolean): void {
|
|
132
|
+
if (typeof a === 'number' && typeof b === 'number') {
|
|
133
|
+
if (numbers && !sameNumber(a, b)) out.push(`${path.replace(/\.$/, '')} would read back as ${a}`);
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
120
136
|
if (Array.isArray(a) && Array.isArray(b)) {
|
|
121
137
|
if (a.length !== b.length) out.push(`${path.replace(/\.$/, '')} would have ${a.length} items, not ${b.length}`);
|
|
122
|
-
else a.forEach((x, i) => shapeDifferences(x, b[i], `${path}${i}.`, out));
|
|
138
|
+
else a.forEach((x, i) => shapeDifferences(x, b[i], `${path}${i}.`, out, numbers));
|
|
123
139
|
return;
|
|
124
140
|
}
|
|
125
141
|
if (a !== null && b !== null && typeof a === 'object' && typeof b === 'object') {
|
|
@@ -128,7 +144,7 @@ function shapeDifferences(a: unknown, b: unknown, path: string, out: string[]):
|
|
|
128
144
|
for (const k of new Set([...Object.keys(ao), ...Object.keys(bo)])) {
|
|
129
145
|
if (!(k in ao)) out.push(`${path}${k} would be lost`);
|
|
130
146
|
else if (!(k in bo)) out.push(`${path}${k} would appear`);
|
|
131
|
-
else shapeDifferences(ao[k], bo[k], `${path}${k}.`, out);
|
|
147
|
+
else shapeDifferences(ao[k], bo[k], `${path}${k}.`, out, numbers);
|
|
132
148
|
}
|
|
133
149
|
return;
|
|
134
150
|
}
|
|
@@ -162,7 +178,8 @@ function check(data: AprsData, a: Attempt): string[] {
|
|
|
162
178
|
problems.push(`it would read back as ${decoded.data.type}`);
|
|
163
179
|
return problems;
|
|
164
180
|
}
|
|
165
|
-
|
|
181
|
+
// An NMEA sentence is written as it is, so everything read from it must be what the data says.
|
|
182
|
+
shapeDifferences(toNeutralData(decoded.data), toNeutralData(expected(data, a)), '', problems, data.type === 'nmea');
|
|
166
183
|
return problems;
|
|
167
184
|
}
|
|
168
185
|
|
|
@@ -175,8 +192,9 @@ export function encodeInformation(data: AprsData): EncodedInformation {
|
|
|
175
192
|
let problems = check(data, first);
|
|
176
193
|
let chosen = first;
|
|
177
194
|
if (problems.length > 0 && typeof (data as { comment?: unknown }).comment === 'string') {
|
|
178
|
-
// The comment would read as something else: try it straight after a frequency, then after a
|
|
179
|
-
|
|
195
|
+
// The comment would read as something else: try it straight after a frequency, then after a /,
|
|
196
|
+
// then after the signpost or corridor braces.
|
|
197
|
+
for (const mode of ['joined', 'delimited', 'braces-first'] as const) {
|
|
180
198
|
let next: Attempt | undefined;
|
|
181
199
|
try {
|
|
182
200
|
next = attempt(data, mode);
|