@packet-net/pdn-aprs 0.1.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. package/CHANGELOG.md +44 -1
  2. package/README.md +9 -5
  3. package/dist/builder.d.ts +33 -8
  4. package/dist/builder.d.ts.map +1 -1
  5. package/dist/builder.js +64 -10
  6. package/dist/builder.js.map +1 -1
  7. package/dist/bytes.d.ts.map +1 -1
  8. package/dist/bytes.js +2 -1
  9. package/dist/bytes.js.map +1 -1
  10. package/dist/codes.js +8 -8
  11. package/dist/codes.js.map +1 -1
  12. package/dist/decode/index.d.ts.map +1 -1
  13. package/dist/decode/index.js +8 -5
  14. package/dist/decode/index.js.map +1 -1
  15. package/dist/decode/message.d.ts +2 -0
  16. package/dist/decode/message.d.ts.map +1 -1
  17. package/dist/decode/message.js +59 -33
  18. package/dist/decode/message.js.map +1 -1
  19. package/dist/decode/other.d.ts +20 -2
  20. package/dist/decode/other.d.ts.map +1 -1
  21. package/dist/decode/other.js +110 -48
  22. package/dist/decode/other.js.map +1 -1
  23. package/dist/decode/positioned.d.ts +26 -2
  24. package/dist/decode/positioned.d.ts.map +1 -1
  25. package/dist/decode/positioned.js +145 -79
  26. package/dist/decode/positioned.js.map +1 -1
  27. package/dist/decode/reports.d.ts.map +1 -1
  28. package/dist/decode/reports.js +47 -5
  29. package/dist/decode/reports.js.map +1 -1
  30. package/dist/decoder.d.ts.map +1 -1
  31. package/dist/decoder.js +2 -4
  32. package/dist/decoder.js.map +1 -1
  33. package/dist/deviceid.d.ts +8 -2
  34. package/dist/deviceid.d.ts.map +1 -1
  35. package/dist/deviceid.js +28 -6
  36. package/dist/deviceid.js.map +1 -1
  37. package/dist/encode/common.d.ts +13 -2
  38. package/dist/encode/common.d.ts.map +1 -1
  39. package/dist/encode/common.js +36 -4
  40. package/dist/encode/common.js.map +1 -1
  41. package/dist/encode/index.d.ts.map +1 -1
  42. package/dist/encode/index.js +59 -22
  43. package/dist/encode/index.js.map +1 -1
  44. package/dist/encode/other.d.ts.map +1 -1
  45. package/dist/encode/other.js +45 -17
  46. package/dist/encode/other.js.map +1 -1
  47. package/dist/encode/position.d.ts +9 -3
  48. package/dist/encode/position.d.ts.map +1 -1
  49. package/dist/encode/position.js +258 -124
  50. package/dist/encode/position.js.map +1 -1
  51. package/dist/header.d.ts +5 -2
  52. package/dist/header.d.ts.map +1 -1
  53. package/dist/header.js +8 -3
  54. package/dist/header.js.map +1 -1
  55. package/dist/index.d.ts +1 -1
  56. package/dist/index.d.ts.map +1 -1
  57. package/dist/index.js.map +1 -1
  58. package/dist/neutral.d.ts.map +1 -1
  59. package/dist/neutral.js +22 -3
  60. package/dist/neutral.js.map +1 -1
  61. package/dist/types.d.ts +19 -9
  62. package/dist/types.d.ts.map +1 -1
  63. package/package.json +1 -1
  64. package/src/builder.ts +77 -14
  65. package/src/bytes.ts +2 -1
  66. package/src/codes.ts +8 -8
  67. package/src/decode/index.ts +7 -4
  68. package/src/decode/message.ts +54 -37
  69. package/src/decode/other.ts +116 -44
  70. package/src/decode/positioned.ts +159 -90
  71. package/src/decode/reports.ts +47 -4
  72. package/src/decoder.ts +2 -5
  73. package/src/deviceid.ts +29 -5
  74. package/src/encode/common.ts +34 -4
  75. package/src/encode/index.ts +57 -20
  76. package/src/encode/other.ts +39 -13
  77. package/src/encode/position.ts +261 -119
  78. package/src/header.ts +9 -2
  79. package/src/index.ts +1 -0
  80. package/src/neutral.ts +24 -3
  81. package/src/types.ts +20 -5
@@ -1,27 +1,59 @@
1
1
  // Encoding positions, objects, items and Mic-E reports, with their data extensions, weather and
2
- // comment elements, in the canonical order: extension, altitude, frequency, comment, base-91
3
- // telemetry, !DAO! (and for Mic-E, the device suffix last).
2
+ // comment elements, in the canonical order: extension, frequency, signpost or corridor braces,
3
+ // altitude, comment, base-91 telemetry, !DAO! (vectors README, "Encoding"). Mic-E status text
4
+ // keeps its own order: extension, altitude, frequency, comment, telemetry, !DAO!, device suffix.
4
5
  import { AREA_COLORS, AREA_SHAPES, KNOTS_TO_MPH, METRES_PER_FOOT, MICROWAVE_BASE } from '../decode/positioned.js';
5
- import { base91, checkSymbol, freeText, isValidTimestamp, pad, refuse, timestamp7, whole } from './common.js';
6
+ import { base91, checkSymbol, freeText, isValidTimestamp, nearest, pad, refuse, timestamp7, whole } from './common.js';
6
7
  const EPS = 1e-9;
8
+ const CUT_TOLERANCE = 1e-6;
7
9
  /** Negative, or negative zero: a zero coordinate keeps its hemisphere as the sign of zero. */
8
10
  export function isNegative(v) {
9
11
  return v < 0 || Object.is(v, -0);
10
12
  }
