@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.
Files changed (61) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/README.md +2 -2
  3. package/dist/bytes.d.ts.map +1 -1
  4. package/dist/bytes.js +2 -1
  5. package/dist/bytes.js.map +1 -1
  6. package/dist/codes.js +8 -8
  7. package/dist/codes.js.map +1 -1
  8. package/dist/decode/index.js +3 -5
  9. package/dist/decode/index.js.map +1 -1
  10. package/dist/decode/message.d.ts +2 -0
  11. package/dist/decode/message.d.ts.map +1 -1
  12. package/dist/decode/message.js +59 -33
  13. package/dist/decode/message.js.map +1 -1
  14. package/dist/decode/other.d.ts +20 -2
  15. package/dist/decode/other.d.ts.map +1 -1
  16. package/dist/decode/other.js +110 -48
  17. package/dist/decode/other.js.map +1 -1
  18. package/dist/decode/positioned.d.ts +10 -2
  19. package/dist/decode/positioned.d.ts.map +1 -1
  20. package/dist/decode/positioned.js +129 -77
  21. package/dist/decode/positioned.js.map +1 -1
  22. package/dist/decode/reports.d.ts.map +1 -1
  23. package/dist/decode/reports.js +24 -4
  24. package/dist/decode/reports.js.map +1 -1
  25. package/dist/deviceid.d.ts.map +1 -1
  26. package/dist/deviceid.js +18 -4
  27. package/dist/deviceid.js.map +1 -1
  28. package/dist/encode/index.d.ts.map +1 -1
  29. package/dist/encode/index.js +29 -12
  30. package/dist/encode/index.js.map +1 -1
  31. package/dist/encode/other.d.ts.map +1 -1
  32. package/dist/encode/other.js +35 -15
  33. package/dist/encode/other.js.map +1 -1
  34. package/dist/encode/position.d.ts +4 -2
  35. package/dist/encode/position.d.ts.map +1 -1
  36. package/dist/encode/position.js +58 -22
  37. package/dist/encode/position.js.map +1 -1
  38. package/dist/header.d.ts +5 -2
  39. package/dist/header.d.ts.map +1 -1
  40. package/dist/header.js +8 -3
  41. package/dist/header.js.map +1 -1
  42. package/dist/neutral.d.ts.map +1 -1
  43. package/dist/neutral.js +2 -0
  44. package/dist/neutral.js.map +1 -1
  45. package/dist/types.d.ts +6 -4
  46. package/dist/types.d.ts.map +1 -1
  47. package/package.json +1 -1
  48. package/src/bytes.ts +2 -1
  49. package/src/codes.ts +8 -8
  50. package/src/decode/index.ts +3 -4
  51. package/src/decode/message.ts +54 -37
  52. package/src/decode/other.ts +116 -44
  53. package/src/decode/positioned.ts +133 -88
  54. package/src/decode/reports.ts +22 -3
  55. package/src/deviceid.ts +17 -2
  56. package/src/encode/index.ts +30 -12
  57. package/src/encode/other.ts +27 -11
  58. package/src/encode/position.ts +54 -20
  59. package/src/header.ts +9 -2
  60. package/src/neutral.ts +2 -0
  61. package/src/types.ts +6 -4
@@ -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,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
- /** Lifts base-91 telemetry and the last `!DAO!` outside it. */
627
- function liftTelemetryAndDao(s: string): { telemetry?: CommentTelemetry; dao?: DaoFound; rest: string } {
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.extensionAtStart) {
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
- const brace = /\{([^{}]{1,3})\}/.exec(s);
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 (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
- }
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
- /** Parses the run of weather fields from `j`. */
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
- let windKnown = options.wind.present;
792
- let windFromFields = false;
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 (or first thing in a positionless report), else snowfall.
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
- const dots = /^\.+/.exec(s.slice(j + 1))?.[0];
811
- if (dots !== undefined) {
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 += 1 + dots.length;
857
+ j += 4;
815
858
  continue;
816
859
  }
817
- if (!/^[0-9.]{3}$/.test(v) || !/[0-9]/.test(v)) break;
818
- w.snow24hIn = Number(v);
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 += 4;
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) ctx.tolerate('non-standard-weather-field-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) ctx.tolerate('out-of-range-value');
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) ctx.tolerate('out-of-range-value');
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
- // Completeness: wind, and gust and temperature.
931
+ const hasDirection = seen.has('c');
932
+ const hasSpeed = seen.has('s:wind');
933
+ const windFromFields = hasDirection || hasSpeed;
894
934
  if (options.positionless) {
895
- if (!seen.has('c') || !seen.has('s:wind') || !seen.has('g') || !seen.has('t')) ctx.tolerate('incomplete-weather');
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
- // 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');
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. One leading
908
- * delimiter is dropped from a position's comment, not from a positionless report's.
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
- dropDelimiter = true,
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 (dropDelimiter && (text[0] === ' ' || text[0] === '/')) text = text.slice(1);
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
- 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);
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
- f.dfBearing = {
1028
- bearingDegrees: Number(df[1]),
1029
- number: Number(df[2]),
1030
- range: Number(df[3]),
1031
- quality: Number(df[4]),
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(ctx: DecodeContext, text: string, symbol: AprsSymbol): { lifted: Lifted } {
1152
- const lifted = liftComment(ctx, text, { extensionFound: false, extensionAtStart: true, symbol });
1153
- return { lifted };
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). */
@@ -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
- if (minutes >= 60 || degrees > 90 || (degrees === 90 && minutes > 0)) ctx.fail('invalid-mic-e-destination');
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 { lifted } = liftMicEComment(ctx, text, pos.symbol);
366
- applyLiftedTo(ctx, f, lifted);
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
- for (const t of compile()) if (t.regex.test(call)) return toInfo(t.entry);
54
- return undefined;
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
  /**
@@ -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, and if that
4
- // does not help either the encoder refuses.
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 = original.length > 0 ? original : encodeInformation(data.packet.data).info;
104
- return { binary: encodeThirdParty(data, bytesToBinary(inner)) };
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
- /** Where the written data would read back differently: strings, booleans and which fields are present. */
118
- function shapeDifferences(a: unknown, b: unknown, path: string, out: string[]): void {
119
- if (typeof a === 'number' && typeof b === 'number') return;
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
- shapeDifferences(toNeutralData(decoded.data), toNeutralData(expected(data, a)), '', problems);
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
- for (const mode of ['joined', 'delimited'] as const) {
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);