@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/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,60 @@ 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
|
+
};
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* A GGA altitude in the cs bytes: 1.002^cs feet, so the nearest value they hold (1 foot for 0 feet
|
|
166
|
+
* or below), and whether that is exact. When it is not, a `/A=` altitude carries it and wins.
|
|
167
|
+
*/
|
|
168
|
+
function ggaAltitude(feet: number): { cs: string; exact: boolean } {
|
|
169
|
+
if (!Number.isFinite(feet)) refuse('altitude is not a number');
|
|
170
|
+
const v = feet <= 1 ? 0 : nearest(Math.log(feet) / Math.log(1.002));
|
|
171
|
+
if (v > 91 * 91 - 1) refuse('altitude is too high for the compressed format');
|
|
172
|
+
const cs = String.fromCharCode(Math.floor(v / 91) + 33) + String.fromCharCode((v % 91) + 33);
|
|
173
|
+
return { cs, exact: Math.abs(Math.pow(1.002, v) - feet) <= 1e-9 * Math.abs(feet) };
|
|
174
|
+
}
|
|
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);
|
|
110
207
|
}
|
|
111
208
|
|
|
112
209
|
// ---- data extensions and comment elements
|
|
@@ -126,8 +223,10 @@ function rateChar(r: number): string {
|
|
|
126
223
|
return v <= 9 ? String(v) : String.fromCharCode(55 + v);
|
|
127
224
|
}
|
|
128
225
|
|
|
129
|
-
function altitudeText(
|
|
130
|
-
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);
|
|
131
230
|
if (feet < 0) {
|
|
132
231
|
if (feet < -99999) refuse('altitude is out of range');
|
|
133
232
|
return `/A=-${pad(-feet, 5)}`;
|
|
@@ -142,7 +241,7 @@ export function frequencyText(f: VoiceFrequency): string {
|
|
|
142
241
|
let whole3: string;
|
|
143
242
|
let fraction: string;
|
|
144
243
|
const resolution = f.tenKhzResolution ? 100 : 1000;
|
|
145
|
-
const scaled =
|
|
244
|
+
const scaled = nearest(mhz * resolution);
|
|
146
245
|
const intPart = Math.floor(scaled / resolution);
|
|
147
246
|
const fracPart = scaled % resolution;
|
|
148
247
|
fraction = pad(fracPart, f.tenKhzResolution ? 2 : 3);
|
|
@@ -191,29 +290,33 @@ export function telemetryText(t: CommentTelemetry): string {
|
|
|
191
290
|
const v = (n: number, what: string): string => base91(whole(n, 0, 8280, what), 2);
|
|
192
291
|
let out = '|' + v(t.sequence, 'telemetry sequence');
|
|
193
292
|
for (const a of t.analog) out += v(a, 'telemetry value');
|
|
194
|
-
|
|
293
|
+
// Eight binary channels; bits 9-13 are reserved.
|
|
294
|
+
if (t.digital !== undefined) out += base91(whole(t.digital, 0, 255, 'telemetry bits'), 2);
|
|
195
295
|
return out + '|';
|
|
196
296
|
}
|
|
197
297
|
|
|
198
|
-
|
|
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 {
|
|
199
303
|
const dao = f.dao!;
|
|
200
304
|
const datum = dao.datum;
|
|
201
|
-
if (
|
|
305
|
+
if (/^[0-9]$/.test(datum)) {
|
|
306
|
+
// A local datum digit has no case to give a precision, so it only goes with spaces.
|
|
307
|
+
if (dao.precision !== 'none') refuse('a !DAO! datum digit carries no added precision');
|
|
308
|
+
return `!${datum} !`;
|
|
309
|
+
}
|
|
310
|
+
if (!/^[A-Za-z]$/.test(datum)) refuse('the !DAO! datum is a letter or a digit');
|
|
202
311
|
switch (dao.precision) {
|
|
203
312
|
case 'none':
|
|
204
313
|
return `!${datum.toUpperCase()} !`;
|
|
205
|
-
case 'thousandths':
|
|
314
|
+
case 'thousandths':
|
|
206
315
|
if (!applied) return `!${datum.toUpperCase()}00!`;
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
return `!${datum.toUpperCase()}${a}${o}!`;
|
|
210
|
-
}
|
|
211
|
-
case 'base91': {
|
|
316
|
+
return `!${datum.toUpperCase()}${latExtra}${lonExtra}!`;
|
|
317
|
+
case 'base91':
|
|
212
318
|
if (!applied) return `!${datum.toLowerCase()}!!!`;
|
|
213
|
-
|
|
214
|
-
const o = Math.min(90, Math.round(lonRemainder * 100 * 91));
|
|
215
|
-
return `!${datum.toLowerCase()}${String.fromCharCode(a + 33)}${String.fromCharCode(o + 33)}!`;
|
|
216
|
-
}
|
|
319
|
+
return `!${datum.toLowerCase()}${String.fromCharCode(latExtra + 33)}${String.fromCharCode(lonExtra + 33)}!`;
|
|
217
320
|
}
|
|
218
321
|
}
|
|
219
322
|
|
|
@@ -226,6 +329,22 @@ function weatherValue(v: number | undefined, width: number, min: number, max: nu
|
|
|
226
329
|
return pad(n, width);
|
|
227
330
|
}
|
|
228
331
|
|
|
332
|
+
/**
|
|
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.
|
|
337
|
+
*/
|
|
338
|
+
function snowfallText(inches: number): string {
|
|
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);
|
|
346
|
+
}
|
|
347
|
+
|
|
229
348
|
/** Weather fields after the wind: gust, temperature, rain, humidity, pressure, and on. */
|
|
230
349
|
function weatherFields(w: Weather, mandatory: boolean): string {
|
|
231
350
|
let out = '';
|
|
@@ -243,12 +362,7 @@ function weatherFields(w: Weather, mandatory: boolean): string {
|
|
|
243
362
|
const l = whole(w.luminosityWM2, 0, 1999, 'luminosity');
|
|
244
363
|
out += l < 1000 ? 'L' + pad(l, 3) : 'l' + pad(l - 1000, 3);
|
|
245
364
|
}
|
|
246
|
-
if (w.snow24hIn !== undefined)
|
|
247
|
-
const s = w.snow24hIn;
|
|
248
|
-
let text = Number.isInteger(s) ? pad(whole(s, 0, 999, 'snowfall'), 3) : String(s);
|
|
249
|
-
if (text.length !== 3 || !/^[0-9.]{3}$/.test(text)) text = refuse('snowfall is 3 characters');
|
|
250
|
-
out += 's' + text;
|
|
251
|
-
}
|
|
365
|
+
if (w.snow24hIn !== undefined) out += 's' + snowfallText(w.snow24hIn);
|
|
252
366
|
if (w.rainRaw !== undefined) out += '#' + pad(whole(w.rainRaw, 0, 999, 'raw rain counter'), 3);
|
|
253
367
|
for (const e of w.extra ?? []) {
|
|
254
368
|
if (!/^[A-Za-z]$/.test(e.letter) || 'csgtrpPhbLl'.includes(e.letter)) refuse(`${e.letter} is not an extra weather field letter`);
|
|
@@ -295,10 +409,11 @@ function hasExtension(f: PositionedFields): string[] {
|
|
|
295
409
|
|
|
296
410
|
/**
|
|
297
411
|
* How the comment is joined on: `plain` (after a space when it follows a frequency), `joined`
|
|
298
|
-
* (straight after a frequency, with no space)
|
|
299
|
-
* otherwise read as something else
|
|
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).
|
|
300
415
|
*/
|
|
301
|
-
export type CommentMode = 'plain' | 'joined' | 'delimited';
|
|
416
|
+
export type CommentMode = 'plain' | 'joined' | 'delimited' | 'spaced';
|
|
302
417
|
|
|
303
418
|
/** Encodes a position and what follows it. */
|
|
304
419
|
function positionedBody(f: PositionedFields, mode: CommentMode): Body {
|
|
@@ -307,7 +422,8 @@ function positionedBody(f: PositionedFields, mode: CommentMode): Body {
|
|
|
307
422
|
if (isWeather && !f.weather) refuse('the weather station symbol (_) needs weather data');
|
|
308
423
|
if (isWeather) return weatherBody(f);
|
|
309
424
|
const exts = hasExtension(f);
|
|
310
|
-
|
|
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));
|
|
311
427
|
if (exts.length + (hasCourse ? 1 : 0) > 1) refuse(`only one data extension can follow the symbol (${[hasCourse ? 'course/speed' : '', ...exts].filter(Boolean).join(', ')})`);
|
|
312
428
|
if (f.dfBearing && !(f.symbol.table === '/' && f.symbol.code === '\\')) refuse('a DF bearing needs the DF symbol (/\\)');
|
|
313
429
|
if (f.storm && !(f.symbol.code === '@' && (f.symbol.table === '/' || f.symbol.table === '\\'))) refuse('storm data needs a hurricane symbol (/@ or \\@)');
|
|
@@ -317,56 +433,54 @@ function positionedBody(f: PositionedFields, mode: CommentMode): Body {
|
|
|
317
433
|
let out = '';
|
|
318
434
|
let compression: CompressionType | undefined;
|
|
319
435
|
let altitudeInCs = false;
|
|
320
|
-
let
|
|
321
|
-
let
|
|
436
|
+
let latExtra = 0;
|
|
437
|
+
let lonExtra = 0;
|
|
322
438
|
let daoApplied = false;
|
|
323
439
|
if (f.compressed) {
|
|
324
440
|
if (f.phg || f.dfs || f.area || f.dfBearing || f.storm) refuse('a compressed report carries only course/speed, range or altitude');
|
|
325
441
|
let cs: string | undefined;
|
|
326
442
|
compression = f.compression;
|
|
327
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).
|
|
328
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');
|
|
329
448
|
if (compression?.source === 'gga') refuse('a GGA compression type means the cs bytes hold altitude');
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
const speed = f.speedKnots ?? 0;
|
|
333
|
-
if (speed < 0) refuse('speed is negative');
|
|
334
|
-
const s = Math.round(Math.log(speed + 1) / Math.log(1.08));
|
|
335
|
-
if (s > 90) refuse('speed is too high for the compressed format');
|
|
336
|
-
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');
|
|
337
451
|
compression = compression ?? DEFAULT_COMPRESSION;
|
|
338
452
|
} else if (f.rangeMiles !== undefined) {
|
|
339
453
|
if (compression?.source === 'gga') refuse('a GGA compression type means the cs bytes hold altitude');
|
|
340
|
-
|
|
341
|
-
if (!(s >= 0 && s <= 90)) refuse('range is out of range for the compressed format');
|
|
342
|
-
cs = '{' + String.fromCharCode(s + 33);
|
|
454
|
+
cs = compressedRange(f.rangeMiles);
|
|
343
455
|
compression = compression ?? DEFAULT_COMPRESSION;
|
|
344
456
|
} else if (f.altitudeFeet !== undefined && compression?.source === 'gga') {
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
cs = String.fromCharCode(Math.floor(v / 91) + 33) + String.fromCharCode((v % 91) + 33);
|
|
349
|
-
altitudeInCs = Math.abs(Math.pow(1.002, v) - f.altitudeFeet) <= 1e-9 * f.altitudeFeet;
|
|
457
|
+
const alt = ggaAltitude(f.altitudeFeet);
|
|
458
|
+
cs = alt.cs;
|
|
459
|
+
altitudeInCs = alt.exact;
|
|
350
460
|
} else if (compression !== undefined) {
|
|
351
461
|
refuse('a compression type needs cs data (course/speed, range or altitude)');
|
|
352
462
|
}
|
|
353
|
-
|
|
463
|
+
const pos = compressedPosition(f, cs, compression);
|
|
464
|
+
out += pos.text;
|
|
465
|
+
latExtra = pos.latExtra;
|
|
466
|
+
lonExtra = pos.lonExtra;
|
|
467
|
+
daoApplied = true;
|
|
354
468
|
} else {
|
|
355
469
|
if (f.compression) refuse('an uncompressed position has no compression type');
|
|
356
|
-
const pos = uncompressedPosition(f
|
|
470
|
+
const pos = uncompressedPosition(f);
|
|
357
471
|
out += pos.text;
|
|
358
|
-
|
|
359
|
-
|
|
472
|
+
latExtra = pos.latExtra;
|
|
473
|
+
lonExtra = pos.lonExtra;
|
|
360
474
|
daoApplied = (f.ambiguity ?? 0) === 0;
|
|
361
475
|
if (hasCourse) {
|
|
476
|
+
// DF or storm data with no course and speed follows `.../...`, which reads back as unknown
|
|
477
|
+
// (vectors rulings, F8).
|
|
362
478
|
out += courseSpeed(f.courseDegrees, f.speedKnots);
|
|
363
479
|
if (f.dfBearing) {
|
|
364
480
|
const b = f.dfBearing;
|
|
365
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')}`;
|
|
366
482
|
}
|
|
367
483
|
if (f.storm) out += stormText(f.storm);
|
|
368
|
-
} else if (f.dfBearing || f.storm) {
|
|
369
|
-
refuse('DF and storm data follow a course/speed extension');
|
|
370
484
|
} else if (f.phg) {
|
|
371
485
|
const p = f.phg;
|
|
372
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')}`;
|
|
@@ -385,29 +499,35 @@ function positionedBody(f: PositionedFields, mode: CommentMode): Body {
|
|
|
385
499
|
out += `${shape}${pad(whole(a.latOffset, 0, 99, 'area offset'), 2)}${c}${pad(whole(a.lonOffset, 0, 99, 'area offset'), 2)}`;
|
|
386
500
|
}
|
|
387
501
|
}
|
|
388
|
-
//
|
|
389
|
-
|
|
390
|
-
|
|
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).
|
|
391
505
|
let text = '';
|
|
392
|
-
// A frequency straight after a 7-byte extension
|
|
393
|
-
|
|
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);
|
|
394
510
|
let braces = '';
|
|
395
511
|
if (f.signpost !== undefined) {
|
|
396
|
-
|
|
512
|
+
// Printable ASCII, a space included, other than the braces around it.
|
|
513
|
+
if (!/^[\x20-\x7a\x7c\x7e]{1,3}$/.test(f.signpost)) refuse('a signpost is 1-3 printable ASCII characters other than { and }');
|
|
397
514
|
braces = `{${f.signpost}}`;
|
|
398
515
|
}
|
|
399
516
|
if (f.area?.corridorWidthMiles !== undefined) {
|
|
400
517
|
if (f.area.shape !== 'line-down-right' && f.area.shape !== 'line-down-left') refuse('only a line has a corridor');
|
|
401
518
|
braces = `{${whole(f.area.corridorWidthMiles, 0, 999, 'corridor width')}}`;
|
|
402
519
|
}
|
|
520
|
+
text += braces;
|
|
521
|
+
if (f.altitudeFeet !== undefined && !altitudeInCs) text += altitudeText(f.altitudeFeet);
|
|
403
522
|
const comment = f.comment ?? '';
|
|
404
523
|
if (comment.length > 0 || mode === 'delimited') {
|
|
405
|
-
|
|
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 += ' ';
|
|
406
527
|
text += (mode === 'delimited' ? '/' : '') + freeText(comment, 'the comment');
|
|
407
528
|
}
|
|
408
|
-
text += braces;
|
|
409
529
|
if (f.telemetry) text += telemetryText(f.telemetry);
|
|
410
|
-
if (f.dao) text += daoText(f,
|
|
530
|
+
if (f.dao) text += daoText(f, latExtra, lonExtra, daoApplied);
|
|
411
531
|
const body: Body = { text: out + text };
|
|
412
532
|
if (compression) body.compression = compression;
|
|
413
533
|
return body;
|
|
@@ -415,7 +535,9 @@ function positionedBody(f: PositionedFields, mode: CommentMode): Body {
|
|
|
415
535
|
|
|
416
536
|
function stormText(s: NonNullable<PositionedFields['storm']>): string {
|
|
417
537
|
const type = s.type === 'tropical-storm' ? 'TS' : s.type === 'hurricane' ? 'HC' : s.type === 'tropical-depression' ? 'TD' : refuse('unknown storm type');
|
|
418
|
-
|
|
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);
|
|
419
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')}`;
|
|
420
542
|
if (s.wholeGaleRadiusNm !== undefined) out += `%${v(s.wholeGaleRadiusNm, 3, 'radius')}`;
|
|
421
543
|
return out;
|
|
@@ -431,8 +553,8 @@ function weatherBody(f: PositionedFields): Body {
|
|
|
431
553
|
if (f.telemetry) refuse('comment telemetry goes in a comment, which a weather report does not have');
|
|
432
554
|
let out = '';
|
|
433
555
|
let compression: CompressionType | undefined;
|
|
434
|
-
let
|
|
435
|
-
let
|
|
556
|
+
let latExtra = 0;
|
|
557
|
+
let lonExtra = 0;
|
|
436
558
|
let daoApplied = false;
|
|
437
559
|
let altitudeInCs = false;
|
|
438
560
|
if (f.compressed) {
|
|
@@ -440,42 +562,41 @@ function weatherBody(f: PositionedFields): Body {
|
|
|
440
562
|
let cs: string | undefined;
|
|
441
563
|
if (w.windDirectionDegrees !== undefined || w.windSpeedMph !== undefined) {
|
|
442
564
|
if (w.windDirectionDegrees === undefined || w.windSpeedMph === undefined) refuse('compressed wind needs both direction and speed');
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
const s = Math.round(Math.log(knots + 1) / Math.log(1.08));
|
|
446
|
-
if (s < 0 || s > 90) refuse('wind speed is out of range for the compressed format');
|
|
447
|
-
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');
|
|
448
567
|
compression = compression ?? DEFAULT_COMPRESSION;
|
|
449
568
|
if (compression.source === 'gga') refuse('a GGA compression type means the cs bytes hold altitude');
|
|
450
569
|
} else if (f.altitudeFeet !== undefined && compression?.source === 'gga') {
|
|
451
|
-
|
|
452
|
-
cs =
|
|
453
|
-
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;
|
|
454
573
|
} else if (f.rangeMiles !== undefined) {
|
|
455
574
|
if (compression?.source === 'gga') refuse('a GGA compression type means the cs bytes hold altitude');
|
|
456
|
-
|
|
457
|
-
if (!(s >= 0 && s <= 90)) refuse('range is out of range for the compressed format');
|
|
458
|
-
cs = '{' + String.fromCharCode(s + 33);
|
|
575
|
+
cs = compressedRange(f.rangeMiles);
|
|
459
576
|
compression = compression ?? DEFAULT_COMPRESSION;
|
|
460
577
|
} else if (compression !== undefined) {
|
|
461
578
|
refuse('a compression type needs cs data');
|
|
462
579
|
}
|
|
463
|
-
|
|
580
|
+
const pos = compressedPosition(f, cs, compression);
|
|
581
|
+
out += pos.text;
|
|
582
|
+
latExtra = pos.latExtra;
|
|
583
|
+
lonExtra = pos.lonExtra;
|
|
584
|
+
daoApplied = true;
|
|
464
585
|
if (f.altitudeFeet !== undefined && !altitudeInCs) refuse('a weather report has no altitude in its comment');
|
|
465
586
|
out += weatherFields(w, true);
|
|
466
587
|
} else {
|
|
467
588
|
if (f.compression) refuse('an uncompressed position has no compression type');
|
|
468
589
|
if (f.altitudeFeet !== undefined) refuse('a weather report has no altitude in its comment');
|
|
469
|
-
const pos = uncompressedPosition(f
|
|
590
|
+
const pos = uncompressedPosition(f);
|
|
470
591
|
out += pos.text;
|
|
471
|
-
|
|
472
|
-
|
|
592
|
+
latExtra = pos.latExtra;
|
|
593
|
+
lonExtra = pos.lonExtra;
|
|
473
594
|
daoApplied = (f.ambiguity ?? 0) === 0;
|
|
474
595
|
out += courseSpeed(w.windDirectionDegrees, w.windSpeedMph, 'wind direction');
|
|
475
596
|
out += weatherFields(w, true);
|
|
476
597
|
}
|
|
477
598
|
if (f.telemetry) out += telemetryText(f.telemetry);
|
|
478
|
-
if (f.dao) out += daoText(f,
|
|
599
|
+
if (f.dao) out += daoText(f, latExtra, lonExtra, daoApplied);
|
|
479
600
|
const body: Body = { text: out };
|
|
480
601
|
if (compression) body.compression = compression;
|
|
481
602
|
return body;
|
|
@@ -556,8 +677,14 @@ function micEDestination(d: MicEReport, lat: Digits, lonDegrees: number): string
|
|
|
556
677
|
return out;
|
|
557
678
|
}
|
|
558
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
|
+
|
|
559
686
|
export function micEAltitude(feet: number): string | undefined {
|
|
560
|
-
const metres =
|
|
687
|
+
const metres = nearest(feet * METRES_PER_FOOT);
|
|
561
688
|
if (Math.abs(metres / METRES_PER_FOOT - feet) > 1e-6) return undefined;
|
|
562
689
|
const v = metres + 10000;
|
|
563
690
|
if (v < 0 || v > 91 ** 3 - 1) return undefined;
|
|
@@ -574,9 +701,9 @@ export function encodeMicE(d: MicEReport, mode: CommentMode): { text: string; de
|
|
|
574
701
|
if (d.dfBearing || d.storm || d.area || d.signpost !== undefined) refuse('a Mic-E report cannot carry that data');
|
|
575
702
|
const ambiguity = d.ambiguity ?? 0;
|
|
576
703
|
if (!Number.isInteger(ambiguity) || ambiguity < 0 || ambiguity > 4) refuse('ambiguity is 0-4 digits');
|
|
577
|
-
const
|
|
578
|
-
const lat = digitsOf(d.latitude,
|
|
579
|
-
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);
|
|
580
707
|
if (lat.degrees > 90 || (lat.degrees === 90 && lat.hundredths > 0)) refuse('latitude is out of range');
|
|
581
708
|
if (lon.degrees > 179) refuse('Mic-E longitude is below 180 degrees');
|
|
582
709
|
const destination = micEDestination(d, lat, lon.degrees);
|
|
@@ -587,16 +714,24 @@ export function encodeMicE(d: MicEReport, mode: CommentMode): { text: string; de
|
|
|
587
714
|
else if (deg <= 99) dByte = deg + 28;
|
|
588
715
|
else if (deg <= 109) dByte = deg + 8;
|
|
589
716
|
else dByte = deg - 100 + 28;
|
|
590
|
-
// With ambiguity the digits a decoder ignores are written as the centre it reports
|
|
591
|
-
|
|
592
|
-
|
|
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;
|
|
593
726
|
const mByte = mValue < 10 ? mValue + 88 : mValue + 28;
|
|
594
727
|
const hByte = hundredths + 28;
|
|
595
|
-
// 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.
|
|
596
730
|
const speed = d.speedKnots === undefined ? 0 : whole(d.speedKnots, 0, 799, 'speed');
|
|
597
|
-
|
|
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');
|
|
598
733
|
const tens = Math.floor(speed / 10);
|
|
599
|
-
const spByte = tens <
|
|
734
|
+
const spByte = tens < 19 ? tens + 80 + 28 : tens + 28;
|
|
600
735
|
const dcByte = (speed % 10) * 10 + Math.floor(course / 100) + 4 + 28;
|
|
601
736
|
const seByte = (course % 100) + 28;
|
|
602
737
|
const dti = d.oldData ? "'" : '`';
|
|
@@ -618,7 +753,7 @@ export function encodeMicE(d: MicEReport, mode: CommentMode): { text: string; de
|
|
|
618
753
|
}
|
|
619
754
|
let altitudeInComment: number | undefined;
|
|
620
755
|
if (d.altitudeFeet !== undefined) {
|
|
621
|
-
const alt = micEAltitude(d.altitudeFeet);
|
|
756
|
+
const alt = ALTITUDE_GIVEN_IN_FEET.has(d) ? undefined : micEAltitude(d.altitudeFeet);
|
|
622
757
|
if (alt !== undefined) text += alt;
|
|
623
758
|
else altitudeInComment = d.altitudeFeet;
|
|
624
759
|
}
|
|
@@ -640,22 +775,29 @@ export function encodeMicE(d: MicEReport, mode: CommentMode): { text: string; de
|
|
|
640
775
|
if (d.frequency) rest += (exts > 0 && altitudeInComment === undefined && !after.endsWith('/') ? '/' : '') + frequencyText(d.frequency);
|
|
641
776
|
const comment = d.comment ?? '';
|
|
642
777
|
if (comment.length > 0 || mode === 'delimited') {
|
|
643
|
-
if (rest.length > 0 && mode !== 'joined') rest += ' ';
|
|
778
|
+
if ((rest.length > 0 && mode !== 'joined') || mode === 'spaced') rest += ' ';
|
|
644
779
|
rest += (mode === 'delimited' ? '/' : '') + freeText(comment, 'the comment');
|
|
645
780
|
}
|
|
646
781
|
after += rest;
|
|
647
|
-
|
|
648
|
-
|
|
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);
|
|
649
787
|
if (d.locator !== undefined) {
|
|
650
788
|
if (!/^[A-R]{2}[0-9]{2}(?:[A-X]{2})?$/.test(d.locator)) refuse('the locator is 4 or 6 characters, upper case');
|
|
651
789
|
text += `${d.locator}/G`;
|
|
652
790
|
if (after.length > 0) text += ' ';
|
|
653
791
|
}
|
|
654
|
-
text += after;
|
|
792
|
+
text += after + lifted;
|
|
655
793
|
if (d.deviceSuffix !== undefined) {
|
|
656
794
|
if (d.typeCode === undefined) refuse('a Mic-E device suffix needs a type code');
|
|
657
795
|
text += d.deviceSuffix;
|
|
658
796
|
}
|
|
797
|
+
// Mic-E status text must not start with 0x1D, which would be taken for Rev 0 telemetry
|
|
798
|
+
// (APRS12c ch. 10), so a comment that would start it is written after a /.
|
|
799
|
+
const legacy = d.legacyTelemetry !== undefined && d.legacyTelemetry.length > 0;
|
|
800
|
+
if (!legacy && text.startsWith('\x1d') && mode !== 'delimited') return encodeMicE(d, 'delimited');
|
|
659
801
|
info += text;
|
|
660
802
|
return { text: info, destination };
|
|
661
803
|
}
|