@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
@@ -55,7 +55,8 @@ export function decodeStatus(ctx: DecodeContext, s: string): StatusReport {
55
55
  }
56
56
  }
57
57
  }
58
- const beam = /\^([0-9A-Z])([0-9:;<=>?@A-K])$/.exec(body);
58
+ // The ERP codes run 1-9, : to @, then A-K; there is no 0 (APRS12c ch. 16).
59
+ const beam = /\^([0-9A-Z])([1-9:;<=>?@A-K])$/.exec(body);
59
60
  if (beam) {
60
61
  out.beam = { headingCode: beam[1]!, powerCode: beam[2]! };
61
62
  body = body.slice(0, beam.index);
@@ -130,6 +131,10 @@ export function decodeTelemetry(ctx: DecodeContext, s: string): TelemetryReport
130
131
  return out;
131
132
  }
132
133
 
134
+ const FOOTPRINT_NUMBER = '[0-9]+\\.?[0-9]*|\\.[0-9]+';
135
+ const FOOTPRINT_VALUE = `( ?(?:${FOOTPRINT_NUMBER})|-(?:${FOOTPRINT_NUMBER}))`;
136
+ const FOOTPRINT_RE = new RegExp(`^${FOOTPRINT_VALUE},${FOOTPRINT_VALUE},([0-9]{4})$`);
137
+
133
138
  /** `?`: a general query. */
