@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.
Files changed (81) hide show
  1. package/CHANGELOG.md +44 -1
  2. package/README.md +9 -5
  3. package/dist/builder.d.ts +33 -8
  4. package/dist/builder.d.ts.map +1 -1
  5. package/dist/builder.js +64 -10
  6. package/dist/builder.js.map +1 -1
  7. package/dist/bytes.d.ts.map +1 -1
  8. package/dist/bytes.js +2 -1
  9. package/dist/bytes.js.map +1 -1
  10. package/dist/codes.js +8 -8
  11. package/dist/codes.js.map +1 -1
  12. package/dist/decode/index.d.ts.map +1 -1
  13. package/dist/decode/index.js +8 -5
  14. package/dist/decode/index.js.map +1 -1
  15. package/dist/decode/message.d.ts +2 -0
  16. package/dist/decode/message.d.ts.map +1 -1
  17. package/dist/decode/message.js +59 -33
  18. package/dist/decode/message.js.map +1 -1
  19. package/dist/decode/other.d.ts +20 -2
  20. package/dist/decode/other.d.ts.map +1 -1
  21. package/dist/decode/other.js +110 -48
  22. package/dist/decode/other.js.map +1 -1
  23. package/dist/decode/positioned.d.ts +26 -2
  24. package/dist/decode/positioned.d.ts.map +1 -1
  25. package/dist/decode/positioned.js +145 -79
  26. package/dist/decode/positioned.js.map +1 -1
  27. package/dist/decode/reports.d.ts.map +1 -1
  28. package/dist/decode/reports.js +47 -5
  29. package/dist/decode/reports.js.map +1 -1
  30. package/dist/decoder.d.ts.map +1 -1
  31. package/dist/decoder.js +2 -4
  32. package/dist/decoder.js.map +1 -1
  33. package/dist/deviceid.d.ts +8 -2
  34. package/dist/deviceid.d.ts.map +1 -1
  35. package/dist/deviceid.js +28 -6
  36. package/dist/deviceid.js.map +1 -1
  37. package/dist/encode/common.d.ts +13 -2
  38. package/dist/encode/common.d.ts.map +1 -1
  39. package/dist/encode/common.js +36 -4
  40. package/dist/encode/common.js.map +1 -1
  41. package/dist/encode/index.d.ts.map +1 -1
  42. package/dist/encode/index.js +59 -22
  43. package/dist/encode/index.js.map +1 -1
  44. package/dist/encode/other.d.ts.map +1 -1
  45. package/dist/encode/other.js +45 -17
  46. package/dist/encode/other.js.map +1 -1
  47. package/dist/encode/position.d.ts +9 -3
  48. package/dist/encode/position.d.ts.map +1 -1
  49. package/dist/encode/position.js +258 -124
  50. package/dist/encode/position.js.map +1 -1
  51. package/dist/header.d.ts +5 -2
  52. package/dist/header.d.ts.map +1 -1
  53. package/dist/header.js +8 -3
  54. package/dist/header.js.map +1 -1
  55. package/dist/index.d.ts +1 -1
  56. package/dist/index.d.ts.map +1 -1
  57. package/dist/index.js.map +1 -1
  58. package/dist/neutral.d.ts.map +1 -1
  59. package/dist/neutral.js +22 -3
  60. package/dist/neutral.js.map +1 -1
  61. package/dist/types.d.ts +19 -9
  62. package/dist/types.d.ts.map +1 -1
  63. package/package.json +1 -1
  64. package/src/builder.ts +77 -14
  65. package/src/bytes.ts +2 -1
  66. package/src/codes.ts +8 -8
  67. package/src/decode/index.ts +7 -4
  68. package/src/decode/message.ts +54 -37
  69. package/src/decode/other.ts +116 -44
  70. package/src/decode/positioned.ts +159 -90
  71. package/src/decode/reports.ts +47 -4
  72. package/src/decoder.ts +2 -5
  73. package/src/deviceid.ts +29 -5
  74. package/src/encode/common.ts +34 -4
  75. package/src/encode/index.ts +57 -20
  76. package/src/encode/other.ts +39 -13
  77. package/src/encode/position.ts +261 -119
  78. package/src/header.ts +9 -2
  79. package/src/index.ts +1 -0
  80. package/src/neutral.ts +24 -3
  81. package/src/types.ts +20 -5
