@packet-net/pdn-aprs 0.2.0 → 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 (62) hide show
  1. package/CHANGELOG.md +29 -1
  2. package/README.md +8 -4
  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/decode/index.d.ts.map +1 -1
  8. package/dist/decode/index.js +5 -0
  9. package/dist/decode/index.js.map +1 -1
  10. package/dist/decode/other.js +1 -1
  11. package/dist/decode/other.js.map +1 -1
  12. package/dist/decode/positioned.d.ts +17 -1
  13. package/dist/decode/positioned.d.ts.map +1 -1
  14. package/dist/decode/positioned.js +21 -7
  15. package/dist/decode/positioned.js.map +1 -1
  16. package/dist/decode/reports.d.ts.map +1 -1
  17. package/dist/decode/reports.js +34 -12
  18. package/dist/decode/reports.js.map +1 -1
  19. package/dist/decoder.d.ts.map +1 -1
  20. package/dist/decoder.js +2 -4
  21. package/dist/decoder.js.map +1 -1
  22. package/dist/deviceid.d.ts +8 -2
  23. package/dist/deviceid.d.ts.map +1 -1
  24. package/dist/deviceid.js +10 -2
  25. package/dist/deviceid.js.map +1 -1
  26. package/dist/encode/common.d.ts +13 -2
  27. package/dist/encode/common.d.ts.map +1 -1
  28. package/dist/encode/common.js +36 -4
  29. package/dist/encode/common.js.map +1 -1
  30. package/dist/encode/index.d.ts.map +1 -1
  31. package/dist/encode/index.js +36 -16
  32. package/dist/encode/index.js.map +1 -1
  33. package/dist/encode/other.d.ts.map +1 -1
  34. package/dist/encode/other.js +10 -2
  35. package/dist/encode/other.js.map +1 -1
  36. package/dist/encode/position.d.ts +9 -5
  37. package/dist/encode/position.d.ts.map +1 -1
  38. package/dist/encode/position.js +219 -121
  39. package/dist/encode/position.js.map +1 -1
  40. package/dist/index.d.ts +1 -1
  41. package/dist/index.d.ts.map +1 -1
  42. package/dist/index.js.map +1 -1
  43. package/dist/neutral.d.ts.map +1 -1
  44. package/dist/neutral.js +20 -3
  45. package/dist/neutral.js.map +1 -1
  46. package/dist/types.d.ts +13 -5
  47. package/dist/types.d.ts.map +1 -1
  48. package/package.json +1 -1
  49. package/src/builder.ts +77 -14
  50. package/src/decode/index.ts +4 -0
  51. package/src/decode/other.ts +1 -1
  52. package/src/decode/positioned.ts +33 -9
  53. package/src/decode/reports.ts +33 -9
  54. package/src/decoder.ts +2 -5
  55. package/src/deviceid.ts +12 -3
  56. package/src/encode/common.ts +34 -4
  57. package/src/encode/index.ts +33 -14
  58. package/src/encode/other.ts +12 -2
  59. package/src/encode/position.ts +227 -119
  60. package/src/index.ts +1 -0
  61. package/src/neutral.ts +22 -3
  62. package/src/types.ts +14 -1
package/src/builder.ts CHANGED
@@ -13,6 +13,8 @@
13
13
  // `build()` encodes the packet, so anything the spec does not allow is refused there, with an
14
14
  // `AprsEncodeError` that says why.
15
15
 
16
+ import { nearest, refuse } from './encode/common.js';
17
+ import { ALTITUDE_GIVEN_IN_FEET } from './encode/position.js';
16
18
  import { encodeInformation, encodeAx25, encodeTnc2, encodeTnc2Bytes, wrapKiss, type PacketToEncode } from './encode/index.js';
17
19
  import { symbolOf } from './symbols.js';
18
20
  import type {
@@ -74,6 +76,36 @@ function built(header: Header, data: AprsData): BuiltPacket {
74
76
  });
75
77
  }
76
78
 
