@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.
- package/CHANGELOG.md +44 -1
- package/README.md +9 -5
- 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/bytes.d.ts.map +1 -1
- package/dist/bytes.js +2 -1
- package/dist/bytes.js.map +1 -1
- package/dist/codes.js +8 -8
- package/dist/codes.js.map +1 -1
- package/dist/decode/index.d.ts.map +1 -1
- package/dist/decode/index.js +8 -5
- package/dist/decode/index.js.map +1 -1
- package/dist/decode/message.d.ts +2 -0
- package/dist/decode/message.d.ts.map +1 -1
- package/dist/decode/message.js +59 -33
- package/dist/decode/message.js.map +1 -1
- package/dist/decode/other.d.ts +20 -2
- package/dist/decode/other.d.ts.map +1 -1
- package/dist/decode/other.js +110 -48
- package/dist/decode/other.js.map +1 -1
- package/dist/decode/positioned.d.ts +26 -2
- package/dist/decode/positioned.d.ts.map +1 -1
- package/dist/decode/positioned.js +145 -79
- package/dist/decode/positioned.js.map +1 -1
- package/dist/decode/reports.d.ts.map +1 -1
- package/dist/decode/reports.js +47 -5
- 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 +28 -6
- 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 +59 -22
- package/dist/encode/index.js.map +1 -1
- package/dist/encode/other.d.ts.map +1 -1
- package/dist/encode/other.js +45 -17
- package/dist/encode/other.js.map +1 -1
- package/dist/encode/position.d.ts +9 -3
- package/dist/encode/position.d.ts.map +1 -1
- package/dist/encode/position.js +258 -124
- package/dist/encode/position.js.map +1 -1
- package/dist/header.d.ts +5 -2
- package/dist/header.d.ts.map +1 -1
- package/dist/header.js +8 -3
- package/dist/header.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 +22 -3
- package/dist/neutral.js.map +1 -1
- package/dist/types.d.ts +19 -9
- package/dist/types.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/builder.ts +77 -14
- package/src/bytes.ts +2 -1
- package/src/codes.ts +8 -8
- package/src/decode/index.ts +7 -4
- package/src/decode/message.ts +54 -37
- package/src/decode/other.ts +116 -44
- package/src/decode/positioned.ts +159 -90
- package/src/decode/reports.ts +47 -4
- package/src/decoder.ts +2 -5
- package/src/deviceid.ts +29 -5
- package/src/encode/common.ts +34 -4
- package/src/encode/index.ts +57 -20
- package/src/encode/other.ts +39 -13
- package/src/encode/position.ts +261 -119
- package/src/header.ts +9 -2
- package/src/index.ts +1 -0
- package/src/neutral.ts +24 -3
- package/src/types.ts +20 -5
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,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,
|
|
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
|
+
};
|
|
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(
|
|
100
|
-
if (!Number.
|
|
101
|
-
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);
|
|
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 =
|
|
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 +=
|
|
270
|
+
out += base91(whole(t.digital, 0, 255, 'telemetry bits'), 2);
|
|
177
271
|
return out + '|';
|
|
178
272
|
}
|
|
179
|
-
|
|
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
|
-
|
|
191
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
310
|
-
let
|
|
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
|
-
|
|
323
|
-
|
|
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
|
-
|
|
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
|
-
|
|
345
|
-
|
|
346
|
-
|
|
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
|
-
|
|
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
|
|
466
|
+
const pos = uncompressedPosition(f);
|
|
361
467
|
out += pos.text;
|
|
362
|
-
|
|
363
|
-
|
|
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
|
-
//
|
|
401
|
-
|
|
402
|
-
|
|
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
|
|
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 += (
|
|
513
|
+
text += (extended && !out.endsWith('/') ? '/' : '') + frequencyText(f.frequency);
|
|
408
514
|
let braces = '';
|
|
409
515
|
if (f.signpost !== undefined) {
|
|
410
|
-
|
|
411
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
|
458
|
-
let
|
|
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
|
-
|
|
468
|
-
|
|
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
|
-
|
|
479
|
-
cs =
|
|
480
|
-
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;
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
613
|
+
const pos = uncompressedPosition(f);
|
|
505
614
|
out += pos.text;
|
|
506
|
-
|
|
507
|
-
|
|
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,
|
|
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 =
|
|
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
|
|
631
|
-
const lat = digitsOf(d.latitude,
|
|
632
|
-
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);
|
|
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
|
-
|
|
651
|
-
|
|
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
|
|
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
|
-
|
|
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 <
|
|
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
|
-
|
|
844
|
+
lifted += telemetryText(d.telemetry);
|
|
716
845
|
if (d.dao)
|
|
717
|
-
|
|
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
|
}
|