11
- /** Degrees and hundredths of a minute; truncated when a !DAO! or ambiguity follows, else rounded. */
12
- function digitsOf(value, truncate) {
13
+ /** Degrees and hundredths of a minute, and the `!DAO!` digit below them. */
14
+ function digitsOf(value, mode, ambiguity = 0) {
13
15
  const abs = Math.abs(value);
14
- let degrees = Math.floor(abs + EPS);
15
- let minutes = (abs - degrees) * 60;
16
- if (minutes < 0)
17
- minutes = 0;
18
- let hundredths = truncate ? Math.floor(minutes * 100 + EPS) : Math.round(minutes * 100);
16
+ // The ambiguity cut takes F2's tolerance: a value within 1e-6 of the box edge above it, in the
17
+ // box's units, is on that edge (vectors rulings, G2).
18
+ let degrees = Math.floor(abs + (mode === 'truncate' && ambiguity >= 4 ? CUT_TOLERANCE : EPS));
19
+ const minutes = Math.max(0, (abs - degrees) * 60);
20
+ let hundredths;
21
+ let extra = 0;
22
+ switch (mode) {
23
+ case 'round':
24
+ hundredths = nearest(minutes * 100);
25
+ break;
26
+ case 'truncate': {
27
+ // Hundredths of a minute in each box: a tenth, a minute, ten minutes, or a whole degree.
28
+ const box = ambiguity >= 4 ? 6000 : ambiguity === 3 ? 1000 : ambiguity === 2 ? 100 : ambiguity === 1 ? 10 : 1;
29
+ hundredths = ambiguity >= 4 ? 0 : Math.floor((minutes * 100) / box + (ambiguity > 0 ? CUT_TOLERANCE : EPS)) * box;
30
+ break;
31
+ }
32
+ case 'thousandths': {
33
+ const steps = nearest(minutes * 1000);
34
+ hundredths = Math.floor(steps / 10);
35
+ extra = steps % 10;
36
+ break;
37
+ }
38
+ case 'base91': {
39
+ const steps = nearest(minutes * 9100);
40
+ hundredths = Math.floor(steps / 91);
41
+ extra = steps % 91;
42
+ break;
43
+ }
44
+ }
19
45
  if (hundredths >= 6000) {
20
46
  degrees += 1;
21
47
  hundredths -= 6000;
22
48
  }
23
- const remainder = Math.max(0, minutes - hundredths / 100);
24
- return { degrees, hundredths, remainder };
49
+ return { degrees, hundredths, extra };
50
+ }
51
+ /** How a position's digits are found, given its ambiguity and `!DAO!`. */
52
+ function digitsMode(f) {
53
+ if ((f.ambiguity ?? 0) > 0)
54
+ return 'truncate';
55
+ const precision = f.dao?.precision;
56
+ return precision === 'thousandths' || precision === 'base91' ? precision : 'round';
25
57
  }
26
58
  function blank(text, positions, ambiguity) {
27
59
  const chars = text.split('');
@@ -29,8 +61,8 @@ function blank(text, positions, ambiguity) {
29
61
  chars[positions[positions.length - 1 - k]] = ' ';
30
62
  return chars.join('');
31
63
  }
32
- /** `ddmm.hhN/dddmm.hhW$`, with the DAO remainders. */
33
- function uncompressedPosition(f, truncate) {
64
+ /** `ddmm.hhN/dddmm.hhW$`, with the `!DAO!` digits. */
65
+ function uncompressedPosition(f) {
34
66
  if (!(f.latitude >= -90 && f.latitude <= 90))
35
67
  refuse(`latitude ${f.latitude} is out of range`);
36
68
  if (!(f.longitude >= -180 && f.longitude <= 180))
@@ -39,8 +71,9 @@ function uncompressedPosition(f, truncate) {
39
71
  if (!Number.isInteger(ambiguity) || ambiguity < 0 || ambiguity > 4)
40
72
  refuse('ambiguity is 0-4 digits');
41
73
  checkSymbol(f.symbol);
42
- const lat = digitsOf(f.latitude, truncate || ambiguity > 0);
43
- const lon = digitsOf(f.longitude, truncate || ambiguity > 0);
74
+ const mode = digitsMode(f);
75
+ const lat = digitsOf(f.latitude, mode, ambiguity);
76
+ const lon = digitsOf(f.longitude, mode, ambiguity);
44
77
  if (lat.degrees > 90 || (lat.degrees === 90 && lat.hundredths > 0))
45
78
  refuse('latitude is out of range');
46
79
  if (lon.degrees > 180 || (lon.degrees === 180 && lon.hundredths > 0))
@@ -51,8 +84,8 @@ function uncompressedPosition(f, truncate) {
51
84
  const lonText = blank(pad(lon.degrees, 3) + lonMin, [3, 4, 6, 7], ambiguity) + (isNegative(f.longitude) ? 'W' : 'E');
52
85
  return {
53
86
  text: latText + f.symbol.table + lonText + f.symbol.code,
54
- latRemainder: lat.remainder,
55
- lonRemainder: lon.remainder,
87
+ latExtra: lat.extra,
88
+ lonExtra: lon.extra,
56
89
  };
57
90
  }
58
91
  const SOURCES = ['other', 'gll', 'gga', 'rmc'];
@@ -65,7 +98,11 @@ function typeByte(c) {
65
98
  refuse('the compression type is not one APRS defines');
66
99
  return String.fromCharCode(((c.fix === 'current' ? 1 : 0) << 5) + (source << 3) + origin + 33);
67
100
  }
68
- /** The compressed position; `cs` is the two cs bytes or `undefined` for none. */
101
+ /**
102
+ * The compressed position; `cs` is the two cs bytes or `undefined` for none. Also gives the
103
+ * `!DAO!` digits for it: a decoder does not apply them, so they are the ones a `!DAO!` of that
104
+ * precision gives the position the bytes report, written uncompressed (vectors rulings, E2).
105
+ */
69
106
  function compressedPosition(f, cs, compression) {
70
107
  if (!(f.latitude >= -90 && f.latitude <= 90))
71
108
  refuse(`latitude ${f.latitude} is out of range`);
@@ -78,10 +115,64 @@ function compressedPosition(f, cs, compression) {
78
115
  if (table >= '0' && table <= '9')
79
116
  table = String.fromCharCode(table.charCodeAt(0) + 49); // 0-9 -> a-j
80
117
  const max = 91 ** 4 - 1;
81
- const y = Math.min(max, Math.max(0, Math.round(380926 * (90 - f.latitude))));
82
- const x = Math.min(max, Math.max(0, Math.round(190463 * (180 + f.longitude))));
118
+ const y = Math.min(max, Math.max(0, nearest(380926 * (90 - f.latitude))));
119
+ const x = Math.min(max, Math.max(0, nearest(190463 * (180 + f.longitude))));
83
120
  const tail = cs === undefined ? ' sT' : cs + typeByte(compression ?? DEFAULT_COMPRESSION);
84
- return table + base91(y, 4) + base91(x, 4) + f.symbol.code + tail;
121
+ const mode = digitsMode(f);
122
+ return {
123
+ text: table + base91(y, 4) + base91(x, 4) + f.symbol.code + tail,
124
+ latExtra: digitsOf(90 - y / 380926, mode).extra,
125
+ lonExtra: digitsOf(x / 190463 - 180, mode).extra,
126
+ };
127
+ }
128
+ /**
129
+ * A GGA altitude in the cs bytes: 1.002^cs feet, so the nearest value they hold (1 foot for 0 feet
130
+ * or below), and whether that is exact. When it is not, a `/A=` altitude carries it and wins.
131
+ */
132
+ function ggaAltitude(feet) {
133
+ if (!Number.isFinite(feet))
134
+ refuse('altitude is not a number');
135
+ const v = feet <= 1 ? 0 : nearest(Math.log(feet) / Math.log(1.002));
136
+ if (v > 91 * 91 - 1)
137
+ refuse('altitude is too high for the compressed format');
138
+ const cs = String.fromCharCode(Math.floor(v / 91) + 33) + String.fromCharCode((v % 91) + 33);
139
+ return { cs, exact: Math.abs(Math.pow(1.002, v) - feet) <= 1e-9 * Math.abs(feet) };
140
+ }
141
+ /**
142
+ * A course or wind direction in the cs bytes: 4 degree steps, rounded to the nearest (halves away
143
+ * from zero), and one that rounds to 360 is c = 0, north (vectors rulings, E9).
144
+ */
145
+ function compressedDirection(degrees) {
146
+ return String.fromCharCode((nearest(degrees / 4) % 90) + 33);
147
+ }
148
+ /**
149
+ * A speed in knots in the cs bytes: 1.08^s - 1, the nearest s for any whole s, refused when that
150
+ * s is outside the byte (0-90; 90 is `{`), so -0.03 knots is 0 and -0.3 is refused (vectors
151
+ * rulings, G3).
152
+ */
153
+ function compressedSpeed(knots, what) {
154
+ if (!(knots > -1))
155
+ refuse(`${what} has no compressed form`);
156
+ const s = nearest(Math.log(knots + 1) / Math.log(1.08));
157
+ if (s < 0)
158
+ refuse(`${what} is negative`);
159
+ if (s > 90)
160
+ refuse(`${what} is too high for the compressed format`);
161
+ return String.fromCharCode(s + 33);
162
+ }
163
+ /**
164
+ * A range in the cs bytes: `{` and 2 x 1.08^s miles, the nearest s for any whole s, refused when
165
+ * that s is below 0, so 1.95 miles is 2 and 1.9 is refused (vectors rulings, G3).
166
+ */
167
+ function compressedRange(miles) {
168
+ if (!(miles > 0))
169
+ refuse('a compressed range is at least 2 miles');
170
+ const s = nearest(Math.log(miles / 2) / Math.log(1.08));
171
+ if (s < 0)
172
+ refuse('a compressed range is at least 2 miles');
173
+ if (s > 90)
174
+ refuse('range is out of range for the compressed format');
175
+ return '{' + String.fromCharCode(s + 33);
85
176
  }
86
177
  // ---- data extensions and comment elements
87
178
  function courseSpeed(course, speed, what = 'course') {
@@ -96,9 +187,11 @@ function rateChar(r) {
96
187
  const v = whole(r, 1, 35, 'beacons per hour');
97
188
  return v <= 9 ? String(v) : String.fromCharCode(55 + v);
98
189
  }
99
- function altitudeText(feet) {
100
- if (!Number.isInteger(feet))
101
- refuse('an /A= altitude is whole feet');
190
+ function altitudeText(given) {
191
+ if (!Number.isFinite(given))
192
+ refuse('altitude is not a number');
193
+ // Whole feet, the nearest (vectors rulings, F2).
194
+ const feet = nearest(given);
102
195
  if (feet < 0) {
103
196
  if (feet < -99999)
104
197
  refuse('altitude is out of range');
@@ -115,7 +208,7 @@ export function frequencyText(f) {
115
208
  let whole3;
116
209
  let fraction;
117
210
  const resolution = f.tenKhzResolution ? 100 : 1000;
118
- const scaled = Math.round(mhz * resolution);
211
+ const scaled = nearest(mhz * resolution);
119
212
  const intPart = Math.floor(scaled / resolution);
120
213
  const fracPart = scaled % resolution;
121
214
  fraction = pad(fracPart, f.tenKhzResolution ? 2 : 3);
@@ -172,32 +265,37 @@ export function telemetryText(t) {
172
265
  let out = '|' + v(t.sequence, 'telemetry sequence');
173
266
  for (const a of t.analog)
174
267
  out += v(a, 'telemetry value');
268
+ // Eight binary channels; bits 9-13 are reserved.
175
269
  if (t.digital !== undefined)
176
- out += v(t.digital, 'telemetry bits');
270
+ out += base91(whole(t.digital, 0, 255, 'telemetry bits'), 2);
177
271
  return out + '|';
178
272
  }
179
- function daoText(f, latRemainder, lonRemainder, applied) {
273
+ /**
274
+ * The `!DAO!`: its datum, and digits `latExtra` and `lonExtra` (from `digitsOf`), or zeros when
275
+ * `applied` is false (an ambiguous position, whose digits say nothing finer).
276
+ */
277
+ function daoText(f, latExtra, lonExtra, applied) {
180
278
  const dao = f.dao;
181
279
  const datum = dao.datum;
280
+ if (/^[0-9]$/.test(datum)) {
281
+ // A local datum digit has no case to give a precision, so it only goes with spaces.
282
+ if (dao.precision !== 'none')
283
+ refuse('a !DAO! datum digit carries no added precision');
284
+ return `!${datum} !`;
285
+ }
182
286
  if (!/^[A-Za-z]$/.test(datum))
183
- refuse('the !DAO! datum is a letter');
287
+ refuse('the !DAO! datum is a letter or a digit');
184
288
  switch (dao.precision) {
185
289
  case 'none':
186
290
  return `!${datum.toUpperCase()} !`;
187
- case 'thousandths': {
291
+ case 'thousandths':
188
292
  if (!applied)
189
293
  return `!${datum.toUpperCase()}00!`;
190
- const a = Math.min(9, Math.round(latRemainder * 1000));
191
- const o = Math.min(9, Math.round(lonRemainder * 1000));
192
- return `!${datum.toUpperCase()}${a}${o}!`;
193
- }
194
- case 'base91': {
294
+ return `!${datum.toUpperCase()}${latExtra}${lonExtra}!`;
295
+ case 'base91':
195
296
  if (!applied)
196
297
  return `!${datum.toLowerCase()}!!!`;
197
- const a = Math.min(90, Math.round(latRemainder * 100 * 91));
198
- const o = Math.min(90, Math.round(lonRemainder * 100 * 91));
199
- return `!${datum.toLowerCase()}${String.fromCharCode(a + 33)}${String.fromCharCode(o + 33)}!`;
200
- }
298
+ return `!${datum.toLowerCase()}${String.fromCharCode(latExtra + 33)}${String.fromCharCode(lonExtra + 33)}!`;
201
299
  }
202
300
  }
203
301
  // ---- weather
@@ -209,6 +307,25 @@ function weatherValue(v, width, min, max, what) {
209
307
  return '-' + pad(-n, width - 1);
210
308
  return pad(n, width);
211
309
  }
310
+ /**
311
+ * Snowfall in its three characters (vectors rulings, F3): rounded to hundredths if that leaves it
312
+ * under 1, else to tenths if that leaves it under 10, else to a whole number; then a whole number
313
+ * is three digits (`002`, 0.995 as `001`), one under 1 is `.` and two digits (`.50`), any other a
314
+ * digit, `.` and a digit (`2.5`). One that rounds past 999 is refused.
315
+ */
316
+ function snowfallText(inches) {
317
+ if (!Number.isFinite(inches))
318
+ refuse('snowfall is not a number of inches');
319
+ const hundredths = nearest(inches * 100);
320
+ if (hundredths < 0)
321
+ refuse('snowfall is negative');
322
+ if (hundredths < 100)
323
+ return hundredths % 100 === 0 ? '000' : '.' + pad(hundredths, 2);
324
+ const tenths = nearest(inches * 10);
325
+ if (tenths < 100)
326
+ return tenths % 10 === 0 ? pad(tenths / 10, 3) : `${Math.floor(tenths / 10)}.${tenths % 10}`;
327
+ return pad(whole(inches, 0, 999, 'snowfall'), 3);
328
+ }
212
329
  /** Weather fields after the wind: gust, temperature, rain, humidity, pressure, and on. */
213
330
  function weatherFields(w, mandatory) {
214
331
  let out = '';
@@ -232,13 +349,8 @@ function weatherFields(w, mandatory) {
232
349
  const l = whole(w.luminosityWM2, 0, 1999, 'luminosity');
233
350
  out += l < 1000 ? 'L' + pad(l, 3) : 'l' + pad(l - 1000, 3);
234
351
  }
235
- if (w.snow24hIn !== undefined) {
236
- const s = w.snow24hIn;
237
- let text = Number.isInteger(s) ? pad(whole(s, 0, 999, 'snowfall'), 3) : String(s);
238
- if (text.length !== 3 || !/^[0-9.]{3}$/.test(text))
239
- text = refuse('snowfall is 3 characters');
240
- out += 's' + text;
241
- }
352
+ if (w.snow24hIn !== undefined)
353
+ out += 's' + snowfallText(w.snow24hIn);
242
354
  if (w.rainRaw !== undefined)
243
355
  out += '#' + pad(whole(w.rainRaw, 0, 999, 'raw rain counter'), 3);
244
356
  for (const e of w.extra ?? []) {
@@ -292,7 +404,8 @@ function positionedBody(f, mode) {
292
404
  if (isWeather)
293
405
  return weatherBody(f);
294
406
  const exts = hasExtension(f);
295
- const hasCourse = f.courseDegrees !== undefined || f.speedKnots !== undefined;
407
+ // DF and storm data extend a course/speed, so they count as that extension (vectors rulings, F7).
408
+ const hasCourse = f.courseDegrees !== undefined || f.speedKnots !== undefined || (!f.compressed && (f.dfBearing !== undefined || f.storm !== undefined));
296
409
  if (exts.length + (hasCourse ? 1 : 0) > 1)
297
410
  refuse(`only one data extension can follow the symbol (${[hasCourse ? 'course/speed' : '', ...exts].filter(Boolean).join(', ')})`);
298
411
  if (f.dfBearing && !(f.symbol.table === '/' && f.symbol.code === '\\'))
@@ -306,8 +419,8 @@ function positionedBody(f, mode) {
306
419
  let out = '';
307
420
  let compression;
308
421
  let altitudeInCs = false;
309
- let latRem = 0;
310
- let lonRem = 0;
422
+ let latExtra = 0;
423
+ let lonExtra = 0;
311
424
  let daoApplied = false;
312
425
  if (f.compressed) {
313
426
  if (f.phg || f.dfs || f.area || f.dfBearing || f.storm)
@@ -315,54 +428,49 @@ function positionedBody(f, mode) {
315
428
  let cs;
316
429
  compression = f.compression;
317
430
  if (hasCourse) {
431
+ // The cs bytes hold both, so one alone would add a value the data does not have; a course of
432
+ // 0 (after rounding) would read back as 360 (vectors rulings, F1, F5).
318
433
  if (f.courseDegrees === undefined)
319
434
  refuse('a compressed course/speed needs a course');
435
+ if (f.speedKnots === undefined)
436
+ refuse('a compressed course/speed needs a speed');
320
437
  if (compression?.source === 'gga')
321
438
  refuse('a GGA compression type means the cs bytes hold altitude');
322
- const c = whole(f.courseDegrees, 1, 360, 'course') % 360;
323
- if (c % 4 !== 0 && Math.abs(Math.round(c / 4) * 4 - f.courseDegrees) > 2)
324
- refuse('course is out of range');
325
- const speed = f.speedKnots ?? 0;
326
- if (speed < 0)
327
- refuse('speed is negative');
328
- const s = Math.round(Math.log(speed + 1) / Math.log(1.08));
329
- if (s > 90)
330
- refuse('speed is too high for the compressed format');
331
- cs = String.fromCharCode(Math.round(c / 4) % 90 + 33) + String.fromCharCode(s + 33);
439
+ whole(f.courseDegrees, 1, 360, 'course');
440
+ cs = compressedDirection(f.courseDegrees) + compressedSpeed(f.speedKnots, 'speed');
332
441
  compression = compression ?? DEFAULT_COMPRESSION;
333
442
  }
334
443
  else if (f.rangeMiles !== undefined) {
335
444
  if (compression?.source === 'gga')
336
445
  refuse('a GGA compression type means the cs bytes hold altitude');
337
- const s = Math.round(Math.log(f.rangeMiles / 2) / Math.log(1.08));
338
- if (!(s >= 0 && s <= 90))
339
- refuse('range is out of range for the compressed format');
340
- cs = '{' + String.fromCharCode(s + 33);
446
+ cs = compressedRange(f.rangeMiles);
341
447
  compression = compression ?? DEFAULT_COMPRESSION;
342
448
  }
343
449
  else if (f.altitudeFeet !== undefined && compression?.source === 'gga') {
344
- if (f.altitudeFeet < 1)
345
- 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)
348
- refuse('altitude is too high for the compressed format');
349
- cs = String.fromCharCode(Math.floor(v / 91) + 33) + String.fromCharCode((v % 91) + 33);
350
- altitudeInCs = Math.abs(Math.pow(1.002, v) - f.altitudeFeet) <= 1e-9 * f.altitudeFeet;
450
+ const alt = ggaAltitude(f.altitudeFeet);
451
+ cs = alt.cs;
452
+ altitudeInCs = alt.exact;
351
453
  }
352
454
  else if (compression !== undefined) {
353
455
  refuse('a compression type needs cs data (course/speed, range or altitude)');
354
456
  }
355
- out += compressedPosition(f, cs, compression);
457
+ const pos = compressedPosition(f, cs, compression);
458
+ out += pos.text;
459
+ latExtra = pos.latExtra;
460
+ lonExtra = pos.lonExtra;
461
+ daoApplied = true;
356
462
  }
357
463
  else {
358
464
  if (f.compression)
359
465
  refuse('an uncompressed position has no compression type');
360
- const pos = uncompressedPosition(f, f.dao !== undefined);
466
+ const pos = uncompressedPosition(f);
361
467
  out += pos.text;
362
- latRem = pos.latRemainder;
363
- lonRem = pos.lonRemainder;
468
+ latExtra = pos.latExtra;
469
+ lonExtra = pos.lonExtra;
364
470
  daoApplied = (f.ambiguity ?? 0) === 0;
365
471
  if (hasCourse) {
472
+ // DF or storm data with no course and speed follows `.../...`, which reads back as unknown
473
+ // (vectors rulings, F8).
366
474
  out += courseSpeed(f.courseDegrees, f.speedKnots);
367
475
  if (f.dfBearing) {
368
476
  const b = f.dfBearing;
@@ -371,9 +479,6 @@ function positionedBody(f, mode) {
371
479
  if (f.storm)
372
480
  out += stormText(f.storm);
373
481
  }
374
- else if (f.dfBearing || f.storm) {
375
- refuse('DF and storm data follow a course/speed extension');
376
- }
377
482
  else if (f.phg) {
378
483
  const p = f.phg;
379
484
  out += `PHG${whole(p.power, 0, 9, 'power code')}${heightChar(p.height)}${whole(p.gain, 0, 9, 'gain code')}${whole(p.directivity, 0, 9, 'directivity code')}`;
@@ -397,18 +502,20 @@ function positionedBody(f, mode) {
397
502
  out += `${shape}${pad(whole(a.latOffset, 0, 99, 'area offset'), 2)}${c}${pad(whole(a.lonOffset, 0, 99, 'area offset'), 2)}`;
398
503
  }
399
504
  }
400
- // Altitude, unless the compressed cs bytes carry it exactly.
401
- if (f.altitudeFeet !== undefined && !altitudeInCs)
402
- out += altitudeText(f.altitudeFeet);
403
- const extensionEnded = !f.compressed && (hasCourse || exts.length > 0) && f.altitudeFeet === undefined;
505
+ // After the data extension: the frequency with its fields, first, where radios read it (APRS12c
506
+ // ch. 18); the signpost or corridor braces; the altitude, unless the compressed cs bytes carry it
507
+ // exactly; the free text; base-91 telemetry; the !DAO! (vectors rulings, E3).
404
508
  let text = '';
405
- // A frequency straight after a 7-byte extension is separated from it by / (a PHGR already ends in one).
509
+ // A frequency straight after a 7-byte extension follows a / (a PHGR already ends in one); after a
510
+ // compressed position, or a position with no extension, it follows straight on.
511
+ const extended = !f.compressed && (hasCourse || exts.length > 0);
406
512
  if (f.frequency)
407
- text += (extensionEnded && !out.endsWith('/') ? '/' : '') + frequencyText(f.frequency);
513
+ text += (extended && !out.endsWith('/') ? '/' : '') + frequencyText(f.frequency);
408
514
  let braces = '';
409
515
  if (f.signpost !== undefined) {
410
- if (!/^[\x21-\x7c\x7e]{1,3}$/.test(f.signpost) || f.signpost.includes('{') || f.signpost.includes('}'))
411
- refuse('a signpost is 1-3 printable characters');
516
+ // Printable ASCII, a space included, other than the braces around it.
517
+ if (!/^[\x20-\x7a\x7c\x7e]{1,3}$/.test(f.signpost))
518
+ refuse('a signpost is 1-3 printable ASCII characters other than { and }');
412
519
  braces = `{${f.signpost}}`;
413
520
  }
414
521
  if (f.area?.corridorWidthMiles !== undefined) {
@@ -416,17 +523,21 @@ function positionedBody(f, mode) {
416
523
  refuse('only a line has a corridor');
417
524
  braces = `{${whole(f.area.corridorWidthMiles, 0, 999, 'corridor width')}}`;
418
525
  }
526
+ text += braces;
527
+ if (f.altitudeFeet !== undefined && !altitudeInCs)
528
+ text += altitudeText(f.altitudeFeet);
419
529
  const comment = f.comment ?? '';
420
530
  if (comment.length > 0 || mode === 'delimited') {
421
- if (text.length > 0 && mode !== 'joined')
531
+ // After a frequency (and anything after it) the free text follows a space, unless that would
532
+ // make it read as one of the frequency's fields.
533
+ if ((f.frequency && mode !== 'joined') || mode === 'spaced')
422
534
  text += ' ';
423
535
  text += (mode === 'delimited' ? '/' : '') + freeText(comment, 'the comment');
424
536
  }
425
- text += braces;
426
537
  if (f.telemetry)
427
538
  text += telemetryText(f.telemetry);
428
539
  if (f.dao)
429
- text += daoText(f, latRem, lonRem, daoApplied);
540
+ text += daoText(f, latExtra, lonExtra, daoApplied);
430
541
  const body = { text: out + text };
431
542
  if (compression)
432
543
  body.compression = compression;
@@ -434,7 +545,8 @@ function positionedBody(f, mode) {
434
545
  }
435
546
  function stormText(s) {
436
547
  const type = s.type === 'tropical-storm' ? 'TS' : s.type === 'hurricane' ? 'HC' : s.type === 'tropical-depression' ? 'TD' : refuse('unknown storm type');
437
- const v = (n, width, what) => (n === undefined ? ' '.repeat(width) : pad(whole(n, 0, 10 ** width - 1, what), width));
548
+ // Each field is digits: one with no value has no form (vectors README, storm data).
549
+ const v = (n, width, what) => n === undefined ? refuse(`storm data needs its ${what}`) : pad(whole(n, 0, 10 ** width - 1, what), width);
438
550
  let out = `/${type}/${v(s.sustainedWindKnots, 3, 'sustained wind')}^${v(s.gustKnots, 3, 'gust')}/${v(s.centralPressureMbar, 4, 'pressure')}>${v(s.hurricaneRadiusNm, 3, 'radius')}&${v(s.tropicalStormRadiusNm, 3, 'radius')}`;
439
551
  if (s.wholeGaleRadiusNm !== undefined)
440
552
  out += `%${v(s.wholeGaleRadiusNm, 3, 'radius')}`;
@@ -454,8 +566,8 @@ function weatherBody(f) {
454
566
  refuse('comment telemetry goes in a comment, which a weather report does not have');
455
567
  let out = '';
456
568
  let compression;
457
- let latRem = 0;
458
- let lonRem = 0;
569
+ let latExtra = 0;
570
+ let lonExtra = 0;
459
571
  let daoApplied = false;
460
572
  let altitudeInCs = false;
461
573
  if (f.compressed) {
@@ -464,34 +576,31 @@ function weatherBody(f) {
464
576
  if (w.windDirectionDegrees !== undefined || w.windSpeedMph !== undefined) {
465
577
  if (w.windDirectionDegrees === undefined || w.windSpeedMph === undefined)
466
578
  refuse('compressed wind needs both direction and speed');
467
- const dir = whole(w.windDirectionDegrees, 0, 360, 'wind direction');
468
- const knots = w.windSpeedMph / KNOTS_TO_MPH;
469
- const s = Math.round(Math.log(knots + 1) / Math.log(1.08));
470
- if (s < 0 || s > 90)
471
- refuse('wind speed is out of range for the compressed format');
472
- cs = String.fromCharCode((Math.round(dir / 4) % 90) + 33) + String.fromCharCode(s + 33);
579
+ whole(w.windDirectionDegrees, 0, 360, 'wind direction');
580
+ cs = compressedDirection(w.windDirectionDegrees) + compressedSpeed(w.windSpeedMph / KNOTS_TO_MPH, 'wind speed');
473
581
  compression = compression ?? DEFAULT_COMPRESSION;
474
582
  if (compression.source === 'gga')
475
583
  refuse('a GGA compression type means the cs bytes hold altitude');
476
584
  }
477
585
  else if (f.altitudeFeet !== undefined && compression?.source === 'gga') {
478
- const v = Math.round(Math.log(f.altitudeFeet) / Math.log(1.002));
479
- cs = String.fromCharCode(Math.floor(v / 91) + 33) + String.fromCharCode((v % 91) + 33);
480
- altitudeInCs = Math.abs(Math.pow(1.002, v) - f.altitudeFeet) <= 1e-9 * f.altitudeFeet;
586
+ // With no comment to hold a /A=, the nearest value the cs bytes hold (vectors rulings, F14).
587
+ cs = ggaAltitude(f.altitudeFeet).cs;
588
+ altitudeInCs = true;
481
589
  }
482
590
  else if (f.rangeMiles !== undefined) {
483
591
  if (compression?.source === 'gga')
484
592
  refuse('a GGA compression type means the cs bytes hold altitude');
485
- const s = Math.round(Math.log(f.rangeMiles / 2) / Math.log(1.08));
486
- if (!(s >= 0 && s <= 90))
487
- refuse('range is out of range for the compressed format');
488
- cs = '{' + String.fromCharCode(s + 33);
593
+ cs = compressedRange(f.rangeMiles);
489
594
  compression = compression ?? DEFAULT_COMPRESSION;
490
595
  }
491
596
  else if (compression !== undefined) {
492
597
  refuse('a compression type needs cs data');
493
598
  }
494
- out += compressedPosition(f, cs, compression);
599
+ const pos = compressedPosition(f, cs, compression);
600
+ out += pos.text;
601
+ latExtra = pos.latExtra;
602
+ lonExtra = pos.lonExtra;
603
+ daoApplied = true;
495
604
  if (f.altitudeFeet !== undefined && !altitudeInCs)
496
605
  refuse('a weather report has no altitude in its comment');
497
606
  out += weatherFields(w, true);
@@ -501,10 +610,10 @@ function weatherBody(f) {
501
610
  refuse('an uncompressed position has no compression type');
502
611
  if (f.altitudeFeet !== undefined)
503
612
  refuse('a weather report has no altitude in its comment');
504
- const pos = uncompressedPosition(f, f.dao !== undefined);
613
+ const pos = uncompressedPosition(f);
505
614
  out += pos.text;
506
- latRem = pos.latRemainder;
507
- lonRem = pos.lonRemainder;
615
+ latExtra = pos.latExtra;
616
+ lonExtra = pos.lonExtra;
508
617
  daoApplied = (f.ambiguity ?? 0) === 0;
509
618
  out += courseSpeed(w.windDirectionDegrees, w.windSpeedMph, 'wind direction');
510
619
  out += weatherFields(w, true);
@@ -512,7 +621,7 @@ function weatherBody(f) {
512
621
  if (f.telemetry)
513
622
  out += telemetryText(f.telemetry);
514
623
  if (f.dao)
515
- out += daoText(f, latRem, lonRem, daoApplied);
624
+ out += daoText(f, latExtra, lonExtra, daoApplied);
516
625
  const body = { text: out };
517
626
  if (compression)
518
627
  body.compression = compression;
@@ -602,8 +711,13 @@ function micEDestination(d, lat, lonDegrees) {
602
711
  }
603
712
  return out;
604
713
  }
714
+ /**
715
+ * Mic-E reports whose altitude was given in feet by a program, so it goes in a `/A=` and not the
716
+ * Mic-E altitude bytes, whose unit is the metre (vectors rulings, B2).
717
+ */
718
+ export const ALTITUDE_GIVEN_IN_FEET = new WeakSet();
605
719
  export function micEAltitude(feet) {
606
- const metres = Math.round(feet * METRES_PER_FOOT);
720
+ const metres = nearest(feet * METRES_PER_FOOT);
607
721
  if (Math.abs(metres / METRES_PER_FOOT - feet) > 1e-6)
608
722
  return undefined;
609
723
  const v = metres + 10000;
@@ -627,9 +741,9 @@ export function encodeMicE(d, mode) {
627
741
  const ambiguity = d.ambiguity ?? 0;
628
742
  if (!Number.isInteger(ambiguity) || ambiguity < 0 || ambiguity > 4)
629
743
  refuse('ambiguity is 0-4 digits');
630
- const truncate = d.dao !== undefined || ambiguity > 0;
631
- const lat = digitsOf(d.latitude, truncate);
632
- const lon = digitsOf(d.longitude, truncate);
744
+ const digits = digitsMode(d);
745
+ const lat = digitsOf(d.latitude, digits, ambiguity);
746
+ const lon = digitsOf(d.longitude, digits, ambiguity);
633
747
  if (lat.degrees > 90 || (lat.degrees === 90 && lat.hundredths > 0))
634
748
  refuse('latitude is out of range');
635
749
  if (lon.degrees > 179)
@@ -646,16 +760,28 @@ export function encodeMicE(d, mode) {
646
760
  dByte = deg + 8;
647
761
  else
648
762
  dByte = deg - 100 + 28;
649
- // With ambiguity the digits a decoder ignores are written as the centre it reports.
650
- const mValue = Math.floor(lon.hundredths / 100);
651
- const hundredths = lon.hundredths % 100;
763
+ // With ambiguity the digits a decoder ignores are written as the centre it reports (vectors
764
+ // README, Mic-E): 03.5x is 03.55, 03.xx 03.50, 0x.xx 05.00, xx.xx 30.00.
765
+ let lonHundredths = lon.hundredths;
766
+ if (ambiguity === 1)
767
+ lonHundredths = Math.floor(lonHundredths / 10) * 10 + 5;
768
+ else if (ambiguity === 2)
769
+ lonHundredths = Math.floor(lonHundredths / 100) * 100 + 50;
770
+ else if (ambiguity === 3)
771
+ lonHundredths = Math.floor(lonHundredths / 1000) * 1000 + 500;
772
+ else if (ambiguity === 4)
773
+ lonHundredths = 3000;
774
+ const mValue = Math.floor(lonHundredths / 100);
775
+ const hundredths = lonHundredths % 100;
652
776
  const mByte = mValue < 10 ? mValue + 88 : mValue + 28;
653
777
  const hByte = hundredths + 28;
654
- // Speed and course: speed tens + 80 (so values under 200 knots are printable), course + 400.
778
+ // Speed and course in their printable forms (APRS12c ch. 10): speed tens + 80 below 190 knots,
779
+ // and as they are from 190 on (190-199 is /, not DEL: vectors rulings, E5); course + 400.
655
780
  const speed = d.speedKnots === undefined ? 0 : whole(d.speedKnots, 0, 799, 'speed');
656
- const course = d.courseDegrees === undefined ? 0 : whole(d.courseDegrees, 0, 360, 'course');
781
+ // A course of 0 (after rounding) reads back as no course, so it is refused (vectors rulings, F1).
782
+ const course = d.courseDegrees === undefined ? 0 : whole(d.courseDegrees, 1, 360, 'course');
657
783
  const tens = Math.floor(speed / 10);
658
- const spByte = tens < 20 ? tens + 80 + 28 : tens + 28;
784
+ const spByte = tens < 19 ? tens + 80 + 28 : tens + 28;
659
785
  const dcByte = (speed % 10) * 10 + Math.floor(course / 100) + 4 + 28;
660
786
  const seByte = (course % 100) + 28;
661
787
  const dti = d.oldData ? "'" : '`';
@@ -676,7 +802,7 @@ export function encodeMicE(d, mode) {
676
802
  }
677
803
  let altitudeInComment;
678
804
  if (d.altitudeFeet !== undefined) {
679
- const alt = micEAltitude(d.altitudeFeet);
805
+ const alt = ALTITUDE_GIVEN_IN_FEET.has(d) ? undefined : micEAltitude(d.altitudeFeet);
680
806
  if (alt !== undefined)
681
807
  text += alt;
682
808
  else
@@ -706,15 +832,18 @@ export function encodeMicE(d, mode) {
706
832
  rest += (exts > 0 && altitudeInComment === undefined && !after.endsWith('/') ? '/' : '') + frequencyText(d.frequency);
707
833
  const comment = d.comment ?? '';
708
834
  if (comment.length > 0 || mode === 'delimited') {
709
- if (rest.length > 0 && mode !== 'joined')
835
+ if ((rest.length > 0 && mode !== 'joined') || mode === 'spaced')
710
836
  rest += ' ';
711
837
  rest += (mode === 'delimited' ? '/' : '') + freeText(comment, 'the comment');
712
838
  }
713
839
  after += rest;
840
+ // Telemetry and the !DAO! are lifted before a locator is read, so they follow it straight on
841
+ // (vectors rulings, F16).
842
+ let lifted = '';
714
843
  if (d.telemetry)
715
- after += telemetryText(d.telemetry);
844
+ lifted += telemetryText(d.telemetry);
716
845
  if (d.dao)
717
- after += daoText(d, lat.remainder, lon.remainder, ambiguity === 0);
846
+ lifted += daoText(d, lat.extra, lon.extra, ambiguity === 0);
718
847
  if (d.locator !== undefined) {
719
848
  if (!/^[A-R]{2}[0-9]{2}(?:[A-X]{2})?$/.test(d.locator))
720
849
  refuse('the locator is 4 or 6 characters, upper case');
@@ -722,12 +851,17 @@ export function encodeMicE(d, mode) {
722
851
  if (after.length > 0)
723
852
  text += ' ';
724
853
  }
725
- text += after;
854
+ text += after + lifted;
726
855
  if (d.deviceSuffix !== undefined) {
727
856
  if (d.typeCode === undefined)
728
857
  refuse('a Mic-E device suffix needs a type code');
729
858
  text += d.deviceSuffix;
730
859
  }
860
+ // Mic-E status text must not start with 0x1D, which would be taken for Rev 0 telemetry
861
+ // (APRS12c ch. 10), so a comment that would start it is written after a /.
862
+ const legacy = d.legacyTelemetry !== undefined && d.legacyTelemetry.length > 0;
863
+ if (!legacy && text.startsWith('\x1d') && mode !== 'delimited')
864
+ return encodeMicE(d, 'delimited');
731
865
  info += text;
732
866
  return { text: info, destination };
733
867
  }