@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/src/encode/position.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
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
|
|
|
5
6
|
import { AREA_COLORS, AREA_SHAPES, KNOTS_TO_MPH, METRES_PER_FOOT, MICROWAVE_BASE } from '../decode/positioned.js';
|
|
6
7
|
import type {
|
|
@@ -17,9 +18,10 @@ import type {
|
|
|
17
18
|
Weather,
|
|
18
19
|
WeatherReport,
|
|
19
20
|
} from '../types.js';
|
|
20
|
-
import { base91, checkSymbol, freeText, isValidTimestamp, pad, refuse, timestamp7, whole } from './common.js';
|
|
21
|
+
import { base91, checkSymbol, freeText, isValidTimestamp, nearest, pad, refuse, timestamp7, whole } from './common.js';
|
|
21
22
|
|
|
22
23
|
const EPS = 1e-9;
|
|
24
|
+
const CUT_TOLERANCE = 1e-6;
|
|
23
25
|
|
|
24
26
|
/** Negative, or negative zero: a zero coordinate keeps its hemisphere as the sign of zero. */
|
|
25
27
|
export function isNegative(v: number): boolean {
|
|
@@ -32,23 +34,62 @@ interface Digits {
|
|
|
32
34
|
degrees: number;
|
|
33
35
|
/** Hundredths of a minute. */
|
|
34
36
|
hundredths: number;
|
|
35
|
-
/** The
|
|
36
|
-
|
|
37
|
+
/** The `!DAO!` digit below the hundredths: 0-9 in thousandths, 0-90 in ninety-firsts. */
|
|
38
|
+
extra: number;
|
|
37
39
|
}
|
|
38
40
|
|
|
39
|
-
/**
|
|
40
|
-
|
|
41
|
+
/**
|
|
42
|
+
* How a coordinate's digits are found: rounded to the nearest hundredth of a minute, truncated to
|
|
43
|
+
* one (ambiguity, which reports the centre of the box the digits left give), or rounded to the
|
|
44
|
+
* nearest step of a `!DAO!` of that precision (thousandths, or ninety-firsts of a hundredth, vectors
|
|
45
|
+
* interpretations.md), whose last digit is the `!DAO!` one.
|
|
46
|
+
*/
|
|
47
|
+
type DigitsMode = 'round' | 'truncate' | 'thousandths' | 'base91';
|
|
48
|
+
|
|
49
|
+
/** Degrees and hundredths of a minute, and the `!DAO!` digit below them. */
|
|
50
|
+
function digitsOf(value: number, mode: DigitsMode, ambiguity = 0): Digits {
|
|
41
51
|
const abs = Math.abs(value);
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
52
|
+
// The ambiguity cut takes F2's tolerance: a value within 1e-6 of the box edge above it, in the
|
|
53
|
+
// box's units, is on that edge (vectors rulings, G2).
|
|
54
|
+
let degrees = Math.floor(abs + (mode === 'truncate' && ambiguity >= 4 ? CUT_TOLERANCE : EPS));
|
|
55
|
+
const minutes = Math.max(0, (abs - degrees) * 60);
|
|
56
|
+
let hundredths: number;
|
|
57
|
+
let extra = 0;
|
|
58
|
+
switch (mode) {
|
|
59
|
+
case 'round':
|
|
60
|
+
hundredths = nearest(minutes * 100);
|
|
61
|
+
break;
|
|
62
|
+
case 'truncate': {
|
|
63
|
+
// Hundredths of a minute in each box: a tenth, a minute, ten minutes, or a whole degree.
|
|
64
|
+
const box = ambiguity >= 4 ? 6000 : ambiguity === 3 ? 1000 : ambiguity === 2 ? 100 : ambiguity === 1 ? 10 : 1;
|
|
65
|
+
hundredths = ambiguity >= 4 ? 0 : Math.floor((minutes * 100) / box + (ambiguity > 0 ? CUT_TOLERANCE : EPS)) * box;
|
|
66
|
+
break;
|
|
67
|
+
}
|
|
68
|
+
case 'thousandths': {
|
|
69
|
+
const steps = nearest(minutes * 1000);
|
|
70
|
+
hundredths = Math.floor(steps / 10);
|
|
71
|
+
extra = steps % 10;
|
|
72
|
+
break;
|
|
73
|
+
}
|
|
74
|
+
case 'base91': {
|
|
75
|
+
const steps = nearest(minutes * 9100);
|
|
76
|
+
hundredths = Math.floor(steps / 91);
|
|
77
|
+
extra = steps % 91;
|
|
78
|
+
break;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
46
81
|
if (hundredths >= 6000) {
|
|
47
82
|
degrees += 1;
|
|
48
83
|
hundredths -= 6000;
|
|
49
84
|
}
|
|
50
|
-
|
|
51
|
-
|
|
85
|
+
return { degrees, hundredths, extra };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** How a position's digits are found, given its ambiguity and `!DAO!`. */
|
|
89
|
+
function digitsMode(f: PositionedFields): DigitsMode {
|
|
90
|
+
if ((f.ambiguity ?? 0) > 0) return 'truncate';
|
|
91
|
+
const precision = f.dao?.precision;
|
|
92
|
+
return precision === 'thousandths' || precision === 'base91' ? precision : 'round';
|
|
52
93
|
}
|
|
53
94
|
|
|
54
95
|
function blank(text: string, positions: readonly number[], ambiguity: number): string {
|
|
@@ -57,18 +98,16 @@ function blank(text: string, positions: readonly number[], ambiguity: number): s
|
|
|
57
98
|
return chars.join('');
|
|
58
99
|
}
|
|
59
100
|
|
|
60
|
-
/** `ddmm.hhN/dddmm.hhW$`, with the DAO
|
|
61
|
-
function uncompressedPosition(
|
|
62
|
-
f: PositionedFields,
|
|
63
|
-
truncate: boolean,
|
|
64
|
-
): { text: string; latRemainder: number; lonRemainder: number } {
|
|
101
|
+
/** `ddmm.hhN/dddmm.hhW$`, with the `!DAO!` digits. */
|
|
102
|
+
function uncompressedPosition(f: PositionedFields): { text: string; latExtra: number; lonExtra: number } {
|
|
65
103
|
if (!(f.latitude >= -90 && f.latitude <= 90)) refuse(`latitude ${f.latitude} is out of range`);
|
|
66
104
|
if (!(f.longitude >= -180 && f.longitude <= 180)) refuse(`longitude ${f.longitude} is out of range`);
|
|
67
105
|
const ambiguity = f.ambiguity ?? 0;
|
|
68
106
|
if (!Number.isInteger(ambiguity) || ambiguity < 0 || ambiguity > 4) refuse('ambiguity is 0-4 digits');
|
|
69
107
|
checkSymbol(f.symbol);
|
|
70
|
-
const
|
|
71
|
-
const
|
|
108
|
+
const mode = digitsMode(f);
|
|
109
|
+
const lat = digitsOf(f.latitude, mode, ambiguity);
|
|
110
|
+
const lon = digitsOf(f.longitude, mode, ambiguity);
|
|
72
111
|
if (lat.degrees > 90 || (lat.degrees === 90 && lat.hundredths > 0)) refuse('latitude is out of range');
|
|
73
112
|
if (lon.degrees > 180 || (lon.degrees === 180 && lon.hundredths > 0)) refuse('longitude is out of range');
|
|
74
113
|
const latMin = pad(Math.floor(lat.hundredths / 100), 2) + '.' + pad(lat.hundredths % 100, 2);
|
|
@@ -77,8 +116,8 @@ function uncompressedPosition(
|
|
|
77
116
|
const lonText = blank(pad(lon.degrees, 3) + lonMin, [3, 4, 6, 7], ambiguity) + (isNegative(f.longitude) ? 'W' : 'E');
|
|
78
117
|
return {
|
|
79
118
|
text: latText + f.symbol.table + lonText + f.symbol.code,
|
|
80
|
-
|
|
81
|
-
|
|
119
|
+
latExtra: lat.extra,
|
|
120
|
+
lonExtra: lon.extra,
|
|
82
121
|
};
|
|
83
122
|
}
|
|
84
123
|
|
|
@@ -94,8 +133,16 @@ function typeByte(c: CompressionType): string {
|
|
|
94
133
|
return String.fromCharCode(((c.fix === 'current' ? 1 : 0) << 5) + (source << 3) + origin + 33);
|
|
95
134
|
}
|
|
96
135
|
|
|
97
|
-
/**
|
|
98
|
-
|
|
136
|
+
/**
|
|
137
|
+
* The compressed position; `cs` is the two cs bytes or `undefined` for none. Also gives the
|
|
138
|
+
* `!DAO!` digits for it: a decoder does not apply them, so they are the ones a `!DAO!` of that
|
|
139
|
+
* precision gives the position the bytes report, written uncompressed (vectors rulings, E2).
|
|
140
|
+
*/
|
|
141
|
+
function compressedPosition(
|
|
142
|
+
f: PositionedFields,
|
|
143
|
+
cs: string | undefined,
|
|
144
|
+
compression: CompressionType | undefined,
|
|
145
|
+
): { text: string; latExtra: number; lonExtra: number } {
|
|
99
146
|
if (!(f.latitude >= -90 && f.latitude <= 90)) refuse(`latitude ${f.latitude} is out of range`);
|
|
100
147
|
if (!(f.longitude >= -180 && f.longitude <= 180)) refuse(`longitude ${f.longitude} is out of range`);
|
|
101
148
|
if ((f.ambiguity ?? 0) !== 0) refuse('a compressed position cannot be ambiguous');
|
|
@@ -103,10 +150,15 @@ function compressedPosition(f: PositionedFields, cs: string | undefined, compres
|
|
|
103
150
|
let table = f.symbol.table;
|
|
104
151
|
if (table >= '0' && table <= '9') table = String.fromCharCode(table.charCodeAt(0) + 49); // 0-9 -> a-j
|
|
105
152
|
const max = 91 ** 4 - 1;
|
|
106
|
-
const y = Math.min(max, Math.max(0,
|
|
107
|
-
const x = Math.min(max, Math.max(0,
|
|
153
|
+
const y = Math.min(max, Math.max(0, nearest(380926 * (90 - f.latitude))));
|
|
154
|
+
const x = Math.min(max, Math.max(0, nearest(190463 * (180 + f.longitude))));
|
|
108
155
|
const tail = cs === undefined ? ' sT' : cs + typeByte(compression ?? DEFAULT_COMPRESSION);
|
|
109
|
-
|
|
156
|
+
const mode = digitsMode(f);
|
|
157
|
+
return {
|
|
158
|
+
text: table + base91(y, 4) + base91(x, 4) + f.symbol.code + tail,
|
|
159
|
+
latExtra: digitsOf(90 - y / 380926, mode).extra,
|
|
160
|
+
lonExtra: digitsOf(x / 190463 - 180, mode).extra,
|
|
161
|
+
};
|
|
110
162
|
}
|
|
111
163
|
|
|
112
164
|
/**
|
|
@@ -115,12 +167,45 @@ function compressedPosition(f: PositionedFields, cs: string | undefined, compres
|
|
|
115
167
|
*/
|
|
116
168
|
function ggaAltitude(feet: number): { cs: string; exact: boolean } {
|
|
117
169
|
if (!Number.isFinite(feet)) refuse('altitude is not a number');
|
|
118
|
-
const v = feet <= 1 ? 0 :
|
|
170
|
+
const v = feet <= 1 ? 0 : nearest(Math.log(feet) / Math.log(1.002));
|
|
119
171
|
if (v > 91 * 91 - 1) refuse('altitude is too high for the compressed format');
|
|
120
172
|
const cs = String.fromCharCode(Math.floor(v / 91) + 33) + String.fromCharCode((v % 91) + 33);
|
|
121
173
|
return { cs, exact: Math.abs(Math.pow(1.002, v) - feet) <= 1e-9 * Math.abs(feet) };
|
|
122
174
|
}
|
|
123
175
|
|
|
176
|
+
/**
|
|
177
|
+
* A course or wind direction in the cs bytes: 4 degree steps, rounded to the nearest (halves away
|
|
178
|
+
* from zero), and one that rounds to 360 is c = 0, north (vectors rulings, E9).
|
|
179
|
+
*/
|
|
180
|
+
function compressedDirection(degrees: number): string {
|
|
181
|
+
return String.fromCharCode((nearest(degrees / 4) % 90) + 33);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* A speed in knots in the cs bytes: 1.08^s - 1, the nearest s for any whole s, refused when that
|
|
186
|
+
* s is outside the byte (0-90; 90 is `{`), so -0.03 knots is 0 and -0.3 is refused (vectors
|
|
187
|
+
* rulings, G3).
|
|
188
|
+
*/
|
|
189
|
+
function compressedSpeed(knots: number, what: string): string {
|
|
190
|
+
if (!(knots > -1)) refuse(`${what} has no compressed form`);
|
|
191
|
+
const s = nearest(Math.log(knots + 1) / Math.log(1.08));
|
|
192
|
+
if (s < 0) refuse(`${what} is negative`);
|
|
193
|
+
if (s > 90) refuse(`${what} is too high for the compressed format`);
|
|
194
|
+
return String.fromCharCode(s + 33);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* A range in the cs bytes: `{` and 2 x 1.08^s miles, the nearest s for any whole s, refused when
|
|
199
|
+
* that s is below 0, so 1.95 miles is 2 and 1.9 is refused (vectors rulings, G3).
|
|
200
|
+
*/
|
|
201
|
+
function compressedRange(miles: number): string {
|
|
202
|
+
if (!(miles > 0)) refuse('a compressed range is at least 2 miles');
|
|
203
|
+
const s = nearest(Math.log(miles / 2) / Math.log(1.08));
|
|
204
|
+
if (s < 0) refuse('a compressed range is at least 2 miles');
|
|
205
|
+
if (s > 90) refuse('range is out of range for the compressed format');
|
|
206
|
+
return '{' + String.fromCharCode(s + 33);
|
|
207
|
+
}
|
|
208
|
+
|
|
124
209
|
// ---- data extensions and comment elements
|
|
125
210
|
|
|
126
211
|
function courseSpeed(course: number | undefined, speed: number | undefined, what = 'course'): string {
|
|
@@ -138,8 +223,10 @@ function rateChar(r: number): string {
|
|
|
138
223
|
return v <= 9 ? String(v) : String.fromCharCode(55 + v);
|
|
139
224
|
}
|
|
140
225
|
|
|
141
|
-
function altitudeText(
|
|
142
|
-
if (!Number.
|
|
226
|
+
function altitudeText(given: number): string {
|
|
227
|
+
if (!Number.isFinite(given)) refuse('altitude is not a number');
|
|
228
|
+
// Whole feet, the nearest (vectors rulings, F2).
|
|
229
|
+
const feet = nearest(given);
|
|
143
230
|
if (feet < 0) {
|
|
144
231
|
if (feet < -99999) refuse('altitude is out of range');
|
|
145
232
|
return `/A=-${pad(-feet, 5)}`;
|
|
@@ -154,7 +241,7 @@ export function frequencyText(f: VoiceFrequency): string {
|
|
|
154
241
|
let whole3: string;
|
|
155
242
|
let fraction: string;
|
|
156
243
|
const resolution = f.tenKhzResolution ? 100 : 1000;
|
|
157
|
-
const scaled =
|
|
244
|
+
const scaled = nearest(mhz * resolution);
|
|
158
245
|
const intPart = Math.floor(scaled / resolution);
|
|
159
246
|
const fracPart = scaled % resolution;
|
|
160
247
|
fraction = pad(fracPart, f.tenKhzResolution ? 2 : 3);
|
|
@@ -208,7 +295,11 @@ export function telemetryText(t: CommentTelemetry): string {
|
|
|
208
295
|
return out + '|';
|
|
209
296
|
}
|
|
210
297
|
|
|
211
|
-
|
|
298
|
+
/**
|
|
299
|
+
* The `!DAO!`: its datum, and digits `latExtra` and `lonExtra` (from `digitsOf`), or zeros when
|
|
300
|
+
* `applied` is false (an ambiguous position, whose digits say nothing finer).
|
|
301
|
+
*/
|
|
302
|
+
function daoText(f: PositionedFields, latExtra: number, lonExtra: number, applied: boolean): string {
|
|
212
303
|
const dao = f.dao!;
|
|
213
304
|
const datum = dao.datum;
|
|
214
305
|
if (/^[0-9]$/.test(datum)) {
|
|
@@ -220,18 +311,12 @@ function daoText(f: PositionedFields, latRemainder: number, lonRemainder: number
|
|
|
220
311
|
switch (dao.precision) {
|
|
221
312
|
case 'none':
|
|
222
313
|
return `!${datum.toUpperCase()} !`;
|
|
223
|
-
case 'thousandths':
|
|
314
|
+
case 'thousandths':
|
|
224
315
|
if (!applied) return `!${datum.toUpperCase()}00!`;
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
return `!${datum.toUpperCase()}${a}${o}!`;
|
|
228
|
-
}
|
|
229
|
-
case 'base91': {
|
|
316
|
+
return `!${datum.toUpperCase()}${latExtra}${lonExtra}!`;
|
|
317
|
+
case 'base91':
|
|
230
318
|
if (!applied) return `!${datum.toLowerCase()}!!!`;
|
|
231
|
-
|
|
232
|
-
const o = Math.min(90, Math.round(lonRemainder * 100 * 91));
|
|
233
|
-
return `!${datum.toLowerCase()}${String.fromCharCode(a + 33)}${String.fromCharCode(o + 33)}!`;
|
|
234
|
-
}
|
|
319
|
+
return `!${datum.toLowerCase()}${String.fromCharCode(latExtra + 33)}${String.fromCharCode(lonExtra + 33)}!`;
|
|
235
320
|
}
|
|
236
321
|
}
|
|
237
322
|
|
|
@@ -245,19 +330,19 @@ function weatherValue(v: number | undefined, width: number, min: number, max: nu
|
|
|
245
330
|
}
|
|
246
331
|
|
|
247
332
|
/**
|
|
248
|
-
* Snowfall
|
|
249
|
-
*
|
|
333
|
+
* Snowfall in its three characters (vectors rulings, F3): rounded to hundredths if that leaves it
|
|
334
|
+
* under 1, else to tenths if that leaves it under 10, else to a whole number; then a whole number
|
|
335
|
+
* is three digits (`002`, 0.995 as `001`), one under 1 is `.` and two digits (`.50`), any other a
|
|
336
|
+
* digit, `.` and a digit (`2.5`). One that rounds past 999 is refused.
|
|
250
337
|
*/
|
|
251
338
|
function snowfallText(inches: number): string {
|
|
252
|
-
if (!Number.isFinite(inches)
|
|
253
|
-
|
|
254
|
-
if (
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
if (
|
|
258
|
-
|
|
259
|
-
}
|
|
260
|
-
return text;
|
|
339
|
+
if (!Number.isFinite(inches)) refuse('snowfall is not a number of inches');
|
|
340
|
+
const hundredths = nearest(inches * 100);
|
|
341
|
+
if (hundredths < 0) refuse('snowfall is negative');
|
|
342
|
+
if (hundredths < 100) return hundredths % 100 === 0 ? '000' : '.' + pad(hundredths, 2);
|
|
343
|
+
const tenths = nearest(inches * 10);
|
|
344
|
+
if (tenths < 100) return tenths % 10 === 0 ? pad(tenths / 10, 3) : `${Math.floor(tenths / 10)}.${tenths % 10}`;
|
|
345
|
+
return pad(whole(inches, 0, 999, 'snowfall'), 3);
|
|
261
346
|
}
|
|
262
347
|
|
|
263
348
|
/** Weather fields after the wind: gust, temperature, rain, humidity, pressure, and on. */
|
|
@@ -324,12 +409,11 @@ function hasExtension(f: PositionedFields): string[] {
|
|
|
324
409
|
|
|
325
410
|
/**
|
|
326
411
|
* How the comment is joined on: `plain` (after a space when it follows a frequency), `joined`
|
|
327
|
-
* (straight after a frequency, with no space)
|
|
328
|
-
* otherwise read as something else
|
|
329
|
-
*
|
|
330
|
-
* for them (the first well-formed braces are the signpost).
|
|
412
|
+
* (straight after a frequency, with no space), `delimited` (after a `/`) or `spaced` (after a
|
|
413
|
+
* space), for when it would otherwise read as something else: a space keeps a Mic-E comment from
|
|
414
|
+
* joining a PHG's digits into an altitude, where a `/` would not (vectors README, Encoding).
|
|
331
415
|
*/
|
|
332
|
-
export type CommentMode = 'plain' | 'joined' | 'delimited' | '
|
|
416
|
+
export type CommentMode = 'plain' | 'joined' | 'delimited' | 'spaced';
|
|
333
417
|
|
|
334
418
|
/** Encodes a position and what follows it. */
|
|
335
419
|
function positionedBody(f: PositionedFields, mode: CommentMode): Body {
|
|
@@ -338,7 +422,8 @@ function positionedBody(f: PositionedFields, mode: CommentMode): Body {
|
|
|
338
422
|
if (isWeather && !f.weather) refuse('the weather station symbol (_) needs weather data');
|
|
339
423
|
if (isWeather) return weatherBody(f);
|
|
340
424
|
const exts = hasExtension(f);
|
|
341
|
-
|
|
425
|
+
// DF and storm data extend a course/speed, so they count as that extension (vectors rulings, F7).
|
|
426
|
+
const hasCourse = f.courseDegrees !== undefined || f.speedKnots !== undefined || (!f.compressed && (f.dfBearing !== undefined || f.storm !== undefined));
|
|
342
427
|
if (exts.length + (hasCourse ? 1 : 0) > 1) refuse(`only one data extension can follow the symbol (${[hasCourse ? 'course/speed' : '', ...exts].filter(Boolean).join(', ')})`);
|
|
343
428
|
if (f.dfBearing && !(f.symbol.table === '/' && f.symbol.code === '\\')) refuse('a DF bearing needs the DF symbol (/\\)');
|
|
344
429
|
if (f.storm && !(f.symbol.code === '@' && (f.symbol.table === '/' || f.symbol.table === '\\'))) refuse('storm data needs a hurricane symbol (/@ or \\@)');
|
|
@@ -348,29 +433,25 @@ function positionedBody(f: PositionedFields, mode: CommentMode): Body {
|
|
|
348
433
|
let out = '';
|
|
349
434
|
let compression: CompressionType | undefined;
|
|
350
435
|
let altitudeInCs = false;
|
|
351
|
-
let
|
|
352
|
-
let
|
|
436
|
+
let latExtra = 0;
|
|
437
|
+
let lonExtra = 0;
|
|
353
438
|
let daoApplied = false;
|
|
354
439
|
if (f.compressed) {
|
|
355
440
|
if (f.phg || f.dfs || f.area || f.dfBearing || f.storm) refuse('a compressed report carries only course/speed, range or altitude');
|
|
356
441
|
let cs: string | undefined;
|
|
357
442
|
compression = f.compression;
|
|
358
443
|
if (hasCourse) {
|
|
444
|
+
// The cs bytes hold both, so one alone would add a value the data does not have; a course of
|
|
445
|
+
// 0 (after rounding) would read back as 360 (vectors rulings, F1, F5).
|
|
359
446
|
if (f.courseDegrees === undefined) refuse('a compressed course/speed needs a course');
|
|
447
|
+
if (f.speedKnots === undefined) refuse('a compressed course/speed needs a speed');
|
|
360
448
|
if (compression?.source === 'gga') refuse('a GGA compression type means the cs bytes hold altitude');
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
const speed = f.speedKnots ?? 0;
|
|
364
|
-
if (speed < 0) refuse('speed is negative');
|
|
365
|
-
const s = Math.round(Math.log(speed + 1) / Math.log(1.08));
|
|
366
|
-
if (s > 90) refuse('speed is too high for the compressed format');
|
|
367
|
-
cs = String.fromCharCode(Math.round(c / 4) % 90 + 33) + String.fromCharCode(s + 33);
|
|
449
|
+
whole(f.courseDegrees, 1, 360, 'course');
|
|
450
|
+
cs = compressedDirection(f.courseDegrees) + compressedSpeed(f.speedKnots, 'speed');
|
|
368
451
|
compression = compression ?? DEFAULT_COMPRESSION;
|
|
369
452
|
} else if (f.rangeMiles !== undefined) {
|
|
370
453
|
if (compression?.source === 'gga') refuse('a GGA compression type means the cs bytes hold altitude');
|
|
371
|
-
|
|
372
|
-
if (!(s >= 0 && s <= 90)) refuse('range is out of range for the compressed format');
|
|
373
|
-
cs = '{' + String.fromCharCode(s + 33);
|
|
454
|
+
cs = compressedRange(f.rangeMiles);
|
|
374
455
|
compression = compression ?? DEFAULT_COMPRESSION;
|
|
375
456
|
} else if (f.altitudeFeet !== undefined && compression?.source === 'gga') {
|
|
376
457
|
const alt = ggaAltitude(f.altitudeFeet);
|
|
@@ -379,23 +460,27 @@ function positionedBody(f: PositionedFields, mode: CommentMode): Body {
|
|
|
379
460
|
} else if (compression !== undefined) {
|
|
380
461
|
refuse('a compression type needs cs data (course/speed, range or altitude)');
|
|
381
462
|
}
|
|
382
|
-
|
|
463
|
+
const pos = compressedPosition(f, cs, compression);
|
|
464
|
+
out += pos.text;
|
|
465
|
+
latExtra = pos.latExtra;
|
|
466
|
+
lonExtra = pos.lonExtra;
|
|
467
|
+
daoApplied = true;
|
|
383
468
|
} else {
|
|
384
469
|
if (f.compression) refuse('an uncompressed position has no compression type');
|
|
385
|
-
const pos = uncompressedPosition(f
|
|
470
|
+
const pos = uncompressedPosition(f);
|
|
386
471
|
out += pos.text;
|
|
387
|
-
|
|
388
|
-
|
|
472
|
+
latExtra = pos.latExtra;
|
|
473
|
+
lonExtra = pos.lonExtra;
|
|
389
474
|
daoApplied = (f.ambiguity ?? 0) === 0;
|
|
390
475
|
if (hasCourse) {
|
|
476
|
+
// DF or storm data with no course and speed follows `.../...`, which reads back as unknown
|
|
477
|
+
// (vectors rulings, F8).
|
|
391
478
|
out += courseSpeed(f.courseDegrees, f.speedKnots);
|
|
392
479
|
if (f.dfBearing) {
|
|
393
480
|
const b = f.dfBearing;
|
|
394
481
|
out += `/${pad(whole(b.bearingDegrees, 0, 360, 'bearing'), 3)}/${whole(b.number, 0, 9, 'N')}${whole(b.range, 0, 9, 'R')}${whole(b.quality, 0, 9, 'Q')}`;
|
|
395
482
|
}
|
|
396
483
|
if (f.storm) out += stormText(f.storm);
|
|
397
|
-
} else if (f.dfBearing || f.storm) {
|
|
398
|
-
refuse('DF and storm data follow a course/speed extension');
|
|
399
484
|
} else if (f.phg) {
|
|
400
485
|
const p = f.phg;
|
|
401
486
|
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')}`;
|
|
@@ -414,12 +499,14 @@ function positionedBody(f: PositionedFields, mode: CommentMode): Body {
|
|
|
414
499
|
out += `${shape}${pad(whole(a.latOffset, 0, 99, 'area offset'), 2)}${c}${pad(whole(a.lonOffset, 0, 99, 'area offset'), 2)}`;
|
|
415
500
|
}
|
|
416
501
|
}
|
|
417
|
-
//
|
|
418
|
-
|
|
419
|
-
|
|
502
|
+
// After the data extension: the frequency with its fields, first, where radios read it (APRS12c
|
|
503
|
+
// ch. 18); the signpost or corridor braces; the altitude, unless the compressed cs bytes carry it
|
|
504
|
+
// exactly; the free text; base-91 telemetry; the !DAO! (vectors rulings, E3).
|
|
420
505
|
let text = '';
|
|
421
|
-
// A frequency straight after a 7-byte extension
|
|
422
|
-
|
|
506
|
+
// A frequency straight after a 7-byte extension follows a / (a PHGR already ends in one); after a
|
|
507
|
+
// compressed position, or a position with no extension, it follows straight on.
|
|
508
|
+
const extended = !f.compressed && (hasCourse || exts.length > 0);
|
|
509
|
+
if (f.frequency) text += (extended && !out.endsWith('/') ? '/' : '') + frequencyText(f.frequency);
|
|
423
510
|
let braces = '';
|
|
424
511
|
if (f.signpost !== undefined) {
|
|
425
512
|
// Printable ASCII, a space included, other than the braces around it.
|
|
@@ -430,14 +517,17 @@ function positionedBody(f: PositionedFields, mode: CommentMode): Body {
|
|
|
430
517
|
if (f.area.shape !== 'line-down-right' && f.area.shape !== 'line-down-left') refuse('only a line has a corridor');
|
|
431
518
|
braces = `{${whole(f.area.corridorWidthMiles, 0, 999, 'corridor width')}}`;
|
|
432
519
|
}
|
|
520
|
+
text += braces;
|
|
521
|
+
if (f.altitudeFeet !== undefined && !altitudeInCs) text += altitudeText(f.altitudeFeet);
|
|
433
522
|
const comment = f.comment ?? '';
|
|
434
523
|
if (comment.length > 0 || mode === 'delimited') {
|
|
435
|
-
|
|
524
|
+
// After a frequency (and anything after it) the free text follows a space, unless that would
|
|
525
|
+
// make it read as one of the frequency's fields.
|
|
526
|
+
if ((f.frequency && mode !== 'joined') || mode === 'spaced') text += ' ';
|
|
436
527
|
text += (mode === 'delimited' ? '/' : '') + freeText(comment, 'the comment');
|
|
437
528
|
}
|
|
438
|
-
text = mode === 'braces-first' ? braces + text : text + braces;
|
|
439
529
|
if (f.telemetry) text += telemetryText(f.telemetry);
|
|
440
|
-
if (f.dao) text += daoText(f,
|
|
530
|
+
if (f.dao) text += daoText(f, latExtra, lonExtra, daoApplied);
|
|
441
531
|
const body: Body = { text: out + text };
|
|
442
532
|
if (compression) body.compression = compression;
|
|
443
533
|
return body;
|
|
@@ -445,7 +535,9 @@ function positionedBody(f: PositionedFields, mode: CommentMode): Body {
|
|
|
445
535
|
|
|
446
536
|
function stormText(s: NonNullable<PositionedFields['storm']>): string {
|
|
447
537
|
const type = s.type === 'tropical-storm' ? 'TS' : s.type === 'hurricane' ? 'HC' : s.type === 'tropical-depression' ? 'TD' : refuse('unknown storm type');
|
|
448
|
-
|
|
538
|
+
// Each field is digits: one with no value has no form (vectors README, storm data).
|
|
539
|
+
const v = (n: number | undefined, width: number, what: string): string =>
|
|
540
|
+
n === undefined ? refuse(`storm data needs its ${what}`) : pad(whole(n, 0, 10 ** width - 1, what), width);
|
|
449
541
|
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')}`;
|
|
450
542
|
if (s.wholeGaleRadiusNm !== undefined) out += `%${v(s.wholeGaleRadiusNm, 3, 'radius')}`;
|
|
451
543
|
return out;
|
|
@@ -461,8 +553,8 @@ function weatherBody(f: PositionedFields): Body {
|
|
|
461
553
|
if (f.telemetry) refuse('comment telemetry goes in a comment, which a weather report does not have');
|
|
462
554
|
let out = '';
|
|
463
555
|
let compression: CompressionType | undefined;
|
|
464
|
-
let
|
|
465
|
-
let
|
|
556
|
+
let latExtra = 0;
|
|
557
|
+
let lonExtra = 0;
|
|
466
558
|
let daoApplied = false;
|
|
467
559
|
let altitudeInCs = false;
|
|
468
560
|
if (f.compressed) {
|
|
@@ -470,42 +562,41 @@ function weatherBody(f: PositionedFields): Body {
|
|
|
470
562
|
let cs: string | undefined;
|
|
471
563
|
if (w.windDirectionDegrees !== undefined || w.windSpeedMph !== undefined) {
|
|
472
564
|
if (w.windDirectionDegrees === undefined || w.windSpeedMph === undefined) refuse('compressed wind needs both direction and speed');
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
const s = Math.round(Math.log(knots + 1) / Math.log(1.08));
|
|
476
|
-
if (s < 0 || s > 90) refuse('wind speed is out of range for the compressed format');
|
|
477
|
-
cs = String.fromCharCode((Math.round(dir / 4) % 90) + 33) + String.fromCharCode(s + 33);
|
|
565
|
+
whole(w.windDirectionDegrees, 0, 360, 'wind direction');
|
|
566
|
+
cs = compressedDirection(w.windDirectionDegrees) + compressedSpeed(w.windSpeedMph / KNOTS_TO_MPH, 'wind speed');
|
|
478
567
|
compression = compression ?? DEFAULT_COMPRESSION;
|
|
479
568
|
if (compression.source === 'gga') refuse('a GGA compression type means the cs bytes hold altitude');
|
|
480
569
|
} else if (f.altitudeFeet !== undefined && compression?.source === 'gga') {
|
|
481
|
-
|
|
482
|
-
cs =
|
|
483
|
-
altitudeInCs =
|
|
570
|
+
// With no comment to hold a /A=, the nearest value the cs bytes hold (vectors rulings, F14).
|
|
571
|
+
cs = ggaAltitude(f.altitudeFeet).cs;
|
|
572
|
+
altitudeInCs = true;
|
|
484
573
|
} else if (f.rangeMiles !== undefined) {
|
|
485
574
|
if (compression?.source === 'gga') refuse('a GGA compression type means the cs bytes hold altitude');
|
|
486
|
-
|
|
487
|
-
if (!(s >= 0 && s <= 90)) refuse('range is out of range for the compressed format');
|
|
488
|
-
cs = '{' + String.fromCharCode(s + 33);
|
|
575
|
+
cs = compressedRange(f.rangeMiles);
|
|
489
576
|
compression = compression ?? DEFAULT_COMPRESSION;
|
|
490
577
|
} else if (compression !== undefined) {
|
|
491
578
|
refuse('a compression type needs cs data');
|
|
492
579
|
}
|
|
493
|
-
|
|
580
|
+
const pos = compressedPosition(f, cs, compression);
|
|
581
|
+
out += pos.text;
|
|
582
|
+
latExtra = pos.latExtra;
|
|
583
|
+
lonExtra = pos.lonExtra;
|
|
584
|
+
daoApplied = true;
|
|
494
585
|
if (f.altitudeFeet !== undefined && !altitudeInCs) refuse('a weather report has no altitude in its comment');
|
|
495
586
|
out += weatherFields(w, true);
|
|
496
587
|
} else {
|
|
497
588
|
if (f.compression) refuse('an uncompressed position has no compression type');
|
|
498
589
|
if (f.altitudeFeet !== undefined) refuse('a weather report has no altitude in its comment');
|
|
499
|
-
const pos = uncompressedPosition(f
|
|
590
|
+
const pos = uncompressedPosition(f);
|
|
500
591
|
out += pos.text;
|
|
501
|
-
|
|
502
|
-
|
|
592
|
+
latExtra = pos.latExtra;
|
|
593
|
+
lonExtra = pos.lonExtra;
|
|
503
594
|
daoApplied = (f.ambiguity ?? 0) === 0;
|
|
504
595
|
out += courseSpeed(w.windDirectionDegrees, w.windSpeedMph, 'wind direction');
|
|
505
596
|
out += weatherFields(w, true);
|
|
506
597
|
}
|
|
507
598
|
if (f.telemetry) out += telemetryText(f.telemetry);
|
|
508
|
-
if (f.dao) out += daoText(f,
|
|
599
|
+
if (f.dao) out += daoText(f, latExtra, lonExtra, daoApplied);
|
|
509
600
|
const body: Body = { text: out };
|
|
510
601
|
if (compression) body.compression = compression;
|
|
511
602
|
return body;
|
|
@@ -586,8 +677,14 @@ function micEDestination(d: MicEReport, lat: Digits, lonDegrees: number): string
|
|
|
586
677
|
return out;
|
|
587
678
|
}
|
|
588
679
|
|
|
680
|
+
/**
|
|
681
|
+
* Mic-E reports whose altitude was given in feet by a program, so it goes in a `/A=` and not the
|
|
682
|
+
* Mic-E altitude bytes, whose unit is the metre (vectors rulings, B2).
|
|
683
|
+
*/
|
|
684
|
+
export const ALTITUDE_GIVEN_IN_FEET = new WeakSet<object>();
|
|
685
|
+
|
|
589
686
|
export function micEAltitude(feet: number): string | undefined {
|
|
590
|
-
const metres =
|
|
687
|
+
const metres = nearest(feet * METRES_PER_FOOT);
|
|
591
688
|
if (Math.abs(metres / METRES_PER_FOOT - feet) > 1e-6) return undefined;
|
|
592
689
|
const v = metres + 10000;
|
|
593
690
|
if (v < 0 || v > 91 ** 3 - 1) return undefined;
|
|
@@ -604,9 +701,9 @@ export function encodeMicE(d: MicEReport, mode: CommentMode): { text: string; de
|
|
|
604
701
|
if (d.dfBearing || d.storm || d.area || d.signpost !== undefined) refuse('a Mic-E report cannot carry that data');
|
|
605
702
|
const ambiguity = d.ambiguity ?? 0;
|
|
606
703
|
if (!Number.isInteger(ambiguity) || ambiguity < 0 || ambiguity > 4) refuse('ambiguity is 0-4 digits');
|
|
607
|
-
const
|
|
608
|
-
const lat = digitsOf(d.latitude,
|
|
609
|
-
const lon = digitsOf(d.longitude,
|
|
704
|
+
const digits = digitsMode(d);
|
|
705
|
+
const lat = digitsOf(d.latitude, digits, ambiguity);
|
|
706
|
+
const lon = digitsOf(d.longitude, digits, ambiguity);
|
|
610
707
|
if (lat.degrees > 90 || (lat.degrees === 90 && lat.hundredths > 0)) refuse('latitude is out of range');
|
|
611
708
|
if (lon.degrees > 179) refuse('Mic-E longitude is below 180 degrees');
|
|
612
709
|
const destination = micEDestination(d, lat, lon.degrees);
|
|
@@ -617,16 +714,24 @@ export function encodeMicE(d: MicEReport, mode: CommentMode): { text: string; de
|
|
|
617
714
|
else if (deg <= 99) dByte = deg + 28;
|
|
618
715
|
else if (deg <= 109) dByte = deg + 8;
|
|
619
716
|
else dByte = deg - 100 + 28;
|
|
620
|
-
// With ambiguity the digits a decoder ignores are written as the centre it reports
|
|
621
|
-
|
|
622
|
-
|
|
717
|
+
// With ambiguity the digits a decoder ignores are written as the centre it reports (vectors
|
|
718
|
+
// README, Mic-E): 03.5x is 03.55, 03.xx 03.50, 0x.xx 05.00, xx.xx 30.00.
|
|
719
|
+
let lonHundredths = lon.hundredths;
|
|
720
|
+
if (ambiguity === 1) lonHundredths = Math.floor(lonHundredths / 10) * 10 + 5;
|
|
721
|
+
else if (ambiguity === 2) lonHundredths = Math.floor(lonHundredths / 100) * 100 + 50;
|
|
722
|
+
else if (ambiguity === 3) lonHundredths = Math.floor(lonHundredths / 1000) * 1000 + 500;
|
|
723
|
+
else if (ambiguity === 4) lonHundredths = 3000;
|
|
724
|
+
const mValue = Math.floor(lonHundredths / 100);
|
|
725
|
+
const hundredths = lonHundredths % 100;
|
|
623
726
|
const mByte = mValue < 10 ? mValue + 88 : mValue + 28;
|
|
624
727
|
const hByte = hundredths + 28;
|
|
625
|
-
// Speed and course
|
|
728
|
+
// Speed and course in their printable forms (APRS12c ch. 10): speed tens + 80 below 190 knots,
|
|
729
|
+
// and as they are from 190 on (190-199 is /, not DEL: vectors rulings, E5); course + 400.
|
|
626
730
|
const speed = d.speedKnots === undefined ? 0 : whole(d.speedKnots, 0, 799, 'speed');
|
|
627
|
-
|
|
731
|
+
// A course of 0 (after rounding) reads back as no course, so it is refused (vectors rulings, F1).
|
|
732
|
+
const course = d.courseDegrees === undefined ? 0 : whole(d.courseDegrees, 1, 360, 'course');
|
|
628
733
|
const tens = Math.floor(speed / 10);
|
|
629
|
-
const spByte = tens <
|
|
734
|
+
const spByte = tens < 19 ? tens + 80 + 28 : tens + 28;
|
|
630
735
|
const dcByte = (speed % 10) * 10 + Math.floor(course / 100) + 4 + 28;
|
|
631
736
|
const seByte = (course % 100) + 28;
|
|
632
737
|
const dti = d.oldData ? "'" : '`';
|
|
@@ -648,7 +753,7 @@ export function encodeMicE(d: MicEReport, mode: CommentMode): { text: string; de
|
|
|
648
753
|
}
|
|
649
754
|
let altitudeInComment: number | undefined;
|
|
650
755
|
if (d.altitudeFeet !== undefined) {
|
|
651
|
-
const alt = micEAltitude(d.altitudeFeet);
|
|
756
|
+
const alt = ALTITUDE_GIVEN_IN_FEET.has(d) ? undefined : micEAltitude(d.altitudeFeet);
|
|
652
757
|
if (alt !== undefined) text += alt;
|
|
653
758
|
else altitudeInComment = d.altitudeFeet;
|
|
654
759
|
}
|
|
@@ -670,18 +775,21 @@ export function encodeMicE(d: MicEReport, mode: CommentMode): { text: string; de
|
|
|
670
775
|
if (d.frequency) rest += (exts > 0 && altitudeInComment === undefined && !after.endsWith('/') ? '/' : '') + frequencyText(d.frequency);
|
|
671
776
|
const comment = d.comment ?? '';
|
|
672
777
|
if (comment.length > 0 || mode === 'delimited') {
|
|
673
|
-
if (rest.length > 0 && mode !== 'joined') rest += ' ';
|
|
778
|
+
if ((rest.length > 0 && mode !== 'joined') || mode === 'spaced') rest += ' ';
|
|
674
779
|
rest += (mode === 'delimited' ? '/' : '') + freeText(comment, 'the comment');
|
|
675
780
|
}
|
|
676
781
|
after += rest;
|
|
677
|
-
|
|
678
|
-
|
|
782
|
+
// Telemetry and the !DAO! are lifted before a locator is read, so they follow it straight on
|
|
783
|
+
// (vectors rulings, F16).
|
|
784
|
+
let lifted = '';
|
|
785
|
+
if (d.telemetry) lifted += telemetryText(d.telemetry);
|
|
786
|
+
if (d.dao) lifted += daoText(d, lat.extra, lon.extra, ambiguity === 0);
|
|
679
787
|
if (d.locator !== undefined) {
|
|
680
788
|
if (!/^[A-R]{2}[0-9]{2}(?:[A-X]{2})?$/.test(d.locator)) refuse('the locator is 4 or 6 characters, upper case');
|
|
681
789
|
text += `${d.locator}/G`;
|
|
682
790
|
if (after.length > 0) text += ' ';
|
|
683
791
|
}
|
|
684
|
-
text += after;
|
|
792
|
+
text += after + lifted;
|
|
685
793
|
if (d.deviceSuffix !== undefined) {
|
|
686
794
|
if (d.typeCode === undefined) refuse('a Mic-E device suffix needs a type code');
|
|
687
795
|
text += d.deviceSuffix;
|