@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.
- package/CHANGELOG.md +29 -1
- package/README.md +8 -4
- package/dist/builder.d.ts +33 -8
- package/dist/builder.d.ts.map +1 -1
- package/dist/builder.js +64 -10
- package/dist/builder.js.map +1 -1
- package/dist/decode/index.d.ts.map +1 -1
- package/dist/decode/index.js +5 -0
- package/dist/decode/index.js.map +1 -1
- package/dist/decode/other.js +1 -1
- package/dist/decode/other.js.map +1 -1
- package/dist/decode/positioned.d.ts +17 -1
- package/dist/decode/positioned.d.ts.map +1 -1
- package/dist/decode/positioned.js +21 -7
- package/dist/decode/positioned.js.map +1 -1
- package/dist/decode/reports.d.ts.map +1 -1
- package/dist/decode/reports.js +34 -12
- package/dist/decode/reports.js.map +1 -1
- package/dist/decoder.d.ts.map +1 -1
- package/dist/decoder.js +2 -4
- package/dist/decoder.js.map +1 -1
- package/dist/deviceid.d.ts +8 -2
- package/dist/deviceid.d.ts.map +1 -1
- package/dist/deviceid.js +10 -2
- package/dist/deviceid.js.map +1 -1
- package/dist/encode/common.d.ts +13 -2
- package/dist/encode/common.d.ts.map +1 -1
- package/dist/encode/common.js +36 -4
- package/dist/encode/common.js.map +1 -1
- package/dist/encode/index.d.ts.map +1 -1
- package/dist/encode/index.js +36 -16
- package/dist/encode/index.js.map +1 -1
- package/dist/encode/other.d.ts.map +1 -1
- package/dist/encode/other.js +10 -2
- package/dist/encode/other.js.map +1 -1
- package/dist/encode/position.d.ts +9 -5
- package/dist/encode/position.d.ts.map +1 -1
- package/dist/encode/position.js +219 -121
- package/dist/encode/position.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/neutral.d.ts.map +1 -1
- package/dist/neutral.js +20 -3
- package/dist/neutral.js.map +1 -1
- package/dist/types.d.ts +13 -5
- package/dist/types.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/builder.ts +77 -14
- package/src/decode/index.ts +4 -0
- package/src/decode/other.ts +1 -1
- package/src/decode/positioned.ts +33 -9
- package/src/decode/reports.ts +33 -9
- package/src/decoder.ts +2 -5
- package/src/deviceid.ts +12 -3
- package/src/encode/common.ts +34 -4
- package/src/encode/index.ts +33 -14
- package/src/encode/other.ts +12 -2
- package/src/encode/position.ts +227 -119
- package/src/index.ts +1 -0
- package/src/neutral.ts +22 -3
- package/src/types.ts +14 -1
package/dist/encode/position.js
CHANGED
|
@@ -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,
|
|
3
|
-
// telemetry, !DAO! (
|
|
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
|
|
12
|
-
function digitsOf(value,
|
|
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
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
let hundredths
|
|
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
|
-
|
|
24
|
-
|
|
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
|
|
33
|
-
function uncompressedPosition(f
|
|
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
|
|
43
|
-
const
|
|
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
|
-
|
|
55
|
-
|
|
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
|
-
/**
|
|
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,15 @@ 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,
|
|
82
|
-
const x = Math.min(max, Math.max(0,
|
|
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
|
-
|
|
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
|
+
};
|
|
85
127
|
}
|
|
86
128
|
/**
|
|
87
129
|
* A GGA altitude in the cs bytes: 1.002^cs feet, so the nearest value they hold (1 foot for 0 feet
|
|
@@ -90,12 +132,48 @@ function compressedPosition(f, cs, compression) {
|
|
|
90
132
|
function ggaAltitude(feet) {
|
|
91
133
|
if (!Number.isFinite(feet))
|
|
92
134
|
refuse('altitude is not a number');
|
|
93
|
-
const v = feet <= 1 ? 0 :
|
|
135
|
+
const v = feet <= 1 ? 0 : nearest(Math.log(feet) / Math.log(1.002));
|
|
94
136
|
if (v > 91 * 91 - 1)
|
|
95
137
|
refuse('altitude is too high for the compressed format');
|
|
96
138
|
const cs = String.fromCharCode(Math.floor(v / 91) + 33) + String.fromCharCode((v % 91) + 33);
|
|
97
139
|
return { cs, exact: Math.abs(Math.pow(1.002, v) - feet) <= 1e-9 * Math.abs(feet) };
|
|
98
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);
|
|
176
|
+
}
|
|
99
177
|
// ---- data extensions and comment elements
|
|
100
178
|
function courseSpeed(course, speed, what = 'course') {
|
|
101
179
|
const c = course === undefined ? '...' : pad(whole(course, 0, 360, what), 3);
|
|
@@ -109,9 +187,11 @@ function rateChar(r) {
|
|
|
109
187
|
const v = whole(r, 1, 35, 'beacons per hour');
|
|
110
188
|
return v <= 9 ? String(v) : String.fromCharCode(55 + v);
|
|
111
189
|
}
|
|
112
|
-
function altitudeText(
|
|
113
|
-
if (!Number.
|
|
114
|
-
refuse('
|
|
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);
|
|
115
195
|
if (feet < 0) {
|
|
116
196
|
if (feet < -99999)
|
|
117
197
|
refuse('altitude is out of range');
|
|
@@ -128,7 +208,7 @@ export function frequencyText(f) {
|
|
|
128
208
|
let whole3;
|
|
129
209
|
let fraction;
|
|
130
210
|
const resolution = f.tenKhzResolution ? 100 : 1000;
|
|
131
|
-
const scaled =
|
|
211
|
+
const scaled = nearest(mhz * resolution);
|
|
132
212
|
const intPart = Math.floor(scaled / resolution);
|
|
133
213
|
const fracPart = scaled % resolution;
|
|
134
214
|
fraction = pad(fracPart, f.tenKhzResolution ? 2 : 3);
|
|
@@ -190,7 +270,11 @@ export function telemetryText(t) {
|
|
|
190
270
|
out += base91(whole(t.digital, 0, 255, 'telemetry bits'), 2);
|
|
191
271
|
return out + '|';
|
|
192
272
|
}
|
|
193
|
-
|
|
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) {
|
|
194
278
|
const dao = f.dao;
|
|
195
279
|
const datum = dao.datum;
|
|
196
280
|
if (/^[0-9]$/.test(datum)) {
|
|
@@ -204,20 +288,14 @@ function daoText(f, latRemainder, lonRemainder, applied) {
|
|
|
204
288
|
switch (dao.precision) {
|
|
205
289
|
case 'none':
|
|
206
290
|
return `!${datum.toUpperCase()} !`;
|
|
207
|
-
case 'thousandths':
|
|
291
|
+
case 'thousandths':
|
|
208
292
|
if (!applied)
|
|
209
293
|
return `!${datum.toUpperCase()}00!`;
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
return `!${datum.toUpperCase()}${a}${o}!`;
|
|
213
|
-
}
|
|
214
|
-
case 'base91': {
|
|
294
|
+
return `!${datum.toUpperCase()}${latExtra}${lonExtra}!`;
|
|
295
|
+
case 'base91':
|
|
215
296
|
if (!applied)
|
|
216
297
|
return `!${datum.toLowerCase()}!!!`;
|
|
217
|
-
|
|
218
|
-
const o = Math.min(90, Math.round(lonRemainder * 100 * 91));
|
|
219
|
-
return `!${datum.toLowerCase()}${String.fromCharCode(a + 33)}${String.fromCharCode(o + 33)}!`;
|
|
220
|
-
}
|
|
298
|
+
return `!${datum.toLowerCase()}${String.fromCharCode(latExtra + 33)}${String.fromCharCode(lonExtra + 33)}!`;
|
|
221
299
|
}
|
|
222
300
|
}
|
|
223
301
|
// ---- weather
|
|
@@ -230,23 +308,23 @@ function weatherValue(v, width, min, max, what) {
|
|
|
230
308
|
return pad(n, width);
|
|
231
309
|
}
|
|
232
310
|
/**
|
|
233
|
-
* Snowfall
|
|
234
|
-
*
|
|
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.
|
|
235
315
|
*/
|
|
236
316
|
function snowfallText(inches) {
|
|
237
|
-
if (!Number.isFinite(inches)
|
|
317
|
+
if (!Number.isFinite(inches))
|
|
238
318
|
refuse('snowfall is not a number of inches');
|
|
239
|
-
|
|
240
|
-
if (
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
}
|
|
249
|
-
return text;
|
|
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);
|
|
250
328
|
}
|
|
251
329
|
/** Weather fields after the wind: gust, temperature, rain, humidity, pressure, and on. */
|
|
252
330
|
function weatherFields(w, mandatory) {
|
|
@@ -326,7 +404,8 @@ function positionedBody(f, mode) {
|
|
|
326
404
|
if (isWeather)
|
|
327
405
|
return weatherBody(f);
|
|
328
406
|
const exts = hasExtension(f);
|
|
329
|
-
|
|
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));
|
|
330
409
|
if (exts.length + (hasCourse ? 1 : 0) > 1)
|
|
331
410
|
refuse(`only one data extension can follow the symbol (${[hasCourse ? 'course/speed' : '', ...exts].filter(Boolean).join(', ')})`);
|
|
332
411
|
if (f.dfBearing && !(f.symbol.table === '/' && f.symbol.code === '\\'))
|
|
@@ -340,8 +419,8 @@ function positionedBody(f, mode) {
|
|
|
340
419
|
let out = '';
|
|
341
420
|
let compression;
|
|
342
421
|
let altitudeInCs = false;
|
|
343
|
-
let
|
|
344
|
-
let
|
|
422
|
+
let latExtra = 0;
|
|
423
|
+
let lonExtra = 0;
|
|
345
424
|
let daoApplied = false;
|
|
346
425
|
if (f.compressed) {
|
|
347
426
|
if (f.phg || f.dfs || f.area || f.dfBearing || f.storm)
|
|
@@ -349,29 +428,22 @@ function positionedBody(f, mode) {
|
|
|
349
428
|
let cs;
|
|
350
429
|
compression = f.compression;
|
|
351
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).
|
|
352
433
|
if (f.courseDegrees === undefined)
|
|
353
434
|
refuse('a compressed course/speed needs a course');
|
|
435
|
+
if (f.speedKnots === undefined)
|
|
436
|
+
refuse('a compressed course/speed needs a speed');
|
|
354
437
|
if (compression?.source === 'gga')
|
|
355
438
|
refuse('a GGA compression type means the cs bytes hold altitude');
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
refuse('course is out of range');
|
|
359
|
-
const speed = f.speedKnots ?? 0;
|
|
360
|
-
if (speed < 0)
|
|
361
|
-
refuse('speed is negative');
|
|
362
|
-
const s = Math.round(Math.log(speed + 1) / Math.log(1.08));
|
|
363
|
-
if (s > 90)
|
|
364
|
-
refuse('speed is too high for the compressed format');
|
|
365
|
-
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');
|
|
366
441
|
compression = compression ?? DEFAULT_COMPRESSION;
|
|
367
442
|
}
|
|
368
443
|
else if (f.rangeMiles !== undefined) {
|
|
369
444
|
if (compression?.source === 'gga')
|
|
370
445
|
refuse('a GGA compression type means the cs bytes hold altitude');
|
|
371
|
-
|
|
372
|
-
if (!(s >= 0 && s <= 90))
|
|
373
|
-
refuse('range is out of range for the compressed format');
|
|
374
|
-
cs = '{' + String.fromCharCode(s + 33);
|
|
446
|
+
cs = compressedRange(f.rangeMiles);
|
|
375
447
|
compression = compression ?? DEFAULT_COMPRESSION;
|
|
376
448
|
}
|
|
377
449
|
else if (f.altitudeFeet !== undefined && compression?.source === 'gga') {
|
|
@@ -382,17 +454,23 @@ function positionedBody(f, mode) {
|
|
|
382
454
|
else if (compression !== undefined) {
|
|
383
455
|
refuse('a compression type needs cs data (course/speed, range or altitude)');
|
|
384
456
|
}
|
|
385
|
-
|
|
457
|
+
const pos = compressedPosition(f, cs, compression);
|
|
458
|
+
out += pos.text;
|
|
459
|
+
latExtra = pos.latExtra;
|
|
460
|
+
lonExtra = pos.lonExtra;
|
|
461
|
+
daoApplied = true;
|
|
386
462
|
}
|
|
387
463
|
else {
|
|
388
464
|
if (f.compression)
|
|
389
465
|
refuse('an uncompressed position has no compression type');
|
|
390
|
-
const pos = uncompressedPosition(f
|
|
466
|
+
const pos = uncompressedPosition(f);
|
|
391
467
|
out += pos.text;
|
|
392
|
-
|
|
393
|
-
|
|
468
|
+
latExtra = pos.latExtra;
|
|
469
|
+
lonExtra = pos.lonExtra;
|
|
394
470
|
daoApplied = (f.ambiguity ?? 0) === 0;
|
|
395
471
|
if (hasCourse) {
|
|
472
|
+
// DF or storm data with no course and speed follows `.../...`, which reads back as unknown
|
|
473
|
+
// (vectors rulings, F8).
|
|
396
474
|
out += courseSpeed(f.courseDegrees, f.speedKnots);
|
|
397
475
|
if (f.dfBearing) {
|
|
398
476
|
const b = f.dfBearing;
|
|
@@ -401,9 +479,6 @@ function positionedBody(f, mode) {
|
|
|
401
479
|
if (f.storm)
|
|
402
480
|
out += stormText(f.storm);
|
|
403
481
|
}
|
|
404
|
-
else if (f.dfBearing || f.storm) {
|
|
405
|
-
refuse('DF and storm data follow a course/speed extension');
|
|
406
|
-
}
|
|
407
482
|
else if (f.phg) {
|
|
408
483
|
const p = f.phg;
|
|
409
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')}`;
|
|
@@ -427,14 +502,15 @@ function positionedBody(f, mode) {
|
|
|
427
502
|
out += `${shape}${pad(whole(a.latOffset, 0, 99, 'area offset'), 2)}${c}${pad(whole(a.lonOffset, 0, 99, 'area offset'), 2)}`;
|
|
428
503
|
}
|
|
429
504
|
}
|
|
430
|
-
//
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
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).
|
|
434
508
|
let text = '';
|
|
435
|
-
// A frequency straight after a 7-byte extension
|
|
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);
|
|
436
512
|
if (f.frequency)
|
|
437
|
-
text += (
|
|
513
|
+
text += (extended && !out.endsWith('/') ? '/' : '') + frequencyText(f.frequency);
|
|
438
514
|
let braces = '';
|
|
439
515
|
if (f.signpost !== undefined) {
|
|
440
516
|
// Printable ASCII, a space included, other than the braces around it.
|
|
@@ -447,17 +523,21 @@ function positionedBody(f, mode) {
|
|
|
447
523
|
refuse('only a line has a corridor');
|
|
448
524
|
braces = `{${whole(f.area.corridorWidthMiles, 0, 999, 'corridor width')}}`;
|
|
449
525
|
}
|
|
526
|
+
text += braces;
|
|
527
|
+
if (f.altitudeFeet !== undefined && !altitudeInCs)
|
|
528
|
+
text += altitudeText(f.altitudeFeet);
|
|
450
529
|
const comment = f.comment ?? '';
|
|
451
530
|
if (comment.length > 0 || mode === 'delimited') {
|
|
452
|
-
|
|
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')
|
|
453
534
|
text += ' ';
|
|
454
535
|
text += (mode === 'delimited' ? '/' : '') + freeText(comment, 'the comment');
|
|
455
536
|
}
|
|
456
|
-
text = mode === 'braces-first' ? braces + text : text + braces;
|
|
457
537
|
if (f.telemetry)
|
|
458
538
|
text += telemetryText(f.telemetry);
|
|
459
539
|
if (f.dao)
|
|
460
|
-
text += daoText(f,
|
|
540
|
+
text += daoText(f, latExtra, lonExtra, daoApplied);
|
|
461
541
|
const body = { text: out + text };
|
|
462
542
|
if (compression)
|
|
463
543
|
body.compression = compression;
|
|
@@ -465,7 +545,8 @@ function positionedBody(f, mode) {
|
|
|
465
545
|
}
|
|
466
546
|
function stormText(s) {
|
|
467
547
|
const type = s.type === 'tropical-storm' ? 'TS' : s.type === 'hurricane' ? 'HC' : s.type === 'tropical-depression' ? 'TD' : refuse('unknown storm type');
|
|
468
|
-
|
|
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);
|
|
469
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')}`;
|
|
470
551
|
if (s.wholeGaleRadiusNm !== undefined)
|
|
471
552
|
out += `%${v(s.wholeGaleRadiusNm, 3, 'radius')}`;
|
|
@@ -485,8 +566,8 @@ function weatherBody(f) {
|
|
|
485
566
|
refuse('comment telemetry goes in a comment, which a weather report does not have');
|
|
486
567
|
let out = '';
|
|
487
568
|
let compression;
|
|
488
|
-
let
|
|
489
|
-
let
|
|
569
|
+
let latExtra = 0;
|
|
570
|
+
let lonExtra = 0;
|
|
490
571
|
let daoApplied = false;
|
|
491
572
|
let altitudeInCs = false;
|
|
492
573
|
if (f.compressed) {
|
|
@@ -495,34 +576,31 @@ function weatherBody(f) {
|
|
|
495
576
|
if (w.windDirectionDegrees !== undefined || w.windSpeedMph !== undefined) {
|
|
496
577
|
if (w.windDirectionDegrees === undefined || w.windSpeedMph === undefined)
|
|
497
578
|
refuse('compressed wind needs both direction and speed');
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
const s = Math.round(Math.log(knots + 1) / Math.log(1.08));
|
|
501
|
-
if (s < 0 || s > 90)
|
|
502
|
-
refuse('wind speed is out of range for the compressed format');
|
|
503
|
-
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');
|
|
504
581
|
compression = compression ?? DEFAULT_COMPRESSION;
|
|
505
582
|
if (compression.source === 'gga')
|
|
506
583
|
refuse('a GGA compression type means the cs bytes hold altitude');
|
|
507
584
|
}
|
|
508
585
|
else if (f.altitudeFeet !== undefined && compression?.source === 'gga') {
|
|
509
|
-
|
|
510
|
-
cs =
|
|
511
|
-
altitudeInCs =
|
|
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;
|
|
512
589
|
}
|
|
513
590
|
else if (f.rangeMiles !== undefined) {
|
|
514
591
|
if (compression?.source === 'gga')
|
|
515
592
|
refuse('a GGA compression type means the cs bytes hold altitude');
|
|
516
|
-
|
|
517
|
-
if (!(s >= 0 && s <= 90))
|
|
518
|
-
refuse('range is out of range for the compressed format');
|
|
519
|
-
cs = '{' + String.fromCharCode(s + 33);
|
|
593
|
+
cs = compressedRange(f.rangeMiles);
|
|
520
594
|
compression = compression ?? DEFAULT_COMPRESSION;
|
|
521
595
|
}
|
|
522
596
|
else if (compression !== undefined) {
|
|
523
597
|
refuse('a compression type needs cs data');
|
|
524
598
|
}
|
|
525
|
-
|
|
599
|
+
const pos = compressedPosition(f, cs, compression);
|
|
600
|
+
out += pos.text;
|
|
601
|
+
latExtra = pos.latExtra;
|
|
602
|
+
lonExtra = pos.lonExtra;
|
|
603
|
+
daoApplied = true;
|
|
526
604
|
if (f.altitudeFeet !== undefined && !altitudeInCs)
|
|
527
605
|
refuse('a weather report has no altitude in its comment');
|
|
528
606
|
out += weatherFields(w, true);
|
|
@@ -532,10 +610,10 @@ function weatherBody(f) {
|
|
|
532
610
|
refuse('an uncompressed position has no compression type');
|
|
533
611
|
if (f.altitudeFeet !== undefined)
|
|
534
612
|
refuse('a weather report has no altitude in its comment');
|
|
535
|
-
const pos = uncompressedPosition(f
|
|
613
|
+
const pos = uncompressedPosition(f);
|
|
536
614
|
out += pos.text;
|
|
537
|
-
|
|
538
|
-
|
|
615
|
+
latExtra = pos.latExtra;
|
|
616
|
+
lonExtra = pos.lonExtra;
|
|
539
617
|
daoApplied = (f.ambiguity ?? 0) === 0;
|
|
540
618
|
out += courseSpeed(w.windDirectionDegrees, w.windSpeedMph, 'wind direction');
|
|
541
619
|
out += weatherFields(w, true);
|
|
@@ -543,7 +621,7 @@ function weatherBody(f) {
|
|
|
543
621
|
if (f.telemetry)
|
|
544
622
|
out += telemetryText(f.telemetry);
|
|
545
623
|
if (f.dao)
|
|
546
|
-
out += daoText(f,
|
|
624
|
+
out += daoText(f, latExtra, lonExtra, daoApplied);
|
|
547
625
|
const body = { text: out };
|
|
548
626
|
if (compression)
|
|
549
627
|
body.compression = compression;
|
|
@@ -633,8 +711,13 @@ function micEDestination(d, lat, lonDegrees) {
|
|
|
633
711
|
}
|
|
634
712
|
return out;
|
|
635
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();
|
|
636
719
|
export function micEAltitude(feet) {
|
|
637
|
-
const metres =
|
|
720
|
+
const metres = nearest(feet * METRES_PER_FOOT);
|
|
638
721
|
if (Math.abs(metres / METRES_PER_FOOT - feet) > 1e-6)
|
|
639
722
|
return undefined;
|
|
640
723
|
const v = metres + 10000;
|
|
@@ -658,9 +741,9 @@ export function encodeMicE(d, mode) {
|
|
|
658
741
|
const ambiguity = d.ambiguity ?? 0;
|
|
659
742
|
if (!Number.isInteger(ambiguity) || ambiguity < 0 || ambiguity > 4)
|
|
660
743
|
refuse('ambiguity is 0-4 digits');
|
|
661
|
-
const
|
|
662
|
-
const lat = digitsOf(d.latitude,
|
|
663
|
-
const lon = digitsOf(d.longitude,
|
|
744
|
+
const digits = digitsMode(d);
|
|
745
|
+
const lat = digitsOf(d.latitude, digits, ambiguity);
|
|
746
|
+
const lon = digitsOf(d.longitude, digits, ambiguity);
|
|
664
747
|
if (lat.degrees > 90 || (lat.degrees === 90 && lat.hundredths > 0))
|
|
665
748
|
refuse('latitude is out of range');
|
|
666
749
|
if (lon.degrees > 179)
|
|
@@ -677,16 +760,28 @@ export function encodeMicE(d, mode) {
|
|
|
677
760
|
dByte = deg + 8;
|
|
678
761
|
else
|
|
679
762
|
dByte = deg - 100 + 28;
|
|
680
|
-
// With ambiguity the digits a decoder ignores are written as the centre it reports
|
|
681
|
-
|
|
682
|
-
|
|
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;
|
|
683
776
|
const mByte = mValue < 10 ? mValue + 88 : mValue + 28;
|
|
684
777
|
const hByte = hundredths + 28;
|
|
685
|
-
// Speed and course
|
|
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.
|
|
686
780
|
const speed = d.speedKnots === undefined ? 0 : whole(d.speedKnots, 0, 799, 'speed');
|
|
687
|
-
|
|
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');
|
|
688
783
|
const tens = Math.floor(speed / 10);
|
|
689
|
-
const spByte = tens <
|
|
784
|
+
const spByte = tens < 19 ? tens + 80 + 28 : tens + 28;
|
|
690
785
|
const dcByte = (speed % 10) * 10 + Math.floor(course / 100) + 4 + 28;
|
|
691
786
|
const seByte = (course % 100) + 28;
|
|
692
787
|
const dti = d.oldData ? "'" : '`';
|
|
@@ -707,7 +802,7 @@ export function encodeMicE(d, mode) {
|
|
|
707
802
|
}
|
|
708
803
|
let altitudeInComment;
|
|
709
804
|
if (d.altitudeFeet !== undefined) {
|
|
710
|
-
const alt = micEAltitude(d.altitudeFeet);
|
|
805
|
+
const alt = ALTITUDE_GIVEN_IN_FEET.has(d) ? undefined : micEAltitude(d.altitudeFeet);
|
|
711
806
|
if (alt !== undefined)
|
|
712
807
|
text += alt;
|
|
713
808
|
else
|
|
@@ -737,15 +832,18 @@ export function encodeMicE(d, mode) {
|
|
|
737
832
|
rest += (exts > 0 && altitudeInComment === undefined && !after.endsWith('/') ? '/' : '') + frequencyText(d.frequency);
|
|
738
833
|
const comment = d.comment ?? '';
|
|
739
834
|
if (comment.length > 0 || mode === 'delimited') {
|
|
740
|
-
if (rest.length > 0 && mode !== 'joined')
|
|
835
|
+
if ((rest.length > 0 && mode !== 'joined') || mode === 'spaced')
|
|
741
836
|
rest += ' ';
|
|
742
837
|
rest += (mode === 'delimited' ? '/' : '') + freeText(comment, 'the comment');
|
|
743
838
|
}
|
|
744
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 = '';
|
|
745
843
|
if (d.telemetry)
|
|
746
|
-
|
|
844
|
+
lifted += telemetryText(d.telemetry);
|
|
747
845
|
if (d.dao)
|
|
748
|
-
|
|
846
|
+
lifted += daoText(d, lat.extra, lon.extra, ambiguity === 0);
|
|
749
847
|
if (d.locator !== undefined) {
|
|
750
848
|
if (!/^[A-R]{2}[0-9]{2}(?:[A-X]{2})?$/.test(d.locator))
|
|
751
849
|
refuse('the locator is 4 or 6 characters, upper case');
|
|
@@ -753,7 +851,7 @@ export function encodeMicE(d, mode) {
|
|
|
753
851
|
if (after.length > 0)
|
|
754
852
|
text += ' ';
|
|
755
853
|
}
|
|
756
|
-
text += after;
|
|
854
|
+
text += after + lifted;
|
|
757
855
|
if (d.deviceSuffix !== undefined) {
|
|
758
856
|
if (d.typeCode === undefined)
|
|
759
857
|
refuse('a Mic-E device suffix needs a type code');
|