79
+ /**
80
+ * Weather as a program may have it: the units APRS sends (Fahrenheit, inches), or Celsius and
81
+ * millimetres, which the builder converts (vectors rulings, B2). Give each value in one unit.
82
+ */
83
+ export interface WeatherInput extends Weather {
84
+ readonly temperatureC?: number;
85
+ readonly rain1hMm?: number;
86
+ readonly rain24hMm?: number;
87
+ readonly rainMidnightMm?: number;
88
+ }
89
+
90
+ const MM_PER_INCH = 25.4;
91
+
92
+ /** Weather in the units APRS sends; a value in two units is refused. */
93
+ function weatherOf(input: WeatherInput): Weather {
94
+ const { temperatureC, rain1hMm, rain24hMm, rainMidnightMm, ...rest } = input;
95
+ const w: Mutable<Weather> = { ...rest };
96
+ const convert = (metric: number | undefined, key: 'temperatureF' | 'rain1hIn' | 'rain24hIn' | 'rainMidnightIn', f: (v: number) => number): void => {
97
+ if (metric === undefined) return;
98
+ if (w[key] !== undefined) refuse(`weather ${key} is given in two units`);
99
+ w[key] = f(metric);
100
+ };
101
+ // Converted here and rounded once, when the report is written.
102
+ convert(temperatureC, 'temperatureF', (c) => (c * 9) / 5 + 32);
103
+ convert(rain1hMm, 'rain1hIn', (mm) => mm / MM_PER_INCH);
104
+ convert(rain24hMm, 'rain24hIn', (mm) => mm / MM_PER_INCH);
105
+ convert(rainMidnightMm, 'rainMidnightIn', (mm) => mm / MM_PER_INCH);
106
+ return w;
107
+ }
108
+
77
109
  function asSymbol(symbol: AprsSymbol | string): AprsSymbol {
78
110
  return typeof symbol === 'string' ? symbolOf(symbol) : symbol;
79
111
  }
@@ -122,7 +154,7 @@ export class PacketBuilder {
122
154
  }
123
155
 
124
156
  /** A weather report at a position (the weather station symbol, unless you pick another `_`). */