134
139
  export function decodeQuery(ctx: DecodeContext, s: string): Query {
135
140
  const m = /^\?([A-Z]+)\?(.*)$/s.exec(s);
@@ -137,98 +142,164 @@ export function decodeQuery(ctx: DecodeContext, s: string): Query {
137
142
  const out: { -readonly [K in keyof Query]: Query[K] } = { type: 'query', queryType: m[1]! };
138
143
  const rest = m[2]!;
139
144
  if (rest.length > 0) {
140
- const f = /^ ?(-?[0-9]+(?:\.[0-9]*)?),(-?[0-9]+(?:\.[0-9]*)?),([0-9]{4})$/.exec(rest);
145
+ // Decimal degrees, each a number as a telemetry value is (`-.1715`), a positive one with or
146
+ // without a leading space, and a 4-digit radius.
147
+ const f = FOOTPRINT_RE.exec(rest);
141
148
  if (!f) ctx.fail('invalid-general-query');
142
- out.footprint = { latitude: Number(f[1]), longitude: Number(f[2]), radiusMiles: Number(f[3]) };
149
+ const latitude = Number(f[1]);
150
+ const longitude = Number(f[2]);
151
+ // A footprint is a real place (vectors interpretations.md).
152
+ if (Math.abs(latitude) > 90 || Math.abs(longitude) > 180) ctx.fail('invalid-general-query');
153
+ out.footprint = { latitude, longitude, radiusMiles: Number(f[3]) };
143
154
  }
144
155
  return out;
145
156
  }
146
157
 
158
+ /** Drops U+0020 spaces, and no other character, from both ends. */
159
+ function trimSpaces(text: string): string {
160
+ let a = 0;
161
+ let b = text.length;
162
+ while (a < b && text[a] === ' ') a++;
163
+ while (b > a && text[b - 1] === ' ') b--;
164
+ return text.slice(a, b);
165
+ }
166
+
167
+ /** A control character: below U+0020, or U+007F. */
168
+ const CONTROL_RE = /[\x00-\x1f\x7f]/;
169
+
147
170
  /** `<`: station capabilities. */
148
171
  export function decodeCapabilities(ctx: DecodeContext, s: string): Capabilities {
149
172
  const text = ctx.text(s.slice(1));
150
173
  const capabilities: Capability[] = [];
151
174
  let freeText = false;
152
175
  for (const raw of text.split(',')) {
153
- const item = raw.trim();
176
+ // Spaces around an item, a token or a value are padding; nothing else is (vectors
177
+ // interpretations.md, "Station capabilities: items, tokens and values").
178
+ const item = trimSpaces(raw);
154
179
  if (item.length === 0) continue; // empty items are skipped
155
180
  const eq = item.indexOf('=');
156
- const token = eq < 0 ? item : item.slice(0, eq);
157
- if (/\s/.test(token) || token.length === 0) freeText = true;
158
- capabilities.push(eq < 0 ? [item] : [token, item.slice(eq + 1)]);
181
+ const token = eq < 0 ? item : trimSpaces(item.slice(0, eq));
182
+ const value = eq < 0 ? undefined : trimSpaces(item.slice(eq + 1));
183
+ if (token.length === 0 || token.includes(' ') || CONTROL_RE.test(token)) freeText = true;
184
+ if (value !== undefined && CONTROL_RE.test(value)) freeText = true;
185
+ capabilities.push(value === undefined ? [token] : [token, value]);
159
186
  }
160
187
  if (capabilities.length === 0) ctx.fail('invalid-capabilities');
161
188
  if (freeText) ctx.tolerate('free-text-capabilities');
162
189
  return { type: 'capabilities', capabilities };
163
190
  }
164
191
 
165
- function nmeaDegrees(value: string | undefined, hemisphere: string | undefined, width: number): number | undefined {
192
+ /** A coordinate: at least three digits before an optional `.` and fraction, the last two minutes. */
193
+ function nmeaDegrees(value: string | undefined, hemisphere: string | undefined, limit: number): number | undefined {
166
194
  if (value === undefined || hemisphere === undefined) return undefined;
167
- if (!/^[0-9]+(?:\.[0-9]*)?$/.test(value)) return undefined;
168
- const dot = value.indexOf('.');
169
- const intPart = dot < 0 ? value : value.slice(0, dot);
170
- if (intPart.length < 3) return undefined;
171
- const degrees = Number(intPart.slice(0, intPart.length - 2));
172
- const minutes = Number(value.slice(intPart.length - 2));
195
+ const m = /^([0-9]+)([0-9]{2}(?:\.[0-9]*)?)$/.exec(value);
196
+ if (!m) return undefined;
197
+ const degrees = Number(m[1]);
198
+ const minutes = Number(m[2]);
173
199
  if (minutes >= 60) return undefined;
174
200
  const v = degrees + minutes / 60;
175
- if (v > width) return undefined;
176
- if (hemisphere === 'S' || hemisphere === 'W') return v === 0 ? 0 : -v;
177
- if (hemisphere === 'N' || hemisphere === 'E') return v;
201
+ if (v > limit) return undefined;
202
+ if (hemisphere === (limit === 90 ? 'S' : 'W')) return v === 0 ? 0 : -v;
203
+ if (hemisphere === (limit === 90 ? 'N' : 'E')) return v;
178
204
  return undefined;
179
205
  }
180
206
 
207
+ /** `hhmmss` with an optional fraction, as `HH:MM:SS` and the fraction as sent, less trailing zeros. */
181
208
  function nmeaTime(value: string | undefined): string | undefined {
182
209
  if (value === undefined) return undefined;
183
210
  const m = /^([0-9]{2})([0-9]{2})([0-9]{2})(?:\.([0-9]*))?$/.exec(value);
184
211
  if (!m) return undefined;
212
+ if (Number(m[1]) > 23 || Number(m[2]) > 59 || Number(m[3]) > 59) return undefined;
185
213
  let t = `${m[1]}:${m[2]}:${m[3]}`;
186
214
  const fraction = (m[4] ?? '').replace(/0+$/, '');
187
215
  if (fraction.length > 0) t += `.${fraction}`;
188
216
  return t;
189
217
  }
190
218
 
219
+ /**
220
+ * An optional `-`, then digits with an optional `.` and fraction. NMEA 0183 makes leading zeros
221
+ * optional, so a number may start with its decimal point (`.30`).
222
+ */
191
223
  function nmeaNumber(value: string | undefined): number | undefined {
192
- if (value === undefined || !/^-?(?:[0-9]+\.?[0-9]*|\.[0-9]+)$/.test(value)) return undefined;
224
+ if (value === undefined || !/^-?(?:[0-9]+(?:\.[0-9]*)?|\.[0-9]+)$/.test(value)) return undefined;
193
225
  return Number(value);
194
226
  }
195
227
 
196
- /** Verifies an NMEA sentence's checksum; `undefined` when it has none. */
197
- export function nmeaChecksumOk(sentence: string): boolean | undefined {
198
- const m = /\*([0-9A-Fa-f]{2})$/.exec(sentence);
199
- if (!m) return undefined;
200
- let x = 0;
201
- for (let k = 0; k < m.index; k++) x ^= sentence.charCodeAt(k);
202
- return x === parseInt(m[1]!, 16);
228
+ const NMEA_ADDRESS_RE = /^(?:[A-Z0-9]{5}|P[A-Z0-9]{3,})$/;
229
+
230
+ /** An NMEA 0183 sentence taken apart. */
231
+ export interface NmeaParts {
232
+ /** The sentence, up to and including any `*hh` checksum. */
233
+ sentence: string;
234
+ /** The comma-separated fields before any checksum, the address field first. */
235
+ fields: string[];
236
+ /** Whether the sentence has a checksum. */
237
+ hasChecksum: boolean;
238
+ /** Whether the checksum matches; true when there is none. */
239
+ checksumOk: boolean;
240
+ /** Whatever follows the checksum. */
241
+ rest: string;
242
+ }
243
+
244
+ /**
245
+ * Takes the text after `$` apart as an NMEA 0183 sentence (vectors interpretations.md, "What $
246
+ * text is an NMEA sentence"): printable ASCII, an address field of five upper-case letters or
247
+ * digits (or `P` and three or more), at least one field, no `$`, and no `*` but the one that
248
+ * starts a checksum of two hex digits, which ends the sentence. `undefined` when it is not one.
249
+ */
250
+ export function splitNmea(text: string): NmeaParts | undefined {
251
+ const star = text.indexOf('*');
252
+ const hasChecksum = star >= 0;
253
+ if (hasChecksum && !/^[0-9A-Fa-f]{2}$/.test(text.slice(star + 1, star + 3))) return undefined;
254
+ const body = hasChecksum ? text.slice(0, star) : text;
255
+ if (!/^[\x20-\x7e]*$/.test(body) || body.includes('$')) return undefined;
256
+ const fields = body.split(',');
257
+ if (fields.length < 2 || !NMEA_ADDRESS_RE.test(fields[0]!)) return undefined;
258
+ const end = hasChecksum ? star + 3 : text.length;
259
+ let checksumOk = true;
260
+ if (hasChecksum) {
261
+ let x = 0;
262
+ for (let k = 0; k < body.length; k++) x ^= body.charCodeAt(k);
263
+ checksumOk = x === parseInt(text.slice(star + 1, star + 3), 16);
264
+ }
265
+ return { sentence: text.slice(0, end), fields, hasChecksum, checksumOk, rest: text.slice(end) };
203
266
  }
204
267
 
205
268
  /** `$`: raw NMEA, or Ultimeter raw weather (`$ULTW`). */
206
269
  export function decodeDollar(ctx: DecodeContext, s: string): NmeaSentence | RawWeather {
207
270
  if (s.startsWith('$ULTW')) return decodeRawWeather(ctx, s.slice(5), 'ultimeter-packet');
208
271
  ctx.info('obsolete-format');
209
- const sentence = s.slice(1);
210
- if (!/^[A-Z]{5},[\x20-\x7e]*$/.test(sentence)) ctx.fail('invalid-nmea');
211
- const checksum = nmeaChecksumOk(sentence);
212
- if (checksum === false) ctx.fail('nmea-checksum-mismatch');
213
- const body = checksum === undefined ? sentence : sentence.slice(0, sentence.length - 3);
214
- const f = body.split(',');
215
- const out: { -readonly [K in keyof NmeaSentence]: NmeaSentence[K] } = { type: 'nmea', sentence };
216
- if (checksum !== undefined) out.hasChecksum = true;
272
+ // The structure is checked before the checksum.
273
+ const parts = splitNmea(s.slice(1));
274
+ if (!parts) return ctx.fail('invalid-nmea');
275
+ if (!parts.checksumOk) ctx.fail('nmea-checksum-mismatch');
276
+ const f = parts.fields;
277
+ const out: { -readonly [K in keyof NmeaSentence]: NmeaSentence[K] } = { type: 'nmea', sentence: parts.sentence };
278
+ if (parts.hasChecksum) out.hasChecksum = true;
217
279
  const set = <K extends keyof NmeaSentence>(key: K, value: NmeaSentence[K] | undefined): void => {
218
280
  if (value !== undefined) out[key] = value;
219
281
  };
220
- switch (f[0]!.slice(2)) {
282
+ // A position needs both coordinates.
283
+ const position = (lat: number, lon: number): void => {
284
+ const latitude = nmeaDegrees(f[lat], f[lat + 1], 90);
285
+ const longitude = nmeaDegrees(f[lon], f[lon + 1], 180);
286
+ if (latitude === undefined || longitude === undefined) return;
287
+ out.latitude = latitude;
288
+ out.longitude = longitude;
289
+ };
290
+ // Only an approved address (five characters, not P) has a sentence formatter.
291
+ const address = f[0]!;
292
+ const formatter = address.length === 5 && address[0] !== 'P' ? address.slice(2) : '';
293
+ switch (formatter) {
221
294
  case 'GGA': {
222
- set('latitude', nmeaDegrees(f[2], f[3], 90));
223
- set('longitude', nmeaDegrees(f[4], f[5], 180));
295
+ position(2, 4);
224
296
  if (f[6] !== undefined && /^[0-9]$/.test(f[6])) out.fix = f[6] === '0' ? 'invalid' : 'valid';
225
297
  set('altitudeM', nmeaNumber(f[9]));
226
298
  set('time', nmeaTime(f[1]));
227
299
  break;
228
300
  }
229
301
  case 'GLL': {
230
- set('latitude', nmeaDegrees(f[1], f[2], 90));
231
- set('longitude', nmeaDegrees(f[3], f[4], 180));
302
+ position(1, 3);
232
303
  set('time', nmeaTime(f[5]));
233
304
  if (f[6] === 'A') out.fix = 'valid';
234
305
  else if (f[6] === 'V') out.fix = 'invalid';
@@ -238,8 +309,7 @@ export function decodeDollar(ctx: DecodeContext, s: string): NmeaSentence | RawW
238
309
  set('time', nmeaTime(f[1]));
239
310
  if (f[2] === 'A') out.fix = 'valid';
240
311
  else if (f[2] === 'V') out.fix = 'invalid';
241
- set('latitude', nmeaDegrees(f[3], f[4], 90));
242
- set('longitude', nmeaDegrees(f[5], f[6], 180));
312
+ position(3, 5);
243
313
  set('speedKnots', nmeaNumber(f[7]));
244
314
  set('courseDegrees', nmeaNumber(f[8]));
245
315
  break;
@@ -250,12 +320,13 @@ export function decodeDollar(ctx: DecodeContext, s: string): NmeaSentence | RawW
250
320
  break;
251
321
  }
252
322
  case 'WPL': {
253
- set('latitude', nmeaDegrees(f[1], f[2], 90));
254
- set('longitude', nmeaDegrees(f[3], f[4], 180));
323
+ position(1, 3);
255
324
  if (f[5] !== undefined && f[5].length > 0) out.waypoint = f[5];
256
325
  break;
257
326
  }
258
327
  }
328
+ // Text after the checksum is a comment, kept as sent.
329
+ if (parts.rest.length > 0) out.comment = ctx.text(parts.rest);
259
330
  return out;
260
331
  }
261
332
 
@@ -287,8 +358,9 @@ export function decodeTest(ctx: DecodeContext, s: string): TestData {
287
358
 
288
359
  /** `%`: an Agrelo DFJr / MicroFinder report. */
289
360
  export function decodeAgrelo(ctx: DecodeContext, s: string): AgreloDf {
290
- const m = /^%([0-9]{3})\/([0-9])/.exec(s);
291
- if (!m) ctx.fail('invalid-agrelo-df');
361
+ // Exactly %, a bearing of 000 to 360, / and a quality digit (vectors interpretations.md).
362
+ const m = /^%([0-9]{3})\/([0-9])$/.exec(s);
363
+ if (!m || Number(m[1]) > 360) return ctx.fail('invalid-agrelo-df');
292
364
  return { type: 'agrelo-df', bearingDegrees: Number(m[1]), quality: Number(m[2]) };
293
365
  }
294
366