@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
@@ -2,8 +2,9 @@
2
2
  // telemetry, queries, capabilities, and the rest.
3
3
 
4
4
  import { encodeUtf8Binary } from '../bytes.js';
5
- import { nmeaChecksumOk } from '../decode/other.js';
6
- import { formatPath } from '../header.js';
5
+ import { DIRECTED_QUERY_TYPES } from '../decode/message.js';
6
+ import { splitNmea } from '../decode/other.js';
7
+ import { formatPath, isAprsIsAddress } from '../header.js';
7
8
  import type {
8
9
  Ack,
9
10
  AgreloDf,
@@ -124,7 +125,10 @@ export function encodeDirectedQuery(d: DirectedQuery): string {
124
125
  let out = `:${addressee(d.addressee)}:?${d.queryType}`;
125
126
  if (d.target !== undefined) {
126
127
  if (!/^[A-Za-z0-9-]{1,9}$/.test(d.target)) refuse('a query target is one callsign');
127
- out += d.target;
128
+ // A type the spec defines is followed straight by the target, an APRSH target padded to 9
129
+ // characters; any other type has no fixed length, so its target follows one space.
130
+ if (!DIRECTED_QUERY_TYPES.includes(d.queryType)) out += ` ${d.target}`;
131
+ else out += d.queryType === 'APRSH' ? d.target.padEnd(9, ' ') : d.target;
128
132
  }
129
133
  return out;
130
134
  }
@@ -150,7 +154,7 @@ export function encodeStatus(d: StatusReport): string {
150
154
  }
151
155
  out += freeText(text, 'status text');
152
156
  if (d.beam) {
153
- if (!/^[0-9A-Z]$/.test(d.beam.headingCode) || !/^[0-9:;<=>?@A-K]$/.test(d.beam.powerCode))
157
+ if (!/^[0-9A-Z]$/.test(d.beam.headingCode) || !/^[1-9:;<=>?@A-K]$/.test(d.beam.powerCode))
154
158
  refuse('beam heading is 0-9 or A-Z, and power 1-9 or :-K');
155
159
  out += `^${d.beam.headingCode}${d.beam.powerCode}`;
156
160
  }
@@ -186,10 +190,12 @@ export function encodeRawWeather(d: RawWeather): string {
186
190
  }
187
191
 
188
192
  export function encodeNmea(d: NmeaSentence): string {
189
- if (!/^[A-Z]{5},[\x20-\x7e]*$/.test(d.sentence)) refuse('not an NMEA sentence');
190
- const checksum = nmeaChecksumOk(d.sentence);
191
- if (checksum === false) refuse("the NMEA sentence's checksum does not match");
192
- return `$${d.sentence}`;
193
+ const parts = splitNmea(d.sentence);
194
+ if (!parts || parts.rest.length > 0) refuse('not an NMEA 0183 sentence');
195
+ if (!parts.checksumOk) refuse("the NMEA sentence's checksum does not match");
196
+ const comment = d.comment ?? '';
197
+ if (comment.length > 0 && !parts.hasChecksum) refuse('a comment after an NMEA sentence needs the checksum that ends the sentence');
198
+ return `$${d.sentence}${freeText(comment, 'the comment')}`;
193
199
  }
194
200
 
195
201
  export function encodeMaidenhead(d: MaidenheadBeacon): string {
@@ -209,14 +215,20 @@ export function encodeQuery(d: Query): string {
209
215
  return out;
210
216
  }
211
217
 
218
+ /** A control character: below U+0020, or U+007F. */
219
+ const CONTROL_RE = /[\x00-\x1f\x7f]/;
220
+
212
221
  export function encodeCapabilities(d: Capabilities): string {
213
222
  if (d.capabilities.length === 0) refuse('capabilities need at least one item');
223
+ // Anything that would not read back the same (vectors interpretations.md, "Station
224
+ // capabilities: items, tokens and values") is refused.
214
225
  const items = d.capabilities.map((c) => {
215
226
  const token = c[0];
216
- if (token.length === 0 || /[\s,=]/.test(token)) refuse('a capability token has no space, comma or =');
227
+ if (token.length === 0 || /[ ,=]/.test(token) || CONTROL_RE.test(token)) refuse('a capability token is not empty and has no space, control character, comma or =');
217
228
  if (c.length === 1) return token;
218
229
  const value = c[1]!;
219
- if (value.includes(',') || /^\s|\s$/.test(value)) refuse('a capability value has no comma or edge spaces');
230
+ if (value.includes(',') || CONTROL_RE.test(value) || value.startsWith(' ') || value.endsWith(' '))
231
+ refuse('a capability value has no comma or control character, and does not start or end with a space');
220
232
  return `${token}=${value}`;
221
233
  });
222
234
  return '<' + encodeUtf8Binary(items.join(','));
@@ -241,7 +253,11 @@ export function encodeAgrelo(d: AgreloDf): string {
241
253
  /** The third-party header and the encapsulated packet's information field. */
242
254
  export function encodeThirdParty(d: ThirdParty, innerInfo: string): string {
243
255
  const p = d.packet;
256
+ // The source is 1-9 printable ASCII characters other than > and : (APRS12c ch. 17); the
257
+ // destination and path are APRS-IS addresses.
258
+ if (!/^[\x20-\x39\x3b-\x3d\x3f-\x7e]{1,9}$/.test(p.source)) refuse('a third-party source is 1-9 printable ASCII characters other than > and :');
259
+ if (!isAprsIsAddress(p.destination)) refuse('the third-party destination is not a valid address');
260
+ for (const e of p.path) if (!isAprsIsAddress(e.address)) refuse(`the third-party path entry ${JSON.stringify(e.address)} is not a valid address`);
244
261
  const header = p.path.length > 0 ? `${p.source}>${p.destination},${formatPath(p.path)}` : `${p.source}>${p.destination}`;
245
- if (!/^[\x21-\x7e]+$/.test(header) || header.includes(':')) refuse('the third-party header is not printable');
246
262
  return `}${header}:${innerInfo}`;
247
263
  }
@@ -109,6 +109,18 @@ function compressedPosition(f: PositionedFields, cs: string | undefined, compres
109
109
  return table + base91(y, 4) + base91(x, 4) + f.symbol.code + tail;
110
110
  }
111
111
 
112
+ /**
113
+ * A GGA altitude in the cs bytes: 1.002^cs feet, so the nearest value they hold (1 foot for 0 feet
114
+ * or below), and whether that is exact. When it is not, a `/A=` altitude carries it and wins.
115
+ */
116
+ function ggaAltitude(feet: number): { cs: string; exact: boolean } {
117
+ if (!Number.isFinite(feet)) refuse('altitude is not a number');
118
+ const v = feet <= 1 ? 0 : Math.round(Math.log(feet) / Math.log(1.002));
119
+ if (v > 91 * 91 - 1) refuse('altitude is too high for the compressed format');
120
+ const cs = String.fromCharCode(Math.floor(v / 91) + 33) + String.fromCharCode((v % 91) + 33);
121
+ return { cs, exact: Math.abs(Math.pow(1.002, v) - feet) <= 1e-9 * Math.abs(feet) };
122
+ }
123
+
112
124
  // ---- data extensions and comment elements
113
125
 
114
126
  function courseSpeed(course: number | undefined, speed: number | undefined, what = 'course'): string {
@@ -191,14 +203,20 @@ export function telemetryText(t: CommentTelemetry): string {
191
203
  const v = (n: number, what: string): string => base91(whole(n, 0, 8280, what), 2);
192
204
  let out = '|' + v(t.sequence, 'telemetry sequence');
193
205
  for (const a of t.analog) out += v(a, 'telemetry value');
194
- if (t.digital !== undefined) out += v(t.digital, 'telemetry bits');
206
+ // Eight binary channels; bits 9-13 are reserved.
207
+ if (t.digital !== undefined) out += base91(whole(t.digital, 0, 255, 'telemetry bits'), 2);
195
208
  return out + '|';
196
209
  }
197
210
 
198
211
  function daoText(f: PositionedFields, latRemainder: number, lonRemainder: number, applied: boolean): string {
199
212
  const dao = f.dao!;
200
213
  const datum = dao.datum;
201
- if (!/^[A-Za-z]$/.test(datum)) refuse('the !DAO! datum is a letter');
214
+ if (/^[0-9]$/.test(datum)) {
215
+ // A local datum digit has no case to give a precision, so it only goes with spaces.
216
+ if (dao.precision !== 'none') refuse('a !DAO! datum digit carries no added precision');
217
+ return `!${datum} !`;
218
+ }
219
+ if (!/^[A-Za-z]$/.test(datum)) refuse('the !DAO! datum is a letter or a digit');
202
220
  switch (dao.precision) {
203
221
  case 'none':
204
222
  return `!${datum.toUpperCase()} !`;
@@ -226,6 +244,22 @@ function weatherValue(v: number | undefined, width: number, min: number, max: nu
226
244
  return pad(n, width);
227
245
  }
228
246
 
247
+ /**
248
+ * Snowfall written exactly in its three characters, with a decimal point where it needs one
249
+ * (0.32 as `.32`), and refused when they cannot hold it.
250
+ */
251
+ function snowfallText(inches: number): string {
252
+ if (!Number.isFinite(inches) || inches < 0) refuse('snowfall is not a number of inches');
253
+ let text: string;
254
+ if (Number.isInteger(inches)) text = pad(whole(inches, 0, 999, 'snowfall'), 3);
255
+ else if (String(inches).length === 3) text = String(inches);
256
+ else text = inches < 1 ? '.' + pad(Math.round(inches * 100), 2) : '';
257
+ if (!/^[0-9.]{3}$/.test(text) || /\..*\./.test(text) || Math.abs(Number(text) - inches) > 1e-9) {
258
+ refuse(`snowfall ${inches} does not fit in 3 characters`);
259
+ }
260
+ return text;
261
+ }
262
+
229
263
  /** Weather fields after the wind: gust, temperature, rain, humidity, pressure, and on. */
230
264
  function weatherFields(w: Weather, mandatory: boolean): string {
231
265
  let out = '';
@@ -243,12 +277,7 @@ function weatherFields(w: Weather, mandatory: boolean): string {
243
277
  const l = whole(w.luminosityWM2, 0, 1999, 'luminosity');
244
278
  out += l < 1000 ? 'L' + pad(l, 3) : 'l' + pad(l - 1000, 3);
245
279
  }
246
- if (w.snow24hIn !== undefined) {
247
- const s = w.snow24hIn;
248
- let text = Number.isInteger(s) ? pad(whole(s, 0, 999, 'snowfall'), 3) : String(s);
249
- if (text.length !== 3 || !/^[0-9.]{3}$/.test(text)) text = refuse('snowfall is 3 characters');
250
- out += 's' + text;
251
- }
280
+ if (w.snow24hIn !== undefined) out += 's' + snowfallText(w.snow24hIn);
252
281
  if (w.rainRaw !== undefined) out += '#' + pad(whole(w.rainRaw, 0, 999, 'raw rain counter'), 3);
253
282
  for (const e of w.extra ?? []) {
254
283
  if (!/^[A-Za-z]$/.test(e.letter) || 'csgtrpPhbLl'.includes(e.letter)) refuse(`${e.letter} is not an extra weather field letter`);
@@ -296,9 +325,11 @@ function hasExtension(f: PositionedFields): string[] {
296
325
  /**
297
326
  * How the comment is joined on: `plain` (after a space when it follows a frequency), `joined`
298
327
  * (straight after a frequency, with no space) or `delimited` (after a `/`), for when it would
299
- * otherwise read as something else.
328
+ * otherwise read as something else; or `braces-first`, as `plain` but with the signpost or
329
+ * corridor braces written before the comment, for a comment holding braces that would be taken
330
+ * for them (the first well-formed braces are the signpost).
300
331
  */
301
- export type CommentMode = 'plain' | 'joined' | 'delimited';
332
+ export type CommentMode = 'plain' | 'joined' | 'delimited' | 'braces-first';
302
333
 
303
334
  /** Encodes a position and what follows it. */
304
335
  function positionedBody(f: PositionedFields, mode: CommentMode): Body {
@@ -342,11 +373,9 @@ function positionedBody(f: PositionedFields, mode: CommentMode): Body {
342
373
  cs = '{' + String.fromCharCode(s + 33);
343
374
  compression = compression ?? DEFAULT_COMPRESSION;
344
375
  } else if (f.altitudeFeet !== undefined && compression?.source === 'gga') {
345
- if (f.altitudeFeet < 1) refuse('a compressed altitude is at least 1 foot');
346
- const v = Math.round(Math.log(f.altitudeFeet) / Math.log(1.002));
347
- if (v > 91 * 91 - 1) refuse('altitude is too high for the compressed format');
348
- cs = String.fromCharCode(Math.floor(v / 91) + 33) + String.fromCharCode((v % 91) + 33);
349
- altitudeInCs = Math.abs(Math.pow(1.002, v) - f.altitudeFeet) <= 1e-9 * f.altitudeFeet;
376
+ const alt = ggaAltitude(f.altitudeFeet);
377
+ cs = alt.cs;
378
+ altitudeInCs = alt.exact;
350
379
  } else if (compression !== undefined) {
351
380
  refuse('a compression type needs cs data (course/speed, range or altitude)');
352
381
  }
@@ -393,7 +422,8 @@ function positionedBody(f: PositionedFields, mode: CommentMode): Body {
393
422
  if (f.frequency) text += (extensionEnded && !out.endsWith('/') ? '/' : '') + frequencyText(f.frequency);
394
423
  let braces = '';
395
424
  if (f.signpost !== undefined) {
396
- if (!/^[\x21-\x7c\x7e]{1,3}$/.test(f.signpost) || f.signpost.includes('{') || f.signpost.includes('}')) refuse('a signpost is 1-3 printable characters');
425
+ // Printable ASCII, a space included, other than the braces around it.
426
+ if (!/^[\x20-\x7a\x7c\x7e]{1,3}$/.test(f.signpost)) refuse('a signpost is 1-3 printable ASCII characters other than { and }');
397
427
  braces = `{${f.signpost}}`;
398
428
  }
399
429
  if (f.area?.corridorWidthMiles !== undefined) {
@@ -405,7 +435,7 @@ function positionedBody(f: PositionedFields, mode: CommentMode): Body {
405
435
  if (text.length > 0 && mode !== 'joined') text += ' ';
406
436
  text += (mode === 'delimited' ? '/' : '') + freeText(comment, 'the comment');
407
437
  }
408
- text += braces;
438
+ text = mode === 'braces-first' ? braces + text : text + braces;
409
439
  if (f.telemetry) text += telemetryText(f.telemetry);
410
440
  if (f.dao) text += daoText(f, latRem, lonRem, daoApplied);
411
441
  const body: Body = { text: out + text };
@@ -448,9 +478,9 @@ function weatherBody(f: PositionedFields): Body {
448
478
  compression = compression ?? DEFAULT_COMPRESSION;
449
479
  if (compression.source === 'gga') refuse('a GGA compression type means the cs bytes hold altitude');
450
480
  } else if (f.altitudeFeet !== undefined && compression?.source === 'gga') {
451
- const v = Math.round(Math.log(f.altitudeFeet) / Math.log(1.002));
452
- cs = String.fromCharCode(Math.floor(v / 91) + 33) + String.fromCharCode((v % 91) + 33);
453
- altitudeInCs = Math.abs(Math.pow(1.002, v) - f.altitudeFeet) <= 1e-9 * f.altitudeFeet;
481
+ const alt = ggaAltitude(f.altitudeFeet);
482
+ cs = alt.cs;
483
+ altitudeInCs = alt.exact;
454
484
  } else if (f.rangeMiles !== undefined) {
455
485
  if (compression?.source === 'gga') refuse('a GGA compression type means the cs bytes hold altitude');
456
486
  const s = Math.round(Math.log(f.rangeMiles / 2) / Math.log(1.08));
@@ -656,6 +686,10 @@ export function encodeMicE(d: MicEReport, mode: CommentMode): { text: string; de
656
686
  if (d.typeCode === undefined) refuse('a Mic-E device suffix needs a type code');
657
687
  text += d.deviceSuffix;
658
688
  }
689
+ // Mic-E status text must not start with 0x1D, which would be taken for Rev 0 telemetry
690
+ // (APRS12c ch. 10), so a comment that would start it is written after a /.
691
+ const legacy = d.legacyTelemetry !== undefined && d.legacyTelemetry.length > 0;
692
+ if (!legacy && text.startsWith('\x1d') && mode !== 'delimited') return encodeMicE(d, 'delimited');
659
693
  info += text;
660
694
  return { text: info, destination };
661
695
  }
package/src/header.ts CHANGED
@@ -71,17 +71,24 @@ export function parseTnc2Header(line: Uint8Array, options: ParseOptions): Parsed
71
71
  }
72
72
  }
73
73
 
74
- /** Parses `SOURCE>DEST,PATH` (without the colon). Used for TNC2 lines and third-party headers. */
74
+ /** A third-party source: 1-9 printable ASCII characters (APRS12c ch. 17); `>` and `:` end it. */
75
+ const THIRD_PARTY_SOURCE = /^[\x20-\x7e]{1,9}$/;
76
+
77
+ /**
78
+ * Parses `SOURCE>DEST,PATH` (without the colon). Used for TNC2 lines and third-party headers,
79
+ * whose source need not be an APRS-IS address (`thirdParty`).
80
+ */
75
81
  export function parseHeaderText(
76
82
  header: string,
77
83
  options: ParseOptions,
78
84
  diags: Diagnostic[],
85
+ thirdParty = false,
79
86
  ): { source: string; destination: string; path: PathEntry[] } {
80
87
  const gt = header.indexOf('>');
81
88
  if (gt < 0) fail(diags, 'invalid-header');
82
89
  const source = header.slice(0, gt);
83
90
  if (source.length === 0) fail(diags, 'invalid-header');
84
- if (!APRS_IS_ADDRESS.test(source)) fail(diags, 'invalid-address');
91
+ if (!(thirdParty ? THIRD_PARTY_SOURCE : APRS_IS_ADDRESS).test(source)) fail(diags, 'invalid-address');
85
92
  const parts = header.slice(gt + 1).split(',');
86
93
  const destination = parts[0]!;
87
94
  if (destination.length === 0) tolerate(options, diags, 'empty-destination');
package/src/neutral.ts CHANGED
@@ -281,6 +281,7 @@ export function toNeutralData(d: AprsData): Neutral {
281
281
  put(o, 'altitude_m', d.altitudeM);
282
282
  put(o, 'time', d.time);
283
283
  put(o, 'waypoint', d.waypoint);
284
+ put(o, 'comment', d.comment);
284
285
  break;
285
286
  case 'maidenhead-beacon':
286
287
  put(o, 'locator', d.locator);
@@ -560,6 +561,7 @@ export function fromNeutralData(d: J): AprsData {
560
561
  opt(o, 'altitudeM', d.altitude_m);
561
562
  opt(o, 'time', d.time);
562
563
  opt(o, 'waypoint', d.waypoint);
564
+ opt(o, 'comment', d.comment);
563
565
  break;
564
566
  case 'maidenhead-beacon':
565
567
  o.locator = d.locator ?? '';
package/src/types.ts CHANGED
@@ -136,7 +136,7 @@ export type DaoPrecision = 'none' | 'thousandths' | 'base91';
136
136
 
137
137
  /** The `!DAO!` datum and precision (APRS12c ch. 5). */
138
138
  export interface Dao {
139
- /** The datum letter, upper case (`W` is WGS84). */
139
+ /** The datum: a letter, upper case (`W` is WGS84), or a digit for a local datum. */
140
140
  readonly datum: string;
141
141
  readonly precision: DaoPrecision;
142
142
  }
@@ -146,7 +146,7 @@ export interface CommentTelemetry {
146
146
  readonly sequence: number;
147
147
  /** One to five analog values, 0-8280. */
148
148
  readonly analog: readonly number[];
149
- /** The eight digital bits as a number, B1 the least significant bit. */
149
+ /** The eight binary channels as a number, 0-255, B1 the least significant bit. */
150
150
  readonly digital?: number;
151
151
  }
152
152
 
@@ -419,7 +419,7 @@ export interface RawWeather {
419
419
  /** A raw NMEA sentence (`$`). */
420
420
  export interface NmeaSentence {
421
421
  readonly type: 'nmea';
422
- /** The sentence without the `$`. */
422
+ /** The sentence without the `$`, up to and including any `*hh` checksum. */
423
423
  readonly sentence: string;
424
424
  readonly hasChecksum?: boolean;
425
425
  readonly latitude?: number;
@@ -428,9 +428,11 @@ export interface NmeaSentence {
428
428
  readonly courseDegrees?: number;
429
429
  readonly speedKnots?: number;
430
430
  readonly altitudeM?: number;
431
- /** `HH:MM:SS`, with any fraction of a second. */
431
+ /** `HH:MM:SS`, with any fraction of a second as sent. */
432
432
  readonly time?: string;
433
433
  readonly waypoint?: string;
434
+ /** Text after the checksum (TinyTrack and FreeTrak send one), as sent. */
435
+ readonly comment?: string;
434
436
  }
435
437
 
436
438
  /** A Maidenhead locator beacon (`[`), obsolete. */