@@ -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
- export const KNOTS_TO_MPH = 1.150779448;
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
- if (rawLatMinutes >= 60 || latDegrees > 90 || (latDegrees === 90 && rawLatMinutes > 0)) ctx.fail('invalid-latitude');
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
- const lonBlanks = ambiguityDigits([lon[3]!, lon[4]!, lon[6]!, lon[7]!]);
178
- if (lonBlanks === undefined || lonBlanks > latAmbiguity) ctx.fail('invalid-longitude');
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
- if (rawLonMinutes >= 60 || lonDegrees > 180 || (lonDegrees === 180 && rawLonMinutes > 0)) ctx.fail('invalid-longitude');
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
- 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;
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
- /** Lifts base-91 telemetry and the last `!DAO!` outside it. */
627
- function liftTelemetryAndDao(s: string): { telemetry?: CommentTelemetry; dao?: DaoFound; rest: string } {
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
- const out: { telemetry?: CommentTelemetry; dao?: DaoFound; rest: string } = { rest: removeRanges(s, ranges) };
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.extensionAtStart) {
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
- const brace = /\{([^{}]{1,3})\}/.exec(s);
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 (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
- }
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
- /** Parses the run of weather fields from `j`. */
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
- let windKnown = options.wind.present;
792
- let windFromFields = false;
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 (or first thing in a positionless report), else snowfall.
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
- const dots = /^\.+/.exec(s.slice(j + 1))?.[0];
811
- if (dots !== undefined) {
812
- if (dots.length !== 3) ctx.tolerate('non-standard-weather-field-width');
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 += 1 + dots.length;
879
+ j += 4;
815
880
  continue;
816
881
  }
817
- if (!/^[0-9.]{3}$/.test(v) || !/[0-9]/.test(v)) break;
818
- w.snow24hIn = Number(v);
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 += 4;
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) ctx.tolerate('non-standard-weather-field-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) ctx.tolerate('out-of-range-value');
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) ctx.tolerate('out-of-range-value');
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
- // Completeness: wind, and gust and temperature.
953
+ const hasDirection = seen.has('c');
954
+ const hasSpeed = seen.has('s:wind');
955
+ const windFromFields = hasDirection || hasSpeed;
894
956
  if (options.positionless) {
895
- if (!seen.has('c') || !seen.has('s:wind') || !seen.has('g') || !seen.has('t')) ctx.tolerate('incomplete-weather');
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
- // 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');
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. One leading
908
- * delimiter is dropped from a position's comment, not from a positionless report's.
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
- dropDelimiter = true,
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 (dropDelimiter && (text[0] === ' ' || text[0] === '/')) text = text.slice(1);
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
- 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);
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
- f.dfBearing = {
1028
- bearingDegrees: Number(df[1]),
1029
- number: Number(df[2]),
1030
- range: Number(df[3]),
1031
- quality: Number(df[4]),
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
- 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);
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(ctx: DecodeContext, text: string, symbol: AprsSymbol): { lifted: Lifted } {
1152
- const lifted = liftComment(ctx, text, { extensionFound: false, extensionAtStart: true, symbol });
1153
- return { lifted };
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). */
@@ -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
- if (minutes >= 60 || degrees > 90 || (degrees === 90 && minutes > 0)) ctx.fail('invalid-mic-e-destination');
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 = /^([A-Ra-r]{2}[0-9]{2}(?:[A-Xa-x]{2})?)\/G/.exec(text);
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
- const { lifted } = liftMicEComment(ctx, text, pos.symbol);
366
- applyLiftedTo(ctx, f, lifted);
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 { identifyMicE, identifyTocall } from './deviceid.js';
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
  }