125
- weather(latitude: number, longitude: number, weather: Weather): PositionBuilder {
157
+ weather(latitude: number, longitude: number, weather: WeatherInput): PositionBuilder {
126
158
  return new PositionBuilder(this.header, latitude, longitude).symbol('/_').weather(weather);
127
159
  }
128
160
 
@@ -166,10 +198,13 @@ export class PacketBuilder {
166
198
  return new StatusBuilder(this.header, text);
167
199
  }
168
200
 
169
- /** A telemetry report: sequence, five analog values and eight bits (`'01101001'`, B1 first). */
201
+ /**
202
+ * A telemetry report: sequence, five analog values and eight bits (`'01101001'`, B1 first). A
203
+ * sequence given as a number is 0-999, written in three digits; a larger one is refused when
204
+ * the packet is built, not wrapped (vectors rulings, B6).
205
+ */
170
206
  telemetry(sequence: number | string, analog: readonly (number | null)[], bits: string): TelemetryBuilder {
171
- const seq = typeof sequence === 'number' ? String(sequence).padStart(3, '0') : sequence;
172
- return new TelemetryBuilder(this.header, seq, analog, bits);
207
+ return new TelemetryBuilder(this.header, sequence, analog, bits);
173
208
  }
174
209
 
175
210
  /** Telemetry channel names (`PARM.`), sent to the station whose telemetry they describe. */
@@ -195,7 +230,7 @@ export class PacketBuilder {
195
230
  }
196
231
 
197
232
  /** A positionless weather report (not recommended: a weather report with a position is better). */
198
- weatherReport(weather: Weather, at: Date = new Date()): DataBuilder {
233
+ weatherReport(weather: WeatherInput, at: Date = new Date()): DataBuilder {
199
234
  const timestamp: AprsTimestamp = {
200
235
  kind: 'mdhm',
201
236
  month: at.getUTCMonth() + 1,
@@ -203,7 +238,7 @@ export class PacketBuilder {
203
238
  hour: at.getUTCHours(),
204
239
  minute: at.getUTCMinutes(),
205
240
  };
206
- return new DataBuilder(this.header, { type: 'weather', timestamp, weather });
241
+ return new DataBuilder(this.header, { type: 'weather', timestamp, weather: weatherOf(weather) });
207
242
  }
208
243
 
209
244
  /** Any data, built by hand. */
@@ -250,7 +285,11 @@ abstract class PositionedBuilder<Self> {
250
285
  return this.self();
251
286
  }
252
287
 
253
- /** Course over the ground, degrees 1-360 (360 is north). */
288
+ /**
289
+ * Course over the ground, degrees: 360 is north and 0 is unknown, as APRS has it (vectors
290
+ * rulings, B1). A course of 0 is written `000` in an uncompressed report, left out of a Mic-E
291
+ * report, and refused in a compressed one, whose bytes have no unknown course.
292
+ */
254
293
  course(degrees: number): Self {
255
294
  this.fields.courseDegrees = degrees;
256
295
  return this.self();
@@ -262,18 +301,32 @@ abstract class PositionedBuilder<Self> {
262
301
  return this.self();
263
302
  }
264
303
 
265
- /** Altitude, feet. */
304
+ /** Speed, km/h (sent in knots, 1.852 km/h each, rounded once to the knot). */
305
+ speedKmh(kmh: number): Self {
306
+ this.fields.speedKnots = kmh / 1.852;
307
+ return this.self();
308
+ }
309
+
310
+ /** Altitude, feet, sent as a `/A=` in whole feet (rounded to the nearest, halves away from zero). */
266
311
  altitude(feet: number): Self {
267
312
  this.fields.altitudeFeet = feet;
313
+ this.altitudeUnit = 'feet';
268
314
  return this.self();
269
315
  }
270
316
 
271
- /** Altitude, metres (sent in feet, rounded). */
317
+ /**
318
+ * Altitude, metres: converted to feet (0.3048 m each) and rounded once for a `/A=`; a Mic-E
319
+ * report sends it in its own altitude bytes, whose unit is the metre, rounded to the metre.
320
+ */
272
321
  altitudeMetres(metres: number): Self {
273
- this.fields.altitudeFeet = Math.round(metres / 0.3048);
322
+ this.fields.altitudeFeet = metres / 0.3048;
323
+ this.altitudeUnit = 'metres';
274
324
  return this.self();
275
325
  }
276
326
 
327
+ /** The unit the altitude was given in. */
328
+ protected altitudeUnit: 'feet' | 'metres' | undefined;
329
+
277
330
  /** Free text. */
278
331
  comment(text: string): Self {
279
332
  this.fields.comment = text;
@@ -324,8 +377,8 @@ abstract class ReportBuilder<Self> extends PositionedBuilder<Self> {
324
377
  }
325
378
 
326
379
  /** Weather data (needs a weather station symbol, `_`). */
327
- weather(weather: Weather): Self {
328
- this.fields.weather = weather;
380
+ weather(weather: WeatherInput): Self {
381
+ this.fields.weather = weatherOf(weather);
329
382
  return this.self();
330
383
  }
331
384
 
@@ -487,6 +540,11 @@ export class MicEBuilder extends PositionedBuilder<MicEBuilder> {
487
540
 
488
541
  build(): BuiltPacket {
489
542
  const data: Mutable<Extract<AprsData, { type: 'mic-e' }>> = { type: 'mic-e', micEMessage: this.messageValue, ...this.fields };
543
+ // A course of 0 (after rounding) is unknown, which Mic-E writes as 0: no course (B1).
544
+ if (data.courseDegrees !== undefined && Number.isFinite(data.courseDegrees) && nearest(data.courseDegrees) === 0) delete data.courseDegrees;
545
+ // Metres go in the Mic-E altitude bytes, to the metre; feet in a /A= (B2).
546
+ if (data.altitudeFeet !== undefined && this.altitudeUnit === 'metres') data.altitudeFeet = nearest(data.altitudeFeet * 0.3048) / 0.3048;
547
+ if (data.altitudeFeet !== undefined && this.altitudeUnit === 'feet') ALTITUDE_GIVEN_IN_FEET.add(data);
490
548
  if (this.typeCodeValue !== undefined) data.typeCode = this.typeCodeValue;
491
549
  if (this.locatorValue !== undefined) data.locator = this.locatorValue;
492
550
  if (this.oldValue) data.oldData = true;
@@ -589,7 +647,7 @@ export class TelemetryBuilder {
589
647
 
590
648
  constructor(
591
649
  private readonly header: Header,
592
- private readonly sequence: string,
650
+ private readonly sequence: number | string,
593
651
  private readonly analog: readonly (number | null)[],
594
652
  private readonly bits: string,
595
653
  ) {}
@@ -601,9 +659,14 @@ export class TelemetryBuilder {
601
659
  }
602
660
 
603
661
  build(): BuiltPacket {
662
+ let sequence = this.sequence;
663
+ if (typeof sequence === 'number') {
664
+ if (!Number.isInteger(sequence) || sequence < 0 || sequence > 999) refuse(`telemetry sequence ${sequence} is not 0-999`);
665
+ sequence = String(sequence).padStart(3, '0');
666
+ }
604
667
  const data: Mutable<Extract<AprsData, { type: 'telemetry' }>> = {
605
668
  type: 'telemetry',
606
- sequence: this.sequence,
669
+ sequence,
607
670
  analog: [...this.analog],
608
671
  bits: this.bits,
609
672
  };
@@ -2,6 +2,7 @@
2
2
 
3
3
  import { binaryToBytes, bytesToBinary } from '../bytes.js';
4
4
  import type { Diagnostic, ParseOptions } from '../diagnostics.js';
5
+ import { identifyDevice } from '../deviceid.js';
5
6
  import { parseHeaderText } from '../header.js';
6
7
  import type { AprsData, AprsPacket, ThirdParty } from '../types.js';
7
8
  import { DecodeContext, Rejected } from './context.js';
@@ -128,6 +129,9 @@ function decodeThirdParty(ctx: DecodeContext, s: string, options: ParseOptions):
128
129
  data: inner.data,
129
130
  diagnostics: [...headerDiags, ...inner.diagnostics],
130
131
  };
132
+ // The inner packet has its device too, from its own destination (vectors rulings, A2).
133
+ const device = identifyDevice(inner.data, header.destination);
134
+ if (device) packet.device = device;
131
135
  // A q-construct is read only in the outer header: the third-party path is kept as sent.
132
136
  return { type: 'third-party', packet };
133
137
  }
@@ -150,7 +150,7 @@ export function decodeQuery(ctx: DecodeContext, s: string): Query {
150
150
  const longitude = Number(f[2]);
151
151
  // A footprint is a real place (vectors interpretations.md).
152
152
  if (Math.abs(latitude) > 90 || Math.abs(longitude) > 180) ctx.fail('invalid-general-query');
153
- out.footprint = { latitude, longitude, radiusMiles: Number(f[3]) };
153
+ out.footprint = { latitude, longitude, radiusMiles: Number(f[3]), latitudeText: f[1]!, longitudeText: f[2]! };
154
154
  }
155
155
  return out;
156
156
  }
@@ -618,6 +618,15 @@ interface LiftOptions {
618
618
  symbol: AprsSymbol;
619
619
  /** An area object's line shape, whose corridor width may be in braces. */
620
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;
621
630
  }
622
631
 
623
632
  /** What the comment lifting found. */
@@ -650,10 +659,10 @@ function removeRanges(s: string, ranges: readonly [number, number][]): string {
650
659
  * Lifts base-91 telemetry and the last `!DAO!` outside it. A `!DAO!` is five bytes as sent, so one
651
660
  * is never read across a `join`, a place in `s` where another element was already taken out.
652
661
  */
653
- function liftTelemetryAndDao(
662
+ export function liftTelemetryAndDao(
654
663
  s: string,
655
664
  joins: readonly number[] = [],
656
- ): { telemetry?: CommentTelemetry; dao?: DaoFound; rest: string } {
665
+ ): { telemetry?: CommentTelemetry; dao?: DaoFound; rest: string; cuts: number[] } {
657
666
  const tel = findCommentTelemetry(s);
658
667
  let dao: DaoFound | undefined;
659
668
  for (let p = s.length - 5; p >= 0; p--) {
@@ -668,7 +677,14 @@ function liftTelemetryAndDao(
668
677
  const ranges: [number, number][] = [];
669
678
  if (tel) ranges.push([tel.start, tel.end]);
670
679
  if (dao) ranges.push([dao.start, dao.start + 5]);
671
- 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 };
672
688
  if (tel) out.telemetry = tel.telemetry;
673
689
  if (dao) out.dao = dao;
674
690
  return out;
@@ -678,7 +694,7 @@ const ALTITUDE_RE = /\/A=(-[0-9]{5}|[0-9]{6})/;
678
694
 
679
695
  /** Lifts the structured elements out of a comment, in the order the vectors' README gives. */
680
696
  function liftComment(ctx: DecodeContext, text: string, options: LiftOptions): Lifted {
681
- const first = liftTelemetryAndDao(text, options.joins);
697
+ const first = options.pre ? { ...options.pre, rest: text } : liftTelemetryAndDao(text, options.joins);
682
698
  const lifted: Lifted = { rest: first.rest };
683
699
  if (first.telemetry) lifted.telemetry = first.telemetry;
684
700
  if (first.dao) lifted.dao = first.dao;
@@ -857,6 +873,12 @@ function parseWeatherFields(ctx: DecodeContext, s: string, j: number, options: W
857
873
  j += 4;
858
874
  continue;
859
875
  }
876
+ // Exactly three spaces is a snowfall sent as unknown, as for any other field.
877
+ if (v === ' ') {
878
+ seen.add(key);
879
+ j += 4;
880
+ continue;
881
+ }
860
882
  // Only a run of dots may be shorter than three, and only when no digit follows it: s..6 is
861
883
  // not a field, so the fields end there.
862
884
  const dots = /^\.{1,3}/.exec(v)?.[0];
@@ -961,7 +983,7 @@ function weatherTail(
961
983
  rest: string,
962
984
  positioned: boolean,
963
985
  ): { comment?: string; telemetry?: CommentTelemetry; dao?: DaoFound } {
964
- const lifted = positioned ? liftTelemetryAndDao(rest) : { rest };
986
+ const lifted: PreLifted = positioned ? liftTelemetryAndDao(rest) : { rest };
965
987
  const out: { comment?: string; telemetry?: CommentTelemetry; dao?: DaoFound } = {};
966
988
  if (lifted.telemetry) out.telemetry = lifted.telemetry;
967
989
  if (lifted.dao) out.dao = lifted.dao;
@@ -1118,7 +1140,8 @@ function applyLifted(ctx: DecodeContext, f: Fields, lifted: Lifted): void {
1118
1140
  const STORM_TYPES: Record<string, StormType> = { TS: 'tropical-storm', HC: 'hurricane', TD: 'tropical-depression' };
1119
1141
 
1120
1142
  function parseStorm(s: string): { storm: Storm; length: number } | undefined {
1121
- 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);
1122
1145
  if (!m) return undefined;
1123
1146
  const num = (v: string | undefined): number | undefined => (v !== undefined && /^[0-9]+$/.test(v) ? Number(v) : undefined);
1124
1147
  const storm: { -readonly [K in keyof Storm]: Storm[K] } = { type: STORM_TYPES[m[1]!]! };
@@ -1190,11 +1213,12 @@ export function liftMicEComment(
1190
1213
  text: string,
1191
1214
  symbol: AprsSymbol,
1192
1215
  extension: ExtensionFound | undefined,
1193
- joins: readonly number[],
1216
+ pre: PreLifted,
1217
+ atStart = true,
1194
1218
  ): { lifted: Lifted } {
1195
1219
  const options: LiftOptions = extension
1196
- ? { extensionFound: true, extension, symbol, joins }
1197
- : { extensionFound: false, extensionAtStart: true, symbol, joins };
1220
+ ? { extensionFound: true, extension, symbol, pre }
1221
+ : { extensionFound: false, extensionAtStart: atStart, symbol, pre };
1198
1222
  return { lifted: liftComment(ctx, text, options) };
1199
1223
  }
1200
1224
 
@@ -20,6 +20,7 @@ import {
20
20
  isDigit,
21
21
  isSymbolTable,
22
22
  liftMicEComment,
23
+ liftTelemetryAndDao,
23
24
  METRES_PER_FOOT,
24
25
  parsePosition,
25
26
  parsePositionlessWeather,
@@ -257,6 +258,8 @@ function micEMinutes(tens: number, units: number, hundredths: number, ambiguity:
257
258
 
258
259
  const TYPE_CODES = '`\'>] ';
259
260
 
261
+ const MIC_E_LOCATOR = /^([A-Ra-r]{2}[0-9]{2}(?:[A-Xa-x]{2})?)\/G/;
262
+
260
263
  /** `` ` ``, `'`, 0x1c, 0x1d: a Mic-E report. */
261
264
  export function decodeMicE(ctx: DecodeContext, s: string): MicEReport {
262
265
  // The Rev 0 beta data type identifiers are obsolete: said at the DTI, before anything is checked.
@@ -348,6 +351,7 @@ export function decodeMicE(ctx: DecodeContext, s: string): MicEReport {
348
351
  }
349
352
  let altitudeFeet: number | undefined;
350
353
  let extension: ExtensionFound | undefined;
354
+ let extensionAfterLocator = false;
351
355
  // Where a later altitude was taken out of the text: a !DAO! is never read across it.
352
356
  let join: number | undefined;
353
357
  const altFirst = /^([!-{]{3})\}/.exec(text);
@@ -355,32 +359,52 @@ export function decodeMicE(ctx: DecodeContext, s: string): MicEReport {
355
359
  altitudeFeet = (base91Value(altFirst[1]!) - 10000) / METRES_PER_FOOT;
356
360
  text = text.slice(4);
357
361
  } 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);
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
+ }
362
372
  const later = /([!-{]{3})\}/.exec(text);
363
373
  if (later && ctx.allows('mic-e-altitude-not-first')) {
364
374
  altitudeFeet = (base91Value(later[1]!) - 10000) / METRES_PER_FOOT;
365
375
  text = text.slice(0, later.index) + text.slice(later.index + 4);
366
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');
367
384
  }
368
385
  }
369
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).
370
394
  let locator: string | undefined;
371
- 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);
372
396
  if (loc) {
373
397
  locator = loc[1]!.toUpperCase();
374
- const before = text.length;
375
398
  text = text.slice(loc[0].length);
376
399
  if (text.length > 0) {
377
400
  if (text[0] === ' ') text = text.slice(1);
378
401
  else ctx.tolerate('missing-space-after-locator');
379
402
  }
380
- if (join !== undefined) join -= before - text.length;
381
403
  }
382
- const joins = join !== undefined && join > 0 ? [join] : [];
383
- const { lifted } = liftMicEComment(ctx, text, pos.symbol, extension, joins);
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);
384
408
  // The !DAO! is structure, so it is judged before the text's encoding.
385
409
  applyDao(ctx, f, pos, lifted.dao);
386
410
  applyLiftedTo(ctx, f, lifted);
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
  }
package/src/deviceid.ts CHANGED
@@ -3,7 +3,7 @@
3
3
  // its type code and suffix.
4
4
 
5
5
  import { MICE, MICE_LEGACY, TOCALLS, type DeviceEntry } from './deviceid-data.js';
6
- import type { DeviceInfo } from './types.js';
6
+ import type { AprsData, DeviceInfo } from './types.js';
7
7
 
8
8
  export { DEVICEID_SOURCE, DEVICE_CLASSES } from './deviceid-data.js';
9
9
 
@@ -53,9 +53,13 @@ function compile(): CompiledTocall[] {
53
53
  const tocallCache = new Map<string, DeviceInfo | undefined>();
54
54
  const TOCALL_CACHE_LIMIT = 4096;
55
55
 
56
- /** The device a destination address (tocall) identifies, e.g. `APDW18` is Dire Wolf. */
56
+ /**
57
+ * The device a destination address (tocall) identifies, e.g. `APDW18` is Dire Wolf. The lookup is
58
+ * case-sensitive: the database lists tocalls in upper case and a destination is a callsign, so
59
+ * `APNl02` matches nothing (vectors rulings, A1).
60
+ */
57
61
  export function identifyTocall(destination: string): DeviceInfo | undefined {
58
- const call = destination.split('-')[0]!.toUpperCase();
62
+ const call = destination.split('-')[0]!;
59
63
  if (tocallCache.has(call)) return tocallCache.get(call);
60
64
  let found: DeviceInfo | undefined;
61
65
  for (const t of compile()) {
@@ -96,3 +100,8 @@ export function identifyMicE(typeCode: string | undefined, suffix: string | unde
96
100
  }
97
101
  return undefined;
98
102
  }
103
+
104
+ /** The device a packet identifies: by its Mic-E type code and suffix, else by its destination. */
105
+ export function identifyDevice(data: AprsData, destination: string): DeviceInfo | undefined {
106
+ return data.type === 'mic-e' ? identifyMicE(data.typeCode, data.deviceSuffix) : identifyTocall(destination);
107
+ }
@@ -19,10 +19,26 @@ export function pad(n: number, width: number): string {
19
19
  return String(n).padStart(width, '0');
20
20
  }
21
21
 
22
- /** A whole number within [min, max], else refused. */
22
+ /**
23
+ * The nearest whole number, halves away from zero, as the vectors' rules round wherever they say
24
+ * "nearest" (`Math.round` takes a half towards +infinity, so -2.5 to -2). A value within 1e-6 of a
25
+ * half is the half: decimal text such as 58.585 is seldom exactly that in binary, and the half the
26
+ * text means wins (vectors rulings, F2).
27
+ */
28
+ export function nearest(value: number): number {
29
+ const abs = Math.abs(value);
30
+ const below = Math.floor(abs);
31
+ const n = abs - below >= 0.5 - HALF_TOLERANCE ? below + 1 : below;
32
+ return value < 0 ? -n : n;
33
+ }
34
+
35
+ /** How near a half, in steps, a value is taken for the half. */
36
+ const HALF_TOLERANCE = 1e-6;
37
+
38
+ /** A whole number within [min, max], else refused; a fraction is rounded to the nearest. */
23
39
  export function whole(value: number, min: number, max: number, what: string): number {
24
40
  if (!Number.isFinite(value)) refuse(`${what} is not a number`);
25
- const n = Math.round(value);
41
+ const n = nearest(value);
26
42
  if (n < min || n > max) refuse(`${what} ${value} is out of range (${min}-${max})`);
27
43
  return n;
28
44
  }
@@ -80,8 +96,22 @@ export function base91(value: number, width: number): string {
80
96
  return out;
81
97
  }
82
98
 
83
- /** Formats a number as short text: `0.53`, `-32`, `1e-7`. */
99
+ /**
100
+ * A number as plain decimal text: the shortest that reads back as the same number, with no
101
+ * exponent and no `+`, a `0` before a leading point and no trailing `.0` (`0.53`, `-94`, 1e61 as
102
+ * `1` and 61 zeros, 1e-7 as `0.0000001`; vectors rulings, F11).
103
+ */
84
104
  export function numberText(v: number): string {
85
105
  if (!Number.isFinite(v)) refuse('a value is not a number');
86
- return String(v);
106
+ if (Object.is(v, -0)) return '0';
107
+ const shortest = String(v);
108
+ const m = /^(-?)([0-9])(?:\.([0-9]+))?e([+-][0-9]+)$/.exec(shortest);
109
+ if (!m) return shortest;
110
+ const sign = m[1]!;
111
+ const digits = m[2]! + (m[3] ?? '');
112
+ // The point sits after the first digit; the exponent moves it.
113
+ const point = 1 + Number(m[4]);
114
+ if (point <= 0) return `${sign}0.${'0'.repeat(-point)}${digits}`;
115
+ if (point >= digits.length) return sign + digits + '0'.repeat(point - digits.length);
116
+ return `${sign}${digits.slice(0, point)}.${digits.slice(point)}`;
87
117
  }
@@ -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, then the
4
- // signpost or corridor braces before the comment, and if none of that helps the encoder refuses.
3
+ // data (a comment that would read as an altitude, say), the comment is tried straight after a
4
+ // frequency, then after a `/` delimiter, and if neither helps the encoder refuses.
5
5
 
6
6
  import { binaryToBytes, bytesToBinary, decodeUtf8 } from '../bytes.js';
7
7
  import { decodeInformation } from '../decode/index.js';
@@ -30,7 +30,7 @@ import {
30
30
  encodeThirdParty,
31
31
  encodeUserDefined,
32
32
  } from './other.js';
33
- import { encodeItem, encodeMicE, encodeObject, encodePositionReport, encodeWeatherReport, type CommentMode } from './position.js';
33
+ import { DEFAULT_COMPRESSION, encodeItem, encodeMicE, encodeObject, encodePositionReport, encodeWeatherReport, type CommentMode } from './position.js';
34
34
 
35
35
  export { AprsEncodeError } from './common.js';
36
36
 
@@ -156,14 +156,19 @@ function expected(data: AprsData, a: Attempt): AprsData {
156
156
  let d = data;
157
157
  if ((d.type === 'position' || d.type === 'object' || d.type === 'item') && d.compressed && a.compression) {
158
158
  if (!d.compression) d = { ...d, compression: a.compression };
159
- if (d.courseDegrees !== undefined && d.speedKnots === undefined) d = { ...d, speedKnots: 0 };
160
159
  }
161
- if (d.type === 'mic-e') {
162
- if (d.speedKnots === undefined) d = { ...d, speedKnots: 0 };
163
- if (d.courseDegrees === 0) {
164
- const copy: Record<string, unknown> = { ...d };
165
- delete copy.courseDegrees;
166
- d = copy as unknown as AprsData;
160
+ if (d.type === 'mic-e' && d.speedKnots === undefined) d = { ...d, speedKnots: 0 };
161
+ if (d.type === 'third-party' && d.packet.information.length === 0) {
162
+ // An inner packet written from its data: cs values with no compression type got the default.
163
+ const inner = d.packet.data;
164
+ if ((inner.type === 'position' || inner.type === 'object' || inner.type === 'item') && inner.compressed && !inner.compression) {
165
+ const w = inner.weather;
166
+ const carries =
167
+ inner.courseDegrees !== undefined ||
168
+ inner.speedKnots !== undefined ||
169
+ inner.rangeMiles !== undefined ||
170
+ (w !== undefined && (w.windDirectionDegrees !== undefined || w.windSpeedMph !== undefined));
171
+ if (carries) d = { ...d, packet: { ...d.packet, data: { ...inner, compression: DEFAULT_COMPRESSION } } };
167
172
  }
168
173
  }
169
174
  return d;
@@ -179,10 +184,24 @@ function check(data: AprsData, a: Attempt): string[] {
179
184
  return problems;
180
185
  }
181
186
  // 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');
187
+ shapeDifferences(withoutInnerInfo(toNeutralData(decoded.data)), withoutInnerInfo(toNeutralData(expected(data, a))), '', problems, data.type === 'nmea');
183
188
  return problems;
184
189
  }
185
190
 
191
+ /**
192
+ * A third-party packet's neutral data without its inner packet's `info` diagnostics: an info is
193
+ * not a defect, so an inner packet that reads back with only those is written (vectors rulings, F10).
194
+ */
195
+ function withoutInnerInfo(n: Record<string, unknown>): Record<string, unknown> {
196
+ const packet = n.packet as Record<string, unknown> | undefined;
197
+ if (n.type !== 'third-party' || !packet || !Array.isArray(packet.diagnostics)) return n;
198
+ const kept = (packet.diagnostics as string[]).filter((d) => !d.startsWith('info:'));
199
+ const copy: Record<string, unknown> = { ...packet };
200
+ if (kept.length > 0) copy.diagnostics = kept;
201
+ else delete copy.diagnostics;
202
+ return { ...n, packet: copy };
203
+ }
204
+
186
205
  /**
187
206
  * Encodes data as an information field. Mic-E also gives the destination address it computed.
188
207
  * Throws `AprsEncodeError`, with the reason, for anything the spec does not allow.
@@ -192,9 +211,9 @@ export function encodeInformation(data: AprsData): EncodedInformation {
192
211
  let problems = check(data, first);
193
212
  let chosen = first;
194
213
  if (problems.length > 0 && typeof (data as { comment?: unknown }).comment === 'string') {
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) {
214
+ // The comment would read as something else: try it straight after a frequency, then after a
215
+ // /, then after a space.
216
+ for (const mode of ['joined', 'delimited', 'spaced'] as const) {
198
217
  let next: Attempt | undefined;
199
218
  try {
200
219
  next = attempt(data, mode);
@@ -209,12 +209,22 @@ export function encodeQuery(d: Query): string {
209
209
  if (d.footprint) {
210
210
  const f = d.footprint;
211
211
  if (!(f.latitude >= -90 && f.latitude <= 90) || !(f.longitude >= -180 && f.longitude <= 180)) refuse('the footprint is out of range');
212
- const coord = (v: number): string => (v < 0 ? numberText(v) : ` ${numberText(v)}`);
213
- out += `${coord(f.latitude)},${numberText(f.longitude)},${pad(whole(f.radiusMiles, 0, 9999, 'radius'), 4)}`;
212
+ // Each value as sent, a leading space or its absence included, when that is the value (vectors
213
+ // rulings, E1); otherwise one that is not negative, 0 included, after a space (APRS12c ch. 15;
214
+ // vectors rulings, F11, F15).
215
+ const asSent = (text: string | undefined, v: number): string | undefined =>
216
+ text !== undefined && FOOTPRINT_VALUE.test(text) && Number(text) === v ? text : undefined;
217
+ const value = (v: number): string => (v < 0 ? numberText(v) : ` ${numberText(v)}`);
218
+ const lat = asSent(f.latitudeText, f.latitude) ?? value(f.latitude);
219
+ const lon = asSent(f.longitudeText, f.longitude) ?? value(f.longitude);
220
+ out += `${lat},${lon},${pad(whole(f.radiusMiles, 0, 9999, 'radius'), 4)}`;
214
221
  }
215
222
  return out;
216
223
  }
217
224
 
225
+ /** A footprint value as a decoder reads it: a number as a telemetry value is, a positive one after at most one space. */
226
+ const FOOTPRINT_VALUE = /^(?: ?(?:[0-9]+\.?[0-9]*|\.[0-9]+)|-(?:[0-9]+\.?[0-9]*|\.[0-9]+))$/;
227
+
218
228
  /** A control character: below U+0020, or U+007F. */
219
229
  const CONTROL_RE = /[\x00-\x1f\x7f]/;
220
230